44#
55# validate-a2ml.sh — A2ML manifest validation script
66#
7- # Scans for .a2ml files and validates:
7+ # Scans for .a2ml and .deed files and validates:
88# 1. Required fields: agent-id or pedigree name, version
99# 2. SPDX-License-Identifier header presence
1010# 3. Attestation block structure (if present)
@@ -89,7 +89,7 @@ report_issue() {
8989}
9090
9191# ---------------------------------------------------------------------------
92- # Validator: check a single .a2ml file
92+ # Validator: check a single .a2ml or .deed file
9393# ---------------------------------------------------------------------------
9494validate_a2ml () {
9595 local file=" $1 "
@@ -123,6 +123,7 @@ validate_a2ml() {
123123 # - project = "..." (for STATE.a2ml)
124124 local has_identity=false
125125 local has_version=false
126+ local first_form_seen=false
126127 line_num=0
127128
128129 while IFS= read -r line; do
@@ -150,6 +151,29 @@ validate_a2ml() {
150151 if [[ " $line " =~ ^[[:space:]]* (agent[-_]id| name| project| id)[[:space:]]* : ]]; then
151152 has_identity=true
152153 fi
154+ # DEED s-expression head form: `(estate-deed`, `(repo-deed`,
155+ # `(estate-atlas-deed`, `(praxis-deed`. Per DEED-GRAMMAR-SPEC
156+ # <<identity>>, a file whose first form is one of the four declared
157+ # heads is a deed of that kind, and the head satisfies the structural
158+ # half of identity. This is what lets ATLAS.deed — which carries
159+ # :registry-version and legitimately no :canonical-name — validate.
160+ # The head is the FIRST form (DEED-GRAMMAR-SPEC <<concrete-syntax>>:
161+ # `Deed ::= Header Sep? Form Sep?` — one form, and it carries the head).
162+ # Checking every line let a malformed file open with some other form and
163+ # then append `(estate-deed ...)` lower down to buy identity. Only the
164+ # first form is eligible.
165+ if [[ " $first_form_seen " == " false" && " $line " =~ ^[[:space:]]* \( ]]; then
166+ first_form_seen=true
167+ if [[ " $line " =~ ^[[:space:]]* \( (estate-deed| repo-deed| estate-atlas-deed| praxis-deed)([[:space:]]| $) ]]; then
168+ has_identity=true
169+ fi
170+ fi
171+ # DEED keyword identity form: `:canonical-name "..."` and the two other
172+ # identity keywords the spec names. Note the leading colon: none of the
173+ # three forms above match it, because they test the bare words.
174+ if [[ " $line " =~ ^[[:space:]]* :(canonical-name| estate-authority| agent-id)[[:space:]] ]]; then
175+ has_identity=true
176+ fi
153177 # Check for version field — TOML form
154178 if [[ " $line " =~ ^[[:space:]]* (version| schema_version)[[:space:]]* = ]]; then
155179 has_version=true
@@ -162,17 +186,34 @@ validate_a2ml() {
162186 if [[ " $line " =~ ^[[:space:]]* (version| schema_version)[[:space:]]* : ]]; then
163187 has_version=true
164188 fi
189+ # DEED keyword version form: `:schema-version "1.0.0"` — leading colon,
190+ # hyphenated, REQUIRED on all four deed heads (DEED-GRAMMAR-SPEC
191+ # <<version-field>>). All three patterns above spell it `schema_version`
192+ # with no leading colon, so a conforming deed matched none of them.
193+ # `:registry-version` is a distinct field, optional on the atlas.
194+ # `:schema-version` ONLY. `:registry-version` is a distinct, optional
195+ # atlas field (see the note above) and never satisfies the version
196+ # requirement, which DEED-GRAMMAR-SPEC <<version-field>> makes REQUIRED
197+ # on all four heads. Accepting it let a registry-only atlas head pass
198+ # with no schema version at all.
199+ if [[ " $line " =~ ^[[:space:]]* :schema-version[[:space:]] ]]; then
200+ has_version=true
201+ fi
165202 done < " $file "
166203
167204 # AI manifest files (0-AI-MANIFEST.a2ml, 0.1-AI-MANIFEST.a2ml, etc.)
168205 # use markdown-style headers and free text, so identity check is relaxed
169206 local basename
170207 basename=" $( basename " $file " ) "
171208 local is_manifest=false
172- if [[ " $basename " == * " AI-MANIFEST" * ]]; then
209+ # `.a2ml` ONLY. The exemption exists because AI manifests are markdown-ish
210+ # prose with no in-file identity; it is not a property of the name. Matching
211+ # the bare basename meant `example-AI-MANIFEST.deed` was exempted from BOTH
212+ # the identity and version checks — a deed that skipped the whole gate.
213+ if [[ " $basename " == * " AI-MANIFEST" * .a2ml ]]; then
173214 is_manifest=true
174215 fi
175- # Canonical typed manifests under .machine_readable /descriptiles/ — identity comes
216+ # Canonical typed manifests under <machine tree> /descriptiles/ — identity comes
176217 # from the enclosing directory + filename, not an in-file field. Sibling
177218 # files in the same directory (ECOSYSTEM.a2ml, STATE.a2ml) DO carry their
178219 # own $name/project and continue to be validated normally.
@@ -203,20 +244,37 @@ validate_a2ml() {
203244 is_contractile_shape=true
204245 fi
205246
206- # Canonical structured A2ML tree. Everything under a `.machine_readable/`
207- # directory is a typed agent-readable doc (CLADE, ANCHOR, STATE,
208- # ECOSYSTEM, bot_directives/{debt,coverage,methodology}, ai/AI,
209- # policies/*, integrations/*, …). Per the RSR convention these carry
210- # identity structurally — owning repo + path + filename — not via an
211- # in-file `name`/`agent-id`. This generalises the `.machine_readable/descriptiles/`
212- # rationale above to the whole tree: rsr-template-repo itself ships these
213- # files without an in-file identity key, so requiring one produces
214- # estate-wide false positives on every repo built from the canonical
215- # template. Files outside `.machine_readable/` are still validated.
247+ # The structured A2ML tree. Everything under a repo's machine tree —
248+ # `machine-readable/` canonically, `.machine_readable/` in the legacy
249+ # layout — is a typed agent-readable doc (CLADE, ANCHOR, STATE, ECOSYSTEM,
250+ # bot_directives/{debt,coverage,methodology}, ai/AI, policies/*,
251+ # integrations/*, …). Per the RSR convention these carry identity
252+ # structurally — owning repo + path + filename — not via an in-file
253+ # `name`/`agent-id`. This generalises the `descriptiles/` rationale above
254+ # to the whole tree: rsr-template-repo itself ships these files without an
255+ # in-file identity key, so requiring one produces estate-wide false
256+ # positives on every repo built from the canonical template. Files outside
257+ # the machine tree are still validated.
258+ #
259+ # The machine tree is named `machine-readable/` canonically (un-hidden
260+ # 2026-08); `.machine_readable/` is the LEGACY name. BOTH are matched: the
261+ # canon, scaffoldia, the julia variant and ~300 minted repos still carry the
262+ # dotted form, while rsr-template-repo has moved. Matching only one name
263+ # makes whichever half of the estate has not migrated fail this check with
264+ # 16 spurious "missing identity field" errors -- which is exactly what
265+ # happened when the template renamed its tree and this action, being a
266+ # separate implementation from the template's vendored copy, kept matching
267+ # the old name only.
216268 local is_structural_identity=false
217- if [[ " $file " == * " /.machine_readable/" * || " $file " == " ./.machine_readable/" * || " $file " == " .machine_readable/" * ]]; then
218- is_structural_identity=true
219- fi
269+ # `*` matches the empty string, so */machine-readable/* already covers the
270+ # ./-prefixed form that `find .` emits; spelling it out separately (as the
271+ # original three-branch test did) is redundant. Verified equivalent across
272+ # ./-prefixed, bare and absolute paths, and on the negative cases.
273+ case " $file " in
274+ * /machine-readable/* |machine-readable/* |* /.machine_readable/* |.machine_readable/* )
275+ is_structural_identity=true
276+ ;;
277+ esac
220278
221279 if [[ " $has_identity " == " false" && " $is_manifest " == " false" && " $is_contractile_shape " == " false" && " $is_structural_identity " == " false" ]]; then
222280 report_issue " error" " $file " 1 \
@@ -259,7 +317,7 @@ validate_a2ml() {
259317 fi
260318 done < " $file "
261319
262- if [[ $attestation_line -gt 0 && " $attestation_has_content " == " false" ]]; then
320+ if [[ $attestation_line -gt 0 && " $attestation_has_content " == " false" && " $is_manifest " == " false " ]]; then
263321 report_issue " warning" " $file " " $attestation_line " \
264322 " Attestation block found but missing proof/signature/hash fields"
265323 fi
@@ -281,15 +339,15 @@ validate_a2ml() {
281339}
282340
283341# ---------------------------------------------------------------------------
284- # Main: discover and validate .a2ml files
342+ # Main: discover and validate .a2ml and .deed files
285343# ---------------------------------------------------------------------------
286344
287345echo " ::group::A2ML Manifest Validation"
288- echo " Scanning ${SCAN_PATH} for .a2ml files..."
346+ echo " Scanning ${SCAN_PATH} for .a2ml and .deed files..."
289347echo " "
290348
291- # Find all .a2ml files, excluding .git directory
292- mapfile -t a2ml_candidates < < (find " $SCAN_PATH " -name ' *.a2ml' -not -path ' */.git/*' -type f | sort)
349+ # Find all .a2ml and .deed files, excluding .git directory
350+ mapfile -t a2ml_candidates < < (find " $SCAN_PATH " \( -name ' *.a2ml' -o -name ' *.deed ' \) -not -path ' */.git/*' -type f | sort)
293351
294352# Apply paths-ignore filter
295353a2ml_files= ()
@@ -307,15 +365,15 @@ if [[ $SKIPPED -gt 0 ]]; then
307365fi
308366
309367if [[ ${# a2ml_files[@]} -eq 0 ]]; then
310- echo " ::notice::No .a2ml files found in ${SCAN_PATH} "
368+ echo " ::notice::No .a2ml or .deed files found in ${SCAN_PATH} "
311369 echo " files_scanned=0" >> " $GITHUB_OUTPUT_FILE " 2> /dev/null || true
312370 echo " errors=0" >> " $GITHUB_OUTPUT_FILE " 2> /dev/null || true
313371 echo " warnings=0" >> " $GITHUB_OUTPUT_FILE " 2> /dev/null || true
314372 echo " ::endgroup::"
315373 exit 0
316374fi
317375
318- echo " Found ${# a2ml_files[@]} .a2ml file(s)"
376+ echo " Found ${# a2ml_files[@]} .a2ml/.deed file(s)"
319377echo " "
320378
321379for file in " ${a2ml_files[@]} " ; do
0 commit comments