Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions 1-formats/deed/tools/fixtures/valid/canon-clause_chora.deed
Original file line number Diff line number Diff line change
@@ -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"))
57 changes: 57 additions & 0 deletions 1-formats/deed/vocabulary/canon.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// SPDX-License-Identifier: CC-BY-SA-4.0
// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>
= `(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` (`<repo>_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")
----
5 changes: 3 additions & 2 deletions canon.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
90 changes: 69 additions & 21 deletions scripts/check-canon-lockstep.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
#
Expand Down Expand Up @@ -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 <key> <deed-file>: print the value of :<key> 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 <name> <want> <got> <shape-regex>: 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
Expand Down
100 changes: 100 additions & 0 deletions scripts/tests/check-canon-lockstep-deed-test.sh
Original file line number Diff line number Diff line change
@@ -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 <spine-dir>: 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 <name> <spine-dir> <PASS|FAIL> [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 <dir> <version> <criteria> <gates>: write a spine deed with one clause.
deed() {
mkdir -p "$1"
cat > "$1/spine_chora.deed" <<DEED
;; SPDX-License-Identifier: MPL-2.0
; a (canon "decoy") in a comment must not be read
(repo-deed
:schema-version "1.0.0"
:canonical-name "spine"
(canon
:version "$2"
:criteria-sha256 "$3"
:gates-sha256 "$4"))
DEED
}

for v in WANT_VER WANT_CRIT WANT_GATES; do
[ -n "${!v}" ] || { echo "FAIL could not read $v from canon.lock — the test's own reader is broken"; exit 1; }
done

deed "$WORK/match" "$WANT_VER" "$WANT_CRIT" "$WANT_GATES"
expect "deed matching canon.lock passes" "$WORK/match" PASS "reading spine_chora.deed"

deed "$WORK/bad-gates" "$WANT_VER" "$WANT_CRIT" "$(printf '%064d' 0)"
expect "wrong gates hash fails (criteria alone is not lockstep)" "$WORK/bad-gates" FAIL "(gates-sha256)"

deed "$WORK/old-ver" "0.0.1" "$WANT_CRIT" "$WANT_GATES"
expect "stale version fails (the #215 shape)" "$WORK/old-ver" FAIL "(version)"

deed "$WORK/short" "$WANT_VER" "${WANT_CRIT:0:12}" "$WANT_GATES"
expect "malformed hash fails on shape, not as a mismatch" "$WORK/short" FAIL "not well-formed"

deed "$WORK/dup" "$WANT_VER" "$WANT_CRIT" "$WANT_GATES"
sed -i 's/^ :gates-sha256 \(.*\)))$/ :gates-sha256 \1)\n (canon :version "'"$WANT_VER"'" :criteria-sha256 "'"$WANT_CRIT"'" :gates-sha256 "'"$WANT_GATES"'"))/' "$WORK/dup/spine_chora.deed"
expect "two (canon …) clauses fail rather than pick one" "$WORK/dup" FAIL "not well-formed"

mkdir -p "$WORK/two-deeds"; deed "$WORK/two-deeds" "$WANT_VER" "$WANT_CRIT" "$WANT_GATES"
cp "$WORK/two-deeds/spine_chora.deed" "$WORK/two-deeds/other_chora.deed"
expect "two deeds fail" "$WORK/two-deeds" FAIL "one-deed-per-repo"

mkdir -p "$WORK/legacy/.machine_readable"
printf '[canon]\nversion = "0.0.1"\ncriteria_sha256 = "%s"\n' "$WANT_CRIT" > "$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 ]
Loading