From cdb571ce5ac4cb27150222044044b65d258ff7a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 07:58:05 +0000 Subject: [PATCH 1/9] =?UTF-8?q?docs(g-track):=20registry=20generator=20?= =?UTF-8?q?=E2=80=94=20Gate=201=20design=20proposal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The G0 instance was generated by a shell one-liner; a sigil in a parameter value was re-read as code and the corrupted base IRI propagated into every file, birth provenance included. The base IRI has no supersession path. Proposes `product registry generate` / `product registry check`: typed arguments, a single-pass renderer that copies values without ever re-scanning its own output, an abort-before-writing verification gate (parameter round-trip byte-identical, zero surviving placeholders), a parameterised GENERATION.ttl recording generator version alongside template version, and the generate/publish boundary that keeps generation offline and testable. Design only — no implementation. Gates green: cargo t, clippy, xtask check. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HeV8CH1FZLGoSES6gFMGHw --- .../registry-generator-design-2026-08-18.md | 380 ++++++++++++++++++ 1 file changed, 380 insertions(+) create mode 100644 docs/g-track/registry-generator-design-2026-08-18.md 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.** From c30a885bd3aaa3c547afd0a28f11e67fed8df4b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 08:41:00 +0000 Subject: [PATCH 2/9] feat(registry): the registry generator, with the fixtures that are its point MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `product registry generate` mints a ground-registry instance from the versioned template; `product registry check` reads a generated instance against its own rules. The mechanism, per the Gate 1 design: - Typed arguments, no substitution table. A single-pass renderer walks the template once, copies each value's bytes, records the span it wrote, then advances past the token. The cursor never moves backwards and the output is never re-scanned, so a value carrying `&`, `\1`, `$0` or a literal `{{OWNER_ORG}}` is data. A unit test shows sequential `str::replace` failing on the same input. - The gate runs on the in-memory tree before the first byte reaches disk. Check A reads the recorded spans back out and cross-checks the tally against a naive occurrence count over the template. Check B scans the template text that survived rendering — values masked out — for surviving placeholders and for the base-IRI sentinel. A failure is a refusal: no directory is created at all. - Generation is local. `git init` plus one birth commit with inline identity, no system or global config, dates pinned to the mint date, so the same parameters produce the same tree and the same commit id. No remote is configured; the publish commands are printed for a human to run. - `check` evaluates a defined SHACL subset compiled to SPARQL over oxigraph, reading the instance's own shapes files. Any construct outside the subset is reported as unevaluable and fails the check, in its own section, never silently skipped. Template changes the mechanism forced (the rest are held for Gate 3): - GENERATION.ttl is now a parameterised Turtle file, so the birth provenance is produced by the mechanism rather than typed beside it. It records the generator's version alongside the template's, both parameters and agent. - The exemplar's attribution IRI moves into the instance namespace: Check B caught a second host occurrence that base-IRI substitution alone would have left corrupt. - The empty founding-decision slot is dropped, per the Q1 ruling — the slot is a path the ratifier creates, not a file needing a CI carve-out. 69 new tests: 51 unit, 17 generation fixtures, 1 pySHACL divergence measure (both readers agree across six cases, including the sh:sparql constraint). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HeV8CH1FZLGoSES6gFMGHw --- docs/g-track/registry-template/GENERATION.ttl | 56 ++-- .../graphs/canonical/_exemplar.ttl | 2 +- .../graphs/canonical/founding-decision.ttl | 7 - product-cli/src/commands/dispatch.rs | 4 +- product-cli/src/commands/mod.rs | 2 + product-cli/src/commands/registry.rs | 196 ++++++++++++++ product-cli/src/commands/root_enum.rs | 5 + product-cli/tests/registry_generator.rs | 246 ++++++++++++++++++ .../tests/registry_shacl_divergence.rs | 113 ++++++++ product-cli/tests/registry_support/mod.rs | 165 ++++++++++++ product-core/src/lib.rs | 1 + product-core/src/registry/apply.rs | 200 ++++++++++++++ .../src/registry/check/check_tests.rs | 165 ++++++++++++ product-core/src/registry/check/eval.rs | 84 ++++++ product-core/src/registry/check/file_rule.rs | 39 +++ product-core/src/registry/check/mod.rs | 148 +++++++++++ product-core/src/registry/check/shapes.rs | 221 ++++++++++++++++ product-core/src/registry/mod.rs | 23 ++ product-core/src/registry/params.rs | 96 +++++++ product-core/src/registry/params_tests.rs | 66 +++++ product-core/src/registry/plan.rs | 111 ++++++++ product-core/src/registry/plan_tests.rs | 90 +++++++ product-core/src/registry/substitute.rs | 102 ++++++++ product-core/src/registry/substitute_tests.rs | 69 +++++ product-core/src/registry/template.rs | 64 +++++ product-core/src/registry/template_tests.rs | 59 +++++ product-core/src/registry/verify.rs | 178 +++++++++++++ product-core/src/registry/verify_tests.rs | 109 ++++++++ 28 files changed, 2595 insertions(+), 26 deletions(-) delete mode 100644 docs/g-track/registry-template/graphs/canonical/founding-decision.ttl create mode 100644 product-cli/src/commands/registry.rs create mode 100644 product-cli/tests/registry_generator.rs create mode 100644 product-cli/tests/registry_shacl_divergence.rs create mode 100644 product-cli/tests/registry_support/mod.rs create mode 100644 product-core/src/registry/apply.rs create mode 100644 product-core/src/registry/check/check_tests.rs create mode 100644 product-core/src/registry/check/eval.rs create mode 100644 product-core/src/registry/check/file_rule.rs create mode 100644 product-core/src/registry/check/mod.rs create mode 100644 product-core/src/registry/check/shapes.rs create mode 100644 product-core/src/registry/mod.rs create mode 100644 product-core/src/registry/params.rs create mode 100644 product-core/src/registry/params_tests.rs create mode 100644 product-core/src/registry/plan.rs create mode 100644 product-core/src/registry/plan_tests.rs create mode 100644 product-core/src/registry/substitute.rs create mode 100644 product-core/src/registry/substitute_tests.rs create mode 100644 product-core/src/registry/template.rs create mode 100644 product-core/src/registry/template_tests.rs create mode 100644 product-core/src/registry/verify.rs create mode 100644 product-core/src/registry/verify_tests.rs 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/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/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..ed104259 --- /dev/null +++ b/product-cli/tests/registry_generator.rs @@ -0,0 +1,246 @@ +//! 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()); + assert!(inst.read("README.md").contains("emil@okkels-klein.dk")); + assert!(!inst.read("README.md").contains("{{RATIFIER}}")); + let base = "tag:emil@okkels-klein.dk,2026-08-17:ground/"; + for file in inst.files() { + let text = inst.read(&file); + if file != "TEMPLATE.md" && text.contains("tag:") { + assert!(text.contains(base), "{file} carries a mangled base IRI"); + } + } +} + +#[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"], "\"0.1.0\""); + 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..dae30cbd --- /dev/null +++ b/product-cli/tests/registry_shacl_divergence.rs @@ -0,0 +1,113 @@ +//! 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{}", inst.read("shapes/reading.ttl"), inst.read("shapes/structural.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")), + ( + "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..4e0b37c3 --- /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 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 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 fn read(rel: &str) -> String { + std::fs::read_to_string(workspace_root().join(rel)).expect("read workspace file") +} + +/// What a command run produced. +pub struct Run { + pub stdout: String, + pub stderr: String, + pub exit_code: i32, +} + +/// One generation's parameters. +#[derive(Clone)] +pub struct Args { + pub owner: String, + pub repo: String, + pub ratifier: String, + pub display_name: String, + pub base_iri: String, + pub date: String, + pub generated_by: String, +} + +/// The parameters the G0 handoff pins — the same five, the same mint date. +pub 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 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 struct Instance { + dir: tempfile::TempDir, + stdout: String, +} + +/// Generate an instance, asserting the run succeeded. +pub 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 fn path(&self) -> PathBuf { + self.dir.path().join("instance") + } + + /// The directory the instance was generated into. + pub fn parent(&self) -> PathBuf { + self.dir.path().to_path_buf() + } + + /// What the generation printed. + pub fn stdout(&self) -> &str { + &self.stdout + } + + /// One of the instance's files. + pub fn read(&self, rel: &str) -> String { + std::fs::read_to_string(self.path().join(rel)).expect("read instance file") + } + + /// Every tracked file, sorted. + pub 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 fn commit(&self) -> String { + self.git(&["rev-parse", "HEAD"]).trim().to_string() + } + + /// Run git inside the instance. + pub 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 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..43812343 --- /dev/null +++ b/product-core/src/registry/check/check_tests.rs @@ -0,0 +1,165 @@ +//! 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() { + // The decision shape lands in the template at Gate 3; the reader evaluates + // it here to prove the mechanism, against the same constraint vocabulary. + let shapes = format!( + "@prefix sh: .\n@prefix reg: <{BASE}> .\n\ + reg:DecisionShape a sh:NodeShape ; sh:targetClass reg:Decision ;\n\ + sh:property [ sh:path reg:title ; sh:minCount 1 ; sh:message \"A decision carries a title.\" ] ;\n\ + sh:property [ sh:path reg:falsifier ; sh:minCount 1 ; sh:message \"A decision carries a falsifier.\" ] .\n" + ); + let data = format!("@prefix reg: <{BASE}> .\nreg:d-1 a reg:Decision ; reg:title \"Keep a registry\" .\n"); + let extracted = shapes::extract(&shapes).expect("extract"); + let findings = eval::run(&data, &extracted.constraints).expect("evaluate"); + assert!(findings.iter().any(|f| f.message.contains("falsifier")), "{findings:?}"); + assert!(!findings.iter().any(|f| f.message.contains("title")), "{findings:?}"); +} + +#[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..31eee514 --- /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, Kind}; +use super::{Finding, FindingKind}; + +/// Run every constraint over the data graph, returning one finding per row. +pub 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 fn compile(c: &Constraint) -> String { + let class = &c.class; + let path = c.path.clone().unwrap_or_default(); + match &c.kind { + Kind::Sparql(select) => select.clone(), + Kind::MinCount(n) => format!( + "SELECT ?this WHERE {{ ?this a {class} . OPTIONAL {{ ?this {path} ?v }} }} \ + GROUP BY ?this HAVING (COUNT(?v) < {n})" + ), + Kind::MaxCount(n) => format!( + "SELECT ?this WHERE {{ ?this a {class} . OPTIONAL {{ ?this {path} ?v }} }} \ + GROUP BY ?this HAVING (COUNT(?v) > {n})" + ), + Kind::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}) }}") + } + Kind::Datatype(dt) => format!( + "SELECT ?this WHERE {{ ?this a {class} ; {path} ?v . \ + FILTER(!isLiteral(?v) || datatype(?v) != {dt}) }}" + ), + Kind::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.kind { + Kind::MinCount(n) => format!("{path}: fewer than {n} value(s)"), + Kind::MaxCount(n) => format!("{path}: more than {n} value(s)"), + Kind::NodeKind(k) => format!("{path}: a value is not a {k}"), + Kind::Datatype(dt) => format!("{path}: a value is not a {}", local(dt)), + Kind::In(_) => format!("{path}: a value is outside the permitted set"), + Kind::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..88fd3e6e --- /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 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..2383ec1f --- /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 mod eval; +pub mod file_rule; +pub 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 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 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..d57e8109 --- /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 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 kind: Kind, + /// The shape's own message. + pub message: String, +} + +/// The constraint kinds the reader evaluates. +#[derive(Debug, Clone)] +pub enum Kind { + /// 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 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 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()), kind: Kind::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 = |kind: Kind| out.push(Constraint { kind, ..base.clone() }); + if let Some(n) = row.get("minCount").and_then(|v| lexical(v).parse().ok()) { + with(Kind::MinCount(n)); + } + if let Some(n) = row.get("maxCount").and_then(|v| lexical(v).parse().ok()) { + with(Kind::MaxCount(n)); + } + if let Some(k) = row.get("nodeKind").map(|v| local(v)) { + if ["IRI", "Literal", "BlankNode"].contains(&k.as_str()) { + with(Kind::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(Kind::Datatype(dt)); + } + if let Some(list) = members.get(&key(&base.shape, path)) { + with(Kind::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, + kind: Kind::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 fn iri(term: &str) -> Option<&str> { + term.strip_prefix('<')?.strip_suffix('>') +} + +/// A term's local name — after the last `#` or `/`. +pub 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 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..38d97e6c --- /dev/null +++ b/product-core/src/registry/mod.rs @@ -0,0 +1,23 @@ +//! 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 [`apply`], which performs the writes. + +pub mod apply; +pub mod check; +pub mod params; +pub mod plan; +pub mod substitute; +pub mod template; +pub 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::{render, Site, Token}; +pub use template::{BASE_IRI_TOKEN, HOST_SENTINEL, 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..1e3ca905 --- /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 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..fb88056c --- /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. +pub 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..7e4f5405 --- /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 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 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 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 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..53f6fa44 --- /dev/null +++ b/product-core/src/registry/template.rs @@ -0,0 +1,64 @@ +//! 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.1.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 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 const HOST_SENTINEL: &str = "REGISTRY-HOST.example"; + +/// One file of the template. +pub 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 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!("scripts/build-projection.sh", true, 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. +pub 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..88f5bd29 --- /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 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 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 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}}")); +} From 23f8e1965c0bceb71f08a8b89607f97abda42840 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 10:57:10 +0000 Subject: [PATCH 3/9] =?UTF-8?q?feat(registry-template):=20fold=20in=20the?= =?UTF-8?q?=20ratified=20changes;=20template=200.1.0=20=E2=86=92=200.2.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One bump covering every template 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:ratifiedBy` is left unconstrained as to node kind — whether a ratifier is named by literal or IRI is an instance's ruling. - graphs/canonical/_exemplar-decision.ttl: a conforming example, so a ratifier learns the form from a file rather than from SHACL. It shows the optional fields precisely because they are optional. - The CI file rule is 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 slot. - TEMPLATE.md step 1 no longer reads both ways: this file travels verbatim, unsubstituted, and that is what lets an instance re-pin. The generation steps now describe the generator, the gate, the local/publish split, and the founding-decision slot as a path rather than a shipped file. - The base-IRI row documents both routes with their trade-offs: a durable host, or location-independent minting per g-dec-03 with the HTTP form published later as a projection. Neither is the default; the choice is made deliberately or deferred deliberately. - README follows: the slot as a path, the decision shape, the file rule, and why the base IRI's route is a filed decision rather than a fact recoverable from the IRI. Verified on a fresh instance by the instance's own CI steps run verbatim — pyshacl -a over shapes/, and the workflow's rdflib file-rule script — as well as by `product registry check`. The pySHACL divergence measure now covers the decision shape, including sh:datatype: nine cases, no disagreement. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HeV8CH1FZLGoSES6gFMGHw --- .../.github/workflows/validate.yml | 40 ++++++---- docs/g-track/registry-template/README.md | 21 +++-- docs/g-track/registry-template/TEMPLATE.md | 80 +++++++++++++++---- .../graphs/canonical/_exemplar-decision.ttl | 25 ++++++ .../registry-template/shapes/decision.ttl | 62 ++++++++++++++ product-cli/tests/registry_generator.rs | 19 +++-- .../tests/registry_shacl_divergence.rs | 24 +++++- .../src/registry/check/check_tests.rs | 54 ++++++++++--- product-core/src/registry/template.rs | 4 +- 9 files changed, 274 insertions(+), 55 deletions(-) create mode 100644 docs/g-track/registry-template/graphs/canonical/_exemplar-decision.ttl create mode 100644 docs/g-track/registry-template/shapes/decision.ttl 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/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/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/tests/registry_generator.rs b/product-cli/tests/registry_generator.rs index ed104259..123bb099 100644 --- a/product-cli/tests/registry_generator.rs +++ b/product-cli/tests/registry_generator.rs @@ -51,14 +51,21 @@ fn the_g0_handoff_parameters_mint_the_handed_over_instance() { #[test] fn g0_regression_a_sigil_in_the_ratifiers_address_survives() { let inst = generate(&g0_args()); - assert!(inst.read("README.md").contains("emil@okkels-klein.dk")); - assert!(!inst.read("README.md").contains("{{RATIFIER}}")); 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() { - let text = inst.read(&file); - if file != "TEMPLATE.md" && text.contains("tag:") { - assert!(text.contains(base), "{file} carries a mangled base IRI"); + 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" + ); } } @@ -117,7 +124,7 @@ fn the_birth_provenance_is_complete_as_rdf() { assert_eq!(rows.len(), 1, "one registry node, fully described: {rows:?}"); let row = &rows[0]; assert_eq!(row["owner"], "\"Hafeok\""); - assert_eq!(row["tv"], "\"0.1.0\""); + 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"]); } diff --git a/product-cli/tests/registry_shacl_divergence.rs b/product-cli/tests/registry_shacl_divergence.rs index dae30cbd..fb776d85 100644 --- a/product-cli/tests/registry_shacl_divergence.rs +++ b/product-cli/tests/registry_shacl_divergence.rs @@ -62,7 +62,12 @@ fn the_native_reader_agrees_with_pyshacl() { return; } let inst = generate(&g0_args()); - let shapes = format!("{}\n{}", inst.read("shapes/reading.ttl"), inst.read("shapes/structural.ttl")); + 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![ @@ -74,6 +79,23 @@ fn the_native_reader_agrees_with_pyshacl() { 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!( diff --git a/product-core/src/registry/check/check_tests.rs b/product-core/src/registry/check/check_tests.rs index 43812343..2cbfa8af 100644 --- a/product-core/src/registry/check/check_tests.rs +++ b/product-core/src/registry/check/check_tests.rs @@ -116,21 +116,55 @@ fn an_empty_file_fails_the_file_rule() { #[test] fn a_decision_missing_a_required_field_fails() { - // The decision shape lands in the template at Gate 3; the reader evaluates - // it here to prove the mechanism, against the same constraint vocabulary. - let shapes = format!( - "@prefix sh: .\n@prefix reg: <{BASE}> .\n\ - reg:DecisionShape a sh:NodeShape ; sh:targetClass reg:Decision ;\n\ - sh:property [ sh:path reg:title ; sh:minCount 1 ; sh:message \"A decision carries a title.\" ] ;\n\ - sh:property [ sh:path reg:falsifier ; sh:minCount 1 ; sh:message \"A decision carries a falsifier.\" ] .\n" + 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 data = format!("@prefix reg: <{BASE}> .\nreg:d-1 a reg:Decision ; reg:title \"Keep a registry\" .\n"); - let extracted = shapes::extract(&shapes).expect("extract"); - let findings = eval::run(&data, &extracted.constraints).expect("evaluate"); + 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!( diff --git a/product-core/src/registry/template.rs b/product-core/src/registry/template.rs index 53f6fa44..41d85df9 100644 --- a/product-core/src/registry/template.rs +++ b/product-core/src/registry/template.rs @@ -8,7 +8,7 @@ /// 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.1.0"; +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. @@ -50,7 +50,9 @@ pub const TEMPLATE: &[TemplateFile] = &[ 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), ]; From a7936d653b16e11679d932e7f965cd82c4ab70ae Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 11:23:02 +0000 Subject: [PATCH 4/9] =?UTF-8?q?docs(g-track):=20registry=20generator=20?= =?UTF-8?q?=E2=80=94=20session=20report=20and=20manifest?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the four-gate session: the finding that paid for the mechanism, the subcommand surface and the generate/publish split, the single-pass renderer, the gate's two checks with the value-span masking rule, the fixture results, the template at 0.2.0, the pySHACL divergence measurement, and what G0 must do differently when it resumes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HeV8CH1FZLGoSES6gFMGHw --- .../registry-generator-session-2026-08-18.md | 239 ++++++++++++++++++ 1 file changed, 239 insertions(+) create mode 100644 docs/g-track/registry-generator-session-2026-08-18.md 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..0cd6c553 --- /dev/null +++ b/docs/g-track/registry-generator-session-2026-08-18.md @@ -0,0 +1,239 @@ +# 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 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. From 8e83cecb4f22e5f08650f1209c5c28e79b7c0c84 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 11:37:27 +0000 Subject: [PATCH 5/9] refactor(registry): narrow the slice's public surface to its acts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The contract-surface gate counted 151 governed changes, which is a fair reading of what was published: every internal type of the renderer, the gate, the shapes reader stood as public API, plus a test-support module that is scaffolding rather than surface. The slice's public surface is now its re-export list — the acts (plan, apply, check, evaluate) with the types they carry. Submodules are crate-internal, so a caller depends on the acts and never on how the template is rendered or how the gate is spelled. Test support is pub(crate) within its own test binary. No behaviour change; all registry tests pass unchanged. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HeV8CH1FZLGoSES6gFMGHw --- product-cli/tests/registry_support/mod.rs | 54 ++++++++++---------- product-core/src/registry/check/eval.rs | 4 +- product-core/src/registry/check/file_rule.rs | 2 +- product-core/src/registry/check/mod.rs | 10 ++-- product-core/src/registry/check/shapes.rs | 14 ++--- product-core/src/registry/mod.rs | 24 +++++---- product-core/src/registry/plan.rs | 2 +- product-core/src/registry/plan_tests.rs | 2 +- product-core/src/registry/substitute.rs | 8 +-- product-core/src/registry/template.rs | 10 ++-- product-core/src/registry/verify.rs | 6 +-- 11 files changed, 70 insertions(+), 66 deletions(-) diff --git a/product-cli/tests/registry_support/mod.rs b/product-cli/tests/registry_support/mod.rs index 4e0b37c3..abbf2020 100644 --- a/product-cli/tests/registry_support/mod.rs +++ b/product-cli/tests/registry_support/mod.rs @@ -6,7 +6,7 @@ use std::path::{Path, PathBuf}; use std::process::Command; /// The `product` binary the fixtures drive. -pub fn bin() -> PathBuf { +pub(crate) fn bin() -> PathBuf { let mut path = std::env::current_exe().expect("current_exe"); path.pop(); path.pop(); @@ -18,36 +18,36 @@ pub fn bin() -> PathBuf { } /// The workspace root, for reading the template's own files. -pub fn workspace_root() -> PathBuf { +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 fn read(rel: &str) -> String { +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 struct Run { - pub stdout: String, - pub stderr: String, - pub exit_code: i32, +pub(crate) struct Run { + pub(crate) stdout: String, + pub(crate) stderr: String, + pub(crate) exit_code: i32, } /// One generation's parameters. #[derive(Clone)] -pub struct Args { - pub owner: String, - pub repo: String, - pub ratifier: String, - pub display_name: String, - pub base_iri: String, - pub date: String, - pub generated_by: String, +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 fn g0_args() -> Args { +pub(crate) fn g0_args() -> Args { Args { owner: "Hafeok".into(), repo: "ground-registry-g0".into(), @@ -61,7 +61,7 @@ pub fn g0_args() -> Args { impl Args { /// Run `product registry generate` into `out`, returning what it printed. - pub fn run(&self, out: &Path) -> Run { + pub(crate) fn run(&self, out: &Path) -> Run { let output = Command::new(bin()) .args(["registry", "generate"]) .args(["--owner", &self.owner]) @@ -85,13 +85,13 @@ impl Args { /// A generated instance in its own temporary directory. Dropping it removes /// the directory, which is the whole of the generation's footprint. -pub struct Instance { +pub(crate) struct Instance { dir: tempfile::TempDir, stdout: String, } /// Generate an instance, asserting the run succeeded. -pub fn generate(args: &Args) -> Instance { +pub(crate) fn generate(args: &Args) -> Instance { let dir = tempfile::tempdir().expect("tempdir"); let out = dir.path().join("instance"); let run = args.run(&out); @@ -101,27 +101,27 @@ pub fn generate(args: &Args) -> Instance { impl Instance { /// The instance root. - pub fn path(&self) -> PathBuf { + pub(crate) fn path(&self) -> PathBuf { self.dir.path().join("instance") } /// The directory the instance was generated into. - pub fn parent(&self) -> PathBuf { + pub(crate) fn parent(&self) -> PathBuf { self.dir.path().to_path_buf() } /// What the generation printed. - pub fn stdout(&self) -> &str { + pub(crate) fn stdout(&self) -> &str { &self.stdout } /// One of the instance's files. - pub fn read(&self, rel: &str) -> String { + 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 fn files(&self) -> Vec { + pub(crate) fn files(&self) -> Vec { let mut out: Vec = self .git(&["ls-files"]) .lines() @@ -133,12 +133,12 @@ impl Instance { } /// The birth commit's object id. - pub fn commit(&self) -> String { + pub(crate) fn commit(&self) -> String { self.git(&["rev-parse", "HEAD"]).trim().to_string() } /// Run git inside the instance. - pub fn git(&self, args: &[&str]) -> String { + pub(crate) fn git(&self, args: &[&str]) -> String { let out = Command::new("git") .arg("-C") .arg(self.path()) @@ -150,7 +150,7 @@ impl Instance { } /// Run `product registry check` over the instance. - pub fn check(&self) -> Run { + pub(crate) fn check(&self) -> Run { let output = Command::new(bin()) .args(["registry", "check"]) .arg(self.path()) diff --git a/product-core/src/registry/check/eval.rs b/product-core/src/registry/check/eval.rs index 31eee514..36b35168 100644 --- a/product-core/src/registry/check/eval.rs +++ b/product-core/src/registry/check/eval.rs @@ -10,7 +10,7 @@ use super::shapes::{local, Constraint, Kind}; use super::{Finding, FindingKind}; /// Run every constraint over the data graph, returning one finding per row. -pub fn run(data_ttl: &str, constraints: &[Constraint]) -> Result> { +pub(crate) fn run(data_ttl: &str, constraints: &[Constraint]) -> Result> { let mut out = Vec::new(); for c in constraints { let query = compile(c); @@ -34,7 +34,7 @@ pub fn run(data_ttl: &str, constraints: &[Constraint]) -> Result> { } /// The SELECT one constraint becomes. -pub fn compile(c: &Constraint) -> String { +pub(crate) fn compile(c: &Constraint) -> String { let class = &c.class; let path = c.path.clone().unwrap_or_default(); match &c.kind { diff --git a/product-core/src/registry/check/file_rule.rs b/product-core/src/registry/check/file_rule.rs index 88fd3e6e..901399dd 100644 --- a/product-core/src/registry/check/file_rule.rs +++ b/product-core/src/registry/check/file_rule.rs @@ -9,7 +9,7 @@ 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 fn check_file(path: &str, text: &str, base: &str) -> Vec { +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> }} }}" ); diff --git a/product-core/src/registry/check/mod.rs b/product-core/src/registry/check/mod.rs index 2383ec1f..78d18a17 100644 --- a/product-core/src/registry/check/mod.rs +++ b/product-core/src/registry/check/mod.rs @@ -14,9 +14,9 @@ //! The divergence is a standing finding, measured by a fixture that runs both //! when pySHACL is present rather than assumed small. -pub mod eval; -pub mod file_rule; -pub mod shapes; +pub(crate) mod eval; +pub(crate) mod file_rule; +pub(crate) mod shapes; use std::path::Path; @@ -103,7 +103,7 @@ pub fn evaluate(data_ttl: &str, shapes_ttl: &str) -> Result<(Vec, usize /// 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 fn base_iri(dir: &Path) -> Result { +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())))?; @@ -112,7 +112,7 @@ pub fn base_iri(dir: &Path) -> Result { } /// The IRI a Turtle document binds a prefix to, via the parser's own prefix map. -pub fn prefix_binding(turtle: &str, prefix: &str) -> Option { +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 { diff --git a/product-core/src/registry/check/shapes.rs b/product-core/src/registry/check/shapes.rs index d57e8109..8562bc7f 100644 --- a/product-core/src/registry/check/shapes.rs +++ b/product-core/src/registry/check/shapes.rs @@ -18,7 +18,7 @@ const SUPPORTED: &[&str] = &[ /// One evaluable constraint. #[derive(Debug, Clone)] -pub struct Constraint { +pub(crate) struct Constraint { /// The shape it came from. pub shape: String, /// The class it targets. @@ -33,7 +33,7 @@ pub struct Constraint { /// The constraint kinds the reader evaluates. #[derive(Debug, Clone)] -pub enum Kind { +pub(crate) enum Kind { /// At least this many values. MinCount(u64), /// At most this many values. @@ -50,7 +50,7 @@ pub enum Kind { /// What a shapes graph yielded. #[derive(Debug, Default)] -pub struct Extracted { +pub(crate) struct Extracted { /// The constraints the reader will evaluate. pub constraints: Vec, /// Shapes it refused to evaluate. @@ -58,7 +58,7 @@ pub struct Extracted { } /// Read `shapes_ttl`, returning what can be evaluated with what cannot. -pub fn extract(shapes_ttl: &str) -> Result { +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)?); @@ -198,18 +198,18 @@ fn select( } /// The IRI inside a `<…>` term, if it is one. -pub fn iri(term: &str) -> Option<&str> { +pub(crate) fn iri(term: &str) -> Option<&str> { term.strip_prefix('<')?.strip_suffix('>') } /// A term's local name — after the last `#` or `/`. -pub fn local(term: &str) -> String { +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 fn lexical(term: &str) -> String { +pub(crate) fn lexical(term: &str) -> String { let t = term.trim(); if !t.starts_with('"') { return t.to_string(); diff --git a/product-core/src/registry/mod.rs b/product-core/src/registry/mod.rs index 38d97e6c..52ea2f68 100644 --- a/product-core/src/registry/mod.rs +++ b/product-core/src/registry/mod.rs @@ -5,19 +5,23 @@ //! 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 [`apply`], which performs the writes. +//! 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 mod apply; -pub mod check; -pub mod params; -pub mod plan; -pub mod substitute; -pub mod template; -pub mod verify; +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::{render, Site, Token}; -pub use template::{BASE_IRI_TOKEN, HOST_SENTINEL, TEMPLATE, TEMPLATE_VERSION}; +pub use substitute::Site; +pub use template::TEMPLATE_VERSION; diff --git a/product-core/src/registry/plan.rs b/product-core/src/registry/plan.rs index 1e3ca905..7c0b10f4 100644 --- a/product-core/src/registry/plan.rs +++ b/product-core/src/registry/plan.rs @@ -92,7 +92,7 @@ fn gated<'a>(files: &'a [PlannedFile]) -> Vec> { /// The closed substitution table. Every placeholder the template carries has an /// entry here; a placeholder without one fails the gate rather than shipping. -pub fn token_table<'a>(params: &'a RegistryParams, generator_version: &'a str) -> Vec> { +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 }, diff --git a/product-core/src/registry/plan_tests.rs b/product-core/src/registry/plan_tests.rs index fb88056c..52c27aa3 100644 --- a/product-core/src/registry/plan_tests.rs +++ b/product-core/src/registry/plan_tests.rs @@ -5,7 +5,7 @@ use crate::registry::params::RegistryParams; /// The parameters the discarded G0 instance was minted from, carried forward /// verbatim from the ratified handoff. -pub fn g0_params() -> RegistryParams { +fn g0_params() -> RegistryParams { RegistryParams { owner: "Hafeok".into(), repo: "ground-registry-g0".into(), diff --git a/product-core/src/registry/substitute.rs b/product-core/src/registry/substitute.rs index 7e4f5405..2424307b 100644 --- a/product-core/src/registry/substitute.rs +++ b/product-core/src/registry/substitute.rs @@ -16,7 +16,7 @@ /// 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 struct Token<'a> { +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. @@ -38,7 +38,7 @@ pub struct Site { /// A rendered file: its text, plus every span the renderer wrote a value into. #[derive(Debug, Clone)] -pub struct Rendered { +pub(crate) struct Rendered { /// The rendered text. pub text: String, /// One entry per emitted value, in output order. @@ -46,7 +46,7 @@ pub struct Rendered { } /// Render `input`, substituting every token occurrence exactly once. -pub fn render(input: &str, tokens: &[Token]) -> Rendered { +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())); @@ -83,7 +83,7 @@ pub fn render(input: &str, tokens: &[Token]) -> Rendered { /// 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 fn tokens_are_disjoint(tokens: &[Token]) -> Result<(), String> { +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) { diff --git a/product-core/src/registry/template.rs b/product-core/src/registry/template.rs index 41d85df9..e6fce411 100644 --- a/product-core/src/registry/template.rs +++ b/product-core/src/registry/template.rs @@ -12,14 +12,14 @@ 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 const BASE_IRI_TOKEN: &str = "https://REGISTRY-HOST.example/ns#"; +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 const HOST_SENTINEL: &str = "REGISTRY-HOST.example"; +pub(crate) const HOST_SENTINEL: &str = "REGISTRY-HOST.example"; /// One file of the template. -pub struct TemplateFile { +pub(crate) struct TemplateFile { /// Path relative to the instance root. pub path: &'static str, /// The file's contents as they stand in the template. @@ -44,7 +44,7 @@ macro_rules! template_file { } /// Every file a generated instance carries. -pub const TEMPLATE: &[TemplateFile] = &[ +pub(crate) const TEMPLATE: &[TemplateFile] = &[ template_file!("TEMPLATE.md", false, false), template_file!("README.md", false, true), template_file!("GENERATION.ttl", false, true), @@ -59,7 +59,7 @@ pub const TEMPLATE: &[TemplateFile] = &[ /// The template's source directory, relative to the workspace root — where the /// drift test walks. -pub const TEMPLATE_DIR: &str = "docs/g-track/registry-template"; +pub(crate) const TEMPLATE_DIR: &str = "docs/g-track/registry-template"; #[cfg(test)] #[path = "template_tests.rs"] diff --git a/product-core/src/registry/verify.rs b/product-core/src/registry/verify.rs index 88f5bd29..6e7e60b4 100644 --- a/product-core/src/registry/verify.rs +++ b/product-core/src/registry/verify.rs @@ -18,7 +18,7 @@ use super::substitute::{tokens_are_disjoint, Site, Token}; use super::template::HOST_SENTINEL; /// One rendered file as the gate sees it. -pub struct Gated<'a> { +pub(crate) struct Gated<'a> { /// Path relative to the instance root. pub path: &'a str, /// The template's own text for this file. @@ -39,7 +39,7 @@ pub struct GateReport { } /// Run both checks over a rendered tree. -pub fn gate(files: &[Gated], tokens: &[Token]) -> Result { +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)?; @@ -152,7 +152,7 @@ fn mask_values(rendered: &str, sites: &[Site]) -> String { } /// The first `{{IDENT}}` in `text`, if any. -pub fn first_placeholder(text: &str) -> Option { +pub(crate) fn first_placeholder(text: &str) -> Option { let mut rest = text; while let Some(start) = rest.find("{{") { let after = &rest[start + 2..]; From 8882d61daef3cf1531c2872490d0146cab67ce26 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 11:42:32 +0000 Subject: [PATCH 6/9] ddd: file the declarations the contract-surface gate demands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ten seams over the registry generator's surface, each carrying what a caller actually learns at that boundary and what the boundary cannot do — the apply seam states that publishing is not among its powers and that COMMIT_EMAIL is a reserved .invalid address rather than an identity; the check seam states its own limit, that it is a second reader of rules the instance's CI runs under pySHACL and the divergence is measured rather than assumed; the params seam states that validation judges meaning and never character safety, because a character rule is an escaping rule and an escaping rule is what corrupted the first generated instance. 59 bindings filed by `ddd bind` over 61222d2..HEAD. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HeV8CH1FZLGoSES6gFMGHw --- ...-product-cli-src-commands-registry-rs.yaml | 26 ++++ ...product-cli-src-commands-root-enum-rs.yaml | 14 +++ .../seam-rust-product-core-src-lib-rs.yaml | 14 +++ ...st-product-core-src-registry-apply-rs.yaml | 50 ++++++++ ...roduct-core-src-registry-check-mod-rs.yaml | 116 ++++++++++++++++++ ...t-product-core-src-registry-params-rs.yaml | 68 ++++++++++ ...ust-product-core-src-registry-plan-rs.yaml | 80 ++++++++++++ ...oduct-core-src-registry-substitute-rs.yaml | 32 +++++ ...product-core-src-registry-template-rs.yaml | 14 +++ ...t-product-core-src-registry-verify-rs.yaml | 20 +++ 10 files changed, 434 insertions(+) create mode 100644 .ddd/seams/seam-rust-product-cli-src-commands-registry-rs.yaml create mode 100644 .ddd/seams/seam-rust-product-cli-src-commands-root-enum-rs.yaml create mode 100644 .ddd/seams/seam-rust-product-core-src-lib-rs.yaml create mode 100644 .ddd/seams/seam-rust-product-core-src-registry-apply-rs.yaml create mode 100644 .ddd/seams/seam-rust-product-core-src-registry-check-mod-rs.yaml create mode 100644 .ddd/seams/seam-rust-product-core-src-registry-params-rs.yaml create mode 100644 .ddd/seams/seam-rust-product-core-src-registry-plan-rs.yaml create mode 100644 .ddd/seams/seam-rust-product-core-src-registry-substitute-rs.yaml create mode 100644 .ddd/seams/seam-rust-product-core-src-registry-template-rs.yaml create mode 100644 .ddd/seams/seam-rust-product-core-src-registry-verify-rs.yaml 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..390c15f7 --- /dev/null +++ b/.ddd/seams/seam-rust-product-core-src-registry-template-rs.yaml @@ -0,0 +1,14 @@ +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 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 From 0e17d182f1b67c4c91dcc57f0c3a4d192902a597 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 11:49:29 +0000 Subject: [PATCH 7/9] refactor(registry): name the shapes reader's constraint enum Rule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The enum's variants mirror SHACL's own constraint-component names, which is deliberate — the reader's vocabulary should be the shapes' vocabulary. Naming the enum Kind put NodeKind in it, which clippy reads as a variant repeating its enum's name; the lint was masked while the type was public API and surfaced when the surface narrowed. Renaming the enum keeps the SHACL names intact. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HeV8CH1FZLGoSES6gFMGHw --- product-core/src/registry/check/eval.rs | 30 +++++++++++------------ product-core/src/registry/check/shapes.rs | 20 +++++++-------- product-core/src/registry/template.rs | 3 ++- 3 files changed, 27 insertions(+), 26 deletions(-) diff --git a/product-core/src/registry/check/eval.rs b/product-core/src/registry/check/eval.rs index 36b35168..4763ac77 100644 --- a/product-core/src/registry/check/eval.rs +++ b/product-core/src/registry/check/eval.rs @@ -6,7 +6,7 @@ use crate::error::{ProductError, Result}; -use super::shapes::{local, Constraint, Kind}; +use super::shapes::{local, Constraint, Rule}; use super::{Finding, FindingKind}; /// Run every constraint over the data graph, returning one finding per row. @@ -37,17 +37,17 @@ pub(crate) fn run(data_ttl: &str, constraints: &[Constraint]) -> Result String { let class = &c.class; let path = c.path.clone().unwrap_or_default(); - match &c.kind { - Kind::Sparql(select) => select.clone(), - Kind::MinCount(n) => format!( + 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})" ), - Kind::MaxCount(n) => format!( + Rule::MaxCount(n) => format!( "SELECT ?this WHERE {{ ?this a {class} . OPTIONAL {{ ?this {path} ?v }} }} \ GROUP BY ?this HAVING (COUNT(?v) > {n})" ), - Kind::NodeKind(kind) => { + Rule::NodeKind(kind) => { let test = match kind.as_str() { "IRI" => "!isIRI(?v)", "Literal" => "!isLiteral(?v)", @@ -55,11 +55,11 @@ pub(crate) fn compile(c: &Constraint) -> String { }; format!("SELECT ?this WHERE {{ ?this a {class} ; {path} ?v . FILTER({test}) }}") } - Kind::Datatype(dt) => format!( + Rule::Datatype(dt) => format!( "SELECT ?this WHERE {{ ?this a {class} ; {path} ?v . \ FILTER(!isLiteral(?v) || datatype(?v) != {dt}) }}" ), - Kind::In(members) => format!( + Rule::In(members) => format!( "SELECT ?this WHERE {{ ?this a {class} ; {path} ?v . FILTER(?v NOT IN ({})) }}", members.join(", ") ), @@ -73,12 +73,12 @@ fn describe(c: &Constraint) -> String { return c.message.clone(); } let path = c.path.as_deref().map(local).unwrap_or_default(); - match &c.kind { - Kind::MinCount(n) => format!("{path}: fewer than {n} value(s)"), - Kind::MaxCount(n) => format!("{path}: more than {n} value(s)"), - Kind::NodeKind(k) => format!("{path}: a value is not a {k}"), - Kind::Datatype(dt) => format!("{path}: a value is not a {}", local(dt)), - Kind::In(_) => format!("{path}: a value is outside the permitted set"), - Kind::Sparql(_) => format!("{}: SPARQL constraint violated", local(&c.shape)), + 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/shapes.rs b/product-core/src/registry/check/shapes.rs index 8562bc7f..d9f098d7 100644 --- a/product-core/src/registry/check/shapes.rs +++ b/product-core/src/registry/check/shapes.rs @@ -26,14 +26,14 @@ pub(crate) struct Constraint { /// The property path, where the constraint has one. pub path: Option, /// What it asserts. - pub kind: Kind, + pub rule: Rule, /// The shape's own message. pub message: String, } /// The constraint kinds the reader evaluates. #[derive(Debug, Clone)] -pub(crate) enum Kind { +pub(crate) enum Rule { /// At least this many values. MinCount(u64), /// At most this many values. @@ -101,7 +101,7 @@ fn property_constraints(ttl: &str, unread: &mut Vec) -> Result, unread: &mut Vec, ) { - let mut with = |kind: Kind| out.push(Constraint { kind, ..base.clone() }); + 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(Kind::MinCount(n)); + with(Rule::MinCount(n)); } if let Some(n) = row.get("maxCount").and_then(|v| lexical(v).parse().ok()) { - with(Kind::MaxCount(n)); + with(Rule::MaxCount(n)); } if let Some(k) = row.get("nodeKind").map(|v| local(v)) { if ["IRI", "Literal", "BlankNode"].contains(&k.as_str()) { - with(Kind::NodeKind(k)); + 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(Kind::Datatype(dt)); + with(Rule::Datatype(dt)); } if let Some(list) = members.get(&key(&base.shape, path)) { - with(Kind::In(list.clone())); + with(Rule::In(list.clone())); } } @@ -169,7 +169,7 @@ fn sparql_constraints(ttl: &str) -> Result> { shape: row.get("shape")?.clone(), class: row.get("class")?.clone(), path: None, - kind: Kind::Sparql(lexical(row.get("select")?)), + rule: Rule::Sparql(lexical(row.get("select")?)), message: row.get("message").map(|m| lexical(m)).unwrap_or_default(), }) }) diff --git a/product-core/src/registry/template.rs b/product-core/src/registry/template.rs index e6fce411..9bfd523a 100644 --- a/product-core/src/registry/template.rs +++ b/product-core/src/registry/template.rs @@ -58,7 +58,8 @@ pub(crate) const TEMPLATE: &[TemplateFile] = &[ ]; /// The template's source directory, relative to the workspace root — where the -/// drift test walks. +/// 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)] From 9b2de25fd651a5c1f4eb2524e4b4200f07876df9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 11:51:14 +0000 Subject: [PATCH 8/9] ddd: re-bind TEMPLATE_VERSION after its file changed The binding chain composes over file hashes, so marking TEMPLATE_DIR test-only invalidated the chain for the symbol beside it. Re-filed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HeV8CH1FZLGoSES6gFMGHw --- .../seam-rust-product-core-src-registry-template-rs.yaml | 6 ++++++ 1 file changed, 6 insertions(+) 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 index 390c15f7..a1b694ac 100644 --- a/.ddd/seams/seam-rust-product-core-src-registry-template-rs.yaml +++ b/.ddd/seams/seam-rust-product-core-src-registry-template-rs.yaml @@ -12,3 +12,9 @@ bindings: 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 From 4c3443eddbd04b3b4fb3c1210f266996b5d20cbf Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Aug 2026 11:51:37 +0000 Subject: [PATCH 9/9] docs(g-track): record the contract-surface gate in the session report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gate failed the first CI run at 151 undischarged changes, which was a fair reading of an over-wide public surface. Narrowing the slice to its acts took it to 59 — the surface actually intended — discharged by ten seam declarations. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HeV8CH1FZLGoSES6gFMGHw --- .../registry-generator-session-2026-08-18.md | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/g-track/registry-generator-session-2026-08-18.md b/docs/g-track/registry-generator-session-2026-08-18.md index 0cd6c553..cb4fb8c1 100644 --- a/docs/g-track/registry-generator-session-2026-08-18.md +++ b/docs/g-track/registry-generator-session-2026-08-18.md @@ -56,6 +56,26 @@ The template stays at `docs/g-track/registry-template/` — the path the PRD and 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,