diff --git a/.github/workflows/contract-compatibility.yml b/.github/workflows/contract-compatibility.yml index e4a710a..e50f62f 100644 --- a/.github/workflows/contract-compatibility.yml +++ b/.github/workflows/contract-compatibility.yml @@ -7,6 +7,8 @@ on: - "asyncapi/**" - "schemas/**" - "scripts/check_openapi_compatibility.rb" + - "scripts/lint_contracts.rb" + - "postman/**" - ".github/workflows/contract-compatibility.yml" push: branches: [main] @@ -30,3 +32,5 @@ jobs: run: | git show "origin/${{ github.base_ref }}:openapi/corelink-public-v1.yaml" > /tmp/base-public.yaml || true ruby scripts/check_openapi_compatibility.rb /tmp/base-public.yaml openapi/corelink-public-v1.yaml + - name: Lint public contract and examples + run: ruby scripts/lint_contracts.rb diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..09a2822 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,23 @@ +# Changelog + +All notable contract releases are recorded here. Contract tags are immutable; +corrections require a new patch tag and must not be moved in place. + +## [1.0.0-draft] - 2026-07-25 + +Initial reviewed draft for the proven Device and Command public slice. + +### Added + +- canonical public device and command identifiers; +- tenant-scoped device listing, lookup, creation and command operations; +- `application/problem+json` error responses with correlation IDs; +- canonical event envelope and device lifecycle event definitions; +- compatibility policy and public OpenAPI compatibility gate. + +### Explicitly not released + +Tenant provisioning, partner credentials, webhooks, telemetry, billing and +privileged administration remain outside this draft until their contracts and +runtime parity evidence are reviewed. + diff --git a/README.md b/README.md index d3f999e..037e986 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,10 @@ administration remain out of public v1 until they have their own reviewed contract. SDKs and the mock server may consume this draft only in prerelease channels; it is not a release claim until runtime parity and CI checks land. +The immutable baseline tag is `v1.0.0-draft`. See the +[changelog](CHANGELOG.md) and [compatibility matrix](docs/compatibility-matrix.md) +for the exact release boundary and consumer status. + ## Contract rules - Public device identity is `corelink_device_id`; integration IDs remain diff --git a/docs/compatibility-matrix.md b/docs/compatibility-matrix.md new file mode 100644 index 0000000..191ddd5 --- /dev/null +++ b/docs/compatibility-matrix.md @@ -0,0 +1,23 @@ +# Contract compatibility matrix + +This matrix records the contract version and verification boundary for each +consumer. A draft tag is immutable, but it is not a production support claim. + +| Surface | Contract source | Version | Compatibility gate | Current status | +| --- | --- | --- | --- | --- | +| Public HTTP API | `openapi/corelink-public-v1.yaml` | `1.0.0-draft` | OpenAPI syntax + public diff checker | Draft reviewed; runtime parity is a separate gate | +| Admin HTTP API | `openapi/corelink-admin-v1.yaml` | `1.0.0-draft` | OpenAPI syntax + authorization review | Internal draft; not a public release | +| Internal HTTP API | `openapi/corelink-internal-v1.yaml` | `1.0.0-draft` | OpenAPI syntax + service ownership review | Internal draft; not a public release | +| Events | `asyncapi/corelink-events-v1.yaml` | `1.0.0-draft` | AsyncAPI validation + event envelope review | Draft; delivery/replay evidence remains platform-owned | +| Python/TypeScript/Java SDKs | Generated or hand-written consumers | N/A | Contract version pinned per release | No stable SDK release claim for this draft | +| CLI, mock server and MCP server | Consumer repositories | N/A | Runtime parity and examples | Must consume a reviewed tag before beta | + +## Release rules + +1. Each released row must point to an immutable Git tag. +2. Additive changes within the same major version require a new minor or patch + tag and a changelog entry. +3. Breaking changes require a new major contract document and migration notes. +4. A contract tag is not a runtime release until the corresponding consumer + parity checks and operational evidence are attached to the release record. + diff --git a/docs/runtime-parity.md b/docs/runtime-parity.md index 5041833..5aa6df1 100644 --- a/docs/runtime-parity.md +++ b/docs/runtime-parity.md @@ -16,3 +16,7 @@ No TypeScript or Python package may be promoted beyond prerelease until these items, a contract-diff check and generated-client compatibility tests are green. This gate preserves current consumers while the public boundary is normalized. + +The repository workflow also runs `scripts/lint_contracts.rb` and parses the +versioned Postman collection/environment. This is an early structural gate; it +does not replace runtime parity or generated-client tests. diff --git a/scripts/lint_contracts.rb b/scripts/lint_contracts.rb new file mode 100644 index 0000000..4152cc4 --- /dev/null +++ b/scripts/lint_contracts.rb @@ -0,0 +1,45 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Small dependency-free contract lint. It catches incomplete operations before +# a full OpenAPI/AsyncAPI validator is introduced in the contract toolchain. + +require "json" +require "yaml" + +PUBLIC_SPEC = "openapi/corelink-public-v1.yaml" +OPERATIONS = %w[get put post patch delete head options].freeze + +spec = YAML.safe_load(File.read(PUBLIC_SPEC), permitted_classes: [], aliases: false) +errors = [] +paths = spec.fetch("paths", {}) +errors << "public contract must declare paths" if paths.empty? + +paths.each do |path, path_item| + OPERATIONS.each do |method| + operation = path_item[method] + next unless operation + + location = "#{method.upcase} #{path}" + errors << "#{location}: missing operationId" if operation["operationId"].to_s.empty? + errors << "#{location}: missing x-corelink-stability" if operation["x-corelink-stability"].to_s.empty? + errors << "#{location}: missing responses" if operation.fetch("responses", {}).empty? + end +end + +%w[ + postman/corelink-public-v1.postman_collection.json + postman/corelink-public-v1.postman_environment.json +].each do |path| + JSON.parse(File.read(path)) +rescue JSON::ParserError => error + errors << "#{path}: invalid JSON (#{error.message})" +end + +if errors.empty? + puts "Contract lint passed." +else + warn errors.join("\n") + exit 1 +end +