From 44f7ed6985dde16d9b2ff16cf277582c5afe24c5 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Mon, 10 Aug 2026 10:08:27 -0700 Subject: [PATCH 1/8] Carry the Host Contract and Declare the jq Floor jq is used by the ruleset normalizer in repo-config/configure.sh, the ruleset diff in AUDIT.md section 6, and the payload regeneration in docs/repo-config-carry.md, and it was declared in neither the contract table nor spec/host-tools.json. A host without it cannot apply, check, or audit a repository's branch configuration, and nothing said so. The floor is 1.6 and is a target rather than a measured breakage, and the entry says which kind it is. 1.6 is where walk/1 arrived, so it is the lowest version at which every jq construct the fleet documents compiles with no portability workaround. Below it the fleet's own filters still run, because both that would need walk define their own recursion instead, so a host on 1.5 is unverified rather than known broken. The measured fact is the failure mode: on jq 1.5 walk/1 does not degrade, it fails to compile, so a diff built on it reports drift on every ruleset it never compared. The claim that keys_unsorted also needs 1.6 stays disproved, measured on jq-1.5-1-a5b5cbe. The source note is release-dependent rather than uniform, because it was checked: Debian trixie packages 1.7.1, older releases sit lower, Homebrew and winget track upstream. host-tools.json becomes a carried baseline file, so a repository states the tools its own procedures need beyond the fleet declaration, and the tighten-only layering has somewhere to live that a reader finds rather than has to know to look for. A repository with nothing to add carries the stub with an empty tools list, the footing OPERATIONS.md already set, and this repository's own root file is that worked example. Measured before landing: none of the 22 cataloged repositories carries one, so this adds one letter finding to each. The gate also joins the procedures it was written for. STANDUP.md section 0 and AUDIT.md now run it, alongside RESYNC.md, and each passes --repo, since the gate reads the target's own declaration relative to that flag and a bare run layers the hub's instead while printing the same healthy digest. Co-Authored-By: Claude Opus 5 (1M context) --- AUDIT.md | 9 +++++++++ STANDUP.md | 14 +++++++++++++- docs/host-setup.md | 2 ++ host-tools.json | 5 +++++ scripts/test_host_gate.py | 18 ++++++++++++------ spec/files.json | 1 + spec/host-tools.json | 13 +++++++++++++ 7 files changed, 55 insertions(+), 7 deletions(-) create mode 100644 host-tools.json diff --git a/AUDIT.md b/AUDIT.md index 63221824..c582b154 100644 --- a/AUDIT.md +++ b/AUDIT.md @@ -14,6 +14,14 @@ This audit is not occasional. Run it whenever you **create, adopt, or materially - **Onboarding a repo is complete only when it either passes this audit** (operational on every applicable check) **or carries a committed `reports//audit.md` plus a tracking issue** enumerating every residual delta. A repo that is partially set up but never audited is itself a **defect**, the exact state this process prevents. The create-to-conformance counterpart is [`STANDUP.md`][standup]. Because both read the same manifests, a repo stood up by that file passes this audit by construction. - **Touching a repo** (any conformance-affecting change) ends by re-running the applicable checks and **reconciling the registry entry to reality**: `status`, `types`, `releaseTrigger`, `workflowModel`, `driftNotes`. The registry records reality, not intent. [`spec/validate.py`][validate] proves the catalog is self-consistent, not that it matches the live repo. Closing that gap is this audit's job. The deterministic subset (settings, rulesets, secret names, file presence, per-scope Markdown section presence, workflow interface conformance, verbatim content, hub-hosted files a repo carries, branch facts) is mechanized in [`spec/audit.py`][audit-runner]: owner-initiated, run on demand when onboarding a repo, on suspected drift, or before fleet-wide changes. A required section missing from a carried Markdown file is a **drift finding**, not a letter, because a heading rename reads as missing and equivalence is judged by hand. A carried `interface` workflow (spec/fidelity-model.md) is checked by name and wiring (required jobs, the ruleset-bound check name, the artifact-name handoff, and the forbidden `artifact-ids:` fork), all at **drift**, since the body is owned and a rename is a hint to verify. A carried `verbatim` unit, whether a whole file (`.markdownlint-cli2.jsonc`) or a canonical workflow job region (the `github-release` job), is content-hashed against the hub's canonical after line-ending normalization. A mismatch is classified **stale** (matches a past hub revision, re-vendor) or **modified** (matches none, the repo changed fixed content), both at **drift**, since equivalence is intent-governed and a byte diff is a hint to review. +**Verify the host before running any hub tool.** The tools carry version floors, and a host below one answers `--version`, looks healthy, and produces a wrong answer, so a clean audit run from a broken host is a clean-looking result rather than a result. + +```shell +python3 scripts/host_gate.py --repo # run from a hub checkout, floors from spec/host-tools.json +``` + +Pass `--repo`, since the gate reads the target's own `host-tools.json` relative to it and defaults to the working directory, so a bare run layers the hub's declaration and prints the same digest either way. A finding is a **host** misconfiguration rather than a repo one, and [`docs/host-setup.md`][host-setup] is the contract it checks. + ## 1. Scope and Ground-Truth Branch Audit one repository at a time. Read the target's **`main` branch** as ground truth: `main` is the released, authoritative state. Read `develop` only to detect divergence. A stale or diverged `develop` (behind `main`, or diverged) is reported as a **drift finding**, never audited as the truth. Do not treat a `develop`-only file as present if it is absent on `main`. @@ -172,6 +180,7 @@ The convergence model: the hub audits and the agent **applies** the fixes via ta [governance-branching-model]: ./GOVERNANCE.md#branching-model [governance-hub-hosted-tooling]: ./GOVERNANCE.md#hub-hosted-tooling [governance-pr-review-etiquette]: ./GOVERNANCE.md#pr-review-etiquette +[host-setup]: ./docs/host-setup.md [project-types]: ./spec/project-types.json [readme-sections]: ./spec/readme-sections.json [readme-structure]: ./spec/readme-structure.md diff --git a/STANDUP.md b/STANDUP.md index 28e2c0ae..ac055d76 100644 --- a/STANDUP.md +++ b/STANDUP.md @@ -22,6 +22,14 @@ if [ "$(git config --global --get gpg.format)" = ssh ]; then ssh-add -L; else gp `--global` rather than the effective config, because the effective value depends on where the command runs: inside any existing repository a repo-local override wins, so a bare `git config --get user.email` there reports that repository's identity and hides the host setting this step exists to check. The two scopes together are what make the result sound, since this block proves the host is right and the block below proves nothing shadows it. +**Verify the host's tools in the same step, since identity is only half of what a standup needs from a machine.** The tools carry version floors, and a host below one does not fail cleanly: it answers `--version`, looks healthy, and produces a wrong answer, which is how both host defects this fleet has hit arrived. + +```shell +python3 scripts/host_gate.py --repo # run from a hub checkout, floors from spec/host-tools.json +``` + +Pass `--repo`, because that flag is what makes the repo's own `host-tools.json` count. The gate reads that file relative to `--repo`, which defaults to the working directory, so a bare run from a hub checkout layers the hub's declaration instead and prints the same healthy digest either way. A repo carrying no local file is silent by design, so the absence of a layering note is not evidence the flag was unnecessary. A finding here is a **host** misconfiguration to fix on the machine or surface to the maintainer, never something to patch per repo, and [`docs/host-setup.md`][host-setup] is the contract it checks. + The agent check branches rather than listing both forms, because they are alternatives and running the wrong one fails on a correctly configured host: an SSH host need not have `gpg` installed at all. Signing is **SSH or GPG**, so judge the format and its agent together rather than requiring `ssh`: what matters is that the configured format has a matching agent holding the key, which is the check [GOVERNANCE.md "Git and Commit Rules"][governance-git-and-commit-rules] prescribes. Any of these wrong or absent is a **host** misconfiguration to surface to the maintainer ([`docs/host-setup.md`][host-setup] is the setup procedure), not something to patch per repo. Patching it locally hides a broken host that then produces wrong identities in every other repo on that machine. After `git init` and before the first commit, confirm the repo added no override of its own. This one needs a repository, since `--local` fails outside one. Read it here and run it in section 0B, which places it between the init and the first commit, so nothing here is a prompt to init early: @@ -104,7 +112,7 @@ Capture the source, verify the capture **against the source**, and hold the veri ## 2. Carry the Baseline Files -Copy every [`spec/files.json`][files] entry whose `appliesTo` matches the repo's **selector set**, **adapted, not cloned**. The selector set is the repo's `types` plus its `workflowModel`, `releaseTrigger`, and `consumerModel`, so filtering on type alone silently drops the entries a non-type selector carries ([`spec/scope-model.md`][scope-model] defines the four namespaces and how they resolve). The prose files (`CODESTYLE.md`, `README.md`, and the like) describe the repo's own toolchain, so adapt them to reality rather than propagating template specifics verbatim (see the "Adapt before propagating" callout in [`CODESTYLE.md`][codestyle], since a verbatim copy that misdescribes the repo is rejected in review). The baseline covers `WORKFLOW.md`, `version.json`, the two rulesets, `.github/dependabot.yml`, `.editorconfig`, `.gitattributes`, the linter configs, and the per-type files (`.vscode/tasks.json` from the language's snippet, `codecov.yml`, `.dockerignore`, `Docker/README.md`). **Every repo carries `repo-config/main.json`**, and only the `develop` payload varies by workflow model: `repo-config/develop.json` for a release repo, `repo-config/operational/develop.json` for an operational one. +Copy every [`spec/files.json`][files] entry whose `appliesTo` matches the repo's **selector set**, **adapted, not cloned**. The selector set is the repo's `types` plus its `workflowModel`, `releaseTrigger`, and `consumerModel`, so filtering on type alone silently drops the entries a non-type selector carries ([`spec/scope-model.md`][scope-model] defines the four namespaces and how they resolve). The prose files (`CODESTYLE.md`, `README.md`, and the like) describe the repo's own toolchain, so adapt them to reality rather than propagating template specifics verbatim (see the "Adapt before propagating" callout in [`CODESTYLE.md`][codestyle], since a verbatim copy that misdescribes the repo is rejected in review). The baseline covers `WORKFLOW.md`, `version.json`, the two rulesets, `.github/dependabot.yml`, `.editorconfig`, `.gitattributes`, `host-tools.json`, the linter configs, and the per-type files (`.vscode/tasks.json` from the language's snippet, `codecov.yml`, `.dockerignore`, `Docker/README.md`). **Every repo carries `repo-config/main.json`**, and only the `develop` payload varies by workflow model: `repo-config/develop.json` for a release repo, `repo-config/operational/develop.json` for an operational one. **`version.json` is a file to carry and a floor to choose.** [`WORKFLOW.md`][workflow] D3.3 makes its `version` field the repo's own major.minor floor, with NBGV appending the git height as the patch, so the number carried in with the file is a claim about a release history the new repo does not have. Set it deliberately, at standup, before the first release: @@ -115,6 +123,8 @@ Copy every [`spec/files.json`][files] entry whose `appliesTo` matches the repo's **This decision is effectively one-way, which is why it belongs here.** Once a repo publishes against a floor, lowering it regresses the released version order, so a floor that was never chosen is kept rather than corrected. Inherited floors are the observed failure, not a hypothetical one: four operational config repos run on a floor none of them picked and have released against it. +**`host-tools.json` is carried at the repo's root, and it is not the fleet declaration.** [`spec/host-tools.json`][host-tools] states what every repo's procedures need and is the hub's to change. The carried root file states what this repo needs **beyond** that, so it is where a tool only this repo uses, or a floor only this repo requires, is declared. [`scripts/host_gate.py`][host-gate] layers the root file over the fleet one, tighten-only: an entry may add a tool, raise a floor, or turn an optional tool required, and may not lower a floor or turn a required tool optional, since that retires a fleet check from inside the repo it protects. A rejected relaxation is reported rather than dropped. A repo with nothing to add carries the stub with an empty `tools` list, the same footing as `OPERATIONS.md`, so the declaration is somewhere a reader finds rather than somewhere they must know to look. This repo's own root file is the worked example. + **Repo-specific content has a declared destination, not a judgment call.** The baseline is what a repo *carries*. Anything the repo knows that the fleet does not needs somewhere to live, and improvising a location per repo is what the destinations in [`spec/section-model.md`][section-model] exist to prevent. Four topical docs take it, chosen by what the content **is**: - [`CODESTYLE.md`][codestyle]: the repo's language and formatting conventions beyond the carried rules. @@ -187,7 +197,9 @@ The same [`AUDIT.md`][audit] run is the on-demand audit for any known repo, and [governance]: ./GOVERNANCE.md [governance-git-and-commit-rules]: ./GOVERNANCE.md#git-and-commit-rules [governance-repository-boundaries-and-write-safety]: ./GOVERNANCE.md#repository-boundaries-and-write-safety +[host-gate]: ./scripts/host_gate.py [host-setup]: ./docs/host-setup.md +[host-tools]: ./spec/host-tools.json [matrix]: ./reports/conformance-matrix.md [project-types]: ./spec/project-types.json [readme-structure]: ./spec/readme-structure.md diff --git a/docs/host-setup.md b/docs/host-setup.md index 96109f08..6283df76 100644 --- a/docs/host-setup.md +++ b/docs/host-setup.md @@ -19,6 +19,7 @@ This section is the **contract**: which tools a host needs and which repo proced | `git` | everything, and the identity and signing contract in [`STANDUP.md`][standup] step 0 | `git --version` | none | | `gh` | the PR and review loop, `gh api` queries, `repo-config/configure.sh` | `gh --version` | **2.47.0**, measured | | Python 3 | `scripts/` and `spec/` (standard library only, no packages to install) | `python3 --version`, or `py -3 --version` on native Windows | **3.13**, target | +| `jq` | the ruleset normalizer in `repo-config/configure.sh`, the ruleset diff in [`AUDIT.md`][audit] section 6, and payload regeneration | `jq --version` | **1.6**, target | | `docker` | the four linters, which run as pinned images rather than local installs | `docker --version` | none | | `uv` / `uvx` | coverage runs, and the Python toolchain (`ruff`, `pyright` or `mypy`) in a Python repo | `uv --version` | none | @@ -241,6 +242,7 @@ A host that fails any row is not ready for the procedure that row names, and the [agent-safety]: ../host-setup/agent-safety/README.md +[audit]: ../AUDIT.md [devcontainer]: ./devcontainer.md [governance-git-and-commit-rules]: ../GOVERNANCE.md#git-and-commit-rules [host-gate]: ../scripts/host_gate.py diff --git a/host-tools.json b/host-tools.json new file mode 100644 index 00000000..bda4eb4d --- /dev/null +++ b/host-tools.json @@ -0,0 +1,5 @@ +{ + "$schema": "./spec/host-tools.schema.json", + "note": "This repository's own host-tool declaration, layered over the fleet declaration in spec/host-tools.json by scripts/host_gate.py. The two are not the same file and do not hold the same thing. The fleet declaration states what every repository's procedures need and is the hub's to change. This one states what this repository needs beyond that, so it is where a tool only this repository uses, or a floor only this repository requires, is declared. Layering is tighten-only: an entry here may add a tool, raise a floor, or turn an optional tool required, and may not lower a floor or turn a required tool optional, because that would retire a fleet check from inside the repository it protects. A rejected relaxation is reported rather than dropped. The tools list is empty because this repository needs no tool the fleet declaration does not already carry, and the file is still present rather than absent, on the same footing as OPERATIONS.md: a repository with nothing to add carries the stub, so the declaration is somewhere a reader can find rather than somewhere they have to know to look.", + "tools": [] +} diff --git a/scripts/test_host_gate.py b/scripts/test_host_gate.py index bcc1ddc3..078a0d95 100755 --- a/scripts/test_host_gate.py +++ b/scripts/test_host_gate.py @@ -414,7 +414,7 @@ def test_the_declared_floors_are_the_ones_with_a_stated_reason(self): one in the data, which is what caught the python3 floor being added without this line. """ floors = {t['name'] for t in self.data['tools'] if t['minimum'] is not None} - self.assertEqual(floors, {'gh', 'git-restore-mtime', 'python3'}) + self.assertEqual(floors, {'gh', 'git-restore-mtime', 'jq', 'python3'}) def test_the_contract_table_carries_every_declared_floor(self): """docs/host-setup.md restates the floors, so the doc goes stale the moment the data moves. @@ -466,15 +466,21 @@ def norm(text): f'{cells[floor_col]!r}') def test_a_target_floor_says_so_rather_than_implying_a_defect(self): - """The python3 floor is a target, so its `why` has to distinguish itself from a measured one. + """A target floor's `why` has to distinguish itself from a measured one. A reader who takes a target floor for a measured one goes looking for a defect report that does not exist, which is the failure the two-kinds wording was written to prevent. + The set is asserted rather than one entry, so a third target floor added without the wording + fails here instead of reading as measured. Both members are named, since a check that only + counted them would pass on the wrong pair. """ - python3 = next(t for t in self.data['tools'] if t['name'] == 'python3') - self.assertEqual(python3['minimum'], '3.13') - self.assertIn('target', python3['why']) - self.assertIn('unverified rather than known broken', python3['why']) + targets = {'jq': '1.6', 'python3': '3.13'} + by_name = {t['name']: t for t in self.data['tools']} + for name, floor in targets.items(): + entry = by_name[name] + self.assertEqual(entry['minimum'], floor) + self.assertIn('target', entry['why']) + self.assertIn('unverified rather than known broken', entry['why']) if __name__ == '__main__': diff --git a/spec/files.json b/spec/files.json index 33660612..04933a04 100644 --- a/spec/files.json +++ b/spec/files.json @@ -17,6 +17,7 @@ { "path": ".markdownlint-cli2.jsonc", "fidelity": "verbatim", "whole": true, "appliesTo": "*" }, { "path": "cspell.json", "fidelity": "intent", "whole": true, "appliesTo": "*" }, { "path": ".gitignore", "appliesTo": "*" }, + { "path": "host-tools.json", "appliesTo": "*" }, { "path": "version.json", "fidelity": "intent", "intentRef": "WORKFLOW.md#d3---versioning-and-classification", "appliesTo": "*" }, { "path": "repo-config/develop.json", "fidelity": "verbatim", "whole": true, "appliesTo": ["release"] }, { "path": "repo-config/operational/develop.json", "fidelity": "verbatim", "whole": true, "appliesTo": ["operational"] }, diff --git a/spec/host-tools.json b/spec/host-tools.json index 874b00b3..61fbcd16 100644 --- a/spec/host-tools.json +++ b/spec/host-tools.json @@ -44,6 +44,19 @@ "windows": "Not applicable, since the tool is used by a Linux deploy path." } }, + { + "name": "jq", + "required": true, + "probes": [["jq", "--version"]], + "pattern": "jq-(\\d+(?:\\.\\d+)*)", + "minimum": "1.6", + "why": "Needed by the ruleset normalizer in repo-config/configure.sh, the ruleset diff in AUDIT.md section 6, and the payload regeneration in docs/repo-config-carry.md, so a host without it cannot apply, check, or audit a repository's branch configuration. The floor is a target rather than a measured breakage one version below it, and it says so rather than implying a defect nobody found. 1.6 is where walk/1 arrived, which makes it the lowest version at which every jq construct the fleet documents compiles with no portability workaround. Below it the fleet's own filters still run, because both of the ones that would need walk define their own recursion instead of calling it, so a host on 1.5 is unverified rather than known broken. What is measured is the failure mode rather than a version to avoid: on jq 1.5 walk/1 does not degrade, it fails to compile the filter, so a diff built on it reports drift on every ruleset it never compared, which is the inverse of the false clean the comparison exists to close. The neighbouring claim that keys_unsorted needs 1.6 is disproved rather than assumed, measured on jq-1.5-1-a5b5cbe where keys_unsorted evaluates and walk(.) answers a compile error, so the two builtins are not in the same position and the analogy between them is the whole of what carried that claim. A separate implementation is worth knowing about and is not this entry's subject: gh --jq is gojq built into the CLI rather than this binary, and it emits object keys sorted, which is why the section 6 filter canonicalizes key order itself rather than depending on whichever implementation produced its input.", + "source": { + "linux": "A distribution package is acceptable where it meets the floor and is the failing case where it does not, which is release-dependent rather than uniform: Debian trixie packages 1.7.1, and older releases sit lower. Install the upstream release from https://github.com/jqlang/jq where the platform's own package is below the floor.", + "macos": "Homebrew, which tracks upstream releases.", + "windows": "winget, which tracks upstream releases, so the hazard the Linux note describes does not arise." + } + }, { "name": "python3", "required": true, From db352f387873d00170a124ac9402f82b5677c87a Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Mon, 10 Aug 2026 10:20:43 -0700 Subject: [PATCH 2/8] Anchor the jq Floor to What the Platform Packages The floor moves to 1.7, which is better anchored than either candidate before it. Current Debian stable packages 1.7.1, so a distribution install satisfies the floor and no host needs a manual build to meet it, while an older release sits below and is the case the floor exists for. A floor at the newest release instead would fail a host running every documented filter correctly, and a floor at 1.6 would encode the walk/1 boundary rather than anything a host operator can act on. Two consequences swept rather than left. The floor now sits above 1.6, so walk/1 is available and the comments in AUDIT.md and repo-config/configure.sh that justified defining a recursion by its absence were made false by this change. Both now say the recursion is kept because it costs nothing and compiles below the floor as well, which is the reason that survives. The boundary is verified through the gate's own comparison rather than a restatement of it: 1.5, 1.6 and 1.6.1 fail, 1.7, 1.7.1 and 1.8.2 meet it. Co-Authored-By: Claude Opus 5 (1M context) --- AUDIT.md | 2 +- docs/host-setup.md | 2 +- repo-config/configure.sh | 2 +- scripts/test_host_gate.py | 2 +- spec/host-tools.json | 6 +++--- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/AUDIT.md b/AUDIT.md index c582b154..1ac6c706 100644 --- a/AUDIT.md +++ b/AUDIT.md @@ -90,7 +90,7 @@ Run [`WORKFLOW.md`][workflow]'s methodology against the repo's **own** Actions: # Rules sort on each rule's whole content, matching the key normalize_ruleset in audit.py sorts by. # Sorting on .type alone leaves two rules of one type in input order, so a reordered pair would read as drift. # canon sorts keys at every depth before serializing, because the committed payload is written key-sorted and the API returns its own order, so a bare tojson gives the same rule two different sort keys. - # It recurses rather than calling walk/1, which arrived in jq 1.6, and no declared floor puts a host above that. + # It recurses rather than calling walk/1, which the declared floor does make available, because the recursion costs nothing and compiles below the floor as well. # A host on jq 1.5 would not degrade on walk, it would fail to compile the filter and report drift on every ruleset it never compared, which is what repo-config/configure.sh defines its own recursion to avoid. canon='def canon: . as $in | if type == "object" then reduce (keys_unsorted|sort)[] as $k ({}; . + { ($k): ($in[$k]|canon) }) elif type == "array" then map(canon) else . end;' norm="$canon"'{name,target,enforcement,conditions,rules} | .rules|=sort_by(canon|tojson)' diff --git a/docs/host-setup.md b/docs/host-setup.md index 6283df76..dea57859 100644 --- a/docs/host-setup.md +++ b/docs/host-setup.md @@ -19,7 +19,7 @@ This section is the **contract**: which tools a host needs and which repo proced | `git` | everything, and the identity and signing contract in [`STANDUP.md`][standup] step 0 | `git --version` | none | | `gh` | the PR and review loop, `gh api` queries, `repo-config/configure.sh` | `gh --version` | **2.47.0**, measured | | Python 3 | `scripts/` and `spec/` (standard library only, no packages to install) | `python3 --version`, or `py -3 --version` on native Windows | **3.13**, target | -| `jq` | the ruleset normalizer in `repo-config/configure.sh`, the ruleset diff in [`AUDIT.md`][audit] section 6, and payload regeneration | `jq --version` | **1.6**, target | +| `jq` | the ruleset normalizer in `repo-config/configure.sh`, the ruleset diff in [`AUDIT.md`][audit] section 6, and payload regeneration | `jq --version` | **1.7**, target | | `docker` | the four linters, which run as pinned images rather than local installs | `docker --version` | none | | `uv` / `uvx` | coverage runs, and the Python toolchain (`ruff`, `pyright` or `mypy`) in a Python repo | `uv --version` | none | diff --git a/repo-config/configure.sh b/repo-config/configure.sh index d0716da7..d029a1cc 100755 --- a/repo-config/configure.sh +++ b/repo-config/configure.sh @@ -239,7 +239,7 @@ check_ruleset() { # payload-file - the live ruleset must match the committed pol # Dropping that would turn array order into false drift. # A scalar array sorts directly, and required_status_checks sorts by context, its identifying field. local ptypes norm - # The walk/1 builtin arrived in jq 1.6, so it is defined here rather than called. + # The walk/1 builtin arrived in jq 1.6, which the declared floor makes available, and it is defined here rather than called so the filter also compiles below the floor. # A host on jq 1.5 would otherwise not degrade, it would fail to compile the filter. # The check_ruleset function would then report drift on every parameterized rule it never actually compared. # That is the inverse of the false clean this comparison was written to close, so the portable definition is worth its length. diff --git a/scripts/test_host_gate.py b/scripts/test_host_gate.py index 078a0d95..d9362deb 100755 --- a/scripts/test_host_gate.py +++ b/scripts/test_host_gate.py @@ -474,7 +474,7 @@ def test_a_target_floor_says_so_rather_than_implying_a_defect(self): fails here instead of reading as measured. Both members are named, since a check that only counted them would pass on the wrong pair. """ - targets = {'jq': '1.6', 'python3': '3.13'} + targets = {'jq': '1.7', 'python3': '3.13'} by_name = {t['name']: t for t in self.data['tools']} for name, floor in targets.items(): entry = by_name[name] diff --git a/spec/host-tools.json b/spec/host-tools.json index 61fbcd16..22b5638d 100644 --- a/spec/host-tools.json +++ b/spec/host-tools.json @@ -49,10 +49,10 @@ "required": true, "probes": [["jq", "--version"]], "pattern": "jq-(\\d+(?:\\.\\d+)*)", - "minimum": "1.6", - "why": "Needed by the ruleset normalizer in repo-config/configure.sh, the ruleset diff in AUDIT.md section 6, and the payload regeneration in docs/repo-config-carry.md, so a host without it cannot apply, check, or audit a repository's branch configuration. The floor is a target rather than a measured breakage one version below it, and it says so rather than implying a defect nobody found. 1.6 is where walk/1 arrived, which makes it the lowest version at which every jq construct the fleet documents compiles with no portability workaround. Below it the fleet's own filters still run, because both of the ones that would need walk define their own recursion instead of calling it, so a host on 1.5 is unverified rather than known broken. What is measured is the failure mode rather than a version to avoid: on jq 1.5 walk/1 does not degrade, it fails to compile the filter, so a diff built on it reports drift on every ruleset it never compared, which is the inverse of the false clean the comparison exists to close. The neighbouring claim that keys_unsorted needs 1.6 is disproved rather than assumed, measured on jq-1.5-1-a5b5cbe where keys_unsorted evaluates and walk(.) answers a compile error, so the two builtins are not in the same position and the analogy between them is the whole of what carried that claim. A separate implementation is worth knowing about and is not this entry's subject: gh --jq is gojq built into the CLI rather than this binary, and it emits object keys sorted, which is why the section 6 filter canonicalizes key order itself rather than depending on whichever implementation produced its input.", + "minimum": "1.7", + "why": "Needed by the ruleset normalizer in repo-config/configure.sh, the ruleset diff in AUDIT.md section 6, and the payload regeneration in docs/repo-config-carry.md, so a host without it cannot apply, check, or audit a repository's branch configuration. The floor is a target rather than a measured breakage one version below it, and it says so rather than implying a defect nobody found. It is anchored to what the fleet's own platform provides: current Debian stable packages 1.7.1, so a distribution install satisfies the floor and no host needs a manual build to meet it, while an older release sits below and is the case the floor is for. That anchor is what keeps it actionable, since a floor set to the newest release instead would fail a host running every documented filter correctly. Two lower versions matter to a reader and neither is the floor. 1.6 is where walk/1 arrived, so the floor sits above it and walk is available, and the filters nonetheless define their own recursion, which costs nothing and keeps them compiling below the floor. On jq 1.5 walk/1 does not degrade, it fails to compile the filter, so a diff built on it reports drift on every ruleset it never compared, which is the inverse of the false clean the comparison exists to close. The neighbouring claim that keys_unsorted needs 1.6 is disproved rather than assumed, measured on jq-1.5-1-a5b5cbe where keys_unsorted evaluates and walk(.) answers a compile error, so the two builtins are not in the same position and the analogy between them is the whole of what carried that claim. A host below the floor is therefore unverified rather than known broken, since every filter here is written to a feature set older than the floor requires. A separate implementation is worth knowing about and is not this entry's subject: gh --jq is gojq built into the CLI rather than this binary, and it emits object keys sorted, which is why the section 6 filter canonicalizes key order itself rather than depending on whichever implementation produced its input.", "source": { - "linux": "A distribution package is acceptable where it meets the floor and is the failing case where it does not, which is release-dependent rather than uniform: Debian trixie packages 1.7.1, and older releases sit lower. Install the upstream release from https://github.com/jqlang/jq where the platform's own package is below the floor.", + "linux": "The distribution package where it meets the floor, which on current Debian stable it does at 1.7.1, so the floor is chosen to be satisfiable without a manual build. An older release sits below it, and there the upstream release at https://github.com/jqlang/jq is the source. Preferring the current upstream release everywhere keeps the fleet on one version in practice, which is what the other two platforms already do.", "macos": "Homebrew, which tracks upstream releases.", "windows": "winget, which tracks upstream releases, so the hazard the Linux note describes does not arise." } From cc5b603a2d2c4b2472dafa7a0c84af6d55b02bea Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Mon, 10 Aug 2026 10:24:39 -0700 Subject: [PATCH 3/8] Put the Rationale Beside the Finding, Not Inside It Three review findings, and the first is the one worth the code change. A floor failure inlined the entry's why into the one line a reader scans, and an entry's why runs to a paragraph, so the finding was unreadable in a terminal or a CI log. Shortening one entry would have left the neighbouring one, nearly as long, doing the same thing. So the head line is now the scannable fact and the rationale follows it on its own indented line, which every entry gets. The printer splits every newline rather than the first, since partitioning once left later continuation lines inside one printed line without their indent. The STANDUP.md placeholder was worse than ambiguous, it was impossible. Section 0 runs before the git init in section 0B, so there is no target checkout to point --repo at, and the repo's own host-tools.json arrives with the baseline in section 2. That step therefore runs bare against the fleet floors, says why it is the one place the flag is omitted, and points at a second run after section 2 where the repo's own floors can count. The claim that a bare run prints the same healthy digest either way was wrong and is gone from both files that made it. A successful layering changes the tool count and emits a note, and now that host-tools.json is a carried baseline file every conformant repo has one to layer. The accurate statement is the one that matters anyway: omitting --repo does not read the target's declaration at all, so its floors go unapplied and the run reports nothing about the omission. Verified by rendering a real failure rather than reasoning about the format, layering a local entry that raises the jq floor above the installed version, which also exercises the tighten-only direction. Co-Authored-By: Claude Opus 5 (1M context) --- AUDIT.md | 2 +- STANDUP.md | 12 ++++++++++-- scripts/host_gate.py | 12 ++++++++---- 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/AUDIT.md b/AUDIT.md index 1ac6c706..aa1ba72d 100644 --- a/AUDIT.md +++ b/AUDIT.md @@ -20,7 +20,7 @@ This audit is not occasional. Run it whenever you **create, adopt, or materially python3 scripts/host_gate.py --repo # run from a hub checkout, floors from spec/host-tools.json ``` -Pass `--repo`, since the gate reads the target's own `host-tools.json` relative to it and defaults to the working directory, so a bare run layers the hub's declaration and prints the same digest either way. A finding is a **host** misconfiguration rather than a repo one, and [`docs/host-setup.md`][host-setup] is the contract it checks. +Pass `--repo`, since the gate reads the target's own `host-tools.json` relative to it and defaults to the working directory. Omitting it does not read the target's declaration at all, so every floor that repo adds goes unapplied, and the run reports nothing about the omission. A finding is a **host** misconfiguration rather than a repo one, and [`docs/host-setup.md`][host-setup] is the contract it checks. ## 1. Scope and Ground-Truth Branch diff --git a/STANDUP.md b/STANDUP.md index ac055d76..47db9e0a 100644 --- a/STANDUP.md +++ b/STANDUP.md @@ -25,10 +25,18 @@ if [ "$(git config --global --get gpg.format)" = ssh ]; then ssh-add -L; else gp **Verify the host's tools in the same step, since identity is only half of what a standup needs from a machine.** The tools carry version floors, and a host below one does not fail cleanly: it answers `--version`, looks healthy, and produces a wrong answer, which is how both host defects this fleet has hit arrived. ```shell -python3 scripts/host_gate.py --repo # run from a hub checkout, floors from spec/host-tools.json +python3 scripts/host_gate.py # from a hub checkout, against the fleet floors in spec/host-tools.json ``` -Pass `--repo`, because that flag is what makes the repo's own `host-tools.json` count. The gate reads that file relative to `--repo`, which defaults to the working directory, so a bare run from a hub checkout layers the hub's declaration instead and prints the same healthy digest either way. A repo carrying no local file is silent by design, so the absence of a layering note is not evidence the flag was unnecessary. A finding here is a **host** misconfiguration to fix on the machine or surface to the maintainer, never something to patch per repo, and [`docs/host-setup.md`][host-setup] is the contract it checks. +**No `--repo` here, and that is the one place in these procedures where it is omitted deliberately.** The flag points the gate at a repo's own `host-tools.json` so its floors are layered over the fleet ones, and at this step there is no repo to point it at: the target does not exist yet, since this section runs before the `git init` in section 0B, and the file itself arrives with the baseline in section 2. So this run checks the fleet floors, which is all that is knowable now. + +**Re-run it with `--repo` once section 2 has carried the file**, because a bare run does not read the target's declaration at all, so any floor that repo adds goes unapplied and the run cannot tell you it was skipped: + +```shell +python3 scripts/host_gate.py --repo # after section 2, so the repo's own floors count +``` + +A finding at either point is a **host** misconfiguration to fix on the machine or surface to the maintainer, never something to patch per repo, and [`docs/host-setup.md`][host-setup] is the contract it checks. The agent check branches rather than listing both forms, because they are alternatives and running the wrong one fails on a correctly configured host: an SSH host need not have `gpg` installed at all. Signing is **SSH or GPG**, so judge the format and its agent together rather than requiring `ssh`: what matters is that the configured format has a matching agent holding the key, which is the check [GOVERNANCE.md "Git and Commit Rules"][governance-git-and-commit-rules] prescribes. Any of these wrong or absent is a **host** misconfiguration to surface to the maintainer ([`docs/host-setup.md`][host-setup] is the setup procedure), not something to patch per repo. Patching it locally hides a broken host that then produces wrong identities in every other repo on that machine. diff --git a/scripts/host_gate.py b/scripts/host_gate.py index 7535ef79..cb0d5dde 100755 --- a/scripts/host_gate.py +++ b/scripts/host_gate.py @@ -293,7 +293,10 @@ def check(tools: list[dict]) -> list[str]: elif compare(found, floor) < 0: # The remedy rides on the finding rather than beside it, since a separate line would count as a second issue. src = (tool.get('source') or {}).get('linux' if sys.platform.startswith('linux') else 'macos' if sys.platform == 'darwin' else 'windows') - issues.append(f'{name} {version} is below the {floor_text} floor - {tool["why"]}' + # The head line stays the scannable fact and the rationale follows it, rather than being inlined into it. + # An entry's why runs to a paragraph, so inlining made the one line a reader scans in CI output unreadable, and the longest entry is not the one that needs it least. + issues.append(f'{name} {version} is below the {floor_text} floor' + + f'\nWHY: {tool["why"]}' + (f'\nINSTALL FROM: {src}' if src else '')) else: NOTES.append(f'{name} {version} meets the {floor_text} floor') @@ -333,10 +336,11 @@ def main(argv: list[str] | None = None) -> int: status = 'FAIL' if issues else 'ok' print(f'[{status:4}] host-tools {len(issues)} issue(s) over {len(tools)} declared tool(s)') for i in issues: - # A finding may carry a continuation line, which is indented under it rather than counted beside it. - head, _, tail = i.partition('\n') + # A finding may carry continuation lines, indented under it rather than counted beside it. + # Every line is split rather than only the first, since partitioning once leaves later newlines inside one printed line and loses the indent on all but the first. + head, *rest = i.split('\n') print(f' {head}') - if tail: + for tail in rest: print(f' {tail}') if not a.quiet: # After the findings and outside the count, since a note is not one. From 1dcff2c012d964fbdfb4859b1543673d56df53ef Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Mon, 10 Aug 2026 10:46:27 -0700 Subject: [PATCH 4/8] Give the Overlay Its Own Schema The carried root host-tools.json pointed at spec/host-tools.schema.json, and that schema forbids both shapes an overlay exists for. Its tools array carries minItems 1, which rejects the empty list a repository with nothing to add carries, and its per-entry required list demands name, probes, pattern, minimum and why, which rejects the partial entry that lets a repository raise one floor without restating a whole tool. The stub shipped schema-invalid. Neither is visible from running anything. CI validates no JSON schema and jsonschema is not installed, so the pointer's only reader is a schema-aware editor, which means the file read as invalid exactly where it is edited and nowhere else. spec/host-tools-local.schema.json states the overlay format instead: an empty list is allowed, only name is required, and the description records that the gate rather than the schema is the authority on tighten-only layering, since a schema compares nothing against the fleet declaration. Four tests hold the split open. The overlay points at its own schema, the two schemas differ on both constraints, the shipped stub satisfies its own, and the overlay would still fail the fleet schema, which is the last one's whole point: if that ever stops being true the split has no justification left and the test says so rather than leaving two schemas nobody can tell apart. Co-Authored-By: Claude Opus 5 (1M context) --- host-tools.json | 4 +-- scripts/test_host_gate.py | 57 +++++++++++++++++++++++++++++++ spec/host-tools-local.schema.json | 38 +++++++++++++++++++++ 3 files changed, 97 insertions(+), 2 deletions(-) create mode 100644 spec/host-tools-local.schema.json diff --git a/host-tools.json b/host-tools.json index bda4eb4d..e6a996bc 100644 --- a/host-tools.json +++ b/host-tools.json @@ -1,5 +1,5 @@ { - "$schema": "./spec/host-tools.schema.json", - "note": "This repository's own host-tool declaration, layered over the fleet declaration in spec/host-tools.json by scripts/host_gate.py. The two are not the same file and do not hold the same thing. The fleet declaration states what every repository's procedures need and is the hub's to change. This one states what this repository needs beyond that, so it is where a tool only this repository uses, or a floor only this repository requires, is declared. Layering is tighten-only: an entry here may add a tool, raise a floor, or turn an optional tool required, and may not lower a floor or turn a required tool optional, because that would retire a fleet check from inside the repository it protects. A rejected relaxation is reported rather than dropped. The tools list is empty because this repository needs no tool the fleet declaration does not already carry, and the file is still present rather than absent, on the same footing as OPERATIONS.md: a repository with nothing to add carries the stub, so the declaration is somewhere a reader can find rather than somewhere they have to know to look.", + "$schema": "./spec/host-tools-local.schema.json", + "note": "This repository's own host-tool declaration, layered over the fleet declaration in spec/host-tools.json by scripts/host_gate.py. The two are not the same file, do not hold the same thing, and do not share a schema: an overlay allows an empty tools list and a partial entry, and the fleet declaration requires at least one entry and every field of each. The fleet declaration states what every repository's procedures need and is the hub's to change. This one states what this repository needs beyond that, so it is where a tool only this repository uses, or a floor only this repository requires, is declared. Layering is tighten-only: an entry here may add a tool, raise a floor, or turn an optional tool required, and may not lower a floor or turn a required tool optional, because that would retire a fleet check from inside the repository it protects. A rejected relaxation is reported rather than dropped. The tools list is empty because this repository needs no tool the fleet declaration does not already carry, and the file is still present rather than absent, on the same footing as OPERATIONS.md: a repository with nothing to add carries the stub, so the declaration is somewhere a reader can find rather than somewhere they have to know to look.", "tools": [] } diff --git a/scripts/test_host_gate.py b/scripts/test_host_gate.py index d9362deb..de8bc180 100755 --- a/scripts/test_host_gate.py +++ b/scripts/test_host_gate.py @@ -384,6 +384,63 @@ def test_a_missing_file_is_a_diagnostic_rather_than_a_raise(self): self.assertIsInstance(host_gate.read_declaration(Path('/definitely/not/here.json'), 'declaration'), str) +class TestLocalOverlay(unittest.TestCase): + """This repo's own root host-tools.json, and the schema it points at. + + The overlay borrowed the fleet schema once, and that schema forbids both shapes an overlay is for: + `minItems: 1` rejects the empty list a repo with nothing to add carries, and the per-entry + `required` list rejects the partial entry that lets a repo raise one floor without restating a + tool. Neither is caught by running the gate, because CI runs no JSON-schema validation at all and + the pointer's only reader is a schema-aware editor, so the file showed as invalid where it is + edited and nowhere else. + """ + + def setUp(self): + self.root = host_gate.SPEC.parent.parent + self.overlay = json.loads((self.root / 'host-tools.json').read_text(encoding='utf-8')) + self.local_schema = json.loads((self.root / 'spec' / 'host-tools-local.schema.json').read_text(encoding='utf-8')) + self.fleet_schema = json.loads((self.root / 'spec' / 'host-tools.schema.json').read_text(encoding='utf-8')) + + def test_the_overlay_points_at_the_overlay_schema(self): + self.assertEqual(self.overlay['$schema'], './spec/host-tools-local.schema.json') + + def test_the_two_schemas_differ_where_the_overlay_needs_them_to(self): + """Asserted as a difference rather than as two absolute values. + + A change that tightened the overlay schema back to the fleet one would satisfy a check written + against either file alone, since each would still be internally consistent. + """ + self.assertEqual(self.fleet_schema['properties']['tools']['minItems'], 1) + self.assertEqual(self.local_schema['properties']['tools']['minItems'], 0) + self.assertEqual(self.fleet_schema['$defs']['tool']['required'], + ['name', 'probes', 'pattern', 'minimum', 'why']) + self.assertEqual(self.local_schema['$defs']['override']['required'], ['name']) + + def test_the_shipped_overlay_satisfies_its_own_schema(self): + """The two constraints that moved, checked directly rather than through a validator. + + jsonschema is not installed and CI validates no schema, so a dependency-free check is the only + one that runs anywhere. + """ + tools = self.overlay['tools'] + self.assertIsInstance(tools, list) + self.assertGreaterEqual(len(tools), self.local_schema['properties']['tools']['minItems']) + allowed = set(self.local_schema['$defs']['override']['properties']) + for entry in tools: + self.assertIn('name', entry, 'an overlay entry names the tool it overrides') + self.assertFalse(set(entry) - allowed, f'unknown overlay field(s) in {entry!r}') + + def test_the_overlay_would_be_invalid_under_the_fleet_schema(self): + """The reason the separate schema exists, asserted so it cannot quietly stop being true. + + If the overlay ever satisfies the fleet schema, the split has no justification left and this + test says so rather than leaving two schemas nobody can tell apart. + """ + violates_min = len(self.overlay['tools']) < self.fleet_schema['properties']['tools']['minItems'] + self.assertTrue(violates_min, + 'the overlay no longer needs its own schema for the empty-list case') + + class TestShippedDeclaration(unittest.TestCase): """The file this repo actually ships, read rather than assumed.""" diff --git a/spec/host-tools-local.schema.json b/spec/host-tools-local.schema.json new file mode 100644 index 00000000..5d749c81 --- /dev/null +++ b/spec/host-tools-local.schema.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/ptr727/ProjectTemplate/spec/host-tools-local.schema.json", + "title": "Repository host tool overlay", + "description": "A repository's own root host-tools.json, which scripts/host_gate.py layers over the fleet declaration in spec/host-tools.json. This is a different format from that file and needs its own schema rather than borrowing it, in two ways a reader hits immediately. An empty tools list is the ordinary case, because a repository with nothing to add still carries the file, where an empty fleet declaration would mean no host contract at all. And an entry may be partial, carrying a name plus only the fields it changes, which is what lets a repository raise one floor without restating a whole tool, where the fleet declaration requires every field of every entry. Only name is required here, since an entry that names no tool cannot be matched against one. The gate rather than this schema is the authority on what an overlay may do: layering is tighten-only, so an entry may add a tool, raise a floor, or turn an optional tool required, and may not lower a floor or turn a required tool optional, and a rejected relaxation is reported rather than dropped. A schema cannot express that, because it compares nothing against the fleet declaration.", + "type": "object", + "required": ["tools"], + "additionalProperties": false, + "properties": { + "$schema": { "type": "string" }, + "note": { "type": "string" }, + "tools": { + "type": "array", + "minItems": 0, + "items": { "$ref": "#/$defs/override" } + } + }, + "$defs": { + "override": { + "type": "object", + "required": ["name"], + "additionalProperties": false, + "properties": { + "name": { "type": "string", "minLength": 1 }, + "required": { "type": "boolean" }, + "probes": { + "type": "array", + "minItems": 1, + "items": { "type": "array", "minItems": 1, "items": { "type": "string", "minLength": 1 } } + }, + "pattern": { "type": "string", "minLength": 1 }, + "minimum": { "type": ["string", "null"] }, + "why": { "type": "string", "minLength": 1 }, + "source": { "type": "object" } + } + } + } +} From 3aea9ac0431dccbae1a43e7432de982112c5c9ea Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Mon, 10 Aug 2026 10:53:35 -0700 Subject: [PATCH 5/8] Say That a Carried Copy Leaves the Schema Pointer Behind The hub's stub points at ./spec/host-tools-local.schema.json and no selector carries a schema file, so a repository copying the stub would hold a relative pointer to a path it does not have, and a schema-aware editor would report the file invalid for a reason nobody there could fix. That is the dangling-pointer problem this branch just fixed for the hub, reintroduced one step downstream. The fleet already answers it and the answer is not a new rule. The hub's spec/secrets.json carries ./secrets.schema.json and every downstream copy of that file omits the key, checked against Blog, PlexCleaner and Utilities. STANDUP.md section 2 now states that a carried copy drops the pointer and why, and the stub's own note says it too, since the stub is what a repository copies and a worked example that omits its own caveat is a trap. Co-Authored-By: Claude Opus 5 (1M context) --- STANDUP.md | 4 +++- host-tools.json | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/STANDUP.md b/STANDUP.md index 47db9e0a..efaca8ed 100644 --- a/STANDUP.md +++ b/STANDUP.md @@ -131,7 +131,9 @@ Copy every [`spec/files.json`][files] entry whose `appliesTo` matches the repo's **This decision is effectively one-way, which is why it belongs here.** Once a repo publishes against a floor, lowering it regresses the released version order, so a floor that was never chosen is kept rather than corrected. Inherited floors are the observed failure, not a hypothetical one: four operational config repos run on a floor none of them picked and have released against it. -**`host-tools.json` is carried at the repo's root, and it is not the fleet declaration.** [`spec/host-tools.json`][host-tools] states what every repo's procedures need and is the hub's to change. The carried root file states what this repo needs **beyond** that, so it is where a tool only this repo uses, or a floor only this repo requires, is declared. [`scripts/host_gate.py`][host-gate] layers the root file over the fleet one, tighten-only: an entry may add a tool, raise a floor, or turn an optional tool required, and may not lower a floor or turn a required tool optional, since that retires a fleet check from inside the repo it protects. A rejected relaxation is reported rather than dropped. A repo with nothing to add carries the stub with an empty `tools` list, the same footing as `OPERATIONS.md`, so the declaration is somewhere a reader finds rather than somewhere they must know to look. This repo's own root file is the worked example. +**`host-tools.json` is carried at the repo's root, and it is not the fleet declaration.** [`spec/host-tools.json`][host-tools] states what every repo's procedures need and is the hub's to change. The carried root file states what this repo needs **beyond** that, so it is where a tool only this repo uses, or a floor only this repo requires, is declared. [`scripts/host_gate.py`][host-gate] layers the root file over the fleet one, tighten-only: an entry may add a tool, raise a floor, or turn an optional tool required, and may not lower a floor or turn a required tool optional, since that retires a fleet check from inside the repo it protects. A rejected relaxation is reported rather than dropped. A repo with nothing to add carries the stub with an empty `tools` list, the same footing as `OPERATIONS.md`, so the declaration is somewhere a reader finds rather than somewhere they must know to look. + +**The carried copy drops the `$schema` pointer, and that is not an oversight to correct.** The schemas are hub-only and no selector carries one, so a relative pointer copied downstream resolves to a path that repo does not have, and a schema-aware editor then reports the file invalid for a reason the repo cannot fix. This is settled fleet practice rather than a new rule: the hub's `spec/secrets.json` carries `./secrets.schema.json` and every downstream copy of that file omits the key. Copy the structure and leave the pointer behind. **Repo-specific content has a declared destination, not a judgment call.** The baseline is what a repo *carries*. Anything the repo knows that the fleet does not needs somewhere to live, and improvising a location per repo is what the destinations in [`spec/section-model.md`][section-model] exist to prevent. Four topical docs take it, chosen by what the content **is**: diff --git a/host-tools.json b/host-tools.json index e6a996bc..6e8177f9 100644 --- a/host-tools.json +++ b/host-tools.json @@ -1,5 +1,5 @@ { "$schema": "./spec/host-tools-local.schema.json", - "note": "This repository's own host-tool declaration, layered over the fleet declaration in spec/host-tools.json by scripts/host_gate.py. The two are not the same file, do not hold the same thing, and do not share a schema: an overlay allows an empty tools list and a partial entry, and the fleet declaration requires at least one entry and every field of each. The fleet declaration states what every repository's procedures need and is the hub's to change. This one states what this repository needs beyond that, so it is where a tool only this repository uses, or a floor only this repository requires, is declared. Layering is tighten-only: an entry here may add a tool, raise a floor, or turn an optional tool required, and may not lower a floor or turn a required tool optional, because that would retire a fleet check from inside the repository it protects. A rejected relaxation is reported rather than dropped. The tools list is empty because this repository needs no tool the fleet declaration does not already carry, and the file is still present rather than absent, on the same footing as OPERATIONS.md: a repository with nothing to add carries the stub, so the declaration is somewhere a reader can find rather than somewhere they have to know to look.", + "note": "This repository's own host-tool declaration, layered over the fleet declaration in spec/host-tools.json by scripts/host_gate.py. The two are not the same file, do not hold the same thing, and do not share a schema: an overlay allows an empty tools list and a partial entry, and the fleet declaration requires at least one entry and every field of each. The fleet declaration states what every repository's procedures need and is the hub's to change. This one states what this repository needs beyond that, so it is where a tool only this repository uses, or a floor only this repository requires, is declared. Layering is tighten-only: an entry here may add a tool, raise a floor, or turn an optional tool required, and may not lower a floor or turn a required tool optional, because that would retire a fleet check from inside the repository it protects. A rejected relaxation is reported rather than dropped. The tools list is empty because this repository needs no tool the fleet declaration does not already carry, and the file is still present rather than absent, on the same footing as OPERATIONS.md: a repository with nothing to add carries the stub, so the declaration is somewhere a reader can find rather than somewhere they have to know to look. A repository copying this file leaves the $schema pointer behind, because the schemas are hub-only and no selector carries one, so a relative pointer resolves to a path that repository does not have and a schema-aware editor reports the file invalid for a reason nobody there can fix. The carried spec/secrets.json already works that way, holding the pointer here and omitting it in every downstream copy.", "tools": [] } From 39b152749d97642932f280a03094f0d87124b4be Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Mon, 10 Aug 2026 11:00:31 -0700 Subject: [PATCH 6/8] Assert the Schemas' Permissiveness, Not Today's Overlay The test asserting that the shipped overlay violates the fleet schema coupled a schema-design invariant to one data instance. An overlay that legitimately grew a full tool entry would satisfy the fleet schema and fail that test while the split stayed exactly as justified, so it would have failed for being right. What justifies the split is that the overlay schema accepts everything the fleet schema does and more. That is now asserted in both directions over constructed documents: a full entry passes both, and an empty tools list and a partial entry pass the overlay and fail the fleet schema. The helper reads only tools.minItems and the per-entry required list, which is the whole of what separates the two, and says it is not a validator rather than implying it is. jsonschema is not installed and CI validates no schema, so a dependency-free check of the two axes is what can run. Checked the replacement against the scenario that motivated it, an overlay holding a full entry, where it passes and the old test would have failed. Then against two mutants, tightening minItems back to 1 and demanding every field, each of which it catches. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/test_host_gate.py | 42 ++++++++++++++++++++++++++++++++------- 1 file changed, 35 insertions(+), 7 deletions(-) diff --git a/scripts/test_host_gate.py b/scripts/test_host_gate.py index de8bc180..e2b3b189 100755 --- a/scripts/test_host_gate.py +++ b/scripts/test_host_gate.py @@ -430,15 +430,43 @@ def test_the_shipped_overlay_satisfies_its_own_schema(self): self.assertIn('name', entry, 'an overlay entry names the tool it overrides') self.assertFalse(set(entry) - allowed, f'unknown overlay field(s) in {entry!r}') - def test_the_overlay_would_be_invalid_under_the_fleet_schema(self): - """The reason the separate schema exists, asserted so it cannot quietly stop being true. + def accepts(self, schema, doc): + """Whether a document satisfies the two constraints the overlay schema deliberately relaxes. - If the overlay ever satisfies the fleet schema, the split has no justification left and this - test says so rather than leaving two schemas nobody can tell apart. + Only `tools.minItems` and the per-entry `required` list are read, which is the whole of what + separates these two schemas. It is not a JSON-schema validator and does not pretend to be one: + jsonschema is not installed and CI validates no schema, so a dependency-free check of the two + axes under discussion is what can actually run. """ - violates_min = len(self.overlay['tools']) < self.fleet_schema['properties']['tools']['minItems'] - self.assertTrue(violates_min, - 'the overlay no longer needs its own schema for the empty-list case') + tools = doc['tools'] + if len(tools) < schema['properties']['tools']['minItems']: + return False + entry_def = schema['$defs']['tool' if 'tool' in schema['$defs'] else 'override'] + return all(not set(entry_def['required']) - set(e) for e in tools) + + def test_the_overlay_schema_is_strictly_the_more_permissive_of_the_two(self): + """The justification for the split, stated about the schemas rather than about today's data. + + This replaced a check that the shipped overlay violates the fleet schema. That coupled a + schema-design invariant to one data instance: an overlay that legitimately grew a full tool + entry would satisfy the fleet schema and fail the test, while the split stayed just as + justified. What justifies it is that the overlay schema accepts everything the fleet schema + does and more, so the assertion is made in both directions over constructed documents. + """ + full = {'name': 'jq', 'probes': [['jq', '--version']], 'pattern': 'jq-(.*)', + 'minimum': '1.7', 'why': 'because'} + empty_list = {'tools': []} + partial_entry = {'tools': [{'name': 'jq', 'minimum': '1.7'}]} + full_entry = {'tools': [full]} + + # Everything the fleet schema accepts, the overlay schema accepts too. + self.assertTrue(self.accepts(self.fleet_schema, full_entry)) + self.assertTrue(self.accepts(self.local_schema, full_entry)) + + # And the overlay accepts two shapes the fleet schema rejects, which is why it exists. + for doc, what in ((empty_list, 'an empty tools list'), (partial_entry, 'a partial entry')): + self.assertFalse(self.accepts(self.fleet_schema, doc), f'the fleet schema now accepts {what}') + self.assertTrue(self.accepts(self.local_schema, doc), f'the overlay schema rejects {what}') class TestShippedDeclaration(unittest.TestCase): From e31939d1f212d89a5a359006039847e775c84755 Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Mon, 10 Aug 2026 11:07:22 -0700 Subject: [PATCH 7/8] Read the Letters Before Routing on Their Count This branch makes host-tools.json a carried baseline file, so on its next audit every repository in the fleet reports one file absent at once, through no fault of its own. RESYNC.md section 0 routes a letter-heavy run to STANDUP.md, and the two together would send a perfectly stood-up repository off to be stood up again over a file the hub added yesterday. The routing rule now says what a few letters usually mean: the baseline grew, so the file is carried inside the resync at the point in section 3 where its class belongs. A handful naming recent additions is a resync, and a baseline's worth naming the instruction set is a standup. Co-Authored-By: Claude Opus 5 (1M context) --- RESYNC.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/RESYNC.md b/RESYNC.md index 46cccbe4..4b89a107 100644 --- a/RESYNC.md +++ b/RESYNC.md @@ -14,6 +14,8 @@ Three states look similar from inside a repository and take different procedures **The distinction is measured, not assumed.** `spec/audit.py ` reports an absent carried file as a `LETTER` and a present-but-stale one as a `DRIFT`, so the finding kinds themselves say which procedure the repository is owed. A run that is mostly letters is a repository that needs [`STANDUP.md`][standup]. +**A few letters are not that, and the ordinary cause is the baseline having grown.** When the manifest gains an entry, every repository in the fleet reports that one file absent on its next audit, all at once and through no fault of its own. That is a file to carry inside a resync, at the point in section 3 where its class belongs, rather than evidence the repository was never stood up. Read the letters before routing on their count: a handful naming files the hub added recently is a resync, and a baseline's worth naming the instruction set is a standup. + ## 1. Reach the Hub, and Verify the Host **Fetch a hub checkout of your own immediately before reading it.** A clone is whatever it last fetched rather than the branch it names, so a stale one answers confidently instead of failing, and a resync driven from a stale hub converges a repository onto a revision that is already history. Read `main`, the promoted and gated state, per [GOVERNANCE.md "Hub-Hosted Tooling"][governance-hub-hosted-tooling]. Work only in that checkout rather than in one that another task is using. From 0e50dec8520bdd9448c83b52bb2b1c606305ec3e Mon Sep 17 00:00:00 2001 From: Pieter Viljoen Date: Mon, 10 Aug 2026 11:13:40 -0700 Subject: [PATCH 8/8] Make the Shared Fields Match, and Check That They Do The overlay schema was hand-written from the fleet one and quietly relaxed two more constraints than the two it means to. minimum lost its version pattern, so a schema-aware editor accepted a floor the gate rejects, and source lost its named platforms and its closed property set, so a key reaching no platform read as valid. Meanwhile the tests beside it said only two axes differed, which made the claim wrong rather than merely incomplete. Both are tightened to the fleet constraint, and a test now compares the two schemas field by field rather than trusting prose, since the alternative to duplication is a cross-file $ref that editors resolve inconsistently. That test immediately found a third divergence, which is the point of writing it: the fleet declares required with a default of true and the overlay declared none. That one is correct and stays. merge() applies only the fields an entry carries, so an omitted required in an overlay means inherit the hub's value rather than true, and a declared default would tell a reader the opposite of what the merge does. It is excluded by name with that reason, so a fourth relaxation fails the test rather than widening the claim. Each exclusion is checked against a mutant: minimum losing its pattern, source reopening its property set, and required declaring a default are all caught. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/test_host_gate.py | 28 ++++++++++++++++++++++++++++ spec/host-tools-local.schema.json | 14 +++++++++++--- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/scripts/test_host_gate.py b/scripts/test_host_gate.py index e2b3b189..dd364787 100755 --- a/scripts/test_host_gate.py +++ b/scripts/test_host_gate.py @@ -404,6 +404,34 @@ def setUp(self): def test_the_overlay_points_at_the_overlay_schema(self): self.assertEqual(self.overlay['$schema'], './spec/host-tools-local.schema.json') + def test_the_two_schemas_agree_on_every_field_they_share(self): + """The "only two axes differ" claim, enforced field by field rather than asserted in prose. + + The overlay schema was written by hand from the fleet one and quietly relaxed two more things: + `minimum` lost its version pattern and `source` lost its named platforms and its closed + property set. A schema-aware editor then accepted a floor the gate rejects and a source key + that reaches no platform, while the tests next door said only two axes differed. + + Comparing each shared field is what keeps the duplication honest, since the alternative is a + cross-file `$ref` that editors resolve inconsistently. The two intended differences are + excluded by name, so adding a third relaxation fails here rather than widening the claim. + """ + fleet = self.fleet_schema['$defs']['tool']['properties'] + overlay = self.local_schema['$defs']['override']['properties'] + self.assertEqual(set(fleet), set(overlay), 'the two schemas describe different field sets') + # `description` is editor help rather than a constraint, so it is not compared. + # `required.default` is excluded because the two files mean different things by an omitted `required`, which this test found rather than assumed. + # In the fleet declaration every entry is an addition, so omitting it means true. + # In an overlay, merge() applies only the fields an entry carries, so omitting it means inherit the hub's value, and declaring a default of true would tell an editor the opposite. + ignore = {'description'} + for field in sorted(overlay): + drop = ignore | ({'default'} if field == 'required' else set()) + want = {k: v for k, v in fleet[field].items() if k not in drop} + got = {k: v for k, v in overlay[field].items() if k not in drop} + self.assertEqual(got, want, f'the overlay constrains {field!r} differently from the fleet schema') + self.assertNotIn('default', overlay['required'], + 'an omitted `required` in an overlay inherits rather than defaulting to true') + def test_the_two_schemas_differ_where_the_overlay_needs_them_to(self): """Asserted as a difference rather than as two absolute values. diff --git a/spec/host-tools-local.schema.json b/spec/host-tools-local.schema.json index 5d749c81..c3107883 100644 --- a/spec/host-tools-local.schema.json +++ b/spec/host-tools-local.schema.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://github.com/ptr727/ProjectTemplate/spec/host-tools-local.schema.json", "title": "Repository host tool overlay", - "description": "A repository's own root host-tools.json, which scripts/host_gate.py layers over the fleet declaration in spec/host-tools.json. This is a different format from that file and needs its own schema rather than borrowing it, in two ways a reader hits immediately. An empty tools list is the ordinary case, because a repository with nothing to add still carries the file, where an empty fleet declaration would mean no host contract at all. And an entry may be partial, carrying a name plus only the fields it changes, which is what lets a repository raise one floor without restating a whole tool, where the fleet declaration requires every field of every entry. Only name is required here, since an entry that names no tool cannot be matched against one. The gate rather than this schema is the authority on what an overlay may do: layering is tighten-only, so an entry may add a tool, raise a floor, or turn an optional tool required, and may not lower a floor or turn a required tool optional, and a rejected relaxation is reported rather than dropped. A schema cannot express that, because it compares nothing against the fleet declaration.", + "description": "A repository's own root host-tools.json, which scripts/host_gate.py layers over the fleet declaration in spec/host-tools.json. This is a different format from that file and needs its own schema rather than borrowing it, in three ways. An empty tools list is the ordinary case, because a repository with nothing to add still carries the file, where an empty fleet declaration would mean no host contract at all. An entry may be partial, carrying a name plus only the fields it changes, which is what lets a repository raise one floor without restating a whole tool, where the fleet declaration requires every field of every entry. Only name is required here, since an entry that names no tool cannot be matched against one. And an omitted required field means inherit rather than true: scripts/host_gate.py applies only the fields an entry carries, so a default declared here would tell a reader the opposite of what the merge does, which is why this file states none where the fleet declaration states true. Every other constraint matches the fleet schema field for field, and a test asserts that rather than trusting this sentence, because this file was hand-written from that one and drifted on two fields before the test existed. The gate rather than this schema is the authority on what an overlay may do: layering is tighten-only, so an entry may add a tool, raise a floor, or turn an optional tool required, and may not lower a floor or turn a required tool optional, and a rejected relaxation is reported rather than dropped. A schema cannot express that, because it compares nothing against the fleet declaration.", "type": "object", "required": ["tools"], "additionalProperties": false, @@ -29,9 +29,17 @@ "items": { "type": "array", "minItems": 1, "items": { "type": "string", "minLength": 1 } } }, "pattern": { "type": "string", "minLength": 1 }, - "minimum": { "type": ["string", "null"] }, + "minimum": { "type": ["string", "null"], "pattern": "^\\d+(\\.\\d+)*$" }, "why": { "type": "string", "minLength": 1 }, - "source": { "type": "object" } + "source": { + "type": "object", + "additionalProperties": false, + "properties": { + "linux": { "type": "string", "minLength": 1 }, + "macos": { "type": "string", "minLength": 1 }, + "windows": { "type": "string", "minLength": 1 } + } + } } } }