diff --git a/.ddd/claims/DDD-gates-02.yaml b/.ddd/claims/DDD-gates-02.yaml new file mode 100644 index 0000000..eb93aba --- /dev/null +++ b/.ddd/claims/DDD-gates-02.yaml @@ -0,0 +1,42 @@ +format: 2 +id: DDD-gates-02 +statement: > + A pipeline of sequentially-gated CI steps reports what broke first, not + what it did not check: when a step fails, every later independent gate is + skipped, its state for that revision is unknown, and the run still reads + as one red build. The masking is not hypothetical — main at 75b5472 + carried two defects and showed one. `9df74c1` added `note` to `AddArgs` + and updated one of four call sites, so the test targets did not compile; + CI stopped there. Behind the compile error, + `ledger-core/src/author/tests.rs` sat at exactly the 400-line fitness + ceiling, and fixing the three call sites put it at 402 — a second, + independent gate failure that the first one hid, found only when someone + repaired the build by hand (7258af1). The repo already recognises this + failure mode one level down and fixed it there: `cargo t` is + `test --no-fail-fast` precisely so one failing test binary cannot hide + the rest (.cargo/config.toml, and CLAUDE.md says so in bold). The same + reasoning is not applied one level up, across the nine steps of + .github/workflows/product-ci.yml, each of which runs under the default + `if: success()`. Family: this is the shape of the M6 leakage prediction + and of L3's cross-file merge finding — a mechanism guarding the path it + can see, blind to what sits behind it. +status: projected +falsifier: > + Either of two negative results, both about whether the hazard is real + rather than about how the pipeline is currently wired. (a) A review of + this repository's red CI runs — or of local full-sequence runs + reproducing them — finds that in every case the steps skipped by an + earlier failure would themselves have passed, so the masking never + concealed a second defect and 75b5472 was a coincidence rather than an + instance. (b) The 75b5472 case is re-graded: the code-quality gate did + report on that run, and the second defect was visible after all, which + would show the observation was misread. Deliberately drafted so the + remedy cannot fire it: making independent gates run anyway changes what + *future* runs report, and says nothing about whether past masking hid + anything — a claim whose falsifier its own remedy satisfies is the + self-sealing defect DDD-method-07 names, and this is drafted to not + carry it. +owner: none +changed: 2026-08-13 +revalidate_by: 2027-02-13 +depends_on: [] diff --git a/.ddd/concordance.yaml b/.ddd/concordance.yaml index a86e6be..7759f46 100644 --- a/.ddd/concordance.yaml +++ b/.ddd/concordance.yaml @@ -1,6 +1,10 @@ format: 1 note: 'Permanent id concordance for the M8 ledger migration (ruling 6: the aliases are permanent, not transitional). Historical ids — decisions, the risk record, seams, manifest entries — remain valid forever; `ddd why` resolves either spelling. Claim ids are not re-keyed: claims remain the ddd ontology''s own.' rows: +- historical: dec/ddd/ci-gates-report-skipped + ledger: dec:hafeok.ddd/01KZX7HW7J4FET6H3JW8EQTK0R + kind: decision + disposition: 'filed 2026-08-13, after the M8 migration: the .ddd entry carries the rationale and the typed bases, the ledger entry is the record of the decision. Same pairing the migration produced, authored rather than migrated.' - historical: dec/bicep/linter-at-error ledger: dec:hafeok.ddd/01KZTGGGS14S17F2P5K6EZEZXH kind: decision diff --git a/.ddd/decisions/ci-gates-report-skipped.yaml b/.ddd/decisions/ci-gates-report-skipped.yaml new file mode 100644 index 0000000..8bb8834 --- /dev/null +++ b/.ddd/decisions/ci-gates-report-skipped.yaml @@ -0,0 +1,65 @@ +format: 7 +id: dec/ddd/ci-gates-report-skipped +title: A CI run reports which gates did not execute; independent gates run anyway +rationale: > + A red build must say what it did not check, not only what broke first. + Two forms, and the weaker one is the floor rather than the fallback. + + + The full form: a step that does not depend on a failed step runs anyway. + Clippy, the ledger gate, the ddd gate, the convention check and the + supply-chain check are independent of each other; today a failure in any + of them silently cancels the rest. They run under `if: ${{ !cancelled() }}` + (or equivalent) so one revision produces one complete verdict. + + + The minimum viable form, which holds even where the full form cannot: the + run reports which gates did not execute. Some steps genuinely depend on + an earlier one — a gate that needs a compiled workspace cannot run + without one — so "run everything" is not universally available, and a + build that cannot run a check must say so rather than leave the reader to + infer a pass from a silence. Unknown and passing are different states and + the report must distinguish them. + + + Scope note, stated so it is not mistaken for done: this entry rules the + arrangement; it does not implement it. The follow-up is a workflow edit + plus a summary step. Its cost is that a red build stops failing fast — + every independent gate's own runtime is then paid on every red run, the + benchmark step most of all — and that cost is the point rather than a + regression: the run buys a complete verdict with it. The minimum-viable + form costs a summary step and nothing else, which is why it is the floor. +principal: Emil (Context&) +based_on: + - type: preference + statement: >- + A gate's report should distinguish "checked and passed" from "not + checked", because a reader who cannot tell them apart will read + silence as a pass — and the arrangement that produces the silence is + an accident of step ordering, not a judgment about what mattered. + - type: claim + claim: DDD-gates-02 + status: projected + changed: 2026-08-13 + content: sha256:65bfeff0770e7de976127d54d87c3428a6e4458d0f6b6088fc0b452a4c6cb89e +date: 2026-08-13 +notes: > + Filed 2026-08-13 for the principal's acceptance; not implemented in the + session that filed it, by that session's own scope. + + + Allocated as a judgment rather than a criterion on purpose. Nothing + currently checks this arrangement, so holding to it rests on a person, + and a criterion would name a discharge pointer that resolves to no + checker — the manufactured-basis shape the 2026-08 basis-quality audit + found and this store now refuses. When the workflow edit lands, the + honest move is to re-allocate this decision to a criterion whose + discharge names the check that then exists; that is a new version, not an + edit. + + + The claim edge is genuine ground here, not a citation: DDD-gates-02 is an + observation of an instance that actually occurred in this repository, and + if that instance is re-graded away this decision loses its reason. It is + therefore filed in `based_on`, not `revisit_if` — the distinction the + 2026-08-13 ruling draws, applied on its first new use. diff --git a/.ddd/decisions/internal-not-surface.yaml b/.ddd/decisions/internal-not-surface.yaml index ef4f434..98cf42b 100644 --- a/.ddd/decisions/internal-not-surface.yaml +++ b/.ddd/decisions/internal-not-surface.yaml @@ -1,4 +1,4 @@ -format: 5 +format: 7 id: dec/ddd/internal-not-surface title: internal is not C# contract surface by default; library repos flip per-repo rationale: > @@ -16,10 +16,11 @@ based_on: repositories are application repositories, so the app-repo posture is the required default and the library posture is an explicit per-repo config flip. - - type: claim - claim: DDD-adapter-02 +revisit_if: + - claim: DDD-adapter-02 status: projected changed: 2026-08-06 + content: sha256:b333063d5058614855329b2dd8a1d1c5f8732ab243a2d2f9ebfa3bf95f2f5fda date: 2026-08-06 notes: > Basis re-typed 2026-08-12, following the 2026-08 basis-quality audit: a @@ -36,3 +37,16 @@ notes: > ruling, not its ground; its fate as a claim is the F-batch's. Format 2 -> 5 per docs/ddd-format-migrations.md. Ratified 2026-08-12 (Emil): accepted as filed (8b7a410). + + Edge re-typed 2026-08-13, following the principal's ruling that a + watched-not-grounding edge is a distinct edge type: a re-decision of + this edge per format 7, not an edit. DDD-adapter-02 moves out of + `based_on` and into `revisit_if` — the pin is carried across unchanged + (projected@2026-08-06, content recovered at that state by the M8 + migration), so nothing about what is recorded changes; what changes is + what the record *means* and how it reports. It never grounded this + decision, and the note above already said so in prose; it now says so in + a field. Its falsifier — a boundary defect traced to an internal member + consumed across a boundary — is the tripwire, and firing it reopens the + mandate rather than undermining it. Format 5 -> 7 per + docs/ddd-format-migrations.md. Awaiting the principal's acceptance. diff --git a/.ddd/decisions/m6-proceeds-no-flip.yaml b/.ddd/decisions/m6-proceeds-no-flip.yaml index bd81663..0bad9c9 100644 --- a/.ddd/decisions/m6-proceeds-no-flip.yaml +++ b/.ddd/decisions/m6-proceeds-no-flip.yaml @@ -1,4 +1,4 @@ -format: 5 +format: 7 id: dec/ddd/m6-proceeds-no-flip title: M6 proceeds as the Rust adapter; the M6/M7 flip condition does not fire rationale: > @@ -32,10 +32,11 @@ based_on: The stated default the principal set for the M6/M7 sequencing: with the flip condition unfiled and therefore unevaluable against filed evidence, the evaluation resolves by that default and M6 proceeds. - - type: claim - claim: DDD-adapter-01 +revisit_if: + - claim: DDD-adapter-01 status: projected changed: 2026-08-02 + content: sha256:922a0a70f69509bfcd73325fbdc1cd5ac281b1b52281b14f56a8f519afd99f52 date: 2026-08-10 notes: > M6's content is the third language adapter, which is the experiment that @@ -81,3 +82,17 @@ notes: > Ratified 2026-08-12 (Emil): accepted as filed (8531938) — the retained edge is a claim being watched, with the content mismatch explicit so it is not later read as ground. + + Edge re-typed 2026-08-13, following the principal's ruling that a + watched-not-grounding edge is a distinct edge type: a re-decision of + this edge per format 7, not an edit. DDD-adapter-01 moves out of + `based_on` and into `revisit_if`, pin carried across unchanged + (projected@2026-08-02, content recovered at that state by the M8 + migration). This is the one of the three whose pin has drifted, and the + drift is deliberate: the claim moved projected -> reported -> + established, and the two paragraphs above are the record that the + movement was looked at each time. That drift now reports as a **reopen** + finding rather than a basis-loss one, which is the correction the ruling + makes — the ground of this decision is the principal's stated default and + has never moved; what moved is the stake being tracked. Format 5 -> 7 per + docs/ddd-format-migrations.md. Awaiting the principal's acceptance. diff --git a/.ddd/decisions/no-unwrap-gate.yaml b/.ddd/decisions/no-unwrap-gate.yaml index d506856..fa8c690 100644 --- a/.ddd/decisions/no-unwrap-gate.yaml +++ b/.ddd/decisions/no-unwrap-gate.yaml @@ -1,4 +1,4 @@ -format: 5 +format: 7 id: dec/rust/no-unwrap title: Deny clippy::unwrap_used workspace-wide rationale: > @@ -16,10 +16,11 @@ based_on: to a defined exit code; an unwrap is a panic path that structurally bypasses that contract, so denying the lint is what keeps the error model total. - - type: claim - claim: DDD-gates-01 +revisit_if: + - claim: DDD-gates-01 status: reported changed: 2026-08-06 + content: sha256:6f500ee8ef3bcbef7ce1a1d6ce2c093d61858f7a01bb3685b1c0f56aa32b4395 date: 2026-08-06 notes: > Basis re-typed 2026-08-12, following the 2026-08 basis-quality audit: a @@ -36,3 +37,15 @@ notes: > fate as a claim is the F-batch's. Format 2 -> 5 per docs/ddd-format-migrations.md. Ratified 2026-08-12 (Emil): accepted as filed (8b7a410). + + Edge re-typed 2026-08-13, following the principal's ruling that a + watched-not-grounding edge is a distinct edge type: a re-decision of + this edge per format 7, not an edit. DDD-gates-01 moves out of + `based_on` and into `revisit_if`, pin carried across unchanged + (reported@2026-08-06, content recovered at that state by the M8 + migration). The constraint is the ground and stays the ground; the claim + is the cost check, and the note above already called it that. Its + falsifier — waiver pressure, or a class of code where the lint produces + worse handling than the unwrap it replaced — is the tripwire, and firing + it reopens the gate rather than removing its ground. Format 5 -> 7 per + docs/ddd-format-migrations.md. Awaiting the principal's acceptance. diff --git a/.ddd/seams/seam-rust-ddd-cli-src-commands-content-hash-rs.yaml b/.ddd/seams/seam-rust-ddd-cli-src-commands-content-hash-rs.yaml new file mode 100644 index 0000000..216f524 --- /dev/null +++ b/.ddd/seams/seam-rust-ddd-cli-src-commands-content-hash-rs.yaml @@ -0,0 +1,14 @@ +format: 2 +id: seam/rust/ddd-cli-src-commands-content-hash-rs +boundary: contract surface of ddd-cli/src/commands/content_hash.rs +verdict_knowledge: "A caller learns that the content hash a ledger entry pins can be asked for rather than recomputed by hand — the number the M8 migration hard-coded — and that it covers the three families that carry one: decisions, claims and seams. Read-only: it computes and prints, and writes nothing." +contract_location: ddd-cli/src/commands/content_hash.rs +obligations: [] +metadata: {} +bindings: +- symbol: run + file: ddd-cli/src/commands/content_hash.rs + before: absent + after: sha256:a97bf8115fb7697aadda9e75ad15c02644a610ae031e248e2c21a8dd4d89a515 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:f81e2ed756531debccecd62893dbed2c2246eb4b617594f3533965bcdc91b33a diff --git a/.ddd/seams/seam-rust-ddd-cli-src-commands-mod-rs.yaml b/.ddd/seams/seam-rust-ddd-cli-src-commands-mod-rs.yaml index 07837c9..8bb5aa3 100644 --- a/.ddd/seams/seam-rust-ddd-cli-src-commands-mod-rs.yaml +++ b/.ddd/seams/seam-rust-ddd-cli-src-commands-mod-rs.yaml @@ -30,3 +30,9 @@ bindings: after: sha256:7dd38d1825a4292c74d18acc551fe5bf4e9e76a054eb0e6b3aed302ee530f657 base_revision: 6d3dff57e80514b7b4665ef38b364730c53201a3 hash: sha256:6d9388f03e9d613eb86e97b8413f83a199cacce9c9dc153dc9f38c74b2ae171f +- symbol: ContentHash + file: ddd-cli/src/commands/mod.rs + before: sha256:7dd38d1825a4292c74d18acc551fe5bf4e9e76a054eb0e6b3aed302ee530f657 + after: sha256:d12237b829fa1a61a9de7193cc0a4fd915b0b5a257d050fd3e3a6e37e2d0b1d8 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:b681dcee1a585339bee219cdbaf0610d00df6a5bcb01e3a542d4a42684f79055 diff --git a/.ddd/seams/seam-rust-ddd-core-src-decision-rs.yaml b/.ddd/seams/seam-rust-ddd-core-src-decision-rs.yaml index 6c59c59..847f67b 100644 --- a/.ddd/seams/seam-rust-ddd-core-src-decision-rs.yaml +++ b/.ddd/seams/seam-rust-ddd-core-src-decision-rs.yaml @@ -1,7 +1,7 @@ format: 2 id: seam/rust/ddd-core-src-decision-rs boundary: contract surface of ddd-core/src/decision.rs -verdict_knowledge: 'Consumers learn a claim pin may carry the claim''s content hash at decision time (format 6), which is what turns basis loss from a status+date heuristic into an exact comparison.' +verdict_knowledge: Consumers learn a claim pin may carry the claim's content hash at decision time (format 6), which is what turns basis loss from a status+date heuristic into an exact comparison. contract_location: ddd-core/src/decision.rs obligations: [] metadata: {} @@ -12,3 +12,9 @@ bindings: after: sha256:22e7901af1fe61a02f486b79b73cf19cc48379e1aa363a340df4ff052cb003c7 base_revision: 6d3dff57e80514b7b4665ef38b364730c53201a3 hash: sha256:09cfcbd0507c6ddb14c26b427f0a5b98d2539bbebe568cda1e259ce0cf81dbe9 +- symbol: revisit_if + file: ddd-core/src/decision.rs + before: sha256:22e7901af1fe61a02f486b79b73cf19cc48379e1aa363a340df4ff052cb003c7 + after: sha256:991e0096baa0eb2a9299352ec0dc8b4f112095dae619a6d6f9d6e701352526bf + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:2dd2a80522cca60bd06755184613ed031ba2edadca4019eace6d313e502941af diff --git a/.ddd/seams/seam-rust-ddd-core-src-ledger-gate-rs.yaml b/.ddd/seams/seam-rust-ddd-core-src-ledger-gate-rs.yaml index 2592bad..d309502 100644 --- a/.ddd/seams/seam-rust-ddd-core-src-ledger-gate-rs.yaml +++ b/.ddd/seams/seam-rust-ddd-core-src-ledger-gate-rs.yaml @@ -1,7 +1,7 @@ format: 2 id: seam/rust/ddd-core-src-ledger-gate-rs boundary: contract surface of ddd-core/src/ledger_gate.rs -verdict_knowledge: 'Callers learn that readiness and completeness are the ledger''s verdicts restated — ddd surfaces them without a second rulebook — and that migrated basis pins are checked exactly, marker by marker.' +verdict_knowledge: Callers learn that readiness and completeness are the ledger's verdicts restated — ddd surfaces them without a second rulebook — and that migrated basis pins are checked exactly, marker by marker. contract_location: ddd-core/src/ledger_gate.rs obligations: [] metadata: {} @@ -102,3 +102,51 @@ bindings: after: sha256:c8a9e77a1bcdbe8b9719aa8c8f0bd8d01ce16b7315d81b2790f2cfbb15ed87fe base_revision: 6d3dff57e80514b7b4665ef38b364730c53201a3 hash: sha256:4bd797f767f409c661c2fe586c15702e849ff4789b3fb683dfce047507dc9f90 +- symbol: Reopened + file: ddd-core/src/ledger_gate.rs + before: sha256:c8a9e77a1bcdbe8b9719aa8c8f0bd8d01ce16b7315d81b2790f2cfbb15ed87fe + after: sha256:aecd5dcdae830a72861a0eced2942216020f5f5dc7b6eb7e832be1af8be8be81 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:4ccfd7cfbb5374f21c9c690b79a28cbe711f78dc07b60a720cd92717cb09a98d +- symbol: reopen_checked + file: ddd-core/src/ledger_gate.rs + before: sha256:c8a9e77a1bcdbe8b9719aa8c8f0bd8d01ce16b7315d81b2790f2cfbb15ed87fe + after: sha256:aecd5dcdae830a72861a0eced2942216020f5f5dc7b6eb7e832be1af8be8be81 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:564c5d4c7f2f030cb2718d31d00398be2dc05782460fa74370442d004e93cece +- symbol: reopened + file: ddd-core/src/ledger_gate.rs + before: sha256:c8a9e77a1bcdbe8b9719aa8c8f0bd8d01ce16b7315d81b2790f2cfbb15ed87fe + after: sha256:aecd5dcdae830a72861a0eced2942216020f5f5dc7b6eb7e832be1af8be8be81 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:8eec238568c173856c7df9da40617a2fd79ccd08c74e1579f23a01757a546843 +- symbol: claim + file: ddd-core/src/ledger_gate.rs + before: sha256:c8a9e77a1bcdbe8b9719aa8c8f0bd8d01ce16b7315d81b2790f2cfbb15ed87fe + after: sha256:aecd5dcdae830a72861a0eced2942216020f5f5dc7b6eb7e832be1af8be8be81 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:e7e4513c8ce3b6f654e8fe320ff10f09ecade063e9deeffab4a5a701b584a77f +- symbol: current + file: ddd-core/src/ledger_gate.rs + before: sha256:c8a9e77a1bcdbe8b9719aa8c8f0bd8d01ce16b7315d81b2790f2cfbb15ed87fe + after: sha256:aecd5dcdae830a72861a0eced2942216020f5f5dc7b6eb7e832be1af8be8be81 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:c872e1428dacbf8f414170ab136b57e54fc7a34a005e906925b7619277659c4f +- symbol: decision + file: ddd-core/src/ledger_gate.rs + before: sha256:c8a9e77a1bcdbe8b9719aa8c8f0bd8d01ce16b7315d81b2790f2cfbb15ed87fe + after: sha256:aecd5dcdae830a72861a0eced2942216020f5f5dc7b6eb7e832be1af8be8be81 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:26f29a24437eb340f5f35d455a6350281f3586f117ccf7bce600a66d032c3b1e +- symbol: historical + file: ddd-core/src/ledger_gate.rs + before: sha256:c8a9e77a1bcdbe8b9719aa8c8f0bd8d01ce16b7315d81b2790f2cfbb15ed87fe + after: sha256:aecd5dcdae830a72861a0eced2942216020f5f5dc7b6eb7e832be1af8be8be81 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:4e4306ac34c340a8ffe9d359f3074c28bed4f4fceb1e4749447f092cee1d1d04 +- symbol: pinned + file: ddd-core/src/ledger_gate.rs + before: sha256:c8a9e77a1bcdbe8b9719aa8c8f0bd8d01ce16b7315d81b2790f2cfbb15ed87fe + after: sha256:aecd5dcdae830a72861a0eced2942216020f5f5dc7b6eb7e832be1af8be8be81 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:bbdf7694e334de1843867a65951db0f301ca24a27308bb535b9889fdb6f8147f diff --git a/.ddd/seams/seam-rust-ddd-core-src-lib-rs.yaml b/.ddd/seams/seam-rust-ddd-core-src-lib-rs.yaml index 48f288d..ba56f86 100644 --- a/.ddd/seams/seam-rust-ddd-core-src-lib-rs.yaml +++ b/.ddd/seams/seam-rust-ddd-core-src-lib-rs.yaml @@ -42,3 +42,9 @@ bindings: after: sha256:6f1bb26354ff88cf862053010e16b0cc9b6e701e32528438fc2a7daf42a92d87 base_revision: 6d3dff57e80514b7b4665ef38b364730c53201a3 hash: sha256:cd2e510ca7451e4d78188735ed40d3a93e2017c4c3166f4b2db8bd4e259ec43b +- symbol: revisit + file: ddd-core/src/lib.rs + before: sha256:6f1bb26354ff88cf862053010e16b0cc9b6e701e32528438fc2a7daf42a92d87 + after: sha256:4471827b0883b97b6cb3618b6fe668ac569d586ac0773d2270a07ef4269db72b + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:143456fd8c9a76a6bfd082f3eb92c59419bdf6d45df83f7cb1d61d2c82cec408 diff --git a/.ddd/seams/seam-rust-ddd-core-src-report-rs.yaml b/.ddd/seams/seam-rust-ddd-core-src-report-rs.yaml index f34ba6e..bc465e2 100644 --- a/.ddd/seams/seam-rust-ddd-core-src-report-rs.yaml +++ b/.ddd/seams/seam-rust-ddd-core-src-report-rs.yaml @@ -1,7 +1,7 @@ format: 2 id: seam/rust/ddd-core-src-report-rs boundary: contract surface of ddd-core/src/report.rs -verdict_knowledge: 'Report consumers learn that a content pin, when present, is authoritative for basis loss — hash equality subsumes the status+date reading — and that both pin forms render side by side.' +verdict_knowledge: Report consumers learn that a content pin, when present, is authoritative for basis loss — hash equality subsumes the status+date reading — and that both pin forms render side by side. contract_location: ddd-core/src/report.rs obligations: [] metadata: {} @@ -18,3 +18,15 @@ bindings: after: sha256:e7dd8a3c47e53c0a468024caf67ab96b6ca7fee242f352a73db27f8445f82e14 base_revision: 6d3dff57e80514b7b4665ef38b364730c53201a3 hash: sha256:47665af84087a0a49499f9fdd615e9b61778d67093424cdf57414b108773e337 +- symbol: reopen + file: ddd-core/src/report.rs + before: sha256:e7dd8a3c47e53c0a468024caf67ab96b6ca7fee242f352a73db27f8445f82e14 + after: sha256:d9d6d9a7faefb5e3ffc807501de04810ae6138b2efc56184ad09953404e107a4 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:4786437774268db4193d869df1dd6eb8645c6e0324c1167def49c7928783e4e3 +- symbol: reopen_checked + file: ddd-core/src/report.rs + before: sha256:e7dd8a3c47e53c0a468024caf67ab96b6ca7fee242f352a73db27f8445f82e14 + after: sha256:d9d6d9a7faefb5e3ffc807501de04810ae6138b2efc56184ad09953404e107a4 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:58aeb7a13deb891039c7823914ef5af1d68603c838e9c256de7c6986649be71b diff --git a/.ddd/seams/seam-rust-ddd-core-src-revisit-rs.yaml b/.ddd/seams/seam-rust-ddd-core-src-revisit-rs.yaml new file mode 100644 index 0000000..57b2f47 --- /dev/null +++ b/.ddd/seams/seam-rust-ddd-core-src-revisit-rs.yaml @@ -0,0 +1,110 @@ +format: 2 +id: seam/rust/ddd-core-src-revisit-rs +boundary: contract surface of ddd-core/src/revisit.rs +verdict_knowledge: "A caller learns that a .ddd reopen edge always pins (an unpinned tripwire cannot fire), that a claim is ground or tripwire and never both, and that a fired edge is a reopen finding whose message never says basis loss. It also learns what it does not do: firing means the decision is due a fresh look, not that its ground moved." +contract_location: ddd-core/src/revisit.rs +obligations: [] +metadata: {} +bindings: +- symbol: gates + file: ddd-core/src/revisit.rs + before: absent + after: sha256:165805fc42eed30d880d0a8114cbb1f6bff6191d3e2af4fcbc292a457e4496b0 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:851b20b2ad040c9c69dd81755c9965d921656112aa53ff3efb9f482318b19745 +- symbol: reopen_scan + file: ddd-core/src/revisit.rs + before: absent + after: sha256:165805fc42eed30d880d0a8114cbb1f6bff6191d3e2af4fcbc292a457e4496b0 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:ffc871b84ba8729f3fae719e49b655fc665c1605c77154cc6073ec00d5e3666e +- symbol: impl Reopen + file: ddd-core/src/revisit.rs + before: absent + after: sha256:165805fc42eed30d880d0a8114cbb1f6bff6191d3e2af4fcbc292a457e4496b0 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:72b5e36c0f0befac60ebed5b17ea11d1ec28a8d08848dd7f340fe156d57fe326 +- symbol: Reopen + file: ddd-core/src/revisit.rs + before: absent + after: sha256:165805fc42eed30d880d0a8114cbb1f6bff6191d3e2af4fcbc292a457e4496b0 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:ab603547721bde388bbba4ef4463b70ed74737583b54edbd5799eebc7ff9cd0c +- symbol: RevisitEdge + file: ddd-core/src/revisit.rs + before: absent + after: sha256:165805fc42eed30d880d0a8114cbb1f6bff6191d3e2af4fcbc292a457e4496b0 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:0e5f844b7c255f34a18071e6fd190fe0af0e26a3b7a52d12469843a0a085dab2 +- symbol: claim + file: ddd-core/src/revisit.rs + before: absent + after: sha256:165805fc42eed30d880d0a8114cbb1f6bff6191d3e2af4fcbc292a457e4496b0 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:5ad1cd4bcb29deb92fff9924ed3ec3bd00f98a41d12137c78bcdefb35643d4f9 +- symbol: current_changed + file: ddd-core/src/revisit.rs + before: absent + after: sha256:165805fc42eed30d880d0a8114cbb1f6bff6191d3e2af4fcbc292a457e4496b0 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:ccccc7455d7489ab26e337f0508f669933dca5f275e0931feac3393c33186299 +- symbol: current_content + file: ddd-core/src/revisit.rs + before: absent + after: sha256:165805fc42eed30d880d0a8114cbb1f6bff6191d3e2af4fcbc292a457e4496b0 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:a29724181e9d6e247f504a721da7ce50f44add35e928afa7e133688e96f59648 +- symbol: current_status + file: ddd-core/src/revisit.rs + before: absent + after: sha256:165805fc42eed30d880d0a8114cbb1f6bff6191d3e2af4fcbc292a457e4496b0 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:d1117810662702b7291c34b59cb3e1b6c45b36ee5d8d87c6cd6f212306060bb5 +- symbol: decision + file: ddd-core/src/revisit.rs + before: absent + after: sha256:165805fc42eed30d880d0a8114cbb1f6bff6191d3e2af4fcbc292a457e4496b0 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:e69e9631120628df9cd8bdc0f728cd1b255868ddd3dbe8f9ece70d0056e0c3c6 +- symbol: pinned_changed + file: ddd-core/src/revisit.rs + before: absent + after: sha256:165805fc42eed30d880d0a8114cbb1f6bff6191d3e2af4fcbc292a457e4496b0 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:415980f37aa1f33c1481eee3bad07582949a8abd3618822ef4ee8fef45674663 +- symbol: pinned_content + file: ddd-core/src/revisit.rs + before: absent + after: sha256:165805fc42eed30d880d0a8114cbb1f6bff6191d3e2af4fcbc292a457e4496b0 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:8f61adc00afc04177d58a3eeb876f0c71360a31909ff83f0f61ce5b6ef456853 +- symbol: pinned_status + file: ddd-core/src/revisit.rs + before: absent + after: sha256:165805fc42eed30d880d0a8114cbb1f6bff6191d3e2af4fcbc292a457e4496b0 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:656c5c198e526f758463fb2385c1eed2d34e163b0a159d820f6a9f1065cb2dc2 +- symbol: changed + file: ddd-core/src/revisit.rs + before: absent + after: sha256:165805fc42eed30d880d0a8114cbb1f6bff6191d3e2af4fcbc292a457e4496b0 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:146470737e02ee2443452a692553b0e36fa4073f127d729eb1baf15fff9bd70e +- symbol: content + file: ddd-core/src/revisit.rs + before: absent + after: sha256:165805fc42eed30d880d0a8114cbb1f6bff6191d3e2af4fcbc292a457e4496b0 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:44e0ea97effd78b227bdc3962aab558cf2dc5189e94c933227d1c255c8d27f75 +- symbol: status + file: ddd-core/src/revisit.rs + before: absent + after: sha256:165805fc42eed30d880d0a8114cbb1f6bff6191d3e2af4fcbc292a457e4496b0 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:e2f97348b3d3572710f2a3c3a2e6559c0197dcf83f99fe306fb3496e83511ce4 +- symbol: message + file: ddd-core/src/revisit.rs + before: absent + after: sha256:165805fc42eed30d880d0a8114cbb1f6bff6191d3e2af4fcbc292a457e4496b0 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:8a887d64ec63de59c4b810fe39ec50ff05314373a59e7ffee8724c603fbfc4ee diff --git a/.ddd/seams/seam-rust-ddd-core-src-validate-rs.yaml b/.ddd/seams/seam-rust-ddd-core-src-validate-rs.yaml new file mode 100644 index 0000000..d953b27 --- /dev/null +++ b/.ddd/seams/seam-rust-ddd-core-src-validate-rs.yaml @@ -0,0 +1,14 @@ +format: 2 +id: seam/rust/ddd-core-src-validate-rs +boundary: contract surface of ddd-core/src/validate.rs +verdict_knowledge: "A caller learns that format 7 is the version carrying reopen edges, and that every entry is still validated against the version it declares." +contract_location: ddd-core/src/validate.rs +obligations: [] +metadata: {} +bindings: +- symbol: REVISIT_FORMAT + file: ddd-core/src/validate.rs + before: sha256:907bcb3f9c8d4edb4aa29b3059f2c59fa3399d9c8220c151495f5f40fc6e4c0a + after: sha256:2601d79faebd535b29e2c88d1963a337c064b4444176f4c1d83015df2dd425c2 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:4ff8035ceb8f81b3352fec6ac67e98b23c75f6e2e26f823a2e598a6b608ea490 diff --git a/.ddd/seams/seam-rust-ledger-cli-src-commands-add-rs.yaml b/.ddd/seams/seam-rust-ledger-cli-src-commands-add-rs.yaml new file mode 100644 index 0000000..a790ac7 --- /dev/null +++ b/.ddd/seams/seam-rust-ledger-cli-src-commands-add-rs.yaml @@ -0,0 +1,20 @@ +format: 2 +id: seam/rust/ledger-cli-src-commands-add-rs +boundary: contract surface of ledger-cli/src/commands/add.rs +verdict_knowledge: "A caller learns that add states reopen edges and a change-set note alongside its bases, at parity with revise." +contract_location: ledger-cli/src/commands/add.rs +obligations: [] +metadata: {} +bindings: +- symbol: note + file: ledger-cli/src/commands/add.rs + before: sha256:c8d5d0de0ec3ffe75fa4499351faa25b2607b711aa9edec7c74bc226c8b866d6 + after: sha256:12aac600a825c3f577bc786b7f36b0fad08bf6039df0fe37b8693fa381dc2f67 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:2d8bbc41f9c9c9e64689089ff5cc07c452222f6ba384b6e370ba97abcd258145 +- symbol: revisit_if + file: ledger-cli/src/commands/add.rs + before: sha256:c8d5d0de0ec3ffe75fa4499351faa25b2607b711aa9edec7c74bc226c8b866d6 + after: sha256:12aac600a825c3f577bc786b7f36b0fad08bf6039df0fe37b8693fa381dc2f67 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:67877c85344ffc41afde4b273b8ea40ff62f4d760bcb563882f8df8da1943008 diff --git a/.ddd/seams/seam-rust-ledger-cli-src-commands-basis-text-rs.yaml b/.ddd/seams/seam-rust-ledger-cli-src-commands-basis-text-rs.yaml new file mode 100644 index 0000000..8763721 --- /dev/null +++ b/.ddd/seams/seam-rust-ledger-cli-src-commands-basis-text-rs.yaml @@ -0,0 +1,38 @@ +format: 2 +id: seam/rust/ledger-cli-src-commands-basis-text-rs +boundary: contract surface of ledger-cli/src/commands/basis_text.rs +verdict_knowledge: "A caller learns that basis argument text is resolved from the .ddd store for display only, that the lookup tolerates the pin every content pointer carries, and that an absent .ddd store yields an empty resolver rather than an error. It also learns the boundary this respects: ledger-core stays ontology-free, so this resolution lives in the binary and never in the substrate." +contract_location: ledger-cli/src/commands/basis_text.rs +obligations: [] +metadata: {} +bindings: +- symbol: impl DddBasisText + file: ledger-cli/src/commands/basis_text.rs + before: absent + after: sha256:afa6cd42527c32603165cea16ea66eb7068b5bf275b9958f4b9eb6407d73e8cc + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:f12835178deb75502872acde48ba915d7cb569c96ca7243c1abd4f9318732394 +- symbol: DddBasisText + file: ledger-cli/src/commands/basis_text.rs + before: absent + after: sha256:afa6cd42527c32603165cea16ea66eb7068b5bf275b9958f4b9eb6407d73e8cc + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:999c1919e0f3855b1bc09e8b9761db2c0980ee9b03a12464ea3d8c865e0befbe +- symbol: impl BasisText for DddBasisText + file: ledger-cli/src/commands/basis_text.rs + before: absent + after: sha256:afa6cd42527c32603165cea16ea66eb7068b5bf275b9958f4b9eb6407d73e8cc + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:2cea055e391993e20507189e7989009f394cd14c76ba5b75770a1f984c85a98b +- symbol: load + file: ledger-cli/src/commands/basis_text.rs + before: absent + after: sha256:afa6cd42527c32603165cea16ea66eb7068b5bf275b9958f4b9eb6407d73e8cc + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:00ad86ac3396d9c204a3c469121dfdca6f331b86c03b3386a440aeef43334203 +- symbol: resolve + file: ledger-cli/src/commands/basis_text.rs + before: absent + after: sha256:afa6cd42527c32603165cea16ea66eb7068b5bf275b9958f4b9eb6407d73e8cc + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:d5c7022c38e80270d2672c05ae80c8b09398e65432a1ec0fdd9246e722f049af diff --git a/.ddd/seams/seam-rust-ledger-cli-src-commands-common-rs.yaml b/.ddd/seams/seam-rust-ledger-cli-src-commands-common-rs.yaml new file mode 100644 index 0000000..6ee7905 --- /dev/null +++ b/.ddd/seams/seam-rust-ledger-cli-src-commands-common-rs.yaml @@ -0,0 +1,14 @@ +format: 2 +id: seam/rust/ledger-cli-src-commands-common-rs +boundary: contract surface of ledger-cli/src/commands/common.rs +verdict_knowledge: "A caller learns that reopen pointers parse through their own type and never through BasisRef — the ruling's distinction is a type distinction at the CLI boundary too." +contract_location: ledger-cli/src/commands/common.rs +obligations: [] +metadata: {} +bindings: +- symbol: parse_revisit_if + file: ledger-cli/src/commands/common.rs + before: sha256:f65a2e2213ded96be3dd58b39dec5ff1741b45a6c5566bc8b8e68a0671d6406c + after: sha256:583129bd790b60851ff207b3982495a7f058b450e02e49a1268a9e1d4dab02e0 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:33d92ae290d85d4b3d60efb64e6b7b3857ca76ac81d238967dad89a18ec69987 diff --git a/.ddd/seams/seam-rust-ledger-cli-src-commands-evolve-rs.yaml b/.ddd/seams/seam-rust-ledger-cli-src-commands-evolve-rs.yaml new file mode 100644 index 0000000..a87f275 --- /dev/null +++ b/.ddd/seams/seam-rust-ledger-cli-src-commands-evolve-rs.yaml @@ -0,0 +1,44 @@ +format: 2 +id: seam/rust/ledger-cli-src-commands-evolve-rs +boundary: contract surface of ledger-cli/src/commands/evolve.rs +verdict_knowledge: "A caller learns that --revisit-if and --no-revisit-if state opposite things and are refused together, so a revision cannot both set and clear the reopen edges." +contract_location: ledger-cli/src/commands/evolve.rs +obligations: [] +metadata: {} +bindings: +- symbol: revise + file: ledger-cli/src/commands/evolve.rs + before: sha256:b6d1bc97f9004dc47765580d0d35732b1cc2bf9346dfbecd94d71244677d860f + after: sha256:ce7e3e017469f42db24e089a68f8f8bc667e4eddaf03175f65e46c2654d2ad21 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:6b79c21af63352aa7da168f23d30ff2267dae88ab64e7e8e0e75c8c5b20a6c92 +- symbol: ReviseEdges + file: ledger-cli/src/commands/evolve.rs + before: sha256:b6d1bc97f9004dc47765580d0d35732b1cc2bf9346dfbecd94d71244677d860f + after: sha256:ce7e3e017469f42db24e089a68f8f8bc667e4eddaf03175f65e46c2654d2ad21 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:6a775666b394256f3b8f3bd504004722be2e14a9f829424bc143e8d22ed3f9d2 +- symbol: based_on + file: ledger-cli/src/commands/evolve.rs + before: sha256:b6d1bc97f9004dc47765580d0d35732b1cc2bf9346dfbecd94d71244677d860f + after: sha256:ce7e3e017469f42db24e089a68f8f8bc667e4eddaf03175f65e46c2654d2ad21 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:21cd681005d4cba8d330615a654f34a46b8b4593f9aef1f8129cf628de2322e8 +- symbol: no_revisit_if + file: ledger-cli/src/commands/evolve.rs + before: sha256:b6d1bc97f9004dc47765580d0d35732b1cc2bf9346dfbecd94d71244677d860f + after: sha256:ce7e3e017469f42db24e089a68f8f8bc667e4eddaf03175f65e46c2654d2ad21 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:76159f66a14f49f2dd6c9b807fdab83feb4b308b35b29d2424d6aea00bedb6fc +- symbol: note + file: ledger-cli/src/commands/evolve.rs + before: sha256:b6d1bc97f9004dc47765580d0d35732b1cc2bf9346dfbecd94d71244677d860f + after: sha256:ce7e3e017469f42db24e089a68f8f8bc667e4eddaf03175f65e46c2654d2ad21 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:99419b0184171b84c121195a00f964760e853b40ad0ee055bc359766a6d92cb6 +- symbol: revisit_if + file: ledger-cli/src/commands/evolve.rs + before: sha256:b6d1bc97f9004dc47765580d0d35732b1cc2bf9346dfbecd94d71244677d860f + after: sha256:ce7e3e017469f42db24e089a68f8f8bc667e4eddaf03175f65e46c2654d2ad21 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:a36d023355750071ee59340913a821accfecec7ea9ab419b824d4b8bbeef6a36 diff --git a/.ddd/seams/seam-rust-ledger-cli-src-commands-inspect-rs.yaml b/.ddd/seams/seam-rust-ledger-cli-src-commands-inspect-rs.yaml new file mode 100644 index 0000000..118d920 --- /dev/null +++ b/.ddd/seams/seam-rust-ledger-cli-src-commands-inspect-rs.yaml @@ -0,0 +1,50 @@ +format: 2 +id: seam/rust/ledger-cli-src-commands-inspect-rs +boundary: contract surface of ledger-cli/src/commands/inspect.rs +verdict_knowledge: "A caller learns that show takes exactly one selector and refuses two, so a pass never silently covers more than was asked for, and that a named id with no filed version is an error rather than an empty screen — the one outcome an acceptance pass must not mistake for nothing to sign." +contract_location: ledger-cli/src/commands/inspect.rs +obligations: [] +metadata: {} +bindings: +- symbol: show + file: ledger-cli/src/commands/inspect.rs + before: sha256:476951f002e222c357c94494aee8e36779fff0116c31eb1d52b13c9a62b06bd7 + after: sha256:9d9732d3284a0d9115902a4fa471b6a61a090129b5c9bfebfdf462573ef7ed84 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:a855d19a45ccbc946295245f409499923b7604d7a08a348ba84f9882eeb418b1 +- symbol: ShowFlags + file: ledger-cli/src/commands/inspect.rs + before: sha256:476951f002e222c357c94494aee8e36779fff0116c31eb1d52b13c9a62b06bd7 + after: sha256:9d9732d3284a0d9115902a4fa471b6a61a090129b5c9bfebfdf462573ef7ed84 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:5add536cc599ec2ba4fa588b8a22645b31146249c7194d56a98679250ce5f772 +- symbol: decision + file: ledger-cli/src/commands/inspect.rs + before: sha256:476951f002e222c357c94494aee8e36779fff0116c31eb1d52b13c9a62b06bd7 + after: sha256:9d9732d3284a0d9115902a4fa471b6a61a090129b5c9bfebfdf462573ef7ed84 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:40ef767792d5567cae7eb16f259bdac65660889c96e59c1de9e09ae607523167 +- symbol: group + file: ledger-cli/src/commands/inspect.rs + before: sha256:476951f002e222c357c94494aee8e36779fff0116c31eb1d52b13c9a62b06bd7 + after: sha256:9d9732d3284a0d9115902a4fa471b6a61a090129b5c9bfebfdf462573ef7ed84 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:a7a1f7eb74c8c13e6c7babe4c2db36e437a2aef31664c9bb7354cba72bce5c5d +- symbol: json + file: ledger-cli/src/commands/inspect.rs + before: sha256:476951f002e222c357c94494aee8e36779fff0116c31eb1d52b13c9a62b06bd7 + after: sha256:9d9732d3284a0d9115902a4fa471b6a61a090129b5c9bfebfdf462573ef7ed84 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:4ffcb803602521374d912dfc67f838adf316a314c4811fd82fb37a63063a8aea +- symbol: set + file: ledger-cli/src/commands/inspect.rs + before: sha256:476951f002e222c357c94494aee8e36779fff0116c31eb1d52b13c9a62b06bd7 + after: sha256:9d9732d3284a0d9115902a4fa471b6a61a090129b5c9bfebfdf462573ef7ed84 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:04cbd1203130f2642a0eeebb4bc2dc9d8ff907900290e178593d2b963f26d9e2 +- symbol: today + file: ledger-cli/src/commands/inspect.rs + before: sha256:476951f002e222c357c94494aee8e36779fff0116c31eb1d52b13c9a62b06bd7 + after: sha256:9d9732d3284a0d9115902a4fa471b6a61a090129b5c9bfebfdf462573ef7ed84 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:a3ac516cf87fdcaafbb56cc31d7cabd375566c7a8716964786f9251563a71630 diff --git a/.ddd/seams/seam-rust-ledger-cli-src-commands-mod-rs.yaml b/.ddd/seams/seam-rust-ledger-cli-src-commands-mod-rs.yaml new file mode 100644 index 0000000..7163aee --- /dev/null +++ b/.ddd/seams/seam-rust-ledger-cli-src-commands-mod-rs.yaml @@ -0,0 +1,14 @@ +format: 2 +id: seam/rust/ledger-cli-src-commands-mod-rs +boundary: contract surface of ledger-cli/src/commands/mod.rs +verdict_knowledge: "A caller learns the dispatch is split along the line the store draws — verbs that append to the log, reads that never touch it — and that a variant wired into neither half is reported rather than panicked." +contract_location: ledger-cli/src/commands/mod.rs +obligations: [] +metadata: {} +bindings: +- symbol: Show + file: ledger-cli/src/commands/mod.rs + before: sha256:c7b64817eadd35083a0d7d679a8c5c81d1702a2b0d0141d2894810e9a96fd25a + after: sha256:8b4b19c07692c320df8f40eb3bdc5900bd88d727fd44e73228d806818935c00e + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:5bf93076f9aa528629225b22b0b4ab52c49b81ff42197f47b9946123921bc2f3 diff --git a/.ddd/seams/seam-rust-ledger-core-src-author-decision-rs.yaml b/.ddd/seams/seam-rust-ledger-core-src-author-decision-rs.yaml index a660291..beb1ef7 100644 --- a/.ddd/seams/seam-rust-ledger-core-src-author-decision-rs.yaml +++ b/.ddd/seams/seam-rust-ledger-core-src-author-decision-rs.yaml @@ -1,7 +1,7 @@ format: 2 id: seam/rust/ledger-core-src-author-decision-rs boundary: contract surface of ledger-core/src/author/decision.rs -verdict_knowledge: 'Authoring callers learn an add can carry a change-set note and that the written file declares its needed format — the migration''s per-entry provenance rides this.' +verdict_knowledge: Authoring callers learn an add can carry a change-set note and that the written file declares its needed format — the migration's per-entry provenance rides this. contract_location: ledger-core/src/author/decision.rs obligations: [] metadata: {} @@ -12,3 +12,9 @@ bindings: after: sha256:73022db1d32437eeb66ef5a43f33765f6563e65626c0946ded3ed38f564cab34 base_revision: 6d3dff57e80514b7b4665ef38b364730c53201a3 hash: sha256:1a382b72b0250aeb28b6fcd35f6c1b92e8953d1db3665e3ce65ea745910244f3 +- symbol: revisit_if + file: ledger-core/src/author/decision.rs + before: sha256:73022db1d32437eeb66ef5a43f33765f6563e65626c0946ded3ed38f564cab34 + after: sha256:f38f96caafd08da1fe5b89a00fc14de94a176058f54e58716805ed44bf141ae2 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:3697f53f7fe4e3ef954cb0f36a688b683b8031d0d1b088aacc19727bc1071038 diff --git a/.ddd/seams/seam-rust-ledger-core-src-author-version-ops-rs.yaml b/.ddd/seams/seam-rust-ledger-core-src-author-version-ops-rs.yaml new file mode 100644 index 0000000..2e4d5ba --- /dev/null +++ b/.ddd/seams/seam-rust-ledger-core-src-author-version-ops-rs.yaml @@ -0,0 +1,20 @@ +format: 2 +id: seam/rust/ledger-core-src-author-version-ops-rs +boundary: contract surface of ledger-core/src/author/version_ops.rs +verdict_knowledge: "A caller learns that revise states its reopen edges explicitly — None leaves the inherited set alone, Some(empty) clears it — so re-typing an edge is an act a reader can see rather than a silent reclassification, and that a revision carries a note for why." +contract_location: ledger-core/src/author/version_ops.rs +obligations: [] +metadata: {} +bindings: +- symbol: note + file: ledger-core/src/author/version_ops.rs + before: sha256:b3fe0c55cd025a80e54361a9d3a7126387cf1270afe8e8d5b5857a32be56b8b9 + after: sha256:a005b0e439e9cf9e6c0d57d438c2c462d51ac3bb9a71a875a6716c8f645f6d27 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:1e788db35686f854a0a04cd3abb03f9854caa515b978946a363b23c92843b449 +- symbol: revisit_if + file: ledger-core/src/author/version_ops.rs + before: sha256:b3fe0c55cd025a80e54361a9d3a7126387cf1270afe8e8d5b5857a32be56b8b9 + after: sha256:a005b0e439e9cf9e6c0d57d438c2c462d51ac3bb9a71a875a6716c8f645f6d27 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:5e9522f5f1211954e433dd607f417336a5ac7813d93aec16f7446922ec17105d diff --git a/.ddd/seams/seam-rust-ledger-core-src-format-rs.yaml b/.ddd/seams/seam-rust-ledger-core-src-format-rs.yaml index 5871d32..61e1418 100644 --- a/.ddd/seams/seam-rust-ledger-core-src-format-rs.yaml +++ b/.ddd/seams/seam-rust-ledger-core-src-format-rs.yaml @@ -18,3 +18,9 @@ bindings: after: sha256:cce9de3208cd6c1b4f5bb81f83eeca707814d51053dc8763531c601f86c62181 base_revision: 6d3dff57e80514b7b4665ef38b364730c53201a3 hash: sha256:cfd38c2467e3e762941c7a1960e6c9688edf5364457b8ac5a3633aa417348dbd +- symbol: REVISIT_FORMAT + file: ledger-core/src/format.rs + before: sha256:cce9de3208cd6c1b4f5bb81f83eeca707814d51053dc8763531c601f86c62181 + after: sha256:e9029de89b7816bb89cfd8e0e7ad2192e583f030f4892f62ea7bd30d1727369a + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:e2b23292d3b2ee1dd7665c6402b5e2c01afadde05806be39eb004c674d8d85cb diff --git a/.ddd/seams/seam-rust-ledger-core-src-lib-rs.yaml b/.ddd/seams/seam-rust-ledger-core-src-lib-rs.yaml new file mode 100644 index 0000000..7471eb3 --- /dev/null +++ b/.ddd/seams/seam-rust-ledger-core-src-lib-rs.yaml @@ -0,0 +1,20 @@ +format: 2 +id: seam/rust/ledger-core-src-lib-rs +boundary: contract surface of ledger-core/src/lib.rs +verdict_knowledge: "A consumer learns which modules are the crate's surface; revisit and show join it at spec v1.5, and neither pulls in an ontology — the substrate an outside implementation imports stays ddd-free." +contract_location: ledger-core/src/lib.rs +obligations: [] +metadata: {} +bindings: +- symbol: revisit + file: ledger-core/src/lib.rs + before: sha256:4497ab959ef5de9f0cf5cc3166d0bb34eb34ab469b5bd442cdb799fe6347782e + after: sha256:e975aaeacbaa8d69f774e4084027dd91f7826cb5dbd9bd8d3f7c5451d7729cfd + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:a5c24c6c0e9fc7524e0605b50601e78db7a7642e42765895b8f7930286f95208 +- symbol: show + file: ledger-core/src/lib.rs + before: sha256:4497ab959ef5de9f0cf5cc3166d0bb34eb34ab469b5bd442cdb799fe6347782e + after: sha256:e975aaeacbaa8d69f774e4084027dd91f7826cb5dbd9bd8d3f7c5451d7729cfd + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:265e9103bf53f77ebff282df9389a6e484e069b85ad7666058ad6ce3605aa060 diff --git a/.ddd/seams/seam-rust-ledger-core-src-revisit-rs.yaml b/.ddd/seams/seam-rust-ledger-core-src-revisit-rs.yaml new file mode 100644 index 0000000..c09ce86 --- /dev/null +++ b/.ddd/seams/seam-rust-ledger-core-src-revisit-rs.yaml @@ -0,0 +1,50 @@ +format: 2 +id: seam/rust/ledger-core-src-revisit-rs +boundary: contract surface of ledger-core/src/revisit.rs +verdict_knowledge: "A caller learns that a reopen pointer is its own type, RevisitRef, and cannot be passed where a basis is expected — the 2026-08-13 ruling made structural rather than conventional. It also learns what the type does not do: the vocabulary is open at L0, exactly as based_on's is, so nothing here resolves a pointer or proves the claim it names exists." +contract_location: ledger-core/src/revisit.rs +obligations: [] +metadata: {} +bindings: +- symbol: impl RevisitRef + file: ledger-core/src/revisit.rs + before: absent + after: sha256:0ffba08454f03816b885b8cd1a87827abc8b5acc628fe4fd933566e47879c258 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:da6bcd57b4e2558994386dfe32130de0d5f1673ab2cf4604649092dd5cb688d0 +- symbol: RevisitRef + file: ledger-core/src/revisit.rs + before: absent + after: sha256:0ffba08454f03816b885b8cd1a87827abc8b5acc628fe4fd933566e47879c258 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:10138b020fd4630c062e204ffb572046f0e69e768fba6aad653acaafc7efdab6 +- symbol: impl Deserialize<'de> for RevisitRef + file: ledger-core/src/revisit.rs + before: absent + after: sha256:0ffba08454f03816b885b8cd1a87827abc8b5acc628fe4fd933566e47879c258 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:4e23bc8c2097c037e04026adac8a18e377f8e87494e0ec64a604f0f9e280b0a5 +- symbol: impl FromStr for RevisitRef + file: ledger-core/src/revisit.rs + before: absent + after: sha256:0ffba08454f03816b885b8cd1a87827abc8b5acc628fe4fd933566e47879c258 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:00d6dfb0334189eb15b1a6618e45b16e480974f4861e271c89e88416b5c1f01d +- symbol: impl Serialize for RevisitRef + file: ledger-core/src/revisit.rs + before: absent + after: sha256:0ffba08454f03816b885b8cd1a87827abc8b5acc628fe4fd933566e47879c258 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:4d7a04fdc29160c7fe013146d537109366bec20cb9490b365b8f10959f5bf68d +- symbol: impl fmt::Display for RevisitRef + file: ledger-core/src/revisit.rs + before: absent + after: sha256:0ffba08454f03816b885b8cd1a87827abc8b5acc628fe4fd933566e47879c258 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:4e2c0d14cb7de18c93d19e61e54d3af37d2f5b967fc59c5ae18b8cfcd87a7fe6 +- symbol: as_str + file: ledger-core/src/revisit.rs + before: absent + after: sha256:0ffba08454f03816b885b8cd1a87827abc8b5acc628fe4fd933566e47879c258 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:af56fc81fd0b526bd081555fc13ee60ee63f27cff6bc1963a03ee91f17333746 diff --git a/.ddd/seams/seam-rust-ledger-core-src-show-render-rs.yaml b/.ddd/seams/seam-rust-ledger-core-src-show-render-rs.yaml new file mode 100644 index 0000000..1a2ea79 --- /dev/null +++ b/.ddd/seams/seam-rust-ledger-core-src-show-render-rs.yaml @@ -0,0 +1,20 @@ +format: 2 +id: seam/rust/ledger-core-src-show-render-rs +boundary: contract surface of ledger-core/src/show_render.rs +verdict_knowledge: "A reader learns that the two edge kinds render as separate blocks with each pointer's argument indented beneath it, and that an unresolved argument leaves the pointer standing rather than a blank line implying there is nothing to read." +contract_location: ledger-core/src/show_render.rs +obligations: [] +metadata: {} +bindings: +- symbol: render + file: ledger-core/src/show_render.rs + before: absent + after: sha256:2a27a09eeeb8c2bea89757f5c55be58a5184ba06de4fc8b0ed51cfb9cb498aee + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:fd34234e3b9782d90323e8aad05560f680788797a9e99f924448b00e30a2bd2a +- symbol: render_all + file: ledger-core/src/show_render.rs + before: absent + after: sha256:2a27a09eeeb8c2bea89757f5c55be58a5184ba06de4fc8b0ed51cfb9cb498aee + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:acb9f6fc267f7b9f809eff799e6c9db04e56eda407681666bdfc8eeec2385bbe diff --git a/.ddd/seams/seam-rust-ledger-core-src-show-rs.yaml b/.ddd/seams/seam-rust-ledger-core-src-show-rs.yaml new file mode 100644 index 0000000..1d3fa7e --- /dev/null +++ b/.ddd/seams/seam-rust-ledger-core-src-show-rs.yaml @@ -0,0 +1,332 @@ +format: 2 +id: seam/rust/ledger-core-src-show-rs +boundary: contract surface of ledger-core/src/show.rs +verdict_knowledge: "A caller learns that a decision screen is assembled from the same View the gate reads, so show can never disagree with verify about state; that a selector (one id, a set, a weight class) covers a pass in one traversal; and that the weight class is derived from the discharge rather than listed, so it cannot go stale. It also learns the limit: ledger-core resolves no basis argument itself — it takes a BasisText resolver as an input, and with NoText the pointers render bare." +contract_location: ledger-core/src/show.rs +obligations: [] +metadata: {} +bindings: +- symbol: Group + file: ledger-core/src/show.rs + before: absent + after: sha256:e76012c29efe9e0b3768d3e19e0e6011727cb73fec1fb16ef06b11431fc74d10 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:93de3a2f3d19d6d795f492d51286084907f937966a269518e7c1958a1c214e2f +- symbol: Selector + file: ledger-core/src/show.rs + before: absent + after: sha256:e76012c29efe9e0b3768d3e19e0e6011727cb73fec1fb16ef06b11431fc74d10 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:9f0f58cde5f8d613074dd81ad8b0521e58d8194f1be43351a44d1c8e2124e954 +- symbol: screen + file: ledger-core/src/show.rs + before: absent + after: sha256:e76012c29efe9e0b3768d3e19e0e6011727cb73fec1fb16ef06b11431fc74d10 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:2618f13d812be09e8e6b2193264aec35ac40015000f4e5530162b38908f4699e +- symbol: screens + file: ledger-core/src/show.rs + before: absent + after: sha256:e76012c29efe9e0b3768d3e19e0e6011727cb73fec1fb16ef06b11431fc74d10 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:8369cf3d934c42a42a42c841b9b31fa522e4c9d31f7f043e998d3b4e386c67af +- symbol: impl Group + file: ledger-core/src/show.rs + before: absent + after: sha256:e76012c29efe9e0b3768d3e19e0e6011727cb73fec1fb16ef06b11431fc74d10 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:c6b31d1e578abc46d22ab10833cbf7de2fb8ff6563636d5b2747f3aa2a82f88c +- symbol: AcceptanceLine + file: ledger-core/src/show.rs + before: absent + after: sha256:e76012c29efe9e0b3768d3e19e0e6011727cb73fec1fb16ef06b11431fc74d10 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:d4b18af43f02f4d3d431e449a114b702e6a733d760a24100371fb5e75672794b +- symbol: Edge + file: ledger-core/src/show.rs + before: absent + after: sha256:e76012c29efe9e0b3768d3e19e0e6011727cb73fec1fb16ef06b11431fc74d10 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:ebd74373f32bf804ec0c7d993a61e6edd1e3d4602d46a1ea4893df07cde69018 +- symbol: Filing + file: ledger-core/src/show.rs + before: absent + after: sha256:e76012c29efe9e0b3768d3e19e0e6011727cb73fec1fb16ef06b11431fc74d10 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:a23f8ea371a2722d7679d1e4ed2a3f2daa224086a4f03bdccc1e2836fad2de80 +- symbol: NoText + file: ledger-core/src/show.rs + before: absent + after: sha256:e76012c29efe9e0b3768d3e19e0e6011727cb73fec1fb16ef06b11431fc74d10 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:32b503615349446dc72306d63c447b0de340c7d76098234a1b29bdf43a1a5f3e +- symbol: Screen + file: ledger-core/src/show.rs + before: absent + after: sha256:e76012c29efe9e0b3768d3e19e0e6011727cb73fec1fb16ef06b11431fc74d10 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:621501e638a6ee4dc13401a86ca2f7c1c361f150241f6e492453619d5eece422 +- symbol: BasisText + file: ledger-core/src/show.rs + before: absent + after: sha256:e76012c29efe9e0b3768d3e19e0e6011727cb73fec1fb16ef06b11431fc74d10 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:a2bb55722cf54643aa2384fb3b5eb38e1fa8e98851e6dbe54f2c0c3ca9068d26 +- symbol: impl BasisText for NoText + file: ledger-core/src/show.rs + before: absent + after: sha256:e76012c29efe9e0b3768d3e19e0e6011727cb73fec1fb16ef06b11431fc74d10 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:1c9f66ea686859b0c2c01a929b542ce632753bbc4fb9402954de3b40f5136619 +- symbol: actor + file: ledger-core/src/show.rs + before: absent + after: sha256:e76012c29efe9e0b3768d3e19e0e6011727cb73fec1fb16ef06b11431fc74d10 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:8e3909c91b5e4ceba1f4f2a286439f6472e82daf8d04629a702c5a3ba987b2d0 +- symbol: at + file: ledger-core/src/show.rs + before: absent + after: sha256:e76012c29efe9e0b3768d3e19e0e6011727cb73fec1fb16ef06b11431fc74d10 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:428d7e561186243e4a9215ca912fb824c11c1ec6ca82514309dfe5f1fa539762 +- symbol: expires_at + file: ledger-core/src/show.rs + before: absent + after: sha256:e76012c29efe9e0b3768d3e19e0e6011727cb73fec1fb16ef06b11431fc74d10 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:9d401ab2619717ebb084042cc732045b82ee5cc5091b38068c0d2b158dd6cdbd +- symbol: id + file: ledger-core/src/show.rs + before: absent + after: sha256:e76012c29efe9e0b3768d3e19e0e6011727cb73fec1fb16ef06b11431fc74d10 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:8c1064b1bfa072f5052ff6f525e18d4728e3c4b0b86eb5bb71968a67da612956 +- symbol: standing + file: ledger-core/src/show.rs + before: absent + after: sha256:e76012c29efe9e0b3768d3e19e0e6011727cb73fec1fb16ef06b11431fc74d10 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:34b9144c44e1233ddc7d9eddba137c649ffbd3c293cb79b4eadeb6ecc454de5c +- symbol: version + file: ledger-core/src/show.rs + before: absent + after: sha256:e76012c29efe9e0b3768d3e19e0e6011727cb73fec1fb16ef06b11431fc74d10 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:cbda181bbcd652a8f3b8c5b2d127ab27cd553dc761445c223d1985208686c2c9 +- symbol: text + file: ledger-core/src/show.rs + before: absent + after: sha256:e76012c29efe9e0b3768d3e19e0e6011727cb73fec1fb16ef06b11431fc74d10 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:fadfc67ea705c688e42763b87fc88d986153f2d0a0d68471682f81a84fdb595f +- symbol: argument + file: ledger-core/src/show.rs + before: absent + after: sha256:e76012c29efe9e0b3768d3e19e0e6011727cb73fec1fb16ef06b11431fc74d10 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:5d48520f242f5c320cab2c1dd3169cb060b4523cdbe7e70367762b3d9aea52c3 +- symbol: pointer + file: ledger-core/src/show.rs + before: absent + after: sha256:e76012c29efe9e0b3768d3e19e0e6011727cb73fec1fb16ef06b11431fc74d10 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:b83758a1f0314d02f07b5252890f5364da57458767f948d07ce9800b1b3ca186 +- symbol: by + file: ledger-core/src/show.rs + before: absent + after: sha256:e76012c29efe9e0b3768d3e19e0e6011727cb73fec1fb16ef06b11431fc74d10 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:06fc4758ae5207659aeefa79db474e42a2d0b17834deea556f423ef720208f40 +- symbol: change_set + file: ledger-core/src/show.rs + before: absent + after: sha256:e76012c29efe9e0b3768d3e19e0e6011727cb73fec1fb16ef06b11431fc74d10 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:8846ca5a3bc0b773fa40704ab063c7740b2dddf603085fdad3a270128fa420c5 +- symbol: note + file: ledger-core/src/show.rs + before: absent + after: sha256:e76012c29efe9e0b3768d3e19e0e6011727cb73fec1fb16ef06b11431fc74d10 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:30035a313fced3562bd405c8d19a54b33f92a6035fef71fb5666b17e8eb5dc0e +- symbol: Individual + file: ledger-core/src/show.rs + before: absent + after: sha256:e76012c29efe9e0b3768d3e19e0e6011727cb73fec1fb16ef06b11431fc74d10 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:23b4eaaa7471b7de67da416006b318ac68b31b42c995947005fbd70b4e4c272a +- symbol: Mechanical + file: ledger-core/src/show.rs + before: absent + after: sha256:e76012c29efe9e0b3768d3e19e0e6011727cb73fec1fb16ef06b11431fc74d10 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:b47fef793cedf0d489d9d42c1637a1b2bc3efc2fb9c53ea356d80452d469afae +- symbol: acceptances + file: ledger-core/src/show.rs + before: absent + after: sha256:e76012c29efe9e0b3768d3e19e0e6011727cb73fec1fb16ef06b11431fc74d10 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:396ce8f9b77e57e189f8b7732710cee4e3a0d17ebd76d7c19496aaea248ffa8d +- symbol: allocation + file: ledger-core/src/show.rs + before: absent + after: sha256:e76012c29efe9e0b3768d3e19e0e6011727cb73fec1fb16ef06b11431fc74d10 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:6d4f43ed08555a97862d4ebcc03732b7c8f260a3b8bfe8cefd309b3bed8be428 +- symbol: based_on + file: ledger-core/src/show.rs + before: absent + after: sha256:e76012c29efe9e0b3768d3e19e0e6011727cb73fec1fb16ef06b11431fc74d10 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:4fb6d3dcc1535cf20536f688ac2adaba75905113cb243b9a7578f1f6732c94cb +- symbol: decision + file: ledger-core/src/show.rs + before: absent + after: sha256:e76012c29efe9e0b3768d3e19e0e6011727cb73fec1fb16ef06b11431fc74d10 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:ee7034943866df67d3672c36b19f7f72cf09da1547d7bee3bd03b953230013cf +- symbol: discharge + file: ledger-core/src/show.rs + before: absent + after: sha256:e76012c29efe9e0b3768d3e19e0e6011727cb73fec1fb16ef06b11431fc74d10 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:50511843915c3fd704f62097279f9259b71188ee081a130c848f5b3bf9ee8176 +- symbol: discharge_stage + file: ledger-core/src/show.rs + before: absent + after: sha256:e76012c29efe9e0b3768d3e19e0e6011727cb73fec1fb16ef06b11431fc74d10 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:a31f60e8a3730a0dd3aa1c27aca2d883299fceda265a362f00b49d5b32335670 +- symbol: exposure + file: ledger-core/src/show.rs + before: absent + after: sha256:e76012c29efe9e0b3768d3e19e0e6011727cb73fec1fb16ef06b11431fc74d10 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:684addebba5896136af317ec09ad81b9a59ea6b840df6a9fddac4eac0cfadcb0 +- symbol: filed + file: ledger-core/src/show.rs + before: absent + after: sha256:e76012c29efe9e0b3768d3e19e0e6011727cb73fec1fb16ef06b11431fc74d10 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:f2a413af27b76cba8a78c1d0ac0d2c89ac735452a9ca3d15be63f2d7e1c4e361 +- symbol: forked + file: ledger-core/src/show.rs + before: absent + after: sha256:e76012c29efe9e0b3768d3e19e0e6011727cb73fec1fb16ef06b11431fc74d10 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:379f1e79987afb1d9caba947172059893179d35b7b113dbacc50f188f0479d5f +- symbol: group + file: ledger-core/src/show.rs + before: absent + after: sha256:e76012c29efe9e0b3768d3e19e0e6011727cb73fec1fb16ef06b11431fc74d10 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:3126a4244e51f2c8040dd868341d76534106d3bd409e694de2064ccf007b34aa +- symbol: hash + file: ledger-core/src/show.rs + before: absent + after: sha256:e76012c29efe9e0b3768d3e19e0e6011727cb73fec1fb16ef06b11431fc74d10 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:4dc8f6e6d1e3ffd428bdcdf3292fc1446e4b1908dd182bc0c6cf867b0b080663 +- symbol: parent + file: ledger-core/src/show.rs + before: absent + after: sha256:e76012c29efe9e0b3768d3e19e0e6011727cb73fec1fb16ef06b11431fc74d10 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:c420fe55db54c6be65d89efd438c7ae1c7e922d9f38fddfceedb4ec0d31fdff1 +- symbol: review_by + file: ledger-core/src/show.rs + before: absent + after: sha256:e76012c29efe9e0b3768d3e19e0e6011727cb73fec1fb16ef06b11431fc74d10 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:05e6a4b0380fdebf23746c9d6ebbffdfa399199ce55d9c3a7db0a497e5437017 +- symbol: revisit_if + file: ledger-core/src/show.rs + before: absent + after: sha256:e76012c29efe9e0b3768d3e19e0e6011727cb73fec1fb16ef06b11431fc74d10 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:7d34b0a4a7089375752b4bc719e2e42a9cfca2749d8add3360bc25f5600ba48b +- symbol: set + file: ledger-core/src/show.rs + before: absent + after: sha256:e76012c29efe9e0b3768d3e19e0e6011727cb73fec1fb16ef06b11431fc74d10 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:0fcb5072ccad0839e139b40624321e01722c1e5c55eff6cc7ffc21fb4c715976 +- symbol: set_floor + file: ledger-core/src/show.rs + before: absent + after: sha256:e76012c29efe9e0b3768d3e19e0e6011727cb73fec1fb16ef06b11431fc74d10 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:34482f5f57679cf2515f9f781b5054118acba7fdaa38f9910bcf2b52bbc63cc5 +- symbol: state + file: ledger-core/src/show.rs + before: absent + after: sha256:e76012c29efe9e0b3768d3e19e0e6011727cb73fec1fb16ef06b11431fc74d10 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:49a62d5286544adad71ba732cc5af528193e551d3fdc37605073970b0c227070 +- symbol: statement + file: ledger-core/src/show.rs + before: absent + after: sha256:e76012c29efe9e0b3768d3e19e0e6011727cb73fec1fb16ef06b11431fc74d10 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:ac58b45e421a71b655895760c1db207e4c25bb84c72a8d125050a9bfb5ac757a +- symbol: superseded_by + file: ledger-core/src/show.rs + before: absent + after: sha256:e76012c29efe9e0b3768d3e19e0e6011727cb73fec1fb16ef06b11431fc74d10 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:234f78f4646d6a9ee9797ef67bb4db981671c947843467d9f987b80fcc73368d +- symbol: supersedes + file: ledger-core/src/show.rs + before: absent + after: sha256:e76012c29efe9e0b3768d3e19e0e6011727cb73fec1fb16ef06b11431fc74d10 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:71058e2803ef6d4f3936a66b82d757b8b649c8a3fe5f961a12f5d0cd7a12e7d7 +- symbol: tolerance_floor_at_creation + file: ledger-core/src/show.rs + before: absent + after: sha256:e76012c29efe9e0b3768d3e19e0e6011727cb73fec1fb16ef06b11431fc74d10 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:a94c25381441c5cfc314e60850a6be4616deff621c88326b5f2f15dcfc6a67ea +- symbol: tolerance_override + file: ledger-core/src/show.rs + before: absent + after: sha256:e76012c29efe9e0b3768d3e19e0e6011727cb73fec1fb16ef06b11431fc74d10 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:f6ef5b6f3a7cb623602c06b853f29a09307979f838516a9be733acf352492ca5 +- symbol: versions_in_chain + file: ledger-core/src/show.rs + before: absent + after: sha256:e76012c29efe9e0b3768d3e19e0e6011727cb73fec1fb16ef06b11431fc74d10 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:ccec18ee09028023c3ec25124fd74b24a5269f7a3e63ed4bf2f17f821aa69639 +- symbol: All + file: ledger-core/src/show.rs + before: absent + after: sha256:e76012c29efe9e0b3768d3e19e0e6011727cb73fec1fb16ef06b11431fc74d10 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:51e26a11cb31b374fa5d409a83b343b4b8b719da895ee0ec350d4e9274703c5f +- symbol: One + file: ledger-core/src/show.rs + before: absent + after: sha256:e76012c29efe9e0b3768d3e19e0e6011727cb73fec1fb16ef06b11431fc74d10 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:c188658b52f32afe9265cd43c9b6dda97e6dd5eb55bed26c98d800e423233ad3 +- symbol: Set + file: ledger-core/src/show.rs + before: absent + after: sha256:e76012c29efe9e0b3768d3e19e0e6011727cb73fec1fb16ef06b11431fc74d10 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:8b6a541a05237096867caaa0ec6873eb178b0232522e838e052b6a9048c1d27f +- symbol: as_str + file: ledger-core/src/show.rs + before: absent + after: sha256:e76012c29efe9e0b3768d3e19e0e6011727cb73fec1fb16ef06b11431fc74d10 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:cae79e6c89ef5e7790f2b9c7a6371d6dcaa67800a6ab47ba4e428fdbc38f9aba +- symbol: parse + file: ledger-core/src/show.rs + before: absent + after: sha256:e76012c29efe9e0b3768d3e19e0e6011727cb73fec1fb16ef06b11431fc74d10 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:616edfb5f65a21cdbcf2133b52e694f58e433393139381e330162e1b1a574b58 diff --git a/.ddd/seams/seam-rust-ledger-core-src-version-rs.yaml b/.ddd/seams/seam-rust-ledger-core-src-version-rs.yaml new file mode 100644 index 0000000..d10b2a6 --- /dev/null +++ b/.ddd/seams/seam-rust-ledger-core-src-version-rs.yaml @@ -0,0 +1,14 @@ +format: 2 +id: seam/rust/ledger-core-src-version-rs +boundary: contract surface of ledger-core/src/version.rs +verdict_knowledge: "A caller learns a version carries two edge lists, based_on and revisit_if, that never merge: ground and tripwire are separate fields on the wire, separate keys in the hash, and separate types. A caller reading either as the other would report a fired tripwire as a lost basis." +contract_location: ledger-core/src/version.rs +obligations: [] +metadata: {} +bindings: +- symbol: revisit_if + file: ledger-core/src/version.rs + before: sha256:9ac2885d62aea15ca6a9a778496b8a163afd424d3ce1d74da8bbc5d4714f6b43 + after: sha256:97dee56cb7c9c9f54ad96856ab0659d80efe67036a26a5a45bb39d0d5dd28023 + base_revision: 15c635210dca8246aae371460e167ad7d293c2e6 + hash: sha256:bc8027d153a6d491c11ec8bb32b48ad3e1ca8a9ef7204c42a93080ed3839390a diff --git a/.decisions/log/01KZX70EMPA47TBR0PFKX4M32Z.yml b/.decisions/log/01KZX70EMPA47TBR0PFKX4M32Z.yml new file mode 100644 index 0000000..d361b7b --- /dev/null +++ b/.decisions/log/01KZX70EMPA47TBR0PFKX4M32Z.yml @@ -0,0 +1,19 @@ +format: 1 +id: cs:01KZX70EMPA47TBR0PFKX4M32Z +created_at: 2026-08-13T09:24:46.862155863Z +created_by: noreply@anthropic.com +note: 'Edge re-typed per the principal''s ruling of 2026-08-13: a watched-not-grounding edge is a distinct edge type, not a basis. DDD-adapter-02 moves out of based_on and into revisit_if, pin carried across unchanged (projected@2026-08-06). The mandate is and remains the ground; the claim is the tripwire whose falsification reopens this decision. A re-decision of the edge, filed for the principal''s acceptance — not a silent rewrite. The ddd-content pin re-takes the .ddd entry at its new content.' +versions: +- decision: dec:hafeok.ddd/01KZTGGM6BRXDVPSDRNKW1B5YJ + parent: sha256:b161914784a00c2bf3c97e06c77c56aacde03c23d9954e51257c5ebfe8793b62 + hash: sha256:fe375194d566504902badd99444e720093993341bfb330c5c5e471c7fef2dd36 + set: ddd-governance + statement: internal is not C# contract surface by default; library repos flip per-repo + allocation: judgment + actor: emk@delegate.dk + tolerance_floor_at_creation: T1 + based_on: + - ddd-content:dec/ddd/internal-not-surface@sha256:c8f26768ba31e60044448e2d3bd6f56cc7841551411b095798141aea63fc1aea + - mandate:dec/ddd/internal-not-surface + revisit_if: + - claim:DDD-adapter-02@sha256:b333063d5058614855329b2dd8a1d1c5f8732ab243a2d2f9ebfa3bf95f2f5fda diff --git a/.decisions/log/01KZX70EQGQCB1B190TS9FZ1A2.yml b/.decisions/log/01KZX70EQGQCB1B190TS9FZ1A2.yml new file mode 100644 index 0000000..326da4a --- /dev/null +++ b/.decisions/log/01KZX70EQGQCB1B190TS9FZ1A2.yml @@ -0,0 +1,20 @@ +format: 1 +id: cs:01KZX70EQGQCB1B190TS9FZ1A2 +created_at: 2026-08-13T09:24:46.957125497Z +created_by: noreply@anthropic.com +note: 'Edge re-typed per the principal''s ruling of 2026-08-13: a watched-not-grounding edge is a distinct edge type, not a basis. DDD-gates-01 moves out of based_on and into revisit_if, pin carried across unchanged (reported@2026-08-06). The workspace error contract is and remains the ground; the claim is the cost check whose falsification reopens this decision. A re-decision of the edge, filed for the principal''s acceptance — not a silent rewrite. The ddd-content pin re-takes the .ddd entry at its new content.' +versions: +- decision: dec:hafeok.ddd/01KZTGGTKM7T8Y6GY78TGJRNB8 + parent: sha256:15fc86a5ad4771b6ca6449df2bf8f64c7b77dcbab28b67ede5ffa57b82681a0a + hash: sha256:edc9c0b4dcaaaf49ffde644926154e24c29766298e5527d1d2b27d922cb040eb + set: ddd-governance + statement: Deny clippy::unwrap_used workspace-wide + allocation: constraint + discharge: + - analyzer:clippy/clippy::unwrap_used + tolerance_floor_at_creation: T1 + based_on: + - ddd-content:dec/rust/no-unwrap@sha256:d89fed3d663ff16ee6f858a38747e963f4a861ebcc2bc9664080a58e389fdc8c + - constraint:dec/rust/no-unwrap + revisit_if: + - claim:DDD-gates-01@sha256:6f500ee8ef3bcbef7ce1a1d6ce2c093d61858f7a01bb3685b1c0f56aa32b4395 diff --git a/.decisions/log/01KZX70ET1GMR2012XKEP5EWDW.yml b/.decisions/log/01KZX70ET1GMR2012XKEP5EWDW.yml new file mode 100644 index 0000000..7c35bbe --- /dev/null +++ b/.decisions/log/01KZX70ET1GMR2012XKEP5EWDW.yml @@ -0,0 +1,19 @@ +format: 1 +id: cs:01KZX70ET1GMR2012XKEP5EWDW +created_at: 2026-08-13T09:24:47.037120989Z +created_by: noreply@anthropic.com +note: 'Edge re-typed per the principal''s ruling of 2026-08-13: a watched-not-grounding edge is a distinct edge type, not a basis. DDD-adapter-01 moves out of based_on and into revisit_if, pin carried across unchanged (projected@2026-08-02). This is the one of the three whose pin has drifted — the claim moved projected -> reported -> established — so it fires, and it now fires as a reopen finding rather than a basis-loss one. That is the correction: the ground here is the principal''s stated default and has never moved. A re-decision of the edge, filed for the principal''s acceptance.' +versions: +- decision: dec:hafeok.ddd/01KZTGGMPHCW14AH6AJW4YSZ5Z + parent: sha256:40a6bf92bf9436d21989c690dd6518ed0669c5b860cc58d1ed3cbf6dea3fbfac + hash: sha256:ecc4e597a4865d51aa1eec83c21796dcb64e9061c31b59d4e479405934e4f9f3 + set: ddd-governance + statement: M6 proceeds as the Rust adapter; the M6/M7 flip condition does not fire + allocation: judgment + actor: emk@delegate.dk + tolerance_floor_at_creation: T1 + based_on: + - ddd-content:dec/ddd/m6-proceeds-no-flip@sha256:3cf1f354823ec3d3850e1e6dc4d16ae5c86c06c6559b2ae4b2247e5f57f5331c + - mandate:dec/ddd/m6-proceeds-no-flip + revisit_if: + - claim:DDD-adapter-01@sha256:922a0a70f69509bfcd73325fbdc1cd5ac281b1b52281b14f56a8f519afd99f52 diff --git a/.decisions/log/01KZX70S86QGXVCA5GW5WSY6XA.yml b/.decisions/log/01KZX70S86QGXVCA5GW5WSY6XA.yml new file mode 100644 index 0000000..2b0fe3c --- /dev/null +++ b/.decisions/log/01KZX70S86QGXVCA5GW5WSY6XA.yml @@ -0,0 +1,17 @@ +format: 1 +id: cs:01KZX70S86QGXVCA5GW5WSY6XA +created_at: 2026-08-13T09:24:57.730449688Z +created_by: noreply@anthropic.com +note: 'The principal ruled the question this decision was filed to hold open (2026-08-13). The marker is not retired at the F-batch and is not an ordinary basis: it becomes revisit_if, specified in the ledger format at spec v1.5 / format 4 and adopted by .ddd at its format 7. The three migrated edges the M8 migration carried as provisional watched: markers were re-decided onto the real edge type, each filed for acceptance. This version records the ruling; the earlier version stated the open question and remains what it always said.' +versions: +- decision: dec:hafeok.ddd/01KZTGHF4B718YCRCKCGVM7XWA + parent: sha256:339d575ede35ff42fbbab0b836c07fc3a55de8f79c5c1a9035bc3b4fd40a2ebd + hash: sha256:719e8970c121e017bed9858dc31420732c9723ab85ad70d7ce3315083ed3651c + set: ddd-governance + statement: 'A watched-not-grounding edge is a distinct edge type, revisit_if: this claim''s death reopens the decision; it is not the decision''s ground. It is filed as its own edge with its own vocabulary, never inside based_on, and a status movement on it produces a reopen finding, never a basis-loss one.' + allocation: judgment + actor: emk@delegate.dk + tolerance_floor_at_creation: T1 + based_on: + - ruling:emil-2026-08-13#revisit-if-is-a-distinct-edge-type + - handoff:basis-quality-retyping-2026-08-12#watched-not-grounding diff --git a/.decisions/log/01KZX7HW7JY5BKE3YPTXXX9BDH.yml b/.decisions/log/01KZX7HW7JY5BKE3YPTXXX9BDH.yml new file mode 100644 index 0000000..1117abf --- /dev/null +++ b/.decisions/log/01KZX7HW7JY5BKE3YPTXXX9BDH.yml @@ -0,0 +1,21 @@ +format: 1 +id: cs:01KZX7HW7JY5BKE3YPTXXX9BDH +created_at: 2026-08-13T09:34:17.837729151Z +created_by: noreply@anthropic.com +note: 'The remedy for DDD-gates-02, filed for the principal''s acceptance and deliberately not implemented by the session that filed it. A red build must say what it did not check: independent gates run anyway, or — the floor, where a step genuinely depends on a failed one — the run reports which gates did not execute. Allocated as a judgment because nothing checks the arrangement yet, so holding to it rests on a person; a criterion here would name a discharge pointer that resolves to no checker. When the workflow edit lands, re-allocating to a criterion whose discharge names the check that then exists is a new version, not an edit. Cost of the follow-up: a red build stops failing fast and pays each independent gate''s runtime, the benchmark step most of all.' +decisions: +- id: dec:hafeok.ddd/01KZX7HW7J4FET6H3JW8EQTK0R + created_at: 2026-08-13T09:34:17.837729151Z + created_by: noreply@anthropic.com +versions: +- decision: dec:hafeok.ddd/01KZX7HW7J4FET6H3JW8EQTK0R + hash: sha256:cc17742a5bf0f674a0c4a70eaef09c4548305b1d8066ea69845b4d301852411c + set: ddd-governance + statement: A CI run reports which gates did not execute; independent gates run anyway + allocation: judgment + actor: emk@delegate.dk + tolerance_floor_at_creation: T1 + based_on: + - ddd-content:dec/ddd/ci-gates-report-skipped@sha256:a792744a234259cdedecad0c0a8fa71f57080f95aae3a27c6b594458f0f442dd + - claim:DDD-gates-02@sha256:65bfeff0770e7de976127d54d87c3428a6e4458d0f6b6088fc0b452a4c6cb89e + - preference:dec/ddd/ci-gates-report-skipped diff --git a/ddd-cli/src/commands/content_hash.rs b/ddd-cli/src/commands/content_hash.rs new file mode 100644 index 0000000..3220ea1 --- /dev/null +++ b/ddd-cli/src/commands/content_hash.rs @@ -0,0 +1,40 @@ +//! `ddd content-hash` — the pin a ledger entry takes of a stored entry. +//! +//! A `.ddd` entry that is re-decided moves its content hash, so the ledger +//! version pinning it must state the new one. The M8 migration computed +//! these inline and hard-coded them; anyone re-deciding an entry afterwards +//! needs the same number and had no way to ask for it. Read-only: it +//! computes and prints, and writes nothing. + +use product_core::error::{ProductError, Result}; + +use super::resolve_root; + +/// Print the content hash of one stored entry, whatever family it is in. +pub fn run(root: Option, id: &str) -> Result<()> { + let repo_root = resolve_root(root)?; + let store = ddd_core::store::load(&repo_root.join(ddd_core::store::STORE_DIR)); + let hash = hash_of(&store, id).ok_or_else(|| { + ProductError::NotFound(format!( + "{id} is not a decision, claim or seam in this store" + )) + })?; + println!("{hash}"); + Ok(()) +} + +/// The three families that carry a content hash, each under its own +/// domain-separation prefix (`ddd_core::basis_pin`). +fn hash_of(store: &ddd_core::store::DddStore, id: &str) -> Option { + if let Some(d) = store.decisions.iter().find(|d| d.id == id) { + return Some(ddd_core::basis_pin::decision_content_hash(d)); + } + if let Some(c) = store.claims.iter().find(|c| c.id == id) { + return Some(ddd_core::basis_pin::claim_content_hash(c)); + } + store + .seams + .iter() + .find(|s| s.id == id) + .map(ddd_core::basis_pin::seam_content_hash) +} diff --git a/ddd-cli/src/commands/migrate.rs b/ddd-cli/src/commands/migrate.rs index 23eaae6..ef66e7a 100644 --- a/ddd-cli/src/commands/migrate.rs +++ b/ddd-cli/src/commands/migrate.rs @@ -83,6 +83,10 @@ fn file_entry( allocation: clone_allocation(&entry.allocation), tolerance_override: None, based_on: entry.based_on.clone(), + // The M8 migration states no reopen edges: the three it carried + // as provisional `watched:` markers were re-decided into real + // `revisit_if` edges afterwards, as their own filed versions. + revisit_if: Vec::new(), note: Some(entry.note.clone()), }) .map_err(|e| author_err(&e))?; diff --git a/ddd-cli/src/commands/mod.rs b/ddd-cli/src/commands/mod.rs index ca727a1..56071ce 100644 --- a/ddd-cli/src/commands/mod.rs +++ b/ddd-cli/src/commands/mod.rs @@ -1,6 +1,7 @@ //! Subcommand surface for the `ddd` binary. mod bind; +mod content_hash; mod diff; mod diff_contracts; mod init; @@ -32,6 +33,11 @@ pub enum Commands { #[arg(long)] verdict: Option, }, + /// The content hash a ledger entry pins of a stored decision, claim or seam + ContentHash { + /// The entry id, e.g. dec/ddd/internal-not-surface + id: String, + }, /// Declared vs. detected rules: UNGOVERNED / STALE / UNCITED_SUPPRESSION Diff { /// SARIF file(s) with emitted diagnostics (adds to config detect.sarif) @@ -139,6 +145,7 @@ pub enum ReportWhat { pub fn run(cmd: Commands, root: Option) -> Result<()> { match cmd { Commands::Bind { range, wait, verdict } => bind::run(root, range, wait, verdict), + Commands::ContentHash { id } => content_hash::run(root, &id), Commands::Diff { sarif, json } => diff::run(root, sarif, json), Commands::DiffContracts { range, json, wait } => { diff_contracts::run(root, range, json, wait) diff --git a/ddd-cli/src/commands/report.rs b/ddd-cli/src/commands/report.rs index dab3e13..9729c45 100644 --- a/ddd-cli/src/commands/report.rs +++ b/ddd-cli/src/commands/report.rs @@ -73,6 +73,22 @@ fn print_ledger(s: &ddd_core::ledger_gate::LedgerSection) { short(current) ); } + // The reopen edges get their own lines here too. A ledger entry whose + // tripwire fired is due a fresh look; saying so under "pin drift" + // would be the merge the ruling forbids. + if s.reopened.is_empty() { + println!("no reopen edge fired — {} checked", s.reopen_checked); + } + for r in &s.reopened { + let who = r.historical.as_deref().unwrap_or(&r.decision); + let current = r.current.as_deref().unwrap_or("claim no longer exists"); + println!( + "reopen: {who} — {} pinned {}, now {}", + r.claim, + short(&r.pinned), + short(current) + ); + } } fn short(hash: &str) -> &str { @@ -88,6 +104,7 @@ fn print_report( print_diff(report); print_cadence(report, today); print_basis(report); + print_reopen(report); print_pair(pair); if report.is_clean() && pair.is_clean() { println!("\nno escaped decisions — every governed diagnostic resolves"); @@ -95,6 +112,7 @@ fn print_report( let n = report.diff.findings.len() + report.cadence.len() + report.basis_loss.len() + + report.reopen.len() + pair.findings.len(); println!("\n{n} escape(s) — file the missing entries or revalidate the claims"); } @@ -192,6 +210,19 @@ fn print_basis(report: &EscapesReport) { } } +/// The reopen section — its own heading, deliberately. A fired tripwire and +/// a lost basis are different facts, so they never share a list. +fn print_reopen(report: &EscapesReport) { + println!("\n== reopen =="); + if report.reopen.is_empty() { + println!("clean — {} reopen edge(s) checked", report.reopen_checked); + return; + } + for r in &report.reopen { + println!("{}", r.message()); + } +} + /// Name the uncheckable set, but keep one long list from swamping the /// report: past six, name six and count the rest. fn join_capped(ids: &[String]) -> String { diff --git a/ddd-core/src/basis_pin.rs b/ddd-core/src/basis_pin.rs index ee017b3..efa2f17 100644 --- a/ddd-core/src/basis_pin.rs +++ b/ddd-core/src/basis_pin.rs @@ -83,7 +83,7 @@ pub const SEAM_CONTENT_FORM: &str = "ddd.seam-content.v1"; /// The hash a migrated ledger entry pins of its decision's content. pub fn decision_content_hash(d: &crate::decision::Decision) -> String { let crate::decision::Decision { - format: _, id, kind, title, rationale, principal, based_on, date, notes, + format: _, id, kind, title, rationale, principal, based_on, revisit_if, date, notes, } = d; let mut m = Map::new(); for (key, value) in [ @@ -102,6 +102,15 @@ pub fn decision_content_hash(d: &crate::decision::Decision) -> String { if !bases.is_empty() { m.insert("based_on".to_string(), Value::Array(bases)); } + // Reopen edges are authored content too, and hashed under their own + // key: re-typing an edge from ground to tripwire moves the decision's + // content hash, which is what makes the re-decision visible to the + // ledger entry that pins it rather than a silent rewrite. + let reopen: Vec = + revisit_if.iter().filter_map(|r| serde_json::to_value(r).ok()).collect(); + if !reopen.is_empty() { + m.insert("revisit_if".to_string(), Value::Array(reopen)); + } ledger_core::hash::domain_hash(DECISION_CONTENT_FORM, Value::Object(m).to_string().as_bytes()) } diff --git a/ddd-core/src/decision.rs b/ddd-core/src/decision.rs index 27a3363..947e672 100644 --- a/ddd-core/src/decision.rs +++ b/ddd-core/src/decision.rs @@ -36,6 +36,11 @@ pub struct Decision { /// files plain claim ids; format 2 pins each edge (rule 6). #[serde(default)] pub based_on: Vec, + /// Format 7: claims whose *death reopens* this decision. Never ground — + /// a separate field, a separate type, a separate scan, so nothing can + /// read one as basis (`crate::revisit`). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub revisit_if: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub date: Option, #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/ddd-core/src/diff_tests.rs b/ddd-core/src/diff_tests.rs index b7bd192..e51ea52 100644 --- a/ddd-core/src/diff_tests.rs +++ b/ddd-core/src/diff_tests.rs @@ -118,6 +118,7 @@ fn a_cited_suppression_is_no_finding() { rationale: "R".into(), principal: "Emil".into(), based_on: Vec::new(), + revisit_if: Vec::new(), date: None, notes: None, }); @@ -140,6 +141,7 @@ fn a_citation_to_a_plain_decision_stays_uncited() { rationale: "R".into(), principal: "Emil".into(), based_on: Vec::new(), + revisit_if: Vec::new(), date: None, notes: None, }); diff --git a/ddd-core/src/ledger_gate.rs b/ddd-core/src/ledger_gate.rs index ff93ec6..6935b46 100644 --- a/ddd-core/src/ledger_gate.rs +++ b/ddd-core/src/ledger_gate.rs @@ -28,6 +28,25 @@ pub struct LedgerSection { pub pin_drift: Vec, /// Claim-token pins checked (the coverage denominator). pub pins_checked: usize, + /// Reopen edges that have fired, from ledger versions' `revisit_if`. + /// A separate list from `pin_drift` for the same reason the store's + /// report keeps them apart: a fired tripwire is not a lost basis. + pub reopened: Vec, + /// Reopen edges checked (the coverage denominator for that list). + pub reopen_checked: usize, +} + +/// One ledger `revisit_if` edge whose claim has moved. +#[derive(Debug, Serialize)] +pub struct Reopened { + pub decision: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub historical: Option, + pub claim: String, + pub pinned: String, + /// `None` when the claim no longer exists. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub current: Option, } /// One migrated basis pin whose claim content moved. @@ -37,9 +56,11 @@ pub struct PinDrift { /// The historical id, when the concordance maps one. #[serde(default, skip_serializing_if = "Option::is_none")] pub historical: Option, - /// `claim` | `watched` | `indeterminate` — a watched or - /// indeterminate edge drifting is still worth seeing, but it never - /// grounded the decision. + /// `claim` | `indeterminate` — an indeterminate edge drifting is + /// still worth seeing, but it never grounded the decision. `watched` + /// was retired here on 2026-08-13: a watched edge is a `revisit_if` + /// edge now, and it reports through [`Reopened`], not through this + /// list. pub marker: String, pub claim: String, pub pinned: String, @@ -70,6 +91,7 @@ pub fn ledger_section( *dispositions.entry(state.as_str().to_string()).or_default() += 1; } let (pin_drift, pins_checked) = pin_scan(store, &ledger); + let (reopened, reopen_checked) = reopen_scan(store, &ledger); Some(LedgerSection { readiness, completeness, @@ -83,9 +105,59 @@ pub fn ledger_section( dispositions, pin_drift, pins_checked, + reopened, + reopen_checked, }) } +/// Every `revisit_if` edge on a latest version, compared against the +/// claim's current content. Reads a different field from [`pin_scan`] and +/// reports into a different list: the ruling is that these are two facts, +/// so nothing here may fold into the basis-loss output. +fn reopen_scan(store: &DddStore, ledger: &ledger_core::store::Store) -> (Vec, usize) { + let concordance = crate::concordance::load(&store.dir); + let view = ledger_core::verify::view::View::build(ledger); + let mut fired = Vec::new(); + let mut checked = 0usize; + for viewed in view.latest_versions() { + for token in &viewed.raw.revisit_if { + let Some((claim_id, pinned)) = parse_reopen(&token.to_string()) else { + continue; + }; + checked += 1; + let current = store + .claims + .iter() + .find(|c| c.id == claim_id) + .map(crate::basis_pin::claim_content_hash); + if current.as_deref() != Some(pinned.as_str()) { + let decision = viewed.raw.decision.to_string(); + fired.push(Reopened { + historical: concordance + .as_ref() + .and_then(|c| c.historical_of(&decision)) + .map(str::to_string), + decision, + claim: claim_id, + pinned, + current, + }); + } + } + } + fired.sort_by(|a, b| (&a.decision, &a.claim).cmp(&(&b.decision, &b.claim))); + (fired, checked) +} + +/// `claim:DDD-x-01@sha256:…` → (`DDD-x-01`, `sha256:…`). A reopen token +/// carries no marker vocabulary of its own: the field it sits in already +/// says what kind of edge it is. +fn parse_reopen(token: &str) -> Option<(String, String)> { + let rest = token.strip_prefix("claim:")?; + let (claim, hash) = rest.split_once('@')?; + hash.starts_with("sha256:").then(|| (claim.to_string(), hash.to_string())) +} + fn gate_passes( ledger: &ledger_core::store::Store, today: chrono::NaiveDate, @@ -140,7 +212,11 @@ fn pin_scan(store: &DddStore, ledger: &ledger_core::store::Store) -> (Vec Option<(&str, &str, String)> { let (marker, rest) = token.split_once(':')?; - if !matches!(marker, "claim" | "watched" | "indeterminate") { + // `watched` is deliberately absent: as of the 2026-08-13 ruling a + // watched edge is a `revisit_if` edge in its own field, and a basis + // scan that still recognised the marker would keep reading it as + // ground — the exact conflation the ruling ends. + if !matches!(marker, "claim" | "indeterminate") { return None; } let (claim, hash) = rest.split_once('@')?; @@ -155,9 +231,25 @@ mod tests { fn pin_tokens_parse_and_others_are_ignored() { let (m, c, h) = parse_pin("claim:DDD-x-01@sha256:abc").expect("parse"); assert_eq!((m, c, h.as_str()), ("claim", "DDD-x-01", "sha256:abc")); - assert!(parse_pin("watched:DDD-y-02@sha256:def").is_some()); + assert!(parse_pin("indeterminate:DDD-y-02@sha256:def").is_some()); assert!(parse_pin("ddd-content:dec/x/y@sha256:abc").is_none()); assert!(parse_pin("mandate:dec/x/y").is_none()); assert!(parse_pin("claim:DDD-x-01").is_none()); } + + /// The retirement, asserted rather than assumed: a stray `watched:` + /// token left in a `based_on` list is no longer read as a basis pin, + /// so it can never surface as basis loss again. + #[test] + fn a_watched_marker_is_no_longer_a_basis_pin() { + assert!(parse_pin("watched:DDD-y-02@sha256:def").is_none()); + } + + #[test] + fn reopen_tokens_parse_from_the_field_without_a_marker_vocabulary() { + let (claim, hash) = parse_reopen("claim:DDD-adapter-02@sha256:b333").expect("parse"); + assert_eq!((claim.as_str(), hash.as_str()), ("DDD-adapter-02", "sha256:b333")); + assert!(parse_reopen("watched:DDD-adapter-02@sha256:b333").is_none()); + assert!(parse_reopen("claim:DDD-adapter-02").is_none()); + } } diff --git a/ddd-core/src/lib.rs b/ddd-core/src/lib.rs index c90e81e..072bcb9 100644 --- a/ddd-core/src/lib.rs +++ b/ddd-core/src/lib.rs @@ -29,6 +29,7 @@ pub mod pattern; pub mod predicate; pub mod render; pub mod report; +pub mod revisit; pub mod rules; pub mod sarif; pub mod seam; diff --git a/ddd-core/src/report.rs b/ddd-core/src/report.rs index 1d08804..cf81cb3 100644 --- a/ddd-core/src/report.rs +++ b/ddd-core/src/report.rs @@ -1,9 +1,11 @@ //! Escape reporting — every escaped decision the graph can currently name. //! -//! Three sections (PRD §7): the governance diff findings, claims past their +//! Four sections (PRD §7): the governance diff findings, claims past their //! `revalidate_by` cadence (the stale-catalog mitigation, PRD §11), and //! basis loss — decisions whose pinned `based_on` status or `changed` date -//! no longer matches the claim (PRD §6 rule 6). Cadence plus basis loss +//! no longer matches the claim (PRD §6 rule 6), and reopen — decisions +//! whose `revisit_if` tripwire has fired, reported as its own class +//! because a fired tripwire is not a lost basis. Cadence plus basis loss //! only exist for format-2 entries, so each section also reports its own //! coverage: what it checked and what it could not see //! (`dec/ddd/report-coverage-explicit`). Coverage is a note, never an @@ -25,6 +27,12 @@ pub struct EscapesReport { pub cadence_coverage: CadenceCoverage, pub basis_loss: Vec, pub basis_coverage: BasisCoverage, + /// Reopen edges that have fired — a distinct section, never merged + /// into `basis_loss`. A decision whose tripwire fired is due a fresh + /// look; its ground has not moved (`crate::revisit`). + pub reopen: Vec, + /// Reopen edges checked — the coverage denominator for the section. + pub reopen_checked: usize, } /// What the cadence check could and could not see. A claim with no @@ -92,7 +100,10 @@ pub struct BasisLoss { impl EscapesReport { pub fn is_clean(&self) -> bool { - self.diff.findings.is_empty() && self.cadence.is_empty() && self.basis_loss.is_empty() + self.diff.findings.is_empty() + && self.cadence.is_empty() + && self.basis_loss.is_empty() + && self.reopen.is_empty() } } @@ -101,12 +112,15 @@ impl EscapesReport { pub fn report_escapes(store: &DddStore, detected: &DetectedState, today: &str) -> EscapesReport { let (cadence, cadence_coverage) = cadence_scan(store, today); let (basis_loss, basis_coverage) = basis_scan(store); + let (reopen, reopen_checked) = crate::revisit::reopen_scan(store); EscapesReport { diff: diff(store, detected), cadence, cadence_coverage, basis_loss, basis_coverage, + reopen, + reopen_checked, } } @@ -192,6 +206,14 @@ fn pin_loss( }) } +/// The basis scan, exposed to this crate's tests so the reopen suite can +/// assert the separation directly: a fired reopen edge must be invisible +/// here, and a test that cannot call this could only assert it indirectly. +#[cfg(test)] +pub(crate) fn basis_scan_for_tests(store: &DddStore) -> (Vec, BasisCoverage) { + basis_scan(store) +} + #[cfg(test)] #[path = "report_tests.rs"] mod tests; diff --git a/ddd-core/src/report_tests.rs b/ddd-core/src/report_tests.rs index 748aa96..41961d4 100644 --- a/ddd-core/src/report_tests.rs +++ b/ddd-core/src/report_tests.rs @@ -38,6 +38,7 @@ fn pinned_decision(id: &str, claim: &str, status: ClaimStatus, changed: &str) -> changed: changed.into(), content: None, })], + revisit_if: Vec::new(), date: None, notes: None, } diff --git a/ddd-core/src/revisit.rs b/ddd-core/src/revisit.rs new file mode 100644 index 0000000..695dc5f --- /dev/null +++ b/ddd-core/src/revisit.rs @@ -0,0 +1,149 @@ +//! The reopen edge — a claim whose death reopens a decision (format 7). +//! +//! Ruled by the principal (2026-08-13), adopting into `.ddd/` the one shape +//! the ledger format specifies at spec v1.5 / `format: 4` (§3.7). A +//! `revisit_if` edge is the converse of ground: the decision does not rest +//! on the claim, so falsifying the claim does not undermine the decision — +//! it obliges someone to look at the decision again. +//! +//! The separation is structural, not conventional. A reopen edge lives in +//! its own field, carries its own type, and is scanned by its own pass, so +//! `based_on`'s basis-loss scan cannot see it and `why` cannot render it as +//! ground. A movement here is a **reopen** finding: a distinct class with a +//! distinct message, because "the ground you stood on moved" and "the +//! tripwire you set has fired" are different facts about a decision, and a +//! report that merges them tells the reader neither. + +use serde::{Deserialize, Serialize}; + +use crate::claim::ClaimStatus; +use crate::store::DddStore; + +/// One `revisit_if` edge: the claim, pinned as a claim basis is pinned so +/// the same exact comparison is available. Pinning is what makes a reopen +/// detectable at all — an unpinned tripwire cannot fire. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RevisitEdge { + /// The claim id whose death reopens the decision. + pub claim: String, + /// The claim's status when the edge was filed. + pub status: ClaimStatus, + /// The claim's `changed` date when the edge was filed. + pub changed: String, + /// The claim's canonical content hash when the edge was filed + /// (`ddd.claim-content.v1`). Required at format 7: the exactness the + /// M8 content pin bought must not be lost by moving an edge. + pub content: String, +} + +/// A reopen edge whose claim has moved: the decision is due a fresh look. +/// Deliberately *not* a [`crate::report::BasisLoss`] — same shape of +/// comparison, different fact, different report. +#[derive(Debug, Serialize)] +pub struct Reopen { + pub decision: String, + pub claim: String, + pub pinned_status: ClaimStatus, + pub pinned_changed: String, + pub pinned_content: String, + /// `None` when the claim no longer exists at all. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub current_status: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub current_changed: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub current_content: Option, +} + +impl Reopen { + /// The finding line. It says *reopen*, never *basis loss*: a reader who + /// sees this must know the decision needs revisiting, not that its + /// ground has gone. + pub fn message(&self) -> String { + let moved = match (&self.current_status, &self.current_changed) { + (Some(s), Some(c)) => format!("now {}@{c}", s.as_str()), + _ => "the claim no longer exists".to_string(), + }; + format!( + "{} — reopen: {} was pinned at {}@{} and has moved ({moved}); the decision is due a fresh look, its ground is untouched", + self.decision, + self.claim, + self.pinned_status.as_str(), + self.pinned_changed, + ) + } +} + +/// Every reopen edge in the store, compared against its claim's current +/// content. Returns the fired edges plus how many were checked — the same +/// state-your-coverage discipline `basis_scan` follows. +pub fn reopen_scan(store: &DddStore) -> (Vec, usize) { + let mut fired = Vec::new(); + let mut checked = 0usize; + for d in &store.decisions { + for edge in &d.revisit_if { + checked += 1; + let current = store.claims.iter().find(|c| c.id == edge.claim); + let current_content = current.map(crate::basis_pin::claim_content_hash); + let moved = current_content.as_deref() != Some(edge.content.as_str()); + if moved { + fired.push(Reopen { + decision: d.id.clone(), + claim: edge.claim.clone(), + pinned_status: edge.status, + pinned_changed: edge.changed.clone(), + pinned_content: edge.content.clone(), + current_status: current.map(|c| c.status), + current_changed: current.map(|c| c.changed.clone()), + current_content, + }); + } + } + } + fired.sort_by(|a, b| (&a.decision, &a.claim).cmp(&(&b.decision, &b.claim))); + (fired, checked) +} + +/// Format gates for the reopen edges of one decision. +/// +/// Three rules, each closing a way the ruling could be lost: the field is +/// format 7, every edge pins exactly, and no claim is both ground and +/// tripwire — an edge that is read both ways is exactly the conflation the +/// distinct edge type exists to end. +pub fn gates(d: &crate::decision::Decision) -> Vec { + let mut out = Vec::new(); + let mut push = |message: String| { + out.push(product_core::pf::validate::Violation { + focus: d.id.clone(), + path: "revisit_if".to_string(), + message, + severity: "violation".to_string(), + }) + }; + if d.revisit_if.is_empty() { + return out; + } + if d.format < crate::validate::REVISIT_FORMAT { + push("reopen edges are format 7 — declare `format: 7`".to_string()); + } + for edge in &d.revisit_if { + if edge.content.trim().is_empty() { + push(format!( + "the reopen edge on {} carries no content hash — an unpinned tripwire cannot fire", + edge.claim + )); + } + if d.based_on.iter().any(|b| b.claim_id() == Some(edge.claim.as_str())) { + push(format!( + "{} is filed as both ground and reopen edge — a claim is one or the other, which is the whole point of the distinct edge type", + edge.claim + )); + } + } + out +} + +#[cfg(test)] +#[path = "revisit_tests.rs"] +mod tests; diff --git a/ddd-core/src/revisit_tests.rs b/ddd-core/src/revisit_tests.rs new file mode 100644 index 0000000..03b6a46 --- /dev/null +++ b/ddd-core/src/revisit_tests.rs @@ -0,0 +1,175 @@ +//! Reopen edges over hand-built stores: firing, coverage, the gates, and +//! the separation from basis loss. + +use super::*; +use crate::claim::Claim; +use crate::decision::{BasedOn, BasisPin, Decision, DecisionKind}; + +fn claim(status: ClaimStatus, changed: &str) -> Claim { + Claim { + format: 2, + id: "DDD-adapter-02".into(), + statement: "Internal members leak across boundaries.".into(), + status, + evidence: None, + falsifier: Some("a boundary defect traced to an internal member".into()), + owner: "none".into(), + changed: changed.into(), + revalidate_by: None, + version_index: None, + depends_on: Vec::new(), + refines: Vec::new(), + predicate: None, + } +} + +fn decision(revisit: Vec, based_on: Vec) -> Decision { + Decision { + format: 7, + id: "dec/ddd/internal-not-surface".into(), + kind: DecisionKind::Decision, + title: "internal is not contract surface".into(), + rationale: "App-repo posture is the default.".into(), + principal: "Emil".into(), + based_on, + revisit_if: revisit, + date: Some("2026-08-06".into()), + notes: None, + } +} + +fn edge(at: &Claim) -> RevisitEdge { + RevisitEdge { + claim: at.id.clone(), + status: at.status, + changed: at.changed.clone(), + content: crate::basis_pin::claim_content_hash(at), + } +} + +fn store_of(d: Decision, c: Claim) -> DddStore { + let mut store = DddStore::default(); + store.decisions.push(d); + store.claims.push(c); + store +} + +fn mandate() -> BasedOn { + BasedOn::Typed(crate::decision::TypedBasis { + basis_type: crate::decision::BasisType::Mandate, + statement: Some("The principal's ruling.".into()), + reference: None, + }) +} + +#[test] +fn an_unmoved_reopen_edge_does_not_fire_but_is_counted() { + let c = claim(ClaimStatus::Projected, "2026-08-06"); + let store = store_of(decision(vec![edge(&c)], vec![mandate()]), c); + let (fired, checked) = reopen_scan(&store); + assert!(fired.is_empty()); + assert_eq!(checked, 1); +} + +#[test] +fn a_status_move_on_a_reopen_edge_fires_a_reopen_and_says_so() { + let pinned = claim(ClaimStatus::Projected, "2026-08-06"); + let e = edge(&pinned); + let moved = claim(ClaimStatus::Retired, "2026-08-13"); + let store = store_of(decision(vec![e], vec![mandate()]), moved); + let (fired, checked) = reopen_scan(&store); + assert_eq!(checked, 1); + assert_eq!(fired.len(), 1); + assert_eq!(fired[0].current_status, Some(ClaimStatus::Retired)); + let message = fired[0].message(); + assert!(message.contains("reopen:"), "{message}"); + assert!(message.contains("due a fresh look"), "{message}"); + assert!( + !message.contains("basis loss") && !message.contains("basis-loss"), + "a reopen must never report as basis loss: {message}" + ); +} + +/// The load-bearing separation: a reopen edge is invisible to the +/// basis-loss scan, whatever it does. If this ever fails, the ruling has +/// been undone somewhere. +#[test] +fn a_fired_reopen_edge_never_appears_in_basis_loss() { + let pinned = claim(ClaimStatus::Projected, "2026-08-06"); + let e = edge(&pinned); + let moved = claim(ClaimStatus::Retired, "2026-08-13"); + let store = store_of(decision(vec![e], vec![mandate()]), moved); + let (losses, coverage) = crate::report::basis_scan_for_tests(&store); + assert!(losses.is_empty(), "a reopen edge surfaced as basis loss: {losses:?}"); + assert_eq!(coverage.pinned, 0, "a reopen edge was counted as a pinned basis"); + let (fired, _) = reopen_scan(&store); + assert_eq!(fired.len(), 1, "and it did fire on its own channel"); +} + +#[test] +fn a_vanished_claim_fires_with_no_current_state() { + let pinned = claim(ClaimStatus::Projected, "2026-08-06"); + let e = edge(&pinned); + let mut store = DddStore::default(); + store.decisions.push(decision(vec![e], vec![mandate()])); + let (fired, _) = reopen_scan(&store); + assert_eq!(fired.len(), 1); + assert!(fired[0].current_status.is_none()); + assert!(fired[0].message().contains("no longer exists")); +} + +#[test] +fn reopen_edges_below_format_seven_are_a_violation() { + let c = claim(ClaimStatus::Projected, "2026-08-06"); + let mut d = decision(vec![edge(&c)], vec![mandate()]); + d.format = 6; + let out = gates(&d); + assert_eq!(out.len(), 1, "{out:?}"); + assert!(out[0].message.contains("format: 7"), "{:?}", out[0]); +} + +#[test] +fn an_unpinned_reopen_edge_is_a_violation_because_it_can_never_fire() { + let c = claim(ClaimStatus::Projected, "2026-08-06"); + let mut e = edge(&c); + e.content = String::new(); + let out = gates(&decision(vec![e], vec![mandate()])); + assert_eq!(out.len(), 1, "{out:?}"); + assert!(out[0].message.contains("cannot fire"), "{:?}", out[0]); +} + +#[test] +fn one_claim_cannot_be_both_ground_and_tripwire() { + let c = claim(ClaimStatus::Projected, "2026-08-06"); + let ground = BasedOn::Pinned(BasisPin { + basis_type: Some(crate::decision::BasisType::Claim), + claim: c.id.clone(), + status: c.status, + changed: c.changed.clone(), + content: Some(crate::basis_pin::claim_content_hash(&c)), + }); + let out = gates(&decision(vec![edge(&c)], vec![ground])); + assert_eq!(out.len(), 1, "{out:?}"); + assert!(out[0].message.contains("both ground and reopen"), "{:?}", out[0]); +} + +/// Re-typing an edge from ground to tripwire changes what the decision +/// says, so it must move the decision's content hash — that is what makes +/// the migration a re-decision the ledger can pin, not a silent rewrite. +#[test] +fn moving_an_edge_from_ground_to_reopen_moves_the_decision_content_hash() { + let c = claim(ClaimStatus::Projected, "2026-08-06"); + let ground = BasedOn::Pinned(BasisPin { + basis_type: Some(crate::decision::BasisType::Claim), + claim: c.id.clone(), + status: c.status, + changed: c.changed.clone(), + content: Some(crate::basis_pin::claim_content_hash(&c)), + }); + let before = decision(Vec::new(), vec![mandate(), ground]); + let after = decision(vec![edge(&c)], vec![mandate()]); + assert_ne!( + crate::basis_pin::decision_content_hash(&before), + crate::basis_pin::decision_content_hash(&after) + ); +} diff --git a/ddd-core/src/validate.rs b/ddd-core/src/validate.rs index 9f8c488..e4b7d65 100644 --- a/ddd-core/src/validate.rs +++ b/ddd-core/src/validate.rs @@ -18,7 +18,11 @@ use crate::turtle; /// The entry format versions this tool validates against; each entry is /// checked against the version it declares (PRD §6). -pub const SUPPORTED_FORMATS: &[u32] = &[1, 2, 3, 4, 5, 6]; +pub const SUPPORTED_FORMATS: &[u32] = &[1, 2, 3, 4, 5, 6, 7]; + +/// The decision format that carries reopen edges (`revisit_if`), adopting +/// the ledger's spec-v1.5 shape into this store. See `crate::revisit`. +pub const REVISIT_FORMAT: u32 = 7; /// Run every check over the store; empty means conformant. pub fn validate_store(store: &DddStore) -> Vec { @@ -53,7 +57,7 @@ fn format_checks(store: &DddStore) -> Vec { out.push(fault( id, "format", - format!("declares format {format}; this tool validates formats 1-6"), + format!("declares format {format}; this tool validates formats 1-7"), )); } }; @@ -210,6 +214,7 @@ fn decision_format_gates(d: &crate::decision::Decision) -> Vec { out.extend(typed_basis_gates(&d.id, t)); } } + out.extend(crate::revisit::gates(d)); out } diff --git a/ddd-core/src/validate_format_tests.rs b/ddd-core/src/validate_format_tests.rs index 77794be..94816bf 100644 --- a/ddd-core/src/validate_format_tests.rs +++ b/ddd-core/src/validate_format_tests.rs @@ -11,9 +11,9 @@ use crate::decision::{BasedOn, BasisPin}; fn declared_formats_beyond_the_known_set_are_named() { let mut s = DddStore::default(); let mut p = pred("pred/a/x"); - p.format = 7; + p.format = 8; s.predicates.push(p); - assert_names(&validate_store(&s), "pred/a/x", "declares format 7"); + assert_names(&validate_store(&s), "pred/a/x", "declares format 8"); } #[test] @@ -66,18 +66,18 @@ fn a_version_index_anchoring_nothing_is_a_violation() { } #[test] -fn format_six_is_a_known_format() { +fn format_seven_is_a_known_format() { let mut s = DddStore::default(); let mut c = claim("DDD-a-1"); - c.format = 6; + c.format = 7; s.claims.push(c); assert!(validate_store(&s).is_empty(), "{}", msgs(&validate_store(&s))); let mut ahead = DddStore::default(); let mut c2 = claim("DDD-a-2"); - c2.format = 7; + c2.format = 8; ahead.claims.push(c2); - assert_names(&validate_store(&ahead), "DDD-a-2", "formats 1-6"); + assert_names(&validate_store(&ahead), "DDD-a-2", "formats 1-7"); } #[test] diff --git a/ddd-core/src/validate_tests.rs b/ddd-core/src/validate_tests.rs index 300b75d..c6e089a 100644 --- a/ddd-core/src/validate_tests.rs +++ b/ddd-core/src/validate_tests.rs @@ -57,6 +57,7 @@ fn decision(id: &str, based_on: &[&str]) -> Decision { rationale: "R".into(), principal: "Emil".into(), based_on: based_on.iter().map(|s| BasedOn::from(*s)).collect(), + revisit_if: Vec::new(), date: None, notes: None, } diff --git a/ddd-core/src/why.rs b/ddd-core/src/why.rs index 9c1fda8..a6af1df 100644 --- a/ddd-core/src/why.rs +++ b/ddd-core/src/why.rs @@ -146,6 +146,19 @@ fn render_decision(store: &DddStore, d: &Decision, depth: usize) -> String { } } } + // The reopen edges render under their own heading, below the ground and + // visibly apart from it. `why` answers "what is this resting on"; a + // reopen edge is not an answer to that question, and printing it inside + // `basedOn:` would make it read as one. + if !d.revisit_if.is_empty() { + out.push_str(&format!("{p} revisitIf (not ground — these reopen the decision):\n")); + for edge in &d.revisit_if { + match store.claims.iter().find(|c| c.id == edge.claim) { + Some(c) => out.push_str(&render_claim(c, depth + 2)), + None => out.push_str(&format!("{p} {} (claim not found)\n", edge.claim)), + } + } + } out } diff --git a/ddd-mcp/src/govern_tools.rs b/ddd-mcp/src/govern_tools.rs index ccb5aac..37f9e88 100644 --- a/ddd-mcp/src/govern_tools.rs +++ b/ddd-mcp/src/govern_tools.rs @@ -368,6 +368,7 @@ pub fn accept_risk(state: &ServeState, args: &Value) -> ToolResult { rationale: field(args, "rationale", existing.as_ref().map(|e| e.rationale.clone()))?, principal: field(args, "principal", existing.as_ref().map(|e| e.principal.clone()))?, based_on: existing.as_ref().map(|e| e.based_on.clone()).unwrap_or_default(), + revisit_if: existing.as_ref().map(|e| e.revisit_if.clone()).unwrap_or_default(), date: Some(chrono::Utc::now().format("%Y-%m-%d").to_string()), notes: opt_str(args, "notes").or_else(|| existing.as_ref().and_then(|e| e.notes.clone())), }; diff --git a/docs/acceptance-worksheet-2026-08.md b/docs/acceptance-worksheet-2026-08.md new file mode 100644 index 0000000..ea089fe --- /dev/null +++ b/docs/acceptance-worksheet-2026-08.md @@ -0,0 +1,272 @@ +# Acceptance worksheet — 2026-08 + +**Purpose:** make the pass over the pending ledger entries a reviewable +morning rather than a shell loop. + +**What this document is not:** the entries themselves. `ledger show` is the +read — one screen per decision, carrying the hashed content, both edge kinds +kept apart, each edge's *argument* under its pointer, the filing act and the +acceptance situation. This worksheet is only the grouping, the flags, and +the sequence. Where the two disagree, `ledger show` is right: it is derived +from the log, and this file is written by hand. + +**State when written** (2026-08-13, after the `revisit_if` amendment): + +| | count | +|---|---| +| awaiting acceptance | 79 | +| escaped, priced | 1 | +| **pending total** | **80** | +| already decided (`ledger-design`) | 12 | + +The count was 79 in the session that scheduled this pass; it is 80 because +that session filed one new decision — the masked-gate remedy — which is in +group B below and flagged there. + +--- + +## The two groups + +The split is **derived, not hand-listed**: `ledger show --group` computes it +from the discharge, so it cannot go stale as entries are added. A decision +is *mechanical* when it is a criterion discharged solely by the +repository-diff contract check; everything else is an individual read. + +### Group A — 33 transcribed seam declarations (mechanical) + +``` +ledger show --group mechanical +``` + +**Read the group, not the entries.** All 33 are one shape, and that is +checked rather than asserted — every one of them is: + +- `allocation: criterion`, `discharge_stage: pr`; +- discharged by exactly one `contract:seam/…` pointer; +- grounded by exactly one `ddd-content:seam/…` basis and nothing else; +- no reopen edge, floor `T1`, first version, all filed 2026-08-12 by the + M8 migration. + +By adapter: 19 `seam/web/…`, 10 `seam/ledger/…`, 3 `seam/rust/…`, +1 `seam/mcp/…`. + +**What you are actually ruling**, once, for the group: *that the +transcription is faithful* — that each ledger entry says what its `.ddd` +seam declaration says, and that the contract check named as its discharge is +the right checker for it. You are not re-ruling the seam declarations; those +were decided when they were declared. If the transcription is faithful, the +group is one judgment. + +**What would break the group** and force entry-by-entry reading: any entry +whose statement does not match its seam's `contract_location`, or whose +`ddd-content:` pin no longer resolves. Neither is currently reported as +drifting — `ddd report escapes` shows the ledger section clean apart from +the two basis-loss rows and the one reopen row, none of them in this group. + +### Group B — 47 individual reads + +``` +ledger show --group individual +``` + +46 awaiting plus the one priced escape. Each is a separate judgment; the +list below is one line each on *what you are ruling*, not on what the entry +says. Read them from `ledger show`, which now prints each basis's argument +text under its pointer — for the re-typed entries that argument is the +thing being ruled on, so it belongs on the screen, not in this file. + +The 12 `ledger-design` decisions are already decided and are not in this +pass. + +**The sub-groups inside B.** They partition the 47 — every entry is in +exactly one — and are listed in the order worth reading them: + +1. **The re-typed bases — 7** (the 2026-08 basis-quality audit's seven): + `git-is-the-amend-trail`, `internal-not-surface`, `no-unwrap`, + `predicates-carry-no-status`, `what-boundaries-priced-not-paid`, + `m6-proceeds-no-flip`, `rust-class-enforced-here`. Ruling: that the + mandate/constraint/preference now stated as the ground *is* the ground, + and that the claim demoted from it was never doing the work. Both + batches were ratified in `.ddd` on 2026-08-12; what is pending here is + the ledger entry recording them. + + **Three of these seven carry a second ruling** and are version 2 of + their decision: `internal-not-surface`, `no-unwrap` and + `m6-proceeds-no-flip` are the `revisit_if` conversions of 2026-08-13. + For those three you are also ruling that the edge belongs on the reopen + side rather than the ground side, and that carrying its pin across + unchanged was right. Version 1 said the same thing with the edge as a + `watched:` marker inside `based_on`, and is now history. One screen + covers both rulings; `revisit if` is its own block on it. + +2. **The ruling record — 1.** `question/ddd/watched-edge-kind`, version 2. + Ruling: that the statement now on file is the ruling you gave. + +3. **The derived allocations — 37.** The M8 migration read each `.ddd` + decision's own shape and allocated accordingly — mostly `judgment` with + you as actor, four `constraint`. Ruling: that the allocation the + migration derived is the one you would have chosen. The largest + sub-group, and the one where a wrong derivation is easiest to miss, + because a judgment allocated to you reads as unremarkable. One of the 37 + is the held entry (`interceptor-not-extension`), so 36 are acceptable. + +4. **The risk record — 1** (`escaped-priced`). + `risk/ddd/undeclared-what-boundaries` + (`dec:hafeok.ddd/01KZTGGX5ABSQ2PVTQ32NPKVNE`) — 24 undeclared What + boundaries, exposure accepted until review. Ruling: that the exposure is still worth + carrying, and that the review date still holds. The only entry in the + whole pass where accepting means accepting a *known* cost. + +5. **The masked-gate remedy — 1** (new, 2026-08-13). + `ci-gates-report-skipped`. Ruling: that a red build should report which + gates did not execute, and that the judgment allocation is right while + nothing checks it. Filed for acceptance; not implemented. + +--- + +## Must not be accepted yet + +**`dec/ddd/interceptor-not-extension`** — `dec:hafeok.ddd/01KZTGGKYN3VT9XYPATK0S0SAA`. +Its basis is `indeterminate:DDD-arch-03`: the edge is filed as +*indeterminate*, awaiting your F-7h ruling. Accepting it signs that +indeterminacy into the record as though it were settled. Rule F-7h first; +the acceptance is a separate act afterwards, against whatever version that +ruling produces. + +**Nothing else is held.** In particular, nothing is mid-migration: the three +`revisit_if` conversions all landed as complete versions with both stores +gates-green, and the fourth entry the amendment touched (the ruling record) +likewise. If a conversion had been left half-filed it would appear here; it +does not. + +One thing is *blocked* rather than held, and it is not in this pass at all: +the provenance audit's one upstream watched-not-grounding row +(`workspace-member-delivery` → the What/How vocabulary) is still unfiled, +waiting on a cross-repo reference shape that has not landed. There is +nothing to accept for it because there is nothing filed. + +--- + +## The uniform-T1 observation + +**Every one of the 80 sits at `T1`, the `ddd-governance` floor. Not one +carries a `tolerance_override`.** The tolerance floor therefore did no +discriminating work in this migration: it was pinned, not chosen. That is +expected of a mechanical migration — it carries forward what was declared, +and no tier was declared — but it means the tier tells you nothing about +which entries deserve more of your attention, and this worksheet's grouping +is doing the job the tier would otherwise do. + +**Proposed as a genuine T2 case (a proposal for `revise`, not applied):** + +- **`risk/ddd/undeclared-what-boundaries`** and its decision + **`dec/ddd/what-boundaries-priced-not-paid`**. These are the pair that + knowingly carries exposure across 24 boundaries with a review date. T2 is + where a signature is meant to cost more, and a priced escape is the one + shape in this store where "who signed this, and how carefully" is the + whole question. It is also the pair that L6's certificate signing would + bind first, since that revision requires a signature only above the floor + — at a uniform T1 it would require none anywhere. + +**Considered and not proposed:** `dec/ddd/typed-basis`. It reshapes every +future entry, which is an argument for weight, but it is a *format* ruling +whose consequences are mechanically checked by `ddd validate`; the check, +not the tier, is what holds it. Raising it would be tier inflation. + +**Cost of acting on the proposal**, stated so it is not a surprise: an +up-only override goes on a *new version*, and `ledger revise` has no +`--tolerance-override` flag today — only `ledger add` does. So applying it +is a small CLI addition plus two revisions, and each revision returns its +decision to awaiting-acceptance. Raising the *set floor* instead is the +wrong instrument: it strands every member pinned below it (`L005`) until +each is re-pinned. + +--- + +## The sequence + +Run from the repo root, on a branch, with the store clean +(`ledger verify` exit 0 before you start). + +**0 — identity.** `ledger accept` takes the acceptor from git config, and +`L009` checks that the acceptance's actor is the author of the commit that +introduced it. So both must be you: + +```sh +git config user.email # must be emk@delegate.dk, not a model or CI identity +git config user.name +``` + +If the commit ends up authored by anyone else, `ledger verify` fails +`L009`. `--no-blame` skips that check; it is a bypass, not a fix, and using +it defeats the one mechanism that catches an acceptance filed under a +borrowed identity. + +**1 — group A, one read then one recorded judgment.** + +```sh +ledger show --group mechanical | less # the read; this is the actual work +``` + +Then, having read it, record the one judgment across the group: + +```sh +ledger show --group mechanical --json \ + | jq -r '.[] | select(.state=="awaiting-acceptance") | .decision' \ + | while read -r id; do ledger accept "$id"; done +``` + +**On the loop, honestly:** the format has no group-acceptance primitive — an +acceptance signs one version hash, and `scope: class:` parses but still +signs one version. So the loop is the only mechanism there is. What makes it +legitimate here is that the *reading* happened once, above, for a group +proven to be one shape; the loop records that single judgment 33 times +rather than making 33 judgments. If reading the group left you unsure about +any single entry, pull it out and read it on its own screen first — the loop +is not the place to resolve a doubt. + +**2 — group B, entry by entry.** + +```sh +ledger show --group individual | less # or one at a time: +ledger show dec:hafeok.ddd/ +ledger accept dec:hafeok.ddd/ +``` + +Skip `dec:hafeok.ddd/01KZTGGKYN3VT9XYPATK0S0SAA` +(`interceptor-not-extension`) per the flag above. + +**3 — check and commit.** + +```sh +ledger verify # exit 0; L009 will police step 0 +ledger status | tail -20 # should show 1 awaiting (the held entry) +ledger reindex # the L2 index follows the log +git add .decisions && git commit -m "Accept the 2026-08 pending batch" +``` + +--- + +## Wall-clock + +Record the elapsed time of the pass here when it is done. It is not +bookkeeping: this is the **first real reading of the acceptance-latency +instrument** — how long a principal actually takes per entry, split by +weight class — and it is the requirements input for L5, the acceptance +workbench. A workbench designed without it would be designed against a +guess. + +Capture at least: total elapsed, and the split between group A (one read +plus the loop) and group B (per-entry). If group A's per-entry cost turns +out to be near group B's, the grouping did not buy anything and L5 should +know that too — a null result here is as useful as a positive one. + +| | start | end | elapsed | entries | per entry | +|---|---|---|---|---|---| +| Group A (mechanical, 33) | | | | 33 | | +| Group B (individual, 47) | | | | 46 accepted, 1 held | | +| **Total** | | | | **79 of 80** | | + +Notes on anything that slowed the pass down (an entry that needed the `.ddd` +file opened, an argument that did not render, a screen that lacked something +you wanted): diff --git a/docs/audits/provenance-2026-08.md b/docs/audits/provenance-2026-08.md index 0b17f25..768dbdd 100644 --- a/docs/audits/provenance-2026-08.md +++ b/docs/audits/provenance-2026-08.md @@ -618,3 +618,32 @@ Three observations, and no proposal: edges. The audit's one watched-not-grounding *upstream* row — `ddd/workspace-member-delivery` → the What/How vocabulary — is untouched and still has no home. + +### 10.4 Addendum (2026-08-13) — the relation is ruled; the upstream row is not + +Recorded, not re-cut: the reading above stands as taken. What changed after it +is one ruling and its consequence for the one row §10.3 left open. + +The principal ruled (2026-08-13) that a watched-not-grounding edge is a +**distinct edge type**, `revisit_if` — this claim's death reopens the decision; +it is not the decision's ground — filed as its own edge with its own +vocabulary, never inside `based_on`. §7.2's bucket therefore closes, and +§10.3's observation 1 is superseded: the relation now has a ruled ontology, +not only a transport. The three intra-repo edges §10.3 names were re-decided +onto the real edge type, each filed for the principal's acceptance, with their +pins carried across unchanged. `dec/ddd/m6-proceeds-no-flip`'s drifted edge — +the only one of the three that fires — now reports as a **reopen** finding +rather than a basis-loss one, which is the correction the ruling makes: that +decision's ground is the principal's stated default and has never moved. + +§10.3's observation 3 is **unchanged and now the whole of what is open**. The +audit's one *upstream* watched-not-grounding row — +`dec/ddd/workspace-member-delivery` tracking the What/How vocabulary — is still +unfiled, and the reason is precise: `revisit_if` gives the *relation* a home; it +does not give a *cross-repo pointer* one. That needs a reference shape carrying +a repo and a revision, which is a separate amendment that has not landed and +was not in flight on 2026-08-13. The row is blocked on it, not forgotten. When +that shape lands the row becomes expressible with no further change to this +edge type — one shape, both pointer fields. + +§4.4's cross-repo count is untouched: still zero declared cross-repo edges. diff --git a/docs/ddd-format-migrations.md b/docs/ddd-format-migrations.md index 21e45e0..95219a1 100644 --- a/docs/ddd-format-migrations.md +++ b/docs/ddd-format-migrations.md @@ -176,6 +176,73 @@ The field is what a promoting repo checks before inheriting an entry SDK the destination does not run is worse than no entry, because it carries the catalog's authority without holding there. +## Format 7 (the reopen edge, 2026-08-13) + +Formats 1–6 remain valid unchanged. Format 7 exists for one ruling, and +adopts into this store the one shape the ledger format specifies at spec +v1.5 / `format: 4` (`ledger-format-v1.md` §3.7) — the same one-shape rule +the M8 `contract:` scheme followed. + +### Decisions: `revisit_if`, the reopen edge + +The principal ruled (2026-08-13) that a **watched-not-grounding edge is a +distinct edge type, not a basis**: this claim's death reopens the decision; +it is not the decision's ground. + +```yaml +format: 7 +based_on: + - type: mandate + statement: >- + The principal's ruling settling PRD §14 question 2 … +revisit_if: + - claim: DDD-adapter-02 + status: projected + changed: 2026-08-06 + content: sha256:… +``` + +Rules that arrive with it: + +- **A reopen edge is never a basis.** It lives in its own field, carries its + own type, and is scanned by its own pass. `based_on`'s basis-loss scan + cannot see it and `ddd why` renders it under its own heading, below the + ground and visibly apart. Declaring `revisit_if` under formats 1–6 is a + violation. +- **Every reopen edge pins**, `content:` included — an unpinned tripwire + cannot fire, so an edge without one is a violation. The pin is the + claim's canonical content hash (`ddd.claim-content.v1`), exactly as a + format-6 claim basis pins. +- **One claim is ground or tripwire, never both.** A claim appearing in + both lists on one decision is a violation: an edge read both ways is + precisely the conflation the distinct type exists to end. +- **It reports as `reopen`, never as basis loss.** `ddd report escapes` + gains a fourth section with its own heading and its own message: a fired + tripwire means the decision is due a fresh look, its ground untouched. + A lost basis means the ground moved. Merging the two tells the reader + neither. +- **It is hashed content.** `revisit_if` joins `decision_content_hash` + under its own key, so re-typing an edge from ground to tripwire moves the + decision's content hash — which is what makes the re-decision visible to + the ledger entry pinning it rather than a silent rewrite. + +**Migrating a format-5/6 decision:** set `format: 7`, move the claim edge +out of `based_on` into `revisit_if`, and carry its pin across unchanged +(`status`, `changed`, and the `content` hash at that pinned state — the M8 +migration's recovered hashes are the ones already in the ledger's `watched:` +tokens). Do **not** re-pin at the claim's current state: a recorded drift is +the record that the movement was looked at, and re-pinning would erase it. +Then file a new ledger version for the decision, re-taking its +`ddd-content:` pin at the entry's new content and stating the reopen edge — +a re-decision filed for the principal's acceptance, never a silent rewrite. +`ddd content-hash ` prints the pin to state. + +**Migrated by this amendment:** the three edges the M8 migration carried as +provisional `watched:` markers — `DDD-adapter-02` on +`dec/ddd/internal-not-surface`, `DDD-gates-01` on `dec/rust/no-unwrap`, +`DDD-adapter-01` on `dec/ddd/m6-proceeds-no-flip`. The third is the one +whose pin has drifted; it fires, now as a reopen finding. + ## Format 6 (M8, 2026-08) Formats 1–5 remain valid unchanged. Format 6 exists for the two M8 diff --git a/docs/ledger-format-migrations.md b/docs/ledger-format-migrations.md index bacd94f..483ec84 100644 --- a/docs/ledger-format-migrations.md +++ b/docs/ledger-format-migrations.md @@ -19,6 +19,70 @@ only adds an unhashed field. --- +## Format 4 / Spec v1.5 — the `revisit_if` reopen edge (2026-08-13) + +A **`format` bump without a `CANONICAL_FORM` bump**, by the same +reasoning as formats 2 and 3: `format: 4` adds exactly one optional +version field, `revisit_if` — a list of pointers to claims whose *death +reopens* the decision. The field is hashed when present; an absent key is +omitted from the canonical object (spec §4.2 step 3), so every version +written before it existed canonicalises to byte-identical content. No +digest moves, no acceptance is invalidated, the prefix stays +`ledger.decision-version.v1`, and the file gate's ten classes are +unchanged. + +**What was ruled.** The principal ruled (2026-08-13) that a +watched-not-grounding edge is a **distinct edge type, not a basis**: this +claim's death reopens the decision; it is not the decision's ground. It is +filed as its own edge with its own vocabulary, never inside `based_on`, so +neither the basis-loss scan nor `why` reads it as ground — and a claim's +status movement on a `revisit_if` edge produces a **reopen** finding, not a +basis-loss finding. The two mean different things and must report +differently. This settles the question the 2026-08 basis-quality re-typing +session left open and the ddd M8 migration carried as a provisional +`watched:` marker inside `based_on`. + +Rules that arrive with it (spec §3.7): + +- A writer declares `format: 4` only on a change-set that actually carries + a `revisit_if` — a store that never states one remains a pure + format-1/2/3 store. A lower-format file carrying the field is a schema + fault (the `merged_from` rule, applied to a field again). +- The two edge lists canonicalise under separate keys, so one token filed + as ground and the same token filed as a reopen edge are different + content: an acceptance always names which of the two it signed. +- Resolution — does the named claim exist, has it moved — is not the file + gate's business, the same posture as every discharge scheme and every + basis pointer at L0. The reopen finding is a consumer's report + (`ddd report escapes`), never an eleventh class. + +**Migration note.** Nothing to migrate mechanically: existing stores are +format 1–3 and stay valid. What *was* migrated is the three provisional +`watched:` markers the M8 migration filed inside `based_on` +(`DDD-adapter-02` on `dec/ddd/internal-not-surface`, `DDD-gates-01` on +`dec/rust/no-unwrap`, `DDD-adapter-01` on `dec/ddd/m6-proceeds-no-flip`). +Each moved to a real `revisit_if` edge as a **new version filed for the +principal's acceptance** — a re-decision of that entry's edge, not a +silent rewrite. Their prior versions keep signing exactly the historical +content they named; the new versions await a fresh signature. The +`.ddd/` store adopts the same one shape at its own format 7 +(`ddd-format-migrations.md`). + +**Left blocked, deliberately.** The 2026-08 provenance audit's one +*upstream* watched-not-grounding row — `dec/ddd/workspace-member-delivery` +tracking the What/How vocabulary — is still not expressible: it needs a +cross-repo reference shape (a repo pin plus a revision), which is the +subject of a separate amendment that has **not landed**. `revisit_if` gives +the relation a home; it does not give a cross-repo pointer one. That row +stays unfiled until the cross-repo amendment lands, at which point it +becomes expressible with no further change to this field. + +**Renumbering note:** the L6 signing revision, which had renumbered from +`format: 2` to `format: 3` when L3 consumed its slot and to `format: 4` +when M8 consumed that one, renumbers a third time to **spec v1.6 / +`format: 5`**. Nothing else about the L6 plan changes; it remains ruled +and unimplemented. + ## Format 3 / Spec v1.4 — the `contract:` discharge scheme (2026-08-12, ddd M8) A **`format` bump without a `CANONICAL_FORM` bump**, by the same @@ -47,8 +111,9 @@ Rules that arrive with it: **Renumbering note:** the L6 signing revision, which had renumbered from `format: 2` to `format: 3` when L3 consumed its slot, renumbers a second -time to **spec v1.5 / `format: 4`**. Nothing else about the L6 plan -changes; it remains ruled and unimplemented. +time to **spec v1.5 / `format: 4`**. (Renumbered again by the v1.5 reopen +edge — see the format-4 entry above; L6 now holds spec v1.6 / `format: 5`.) +Nothing else about the L6 plan changes; it remains ruled and unimplemented. **Migration note:** nothing to migrate. Existing stores stay valid; the first consumers of the scheme are the ddd M8 migration's seam-declaration @@ -173,11 +238,12 @@ Recorded now so the shape of the change is not a surprise. further `format` bump with a migration path for entries that carry none (this entry originally said `format: 2`, a number since consumed by L3's `merged_from`). Hashing is unaffected: `expires_at` is not hashed. -- **OD-3 — signatures. Scheduled 2026-08-11 as milestone L6 / spec v1.5 / - `format: 4`; unimplemented.** This entry originally read "populating +- **OD-3 — signatures. Scheduled 2026-08-11 as milestone L6 / spec v1.6 / + `format: 5`; unimplemented.** This entry originally read "populating `signature` is a `format: 2`" — that number was consumed by L3's - `merged_from`, and the renumbered `format: 3` slot in turn by M8's - `contract:` scheme, so the signing bump renumbers to `format: 4`; + `merged_from`, the renumbered `format: 3` slot in turn by M8's + `contract:` scheme, and `format: 4` by v1.5's `revisit_if` edge, so the + signing bump renumbers to `format: 5`; nothing else about the plan changes. What the revision will occupy (PRD §4.5): `signature` goes live as a detached, certificate-based signature (git's @@ -195,4 +261,11 @@ Recorded now so the shape of the change is not a surprise. - **§9.4 — upstreams manifest.** A new file schema, not a change to these two. Closing the `based_on` vocabulary at that point **is** a hashed-meaning change and would require a `CANONICAL_FORM` bump, so the closure should - arrive as validation over an unchanged canonical form instead. + arrive as validation over an unchanged canonical form instead. The same + reasoning applies to `revisit_if`, whose vocabulary is open for the same + reason and closes by the same route. +- **The cross-repo reference shape.** A basis or reopen pointer that names + a *repository* and a revision, so an edge can cross a store boundary. + Not landed as of 2026-08-13; the 2026-08 provenance audit's one upstream + watched-not-grounding row waits on it (see the format-4 entry). Whatever + shape it takes applies to both pointer fields — one shape, both edges. diff --git a/docs/ledger-format-v1.md b/docs/ledger-format-v1.md index 377c905..367c085 100644 --- a/docs/ledger-format-v1.md +++ b/docs/ledger-format-v1.md @@ -1,7 +1,13 @@ # Decision Ledger — Entry Format v1 -**Status:** normative for `format: 1`, `format: 2` and `format: 3`. -Specification revision **v1.4** (2026-08-12, ddd M8): introduces +**Status:** normative for `format: 1` through `format: 4`. +Specification revision **v1.5** (2026-08-13): introduces `format: 4`, +which adds one optional version field, `revisit_if` — the reopen edge, +ruled by the principal a **distinct edge type and never a basis** (§3.7). +The field is hashed when present and omitted when absent, so **every +existing digest is unchanged** and `CANONICAL_FORM` does not bump; the +file gate's ten classes are unchanged. Revision +**v1.4** (2026-08-12, ddd M8): introduces `format: 3`, which adds one discharge scheme, `contract:` — the repository-diff contract check as a discharge kind (§3.4). No field changes, no hashed-meaning changes: **every existing digest is @@ -241,6 +247,7 @@ versions: tolerance_floor_at_creation: T1 tolerance_override: T2 # optional, strictly above the pin based_on: [prd:decision-ledger-prd#4.2.1] + revisit_if: [claim:DDD-adapter-02@sha256:…] # format 4 only; not ground supersedes: dec:… # optional; no command at L0 acceptances: @@ -278,11 +285,12 @@ a shippable state, which is what `L001` says. `signature` is reserved and must be empty under `format: 1`. It exists so a cryptographic upgrade (OD-3) is additive rather than a migration; its format -is deliberately unspecified. That upgrade is now scheduled — spec v1.5 / -`format: 4`, PRD §4.5 / milestone L6 (renumbered a second time: its -original `format: 2` slot was consumed by L3's `merged_from`, and its -`format: 3` slot by the M8 `contract:` scheme; nothing else about the -plan changes) — and remains **planned, not normative**: under every +is deliberately unspecified. That upgrade is now scheduled — spec v1.6 / +`format: 5`, PRD §4.5 / milestone L6 (renumbered a third time: its +original `format: 2` slot was consumed by L3's `merged_from`, its +`format: 3` slot by the M8 `contract:` scheme, and its `format: 4` slot by +the v1.5 `revisit_if` edge; nothing else about the plan changes) — and +remains **planned, not normative**: under every format this document specifies, a non-empty `signature` is still a schema fault (§6, last bullet). @@ -301,6 +309,63 @@ named, and the reconciled content awaits a fresh signature. vocabulary now would reject adopters' existing reference schemes for no gain. §9.4 of the PRD closes it at L4. +`revisit_if` is specified in §3.7. It is **not** part of `based_on` and +never appears inside it. + +### 3.7 The reopen edge — `revisit_if` (format 4) + +Ruled by the principal (2026-08-13), settling the watched-edge question the +2026-08 basis-quality re-typing session left open and the ddd M8 migration +carried as a provisional `watched:` marker *inside* `based_on`. + +A `revisit_if` pointer names a claim whose **death reopens the decision**. +That is the converse of ground, not a weaker form of it: the decision does +not rest on the claim, so falsifying the claim does not undermine the +decision — it obliges someone to look at it again. + +```yaml +format: 4 +versions: + - decision: dec:hafeok.ddd/01KZ… + based_on: [mandate:dec/ddd/internal-not-surface] + revisit_if: [claim:DDD-adapter-02@sha256:b333063d…] +``` + +Rules: + +- **A reopen edge is never a basis.** It lives in its own field with its + own vocabulary. Writing one inside `based_on` — as a `watched:` token or + under any other marker — is not the way to say this, and a consumer must + not read `revisit_if` as ground. In the reference implementation the two + are distinct *types* (`RevisitRef`, `BasisRef`), so the separation is not + a convention anyone can forget. +- **The two report differently.** A claim on a `based_on` edge moving + produces a **basis-loss** finding — the ground shifted under a standing + decision. A claim on a `revisit_if` edge moving produces a **reopen** + finding — the tripwire fired and the decision is due a fresh look. These + are different facts about a decision and a report that merges them tells + the reader neither. Neither finding is a gate class (see below). +- **Same declare-what-you-need rule as formats 2 and 3.** A change-set + declares `format: 4` only when one of its versions actually carries a + `revisit_if`; a store that never states one stays a pure format-1/2/3 + store, and a lower-format file carrying the field is a schema fault. +- **The vocabulary is open**, exactly as `based_on`'s is: L0 dereferences + no pointer. Open is not shared — the pointer types stay distinct. +- **Hashing.** `revisit_if` joins the hashed field set as a list (a *set*, + like `discharge` and `based_on`: deduplicated, code-point sorted, + reordering is formatting). An absent key is omitted from the canonical + object (§4.2 step 3), so every version written before the field existed + canonicalises to byte-identical content: no digest moves, no acceptance + is invalidated, and the prefix stays `ledger.decision-version.v1`. The + two lists canonicalise under **separate keys**, so one token filed as + ground and the same token filed as a reopen edge are different content — + an acceptance always names which of the two it signed. +- **Not a gate class.** The file gate's ten classes are unchanged: nothing + here fails `verify`. Resolving a reopen pointer — does the claim exist, + has it moved — is a consumer's business at L0, the same posture every + discharge scheme and every basis pointer already has. An eleventh class + would be a further format-spec change, by the `L010` mechanism. + --- ## 4. Canonicalisation and hashing @@ -321,14 +386,14 @@ Exactly these keys, and no others: decision · parent · merged_from · set · statement · allocation · discharge · discharge_stage · actor · expectation · exposure · accepted_by · review_by · tolerance_floor_at_creation · -tolerance_override · based_on · supersedes +tolerance_override · based_on · revisit_if · supersedes ``` -`merged_from` joined the set at spec v1.3 (`format: 2`). Because an absent -key is omitted from the canonical object (§4.2 step 3), every version -written before the field existed canonicalises to the same bytes as -before: no digest moved, no acceptance was invalidated, and -`CANONICAL_FORM` stays `v1`. +`merged_from` joined the set at spec v1.3 (`format: 2`) and `revisit_if` at +spec v1.5 (`format: 4`). Because an absent key is omitted from the canonical +object (§4.2 step 3), every version written before either field existed +canonicalises to the same bytes as before: no digest moved, no acceptance +was invalidated, and `CANONICAL_FORM` stays `v1`. Outside the hash: the `hash` field itself (including it would be circular), everything at change-set level (`format`, `id`, `created_at`, `created_by`, @@ -351,7 +416,7 @@ content. 3. **Treat as absent**: a missing key, an explicit `null`, an empty collection, and any string that step 2 reduces to the empty string. Absent keys are omitted from the object; there is no `null` in the canonical form. -4. **List fields are sets.** `discharge` and `based_on` are rendered as their +4. **List fields are sets.** `discharge`, `based_on` and `revisit_if` are rendered as their members' canonical string forms, deduplicated, then sorted ascending by Unicode code point. Reordering a list in a file is formatting. 5. Emit a JSON object with keys sorted ascending by Unicode code point, with @@ -527,9 +592,10 @@ Stated so an adopter meets them in this document rather than in production. milestone. Late-discovery rate is the lagging proxy. - **ULID generation is not specified here** because L0 mints no ids. L1's `add` needs it. -- **Planned, not yet normative — spec v1.5 / `format: 4` (PRD §4.5, +- **Planned, not yet normative — spec v1.6 / `format: 5` (PRD §4.5, milestone L6; ruled 2026-08-11, unimplemented; renumbered by the M8 - `contract:` scheme consuming `format: 3`).** The signing revision: + `contract:` scheme consuming `format: 3` and the v1.5 reopen edge + consuming `format: 4`).** The signing revision: `signature` goes live as a detached, certificate-based signature (git's `gpg.format` trio — `openpgp` | `ssh` | `x509`) over a canonical acceptance payload — decision id, version hash, actor, signing timestamp diff --git a/ledger-cli/Cargo.toml b/ledger-cli/Cargo.toml index 99456af..5dbbaf4 100644 --- a/ledger-cli/Cargo.toml +++ b/ledger-cli/Cargo.toml @@ -23,6 +23,13 @@ dist = false [dependencies] ledger-core = { path = "../ledger-core", version = "0.6.0" } +# Display-only, and only for `ledger show`: the argument behind a basis +# pointer lives in the store that pointer names, and for this repo's +# entries that store is `.ddd/`. Kept out of `ledger-core` — the substrate +# an outside implementation imports stays ontology-free (OD-2), and this +# binary's read is inert when no `.ddd/` exists, the mirror of `ddd`'s own +# inert read of `.decisions/`. +ddd-core = { path = "../ddd-core", version = "0.6.0" } chrono = "0.4" clap = { version = "4", features = ["derive"] } serde_json = "1" diff --git a/ledger-cli/src/commands/add.rs b/ledger-cli/src/commands/add.rs index 9ac76af..38a4b11 100644 --- a/ledger-cli/src/commands/add.rs +++ b/ledger-cli/src/commands/add.rs @@ -4,7 +4,9 @@ use std::path::PathBuf; use ledger_core::author::AddArgs; -use super::common::{allocation_args, finish, open_author, parse_based_on, parse_tier}; +use super::common::{ + allocation_args, finish, open_author, parse_based_on, parse_revisit_if, parse_tier, +}; pub struct Flags { pub set: String, @@ -17,6 +19,8 @@ pub struct Flags { pub actor: Option, pub tolerance_override: Option, pub based_on: Vec, + pub revisit_if: Vec, + pub note: Option, } pub fn run(root: Option, flags: Flags) -> Result { @@ -34,7 +38,8 @@ pub fn run(root: Option, flags: Flags) -> Result { )?, tolerance_override: flags.tolerance_override.as_deref().map(parse_tier).transpose()?, based_on: parse_based_on(&flags.based_on)?, - note: None, + revisit_if: parse_revisit_if(&flags.revisit_if)?, + note: flags.note, }; finish(author.add(args)) } diff --git a/ledger-cli/src/commands/basis_text.rs b/ledger-cli/src/commands/basis_text.rs new file mode 100644 index 0000000..44cd0b2 --- /dev/null +++ b/ledger-cli/src/commands/basis_text.rs @@ -0,0 +1,91 @@ +//! Resolving a basis pointer to the argument it carries. +//! +//! A pointer's *type* is not the thing a principal rules on. `mandate:…` +//! says an edge is a mandate; what the mandate asserts is one sentence +//! away, in the store the pointer names. For this repo that store is +//! `.ddd/`, so this resolver reads it — display only, never a correctness +//! path, and silent when the store is absent. +//! +//! `ledger-core` stays free of it deliberately: the substrate an outside +//! implementation imports must not carry one adopter's ontology. The +//! resolver is an input to the screen, not part of it. + +use std::collections::BTreeMap; +use std::path::Path; + +use ledger_core::show::BasisText; + +/// Arguments resolved once per pass, keyed by pointer. +pub struct DddBasisText { + by_pointer: BTreeMap, +} + +impl BasisText for DddBasisText { + fn text(&self, pointer: &str) -> Option { + self.resolve(pointer) + } +} + +impl DddBasisText { + /// Read every argument the `.ddd/` store can supply. An absent store + /// yields an empty resolver, which renders bare pointers — the same + /// output a store-less adopter gets. + pub fn load(root: &Path) -> Self { + let mut by_pointer = BTreeMap::new(); + let dir = root.join(ddd_core::store::STORE_DIR); + if !dir.is_dir() { + return Self { by_pointer }; + } + let store = ddd_core::store::load(&dir); + for d in &store.decisions { + index_decision(&mut by_pointer, d); + } + for c in &store.claims { + // A claim pointer carries a content pin, so the key is a + // prefix match rather than an equality — indexed by id here + // and matched in `text` through the prefix table below. + by_pointer.insert(format!("claim:{}", c.id), c.statement.trim().to_string()); + } + for s in &store.seams { + by_pointer + .insert(format!("ddd-content:{}", s.id), s.verdict_knowledge.trim().to_string()); + } + Self { by_pointer } + } + + /// Resolve a pointer, tolerating the `@sha256:…` pin every content + /// pointer carries: the pin identifies a state, the text is the same + /// text either way, and refusing to render it because of the suffix + /// would leave the reader with a type and no argument. + pub fn resolve(&self, pointer: &str) -> Option { + if let Some(hit) = self.by_pointer.get(pointer) { + return Some(hit.clone()); + } + let unpinned = pointer.split('@').next()?; + self.by_pointer.get(unpinned).cloned() + } +} + +/// A decision contributes two kinds of argument: its own rationale, behind +/// the `ddd-content:` pointer that pins it, and each typed basis statement, +/// behind the `:` pointer the migration wrote. +fn index_decision(out: &mut BTreeMap, d: &ddd_core::decision::Decision) { + out.insert(format!("ddd-content:{}", d.id), d.rationale.trim().to_string()); + for basis in &d.based_on { + let ddd_core::decision::BasedOn::Typed(t) = basis else { continue }; + let Some(statement) = t.statement.as_deref().map(str::trim).filter(|s| !s.is_empty()) + else { + continue; + }; + // Several typed bases of the same kind on one decision would + // collide on this key. Joining rather than overwriting keeps every + // argument visible; a silently dropped one is the failure mode + // that matters here. + out.entry(format!("{}:{}", t.basis_type.as_str(), d.id)) + .and_modify(|existing| { + existing.push('\n'); + existing.push_str(statement); + }) + .or_insert_with(|| statement.to_string()); + } +} diff --git a/ledger-cli/src/commands/common.rs b/ledger-cli/src/commands/common.rs index cdfb522..930fe2a 100644 --- a/ledger-cli/src/commands/common.rs +++ b/ledger-cli/src/commands/common.rs @@ -111,3 +111,9 @@ pub fn allocation_args( pub fn parse_based_on(raw: &[String]) -> Result, String> { raw.iter().map(|b| b.parse()).collect() } + +/// Reopen edges parse through their own type, never through `BasisRef` — +/// the distinction the ruling draws is a type distinction here too. +pub fn parse_revisit_if(raw: &[String]) -> Result, String> { + raw.iter().map(|r| r.parse()).collect() +} diff --git a/ledger-cli/src/commands/evolve.rs b/ledger-cli/src/commands/evolve.rs index c388c42..6eb8741 100644 --- a/ledger-cli/src/commands/evolve.rs +++ b/ledger-cli/src/commands/evolve.rs @@ -36,17 +36,38 @@ pub fn escape( finish(author.escape(&decision(id)?, args)) } +/// What a revision states about its reopen edges: the flags as given. +pub struct ReviseEdges<'a> { + pub based_on: &'a [String], + pub revisit_if: &'a [String], + /// Drop every inherited reopen edge — the decision has been reopened + /// and re-decided, so the edge that reopened it is spent. + pub no_revisit_if: bool, + /// What this act was, recorded on the change-set. + pub note: Option, +} + pub fn revise( root: Option, id: &str, statement: String, - based_on: &[String], + edges: ReviseEdges<'_>, parent: Option<&str>, ) -> Result { let mut author = open_author(root)?; + let revisit_if = match (edges.no_revisit_if, edges.revisit_if.is_empty()) { + (true, false) => { + return Err("--no-revisit-if and --revisit-if state opposite things".to_string()) + } + (true, true) => Some(Vec::new()), + (false, true) => None, + (false, false) => Some(super::common::parse_revisit_if(edges.revisit_if)?), + }; let args = ReviseArgs { statement, - based_on: parse_based_on(based_on)?, + based_on: parse_based_on(edges.based_on)?, + revisit_if, + note: edges.note.clone(), expected_parent: parent.map(str::parse).transpose()?, }; finish(author.revise(&decision(id)?, args)) diff --git a/ledger-cli/src/commands/inspect.rs b/ledger-cli/src/commands/inspect.rs index ff85b4d..909cc39 100644 --- a/ledger-cli/src/commands/inspect.rs +++ b/ledger-cli/src/commands/inspect.rs @@ -1,4 +1,4 @@ -//! Adapters for the read-only projections: status, log, blame. +//! Adapters for the read-only projections: status, log, blame, show. use std::path::PathBuf; @@ -25,6 +25,54 @@ pub fn log(root: Option, set: Option<&str>) -> Result { Ok(EXIT_OK) } +/// What a `show` covers and how it prints. +pub struct ShowFlags { + pub decision: Option, + pub set: Option, + pub group: Option, + pub json: bool, + pub today: Option, +} + +/// Decisions on screen — the read a signature is given before it. +/// +/// A named id that has no filed version is an error, not an empty screen: +/// a blank read is the one outcome an acceptance pass must never mistake +/// for "nothing to sign". A selector that matches nothing says so plainly. +pub fn show(root: Option, flags: ShowFlags) -> Result { + let repo_root = resolve_root(root)?; + let today = match flags.today.as_deref() { + Some(raw) => parse_date(raw)?, + None => Utc::now().date_naive(), + }; + let selector = selector(&flags)?; + let store = store::load(&repo_root); + let texts = super::basis_text::DddBasisText::load(&repo_root); + let screens = ledger_core::show::screens(&store, &selector, today, &texts); + if let (Some(id), true) = (&flags.decision, screens.is_empty()) { + return Err(format!("{id} has no filed version in this store")); + } + if flags.json { + println!("{}", serde_json::to_string_pretty(&screens).map_err(|e| e.to_string())?); + } else { + print!("{}", ledger_core::show::render_all(&screens)); + } + Ok(EXIT_OK) +} + +/// Exactly one selector, so a pass never silently covers more than the +/// caller asked for. No selector at all reads the whole store. +fn selector(flags: &ShowFlags) -> Result { + use ledger_core::show::{Group, Selector}; + match (&flags.decision, &flags.set, &flags.group) { + (Some(id), None, None) => Ok(Selector::One(id.parse()?)), + (None, Some(set), None) => Ok(Selector::Set(set.clone())), + (None, None, Some(group)) => Ok(Selector::Group(Group::parse(group)?)), + (None, None, None) => Ok(Selector::All), + _ => Err("name a decision, or --set, or --group — not more than one".to_string()), + } +} + pub fn blame(root: Option, decision: &str) -> Result { let repo_root = resolve_root(root)?; println!("{}", author::blame(&store::load(&repo_root), &decision.parse()?)); diff --git a/ledger-cli/src/commands/mod.rs b/ledger-cli/src/commands/mod.rs index 5fb7cde..3c5e935 100644 --- a/ledger-cli/src/commands/mod.rs +++ b/ledger-cli/src/commands/mod.rs @@ -7,6 +7,7 @@ //! exists on this side of the crate boundary. mod add; +mod basis_text; mod common; mod declare; mod diff_cmd; @@ -70,6 +71,12 @@ pub enum Commands { /// Basis pointer(s) this decision rests on (repeatable) #[arg(long, value_name = "BASIS")] based_on: Vec, + /// Claim(s) whose death reopens this decision — never ground + #[arg(long, value_name = "REF")] + revisit_if: Vec, + /// What this act was, recorded on the change-set + #[arg(long)] + note: Option, }, /// Allocate (or re-allocate) a decision by filing its next version Allocate { @@ -179,6 +186,15 @@ pub enum Commands { statement: String, #[arg(long, value_name = "BASIS")] based_on: Vec, + /// Reopen edge(s) this version states, replacing the inherited set + #[arg(long, value_name = "REF")] + revisit_if: Vec, + /// Drop every inherited reopen edge (the decision was reopened) + #[arg(long)] + no_revisit_if: bool, + /// What this act was, recorded on the change-set + #[arg(long)] + note: Option, /// The hash believed to be the tip; refused when stale (merge is L3) #[arg(long, value_name = "HASH")] parent: Option, @@ -189,6 +205,23 @@ pub enum Commands { #[arg(long)] reason: String, }, + /// Decisions on screen: content, edges with their arguments, filing, + /// acceptance. One id, or a whole set or group in one pass. + Show { + decision: Option, + /// Every decision in one set + #[arg(long, value_name = "SET")] + set: Option, + /// Every decision in one weight class: mechanical | individual + #[arg(long, value_name = "GROUP")] + group: Option, + /// Emit the screens as JSON + #[arg(long)] + json: bool, + /// Judge acceptance expiry against this date instead of today + #[arg(long, value_name = "DATE")] + today: Option, + }, /// Every decision's disposition state, grouped by set Status { /// Judge expiry against this date instead of today @@ -235,32 +268,28 @@ pub fn run(command: Commands, root: Option) -> i32 { } } +/// Dispatch. Split in two along the line the store itself draws: verbs +/// that append to the log, and reads that never touch it. fn dispatch(command: Commands, root: Option) -> Result { match command { Commands::Accept { decision, expires } => sign::accept(root, &decision, expires.as_deref()), Commands::Add { set, statement, namespace, store, discharge, stage, expectation, actor, - tolerance_override, based_on, + tolerance_override, based_on, revisit_if, note, } => add::run(root, add::Flags { set, statement, namespace, store, discharge, stage, expectation, actor, - tolerance_override, based_on, + tolerance_override, based_on, revisit_if, note, }), Commands::Allocate { decision, store, discharge, stage, expectation, actor } => { evolve::allocate(root, &decision, &store, &discharge, stage.as_deref(), expectation, actor.as_deref()) } - Commands::Blame { decision } => inspect::blame(root, &decision), - Commands::Coverage { set, json, today } => { - graph_cmds::coverage(root, set.as_deref(), json, today.as_deref()) - } Commands::Declare { set, title, tolerance_floor, ground, owner, notes } => { declare::run(root, declare::Flags { set, title, tolerance_floor, ground, owner, notes }) } - Commands::Diff { spec, json } => diff_cmd::run(root, &spec, json), Commands::Escape { decision, exposure, review_by } => { evolve::escape(root, &decision, exposure, &review_by) } Commands::Init => init::run(root), - Commands::Log { set } => inspect::log(root, set.as_deref()), Commands::Merge { rev, resolve, install, json } => { merge_cmd::run(root, merge_cmd::Flags { rev, resolve, install, json }) } @@ -268,17 +297,42 @@ fn dispatch(command: Commands, root: Option) -> Result { merge_cmd::driver(&base, &ours, &theirs, &path) } Commands::Reindex => graph_cmds::reindex(root), - Commands::Revise { decision, statement, based_on, parent } => { - evolve::revise(root, &decision, statement, &based_on, parent.as_deref()) + Commands::Revise { + decision, statement, based_on, revisit_if, no_revisit_if, note, parent + } => { + let edges = evolve::ReviseEdges { + based_on: &based_on, revisit_if: &revisit_if, no_revisit_if, note, + }; + evolve::revise(root, &decision, statement, edges, parent.as_deref()) } Commands::Revoke { acceptance, reason } => sign::revoke(root, &acceptance, reason), - Commands::Status { today } => inspect::status(root, today.as_deref()), Commands::Supersede { decision, by, reason } => { evolve::supersede(root, &decision, &by, reason) } + read => dispatch_read(read, root), + } +} + +/// The read-only projections: nothing here appends to the log. +fn dispatch_read(command: Commands, root: Option) -> Result { + match command { + Commands::Blame { decision } => inspect::blame(root, &decision), + Commands::Coverage { set, json, today } => { + graph_cmds::coverage(root, set.as_deref(), json, today.as_deref()) + } + Commands::Diff { spec, json } => diff_cmd::run(root, &spec, json), + Commands::Log { set } => inspect::log(root, set.as_deref()), + Commands::Show { decision, set, group, json, today } => { + inspect::show(root, inspect::ShowFlags { decision, set, group, json, today }) + } + Commands::Status { today } => inspect::status(root, today.as_deref()), Commands::Verify { gate, json, today, no_blame } => { verify::run(root, verify::Args { gate, json, today, blame: !no_blame }) } + // Every writing verb is handled by `dispatch`, which routes here + // only for what is left. Reaching this arm means a variant was + // added to the enum and to neither match — reported, never panicked. + _ => Err("internal: this subcommand is not wired into either dispatch half".to_string()), } } diff --git a/ledger-cli/tests/cli.rs b/ledger-cli/tests/cli.rs index c828686..813f2a2 100644 --- a/ledger-cli/tests/cli.rs +++ b/ledger-cli/tests/cli.rs @@ -86,8 +86,8 @@ fn the_surface_is_the_format_the_verbs_the_graph_and_merge() { let out = cmd.arg("--help").output().expect("run"); let text = stdout(&out); for present in [ - "init", "verify", "accept", "add", "allocate", "revoke", "status", "supersede", "diff", - "merge", + "init", "verify", "accept", "add", "allocate", "revoke", "show", "status", "supersede", + "diff", "merge", ] { assert!(text.contains(&format!(" {present}")), "{present} is missing: {text}"); } diff --git a/ledger-cli/tests/show.rs b/ledger-cli/tests/show.rs new file mode 100644 index 0000000..9075676 --- /dev/null +++ b/ledger-cli/tests/show.rs @@ -0,0 +1,192 @@ +//! `ledger show` end to end: the screen, the selectors, the reopen edge. +//! +//! The reopen edge is exercised here rather than only in the library because +//! the ruling it implements is about what a *reader* sees — that ground and +//! tripwire never share a block — and that is a property of the rendered +//! screen, not of the struct behind it. + +use std::path::Path; +use std::process::Output; + +use assert_cmd::Command; + +const TODAY: &str = "2026-08-13"; + +struct Repo { + dir: tempfile::TempDir, +} + +impl Repo { + fn new() -> Self { + let dir = tempfile::tempdir().expect("tempdir"); + let repo = Self { dir }; + repo.git(&["init", "-q"]); + repo.git(&["config", "user.email", "fixture-human@example"]); + repo.git(&["config", "user.name", "Fixture Human"]); + repo.ok(&["init"]); + repo.ok(&["declare", "--set", "shown", "--tolerance-floor", "T1"]); + repo + } + + fn path(&self) -> &Path { + self.dir.path() + } + + fn git(&self, args: &[&str]) { + Command::new("git") + .current_dir(self.path()) + .args(args) + .output() + .expect("git"); + } + + fn run(&self, args: &[&str]) -> Output { + let mut cmd = Command::cargo_bin("ledger").expect("binary"); + cmd.current_dir(self.path()).arg("--root").arg(self.path()).args(args); + cmd.output().expect("run") + } + + fn ok(&self, args: &[&str]) -> String { + let out = self.run(args); + assert_eq!( + out.status.code(), + Some(0), + "`{}` failed: {}", + args.join(" "), + String::from_utf8_lossy(&out.stderr) + ); + String::from_utf8_lossy(&out.stdout).into_owned() + } + + /// Add a decision and return its id. + fn add(&self, statement: &str, extra: &[&str]) -> String { + let mut args = vec![ + "add", "--set", "shown", "--namespace", "fixture.shown", + "--statement", statement, "--store", "constraint", + "--discharge", "analyzer:DEC001", + ]; + args.extend_from_slice(extra); + let text = self.ok(&args); + text.lines() + .next() + .and_then(|l| l.split_whitespace().nth(1)) + .expect("decision id in output") + .to_string() + } +} + +#[test] +fn the_screen_carries_what_a_signature_needs() { + let repo = Repo::new(); + let id = repo.add("Monetary amounts use decimal, never double.", &[ + "--based-on", "prd:decision-ledger-prd#4.2.1", + "--note", "the filing act", + ]); + let text = repo.ok(&["show", &id, "--today", TODAY]); + for needle in [ + "awaiting-acceptance", + "shown (floor T1)", + "Monetary amounts use decimal, never double.", + "constraint", + "prd:decision-ledger-prd#4.2.1", + "the filing act", + "none on record", + ] { + assert!(text.contains(needle), "`{needle}` missing from:\n{text}"); + } + assert!(text.contains(&format!("ledger accept {id}")), "{text}"); +} + +/// The ruling, at the surface a reader actually meets: the reopen edge has +/// its own block and never appears inside the ground. +#[test] +fn ground_and_reopen_never_share_a_block() { + let repo = Repo::new(); + let id = repo.add("internal is not contract surface by default.", &[ + "--based-on", "mandate:dec/ddd/internal-not-surface", + "--revisit-if", "claim:DDD-adapter-02@sha256:b333063d", + ]); + let text = repo.ok(&["show", &id, "--today", TODAY]); + let ground = text.find("based on").expect("ground block"); + let reopen = text.find("revisit if").expect("reopen block"); + assert!(ground < reopen, "{text}"); + assert!( + !text[ground..reopen].contains("DDD-adapter-02"), + "a reopen edge rendered as ground:\n{text}" + ); + assert!(text[reopen..].contains("DDD-adapter-02"), "{text}"); + + // And the same fact in the machine form, under its own key. + let json = repo.ok(&["show", &id, "--json", "--today", TODAY]); + let parsed: serde_json::Value = serde_json::from_str(&json).expect("json"); + assert_eq!(parsed[0]["revisit_if"][0]["pointer"], "claim:DDD-adapter-02@sha256:b333063d"); + assert_eq!(parsed[0]["based_on"][0]["pointer"], "mandate:dec/ddd/internal-not-surface"); +} + +/// A store carrying a reopen edge declares the format that needs it, and a +/// store without one stays where it was. +#[test] +fn a_reopen_edge_declares_format_four_and_nothing_else_does() { + let repo = Repo::new(); + repo.add("A decision with no reopen edge.", &[]); + repo.add("A decision that states one.", &[ + "--revisit-if", "claim:DDD-gates-01@sha256:6f500ee8", + ]); + let mut formats: Vec = std::fs::read_dir(repo.path().join(".decisions/log")) + .expect("log dir") + .filter_map(|e| e.ok()) + .filter_map(|e| std::fs::read_to_string(e.path()).ok()) + .filter_map(|t| { + t.lines() + .find_map(|l| l.strip_prefix("format: ").and_then(|n| n.trim().parse().ok())) + }) + .collect(); + formats.sort_unstable(); + assert_eq!(formats, vec![1, 4], "one file needs format 4, the other does not"); + assert_eq!(repo.run(&["verify", "--today", TODAY]).status.code(), Some(0)); +} + +#[test] +fn a_selector_reads_a_group_in_one_pass() { + let repo = Repo::new(); + // A criterion discharged solely by the contract check is a + // transcription; the analyzer-discharged constraints are not. + repo.add("An individual read.", &[]); + repo.add("Another individual read.", &[]); + let mechanical = repo.ok(&[ + "add", "--set", "shown", "--namespace", "fixture.shown", + "--statement", "pub fn canonical_json in ledger-core/src/canon.rs", + "--store", "criterion", "--discharge", "contract:seam/ledger/canonical-form", + "--stage", "pr", + ]); + assert!(mechanical.contains("added"), "{mechanical}"); + + let all = repo.ok(&["show", "--today", TODAY]); + assert!(all.starts_with("3 decision(s)"), "{all}"); + let group = repo.ok(&["show", "--group", "mechanical", "--today", TODAY]); + assert!(group.starts_with("1 decision(s)"), "{group}"); + assert!(group.contains("[mechanical]"), "{group}"); + let individual = repo.ok(&["show", "--group", "individual", "--today", TODAY]); + assert!(individual.starts_with("2 decision(s)"), "{individual}"); + let by_set = repo.ok(&["show", "--set", "shown", "--today", TODAY]); + assert!(by_set.starts_with("3 decision(s)"), "{by_set}"); + assert_eq!(repo.ok(&["show", "--set", "absent", "--today", TODAY]), "no decisions match\n"); +} + +#[test] +fn an_unknown_id_is_an_error_never_a_blank_screen() { + let repo = Repo::new(); + let out = repo.run(&["show", "dec:fixture.shown/01K2C4YQJ3F8M0PT5W7NZ9RDXV"]); + assert_eq!(out.status.code(), Some(2)); + let err = String::from_utf8_lossy(&out.stderr); + assert!(err.contains("has no filed version"), "{err}"); +} + +#[test] +fn two_selectors_at_once_are_refused_rather_than_silently_widened() { + let repo = Repo::new(); + let out = repo.run(&["show", "--set", "shown", "--group", "mechanical"]); + assert_eq!(out.status.code(), Some(2)); + let err = String::from_utf8_lossy(&out.stderr); + assert!(err.contains("not more than one"), "{err}"); +} diff --git a/ledger-core/src/author/decision.rs b/ledger-core/src/author/decision.rs index 59ffd18..dbf6e99 100644 --- a/ledger-core/src/author/decision.rs +++ b/ledger-core/src/author/decision.rs @@ -60,6 +60,8 @@ pub struct AddArgs { pub allocation: AllocationArgs, pub tolerance_override: Option, pub based_on: Vec, + /// Claims whose death reopens the decision — never ground (format 4). + pub revisit_if: Vec, /// Free text on the change-set — what this act was. pub note: Option, } @@ -127,6 +129,7 @@ fn first_version(decision: &DecisionId, args: &AddArgs, floor: Tier) -> VersionR tolerance_floor_at_creation: floor, tolerance_override: args.tolerance_override, based_on: args.based_on.clone(), + revisit_if: args.revisit_if.clone(), supersedes: None, }; args.allocation.apply(&mut raw); diff --git a/ledger-core/src/author/sign_tests.rs b/ledger-core/src/author/sign_tests.rs new file mode 100644 index 0000000..77addbb --- /dev/null +++ b/ledger-core/src/author/sign_tests.rs @@ -0,0 +1,196 @@ +//! The signing verbs: accept, revoke, supersede, plus their refusals. +//! +//! Split out of `tests.rs` at the workspace's 400-line ceiling. The seam is +//! the natural one: filing a decision and signing one are different acts, +//! and only the second is the principal's. + +use crate::finding::VerifyClass; +use crate::testkit; + +// The fixtures live in the sibling that files the decisions these tests +// sign; re-stating them here would be a second copy that can disagree. +use super::super::*; +use super::*; + +#[test] +fn accept_signs_the_tip_and_a_revision_returns_the_decision_to_awaiting() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut a = author(dir.path()); + declare(&mut a); + let id = add_constraint(&mut a); + a.accept(AcceptArgs { decision: id.clone(), expires_at: None }).expect("accept"); + let err = a.accept(AcceptArgs { decision: id.clone(), expires_at: None }).expect_err("twice"); + assert!(matches!(err, AuthorError::Conflict(_)), "{err}"); + + a.revise(&id, ReviseArgs { + statement: "Restated after acceptance.".into(), + based_on: Vec::new(), + revisit_if: None, + note: None, + expected_parent: None, + }) + .expect("revise"); + let text = report::status(&crate::store::load(dir.path()), testkit::date("2026-08-10")); + assert!(text.contains("awaiting-acceptance"), "the old signature is history: {text}"); +} + +#[test] +fn accept_refuses_an_expired_on_arrival_signature_with_l003() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut a = author(dir.path()); + declare(&mut a); + let id = add_constraint(&mut a); + let err = a + .accept(AcceptArgs { decision: id, expires_at: Some(testkit::date("2026-08-01")) }) + .expect_err("expired on arrival"); + let AuthorError::Refused(findings) = err else { panic!("expected refusal, got {err}") }; + assert!(findings.iter().any(|f| f.class == VerifyClass::L003), "{findings:?}"); +} + +#[test] +fn accept_refuses_a_model_identity_with_l006() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut a = author(dir.path()); + declare(&mut a); + let id = add_constraint(&mut a); + let mut model = Author::new( + dir.path(), + testkit::identity("claude@example.com"), + now(), + UlidMint::fixed(1_755_000_200_000), + ); + let err = model.accept(AcceptArgs { decision: id, expires_at: None }).expect_err("model acceptor"); + let AuthorError::Refused(findings) = err else { panic!("expected refusal, got {err}") }; + assert!(findings.iter().any(|f| f.class == VerifyClass::L006), "{findings:?}"); +} + +#[test] +fn revoke_unsays_and_refuses_the_unknown_and_the_double() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut a = author(dir.path()); + declare(&mut a); + let id = add_constraint(&mut a); + a.accept(AcceptArgs { decision: id.clone(), expires_at: None }).expect("accept"); + let store = crate::store::load(dir.path()); + let view = crate::verify::view::View::build(&store); + let acc = view.acceptances.first().expect("acceptance").acceptance.id.clone(); + + // Unknown: refused with the same schema class verify reports. + let ghost: crate::id::AcceptanceId = "acc:01K2C4YQJ3F8M0PT5W7NZ9RDXZ".parse().expect("id"); + let err = a + .revoke(RevokeArgs { acceptance: ghost, reason: "never filed".into() }) + .expect_err("unknown acceptance"); + let AuthorError::Refused(findings) = err else { panic!("expected refusal, got {err}") }; + assert!(findings.iter().any(|f| f.class == VerifyClass::Schema), "{findings:?}"); + + a.revoke(RevokeArgs { acceptance: acc.clone(), reason: "signed the wrong tier".into() }) + .expect("revoke"); + let err = a + .revoke(RevokeArgs { acceptance: acc, reason: "again".into() }) + .expect_err("double revocation"); + assert!(matches!(err, AuthorError::Conflict(_)), "{err}"); + assert_eq!(gate(dir.path()), Vec::::new()); +} + +#[test] +fn supersede_builds_a_walkable_chain_and_refuses_forking_it() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut a = author(dir.path()); + declare(&mut a); + let first = add_constraint(&mut a); + let second = add_constraint(&mut a); + let third = add_constraint(&mut a); + a.supersede(SupersedeArgs { superseded: first.clone(), by: second.clone(), reason: Some("narrowed".into()) }) + .expect("supersede"); + a.supersede(SupersedeArgs { superseded: second.clone(), by: third.clone(), reason: None }) + .expect("chain"); + let err = a + .supersede(SupersedeArgs { superseded: first.clone(), by: third, reason: None }) + .expect_err("forking the chain"); + assert!(matches!(err, AuthorError::Conflict(_)), "{err}"); + + let store = crate::store::load(dir.path()); + let view = crate::verify::view::View::build(&store); + let chain = crate::verify::state::chain_from( + &first.to_string(), + &crate::verify::state::supersession(&view), + ); + assert_eq!(chain.len(), 3, "{chain:?}"); + let rows = crate::verify::state::states(&view, testkit::date("2026-08-10")); + assert_eq!(rows.get(&first.to_string()).expect("row").state.as_str(), "superseded"); +} + +/// A store whose one decision two writers forked: one revised through the +/// verb; the other branch's change-set (same parent, different content) +/// lands as a file of its own — what a git merge of two logs leaves. +fn forked_store(dir: &Path) -> (Author, DecisionId) { + let mut a = author(dir); + declare(&mut a); + let id = add_constraint(&mut a); + let args = ReviseArgs { + statement: "The left writer's revision.".into(), + based_on: Vec::new(), + revisit_if: None, + note: None, + expected_parent: None, + }; + a.revise(&id, args).expect("left revision"); + let store = crate::store::load(dir); + let versions = store.log.iter().flat_map(|c| &c.file.versions); + let left = versions.filter(|v| v.parent.is_some()).next_back().expect("the left revision"); + let mut right = left.clone(); + right.statement = "The right writer's revision.".into(); + right.hash = crate::hash::version_hash(&right); + let mut cs = testkit::changeset(vec![right], Vec::new()); + cs.decisions.clear(); + cs.id = "cs:01K2C4YQJ3F8M0PT5W7NZ9RD99".parse().expect("id"); + let path = dir.join(".decisions/log").join(cs.file_name()); + std::fs::write(&path, serde_yaml::to_string(&cs).expect("yaml")).expect("write"); + (a, id) +} + +#[test] +fn a_forked_chain_refuses_revision_rather_than_burying_the_conflict() { + let dir = tempfile::tempdir().expect("tempdir"); + let (mut a, id) = forked_store(dir.path()); + let err = a + .revise(&id, ReviseArgs { + statement: "Extending either tip buries the conflict.".into(), + based_on: Vec::new(), + revisit_if: None, + note: None, + expected_parent: None, + }) + .expect_err("revise on a fork"); + assert!(matches!(err, AuthorError::Conflict(_)), "{err}"); + assert!(err.to_string().contains("merge --resolve"), "{err}"); +} + +#[test] +fn a_forked_chain_has_no_signable_tip() { + let dir = tempfile::tempdir().expect("tempdir"); + let (mut a, id) = forked_store(dir.path()); + let err = a + .accept(AcceptArgs { decision: id, expires_at: None }) + .expect_err("accept on a fork"); + assert!(matches!(err, AuthorError::Conflict(_)), "{err}"); + assert!(err.to_string().contains("no one latest version"), "{err}"); +} + +#[test] +fn declare_refuses_a_second_declaration_of_the_same_set() { + let dir = tempfile::tempdir().expect("tempdir"); + let mut a = author(dir.path()); + declare(&mut a); + let err = a + .declare(DeclareArgs { + id: "verbs".into(), + title: "Again".into(), + tolerance_floor: Tier::T2, + ground: Ground::Characterised, + owner: None, + notes: None, + }) + .expect_err("duplicate set"); + assert!(matches!(err, AuthorError::Usage(_)), "{err}"); +} diff --git a/ledger-core/src/author/tests.rs b/ledger-core/src/author/tests.rs index 9a624a9..404f42c 100644 --- a/ledger-core/src/author/tests.rs +++ b/ledger-core/src/author/tests.rs @@ -48,6 +48,7 @@ fn constraint_add(statement: &str, tolerance_override: Option) -> AddArgs }, tolerance_override, based_on: Vec::new(), + revisit_if: Vec::new(), note: None, } } @@ -100,6 +101,7 @@ fn an_unallocated_add_is_the_one_sanctioned_pendency() { allocation: AllocationArgs::default(), tolerance_override: None, based_on: Vec::new(), + revisit_if: Vec::new(), note: None, }) .expect("unallocated add writes"); @@ -201,6 +203,8 @@ fn revise_moves_the_hash_and_a_stale_parent_is_a_conflict_not_a_merge() { a.revise(&id, ReviseArgs { statement: "Monetary amounts use decimal everywhere, including imports.".into(), based_on: Vec::new(), + revisit_if: None, + note: None, expected_parent: Some(first_hash.clone()), }) .expect("revise from the tip"); @@ -209,6 +213,8 @@ fn revise_moves_the_hash_and_a_stale_parent_is_a_conflict_not_a_merge() { .revise(&id, ReviseArgs { statement: "A second fork from the same parent.".into(), based_on: Vec::new(), + revisit_if: None, + note: None, expected_parent: Some(first_hash), }) .expect_err("stale parent"); @@ -217,179 +223,5 @@ fn revise_moves_the_hash_and_a_stale_parent_is_a_conflict_not_a_merge() { assert!(message.contains("merge is L3"), "{message}"); } -#[test] -fn accept_signs_the_tip_and_a_revision_returns_the_decision_to_awaiting() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut a = author(dir.path()); - declare(&mut a); - let id = add_constraint(&mut a); - a.accept(AcceptArgs { decision: id.clone(), expires_at: None }).expect("accept"); - let err = a.accept(AcceptArgs { decision: id.clone(), expires_at: None }).expect_err("twice"); - assert!(matches!(err, AuthorError::Conflict(_)), "{err}"); - - a.revise(&id, ReviseArgs { - statement: "Restated after acceptance.".into(), - based_on: Vec::new(), - expected_parent: None, - }) - .expect("revise"); - let text = report::status(&crate::store::load(dir.path()), testkit::date("2026-08-10")); - assert!(text.contains("awaiting-acceptance"), "the old signature is history: {text}"); -} - -#[test] -fn accept_refuses_an_expired_on_arrival_signature_with_l003() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut a = author(dir.path()); - declare(&mut a); - let id = add_constraint(&mut a); - let err = a - .accept(AcceptArgs { decision: id, expires_at: Some(testkit::date("2026-08-01")) }) - .expect_err("expired on arrival"); - let AuthorError::Refused(findings) = err else { panic!("expected refusal, got {err}") }; - assert!(findings.iter().any(|f| f.class == VerifyClass::L003), "{findings:?}"); -} - -#[test] -fn accept_refuses_a_model_identity_with_l006() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut a = author(dir.path()); - declare(&mut a); - let id = add_constraint(&mut a); - let mut model = Author::new( - dir.path(), - testkit::identity("claude@example.com"), - now(), - UlidMint::fixed(1_755_000_200_000), - ); - let err = model.accept(AcceptArgs { decision: id, expires_at: None }).expect_err("model acceptor"); - let AuthorError::Refused(findings) = err else { panic!("expected refusal, got {err}") }; - assert!(findings.iter().any(|f| f.class == VerifyClass::L006), "{findings:?}"); -} - -#[test] -fn revoke_unsays_and_refuses_the_unknown_and_the_double() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut a = author(dir.path()); - declare(&mut a); - let id = add_constraint(&mut a); - a.accept(AcceptArgs { decision: id.clone(), expires_at: None }).expect("accept"); - let store = crate::store::load(dir.path()); - let view = crate::verify::view::View::build(&store); - let acc = view.acceptances.first().expect("acceptance").acceptance.id.clone(); - - // Unknown: refused with the same schema class verify reports. - let ghost: crate::id::AcceptanceId = "acc:01K2C4YQJ3F8M0PT5W7NZ9RDXZ".parse().expect("id"); - let err = a - .revoke(RevokeArgs { acceptance: ghost, reason: "never filed".into() }) - .expect_err("unknown acceptance"); - let AuthorError::Refused(findings) = err else { panic!("expected refusal, got {err}") }; - assert!(findings.iter().any(|f| f.class == VerifyClass::Schema), "{findings:?}"); - - a.revoke(RevokeArgs { acceptance: acc.clone(), reason: "signed the wrong tier".into() }) - .expect("revoke"); - let err = a - .revoke(RevokeArgs { acceptance: acc, reason: "again".into() }) - .expect_err("double revocation"); - assert!(matches!(err, AuthorError::Conflict(_)), "{err}"); - assert_eq!(gate(dir.path()), Vec::::new()); -} - -#[test] -fn supersede_builds_a_walkable_chain_and_refuses_forking_it() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut a = author(dir.path()); - declare(&mut a); - let first = add_constraint(&mut a); - let second = add_constraint(&mut a); - let third = add_constraint(&mut a); - a.supersede(SupersedeArgs { superseded: first.clone(), by: second.clone(), reason: Some("narrowed".into()) }) - .expect("supersede"); - a.supersede(SupersedeArgs { superseded: second.clone(), by: third.clone(), reason: None }) - .expect("chain"); - let err = a - .supersede(SupersedeArgs { superseded: first.clone(), by: third, reason: None }) - .expect_err("forking the chain"); - assert!(matches!(err, AuthorError::Conflict(_)), "{err}"); - - let store = crate::store::load(dir.path()); - let view = crate::verify::view::View::build(&store); - let chain = crate::verify::state::chain_from( - &first.to_string(), - &crate::verify::state::supersession(&view), - ); - assert_eq!(chain.len(), 3, "{chain:?}"); - let rows = crate::verify::state::states(&view, testkit::date("2026-08-10")); - assert_eq!(rows.get(&first.to_string()).expect("row").state.as_str(), "superseded"); -} - -/// A store whose one decision two writers forked: one revised through the -/// verb; the other branch's change-set (same parent, different content) -/// lands as a file of its own — what a git merge of two logs leaves. -fn forked_store(dir: &Path) -> (Author, DecisionId) { - let mut a = author(dir); - declare(&mut a); - let id = add_constraint(&mut a); - let args = ReviseArgs { - statement: "The left writer's revision.".into(), - based_on: Vec::new(), - expected_parent: None, - }; - a.revise(&id, args).expect("left revision"); - let store = crate::store::load(dir); - let versions = store.log.iter().flat_map(|c| &c.file.versions); - let left = versions.filter(|v| v.parent.is_some()).next_back().expect("the left revision"); - let mut right = left.clone(); - right.statement = "The right writer's revision.".into(); - right.hash = crate::hash::version_hash(&right); - let mut cs = testkit::changeset(vec![right], Vec::new()); - cs.decisions.clear(); - cs.id = "cs:01K2C4YQJ3F8M0PT5W7NZ9RD99".parse().expect("id"); - let path = dir.join(".decisions/log").join(cs.file_name()); - std::fs::write(&path, serde_yaml::to_string(&cs).expect("yaml")).expect("write"); - (a, id) -} - -#[test] -fn a_forked_chain_refuses_revision_rather_than_burying_the_conflict() { - let dir = tempfile::tempdir().expect("tempdir"); - let (mut a, id) = forked_store(dir.path()); - let err = a - .revise(&id, ReviseArgs { - statement: "Extending either tip buries the conflict.".into(), - based_on: Vec::new(), - expected_parent: None, - }) - .expect_err("revise on a fork"); - assert!(matches!(err, AuthorError::Conflict(_)), "{err}"); - assert!(err.to_string().contains("merge --resolve"), "{err}"); -} - -#[test] -fn a_forked_chain_has_no_signable_tip() { - let dir = tempfile::tempdir().expect("tempdir"); - let (mut a, id) = forked_store(dir.path()); - let err = a - .accept(AcceptArgs { decision: id, expires_at: None }) - .expect_err("accept on a fork"); - assert!(matches!(err, AuthorError::Conflict(_)), "{err}"); - assert!(err.to_string().contains("no one latest version"), "{err}"); -} - -#[test] -fn declare_refuses_a_second_declaration_of_the_same_set() { - let dir = tempfile::tempdir().expect("tempdir"); - let mut a = author(dir.path()); - declare(&mut a); - let err = a - .declare(DeclareArgs { - id: "verbs".into(), - title: "Again".into(), - tolerance_floor: Tier::T2, - ground: Ground::Characterised, - owner: None, - notes: None, - }) - .expect_err("duplicate set"); - assert!(matches!(err, AuthorError::Usage(_)), "{err}"); -} +#[path = "sign_tests.rs"] +mod sign_tests; diff --git a/ledger-core/src/author/version_ops.rs b/ledger-core/src/author/version_ops.rs index 2fa6479..5e3028b 100644 --- a/ledger-core/src/author/version_ops.rs +++ b/ledger-core/src/author/version_ops.rs @@ -13,6 +13,7 @@ use crate::hash::{version_hash, VersionHash}; use crate::id::DecisionId; +use crate::revisit::RevisitRef; use crate::verify::view::View; use crate::version::{BasisRef, VersionRaw}; @@ -29,6 +30,17 @@ pub struct EscapeArgs { pub struct ReviseArgs { pub statement: String, pub based_on: Vec, + /// The reopen edges this revision states. Stated separately from + /// `based_on` all the way down, so re-typing an edge from ground to + /// watched is one act the reader can see, not a silent reclassification. + /// `None` leaves the inherited edges alone; `Some(vec![])` clears them — + /// a decision that has been reopened and re-decided drops the edge that + /// reopened it, and that has to be sayable. + pub revisit_if: Option>, + /// What this act was, on the change-set. A revision that re-types an + /// edge or restates a decision has a reason, and the reason belongs + /// where the filing is read — `supersede` already carries one. + pub note: Option, /// The version the author believes is the tip. When stated and stale, /// the verb refuses — merge is L3's problem, not a quiet overwrite. pub expected_parent: Option, @@ -77,11 +89,14 @@ impl Author { /// Restate a decision. The allocation carries over unchanged; the hash /// moves, so every acceptance of the prior version becomes history. pub fn revise(&mut self, id: &DecisionId, args: ReviseArgs) -> Result { - self.next_version(id, args.expected_parent.as_ref(), |raw| { + self.next_version_noted(id, args.expected_parent.as_ref(), args.note.clone(), |raw| { raw.statement = args.statement.clone(); if !args.based_on.is_empty() { raw.based_on = args.based_on.clone(); } + if let Some(edges) = &args.revisit_if { + raw.revisit_if = edges.clone(); + } }) .map(|a| a.saying(format!("revised {id} — prior acceptances now sign history"))) } diff --git a/ledger-core/src/canon.rs b/ledger-core/src/canon.rs index 6b0e813..5af6f10 100644 --- a/ledger-core/src/canon.rs +++ b/ledger-core/src/canon.rs @@ -68,18 +68,42 @@ pub fn put_set(map: &mut Map, key: &str, items: impl Iterator String { - // `hash` is bound and discarded on purpose: the stored digest is what - // the hash is compared *to*, so including it would be circular. The two - // tolerance inputs are both hashed rather than the resolved tier — the - // pin is history, the override is authored, and keeping them apart is - // what lets override-rate-per-set (§10) come out of hashed content. + let mut m = Map::new(); + for (key, value) in scalar_fields(raw) { + put(&mut m, key, value); + } + put_set(&mut m, "discharge", raw.discharge.iter().map(ToString::to_string)); + put_set(&mut m, "based_on", raw.based_on.iter().map(ToString::to_string)); + // Hashed like any other list field, and hashed *separately* from + // `based_on`: a reopen edge is authored content, so moving one is a new + // version, but it is never merged into the basis set — an acceptance + // must be able to say which of the two it signed (format 4, spec v1.5). + // Empty on everything written before the field existed, so `put_set` + // omits it and no earlier digest moves. + put_set(&mut m, "revisit_if", raw.revisit_if.iter().map(ToString::to_string)); + // serde_json's map is a BTreeMap whose writer emits no insignificant + // whitespace, so this is key-sorted and compact by construction. Keys in + // this format are ASCII, where code-point order and the JCS UTF-16 + // code-unit order coincide. + Value::Object(m).to_string() +} + +/// The scalar half of the canonical object, and the home of the guard. +/// +/// `hash` is bound and discarded on purpose: the stored digest is what the +/// hash is compared *to*, so including it would be circular. The two +/// tolerance inputs are both hashed rather than the resolved tier — the pin +/// is history, the override is authored, and keeping them apart is what +/// lets override-rate-per-set (§10) come out of hashed content. The three +/// list fields are bound and discarded here because they are canonicalised +/// as sets by the caller. +fn scalar_fields(raw: &VersionRaw) -> [(&'static str, Option); 15] { let VersionRaw { - decision, parent, merged_from, hash: _, set, statement, allocation, discharge, + decision, parent, merged_from, hash: _, set, statement, allocation, discharge: _, discharge_stage, actor, expectation, exposure, accepted_by, review_by, - tolerance_floor_at_creation, tolerance_override, based_on, supersedes, + tolerance_floor_at_creation, tolerance_override, based_on: _, revisit_if: _, supersedes, } = raw; - let mut m = Map::new(); - for (key, value) in [ + [ ("decision", Some(decision.to_string())), ("parent", parent.as_ref().map(ToString::to_string)), // Hashed when present; omitted when absent, which is what keeps @@ -97,16 +121,7 @@ pub fn canonical_json(raw: &VersionRaw) -> String { ("tolerance_floor_at_creation", Some(tolerance_floor_at_creation.to_string())), ("tolerance_override", tolerance_override.map(|t| t.to_string())), ("supersedes", supersedes.as_ref().map(ToString::to_string)), - ] { - put(&mut m, key, value); - } - put_set(&mut m, "discharge", discharge.iter().map(ToString::to_string)); - put_set(&mut m, "based_on", based_on.iter().map(ToString::to_string)); - // serde_json's map is a BTreeMap whose writer emits no insignificant - // whitespace, so this is key-sorted and compact by construction. Keys in - // this format are ASCII, where code-point order and the JCS UTF-16 - // code-unit order coincide. - Value::Object(m).to_string() + ] } /// The canonical bytes, as UTF-8. No trailing newline. diff --git a/ledger-core/src/canon_tests.rs b/ledger-core/src/canon_tests.rs index 1f48c5c..cdcdb76 100644 --- a/ledger-core/src/canon_tests.rs +++ b/ledger-core/src/canon_tests.rs @@ -191,6 +191,9 @@ fn identity_mutations() -> Vec { ("based_on", edit(|v| { v.based_on = vec!["prd:decision-ledger-prd#9.4".parse().expect("basis")]; })), + ("revisit_if", edit(|v| { + v.revisit_if = vec!["claim:DDD-adapter-02@sha256:b333063d".parse().expect("token")]; + })), ("supersedes", edit(|v| v.supersedes = Some(testkit::decision_id()))), ("tolerance_floor_at_creation", edit(|v| v.tolerance_floor_at_creation = Tier::T0)), ("tolerance_override", edit(|v| v.tolerance_override = Some(Tier::T2))), @@ -246,6 +249,35 @@ fn the_mutation_table_covers_the_whole_canonical_form() { assert_eq!(json.matches("\":").count(), mutations.len(), "no field is emitted unexercised"); } +/// The same token as ground and as a reopen edge are different content. +/// This is the ruling made mechanical: `revisit_if` is not a basis, so the +/// two lists canonicalise to separate keys and an acceptance always names +/// which of them it signed. +#[test] +fn one_token_hashes_differently_as_ground_and_as_a_reopen_edge() { + let token = "claim:DDD-adapter-02@sha256:b333063d"; + let mut ground = base(); + ground.based_on = vec![token.parse().expect("basis")]; + ground.revisit_if = Vec::new(); + let mut reopen = base(); + reopen.based_on = Vec::new(); + reopen.revisit_if = vec![token.parse().expect("token")]; + assert_ne!(version_hash(&ground), version_hash(&reopen)); + assert!(canonical_json(&reopen).contains("\"revisit_if\":")); + assert!(!canonical_json(&reopen).contains("\"based_on\":")); +} + +/// A version that states no reopen edge canonicalises exactly as it did +/// before the field existed — which is why format 4 moves no digest and +/// invalidates no acceptance. +#[test] +fn an_absent_reopen_list_is_omitted_from_the_canonical_form() { + let mut v = base(); + v.revisit_if = Vec::new(); + assert!(!canonical_json(&v).contains("revisit_if")); + assert_eq!(version_hash(&v), version_hash(&base())); +} + #[test] fn the_tolerance_inputs_are_hashed_rather_than_the_resolved_tier() { // `T0 floor + T2 override` and `T2 floor` both resolve to an effective diff --git a/ledger-core/src/format.rs b/ledger-core/src/format.rs index d23f37d..cbd10a1 100644 --- a/ledger-core/src/format.rs +++ b/ledger-core/src/format.rs @@ -26,8 +26,14 @@ pub const MERGE_FORMAT: u32 = 2; /// is untouched, so `CANONICAL_FORM` stays `v1`. pub const CONTRACT_FORMAT: u32 = 3; +/// The format a change-set carrying a `revisit_if` edge declares (spec +/// v1.5): the reopen edge, ruled a distinct edge type rather than a basis. +/// Same declare-what-you-need rule as formats 2 and 3; the field is hashed +/// when present and omitted when absent, so `CANONICAL_FORM` stays `v1`. +pub const REVISIT_FORMAT: u32 = 4; + /// Every format version this tool can validate an entry against. -pub const SUPPORTED_FORMATS: &[u32] = &[1, 2, 3]; +pub const SUPPORTED_FORMATS: &[u32] = &[1, 2, 3, 4]; /// Whether an entry declaring `format: n` can be validated here. pub fn is_supported(n: u32) -> bool { @@ -45,6 +51,9 @@ pub fn needed_for(cs: &crate::changeset::ChangeSet) -> u32 { if cs.versions.iter().any(|v| v.discharge.iter().any(|d| d.is_contract())) { needed = needed.max(CONTRACT_FORMAT); } + if cs.versions.iter().any(|v| !v.revisit_if.is_empty()) { + needed = needed.max(REVISIT_FORMAT); + } needed } @@ -68,7 +77,7 @@ mod tests { assert!(!is_supported(9)); assert_eq!( unsupported_message(9), - "declares format 9; this tool validates format(s) 1, 2, 3" + "declares format 9; this tool validates format(s) 1, 2, 3, 4" ); } @@ -81,4 +90,21 @@ mod tests { fn the_contract_format_is_supported() { assert!(is_supported(CONTRACT_FORMAT)); } + + #[test] + fn the_revisit_format_is_supported() { + assert!(is_supported(REVISIT_FORMAT)); + } + + #[test] + fn a_change_set_needs_the_revisit_format_only_when_it_carries_a_reopen_edge() { + let plain = crate::testkit::version(); + assert_eq!(needed_for(&crate::testkit::changeset(vec![plain.clone()], vec![])), CURRENT_FORMAT); + let mut reopening = plain; + reopening.revisit_if = vec!["claim:DDD-x-01@sha256:abc".parse().expect("token")]; + assert_eq!( + needed_for(&crate::testkit::changeset(vec![reopening], vec![])), + REVISIT_FORMAT + ); + } } diff --git a/ledger-core/src/graph/turtle.rs b/ledger-core/src/graph/turtle.rs index 8c1c8ae..a0f6d41 100644 --- a/ledger-core/src/graph/turtle.rs +++ b/ledger-core/src/graph/turtle.rs @@ -178,9 +178,7 @@ fn emit_version(t: &mut Triples, v: &VersionRaw, cs_iri: &str) { if let Some(over) = v.tolerance_override { t.add(&iri, "ledger:toleranceOverride", literal(&over.to_string())); } - for basis in &v.based_on { - t.add(&iri, "ledger:basedOn", literal(basis.as_str())); - } + emit_edges(t, v, &iri); if let Some(old) = &v.supersedes { t.add(&iri, "ledger:supersedes", dec_iri(old)); } @@ -192,6 +190,19 @@ fn emit_version(t: &mut Triples, v: &VersionRaw, cs_iri: &str) { } } +/// The two edge kinds, under two predicates. Deliberately separate: a query +/// walking `ledger:basedOn` is asking what a decision rests on, and a reopen +/// edge is not an answer to that — the same reason they are separate fields +/// on the wire. +fn emit_edges(t: &mut Triples, v: &VersionRaw, iri: &str) { + for basis in &v.based_on { + t.add(iri, "ledger:basedOn", literal(basis.as_str())); + } + for edge in &v.revisit_if { + t.add(iri, "ledger:revisitIf", literal(edge.as_str())); + } +} + fn emit_acceptance(t: &mut Triples, a: &Acceptance, cs_iri: &str) { let iri = format!("", a.id); t.add(&iri, "a", "ledger:Acceptance".into()); diff --git a/ledger-core/src/lib.rs b/ledger-core/src/lib.rs index 7b9e599..2f66921 100644 --- a/ledger-core/src/lib.rs +++ b/ledger-core/src/lib.rs @@ -33,7 +33,9 @@ pub mod merge; pub mod mint; pub mod render; pub mod revision; +pub mod revisit; pub mod set; +pub mod show; pub mod store; pub mod tier; pub mod verify; diff --git a/ledger-core/src/merge/tests.rs b/ledger-core/src/merge/tests.rs index d75807e..be69b8b 100644 --- a/ledger-core/src/merge/tests.rs +++ b/ledger-core/src/merge/tests.rs @@ -171,6 +171,7 @@ mod acts { }, tolerance_override: None, based_on: Vec::new(), + revisit_if: Vec::new(), note: None, }) .expect("add") @@ -200,6 +201,8 @@ mod acts { a.revise(&id, ReviseArgs { statement: "The left writer's revision.".into(), based_on: Vec::new(), + revisit_if: None, + note: None, expected_parent: None, }) .expect("left revision"); diff --git a/ledger-core/src/revisit.rs b/ledger-core/src/revisit.rs new file mode 100644 index 0000000..914357c --- /dev/null +++ b/ledger-core/src/revisit.rs @@ -0,0 +1,102 @@ +//! The `revisit_if` edge — a claim whose death reopens a decision. +//! +//! Ruled by the principal (2026-08-13), settling the watched-edge question +//! the basis-quality re-typing session left open and the ddd M8 migration +//! carried as a provisional `watched:` marker inside `based_on`. +//! +//! A `revisit_if` pointer is **not** a basis. Its meaning is the converse of +//! ground: the decision does not rest on this claim, but the claim's death +//! reopens the decision. Filing it as its own field with its own type is +//! what keeps the two readings apart mechanically rather than by convention +//! — the basis-loss scan reads `based_on` and never this field, and a +//! status movement here produces a *reopen* finding, not a basis-loss one. +//! The two mean different things and must report differently. +//! +//! The vocabulary is open at this version, exactly as `based_on`'s is: L0 +//! resolves no pointer, and closing a vocabulary nothing dereferences yet +//! would reject adopters' schemes for no gain. Open is not the same as +//! shared — a `RevisitRef` is a distinct type, so no code path can read one +//! as ground by passing it where a basis is expected. + +use std::fmt; +use std::str::FromStr; + +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +/// A pointer to the claim whose falsification reopens a decision. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct RevisitRef(String); + +impl RevisitRef { + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl FromStr for RevisitRef { + type Err = String; + + fn from_str(s: &str) -> Result { + let t = s.trim(); + if t.is_empty() { + return Err("a revisit-if pointer is empty".to_string()); + } + if t.chars().any(char::is_whitespace) { + return Err(format!("`{t}` carries whitespace; a revisit-if pointer is a single token")); + } + Ok(Self(t.to_string())) + } +} + +impl fmt::Display for RevisitRef { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +impl Serialize for RevisitRef { + fn serialize(&self, s: S) -> Result { + s.serialize_str(&self.0) + } +} + +impl<'de> Deserialize<'de> for RevisitRef { + fn deserialize>(d: D) -> Result { + String::deserialize(d)?.parse().map_err(serde::de::Error::custom) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_single_token_parses_and_whitespace_is_refused() { + assert_eq!( + "claim:DDD-adapter-02@sha256:abc".parse::().expect("parse").as_str(), + "claim:DDD-adapter-02@sha256:abc" + ); + assert!("two tokens".parse::().is_err()); + assert!(" ".parse::().is_err()); + } + + #[test] + fn surrounding_whitespace_is_trimmed_like_a_basis_pointer() { + assert_eq!(" claim:X ".parse::().expect("parse").as_str(), "claim:X"); + } + + #[test] + fn the_type_is_distinct_from_a_basis_pointer() { + // The guard is structural: a `RevisitRef` cannot be passed where a + // `BasisRef` is expected, so no scan can read one as ground by + // accident. This test exists to fail loudly if the two are ever + // merged into one type — see the module docs for why they are not. + let revisit: RevisitRef = "claim:X@sha256:abc".parse().expect("parse"); + let basis: crate::version::BasisRef = "claim:X@sha256:abc".parse().expect("parse"); + assert_eq!(revisit.to_string(), basis.to_string()); + assert_ne!( + std::any::TypeId::of::(), + std::any::TypeId::of::() + ); + } +} diff --git a/ledger-core/src/show.rs b/ledger-core/src/show.rs new file mode 100644 index 0000000..aa14885 --- /dev/null +++ b/ledger-core/src/show.rs @@ -0,0 +1,318 @@ +//! One decision, on one screen — the read a signature is given before it. +//! +//! `status` answers "where does everything stand"; `blame` answers "who +//! signed what". Neither answers the question an acceptance pass actually +//! asks, which is *what am I signing on this one*. That question needs the +//! hashed content, the edges (ground and reopen, kept apart) **with the +//! argument each edge carries**, the filing act that put it there, and the +//! acceptance situation — assembled from the same [`View`] every other +//! reading uses, so `show` can never disagree with the gate about state. +//! +//! A pointer's type is not the thing being ruled on. `mandate:…` says an +//! edge is a mandate; it does not say what the mandate *is*, and the +//! argument is what a principal actually weighs. Ledger-core cannot read +//! the stores those pointers land in without growing a dependency on every +//! adopter's ontology, so it takes the resolved text as an input: a caller +//! that knows a scheme supplies the argument through [`BasisText`], and a +//! caller that does not passes [`NoText`] and gets bare pointers. +//! +//! The screen is a projection, never an instruction: it says what the +//! decision is and what its state is, and it names the command that would +//! sign it. Filing the signature is the principal's act (OD-3), so nothing +//! here performs one. + +use chrono::NaiveDate; +use serde::Serialize; + +use crate::id::DecisionId; +use crate::store::Store; +use crate::verify::state::{self, DispositionState}; +use crate::verify::view::View; + +/// Resolves a pointer to the argument it carries. Implemented by whoever +/// owns the store the pointer names; ledger-core owns none of them. +pub trait BasisText { + /// The argument behind `pointer`, when this resolver knows the scheme. + fn text(&self, pointer: &str) -> Option; +} + +/// The resolver that knows nothing — pointers render bare. +pub struct NoText; + +impl BasisText for NoText { + fn text(&self, _pointer: &str) -> Option { + None + } +} + +/// Which decisions a `show` covers. A pass over 79 entries is not 79 +/// invocations: grouping is the whole point, so the selector is part of +/// the read rather than something a shell loop reconstructs. +pub enum Selector { + /// Exactly one decision. + One(DecisionId), + /// Every decision in one set. + Set(String), + /// Every decision whose weight-class matches (see [`Group`]). + Group(Group), + /// Every decision the store holds. + All, +} + +/// The two weight classes an acceptance pass actually has. +/// +/// Derived from content, never hand-listed: a decision is *mechanical* when +/// it is a criterion discharged solely by the repository-diff contract +/// check — the transcribed declarations, where what is being ruled is that +/// a transcription is faithful. Everything else is an individual read. +/// Deriving it means the grouping cannot go stale as entries are added. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Group { + Mechanical, + Individual, +} + +impl Group { + /// Parse the CLI spelling. + pub fn parse(s: &str) -> Result { + match s { + "mechanical" => Ok(Self::Mechanical), + "individual" => Ok(Self::Individual), + other => Err(format!("unknown group `{other}` — one of mechanical | individual")), + } + } + + pub fn as_str(self) -> &'static str { + match self { + Self::Mechanical => "mechanical", + Self::Individual => "individual", + } + } +} + +/// One edge as the screen shows it: the pointer, and the argument it +/// carries when a resolver could supply one. +#[derive(Debug, Serialize)] +pub struct Edge { + pub pointer: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub argument: Option, +} + +/// Everything one decision's screen shows. `--json` serialises this whole +/// struct, so the terminal text and the machine form can never carry +/// different facts. +#[derive(Debug, Serialize)] +pub struct Screen { + pub decision: String, + pub set: String, + /// The set's currently declared floor, which is not necessarily the + /// floor this version pinned — a raise strands members until re-pinned. + pub set_floor: Option, + pub state: DispositionState, + /// Which weight class this entry falls in, so a grouped pass can say + /// what it is reading. + pub group: &'static str, + pub statement: String, + pub allocation: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub discharge: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub discharge_stage: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub actor: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub exposure: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub review_by: Option, + pub tolerance_floor_at_creation: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub tolerance_override: Option, + pub hash: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub parent: Option, + /// How many versions of this decision the log holds, the latest included. + pub versions_in_chain: usize, + /// The ground, each edge with its argument. Never merged with + /// `revisit_if`: the two mean different things, and a reader deciding + /// what they sign must see which is which. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub based_on: Vec, + /// The reopen edges — claims whose death reopens this decision. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub revisit_if: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub supersedes: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub superseded_by: Option, + /// The act that filed the latest version: change-set, date, author, note. + pub filed: Filing, + pub acceptances: Vec, + /// True when the chain has more than one tip — nothing may be signed + /// until `ledger merge --resolve` arbitrates. + pub forked: bool, +} + +/// The change-set that filed the version on screen. +#[derive(Debug, Serialize)] +pub struct Filing { + pub change_set: String, + pub at: String, + pub by: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub note: Option, +} + +/// One acceptance of this decision, and whether it still stands. +#[derive(Debug, Serialize)] +pub struct AcceptanceLine { + pub id: String, + pub actor: String, + pub version: String, + pub at: String, + /// `live` | `historical` | `revoked` — the same reading `blame` renders. + pub standing: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + pub expires_at: Option, +} + +/// Build the screen for one decision, or `None` when the log holds no +/// version of it — an unknown id is the caller's error to report, not a +/// blank screen to render. +pub fn screen( + store: &Store, + decision: &DecisionId, + today: NaiveDate, + texts: &dyn BasisText, +) -> Option { + let view = View::build(store); + let rows = state::states(&view, today); + build(store, &view, &rows, &decision.to_string(), texts) +} + +/// Every screen a selector covers, in decision-id order. One `View` build +/// serves the whole pass — the selector exists so a grouped read costs one +/// traversal, not one process per entry. +pub fn screens( + store: &Store, + selector: &Selector, + today: NaiveDate, + texts: &dyn BasisText, +) -> Vec { + let view = View::build(store); + let rows = state::states(&view, today); + view.latest + .keys() + .filter_map(|id| build(store, &view, &rows, id, texts)) + .filter(|s| matches(s, selector)) + .collect() +} + +fn matches(s: &Screen, selector: &Selector) -> bool { + match selector { + Selector::One(id) => s.decision == id.to_string(), + Selector::Set(set) => s.set == *set, + Selector::Group(g) => s.group == g.as_str(), + Selector::All => true, + } +} + +/// A criterion whose every discharge pointer is the repository-diff +/// contract check is a transcribed declaration: mechanical. Anything else +/// — a judgment, an escape, a criterion with an analyzer or a test behind +/// it — is an individual read. +fn group_of(raw: &crate::version::VersionRaw) -> Group { + let mechanical = !raw.discharge.is_empty() && raw.discharge.iter().all(|d| d.is_contract()); + if mechanical { + Group::Mechanical + } else { + Group::Individual + } +} + +fn build( + store: &Store, + view: &View, + rows: &std::collections::BTreeMap, + id: &str, + texts: &dyn BasisText, +) -> Option { + let index = *view.latest.get(id)?; + let viewed = view.versions.get(index)?; + let raw = viewed.raw; + let row = rows.get(id); + let filing = store.log.iter().find(|l| l.path == viewed.path).map(|l| &l.file); + let edges = |items: Vec| -> Vec { + items + .into_iter() + .map(|pointer| Edge { argument: texts.text(&pointer), pointer }) + .collect() + }; + Some(Screen { + decision: id.to_string(), + set: raw.set.clone(), + set_floor: store.set(&raw.set).map(|s| s.tolerance_floor.to_string()), + state: row.map(|r| r.state).unwrap_or(DispositionState::Undecided), + group: group_of(raw).as_str(), + statement: raw.statement.clone(), + allocation: raw.allocation.map(|a| a.as_str().to_string()), + discharge: raw.discharge.iter().map(ToString::to_string).collect(), + discharge_stage: raw.discharge_stage.map(|s| s.to_string()), + actor: raw.actor.as_ref().map(ToString::to_string), + exposure: raw.exposure.clone(), + review_by: raw.review_by.map(|d| d.to_string()), + tolerance_floor_at_creation: raw.tolerance_floor_at_creation.to_string(), + tolerance_override: raw.tolerance_override.map(|t| t.to_string()), + hash: raw.hash.to_string(), + parent: raw.parent.as_ref().map(ToString::to_string), + versions_in_chain: view + .versions + .iter() + .filter(|v| v.raw.decision.to_string() == id) + .count(), + based_on: edges(raw.based_on.iter().map(ToString::to_string).collect()), + revisit_if: edges(raw.revisit_if.iter().map(ToString::to_string).collect()), + supersedes: raw.supersedes.as_ref().map(ToString::to_string), + superseded_by: row.and_then(|r| r.superseded_by.clone()), + filed: Filing { + change_set: filing.map(|f| f.id.to_string()).unwrap_or_default(), + at: filing.map(|f| f.created_at.date_naive().to_string()).unwrap_or_default(), + by: filing.map(|f| f.created_by.to_string()).unwrap_or_default(), + note: filing.and_then(|f| f.note.clone()), + }, + acceptances: acceptance_lines(view, id), + forked: view.is_forked(id), + }) +} + +fn acceptance_lines(view: &View, id: &str) -> Vec { + let latest = + view.latest.get(id).and_then(|i| view.versions.get(*i)).map(|v| v.raw.hash.clone()); + view.acceptances + .iter() + .map(|v| v.acceptance) + .filter(|a| a.decision.to_string() == id) + .map(|a| AcceptanceLine { + id: a.id.to_string(), + actor: a.actor.to_string(), + version: a.version.short().to_string(), + at: a.at.date_naive().to_string(), + standing: if view.is_revoked(a) { + "revoked" + } else if latest.as_ref() == Some(&a.version) { + "live" + } else { + "historical" + }, + expires_at: a.expires_at.map(|d| d.to_string()), + }) + .collect() +} + +#[path = "show_render.rs"] +mod render; +pub use render::{render, render_all}; + +#[path = "show_tests.rs"] +#[cfg(test)] +mod tests; diff --git a/ledger-core/src/show_render.rs b/ledger-core/src/show_render.rs new file mode 100644 index 0000000..1e51a72 --- /dev/null +++ b/ledger-core/src/show_render.rs @@ -0,0 +1,165 @@ +//! The terminal rendering of a [`Screen`], as one aligned block. +//! +//! Kept apart from the assembly so the fields a screen carries are decided +//! in one place and their presentation in another: `--json` serialises the +//! struct directly, so text and machine form cannot drift into disagreeing. + +use super::{AcceptanceLine, Edge, Screen}; + +const LABEL: usize = 12; + +/// A whole pass, one screen after another, with a count at the head so a +/// grouped read says how much it covers before it starts. +pub fn render_all(screens: &[Screen]) -> String { + if screens.is_empty() { + return "no decisions match\n".to_string(); + } + let mut out = format!("{} decision(s)\n", screens.len()); + for s in screens { + out.push('\n'); + out.push_str(&render(s)); + } + out +} + +/// One decision's screen, ready to print. +pub fn render(s: &Screen) -> String { + let mut out = format!("{} — {} [{}]\n", s.decision, s.state.as_str(), s.group); + if s.forked { + out.push_str( + " ** the version chain is forked — nothing may be signed until `ledger merge --resolve` arbitrates\n", + ); + } + let floor = match &s.set_floor { + Some(f) => format!("{} (floor {f})", s.set), + None => format!("{} (undeclared)", s.set), + }; + field(&mut out, "set", &floor); + field(&mut out, "statement", &s.statement); + field(&mut out, "allocation", &allocation_line(s)); + if let Some(exposure) = &s.exposure { + field(&mut out, "exposure", exposure); + } + if let Some(review) = &s.review_by { + field(&mut out, "review by", review); + } + field(&mut out, "tolerance", &tolerance_line(s)); + field(&mut out, "version", &version_line(s)); + if let Some(parent) = &s.parent { + field(&mut out, "parent", parent); + } + edges(&mut out, "based on", &s.based_on, "none — nothing stated as ground"); + // The reopen edges get their own block, never folded into the ground + // above: the ruling this field implements is precisely that these two + // mean different things. + edges(&mut out, "revisit if", &s.revisit_if, "none"); + if let Some(old) = &s.supersedes { + field(&mut out, "supersedes", old); + } + if let Some(by) = &s.superseded_by { + field(&mut out, "superseded", &format!("by {by}")); + } + field(&mut out, "filed", &filing_line(s)); + if let Some(note) = &s.filed.note { + field(&mut out, "note", ¬e.trim().replace('\n', &format!("\n{}", pad()))); + } + out.push_str(&acceptance_block(s)); + out +} + +fn pad() -> String { + " ".repeat(LABEL + 4) +} + +fn field(out: &mut String, label: &str, value: &str) { + out.push_str(&format!(" {label: String { + let mut line = s.allocation.clone().unwrap_or_else(|| "unallocated".to_string()); + if let Some(actor) = &s.actor { + line.push_str(&format!(" — actor {actor}")); + } + if !s.discharge.is_empty() { + line.push_str(&format!(" — discharge {}", s.discharge.join(", "))); + } + if let Some(stage) = &s.discharge_stage { + line.push_str(&format!(" at {stage}")); + } + line +} + +fn tolerance_line(s: &Screen) -> String { + match &s.tolerance_override { + Some(o) => format!("{o} (override; floor pinned at {})", s.tolerance_floor_at_creation), + None => format!("{} (pinned at creation)", s.tolerance_floor_at_creation), + } +} + +fn version_line(s: &Screen) -> String { + let chain = match s.versions_in_chain { + 1 => "first version".to_string(), + n => format!("{n} in chain"), + }; + format!("{} ({chain})", s.hash) +} + +fn filing_line(s: &Screen) -> String { + format!("{} on {} by {}", s.filed.change_set, s.filed.at, s.filed.by) +} + +/// The acceptance situation, and — when there is none — the exact command +/// that would create one. Naming it is not performing it. +fn acceptance_block(s: &Screen) -> String { + let mut out = String::new(); + let lines: Vec = s.acceptances.iter().map(acceptance_line).collect(); + list(&mut out, "acceptances", &lines, "none on record"); + if !s.acceptances.iter().any(|a| a.standing == "live") { + field(&mut out, "to accept", &format!("ledger accept {}", s.decision)); + } + out +} + +fn acceptance_line(a: &AcceptanceLine) -> String { + let mut line = + format!("{} signed {} on {} ({})", a.actor, a.version, a.at, a.standing); + if let Some(expires) = &a.expires_at { + line.push_str(&format!(", expires {expires}")); + } + line +} diff --git a/ledger-core/src/show_tests.rs b/ledger-core/src/show_tests.rs new file mode 100644 index 0000000..a1dbbff --- /dev/null +++ b/ledger-core/src/show_tests.rs @@ -0,0 +1,161 @@ +//! `show` over hand-built stores: the fields, the two edge kinds kept +//! apart, and the acceptance situation. + +use super::*; +use crate::testkit; + +fn today() -> NaiveDate { + testkit::date("2026-08-13") +} + +fn pointers(edges: &[Edge]) -> Vec { + edges.iter().map(|e| e.pointer.clone()).collect() +} + +/// A resolver that knows one pointer, standing in for the store a real +/// pointer lands in. +struct OneText(&'static str, &'static str); + +impl BasisText for OneText { + fn text(&self, pointer: &str) -> Option { + (pointer == self.0).then(|| self.1.to_string()) + } +} + +#[test] +fn an_unknown_decision_has_no_screen() { + let store = testkit::store(testkit::changeset(vec![], vec![])); + assert!(screen(&store, &testkit::decision_id(), today(), &NoText).is_none()); +} + +#[test] +fn the_screen_carries_the_hashed_content_and_the_filing_act() { + let v = testkit::sealed(testkit::version()); + let store = testkit::store(testkit::changeset(vec![v.clone()], vec![])); + let s = screen(&store, &testkit::decision_id(), today(), &NoText).expect("screen"); + assert_eq!(s.statement, v.statement); + assert_eq!(s.set, "ledger-design"); + assert_eq!(s.set_floor.as_deref(), Some("T1")); + assert_eq!(s.hash, v.hash.to_string()); + assert_eq!(s.versions_in_chain, 1); + assert_eq!(s.allocation.as_deref(), Some("constraint")); + assert_eq!(s.filed.by, "fixture-human@example"); + assert_eq!(s.state, DispositionState::AwaitingAcceptance); +} + +#[test] +fn ground_and_reopen_edges_are_separate_fields_and_separate_blocks() { + let mut raw = testkit::version(); + raw.revisit_if = vec!["claim:DDD-adapter-02@sha256:b333063d".parse().expect("token")]; + let store = testkit::store(testkit::changeset(vec![testkit::sealed(raw)], vec![])); + let s = screen(&store, &testkit::decision_id(), today(), &NoText).expect("screen"); + assert_eq!(pointers(&s.based_on), vec!["prd:decision-ledger-prd#4.2.1".to_string()]); + assert_eq!(pointers(&s.revisit_if), vec!["claim:DDD-adapter-02@sha256:b333063d".to_string()]); + let text = render(&s); + let ground = text.find("based on").expect("ground block"); + let reopen = text.find("revisit if").expect("reopen block"); + assert!(ground < reopen, "the two blocks are distinct and ordered:\n{text}"); + // The reopen pointer must not appear inside the ground block. + let between = &text[ground..reopen]; + assert!(!between.contains("DDD-adapter-02"), "a reopen edge rendered as ground:\n{text}"); +} + +#[test] +fn an_unaccepted_decision_names_the_command_that_would_sign_it() { + let store = testkit::store(testkit::changeset(vec![testkit::sealed(testkit::version())], vec![])); + let s = screen(&store, &testkit::decision_id(), today(), &NoText).expect("screen"); + let text = render(&s); + assert!(text.contains("none on record"), "{text}"); + assert!(text.contains("ledger accept dec:hafeok.ledger/"), "{text}"); +} + +#[test] +fn a_live_acceptance_shows_as_live_and_stops_prompting() { + let v = testkit::sealed(testkit::version()); + let acc = testkit::acceptance(&v); + let store = testkit::store(testkit::changeset(vec![v], vec![acc])); + let s = screen(&store, &testkit::decision_id(), today(), &NoText).expect("screen"); + assert_eq!(s.state, DispositionState::Decided); + assert_eq!(s.acceptances.len(), 1); + assert_eq!(s.acceptances[0].standing, "live"); + let text = render(&s); + assert!(!text.contains("to accept"), "a signed decision still prompts:\n{text}"); +} + +#[test] +fn the_json_form_carries_the_same_facts_as_the_text() { + let mut raw = testkit::version(); + raw.revisit_if = vec!["claim:DDD-gates-01@sha256:6f500ee8".parse().expect("token")]; + let store = testkit::store(testkit::changeset(vec![testkit::sealed(raw)], vec![])); + let s = screen(&store, &testkit::decision_id(), today(), &NoText).expect("screen"); + let json = serde_json::to_value(&s).expect("serialize"); + assert_eq!(json["revisit_if"][0]["pointer"], "claim:DDD-gates-01@sha256:6f500ee8"); + assert_eq!(json["state"], "awaiting-acceptance"); + assert_eq!(json["hash"], s.hash); + // An absent edge list is omitted rather than rendered as an empty + // array: absent and empty are the same fact, and one spelling is enough. + assert!(json.get("supersedes").is_none()); +} + +/// The argument, not just the type. A reader ruling on `mandate:…` is +/// ruling on what the mandate says, so the screen has to carry it. +#[test] +fn a_resolved_edge_renders_its_argument_under_the_pointer() { + let mut raw = testkit::version(); + raw.based_on = vec!["mandate:dec/ddd/internal-not-surface".parse().expect("basis")]; + let store = testkit::store(testkit::changeset(vec![testkit::sealed(raw)], vec![])); + let argument = "Context& repositories are application repositories."; + let texts = OneText("mandate:dec/ddd/internal-not-surface", argument); + let s = screen(&store, &testkit::decision_id(), today(), &texts).expect("screen"); + assert_eq!(s.based_on[0].argument.as_deref(), Some(argument)); + assert!(render(&s).contains(argument), "{}", render(&s)); +} + +#[test] +fn an_unresolvable_edge_renders_bare_rather_than_blank() { + let store = testkit::store(testkit::changeset(vec![testkit::sealed(testkit::version())], vec![])); + let s = screen(&store, &testkit::decision_id(), today(), &NoText).expect("screen"); + assert!(s.based_on[0].argument.is_none()); + assert!(render(&s).contains("prd:decision-ledger-prd#4.2.1")); +} + +/// The grouping is derived from content, so it cannot go stale: a +/// criterion discharged solely by the contract check is a transcription; +/// a judgment is an individual read. +#[test] +fn the_weight_class_is_derived_from_the_discharge() { + let mut transcription = testkit::version(); + transcription.allocation = Some(crate::allocation::AllocationKind::Criterion); + transcription.discharge = vec!["contract:seam/ledger/canonical-form".parse().expect("ref")]; + transcription.discharge_stage = Some(crate::discharge::Stage::Pr); + let store = testkit::store(testkit::changeset(vec![testkit::sealed(transcription)], vec![])); + let s = screen(&store, &testkit::decision_id(), today(), &NoText).expect("screen"); + assert_eq!(s.group, "mechanical"); + + // The fixture's default is an analyzer-discharged constraint. + let plain = testkit::store(testkit::changeset(vec![testkit::sealed(testkit::version())], vec![])); + let s = screen(&plain, &testkit::decision_id(), today(), &NoText).expect("screen"); + assert_eq!(s.group, "individual"); +} + +#[test] +fn a_selector_covers_a_whole_group_in_one_pass() { + let store = testkit::store(testkit::changeset(vec![testkit::sealed(testkit::version())], vec![])); + let all = screens(&store, &Selector::All, today(), &NoText); + assert_eq!(all.len(), 1); + let by_set = screens(&store, &Selector::Set("ledger-design".into()), today(), &NoText); + assert_eq!(by_set.len(), 1); + assert!(screens(&store, &Selector::Set("absent".into()), today(), &NoText).is_empty()); + let mechanical = screens(&store, &Selector::Group(Group::Mechanical), today(), &NoText); + assert!(mechanical.is_empty(), "the fixture is an individual read"); + let individual = screens(&store, &Selector::Group(Group::Individual), today(), &NoText); + assert_eq!(individual.len(), 1); + assert!(render_all(&individual).starts_with("1 decision(s)")); + assert_eq!(render_all(&[]), "no decisions match\n"); +} + +#[test] +fn an_unknown_group_names_the_ones_that_exist() { + let err = Group::parse("mechanicals").expect_err("unknown group"); + assert!(err.contains("mechanical | individual"), "{err}"); +} diff --git a/ledger-core/src/testkit.rs b/ledger-core/src/testkit.rs index 9ea74e5..94c923d 100644 --- a/ledger-core/src/testkit.rs +++ b/ledger-core/src/testkit.rs @@ -58,6 +58,7 @@ pub fn version() -> VersionRaw { tolerance_floor_at_creation: Tier::T1, tolerance_override: None, based_on: vec!["prd:decision-ledger-prd#4.2.1".parse().expect("basis")], + revisit_if: Vec::new(), supersedes: None, } } diff --git a/ledger-core/src/version.rs b/ledger-core/src/version.rs index cead3a3..ab7de38 100644 --- a/ledger-core/src/version.rs +++ b/ledger-core/src/version.rs @@ -24,6 +24,7 @@ use crate::finding::{Finding, VerifyClass}; use crate::hash::VersionHash; use crate::id::DecisionId; use crate::identity::Identity; +use crate::revisit::RevisitRef; use crate::tier::{Tier, Tolerance}; /// A pointer to the basis a version rests on: a PRD section, an upstream @@ -119,6 +120,13 @@ pub struct VersionRaw { pub tolerance_override: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub based_on: Vec, + /// Claims whose death reopens this decision (format 4). Not ground: + /// the decision does not rest on these, so the basis-loss scan never + /// reads them and a movement here is a *reopen*, not a basis loss. + /// Hashed as a set when present; absent on every version written + /// before the field existed, so no format-1/2/3 digest moves. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub revisit_if: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub supersedes: Option, } @@ -137,6 +145,9 @@ pub struct DecisionVersion { pub allocation: Option, pub tolerance: Tolerance, pub based_on: Vec, + /// The reopen edges — kept in a field of their own, never folded into + /// `based_on`, so nothing downstream can read one as ground. + pub revisit_if: Vec, pub supersedes: Option, } @@ -171,6 +182,7 @@ impl DecisionVersion { allocation, tolerance, based_on: raw.based_on.clone(), + revisit_if: raw.revisit_if.clone(), supersedes: raw.supersedes.clone(), }) }