From 8ed8194fb8215f088d3ef94e99da5f2572b75af6 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Mon, 20 Jul 2026 12:36:43 -0700 Subject: [PATCH 1/9] Add a scope model and selector mechanism for carried rules (#368, PR-1) Foundational, deterministically inert step toward giving every governance rule a single explicit scope so carried docs are granular single-scope pieces composed per repo, not large pieces with internal carve-outs. - spec/scope-model.md (new, hub-only, not carried): defines the two axes (host vs repo home; hub-only / all-downstream / type-specific reach) and the appliesTo selector vocabulary - four disjoint namespaces (project types, workflowModel, releaseTrigger, consumerModel) - with the any-of and entry-AND-section semantics. Records CODESTYLE's section->scope mapping. - spec/files.schema.json: a sections element may now be a bare string or a {name, appliesTo} object (oneOf), so a section can be scoped, not just a file. Backward-compatible - every existing entry stays valid. - spec/audit.py: match appliesTo against a repo's full selector set (repo_selectors: types + workflowModel + releaseTrigger + consumerModel), and replace the hardcoded operational develop.json path swap with two data entries in files.json (appliesTo release vs operational). The namespaces are disjoint and no current appliesTo token is non-star, so the required-file set is unchanged for all 21 cataloged repos (verified by diffing old vs new). - spec/validate.py: first pass over files.json - every appliesTo token must resolve to a known selector, no project type may collide with a reserved token, and every cataloged repo must declare consumerModel (push/pull) so a push/pull-scoped section cannot fail open. - AUDIT.md section 3 + files.json note: one-line pointers to the scope model. No fleet-finding delta: this PR adds capability and codifies the current state. The section-presence check, the doc surgery, and carrying Verification Discipline follow in later PRs. Co-Authored-By: Claude Opus 4.8 --- AUDIT.md | 2 ++ spec/audit.py | 32 ++++++++++++++++++++++++++++---- spec/files.json | 5 +++-- spec/files.schema.json | 18 +++++++++++++++++- spec/scope-model.md | 37 +++++++++++++++++++++++++++++++++++++ spec/validate.py | 36 ++++++++++++++++++++++++++++++++++++ 6 files changed, 123 insertions(+), 7 deletions(-) create mode 100644 spec/scope-model.md diff --git a/AUDIT.md b/AUDIT.md index 9fa88390..9710cf31 100644 --- a/AUDIT.md +++ b/AUDIT.md @@ -31,6 +31,8 @@ Look up the repo in [`registry/repos.json`][repos] and read its `types[]`. If th Reuse [`WORKFLOW.md`][workflow] section 1: a check that governs a construct the repo does not contain is **N/A** - record it as N/A and **exclude it from the verdict**. N/A is never a defect. A Docker check on a repo with no image, a NuGet check on a Python package, the artifact-lifecycle clauses on a source-only repo - all N/A. +Which carried files and sections a repo is expected to have is decided by its scope selectors (its type(s) plus workflow model, release trigger, and consumer model); the scope model and the `appliesTo` selector vocabulary are defined in [`spec/scope-model.md`](./spec/scope-model.md). + ## 4. Per-Dimension Checks (Letter and Intent) For each applicable type in [`spec/project-types.json`][project-types] and every cross-cutting dimension, evaluate each check at its stated verdict tier: diff --git a/spec/audit.py b/spec/audit.py index 829004a2..4b5f860c 100644 --- a/spec/audit.py +++ b/spec/audit.py @@ -84,6 +84,30 @@ def repo_slug(entry): return "/".join(entry["url"].rstrip("/").split("/")[-2:]) +def repo_selectors(entry, defaults): + """The scope-selector set a files.json appliesTo is matched against (see spec/scope-model.md). + + The four namespaces - project types, workflowModel, releaseTrigger, consumerModel - are disjoint, so + a flat token set is unambiguous. Defaults resolve the same way configure.sh does (repo -> defaults -> + fleet default), so a repo relying on a defaults value scopes the same as one setting it explicitly. + """ + sel = set(entry.get("types", [])) + sel.add(entry.get("workflowModel") or defaults.get("workflowModel") or "release") + sel.add(entry.get("releaseTrigger") or defaults.get("releaseTrigger") or "two-phase") + cm = entry.get("consumerModel") or defaults.get("consumerModel") + if cm: + sel.add(cm) + return sel + + +def applies(applies_to, sel): + """True if an appliesTo selector applies to a repo's selector set. Disjunctive (any-of); `*` is all.""" + if applies_to == "*": + return True + tokens = applies_to if isinstance(applies_to, list) else [applies_to] + return bool(set(tokens) & sel) + + def audit_repo(entry, spec): findings = [] # (kind, text) slug = repo_slug(entry) @@ -230,14 +254,14 @@ def audit_repo(entry, spec): findings.append(("DRIFT", f"dependabot: {eco} ecosystem not declared though {why}; add it for both main and develop per the fleet norm")) # --- File presence on the ground-truth branch --- + # appliesTo is matched against the repo's full selector set (types + workflowModel + releaseTrigger + + # consumerModel), so the release/operational develop ruleset is two data entries, not a code swap. + sel = repo_selectors(entry, spec["registry"].get("defaults", {})) seen_paths = set() for item in spec["files"]["baseline"]: - applies = item.get("appliesTo", "*") - if applies != "*" and not set(applies) & set(types): + if not applies(item.get("appliesTo", "*"), sel): continue path = item["path"] - if path == "repo-config/develop.json" and model == "operational": - path = "repo-config/operational/develop.json" if path in seen_paths: continue seen_paths.add(path) diff --git a/spec/files.json b/spec/files.json index fb9760c3..7d3fd379 100644 --- a/spec/files.json +++ b/spec/files.json @@ -1,6 +1,6 @@ { "$schema": "./files.schema.json", - "note": "The standardization baseline: files and sections a fleet repo is expected to carry, and their intent authority. The audit checks presence (letter) and equivalence (intent); a section for an absent language or target is N/A.", + "note": "The standardization baseline: files and sections a fleet repo is expected to carry, and their intent authority. The audit checks presence (letter) and equivalence (intent); a section for an absent language or target is N/A. Each entry, and each section, carries an appliesTo selector - see spec/scope-model.md for the scope model and selector vocabulary.", "baseline": [ { "path": "AGENTS.md", "sections": ["Repository Boundaries and Write Safety", "Git and Commit Rules", "Branching Model", "Release Model", "Pull Request Title and Commit Message Conventions", "Documentation Style Conventions", "PR Review Etiquette", "Workflow YAML Conventions"], "intentRef": "AGENTS.md", "appliesTo": "*" }, { "path": "CODESTYLE.md", "whole": true, "placeholders": ["InternalsVisibleTo project names"], "intentRef": "CODESTYLE.md", "appliesTo": "*" }, @@ -14,7 +14,8 @@ { "path": "cspell.json", "whole": true, "appliesTo": "*" }, { "path": ".gitignore", "appliesTo": "*" }, { "path": "version.json", "intentRef": "WORKFLOW.md#d3---versioning-and-classification", "appliesTo": "*" }, - { "path": "repo-config/develop.json", "intentRef": "repo-config/README.md", "appliesTo": "*" }, + { "path": "repo-config/develop.json", "intentRef": "repo-config/README.md", "appliesTo": ["release"] }, + { "path": "repo-config/operational/develop.json", "intentRef": "repo-config/README.md", "appliesTo": ["operational"] }, { "path": "repo-config/main.json", "intentRef": "repo-config/README.md", "appliesTo": "*" }, { "path": "AUDIT.md", "intentRef": "docs/repo-config-carry.md", "appliesTo": "*" }, { "path": "spec/secrets.json", "intentRef": "docs/repo-config-carry.md", "appliesTo": "*" }, diff --git a/spec/files.schema.json b/spec/files.schema.json index 7013e1a7..cfba50c3 100644 --- a/spec/files.schema.json +++ b/spec/files.schema.json @@ -16,7 +16,23 @@ "properties": { "path": { "type": "string" }, "whole": { "type": "boolean" }, - "sections": { "type": "array", "items": { "type": "string" } }, + "sections": { + "type": "array", + "items": { + "oneOf": [ + { "type": "string" }, + { + "type": "object", + "required": ["name"], + "additionalProperties": false, + "properties": { + "name": { "type": "string" }, + "appliesTo": { "type": ["string", "array"] } + } + } + ] + } + }, "placeholders": { "type": "array", "items": { "type": "string" } }, "reference": { "type": "string" }, "intentRef": { "type": "string" }, diff --git a/spec/scope-model.md b/spec/scope-model.md new file mode 100644 index 00000000..0288a53d --- /dev/null +++ b/spec/scope-model.md @@ -0,0 +1,37 @@ +# Scope Model + +How every governance rule is scoped, so the carried docs are granular single-scope pieces composed per repo, not large pieces with internal carve-outs a reader must piece out. This is a hub-only doc: it governs the carrying machinery (`spec/files.json`, `spec/files.schema.json`, `spec/audit.py`) and is not itself carried to the fleet. + +## Two axes + +A rule has a physical home, and - if it is a repo rule - a reach. + +- **Axis A, home.** A rule lives on the **host** (per-machine, `~/.claude`, `host-setup/`; it loads in every session regardless of repo, and covers ad-hoc work outside any project) or in the **repo** (it travels with a repo and can assume repo context). A rule that must hold in both places is stated in both and kept in sync deliberately, because the populations differ - the write-safety rules are the worked example, living in the host `~/.claude/CLAUDE.md` and the carried `AGENTS.md` at once. +- **Axis B, reach** (repo rules only). A repo rule is **hub-only** (meaningful only in this coordinator repo - the registry, the spec, the audit, fleet coordination), **all-downstream** (every derived repo), or **type-specific** (only repos matching a selector). Hub-only rules are simply absent from the carried baseline; all-downstream and type-specific rules are carried, gated by an `appliesTo` selector. + +## Selectors + +A selector is one token from one of four **disjoint** namespaces. Because the namespaces share no token, a single flat `appliesTo` list is unambiguous. + +| Namespace | Tokens | Source of truth | +| --- | --- | --- | +| project type | `csharp` `nuget` `pypi` `python` `console` `docker` `homeassistant` `eda` `codegen` `upstream-wrapper` `source-only` `docs` | [`spec/project-types.json`](./project-types.json) | +| workflow model | `release` `operational` | [`registry/repos.schema.json`](../registry/repos.schema.json) | +| release trigger | `two-phase` `publish-on-merge` `dispatch-only` `none` | [`registry/repos.schema.json`](../registry/repos.schema.json) | +| consumer model | `push` `pull` | [`registry/repos.schema.json`](../registry/repos.schema.json) | + +A repo's **selector set** is its `types` plus its `workflowModel`, `releaseTrigger`, and `consumerModel` (each resolved repo value, then `defaults`, then the fleet default). [`spec/validate.py`](./validate.py) enforces that every `appliesTo` token resolves to a known selector and that no project type collides with a reserved token; [`spec/audit.py`](./audit.py) resolves the set in `repo_selectors`. + +## appliesTo semantics + +`appliesTo` appears on a [`spec/files.json`](./files.json) entry (which files a repo carries) and, per the section-object form in [`files.schema.json`](./files.schema.json), on an individual `sections` element (which sections within a carried file apply). + +- **`*`** means all repos. +- A list is **disjunctive (any-of)**: `["csharp", "operational"]` reads "csharp OR operational". Cross-axis **AND is not expressible**, and that is deliberate - a single-scope piece carries one selector, so the need for AND is the signal to split the piece further, not to write a two-token entry. +- Entry-level and section-level `appliesTo` compose with **AND**: a section applies only if its file is carried by the repo *and* the section's own selector matches. + +## Documenting a whole-carried file's section scopes + +A file carried `whole` (no `sections` allowlist) still has single-scope sections; the applicability gate resolves an inapplicable section to N/A at read time, so no split is needed. Record the mapping here rather than mechanizing it. + +- [`CODESTYLE.md`](../CODESTYLE.md): **General** = all-downstream; **.NET** = `csharp`; **Python** = `python`. A non-`csharp` repo reads the .NET section as N/A, a non-`python` repo the Python section. diff --git a/spec/validate.py b/spec/validate.py index 6787404e..6a6b36ff 100644 --- a/spec/validate.py +++ b/spec/validate.py @@ -12,6 +12,13 @@ ROOT = pathlib.Path(__file__).resolve().parent.parent +# Scope-selector vocabularies (see spec/scope-model.md), kept in sync with registry/repos.schema.json +# $defs. The four namespaces - project types plus these three - must stay disjoint, so a flat appliesTo +# token set in spec/files.json is unambiguous. +WORKFLOW_MODELS = ("release", "operational") +RELEASE_TRIGGERS = ("two-phase", "publish-on-merge", "dispatch-only", "none") +CONSUMER_MODELS = ("push", "pull") + def load(rel): return json.loads((ROOT / rel).read_text(encoding="utf-8")) @@ -124,6 +131,12 @@ def check_secret_set(label, entry, need_kind): if model is not None and model not in ("release", "operational"): errors.append(f"{name}: workflowModel '{model}' invalid (expected release or operational)") + # consumerModel is a scope selector (spec/scope-model.md), so a cataloged repo must declare it or a + # push/pull-scoped section would fail open (never matched) on that repo. + cm = repo.get("consumerModel") + if cm not in CONSUMER_MODELS: + errors.append(f"{name}: consumerModel '{cm}' invalid or missing (expected push or pull)") + eol = repo.get("lineEndings") if eol is not None and eol not in ("lf", "crlf"): errors.append(f"{name}: lineEndings '{eol}' invalid (expected lf or crlf)") @@ -167,6 +180,29 @@ def check_secret_set(label, entry, need_kind): if kind and mech != kind: errors.append(f"{name}: {target} labeled '{mech}' but its mechanism is '{kind}'") + # files.json appliesTo selectors must resolve to a known token, and no project type may collide with a + # reserved selector - a flat token set is only unambiguous while the namespaces stay disjoint. An + # unknown token fails open (it never matches), so a required file/section would silently apply nowhere. + reserved = set(WORKFLOW_MODELS) | set(RELEASE_TRIGGERS) | set(CONSUMER_MODELS) + clash = known_types & reserved + if clash: + errors.append(f"files.json: project type(s) collide with a reserved scope selector: {', '.join(sorted(clash))}") + universe = known_types | reserved + + def check_selector(where, applies_to): + tokens = [] if applies_to == "*" else (applies_to if isinstance(applies_to, list) else [applies_to]) + for tok in tokens: + if tok not in universe: + errors.append(f"files.json: {where} appliesTo '{tok}' is not a known selector") + + files = load("spec/files.json") + for item in files.get("baseline", []): + path = item.get("path", "?") + check_selector(path, item.get("appliesTo", "*")) + for elt in item.get("sections", []): + if isinstance(elt, dict): + check_selector(f"{path} section '{elt.get('name', '?')}'", elt.get("appliesTo", "*")) + if errors: print("Spec validation FAILED:") for e in errors: From 65aeef13b20d6125510b10997016864fd59e3f3f Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Mon, 20 Jul 2026 12:48:08 -0700 Subject: [PATCH 2/9] Use reference-style links in the scope-model doc and AUDIT.md pointer (#369) The repo doc convention (AGENTS.md) requires reference-style links in every markdown file except the agent-instruction files. Convert spec/scope-model.md's inline links to reference-style with a definitions block, and change the new AUDIT.md pointer to the reference-style [scope-model] with its definition added alphabetically to the Repo group. Both Copilot round-1 findings. Co-Authored-By: Claude Opus 4.8 --- AUDIT.md | 3 ++- spec/scope-model.md | 25 +++++++++++++++++-------- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/AUDIT.md b/AUDIT.md index 9710cf31..da36aa05 100644 --- a/AUDIT.md +++ b/AUDIT.md @@ -31,7 +31,7 @@ Look up the repo in [`registry/repos.json`][repos] and read its `types[]`. If th Reuse [`WORKFLOW.md`][workflow] section 1: a check that governs a construct the repo does not contain is **N/A** - record it as N/A and **exclude it from the verdict**. N/A is never a defect. A Docker check on a repo with no image, a NuGet check on a Python package, the artifact-lifecycle clauses on a source-only repo - all N/A. -Which carried files and sections a repo is expected to have is decided by its scope selectors (its type(s) plus workflow model, release trigger, and consumer model); the scope model and the `appliesTo` selector vocabulary are defined in [`spec/scope-model.md`](./spec/scope-model.md). +Which carried files and sections a repo is expected to have is decided by its scope selectors (its type(s) plus workflow model, release trigger, and consumer model); the scope model and the `appliesTo` selector vocabulary are defined in [`spec/scope-model.md`][scope-model]. ## 4. Per-Dimension Checks (Letter and Intent) @@ -154,6 +154,7 @@ The convergence model: the hub audits and the agent **applies** the fixes via ta [repo-config-settings]: ./repo-config/settings.json [reports]: ./reports/ [repos]: ./registry/repos.json +[scope-model]: ./spec/scope-model.md [secrets]: ./spec/secrets.json [spec]: ./spec/ [standup]: ./STANDUP.md diff --git a/spec/scope-model.md b/spec/scope-model.md index 0288a53d..712ad029 100644 --- a/spec/scope-model.md +++ b/spec/scope-model.md @@ -1,6 +1,6 @@ # Scope Model -How every governance rule is scoped, so the carried docs are granular single-scope pieces composed per repo, not large pieces with internal carve-outs a reader must piece out. This is a hub-only doc: it governs the carrying machinery (`spec/files.json`, `spec/files.schema.json`, `spec/audit.py`) and is not itself carried to the fleet. +How every governance rule is scoped, so the carried docs are granular single-scope pieces composed per repo, not large pieces with internal carve-outs a reader must piece out. This is a hub-only doc: it governs the carrying machinery ([`spec/files.json`][files], [`spec/files.schema.json`][files-schema], [`spec/audit.py`][audit]) and is not itself carried to the fleet. ## Two axes @@ -15,16 +15,16 @@ A selector is one token from one of four **disjoint** namespaces. Because the na | Namespace | Tokens | Source of truth | | --- | --- | --- | -| project type | `csharp` `nuget` `pypi` `python` `console` `docker` `homeassistant` `eda` `codegen` `upstream-wrapper` `source-only` `docs` | [`spec/project-types.json`](./project-types.json) | -| workflow model | `release` `operational` | [`registry/repos.schema.json`](../registry/repos.schema.json) | -| release trigger | `two-phase` `publish-on-merge` `dispatch-only` `none` | [`registry/repos.schema.json`](../registry/repos.schema.json) | -| consumer model | `push` `pull` | [`registry/repos.schema.json`](../registry/repos.schema.json) | +| project type | `csharp` `nuget` `pypi` `python` `console` `docker` `homeassistant` `eda` `codegen` `upstream-wrapper` `source-only` `docs` | [`spec/project-types.json`][project-types] | +| workflow model | `release` `operational` | [`registry/repos.schema.json`][repos-schema] | +| release trigger | `two-phase` `publish-on-merge` `dispatch-only` `none` | [`registry/repos.schema.json`][repos-schema] | +| consumer model | `push` `pull` | [`registry/repos.schema.json`][repos-schema] | -A repo's **selector set** is its `types` plus its `workflowModel`, `releaseTrigger`, and `consumerModel` (each resolved repo value, then `defaults`, then the fleet default). [`spec/validate.py`](./validate.py) enforces that every `appliesTo` token resolves to a known selector and that no project type collides with a reserved token; [`spec/audit.py`](./audit.py) resolves the set in `repo_selectors`. +A repo's **selector set** is its `types` plus its `workflowModel`, `releaseTrigger`, and `consumerModel` (each resolved repo value, then `defaults`, then the fleet default). [`spec/validate.py`][validate] enforces that every `appliesTo` token resolves to a known selector and that no project type collides with a reserved token; [`spec/audit.py`][audit] resolves the set in `repo_selectors`. ## appliesTo semantics -`appliesTo` appears on a [`spec/files.json`](./files.json) entry (which files a repo carries) and, per the section-object form in [`files.schema.json`](./files.schema.json), on an individual `sections` element (which sections within a carried file apply). +`appliesTo` appears on a [`spec/files.json`][files] entry (which files a repo carries) and, per the section-object form in [`spec/files.schema.json`][files-schema], on an individual `sections` element (which sections within a carried file apply). - **`*`** means all repos. - A list is **disjunctive (any-of)**: `["csharp", "operational"]` reads "csharp OR operational". Cross-axis **AND is not expressible**, and that is deliberate - a single-scope piece carries one selector, so the need for AND is the signal to split the piece further, not to write a two-token entry. @@ -34,4 +34,13 @@ A repo's **selector set** is its `types` plus its `workflowModel`, `releaseTrigg A file carried `whole` (no `sections` allowlist) still has single-scope sections; the applicability gate resolves an inapplicable section to N/A at read time, so no split is needed. Record the mapping here rather than mechanizing it. -- [`CODESTYLE.md`](../CODESTYLE.md): **General** = all-downstream; **.NET** = `csharp`; **Python** = `python`. A non-`csharp` repo reads the .NET section as N/A, a non-`python` repo the Python section. +- [`CODESTYLE.md`][codestyle]: **General** = all-downstream; **.NET** = `csharp`; **Python** = `python`. A non-`csharp` repo reads the .NET section as N/A, a non-`python` repo the Python section. + + +[audit]: ./audit.py +[codestyle]: ../CODESTYLE.md +[files]: ./files.json +[files-schema]: ./files.schema.json +[project-types]: ./project-types.json +[repos-schema]: ../registry/repos.schema.json +[validate]: ./validate.py From b0efa83a7c10b5908bb41b6bae53db049f77b65e Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Mon, 20 Jul 2026 12:54:21 -0700 Subject: [PATCH 3/9] Constrain appliesTo to strings, guard the matcher, drop prose semicolons (#369) Copilot round-2 findings: - files.schema.json: appliesTo array items are now constrained to strings at both the entry level and the new section-object level, so a non-string token cannot reach the runtime matchers. - validate.py: check_selector guards a non-string token explicitly (CI runs no JSON-schema validation) instead of crashing on set membership of an unhashable value. - scope-model.md, audit.py, AUDIT.md: replace clause-joining semicolons in the new prose and docstring with the house comma/two-sentence form. Co-Authored-By: Claude Opus 4.8 --- AUDIT.md | 2 +- spec/audit.py | 2 +- spec/files.schema.json | 4 ++-- spec/scope-model.md | 8 ++++---- spec/validate.py | 6 +++++- 5 files changed, 13 insertions(+), 9 deletions(-) diff --git a/AUDIT.md b/AUDIT.md index da36aa05..43417fe7 100644 --- a/AUDIT.md +++ b/AUDIT.md @@ -31,7 +31,7 @@ Look up the repo in [`registry/repos.json`][repos] and read its `types[]`. If th Reuse [`WORKFLOW.md`][workflow] section 1: a check that governs a construct the repo does not contain is **N/A** - record it as N/A and **exclude it from the verdict**. N/A is never a defect. A Docker check on a repo with no image, a NuGet check on a Python package, the artifact-lifecycle clauses on a source-only repo - all N/A. -Which carried files and sections a repo is expected to have is decided by its scope selectors (its type(s) plus workflow model, release trigger, and consumer model); the scope model and the `appliesTo` selector vocabulary are defined in [`spec/scope-model.md`][scope-model]. +Which carried files and sections a repo is expected to have is decided by its scope selectors (its type(s) plus workflow model, release trigger, and consumer model). The scope model and the `appliesTo` selector vocabulary are defined in [`spec/scope-model.md`][scope-model]. ## 4. Per-Dimension Checks (Letter and Intent) diff --git a/spec/audit.py b/spec/audit.py index 4b5f860c..2e35cef0 100644 --- a/spec/audit.py +++ b/spec/audit.py @@ -101,7 +101,7 @@ def repo_selectors(entry, defaults): def applies(applies_to, sel): - """True if an appliesTo selector applies to a repo's selector set. Disjunctive (any-of); `*` is all.""" + """True if an appliesTo selector applies to a repo's selector set. Disjunctive any-of, with `*` meaning all.""" if applies_to == "*": return True tokens = applies_to if isinstance(applies_to, list) else [applies_to] diff --git a/spec/files.schema.json b/spec/files.schema.json index cfba50c3..b830c768 100644 --- a/spec/files.schema.json +++ b/spec/files.schema.json @@ -27,7 +27,7 @@ "additionalProperties": false, "properties": { "name": { "type": "string" }, - "appliesTo": { "type": ["string", "array"] } + "appliesTo": { "type": ["string", "array"], "items": { "type": "string" } } } } ] @@ -36,7 +36,7 @@ "placeholders": { "type": "array", "items": { "type": "string" } }, "reference": { "type": "string" }, "intentRef": { "type": "string" }, - "appliesTo": { "type": ["string", "array"] } + "appliesTo": { "type": ["string", "array"], "items": { "type": "string" } } } } } diff --git a/spec/scope-model.md b/spec/scope-model.md index 712ad029..e2f7789b 100644 --- a/spec/scope-model.md +++ b/spec/scope-model.md @@ -6,8 +6,8 @@ How every governance rule is scoped, so the carried docs are granular single-sco A rule has a physical home, and - if it is a repo rule - a reach. -- **Axis A, home.** A rule lives on the **host** (per-machine, `~/.claude`, `host-setup/`; it loads in every session regardless of repo, and covers ad-hoc work outside any project) or in the **repo** (it travels with a repo and can assume repo context). A rule that must hold in both places is stated in both and kept in sync deliberately, because the populations differ - the write-safety rules are the worked example, living in the host `~/.claude/CLAUDE.md` and the carried `AGENTS.md` at once. -- **Axis B, reach** (repo rules only). A repo rule is **hub-only** (meaningful only in this coordinator repo - the registry, the spec, the audit, fleet coordination), **all-downstream** (every derived repo), or **type-specific** (only repos matching a selector). Hub-only rules are simply absent from the carried baseline; all-downstream and type-specific rules are carried, gated by an `appliesTo` selector. +- **Axis A, home.** A rule lives on the **host** (per-machine, `~/.claude`, `host-setup/` - it loads in every session regardless of repo and covers ad-hoc work outside any project) or in the **repo** (it travels with a repo and can assume repo context). A rule that must hold in both places is stated in both and kept in sync deliberately, because the populations differ - the write-safety rules are the worked example, living in the host `~/.claude/CLAUDE.md` and the carried `AGENTS.md` at once. +- **Axis B, reach** (repo rules only). A repo rule is **hub-only** (meaningful only in this coordinator repo - the registry, the spec, the audit, fleet coordination), **all-downstream** (every derived repo), or **type-specific** (only repos matching a selector). Hub-only rules are simply absent from the carried baseline. All-downstream and type-specific rules are carried, gated by an `appliesTo` selector. ## Selectors @@ -20,7 +20,7 @@ A selector is one token from one of four **disjoint** namespaces. Because the na | release trigger | `two-phase` `publish-on-merge` `dispatch-only` `none` | [`registry/repos.schema.json`][repos-schema] | | consumer model | `push` `pull` | [`registry/repos.schema.json`][repos-schema] | -A repo's **selector set** is its `types` plus its `workflowModel`, `releaseTrigger`, and `consumerModel` (each resolved repo value, then `defaults`, then the fleet default). [`spec/validate.py`][validate] enforces that every `appliesTo` token resolves to a known selector and that no project type collides with a reserved token; [`spec/audit.py`][audit] resolves the set in `repo_selectors`. +A repo's **selector set** is its `types` plus its `workflowModel`, `releaseTrigger`, and `consumerModel` (each resolved repo value, then `defaults`, then the fleet default). [`spec/validate.py`][validate] enforces that every `appliesTo` token resolves to a known selector and that no project type collides with a reserved token. [`spec/audit.py`][audit] resolves the set in `repo_selectors`. ## appliesTo semantics @@ -32,7 +32,7 @@ A repo's **selector set** is its `types` plus its `workflowModel`, `releaseTrigg ## Documenting a whole-carried file's section scopes -A file carried `whole` (no `sections` allowlist) still has single-scope sections; the applicability gate resolves an inapplicable section to N/A at read time, so no split is needed. Record the mapping here rather than mechanizing it. +A file carried `whole` (no `sections` allowlist) still has single-scope sections, and the applicability gate resolves an inapplicable section to N/A at read time, so no split is needed. Record the mapping here rather than mechanizing it. - [`CODESTYLE.md`][codestyle]: **General** = all-downstream; **.NET** = `csharp`; **Python** = `python`. A non-`csharp` repo reads the .NET section as N/A, a non-`python` repo the Python section. diff --git a/spec/validate.py b/spec/validate.py index 6a6b36ff..7828466e 100644 --- a/spec/validate.py +++ b/spec/validate.py @@ -192,7 +192,11 @@ def check_secret_set(label, entry, need_kind): def check_selector(where, applies_to): tokens = [] if applies_to == "*" else (applies_to if isinstance(applies_to, list) else [applies_to]) for tok in tokens: - if tok not in universe: + # CI runs no JSON-schema validation, so guard the type here rather than crash on an unhashable + # token (e.g. a nested object) reaching the set-membership test below. + if not isinstance(tok, str): + errors.append(f"files.json: {where} appliesTo has a non-string token {tok!r}") + elif tok not in universe: errors.append(f"files.json: {where} appliesTo '{tok}' is not a known selector") files = load("spec/files.json") From 7f9b55099f533370027f11fce862b94c46be0784 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Mon, 20 Jul 2026 12:59:58 -0700 Subject: [PATCH 4/9] Match the selector-resolution docs to the code (#369) Copilot round-3 findings: scope-model.md and the repo_selectors docstring claimed every selector resolves repo -> defaults -> fleet default, but consumerModel has no fleet default - validate.py requires it on every cataloged repo instead. State each selector's resolution accurately: workflowModel and releaseTrigger fall back to a fleet default (release, two-phase); consumerModel is required per cataloged repo, so a cataloged repo always contributes one. Co-Authored-By: Claude Opus 4.8 --- spec/audit.py | 5 +++-- spec/scope-model.md | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/spec/audit.py b/spec/audit.py index 2e35cef0..e267fade 100644 --- a/spec/audit.py +++ b/spec/audit.py @@ -88,8 +88,9 @@ def repo_selectors(entry, defaults): """The scope-selector set a files.json appliesTo is matched against (see spec/scope-model.md). The four namespaces - project types, workflowModel, releaseTrigger, consumerModel - are disjoint, so - a flat token set is unambiguous. Defaults resolve the same way configure.sh does (repo -> defaults -> - fleet default), so a repo relying on a defaults value scopes the same as one setting it explicitly. + a flat token set is unambiguous. workflowModel and releaseTrigger resolve repo -> defaults -> fleet + default (as configure.sh does). consumerModel has no fleet default - validate.py requires it on every + cataloged repo, so a cataloged repo always contributes one. """ sel = set(entry.get("types", [])) sel.add(entry.get("workflowModel") or defaults.get("workflowModel") or "release") diff --git a/spec/scope-model.md b/spec/scope-model.md index e2f7789b..a4ab5408 100644 --- a/spec/scope-model.md +++ b/spec/scope-model.md @@ -20,7 +20,7 @@ A selector is one token from one of four **disjoint** namespaces. Because the na | release trigger | `two-phase` `publish-on-merge` `dispatch-only` `none` | [`registry/repos.schema.json`][repos-schema] | | consumer model | `push` `pull` | [`registry/repos.schema.json`][repos-schema] | -A repo's **selector set** is its `types` plus its `workflowModel`, `releaseTrigger`, and `consumerModel` (each resolved repo value, then `defaults`, then the fleet default). [`spec/validate.py`][validate] enforces that every `appliesTo` token resolves to a known selector and that no project type collides with a reserved token. [`spec/audit.py`][audit] resolves the set in `repo_selectors`. +A repo's **selector set** is its `types` plus its `workflowModel`, `releaseTrigger`, and `consumerModel`. `workflowModel` and `releaseTrigger` resolve as the repo value, then `defaults`, then the fleet default (`release`, `two-phase`). `consumerModel` has no fleet default - [`spec/validate.py`][validate] requires it on every cataloged repo, so a cataloged repo always contributes one. `validate.py` also enforces that every `appliesTo` token resolves to a known selector and that no project type collides with a reserved token, and [`spec/audit.py`][audit] resolves the set in `repo_selectors`. ## appliesTo semantics From a04c521110d0002cadbbd2ecc44af90098ea0a7e Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Mon, 20 Jul 2026 13:04:08 -0700 Subject: [PATCH 5/9] Reject an empty appliesTo list (#369) Copilot round-4 finding: check_selector accepted an empty appliesTo list, which scopes an entry or section to no repo at all (it fails open - silently applies nowhere). Reject [] in validate.py with a clear message, and add minItems:1 to both appliesTo definitions in files.schema.json so an empty list is also a schema violation. Use "*" for all repos, or list selectors. Co-Authored-By: Claude Opus 4.8 --- spec/files.schema.json | 4 ++-- spec/validate.py | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/spec/files.schema.json b/spec/files.schema.json index b830c768..aafc5de1 100644 --- a/spec/files.schema.json +++ b/spec/files.schema.json @@ -27,7 +27,7 @@ "additionalProperties": false, "properties": { "name": { "type": "string" }, - "appliesTo": { "type": ["string", "array"], "items": { "type": "string" } } + "appliesTo": { "type": ["string", "array"], "items": { "type": "string" }, "minItems": 1 } } } ] @@ -36,7 +36,7 @@ "placeholders": { "type": "array", "items": { "type": "string" } }, "reference": { "type": "string" }, "intentRef": { "type": "string" }, - "appliesTo": { "type": ["string", "array"], "items": { "type": "string" } } + "appliesTo": { "type": ["string", "array"], "items": { "type": "string" }, "minItems": 1 } } } } diff --git a/spec/validate.py b/spec/validate.py index 7828466e..bea4278f 100644 --- a/spec/validate.py +++ b/spec/validate.py @@ -190,6 +190,9 @@ def check_secret_set(label, entry, need_kind): universe = known_types | reserved def check_selector(where, applies_to): + if isinstance(applies_to, list) and not applies_to: + errors.append(f"files.json: {where} appliesTo is an empty list (use \"*\" for all repos, or list selectors) - it would apply nowhere") + return tokens = [] if applies_to == "*" else (applies_to if isinstance(applies_to, list) else [applies_to]) for tok in tokens: # CI runs no JSON-schema validation, so guard the type here rather than crash on an unhashable From 0803fbb35ad463a4bb7483c36905deb6cfa5952f Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Mon, 20 Jul 2026 13:10:22 -0700 Subject: [PATCH 6/9] Validate releaseTrigger per-repo and in defaults (#369) Copilot round-5 findings: releaseTrigger is now a scope selector, but validate checked only workflowModel, so an invalid releaseTrigger would silently fail to match any releaseTrigger-scoped section instead of erroring. Add per-repo and defaults.releaseTrigger validation against RELEASE_TRIGGERS, mirroring the workflowModel checks, and drive all three model/trigger/consumer checks off the vocabulary constants so the messages and membership tests share one source. Co-Authored-By: Claude Opus 4.8 --- spec/validate.py | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/spec/validate.py b/spec/validate.py index bea4278f..5c0e2692 100644 --- a/spec/validate.py +++ b/spec/validate.py @@ -97,11 +97,16 @@ def check_secret_set(label, entry, need_kind): print(f" - {e}") return 1 - # defaults.workflowModel feeds configure.sh's fallback, so an invalid value here breaks the apply while every - # per-repo entry still validates - check it once. - default_model = repos.get("defaults", {}).get("workflowModel") - if default_model is not None and default_model not in ("release", "operational"): - errors.append(f"defaults.workflowModel '{default_model}' invalid (expected release or operational)") + # defaults.workflowModel/releaseTrigger feed configure.sh's fallback and selector resolution, so an + # invalid value here breaks the apply or scopes wrong while every per-repo entry still validates - check + # them once. + reg_defaults = repos.get("defaults", {}) + default_model = reg_defaults.get("workflowModel") + if default_model is not None and default_model not in WORKFLOW_MODELS: + errors.append(f"defaults.workflowModel '{default_model}' invalid (expected {' or '.join(WORKFLOW_MODELS)})") + default_trigger = reg_defaults.get("releaseTrigger") + if default_trigger is not None and default_trigger not in RELEASE_TRIGGERS: + errors.append(f"defaults.releaseTrigger '{default_trigger}' invalid (expected one of {', '.join(RELEASE_TRIGGERS)})") for i, repo in enumerate(repos["repos"]): if not isinstance(repo, dict): @@ -128,14 +133,20 @@ def check_secret_set(label, entry, need_kind): errors.append(f"{name}: type '{t}' not defined in project-types.json") model = repo.get("workflowModel") - if model is not None and model not in ("release", "operational"): - errors.append(f"{name}: workflowModel '{model}' invalid (expected release or operational)") + if model is not None and model not in WORKFLOW_MODELS: + errors.append(f"{name}: workflowModel '{model}' invalid (expected {' or '.join(WORKFLOW_MODELS)})") + + # releaseTrigger is a scope selector (spec/scope-model.md), so an invalid value would silently fail + # to match any releaseTrigger-scoped section rather than error. + trigger = repo.get("releaseTrigger") + if trigger is not None and trigger not in RELEASE_TRIGGERS: + errors.append(f"{name}: releaseTrigger '{trigger}' invalid (expected one of {', '.join(RELEASE_TRIGGERS)})") # consumerModel is a scope selector (spec/scope-model.md), so a cataloged repo must declare it or a # push/pull-scoped section would fail open (never matched) on that repo. cm = repo.get("consumerModel") if cm not in CONSUMER_MODELS: - errors.append(f"{name}: consumerModel '{cm}' invalid or missing (expected push or pull)") + errors.append(f"{name}: consumerModel '{cm}' invalid or missing (expected {' or '.join(CONSUMER_MODELS)})") eol = repo.get("lineEndings") if eol is not None and eol not in ("lf", "crlf"): From 374e255f1f785d32c44fb7e37525ecaec5112ce1 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Mon, 20 Jul 2026 13:15:31 -0700 Subject: [PATCH 7/9] Title-case scope-model headings; comma-separate the CODESTYLE mapping (#369) Copilot round-6 findings: three scope-model.md headings were sentence-case (AGENTS.md title-case rule) and the CODESTYLE section-scope list used semicolons between comma-free items, reading as clause-joining. Title-case the headings and switch the list to commas. Co-Authored-By: Claude Opus 4.8 --- spec/scope-model.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/spec/scope-model.md b/spec/scope-model.md index a4ab5408..a0eafe05 100644 --- a/spec/scope-model.md +++ b/spec/scope-model.md @@ -2,7 +2,7 @@ How every governance rule is scoped, so the carried docs are granular single-scope pieces composed per repo, not large pieces with internal carve-outs a reader must piece out. This is a hub-only doc: it governs the carrying machinery ([`spec/files.json`][files], [`spec/files.schema.json`][files-schema], [`spec/audit.py`][audit]) and is not itself carried to the fleet. -## Two axes +## Two Axes A rule has a physical home, and - if it is a repo rule - a reach. @@ -22,7 +22,7 @@ A selector is one token from one of four **disjoint** namespaces. Because the na A repo's **selector set** is its `types` plus its `workflowModel`, `releaseTrigger`, and `consumerModel`. `workflowModel` and `releaseTrigger` resolve as the repo value, then `defaults`, then the fleet default (`release`, `two-phase`). `consumerModel` has no fleet default - [`spec/validate.py`][validate] requires it on every cataloged repo, so a cataloged repo always contributes one. `validate.py` also enforces that every `appliesTo` token resolves to a known selector and that no project type collides with a reserved token, and [`spec/audit.py`][audit] resolves the set in `repo_selectors`. -## appliesTo semantics +## appliesTo Semantics `appliesTo` appears on a [`spec/files.json`][files] entry (which files a repo carries) and, per the section-object form in [`spec/files.schema.json`][files-schema], on an individual `sections` element (which sections within a carried file apply). @@ -30,11 +30,11 @@ A repo's **selector set** is its `types` plus its `workflowModel`, `releaseTrigg - A list is **disjunctive (any-of)**: `["csharp", "operational"]` reads "csharp OR operational". Cross-axis **AND is not expressible**, and that is deliberate - a single-scope piece carries one selector, so the need for AND is the signal to split the piece further, not to write a two-token entry. - Entry-level and section-level `appliesTo` compose with **AND**: a section applies only if its file is carried by the repo *and* the section's own selector matches. -## Documenting a whole-carried file's section scopes +## Documenting a Whole-Carried File's Section Scopes A file carried `whole` (no `sections` allowlist) still has single-scope sections, and the applicability gate resolves an inapplicable section to N/A at read time, so no split is needed. Record the mapping here rather than mechanizing it. -- [`CODESTYLE.md`][codestyle]: **General** = all-downstream; **.NET** = `csharp`; **Python** = `python`. A non-`csharp` repo reads the .NET section as N/A, a non-`python` repo the Python section. +- [`CODESTYLE.md`][codestyle]: **General** is all-downstream, **.NET** is `csharp`, **Python** is `python`. A non-`csharp` repo reads the .NET section as N/A, a non-`python` repo the Python section. [audit]: ./audit.py From 243329331afb536c47cd76edb07292aa3bd05078 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Mon, 20 Jul 2026 13:19:40 -0700 Subject: [PATCH 8/9] Drop the dead defaults.consumerModel fallback in repo_selectors (#369) Copilot round-7 low-confidence finding (correct): repo_selectors fell back to defaults.get("consumerModel"), but registry/repos.schema.json's defaults object is additionalProperties:false with only groundTruthBranch/releaseTrigger/ workflowModel, so defaults.consumerModel can never exist - the fallback was dead code that misrepresents the supported registry shape. Read only the per-repo consumerModel (validate.py requires it on every cataloged repo); the guard stays to shield a malformed non-cataloged entry. Still inert: required-file set unchanged for all 21 repos. Co-Authored-By: Claude Opus 4.8 --- spec/audit.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/spec/audit.py b/spec/audit.py index e267fade..d6945563 100644 --- a/spec/audit.py +++ b/spec/audit.py @@ -95,7 +95,9 @@ def repo_selectors(entry, defaults): sel = set(entry.get("types", [])) sel.add(entry.get("workflowModel") or defaults.get("workflowModel") or "release") sel.add(entry.get("releaseTrigger") or defaults.get("releaseTrigger") or "two-phase") - cm = entry.get("consumerModel") or defaults.get("consumerModel") + # consumerModel has no defaults fallback - the registry schema does not allow defaults.consumerModel, and + # validate.py requires it on every cataloged repo. The guard only shields a malformed non-cataloged entry. + cm = entry.get("consumerModel") if cm: sel.add(cm) return sel From 89d1eded468af039eafeed44ed1cd512eb9542e2 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Mon, 20 Jul 2026 13:23:55 -0700 Subject: [PATCH 9/9] Shape-check files.json defensively in validate.py (#369) Copilot round-8 finding: the files.json validation loop exists because CI runs no JSON-schema validation, but it assumed well-formed input - a non-array baseline, a non-object entry, a non-array sections, or a non-string/non-object section element would raise AttributeError or iterate a string instead of reporting a clear validation error. Guard each shape and continue, mirroring the existing secrets.json shape checks. Verified all four malformed shapes now exit 1 with a clear message and no traceback. Co-Authored-By: Claude Opus 4.8 --- spec/validate.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/spec/validate.py b/spec/validate.py index 5c0e2692..28b4cf19 100644 --- a/spec/validate.py +++ b/spec/validate.py @@ -213,13 +213,28 @@ def check_selector(where, applies_to): elif tok not in universe: errors.append(f"files.json: {where} appliesTo '{tok}' is not a known selector") + # CI runs no JSON-schema validation, so shape-check files.json here rather than crash on a malformed + # entry (a non-object baseline item, a non-array sections, a section that is neither string nor object). files = load("spec/files.json") - for item in files.get("baseline", []): + baseline = files.get("baseline", []) + if not isinstance(baseline, list): + errors.append("files.json: 'baseline' must be an array") + baseline = [] + for item in baseline: + if not isinstance(item, dict): + errors.append(f"files.json: baseline entry {item!r} is not an object") + continue path = item.get("path", "?") check_selector(path, item.get("appliesTo", "*")) - for elt in item.get("sections", []): + sections = item.get("sections", []) + if not isinstance(sections, list): + errors.append(f"files.json: {path} sections must be an array") + continue + for elt in sections: if isinstance(elt, dict): check_selector(f"{path} section '{elt.get('name', '?')}'", elt.get("appliesTo", "*")) + elif not isinstance(elt, str): + errors.append(f"files.json: {path} section entry {elt!r} must be a string or object") if errors: print("Spec validation FAILED:")