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}}"));
+}