From 085353cb580b5683b01538e804126e8de03a214c Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 2 Oct 2026 00:35:41 +0100 Subject: [PATCH] =?UTF-8?q?feat(canon):=20Gate=20A=20assertion=203=20reads?= =?UTF-8?q?=20the=20spine=20deed=20(canon=20=E2=80=A6)=20clause?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spine's canon pin moves from rsr-profile.a2ml [canon] (frozen by D43, so it could not follow canon 2.1.2) into a `(canon …)` clause of its repo deed (hyperpolymath/rsr-template-repo#215, #222). - 1-formats/deed/vocabulary/canon.adoc: the clause's terms and reader obligations, plus fixture canon-clause_chora.deed (deed_lint OK). - check-canon-lockstep.sh assertion 3 reads the single *_chora.deed and compares version, criteria and gates, shape-checking each value. The shared `deed_canon` reader is byte-identical with rsr's dogfood-gate and repo-init. A spine without the clause falls back to the legacy a2ml criteria hash, so Gate A works whichever PR lands first. - canon.lock: the current-reader comments name the deed. - scripts/tests/check-canon-lockstep-deed-test.sh: 9 cases. They include a wrong gates hash, a stale version, a malformed hash, a duplicated clause, two deeds, the legacy fallback, no pin at all, and the vocabulary fixture against the live canon. A mutant that makes every comparison pass is killed (2 reds). Against real spines: the rsr#222 head passes all three pins through the deed, and the rsr main pass is legacy criteria only. Full Gate A: passed 11, failed 0. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Hw7qg3u9PAP6b2oKyVTSVC --- .../fixtures/valid/canon-clause_chora.deed | 11 ++ 1-formats/deed/vocabulary/canon.adoc | 57 ++++++++++ canon.lock | 5 +- scripts/check-canon-lockstep.sh | 90 ++++++++++++---- .../tests/check-canon-lockstep-deed-test.sh | 100 ++++++++++++++++++ 5 files changed, 240 insertions(+), 23 deletions(-) create mode 100644 1-formats/deed/tools/fixtures/valid/canon-clause_chora.deed create mode 100644 1-formats/deed/vocabulary/canon.adoc create mode 100755 scripts/tests/check-canon-lockstep-deed-test.sh diff --git a/1-formats/deed/tools/fixtures/valid/canon-clause_chora.deed b/1-formats/deed/tools/fixtures/valid/canon-clause_chora.deed new file mode 100644 index 000000000..bdd403a5c --- /dev/null +++ b/1-formats/deed/tools/fixtures/valid/canon-clause_chora.deed @@ -0,0 +1,11 @@ +;; SPDX-License-Identifier: CC-BY-SA-4.0 +; Exercises every term of the (canon ...) vocabulary: +; 1-formats/deed/vocabulary/canon.adoc +(repo-deed + :schema-version "1.0.0" + :canonical-name "canon-clause" + (canon + :version "2.1.2" + :criteria-sha256 "6a5aa8857bd0d0d58ef48827938ca17c251b6b854dacf59d306388d61694d82a" + :gates-sha256 "e70efd2f53c9445e30da4baf770366f04a4a84ffd844a01426e587e565b53e6a" + :lockstep-since "2026-09-17")) diff --git a/1-formats/deed/vocabulary/canon.adoc b/1-formats/deed/vocabulary/canon.adoc new file mode 100644 index 000000000..8323d70b4 --- /dev/null +++ b/1-formats/deed/vocabulary/canon.adoc @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: CC-BY-SA-4.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell += `(canon …)` — repo-deed vocabulary for the canon pin +:toc: + +Status:: vocabulary, v1 (2026-10-02). Grammar unchanged: this is a `clause` +under the normative link:../spec/abnf/deed.abnf[deed.abnf] v1.0.0. +Fixture:: link:../tools/fixtures/valid/canon-clause_chora.deed[canon-clause_chora.deed]. +Origin:: rsr-template-repo#215. The pin lived in `.machine_readable/rsr-profile.a2ml` +`[canon]`. D43 freezes `.a2ml` writes until #837 steps 1–3, so the pin could not +follow canon 2.1.2 and Gate A went red. The pin moved to the deed, which is +writable and is where #837 folds the descriptiles anyway. + +The terms below move into `estate_chora.deed`'s `(vocabulary …)` clause when that +file lands, exactly as for link:updates.adoc[`(updates …)`]. + +== Where it appears + +In a `repo-deed` (`_chora.deed`), **exactly once**, as a direct child of the +form. Today only the spine (`rsr-template-repo`) and repos minted from it carry it. +A minted repo's `repo-init` reads the clause and writes it into `PROVENANCE`. + +== Terms + +[cols="2,2,6",options="header"] +|=== +| Field | Value | Rule + +| `:version` | STRING | Required. `MAJOR.MINOR.PATCH`, equal to `canon.lock [canon].version`. +| `:criteria-sha256` | STRING | Required. 64 lowercase hex, equal to `canon.lock [canon.artifacts].criteria.sha256`. +| `:gates-sha256` | STRING | Required. 64 lowercase hex, equal to `canon.lock [canon.artifacts].gates.sha256`. +| `:lockstep-since` | STRING | Optional. `YYYY-MM-DD` the repo first adopted lockstep. Informational. +|=== + +== Reader obligations + +* Zero clauses, or more than one, yields **no pin**. A reader must not pick one. +* Each value is shape-checked (semver / 64-hex) before comparison. A captured value + of the wrong shape is a failure, never a pass. `"" = ""` is not lockstep. +* Comment lines (`;`) are not read: a `; (canon …)` in prose must not count. +* The three readers share one function, `deed_canon`, kept byte-identical: + `scripts/check-canon-lockstep.sh` (here, Gate A assertion 3), + rsr-template-repo `.github/workflows/dogfood-gate.yml` (`Canon lockstep`), and + rsr-template-repo `build/just/repo-init.just`. +* Order is `canon.lock [canon.lockstep].order`, `spine-adopts-then-canon-releases`: + the spine re-pins this clause **first**, and the canon release lands after. + +== Example + +[source,lisp] +---- +(canon + :version "2.1.2" + :criteria-sha256 "6a5aa8857bd0d0d58ef48827938ca17c251b6b854dacf59d306388d61694d82a" + :gates-sha256 "e70efd2f53c9445e30da4baf770366f04a4a84ffd844a01426e587e565b53e6a" + :lockstep-since "2026-09-17") +---- diff --git a/canon.lock b/canon.lock index cc4bd129c..47317a1de 100644 --- a/canon.lock +++ b/canon.lock @@ -42,7 +42,8 @@ # --------------------------------------------------------------------------- # WHO READS IT # -# rsr-template-repo/.machine_readable/rsr-profile.a2ml [canon] block +# rsr-template-repo/rsr-template-repo_chora.deed (canon …) clause (was the +# rsr-profile.a2ml [canon] block, frozen by D43; rsr-template-repo#215) # rsr-template-repo/build/just/repo-init.just writes PROVENANCE.a2ml # minted repos' .machine_readable/PROVENANCE.a2ml # hypatia -> Hypatia.Rules.RsrConformance @@ -343,7 +344,7 @@ telemetry= "hyperpolymath/estate-telemetry" # ← proposed split, see 03-STA require-verified-hashes = true # 2. [canon].version was bumped in the same commit as any artefact change require-version-bump = true -# 3. spine@HEAD declares criteria_sha256 == [canon.artifacts].criteria.sha256 +# 3. spine@HEAD deed (canon …) == [canon].version + criteria + gates sha256 require-spine-adopted = true # 4. spine dogfood-gate is GREEN against THESE criteria require-spine-green = true diff --git a/scripts/check-canon-lockstep.sh b/scripts/check-canon-lockstep.sh index 4300197da..d91d8d81f 100644 --- a/scripts/check-canon-lockstep.sh +++ b/scripts/check-canon-lockstep.sh @@ -17,7 +17,7 @@ # 2 touching a canon artefact forces a version bump # # INFORMATIONAL unless --strict: -# 3 the spine declares criteria_sha256 == canon.lock's criteria hash +# 3 the spine deed (canon …) clause == canon.lock version + criteria + gates # 4 the spine is GREEN against those criteria # 5 the canon itself scores Gold on its own applicable set # @@ -259,32 +259,80 @@ echo # =========================================================================== # ASSERTION 3 — the spine has adopted this canon # =========================================================================== -echo "[3] spine declares the same criteria hash" +# The pin's home is the spine deed's `(canon …)` clause +# (1-formats/deed/vocabulary/canon.adoc). It moved there from +# rsr-profile.a2ml [canon] because D43 freezes .a2ml writes, so that block +# could not follow a canon release (rsr-template-repo#215). The a2ml is still +# read as a LEGACY fallback, criteria only, so this gate keeps working against +# a spine that has not adopted the deed clause yet. Delete the fallback once +# no spine checkout lacks the clause. + +# deed_canon : print the value of : from the +# deed's one (canon …) clause, or nothing when the clause or key is +# absent or the clause is not unique. The SAME function is in +# rsr-template-repo .github/workflows/dogfood-gate.yml and +# build/just/repo-init.just; keep the three identical. +deed_canon() { + _clauses="$(grep -vE '^[[:space:]]*;' "$2" | awk '{ printf "%s ", $0 }' | grep -oE '[(]canon[[:space:]][^()]*[)]')" || true + _n="$(echo "$_clauses" | grep -c '(canon' || true)" + if [ "$_n" != "1" ]; then + echo "deed_canon: $2 carries ${_n:-0} (canon …) clauses, need exactly 1" >&2 + return 0 + fi + echo "$_clauses" | grep -oE ':'"$1"'[[:space:]]+"[^"]*"' | head -1 | sed -E 's/^[^"]*"//; s/"$//' || true +} + +# pin_row : compare one pin. A value of the +# wrong shape is a failure, never a pass: "" = "" is not lockstep. +pin_row() { + if ! printf '%s' "$3" | grep -qxE "$4"; then + softfail "spine deed :$1 is not well-formed (got '${3}')" + elif ! printf '%s' "$2" | grep -qxE "$4"; then + softfail "canon.lock $1 is not well-formed (got '${2}')" + elif [ "$2" = "$3" ]; then + pass "spine :$1 == canon.lock ($(echo "$3" | cut -c1-12)…)" + else + softfail "spine is on a DIFFERENT canon ($1) + canon.lock $(echo "$2" | cut -c1-16)… + spine $(echo "$3" | cut -c1-16)… + -> land the spine's adoption AFTER this canon release; run with + --strict to make this a hard failure once both are on main." + fi +} + +echo "[3] spine declares the same canon pin" if [ -z "$SPINE" ] || [ ! -d "$SPINE" ]; then skip "no --spine DIR given (set --strict in CI release jobs)" else - PROFILE="$SPINE/.machine_readable/rsr-profile.a2ml" - # Hyphenated is the minority spelling, not a rejected one: this branch stays so - # the ~9 repos still carrying it keep resolving. - [ -f "$PROFILE" ] || PROFILE="$SPINE/machine-readable/rsr-profile.a2ml" - if [ ! -f "$PROFILE" ]; then - softfail "spine has no rsr-profile.a2ml at either .machine_readable/ or machine-readable/" + DEEDS=() + for d in "$SPINE"/*_chora.deed; do [ -f "$d" ] && DEEDS+=("$d"); done + HEX='[0-9a-f]{64}' + if [ "${#DEEDS[@]}" -gt 1 ]; then + softfail "spine carries ${#DEEDS[@]} *_chora.deed files; one-deed-per-repo (#837) expects exactly 1" + elif [ "${#DEEDS[@]}" -eq 1 ] && grep -vE '^[[:space:]]*;' "${DEEDS[0]}" | grep -qE '[(]canon([[:space:]]|$)'; then + DEED="${DEEDS[0]}" + echo " reading $(basename "$DEED") (canon …)" + pin_row version "$CANON_VERSION" "$(deed_canon version "$DEED")" '[0-9]+\.[0-9]+\.[0-9]+' + pin_row criteria-sha256 "$(toml_hash criteria)" "$(deed_canon criteria-sha256 "$DEED")" "$HEX" + pin_row gates-sha256 "$(toml_hash gates)" "$(deed_canon gates-sha256 "$DEED")" "$HEX" else - WANT="$(toml_hash criteria)" - GOT="$(grep -E '^[[:space:]]*criteria_sha256[[:space:]]*=' "$PROFILE" \ - | grep -oE '[0-9a-f]{64}' | head -1)" - if [ -z "$GOT" ]; then - softfail "spine rsr-profile.a2ml has no [canon] criteria_sha256 + PROFILE="$SPINE/.machine_readable/rsr-profile.a2ml" + # Hyphenated is the minority spelling, not a rejected one: this branch stays so + # the ~9 repos still carrying it keep resolving. + [ -f "$PROFILE" ] || PROFILE="$SPINE/machine-readable/rsr-profile.a2ml" + if [ ! -f "$PROFILE" ]; then + softfail "spine has no (canon …) deed clause and no rsr-profile.a2ml" + else + echo " LEGACY: no (canon …) deed clause; reading rsr-profile.a2ml [canon] criteria only" + GOT="$(grep -E '^[[:space:]]*criteria_sha256[[:space:]]*=' "$PROFILE" \ + | grep -oE "$HEX" | head -1)" + if [ -z "$GOT" ]; then + softfail "spine rsr-profile.a2ml has no [canon] criteria_sha256 -> the spine still declares conformance in free text. The binding does not exist until this is a hash." - elif [ "$WANT" = "$GOT" ]; then - pass "spine criteria_sha256 == canon.lock criteria ($(echo "$GOT" | cut -c1-12)…)" - else - softfail "spine is on a DIFFERENT canon - canon.lock $(echo "$WANT" | cut -c1-16)… - spine $(echo "$GOT" | cut -c1-16)… - -> land the spine's adoption AFTER this canon release; run with - --strict to make this a hard failure once both are on main." + else + pin_row criteria-sha256 "$(toml_hash criteria)" "$GOT" "$HEX" + fi fi fi fi diff --git a/scripts/tests/check-canon-lockstep-deed-test.sh b/scripts/tests/check-canon-lockstep-deed-test.sh new file mode 100755 index 000000000..10b522f91 --- /dev/null +++ b/scripts/tests/check-canon-lockstep-deed-test.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: MPL-2.0 +# Gate A assertion 3 reads the spine deed's (canon …) clause +# (1-formats/deed/vocabulary/canon.adoc, rsr-template-repo#215), with the +# rsr-profile.a2ml [canon] block as a legacy fallback. Each case builds a fake +# spine and asserts the [3] section's verdict under --strict. +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO="$(cd "$SCRIPT_DIR/../.." && pwd)" +CHECK="$REPO/scripts/check-canon-lockstep.sh" +FIXTURE="$REPO/1-formats/deed/tools/fixtures/valid/canon-clause_chora.deed" +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +PASSED=0 +FAILED=0 + +# The live pin, read from canon.lock with the script's own shape rules, so the +# test does not go stale on the next canon release. +LOCK="$REPO/canon.lock" +WANT_VER="$(awk '/^\[canon\]/{f=1;next} /^\[/{f=0} f && /^version[[:space:]]*=/{gsub(/.*=[[:space:]]*"|".*/,"");print;exit}' "$LOCK")" +WANT_CRIT="$(awk '/^[[:space:]]*criteria[[:space:]]*=/{f=1} f{print} f&&/}/{exit}' "$LOCK" | grep -oE '[0-9a-f]{64}' | head -1)" +WANT_GATES="$(awk '/^[[:space:]]*gates[[:space:]]*=/{f=1} f{print} f&&/}/{exit}' "$LOCK" | grep -oE '[0-9a-f]{64}' | head -1)" + +# section3 : run the gate and print only the [3] section. +section3() { + bash "$CHECK" --canon "$REPO" --spine "$1" --base HEAD --strict 2>&1 \ + | awk '/^\[3\]/{f=1} /^\[4\]/{f=0} f' +} + +# expect [needle]: assert the [3] verdict, and +# that needle (if given) appears in the section. +expect() { + local out verdict + out="$(section3 "$2")" + if printf '%s' "$out" | grep -q 'FAIL'; then verdict=FAIL + elif printf '%s' "$out" | grep -q 'PASS'; then verdict=PASS + else verdict=NONE; fi + if [ "$verdict" = "$3" ] && { [ -z "${4:-}" ] || printf '%s' "$out" | grep -qF -- "$4"; }; then + PASSED=$((PASSED + 1)); echo "ok $1" + else + FAILED=$((FAILED + 1)); echo "FAIL $1 (wanted $3${4:+ + '$4'}, got $verdict)"; printf '%s\n' "$out" | sed 's/^/ /' + fi +} + +# deed : write a spine deed with one clause. +deed() { + mkdir -p "$1" + cat > "$1/spine_chora.deed" < "$WORK/legacy/.machine_readable/rsr-profile.a2ml" +expect "legacy a2ml fallback still passes on criteria" "$WORK/legacy" PASS "LEGACY" + +mkdir -p "$WORK/none" +expect "no deed clause and no profile fails" "$WORK/none" FAIL "no (canon …) deed clause" + +# The shipped fixture is the vocabulary's own example; it must stay on the live +# canon, or the page documents a pin nobody can use. +mkdir -p "$WORK/fixture"; cp "$FIXTURE" "$WORK/fixture/" +expect "vocabulary fixture is on the live canon" "$WORK/fixture" PASS + +echo "passed $PASSED failed $FAILED" +[ "$FAILED" -eq 0 ]