diff --git a/.ddd/seams/seam-rust-product-cli-src-commands-registry-rs.yaml b/.ddd/seams/seam-rust-product-cli-src-commands-registry-rs.yaml new file mode 100644 index 00000000..b0385851 --- /dev/null +++ b/.ddd/seams/seam-rust-product-cli-src-commands-registry-rs.yaml @@ -0,0 +1,26 @@ +format: 2 +id: seam/rust/product-cli-src-commands-registry-rs +boundary: contract surface of product-cli/src/commands/registry.rs +verdict_knowledge: 'CLI users learn minting an instance and reading one are separate verbs. generate is local and complete: it writes a tree, records a birth commit, prints the two commands that would publish it, and pushes nothing — publishing is a separate act with a different authority. check exits non-zero on any finding and reports an unreadable shape under its own heading, distinctly from data violating a shape, so a fail-closed run is never read as a violated constraint.' +contract_location: product-cli/src/commands/registry.rs +obligations: [] +metadata: {} +bindings: +- symbol: RegistryCommands + file: product-cli/src/commands/registry.rs + before: absent + after: sha256:670be017fc201f79a63196716a6f1ecd48c97a89b72906fe8d75119fbab2ce0c + base_revision: 61222d2d386993ad671643dc46692202a2a4f1e7 + hash: sha256:41ac06715c193bfe663c2b267f0eec3d3d4fb433c9460bc89197fa83c2efa355 +- symbol: Check + file: product-cli/src/commands/registry.rs + before: absent + after: sha256:670be017fc201f79a63196716a6f1ecd48c97a89b72906fe8d75119fbab2ce0c + base_revision: 61222d2d386993ad671643dc46692202a2a4f1e7 + hash: sha256:849a768e6dce9250f5b223a58ecc0430a4b05f5c162025bfa35ffa9104c1a1cb +- symbol: Generate + file: product-cli/src/commands/registry.rs + before: absent + after: sha256:670be017fc201f79a63196716a6f1ecd48c97a89b72906fe8d75119fbab2ce0c + base_revision: 61222d2d386993ad671643dc46692202a2a4f1e7 + hash: sha256:a6243953634cd26af54fe92ea9798ea3e4e941c8e177ebf68cc2510e77c9c833 diff --git a/.ddd/seams/seam-rust-product-cli-src-commands-root-enum-rs.yaml b/.ddd/seams/seam-rust-product-cli-src-commands-root-enum-rs.yaml new file mode 100644 index 00000000..171b1111 --- /dev/null +++ b/.ddd/seams/seam-rust-product-cli-src-commands-root-enum-rs.yaml @@ -0,0 +1,14 @@ +format: 2 +id: seam/rust/product-cli-src-commands-root-enum-rs +boundary: contract surface of product-cli/src/commands/root_enum.rs +verdict_knowledge: 'CLI users learn product registry is where ground-registry instances are minted from the versioned template — a G-track surface that reads and writes no .product graph in any repository, and therefore takes no --product argument and belongs to no session phase.' +contract_location: product-cli/src/commands/root_enum.rs +obligations: [] +metadata: {} +bindings: +- symbol: Registry + file: product-cli/src/commands/root_enum.rs + before: sha256:9ee4cae354a6261698f961528f3b55c036af90eabdb3e81f47399b7cf892e53d + after: sha256:e9bdd37f3d13329814aee927c1163934cfe7ac1ab5550f86efac7b2c85acf57e + base_revision: 61222d2d386993ad671643dc46692202a2a4f1e7 + hash: sha256:a375b19f6f7bf47323d32d659d62fcc44abf2ccc886d06db4efb99e0d2638220 diff --git a/.ddd/seams/seam-rust-product-core-src-lib-rs.yaml b/.ddd/seams/seam-rust-product-core-src-lib-rs.yaml new file mode 100644 index 00000000..87acf991 --- /dev/null +++ b/.ddd/seams/seam-rust-product-core-src-lib-rs.yaml @@ -0,0 +1,14 @@ +format: 2 +id: seam/rust/product-core-src-lib-rs +boundary: contract surface of product-core/src/lib.rs +verdict_knowledge: 'Consumers learn product-core carries ground-registry generation alongside the framework graph, and that the surface is the acts — plan, apply, check. The modules beneath are crate-internal: how the template is rendered, and how the gate is spelled, are not part of the contract.' +contract_location: product-core/src/lib.rs +obligations: [] +metadata: {} +bindings: +- symbol: registry + file: product-core/src/lib.rs + before: sha256:72be978c89ffd58d92fc0b8728def0de717cd63e1e6d0281c706a52410dcd6ee + after: sha256:3d1bb8cb6b4faac3f084b8a8cf11ebed9db7b7ac768b8ca9c27029c08c73fd45 + base_revision: 61222d2d386993ad671643dc46692202a2a4f1e7 + hash: sha256:0117c3fcad2d4b1f681bf521bacfa16bfead015df594bde1e4bcc5af213e8bb3 diff --git a/.ddd/seams/seam-rust-product-core-src-registry-apply-rs.yaml b/.ddd/seams/seam-rust-product-core-src-registry-apply-rs.yaml new file mode 100644 index 00000000..5f202501 --- /dev/null +++ b/.ddd/seams/seam-rust-product-core-src-registry-apply-rs.yaml @@ -0,0 +1,50 @@ +format: 2 +id: seam/rust/product-core-src-registry-apply-rs +boundary: contract surface of product-core/src/registry/apply.rs +verdict_knowledge: 'A caller learns applying a plan is local and total: the tree is written into a target that must be empty, a repository is initialised, and one birth commit is recorded whose identity, dates and config come from the parameters alone — so the same plan yields the same commit id. What it will not do is publish: no remote is configured and nothing is pushed. GenerationReport.publish carries the commands a human would run, and COMMIT_EMAIL is an RFC 2606 .invalid address stating that no email identity was chosen — the agent is the generated-by parameter.' +contract_location: product-core/src/registry/apply.rs +obligations: [] +metadata: {} +bindings: +- symbol: COMMIT_EMAIL + file: product-core/src/registry/apply.rs + before: absent + after: sha256:48cce0a066ea66fd13f0518fd5492df3ccd71a901fe9c00363918e28e9818bc6 + base_revision: 61222d2d386993ad671643dc46692202a2a4f1e7 + hash: sha256:deaf58930629cbc80c9b05443b966a6a866bbe0892173289fc352ee0608282f3 +- symbol: apply_generation + file: product-core/src/registry/apply.rs + before: absent + after: sha256:48cce0a066ea66fd13f0518fd5492df3ccd71a901fe9c00363918e28e9818bc6 + base_revision: 61222d2d386993ad671643dc46692202a2a4f1e7 + hash: sha256:bf83db200425e60ce4b34dc38b0af0700c6e556e19e4e591d16c86a432711c67 +- symbol: GenerationReport + file: product-core/src/registry/apply.rs + before: absent + after: sha256:48cce0a066ea66fd13f0518fd5492df3ccd71a901fe9c00363918e28e9818bc6 + base_revision: 61222d2d386993ad671643dc46692202a2a4f1e7 + hash: sha256:c5deea6ce6f0e7aa2b9615c1cb3dee56abbc05f8e41af3f1fb3b8023a3bc0705 +- symbol: commit + file: product-core/src/registry/apply.rs + before: absent + after: sha256:48cce0a066ea66fd13f0518fd5492df3ccd71a901fe9c00363918e28e9818bc6 + base_revision: 61222d2d386993ad671643dc46692202a2a4f1e7 + hash: sha256:4621d8cdc9e25c3744721cc4f187a33541395b56a03112cb7039d06ec40f6e01 +- symbol: files + file: product-core/src/registry/apply.rs + before: absent + after: sha256:48cce0a066ea66fd13f0518fd5492df3ccd71a901fe9c00363918e28e9818bc6 + base_revision: 61222d2d386993ad671643dc46692202a2a4f1e7 + hash: sha256:45848582cecc3d3aa08cef3c2dd0e950383bae4922b2039854d507df7b7431b6 +- symbol: out + file: product-core/src/registry/apply.rs + before: absent + after: sha256:48cce0a066ea66fd13f0518fd5492df3ccd71a901fe9c00363918e28e9818bc6 + base_revision: 61222d2d386993ad671643dc46692202a2a4f1e7 + hash: sha256:82c1a3c9d754fcc0f254b2ce531883391b2115f14c7cd767f8297d24ce0c4a52 +- symbol: publish + file: product-core/src/registry/apply.rs + before: absent + after: sha256:48cce0a066ea66fd13f0518fd5492df3ccd71a901fe9c00363918e28e9818bc6 + base_revision: 61222d2d386993ad671643dc46692202a2a4f1e7 + hash: sha256:1f31aa4d9f571595c4faf9dac96372078e640782dac6297ca5c208e8134fa77e diff --git a/.ddd/seams/seam-rust-product-core-src-registry-check-mod-rs.yaml b/.ddd/seams/seam-rust-product-core-src-registry-check-mod-rs.yaml new file mode 100644 index 00000000..8c055c87 --- /dev/null +++ b/.ddd/seams/seam-rust-product-core-src-registry-check-mod-rs.yaml @@ -0,0 +1,116 @@ +format: 2 +id: seam/rust/product-core-src-registry-check-mod-rs +boundary: contract surface of product-core/src/registry/check/mod.rs +verdict_knowledge: 'A caller learns whether a generated instance conforms to its own rules, with three finding kinds kept apart: a file breaking the one-assertion-or-one-decision rule, data violating a shape, and a shape the reader could not read. The third is the load-bearing one — an unevaluable shape fails the check rather than being skipped, so no shape passes silently. The honest limit: this is a second reader of rules the instance''s own CI runs under pySHACL, covering a defined SHACL subset; the divergence between the two is measured by fixture, not assumed small.' +contract_location: product-core/src/registry/check/mod.rs +obligations: [] +metadata: {} +bindings: +- symbol: FindingKind + file: product-core/src/registry/check/mod.rs + before: absent + after: sha256:573dd6a01b6e6b6c417c1cc00d34c31d4a05e065d83674dd60a57088fb3d271c + base_revision: 61222d2d386993ad671643dc46692202a2a4f1e7 + hash: sha256:2f11c392549a05c40703051ad9f41bbb0cf4bce6a95b77201d2f16bb41ead3a5 +- symbol: check_instance + file: product-core/src/registry/check/mod.rs + before: absent + after: sha256:573dd6a01b6e6b6c417c1cc00d34c31d4a05e065d83674dd60a57088fb3d271c + base_revision: 61222d2d386993ad671643dc46692202a2a4f1e7 + hash: sha256:ec1efa6afbb99d68c220d72b8474d51bc831368229eb5966c23f6d77e06c655c +- symbol: evaluate + file: product-core/src/registry/check/mod.rs + before: absent + after: sha256:573dd6a01b6e6b6c417c1cc00d34c31d4a05e065d83674dd60a57088fb3d271c + base_revision: 61222d2d386993ad671643dc46692202a2a4f1e7 + hash: sha256:729d6ac9f734edf91d25e6e7ec262a6b96b991525a5a272d0fbd414118e082d0 +- symbol: impl CheckReport + file: product-core/src/registry/check/mod.rs + before: absent + after: sha256:573dd6a01b6e6b6c417c1cc00d34c31d4a05e065d83674dd60a57088fb3d271c + base_revision: 61222d2d386993ad671643dc46692202a2a4f1e7 + hash: sha256:a4d3dfe3dd8d0e385cc9ef472daed33fa67aa9c02e0d1fad447cbc6bd4e7ab37 +- symbol: CheckReport + file: product-core/src/registry/check/mod.rs + before: absent + after: sha256:573dd6a01b6e6b6c417c1cc00d34c31d4a05e065d83674dd60a57088fb3d271c + base_revision: 61222d2d386993ad671643dc46692202a2a4f1e7 + hash: sha256:952627dc5015b8ab94f317b33abdbf6276f112206c9d7d3259b962df145f2291 +- symbol: Finding + file: product-core/src/registry/check/mod.rs + before: absent + after: sha256:573dd6a01b6e6b6c417c1cc00d34c31d4a05e065d83674dd60a57088fb3d271c + base_revision: 61222d2d386993ad671643dc46692202a2a4f1e7 + hash: sha256:951b5f12a4b28e802f2c54f4e6f56e91f27230a911b544bc42251587a9d51852 +- symbol: constraints_evaluated + file: product-core/src/registry/check/mod.rs + before: absent + after: sha256:573dd6a01b6e6b6c417c1cc00d34c31d4a05e065d83674dd60a57088fb3d271c + base_revision: 61222d2d386993ad671643dc46692202a2a4f1e7 + hash: sha256:49437cb74129f7d144f29f59b86eb87c3f6676ce791e64d76d4fe71b9861cb75 +- symbol: files_checked + file: product-core/src/registry/check/mod.rs + before: absent + after: sha256:573dd6a01b6e6b6c417c1cc00d34c31d4a05e065d83674dd60a57088fb3d271c + base_revision: 61222d2d386993ad671643dc46692202a2a4f1e7 + hash: sha256:40e44e6a9b3741147f3fd63e79d94ca5413257813db8404fb8231190a48f42ce +- symbol: findings + file: product-core/src/registry/check/mod.rs + before: absent + after: sha256:573dd6a01b6e6b6c417c1cc00d34c31d4a05e065d83674dd60a57088fb3d271c + base_revision: 61222d2d386993ad671643dc46692202a2a4f1e7 + hash: sha256:111603671a0c33d66ef35c4dccb7e90af0b42cff7bfc1bc38b58b01ab005c266 +- symbol: file + file: product-core/src/registry/check/mod.rs + before: absent + after: sha256:573dd6a01b6e6b6c417c1cc00d34c31d4a05e065d83674dd60a57088fb3d271c + base_revision: 61222d2d386993ad671643dc46692202a2a4f1e7 + hash: sha256:31498218226b6e700a4038e419019f662d9dd4a16c7d72f1e8fe50d560220b3c +- symbol: focus + file: product-core/src/registry/check/mod.rs + before: absent + after: sha256:573dd6a01b6e6b6c417c1cc00d34c31d4a05e065d83674dd60a57088fb3d271c + base_revision: 61222d2d386993ad671643dc46692202a2a4f1e7 + hash: sha256:91311f5d354aedfe964bccedd9f4421b056f00ef0ef1c90135d21c4db9e19803 +- symbol: kind + file: product-core/src/registry/check/mod.rs + before: absent + after: sha256:573dd6a01b6e6b6c417c1cc00d34c31d4a05e065d83674dd60a57088fb3d271c + base_revision: 61222d2d386993ad671643dc46692202a2a4f1e7 + hash: sha256:279228345a486ae5e137165639d74076aa52e8acef0d0791e77f2b74bc662610 +- symbol: message + file: product-core/src/registry/check/mod.rs + before: absent + after: sha256:573dd6a01b6e6b6c417c1cc00d34c31d4a05e065d83674dd60a57088fb3d271c + base_revision: 61222d2d386993ad671643dc46692202a2a4f1e7 + hash: sha256:89306efefc5e1d269082b81201a38c24db4127d6b439c0be8b8afe07685e0487 +- symbol: FileRule + file: product-core/src/registry/check/mod.rs + before: absent + after: sha256:573dd6a01b6e6b6c417c1cc00d34c31d4a05e065d83674dd60a57088fb3d271c + base_revision: 61222d2d386993ad671643dc46692202a2a4f1e7 + hash: sha256:a61f123b2f0579850a0692c68fa175317508d8585d8249cd2378dfb23d7823b2 +- symbol: ShapeViolation + file: product-core/src/registry/check/mod.rs + before: absent + after: sha256:573dd6a01b6e6b6c417c1cc00d34c31d4a05e065d83674dd60a57088fb3d271c + base_revision: 61222d2d386993ad671643dc46692202a2a4f1e7 + hash: sha256:573e7887c81907956e9f937c3885398a99464488edc7f862057ccce85073a321 +- symbol: Unevaluable + file: product-core/src/registry/check/mod.rs + before: absent + after: sha256:573dd6a01b6e6b6c417c1cc00d34c31d4a05e065d83674dd60a57088fb3d271c + base_revision: 61222d2d386993ad671643dc46692202a2a4f1e7 + hash: sha256:0366b090603146fd0236f690d262b611428b2e2cdc5e66741e5b2dee4d177580 +- symbol: conforms + file: product-core/src/registry/check/mod.rs + before: absent + after: sha256:573dd6a01b6e6b6c417c1cc00d34c31d4a05e065d83674dd60a57088fb3d271c + base_revision: 61222d2d386993ad671643dc46692202a2a4f1e7 + hash: sha256:5f5c4dd075bf09fb7d7974098383285628ebad0dbb6809e9147b41af887b62a6 +- symbol: of_kind + file: product-core/src/registry/check/mod.rs + before: absent + after: sha256:573dd6a01b6e6b6c417c1cc00d34c31d4a05e065d83674dd60a57088fb3d271c + base_revision: 61222d2d386993ad671643dc46692202a2a4f1e7 + hash: sha256:a5ef98939537a89956ef7bf2a8a8a268eabfa8357a0e08ed351cd2c9d7c403dd diff --git a/.ddd/seams/seam-rust-product-core-src-registry-params-rs.yaml b/.ddd/seams/seam-rust-product-core-src-registry-params-rs.yaml new file mode 100644 index 00000000..7653b326 --- /dev/null +++ b/.ddd/seams/seam-rust-product-core-src-registry-params-rs.yaml @@ -0,0 +1,68 @@ +format: 2 +id: seam/rust/product-core-src-registry-params-rs +boundary: contract surface of product-core/src/registry/params.rs +verdict_knowledge: 'A caller learns generation parameters are typed, and validated for meaning only: a date that parses as a date, a base IRI the RDF parser accepts and that ends where identifiers are minted, a repository name that is one path segment. What validation deliberately does not do is judge a value safe — no character is rejected, because the renderer carries any bytes verbatim and a character rule would be an escaping rule, which is what corrupted the first generated instance.' +contract_location: product-core/src/registry/params.rs +obligations: [] +metadata: {} +bindings: +- symbol: impl RegistryParams + file: product-core/src/registry/params.rs + before: absent + after: sha256:aad55c91b8ae590a53f9f7136804e9efd337b28665c4b27e0a75ce356e260c98 + base_revision: 61222d2d386993ad671643dc46692202a2a4f1e7 + hash: sha256:dc2915844a4178c0f4cfaae23f5890265ce7c6ac621d0668efdf66c5a7ff6fe3 +- symbol: RegistryParams + file: product-core/src/registry/params.rs + before: absent + after: sha256:aad55c91b8ae590a53f9f7136804e9efd337b28665c4b27e0a75ce356e260c98 + base_revision: 61222d2d386993ad671643dc46692202a2a4f1e7 + hash: sha256:31e001c171ef337a06a4ef3da26aa3cbadafa15cad4d8c97279402f1c2e93145 +- symbol: base_iri + file: product-core/src/registry/params.rs + before: absent + after: sha256:aad55c91b8ae590a53f9f7136804e9efd337b28665c4b27e0a75ce356e260c98 + base_revision: 61222d2d386993ad671643dc46692202a2a4f1e7 + hash: sha256:e6feb46bb9fe490ac162b0de75340d0f110da1162e53d2b4bf2f41f595b7227f +- symbol: display_name + file: product-core/src/registry/params.rs + before: absent + after: sha256:aad55c91b8ae590a53f9f7136804e9efd337b28665c4b27e0a75ce356e260c98 + base_revision: 61222d2d386993ad671643dc46692202a2a4f1e7 + hash: sha256:a6f6795402f099550ea2933c3fe80682288f4309e55d9bd6f3badd1c96da6e4b +- symbol: generated_by + file: product-core/src/registry/params.rs + before: absent + after: sha256:aad55c91b8ae590a53f9f7136804e9efd337b28665c4b27e0a75ce356e260c98 + base_revision: 61222d2d386993ad671643dc46692202a2a4f1e7 + hash: sha256:fe38292d9a4a45f78159e971e0b9426687b6f5585af000381a9d01d6b49de1ee +- symbol: mint_date + file: product-core/src/registry/params.rs + before: absent + after: sha256:aad55c91b8ae590a53f9f7136804e9efd337b28665c4b27e0a75ce356e260c98 + base_revision: 61222d2d386993ad671643dc46692202a2a4f1e7 + hash: sha256:b930a9e8aa4412fdaa9a2dcd00d17fd12875170207e39392e5c0bdfcd0884e00 +- symbol: owner + file: product-core/src/registry/params.rs + before: absent + after: sha256:aad55c91b8ae590a53f9f7136804e9efd337b28665c4b27e0a75ce356e260c98 + base_revision: 61222d2d386993ad671643dc46692202a2a4f1e7 + hash: sha256:edef3c0f965cb0e1b542a45a0bd06119b4bc5e1019f5186d033f664b7e77b27c +- symbol: ratifier + file: product-core/src/registry/params.rs + before: absent + after: sha256:aad55c91b8ae590a53f9f7136804e9efd337b28665c4b27e0a75ce356e260c98 + base_revision: 61222d2d386993ad671643dc46692202a2a4f1e7 + hash: sha256:f068f90452343aedd5656ebf2587d32941286be719a1921144d53ee4f849040b +- symbol: repo + file: product-core/src/registry/params.rs + before: absent + after: sha256:aad55c91b8ae590a53f9f7136804e9efd337b28665c4b27e0a75ce356e260c98 + base_revision: 61222d2d386993ad671643dc46692202a2a4f1e7 + hash: sha256:9fc65aeaf0d46a16ba3bb6c1397d9760618009b8df242bfcf45a48935e3a7047 +- symbol: validate + file: product-core/src/registry/params.rs + before: absent + after: sha256:aad55c91b8ae590a53f9f7136804e9efd337b28665c4b27e0a75ce356e260c98 + base_revision: 61222d2d386993ad671643dc46692202a2a4f1e7 + hash: sha256:91206f8a495653fd6378f36d647e7cad84cb0760c3d48ac9b3f434492bb760f5 diff --git a/.ddd/seams/seam-rust-product-core-src-registry-plan-rs.yaml b/.ddd/seams/seam-rust-product-core-src-registry-plan-rs.yaml new file mode 100644 index 00000000..4ff6c421 --- /dev/null +++ b/.ddd/seams/seam-rust-product-core-src-registry-plan-rs.yaml @@ -0,0 +1,80 @@ +format: 2 +id: seam/rust/product-core-src-registry-plan-rs +boundary: contract surface of product-core/src/registry/plan.rs +verdict_knowledge: 'A caller learns a generation is planned whole in memory and handed back only if the verification gate passed: a GenerationPlan is a verified tree, carrying each file''s rendered contents, its executable bit, and the spans every parameter was written into. Planning touches no disk, so a refusal leaves no directory behind — not even a partial one. The plan cannot publish, and cannot be applied in part.' +contract_location: product-core/src/registry/plan.rs +obligations: [] +metadata: {} +bindings: +- symbol: plan_generation + file: product-core/src/registry/plan.rs + before: absent + after: sha256:7c659b5dd993263df73b124bb626f7ba6453399a9e9b1184e42af55b8526965b + base_revision: 61222d2d386993ad671643dc46692202a2a4f1e7 + hash: sha256:4014f8f7d6015a44667e1fb9ffc673b2ed3f578a4bf33bececa7806e0257bdb7 +- symbol: GenerationPlan + file: product-core/src/registry/plan.rs + before: absent + after: sha256:7c659b5dd993263df73b124bb626f7ba6453399a9e9b1184e42af55b8526965b + base_revision: 61222d2d386993ad671643dc46692202a2a4f1e7 + hash: sha256:f29929e9c90d358918c94c66875ea8c1ce314a259c4440d90130ace3aa87dad3 +- symbol: PlannedFile + file: product-core/src/registry/plan.rs + before: absent + after: sha256:7c659b5dd993263df73b124bb626f7ba6453399a9e9b1184e42af55b8526965b + base_revision: 61222d2d386993ad671643dc46692202a2a4f1e7 + hash: sha256:1f5b7938375230d3e18056ed9e9e871217f139ccda40f9dd8d92443a0b0a3456 +- symbol: files + file: product-core/src/registry/plan.rs + before: absent + after: sha256:7c659b5dd993263df73b124bb626f7ba6453399a9e9b1184e42af55b8526965b + base_revision: 61222d2d386993ad671643dc46692202a2a4f1e7 + hash: sha256:47e8c81167dee8f1e61cab50b36d66c2d9b2b4f057552ea5db453a01ce5f9750 +- symbol: gate + file: product-core/src/registry/plan.rs + before: absent + after: sha256:7c659b5dd993263df73b124bb626f7ba6453399a9e9b1184e42af55b8526965b + base_revision: 61222d2d386993ad671643dc46692202a2a4f1e7 + hash: sha256:3de6ec5f5b9a9e8a7ee809cd3a7f13a6cba7bbb4bb9dfc4db6c72f62c288770e +- symbol: generator_version + file: product-core/src/registry/plan.rs + before: absent + after: sha256:7c659b5dd993263df73b124bb626f7ba6453399a9e9b1184e42af55b8526965b + base_revision: 61222d2d386993ad671643dc46692202a2a4f1e7 + hash: sha256:a537bafd1d60d74e7b2450a9681fb05e0d2d20f79197d0e8c2fdaaa61348f58d +- symbol: params + file: product-core/src/registry/plan.rs + before: absent + after: sha256:7c659b5dd993263df73b124bb626f7ba6453399a9e9b1184e42af55b8526965b + base_revision: 61222d2d386993ad671643dc46692202a2a4f1e7 + hash: sha256:af36b199c54b4bf08d1aa06e8741a9de070c0568e21fd6cc41b195368497b457 +- symbol: template_version + file: product-core/src/registry/plan.rs + before: absent + after: sha256:7c659b5dd993263df73b124bb626f7ba6453399a9e9b1184e42af55b8526965b + base_revision: 61222d2d386993ad671643dc46692202a2a4f1e7 + hash: sha256:6e83e18f335309a47ae20c41df7850796f213c011101a22995f4dbe4d4c21023 +- symbol: contents + file: product-core/src/registry/plan.rs + before: absent + after: sha256:7c659b5dd993263df73b124bb626f7ba6453399a9e9b1184e42af55b8526965b + base_revision: 61222d2d386993ad671643dc46692202a2a4f1e7 + hash: sha256:ea3813fd1b1320e8c673e9433c819e8d1335bf13b32a9818ac0499a1522a09c6 +- symbol: executable + file: product-core/src/registry/plan.rs + before: absent + after: sha256:7c659b5dd993263df73b124bb626f7ba6453399a9e9b1184e42af55b8526965b + base_revision: 61222d2d386993ad671643dc46692202a2a4f1e7 + hash: sha256:0c714a57f4eb24d2921de4e6a6e7352902b7c3e188a0f73889dd7fc2b17c18ad +- symbol: path + file: product-core/src/registry/plan.rs + before: absent + after: sha256:7c659b5dd993263df73b124bb626f7ba6453399a9e9b1184e42af55b8526965b + base_revision: 61222d2d386993ad671643dc46692202a2a4f1e7 + hash: sha256:dfc66851c328650baf01c724611aa8fdbfa8d29dcc90d53793e2f167a8a7b97d +- symbol: sites + file: product-core/src/registry/plan.rs + before: absent + after: sha256:7c659b5dd993263df73b124bb626f7ba6453399a9e9b1184e42af55b8526965b + base_revision: 61222d2d386993ad671643dc46692202a2a4f1e7 + hash: sha256:9b0431a336e01dc630cafbde602e8586a11cf4acd65dd8fe98c47d783a5fd865 diff --git a/.ddd/seams/seam-rust-product-core-src-registry-substitute-rs.yaml b/.ddd/seams/seam-rust-product-core-src-registry-substitute-rs.yaml new file mode 100644 index 00000000..5bb726a0 --- /dev/null +++ b/.ddd/seams/seam-rust-product-core-src-registry-substitute-rs.yaml @@ -0,0 +1,32 @@ +format: 2 +id: seam/rust/product-core-src-registry-substitute-rs +boundary: contract surface of product-core/src/registry/substitute.rs +verdict_knowledge: 'A caller learns where each parameter value was written into a rendered file — label, byte offset, byte length — which is what lets the gate read the values back out and check them rather than trust the renderer that wrote them. A Site addresses the output, not the template: it cannot say which placeholder produced it, and it is meaningless against any text but the rendered file it came from.' +contract_location: product-core/src/registry/substitute.rs +obligations: [] +metadata: {} +bindings: +- symbol: Site + file: product-core/src/registry/substitute.rs + before: absent + after: sha256:c5ba522041f2b68021be18c00bbbb8fddb03a6cf7ab1800c5e2873f277325d16 + base_revision: 61222d2d386993ad671643dc46692202a2a4f1e7 + hash: sha256:3cfaf9096c79b48514f3ab4e4d35bbb84d04e93edd321736bef2c73d0cbfaa9a +- symbol: label + file: product-core/src/registry/substitute.rs + before: absent + after: sha256:c5ba522041f2b68021be18c00bbbb8fddb03a6cf7ab1800c5e2873f277325d16 + base_revision: 61222d2d386993ad671643dc46692202a2a4f1e7 + hash: sha256:9009ac81121a602d3f3288c295d8b432931f6b238051e9d63101aea041408955 +- symbol: len + file: product-core/src/registry/substitute.rs + before: absent + after: sha256:c5ba522041f2b68021be18c00bbbb8fddb03a6cf7ab1800c5e2873f277325d16 + base_revision: 61222d2d386993ad671643dc46692202a2a4f1e7 + hash: sha256:525b63633c80cb6fe33cbe45349698104bb4b15e5fb839d3c1f638bc97e08347 +- symbol: offset + file: product-core/src/registry/substitute.rs + before: absent + after: sha256:c5ba522041f2b68021be18c00bbbb8fddb03a6cf7ab1800c5e2873f277325d16 + base_revision: 61222d2d386993ad671643dc46692202a2a4f1e7 + hash: sha256:6d1960a3b674935ba048278f4088bad07ebf7af3e62e0599e0661e8e49a1fd2c diff --git a/.ddd/seams/seam-rust-product-core-src-registry-template-rs.yaml b/.ddd/seams/seam-rust-product-core-src-registry-template-rs.yaml new file mode 100644 index 00000000..a1b694ac --- /dev/null +++ b/.ddd/seams/seam-rust-product-core-src-registry-template-rs.yaml @@ -0,0 +1,20 @@ +format: 2 +id: seam/rust/product-core-src-registry-template-rs +boundary: contract surface of product-core/src/registry/template.rs +verdict_knowledge: 'A caller learns which version of the registry template a generated instance came from — the version that instance re-pins against, held in step with TEMPLATE.md''s own header by test. It does not say which generator produced the instance: that is recorded separately in the birth provenance, because the template and the generator move independently and an instance may need to re-pin against either.' +contract_location: product-core/src/registry/template.rs +obligations: [] +metadata: {} +bindings: +- symbol: TEMPLATE_VERSION + file: product-core/src/registry/template.rs + before: absent + after: sha256:bba0ebcd30bfd943de3fd6ae02fca0942ec05704d5d00fdc16c4b50695576f3f + base_revision: 61222d2d386993ad671643dc46692202a2a4f1e7 + hash: sha256:3c121abeb81dd76cd57e7688cdeb44be0c7364d04057b5d589ccda7ba7321d83 +- symbol: TEMPLATE_VERSION + file: product-core/src/registry/template.rs + before: absent + after: sha256:356ef4005495646a068ec99be7461518a50bbb8823dfb7b9c86fc58775e27a04 + base_revision: 61222d2d386993ad671643dc46692202a2a4f1e7 + hash: sha256:2350eb942dd708e8aafdf9fedc07447fe460de271bf65229e0e55517604f3aa5 diff --git a/.ddd/seams/seam-rust-product-core-src-registry-verify-rs.yaml b/.ddd/seams/seam-rust-product-core-src-registry-verify-rs.yaml new file mode 100644 index 00000000..6261f18c --- /dev/null +++ b/.ddd/seams/seam-rust-product-core-src-registry-verify-rs.yaml @@ -0,0 +1,20 @@ +format: 2 +id: seam/rust/product-core-src-registry-verify-rs +boundary: contract surface of product-core/src/registry/verify.rs +verdict_knowledge: 'A caller learns how many sites each parameter reached in the generated tree, per label — the gate''s own tally. A GateReport existing at all means both checks passed: every value round-tripped byte-identically against the spans it was written into, and no placeholder survived outside a value. It is never a partial result and carries no findings — a gate failure is an error that aborts generation, not a report to inspect afterwards.' +contract_location: product-core/src/registry/verify.rs +obligations: [] +metadata: {} +bindings: +- symbol: GateReport + file: product-core/src/registry/verify.rs + before: absent + after: sha256:069be9556e49d2bf581d40d5b1e73812c0a96db502588b0749da48f5255c5bdf + base_revision: 61222d2d386993ad671643dc46692202a2a4f1e7 + hash: sha256:741cd40cbfb4bf83ffa8a201a1a13b593d562ca76daba954408d34b0a7f4e0ef +- symbol: sites_per_label + file: product-core/src/registry/verify.rs + before: absent + after: sha256:069be9556e49d2bf581d40d5b1e73812c0a96db502588b0749da48f5255c5bdf + base_revision: 61222d2d386993ad671643dc46692202a2a4f1e7 + hash: sha256:cb0d8140f522a8d515a0bed3be0b1ad16463ddac41fb4c36d66ab1ed755a09e2 diff --git a/docs/g-track/registry-generator-design-2026-08-18.md b/docs/g-track/registry-generator-design-2026-08-18.md new file mode 100644 index 00000000..107fed64 --- /dev/null +++ b/docs/g-track/registry-generator-design-2026-08-18.md @@ -0,0 +1,380 @@ +# The registry generator — design, for Gate 1 ratification + +**Session:** registry generator (G-track), 2026-08-18. **Repository:** `Hafeok/product-cli`, +branch `claude/registry-generator-mechanism-wf1dsr`. +**Status:** proposal. Nothing is implemented; this document is the Gate 1 hold. + +**Inputs read:** `docs/g-track/registry-template/` (whole tree, version 0.1.0), +`docs/g-track/prd-ground-as-ontology.md` (§3 registry authority, §4.1 Reading), +the G-1 session report's Gate 3 rulings (§3.3), and the G0 handoff +*Founding content, ratified as text* — which records the generation this session replaces. + +## 0. What this is answering + +The G0 instance was generated by a shell one-liner. A parameter value — the ratifier's address — +carried a sigil the substitution engine re-read as code, and the corrupted base IRI propagated into +every file including the birth provenance. A human reading the output caught it. The base IRI is the +one registry parameter with no supersession path (g-dec-03): a changed IRI orphans rather than +supersedes. + +Two faults, not one: + +1. **String substitution re-reads values as code** in every engine that does it — `sed`'s `&` and + `\1`, the shell's `$` and backtick, a regex replacement string's group references. +2. **A scratchpad script cannot be tested.** Registry generation mints identity, records birth + provenance, and creates the artefact a founding decision is filed into. It is the act the track + stands on. + +The remedy is a subcommand with fixtures, not a better one-liner. The reason to build it now rather +than later is testability: a generator invocable from code turns the template's guarantees from +hand-verified-once into fixtures that run on every commit. + +--- + +## 1. Where it lives, and what it is called + +### 1.1 The invocation + +``` +product registry generate \ + --owner Hafeok \ + --repo ground-registry-g0 \ + --ratifier emil@okkels-klein.dk \ + --display-name 'Ground Registry (G0 validation)' \ + --base-iri 'tag:emil@okkels-klein.dk,2026-08-17:ground/' \ + --date 2026-08-17 \ + --generated-by 'product-cli registry generator (session …)' \ + --out ../ground-registry-g0 + +product registry check # read-only: the instance's own rules over a tree +``` + +`product`, not `ddd` and not `ledger`. The ground registry is neither a `.ddd/` decision store nor a +`.decisions/` ledger, and the generator writes **no** store in the current repository — it writes a +new repository elsewhere. `product` is the workspace's front CLI, the G-track is a product-cli +track, and the template it instantiates lives in this repo's `docs/`. The alternative considered was +a fourth binary (`registry`); rejected as a whole crate's worth of packaging for two verbs. + +`generate`, not `new` — the repo's usual verb for minting things. The template, the PRD and +`GENERATION.ttl` already name this act *generation*, with *generation parameters* and *birth +provenance*. A second word for one act is how vocabulary drifts. + +### 1.2 The code + +Slice + adapter, as the workspace does everywhere: + +| Path | Holds | Purity | +|---|---|---| +| `product-core/src/registry/mod.rs` | the slice's surface | pure | +| `…/registry/params.rs` | `RegistryParams` — the typed parameter struct, plus meaning-validation | pure | +| `…/registry/template.rs` | the embedded template: path → bytes manifest, `TEMPLATE_VERSION` | pure | +| `…/registry/substitute.rs` | the single-pass renderer (§2.2) | pure | +| `…/registry/verify.rs` | the generation gate (§4) | pure | +| `…/registry/plan.rs` | `plan_generation(&RegistryParams) -> GenerationPlan` | pure | +| `…/registry/apply.rs` | `apply_generation(&GenerationPlan, &Path)` — writes the tree, `git init`, one commit | I/O only | +| `…/registry/check/` | the instance conformance reader (§6): file rule + shapes | pure over a loaded tree | +| `product-cli/src/commands/registry.rs` | `RegistryCommands` (clap) + two thin handlers returning `CmdResult` | adapter | + +`Commands::Registry` sorts before `Commands::Release` (the `cli_subcommands_are_sorted` gate). +Every file stays under the 400-line CTX001 limit and carries a single-responsibility `//!` line. + +**No MCP mirror.** CLAUDE.md's step 7 attaches a `product_*` tool to commands that mutate the What +graph. This one mutates no `.product` graph in any repository; it writes a new tree outside the +workspace. Adding it to a phase-gated session surface would put repository minting inside an +authoring session, which is precisely the conflation §3 separates. + +**How the template is embedded.** `include_str!` over `docs/g-track/registry-template/…`, one entry +per file in a hand-maintained manifest — the pattern `product-cli/src/commands/skills.rs` and +`product-core/src/pf/*` already use for `schema/examples/`. The template stays where the PRD and the +G0 handoff pin it (`docs/g-track/registry-template @ `); no second copy is vendored, because +a second copy is a projection presenting as a source. A **manifest-drift test** walks the on-disk +template and fails if the file set or any byte differs from the embedded manifest, so adding a +template file without wiring it cannot pass silently. + +--- + +## 2. Parameter handling + +### 2.1 The surface + +Typed arguments. There is no `--set KEY=VALUE`, no substitution table on the command line, and no +parameter file — an untyped table is what lets an unwired placeholder or a mistyped key through. + +| Flag | Type | Token in the template | Lands in | +|---|---|---|---| +| `--owner` | string | `{{OWNER_ORG}}` | `README.md`, `GENERATION.ttl` | +| `--repo` | string | `{{REPO_NAME}}` | `README.md`, `GENERATION.ttl` | +| `--ratifier` | string | `{{RATIFIER}}` | `README.md`, `GENERATION.ttl` | +| `--display-name` | string | `{{DISPLAY_NAME}}` | `README.md`, `GENERATION.ttl` | +| `--base-iri` | string | `https://REGISTRY-HOST.example/ns#` | `shapes/*.ttl`, `graphs/**/*.ttl`, `.github/workflows/validate.yml`, `GENERATION.ttl` | +| `--date` | `YYYY-MM-DD` | `{{MINT_DATE}}` | `GENERATION.ttl`; also the commit's author/committer date | +| `--generated-by` | string | `{{GENERATED_BY}}` | `GENERATION.ttl` (`prov:wasAttributedTo`) | +| *(derived)* | — | `{{TEMPLATE_VERSION}}` | `GENERATION.ttl` | +| *(derived)* | — | `{{GENERATOR_VERSION}}` | `GENERATION.ttl` | +| `--out` | path | — | the target directory; substituted nowhere | + +Two notes on the table: + +- **The base IRI's placeholder is a literal example IRI, not a moustache.** G-1 Gate 3 kept it + IRI-valid so the template's own Turtle parses and the template validates as-is. That is a + property of the *token*, not an exception to the mechanism: the renderer's token table holds + `https://REGISTRY-HOST.example/ns#` alongside `{{OWNER_ORG}}` and treats them identically. +- **`{{REPO_NAME}}` currently appears only in TEMPLATE.md's own parameter table** — i.e. it reaches + no generated content, so a round-trip check (§4) would have nothing to assert about it. Gate 3 + gives it a home in `GENERATION.ttl`, where the repository name belongs anyway. + +**Meaning-validation, never escaping.** Before rendering: no parameter is empty; `--date` parses as +a calendar date (chrono); `--base-iri` is an absolute IRI ending in `#` or `/`, checked by asking +the real Turtle parser (oxigraph) to parse a one-line probe document with it as a prefix — not by a +regex, and not by a character allow-list. The mechanism must carry any bytes verbatim; a validator +that rejects sigils would be an escaping rule in disguise, and escaping rules are what produced the +G0 bug. + +### 2.2 The renderer, and why it cannot re-read a value + +One pass over the template text. The scanner walks the input with a cursor, matching the longest +token from a closed table at each position; on a match it **copies the parameter's bytes to the +output and advances the cursor past the token**. The cursor never moves backwards, and the output is +never re-scanned. + +The invariant, stated so a test can assert it: *the output is the concatenation of literal spans of +the template and byte-for-byte copies of parameter values.* Consequences — + +- A value containing `&`, `\1`, `$0`, a backslash, a pipe, or a slash is data. There is no + replacement grammar to re-read it in. +- A value that itself contains `{{OWNER_ORG}}` is emitted, not substituted. Sequential + `str::replace` calls do **not** have this property: a value inserted by an earlier replace is in + the buffer the later replaces scan. +- Substitution order is irrelevant, because there is no order. + +`str::replace` in a loop would be nearly right and is the honest fallback; the single-pass scanner +is ~60 lines and closes the last hole, so it is what is proposed. + +**No templating dependency.** `handlebars`, `tinytemplate`, `minijinja` all bring an expression +language, an escaping mode, and a partials/lookup mechanism — surface whose *purpose* is to +interpret its inputs. The workspace has no templating crate today (`author/domain.rs` uses a bare +`include_str!(…).replace("{{PRODUCT}}", …)`), and auditing a template engine's replacement +semantics costs more than the sixty lines it would replace. This is the one place in the track where +"the engine re-read the value" is the recorded failure; adding an engine is the wrong direction. + +--- + +## 3. The generate / publish split + +**Generation is local and offline. Publication is a separate act, and is not a subcommand.** + +`product registry generate`: + +1. builds the whole rendered tree **in memory**; +2. runs the verification gate (§4) — on failure it aborts, having written nothing; +3. creates `--out` (which must not exist, or must be an empty directory — the generator never merges + into an existing tree); +4. writes every file, atomically, only under `--out`; template paths are relative and are rejected if + they normalise outside the target; +5. runs `git init`, `git add -A`, and **one commit** — the birth-provenance commit — with + `git -C -c user.name=… -c user.email=… -c commit.gpgsign=false`, `GIT_CONFIG_NOSYSTEM=1`, + and `GIT_AUTHOR_DATE`/`GIT_COMMITTER_DATE` pinned to the mint date. No global git config is read + or written, no ambient identity is required, and the commit is deterministic: the same parameters + and the same generator produce the same tree bytes and the same commit id. +6. prints the two commands that would publish it, and stops: + +``` +git -C remote add origin git@github.com:/.git +git -C push -u origin main +``` + +No network call. No GitHub API. No remote is configured. No repository is created under any +organisation. `product registry check` is read-only. + +The boundary is where it is because minting identity and publishing it are acts with different +authority and different consequences — and because only generation can be on the test path. A +fixture is a `TempDir` holding a local git repository; if publication rode along inside `generate`, +the untested half would ride on the tested half's back, which is how G0's one-liner shipped a +corrupted base IRI with a green-looking run. + +*(Scope note: `Hafeok/ground-registry-g0` stays empty until G0 resumes. This session pushes to its +own branch and nowhere else.)* + +--- + +## 4. Verification as a gate + +After rendering, **before the first byte is written to disk**, over the in-memory tree. A failure +aborts generation with a non-zero exit and no target directory — not a warning, and not a partial +tree for someone to inspect. + +**Check A — every parameter round-trips byte-identically.** The renderer records, per file, the byte +offset and length of every value it emitted. The gate reads those spans back out of the rendered +bytes and asserts each equals its parameter exactly; then it asserts, per parameter, that the number +of emitted sites equals the number of token occurrences the template holds. A value that was +truncated, re-read, re-escaped, or partially overwritten fails here. *This is the check that would +have caught the G0 bug, loudly, at generation time.* + +**Check B — zero surviving placeholders.** Every rendered file is scanned for any `{{IDENT}}` and for +the base-IRI sentinel `REGISTRY-HOST.example`. Any hit aborts. This catches the failure Check A +cannot see: a placeholder in the template that no parameter is wired to. A new template placeholder +therefore fails generation until it is given a typed argument — the drift is impossible to ship. + +**The one exemption, asserted rather than tolerated:** `TEMPLATE.md` travels **verbatim**. The gate +skips it for Check B and instead asserts it is byte-identical to the template's own copy. That is +the instance's record of what its placeholders were, and its re-pin hook when the template moves. + +--- + +## 5. `GENERATION.ttl` — birth provenance + +Today the template's `GENERATION.ttl` is a comment block describing a shape to be filled by hand. +It becomes a **parameterised Turtle file** filled by the same renderer and checked by the same gate — +so the birth provenance is generated by the mechanism, not typed beside it (the G0 corruption reached +the birth provenance too). + +It records, as one `prov:Activity` (the generation act) and one `reg:Registry`: + +| Recorded | From | +|---|---| +| `reg:templateVersion` | the template's own version constant | +| `reg:generatorVersion` | the `product-cli` crate version | +| `reg:owner`, `reg:repository`, `reg:ratifier`, `reg:displayName` | the four string parameters | +| the base IRI | the `@prefix reg:` of the file itself | +| `reg:mintDate` / `prov:generatedAtTime` | `--date` | +| `prov:wasAttributedTo` | `--generated-by` | + +Recording the generator's version *alongside* the template's is the point: an instance can re-pin +against either moving. The file stays valid Turtle in the template (the base-IRI placeholder is +IRI-valid), so the template continues to parse as-is. + +--- + +## 6. Testing the instance's own rules, and the honest cost + +The fixture *"instance CI passes on a fresh instance"* needs the two rules the instance's CI runs: +the file rule, and SHACL over `shapes/*.ttl`. The instance's CI runs them in Python (`pyshacl`). + +Requiring `python` + `pyshacl` inside `cargo t` was considered and rejected: it makes the gate depend +on a toolchain no developer machine is promised, and the usual mitigation — skip when absent — turns +a gate into a habit, which is the failure this whole session exists to remove. + +So `product registry check ` reads the instance's own files and evaluates them natively: + +- **File rule** — every `graphs/**/*.ttl` carries exactly one assertion **or** exactly one decision + (the Gate 3 generalisation), parsed with oxigraph. +- **Shapes** — a deliberately small SHACL subset, compiled to SPARQL and run through the existing + `pf::sparql_rules` surface: `sh:NodeShape` + `sh:targetClass`, `sh:property` with `sh:path`, + `sh:minCount`, `sh:maxCount`, `sh:nodeKind`, `sh:in`, `sh:datatype`, `sh:message`, and + `sh:sparql`/`sh:select` (which `shapes/reading.ttl` already uses). The shapes files stay the single + statement of the constraints; nothing is restated in Rust. +- **Fail-closed.** Any SHACL construct outside that subset, encountered in a shapes file, makes the + check **fail** with "unsupported construct", never pass. A shape the reader cannot evaluate is + reported as unevaluated, so a future template shape cannot be vacuously green. + +**The residual cost, stated rather than hidden:** two readers of one rule set — `pyshacl` in the +instance, this reader in the fixture. Fail-closed evaluation bounds the divergence to *constructs the +reader refuses*, never to *constraints it silently ignores*. See open question Q2 for the alternative. + +--- + +## 7. Teardown + +- Every fixture generates into its own `tempfile::TempDir` (already a `product-core` dev-dependency); + the directory is the whole of the generation's footprint. +- The generator writes only under `--out`: paths come from the embedded manifest, are relative, and + are rejected if they normalise outside the target. No `$HOME`, no global git config, no workspace + file, no `/tmp` scratch. +- `git` is invoked with `-C `, `GIT_CONFIG_NOSYSTEM=1` and inline `-c user.*` — it neither reads + nor writes the developer's identity. +- Fixtures assert the residue directly: the parent of the target holds nothing but the target; after + the `TempDir` drops, the path is gone. +- Two generations in one test run go to two temporary directories, run in either order, and produce + byte-identical trees for identical parameters (determinism, asserted). + +--- + +## 8. The fixture list proposed for Gate 2 + +| Test | Asserts | +|---|---| +| `parameters_round_trip` | every parameter byte-identical at every site in the generated tree | +| `g0_regression_sigil_in_ratifier_address` | the G0 case exactly: `emil@okkels-klein.dk` and `tag:emil@okkels-klein.dk,2026-08-17:ground/` survive intact — named for the bug it exists to prevent | +| `hostile_parameter_values` | values carrying `&`, `\1`, `$0`, `\`, `|`, `/`, and a literal `{{OWNER_ORG}}` all emit verbatim and none is re-read | +| `no_surviving_placeholders` | zero `{{…}}` and zero `REGISTRY-HOST.example` across the tree, TEMPLATE.md excepted | +| `template_md_travels_verbatim` | TEMPLATE.md byte-identical to the template's own copy | +| `generation_ttl_is_complete` | template version, generator version, all parameters, generated-by, date, `prov:wasAttributedTo` — parsed as Turtle and queried, not string-matched | +| `fresh_instance_passes_its_own_rules` | file rule + shapes clean on a freshly generated tree (§6) | +| `negative_institutional_reading_without_trust_decision` | **fails** | +| `negative_two_assertions_in_one_file` | **fails** | +| `negative_decision_missing_required_field` | **fails** | +| `unsupported_shacl_construct_fails_closed` | an unevaluable shape fails rather than passes | +| `gate_aborts_before_writing` | an unwired placeholder in the template leaves **no** target directory behind | +| `refuses_non_empty_target` | generation into an occupied directory refuses | +| `generation_is_deterministic` | same parameters → identical bytes and identical commit id | +| `teardown_leaves_no_residue` | nothing outside the temporary directory; two generations do not interfere | +| `embedded_template_matches_on_disk` | manifest-drift guard on `docs/g-track/registry-template/` | + +The negative cases carry the same weight as the positive ones: they are the template's guarantees, +and until now they were verified by hand, once. + +--- + +## 9. Template changes proposed for Gate 3 + +Folded into the template proper, version **0.1.0 → 0.2.0** (the change is behavioural for its output): + +1. **`shapes/decision.ttl`** — a `reg:Decision` requires `reg:title`, `reg:resolution`, `reg:region`, + `reg:falsifier`, `reg:ratifiedBy`, `reg:status`, `reg:made`. `reg:basis`, `reg:acceptedCost` and + `reg:revisitIf` are deliberately **not** required: a decision may honestly have none, and + demanding them produces filler. (Field names follow the G0 handoff's filed texts.) +2. **The CI file rule generalised** — from a filename exemption to the class rule it stood in for: + every `graphs/**/*.ttl` carries exactly one assertion **or** exactly one decision. The zero-triple + tolerance goes with the empty slot: `graphs/canonical/founding-decision.ttl` is no longer shipped + as an empty file. The slot stays a **named path** documented in README/TEMPLATE.md, created by the + founding decision's own first merge — which keeps a freshly generated instance's CI clean without + an exemption. +3. **TEMPLATE.md step 1 disambiguated** — TEMPLATE.md itself travels **verbatim**, unsubstituted, as + the instance's record of what its placeholders were. The current wording ("minus this file's + version header context — the file itself travels") reads both ways. +4. **The base-IRI row documents two routes**, with their trade-offs: (a) a host the owner controls + durably, resolvable from day one, committing the owner to keep resolving it for the registry's + lifetime; (b) location-independent minting (`tag:` per RFC 4151, or `urn:`) with the HTTP form + published later as a projection via a rebasing parameter — g-dec-03's route, which defers a + commitment that has no supersession path at the cost of an internal-to-published mapping at + projection build. +5. **`GENERATION.ttl` becomes parameterised** (§5), and `{{REPO_NAME}}` gains its home there. + +--- + +## 10. Gates, and what this session does not touch + +Green before every hold: `cargo t`, `cargo clippy --workspace -- -D warnings -D clippy::unwrap_used`, +`cargo xtask check`. Commit and push before reporting at each hold. + +Out of scope, unchanged: resuming G0; the extractor; publishing any instance to any remote +(`Hafeok/ground-registry-g0` stays empty); any PRD edit; canon. The founding texts are ratified and +carry forward verbatim, with the same five parameters and the same mint date, 2026-08-17. + +--- + +## 11. Open questions for the ratifier + +**Q1 — the empty founding-decision slot.** §9.2 removes `graphs/canonical/founding-decision.ttl` as a +shipped empty file, keeping the slot as a documented path. This is read from "the zero-triple +tolerance goes with the empty slot". If instead the empty file should ship, the class rule needs a +zero-triple tolerance and a fresh instance's CI passes only by that tolerance — the exemption +returns in a different coat. **Recommendation: drop the empty file.** + +**Q2 — one reader or two.** The instance's CI could run `product registry check` from a pinned +released binary instead of `pyshacl`, collapsing §6's two readers into one. It would also make every +instance's CI depend on a tool the registry is supposed to outlive. **Recommendation: keep `pyshacl` +in the instance, keep the fail-closed native reader in the fixtures, and record the divergence risk +rather than trading it for a tool dependency.** + +**Q3 — a decision exemplar in the template.** `graphs/canonical/_exemplar.ttl` demonstrates an +assertion; `shapes/decision.ttl` arrives at Gate 3 with no exemplar to validate against in the +template itself. Adding `_exemplar-decision.ttl` would keep "the template validates as-is" true for +both halves of the class rule. It is a template addition beyond what Gate 3 sanctions, so it is +asked rather than assumed. **Recommendation: add it.** + +**Q4 — `--generated-by` is required, with no default.** Defaulting it to the ambient git identity +would make generation depend on unrecorded environment state. Required means it is always in the +birth provenance because someone typed it. **Recommendation: keep it required.** + +**Status line: the design is proposed; nothing is implemented; the mechanism is held at Gate 1.** diff --git a/docs/g-track/registry-generator-session-2026-08-18.md b/docs/g-track/registry-generator-session-2026-08-18.md new file mode 100644 index 00000000..cb4fb8c1 --- /dev/null +++ b/docs/g-track/registry-generator-session-2026-08-18.md @@ -0,0 +1,259 @@ +# The registry generator — session report (G-track, 2026-08-18) + +**Repository:** `Hafeok/product-cli`, branch `claude/registry-generator-mechanism-wf1dsr`. +**Gates:** 1 design · 2 implementation · 3 template changes · 4 close. All four ratified by +emil@okkels-klein.dk. **Design document:** `docs/g-track/registry-generator-design-2026-08-18.md`. + +**Status: the generator is the mechanism; G0 regenerates through it.** + +--- + +## 1. Why this session existed + +The G0 session generated a registry instance with a shell one-liner and hit a **silent corruption of +the base IRI**: a sigil in a parameter value was re-read as code by the substitution engine, and the +wrong value propagated into every file including the birth provenance. A human reading the output +caught it. The base IRI is the one registry parameter with no supersession path — a changed IRI +orphans identifiers rather than superseding them (`g-dec-03`). + +Two faults, not one. String substitution re-reads values as code in every engine that does it, and a +scratchpad script cannot be tested. Registry generation mints identity, records birth provenance, and +creates the artefact a founding decision is filed into. The remedy is a subcommand with fixtures. + +--- + +## 2. The finding: the generator paid for itself before it minted anything + +`graphs/canonical/_exemplar.ttl` carried its provenance attribution as +`` — a **second occurrence of the placeholder host**, +outside the `/ns#` base every other file used. Substituting the base IRI alone would have left that +IRI pointing at `REGISTRY-HOST.example` in every generated instance's data file: a corrupted host in +ratified content, of exactly the class that produced the G0 bug, and invisible to anyone reading the +diff for the parameter they supplied. + +The gate's Check B found it at Gate 2, before any tree was written. **Correction applied:** the +attribution now sits in the instance's own namespace, `reg:agent-g1-session`, leaving one host token +in the template. This is the claim made for building the mechanism, demonstrated rather than argued. + +--- + +## 3. The subcommand surface + +``` +product registry generate --owner --repo --ratifier \ + --display-name --base-iri --date \ + --generated-by --out + +product registry check +``` + +Slice + adapter, as the workspace does everywhere: the pure slice is `product-core/src/registry/` +(`params` · `template` · `substitute` · `verify` · `plan` · `apply` · `check/`), the thin adapter is +`product-cli/src/commands/registry.rs`. No MCP mirror: the command mutates no `.product` graph in any +repository, and repository minting does not belong inside a phase-gated authoring session. + +The template stays at `docs/g-track/registry-template/` — the path the PRD and every instance's +provenance pin — and is embedded by `include_str!` with a manifest-drift test, so a template file +added without wiring fails a test rather than vanishing from generated instances. + +### The public surface, as the governance gate read it + +The DDD contract-surface gate failed the first CI run with **151 undischarged changes** — a fair +reading of what had been published: every internal type of the renderer, the gate and the shapes +reader stood as public API, plus a test-support module that is scaffolding rather than surface. + +Two things were wrong, and both were fixed. The slice's public surface is now its **re-export list** — +the acts (`plan_generation`, `apply_generation`, `check_instance`, `evaluate`) with the types they +carry — and the modules beneath are crate-internal, so a caller depends on the acts and never on how +the template is rendered or how the gate is spelled. That took the governed surface from 151 events +to **59**, which is the surface actually intended. Those 59 are discharged by ten seam declarations +under `.ddd/seams/`, each carrying what a caller learns at that boundary *and what the boundary +cannot do* — the apply seam states that publishing is not among its powers, the check seam states its +own limit as a second reader of pySHACL's rules, the params seam states that validation judges +meaning and never character safety. + +One lint surfaced with the narrowing rather than being introduced by it: clippy's +`enum_variant_names` does not fire on public enums, so the shapes reader's constraint enum was +renamed `Kind` → `Rule` to keep its variants mirroring SHACL's own constraint-component names. + +### The generate / publish split + +**Generation is local and offline.** It renders in memory, gates, refuses a target that is not empty, +writes only beneath `--out`, then `git init` and **one** birth commit with inline identity, no system +or global git config, and author/committer dates pinned to the mint date — so the same parameters +through the same generator produce the same tree bytes and the same commit id. + +**Publication is a separate act and is not a subcommand.** No remote is configured, no network call is +made, no repository is created anywhere. The two commands that would publish are printed for a human +to run. The boundary is where it is because minting identity and publishing it answer to different +authorities, and because only the local half can be a fixture — if publication rode inside `generate`, +the untested half would ride on the tested half's back, which is how G0's one-liner shipped a +corrupted base IRI with a green-looking run. + +--- + +## 4. The substitution mechanism + +Typed arguments; no `--set KEY=VALUE` table, no parameter file. The renderer walks the template **once**: +at each character boundary it matches the longest token from a closed table, copies the parameter's +bytes to the output, records the span it wrote, and advances the cursor past the token. The cursor +never moves backwards and the output is never re-scanned. + +The invariant, stated so a test can assert it: *the output is the concatenation of literal spans of +the template with byte-for-byte copies of parameter values.* Therefore `&`, `\1`, `$0`, a backslash, a +pipe, a backtick, `$(…)` are data — there is no replacement grammar to re-read them in — and +substitution order is irrelevant, because there is no order. + +Sequential `str::replace` is the mechanism that would nearly have worked and does not: a value +inserted by an earlier replacement sits in the buffer that later replacements scan. The fixture +`sequential_replace_would_have_re_read_it` runs both on the same input and shows the naive one +producing `SECOND` where the renderer produces the value that was supplied. That is a bug this session +would have shipped, kept as a test rather than a comment. + +**No templating dependency.** Every candidate engine brings an expression language and an escaping +mode — surface whose purpose is to interpret its inputs. The renderer is ~60 lines and closes the last +hole; auditing an engine's replacement semantics would cost more than the lines it replaced. + +--- + +## 5. The verification gate + +Runs on the in-memory tree **before the first byte reaches disk**. A failure is a refusal, not a +warning: no target directory is created at all. + +- **Check A — round-trip.** The renderer's recorded spans are read back out of the rendered bytes and + compared to the parameters; the per-parameter tally is cross-checked against a naive occurrence + count over the template (two mechanisms counting the same thing); every parameter must reach the + output at least once. This is what would have caught the G0 bug, loudly, at generation time. +- **Check B — no survivors.** No `{{IDENT}}` and no base-IRI sentinel may remain. A placeholder the + template carries without a typed parameter therefore fails generation rather than shipping. +- **`TEMPLATE.md` is asserted, not exempted** — byte-identical to the template's own copy, because it + travels verbatim as the instance's record of what its placeholders were. + +### The value-span masking rule + +Check B reads the template text that *survived* rendering — the rendered file with every recorded +value span masked out — not the raw output. The first run of the hostile-value fixture refused a +display name containing `{{OWNER_ORG}}`. Refusing it would have been fail-closed but wrong: **"values +are data" that stops holding once a value looks like a template is not a property at all.** Masking +keeps both guarantees whole — an unwired placeholder *outside* a value still aborts, and a value is +data end to end. The same reasoning applies to the base-IRI sentinel: a supplied IRI containing +`REGISTRY-HOST.example` is the parameter, not a survivor. + +--- + +## 6. Fixtures — 70 new tests, all passing + +52 unit tests in `product-core/src/registry/`, 17 generation fixtures in +`product-cli/tests/registry_generator.rs`, 1 divergence measure in +`product-cli/tests/registry_shacl_divergence.rs`. + +| Fixture | Asserts | Result | +|---|---|---| +| `parameters_round_trip_byte_identically` | every parameter byte-identical at every site | pass | +| `g0_regression_a_sigil_in_the_ratifiers_address_survives` | strike out every whole value; **no fragment** of the address or base IRI remains anywhere — the shape the G0 corruption took | pass | +| `the_g0_handoff_parameters_mint_the_handed_over_instance` | the handoff's five parameters + mint date; base IRI `tag:emil@okkels-klein.dk,2026-08-17:ground/` byte for byte, in the prefix, the `reg:baseIri` literal, the shapes, the exemplars | pass | +| `hostile_parameter_values_are_data` | `&`, `\1`, `$0`, `` ` ``, `$(…)`, `|`, `/`, a literal `{{OWNER_ORG}}` | pass | +| `sequential_replace_would_have_re_read_it` | the bug that would have shipped, shown failing | pass | +| `no_placeholder_survives_the_generated_tree` | zero across the tree | pass | +| `template_md_travels_verbatim` | byte-identical to the template's copy, placeholders intact | pass | +| `the_birth_provenance_is_complete_as_rdf` | queried as RDF, not string-matched: both versions, every parameter, the agent | pass | +| `a_fresh_instance_passes_its_own_rules` | file rule + shapes clean on a fresh instance | pass | +| Negative shapes | institutional Reading without a trust decision · two assertions in one file · decision missing its falsifier · decision dated by a bare string · provenance outside the vocabulary · an empty file | all **fail** as required | +| `unsupported_shacl_construct_fails_closed` · `an_unreadable_shape_fails_closed_with_its_own_heading` | an unreadable shape fails, in its own section, with no violations section | pass | +| `a_refused_parameter_writes_nothing` | gate refuses → **no target directory** | pass | +| `generation_refuses_a_non_empty_target` | prior content untouched | pass | +| `generation_is_deterministic` | identical bytes **and** identical commit id | pass | +| `two_generations_in_one_run_do_not_interfere` · `generation_leaves_no_residue_outside_its_target` | teardown provable | pass | +| `generation_configures_no_remote` | no remote; publish commands printed, never run | pass | +| `embedded_template_matches_on_disk` · version-constant drift | manifest and header cannot drift | pass | + +Workspace gates green at every hold: `cargo t` (1352 tests, 0 failed), `cargo clippy --workspace -- -D +warnings -D clippy::unwrap_used`, `cargo xtask check`. + +--- + +## 7. The template at 0.2.0 + +One bump covering every change this session made — the template is not released between gates, so two +bumps would record a state that never existed. + +- **`shapes/decision.ttl`** — title, resolution, region, falsifier, ratifier, status, date required; + `basis` / `acceptedCost` / `revisitIf` deliberately **not**, because a decision may honestly have + none and demanding them produces filler. `reg:made` is constrained to `xsd:date`. `reg:ratifiedBy` + is left unconstrained as to node kind: whether a ratifier is named by literal or IRI depends on + whether the owner has a durable identifier for people, which is an instance's ruling — the G0 + instance uses an email literal, an institutional instance might use an IRI. +- **`graphs/canonical/_exemplar-decision.ttl`** — a conforming example, so a ratifier learns the form + from a file rather than from SHACL. It carries `{{RATIFIER}}` so the form looks like theirs, with a + status stating plainly that it is not ground. +- **The CI file rule** — generalised from a filename exemption to the class rule it stood in for: + exactly one assertion **or** exactly one decision per `graphs/**/*.ttl`, no exemptions. The + zero-triple tolerance went with the empty founding-decision file, which is no longer shipped: the + slot is a path the ratifier creates. A rule with an exception decays. +- **TEMPLATE.md step 1** no longer reads both ways — the file travels **verbatim**, unsubstituted. +- **The base IRI documents two routes** with their trade-offs: a host the owner controls durably + (resolvable from day one, and a real commitment through rebrands and transfers), or + location-independent minting per `g-dec-03` (`tag:`/`urn:`, HTTP form published later as a + projection through a rebasing parameter, accepted cost an internal-to-published mapping). Neither is + the default. The README records that **which route an instance took is a decision filed in that + registry, not a fact recoverable from the IRI** — a `tag:` IRI records the mint date and authority + but not whether the deferral was deliberate or a durable host was rejected. +- **`GENERATION.ttl` is parameterised**, filled by the same renderer under the same gate, recording + the generator's version alongside the template's, and correcting the PROV: `prov:wasGeneratedBy` the + act, `prov:wasAttributedTo` the agent. + +--- + +## 8. The pySHACL divergence measure + +The instance's own CI runs `pyshacl`; the fixtures run a fail-closed native reader that evaluates a +defined SHACL subset compiled to SPARQL over oxigraph, because a gate depending on a Python toolchain +becomes a habit the moment it is skipped when absent. Anything outside the subset is reported as +**unevaluable** and fails the check — never silently skipped, and reported distinctly from data +violating a shape. + +Two readers of one rule set is a **standing finding**, so it is measured rather than assumed small. +`the_native_reader_agrees_with_pyshacl` runs both over nine cases whenever pySHACL is present and +fails on any disagreement; when absent it says on stderr that the divergence was not measured. + +**Run in this session** — pySHACL 0.40.1, nine cases: shipped assertion exemplar, shipped decision +exemplar, conforming Reading, institutional without a trust decision, institutional with one, +provenance outside the vocabulary, assertion without a predicate, decision missing its falsifier, +decision dated by a bare string. **No disagreement.** Non-vacuity confirmed: pySHACL independently +reports `Conforms: False` on the negatives, and a fresh 0.2.0 instance was additionally validated by +the instance workflow's own two steps run verbatim (`pyshacl -a` over `shapes/`, and the workflow's +rdflib file-rule script) — both clean. + +--- + +## 9. What G0 must do differently when it resumes + +1. **Regenerate through the subcommand.** No shell substitution, no hand-editing of the tree. The + invocation is in §3 with the handoff's parameters; the `the_g0_handoff_parameters_...` fixture + pins it. +2. **The same five parameters and the same mint date, 2026-08-17.** The mint date records when the + authority was demonstrably controlled, not when the tree was written; it does not move because the + tree was rewritten. +3. **File the two ratified texts verbatim** — the founding decision and `g-dec-03`, from + *Founding content, ratified as text*. They were ratified as text, not as the tree they were + written into; a paraphrase at this handoff is the drift the founding decision's own + non-graduation clause guards against. +4. **The founding decision creates its file.** `graphs/canonical/founding-decision.ttl` no longer + ships as an empty slot; the ratifier's first merge creates it, conforming to + `shapes/decision.ttl`. +5. **Do not re-apply the two template additions as instance-local.** `shapes/decision.ttl` and the + generalised file rule are in the template at 0.2.0; an instance that re-adds them locally forks + the template. +6. **The instance records template 0.2.0 and the generator's version.** Re-pinning is possible + against either. +7. **Publishing stays a separate, explicit act.** `Hafeok/ground-registry-g0` is still empty; the + generator prints the two commands and pushes nothing. + +--- + +## 10. What this session did not touch + +G0 itself; the extractor; any instance published to any remote (`Hafeok/ground-registry-g0` remains +empty); the PRD; canon. No repository was created under any organisation. The session pushed to its +own branch and nowhere else. diff --git a/docs/g-track/registry-template/.github/workflows/validate.yml b/docs/g-track/registry-template/.github/workflows/validate.yml index d70b7747..7e780b61 100644 --- a/docs/g-track/registry-template/.github/workflows/validate.yml +++ b/docs/g-track/registry-template/.github/workflows/validate.yml @@ -1,16 +1,20 @@ # SHACL validation on every change (PRD §3, authority row; G-1 session prompt step 3.2). -# Shapes start minimal — the §4.1 Reading tuple constraint and structural -# well-formedness — and grow at G0. +# Shapes start minimal — the §4.1 Reading tuple constraint, structural +# well-formedness, the decision shape — and grow at G0. # # Format: Turtle (G-1 Gate 3 ruling) — the authority file is the assertion, # SHACL reads it directly, zero conversion in CI. # -# File rules: -# - `_`-prefixed files are exemplars: validated, excluded from projection builds. -# - graphs/canonical/founding-decision.ttl may parse to zero triples until the -# instance's ratifier files the founding decision; once non-empty it holds a -# decision node, not an assertion (decision shapes grow at G0). -# - every other graphs/**/*.ttl carries exactly one assertion. +# File rule (one rule, no exemptions): +# - every graphs/**/*.ttl carries exactly one assertion OR exactly one +# decision. Nothing else, and never both. +# - `_`-prefixed files are exemplars: validated like any other file, excluded +# from projection builds. +# +# The rule used to exempt graphs/canonical/founding-decision.ttl by filename so +# an empty slot could ship. The exemption was generalised into the rule it stood +# in for, and the empty file went with it: the founding decision is created by +# the ratifier who files it, so there is no empty file to tolerate. name: validate on: @@ -27,25 +31,27 @@ jobs: python-version: "3.12" - name: Install validators run: pip install pyshacl rdflib - - name: One assertion per file + - name: One assertion or one decision per file run: | python - <<'EOF' - import glob, os, sys + import glob, sys from rdflib import Graph from rdflib.namespace import RDF, Namespace REG = Namespace("https://REGISTRY-HOST.example/ns#") bad = [] for path in glob.glob("graphs/**/*.ttl", recursive=True): - base = os.path.basename(path) g = Graph(); g.parse(path, format="turtle") - if base == "founding-decision.ttl": - continue # empty slot until filled; holds a decision, not an assertion - n = len(list(g.subjects(RDF.type, REG.Assertion))) - if n != 1: - bad.append(f"{path}: {n} assertions (exactly 1 required)") + assertions = set(g.subjects(RDF.type, REG.Assertion)) + decisions = set(g.subjects(RDF.type, REG.Decision)) + counts = (len(assertions), len(decisions)) + if counts not in ((1, 0), (0, 1)): + bad.append( + f"{path}: {counts[0]} assertion(s), {counts[1]} decision(s) " + "— exactly one assertion or exactly one decision required" + ) if bad: print("\n".join(bad)); sys.exit(1) - print("one-assertion-per-file: ok") + print("one-assertion-or-one-decision-per-file: ok") EOF - name: SHACL run: | diff --git a/docs/g-track/registry-template/GENERATION.ttl b/docs/g-track/registry-template/GENERATION.ttl index 21d5fccf..9998f51d 100644 --- a/docs/g-track/registry-template/GENERATION.ttl +++ b/docs/g-track/registry-template/GENERATION.ttl @@ -1,20 +1,42 @@ -# Birth provenance of this instance — FILLED BY THE INSTANCE'S FIRST COMMIT. +# Birth provenance of this instance — written by the generation act. # -# The generation act records: template version, parameters supplied, -# generated-by, date — prov:wasAttributedTo the generation act. This is what -# lets the instance re-pin when the template moves (TEMPLATE.md). +# `product registry generate` fills this file through the same single-pass +# renderer, under the same verification gate, as every other file in the tree: +# the birth provenance is produced by the mechanism, never typed beside it. # -# Shape, to be filled (base IRI substituted at generation): +# It records the template version, the generator's own version, every parameter +# supplied, who generated it, and the mint date — which is when the authority +# was demonstrably controlled, not when the tree was written. Recording both +# versions is what lets the instance re-pin when either one moves. # -# @prefix reg: . -# @prefix prov: . -# @prefix xsd: . -# -# reg:this-instance -# a reg:Registry ; -# reg:templateVersion "0.1.0" ; -# reg:owner "{{OWNER_ORG}}" ; -# reg:ratifier "{{RATIFIER}}" ; -# reg:displayName "{{DISPLAY_NAME}}" ; -# prov:wasAttributedTo ; -# prov:generatedAtTime "…"^^xsd:dateTime . +# The base IRI is a generation parameter; the placeholder is IRI-valid so the +# template itself parses. + +@prefix reg: . +@prefix prov: . +@prefix xsd: . + +reg:this-instance + a reg:Registry ; + reg:displayName "{{DISPLAY_NAME}}" ; + reg:owner "{{OWNER_ORG}}" ; + reg:repository "{{REPO_NAME}}" ; + reg:ratifier "{{RATIFIER}}" ; + reg:baseIri "https://REGISTRY-HOST.example/ns#" ; + reg:mintDate "{{MINT_DATE}}"^^xsd:date ; + reg:templateVersion "{{TEMPLATE_VERSION}}" ; + reg:generatorVersion "{{GENERATOR_VERSION}}" ; + prov:wasGeneratedBy reg:generation-act ; + prov:wasAttributedTo reg:generating-agent . + +reg:generation-act + a prov:Activity ; + reg:templateVersion "{{TEMPLATE_VERSION}}" ; + reg:generatorVersion "{{GENERATOR_VERSION}}" ; + prov:wasAssociatedWith reg:generating-agent ; + prov:generatedAtTime "{{MINT_DATE}}T00:00:00Z"^^xsd:dateTime ; + prov:generated reg:this-instance . + +reg:generating-agent + a prov:Agent ; + reg:name "{{GENERATED_BY}}" . diff --git a/docs/g-track/registry-template/README.md b/docs/g-track/registry-template/README.md index cc46bd4d..1c840b5d 100644 --- a/docs/g-track/registry-template/README.md +++ b/docs/g-track/registry-template/README.md @@ -13,7 +13,7 @@ provenance (template version, parameters, generated-by, date) is `GENERATION.ttl |---|---| | `graphs//` | one named graph per directory; **one assertion per file, in Turtle** (the per-claim file pattern applied to triples — the pattern transfers: per-assertion files, PR review, supersession; G-1 Gate 3 ruled the format) | | `graphs/canonical/` | the ratified graph | -| `graphs/canonical/founding-decision.ttl` | **the founding-decision slot**: {{OWNER_ORG}}'s decision to keep a registry, filed by {{RATIFIER}} as the first ratified content — empty until then | +| `graphs/canonical/founding-decision.ttl` | **the founding-decision slot** — a path, not a shipped file: {{OWNER_ORG}}'s decision to keep a registry, filed by {{RATIFIER}} as the first ratified content, creates it. The form is `shapes/decision.ttl`, with a conforming example in `graphs/canonical/_exemplar-decision.ttl` | | `shapes/` | SHACL shapes; CI runs them on every change | | `scripts/build-projection.sh` | ref in, store build out (stub until G0) | | `GENERATION.ttl` | this instance's birth provenance | @@ -42,13 +42,22 @@ grow at G0: as-of, provenance, assurance; `institutional` provenance **requires** a `trust_decision` reference; the provenance value set is track vocabulary per G-track decision `g-dec-01` (superseded the moment canon files provenance typing). -- **Structural well-formedness**: every data file parses; assertion files carry exactly one - assertion (the founding-decision slot is exempt while empty, and holds a decision, not an - assertion, once filled). +- **Structural well-formedness** (`shapes/structural.ttl`): every data file parses; an assertion + names exactly one subject, predicate, object. +- **The decision shape** (`shapes/decision.ttl`): a decision carries title, resolution, region, + falsifier, ratifier, status, date. `basis`, `acceptedCost` and `revisitIf` are deliberately not + required — a decision may honestly have none, and demanding them produces filler. +- **One assertion or one decision per file**, across every `graphs/**/*.ttl`. One rule, no filename + exemptions. ## Ownership Ownership is a **generation parameter** of this instance: {{OWNER_ORG}} owns this registry, and that ownership is the accountable-principal field of every trust decision that references it -(PRD §4.4 — connecting is a trust decision, Q27). The base IRI is served from a host -{{OWNER_ORG}} controls durably; IRIs outlive hosting choices. +(PRD §4.4 — connecting is a trust decision, Q27). + +The base IRI is the one parameter with no supersession path: a changed IRI orphans identifiers +rather than superseding them. It was settled at generation by one of the two routes the template +documents — a host {{OWNER_ORG}} controls durably, or location-independent minting (`tag:`/`urn:`) +with the resolvable HTTP form produced later as a projection. Which route this instance took, and +why, is a decision filed in this registry, not a fact recoverable from the IRI alone. diff --git a/docs/g-track/registry-template/TEMPLATE.md b/docs/g-track/registry-template/TEMPLATE.md index 1b0dcbe5..d65de8bb 100644 --- a/docs/g-track/registry-template/TEMPLATE.md +++ b/docs/g-track/registry-template/TEMPLATE.md @@ -1,8 +1,8 @@ # Registry template -**Template version: 0.1.0** (G-1 session, 2026-08-17; versioned with product-cli — instances pin -this version and re-pin when it moves, by the same re-derive-at-a-named-commit discipline the -G-track itself runs). +**Template version: 0.2.0** (G-1 session 2026-08-17, revised by the registry-generator session +2026-08-18; versioned with product-cli — instances pin this version and re-pin when it moves, by the +same re-derive-at-a-named-commit discipline the G-track itself runs). This directory is a **parameterised template for any ground registry**. It is not a repository and names no instance: everything instance-specific is a generation parameter, supplied when an @@ -14,22 +14,74 @@ instance is generated — never filled in the template (G-1 Gate 3 ruling). |---|---|---| | Owning organisation | `{{OWNER_ORG}}` | the accountable-principal field of every trust decision that will reference the instance | | Repository name | `{{REPO_NAME}}` | — | -| Base IRI | `https://REGISTRY-HOST.example/ns#` (parses as a valid IRI, so the template validates as-is) | derived from a host the owner controls **durably** — IRIs outlive hosting choices, so the host must be one the owner can keep resolving for the registry's lifetime; the template documents the requirement and does not pick a host | | Ratifier | `{{RATIFIER}}` | the named person whose merge is ratification for this instance | | Registry display name | `{{DISPLAY_NAME}}` | — | +| Base IRI | `https://REGISTRY-HOST.example/ns#` (parses as a valid IRI, so the template validates as-is) | the base every identifier is minted under. **Two routes, below** — it is the one parameter with no supersession path, so the choice is made deliberately or deferred deliberately | +| Mint date | `{{MINT_DATE}}` | when the authority was demonstrably controlled — **not** when the tree was written. It does not move when an instance is regenerated | +| Generated by | `{{GENERATED_BY}}` | who ran the generation; recorded as the agent in the birth provenance | +| Template version | `{{TEMPLATE_VERSION}}` | derived — this file's version, written into `GENERATION.ttl` | +| Generator version | `{{GENERATOR_VERSION}}` | derived — the generator's own version, so an instance can re-pin when *either* moves | + +### The base IRI: two routes + +The base IRI is the one registry parameter with **no supersession path** — a changed IRI orphans +identifiers rather than superseding them. There are two honest ways to settle it, and an instance +picks one at generation: + +1. **Durable host.** Mint under `https:///…`, resolvable from day one. + The commitment is real: the owner must keep that host resolving for the registry's lifetime, + through rebrands, domain transfers and hosting changes. Choose it when the owner's durable + domain is already settled and outlives any particular deployment. +2. **Location-independent, published later** (the route `g-dec-03` establishes). Mint under an + RFC 4151 `tag:` or a `urn:` — identity that names no host and therefore cannot be orphaned by + one. The resolvable HTTP form is then a **projection** of identity, produced by the projection + build through a rebasing parameter when a durable domain exists, never by rewriting stored + assertions. The accepted cost is an internal-to-published identity mapping at projection + build; the cost is worth paying when the owner's durable domain is not yet determined, because + the alternative is an unrecoverable commitment rather than a convenience. + +Neither route is the template's default. An instance generated before its owner's durable domain +is settled should take route 2 and record the deferral as a filed decision in its own graph. ## Generating an instance -1. Copy the template's contents (minus this file's version header context — the file itself - travels) to the new repository's root. -2. Substitute every parameter, including the base IRI in `shapes/*.ttl` and - `graphs/**/*.ttl`. -3. **Record the birth provenance.** The instance's first commit fills `GENERATION.ttl`: template - version, parameters supplied, generated-by, date — `prov:wasAttributedTo` the generation act. - This is what lets the instance re-pin when the template moves. -4. **The founding-decision slot** (`graphs/canonical/founding-decision.ttl`) stays empty at - generation. The owning organisation's decision to keep a registry, filed by its ratifier, is - the **first ratified content** — the template provides the slot; only the instance can fill it. +Use the generator — `product registry generate` in product-cli. It renders this template with typed +parameters through a single-pass substitution that never re-reads a value as code, holds the result +at a verification gate, and writes the tree only if the gate passes. Hand-substitution with `sed` or +a shell one-liner is how the first generated instance acquired a silently corrupted base IRI. + +``` +product registry generate \ + --owner --repo --ratifier --display-name \ + --base-iri --date --generated-by --out +``` + +1. **This file travels verbatim.** `TEMPLATE.md` is copied into the instance **unsubstituted**: its + placeholders stay as placeholders, because the instance's record of *what its parameters were* + is what lets it re-pin when the template moves. Every other file is substituted. (This was + ambiguous in 0.1.0 and is now stated: no parameter is ever written into this file.) +2. **The gate refuses rather than warns.** Before anything is written: every supplied parameter must + appear in the output byte-identical to its input, and no placeholder may survive anywhere in the + tree. A failure aborts generation, leaving no directory behind. +3. **The birth provenance is generated, not typed.** `GENERATION.ttl` is a parameterised file filled + by the same mechanism under the same gate: template version, generator version, every parameter, + the generating agent, the mint date — `prov:wasGeneratedBy` the generation act, + `prov:wasAttributedTo` its agent. This is what lets the instance re-pin when either version moves. +4. **Generation is local; publishing is a separate act.** The generator initialises a repository and + records one birth commit. It configures no remote and pushes nothing. +5. **The founding decision is the instance's first ratified content.** It lands at + `graphs/canonical/founding-decision.ttl` — a **path, not a shipped file**. The owning + organisation's decision to keep a registry, filed by its ratifier, creates it. The template + provides the form (`shapes/decision.ttl`, with a conforming example in + `graphs/canonical/_exemplar-decision.ttl`); only the instance can fill it. + +## What CI enforces in a generated instance + +- **One assertion or one decision per file**, across every `graphs/**/*.ttl`. One rule, no filename + exemptions: 0.1.0 exempted the empty founding-decision slot, and generalising the exemption into + the rule it stood in for is what removed both the carve-out and the empty file. +- **The shapes in `shapes/`**: the §4.1 Reading tuple (`reading.ttl`), structural well-formedness of + an assertion (`structural.ttl`), and what a decision must carry (`decision.ttl`). ## Instance generation is a G0-entry step diff --git a/docs/g-track/registry-template/graphs/canonical/_exemplar-decision.ttl b/docs/g-track/registry-template/graphs/canonical/_exemplar-decision.ttl new file mode 100644 index 00000000..3222fd30 --- /dev/null +++ b/docs/g-track/registry-template/graphs/canonical/_exemplar-decision.ttl @@ -0,0 +1,25 @@ +# Exemplar — one decision per file, in Turtle. Underscore prefix marks it an +# exemplar: CI validates it, the projection build excludes `_`-prefixed files. +# +# It exists so the decision shape has a conforming example in the template +# itself: a ratifier learns the form from this file rather than from SHACL. +# The optional fields — basis, acceptedCost, revisitIf — are shown here, and +# are not required; a decision honestly having none of them still conforms. + +@prefix reg: . +@prefix prov: . +@prefix xsd: . + +reg:decision-0001 + a reg:Decision ; + reg:title "Exemplar: assertions are reviewed per triple" ; + reg:resolution "Proposed assertions are accepted or rejected one file at a time. A run is not a unit of acceptance; wholesale acceptance of an extraction run is manufactured ground." ; + reg:region "This exemplar only. It governs nothing: it is the template's worked example of the decision form." ; + reg:falsifier "A merge accepting a proposal branch without per-file review, treated afterwards as ratified ground." ; + reg:basis "Per-triple review is what makes ratification an act rather than a formality." ; + reg:acceptedCost "Review effort proportional to the number of proposed triples." ; + reg:revisitIf "Extraction volume makes per-triple review impossible in practice at some scale, and that scale is reached." ; + reg:ratifiedBy "{{RATIFIER}}" ; + reg:status "exemplar — not ratified, and not ground" ; + reg:made "2026-08-17"^^xsd:date ; + prov:wasAttributedTo reg:agent-registry-template . diff --git a/docs/g-track/registry-template/graphs/canonical/_exemplar.ttl b/docs/g-track/registry-template/graphs/canonical/_exemplar.ttl index 79917ee3..a4c2c13a 100644 --- a/docs/g-track/registry-template/graphs/canonical/_exemplar.ttl +++ b/docs/g-track/registry-template/graphs/canonical/_exemplar.ttl @@ -20,4 +20,4 @@ reg:assertion-0001 reg:asOf "2026-08-17T00:00:00Z"^^xsd:dateTime ; reg:provenance reg:controlled ; reg:assurance "exemplar — hand-authored" ; - prov:wasAttributedTo . + prov:wasAttributedTo reg:agent-g1-session . diff --git a/docs/g-track/registry-template/graphs/canonical/founding-decision.ttl b/docs/g-track/registry-template/graphs/canonical/founding-decision.ttl deleted file mode 100644 index f27fdfdc..00000000 --- a/docs/g-track/registry-template/graphs/canonical/founding-decision.ttl +++ /dev/null @@ -1,7 +0,0 @@ -# The founding-decision slot — EMPTY BY DESIGN at generation (G-1 Gate 3 ruling). -# -# This file holds the owning organisation's decision to keep a registry, filed -# by the instance's ratifier as the FIRST ratified content. The template -# provides the slot; only the instance can fill it. CI passes this file while -# it parses to zero triples; once filled, it holds one decision node and the -# decision shapes (grown at G0) validate it. diff --git a/docs/g-track/registry-template/shapes/decision.ttl b/docs/g-track/registry-template/shapes/decision.ttl new file mode 100644 index 00000000..5d1c8de1 --- /dev/null +++ b/docs/g-track/registry-template/shapes/decision.ttl @@ -0,0 +1,62 @@ +# Decision shape — what a ratified decision must carry to be one. +# +# Required: title, resolution, region, falsifier, ratifier, status, date. A +# decision without a region governs nothing in particular; one without a +# falsifier cannot be shown wrong, and an unfalsifiable decision is a +# preference wearing a decision's clothes. +# +# Deliberately NOT required: basis, acceptedCost, revisitIf. A decision may +# honestly have none of them — no cost worth recording, no condition that +# would reopen it — and demanding them produces filler, which is worse than +# their absence because filler reads as considered. +# +# `reg:ratifiedBy` is left unconstrained as to node kind: whether a ratifier is +# named by literal or by IRI is an instance's ruling, not the template's. + +@prefix sh: . +@prefix xsd: . +@prefix reg: . + +reg:DecisionShape + a sh:NodeShape ; + sh:targetClass reg:Decision ; + sh:property [ + sh:path reg:title ; + sh:minCount 1 ; + sh:maxCount 1 ; + sh:message "A decision carries exactly one title." ; + ] ; + sh:property [ + sh:path reg:resolution ; + sh:minCount 1 ; + sh:message "A decision carries its resolution — what was decided." ; + ] ; + sh:property [ + sh:path reg:region ; + sh:minCount 1 ; + sh:message "A decision carries the region it governs; an unregioned decision governs nothing in particular." ; + ] ; + sh:property [ + sh:path reg:falsifier ; + sh:minCount 1 ; + sh:message "A decision carries a falsifier — what would show it wrong." ; + ] ; + sh:property [ + sh:path reg:ratifiedBy ; + sh:minCount 1 ; + sh:maxCount 1 ; + sh:message "A decision names exactly one ratifier; ratification is one person's merge." ; + ] ; + sh:property [ + sh:path reg:status ; + sh:minCount 1 ; + sh:maxCount 1 ; + sh:message "A decision carries exactly one status." ; + ] ; + sh:property [ + sh:path reg:made ; + sh:minCount 1 ; + sh:maxCount 1 ; + sh:datatype xsd:date ; + sh:message "A decision carries exactly one date it was made, as xsd:date." ; + ] . diff --git a/product-cli/src/commands/dispatch.rs b/product-cli/src/commands/dispatch.rs index 667311aa..4b14318e 100644 --- a/product-cli/src/commands/dispatch.rs +++ b/product-cli/src/commands/dispatch.rs @@ -5,7 +5,8 @@ use clap::Command as ClapCommand; use super::{ author, blueprint, build, cell, codegen, completions, decider, deliverable, deployable_unit, design_system, domain, - guide, hooks, how, init, lsp, mcp_cmd, preview, primitive, product, projector, release, render, + guide, hooks, how, init, lsp, mcp_cmd, preview, primitive, product, projector, registry, + release, render, scope, seam, session, skills, feature, target, verdict, work_unit, worker, BoxResult, Commands, }; @@ -19,6 +20,7 @@ pub(crate) fn dispatch(command: Commands, fmt: &str, cli_command: &mut ClapComma Commands::InstallHooks => hooks::handle_install_hooks(), Commands::Lsp { command } => lsp::handle_lsp(command), Commands::Mcp { .. } => dispatch_mcp(command), + Commands::Registry { command } => render(registry::handle_registry(command), fmt), Commands::Scope { command } => render(scope::handle_scope(command), fmt), Commands::Session { command } => session::handle_session(command), Commands::Skills { command } => skills::handle_skills(command), diff --git a/product-cli/src/commands/mod.rs b/product-cli/src/commands/mod.rs index 0f935ad8..146fb7ac 100644 --- a/product-cli/src/commands/mod.rs +++ b/product-cli/src/commands/mod.rs @@ -34,6 +34,7 @@ mod mcp_cmd; mod output; mod preview; mod product; +mod registry; mod release; mod scope; mod seam; @@ -65,6 +66,7 @@ pub use self::how::HowCommands; pub use self::lsp::LspCommands; pub use self::preview::PreviewCommands; pub use self::product::ProductCommands; +pub use self::registry::RegistryCommands; pub use self::release::ReleaseCommands; pub use self::scope::ScopeCommands; pub use self::session::SessionCommands; diff --git a/product-cli/src/commands/registry.rs b/product-cli/src/commands/registry.rs new file mode 100644 index 00000000..37ce82ce --- /dev/null +++ b/product-cli/src/commands/registry.rs @@ -0,0 +1,196 @@ +//! `product registry …` — minting a ground-registry instance from the template. +//! +//! `generate` renders the versioned registry template with typed parameters, +//! holds the result at the verification gate, writes the tree, records the +//! birth provenance in one local commit. It configures no remote: **publishing +//! is a separate act**, printed for a human to run. +//! +//! `check` reads a generated instance against its own rules — the file rule +//! plus the SHACL shapes it ships — reporting an unreadable shape distinctly +//! from data that violates one. + +use std::path::{Path, PathBuf}; + +use clap::Subcommand; +use product_core::error::ProductError; +use product_core::registry::{ + apply_generation, check_instance, plan_generation, CheckReport, FindingKind, GenerationReport, + RegistryParams, +}; +use serde_json::json; + +use super::output::{CmdResult, Output}; + +/// This generator's own version, recorded in every instance's birth provenance +/// beside the template's — so an instance can re-pin when either one moves. +const GENERATOR_VERSION: &str = env!("CARGO_PKG_VERSION"); + +#[derive(Subcommand)] +pub enum RegistryCommands { + /// Read a generated instance against its own rules: the file rule, the shapes it ships + Check { + /// The instance's root directory + dir: PathBuf, + }, + /// Generate a registry instance from the versioned template into a fresh directory + Generate { + /// The owning organisation + #[arg(long)] + owner: String, + /// The repository name + #[arg(long)] + repo: String, + /// The named person whose merge is ratification + #[arg(long)] + ratifier: String, + /// The registry's display name + #[arg(long = "display-name")] + display_name: String, + /// The base IRI identifiers are minted under (ends in `#` or `/`) + #[arg(long = "base-iri")] + base_iri: String, + /// Mint date, YYYY-MM-DD — when the authority was demonstrably controlled + #[arg(long)] + date: String, + /// Who ran the generation, recorded in the birth provenance + #[arg(long = "generated-by")] + generated_by: String, + /// Target directory — must not exist, or must be empty + #[arg(long)] + out: PathBuf, + }, +} + +pub(crate) fn handle_registry(command: RegistryCommands) -> CmdResult { + match command { + RegistryCommands::Check { dir } => handle_check(&dir), + RegistryCommands::Generate { + owner, + repo, + ratifier, + display_name, + base_iri, + date, + generated_by, + out, + } => { + let params = RegistryParams { + owner, + repo, + ratifier, + display_name, + base_iri, + mint_date: date, + generated_by, + }; + handle_generate(¶ms, &out) + } + } +} + +fn handle_generate(params: &RegistryParams, out: &Path) -> CmdResult { + let plan = plan_generation(params, GENERATOR_VERSION)?; + let report = apply_generation(&plan, out)?; + let sites: usize = plan.gate.sites_per_label.values().sum(); + Ok(Output::both( + render_generation(params, &plan.template_version, sites, &report), + json!({ + "out": report.out, + "params": params, + "template_version": plan.template_version, + "generator_version": plan.generator_version, + "files": report.files, + "commit": report.commit, + "parameter_sites_verified": sites, + "publish": report.publish, + }), + )) +} + +fn render_generation( + params: &RegistryParams, + template_version: &str, + sites: usize, + report: &GenerationReport, +) -> String { + let mut s = format!( + "generated '{}' at {}\n template {} · generator {} · minted {}\n \ + {} files · gate clean ({} parameter sites verified byte-identical)\n birth commit {}\n", + params.display_name, + report.out.display(), + template_version, + GENERATOR_VERSION, + params.mint_date, + report.files.len(), + sites, + report.commit, + ); + s.push_str("\nnothing has been published — generation is local, publishing is a separate act:\n"); + for line in &report.publish { + s.push_str(&format!(" {line}\n")); + } + s.push_str( + "\nthe founding decision is the instance's first ratified content, filed by its ratifier.\n", + ); + s +} + +fn handle_check(dir: &Path) -> CmdResult { + let report = check_instance(dir)?; + let text = render_check(dir, &report); + if report.conforms() { + return Ok(Output::both(text, check_json(&report))); + } + Err(ProductError::ConfigError(text)) +} + +fn render_check(dir: &Path, report: &CheckReport) -> String { + let unevaluable = report.of_kind(FindingKind::Unevaluable); + let violations = report.of_kind(FindingKind::ShapeViolation); + let file_rule = report.of_kind(FindingKind::FileRule); + let mut s = format!( + "registry check {} — {} data file(s), {} constraint(s) evaluated\n", + dir.display(), + report.files_checked, + report.constraints_evaluated + ); + if report.conforms() { + s.push_str(" conformant: file rule clean, every shape read, no violation\n"); + return s; + } + s.push_str(&format!( + " FAILED — {} file-rule finding(s), {} shape violation(s), {} unreadable shape(s)\n", + file_rule.len(), + violations.len(), + unevaluable.len() + )); + if !unevaluable.is_empty() { + s.push_str("\nunreadable shapes (the check fails closed — no data was judged against these):\n"); + for f in &unevaluable { + s.push_str(&format!(" {} — {}\n", f.focus, f.message)); + } + } + push_findings(&mut s, "file rule", &file_rule); + push_findings(&mut s, "shape violations", &violations); + s +} + +fn push_findings(s: &mut String, heading: &str, findings: &[&product_core::registry::Finding]) { + if findings.is_empty() { + return; + } + s.push_str(&format!("\n{heading}:\n")); + for f in findings { + let where_ = f.file.clone().unwrap_or_else(|| f.focus.clone()); + s.push_str(&format!(" {where_} — {}\n", f.message)); + } +} + +fn check_json(report: &CheckReport) -> serde_json::Value { + json!({ + "conforms": report.conforms(), + "files_checked": report.files_checked, + "constraints_evaluated": report.constraints_evaluated, + "findings": report.findings, + }) +} diff --git a/product-cli/src/commands/root_enum.rs b/product-cli/src/commands/root_enum.rs index 06897d94..848b27ae 100644 --- a/product-cli/src/commands/root_enum.rs +++ b/product-cli/src/commands/root_enum.rs @@ -198,6 +198,11 @@ pub enum Commands { #[command(subcommand)] command: ProjectorCommands, }, + /// Ground registry (G-track) — generate an instance from the versioned template + Registry { + #[command(subcommand)] + command: RegistryCommands, + }, /// Release — a coherent set of delivery features (§7.1) Release { #[command(subcommand)] diff --git a/product-cli/tests/registry_generator.rs b/product-cli/tests/registry_generator.rs new file mode 100644 index 00000000..123bb099 --- /dev/null +++ b/product-cli/tests/registry_generator.rs @@ -0,0 +1,253 @@ +//! Fixtures for the ground-registry generator: generation, the gate, teardown. +//! +//! These are the reason the generator exists as a subcommand rather than a +//! script. Each fixture generates into its own temporary directory, which is +//! the whole of the generation's footprint. + +#![allow(clippy::unwrap_used)] + +mod registry_support; + +use registry_support::{bin, g0_args, generate, read}; + +#[test] +fn parameters_round_trip_byte_identically() { + let inst = generate(&g0_args()); + let generation = inst.read("GENERATION.ttl"); + for value in [ + "Hafeok", + "ground-registry-g0", + "emil@okkels-klein.dk", + "Ground Registry (G0 validation)", + "tag:emil@okkels-klein.dk,2026-08-17:ground/", + "2026-08-17", + ] { + assert!(generation.contains(value), "{value:?} did not reach the birth provenance"); + } +} + +/// The G0 handoff, pinned: the instance the mechanism exists to mint, with the +/// exact five parameters and the mint date that do not move when it regenerates. +#[test] +fn the_g0_handoff_parameters_mint_the_handed_over_instance() { + let inst = generate(&g0_args()); + let generation = inst.read("GENERATION.ttl"); + assert!( + generation.contains("@prefix reg: ."), + "the base IRI must be byte-for-byte the handed-over value" + ); + assert!(generation.contains("reg:baseIri \"tag:emil@okkels-klein.dk,2026-08-17:ground/\" ;")); + assert!(generation.contains("reg:mintDate \"2026-08-17\"^^xsd:date ;")); + for file in ["shapes/reading.ttl", "shapes/structural.ttl", "graphs/canonical/_exemplar.ttl"] { + assert!( + inst.read(file).contains("tag:emil@okkels-klein.dk,2026-08-17:ground/"), + "{file} did not receive the base IRI" + ); + } +} + +/// The G0 bug's exact case: the ratifier's address carried a sigil the shell +/// one-liner re-read as code, and the corrupted base IRI reached every file. +#[test] +fn g0_regression_a_sigil_in_the_ratifiers_address_survives() { + let inst = generate(&g0_args()); + let base = "tag:emil@okkels-klein.dk,2026-08-17:ground/"; + let ratifier = "emil@okkels-klein.dk"; + assert!(inst.read("README.md").contains(ratifier)); + assert!(!inst.read("README.md").contains("{{RATIFIER}}")); + for file in inst.files() { + if file == "TEMPLATE.md" { + continue; + } + // Strike out every whole value; any fragment of the address left behind + // is a mangled copy — the shape the G0 corruption took. + let residue = inst.read(&file).replace(base, "").replace(ratifier, ""); + assert!( + !residue.contains("okkels-klein"), + "{file} carries a partial copy of a parameter — the G0 failure mode" + ); + } +} + +#[test] +fn hostile_parameter_values_are_data() { + let hostile = "R&D \\1 $0 | / \\ {{OWNER_ORG}} `id` $(whoami)"; + let mut args = g0_args(); + args.display_name = hostile.to_string(); + let inst = generate(&args); + assert!(inst.read("GENERATION.ttl").contains(hostile), "a hostile value must be carried verbatim"); + assert!(inst.read("README.md").contains(hostile)); +} + +#[test] +fn no_placeholder_survives_the_generated_tree() { + let inst = generate(&g0_args()); + for file in inst.files() { + if file == "TEMPLATE.md" { + continue; + } + let text = inst.read(&file); + assert!(!text.contains("REGISTRY-HOST.example"), "{file} kept the placeholder host"); + assert!( + !text.contains("{{") || !text.contains("}}"), + "{file} kept a placeholder" + ); + } +} + +#[test] +fn template_md_travels_verbatim() { + let inst = generate(&g0_args()); + let shipped = inst.read("TEMPLATE.md"); + let source = read("docs/g-track/registry-template/TEMPLATE.md"); + assert_eq!(shipped, source, "TEMPLATE.md is the instance's record of its own placeholders"); + assert!(shipped.contains("{{OWNER_ORG}}")); +} + +#[test] +fn the_birth_provenance_is_complete_as_rdf() { + let inst = generate(&g0_args()); + let ttl = inst.read("GENERATION.ttl"); + let base = "tag:emil@okkels-klein.dk,2026-08-17:ground/"; + let rows = product_core::pf::sparql_rules::select( + &ttl, + &format!( + "SELECT ?owner ?repo ?ratifier ?name ?date ?tv ?gv ?agent WHERE {{ + ?i <{base}owner> ?owner ; <{base}repository> ?repo ; <{base}ratifier> ?ratifier ; + <{base}displayName> ?name ; <{base}mintDate> ?date ; + <{base}templateVersion> ?tv ; <{base}generatorVersion> ?gv ; + ?a . + ?a <{base}name> ?agent . }}" + ), + ) + .expect("the birth provenance parses as Turtle"); + assert_eq!(rows.len(), 1, "one registry node, fully described: {rows:?}"); + let row = &rows[0]; + assert_eq!(row["owner"], "\"Hafeok\""); + assert_eq!(row["tv"], format!("\"{}\"", product_core::registry::TEMPLATE_VERSION)); + assert!(row["gv"].starts_with('"'), "the generator's own version is recorded"); + assert!(row["agent"].contains("fixture"), "generated-by is recorded: {}", row["agent"]); +} + +#[test] +fn a_fresh_instance_passes_its_own_rules() { + let inst = generate(&g0_args()); + let out = inst.check(); + assert_eq!(out.exit_code, 0, "a freshly generated instance must be conformant:\n{}{}", out.stdout, out.stderr); + assert!(out.stdout.contains("conformant"), "{}", out.stdout); +} + +#[test] +fn generation_is_deterministic() { + let first = generate(&g0_args()); + let second = generate(&g0_args()); + assert_eq!(first.files(), second.files()); + for file in first.files() { + assert_eq!(first.read(&file), second.read(&file), "{file} differs between generations"); + } + assert_eq!(first.commit(), second.commit(), "the birth commit depends on the parameters alone"); +} + +#[test] +fn two_generations_in_one_run_do_not_interfere() { + let first = generate(&g0_args()); + let mut other = g0_args(); + other.owner = "Other".into(); + other.repo = "other-registry".into(); + other.base_iri = "https://other.test/ns#".into(); + let second = generate(&other); + assert!(first.read("GENERATION.ttl").contains("\"Hafeok\"")); + assert!(second.read("GENERATION.ttl").contains("\"Other\"")); + assert!(!second.read("GENERATION.ttl").contains("Hafeok")); + assert_ne!(first.commit(), second.commit()); +} + +#[test] +fn generation_leaves_no_residue_outside_its_target() { + let inst = generate(&g0_args()); + let parent: Vec = std::fs::read_dir(inst.parent()) + .unwrap() + .flatten() + .map(|e| e.file_name().to_string_lossy().to_string()) + .collect(); + assert_eq!(parent, vec!["instance".to_string()], "the target is the whole footprint"); + let path = inst.path().to_path_buf(); + drop(inst); + assert!(!path.exists(), "teardown must leave nothing behind"); +} + +#[test] +fn a_refused_parameter_writes_nothing() { + let dir = tempfile::tempdir().unwrap(); + let out = dir.path().join("instance"); + let mut args = g0_args(); + args.base_iri = "https://example.test/ns".into(); // no terminator + let result = args.run(&out); + assert_ne!(result.exit_code, 0, "a bad parameter must refuse"); + assert!(result.stderr.contains("--base-iri"), "{}", result.stderr); + assert!(!out.exists(), "the gate refuses before anything reaches disk"); +} + +#[test] +fn generation_refuses_a_non_empty_target() { + let dir = tempfile::tempdir().unwrap(); + let out = dir.path().join("instance"); + std::fs::create_dir_all(&out).unwrap(); + std::fs::write(out.join("existing.txt"), "prior content").unwrap(); + let result = g0_args().run(&out); + assert_ne!(result.exit_code, 0); + assert!(result.stderr.contains("not empty"), "{}", result.stderr); + assert_eq!(std::fs::read_to_string(out.join("existing.txt")).unwrap(), "prior content"); +} + +#[test] +fn generation_configures_no_remote() { + let inst = generate(&g0_args()); + let remotes = inst.git(&["remote", "-v"]); + assert!(remotes.trim().is_empty(), "publishing is a separate act: {remotes}"); + assert!(inst.stdout().contains("nothing has been published")); + assert!(inst.stdout().contains("git -C"), "the publish commands are printed, never run"); +} + +#[test] +fn the_binary_is_where_the_fixtures_expect_it() { + assert!(bin().exists(), "the fixtures drive the built `product` binary"); +} + +#[test] +fn a_broken_file_rule_fails_the_check_in_its_own_section() { + let inst = generate(&g0_args()); + std::fs::write( + inst.path().join("graphs/canonical/two-assertions.ttl"), + "@prefix reg: .\n\ + reg:a-1 a reg:Assertion .\nreg:a-2 a reg:Assertion .\n", + ) + .unwrap(); + let out = inst.check(); + assert_ne!(out.exit_code, 0); + let text = format!("{}{}", out.stdout, out.stderr); + assert!(text.contains("file rule:"), "{text}"); + assert!(text.contains("carries 2 assertion/decision typings"), "{text}"); +} + +/// A shape the reader cannot read fails the check, and says so distinctly from +/// data that violates a shape — an unreadable shape is not a confusing red. +#[test] +fn an_unreadable_shape_fails_closed_with_its_own_heading() { + let inst = generate(&g0_args()); + std::fs::write( + inst.path().join("shapes/exotic.ttl"), + "@prefix sh: .\n\ + @prefix reg: .\n\ + reg:ExoticShape a sh:NodeShape ; sh:targetClass reg:Reading ;\n\ + sh:property [ sh:path reg:value ; sh:qualifiedValueShape reg:Other ] .\n", + ) + .unwrap(); + let out = inst.check(); + assert_ne!(out.exit_code, 0, "an unreadable shape must fail the check"); + let text = format!("{}{}", out.stdout, out.stderr); + assert!(text.contains("unreadable shape"), "{text}"); + assert!(text.contains("no data was judged against these"), "{text}"); + assert!(text.contains("sh:qualifiedValueShape"), "{text}"); + assert!(!text.contains("shape violations:"), "no data violated anything here:\n{text}"); +} diff --git a/product-cli/tests/registry_shacl_divergence.rs b/product-cli/tests/registry_shacl_divergence.rs new file mode 100644 index 00000000..fb776d85 --- /dev/null +++ b/product-cli/tests/registry_shacl_divergence.rs @@ -0,0 +1,135 @@ +//! Measuring the native shapes reader against pySHACL over the same cases. +//! +//! The instance's own CI runs `pyshacl`; the fixtures run a fail-closed native +//! reader, because a gate that depends on a Python toolchain is a gate that +//! quietly becomes a habit. Two readers of one rule set is a standing finding, +//! so it is measured rather than assumed small: this fixture runs both over the +//! same cases whenever pySHACL is present, failing on any disagreement. +//! +//! When pySHACL is absent the fixture says so and measures nothing — it is the +//! divergence detector, never the gate. + +#![allow(clippy::unwrap_used)] + +mod registry_support; + +use registry_support::{g0_args, generate}; + +const BASE: &str = "tag:emil@okkels-klein.dk,2026-08-17:ground/"; + +fn pyshacl_available() -> bool { + std::process::Command::new("python3") + .args(["-c", "import pyshacl"]) + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +/// pySHACL's verdict: does the data conform to the shapes? +fn pyshacl_conforms(dir: &std::path::Path, data: &str, shapes: &str) -> bool { + std::fs::write(dir.join("data.ttl"), data).unwrap(); + std::fs::write(dir.join("shapes.ttl"), shapes).unwrap(); + let out = std::process::Command::new("python3") + .arg("-m") + .arg("pyshacl") + .arg("-a") + .arg("-s") + .arg(dir.join("shapes.ttl")) + .arg(dir.join("data.ttl")) + .output() + .expect("run pyshacl"); + String::from_utf8_lossy(&out.stdout).contains("Conforms: True") +} + +fn reading(extra: &str) -> String { + format!( + "@prefix reg: <{BASE}> .\n@prefix xsd: .\n\ + reg:a-1 a reg:Assertion , reg:Reading ;\n\ + reg:subject reg:Order ; reg:predicate reg:hasPart ; reg:object reg:OrderLine ;\n\ + reg:value \"Order hasPart OrderLine\" ;\n\ + reg:asOf \"2026-08-17T00:00:00Z\"^^xsd:dateTime ;\n\ + reg:assurance \"hand-authored\" ;\n{extra} .\n" + ) +} + +#[test] +fn the_native_reader_agrees_with_pyshacl() { + if !pyshacl_available() { + eprintln!( + "pySHACL is not installed — the native reader's divergence from the instance's own \ + CI was NOT measured in this run. Install `pyshacl` to measure it." + ); + return; + } + let inst = generate(&g0_args()); + let shapes = format!( + "{}\n{}\n{}", + inst.read("shapes/reading.ttl"), + inst.read("shapes/structural.ttl"), + inst.read("shapes/decision.ttl") + ); + let scratch = tempfile::tempdir().unwrap(); + + let cases: Vec<(&str, String)> = vec![ + ("shipped exemplar", inst.read("graphs/canonical/_exemplar.ttl")), + ("conforming reading", reading(" reg:provenance reg:controlled")), + ("institutional without trust decision", reading(" reg:provenance reg:institutional")), + ( + "institutional with trust decision", + reading(" reg:provenance reg:institutional ;\n reg:trust_decision reg:td-1"), + ), + ("provenance outside the vocabulary", reading(" reg:provenance reg:hearsay")), + ("shipped decision exemplar", inst.read("graphs/canonical/_exemplar-decision.ttl")), + ( + "decision missing its falsifier", + format!( + "@prefix reg: <{BASE}> .\n@prefix xsd: .\n\ + reg:d-1 a reg:Decision ; reg:title \"t\" ; reg:resolution \"r\" ; reg:region \"g\" ;\n\ + reg:ratifiedBy \"who\" ; reg:status \"s\" ; reg:made \"2026-08-17\"^^xsd:date .\n" + ), + ), + ( + "decision dated by a bare string", + format!( + "@prefix reg: <{BASE}> .\n\ + reg:d-2 a reg:Decision ; reg:title \"t\" ; reg:resolution \"r\" ; reg:region \"g\" ;\n\ + reg:falsifier \"f\" ; reg:ratifiedBy \"who\" ; reg:status \"s\" ; reg:made \"2026-08-17\" .\n" + ), + ), + ( + "assertion without a predicate", + format!( + "@prefix reg: <{BASE}> .\nreg:a-2 a reg:Assertion ; reg:subject reg:Order ; reg:object reg:OrderLine .\n" + ), + ), + ]; + + let mut divergence = Vec::new(); + for (name, data) in &cases { + let (findings, evaluated) = product_core::registry::evaluate(data, &shapes).expect("native reader"); + assert!(evaluated > 0, "the native reader evaluated no constraint for '{name}'"); + let native = findings.is_empty(); + let python = pyshacl_conforms(scratch.path(), data, &shapes); + if native != python { + divergence.push(format!( + " {name}: native reader says {}, pySHACL says {}\n findings: {:?}", + verdict(native), + verdict(python), + findings.iter().map(|f| f.message.clone()).collect::>() + )); + } + } + assert!( + divergence.is_empty(), + "the two readers of one rule set disagree — the divergence is real, not assumed small:\n{}", + divergence.join("\n") + ); +} + +fn verdict(conforms: bool) -> &'static str { + if conforms { + "conformant" + } else { + "violating" + } +} diff --git a/product-cli/tests/registry_support/mod.rs b/product-cli/tests/registry_support/mod.rs new file mode 100644 index 00000000..abbf2020 --- /dev/null +++ b/product-cli/tests/registry_support/mod.rs @@ -0,0 +1,165 @@ +//! Support for the registry-generator fixtures: parameters, targets, teardown. + +#![allow(dead_code, clippy::unwrap_used)] + +use std::path::{Path, PathBuf}; +use std::process::Command; + +/// The `product` binary the fixtures drive. +pub(crate) fn bin() -> PathBuf { + let mut path = std::env::current_exe().expect("current_exe"); + path.pop(); + path.pop(); + path.push("product"); + if !path.exists() { + path = PathBuf::from("target/debug/product"); + } + path +} + +/// The workspace root, for reading the template's own files. +pub(crate) fn workspace_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).parent().expect("workspace root").to_path_buf() +} + +/// Read a file relative to the workspace root. +pub(crate) fn read(rel: &str) -> String { + std::fs::read_to_string(workspace_root().join(rel)).expect("read workspace file") +} + +/// What a command run produced. +pub(crate) struct Run { + pub(crate) stdout: String, + pub(crate) stderr: String, + pub(crate) exit_code: i32, +} + +/// One generation's parameters. +#[derive(Clone)] +pub(crate) struct Args { + pub(crate) owner: String, + pub(crate) repo: String, + pub(crate) ratifier: String, + pub(crate) display_name: String, + pub(crate) base_iri: String, + pub(crate) date: String, + pub(crate) generated_by: String, +} + +/// The parameters the G0 handoff pins — the same five, the same mint date. +pub(crate) fn g0_args() -> Args { + Args { + owner: "Hafeok".into(), + repo: "ground-registry-g0".into(), + ratifier: "emil@okkels-klein.dk".into(), + display_name: "Ground Registry (G0 validation)".into(), + base_iri: "tag:emil@okkels-klein.dk,2026-08-17:ground/".into(), + date: "2026-08-17".into(), + generated_by: "the registry-generator fixture".into(), + } +} + +impl Args { + /// Run `product registry generate` into `out`, returning what it printed. + pub(crate) fn run(&self, out: &Path) -> Run { + let output = Command::new(bin()) + .args(["registry", "generate"]) + .args(["--owner", &self.owner]) + .args(["--repo", &self.repo]) + .args(["--ratifier", &self.ratifier]) + .args(["--display-name", &self.display_name]) + .args(["--base-iri", &self.base_iri]) + .args(["--date", &self.date]) + .args(["--generated-by", &self.generated_by]) + .arg("--out") + .arg(out) + .output() + .expect("run product registry generate"); + Run { + stdout: String::from_utf8_lossy(&output.stdout).to_string(), + stderr: String::from_utf8_lossy(&output.stderr).to_string(), + exit_code: output.status.code().unwrap_or(-1), + } + } +} + +/// A generated instance in its own temporary directory. Dropping it removes +/// the directory, which is the whole of the generation's footprint. +pub(crate) struct Instance { + dir: tempfile::TempDir, + stdout: String, +} + +/// Generate an instance, asserting the run succeeded. +pub(crate) fn generate(args: &Args) -> Instance { + let dir = tempfile::tempdir().expect("tempdir"); + let out = dir.path().join("instance"); + let run = args.run(&out); + assert_eq!(run.exit_code, 0, "generation failed:\n{}{}", run.stdout, run.stderr); + Instance { dir, stdout: run.stdout } +} + +impl Instance { + /// The instance root. + pub(crate) fn path(&self) -> PathBuf { + self.dir.path().join("instance") + } + + /// The directory the instance was generated into. + pub(crate) fn parent(&self) -> PathBuf { + self.dir.path().to_path_buf() + } + + /// What the generation printed. + pub(crate) fn stdout(&self) -> &str { + &self.stdout + } + + /// One of the instance's files. + pub(crate) fn read(&self, rel: &str) -> String { + std::fs::read_to_string(self.path().join(rel)).expect("read instance file") + } + + /// Every tracked file, sorted. + pub(crate) fn files(&self) -> Vec { + let mut out: Vec = self + .git(&["ls-files"]) + .lines() + .map(|l| l.to_string()) + .filter(|l| !l.is_empty()) + .collect(); + out.sort(); + out + } + + /// The birth commit's object id. + pub(crate) fn commit(&self) -> String { + self.git(&["rev-parse", "HEAD"]).trim().to_string() + } + + /// Run git inside the instance. + pub(crate) fn git(&self, args: &[&str]) -> String { + let out = Command::new("git") + .arg("-C") + .arg(self.path()) + .args(args) + .env("GIT_CONFIG_NOSYSTEM", "1") + .output() + .expect("run git"); + String::from_utf8_lossy(&out.stdout).to_string() + } + + /// Run `product registry check` over the instance. + pub(crate) fn check(&self) -> Run { + let output = Command::new(bin()) + .args(["registry", "check"]) + .arg(self.path()) + .output() + .expect("run product registry check"); + Run { + stdout: String::from_utf8_lossy(&output.stdout).to_string(), + stderr: String::from_utf8_lossy(&output.stderr).to_string(), + exit_code: output.status.code().unwrap_or(-1), + } + } +} diff --git a/product-core/src/lib.rs b/product-core/src/lib.rs index 38d791eb..584c37ae 100644 --- a/product-core/src/lib.rs +++ b/product-core/src/lib.rs @@ -8,6 +8,7 @@ pub mod error; pub mod fileops; pub mod guide; pub mod pf; +pub mod registry; pub mod root; // Wrapper modules for canonical module structure (ADR-029) diff --git a/product-core/src/registry/apply.rs b/product-core/src/registry/apply.rs new file mode 100644 index 00000000..e76b3c4e --- /dev/null +++ b/product-core/src/registry/apply.rs @@ -0,0 +1,200 @@ +//! Committing a verified generation plan to a fresh local directory. +//! +//! This is the only part of the slice that touches the world, and it touches it +//! locally: it writes the tree, initialises a git repository, records the birth +//! provenance in one commit. **Publishing is a separate act** — no remote is +//! configured, no network call is made, no repository is created anywhere. The +//! report carries the two commands that would publish it, for a human to run. +//! +//! Minting identity and publishing it answer to different authorities, and only +//! the local half can be a fixture; a generator that did both would put the +//! untested half on the tested half's back. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use crate::error::{ProductError, Result}; + +use super::plan::GenerationPlan; + +/// The email recorded on the birth commit. RFC 2606 reserves `.invalid`: no +/// address was chosen, and none is implied. The *agent* is `--generated-by`. +pub const COMMIT_EMAIL: &str = "registry-generator@invalid"; + +/// A path git reads as an empty global config, so the developer's identity, +/// default branch, signing settings, line-ending rules never reach the birth +/// commit — the commit depends on the parameters alone. +#[cfg(unix)] +const NO_GLOBAL_CONFIG: &str = "/dev/null"; +#[cfg(not(unix))] +const NO_GLOBAL_CONFIG: &str = "NUL"; + +/// What a generation did. +#[derive(Debug, serde::Serialize)] +pub struct GenerationReport { + /// Where the instance was written. + pub out: PathBuf, + /// Files written, in template order. + pub files: Vec, + /// The birth commit's object id. + pub commit: String, + /// The commands that would publish the instance — not run here. + pub publish: Vec, +} + +/// Write a verified plan into `out`, then record the birth commit. +pub fn apply_generation(plan: &GenerationPlan, out: &Path) -> Result { + ensure_free(out)?; + std::fs::create_dir_all(out).map_err(|e| io_fault(out, &e.to_string()))?; + for file in &plan.files { + write_one(out, &file.path, &file.contents, file.executable)?; + } + let commit = birth_commit(plan, out)?; + Ok(GenerationReport { + out: out.to_path_buf(), + files: plan.files.iter().map(|f| f.path.clone()).collect(), + commit, + publish: vec![ + format!( + "git -C {} remote add origin git@github.com:{}/{}.git", + out.display(), + plan.params.owner, + plan.params.repo + ), + format!("git -C {} push -u origin main", out.display()), + ], + }) +} + +/// The target must not already hold anything — a generation never merges into +/// an existing tree. +fn ensure_free(out: &Path) -> Result<()> { + if !out.exists() { + return Ok(()); + } + if !out.is_dir() { + return Err(fault(&format!("{} exists but is not a directory", out.display()))); + } + let mut entries = std::fs::read_dir(out).map_err(|e| io_fault(out, &e.to_string()))?; + if entries.next().is_some() { + return Err(fault(&format!( + "{} is not empty — generation never writes into an existing tree", + out.display() + ))); + } + Ok(()) +} + +/// Write one planned file beneath `out`, refusing any path that escapes it. +fn write_one(out: &Path, rel: &str, contents: &str, executable: bool) -> Result<()> { + if rel.starts_with('/') || rel.split('/').any(|seg| seg == ".." || seg.is_empty()) { + return Err(fault(&format!("template path '{rel}' does not stay inside the target"))); + } + let path = out.join(rel); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| io_fault(parent, &e.to_string()))?; + } + std::fs::write(&path, contents).map_err(|e| io_fault(&path, &e.to_string()))?; + if executable { + set_executable(&path)?; + } + Ok(()) +} + +#[cfg(unix)] +fn set_executable(path: &Path) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)) + .map_err(|e| io_fault(path, &e.to_string())) +} + +#[cfg(not(unix))] +fn set_executable(_path: &Path) -> Result<()> { + Ok(()) +} + +/// Initialise the repository, then record the birth provenance in one commit. +/// +/// The identity is inline, the dates are the mint date, the system config is +/// ignored: the commit depends on the parameters alone, so the same parameters +/// through the same generator produce the same commit id. +fn birth_commit(plan: &GenerationPlan, out: &Path) -> Result { + let stamp = format!("{}T00:00:00Z", plan.params.mint_date); + git(out, &["init", "-q", "-b", "main"], &[])?; + git(out, &["add", "-A"], &[])?; + let message = commit_message(plan); + git( + out, + &[ + "-c", + &format!("user.name={}", plan.params.generated_by), + "-c", + &format!("user.email={COMMIT_EMAIL}"), + "-c", + "commit.gpgsign=false", + "commit", + "-q", + "-m", + &message, + ], + &[("GIT_AUTHOR_DATE", &stamp), ("GIT_COMMITTER_DATE", &stamp)], + )?; + Ok(git(out, &["rev-parse", "HEAD"], &[])?.trim().to_string()) +} + +/// The birth commit's message — the parameters, in the record. +fn commit_message(plan: &GenerationPlan) -> String { + let p = &plan.params; + format!( + "Birth provenance — {}\n\n\ + Generated from the registry template {} by the product-cli registry\n\ + generator {}. The founding decision is not filed here: it is the\n\ + instance's first ratified content, filed by its ratifier.\n\n\ + owner: {}\n\ + repository: {}\n\ + ratifier: {}\n\ + base IRI: {}\n\ + mint date: {}\n\ + generated by: {}\n", + p.display_name, + plan.template_version, + plan.generator_version, + p.owner, + p.repo, + p.ratifier, + p.base_iri, + p.mint_date, + p.generated_by, + ) +} + +/// Run git inside the generated tree only, reading neither the system config +/// nor the developer's identity. +fn git(dir: &Path, args: &[&str], env: &[(&str, &str)]) -> Result { + let mut cmd = Command::new("git"); + cmd.arg("-C") + .arg(dir) + .args(args) + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_CONFIG_GLOBAL", NO_GLOBAL_CONFIG); + for (k, v) in env { + cmd.env(k, v); + } + let out = cmd.output().map_err(|e| fault(&format!("git {:?} could not run: {e}", args)))?; + if !out.status.success() { + return Err(fault(&format!( + "git {:?} failed: {}", + args, + String::from_utf8_lossy(&out.stderr).trim() + ))); + } + Ok(String::from_utf8_lossy(&out.stdout).to_string()) +} + +fn fault(message: &str) -> ProductError { + ProductError::ConfigError(format!("registry generate: {message}")) +} + +fn io_fault(path: &Path, message: &str) -> ProductError { + ProductError::WriteError { path: path.to_path_buf(), message: message.to_string() } +} diff --git a/product-core/src/registry/check/check_tests.rs b/product-core/src/registry/check/check_tests.rs new file mode 100644 index 00000000..2cbfa8af --- /dev/null +++ b/product-core/src/registry/check/check_tests.rs @@ -0,0 +1,199 @@ +//! Unit tests for the conformance reader, positive with negative. + +use super::*; +use crate::registry::plan::plan_generation; + +const BASE: &str = "https://registry.test/ns#"; + +fn params() -> crate::registry::params::RegistryParams { + crate::registry::params::RegistryParams { + owner: "Hafeok".into(), + repo: "registry-under-test".into(), + ratifier: "ratifier@example.test".into(), + display_name: "Registry under test".into(), + base_iri: BASE.into(), + mint_date: "2026-08-17".into(), + generated_by: "the test suite".into(), + } +} + +/// The shapes an instance generated with `BASE` actually ships. +fn instance_shapes() -> String { + let plan = plan_generation(¶ms(), "test").expect("plan"); + plan.files + .iter() + .filter(|f| f.path.starts_with("shapes/")) + .map(|f| f.contents.as_str()) + .collect::>() + .join("\n") +} + +fn evaluate(data: &str) -> Vec { + let shapes = instance_shapes(); + let extracted = shapes::extract(&shapes).expect("extract"); + assert!(extracted.unevaluable.is_empty(), "the shipped shapes must be readable: {:?}", extracted.unevaluable); + assert!(!extracted.constraints.is_empty(), "the shipped shapes yielded no constraints"); + eval::run(data, &extracted.constraints).expect("evaluate") +} + +fn reading(extra: &str) -> String { + format!( + "@prefix reg: <{BASE}> .\n@prefix xsd: .\n\ + reg:a-1 a reg:Assertion , reg:Reading ;\n\ + reg:subject reg:Order ; reg:predicate reg:hasPart ; reg:object reg:OrderLine ;\n\ + reg:value \"Order hasPart OrderLine\" ;\n\ + reg:asOf \"2026-08-17T00:00:00Z\"^^xsd:dateTime ;\n\ + reg:assurance \"hand-authored\" ;\n{extra} .\n" + ) +} + +#[test] +fn a_conforming_reading_passes() { + let findings = evaluate(&reading(" reg:provenance reg:controlled")); + assert!(findings.is_empty(), "{findings:?}"); +} + +#[test] +fn an_institutional_reading_without_a_trust_decision_fails() { + let findings = evaluate(&reading(" reg:provenance reg:institutional")); + assert!( + findings.iter().any(|f| f.message.contains("trust_decision")), + "institutional provenance must require a trust decision: {findings:?}" + ); +} + +#[test] +fn an_institutional_reading_with_a_trust_decision_passes() { + let findings = evaluate(&reading(" reg:provenance reg:institutional ;\n reg:trust_decision reg:td-1")); + assert!(findings.is_empty(), "{findings:?}"); +} + +#[test] +fn a_reading_missing_its_value_fails() { + let data = format!( + "@prefix reg: <{BASE}> .\n@prefix xsd: .\n\ + reg:a-1 a reg:Assertion , reg:Reading ;\n\ + reg:subject reg:Order ; reg:predicate reg:hasPart ; reg:object reg:OrderLine ;\n\ + reg:asOf \"2026-08-17T00:00:00Z\"^^xsd:dateTime ;\n\ + reg:assurance \"hand-authored\" ; reg:provenance reg:controlled .\n" + ); + let findings = evaluate(&data); + assert!(findings.iter().any(|f| f.message.contains("carries a value")), "{findings:?}"); +} + +#[test] +fn a_provenance_outside_the_vocabulary_fails() { + let findings = evaluate(&reading(" reg:provenance reg:hearsay")); + assert!(findings.iter().any(|f| f.message.contains("controlled/observed")), "{findings:?}"); +} + +#[test] +fn a_file_with_two_assertions_fails_the_file_rule() { + let data = format!( + "@prefix reg: <{BASE}> .\nreg:a-1 a reg:Assertion .\nreg:a-2 a reg:Assertion .\n" + ); + let findings = file_rule::check_file("graphs/canonical/two.ttl", &data, BASE); + assert_eq!(findings.len(), 1); + assert_eq!(findings[0].kind, FindingKind::FileRule); + assert!(findings[0].message.contains("carries 2"), "{:?}", findings[0]); +} + +#[test] +fn a_file_with_one_assertion_or_one_decision_passes_the_file_rule() { + for kind in ["Assertion", "Decision"] { + let data = format!("@prefix reg: <{BASE}> .\nreg:x a reg:{kind} .\n"); + assert!(file_rule::check_file("graphs/canonical/one.ttl", &data, BASE).is_empty()); + } +} + +#[test] +fn an_empty_file_fails_the_file_rule() { + // The zero-triple tolerance went with the empty founding-decision slot. + let findings = file_rule::check_file("graphs/canonical/empty.ttl", "# nothing\n", BASE); + assert_eq!(findings.len(), 1); + assert!(findings[0].message.contains("carries 0"), "{:?}", findings[0]); +} + +#[test] +fn a_decision_missing_a_required_field_fails() { + let data = format!( + "@prefix reg: <{BASE}> .\n@prefix xsd: .\n\ + reg:d-1 a reg:Decision ; reg:title \"Keep a registry\" ;\n\ + reg:resolution \"We keep one.\" ; reg:region \"This instance.\" ;\n\ + reg:ratifiedBy \"ratifier@example.test\" ; reg:status \"ratified\" ;\n\ + reg:made \"2026-08-17\"^^xsd:date .\n" + ); + let findings = evaluate(&data); + assert!(findings.iter().any(|f| f.message.contains("falsifier")), "{findings:?}"); + assert!(!findings.iter().any(|f| f.message.contains("title")), "{findings:?}"); +} + +#[test] +fn a_decision_without_the_optional_fields_conforms() { + // basis / acceptedCost / revisitIf are deliberately not required: a decision + // may honestly have none, and demanding them produces filler. + let data = format!( + "@prefix reg: <{BASE}> .\n@prefix xsd: .\n\ + reg:d-1 a reg:Decision ; reg:title \"Keep a registry\" ;\n\ + reg:resolution \"We keep one.\" ; reg:region \"This instance.\" ;\n\ + reg:falsifier \"Ground kept outside it.\" ;\n\ + reg:ratifiedBy \"ratifier@example.test\" ; reg:status \"ratified\" ;\n\ + reg:made \"2026-08-17\"^^xsd:date .\n" + ); + assert!(evaluate(&data).is_empty(), "{:?}", evaluate(&data)); +} + +#[test] +fn a_decision_dated_by_a_bare_string_fails() { + let data = format!( + "@prefix reg: <{BASE}> .\n\ + reg:d-1 a reg:Decision ; reg:title \"t\" ; reg:resolution \"r\" ; reg:region \"g\" ;\n\ + reg:falsifier \"f\" ; reg:ratifiedBy \"who\" ; reg:status \"s\" ; reg:made \"2026-08-17\" .\n" + ); + assert!(evaluate(&data).iter().any(|f| f.message.contains("xsd:date")), "{:?}", evaluate(&data)); +} + +#[test] +fn the_shipped_decision_exemplar_conforms() { + let plan = plan_generation(¶ms(), "test").expect("plan"); + let exemplar = plan + .files + .iter() + .find(|f| f.path == "graphs/canonical/_exemplar-decision.ttl") + .expect("the template ships a decision exemplar"); + assert!(evaluate(&exemplar.contents).is_empty(), "{:?}", evaluate(&exemplar.contents)); + assert!(file_rule::check_file(&exemplar.path, &exemplar.contents, BASE).is_empty()); +} + +#[test] +fn an_unsupported_shacl_construct_fails_closed() { + let shapes = format!( + "@prefix sh: .\n@prefix reg: <{BASE}> .\n\ + reg:S a sh:NodeShape ; sh:targetClass reg:Reading ;\n\ + sh:property [ sh:path reg:value ; sh:qualifiedValueShape reg:Other ] .\n" + ); + let extracted = shapes::extract(&shapes).expect("extract"); + assert!( + extracted.unevaluable.iter().any(|f| f.focus.contains("qualifiedValueShape")), + "an unreadable construct must be reported, never skipped: {:?}", + extracted.unevaluable + ); + assert_eq!(extracted.unevaluable[0].kind, FindingKind::Unevaluable); +} + +#[test] +fn a_property_path_expression_is_reported_rather_than_ignored() { + let shapes = format!( + "@prefix sh: .\n@prefix reg: <{BASE}> .\n\ + reg:S a sh:NodeShape ; sh:targetClass reg:Reading ;\n\ + sh:property [ sh:path [ sh:inversePath reg:value ] ; sh:minCount 1 ] .\n" + ); + let extracted = shapes::extract(&shapes).expect("extract"); + assert!(!extracted.unevaluable.is_empty()); +} + +#[test] +fn a_base_iri_is_read_from_the_instances_own_prefix() { + let ttl = format!("@prefix reg: <{BASE}> .\nreg:x a reg:Registry .\n"); + assert_eq!(prefix_binding(&ttl, "reg").as_deref(), Some(BASE)); +} diff --git a/product-core/src/registry/check/eval.rs b/product-core/src/registry/check/eval.rs new file mode 100644 index 00000000..4763ac77 --- /dev/null +++ b/product-core/src/registry/check/eval.rs @@ -0,0 +1,84 @@ +//! Evaluating extracted constraints as SPARQL over an instance's data graph. +//! +//! Each constraint compiles to one SELECT whose every result row is a +//! violation — the same shape `pf::sparql_rules` already runs the framework's +//! own graph rules in, so oxigraph stays the single query engine. + +use crate::error::{ProductError, Result}; + +use super::shapes::{local, Constraint, Rule}; +use super::{Finding, FindingKind}; + +/// Run every constraint over the data graph, returning one finding per row. +pub(crate) fn run(data_ttl: &str, constraints: &[Constraint]) -> Result> { + let mut out = Vec::new(); + for c in constraints { + let query = compile(c); + let rows = crate::pf::sparql_rules::select(data_ttl, &query).map_err(|e| { + ProductError::ConfigError(format!( + "registry check: constraint on {} could not run: {e}", + local(&c.shape) + )) + })?; + for row in rows { + let focus = row.get("this").cloned().unwrap_or_else(|| local(&c.shape)); + out.push(Finding { + kind: FindingKind::ShapeViolation, + file: None, + focus: local(&focus), + message: describe(c), + }); + } + } + Ok(out) +} + +/// The SELECT one constraint becomes. +pub(crate) fn compile(c: &Constraint) -> String { + let class = &c.class; + let path = c.path.clone().unwrap_or_default(); + match &c.rule { + Rule::Sparql(select) => select.clone(), + Rule::MinCount(n) => format!( + "SELECT ?this WHERE {{ ?this a {class} . OPTIONAL {{ ?this {path} ?v }} }} \ + GROUP BY ?this HAVING (COUNT(?v) < {n})" + ), + Rule::MaxCount(n) => format!( + "SELECT ?this WHERE {{ ?this a {class} . OPTIONAL {{ ?this {path} ?v }} }} \ + GROUP BY ?this HAVING (COUNT(?v) > {n})" + ), + Rule::NodeKind(kind) => { + let test = match kind.as_str() { + "IRI" => "!isIRI(?v)", + "Literal" => "!isLiteral(?v)", + _ => "!isBlank(?v)", + }; + format!("SELECT ?this WHERE {{ ?this a {class} ; {path} ?v . FILTER({test}) }}") + } + Rule::Datatype(dt) => format!( + "SELECT ?this WHERE {{ ?this a {class} ; {path} ?v . \ + FILTER(!isLiteral(?v) || datatype(?v) != {dt}) }}" + ), + Rule::In(members) => format!( + "SELECT ?this WHERE {{ ?this a {class} ; {path} ?v . FILTER(?v NOT IN ({})) }}", + members.join(", ") + ), + } +} + +/// What a constraint says when it is violated — the shape's own `sh:message` +/// where it has one, otherwise a rendering of the constraint itself. +fn describe(c: &Constraint) -> String { + if !c.message.is_empty() { + return c.message.clone(); + } + let path = c.path.as_deref().map(local).unwrap_or_default(); + match &c.rule { + Rule::MinCount(n) => format!("{path}: fewer than {n} value(s)"), + Rule::MaxCount(n) => format!("{path}: more than {n} value(s)"), + Rule::NodeKind(k) => format!("{path}: a value is not a {k}"), + Rule::Datatype(dt) => format!("{path}: a value is not a {}", local(dt)), + Rule::In(_) => format!("{path}: a value is outside the permitted set"), + Rule::Sparql(_) => format!("{}: SPARQL constraint violated", local(&c.shape)), + } +} diff --git a/product-core/src/registry/check/file_rule.rs b/product-core/src/registry/check/file_rule.rs new file mode 100644 index 00000000..901399dd --- /dev/null +++ b/product-core/src/registry/check/file_rule.rs @@ -0,0 +1,39 @@ +//! The file rule: one assertion or one decision per data file. +//! +//! Generalised from the template's original filename exemption to the class +//! rule it stood in for. A rule with an exception decays; the empty +//! founding-decision slot went with the tolerance, so the slot is a documented +//! path an instance's ratifier creates rather than an empty file CI carves out. + +use super::{Finding, FindingKind}; + +/// Check one data file. Returns a finding when the file does not carry exactly +/// one assertion or exactly one decision. +pub(crate) fn check_file(path: &str, text: &str, base: &str) -> Vec { + let query = format!( + "SELECT ?s WHERE {{ ?s a ?c . VALUES ?c {{ <{base}Assertion> <{base}Decision> }} }}" + ); + let rows = match crate::pf::sparql_rules::select(text, &query) { + Ok(rows) => rows, + Err(e) => { + return vec![Finding { + kind: FindingKind::FileRule, + file: Some(path.to_string()), + focus: "parse".to_string(), + message: format!("the file could not be read as Turtle: {e}"), + }] + } + }; + let subjects: Vec = rows.iter().filter_map(|r| r.get("s").cloned()).collect(); + match subjects.len() { + 1 => Vec::new(), + n => vec![Finding { + kind: FindingKind::FileRule, + file: Some(path.to_string()), + focus: subjects.join(", "), + message: format!( + "carries {n} assertion/decision typings — exactly one assertion or exactly one decision is required" + ), + }], + } +} diff --git a/product-core/src/registry/check/mod.rs b/product-core/src/registry/check/mod.rs new file mode 100644 index 00000000..78d18a17 --- /dev/null +++ b/product-core/src/registry/check/mod.rs @@ -0,0 +1,148 @@ +//! Conformance reader for a generated registry instance. +//! +//! Runs the two rules an instance's own CI runs — the file rule (every +//! `graphs/**/*.ttl` carries exactly one assertion or exactly one decision) and +//! the SHACL shapes in `shapes/` — over a tree on disk. +//! +//! It reads a **defined subset** of SHACL, compiled to SPARQL, so the shapes +//! files stay the single statement of the constraints: nothing is restated in +//! Rust. Anything outside that subset is reported as *unevaluable* and **fails** +//! the check. A shape the reader cannot read never passes silently, and an +//! unevaluable shape is reported distinctly from data violating a shape. +//! +//! This is a second reader of rules `pyshacl` also reads in the instance's CI. +//! The divergence is a standing finding, measured by a fixture that runs both +//! when pySHACL is present rather than assumed small. + +pub(crate) mod eval; +pub(crate) mod file_rule; +pub(crate) mod shapes; + +use std::path::Path; + +use crate::error::{ProductError, Result}; + +/// What kind of finding this is — the three are reported separately. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum FindingKind { + /// A file broke the one-assertion-or-one-decision rule. + FileRule, + /// Data violated a shape the reader evaluated. + ShapeViolation, + /// A shape the reader could not read. The check fails closed. + Unevaluable, +} + +/// One finding. +#[derive(Debug, Clone, serde::Serialize)] +pub struct Finding { + /// Which kind of finding. + pub kind: FindingKind, + /// The file it was found in, where one applies. + pub file: Option, + /// The focus node, shape, or construct at fault. + pub focus: String, + /// What is wrong. + pub message: String, +} + +/// The result of a check. +#[derive(Debug, Default, serde::Serialize)] +pub struct CheckReport { + /// Data files read. + pub files_checked: usize, + /// Constraints evaluated. + pub constraints_evaluated: usize, + /// Everything found. + pub findings: Vec, +} + +impl CheckReport { + /// True when nothing was found — no violation, no unreadable shape. + pub fn conforms(&self) -> bool { + self.findings.is_empty() + } + + /// Findings of one kind. + pub fn of_kind(&self, kind: FindingKind) -> Vec<&Finding> { + self.findings.iter().filter(|f| f.kind == kind).collect() + } +} + +/// Read an instance at `dir` against its own rules. +pub fn check_instance(dir: &Path) -> Result { + let data_files = ttl_files(&dir.join("graphs"))?; + let shape_files = ttl_files(&dir.join("shapes"))?; + let base = base_iri(dir)?; + + let mut report = CheckReport { files_checked: data_files.len(), ..Default::default() }; + for (path, text) in &data_files { + report.findings.extend(file_rule::check_file(path, text, &base)); + } + + let shapes_text = join(&shape_files); + let data_text = join(&data_files); + let (findings, evaluated) = evaluate(&data_text, &shapes_text)?; + report.constraints_evaluated = evaluated; + report.findings.extend(findings); + Ok(report) +} + +/// Evaluate a shapes graph over a data graph, both as Turtle text, returning +/// the findings with the number of constraints that were evaluated. +/// +/// Shapes the reader cannot read come back as [`FindingKind::Unevaluable`] +/// findings rather than silence — the reader fails closed. +pub fn evaluate(data_ttl: &str, shapes_ttl: &str) -> Result<(Vec, usize)> { + let extracted = shapes::extract(shapes_ttl)?; + let mut findings = extracted.unevaluable.clone(); + findings.extend(eval::run(data_ttl, &extracted.constraints)?); + Ok((findings, extracted.constraints.len())) +} + +/// The instance's base IRI, read from `GENERATION.ttl`'s own prefix binding by +/// the RDF parser — the instance states its identity, the reader does not guess. +pub(crate) fn base_iri(dir: &Path) -> Result { + let path = dir.join("GENERATION.ttl"); + let text = std::fs::read_to_string(&path) + .map_err(|e| fault(&format!("cannot read {}: {e}", path.display())))?; + prefix_binding(&text, "reg") + .ok_or_else(|| fault(&format!("{} declares no `reg:` prefix", path.display()))) +} + +/// The IRI a Turtle document binds a prefix to, via the parser's own prefix map. +pub(crate) fn prefix_binding(turtle: &str, prefix: &str) -> Option { + use oxigraph::io::{RdfFormat, RdfParser}; + let mut parser = RdfParser::from_format(RdfFormat::Turtle).for_reader(turtle.as_bytes()); + for quad in &mut parser { + quad.ok()?; + } + parser.prefixes().find(|(p, _)| *p == prefix).map(|(_, iri)| iri.to_string()) +} + +/// Every `*.ttl` beneath `dir`, sorted by path. +fn ttl_files(dir: &Path) -> Result> { + let pattern = format!("{}/**/*.ttl", dir.display()); + let mut out = Vec::new(); + let paths = glob::glob(&pattern).map_err(|e| fault(&format!("bad glob: {e}")))?; + for entry in paths.flatten() { + let text = std::fs::read_to_string(&entry) + .map_err(|e| fault(&format!("cannot read {}: {e}", entry.display())))?; + out.push((entry.display().to_string(), text)); + } + out.sort(); + Ok(out) +} + +fn join(files: &[(String, String)]) -> String { + files.iter().map(|(_, t)| t.as_str()).collect::>().join("\n") +} + +fn fault(message: &str) -> ProductError { + ProductError::ConfigError(format!("registry check: {message}")) +} + +#[cfg(test)] +#[path = "check_tests.rs"] +mod tests; diff --git a/product-core/src/registry/check/shapes.rs b/product-core/src/registry/check/shapes.rs new file mode 100644 index 00000000..d9f098d7 --- /dev/null +++ b/product-core/src/registry/check/shapes.rs @@ -0,0 +1,221 @@ +//! Reading a defined subset of SHACL out of an instance's shapes files. +//! +//! The subset is: `sh:NodeShape` with `sh:targetClass`, `sh:property` carrying +//! `sh:path` with `sh:minCount` / `sh:maxCount` / `sh:nodeKind` / `sh:datatype` +//! / `sh:in` / `sh:message`, plus `sh:sparql` with `sh:select`. Every other +//! SHACL predicate encountered makes the check fail as *unevaluable* — the +//! reader never passes a shape it did not read. + +use crate::error::{ProductError, Result}; + +use super::{Finding, FindingKind}; + +const SH: &str = "http://www.w3.org/ns/shacl#"; +const SUPPORTED: &[&str] = &[ + "targetClass", "property", "sparql", "path", "minCount", "maxCount", "nodeKind", "datatype", + "in", "message", "select", "prefixes", +]; + +/// One evaluable constraint. +#[derive(Debug, Clone)] +pub(crate) struct Constraint { + /// The shape it came from. + pub shape: String, + /// The class it targets. + pub class: String, + /// The property path, where the constraint has one. + pub path: Option, + /// What it asserts. + pub rule: Rule, + /// The shape's own message. + pub message: String, +} + +/// The constraint kinds the reader evaluates. +#[derive(Debug, Clone)] +pub(crate) enum Rule { + /// At least this many values. + MinCount(u64), + /// At most this many values. + MaxCount(u64), + /// Every value is an IRI, a literal, or a blank node. + NodeKind(String), + /// Every value carries this datatype. + Datatype(String), + /// Every value is drawn from this set. + In(Vec), + /// A `sh:SPARQLConstraint` — the select returns one row per violation. + Sparql(String), +} + +/// What a shapes graph yielded. +#[derive(Debug, Default)] +pub(crate) struct Extracted { + /// The constraints the reader will evaluate. + pub constraints: Vec, + /// Shapes it refused to evaluate. + pub unevaluable: Vec, +} + +/// Read `shapes_ttl`, returning what can be evaluated with what cannot. +pub(crate) fn extract(shapes_ttl: &str) -> Result { + let mut out = Extracted { unevaluable: unsupported_predicates(shapes_ttl)?, ..Default::default() }; + out.constraints.extend(property_constraints(shapes_ttl, &mut out.unevaluable)?); + out.constraints.extend(sparql_constraints(shapes_ttl)?); + Ok(out) +} + +/// Fail-closed guard: every SHACL predicate used must be one the reader knows. +fn unsupported_predicates(ttl: &str) -> Result> { + let q = format!( + "SELECT DISTINCT ?p WHERE {{ ?s ?p ?o . FILTER(STRSTARTS(STR(?p), \"{SH}\")) }}" + ); + let rows = select(ttl, &q)?; + Ok(rows + .iter() + .filter_map(|r| r.get("p").map(|p| local(p))) + .filter(|name| !SUPPORTED.contains(&name.as_str())) + .map(|name| unevaluable(&format!("sh:{name}"), "unsupported SHACL construct — the shape was not evaluated")) + .collect()) +} + +/// The property-shape constraints, one per constraint kind. +fn property_constraints(ttl: &str, unread: &mut Vec) -> Result> { + let q = format!( + "PREFIX sh: <{SH}> + SELECT ?shape ?class ?path ?minCount ?maxCount ?nodeKind ?datatype ?message WHERE {{ + ?shape sh:targetClass ?class ; sh:property ?p . ?p sh:path ?path . + OPTIONAL {{ ?p sh:minCount ?minCount }} OPTIONAL {{ ?p sh:maxCount ?maxCount }} + OPTIONAL {{ ?p sh:nodeKind ?nodeKind }} OPTIONAL {{ ?p sh:datatype ?datatype }} + OPTIONAL {{ ?p sh:message ?message }} }}" + ); + let members = in_members(ttl)?; + let mut out = Vec::new(); + for row in select(ttl, &q)? { + let (shape, class, path) = match (row.get("shape"), row.get("class"), row.get("path")) { + (Some(s), Some(c), Some(p)) => (s.clone(), c.clone(), p.clone()), + _ => continue, + }; + if iri(&path).is_none() { + unread.push(unevaluable(&shape, "sh:path is not a plain IRI — property paths are not evaluated")); + continue; + } + let message = row.get("message").map(|m| lexical(m)).unwrap_or_default(); + let base = Constraint { shape, class, path: Some(path.clone()), rule: Rule::MinCount(0), message }; + push_kinds(&row, &members, &path, base, &mut out, unread); + } + Ok(out) +} + +/// Turn one result row into one constraint per kind it carries. +fn push_kinds( + row: &std::collections::BTreeMap, + members: &std::collections::BTreeMap>, + path: &str, + base: Constraint, + out: &mut Vec, + unread: &mut Vec, +) { + let mut with = |rule: Rule| out.push(Constraint { rule, ..base.clone() }); + if let Some(n) = row.get("minCount").and_then(|v| lexical(v).parse().ok()) { + with(Rule::MinCount(n)); + } + if let Some(n) = row.get("maxCount").and_then(|v| lexical(v).parse().ok()) { + with(Rule::MaxCount(n)); + } + if let Some(k) = row.get("nodeKind").map(|v| local(v)) { + if ["IRI", "Literal", "BlankNode"].contains(&k.as_str()) { + with(Rule::NodeKind(k)); + } else { + unread.push(unevaluable(&base.shape, &format!("sh:nodeKind sh:{k} is not evaluated"))); + } + } + if let Some(dt) = row.get("datatype").cloned() { + with(Rule::Datatype(dt)); + } + if let Some(list) = members.get(&key(&base.shape, path)) { + with(Rule::In(list.clone())); + } +} + +/// `sh:in` membership, keyed by shape + path so no blank-node label is relied on. +fn in_members(ttl: &str) -> Result>> { + let q = format!( + "PREFIX sh: <{SH}> + SELECT ?shape ?path ?member WHERE {{ + ?shape sh:property ?p . ?p sh:path ?path ; sh:in/*/ ?member }}" + ); + let mut map: std::collections::BTreeMap> = Default::default(); + for row in select(ttl, &q)? { + if let (Some(s), Some(p), Some(m)) = (row.get("shape"), row.get("path"), row.get("member")) { + map.entry(key(s, p)).or_default().push(m.clone()); + } + } + Ok(map) +} + +/// The `sh:sparql` constraints — a select returning one row per violation. +fn sparql_constraints(ttl: &str) -> Result> { + let q = format!( + "PREFIX sh: <{SH}> + SELECT ?shape ?class ?select ?message WHERE {{ + ?shape sh:targetClass ?class ; sh:sparql ?c . ?c sh:select ?select . + OPTIONAL {{ ?c sh:message ?message }} }}" + ); + Ok(select(ttl, &q)? + .iter() + .filter_map(|row| { + Some(Constraint { + shape: row.get("shape")?.clone(), + class: row.get("class")?.clone(), + path: None, + rule: Rule::Sparql(lexical(row.get("select")?)), + message: row.get("message").map(|m| lexical(m)).unwrap_or_default(), + }) + }) + .collect()) +} + +fn key(shape: &str, path: &str) -> String { + format!("{shape} {path}") +} + +fn unevaluable(focus: &str, message: &str) -> Finding { + Finding { + kind: FindingKind::Unevaluable, + file: None, + focus: focus.to_string(), + message: message.to_string(), + } +} + +fn select( + ttl: &str, + query: &str, +) -> Result>> { + crate::pf::sparql_rules::select(ttl, query) + .map_err(|e| ProductError::ConfigError(format!("registry check: shapes could not be read: {e}"))) +} + +/// The IRI inside a `<…>` term, if it is one. +pub(crate) fn iri(term: &str) -> Option<&str> { + term.strip_prefix('<')?.strip_suffix('>') +} + +/// A term's local name — after the last `#` or `/`. +pub(crate) fn local(term: &str) -> String { + let t = term.trim_start_matches('<').trim_end_matches('>'); + t.rsplit(['#', '/']).next().unwrap_or(t).to_string() +} + +/// A literal term's lexical form, without quotes, datatype, language tag. +pub(crate) fn lexical(term: &str) -> String { + let t = term.trim(); + if !t.starts_with('"') { + return t.to_string(); + } + match t.rfind('"') { + Some(end) if end > 0 => t[1..end].replace("\\\"", "\"").replace("\\n", "\n"), + _ => t.to_string(), + } +} diff --git a/product-core/src/registry/mod.rs b/product-core/src/registry/mod.rs new file mode 100644 index 00000000..52ea2f68 --- /dev/null +++ b/product-core/src/registry/mod.rs @@ -0,0 +1,27 @@ +//! Ground-registry instance generation from the versioned registry template. +//! +//! The generator mints a registry instance: it renders the template's files +//! with typed parameters, gates the result, writes the tree, records the birth +//! provenance in the first commit. Generation is local — publishing a tree to a +//! remote is a separate act that lives outside this module (see `apply`). +//! +//! The slice is pure apart from the apply step, which performs the writes. +//! +//! The public surface is the re-export list below, deliberately: the modules +//! are crate-internal, so a caller depends on the acts (plan, apply, check) and +//! never on how the template is rendered or how the gate is spelled. + +pub(crate) mod apply; +pub(crate) mod check; +pub(crate) mod params; +pub(crate) mod plan; +pub(crate) mod substitute; +pub(crate) mod template; +pub(crate) mod verify; + +pub use apply::{apply_generation, GenerationReport}; +pub use check::{check_instance, evaluate, CheckReport, Finding, FindingKind}; +pub use params::RegistryParams; +pub use plan::{plan_generation, GenerationPlan, PlannedFile}; +pub use substitute::Site; +pub use template::TEMPLATE_VERSION; diff --git a/product-core/src/registry/params.rs b/product-core/src/registry/params.rs new file mode 100644 index 00000000..a474abb5 --- /dev/null +++ b/product-core/src/registry/params.rs @@ -0,0 +1,96 @@ +//! Typed generation parameters for a registry instance. +//! +//! Validation here is about *meaning* — a date that is a date, an IRI the RDF +//! parser accepts — never about which characters a value may contain. A value +//! carrying `&`, `$`, a backslash or a brace is data the renderer copies +//! verbatim; rejecting sigils would be an escaping rule in disguise, and an +//! escaping rule is what corrupted the first generated instance. + +use crate::error::{ProductError, Result}; + +/// The parameters a registry instance is minted from. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +pub struct RegistryParams { + /// The owning organisation (`--owner`). + pub owner: String, + /// The repository name (`--repo`). + pub repo: String, + /// The named person whose merge is ratification (`--ratifier`). + pub ratifier: String, + /// The registry's display name (`--display-name`). + pub display_name: String, + /// The base IRI every identifier is minted under (`--base-iri`). + pub base_iri: String, + /// The mint date, `YYYY-MM-DD` (`--date`) — when the authority was + /// demonstrably controlled, not when the tree was written. + pub mint_date: String, + /// Who ran the generation (`--generated-by`), recorded in the provenance. + pub generated_by: String, +} + +impl RegistryParams { + /// Check every parameter carries a usable meaning. Returns the first fault. + pub fn validate(&self) -> Result<()> { + self.check_present()?; + self.check_name("--owner", &self.owner)?; + self.check_name("--repo", &self.repo)?; + check_date(&self.mint_date)?; + check_base_iri(&self.base_iri) + } + + fn check_present(&self) -> Result<()> { + let fields = [ + ("--owner", &self.owner), + ("--repo", &self.repo), + ("--ratifier", &self.ratifier), + ("--display-name", &self.display_name), + ("--base-iri", &self.base_iri), + ("--date", &self.mint_date), + ("--generated-by", &self.generated_by), + ]; + for (flag, value) in fields { + if value.trim().is_empty() { + return Err(fault(&format!("{flag} is empty — every parameter reaches the birth provenance, so none may be blank"))); + } + } + Ok(()) + } + + fn check_name(&self, flag: &str, value: &str) -> Result<()> { + if value.chars().any(|c| c.is_whitespace() || c == '/') { + return Err(fault(&format!( + "{flag} '{value}' carries whitespace or a path separator — it names one segment of a repository path" + ))); + } + Ok(()) + } +} + +fn check_date(value: &str) -> Result<()> { + chrono::NaiveDate::parse_from_str(value, "%Y-%m-%d") + .map(|_| ()) + .map_err(|_| fault(&format!("--date '{value}' is not a YYYY-MM-DD calendar date"))) +} + +/// Ask the real RDF parser whether the base IRI can carry identifiers — no +/// regex, no character allow-list. +fn check_base_iri(value: &str) -> Result<()> { + if !(value.ends_with('#') || value.ends_with('/')) { + return Err(fault(&format!( + "--base-iri '{value}' must end in '#' or '/' — identifiers are minted directly beneath it" + ))); + } + let probe = format!("@prefix probe: <{value}> .\nprobe:subject probe:predicate probe:object .\n"); + crate::pf::sparql_rules::select(&probe, "SELECT * WHERE { ?s ?p ?o }").map_err(|e| { + fault(&format!("--base-iri '{value}' is not an IRI the RDF parser accepts: {e}")) + })?; + Ok(()) +} + +fn fault(message: &str) -> ProductError { + ProductError::ConfigError(format!("registry parameter: {message}")) +} + +#[cfg(test)] +#[path = "params_tests.rs"] +mod tests; diff --git a/product-core/src/registry/params_tests.rs b/product-core/src/registry/params_tests.rs new file mode 100644 index 00000000..b07a8f36 --- /dev/null +++ b/product-core/src/registry/params_tests.rs @@ -0,0 +1,66 @@ +//! Unit tests for parameter validation. + +use super::*; + +fn params() -> RegistryParams { + RegistryParams { + owner: "Hafeok".into(), + repo: "ground-registry-g0".into(), + ratifier: "emil@okkels-klein.dk".into(), + display_name: "Ground Registry (G0 validation)".into(), + base_iri: "tag:emil@okkels-klein.dk,2026-08-17:ground/".into(), + mint_date: "2026-08-17".into(), + generated_by: "the registry generator".into(), + } +} + +#[test] +fn the_g0_parameters_validate() { + assert!(params().validate().is_ok()); +} + +#[test] +fn sigils_in_values_are_not_rejected() { + // Validation is about meaning, never about which characters a value carries. + let mut p = params(); + p.ratifier = "a&b\\1$0@example.test".into(); + p.display_name = "R&D — \"the\" registry".into(); + assert!(p.validate().is_ok()); +} + +#[test] +fn an_empty_parameter_is_refused() { + let mut p = params(); + p.generated_by = " ".into(); + let err = p.validate().expect_err("empty parameter must be refused"); + assert!(err.to_string().contains("--generated-by"), "{err}"); +} + +#[test] +fn a_non_date_is_refused() { + let mut p = params(); + p.mint_date = "17-08-2026".into(); + assert!(p.validate().is_err()); +} + +#[test] +fn a_base_iri_without_a_terminator_is_refused() { + let mut p = params(); + p.base_iri = "https://example.test/ns".into(); + let err = p.validate().expect_err("must end in # or /"); + assert!(err.to_string().contains("--base-iri"), "{err}"); +} + +#[test] +fn a_base_iri_the_parser_rejects_is_refused() { + let mut p = params(); + p.base_iri = "not an iri at all/".into(); + assert!(p.validate().is_err()); +} + +#[test] +fn a_repository_name_with_a_separator_is_refused() { + let mut p = params(); + p.repo = "Hafeok/ground-registry-g0".into(); + assert!(p.validate().is_err()); +} diff --git a/product-core/src/registry/plan.rs b/product-core/src/registry/plan.rs new file mode 100644 index 00000000..7c0b10f4 --- /dev/null +++ b/product-core/src/registry/plan.rs @@ -0,0 +1,111 @@ +//! Planning a generation: rendering the template, then holding it at the gate. +//! +//! `plan_generation` is pure — it returns the whole tree in memory, already +//! verified. Nothing reaches disk until [`super::apply`] is called with the +//! plan, so a gate failure leaves no directory behind at all. + +use crate::error::Result; + +use super::params::RegistryParams; +use super::substitute::{render, Site, Token}; +use super::template::{BASE_IRI_TOKEN, TEMPLATE, TEMPLATE_VERSION}; +use super::verify::{gate, GateReport, Gated}; + +/// One file of a planned instance. +#[derive(Debug, Clone, serde::Serialize)] +pub struct PlannedFile { + /// Path relative to the instance root. + pub path: String, + /// The rendered contents. + pub contents: String, + /// Whether the file is written executable. + pub executable: bool, + /// Spans the renderer wrote parameter values into. + pub sites: Vec, +} + +/// A verified instance, held in memory. +#[derive(Debug, serde::Serialize)] +pub struct GenerationPlan { + /// The parameters it was minted from. + pub params: RegistryParams, + /// The template version the tree came from. + pub template_version: String, + /// The generator version that rendered it. + pub generator_version: String, + /// Every file, in template order. + pub files: Vec, + /// What the gate saw. + pub gate: GateReport, +} + +/// Render the template with `params` and run the gate. Returns the verified +/// tree, or the first fault — in which case nothing has been written. +pub fn plan_generation(params: &RegistryParams, generator_version: &str) -> Result { + params.validate()?; + let tokens = token_table(params, generator_version); + let files = render_files(&tokens); + let report = gate(&gated(&files), &tokens)?; + Ok(GenerationPlan { + params: params.clone(), + template_version: TEMPLATE_VERSION.to_string(), + generator_version: generator_version.to_string(), + files, + gate: report, + }) +} + +/// Render every template file, leaving the verbatim ones untouched. +fn render_files(tokens: &[Token]) -> Vec { + TEMPLATE + .iter() + .map(|f| { + let rendered = if f.substituted { + render(f.text, tokens) + } else { + super::substitute::Rendered { text: f.text.to_string(), sites: Vec::new() } + }; + PlannedFile { + path: f.path.to_string(), + contents: rendered.text, + executable: f.executable, + sites: rendered.sites, + } + }) + .collect() +} + +/// Pair each rendered file with the template text it came from, for the gate. +fn gated<'a>(files: &'a [PlannedFile]) -> Vec> { + TEMPLATE + .iter() + .zip(files.iter()) + .map(|(t, f)| Gated { + path: &f.path, + source: t.text, + rendered: &f.contents, + sites: &f.sites, + substituted: t.substituted, + }) + .collect() +} + +/// The closed substitution table. Every placeholder the template carries has an +/// entry here; a placeholder without one fails the gate rather than shipping. +pub(crate) fn token_table<'a>(params: &'a RegistryParams, generator_version: &'a str) -> Vec> { + vec![ + Token { label: "--owner", token: "{{OWNER_ORG}}", value: ¶ms.owner }, + Token { label: "--repo", token: "{{REPO_NAME}}", value: ¶ms.repo }, + Token { label: "--ratifier", token: "{{RATIFIER}}", value: ¶ms.ratifier }, + Token { label: "--display-name", token: "{{DISPLAY_NAME}}", value: ¶ms.display_name }, + Token { label: "--date", token: "{{MINT_DATE}}", value: ¶ms.mint_date }, + Token { label: "--generated-by", token: "{{GENERATED_BY}}", value: ¶ms.generated_by }, + Token { label: "template version", token: "{{TEMPLATE_VERSION}}", value: TEMPLATE_VERSION }, + Token { label: "generator version", token: "{{GENERATOR_VERSION}}", value: generator_version }, + Token { label: "--base-iri", token: BASE_IRI_TOKEN, value: ¶ms.base_iri }, + ] +} + +#[cfg(test)] +#[path = "plan_tests.rs"] +mod tests; diff --git a/product-core/src/registry/plan_tests.rs b/product-core/src/registry/plan_tests.rs new file mode 100644 index 00000000..52c27aa3 --- /dev/null +++ b/product-core/src/registry/plan_tests.rs @@ -0,0 +1,90 @@ +//! Unit tests for planning a generation. + +use super::*; +use crate::registry::params::RegistryParams; + +/// The parameters the discarded G0 instance was minted from, carried forward +/// verbatim from the ratified handoff. +fn g0_params() -> RegistryParams { + RegistryParams { + owner: "Hafeok".into(), + repo: "ground-registry-g0".into(), + ratifier: "emil@okkels-klein.dk".into(), + display_name: "Ground Registry (G0 validation)".into(), + base_iri: "tag:emil@okkels-klein.dk,2026-08-17:ground/".into(), + mint_date: "2026-08-17".into(), + generated_by: "product-cli registry generator".into(), + } +} + +fn plan() -> GenerationPlan { + plan_generation(&g0_params(), "0.6.0").expect("the template renders under the gate") +} + +fn file<'a>(p: &'a GenerationPlan, path: &str) -> &'a str { + &p.files.iter().find(|f| f.path == path).expect("file in plan").contents +} + +#[test] +fn the_template_renders_under_the_gate() { + let p = plan(); + assert_eq!(p.files.len(), TEMPLATE.len()); + assert_eq!(p.template_version, TEMPLATE_VERSION); +} + +#[test] +fn every_parameter_reaches_the_tree() { + let p = plan(); + for label in ["--owner", "--repo", "--ratifier", "--display-name", "--base-iri", "--date", "--generated-by"] { + assert!(p.gate.sites_per_label[label] >= 1, "{label} reached no file"); + } +} + +#[test] +fn the_base_iri_is_byte_identical_in_the_output() { + let p = plan(); + let generation = file(&p, "GENERATION.ttl"); + assert!(generation.contains("@prefix reg: .")); + assert!(generation.contains("reg:baseIri \"tag:emil@okkels-klein.dk,2026-08-17:ground/\" ;")); +} + +#[test] +fn template_md_travels_unsubstituted() { + let p = plan(); + let embedded = TEMPLATE.iter().find(|f| f.path == "TEMPLATE.md").expect("TEMPLATE.md"); + assert_eq!(file(&p, "TEMPLATE.md"), embedded.text); + assert!(file(&p, "TEMPLATE.md").contains("{{OWNER_ORG}}"), "the instance keeps its own record of the placeholders"); +} + +#[test] +fn no_placeholder_survives_a_substituted_file() { + let p = plan(); + for f in p.files.iter().filter(|f| f.path != "TEMPLATE.md") { + assert_eq!(crate::registry::verify::first_placeholder(&f.contents), None, "{}", f.path); + } +} + +#[test] +fn the_birth_provenance_records_both_versions() { + let p = plan(); + let ttl = file(&p, "GENERATION.ttl"); + assert!(ttl.contains(&format!("reg:templateVersion \"{TEMPLATE_VERSION}\""))); + assert!(ttl.contains("reg:generatorVersion \"0.6.0\"")); +} + +#[test] +fn a_value_carrying_sigils_survives_planning() { + let mut params = g0_params(); + params.display_name = "R&D \\1 $0 — the {{OWNER_ORG}} registry".into(); + let p = plan_generation(¶ms, "0.6.0").expect("hostile values are data"); + assert!(file(&p, "GENERATION.ttl").contains("R&D \\1 $0 — the {{OWNER_ORG}} registry")); +} + +#[test] +fn planning_writes_nothing() { + // The plan is held in memory; only `apply_generation` touches disk. A gate + // failure therefore leaves no tree at all. + let mut params = g0_params(); + params.base_iri = "https://example.test/ns".into(); + assert!(plan_generation(¶ms, "0.6.0").is_err()); +} diff --git a/product-core/src/registry/substitute.rs b/product-core/src/registry/substitute.rs new file mode 100644 index 00000000..2424307b --- /dev/null +++ b/product-core/src/registry/substitute.rs @@ -0,0 +1,102 @@ +//! Single-pass token renderer that never re-reads its own output. +//! +//! The scanner walks the input once. At each character boundary it matches the +//! longest token from a closed table; on a match it copies the parameter's +//! bytes to the output, records the span it wrote, then advances the cursor +//! past the token. The cursor never moves backwards, so emitted values are +//! never scanned again. +//! +//! The invariant, stated so a test can assert it: *the output is the +//! concatenation of literal spans of the input with byte-for-byte copies of +//! parameter values.* A value carrying `&`, `\1`, `$0` or a literal +//! `{{OWNER_ORG}}` is therefore data, not a further instruction. Sequential +//! `str::replace` does not have this property — a value inserted by an earlier +//! replacement sits in the buffer that later replacements scan. + +/// One entry of the substitution table: the literal token as it appears in the +/// template, the value that replaces it, and the label the gate reports it by. +#[derive(Debug, Clone, Copy)] +pub(crate) struct Token<'a> { + /// The parameter's reporting name (`--base-iri`, `{{OWNER_ORG}}`, …). + pub label: &'a str, + /// The literal text matched in the template. + pub token: &'a str, + /// The bytes emitted in its place. + pub value: &'a str, +} + +/// Where one parameter value was written into a rendered file. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +pub struct Site { + /// Which parameter was written. + pub label: String, + /// Byte offset of the value in the rendered text. + pub offset: usize, + /// Byte length of the value written. + pub len: usize, +} + +/// A rendered file: its text, plus every span the renderer wrote a value into. +#[derive(Debug, Clone)] +pub(crate) struct Rendered { + /// The rendered text. + pub text: String, + /// One entry per emitted value, in output order. + pub sites: Vec, +} + +/// Render `input`, substituting every token occurrence exactly once. +pub(crate) fn render(input: &str, tokens: &[Token]) -> Rendered { + let mut order: Vec<&Token> = tokens.iter().collect(); + order.sort_by_key(|t| std::cmp::Reverse(t.token.len())); + + let mut out = String::with_capacity(input.len()); + let mut sites = Vec::new(); + let mut literal_from = 0usize; + let mut cursor = 0usize; + + while cursor < input.len() { + if !input.is_char_boundary(cursor) { + cursor += 1; + continue; + } + let rest = &input[cursor..]; + match order.iter().find(|t| rest.starts_with(t.token)) { + Some(t) => { + out.push_str(&input[literal_from..cursor]); + sites.push(Site { + label: t.label.to_string(), + offset: out.len(), + len: t.value.len(), + }); + out.push_str(t.value); + cursor += t.token.len(); + literal_from = cursor; + } + None => cursor += 1, + } + } + out.push_str(&input[literal_from..]); + Rendered { text: out, sites } +} + +/// Fail-closed guard: no token may be a substring of another. Overlapping +/// tokens would make the scanner's matches and a naive occurrence count +/// disagree, which is exactly what the gate cross-checks. +pub(crate) fn tokens_are_disjoint(tokens: &[Token]) -> Result<(), String> { + for a in tokens { + for b in tokens { + if !std::ptr::eq(a, b) && a.token.contains(b.token) { + return Err(format!( + "token '{}' contains token '{}' — the substitution table must be disjoint", + a.token, b.token + )); + } + } + } + Ok(()) +} + +#[cfg(test)] +#[path = "substitute_tests.rs"] +mod tests; diff --git a/product-core/src/registry/substitute_tests.rs b/product-core/src/registry/substitute_tests.rs new file mode 100644 index 00000000..9c116079 --- /dev/null +++ b/product-core/src/registry/substitute_tests.rs @@ -0,0 +1,69 @@ +//! Unit tests for the single-pass renderer. + +use super::*; + +fn tok<'a>(label: &'a str, token: &'a str, value: &'a str) -> Token<'a> { + Token { label, token, value } +} + +#[test] +fn substitutes_every_occurrence_once() { + let out = render("a {{X}} b {{X}}", &[tok("--x", "{{X}}", "V")]); + assert_eq!(out.text, "a V b V"); + assert_eq!(out.sites.len(), 2); +} + +#[test] +fn recorded_sites_address_the_written_value() { + let out = render("owner: {{X}}!", &[tok("--x", "{{X}}", "Ø-value")]); + let site = &out.sites[0]; + assert_eq!(&out.text[site.offset..site.offset + site.len], "Ø-value"); +} + +#[test] +fn a_value_that_looks_like_a_token_is_not_re_read() { + let tokens = [tok("--x", "{{X}}", "{{Y}}"), tok("--y", "{{Y}}", "SECOND")]; + let out = render("{{X}}", &tokens); + assert_eq!(out.text, "{{Y}}", "an emitted value must never be scanned again"); +} + +#[test] +fn sequential_replace_would_have_re_read_it() { + // The mechanism this renderer replaces, shown failing on the same input. + let naive = "{{X}}".replace("{{X}}", "{{Y}}").replace("{{Y}}", "SECOND"); + assert_eq!(naive, "SECOND"); + assert_eq!(render("{{X}}", &[tok("--x", "{{X}}", "{{Y}}"), tok("--y", "{{Y}}", "SECOND")]).text, "{{Y}}"); +} + +#[test] +fn regex_and_shell_sigils_are_data() { + for value in ["&", "\\1", "$0", "a|b", "a/b", "back\\slash", "$(whoami)", "`id`"] { + let out = render("[{{X}}]", &[tok("--x", "{{X}}", value)]); + assert_eq!(out.text, format!("[{value}]"), "sigil {value:?} was not carried verbatim"); + } +} + +#[test] +fn multibyte_text_around_a_token_survives() { + let out = render("— {{X}} — æøå", &[tok("--x", "{{X}}", "værdi")]); + assert_eq!(out.text, "— værdi — æøå"); +} + +#[test] +fn longest_token_wins() { + let tokens = [tok("short", "{{X}}", "S"), tok("long", "{{X}}Y", "L")]; + assert_eq!(render("{{X}}Y", &tokens).text, "L"); +} + +#[test] +fn disjointness_is_enforced() { + let tokens = [tok("short", "{{X}}", "S"), tok("long", "{{X}}Y", "L")]; + assert!(tokens_are_disjoint(&tokens).is_err()); + assert!(tokens_are_disjoint(&[tok("a", "{{A}}", "1"), tok("b", "{{B}}", "2")]).is_ok()); +} + +#[test] +fn text_without_tokens_is_unchanged() { + let input = "no placeholders here {{lowercase}} $ & \\"; + assert_eq!(render(input, &[tok("--x", "{{X}}", "V")]).text, input); +} diff --git a/product-core/src/registry/template.rs b/product-core/src/registry/template.rs new file mode 100644 index 00000000..9bfd523a --- /dev/null +++ b/product-core/src/registry/template.rs @@ -0,0 +1,67 @@ +//! The registry template, embedded from the repository at compile time. +//! +//! The template's home stays `docs/g-track/registry-template/` — the path the +//! PRD and every generated instance's provenance pin. Nothing is vendored into +//! this crate: a second copy would be a projection presenting as a source. The +//! manifest below is hand-maintained, with a drift test that fails when the +//! tree on disk holds a file the manifest does not. + +/// The template's own version, as declared in its `TEMPLATE.md` header. A test +/// fails if the two drift apart. +pub const TEMPLATE_VERSION: &str = "0.2.0"; + +/// The base-IRI placeholder. G-1 Gate 3 kept it IRI-valid so the template's own +/// Turtle parses; it is a token like any other, not an exception. +pub(crate) const BASE_IRI_TOKEN: &str = "https://REGISTRY-HOST.example/ns#"; + +/// The host substring the gate scans for. Any survivor means a base IRI reached +/// the output unsubstituted. +pub(crate) const HOST_SENTINEL: &str = "REGISTRY-HOST.example"; + +/// One file of the template. +pub(crate) struct TemplateFile { + /// Path relative to the instance root. + pub path: &'static str, + /// The file's contents as they stand in the template. + pub text: &'static str, + /// Whether the file is written with the executable bit set. + pub executable: bool, + /// Whether parameters are substituted into it. `TEMPLATE.md` travels + /// verbatim: it is the instance's record of what its placeholders were, + /// which is how it re-pins when the template moves. + pub substituted: bool, +} + +macro_rules! template_file { + ($path:literal, $exec:literal, $subst:literal) => { + TemplateFile { + path: $path, + text: include_str!(concat!("../../../docs/g-track/registry-template/", $path)), + executable: $exec, + substituted: $subst, + } + }; +} + +/// Every file a generated instance carries. +pub(crate) const TEMPLATE: &[TemplateFile] = &[ + template_file!("TEMPLATE.md", false, false), + template_file!("README.md", false, true), + template_file!("GENERATION.ttl", false, true), + template_file!(".github/workflows/validate.yml", false, true), + template_file!("graphs/canonical/_exemplar.ttl", false, true), + template_file!("graphs/canonical/_exemplar-decision.ttl", false, true), + template_file!("scripts/build-projection.sh", true, true), + template_file!("shapes/decision.ttl", false, true), + template_file!("shapes/reading.ttl", false, true), + template_file!("shapes/structural.ttl", false, true), +]; + +/// The template's source directory, relative to the workspace root — where the +/// drift test walks. Test-only: the embedded manifest is what generation reads. +#[cfg(test)] +pub(crate) const TEMPLATE_DIR: &str = "docs/g-track/registry-template"; + +#[cfg(test)] +#[path = "template_tests.rs"] +mod tests; diff --git a/product-core/src/registry/template_tests.rs b/product-core/src/registry/template_tests.rs new file mode 100644 index 00000000..8989524b --- /dev/null +++ b/product-core/src/registry/template_tests.rs @@ -0,0 +1,59 @@ +//! Drift tests binding the embedded template to the tree on disk. + +use super::*; +use std::path::PathBuf; + +fn template_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..").join(TEMPLATE_DIR) +} + +#[test] +fn the_embedded_manifest_matches_the_tree_on_disk() { + let dir = template_dir(); + let mut on_disk = Vec::new(); + walk(&dir, &dir, &mut on_disk); + on_disk.sort(); + let mut embedded: Vec = TEMPLATE.iter().map(|f| f.path.to_string()).collect(); + embedded.sort(); + assert_eq!( + on_disk, embedded, + "the template tree and the embedded manifest disagree — wire the new file into template.rs" + ); +} + +#[test] +fn embedded_bytes_match_the_files_on_disk() { + for f in TEMPLATE { + let disk = std::fs::read_to_string(template_dir().join(f.path)).expect("read template file"); + assert_eq!(disk, f.text, "{} drifted from its embedded copy", f.path); + } +} + +#[test] +fn the_version_constant_matches_the_template_header() { + let header = std::fs::read_to_string(template_dir().join("TEMPLATE.md")).expect("read TEMPLATE.md"); + let declared = header + .lines() + .find_map(|l| l.strip_prefix("**Template version: ")) + .and_then(|l| l.split('*').next()) + .map(|v| v.trim().to_string()) + .expect("TEMPLATE.md declares a version"); + assert_eq!(declared, TEMPLATE_VERSION); +} + +#[test] +fn template_md_is_the_only_verbatim_file() { + let verbatim: Vec<&str> = TEMPLATE.iter().filter(|f| !f.substituted).map(|f| f.path).collect(); + assert_eq!(verbatim, vec!["TEMPLATE.md"]); +} + +fn walk(root: &std::path::Path, dir: &std::path::Path, out: &mut Vec) { + for entry in std::fs::read_dir(dir).expect("read template dir").flatten() { + let path = entry.path(); + if path.is_dir() { + walk(root, &path, out); + } else if let Ok(rel) = path.strip_prefix(root) { + out.push(rel.display().to_string()); + } + } +} diff --git a/product-core/src/registry/verify.rs b/product-core/src/registry/verify.rs new file mode 100644 index 00000000..6e7e60b4 --- /dev/null +++ b/product-core/src/registry/verify.rs @@ -0,0 +1,178 @@ +//! The generation gate, run before a single byte reaches disk. +//! +//! Two checks. **A** — every parameter round-trips byte-identically: the +//! renderer recorded the span it wrote each value into, the gate reads those +//! spans back out of the rendered text, and the count of spans is cross-checked +//! against a naive occurrence count over the template. **B** — no placeholder +//! survives anywhere in the tree. +//! +//! A failure is a refusal, not a warning: `plan_generation` returns the error +//! and nothing is written, so a corrupted parameter can never reach a tree. +//! Check A is what would have caught the first instance's mangled base IRI. + +use std::collections::BTreeMap; + +use crate::error::{ProductError, Result}; + +use super::substitute::{tokens_are_disjoint, Site, Token}; +use super::template::HOST_SENTINEL; + +/// One rendered file as the gate sees it. +pub(crate) struct Gated<'a> { + /// Path relative to the instance root. + pub path: &'a str, + /// The template's own text for this file. + pub source: &'a str, + /// The rendered text. + pub rendered: &'a str, + /// Spans the renderer wrote values into. + pub sites: &'a [Site], + /// Whether parameters were substituted into this file. + pub substituted: bool, +} + +/// What the gate saw: how many sites each parameter reached. +#[derive(Debug, Default, serde::Serialize)] +pub struct GateReport { + /// Sites per parameter label. + pub sites_per_label: BTreeMap, +} + +/// Run both checks over a rendered tree. +pub(crate) fn gate(files: &[Gated], tokens: &[Token]) -> Result { + tokens_are_disjoint(tokens).map_err(|e| fault(&e))?; + let report = check_round_trip(files, tokens)?; + check_no_survivors(files)?; + Ok(report) +} + +/// Check A — every emitted value is byte-identical to its parameter, every +/// token occurrence in the template produced exactly one emission, and every +/// parameter reached the output at least once. +fn check_round_trip(files: &[Gated], tokens: &[Token]) -> Result { + let by_label: BTreeMap<&str, &Token> = tokens.iter().map(|t| (t.label, t)).collect(); + let mut seen: BTreeMap = tokens.iter().map(|t| (t.label.to_string(), 0)).collect(); + + for f in files { + for site in f.sites { + let token = by_label + .get(site.label.as_str()) + .ok_or_else(|| fault(&format!("{}: value for unknown parameter '{}'", f.path, site.label)))?; + let end = site.offset + site.len; + let written = f.rendered.get(site.offset..end).ok_or_else(|| { + fault(&format!("{}: '{}' was written past the end of the file", f.path, site.label)) + })?; + if written != token.value { + return Err(fault(&format!( + "{}: '{}' did not round-trip — supplied {:?}, output holds {:?}", + f.path, site.label, token.value, written + ))); + } + *seen.entry(site.label.clone()).or_default() += 1; + } + } + check_counts(files, tokens, &seen)?; + Ok(GateReport { sites_per_label: seen }) +} + +/// Cross-check the renderer's own tally against a naive occurrence count over +/// the template sources — two mechanisms counting the same thing. +fn check_counts(files: &[Gated], tokens: &[Token], seen: &BTreeMap) -> Result<()> { + for token in tokens { + let expected: usize = files + .iter() + .filter(|f| f.substituted) + .map(|f| f.source.matches(token.token).count()) + .sum(); + let got = seen.get(token.label).copied().unwrap_or(0); + if got != expected { + return Err(fault(&format!( + "'{}' was written {got} time(s), the template holds {expected} occurrence(s) of {}", + token.label, token.token + ))); + } + if got == 0 { + return Err(fault(&format!( + "'{}' reaches no file in the generated tree — a parameter that lands nowhere cannot be verified", + token.label + ))); + } + } + Ok(()) +} + +/// Check B — no `{{PLACEHOLDER}}` and no base-IRI sentinel survives in a +/// substituted file; a verbatim file is byte-identical to the template's. +fn check_no_survivors(files: &[Gated]) -> Result<()> { + for f in files { + if !f.substituted { + if f.rendered != f.source { + return Err(fault(&format!("{}: travels verbatim, but the output differs from the template", f.path))); + } + continue; + } + let template_text = mask_values(f.rendered, f.sites); + if let Some(found) = first_placeholder(&template_text) { + return Err(fault(&format!( + "{}: placeholder {found} survived — every placeholder needs a typed parameter", + f.path + ))); + } + if template_text.contains(HOST_SENTINEL) { + return Err(fault(&format!( + "{}: the base-IRI placeholder host '{HOST_SENTINEL}' survived", + f.path + ))); + } + } + Ok(()) +} + +/// Everything in a rendered file *except* the spans parameter values were +/// written into — i.e. the template's own text as it survived rendering. +/// +/// Check B reads this rather than the raw output, which keeps the two checks +/// coherent: a parameter's bytes are data, checked for round-trip by Check A, +/// and anything placeholder-shaped **outside** a value is still an unwired +/// placeholder and still aborts. A display name may honestly contain +/// `{{OWNER_ORG}}`; a template file may not. +fn mask_values(rendered: &str, sites: &[Site]) -> String { + let mut out = String::with_capacity(rendered.len()); + let mut cursor = 0usize; + for site in sites { + if site.offset < cursor { + continue; + } + out.push_str(rendered.get(cursor..site.offset).unwrap_or_default()); + out.push('\n'); + cursor = site.offset + site.len; + } + out.push_str(rendered.get(cursor..).unwrap_or_default()); + out +} + +/// The first `{{IDENT}}` in `text`, if any. +pub(crate) fn first_placeholder(text: &str) -> Option { + let mut rest = text; + while let Some(start) = rest.find("{{") { + let after = &rest[start + 2..]; + if let Some(end) = after.find("}}") { + let ident = &after[..end]; + if !ident.is_empty() + && ident.chars().all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_') + { + return Some(format!("{{{{{ident}}}}}")); + } + } + rest = &rest[start + 2..]; + } + None +} + +fn fault(message: &str) -> ProductError { + ProductError::ConfigError(format!("generation gate: {message}")) +} + +#[cfg(test)] +#[path = "verify_tests.rs"] +mod tests; diff --git a/product-core/src/registry/verify_tests.rs b/product-core/src/registry/verify_tests.rs new file mode 100644 index 00000000..2b5ecbb6 --- /dev/null +++ b/product-core/src/registry/verify_tests.rs @@ -0,0 +1,109 @@ +//! Unit tests for the generation gate. + +use super::*; +use crate::registry::substitute::render; + +fn tokens<'a>(value: &'a str) -> Vec> { + vec![Token { label: "--owner", token: "{{OWNER_ORG}}", value }] +} + +fn gated<'a>(path: &'a str, source: &'a str, rendered: &'a str, sites: &'a [Site]) -> Gated<'a> { + Gated { path, source, rendered, sites, substituted: true } +} + +#[test] +fn a_clean_render_passes() { + let source = "owner: {{OWNER_ORG}}\n"; + let t = tokens("Hafeok"); + let out = render(source, &t); + let report = gate(&[gated("README.md", source, &out.text, &out.sites)], &t).expect("gate"); + assert_eq!(report.sites_per_label["--owner"], 1); +} + +#[test] +fn a_mangled_value_is_caught() { + let source = "owner: {{OWNER_ORG}}\n"; + let t = tokens("tag:emil@okkels-klein.dk,2026-08-17:ground/"); + let out = render(source, &t); + // Simulate the G0 failure: the value in the output is not the value supplied. + let corrupted = out.text.replace("okkels-klein.dk", "okkels-klein.dkokkels-klein.dk"); + let err = gate(&[gated("README.md", source, &corrupted, &out.sites)], &t) + .expect_err("a corrupted value must abort generation"); + assert!(err.to_string().contains("did not round-trip"), "{err}"); +} + +#[test] +fn a_surviving_placeholder_is_caught() { + let source = "owner: {{OWNER_ORG}} ratifier: {{RATIFIER}}\n"; + let t = tokens("Hafeok"); + let out = render(source, &t); + let err = gate(&[gated("README.md", source, &out.text, &out.sites)], &t) + .expect_err("an unwired placeholder must abort generation"); + assert!(err.to_string().contains("{{RATIFIER}}"), "{err}"); +} + +#[test] +fn a_surviving_host_sentinel_is_caught() { + let source = "prefix: {{OWNER_ORG}} \n"; + let t = tokens("Hafeok"); + let out = render(source, &t); + let err = gate(&[gated("x.ttl", source, &out.text, &out.sites)], &t) + .expect_err("a surviving base-IRI host must abort generation"); + assert!(err.to_string().contains("REGISTRY-HOST.example"), "{err}"); +} + +#[test] +fn a_parameter_that_reaches_nothing_is_caught() { + let source = "nothing to substitute\n"; + let t = tokens("Hafeok"); + let out = render(source, &t); + let err = gate(&[gated("README.md", source, &out.text, &out.sites)], &t) + .expect_err("a parameter landing nowhere cannot be verified"); + assert!(err.to_string().contains("reaches no file"), "{err}"); +} + +#[test] +fn an_edited_verbatim_file_is_caught() { + let source = "travels verbatim {{OWNER_ORG}}\n"; + let t = tokens("Hafeok"); + let subst = render("owner {{OWNER_ORG}}", &t); + let files = [ + gated("README.md", "owner {{OWNER_ORG}}", &subst.text, &subst.sites), + Gated { path: "TEMPLATE.md", source, rendered: "edited", sites: &[], substituted: false }, + ]; + let err = gate(&files, &t).expect_err("a verbatim file must travel unchanged"); + assert!(err.to_string().contains("verbatim"), "{err}"); +} + +#[test] +fn a_value_containing_a_placeholder_is_data() { + // Check B reads the template text that survived rendering, not the values + // written into it: a display name may honestly carry `{{RATIFIER}}`. + let source = "owner: {{OWNER_ORG}}\n"; + let t = tokens("{{RATIFIER}}"); + let out = render(source, &t); + gate(&[gated("README.md", source, &out.text, &out.sites)], &t) + .expect("a parameter value is data, never a further placeholder"); +} + +#[test] +fn a_value_carrying_the_sentinel_host_is_data() { + let source = "@prefix reg: .\n{{OWNER_ORG}}\n"; + let t = vec![ + Token { label: "--owner", token: "{{OWNER_ORG}}", value: "Hafeok" }, + Token { + label: "--base-iri", + token: "https://REGISTRY-HOST.example/ns#", + value: "https://REGISTRY-HOST.example.test/ns#", + }, + ]; + let out = render(source, &t); + gate(&[gated("x.ttl", source, &out.text, &out.sites)], &t) + .expect("a supplied base IRI is the parameter, however it reads"); +} + +#[test] +fn placeholder_scan_ignores_non_placeholder_braces() { + assert_eq!(first_placeholder("{{ not a placeholder }} {{lower}}"), None); + assert_eq!(first_placeholder("x {{OWNER_ORG}} y").as_deref(), Some("{{OWNER_ORG}}")); +}