Skip to content

fix(spec): align v0.9.1 protocol doc catalogId with the declared catalog schema - #2675

Draft
polina-c wants to merge 2 commits into
mainfrom
fix/v0_9_1-catalog-id-conformance
Draft

polina-c wants to merge 2 commits into
mainfrom
fix/v0_9_1-catalog-id-conformance

Conversation

@polina-c

Copy link
Copy Markdown
Collaborator

Summary

The v0.9.1 protocol documentation tells agents to emit a basic-catalog ID that no catalog schema in this repository declares. Clients that do not happen to tolerate the unknown ID render an empty surface with no error.

This PR corrects the two offending references and adds a check to specification/scripts/validate.py so the class of bug cannot return.

The bug

specification/v0_9_1/docs/a2ui_protocol.md (lines 195 and 288) instructs agents to send:

"catalogId": "https://a2ui.org/specification/v0_9_1/catalogs/basic/catalog.json"

But specification/v0_9_1/catalogs/basic/catalog.json declares:

"$id":       "https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json",
"catalogId": "https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json"

A tally of every https://a2ui.org/specification/*/catalogs/*/catalog.json reference under each version directory shows how isolated the discrepancy is:

Version dir Refs to v0_9 ID Refs to v0_9_1 ID Refs to v1_0 ID
v0_9/ 66 0 0
v0_9_1/ 54 2 0
v1_0/ 0 0 80

v0_9/ and v1_0/ are internally consistent. v0_9_1/ is not: the schema, all 37 examples, and rules.txt use the v0_9 ID, while the protocol doc uses a v0_9_1 ID in exactly 2 places.

The v0_9_1 basic catalog is byte-identical to the v0_9 one:

$ diff specification/v0_9/catalogs/basic/catalog.json \
       specification/v0_9_1/catalogs/basic/catalog.json
(no output)

so reusing the v0.9 catalog identity in v0.9.1 is deliberate and correct — the catalog genuinely did not change between the two releases. The doc is the outlier.

Why this matters in practice

Renderers differ in how they treat an unrecognised catalogId. swift/core/Sources/BasicCatalog/BasicCatalog.swift happens to accept both, via v091CatalogURI; others register only the declared ID.

A renderer that does not recognise the ID generally cannot distinguish it from a typo, so the common behaviour is to register an empty placeholder catalog: the surface reports as created, then renders nothing, and no error is raised at any layer. That is expensive to debug from the agent side, because every layer reports success.

We hit precisely this failure mode downstream when an implementation's hard-coded basic-catalog constant disagreed with the spec URL. It took a full integration trace to find.

Changes

1. specification/v0_9_1/docs/a2ui_protocol.md

Two occurrences of the undeclared v0_9_1 catalog ID replaced with the declared v0_9 one.

Note the "version" field stays v0.9.1. Protocol version and catalog identity are independent; a v0.9.1 message legitimately references the v0.9 basic catalog.

2. specification/scripts/validate.py — new check_catalog_ids()

Enforces two invariants:

  1. Every catalog schema agrees with itself: $id == catalogId.
  2. Every catalog URL referenced anywhere under specification/ is an ID that some catalog schema actually declares.

Invariant 2 is the one that generalises: it stops any doc, example, or test fixture from inventing a catalog ID that no catalog answers to.

On an invariant-1 failure the catalogId is still registered as declared, so one bad $id does not cascade into a bogus "undeclared ID" error for every file that references that catalog.

3. .github/workflows/validate_specifications.yml — trigger paths

Added specification/**/catalogs/** and specification/**/docs/**.

The workflow previously ran only on specification/**/json/** and the script itself. The new check reads catalog and doc files, so without this the guard would not run on the very class of change that introduced this bug. This does mean documentation-only PRs now run the validation suite.

Verification

Run against this branch and against main.

On main (before the doc fix) — flags exactly one ID, from exactly one file, with no false positives anywhere else under specification/:

=== Validating catalog IDs ===
  3 catalog ID(s) declared by 4 schema(s):
    https://a2ui.org/specification/v0_9/catalogs/basic/catalog.json
    https://a2ui.org/specification/v0_9/catalogs/minimal/catalog.json
    https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json
    [FAIL] Undeclared catalog ID: https://a2ui.org/specification/v0_9_1/catalogs/basic/catalog.json
           No catalog.json under specification/ declares this ID.
           Referenced from:
             specification/v0_9_1/docs/a2ui_protocol.md
EXIT=1

On this branch (after the doc fix):

=== Validating catalog IDs ===
  3 catalog ID(s) declared by 4 schema(s):
    ...
    [PASS] Every referenced catalog ID is declared by a schema
EXIT=0

Invariant 1 sensitivity — temporarily corrupting $id in v1_0/catalogs/basic/catalog.json yields a single targeted error and no cascade:

    [FAIL] specification/v1_0/catalogs/basic/catalog.json: '$id' and 'catalogId' disagree
           $id       https://a2ui.org/specification/v1_0/catalogs/basic/OOPS.json
           catalogId https://a2ui.org/specification/v1_0/catalogs/basic/catalog.json
  3 catalog ID(s) declared by 4 schema(s):
    ...
    [PASS] Every referenced catalog ID is declared by a schema
EXIT=1

Deliberately out of scope

  • v0_9_1 is absent from the configs dict in validate.py, so that version's examples receive no schema validation today. Worth fixing, but it is a separate change with its own fallout to triage.
  • BasicCatalog.v091CatalogURI in the Swift SDK now provably matches no declared catalog. Harmless as tolerance; removing it is an SDK decision.
  • Per-SDK assertions. The natural follow-up is for each implementation to test its hard-coded catalog constant against the declared set rather than against a privately held literal.

Open question for maintainers

This PR assumes the schema is authoritative and the doc is wrong, because 54 of 56 references within v0_9_1/, plus the shipped SDKs and renderers, use the v0_9 catalog ID, and the two catalogs are byte-identical.

The alternative reading — that v0.9.1 was meant to mint its own catalog identity — would require changing the schema, all 37 examples, rules.txt, and every SDK constant, and would break already-deployed agents. Please confirm the direction before merge if that was in fact the intent.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces catalog ID validation in specification/scripts/validate.py to ensure that catalog IDs match their schema $id and that all referenced catalog URLs are declared. It also updates catalog IDs in the a2ui_protocol.md documentation examples. Feedback on the validation script includes specifying encoding="utf-8" when opening files for cross-platform compatibility, verifying that the parsed JSON is a dictionary, and avoiding defensive .get() calls for strict invariants to allow failing fast.

Comment thread specification/scripts/validate.py Outdated
for catalog_path in catalog_paths:
rel = os.path.relpath(catalog_path, repo_root)
try:
with open(catalog_path, "r") as f:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

When opening files for reading or writing text, always specify encoding="utf-8" to ensure cross-platform compatibility, especially on Windows environments where the default system encoding might not be UTF-8.

Suggested change
with open(catalog_path, "r") as f:
with open(catalog_path, "r", encoding="utf-8") as f:

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 5aa2437. Agreed — the spec tree contains non-ASCII content and the default encoding is locale-dependent, so this was a real portability bug rather than a style nit.

Scoped to the two open() calls this PR adds; the pre-existing ones elsewhere in the file are left alone to keep the diff reviewable.

Comment thread specification/scripts/validate.py Outdated
if f"{os.sep}node_modules{os.sep}" in path:
continue
try:
with open(path, "r", errors="ignore") as f:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Specify encoding="utf-8" when opening files to ensure consistent decoding across different platforms.

Suggested change
with open(path, "r", errors="ignore") as f:
with open(path, "r", encoding="utf-8", errors="ignore") as f:

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 5aa2437.

Worth noting errors="ignore" alone would not have saved this one: without an explicit encoding, a non-UTF-8 locale could decode bytes into different but still valid characters, which could silently hide a catalog URL from the regex rather than raise. Explicit encoding="utf-8" is what actually makes the scan deterministic.

Comment on lines +277 to +278
schema_id = catalog.get("$id")
catalog_id = catalog.get("catalogId")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Verify that the parsed JSON is a dictionary before accessing its keys to prevent potential crashes. Additionally, avoid using defensive .get() for keys that are strict invariants (such as $id and catalogId); direct key access is preferred to fail fast and signal invariant violations.

Suggested change
schema_id = catalog.get("$id")
catalog_id = catalog.get("catalogId")
if not isinstance(catalog, dict):
print(f" [FAIL] {rel} is not a JSON object")
success = False
continue
schema_id = catalog["$id"]
catalog_id = catalog["catalogId"]
References
  1. Do not use defensive '.get()' or fallback values when accessing dictionary or metadata keys if the presence of the key is a strict invariant. Raising a 'KeyError' is preferred to fail fast and signal invariant violations.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Split decision — took the first half, declining the second.

Adopted: the isinstance(catalog, dict) guard, in 5aa2437. A catalog.json containing a JSON array would previously have died with AttributeError on .get(). Now it reports [FAIL] <path> is not a JSON object. Verified against a synthetic non-object catalog.

Declining the catalog["$id"] / catalog["catalogId"] change, because the premise does not hold here: these keys are not invariants this function may assume, they are the invariants it exists to check.

Concretely, the very next lines are:

if not catalog_id:
    print(f"    [FAIL] {rel} declares no 'catalogId'")
    success = False
    continue

Direct indexing would convert that reported failure into an uncaught KeyError, which is strictly worse for a validation script:

  1. It aborts the run, so you fix one file, re-run, discover the next — instead of getting every violation in one pass. The rest of validate.py is built around accumulating into success and continuing, and this function follows that.
  2. The traceback loses the rel path context that the [FAIL] line carries.
  3. A missing $id is legitimately representable, and is already surfaced by the $id/catalogId disagreement branch, which prints both values. Crashing would report less.

There is also precedent in this file — the existing ajv aliasing block guards with if "$id" in catalog: rather than indexing.

Fail-fast is the right default for production code that has already established its invariants. A linter whose job is to find malformed input is the standard exception.

…log schema

The v0.9.1 protocol doc tells agents to send
  https://a2ui.org/specification/v0_9_1/catalogs/basic/catalog.json
but no catalog schema in the repository declares that ID. The v0_9_1 basic
catalog is byte-identical to the v0_9 one and declares the v0_9 ID, as do all
37 v0_9_1 examples and rules.txt. The doc is the only outlier, at two lines.

Renderers that do not recognise a catalogId commonly fall back to registering
an empty placeholder catalog, so the surface reports as created and then
renders nothing, with no error anywhere. Agents written against the doc hit
exactly that.

Add check_catalog_ids() to the spec validation script to prevent recurrence.
It enforces that every catalog schema's $id matches its catalogId, and that
every catalog URL referenced under specification/ is an ID some schema
actually declares.

Widen the validate_specifications workflow trigger paths to include
catalogs/ and docs/. The new check reads those files, so without this the
guard would not run on the very class of change that introduced the bug.
@polina-c
polina-c force-pushed the fix/v0_9_1-catalog-id-conformance branch from 7db640a to 8b6e9b2 Compare September 16, 2026 17:09
…JSON

Addresses review feedback on check_catalog_ids():
- Decode files as utf-8 explicitly rather than relying on the platform locale.
- Report a clear failure when a catalog.json does not parse to a JSON object,
  instead of raising AttributeError on the subsequent key access.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant