diff --git a/.ddd/seams/seam-rust-ledger-cli-tests-common-hand-rs.yaml b/.ddd/seams/seam-rust-ledger-cli-tests-common-hand-rs.yaml index 6ee11cf2..d01d9c42 100644 --- a/.ddd/seams/seam-rust-ledger-cli-tests-common-hand-rs.yaml +++ b/.ddd/seams/seam-rust-ledger-cli-tests-common-hand-rs.yaml @@ -1,7 +1,7 @@ format: 2 id: seam/rust/ledger-cli-tests-common-hand-rs boundary: contract surface of ledger-cli/tests/common/hand.rs -verdict_knowledge: Test suites learn how to write ledger entities by hand — acceptances, bindings and policies with any at, under or signer — so verify is exercised on files no verb made. +verdict_knowledge: 'Test-only: the hand-writing half of the ledger-cli suites'' harness, with no consumer outside ledger-cli/tests/ — test suites learn how to write ledger entities by hand (acceptances, bindings, policies with any at, under or signer) and how to found a namespace with its genesis, roles and first policy without the writer, so verify is exercised on files no verb made.' contract_location: ledger-cli/tests/common/hand.rs obligations: [] metadata: {} @@ -186,3 +186,9 @@ bindings: after: sha256:6bec31662ee3ba78c736178572b39ca0039fa83f23be4af7317f7422d833824b base_revision: 88b3de113c03fa70091d00fef217e55d44ccac3e hash: sha256:3fe513692e074586bd1e3d50e6123c7db9f90d76d2619ac108de67879c7bdee7 +- symbol: found + file: ledger-cli/tests/common/hand.rs + before: sha256:b8a216763ec322bf9470148afbcbea4077dbdbe6730e459bb1d03fa5da2d553a + after: sha256:e111d09b6d84e63d8aea9fbbe7cca766bd4bdd1d103674aea3c695e2f81ed99b + base_revision: ec43481f591b1022d8840755c8c0381b06a59387 + hash: sha256:6f5ce54063e728276bd5f3c86b4f7f2f3df238e3e49e4da5b137b9f6690d1369 diff --git a/.ddd/seams/seam-rust-ledger-core-src-author-authority-ops-rs.yaml b/.ddd/seams/seam-rust-ledger-core-src-author-authority-ops-rs.yaml index 58298b75..a643ec0a 100644 --- a/.ddd/seams/seam-rust-ledger-core-src-author-authority-ops-rs.yaml +++ b/.ddd/seams/seam-rust-ledger-core-src-author-authority-ops-rs.yaml @@ -1,7 +1,7 @@ format: 2 id: seam/rust/ledger-core-src-author-authority-ops-rs boundary: contract surface of ledger-core/src/author/authority_ops.rs -verdict_knowledge: Callers learn the authority verbs (init_namespace, declare_role, grant, accept_grant, revoke_grant) run the role check, then the same delta-gate as every verb, refusing with AuthorError::Unauthorized or the verify finding. +verdict_knowledge: 'Callers learn init_namespace opens every namespace with its own genesis grant, root and accept roles and first policy under .decisions/ns// (a mandate is required), and that declare_role runs the role check over that namespace''s authority, then the same delta-gate as every verb, refusing with AuthorError::Unauthorized or the verify finding; the grant verbs live in grant_ops (LP-6.5, ruling 47).' contract_location: ledger-core/src/author/authority_ops.rs obligations: [] metadata: {} @@ -156,3 +156,27 @@ bindings: after: sha256:5ee3f00e52414f633ca8b3970070a8ea892e566db63ac49511c311d9f7700c18 base_revision: 70395dfe317cb9a00665433813766ca763f3c58a hash: sha256:bc915b2048b8716dff644b51608397512a153cd62eb87e6091331047bd121a90 +- symbol: namespace + file: ledger-core/src/author/authority_ops.rs + before: sha256:5ee3f00e52414f633ca8b3970070a8ea892e566db63ac49511c311d9f7700c18 + after: sha256:48aa783f1903bfcae7c9e5dfc63cc80a6ceb46005ba1fc15af7233711597bce6 + base_revision: ec43481f591b1022d8840755c8c0381b06a59387 + hash: sha256:c62fb0ecfe69a0f3867091334f08f2a2597dba2fba2f98ac03ce8f94ab804f87 +- symbol: accept_grant + file: ledger-core/src/author/authority_ops.rs + before: sha256:5ee3f00e52414f633ca8b3970070a8ea892e566db63ac49511c311d9f7700c18 + after: sha256:48aa783f1903bfcae7c9e5dfc63cc80a6ceb46005ba1fc15af7233711597bce6 + base_revision: ec43481f591b1022d8840755c8c0381b06a59387 + hash: sha256:1df6cb34c71c53f2161b8ed39a8a6fcd2dbf64496f0d0cc79379b549964ea323 +- symbol: grant + file: ledger-core/src/author/authority_ops.rs + before: sha256:5ee3f00e52414f633ca8b3970070a8ea892e566db63ac49511c311d9f7700c18 + after: sha256:48aa783f1903bfcae7c9e5dfc63cc80a6ceb46005ba1fc15af7233711597bce6 + base_revision: ec43481f591b1022d8840755c8c0381b06a59387 + hash: sha256:4b6a96428d9ce8d4fa86bb99691551a60b71dbee97256ba9db6becf528f592bf +- symbol: revoke_grant + file: ledger-core/src/author/authority_ops.rs + before: sha256:5ee3f00e52414f633ca8b3970070a8ea892e566db63ac49511c311d9f7700c18 + after: sha256:48aa783f1903bfcae7c9e5dfc63cc80a6ceb46005ba1fc15af7233711597bce6 + base_revision: ec43481f591b1022d8840755c8c0381b06a59387 + hash: sha256:6fef7024dec55afb1af27752d357b4f871c016ad935d584207a21b581a7a35e2 diff --git a/.ddd/seams/seam-rust-ledger-core-src-author-grant-ops-rs.yaml b/.ddd/seams/seam-rust-ledger-core-src-author-grant-ops-rs.yaml new file mode 100644 index 00000000..ec04240f --- /dev/null +++ b/.ddd/seams/seam-rust-ledger-core-src-author-grant-ops-rs.yaml @@ -0,0 +1,32 @@ +format: 2 +id: seam/rust/ledger-core-src-author-grant-ops-rs +boundary: contract surface of ledger-core/src/author/grant_ops.rs +verdict_knowledge: 'Callers learn the grant verbs (grant, accept_grant, revoke_grant) file into the namespace of the grant they act under or name, with --namespace deciding where a grant is filed when the store holds several, and run the role check over that namespace''s authority before the same delta-gate as every verb (LP-6.5, ruling 47).' +contract_location: ledger-core/src/author/grant_ops.rs +obligations: [] +metadata: {} +bindings: +- symbol: impl Author + file: ledger-core/src/author/grant_ops.rs + before: absent + after: sha256:077ac022737937424c066dc86eec75677f67b739e5cf648289c9a60cef2463fc + base_revision: ec43481f591b1022d8840755c8c0381b06a59387 + hash: sha256:110ce8a5e6db2dad95318c0fbb111ddabe07943fd2cb50ab1c43765cfed15a8a +- symbol: accept_grant + file: ledger-core/src/author/grant_ops.rs + before: absent + after: sha256:077ac022737937424c066dc86eec75677f67b739e5cf648289c9a60cef2463fc + base_revision: ec43481f591b1022d8840755c8c0381b06a59387 + hash: sha256:bcbd03d5b6b8cf505c6da4ecff936a11cdeab70a00e17cd70224e0fe8c0f3f26 +- symbol: grant + file: ledger-core/src/author/grant_ops.rs + before: absent + after: sha256:077ac022737937424c066dc86eec75677f67b739e5cf648289c9a60cef2463fc + base_revision: ec43481f591b1022d8840755c8c0381b06a59387 + hash: sha256:4d0914674b3caa5c35dce1f41733cf185ca3fe965e47ee110a547567e239855a +- symbol: revoke_grant + file: ledger-core/src/author/grant_ops.rs + before: absent + after: sha256:077ac022737937424c066dc86eec75677f67b739e5cf648289c9a60cef2463fc + base_revision: ec43481f591b1022d8840755c8c0381b06a59387 + hash: sha256:5bc507967c9e07b95b5ec42455fae91e8ade6136e4ec301ffa6b11b104742c45 diff --git a/.ddd/seams/seam-rust-ledger-core-src-authority-references-rs.yaml b/.ddd/seams/seam-rust-ledger-core-src-authority-references-rs.yaml index bb686470..f53e2191 100644 --- a/.ddd/seams/seam-rust-ledger-core-src-authority-references-rs.yaml +++ b/.ddd/seams/seam-rust-ledger-core-src-authority-references-rs.yaml @@ -1,7 +1,7 @@ format: 2 id: seam/rust/ledger-core-src-authority-references-rs boundary: contract surface of ledger-core/src/authority/references.rs -verdict_knowledge: "Consumers learn the cross-record SCHEMA rules: every reference resolves, grant acceptances are the holder's over the grant's hash, nothing is revoked or closed twice, a policy chain has one root and one tip." +verdict_knowledge: 'Consumers learn the cross-record SCHEMA rules: every reference resolves, grant acceptances are the holder''s over the grant''s hash, nothing is revoked or closed twice, a policy chain has one root and one tip.' contract_location: ledger-core/src/authority/references.rs obligations: [] metadata: {} @@ -12,3 +12,9 @@ bindings: after: sha256:f769607fa24749154575a997343858f32c8bdd8e563754597ce3f5c92175a838 base_revision: e33398d874ec5ddd52404c4f2c16039d5ed7cdbb hash: sha256:df70573cb08c341def4b796941772efab92ed37339af9745e1bb357a3812ce0a +- symbol: faults + file: ledger-core/src/authority/references.rs + before: sha256:46fc01a0349efbb313c844e871fb502fc48690f75b34e01e6f9abb8fff52701a + after: sha256:0f336007b90e01d37230407e17fbf082c098f4b826abb51e7e37f792cea88b55 + base_revision: ec43481f591b1022d8840755c8c0381b06a59387 + hash: sha256:40057befcfc460c1f0d9ba8666e45f885b8202b37f8f1889fbbdc0f4dfb6d624 diff --git a/.ddd/seams/seam-rust-ledger-core-src-authority-structure-rs.yaml b/.ddd/seams/seam-rust-ledger-core-src-authority-structure-rs.yaml index 323cb551..e57b1980 100644 --- a/.ddd/seams/seam-rust-ledger-core-src-authority-structure-rs.yaml +++ b/.ddd/seams/seam-rust-ledger-core-src-authority-structure-rs.yaml @@ -1,7 +1,7 @@ format: 2 id: seam/rust/ledger-core-src-authority-structure-rs boundary: contract surface of ledger-core/src/authority/structure.rs -verdict_knowledge: "Consumers learn the single-record SCHEMA rules for roles, grants, intervals, bindings and policies." +verdict_knowledge: 'Consumers learn the single-record SCHEMA rules for roles, grants, intervals, bindings and policies, judged in the record''s own namespace: a grant''s scope is read inside that namespace (* is the whole namespace) and a scope naming another namespace is a schema fault (LP-6.16, ruling 47).' contract_location: ledger-core/src/authority/structure.rs obligations: [] metadata: {} @@ -18,3 +18,9 @@ bindings: after: sha256:e0c3d508f0ef2979262139e05969da693efea418db1ceb75badccdcc7e72d504 base_revision: e33398d874ec5ddd52404c4f2c16039d5ed7cdbb hash: sha256:1163450ba0c074eabde3261db44d8bdff046bf260aa5e2dbd617edd78e9e3b81 +- symbol: entry_faults + file: ledger-core/src/authority/structure.rs + before: sha256:9e805d601ab60a3ab5ed4ad1fd523a183eb986067e9a7b5d3c8f562901eb3cb9 + after: sha256:8beb004635ac126329ee2b3f5fc0c4abf393044a879fa56b364b37f625fc21a2 + base_revision: ec43481f591b1022d8840755c8c0381b06a59387 + hash: sha256:2ab4c9253aa59d44a1a13b1429b20d4f0bc2de58a58346c054dd526549b7ec0c diff --git a/.ddd/seams/seam-rust-ledger-core-src-authority-view-rs.yaml b/.ddd/seams/seam-rust-ledger-core-src-authority-view-rs.yaml index 4e3ac0c0..acdc518d 100644 --- a/.ddd/seams/seam-rust-ledger-core-src-authority-view-rs.yaml +++ b/.ddd/seams/seam-rust-ledger-core-src-authority-view-rs.yaml @@ -1,7 +1,7 @@ format: 2 id: seam/rust/ledger-core-src-authority-view-rs boundary: contract surface of ledger-core/src/authority/view.rs -verdict_knowledge: Callers learn the Authority view's answers — live, accepted, available, revoked, superseded, the genesis, the policy in force — that every authority check reads. +verdict_knowledge: 'Callers learn the Authority view is built per namespace, of(store, ns) now and as_of(store, landing, pos, ns) at a past act, and answers live, accepted, available, revoked, superseded, the genesis and the policy in force for that namespace alone (LP-6.5, ruling 47).' contract_location: ledger-core/src/authority/view.rs obligations: [] metadata: {} @@ -156,3 +156,15 @@ bindings: after: sha256:a95a2e5a573e61b8bbf03ed29a0e0da62565668d3a6b0db6ec80e7bce38c86a3 base_revision: 88b3de113c03fa70091d00fef217e55d44ccac3e hash: sha256:8695ec2f847bedaa2c2643683652a1f6d5eefc81b4fdead32b0f87675cdbb357 +- symbol: as_of + file: ledger-core/src/authority/view.rs + before: sha256:a8b7b76054a59ee6efcea9392d21446f3cac91e586502902bbd7fa7afcf90c68 + after: sha256:c3153380e8201023a1f597f6168ccc9cf946100d28bf39f4598c17a598fd78f7 + base_revision: ec43481f591b1022d8840755c8c0381b06a59387 + hash: sha256:d583dbf483594c53d3ce621627b43ed7d5c35a23d747a7b81f4a72f24a208d33 +- symbol: of + file: ledger-core/src/authority/view.rs + before: sha256:a8b7b76054a59ee6efcea9392d21446f3cac91e586502902bbd7fa7afcf90c68 + after: sha256:c3153380e8201023a1f597f6168ccc9cf946100d28bf39f4598c17a598fd78f7 + base_revision: ec43481f591b1022d8840755c8c0381b06a59387 + hash: sha256:0f7ad170fd1459d5401f376ed2353ee507f8eaba5ef3b2a073f50bb4b7d90ee8 diff --git a/.ddd/seams/seam-rust-ledger-core-src-store-rs.yaml b/.ddd/seams/seam-rust-ledger-core-src-store-rs.yaml index 820ac8e2..ae3e14d4 100644 --- a/.ddd/seams/seam-rust-ledger-core-src-store-rs.yaml +++ b/.ddd/seams/seam-rust-ledger-core-src-store-rs.yaml @@ -1,7 +1,7 @@ format: 2 id: seam/rust/ledger-core-src-store-rs boundary: contract surface of ledger-core/src/store.rs -verdict_knowledge: 'Consumers learn the store loads every namespace''s sets, roles and log from .decisions/ns//, that a loaded set or change-set carries the namespace of the directory it was read from, that a set is looked up by (namespace, id) with set_in, and that every file must declare the format its fields need (LP-3.34).' +verdict_knowledge: 'Consumers learn the store loads every namespace''s sets, roles and log from .decisions/ns//, that a loaded set, role or change-set carries the namespace of the directory it was read from, that a set or a role is looked up by (namespace, id) with set_in and role_in, and that every file must declare the format its fields need (LP-3.34, ruling 47).' contract_location: ledger-core/src/store.rs obligations: [] metadata: {} @@ -54,3 +54,15 @@ bindings: after: sha256:dafa8d9927a1ed6ef80b99b682f949d26a927ece4a8b31ac044602b907884b26 base_revision: 70395dfe317cb9a00665433813766ca763f3c58a hash: sha256:68d5ee772625beaa0a9d2645073dca38bf54888b200ad90e9423cc28343e2128 +- symbol: role_in + file: ledger-core/src/store.rs + before: sha256:dafa8d9927a1ed6ef80b99b682f949d26a927ece4a8b31ac044602b907884b26 + after: sha256:f3d39821004043850810504c32f380e2cd29b21cfbf36010bfe0d0841c009826 + base_revision: ec43481f591b1022d8840755c8c0381b06a59387 + hash: sha256:39464c8758f1a1ab771855e12826b607b17b9d1d157a937e1fe73b8ea7d09971 +- symbol: role + file: ledger-core/src/store.rs + before: sha256:dafa8d9927a1ed6ef80b99b682f949d26a927ece4a8b31ac044602b907884b26 + after: sha256:f3d39821004043850810504c32f380e2cd29b21cfbf36010bfe0d0841c009826 + base_revision: ec43481f591b1022d8840755c8c0381b06a59387 + hash: sha256:a3a3c2c3ffbebd3cc37f21aa407f2f12808da096af45cd7f603e72d635dfbedb 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 f12489ec..c2692f6f 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 @@ -144,3 +144,21 @@ bindings: after: sha256:005fa2ac0a75a8c4cf4b1d60c90eff616b8e2fa84b879ab7a233d566775c09c6 base_revision: 74fb8581e549c803a1b4a0ddff3a6ce3ed90cc06 hash: sha256:50077d369f2ca0de42441e4d36809c172a001080777b7592ca84c080d9f38125 +- symbol: namespace + file: ledger-core/src/verify/mod.rs + before: sha256:005fa2ac0a75a8c4cf4b1d60c90eff616b8e2fa84b879ab7a233d566775c09c6 + after: sha256:f3695f3d10145990969ab880418d22b042965be165dd6b9543083d08f2d79071 + base_revision: ec43481f591b1022d8840755c8c0381b06a59387 + hash: sha256:0052a3c524aec10f372a29d7887054ddcc65165819d63459a9dab09f02d8923e +- symbol: genesis_unbound + file: ledger-core/src/verify/mod.rs + before: sha256:005fa2ac0a75a8c4cf4b1d60c90eff616b8e2fa84b879ab7a233d566775c09c6 + after: sha256:f3695f3d10145990969ab880418d22b042965be165dd6b9543083d08f2d79071 + base_revision: ec43481f591b1022d8840755c8c0381b06a59387 + hash: sha256:290f3a715ebc906dc2b796dc54e9c345d36d1a2ddf03f79b15e0fc29a13afaf8 +- symbol: namespaces + file: ledger-core/src/verify/mod.rs + before: sha256:005fa2ac0a75a8c4cf4b1d60c90eff616b8e2fa84b879ab7a233d566775c09c6 + after: sha256:f3695f3d10145990969ab880418d22b042965be165dd6b9543083d08f2d79071 + base_revision: ec43481f591b1022d8840755c8c0381b06a59387 + hash: sha256:b6181d32eae2268519f21749b68bbc52b97f0d30aeb7ce7e162221d88ed05a96 diff --git a/CLAUDE.md b/CLAUDE.md index 97628928..762374d7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -324,13 +324,21 @@ store (`.decisions/`), separate ontology. - **Authority (format 6 / spec v1.7, #69)** — roles (`.decisions/ns//roles/.yml`), grants + grant acceptances, unavailability / availability, key bindings, namespace policy, and the `rev:` revocation - entity, all in `ledger-core/src/authority/`. `ledger init --namespace - --external-ref ` bootstraps the genesis; `role declare`, - `grant new|accept|revoke`, `unavailable`, `available`, `identity + entity, all in `ledger-core/src/authority/`. **Authority is per + namespace** (spec v1.9, ruling 47): `ledger init --namespace + --external-ref ` opens every namespace with its own genesis + grant, root and accept roles and first policy, each under + `.decisions/ns//`; a grant belongs to the directory that holds it and + its scope is read inside that namespace (`*` = the whole namespace, + `ns:` a schema fault); `grant new --namespace` names where a grant + is filed when the store has several. `role declare`, `grant + new|accept|revoke`, `unavailable`, `available`, `identity add|rotate|revoke|sync`, `policy show|set`. The role check is one - function, `authority::authorize`, which `accept`, `revoke` and the grant - verbs call; a namespace **without** a policy is pre-v2 and not - role-checked. `allowed_signers` is derived (never hand-edited) and held by + 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 `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 diff --git a/ledger-cli/src/commands/authority.rs b/ledger-cli/src/commands/authority.rs index 5bb19f6a..5721d8e9 100644 --- a/ledger-cli/src/commands/authority.rs +++ b/ledger-cli/src/commands/authority.rs @@ -33,8 +33,9 @@ pub fn role(root: Option, cmd: RoleCmd) -> Result { pub fn grant(root: Option, cmd: GrantCmd) -> Result { match cmd { GrantCmd::Accept { grant } => finish(open_author(root)?.accept_grant(&grant.parse()?)), - GrantCmd::New { role, to, scope, order, limits, supersedes, as_role } => { + GrantCmd::New { role, to, scope, namespace, order, limits, supersedes, as_role } => { let args = GrantArgs { + namespace, role, holder: to.parse()?, scope: scope.parse()?, diff --git a/ledger-cli/src/commands/authority_enum.rs b/ledger-cli/src/commands/authority_enum.rs index 843b4fd8..03a72088 100644 --- a/ledger-cli/src/commands/authority_enum.rs +++ b/ledger-cli/src/commands/authority_enum.rs @@ -40,9 +40,14 @@ pub enum GrantCmd { /// The holder #[arg(long, value_name = "IDENTITY")] to: String, - /// `*`, `ns:`, `set:` or `pattern:` + /// `*`, `ns:`, `set:` or `pattern:` — read + /// inside the grant's own namespace #[arg(long)] scope: String, + /// The namespace whose directory holds the grant; inferred from a + /// `ns:` scope, else when the store has one namespace + #[arg(long)] + namespace: Option, /// `primary` or `fallback-N` #[arg(long, default_value = "primary")] order: String, diff --git a/ledger-cli/tests/authority.rs b/ledger-cli/tests/authority.rs index 8571207c..ff39aeed 100644 --- a/ledger-cli/tests/authority.rs +++ b/ledger-cli/tests/authority.rs @@ -299,10 +299,16 @@ fn a_grantor_below_star_is_refused_a_grant_over_another_scope() { repo.act_as(ARCHITECT); repo.ok(&["grant", "accept", &grant]); repo.ok(&["grant", "accept", &delegate]); - for scope in ["*", "ns:fixture.other", "set:ledger-design"] { + for scope in ["*", "set:ledger-design"] { let refused = repo.refused(&["grant", "new", "delegate", "--to", "third@customer.example", "--scope", scope]); assert!(refused.contains("does not cover this"), "{scope}: {refused}"); } + // A `ns:` scope names the namespace the grant is filed in (ruling 47): + // over another namespace the grant would be made in that namespace's + // authority, where the role is not even declared. + let elsewhere = repo.ledger(&["grant", "new", "delegate", "--to", "third@customer.example", "--scope", "ns:fixture.other"]); + assert_eq!(elsewhere.status.code(), Some(2), "{}", common::both(&elsewhere)); + assert!(common::both(&elsewhere).contains("not declared in `fixture.other`"), "{}", common::both(&elsewhere)); repo.ok(&["grant", "new", "delegate", "--to", "third@customer.example", "--scope", &format!("ns:{NS}")]); repo.ok(&["verify", "--no-blame"]); } diff --git a/ledger-cli/tests/authority_namespaces.rs b/ledger-cli/tests/authority_namespaces.rs new file mode 100644 index 00000000..c7b34ed2 --- /dev/null +++ b/ledger-cli/tests/authority_namespaces.rs @@ -0,0 +1,268 @@ +//! Authority is per namespace (LP-6.31, ruling 47; PRD §3.2; issue 5). +//! +//! Each namespace is opened with its own genesis grant, roles and first +//! policy, declared and filed under its own directory; a scope is read +//! inside the grant's namespace; `A003` and `A005` count per namespace +//! because the graph stage runs over each namespace's graph alone; and a +//! store that shared one genesis across namespaces, as the writer built one +//! before this, fails verification (AC-47 first bullet, AC-48, AC-68). +//! +//! Keys are still bound and trusted store-wide here (issue 6 scopes them), +//! so the genesis holder's key bound in A signs in B once `init` binds it +//! there. + +mod common; + +use common::{hand, Repo}; +use ledger_core::authority::payload::grant_hash; +use ledger_core::authority::{Grant, GrantScope, Order, Policy, Scheme}; +use ledger_core::changeset::ChangeSet; +use ledger_core::mint::UlidMint; + +const OWNER: &str = "owner@customer.example"; +const ARCHITECT: &str = "architect@customer.example"; +const MANDATE_A: &str = "contract 2026/117"; +const MANDATE_B: &str = "contract 2026/118"; +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)) +} + +fn store(repo: &Repo) -> ledger_core::store::Store { + ledger_core::store::load(repo.path()) +} + +/// The live genesis grants, by the namespace whose directory holds them. +fn genesis_by_namespace(repo: &Repo) -> Vec<(String, String)> { + let store = store(repo); + let mut out: Vec<(String, String)> = store + .log + .iter() + .flat_map(|l| l.file.grants.iter().filter(|g| g.genesis).map(move |g| (l.namespace.clone(), g.id.to_string()))) + .collect(); + out.sort(); + out +} + +fn decision(repo: &Repo, ns: &str, statement: &str) -> String { + let out = repo.ok(&["add", "--set", "ledger-design", "--namespace", ns, "--statement", statement, "--store", "constraint", "--discharge", "analyzer:DEC001"]); + common::decision_id(&out) +} + +/// Two namespaces, each opened on its own mandate by the same holder, a set +/// declared in each, committed. +fn two_governed() -> (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_A]); + repo.ok(&["init", "--namespace", B, "--external-ref", MANDATE_B]); + repo.declare_in(A); + repo.declare_in(B); + hand::commit(&repo, "two namespaces, two trust roots"); + (repo, key) +} + +/// An acceptor grant to `who` over `scope`, filed in `ns` and accepted. +fn acceptor(repo: &Repo, ns: &str, who: &str, scope: &str) -> String { + let grant = hand::word(&repo.ok(&["grant", "new", "acceptor", "--to", who, "--scope", scope, "--namespace", ns]), "grant:"); + repo.act_as(who); + repo.ok(&["grant", "accept", &grant]); + repo.act_as(OWNER); + grant +} + +/// File a grant by hand under `dir`'s directory, past the verb. +fn file_grant(repo: &Repo, dir: &str, grant: Grant) -> String { + let id = grant.id.to_string(); + let mut mint = UlidMint::system(); + let mut cs = ChangeSet::empty(ledger_core::format::SIGNING_FORMAT, mint.mint_id("cs").expect("cs id"), grant.at, OWNER.parse().expect("identity"), None); + cs.grants.push(grant); + let log = repo.log_dir(dir); + std::fs::create_dir_all(&log).expect("log dir"); + std::fs::write(log.join(cs.file_name()), serde_yaml::to_string(&cs).expect("yaml")).expect("write"); + id +} + +// ---- AC-47, first bullet ------------------------------------------------- + +#[test] +fn each_namespace_has_its_own_genesis_roles_and_policy_and_a003_a005_do_not_fire() { + let (repo, _) = two_governed(); + let genesis = genesis_by_namespace(&repo); + assert_eq!(genesis.len(), 2, "one trust root per namespace: {genesis:?}"); + assert_eq!(genesis[0].0, A); + assert_eq!(genesis[1].0, B); + for ns in [A, B] { + assert!(repo.roles_dir(ns).join("steward.yml").is_file(), "`{ns}` declares its own root role"); + assert!(repo.roles_dir(ns).join("acceptor.yml").is_file(), "`{ns}` declares its own accept role"); + let store = store(&repo); + let policy = store.log.iter().flat_map(|l| l.file.policies.iter()).find(|p| p.namespace == ns).expect("policy"); + let under = policy.under.as_ref().map(ToString::to_string).expect("under"); + assert!(genesis.contains(&(ns.to_string(), under)), "`{ns}`'s first policy is under its own genesis"); + } + let (code, text) = verify(&repo); + assert_eq!(code, 0, "{text}"); + assert!(!text.contains("[A005]") && !text.contains("[A003]"), "{text}"); + assert!(text.contains("conformant"), "{text}"); +} + +#[test] +fn a_second_init_of_a_namespace_needs_its_own_mandate_and_a_namespace_has_one_trust_root() { + let repo = Repo::with_identity(OWNER); + repo.ok(&["init", "--namespace", A, "--external-ref", MANDATE_A, "--without-key"]); + // Every namespace is opened on its own mandate (ruling 47): no joining + // the first namespace's genesis. + let out = repo.ledger(&["init", "--namespace", B]); + assert_eq!(out.status.code(), Some(2), "{}", common::both(&out)); + assert!(common::both(&out).contains("--external-ref"), "{}", common::both(&out)); + assert!(genesis_by_namespace(&repo).len() == 1, "nothing written for `{B}`"); + // And a namespace is opened once. + let again = repo.ledger(&["init", "--namespace", A, "--external-ref", "another"]); + assert_eq!(again.status.code(), Some(1), "{}", common::both(&again)); + assert!(common::both(&again).contains("already under policy"), "{}", common::both(&again)); +} + +#[test] +fn a_grant_over_star_in_a_covers_a_alone() { + let (repo, key) = two_governed(); + // `*` is the whole of the grant's own namespace (ruling 67). With two + // namespaces the grant names where it is filed. + let refused = repo.ledger(&["grant", "new", "acceptor", "--to", ARCHITECT, "--scope", "*"]); + assert_eq!(refused.status.code(), Some(2), "{}", common::both(&refused)); + assert!(common::both(&refused).contains("pass --namespace"), "{}", common::both(&refused)); + acceptor(&repo, A, ARCHITECT, "*"); + let in_a = decision(&repo, A, "Accepted in A under a star grant of A."); + let in_b = decision(&repo, B, "Not acceptable in B under A's star."); + hand::commit(&repo, "grants and decisions"); + let arch = repo.keygen("architect"); + repo.ok(&["identity", "add", "--namespace", A, "--key-file", &format!("{arch}.pub"), "--for", ARCHITECT]); + repo.act_as(ARCHITECT); + repo.use_key(&arch); + repo.ok_tty(&["accept", &in_a]); + let refused = repo.refused_tty(&["accept", &in_b]); + assert!(refused.contains(&format!("may not accept in `{B}`")) && refused.contains("holds no grant"), "{refused}"); + repo.act_as(OWNER); + repo.use_key(&key); + hand::commit(&repo, "accepted in A only"); + let (code, text) = verify(&repo); + assert_eq!(code, 0, "{text}"); +} + +#[test] +fn a_grant_scoped_to_another_namespace_is_refused_by_the_verb_and_a_schema_fault_by_hand() { + let (repo, _) = two_governed(); + let refused = repo.ledger(&["grant", "new", "acceptor", "--to", ARCHITECT, "--scope", &format!("ns:{B}"), "--namespace", A]); + assert_eq!(refused.status.code(), Some(2), "{}", common::both(&refused)); + assert!(common::both(&refused).contains("names another namespace"), "{}", common::both(&refused)); + // Past the verb: a grant filed under A scoped `ns:b.ledger` could never + // have effect (LP-6.16). + let genesis = genesis_by_namespace(&repo).into_iter().find(|(ns, _)| ns == A).map(|(_, id)| id).expect("A's genesis"); + let mut grant = Grant { + id: UlidMint::system().mint_id("grant").expect("id"), + role: "acceptor".into(), + scope: GrantScope::Namespace(B.into()), + holder: ARCHITECT.parse().expect("identity"), + granted_by: OWNER.parse().expect("identity"), + order: Order::PRIMARY, + limits: Vec::new(), + genesis: false, + external_ref: None, + supersedes: None, + under: Some(genesis.parse().expect("grant id")), + at: chrono::Utc::now(), + hash: ledger_core::hash::VersionHash::zero(), + }; + grant.hash = grant_hash(&grant); + let id = file_grant(&repo, A, grant); + hand::commit(&repo, "a grant scoped elsewhere, by hand"); + let (code, text) = verify(&repo); + assert_eq!(code, 1, "{text}"); + assert!(text.contains(&format!("[SCHEMA] {id}")) && text.contains(&format!("scoped `ns:{B}` but filed under `{A}`")), "{text}"); +} + +// ---- AC-48 --------------------------------------------------------------- + +#[test] +fn no_grant_carries_a_namespace_field_and_authority_records_sit_apart_from_decisions() { + let (repo, _) = two_governed(); + acceptor(&repo, A, OWNER, &format!("ns:{A}")); + decision(&repo, A, "A decision beside the authority records."); + let store = store(&repo); + for logged in &store.log { + let cs = &logged.file; + for g in &cs.grants { + let text = serde_yaml::to_string(g).expect("yaml"); + assert!(!text.contains("namespace"), "a grant belongs by directory, never by a field (PRD §3.12): {text}"); + } + let authority = !cs.grants.is_empty() || !cs.grant_acceptances.is_empty() || !cs.policies.is_empty() || !cs.key_bindings.is_empty() || !cs.revocations.is_empty(); + if authority { + assert!(cs.decisions.is_empty() && cs.versions.is_empty() && cs.acceptances.is_empty(), "authority records stay in change-sets of their own: {}", logged.path.display()); + } + } +} + +// ---- AC-68 --------------------------------------------------------------- + +/// B's first policy as the writer filed it before this: under A's genesis, +/// signed by the holder's key trusted in A, with the holder's own `add` +/// in B signed the same way. +#[test] +fn a_store_that_shared_one_genesis_across_namespaces_fails_a006_and_d7() { + let repo = Repo::with_identity(OWNER); + let key = repo.keygen("owner"); + repo.use_key(&key); + repo.ok(&["init", "--namespace", A, "--external-ref", MANDATE_A]); + hand::commit(&repo, "A"); + let store = store(&repo); + let genesis = store.log.iter().flat_map(|l| l.file.grants.iter()).find(|g| g.genesis).cloned().expect("A's genesis"); + let mut policy = Policy { + id: UlidMint::system().mint_id("pol").expect("id"), + namespace: B.into(), + schemes: vec![Scheme::Ssh], + require_sk: false, + accept_role: "acceptor".into(), + reaccept_within_days: None, + replaces: None, + by: OWNER.parse().expect("identity"), + under: Some(genesis.id.clone()), + at: chrono::Utc::now(), + hash: ledger_core::hash::VersionHash::zero(), + }; + policy.hash = ledger_core::authority::payload::policy_hash(&policy); + let mut binding = hand::binding(ledger_core::authority::BindingAct::Add, OWNER, OWNER, B, Some(&format!("{key}.pub")), None, None); + binding.at = policy.at; + binding.hash = ledger_core::authority::payload::binding_hash(&binding); + let (pol_id, pol_ulid) = (policy.id.to_string(), policy.id.ulid().to_string()); + let (key_id, key_ulid) = (binding.id.to_string(), binding.id.ulid().to_string()); + hand::file(&repo, vec![binding], vec![policy], &[(&pol_ulid, &key), (&key_ulid, &key)]); + hand::commit(&repo, "B under A's genesis, as the writer once filed it"); + let (code, text) = verify(&repo); + assert_eq!(code, 1, "{text}"); + assert!(text.contains(&format!("[A006] {pol_id}")) && text.contains(&format!("no live genesis grant of `{B}`")), "{text}"); + assert!(text.contains(&format!("[SCHEMA] {key_id}")) && text.contains("D7"), "its first key binding leans on no other namespace: {text}"); +} + +// ---- Carried: no agent, no non-interactive acceptance, in a later namespace + +#[test] +fn no_agent_identity_and_no_non_interactive_session_produces_an_acceptance_in_a_later_namespace() { + let (repo, _) = two_governed(); + acceptor(&repo, B, OWNER, &format!("ns:{B}")); + let id = decision(&repo, B, "Decided in B."); + let before = repo.log_files(); + let piped = repo.piped(&["accept", &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(&["accept", &id]); + assert_ne!(out.status.code(), Some(0), "{agent}: {}", common::both(&out)); + } + assert_eq!(repo.log_files(), before, "nothing filed"); + repo.act_as(OWNER); + repo.ok_tty(&["accept", &id]); + assert_eq!(repo.log_files().len(), before.len() + 1, "the holder, at a terminal, accepts in B"); +} diff --git a/ledger-cli/tests/binding_accounting.rs b/ledger-cli/tests/binding_accounting.rs index 8882419d..242cd2b2 100644 --- a/ledger-cli/tests/binding_accounting.rs +++ b/ledger-cli/tests/binding_accounting.rs @@ -7,7 +7,7 @@ mod common; use chrono::{Duration, Utc}; use common::{hand, Repo}; -use ledger_core::authority::{BindingAct, Policy, Scheme}; +use ledger_core::authority::{BindingAct, Scheme}; use ledger_core::mint::UlidMint; const OWNER: &str = "owner@customer.example"; @@ -81,30 +81,11 @@ fn by_hand(repo: &Repo, by: &str, principal: &str, ns: &str, at: chrono::DateTim blob(&key) } -/// A first policy for `ns` filed by hand by the owner under the genesis -/// grant, signed by `signer` when given. +/// A first policy for `ns`, founded by hand with its own genesis ten seconds +/// older (ruling 47: no namespace joins another's genesis), signed by +/// `signer` when given. Returns the policy's `at`. fn first_policy(repo: &Repo, ns: &str, schemes: Vec, signer: Option<&str>) -> chrono::DateTime { - let store = ledger_core::store::load(repo.path()); - let genesis = store.log.iter().flat_map(|l| l.file.grants.iter()).find(|g| g.genesis).cloned().expect("genesis"); - let mut p = Policy { - id: UlidMint::system().mint_id("pol").expect("id"), - namespace: ns.into(), - schemes, - require_sk: false, - accept_role: "acceptor".into(), - reaccept_within_days: None, - replaces: None, - by: OWNER.parse().expect("id"), - under: Some(genesis.id.clone()), - at: Utc::now(), - hash: ledger_core::hash::VersionHash::zero(), - }; - p.hash = ledger_core::authority::payload::policy_hash(&p); - let ulid = p.id.ulid().to_string(); - let signers: Vec<(&str, &str)> = signer.map(|s| vec![(ulid.as_str(), s)]).unwrap_or_default(); - let at = p.at; - hand::file(repo, Vec::new(), vec![p], &signers); - at + hand::found(repo, ns, OWNER, schemes, signer).at } #[test] diff --git a/ledger-cli/tests/closed_key.rs b/ledger-cli/tests/closed_key.rs index 8606ca7c..24ef0368 100644 --- a/ledger-cli/tests/closed_key.rs +++ b/ledger-cli/tests/closed_key.rs @@ -165,16 +165,19 @@ fn the_genesis_holders_first_binding_in_a_second_namespace_vouched_by_the_closed repo.ok(&["init", "--namespace", SECOND, "--external-ref", "contract 2026/117"]); hand::commit(&repo, "second namespace"); let fresh = repo.keygen("owner-second"); + // Dated after the second namespace's policy and genesis (ruling 47: a + // binding before its namespace's genesis is D7's, whatever signs it), so + // the only question left is the signature — and the closed key answers + // for nothing. + let policy_at = store(&repo).log.iter().flat_map(|l| l.file.policies.iter()).find(|p| p.namespace == SECOND).map(|p| p.at).expect("policy"); let mut b = hand::binding(BindingAct::Add, OWNER, OWNER, SECOND, Some(&format!("{fresh}.pub")), None, None); - b.at = inside; + b.at = policy_at + Duration::seconds(1); b.hash = binding_hash(&b); let id = b.id.to_string(); let signed = vec![(b.id.ulid().to_string(), SECOND.to_string(), binding_bytes(&b))]; let mut cs = change_set(inside); cs.key_bindings.push(b); file_signed(&repo, cs, &signed, &key); - // Dated before the second namespace's first policy but landed after it, - // so the policy governs it (D5 (c), D6) — it is no pre-policy act. fails_with(&repo, &id, "L011"); let signers = derived_signers(&repo); assert!(!signers.contains(&public(&fresh)), "{signers}"); diff --git a/ledger-cli/tests/common/hand.rs b/ledger-cli/tests/common/hand.rs index 8ce5cee7..4ec1468e 100644 --- a/ledger-cli/tests/common/hand.rs +++ b/ledger-cli/tests/common/hand.rs @@ -194,3 +194,69 @@ pub fn append_into(repo: &Repo, id: &str, into: &std::path::Path) { std::fs::write(into, serde_yaml::to_string(&target).expect("yaml")).expect("write"); std::fs::remove_file(from).expect("remove"); } + +/// Found `ns` by hand with a genesis older than its first policy (ruling 47: +/// every namespace has its own): the root and accept roles under its +/// `roles/`, one change-set holding `holder`'s genesis grant and its +/// acceptance dated ten seconds before the policy, and the first policy +/// under that genesis — signed by `signer` when given. The writer never +/// files this shape (`init` dates a namespace's genesis with its policy), so +/// it is the hand-built store in which a binding can be before the first +/// policy yet after the genesis. +pub fn found(repo: &Repo, ns: &str, holder: &str, schemes: Vec, signer: Option<&str>) -> ledger_core::authority::Policy { + use ledger_core::authority::{Capability, Grant, GrantAcceptance, GrantScope, Order, Policy, Role}; + let who: ledger_core::identity::Identity = holder.parse().expect("holder"); + let mut mint = UlidMint::system(); + let roles_dir = repo.roles_dir(ns); + std::fs::create_dir_all(&roles_dir).expect("roles dir"); + for (id, may) in [("steward", Capability::ROOT.to_vec()), ("acceptor", vec![Capability::AcceptDecision])] { + let role = Role { format: 6, id: id.into(), namespace: ns.into(), title: None, owner: who.clone(), may, created_at: Utc::now().date_naive(), notes: None }; + std::fs::write(roles_dir.join(role.file_name()), serde_yaml::to_string(&role).expect("yaml")).expect("role"); + } + let t0 = Utc::now() - chrono::Duration::seconds(10); + let mut genesis = Grant { + id: mint.mint_id("grant").expect("id"), + role: "steward".into(), + scope: GrantScope::All, + holder: who.clone(), + granted_by: who.clone(), + order: Order::PRIMARY, + limits: Vec::new(), + genesis: true, + external_ref: Some(format!("mandate for {ns}")), + supersedes: None, + under: None, + at: t0, + hash: ledger_core::hash::VersionHash::zero(), + }; + genesis.hash = ledger_core::authority::payload::grant_hash(&genesis); + let accepted = GrantAcceptance { id: mint.mint_id("gacc").expect("id"), grant: genesis.id.clone(), signs: genesis.hash.clone(), actor: who.clone(), at: t0 }; + let mut policy = Policy { + id: mint.mint_id("pol").expect("id"), + namespace: ns.into(), + schemes, + require_sk: false, + accept_role: "acceptor".into(), + reaccept_within_days: None, + replaces: None, + by: who.clone(), + under: Some(genesis.id.clone()), + at: t0 + chrono::Duration::seconds(10), + hash: ledger_core::hash::VersionHash::zero(), + }; + policy.hash = ledger_core::authority::payload::policy_hash(&policy); + if let Some(key) = signer { + let dir = repo.sig_dir(ns); + std::fs::create_dir_all(&dir).expect("sig dir"); + let sig = ssh_sign(key, ns, &ledger_core::authority::payload::policy_bytes(&policy)); + std::fs::write(dir.join(format!("{}.ssh.sig", policy.id.ulid())), sig).expect("sig"); + } + let mut cs = ChangeSet::empty(7, mint.mint_id("cs").expect("cs id"), t0, who, Some(format!("found {ns} by hand"))); + cs.grants.push(genesis); + cs.grant_acceptances.push(accepted); + cs.policies.push(policy.clone()); + let log = repo.log_dir(ns); + std::fs::create_dir_all(&log).expect("log dir"); + std::fs::write(log.join(cs.file_name()), serde_yaml::to_string(&cs).expect("yaml")).expect("write log"); + policy +} diff --git a/ledger-cli/tests/pre_policy_binding.rs b/ledger-cli/tests/pre_policy_binding.rs index e4b1829e..d2f2b6c8 100644 --- a/ledger-cli/tests/pre_policy_binding.rs +++ b/ledger-cli/tests/pre_policy_binding.rs @@ -3,13 +3,13 @@ //! and by the signature requirement of that first policy. The pre-policy //! exemption covers acceptances and revocations only. //! -//! **How one arises.** In a fresh store the genesis grant carries the first -//! policy's `at`, so a binding dated before the policy is also before the -//! grant, and D7 refuses it. In a later namespace the grant is older than -//! the policy, and no verb compares a binding's `at` with the policy's. A -//! binding filed on a clock behind the policy's, and committed with it, is -//! *before* the policy by D6 (same landing, earlier `at`). By hand, any -//! writer can file the same. +//! **How one arises.** `init` dates a namespace's genesis grant with its +//! first policy — every namespace, since each has its own genesis (ruling +//! 47) — so a binding dated before the policy is also before the grant, and +//! D7 refuses it. By hand a namespace can be founded with a genesis older +//! than its policy (`hand::found`), and a binding dated between them is +//! *before* the policy by D6 (same landing, earlier `at`) yet after the +//! grant: that is the store the ruling's cases reach. //! //! **What the ruling gives.** //! - A signed binding before the policy is trusted. @@ -18,9 +18,9 @@ //! - Under a `[none]` first policy, D7 alone decides it. //! - A binding in a namespace no policy governs at all stays a schema fault. //! -//! Since `init --namespace` binds the holder's key in the same act, even in -//! a later namespace, a skewed further key dated before that binding is -//! refused for what it would do to `init`'s own binding (asserted below). +//! Since `init --namespace` binds the holder's key in the same act, dated +//! with the policy and the genesis, a skewed further key dated before them +//! is refused by D7: as of it the namespace has no genesis (asserted below). mod common; @@ -91,38 +91,14 @@ fn in_a_fresh_store_d7_refuses_a_binding_dated_before_the_first_policy() { assert!(format!("{refused:?}").contains("D7"), "{refused:?}"); } -/// The second namespace's first policy as an earlier CLI filed it — no key -/// binding in the same act — signed by the owner's key trusted in `NS`. -fn legacy_second_policy(repo: &Repo, key: &str) -> Policy { - let store = ledger_core::store::load(repo.path()); - let genesis = store.log.iter().flat_map(|l| l.file.grants.iter()).find(|g| g.genesis).cloned().expect("genesis"); - let mut policy = Policy { - id: UlidMint::system().mint_id("pol").expect("id"), - namespace: SECOND.into(), - schemes: vec![Scheme::Ssh], - require_sk: false, - accept_role: "acceptor".into(), - reaccept_within_days: None, - replaces: None, - by: OWNER.parse().expect("id"), - under: Some(genesis.id.clone()), - at: chrono::Utc::now(), - hash: ledger_core::hash::VersionHash::zero(), - }; - policy.hash = ledger_core::authority::payload::policy_hash(&policy); - let ulid = policy.id.ulid().to_string(); - hand::file(repo, Vec::new(), vec![policy.clone()], &[(&ulid, key)]); - 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 opened before `init` bound the holder's key in the same - // act; then a key added on a clock behind its policy, signed by the - // owner's key trusted in the first namespace. - legacy_second_policy(&repo, &owner); + // 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)); let next = repo.keygen("owner-second"); 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"); @@ -145,28 +121,28 @@ fn a_signed_binding_before_the_first_policy_is_trusted_and_signs() { repo.ok_tty(&["accept", &common::decision_id(&out)]); } -/// Since `init` binds the holder's key in the same act, a further key dated -/// before that binding takes the namespace's first-key place. `init`'s own -/// binding — signed by the holder's key from another namespace — is then a -/// further `add`, which must be signed by a key of theirs in this namespace, -/// and it would fail `L011`. The verb refuses to introduce that. +/// `init` dates a later namespace's genesis with its policy (ruling 47), so +/// a further key dated before that policy is before the genesis too, and D7 +/// refuses it: as of the binding nobody is the genesis holder there. #[test] -fn a_skewed_add_dated_before_inits_binding_is_refused_for_what_it_would_do_to_it() { +fn a_skewed_add_dated_before_inits_genesis_is_refused_by_d7() { let repo = governed(); repo.ok(&["init", "--namespace", SECOND, "--external-ref", "contract 2026/117"]); let next = repo.keygen("owner-second"); let (key_type, blob) = public(&next); let refused = bind_behind(&repo, SECOND, Duration::seconds(1), key_type, blob).expect_err("refused"); let text = format!("{refused:?}"); - assert!(text.contains("L011") && text.contains("no key bound to"), "{text}"); + assert!(text.contains("D7") && text.contains(&format!("no live key in `{SECOND}`")), "{text}"); } #[test] fn an_unsigned_binding_before_a_signed_first_policy_fails_l011_and_is_never_trusted() { let repo = governed(); - // Filed with the policy, dated a second before it. - repo.ok(&["init", "--namespace", SECOND, "--external-ref", "contract 2026/117"]); - let (id, blob) = unsigned_before(&repo, SECOND, policy_of(&repo, SECOND).at); + 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)); + 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"]); let text = common::both(&out); @@ -177,7 +153,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_will_not_govern_it() { +fn a_binding_in_a_namespace_no_policy_governs_is_a_schema_fault_and_init_does_not_launder_it() { let repo = governed(); let (id, _) = unsigned_before(&repo, SECOND, chrono::Utc::now()); hand::commit(&repo, "a binding in a namespace nobody governs"); @@ -185,35 +161,24 @@ fn a_binding_in_a_namespace_no_policy_governs_is_a_schema_fault_and_init_will_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}"); - // Opting the namespace in would turn it into L011 — judged under the new - // first policy, unsigned — which init's gate refuses to introduce. - let refused = repo.refused(&["init", "--namespace", SECOND, "--external-ref", "contract 2026/117"]); - assert!(refused.contains("L011") && refused.contains(&id), "{refused}"); + // 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}"); } #[test] fn under_a_none_first_policy_d7_alone_decides_a_binding_before_it() { let repo = governed(); - let store = ledger_core::store::load(repo.path()); - let genesis = store.log.iter().flat_map(|l| l.file.grants.iter()).find(|g| g.genesis).cloned().expect("genesis"); - let mut mint = UlidMint::system(); - let mut policy = Policy { - id: mint.mint_id("pol").expect("id"), - namespace: SECOND.into(), - schemes: vec![Scheme::None], - require_sk: false, - accept_role: "acceptor".into(), - reaccept_within_days: None, - replaces: None, - by: OWNER.parse().expect("id"), - under: Some(genesis.id.clone()), - at: chrono::Utc::now(), - hash: ledger_core::hash::VersionHash::zero(), - }; - policy.hash = ledger_core::authority::payload::policy_hash(&policy); - let at = policy.at; - hand::file(&repo, Vec::new(), vec![policy], &[]); - let (id, blob) = unsigned_before(&repo, SECOND, at); + // A namespace founded by hand with a `[none]` first policy, its genesis + // older; an unsigned binding dated before the policy, after the genesis. + let policy = hand::found(&repo, SECOND, OWNER, vec![Scheme::None], None); + let (id, blob) = unsigned_before(&repo, SECOND, policy.at); hand::commit(&repo, "a [none] first policy, and an unsigned binding before it"); repo.ok(&["identity", "sync"]); let out = repo.ledger(&["verify", "--no-blame"]); diff --git a/ledger-core/src/author/acceptance_ops.rs b/ledger-core/src/author/acceptance_ops.rs index c9609c26..b6355dd0 100644 --- a/ledger-core/src/author/acceptance_ops.rs +++ b/ledger-core/src/author/acceptance_ops.rs @@ -64,7 +64,7 @@ impl Author { decision: &DecisionId, act: Act, ) -> Result, AuthorError> { - let auth = Authority::build(store); + let auth = Authority::of(store, decision.namespace()); let Some(policy) = auth.policy(decision.namespace()) else { return Ok(None) }; let set = view .latest @@ -73,7 +73,7 @@ impl Author { .map(|v| v.raw.set.clone()) .unwrap_or_default(); let target = Target::Decision { namespace: decision.namespace(), set: &set }; - self.authorized(store, act, target, Some(&policy.accept_role)).map(Some) + self.authorized(store, decision.namespace(), act, target, Some(&policy.accept_role)).map(Some) } /// Sign the latest version of a decision as the configured identity. @@ -91,7 +91,7 @@ impl Author { at: self.now, scope: AcceptanceScope::Version, expires_at: args.expires_at, - under: held.as_ref().map(super::authority_ops::under_of).transpose()?, + under: held.as_ref().map(super::grant_ops::under_of).transpose()?, signature: String::new(), }; let policy = Authority::build(&store).policy(args.decision.namespace()).cloned(); @@ -169,7 +169,7 @@ impl Author { Some(acceptance) => self.decision_authority(&store, &view, &acceptance.decision, Act::RevokeAcceptance)?, None => None, }; - let under = held.as_ref().map(super::authority_ops::under_of).transpose()?; + let under = held.as_ref().map(super::grant_ops::under_of).transpose()?; let revocation = self.revocation(Revocable::Acceptance(args.acceptance.clone()), args.reason.clone(), under)?; let id = revocation.id.as_ref().map(ToString::to_string).unwrap_or_default(); if let Some(acceptance) = revoked { diff --git a/ledger-core/src/author/authority_ops.rs b/ledger-core/src/author/authority_ops.rs index 7366291a..983418a7 100644 --- a/ledger-core/src/author/authority_ops.rs +++ b/ledger-core/src/author/authority_ops.rs @@ -8,21 +8,23 @@ //! (an acceptance by an actor who held no grant, `A006`) is deferred until //! the decision-class → role mapping exists. //! -//! **Genesis.** The first `init --namespace` in a store bootstraps the -//! trust root: the root role carrying the four authority capabilities -//! ([`Capability::ROOT`]) and none of the decision ones, the genesis grant -//! (self-granted, `*`, primary, naming the out-of-band mandate), the -//! holder's acceptance of it, and the namespace's first policy, whose -//! `accept_role` is a different role (D9 (f)). Nobody holds the accept role -//! until it is granted — to the genesis holder too, if that person is to -//! accept. Later namespaces are the genesis holder's to initialise, and -//! file a policy (and the accept role, if new) only — a store has one trust -//! root (`A005`). +//! **Genesis.** Every `init --namespace` opens a trust root of its own +//! (LP-6.31, ruling 47): the root role carrying the four authority +//! capabilities ([`Capability::ROOT`]) and none of the decision ones, +//! declared under the namespace's `roles/`; the genesis grant (self-granted, +//! `*` — the whole of this namespace — primary, naming the out-of-band +//! mandate); the holder's acceptance of it; and the namespace's first +//! policy, whose `accept_role` is a different role (D9 (f)). Nobody holds +//! the accept role until it is granted — to the genesis holder too, if that +//! person is to accept. A namespace has one trust root (`A005`); a store +//! has as many as it has namespaces, and nothing links them. Authority +//! records are filed in change-sets of their own, apart from decisions +//! (PRD §3.12). -use crate::authority::payload::{grant_hash, revocation_hash}; +use crate::authority::payload::grant_hash; use crate::authority::{ authorize, Act, Authority, Authorized, Capability, Grant, GrantAcceptance, GrantScope, Limit, - Order, Policy, Revocable, Revocation, Role, Scheme, Target, + Order, Policy, Role, Scheme, Target, }; use crate::id::GrantId; use crate::store::Store; @@ -32,8 +34,8 @@ use super::{Applied, Author, AuthorError}; /// What `init --namespace` states. pub struct InitNamespaceArgs { pub namespace: String, - /// The mandate outside the tool — required when the store has no - /// genesis yet, ignored after. + /// The mandate outside the tool the namespace's genesis rests on — + /// required every time: each namespace is opened on its own (ruling 47). pub external_ref: Option, /// The genesis role's id (created with [`Capability::ROOT`] if absent). pub role: String, @@ -62,6 +64,10 @@ pub struct RoleArgs { /// What `grant new` states. pub struct GrantArgs { + /// The namespace whose directory holds the grant and whose authority + /// the grantor acts in: explicit, else a `ns:` scope's, else the one + /// namespace the store has a directory for. + pub namespace: Option, pub role: String, pub holder: crate::identity::Identity, pub scope: GrantScope, @@ -71,38 +77,41 @@ pub struct GrantArgs { } impl Author { - /// The role check, as a verb's refusal. + /// The role check in `namespace`'s authority, as a verb's refusal + /// (ruling 47: a grant acts in the namespace whose directory holds it). pub(crate) fn authorized( &self, store: &Store, + namespace: &str, act: Act, target: Target<'_>, role: Option<&str>, ) -> Result { let as_role = self.as_role.as_deref(); - authorize(&Authority::build(store), &self.who, act, target, self.now, role, as_role).map_err(|d| { - AuthorError::Unauthorized(format!("{} may not {}: {d}", self.who, act.as_str())) + authorize(&Authority::of(store, namespace), &self.who, act, target, self.now, role, as_role).map_err(|d| { + AuthorError::Unauthorized(format!("{} may not {} in `{namespace}`: {d}", self.who, act.as_str())) }) } - /// Put a namespace under policy, bootstrapping the genesis if needed. + /// Put a namespace under policy, opening its own trust root (LP-6.31). pub fn init_namespace(&mut self, args: InitNamespaceArgs) -> Result { crate::id::validate_namespace(&args.namespace).map_err(AuthorError::Usage)?; let mut store = self.load(); - let auth = Authority::build(&store); + let auth = Authority::of(&store, &args.namespace); if auth.policy(&args.namespace).is_some() { return Err(AuthorError::Conflict(format!( "namespace `{}` is already under policy — `ledger policy set` changes it", args.namespace ))); } - let genesis = auth.genesis().cloned(); - let joined = genesis.as_ref().map(|g| g.id.clone()); + if let Some(g) = auth.genesis() { + return Err(AuthorError::Conflict(format!( + "namespace `{}` already has a live genesis grant ({}) — a namespace has one trust root (A005)", + args.namespace, g.id + ))); + } let mut candidate = self.shell(Some(format!("init namespace {}", args.namespace)))?; - let (mut new_roles, root_role, mut lines) = match genesis.clone() { - None => self.bootstrap(&store, &args, &mut candidate)?, - Some(g) => self.join_genesis(&auth, &g)?, - }; + let (mut new_roles, root_role, mut lines) = self.bootstrap(&store, &args, &mut candidate)?; let accept_role = args.accept_role.clone().unwrap_or_else(|| DEFAULT_ACCEPT_ROLE.to_string()); if accept_role == root_role { return Err(AuthorError::Usage(format!( @@ -110,13 +119,12 @@ impl Author { the authority structure, not on decisions (D9 (f))" ))); } - if store.roles.iter().all(|r| r.id != accept_role) { + if store.role_in(&args.namespace, &accept_role).is_none() { new_roles.push(self.new_role(&args.namespace, &accept_role, "Accepts decisions", &[Capability::AcceptDecision])); - lines.push(format!("declared role `{accept_role}` — may accept-decision; held by nobody until granted")); + lines.push(format!("declared role `{accept_role}` in `{}` — may accept-decision; held by nobody until granted", args.namespace)); } - let root = candidate.grants.first().cloned().or(genesis).ok_or_else(|| AuthorError::Io("no genesis grant".into()))?; - let under = joined.or_else(|| Some(root.id.clone())); - let policy = self.first_policy(&args.namespace, &accept_role, under)?; + let root = candidate.grants.first().cloned().ok_or_else(|| AuthorError::Io("no genesis grant".into()))?; + let policy = self.first_policy(&args.namespace, &accept_role, Some(root.id.clone()))?; lines.push(format!("namespace `{}` under policy {} (accept role `{accept_role}`)", args.namespace, policy.id)); lines.extend(self.genesis_key(&store, &mut candidate, &root, &policy, args.without_key)?); candidate.policies.push(policy); @@ -152,9 +160,9 @@ impl Author { } } - /// The genesis records: the root role (if new), grant, its acceptance. - /// An existing role of the root id must carry every root capability and - /// no decision capability (D9 (f), extended by ruling 50). + /// The namespace's genesis records: the root role (if new in it), grant, + /// its acceptance. An existing role of the root id must carry every root + /// capability and no decision capability (D9 (f), extended by ruling 50). fn bootstrap( &mut self, store: &Store, @@ -162,9 +170,12 @@ impl Author { candidate: &mut crate::changeset::ChangeSet, ) -> Result<(Vec, String, Vec), AuthorError> { let mandate = args.external_ref.clone().filter(|r| !r.trim().is_empty()).ok_or_else(|| { - AuthorError::Usage("the store has no genesis yet — `--external-ref` names the mandate it rests on".into()) + AuthorError::Usage(format!( + "a namespace is opened on its own mandate (ruling 47) — `--external-ref` names the one `{}`'s genesis rests on", + args.namespace + )) })?; - let new_roles = match store.role(&args.role) { + let new_roles = match store.role_in(&args.namespace, &args.role) { None => vec![self.new_role(&args.namespace, &args.role, "Genesis steward", Capability::ROOT)], Some(existing) => match existing.genesis_refusal() { Some(why) => return Err(AuthorError::Conflict(format!("role `{}` cannot be the genesis role: {why}", args.role))), @@ -172,6 +183,7 @@ impl Author { }, }; let grant = self.seal_grant(&GrantArgs { + namespace: Some(args.namespace.clone()), role: args.role.clone(), holder: self.who.clone(), scope: GrantScope::All, @@ -179,27 +191,12 @@ impl Author { limits: Vec::new(), supersedes: None, }, Some(mandate), None)?; - let line = format!("genesis {} — {} holds `{}` over *", grant.id, self.who, args.role); + let line = format!("genesis {} — {} holds `{}` over * (the whole of `{}`)", grant.id, self.who, args.role, args.namespace); candidate.grant_acceptances.push(self.grant_acceptance(&grant)?); candidate.grants.push(grant); Ok((new_roles, args.role.clone(), vec![line])) } - /// A later namespace: the live, available genesis holder's act. - fn join_genesis( - &self, - auth: &Authority<'_>, - genesis: &Grant, - ) -> Result<(Vec, String, Vec), AuthorError> { - if genesis.holder != self.who || !auth.is_available(genesis, self.now) { - return Err(AuthorError::Unauthorized(format!( - "a namespace is put under policy by the genesis holder ({}), available now", - genesis.holder - ))); - } - Ok((Vec::new(), genesis.role.clone(), Vec::new())) - } - /// The namespace's first policy, made under the genesis grant (D9 (a)). fn first_policy( &mut self, @@ -251,7 +248,7 @@ impl Author { Ok(grant) } - fn grant_acceptance(&mut self, grant: &Grant) -> Result { + pub(super) fn grant_acceptance(&mut self, grant: &Grant) -> Result { Ok(GrantAcceptance { id: self.mint.mint_id("gacc").map_err(AuthorError::Io)?, grant: grant.id.clone(), @@ -274,15 +271,16 @@ impl Author { product_core::fileops::write_file_atomic(&path, &text).map_err(|e| AuthorError::Io(e.to_string())) } - /// Declare a role: `grant-role` over `*`, withheld by `no-role-edits`. + /// Declare a role in a namespace: `grant-role` over `*` there, withheld + /// by `no-role-edits`. pub fn declare_role(&mut self, args: RoleArgs) -> Result { let store = self.load(); - self.authorized(&store, Act::DeclareRole, Target::Scope(&GrantScope::All), None)?; + let namespace = super::home::resolve_namespace_dir(&self.root, args.namespace.as_deref())?; + self.authorized(&store, &namespace, Act::DeclareRole, Target::Scope(&GrantScope::All), None)?; crate::set::DecisionSet::validate_id(&args.id).map_err(AuthorError::Usage)?; if args.may.is_empty() { return Err(AuthorError::Usage("a role names at least one capability (`--may`)".into())); } - let namespace = super::home::resolve_namespace_dir(&self.root, args.namespace.as_deref())?; let role = Role { format: crate::format::AUTHORITY_FORMAT, id: args.id, @@ -299,96 +297,4 @@ impl Author { Ok(Applied { path, lines: vec![format!("declared role `{}` in `{}` — may {}", role.id, role.namespace, caps.join(", "))] }) } - /// Grant a role: `grant-role` over the new grant's scope. Below the - /// genesis, a grantor gives only the role it acts under. - pub fn grant(&mut self, args: GrantArgs) -> Result { - let store = self.load(); - if store.role(&args.role).is_none() { - return Err(AuthorError::Usage(format!("role `{}` is not declared — `ledger role declare` it", args.role))); - } - let auth = Authority::build(&store); - let as_role = self.as_role.as_deref(); - let by = crate::authority::choice::grantor(&auth, &self.who, &args.scope, &args.role, self.now, as_role) - .map_err(|d| AuthorError::Unauthorized(format!("{} may not {}: {d}", self.who, Act::Grant.as_str())))?; - let grant = self.seal_grant(&args, None, Some(under_of(&by)?))?; - let line = format!("{} granted `{}` over {} to {} ({})", self.who, grant.role, grant.scope, grant.holder, grant.order); - let note = format!("grant {}", grant.id); - let id = grant.id.clone(); - let mut candidate = self.shell(Some(note))?; - candidate.grants.push(grant); - self.refusal_check(&store, &candidate, |_| false)?; - let path = self.append(&candidate)?; - Ok(Applied { path, lines: vec![line, format!("{id} is live once its holder runs `ledger grant accept {id}`"), by.line()] }) - } - - /// The holder accepts a grant: it signs the grant's hash. - pub fn accept_grant(&mut self, id: &GrantId) -> Result { - let store = self.load(); - let auth = Authority::build(&store); - let grant = auth.grants.get(&id.to_string()).copied().cloned().ok_or_else(|| { - AuthorError::Usage(format!("{id} is not a filed grant")) - })?; - if grant.holder != self.who { - return Err(AuthorError::Unauthorized(format!("{id} is held by {}; only the holder accepts it", grant.holder))); - } - if auth.is_accepted(&grant) { - return Err(AuthorError::Conflict(format!("{id} is already accepted — accepting twice adds nothing"))); - } - let mut candidate = self.shell(None)?; - candidate.grant_acceptances.push(self.grant_acceptance(&grant)?); - self.refusal_check(&store, &candidate, |_| false)?; - let path = self.append(&candidate)?; - Ok(Applied { path, lines: vec![format!("{} accepted {id} — `{}` over {} is live", self.who, grant.role, grant.scope)] }) - } - - /// Revoke a grant: `revoke-grant` over its scope, withheld by - /// `no-grant-revocations`. Files a `rev:` entity; the grant is untouched. - pub fn revoke_grant(&mut self, id: &GrantId, reason: String) -> Result { - let store = self.load(); - let auth = Authority::build(&store); - let grant = auth.grants.get(&id.to_string()).copied().cloned().ok_or_else(|| { - AuthorError::Usage(format!("{id} is not a filed grant")) - })?; - if auth.is_revoked(&Revocable::Grant(id.clone())) { - return Err(AuthorError::Conflict(format!("{id} is already revoked — one revocation is enough"))); - } - let by = self.authorized(&store, Act::RevokeGrant, Target::Scope(&grant.scope), None)?; - let revocation = self.revocation(Revocable::Grant(id.clone()), reason, Some(under_of(&by)?))?; - let line = format!("revoked {id} — {}", revocation.reason); - let mut candidate = self.shell(None)?; - candidate.revocations.push(revocation); - self.refusal_check(&store, &candidate, |_| false)?; - let path = self.append(&candidate)?; - Ok(Applied { path, lines: vec![line, by.line()] }) - } - - /// A sealed `rev:` entity for `target` (spec v1.7). - pub(crate) fn revocation( - &mut self, - target: Revocable, - reason: String, - under: Option, - ) -> Result { - if reason.trim().is_empty() { - return Err(AuthorError::Usage("a revocation carries its reason".to_string())); - } - let mut r = Revocation { - id: Some(self.mint.mint_id("rev").map_err(AuthorError::Io)?), - revokes: Some(target), - acceptance: None, - at: self.now, - actor: Some(self.who.clone()), - by: None, - reason, - under, - hash: None, - }; - r.hash = Some(revocation_hash(&r)); - Ok(r) - } -} - -/// The grant id an authorised act records as its `under`. -pub(crate) fn under_of(by: &Authorized) -> Result { - by.grant.parse().map_err(AuthorError::Io) } diff --git a/ledger-core/src/author/availability_ops.rs b/ledger-core/src/author/availability_ops.rs index fc236a3d..813ffc12 100644 --- a/ledger-core/src/author/availability_ops.rs +++ b/ledger-core/src/author/availability_ops.rs @@ -26,13 +26,16 @@ impl Author { /// File an unavailability interval on a grant. pub fn unavailable(&mut self, args: UnavailableArgs) -> Result { let store = self.load(); - let auth = Authority::build(&store); + let ns = super::home::grant_namespace(&store, &args.grant.to_string()).ok_or_else(|| { + AuthorError::Usage(format!("{} is not a filed grant", args.grant)) + })?; + let auth = Authority::of(&store, &ns); let grant = auth.grants.get(&args.grant.to_string()).copied().cloned().ok_or_else(|| { AuthorError::Usage(format!("{} is not a filed grant", args.grant)) })?; let basis = self.basis(&auth, &grant)?; if basis != Basis::SelfDeclared { - self.authorized(&store, Act::DeclareUnavailability, Target::Scope(&grant.scope), None)?; + self.authorized(&store, &ns, Act::DeclareUnavailability, Target::Scope(&grant.scope), None)?; } let interval = Unavailability { id: self.mint.mint_id("unav").map_err(AuthorError::Io)?, diff --git a/ledger-core/src/author/grant_ops.rs b/ledger-core/src/author/grant_ops.rs new file mode 100644 index 00000000..87a5681a --- /dev/null +++ b/ledger-core/src/author/grant_ops.rs @@ -0,0 +1,140 @@ +//! `grant new|accept|revoke` — the grant verbs, each in one namespace's authority. +//! +//! A grant belongs to the namespace whose directory holds it, and the +//! grantor acts in that namespace's authority (LP-6.31, ruling 47): `grant +//! new` is homed explicitly, by a `ns:` scope, or by the store's one +//! namespace; `accept` and `revoke` read the namespace the grant is filed +//! in. Each verb builds the record it intends to file, asks the role check +//! whether the actor may, and runs the same delta-gate every verb runs. + +use crate::authority::payload::revocation_hash; +use crate::authority::{Act, Authority, Authorized, GrantScope, Revocable, Revocation, Target}; +use crate::id::GrantId; +use crate::store::Store; + +use super::authority_ops::GrantArgs; +use super::{Applied, Author, AuthorError}; + +impl Author { + /// Grant a role: `grant-role` over the new grant's scope, in the + /// namespace whose directory will hold it. Below the genesis, a grantor + /// gives only the role it acts under. + pub fn grant(&mut self, args: GrantArgs) -> Result { + let store = self.load(); + let namespace = self.grant_home(&args)?; + if store.role_in(&namespace, &args.role).is_none() { + return Err(AuthorError::Usage(format!( + "role `{}` is not declared in `{namespace}` — `ledger role declare --namespace {namespace}` it", + args.role + ))); + } + let auth = Authority::of(&store, &namespace); + let as_role = self.as_role.as_deref(); + let by = crate::authority::choice::grantor(&auth, &self.who, &args.scope, &args.role, self.now, as_role) + .map_err(|d| AuthorError::Unauthorized(format!("{} may not {}: {d}", self.who, Act::Grant.as_str())))?; + let grant = self.seal_grant(&args, None, Some(under_of(&by)?))?; + let line = format!("{} granted `{}` over {} to {} ({})", self.who, grant.role, grant.scope, grant.holder, grant.order); + let note = format!("grant {}", grant.id); + let id = grant.id.clone(); + let mut candidate = self.shell(Some(note))?; + candidate.grants.push(grant); + self.refusal_check(&store, &candidate, |_| false)?; + let path = self.append(&candidate)?; + Ok(Applied { path, lines: vec![line, format!("{id} is live once its holder runs `ledger grant accept {id}`"), by.line()] }) + } + + /// The namespace a new grant is filed in: explicit, a `ns:` scope's, + /// else the store's one namespace directory. + fn grant_home(&self, args: &GrantArgs) -> Result { + let scoped = match &args.scope { + GrantScope::Namespace(ns) => Some(ns.as_str()), + _ => None, + }; + if let (Some(explicit), Some(scoped)) = (args.namespace.as_deref(), scoped) { + if explicit != scoped { + return Err(AuthorError::Usage(format!( + "the scope `ns:{scoped}` names another namespace than `--namespace {explicit}` — a scope is read inside its own namespace (ruling 47)" + ))); + } + } + super::home::resolve_namespace_dir(&self.root, args.namespace.as_deref().or(scoped)) + } + + /// The namespace whose directory holds the grant `id`. + fn filed_in(store: &Store, id: &GrantId) -> Result { + super::home::grant_namespace(store, &id.to_string()).ok_or_else(|| AuthorError::Usage(format!("{id} is not a filed grant"))) + } + + /// The holder accepts a grant: it signs the grant's hash. + pub fn accept_grant(&mut self, id: &GrantId) -> Result { + let store = self.load(); + let auth = Authority::of(&store, &Self::filed_in(&store, id)?); + let grant = auth.grants.get(&id.to_string()).copied().cloned().ok_or_else(|| { + AuthorError::Usage(format!("{id} is not a filed grant")) + })?; + if grant.holder != self.who { + return Err(AuthorError::Unauthorized(format!("{id} is held by {}; only the holder accepts it", grant.holder))); + } + if auth.is_accepted(&grant) { + return Err(AuthorError::Conflict(format!("{id} is already accepted — accepting twice adds nothing"))); + } + let mut candidate = self.shell(None)?; + candidate.grant_acceptances.push(self.grant_acceptance(&grant)?); + self.refusal_check(&store, &candidate, |_| false)?; + let path = self.append(&candidate)?; + Ok(Applied { path, lines: vec![format!("{} accepted {id} — `{}` over {} is live", self.who, grant.role, grant.scope)] }) + } + + /// Revoke a grant: `revoke-grant` over its scope, withheld by + /// `no-grant-revocations`. Files a `rev:` entity; the grant is untouched. + pub fn revoke_grant(&mut self, id: &GrantId, reason: String) -> Result { + let store = self.load(); + let namespace = Self::filed_in(&store, id)?; + let auth = Authority::of(&store, &namespace); + let grant = auth.grants.get(&id.to_string()).copied().cloned().ok_or_else(|| { + AuthorError::Usage(format!("{id} is not a filed grant")) + })?; + if auth.is_revoked(&Revocable::Grant(id.clone())) { + return Err(AuthorError::Conflict(format!("{id} is already revoked — one revocation is enough"))); + } + let by = self.authorized(&store, &namespace, Act::RevokeGrant, Target::Scope(&grant.scope), None)?; + let revocation = self.revocation(Revocable::Grant(id.clone()), reason, Some(under_of(&by)?))?; + let line = format!("revoked {id} — {}", revocation.reason); + let mut candidate = self.shell(None)?; + candidate.revocations.push(revocation); + self.refusal_check(&store, &candidate, |_| false)?; + let path = self.append(&candidate)?; + Ok(Applied { path, lines: vec![line, by.line()] }) + } + + /// A sealed `rev:` entity for `target` (spec v1.7). + pub(crate) fn revocation( + &mut self, + target: Revocable, + reason: String, + under: Option, + ) -> Result { + if reason.trim().is_empty() { + return Err(AuthorError::Usage("a revocation carries its reason".to_string())); + } + let mut r = Revocation { + id: Some(self.mint.mint_id("rev").map_err(AuthorError::Io)?), + revokes: Some(target), + acceptance: None, + at: self.now, + actor: Some(self.who.clone()), + by: None, + reason, + under, + hash: None, + }; + r.hash = Some(revocation_hash(&r)); + Ok(r) + } +} + + +/// The grant id an authorised act records as its `under`. +pub(crate) fn under_of(by: &Authorized) -> Result { + by.grant.parse().map_err(AuthorError::Io) +} diff --git a/ledger-core/src/author/group_accept.rs b/ledger-core/src/author/group_accept.rs index 8d28754e..61da1a98 100644 --- a/ledger-core/src/author/group_accept.rs +++ b/ledger-core/src/author/group_accept.rs @@ -97,7 +97,7 @@ impl Author { at: self.now, scope: AcceptanceScope::Version, expires_at, - under: held.as_ref().map(super::authority_ops::under_of).transpose()?, + under: held.as_ref().map(super::grant_ops::under_of).transpose()?, signature: String::new(), }; // One signature per acceptance, never one over the selection. diff --git a/ledger-core/src/author/identity_ops.rs b/ledger-core/src/author/identity_ops.rs index 53fea98f..b5507b86 100644 --- a/ledger-core/src/author/identity_ops.rs +++ b/ledger-core/src/author/identity_ops.rs @@ -68,6 +68,9 @@ impl Author { }; // A `rotate` is signed by the key it closes (LP-4.12, ruling 53). let closed_key = target.filter(|_| act == BindingAct::Rotate).and_then(|b| b.key_type.clone().zip(b.key.clone())); + // The namespace's own authority decides who its genesis holder is + // (ruling 47); its bindings still range over the store (issue 6). + let auth = Authority::of(&store, &namespace); let policy = auth.policy(&namespace).cloned().ok_or_else(|| { AuthorError::Usage(format!("namespace `{namespace}` has no policy — `ledger init --namespace {namespace}` first")) })?; diff --git a/ledger-core/src/author/mod.rs b/ledger-core/src/author/mod.rs index 712669d0..7a863a3f 100644 --- a/ledger-core/src/author/mod.rs +++ b/ledger-core/src/author/mod.rs @@ -22,6 +22,7 @@ mod availability_ops; mod batch_accept; mod declare; mod genesis_key; +mod grant_ops; mod home; mod decision; mod group_accept; diff --git a/ledger-core/src/author/policy_ops.rs b/ledger-core/src/author/policy_ops.rs index b329c3c0..079d7bb5 100644 --- a/ledger-core/src/author/policy_ops.rs +++ b/ledger-core/src/author/policy_ops.rs @@ -23,8 +23,9 @@ pub struct PolicyArgs { } impl Author { - /// A policy change is the live, available genesis holder's act, and it - /// never maps acceptance to the genesis role (D9 (f)). + /// A policy change is the act of the namespace's own live, available + /// genesis holder (LP-6.28, ruling 47), and it never maps acceptance to + /// the genesis role (D9 (f)). fn refuse_policy_change(&self, auth: &Authority<'_>, args: &PolicyArgs) -> Result<(), AuthorError> { let genesis_now = auth.genesis().is_some_and(|g| g.holder == self.who && auth.is_available(g, self.now)); if !genesis_now { @@ -44,7 +45,7 @@ impl Author { /// File the namespace's next policy. pub fn policy_set(&mut self, args: PolicyArgs) -> Result { let store = self.load(); - let auth = Authority::build(&store); + let auth = Authority::of(&store, &args.namespace); let current = auth.policy(&args.namespace).cloned().ok_or_else(|| { AuthorError::Usage(format!("namespace `{}` has no policy — `ledger init --namespace` first", args.namespace)) })?; diff --git a/ledger-core/src/authority/references.rs b/ledger-core/src/authority/references.rs index 3d4ad94f..9ec88279 100644 --- a/ledger-core/src/authority/references.rs +++ b/ledger-core/src/authority/references.rs @@ -4,6 +4,9 @@ //! records that a revocation or a reference resolves by: an acceptance's //! id, and a decision's identity object (ruling 49). //! +//! Every reference resolves inside the namespace whose directory holds the +//! record (ruling 47): a grant of another namespace is, here, not filed. +//! //! The authority twin of "a revocation naming an acceptance nobody filed": //! every reference resolves, every acceptance of a grant is the holder's //! and signs the grant's own hash, every unavailability is declared on the @@ -23,14 +26,19 @@ use super::revocation::Revocable; use super::role::Capability; use super::view::Authority; -/// Every cross-entry authority fault in the store. -pub fn faults(store: &Store, auth: &Authority<'_>) -> Vec { +/// Every cross-entry authority fault in the store: ids are unique across +/// the store (ruling 49); everything else resolves inside one namespace's +/// authority (ruling 47). +pub fn faults(store: &Store) -> Vec { let mut out = duplicate_ids(store); - out.extend(grant_refs(auth)); - out.extend(unavailability_refs(auth)); - out.extend(revocation_refs(store, auth)); - out.extend(binding_refs(auth)); - out.extend(policy_refs(auth)); + for ns in store.namespaces() { + let auth = Authority::of(store, &ns); + out.extend(grant_refs(&auth)); + out.extend(unavailability_refs(&auth)); + out.extend(revocation_refs(store, &auth)); + out.extend(binding_refs(&auth, &ns)); + out.extend(policy_refs(&auth)); + } out } @@ -174,12 +182,15 @@ fn revocation_refs(store: &Store, auth: &Authority<'_>) -> Vec { out } -fn binding_refs(auth: &Authority<'_>) -> Vec { +/// The bindings of `ns` against its own authority: its policy, its genesis +/// mandate. Bindings range over the store (view note), so only `ns`'s own +/// are judged here. +fn binding_refs(auth: &Authority<'_>, ns: &str) -> Vec { let mut out = Vec::new(); let mut closed: BTreeSet = BTreeSet::new(); let mandates: BTreeSet<&str> = auth.grants.values().filter(|g| g.genesis).filter_map(|g| g.external_ref.as_deref()).collect(); - for b in &auth.bindings { + for b in auth.bindings.iter().filter(|b| b.namespace == ns) { let id = b.id.to_string(); if auth.policies_of(&b.namespace).is_empty() { out.push(fault(&id, format!("binds a key in `{}`, a namespace with no policy — `ledger init --namespace` first", b.namespace))); diff --git a/ledger-core/src/authority/structure.rs b/ledger-core/src/authority/structure.rs index 7b1fb7e6..f291fe3f 100644 --- a/ledger-core/src/authority/structure.rs +++ b/ledger-core/src/authority/structure.rs @@ -3,7 +3,8 @@ //! What one record must say about itself, judged without reading any other //! record: a primary grant carries no limits; the genesis grant is //! self-granted, scope `*`, primary, with an external reference, and only -//! the genesis carries one; an interval ends after it starts; a key-binding +//! the genesis carries one; a `ns:` scope names the namespace the grant is +//! filed in (ruling 47); an interval ends after it starts; a key-binding //! act carries exactly the fields its act defines; a policy names at least //! one scheme. Every fault is a `SCHEMA` finding — the shapes in //! `ledger/spec/authority/ledger-authority-shapes.ttl`, enforced where an @@ -30,8 +31,8 @@ pub fn role_faults(role: &Role, stem: &str, store: &Store) -> Vec { if stem != role.id { out.push(format!("declares id `{}` but is filed as `{stem}`", role.id)); } - if store.roles.iter().any(|r| r.id == role.id) { - out.push(format!("role `{}` is declared twice", role.id)); + if store.roles.iter().any(|r| r.id == role.id && r.namespace == role.namespace) { + out.push(format!("role `{}` is declared twice in `{}`", role.id, role.namespace)); } if role.may.is_empty() { out.push("a role that may do nothing grants nothing — `may` names at least one capability".to_string()); @@ -39,11 +40,19 @@ pub fn role_faults(role: &Role, stem: &str, store: &Store) -> Vec { out } -/// Faults every authority record in one change-set carries on its own. -pub fn entry_faults(cs: &ChangeSet) -> Vec { +/// Faults every authority record in one change-set carries on its own, +/// read under the namespace `ns` whose directory holds the file. +pub fn entry_faults(cs: &ChangeSet, ns: &str) -> Vec { let mut out = Vec::new(); for g in &cs.grants { out.extend(grant_faults(g).into_iter().map(|m| Finding::schema(&g.id.to_string(), m))); + if let GrantScope::Namespace(other) = &g.scope { + if other != ns { + out.push(Finding::schema(&g.id.to_string(), format!( + "is scoped `ns:{other}` but filed under `{ns}` — a scope is read inside its own namespace, so the grant could never have effect (LP-6.16, ruling 47); `*` or `ns:{ns}` is the whole of it" + ))); + } + } } for u in &cs.unavailabilities { if u.until.is_some_and(|until| until <= u.from) { diff --git a/ledger-core/src/authority/view.rs b/ledger-core/src/authority/view.rs index 98bcda89..d131353f 100644 --- a/ledger-core/src/authority/view.rs +++ b/ledger-core/src/authority/view.rs @@ -6,6 +6,21 @@ //! namespace, which grant is the genesis. The role check //! ([`super::check::authorize`]), the schema rules and the emitter all read //! the same answers from here. +//! +//! **Authority is per namespace** (LP-6.31, ruling 47). A grant, grant +//! acceptance, interval, revocation or role belongs to the namespace whose +//! directory holds it, and nothing in one namespace's authority has effect +//! in another: [`Authority::of`] and [`Authority::as_of`] read one +//! 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. use std::collections::{BTreeMap, BTreeSet}; @@ -35,41 +50,68 @@ pub struct Authority<'a> { } impl<'a> Authority<'a> { - /// Read every authority record the store holds. + /// Every authority record the store holds, whichever namespace: the + /// store-wide union (see the module note on what may be read off it). pub fn build(store: &'a Store) -> Self { + Self::gather(store, None) + } + + /// Every authority record of one namespace: the records its directory + /// holds, and the roles declared under its `roles/` (ruling 47). + pub fn of(store: &'a Store, namespace: &str) -> Self { + Self::gather(store, Some(namespace)) + } + + fn gather(store: &'a Store, namespace: Option<&str>) -> Self { let mut a = Self::default(); - for role in &store.roles { + for role in store.roles.iter().filter(|r| namespace.is_none_or(|ns| r.namespace == ns)) { a.roles.insert(role.id.clone(), role); } - for cs in store.log.iter().map(|l| &l.file) { + 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.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))); a.availabilities.extend(&cs.availabilities); a.revocations.extend(&cs.revocations); - a.bindings.extend(&cs.key_bindings); a.policies.extend(&cs.policies); } a } - /// The records as they stood at `pos` (D6): role files landed no later - /// (landing alone: a role file has no signed `at`); enabling entries - /// (grants, grant acceptances, key bindings that open a key) that are - /// not after it; terminating ones (revocations, key 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). - pub fn as_of(store: &'a Store, landing: &Landing, pos: Position) -> Self { + /// One namespace's records as they stood at `pos` (D6): role files + /// landed no later (landing alone: a role file has no signed `at`); + /// enabling entries (grants, grant acceptances, key bindings that open a + /// key) that are not after it; terminating ones (revocations, key + /// 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). + 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| role_landing(landing, r) <= pos.index) { + 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 { 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); + // 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); + 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; - let cs = &logged.file; 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)), ); @@ -85,12 +127,6 @@ impl<'a> Authority<'a> { a.revocations.extend(cs.revocations.iter().filter(|r| { !pos.before(&landing.position(&path, &crate::landed::revocation_key(r), r.at)) })); - // 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_bindings", b.id.to_string(), b.at); - if b.closes.is_some() { !pos.before(&here) } else { here.not_after(&pos) } - })); // A policy governs every act not before it (D5 (c), D6): an act // that landed after a policy is under it, however it is dated. a.policies.extend(cs.policies.iter().filter(|p| !pos.before(&at("policies", p.id.to_string(), p.at)))); @@ -138,7 +174,8 @@ impl<'a> Authority<'a> { }) } - /// The live genesis grant, when the store has one. + /// The namespace's live genesis grant, when it has one (LP-6.5). Read + /// off a per-namespace view only: each namespace has its own. pub fn genesis(&self) -> Option<&'a Grant> { self.grants.values().copied().find(|g| g.genesis && self.is_live(g)) } diff --git a/ledger-core/src/graph/export.rs b/ledger-core/src/graph/export.rs index ddc7263e..5ec3b520 100644 --- a/ledger-core/src/graph/export.rs +++ b/ledger-core/src/graph/export.rs @@ -61,12 +61,13 @@ pub fn write(path: &Path, text: &str) -> Result<(), String> { /// The store restricted to one namespace: its decisions, their versions, /// the acceptances of those decisions, the revocations of those /// acceptances, the sets those versions name, and the change-sets that -/// filed any of it (each holding only what belongs here). The authority -/// records that reach the namespace come too (spec v1.7): its policy -/// versions and key bindings, the grants whose scope covers it (`*`, its -/// `ns:`, or a set its versions name) with their acceptances, -/// unavailability, availability and revocations, and the roles those -/// grants and policies name. +/// filed any of it (each holding only what belongs here). Its authority +/// records come too, and only its own (LP-9.11, ruling 47): the policy +/// versions and key bindings that carry its name, the grants filed under +/// its directory with their acceptances, unavailability, availability and +/// revocations, and the roles declared under its `roles/` that those +/// grants and policies name. A grant of another namespace reaches it +/// whatever its scope says — a scope is read inside its own namespace. pub fn select(store: &Store, namespace: &str) -> Store { let ours = |id: &crate::id::DecisionId| id.namespace() == namespace; let named: BTreeSet = store @@ -76,7 +77,7 @@ pub fn select(store: &Store, namespace: &str) -> Store { .filter(|v| ours(&v.decision)) .map(|v| v.set.clone()) .collect(); - let reach = Reach::of(store, namespace, &named); + let reach = Reach::of(store, namespace); let log: Vec = store .log .iter() @@ -92,7 +93,7 @@ pub fn select(store: &Store, namespace: &str) -> Store { root: store.root.clone(), dir: store.dir.clone(), sets: store.sets.iter().filter(|s| s.namespace == namespace && named.contains(&s.id)).cloned().collect(), - roles: store.roles.iter().filter(|r| roles.contains(&r.id)).cloned().collect(), + roles: store.roles.iter().filter(|r| r.namespace == namespace && roles.contains(&r.id)).cloned().collect(), log, sidecars: store.sidecars.clone(), schema_findings: Vec::new(), @@ -108,22 +109,19 @@ struct Reach<'n> { } impl<'n> Reach<'n> { - fn of(store: &Store, namespace: &'n str, named: &BTreeSet) -> Self { - use crate::authority::GrantScope; + fn of(store: &Store, namespace: &'n str) -> Self { let files = || store.log.iter().map(|c| &c.file); let acceptances = files() .flat_map(|c| c.acceptances.iter()) .filter(|a| a.decision.namespace() == namespace) .map(|a| a.id.to_string()) .collect(); - let grants: BTreeSet = files() - .flat_map(|c| c.grants.iter()) - .filter(|g| match &g.scope { - GrantScope::All => true, - GrantScope::Namespace(n) => n == namespace, - GrantScope::Set(s) => named.contains(s), - GrantScope::Pattern(_) => false, - }) + // A grant belongs to the namespace whose directory holds it. + let grants: BTreeSet = store + .log + .iter() + .filter(|l| l.namespace == namespace) + .flat_map(|l| l.file.grants.iter()) .map(|g| g.id.to_string()) .collect(); let intervals = files() diff --git a/ledger-core/src/graph/export_tests.rs b/ledger-core/src/graph/export_tests.rs index 58e951e2..41f8ab61 100644 --- a/ledger-core/src/graph/export_tests.rs +++ b/ledger-core/src/graph/export_tests.rs @@ -185,16 +185,31 @@ fn a_namespace_export_carries_its_own_authority_records_only() { use crate::authority::fixture::{self, accepted, genesis, grant, role}; use crate::authority::Capability; let g = genesis("1", "steward"); - let elsewhere = grant("2", "steward", "other@x", "ns:hafeok.other", 0); - let mut cs = fixture::changeset(vec![g.clone(), elsewhere.clone()], vec![accepted("3", &g)]); + let mut cs = fixture::changeset(vec![g.clone()], vec![accepted("3", &g)]); let sealed = testkit::sealed(testkit::version()); cs.decisions = testkit::changeset(vec![sealed.clone()], Vec::new()).decisions; cs.versions = vec![sealed]; let mut store = fixture::store(vec![role("steward", Capability::ALL)], cs); store.roles.push(role("unrelated", &[Capability::AcceptDecision])); + // Another namespace's directory holds a `*` grant and a `steward` role of + // its own: a scope is read inside its own namespace, so neither reaches + // this export (LP-9.11, ruling 47). + let elsewhere = grant("2", "steward", "other@x", "*", 0); + let mut theirs = fixture::changeset(vec![elsewhere.clone()], Vec::new()); + theirs.id = "cs:01K2C4YQJ3F8M0PT5W7NZ9RDY2".parse().expect("id"); + store.log.push(crate::store::LoggedChangeSet { + namespace: "hafeok.other".into(), + path: "/fixture/.decisions/ns/hafeok.other/log/01K2C4YQJ3F8M0PT5W7NZ9RDY2.yml".into(), + file: theirs, + }); + let mut their_role = role("steward", Capability::ALL); + their_role.namespace = "hafeok.other".into(); + store.roles.push(their_role); let text = super::export::export(&store, "hafeok.ledger").expect("the namespace is spoken"); - assert!(text.contains(&format!("", g.id)), "the genesis (*) reaches every namespace"); + assert!(text.contains(&format!("", g.id)), "its own genesis"); assert!(text.contains(""), "the role its grant names"); - assert!(!text.contains(&format!("", elsewhere.id)), "another namespace's grant stays out"); + assert!(!text.contains(&format!("", elsewhere.id)), "another namespace's `*` grant stays out"); assert!(!text.contains("ledger-role:unrelated"), "a role nothing here names stays out"); + let owners = text.lines().filter(|l| l.starts_with(" ") && l.contains("owner")).count(); + assert_eq!(owners, 1, "the other namespace's role of the same id stays out:\n{text}"); } diff --git a/ledger-core/src/graph/shapes.rs b/ledger-core/src/graph/shapes.rs index e58d2669..63feb630 100644 --- a/ledger-core/src/graph/shapes.rs +++ b/ledger-core/src/graph/shapes.rs @@ -214,7 +214,7 @@ const SHAPES: &[Shape] = &[ message: |row| { ( term(row, "g"), - format!("is a live genesis grant beside {} — the store has one trust root", term(row, "other")), + format!("is a live genesis grant beside {} — a namespace has one trust root (LP-6.5, ruling 47)", term(row, "other")), ) }, }, @@ -251,26 +251,45 @@ fn short_hash(term: &str) -> String { hex.get(..12).unwrap_or(hex).to_string() } -/// Run every shape over the store's emitted graph. An engine fault surfaces -/// as a finding on the shape itself rather than a panic or a silent pass — -/// a shape that cannot run must never read as a passing one. +/// Whether a shape is one of the authority shapes (`A003`, `A005`), which +/// hold per namespace: authority is per namespace (LP-6.31, ruling 47). +fn is_authority(class: GraphClass) -> bool { + matches!(class, GraphClass::A003 | GraphClass::A005) +} + +/// Run every shape over the store (LP-8.19). The decision shapes run over +/// the store's graph: a `based_on` edge may cross a namespace, and a +/// `supersedes` edge in history is not judged (ruling 75). The authority +/// shapes run over each namespace's emitted graph alone — the graph the +/// namespace's export carries — so `A005` counts one trust root per +/// namespace. An engine fault surfaces as a finding on the shape itself +/// rather than a panic or a silent pass — a shape that cannot run must +/// never read as a passing one. pub fn graph_findings(store: &Store) -> Vec { - let ttl = emit(store); - run(|query| product_core::pf::sparql_rules::select(&ttl, query)) + let whole = emit(store); + let mut out = run(|query| product_core::pf::sparql_rules::select(&whole, query), |c| !is_authority(c)); + for ns in store.namespaces() { + let ttl = emit(&super::export::select(store, &ns)); + out.extend(run(|query| product_core::pf::sparql_rules::select(&ttl, query), is_authority)); + } + out.sort_by(|a, b| (a.class, &a.subject).cmp(&(b.class, &b.subject))); + out.dedup(); + out } /// Every shape over one named graph of a dataset — that graph alone, never -/// the union: the shapes hold per store (one genesis per store, `A005`), and -/// a graph is one store's committed export (the inbox index, #79). +/// the union: a graph is one namespace's committed export (the inbox index, +/// #79), and the authority shapes hold per namespace (one genesis each, +/// `A005`). pub fn graph_findings_in(dataset: &product_core::pf::sparql_dataset::Dataset, graph: &str) -> Vec { - run(|query| dataset.select_in(graph, query)) + run(|query| dataset.select_in(graph, query), |_| true) } type Rows = Vec>; -fn run(select: impl Fn(&str) -> Result) -> Vec { +fn run(select: impl Fn(&str) -> Result, wanted: impl Fn(GraphClass) -> bool) -> Vec { let mut out = Vec::new(); - for shape in SHAPES { + for shape in SHAPES.iter().filter(|s| wanted(s.class)) { match select(shape.select) { Ok(rows) => { for row in rows { diff --git a/ledger-core/src/inbox/list.rs b/ledger-core/src/inbox/list.rs index 45485484..e4141aa0 100644 --- a/ledger-core/src/inbox/list.rs +++ b/ledger-core/src/inbox/list.rs @@ -233,5 +233,5 @@ pub fn grant_on(index: &Index, branch: &Branch, holder: &Identity, decision: &st // check the role against: it is not offered for affirmation. let set = view.latest.get(decision).and_then(|i| view.versions.get(*i)).map(|v| v.raw.set.clone())?; let ns = decision.strip_prefix("dec:")?.split('/').next()?.to_string(); - holder_grant(&Authority::build(&store), holder, &ns, &set, as_role) + holder_grant(&Authority::of(&store, &ns), holder, &ns, &set, as_role) } diff --git a/ledger-core/src/render.rs b/ledger-core/src/render.rs index 8ac2d55b..553a9eba 100644 --- a/ledger-core/src/render.rs +++ b/ledger-core/src/render.rs @@ -99,11 +99,10 @@ fn notices(n: &crate::verify::Notices, out: &mut Vec) { "notice: namespace `{ns}` is under policy `[none]` — governed and role-checked (D7, `A006`), but no signature is required: an absent signature is no finding, `L012` cannot arise, key bindings are trusted unsigned, a policy change is unsigned, and `ssh-keygen` is not consulted unless an `ssh` sidecar exists; a sidecar that is present is still verified" )); } - if let Some(u) = &n.genesis_unbound { + for u in &n.genesis_unbound { out.push(format!( - "notice: the genesis holder {} has no trusted key — governed namespace(s) {}: the first self-bound binding to land for that address will be the one trusted (D7); bind one with `ledger identity add --namespace `", - u.holder, - u.namespaces.iter().map(|n| format!("`{n}`")).collect::>().join(", ") + "notice: the genesis holder {} has no trusted key — governed namespace `{}`: the first self-bound binding to land for that address will be the one trusted (D7); bind one with `ledger identity add --namespace {}`", + u.holder, u.namespace, u.namespace )); } } diff --git a/ledger-core/src/signing/check.rs b/ledger-core/src/signing/check.rs index 03a3ede3..6285e355 100644 --- a/ledger-core/src/signing/check.rs +++ b/ledger-core/src/signing/check.rs @@ -185,7 +185,7 @@ fn ordered(mut all: Vec>) -> Vec> { /// The policy a subject is judged under: a policy change under the one it /// replaces; anything else under the policy in force at its position. fn governing(store: &Store, landing: &Landing, s: &Subject<'_>) -> Option { - let auth = Authority::as_of(store, landing, s.position); + let auth = Authority::as_of(store, landing, s.position, &s.namespace); match s.policy.and_then(|p| p.replaces.as_ref()) { Some(prior) => Authority::build(store).policies.iter().find(|p| p.hash == *prior).map(|p| (*p).clone()), None => auth.policy(&s.namespace).cloned(), @@ -201,7 +201,7 @@ fn trust_bindings<'a>(store: &'a Store, landing: &Landing, all: &[Subject<'a>], out.findings.push(Finding::schema(&s.id, rule)); continue; } - let auth = Authority::as_of(store, landing, s.position); + let auth = Authority::as_of(store, landing, s.position, &s.namespace); if let Err(rule) = may_file(&auth, b) { out.findings.push(Finding::schema(&s.id, format!("D7: {rule}"))); continue; diff --git a/ledger-core/src/signing/check_tests.rs b/ledger-core/src/signing/check_tests.rs index 666614e4..a46fb74d 100644 --- a/ledger-core/src/signing/check_tests.rs +++ b/ledger-core/src/signing/check_tests.rs @@ -16,7 +16,8 @@ use crate::testkit; use super::check; -const NS: &str = "fixture.ledger"; +/// One namespace throughout: the fixture change-set is filed under `testkit::NS`, and authority is per namespace (ruling 47). +const NS: &str = testkit::NS; fn envelope(key: &SigningKey, bytes: &[u8]) -> Vec { let sig = key.sign(&pae(PAYLOAD_TYPE, bytes)); diff --git a/ledger-core/src/store.rs b/ledger-core/src/store.rs index 715bef04..3357d981 100644 --- a/ledger-core/src/store.rs +++ b/ledger-core/src/store.rs @@ -68,9 +68,10 @@ impl Store { out } - /// The role with this id, when one is declared. - pub fn role(&self, id: &str) -> Option<&crate::authority::Role> { - self.roles.iter().find(|r| r.id == id) + /// The role with this id declared under `namespace`'s `roles/`, when one + /// is: roles are per namespace (LP-5.19, ruling 47). + pub fn role_in(&self, namespace: &str, id: &str) -> Option<&crate::authority::Role> { + self.roles.iter().find(|r| r.id == id && r.namespace == namespace) } /// How many entries loaded cleanly, for the summary line. diff --git a/ledger-core/src/verify/acts.rs b/ledger-core/src/verify/acts.rs index 9bafcda8..938b7306 100644 --- a/ledger-core/src/verify/acts.rs +++ b/ledger-core/src/verify/acts.rs @@ -3,8 +3,8 @@ //! `verify` is the only gate that sees a file nobody made with the verb, so //! the role check the verbs run is run again here, over history: every //! acceptance and every revocation, against the authority records as they -//! stood at the act's position (D6, [`crate::authority::Authority::as_of`]), -//! through the same function the verbs call +//! stood at the act's position (D6, [`crate::authority::Authority::as_of`]) +//! in the act's own namespace (ruling 47), through the same function the verbs call //! ([`crate::authority::authorize_named`]). It checks the grant the act //! names and never searches for another; a governed act that names none //! fails. A policy, first or change, is the genesis holder's act: the @@ -33,8 +33,8 @@ pub fn unauthorised(store: &Store, landing: &Landing) -> Vec { let path = relative(&store.root, &logged.path); for a in &logged.file.acceptances { let pos = landing.position(&path, &crate::landed::key("acceptances", &a.id.to_string()), a.at); - let auth = Authority::as_of(store, landing, pos); let ns = a.decision.namespace(); + let auth = Authority::as_of(store, landing, pos, ns); let Some(policy) = auth.policy(ns) else { continue }; let set = sets.get(&a.decision.to_string()).map(String::as_str).unwrap_or_default(); let target = Target::Decision { namespace: ns, set }; @@ -43,7 +43,7 @@ pub fn unauthorised(store: &Store, landing: &Landing) -> Vec { out.extend(judge(&auth, &act, target, Some(&policy.accept_role))); } for r in &logged.file.revocations { - out.extend(revocation_verdict(store, landing, &sets, &path, r)); + out.extend(revocation_verdict(store, landing, &sets, (&path, &logged.namespace), r)); } for p in &logged.file.policies { out.extend(policy_verdict(store, landing, &path, p)); @@ -59,11 +59,11 @@ pub fn unauthorised(store: &Store, landing: &Landing) -> Vec { fn policy_verdict(store: &Store, landing: &Landing, path: &str, p: &crate::authority::Policy) -> Option { let id = p.id.to_string(); let pos = landing.position(path, &crate::landed::key("policies", &id), p.at); - let auth = Authority::as_of(store, landing, pos); + let auth = Authority::as_of(store, landing, pos, &p.namespace); let Some(genesis) = auth.genesis().map(|g| g.id.clone()) else { return Some(finding(&id, format!( - "{} sets `{}`'s policy with no live genesis grant as of the policy — a policy is the genesis holder's act", - p.by, p.namespace + "{} sets `{}`'s policy with no live genesis grant of `{}` as of the policy — a policy is the genesis holder's act, and each namespace has its own genesis (LP-6.31, ruling 47)", + p.by, p.namespace, p.namespace ))); }; if let Some(under) = p.under.as_ref().filter(|u| **u != genesis) { @@ -81,22 +81,24 @@ fn policy_verdict(store: &Store, landing: &Landing, path: &str, p: &crate::autho /// revocation (`acceptance`, `by`) names no grant and can carry no /// signature, so it is valid only before its namespace's first policy; one /// that is not before it (D6) fails like a `rev:` revocation naming none. +/// `(path, dir)` is the revocation's file and the namespace whose directory +/// holds it: a grant's revocation is judged in that namespace's authority. fn revocation_verdict( store: &Store, landing: &Landing, sets: &std::collections::BTreeMap, - path: &str, + (path, dir): (&str, &str), r: &crate::authority::Revocation, ) -> Option { let (Some(target), Some(actor)) = (r.target(), r.actor()) else { return None }; let id = r.subject(); let act = |kind| ActRef { subject: &id, actor, under: r.under.as_ref(), act: kind, at: r.at }; let pos = landing.position(path, &crate::landed::revocation_key(r), r.at); - let auth = Authority::as_of(store, landing, pos); match &target { Revocable::Acceptance(acc) => { let revoked = store.log.iter().flat_map(|l| l.file.acceptances.iter()).find(|a| a.id == *acc)?; let ns = revoked.decision.namespace(); + let auth = Authority::as_of(store, landing, pos, ns); let policy = auth.policy(ns)?; if !r.is_entity() { return Some(finding(&id, format!( @@ -108,6 +110,7 @@ fn revocation_verdict( judge(&auth, &act(Act::RevokeAcceptance), t, Some(&policy.accept_role)) } Revocable::Grant(g) => { + let auth = Authority::as_of(store, landing, pos, dir); let grant = auth.grants.get(&g.to_string()).copied()?; auth.genesis()?; judge(&auth, &act(Act::RevokeGrant), Target::Scope(&grant.scope), None) diff --git a/ledger-core/src/verify/authority.rs b/ledger-core/src/verify/authority.rs index 8d84a5e0..8fe02add 100644 --- a/ledger-core/src/verify/authority.rs +++ b/ledger-core/src/verify/authority.rs @@ -25,8 +25,8 @@ use crate::store::Store; /// Every authority finding in the store, across the three classes. pub fn findings(store: &Store) -> Vec { let auth = Authority::build(store); - let mut out: Vec = store.log.iter().flat_map(|l| structure::entry_faults(&l.file)).collect(); - out.extend(references::faults(store, &auth)); + let mut out: Vec = store.log.iter().flat_map(|l| structure::entry_faults(&l.file, &l.namespace)).collect(); + out.extend(references::faults(store)); out.extend(model_actors(&auth)); out.extend(hash_mismatches(&auth)); out diff --git a/ledger-core/src/verify/genesis_role.rs b/ledger-core/src/verify/genesis_role.rs index d7faf416..8f9a2fb1 100644 --- a/ledger-core/src/verify/genesis_role.rs +++ b/ledger-core/src/verify/genesis_role.rs @@ -26,7 +26,7 @@ pub(crate) fn findings(store: &Store, landing: &Landing) -> Vec { for p in &logged.file.policies { let id = p.id.to_string(); let pos = landing.position(&path, &crate::landed::key("policies", &id), p.at); - let Some(genesis) = Authority::as_of(store, landing, pos).genesis() else { continue }; + let Some(genesis) = Authority::as_of(store, landing, pos, &p.namespace).genesis() else { continue }; if genesis.role == p.accept_role { out.push(Finding::schema(&id, format!( "maps accept-decision to `{}`, the genesis role as of the policy ({}) — the accept role is never the genesis role (LP-6.17, D9 (f))", @@ -38,13 +38,19 @@ pub(crate) fn findings(store: &Store, landing: &Landing) -> Vec { out } -/// Each role a genesis grant names that carries a decision capability. +/// Each role a genesis grant names, in the grant's own namespace, that +/// carries a decision capability. fn decision_capable_roots(store: &Store) -> Vec { - let roots: BTreeSet<&str> = - store.log.iter().flat_map(|l| l.file.grants.iter()).filter(|g| g.genesis).map(|g| g.role.as_str()).collect(); + let roots: BTreeSet<(&str, &str)> = store + .log + .iter() + .flat_map(|l| l.file.grants.iter().map(move |g| (l.namespace.as_str(), g))) + .filter(|(_, g)| g.genesis) + .map(|(ns, g)| (ns, g.role.as_str())) + .collect(); roots .into_iter() - .filter_map(|id| store.role(id)) + .filter_map(|(ns, id)| store.role_in(ns, id)) .filter_map(|role| { let decision: Vec<&str> = role.decision_capabilities().into_iter().map(|c| c.as_str()).collect(); (!decision.is_empty()).then(|| { diff --git a/ledger-core/src/verify/mod.rs b/ledger-core/src/verify/mod.rs index be23b4e1..5f586e6f 100644 --- a/ledger-core/src/verify/mod.rs +++ b/ledger-core/src/verify/mod.rs @@ -114,36 +114,37 @@ pub struct Notices { /// role-checked, but no signature is required. #[serde(skip_serializing_if = "Vec::is_empty")] pub unsigned: Vec, - /// The genesis holder, while they have no trusted key: until one is + /// Each namespace whose genesis holder has no trusted key: until one is /// bound, the first self-bound binding to land for the address is the - /// one trusted (D7, #96). With the governed namespaces it bears on. - #[serde(skip_serializing_if = "Option::is_none")] - pub genesis_unbound: Option, + /// one trusted (D7, #96). Per namespace, since each has its own genesis + /// (LP-8.31, ruling 47). + #[serde(skip_serializing_if = "Vec::is_empty")] + pub genesis_unbound: Vec, } -/// A genesis holder with no trusted key, and the governed namespaces. +/// A namespace whose genesis holder has no trusted key. #[derive(Debug, Default, serde::Serialize)] pub struct GenesisUnbound { + pub namespace: String, pub holder: String, - pub namespaces: Vec, } fn notices(store: &Store, trusted: &[&crate::authority::KeyBinding]) -> Notices { Notices { unchecked: unchecked(store), unsigned: unsigned(store), genesis_unbound: genesis_unbound(store, trusted) } } -/// The genesis holder, when no key of theirs is trusted, with every -/// namespace under policy. -fn genesis_unbound(store: &Store, trusted: &[&crate::authority::KeyBinding]) -> Option { - let auth = crate::authority::Authority::build(store); - let genesis = auth.genesis()?; - if trusted.iter().any(|k| k.principal == genesis.holder && k.act.opens()) { - return None; - } - let mut namespaces: Vec = auth.policies.iter().map(|p| p.namespace.clone()).collect(); - namespaces.sort(); - namespaces.dedup(); - Some(GenesisUnbound { holder: genesis.holder.to_string(), namespaces }) +/// Each namespace with a live genesis grant whose holder has no trusted +/// key (a trusted key is store-wide until issue 6). +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()); + (!bound).then(|| GenesisUnbound { namespace: ns, holder: genesis.holder.to_string() }) + }) + .collect() } impl Report { @@ -218,7 +219,8 @@ pub fn verify(store: &Store, opts: &Options) -> Report { report } -/// The graph stage: the SPARQL shapes, then `A006` over history. +/// The graph stage: the SPARQL shapes — the authority shapes over each +/// namespace's graph alone (LP-8.19, ruling 47) — then `A006` over history. fn graph_stage(store: &Store, landing: &crate::landing::Landing) -> Vec { let mut graph = crate::graph::shapes::graph_findings(store); graph.extend(acts::unauthorised(store, landing)); diff --git a/ledger-core/src/verify/order_grid.rs b/ledger-core/src/verify/order_grid.rs index 5a9ee55b..0af8db06 100644 --- a/ledger-core/src/verify/order_grid.rs +++ b/ledger-core/src/verify/order_grid.rs @@ -18,7 +18,8 @@ use crate::signing::Sidecar; use crate::store::Store; use crate::testkit; -pub(super) const NS: &str = "fixture.ledger"; +/// One namespace throughout: the fixture change-set is filed under `testkit::NS`, and authority is per namespace (ruling 47). +pub(super) const NS: &str = testkit::NS; /// The fixture change-set's repo-relative path, under its namespace's directory. pub(super) fn path() -> String { format!(".decisions/ns/{}/log/{}.yml", testkit::NS, testkit::CS_ULID) diff --git a/ledger/spec/ledger-protocol.md b/ledger/spec/ledger-protocol.md index 7d1953ad..5a7fd254 100644 --- a/ledger/spec/ledger-protocol.md +++ b/ledger/spec/ledger-protocol.md @@ -702,7 +702,7 @@ notes: … # optional *§3.9.5 in the example is section 4.9 here.* -- **LP-5.19** (W, V) A role file declares `format: 6`, is named by its id, is not duplicated, and `may` do at least one thing from the closed capability vocabulary (LP-6.1). *Implemented as store-wide: one `roles/` directory serves every namespace. Superseded by ruling 47 once implemented (LP-6.31).* +- **LP-5.19** (W, V) A role file declares `format: 6`, is named by its id, is not duplicated within its namespace, and `may` do at least one thing from the closed capability vocabulary (LP-6.1). A role is declared under one namespace's `roles/` (LP-3.34) and is that namespace's: a grant or a policy names a role of its own namespace, and the same id in two namespaces is two roles (spec v1.9, ruling 47; LP-3.36). #### Log entries @@ -803,10 +803,10 @@ An act is valid only when its actor holds a grant whose role carries the claim t - **LP-6.2** (W, V) The role check: the actor holds a live, accepted, available grant of a role that `may` the act, over a scope covering the act's target, and, for a fallback, one not limited from the act and not outranked (LP-6.16). In a governed namespace the grant is the one the act names (`under`, LP-6.19), chosen by the rules of section 6.2. The check fails on no such grant, a grant not accepted, a holder unavailable, a wrong scope, a fallback limit or a fallback outranked. - **LP-6.3** (V) A grant is live only once its holder has accepted it by its hash. - **LP-6.4** (V) A primary grant carries no limits; a primary grant with limits is a schema fault. A fallback grant MAY carry `no-grants`, `no-grant-revocations`, `no-genesis`, `no-role-edits`. -- **LP-6.5** (V) The genesis grant is self-granted, has scope `*` and order `primary`, and carries an `external_ref`. `external_ref` appears on the genesis grant only. At most one genesis grant is live (`A005`). *Implemented as one genesis grant for the store, whose scope `*` covers every namespace. Superseded by ruling 47 once implemented: each namespace then has its own (LP-6.31).* +- **LP-6.5** (V) The genesis grant is self-granted, has scope `*` and order `primary`, and carries an `external_ref`. `external_ref` appears on the genesis grant only. Each namespace has its own genesis grant, filed under its directory, and its `*` is the whole of that namespace (rulings 47 and 67). At most one genesis grant is live per namespace (`A005`, counted over the namespace's graph alone — LP-8.19). - **LP-6.6** (V) No two live grants share role, scope and order (`A003`). Live means unrevoked, unsuperseded, and accepted by a grant acceptance (LP-6.15). - **LP-6.15** (V) **Liveness.** A grant is *live* when unrevoked, unsuperseded, and accepted by its holder (a grant acceptance signing its current hash); *available* at an instant when no unavailability covers it (`[from, until)`, unless an availability ended it at or before the instant). The namespace's policy *in force* is the tip of its `replaces` chain. -- **LP-6.16** (W, V) **The role check is verb-time.** An authoring act asks whether the actor holds a live, accepted, available grant of a role that `may` the act, over a scope covering it (`*`; a namespace scope its own namespace; a set scope its own set; namespaces match exactly), and, for a fallback, one not limited from the act while no live, available grant of the same role at a lower rank covers the act's target. Fallback order is by covering scope (D9 (e)): a `fallback-1` over a set waits on a primary over `*` in its role; another role never outranks; equal rank acts concurrently. Since v1.8 the check also runs at verification, over history, as of each act (`A006`, section 6.6). *A scope of `*` or `pattern:` covers every namespace of the store. Ruling 47 supersedes that once implemented; what such scopes then mean is open (section 12.3).* +- **LP-6.16** (W, V) **The role check is verb-time.** An authoring act asks whether the actor holds a live, accepted, available grant of a role that `may` the act, over a scope covering it (`*`; a namespace scope its own namespace; a set scope its own set; namespaces match exactly), and, for a fallback, one not limited from the act while no live, available grant of the same role at a lower rank covers the act's target. Fallback order is by covering scope (D9 (e)): a `fallback-1` over a set waits on a primary over `*` in its role; another role never outranks; equal rank acts concurrently. Since v1.8 the check also runs at verification, over history, as of each act (`A006`, section 6.6). **A scope is read inside its own namespace** (spec v1.9, rulings 47 and 67): the grants that count for an act are those filed under the directory of the namespace the act is in; `*` and `ns:` are the whole of that namespace; `set:` is a set of it; `pattern:` is unchanged and covers no decision; and a grant scoped `ns:` than the namespace it is filed in could never have effect, so it is a schema fault (LP-8.8). Nothing in one namespace's authority has effect in another. - **LP-6.17** (W, V) In a namespace with a policy, accepting and revoking an acceptance count only grants of the policy's `accept_role`, which is never the genesis role. The genesis (root) role carries `grant-role`, `revoke-grant`, `declare-unavailability` and `rotate-genesis` and none of the decision capabilities (D9 (f)). A policy whose `accept_role` is no declared role that may `accept-decision` is a schema fault. So is a policy whose `accept_role` is the genesis role, which at a policy's position is the role of the genesis grant live as of that policy (section 8.7), and a genesis role that carries any decision capability (`accept-decision`, `sign-off-pattern`, `waive-invalidation`) (ruling 50). A writer that opens a namespace refuses an existing root role that lacks a root capability or carries a decision capability (D9 (f), extended by ruling 50). - **LP-6.18** (V) A namespace without a policy is a pre-v2 namespace: nothing is role-checked there. @@ -859,7 +859,7 @@ LP-6.14 binds one implementation and cannot be confirmed from a store. The guara ### 6.6 The role check over history - **LP-6.27** (V) **`A006`** (graph stage, section 8.6). Every acceptance and every `rev:` revocation is re-judged as of its position (section 8.7): the grant it names (`under`) must be held by its actor, of a role that may do the act (in a governed namespace, the policy's `accept_role`), over a scope covering the target, live, accepted and available at its `at`, and not outranked (D9 (e)). The check never searches for another grant; a governed act with no `under` fails. An acceptance or revocation before its namespace's first policy is not checked (D5 (c)); the exemption covers those two and no key binding (section 4.10). A grant's revocation is checked once the store has a genesis. -- **LP-6.28** (V) **A policy is the genesis holder's act** (2026-10-05). Every policy, a namespace's first or a change, is judged like any other act as of its own position: the grant it names (`under`) must be the genesis grant as of the policy, held by its `by`, live and available at its `at`, through the same role check (`A006`). **The genesis grant only:** a grant of `grant-role` over `*` that is not the genesis grant authorises no policy, signed or not. The position rule does not exempt it: a policy defines a namespace's governance, so even the first one is checked. A signature on a policy change says who filed it, not that they were the one who may. *"The genesis grant" is the store's one genesis grant; under ruling 47 it becomes the namespace's own (LP-6.31).* +- **LP-6.28** (V) **A policy is the genesis holder's act** (2026-10-05). Every policy, a namespace's first or a change, is judged like any other act as of its own position: the grant it names (`under`) must be the genesis grant as of the policy, held by its `by`, live and available at its `at`, through the same role check (`A006`). **The genesis grant only:** a grant of `grant-role` over `*` that is not the genesis grant authorises no policy, signed or not. The position rule does not exempt it: a policy defines a namespace's governance, so even the first one is checked. A signature on a policy change says who filed it, not that they were the one who may. "The genesis grant" is the namespace's own (ruling 47): a policy under another namespace's genesis, or with no live genesis of its own namespace as of it, fails `A006`. - **LP-6.30** (V) **A role takes effect from its own landing** (2026-10-05). A role file is an enabling entry, and it carries no signed `at` (`created_at` is a date in no payload), so landing alone places it (D6): it counts for an act whose position landed no earlier than the role file. Its `created_at` plays no part. An act made under a grant whose role landed after it fails `A006`; a role and an act landed in the same commit stand together. - **LP-6.29** (V) **An old-style revocation is a pre-policy act** (2026-10-05). A legacy revocation (`acceptance`, `by`; formats 1–5) of an acceptance is judged like a `rev:` revocation: before its namespace's first policy it stands unchecked; one that is not before that policy (D6: dated *and* landed before it) fails `A006`, because the shape cannot name a grant. @@ -871,7 +871,7 @@ 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) **Not implemented.** Authority is per namespace (ruling 47). Each namespace has its own genesis grant, roles, grants, key bindings and policy, and nothing in one namespace's authority has effect in another. It supersedes, once implemented: the store's one genesis grant of scope `*` (LP-6.5, `A005`); store-wide role files (LP-5.19, LP-6.30, LP-8.27); grant scopes that reach every namespace (LP-6.16, LP-6.28); 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); keys judged across namespaces (LP-4.32, LP-4.37); the store-wide genesis holder of the unbound-genesis notice (LP-8.31); and the export's reach of `*` grants (LP-9.11). +- **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. Authority as its own unit, which namespaces depend on by pin, is intended for later and is not designed (ruling 48, section 12.3). @@ -1022,7 +1022,7 @@ Two verifiers conform when they report the same set of findings for the same sto ### 8.2 The parse gate - **LP-8.7** (V) Verification fails for a **schema fault** or one of **fourteen classes**, and for nothing else. A new reason is a change to this document: `L010` arrived that way, as the spec v1.1 amendment, `L013`/`L014` as spec v1.6, and the signing classes `L011`/`L012` (numbers reserved for them by #65, ruling D3) as spec v1.8. -- **LP-8.8** (V) `SCHEMA` covers: a file that does not parse against the format it declares; an unknown `format`; an unknown key; an unknown discharge scheme; a file stem disagreeing with its declared id; a duplicate id, an acceptance's included, anywhere in the store; a decision identity object filed more than once (LP-5.11, ruling 49); a per-allocation obligation from section 5.3 that is not met (except the escape's, which is `L002`); a non-empty `signature`; a version naming an undeclared set; a revocation naming an acceptance nobody filed; a `key` not matching `^[A-Z][A-Za-z0-9]{0,63}$`; `under` in a file below format 7; a sidecar that is misnamed, names no signable entity, or is on an act no policy governs (ruling 52); an entity under a namespace's directory that belongs to another namespace, a revocation's target and a sidecar's entity included (LP-3.33, ruling 45); a decision's latest version whose `supersedes` names a decision of another namespace (LP-3.31, rulings 43 and 75); a key binding filed by a party D7 does not allow (section 4.10); a format declaration below what a field needs or at or above the format that retired a shape the file uses (LP-3.15); a binding of a key that LP-4.37 refuses; a policy listing `none` with another scheme (LP-4.29); and the authority rules of section 8.4. +- **LP-8.8** (V) `SCHEMA` covers: a file that does not parse against the format it declares; an unknown `format`; an unknown key; an unknown discharge scheme; a file stem disagreeing with its declared id; a duplicate id, an acceptance's included, anywhere in the store; a decision identity object filed more than once (LP-5.11, ruling 49); a per-allocation obligation from section 5.3 that is not met (except the escape's, which is `L002`); a non-empty `signature`; a version naming an undeclared set; a revocation naming an acceptance nobody filed; a `key` not matching `^[A-Z][A-Za-z0-9]{0,63}$`; `under` in a file below format 7; a sidecar that is misnamed, names no signable entity, or is on an act no policy governs (ruling 52); an entity under a namespace's directory that belongs to another namespace, a revocation's target and a sidecar's entity included (LP-3.33, ruling 45); a decision's latest version whose `supersedes` names a decision of another namespace (LP-3.31, rulings 43 and 75); a grant scoped `ns:` than the namespace it is filed in (LP-6.16, ruling 47); a key binding filed by a party D7 does not allow (section 4.10); a format declaration below what a field needs or at or above the format that retired a shape the file uses (LP-3.15); a binding of a key that LP-4.37 refuses; a policy listing `none` with another scheme (LP-4.29); and the authority rules of section 8.4. ### 8.3 The fourteen @@ -1078,7 +1078,7 @@ CI has to tell "the gate said no" apart from "the gate broke". This differs from ### 8.6 The graph stage -- **LP-8.19** (V) The graph stage runs SPARQL shape checks over the emitted graph (section 9.1), cross-entry referential integrity the per-file schema cannot name, and the role check over history: +- **LP-8.19** (V) The graph stage runs SPARQL shape checks over the emitted graph (section 9.1), cross-entry referential integrity the per-file schema cannot name, and the role check over history. The authority shapes `A003` and `A005` run over each namespace's emitted graph alone — the graph that namespace's export carries (LP-9.11) — never the union (spec v1.9, ruling 47); the decision shapes `G001` to `G006` run over the store's graph, where a `based_on` edge may cross a namespace and a `supersedes` edge in history is not judged (LP-3.31, ruling 75): | Code | Fails when | |---|---| @@ -1089,13 +1089,13 @@ CI has to tell "the gate said no" apart from "the gate broke". This differs from | `G005` | one decision is superseded by two live claimants (spec v1.3) | | `G006` | two live decisions of one namespace whose tips share a `key` — the cross-check of `L014` (spec v1.6) | | `A003` | two live grants (unrevoked, unsuperseded, accepted) share role, scope and order (spec v1.7) | -| `A005` | more than one live (unsuperseded, unrevoked) genesis grant (spec v1.7) | +| `A005` | more than one live (unsuperseded, unrevoked) genesis grant in one namespace (spec v1.7; per namespace since spec v1.9) | | `A006` | an acceptance, `rev:` revocation or policy whose named grant did not, as of the act, let its actor do it — or a governed act naming none (spec v1.8, section 6.6); an old-style revocation not before its namespace's first policy; a policy not made under the genesis grant (2026-10-05) | - **LP-8.20** (V) `G004` is the state two divergent writers leave behind: a plain git merge of two branches' logs, each having revised the same decision from the same parent. No file is malformed; the *store* cannot name a latest version, so it is non-conformant until a recorded arbitration extends one chain past the fork, closing the other tip via `merged_from`. A tip, for both `G004` and `G005`, is a version no other version of the decision claims by `parent` *or* `merged_from`. - **LP-8.21** (V) `G005` is the write-time one-superseder-per-decision refusal met across branches, where it cannot refuse retroactively: each side's claim was legal alone. Only live claims count. A claimant whose next version drops the edge has withdrawn, which is exactly the arbitration act recorded for the losing side. - **LP-8.22** (V) The graph classes are closed the same way the file classes are: `G006` arrived as a change to this section (spec v1.6), and a `G007` would be another. -- **LP-8.23** (V) `A003` and `A005` are the authority shapes' gate classes, with two tightenings recorded in the authority shapes: `A003` counts only a grant acceptance as acceptance, and `A005` excludes a revoked genesis. `A006` is not a SPARQL shape: it is computed by the role check over the authority records as of each act, because it needs landing order (section 8.7), which the graph does not carry. *`A005` counts genesis grants across the whole store today. Superseded by ruling 47 once implemented: it then counts per namespace (LP-6.31).* +- **LP-8.23** (V) `A003` and `A005` are the authority shapes' gate classes, with two tightenings recorded in the authority shapes: `A003` counts only a grant acceptance as acceptance, and `A005` excludes a revoked genesis. `A006` is not a SPARQL shape: it is computed by the role check over the authority records as of each act, in the act's own namespace, because it needs landing order (section 8.7), which the graph does not carry. `A003` and `A005` count per namespace: the authority shapes run over each namespace's graph alone (LP-8.19, ruling 47). ### 8.7 What a verifier reads from git: landing and order (D6) @@ -1125,9 +1125,9 @@ CI has to tell "the gate said no" apart from "the gate broke". This differs from - **LP-8.31** (V) A verifier reports these as notices, not failures: - **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). *The store's one genesis holder today; per namespace under ruling 47 (LP-6.31).* While the genesis holder has no trusted key, the verifier says so, naming the governed namespaces: the first self-bound binding to land for that address will be the one trusted (D7). + - **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). -*Note: the reference verifier's machine-readable report carries the second list as `unsigned` and the third as `genesis_unbound` (`holder`, `namespaces`).* +*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`).* ### 8.9 Classes added by the basis rulings @@ -1188,12 +1188,12 @@ The committed export is the read model the analyzers' generator consumes. - acceptances of those decisions, and revocations of those acceptances; - the sets those versions name; - the change-sets that filed any of these, each holding only what belongs to the namespace; - - the authority records that reach the namespace: its policies and key bindings; the grants whose scope covers it (`*`, its `ns:` scope, or a set its versions name) with their grant acceptances, unavailabilities, availabilities and revocations; and the roles those grants and policies name; + - its own authority records, and only its own (spec v1.9, ruling 47): the policies and key bindings that carry its name; the grants filed under its directory with their grant acceptances, unavailabilities, availabilities and revocations; and the roles declared under its `roles/` that those grants and policies name; - one node per sidecar of an entity in it (LP-9.3). `ledger:set` is the set IRI `>`; a reader takes the set id from its local part. - *A grant of scope `*` reaches every namespace's export. Superseded by ruling 47 once implemented (LP-6.31); what an export carries then is open (section 12.3).* + A grant of another namespace reaches it whatever its scope says: a scope is read inside its own namespace (LP-6.16). - **LP-9.12** (W, V) **Form.** RDF 1.2 canonical N-Triples: - every term written in full: no prefixes, and `rdf:type` instead of `a`; - literals with `ECHAR` for BS, HT, LF, FF, CR, `"` and `\`; @@ -1473,6 +1473,7 @@ A deployment should tell holders, before they accept a grant, that their address | 7 October 2026 | Ruling 58 applied: a landed `format:` declaration is compared across first-parent history, and any change other than the LP-3.16 correction fails `L007` (LP-3.16, LP-8.30, `L007`'s wording). This enforces the ruling of 5 October on #81; its three corrections stay green. No class added; no digest moves. | | 7 October 2026 | Ruling 59 applied: a `set:` grant scope accepts every valid set id, dots included (LP-3.3). The reference had refused a dot, which section 3.3 allows. No class added; no digest moves: a scope is hashed as written. | | 7 October 2026 | Ruling 60 applied: a change-set's `parents` is part of its header entity and immutable once landed (LP-8.25). The reference had keyed each parent as its own entity, so a parent appended to a landed header passed. No class added; no digest moves. | +| 9 October 2026 | **Ruling 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). | | 9 October 2026 | **Spec v1.9: the flat layout in history** (rulings 82 and 97; PRD §3.1.1, §3.5.7; issue 2). LP-3.35 loses its not-implemented mark: a verifier detects the flat paths of revision v1.8 on the first-parent history it reads; with the legacy capability it follows each tracked file along its lineage, the flat path and the namespaced one, for `L007`, LP-3.16's `format:` comparison, `L009` and the base overlay, and reads no flat semantics; without it, it refuses with exit 2 naming the first flat commit. LP-8.30 gains the re-layout exception, LP-8.32 the lineage. No class added; no digest moves. The Appendix C note of v1.9 gains its history paragraph. | @@ -1620,6 +1621,54 @@ only adds an unhashed field. --- +#### Authority is per namespace (2026-10-09, no format change) + +**Ruled 7 October 2026** (ruling 47, with 67 and 68; +`ledger/prd/namespace-independence-prd.md` §3.2 and §3.9; issue 5 of its +§6). A grant, grant acceptance, unavailability, availability, revocation or +role belongs to the namespace whose directory holds it; a key binding and a +policy carry their namespace, which must equal the directory. Each +namespace has its own genesis grant, declared in its own `roles/`, its own +grants and its own policy, and nothing in one namespace's authority has +effect in another (LP-6.31). From this change: + +- a scope is read inside its own namespace: `*` and `ns:` are the + whole of it (ruling 67), `set:` a set of it, and a grant scoped + `ns:` than the namespace it is filed in is a `SCHEMA` fault + (LP-6.16, LP-8.8); +- a policy is the act of its own namespace's genesis holder: one under + another namespace's genesis, or with no live genesis of its own namespace + as of it, fails `A006` (LP-6.28); +- the authority shapes run over each namespace's graph alone, so `A003` + and `A005` count per namespace (LP-8.19, LP-8.23) — the decision shapes + keep the store's graph, since `based_on` crosses and a `supersedes` edge + in history is not judged (ruling 75) — and an export carries its own + namespace's authority only: a `*` grant no longer reaches every + namespace's export (LP-9.11); +- the genesis-role rules of ruling 50 and the unbound-genesis notice hold + per namespace (LP-6.17, LP-8.31); +- 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; there is no joining another namespace's genesis. + +**Who must act.** A store whose log holds a policy for more than one +namespace, filed before this change, shared one genesis grant, one set of +role files and one set of trusted keys across them. Its namespaces cannot +be split, and no migration path is provided (ruling 68). Under these rules +such a store fails verification: each namespace after the first has no +genesis grant of its own (`A006` on its policy) and its first key binding +leans on no other namespace (D7 on that binding). It is re-founded per +namespace; the re-founding note is added with issue 9 of the PRD's §6. + +**Keys are not yet per namespace.** A key is still bound, closed and +trusted store-wide (LP-4.12, LP-4.31, LP-4.32, LP-4.37, LP-4.38, LP-4.39): +issue 6 scopes them (LP-6.32). Until then the genesis holder's key bound in +one namespace signs in another once `init` binds it there. + +**No digest moves**, no class is added, and no grant payload gains a field +(PRD §3.12): a grant belongs by where it is filed. No committed store in +this repository holds an authority record, so no stored verdict changes. + #### An entity outside its namespace's directory is refused (2026-10-09, no format change) **Ruled 7 October 2026** (ruling 45; `ledger/prd/namespace-independence-prd.md`