diff --git a/.github/workflows/compatibility.yml b/.github/workflows/compatibility.yml
new file mode 100644
index 0000000..5cdf798
--- /dev/null
+++ b/.github/workflows/compatibility.yml
@@ -0,0 +1,64 @@
+name: Compatibility
+
+on:
+ pull_request:
+ paths:
+ - "schemas/**"
+ - "scripts/compatibility.mjs"
+ - "scripts/lib.mjs"
+ - "tests/compatibility.test.mjs"
+ - ".github/workflows/compatibility.yml"
+
+permissions:
+ contents: read
+
+jobs:
+ classify:
+ name: Classify schema compatibility
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ steps:
+ - name: Check out proposed contracts
+ uses: actions/checkout@v4
+ with:
+ path: candidate
+ fetch-depth: 0
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: npm
+ cache-dependency-path: candidate/package-lock.json
+ - run: npm ci
+ working-directory: candidate
+ - name: Extract latest released contracts
+ shell: bash
+ working-directory: candidate
+ run: |
+ set -euo pipefail
+ latest="$(git tag --list "v[0-9]*.[0-9]*.[0-9]*" --sort=-version:refname | head -n 1)"
+ test -n "$latest"
+ mkdir -p ../baseline
+ git archive "$latest" schemas | tar -x -C ../baseline
+ - name: Classify changes
+ id: classify
+ shell: bash
+ run: |
+ set +e
+ node candidate/scripts/compatibility.mjs \
+ --base baseline/schemas \
+ --head candidate/schemas \
+ --output candidate/compatibility-report.json
+ code=$?
+ {
+ echo "## Compatibility against latest release"
+ echo '```json'
+ cat candidate/compatibility-report.json
+ echo '```'
+ } >> "$GITHUB_STEP_SUMMARY"
+ exit "$code"
+ - name: Upload compatibility report
+ if: always()
+ uses: actions/upload-artifact@v4
+ with:
+ name: compatibility-report
+ path: candidate/compatibility-report.json
diff --git a/.github/workflows/publish-registry.yml b/.github/workflows/publish-registry.yml
new file mode 100644
index 0000000..a03e47f
--- /dev/null
+++ b/.github/workflows/publish-registry.yml
@@ -0,0 +1,66 @@
+name: Publish schema registry
+
+on:
+ push:
+ tags:
+ - "v[0-9]+.[0-9]+.[0-9]+"
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ pages: write
+ id-token: write
+
+concurrency:
+ group: pages
+ cancel-in-progress: false
+
+jobs:
+ build:
+ name: Build registry
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 22
+ cache: npm
+ - run: npm ci
+ - run: npm test
+ - name: Build every released schema version
+ shell: bash
+ run: |
+ set -euo pipefail
+ rm -rf registry release-source
+ while IFS= read -r tag; do
+ version="${tag#v}"
+ line="v${version%.*}"
+ mkdir -p "release-source/$version"
+ git archive "$tag" "schemas/$line" | tar -x -C "release-source/$version"
+ node scripts/build-registry.mjs \
+ --version "$version" \
+ --source "release-source/$version/schemas/$line" \
+ --output registry \
+ --append
+ done < <(git tag --list "v[0-9]*.[0-9]*.[0-9]*" --sort=version:refname)
+ - run: npm run validate:registry -- --registry registry
+ - uses: actions/configure-pages@v5
+ - uses: actions/upload-pages-artifact@v3
+ with:
+ path: registry
+
+ deploy:
+ name: Deploy registry
+ needs: build
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ steps:
+ - name: Deploy to GitHub Pages
+ id: deployment
+ uses: actions/deploy-pages@v4
diff --git a/.gitignore b/.gitignore
index 612c939..b2b198d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,7 @@
node_modules/
coverage/
+registry/
+release-source/
+.tmp/
*.log
.DS_Store
diff --git a/.planning/PROJECT.md b/.planning/PROJECT.md
index 070689c..e8a8727 100644
--- a/.planning/PROJECT.md
+++ b/.planning/PROJECT.md
@@ -13,6 +13,7 @@ Every CAS component can exchange trustworthy lifecycle data without guessing str
### Validated
- v0.1 lifecycle schemas, examples, tests, CI, and governance - Phase 1
+- Automated compatibility classification and stable versioned schema distribution - Phase 2
### Active
@@ -48,6 +49,8 @@ CAS currently has multiple repositories that need a shared language for prompts,
| Use one common lifecycle metadata definition | Prevent traceability drift across contracts | Pending |
| Keep contracts implementation-neutral | Allow all CAS runtimes to adopt them | Pending |
| Publish examples as executable fixtures | Documentation and validation stay synchronized | Pending |
+| Fail unknown schema semantics as review-required | Prevent false compatibility claims | Implemented in Phase 2 |
+| Rebuild all release tags for registry publication | Preserve immutable release URLs while stable lines advance | Implemented in Phase 2 |
## Evolution
@@ -58,4 +61,4 @@ This document evolves at phase transitions and milestone boundaries.
3. Reconfirm that contract interoperability remains the core value.
---
-*Last updated: 2026-06-11 after Phase 1 verification*
+*Last updated: 2026-06-11 after Phase 2 verification*
diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md
index f1c3dcd..29b3ce4 100644
--- a/.planning/REQUIREMENTS.md
+++ b/.planning/REQUIREMENTS.md
@@ -26,8 +26,8 @@
## vNext Requirements
- **SDK-01**: Consumers can install generated typed SDKs for supported languages.
-- **REG-01**: Consumers can resolve published schemas from a stable registry endpoint.
-- **COMP-01**: CI automatically compares proposed schemas against the latest release for compatibility.
+- [x] **REG-01**: Consumers can resolve published schemas from a stable registry endpoint.
+- [x] **COMP-01**: CI automatically compares proposed schemas against the latest release for compatibility.
## Out of Scope
@@ -50,8 +50,10 @@
| GOV-01 | Phase 1 | Complete |
| GOV-02 | Phase 1 | Complete |
| GOV-03 | Phase 1 | Complete |
+| COMP-01 | Phase 2 | Complete |
+| REG-01 | Phase 2 | Complete |
-**Coverage:** 9 v0.1 requirements, 9 mapped, 0 unmapped.
+**Coverage:** 12 requirements, 12 mapped, 0 unmapped.
---
-*Last updated: 2026-06-11 after Phase 1 verification*
+*Last updated: 2026-06-11 after Phase 2 verification*
diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md
index 62d4f84..1b3b8eb 100644
--- a/.planning/ROADMAP.md
+++ b/.planning/ROADMAP.md
@@ -18,6 +18,16 @@
**Requirements:** COMP-01, REG-01
+**Status:** Complete (2026-06-11)
+
+**Plans:** 2/2 plans complete
+
+**Wave 1**
+- [x] 02-01: Automated compatibility classification and PR enforcement
+
+**Wave 2** *(blocked on Wave 1 completion)*
+- [x] 02-02: Deterministic versioned registry and release distribution
+
**Success criteria:**
1. Pull requests receive automated breaking-change classification.
2. Released schemas are resolvable from stable versioned URLs.
diff --git a/.planning/STATE.md b/.planning/STATE.md
index 88935a5..90d5e92 100644
--- a/.planning/STATE.md
+++ b/.planning/STATE.md
@@ -1,21 +1,38 @@
+---
+gsd_state_version: 1.0
+milestone: v0.1
+milestone_name: Foundation
+status: ready_to_plan
+last_updated: 2026-06-11T11:07:16.086Z
+progress:
+ total_phases: 3
+ completed_phases: 2
+ total_plans: 2
+ completed_plans: 2
+ percent: 67
+stopped_at: Phase 2 complete (2/2) - ready to discuss Phase 3
+---
+
# Project State
## Project Reference
See: `.planning/PROJECT.md` (updated 2026-06-11)
-**Core value:** Every CAS component can exchange trustworthy lifecycle data without guessing structure, identity, traceability, or compatibility.
-**Current focus:** Phase 2 - Compatibility Automation and Distribution
+**Core value:** Every CAS component can exchange trustworthy lifecycle data without guessing structure, identity, traceability, or compatibility.
+**Current focus:** Phase 3 - Typed SDKs and Adoption
## Status
-- Phase: 1 of 3 complete; Phase 2 pending
+- Phase: 2 of 3 complete; Phase 3 pending
- Milestone: v0.1
- Mode: YOLO, quality, parallel
-- Next action: Integrate v0.1 contracts into CAS producers and consumers, then automate compatibility checks.
+- Next action: Plan Phase 3 typed SDKs and adoption.
## Decisions
- JSON Schema Draft 2020-12 is authoritative.
- Shared lifecycle metadata is mandatory.
- Examples are executable fixtures.
+- Unknown schema semantic changes require review rather than a compatibility claim.
+- Registry publication preserves immutable releases and advances stable version lines.
diff --git a/.planning/config.json b/.planning/config.json
index 88f1703..a52f3c1 100644
--- a/.planning/config.json
+++ b/.planning/config.json
@@ -10,6 +10,6 @@
"verifier": true,
"nyquist_validation": true,
"auto_advance": true,
- "_auto_chain_active": true
+ "_auto_chain_active": false
}
}
diff --git a/.planning/phases/02-compatibility-automation-and-distribution/02-01-PLAN.md b/.planning/phases/02-compatibility-automation-and-distribution/02-01-PLAN.md
new file mode 100644
index 0000000..93983fb
--- /dev/null
+++ b/.planning/phases/02-compatibility-automation-and-distribution/02-01-PLAN.md
@@ -0,0 +1,54 @@
+---
+phase: 02-compatibility-automation-and-distribution
+plan: "01"
+type: execute
+wave: 1
+depends_on: []
+files_modified:
+ - package.json
+ - scripts/compatibility.mjs
+ - scripts/lib.mjs
+ - tests/compatibility.test.mjs
+ - tests/fixtures/compatibility/
+ - .github/workflows/compatibility.yml
+ - docs/VERSIONING.md
+ - CONTRIBUTING.md
+autonomous: true
+requirements: [COMP-01]
+must_haves:
+ truths:
+ - Pull requests automatically classify schema changes against their merge base.
+ - Breaking and review-required changes cannot be silently reported as compatible.
+ - Compatibility results are available as machine-readable JSON and a CI summary.
+---
+
+
+Implement conservative automated JSON Schema breaking-change classification and enforce it in pull-request CI.
+
+
+
+
+ Implement classifier and CLI
+ scripts/lib.mjs, docs/VERSIONING.md, schemas/v0.1/common.schema.json
+ Add a dependency-free directional schema-tree comparator. Report unchanged, compatible, breaking, and review_required outcomes with JSON details. Exit nonzero for breaking changes and support explicit base/head directories.
+ `node scripts/compatibility.mjs --help` exits successfully and documents CLI inputs and exit behavior.
+
+
+ Add compatibility fixtures and tests
+ tests/contracts.test.mjs, scripts/compatibility.mjs
+ Add tests for additions, removals, required properties, type and enum narrowing, extensibility tightening, constraints, schema removal, and unknown changed keywords.
+ `npm.cmd test` passes and exercises all four classifications.
+
+
+ Integrate PR compatibility CI and governance docs
+ .github/workflows/ci.yml, CONTRIBUTING.md, docs/VERSIONING.md
+ Add a least-privilege pull-request workflow that compares against the merge base, writes a job summary, uploads the JSON report, and fails on breaking changes. Document local classification and review-required semantics.
+ Workflow uses full-history checkout, runs the classifier, uploads its report, and has read-only contents permission.
+
+
+
+
+- `npm.cmd test`
+- `npm.cmd run validate`
+- Run the CLI against identical schema trees and a known breaking fixture.
+
diff --git a/.planning/phases/02-compatibility-automation-and-distribution/02-01-SUMMARY.md b/.planning/phases/02-compatibility-automation-and-distribution/02-01-SUMMARY.md
new file mode 100644
index 0000000..ea5cd19
--- /dev/null
+++ b/.planning/phases/02-compatibility-automation-and-distribution/02-01-SUMMARY.md
@@ -0,0 +1,49 @@
+---
+phase: 02-compatibility-automation-and-distribution
+plan: "01"
+subsystem: compatibility
+tags: [json-schema, ci, semver]
+requires: [01-useful-v0.1-foundation]
+provides: [compatibility-classifier, compatibility-pr-check]
+affects: [distribution, governance]
+tech-stack:
+ added: []
+ patterns: [conservative-directional-classification, machine-readable-ci-report]
+key-files:
+ created:
+ - scripts/compatibility.mjs
+ - tests/compatibility.test.mjs
+ - .github/workflows/compatibility.yml
+ modified:
+ - scripts/lib.mjs
+ - docs/VERSIONING.md
+ - CONTRIBUTING.md
+key-decisions:
+ - Unknown semantic keyword changes are review_required and fail CI rather than being falsely classified compatible.
+requirements-completed: [COMP-01]
+completed: 2026-06-11
+---
+
+# Phase 2 Plan 1: Compatibility Automation Summary
+
+Implemented a dependency-free directional JSON Schema classifier with pull-request enforcement and machine-readable reports.
+
+## Results
+
+- Classifies unchanged, compatible, breaking, and review-required schema tree changes.
+- Detects property, required-field, type, enum, constraint, extensibility, definition, and schema-file compatibility changes.
+- Compares pull requests with the latest semantic release and fails CI for breaking and review-required changes.
+- Documents local use and governance semantics.
+
+## Verification
+
+- `npm.cmd test`: 8/8 passed
+- `npm.cmd run validate`: 7 lifecycle examples validated
+- Identical live schema tree classified as `unchanged`
+- CLI help and exit behavior verified
+
+## Deviations from Plan
+
+Compatibility tests generate isolated fixtures at runtime instead of storing static fixture directories. This keeps each case explicit and avoids fixture drift.
+
+## Self-Check: PASSED
diff --git a/.planning/phases/02-compatibility-automation-and-distribution/02-02-PLAN.md b/.planning/phases/02-compatibility-automation-and-distribution/02-02-PLAN.md
new file mode 100644
index 0000000..84b692b
--- /dev/null
+++ b/.planning/phases/02-compatibility-automation-and-distribution/02-02-PLAN.md
@@ -0,0 +1,55 @@
+---
+phase: 02-compatibility-automation-and-distribution
+plan: "02"
+type: execute
+wave: 2
+depends_on: ["02-01"]
+files_modified:
+ - package.json
+ - scripts/build-registry.mjs
+ - scripts/lib.mjs
+ - tests/registry.test.mjs
+ - .github/workflows/publish-registry.yml
+ - README.md
+ - docs/DISTRIBUTION.md
+ - CHANGELOG.md
+autonomous: true
+requirements: [REG-01]
+must_haves:
+ truths:
+ - Released schemas are packaged reproducibly under immutable release and stable major/minor paths.
+ - Every distributed schema remains valid and resolvable with relative references.
+ - Release publication deploys the validated registry to GitHub Pages using least privilege.
+---
+
+
+Build and publish a deterministic versioned schema registry that serves released schemas from stable URLs.
+
+
+
+
+ Implement deterministic registry builder
+ scripts/lib.mjs, package.json, schemas/v0.1/common.schema.json
+ Add a cross-platform builder that validates a semantic release version, copies the matching schema line to immutable and stable paths, creates digest-bearing manifests, and produces deterministic output.
+ `npm.cmd run build:registry -- --version 0.1.0` produces validated registry output with manifests and SHA-256 digests.
+
+
+ Add registry distribution tests
+ tests/contracts.test.mjs, scripts/build-registry.mjs
+ Test path layout, manifest contents, digests, deterministic rebuilds, compiled distributed schemas, and invalid version rejection.
+ `npm.cmd test` passes with registry tests included.
+
+
+ Add release publication workflow and consumer docs
+ .github/workflows/ci.yml, README.md, docs/VERSIONING.md
+ Add a semantic-tag release workflow that builds, validates, uploads, and deploys the Pages artifact. Document stable and immutable URLs, discovery manifests, integrity verification, and Pages/custom-domain setup.
+ Workflow has explicit Pages permissions, release-tag validation, artifact build validation, and deployment concurrency.
+
+
+
+
+- `npm.cmd test`
+- `npm.cmd run validate`
+- `npm.cmd run build:registry -- --version 0.1.0`
+- Validate generated manifests and distributed schemas.
+
diff --git a/.planning/phases/02-compatibility-automation-and-distribution/02-02-SUMMARY.md b/.planning/phases/02-compatibility-automation-and-distribution/02-02-SUMMARY.md
new file mode 100644
index 0000000..7e79c39
--- /dev/null
+++ b/.planning/phases/02-compatibility-automation-and-distribution/02-02-SUMMARY.md
@@ -0,0 +1,55 @@
+---
+phase: 02-compatibility-automation-and-distribution
+plan: "02"
+subsystem: distribution
+tags: [registry, github-pages, releases, integrity]
+requires: [02-01-compatibility-automation]
+provides: [versioned-schema-registry, release-publication]
+affects: [consumers, release-governance]
+tech-stack:
+ added: []
+ patterns: [deterministic-release-packaging, immutable-and-stable-urls, digest-manifests]
+key-files:
+ created:
+ - scripts/build-registry.mjs
+ - scripts/validate-registry.mjs
+ - tests/registry.test.mjs
+ - .github/workflows/publish-registry.yml
+ - docs/DISTRIBUTION.md
+ modified:
+ - scripts/lib.mjs
+ - README.md
+ - CHANGELOG.md
+key-decisions:
+ - Rebuild all semantic-version tags on publication so immutable release URLs remain available.
+ - Advance stable major/minor URLs to the latest patch release in each line.
+ - Publish digest-bearing manifests and validate the exact Pages artifact before deployment.
+requirements-completed: [REG-01]
+completed: 2026-06-11
+---
+
+# Phase 2 Plan 2: Versioned Registry Distribution Summary
+
+Implemented deterministic schema registry packaging with stable and immutable URLs, integrity manifests, validation, documentation, and release-triggered GitHub Pages deployment.
+
+## Results
+
+- Packages schemas under `/vMAJOR.MINOR/` and `/releases/vMAJOR.MINOR.PATCH/`.
+- Preserves every semantic-version tag during Pages publication.
+- Produces root discovery metadata and SHA-256 manifests.
+- Validates schema compilation, IDs, versions, and digests before deployment.
+- Documents consumer URL selection and administrator setup.
+
+## Verification
+
+- `npm.cmd test`: 12/12 passed
+- `npm.cmd run validate`: 7 lifecycle examples validated
+- `npm.cmd run build:registry -- --version 0.1.0`: 8 schemas built
+- `npm.cmd run validate:registry -- --registry registry`: 1 release and 1 stable line validated
+- Distributed schemas compared compatible with source schemas
+
+## Deviations from Plan
+
+Added a dedicated registry validator beyond the original plan so the exact multi-tag artifact is verified after assembly and before deployment.
+
+## Self-Check: PASSED
diff --git a/.planning/phases/02-compatibility-automation-and-distribution/02-RESEARCH.md b/.planning/phases/02-compatibility-automation-and-distribution/02-RESEARCH.md
new file mode 100644
index 0000000..3f8d27e
--- /dev/null
+++ b/.planning/phases/02-compatibility-automation-and-distribution/02-RESEARCH.md
@@ -0,0 +1,28 @@
+# Phase 2 Research: Compatibility Automation and Distribution
+
+## Findings
+
+- JSON Schema compatibility is directional: a consumer-compatible change must continue accepting every instance accepted by the prior schema.
+- A deterministic, conservative classifier is preferable to an incomplete "compatible" claim. Unknown changed keywords must be reported as `review_required`.
+- Breaking patterns that can be classified reliably include removed schemas or properties, newly required properties, narrowed types or enums, tightened object extensibility, and tightened numeric/string/array bounds.
+- Pull request automation should compare the proposed `schemas/` tree with the merge-base version and publish a machine-readable report plus a human-readable job summary.
+- The authoritative `$id` namespace is already `https://schemas.coding-autopilot.dev/v0.1/`.
+- Distribution should be reproducible locally, validate every generated schema, and deploy only immutable release-tag content to GitHub Pages.
+- A registry manifest should provide release version, available schema IDs, relative paths, and SHA-256 digests so consumers can discover and verify artifacts.
+
+## Strategy
+
+1. Add a dependency-free compatibility classifier and fixtures that prove breaking, non-breaking, unchanged, and review-required outcomes.
+2. Add CI that classifies pull-request schema changes against the merge base and uploads the JSON report.
+3. Add a deterministic registry builder that packages schemas under both an immutable release path and the stable major/minor path.
+4. Add release-triggered Pages deployment with explicit permissions, concurrency, and artifact validation.
+
+## Risks And Mitigations
+
+| Risk | Mitigation |
+|------|------------|
+| False compatibility claim | Classify unsupported changed keywords as `review_required`, never silently compatible |
+| Mutable release artifacts | Build only from semantic-version tags and include SHA-256 digests |
+| Broken relative `$ref` values | Preserve each version directory layout and compile generated schemas in tests |
+| CI unable to compare shallow history | Checkout full history for compatibility job |
+| Pages custom domain not configured | Document DNS/Pages prerequisite and keep generated artifact independently testable |
diff --git a/.planning/phases/02-compatibility-automation-and-distribution/02-VERIFICATION.md b/.planning/phases/02-compatibility-automation-and-distribution/02-VERIFICATION.md
new file mode 100644
index 0000000..de23e04
--- /dev/null
+++ b/.planning/phases/02-compatibility-automation-and-distribution/02-VERIFICATION.md
@@ -0,0 +1,42 @@
+---
+phase: 02-compatibility-automation-and-distribution
+status: passed
+score: 2/2
+verified: 2026-06-11
+requirements: [COMP-01, REG-01]
+---
+
+# Phase 2 Verification
+
+## Goal
+
+Make contracts safe and easy to consume across repositories through automated compatibility classification and stable versioned distribution.
+
+## Must-Have Verification
+
+| Requirement | Result | Evidence |
+|-------------|--------|----------|
+| COMP-01: CI automatically compares proposed schemas against the latest release for compatibility | Passed | `scripts/compatibility.mjs`, 5 compatibility tests, and `.github/workflows/compatibility.yml` compare pull requests with the latest semantic tag and fail breaking or review-required results |
+| REG-01: Consumers can resolve published schemas from a stable registry endpoint | Passed | `scripts/build-registry.mjs`, `scripts/validate-registry.mjs`, 4 registry tests, `.github/workflows/publish-registry.yml`, and `docs/DISTRIBUTION.md` define stable and immutable release URLs |
+
+## Automated Checks
+
+- `npm.cmd test`: 13/13 passed
+- `npm.cmd run validate`: 7 lifecycle examples validated
+- `npm.cmd run build:registry -- --version 0.1.0`: 8 schemas packaged
+- `npm.cmd run validate:registry -- --registry registry`: 1 release and 1 stable line validated
+- Compatibility comparison against archived tag `v0.1.0`: `unchanged`
+- Compatibility comparison against current tree: `unchanged`
+- New workflow YAML validation: passed
+- Schema drift gate: no drift
+- `git diff --check`: passed after normalization
+
+## Review Notes
+
+- The classifier intentionally fails unknown semantic changes as `review_required`.
+- Publication rebuilds all semantic tags so immutable release paths are retained.
+- The GSD codebase-drift command was unavailable; this gate is non-blocking and direct diff review found no structural gaps.
+
+## Result
+
+Phase 2 goal and both mapped requirements are verified.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 51abeb4..9307c4b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,12 @@ All notable changes follow Semantic Versioning and Keep a Changelog conventions.
## [Unreleased]
+### Added
+
+- Conservative automated JSON Schema compatibility classification in pull-request CI.
+- Deterministic schema registry packaging and release-triggered GitHub Pages distribution.
+- Stable major/minor and immutable patch-version schema URL contracts with SHA-256 manifests.
+
## [0.1.0] - 2026-06-11
### Added
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 33ce3dd..40fcf66 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -18,6 +18,14 @@ npm run validate
4. Run the full validation suite.
5. Submit a focused pull request with compatibility impact and migration notes.
+Run the automated compatibility classifier against the current release or target branch before submitting:
+
+```powershell
+npm run compatibility -- --base path\to\baseline\schemas --head schemas --output compatibility-report.json
+```
+
+Pull-request CI repeats this comparison. Breaking changes and changes requiring semantic review fail the compatibility job.
+
Breaking changes require a new major-version directory and explicit maintainer approval.
Do not include secrets, credentials, customer data, or personal data in schemas or examples.
diff --git a/README.md b/README.md
index dab99e0..14e7881 100644
--- a/README.md
+++ b/README.md
@@ -46,6 +46,7 @@ npm run validate
| `examples/v0.1/` | Complete executable lifecycle fixtures |
| `tests/` | Positive, negative, and lifecycle contract tests |
| `docs/VERSIONING.md` | Compatibility and evolution policy |
+| `docs/DISTRIBUTION.md` | Stable and immutable schema registry URLs |
| `.planning/` | GSD project context, requirements, roadmap, and research |
## Adoption
@@ -54,9 +55,11 @@ Producers should emit records that validate against the declared version. Consum
The schemas are public APIs. Review [versioning and compatibility](docs/VERSIONING.md), [contributing](CONTRIBUTING.md), and [security](SECURITY.md) before proposing changes.
+Released schemas are discoverable from `https://schemas.coding-autopilot.dev/index.json`. Use stable `vMAJOR.MINOR` URLs for compatible updates or immutable `releases/vMAJOR.MINOR.PATCH` URLs for reproducible builds. See [schema distribution](docs/DISTRIBUTION.md).
+
## Status
-`v0.1.0` is the useful foundation release. It is ready for early integration and intentionally precedes generated SDKs and automated compatibility comparison.
+`v0.1.0` is the useful foundation release. Compatibility automation and versioned registry distribution are available for subsequent releases.
## License
diff --git a/docs/DISTRIBUTION.md b/docs/DISTRIBUTION.md
new file mode 100644
index 0000000..435ad0f
--- /dev/null
+++ b/docs/DISTRIBUTION.md
@@ -0,0 +1,38 @@
+# Schema Distribution
+
+Released CAS schemas are distributed as a static registry from `https://schemas.coding-autopilot.dev`.
+
+## URL Contract
+
+Use a stable major/minor URL when a consumer should automatically receive compatible patch releases:
+
+```text
+https://schemas.coding-autopilot.dev/v0.1/prompt-envelope.schema.json
+```
+
+Use an immutable release URL when builds or evidence must remain reproducible:
+
+```text
+https://schemas.coding-autopilot.dev/releases/v0.1.0/prompt-envelope.schema.json
+```
+
+Discovery and integrity metadata are available from:
+
+- `/index.json`: available releases and the current release for each major/minor line.
+- `/v0.1/manifest.json`: schemas and SHA-256 digests for the current compatible release.
+- `/releases/v0.1.0/manifest.json`: schemas and SHA-256 digests for one immutable release.
+
+## Local Build
+
+```powershell
+npm run build:registry -- --version 0.1.0
+npm run validate:registry -- --registry registry
+```
+
+The command writes `registry/`, validates the version, preserves relative schema references, and produces deterministic manifests.
+
+## Publication
+
+Pushing a semantic version tag such as `v0.1.1` runs `.github/workflows/publish-registry.yml`. The workflow rebuilds every tagged release, preserves immutable release paths, advances stable major/minor paths, validates the repository, and deploys the result to GitHub Pages.
+
+Repository administrators must configure GitHub Pages to use **GitHub Actions** as its source and configure DNS for the `schemas.coding-autopilot.dev` custom domain. Publication uses GitHub's OIDC token and does not require stored deployment credentials.
diff --git a/docs/VERSIONING.md b/docs/VERSIONING.md
index 186a22a..17d4f86 100644
--- a/docs/VERSIONING.md
+++ b/docs/VERSIONING.md
@@ -27,3 +27,20 @@ Major releases may remove or rename properties, add required properties, narrow
## Change Procedure
Every contract change must include updated examples, tests, changelog entry, compatibility classification, and migration notes for breaking changes.
+
+## Automated Classification
+
+Pull requests that affect schemas run the directional compatibility classifier against the latest semantic-version release:
+
+```powershell
+npm run compatibility -- --base path\to\baseline\schemas --head schemas --output compatibility-report.json
+```
+
+The classifier reports:
+
+- `unchanged`: no schema behavior changed.
+- `compatible`: known additive or relaxed changes only.
+- `breaking`: known changes that invalidate previously accepted payloads.
+- `review_required`: changed semantics that cannot be classified safely by the automated rules.
+
+Both `breaking` and `review_required` exit nonzero. A `review_required` result must be resolved by simplifying the change, extending classifier coverage with tests, or obtaining explicit maintainer review. Automation never treats an unknown semantic change as compatible.
diff --git a/package.json b/package.json
index b0dcf0f..d20790b 100644
--- a/package.json
+++ b/package.json
@@ -8,6 +8,9 @@
"node": ">=22"
},
"scripts": {
+ "build:registry": "node scripts/build-registry.mjs",
+ "compatibility": "node scripts/compatibility.mjs",
+ "validate:registry": "node scripts/validate-registry.mjs",
"validate": "node scripts/validate.mjs",
"test": "node --test"
},
diff --git a/scripts/build-registry.mjs b/scripts/build-registry.mjs
new file mode 100644
index 0000000..77e3760
--- /dev/null
+++ b/scripts/build-registry.mjs
@@ -0,0 +1,103 @@
+import { copyFile, mkdir, readFile, rm, writeFile } from "node:fs/promises";
+import { createHash } from "node:crypto";
+import path from "node:path";
+import { pathToFileURL } from "node:url";
+import { jsonFilesRecursive, readJson, root } from "./lib.mjs";
+
+const versionPattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
+
+function parseArguments(args) {
+ if (args.includes("--help")) return { help: true };
+ const result = { output: path.join(root, "registry"), append: false, domain: "schemas.coding-autopilot.dev" };
+ for (let index = 0; index < args.length; index += 1) {
+ if (args[index] === "--append") result.append = true;
+ else {
+ const key = args[index].replace(/^--/, "");
+ if (!args[index + 1]) throw new Error(`Missing value for ${args[index]}`);
+ result[key] = args[++index];
+ }
+ }
+ if (!versionPattern.test(result.version ?? "")) throw new Error("--version must be a semantic version such as 0.1.0");
+ const [, major, minor] = result.version.match(versionPattern);
+ result.line = `v${major}.${minor}`;
+ result.source ??= path.join(root, "schemas", result.line);
+ return result;
+}
+
+async function digest(file) {
+ return createHash("sha256").update(await readFile(file)).digest("hex");
+}
+
+async function writeJson(file, value) {
+ await mkdir(path.dirname(file), { recursive: true });
+ await writeFile(file, `${JSON.stringify(value, null, 2)}\n`);
+}
+
+async function packageSchemas(source, destination, version) {
+ const files = await jsonFilesRecursive(source);
+ if (!files.length) throw new Error(`No JSON schemas found in ${source}`);
+ const schemas = [];
+ for (const file of files) {
+ const relative = path.relative(source, file).replaceAll("\\", "/");
+ const output = path.join(destination, relative);
+ const schema = await readJson(file);
+ if (!schema.$id) throw new Error(`${relative} does not declare $id`);
+ await mkdir(path.dirname(output), { recursive: true });
+ await copyFile(file, output);
+ schemas.push({ id: schema.$id, path: relative, sha256: await digest(file) });
+ }
+ const manifest = { version, schemas: schemas.sort((left, right) => left.path.localeCompare(right.path)) };
+ await writeJson(path.join(destination, "manifest.json"), manifest);
+ return manifest;
+}
+
+export async function buildRegistry({ version, source, output, append = false, domain = "schemas.coding-autopilot.dev" }) {
+ if (!versionPattern.test(version ?? "")) throw new Error("version must be a semantic version such as 0.1.0");
+ const [, major, minor] = version.match(versionPattern);
+ const line = `v${major}.${minor}`;
+ const outputRoot = path.resolve(output);
+ if (!append) await rm(outputRoot, { recursive: true, force: true });
+ await mkdir(outputRoot, { recursive: true });
+
+ const immutablePath = path.join(outputRoot, "releases", `v${version}`);
+ const stablePath = path.join(outputRoot, line);
+ await rm(immutablePath, { recursive: true, force: true });
+ await rm(stablePath, { recursive: true, force: true });
+ const immutable = await packageSchemas(path.resolve(source), immutablePath, version);
+ const stable = await packageSchemas(path.resolve(source), stablePath, version);
+
+ const indexPath = path.join(outputRoot, "index.json");
+ let existing = { releases: [], lines: {} };
+ if (append) {
+ try { existing = JSON.parse(await readFile(indexPath, "utf8")); } catch (error) {
+ if (error.code !== "ENOENT") throw error;
+ }
+ }
+ const releases = [...new Set([...existing.releases, version])].sort((left, right) =>
+ left.localeCompare(right, undefined, { numeric: true }));
+ const index = {
+ schemaVersion: "1.0.0",
+ releases,
+ lines: { ...existing.lines, [line]: version }
+ };
+ await writeJson(indexPath, index);
+ await writeFile(path.join(outputRoot, "CNAME"), `${domain}\n`);
+ return { index, immutable, stable, output: outputRoot };
+}
+
+async function main() {
+ const args = parseArguments(process.argv.slice(2));
+ if (args.help) {
+ console.log("Usage: node scripts/build-registry.mjs --version [--source ] [--output ] [--append]");
+ return;
+ }
+ const result = await buildRegistry(args);
+ console.log(`Built ${result.stable.schemas.length} schemas for ${args.version} at ${result.output}`);
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
+ main().catch((error) => {
+ console.error(error.message);
+ process.exitCode = 1;
+ });
+}
diff --git a/scripts/compatibility.mjs b/scripts/compatibility.mjs
new file mode 100644
index 0000000..c281bb6
--- /dev/null
+++ b/scripts/compatibility.mjs
@@ -0,0 +1,236 @@
+import { mkdir, writeFile } from "node:fs/promises";
+import path from "node:path";
+import { pathToFileURL } from "node:url";
+import { jsonFilesRecursive, readJson } from "./lib.mjs";
+
+const severity = { unchanged: 0, compatible: 1, review_required: 2, breaking: 3 };
+const annotations = new Set([
+ "$comment", "$schema", "default", "deprecated", "description", "examples",
+ "readOnly", "title", "writeOnly"
+]);
+const handled = new Set([
+ ...annotations, "$defs", "$id", "$ref", "additionalProperties", "const", "enum",
+ "allOf", "anyOf", "exclusiveMaximum", "exclusiveMinimum", "format", "items", "maxItems", "maxLength",
+ "maxProperties", "maximum", "minItems", "minLength", "minProperties", "minimum",
+ "multipleOf", "oneOf", "pattern", "properties", "required", "type", "unevaluatedProperties",
+ "uniqueItems"
+]);
+
+function stable(value) {
+ if (Array.isArray(value)) return `[${value.map(stable).join(",")}]`;
+ if (value && typeof value === "object") {
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stable(value[key])}`).join(",")}}`;
+ }
+ return JSON.stringify(value);
+}
+
+function equal(left, right) {
+ return stable(left) === stable(right);
+}
+
+function add(changes, status, location, message) {
+ changes.push({ status, location, message });
+}
+
+function asSet(value) {
+ if (value === undefined) return null;
+ return new Set(Array.isArray(value) ? value : [value]);
+}
+
+function compareSetKeyword(changes, keyword, before, after, location) {
+ const oldSet = asSet(before);
+ const newSet = asSet(after);
+ if (equal(before, after)) return;
+ if (!newSet) return add(changes, "compatible", location, `${keyword} constraint removed`);
+ if (!oldSet) return add(changes, "breaking", location, `${keyword} constraint added`);
+ const removed = [...oldSet].filter((item) => !newSet.has(item));
+ add(changes, removed.length ? "breaking" : "compatible", location,
+ removed.length ? `${keyword} narrowed by removing ${removed.join(", ")}` : `${keyword} widened`);
+}
+
+function compareBound(changes, keyword, before, after, location, lowerBound) {
+ if (equal(before, after)) return;
+ if (after === undefined) return add(changes, "compatible", location, `${keyword} constraint removed`);
+ if (before === undefined) return add(changes, "breaking", location, `${keyword} constraint added`);
+ const tighter = lowerBound ? after > before : after < before;
+ add(changes, tighter ? "breaking" : "compatible", location, `${keyword} changed from ${before} to ${after}`);
+}
+
+function compareBooleanConstraint(changes, keyword, before, after, location) {
+ if (equal(before, after)) return;
+ if (after === undefined || after === false) {
+ return add(changes, "compatible", location, `${keyword} constraint relaxed`);
+ }
+ add(changes, "breaking", location, `${keyword} constraint tightened`);
+}
+
+function compareSchema(before, after, location, changes) {
+ if (equal(before, after)) return;
+ if (typeof before !== "object" || before === null || typeof after !== "object" || after === null) {
+ add(changes, "review_required", location, "schema shape changed");
+ return;
+ }
+
+ compareSetKeyword(changes, "type", before.type, after.type, location);
+ compareSetKeyword(changes, "enum", before.enum, after.enum, location);
+
+ if (!equal(before.const, after.const)) {
+ if (after.const === undefined) add(changes, "compatible", location, "const constraint removed");
+ else add(changes, "breaking", location, "const constraint added or changed");
+ }
+
+ const oldRequired = new Set(before.required ?? []);
+ const newRequired = new Set(after.required ?? []);
+ for (const name of newRequired) {
+ if (!oldRequired.has(name)) add(changes, "breaking", `${location}/required`, `required property added: ${name}`);
+ }
+ for (const name of oldRequired) {
+ if (!newRequired.has(name)) add(changes, "compatible", `${location}/required`, `required property removed: ${name}`);
+ }
+
+ const oldProperties = before.properties ?? {};
+ const newProperties = after.properties ?? {};
+ for (const name of Object.keys(oldProperties)) {
+ if (!(name in newProperties)) {
+ const closesObject = before.additionalProperties === false || before.unevaluatedProperties === false;
+ add(changes, closesObject ? "breaking" : "compatible", `${location}/properties/${name}`,
+ closesObject ? "property removed from closed object" : "property constraint removed");
+ }
+ else compareSchema(oldProperties[name], newProperties[name], `${location}/properties/${name}`, changes);
+ }
+ for (const name of Object.keys(newProperties)) {
+ if (!(name in oldProperties)) {
+ add(changes, newRequired.has(name) ? "breaking" : "compatible", `${location}/properties/${name}`,
+ newRequired.has(name) ? "required property added" : "optional property added");
+ }
+ }
+
+ for (const keyword of ["additionalProperties", "unevaluatedProperties"]) {
+ const oldValue = before[keyword];
+ const newValue = after[keyword];
+ if (equal(oldValue, newValue)) continue;
+ if (newValue === false && oldValue !== false) add(changes, "breaking", location, `${keyword} tightened to false`);
+ else if (oldValue === false && newValue !== false) add(changes, "compatible", location, `${keyword} relaxed`);
+ else if (typeof oldValue === "object" && typeof newValue === "object") {
+ compareSchema(oldValue, newValue, `${location}/${keyword}`, changes);
+ } else add(changes, "review_required", location, `${keyword} changed`);
+ }
+
+ for (const [keyword, lower] of [
+ ["minimum", true], ["exclusiveMinimum", true], ["minLength", true], ["minItems", true], ["minProperties", true],
+ ["maximum", false], ["exclusiveMaximum", false], ["maxLength", false], ["maxItems", false], ["maxProperties", false]
+ ]) compareBound(changes, keyword, before[keyword], after[keyword], location, lower);
+
+ for (const keyword of ["pattern", "format", "multipleOf", "$ref", "$id"]) {
+ if (equal(before[keyword], after[keyword])) continue;
+ if (after[keyword] === undefined && !["$ref", "$id"].includes(keyword)) {
+ add(changes, "compatible", location, `${keyword} constraint removed`);
+ } else if (before[keyword] === undefined && !["$ref", "$id"].includes(keyword)) {
+ add(changes, "breaking", location, `${keyword} constraint added`);
+ } else add(changes, "review_required", location, `${keyword} changed`);
+ }
+
+ compareBooleanConstraint(changes, "uniqueItems", before.uniqueItems, after.uniqueItems, location);
+ if (!equal(before.items, after.items)) {
+ if (before.items === undefined) add(changes, "breaking", `${location}/items`, "items constraint added");
+ else if (after.items === undefined) add(changes, "compatible", `${location}/items`, "items constraint removed");
+ else compareSchema(before.items, after.items, `${location}/items`, changes);
+ }
+
+ for (const keyword of ["allOf", "anyOf"]) {
+ if (equal(before[keyword], after[keyword])) continue;
+ const oldSchemas = before[keyword] ?? [];
+ const newSchemas = after[keyword] ?? [];
+ const common = Math.min(oldSchemas.length, newSchemas.length);
+ for (let index = 0; index < common; index += 1) {
+ compareSchema(oldSchemas[index], newSchemas[index], `${location}/${keyword}/${index}`, changes);
+ }
+ if (oldSchemas.length !== newSchemas.length) {
+ const additionBreaks = keyword === "allOf";
+ const added = newSchemas.length > oldSchemas.length;
+ add(changes, added === additionBreaks ? "breaking" : "compatible", `${location}/${keyword}`,
+ `${keyword} schema count changed from ${oldSchemas.length} to ${newSchemas.length}`);
+ }
+ }
+
+ if (!equal(before.oneOf, after.oneOf)) {
+ add(changes, "review_required", `${location}/oneOf`, "oneOf semantics changed");
+ }
+
+ if (!equal(before.$defs, after.$defs)) {
+ const oldDefs = before.$defs ?? {};
+ const newDefs = after.$defs ?? {};
+ for (const name of Object.keys(oldDefs)) {
+ if (!(name in newDefs)) add(changes, "breaking", `${location}/$defs/${name}`, "definition removed");
+ else compareSchema(oldDefs[name], newDefs[name], `${location}/$defs/${name}`, changes);
+ }
+ for (const name of Object.keys(newDefs)) {
+ if (!(name in oldDefs)) add(changes, "compatible", `${location}/$defs/${name}`, "definition added");
+ }
+ }
+
+ for (const keyword of new Set([...Object.keys(before), ...Object.keys(after)])) {
+ if (!handled.has(keyword) && !equal(before[keyword], after[keyword])) {
+ add(changes, "review_required", `${location}/${keyword}`, "unsupported keyword changed");
+ }
+ }
+}
+
+export async function classifyDirectories(baseDirectory, headDirectory) {
+ const baseFiles = await jsonFilesRecursive(baseDirectory);
+ const headFiles = await jsonFilesRecursive(headDirectory);
+ const base = new Map(baseFiles.map((file) => [path.relative(baseDirectory, file).replaceAll("\\", "/"), file]));
+ const head = new Map(headFiles.map((file) => [path.relative(headDirectory, file).replaceAll("\\", "/"), file]));
+ const changes = [];
+
+ for (const [relative, file] of base) {
+ if (!head.has(relative)) add(changes, "breaking", relative, "schema removed");
+ else compareSchema(await readJson(file), await readJson(head.get(relative)), relative, changes);
+ }
+ for (const relative of head.keys()) {
+ if (!base.has(relative)) add(changes, "compatible", relative, "schema added");
+ }
+
+ const status = changes.reduce((result, change) => severity[change.status] > severity[result] ? change.status : result, "unchanged");
+ return {
+ status,
+ summary: Object.fromEntries(Object.keys(severity).map((key) => [key, changes.filter((change) => change.status === key).length])),
+ changes
+ };
+}
+
+function parseArguments(args) {
+ if (args.includes("--help")) return { help: true };
+ const result = {};
+ for (let index = 0; index < args.length; index += 2) {
+ const key = args[index]?.replace(/^--/, "");
+ if (!key || !args[index + 1]) throw new Error(`Missing value for ${args[index] ?? "argument"}`);
+ result[key] = args[index + 1];
+ }
+ if (!result.base || !result.head) throw new Error("--base and --head are required");
+ return result;
+}
+
+async function main() {
+ const args = parseArguments(process.argv.slice(2));
+ if (args.help) {
+ console.log("Usage: node scripts/compatibility.mjs --base --head [--output ]");
+ console.log("Exit codes: 0 unchanged/compatible, 1 breaking, 2 review_required or usage error.");
+ return;
+ }
+ const report = await classifyDirectories(path.resolve(args.base), path.resolve(args.head));
+ const output = `${JSON.stringify(report, null, 2)}\n`;
+ if (args.output) {
+ await mkdir(path.dirname(path.resolve(args.output)), { recursive: true });
+ await writeFile(path.resolve(args.output), output);
+ }
+ console.log(output.trim());
+ process.exitCode = report.status === "breaking" ? 1 : report.status === "review_required" ? 2 : 0;
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
+ main().catch((error) => {
+ console.error(error.message);
+ process.exitCode = 2;
+ });
+}
diff --git a/scripts/lib.mjs b/scripts/lib.mjs
index a2eb665..b9f2194 100644
--- a/scripts/lib.mjs
+++ b/scripts/lib.mjs
@@ -18,11 +18,20 @@ export async function jsonFiles(directory) {
.map((name) => path.join(directory, name));
}
-export async function createValidator() {
+export async function jsonFilesRecursive(directory) {
+ const entries = await readdir(directory, { withFileTypes: true });
+ const files = await Promise.all(entries.map((entry) => {
+ const entryPath = path.join(directory, entry.name);
+ return entry.isDirectory() ? jsonFilesRecursive(entryPath) : entry.name.endsWith(".json") ? [entryPath] : [];
+ }));
+ return files.flat().sort();
+}
+
+export async function createValidator(directory = schemaDirectory) {
const ajv = new Ajv2020({ allErrors: true, strict: true });
addFormats(ajv);
- for (const schemaPath of await jsonFiles(schemaDirectory)) {
+ for (const schemaPath of (await jsonFiles(directory)).filter((file) => file.endsWith(".schema.json"))) {
ajv.addSchema(await readJson(schemaPath));
}
diff --git a/scripts/validate-registry.mjs b/scripts/validate-registry.mjs
new file mode 100644
index 0000000..3e01025
--- /dev/null
+++ b/scripts/validate-registry.mjs
@@ -0,0 +1,51 @@
+import assert from "node:assert/strict";
+import { createHash } from "node:crypto";
+import { readFile } from "node:fs/promises";
+import path from "node:path";
+import { pathToFileURL } from "node:url";
+import { createValidator, readJson, root } from "./lib.mjs";
+
+async function validateDirectory(directory, expectedVersion) {
+ const manifest = await readJson(path.join(directory, "manifest.json"));
+ assert.equal(manifest.version, expectedVersion, `${directory} has the expected version`);
+ const validator = await createValidator(directory);
+ for (const entry of manifest.schemas) {
+ const file = path.join(directory, entry.path);
+ const content = await readFile(file);
+ assert.equal(createHash("sha256").update(content).digest("hex"), entry.sha256, `${entry.path} digest matches`);
+ const schema = JSON.parse(content);
+ assert.equal(schema.$id, entry.id, `${entry.path} id matches`);
+ assert.ok(validator.getSchema(entry.id), `${entry.id} compiles`);
+ }
+}
+
+export async function validateRegistry(registry) {
+ const directory = path.resolve(registry);
+ const index = await readJson(path.join(directory, "index.json"));
+ assert.equal(index.schemaVersion, "1.0.0");
+ for (const version of index.releases) {
+ assert.match(version, /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/);
+ await validateDirectory(path.join(directory, "releases", `v${version}`), version);
+ }
+ for (const [line, version] of Object.entries(index.lines)) {
+ assert.match(line, /^v(0|[1-9]\d*)\.(0|[1-9]\d*)$/);
+ assert.ok(index.releases.includes(version), `${line} points to a released version`);
+ await validateDirectory(path.join(directory, line), version);
+ }
+ return index;
+}
+
+async function main() {
+ const index = process.argv.indexOf("--registry");
+ const registry = index >= 0 ? process.argv[index + 1] : path.join(root, "registry");
+ if (!registry) throw new Error("--registry requires a path");
+ const result = await validateRegistry(registry);
+ console.log(`Validated ${result.releases.length} release(s) and ${Object.keys(result.lines).length} stable line(s).`);
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
+ main().catch((error) => {
+ console.error(error.message);
+ process.exitCode = 1;
+ });
+}
diff --git a/tests/compatibility.test.mjs b/tests/compatibility.test.mjs
new file mode 100644
index 0000000..da6fde5
--- /dev/null
+++ b/tests/compatibility.test.mjs
@@ -0,0 +1,63 @@
+import assert from "node:assert/strict";
+import { mkdtemp, mkdir, writeFile } from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import test from "node:test";
+import { classifyDirectories } from "../scripts/compatibility.mjs";
+
+async function classify(before, after, additionalBefore = {}, additionalAfter = {}) {
+ const root = await mkdtemp(path.join(os.tmpdir(), "cas-compat-"));
+ const base = path.join(root, "base");
+ const head = path.join(root, "head");
+ await Promise.all([mkdir(base), mkdir(head)]);
+ const write = (directory, name, value) => writeFile(path.join(directory, name), JSON.stringify(value));
+ await write(base, "contract.schema.json", before);
+ await write(head, "contract.schema.json", after);
+ for (const [name, value] of Object.entries(additionalBefore)) await write(base, name, value);
+ for (const [name, value] of Object.entries(additionalAfter)) await write(head, name, value);
+ return classifyDirectories(base, head);
+}
+
+const objectSchema = {
+ type: "object",
+ additionalProperties: false,
+ required: ["id"],
+ properties: { id: { type: "string" } }
+};
+
+test("classifies identical trees as unchanged", async () => {
+ assert.equal((await classify(objectSchema, objectSchema)).status, "unchanged");
+});
+
+test("classifies additive optional properties and schemas as compatible", async () => {
+ const after = structuredClone(objectSchema);
+ after.properties.label = { type: "string" };
+ const report = await classify(objectSchema, after, {}, { "new.schema.json": { type: "string" } });
+ assert.equal(report.status, "compatible");
+ assert.equal(report.summary.compatible, 2);
+});
+
+test("classifies required additions, removals, narrowing, and tightened constraints as breaking", async () => {
+ const cases = [
+ [{ ...objectSchema }, { ...objectSchema, required: ["id", "label"], properties: { ...objectSchema.properties, label: { type: "string" } } }],
+ [objectSchema, { ...objectSchema, properties: {} }],
+ [{ allOf: [objectSchema] }, { allOf: [{ ...objectSchema, required: ["id", "label"], properties: { ...objectSchema.properties, label: { type: "string" } } }] }],
+ [{ type: ["string", "null"] }, { type: "string" }],
+ [{ enum: ["a", "b"] }, { enum: ["a"] }],
+ [{ type: "string", maxLength: 10 }, { type: "string", maxLength: 5 }],
+ [{ type: "object" }, { type: "object", additionalProperties: false }]
+ ];
+ for (const [before, after] of cases) assert.equal((await classify(before, after)).status, "breaking");
+ assert.equal((await classify(objectSchema, objectSchema, { "removed.schema.json": { type: "string" } })).status, "breaking");
+});
+
+test("classifies unsupported semantic keyword changes as review_required", async () => {
+ const report = await classify({ oneOf: [{ type: "string" }] }, { oneOf: [{ type: "number" }] });
+ assert.equal(report.status, "review_required");
+ assert.match(report.changes[0].location, /oneOf/);
+});
+
+test("classifies property constraint removal from an open object as compatible", async () => {
+ const report = await classify({ type: "object", properties: { value: { type: "string" } } }, { type: "object", properties: {} });
+ assert.equal(report.status, "compatible");
+});
diff --git a/tests/registry.test.mjs b/tests/registry.test.mjs
new file mode 100644
index 0000000..e89063f
--- /dev/null
+++ b/tests/registry.test.mjs
@@ -0,0 +1,60 @@
+import assert from "node:assert/strict";
+import { createHash } from "node:crypto";
+import { mkdtemp, readFile } from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import test from "node:test";
+import { buildRegistry } from "../scripts/build-registry.mjs";
+import { createValidator, jsonFiles, readJson, schemaDirectory } from "../scripts/lib.mjs";
+import { validateRegistry } from "../scripts/validate-registry.mjs";
+
+async function build() {
+ const output = await mkdtemp(path.join(os.tmpdir(), "cas-registry-"));
+ await buildRegistry({ version: "0.1.0", source: schemaDirectory, output });
+ return output;
+}
+
+test("registry packages immutable and stable schema paths with verified digests", async () => {
+ const output = await build();
+ const index = await readJson(path.join(output, "index.json"));
+ const stableManifest = await readJson(path.join(output, "v0.1", "manifest.json"));
+ const immutableManifest = await readJson(path.join(output, "releases", "v0.1.0", "manifest.json"));
+
+ assert.deepEqual(index, { schemaVersion: "1.0.0", releases: ["0.1.0"], lines: { "v0.1": "0.1.0" } });
+ assert.deepEqual(stableManifest, immutableManifest);
+ assert.equal(stableManifest.schemas.length, 8);
+ assert.match(stableManifest.schemas[0].sha256, /^[\da-f]{64}$/);
+ for (const schema of stableManifest.schemas) {
+ const content = await readFile(path.join(output, "v0.1", schema.path));
+ assert.equal(createHash("sha256").update(content).digest("hex"), schema.sha256);
+ }
+ assert.equal(await readFile(path.join(output, "CNAME"), "utf8"), "schemas.coding-autopilot.dev\n");
+});
+
+test("distributed stable schemas compile and retain authoritative ids", async () => {
+ const output = await build();
+ const directory = path.join(output, "v0.1");
+ const validator = await createValidator(directory);
+ for (const file of await jsonFiles(directory)) {
+ const schema = await readJson(file);
+ if (file.endsWith("manifest.json")) continue;
+ assert.ok(validator.getSchema(schema.$id), `${schema.$id} compiles from distributed output`);
+ }
+});
+
+test("registry builds are deterministic and append preserves immutable releases", async () => {
+ const output = await build();
+ const first = await readFile(path.join(output, "v0.1", "manifest.json"), "utf8");
+ await buildRegistry({ version: "0.1.0", source: schemaDirectory, output });
+ assert.equal(await readFile(path.join(output, "v0.1", "manifest.json"), "utf8"), first);
+ await buildRegistry({ version: "0.1.1", source: schemaDirectory, output, append: true });
+ const index = await readJson(path.join(output, "index.json"));
+ assert.deepEqual(index.releases, ["0.1.0", "0.1.1"]);
+ assert.equal(index.lines["v0.1"], "0.1.1");
+ assert.equal((await readJson(path.join(output, "releases", "v0.1.0", "manifest.json"))).version, "0.1.0");
+ assert.deepEqual(await validateRegistry(output), index);
+});
+
+test("registry rejects invalid release versions", async () => {
+ await assert.rejects(() => buildRegistry({ version: "latest", source: schemaDirectory, output: "ignored" }), /semantic version/);
+});