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
32 changes: 32 additions & 0 deletions .github/workflows/contract-compatibility.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
name: Contract compatibility

on:
pull_request:
paths:
- "openapi/corelink-public-v*.yaml"
- "asyncapi/**"
Comment on lines +5 to +7

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Run validation for admin and internal contract changes

When a pull request changes only openapi/corelink-admin-v1.yaml or openapi/corelink-internal-v1.yaml, none of the configured path filters match, so the workflow does not run even though its parse step explicitly includes both files. A malformed administrative or internal contract can therefore merge without the advertised syntax check; include those OpenAPI paths in the trigger.

Useful? React with 👍 / 👎.

- "schemas/**"
- "scripts/check_openapi_compatibility.rb"
- ".github/workflows/contract-compatibility.yml"
push:
branches: [main]

permissions:
contents: read

jobs:
public-openapi:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Parse versioned contracts
run: |
ruby -e 'require "yaml"; %w[openapi/corelink-public-v1.yaml openapi/corelink-admin-v1.yaml openapi/corelink-internal-v1.yaml asyncapi/corelink-events-v1.yaml].each { |path| YAML.safe_load(File.read(path), permitted_classes: [], aliases: false); puts "parsed #{path}" }'
Comment on lines +24 to +26

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Validate newly added public contract versions

When the compatibility policy is followed by adding openapi/corelink-public-v2.yaml alongside v1, the path filter starts this workflow but this hard-coded parse command reads only v1; the compatibility command below also compares only v1. The entire new major contract can thus contain malformed YAML and still pass, so discover and validate every matching versioned public contract rather than enumerating only the current file.

Useful? React with 👍 / 👎.

ruby -rjson -e 'Dir["schemas/*.json"].each { |path| JSON.parse(File.read(path)); puts "parsed #{path}" }'
Comment on lines +24 to +27

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Validate contract references instead of only parsing

When a new operation, message, or schema contains a misspelled or unresolved $ref, YAML.safe_load and JSON.parse still succeed, so this required workflow remains green while generators and validators cannot consume the contract. The release gate says references must be validated, so run an OpenAPI/AsyncAPI/JSON Schema reference-aware validator here rather than treating syntax parsing as sufficient.

Useful? React with 👍 / 👎.

- name: Reject unversioned breaking public changes
if: github.event_name == 'pull_request'
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
Comment on lines +28 to +32

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Enforce compatibility for events and reusable schemas

When a PR removes an AsyncAPI channel or narrows one of the reusable JSON Schemas, the workflow is triggered but merely parses those files; the only compatibility command compares the public OpenAPI document. Consumers generated from asyncapi/ or validating against schemas/ can therefore break while the advertised contract-compatibility job stays green, so diff those public artifacts as well.

Useful? React with 👍 / 👎.

20 changes: 14 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,27 +13,32 @@ SDK, CLI, mock server, MCP server and external integration.
| `openapi/corelink-internal-v1.yaml` | Internal service contract; never expose as public API |
| `asyncapi/corelink-events-v1.yaml` | Published event channels and payloads |
| `schemas/` | Reusable JSON Schemas for device, command, event envelope and errors |
| `postman/` | Collections, environments and runnable examples |
| `postman/` | Versioned collection, sandbox environment and runnable examples |
| `docs/terminology.md` | Shared public-contract vocabulary |

## Current status

The repository structure and JSON Schemas are present, but the OpenAPI and
AsyncAPI specification files are currently empty. They are not usable as
generated-client or mock-server inputs yet. Do not publish an SDK or claim API
compatibility until a reviewed, versioned specification exists.
P3.1 introduces a reviewed `1.0.0-draft` public contract for the proven Device
and Command slice, plus a canonical event envelope. It is intentionally a
small boundary: tenant provisioning, integration callbacks and privileged
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.

## Contract rules

- Public device identity is `corelink_device_id`; integration IDs remain
internal implementation details.
- Model CoreLink resources, not raw Traccar, OpenRemote or Keycloak payloads.
- Model CoreLink resources, not raw integration-provider payloads.
- Keep public, admin and internal audiences in separate documents.
- Define authentication, tenant scope, authorization failures, pagination,
idempotency and problem responses for every operation.
- Make breaking changes through an explicit versioned contract and coordinated
platform/SDK release.

Read [the compatibility policy](docs/compatibility-policy.md) before changing a
public operation.

## Before merging a contract change

1. Check that the change matches the CoreLink ownership boundaries in the
Expand All @@ -42,3 +47,6 @@ compatibility until a reviewed, versioned specification exists.
3. Add representative request, response and error examples.
4. Update affected SDK, mock-server, developer-docs and website references in
the same delivery plan.
5. Let the contract-compatibility workflow classify the public diff. It rejects
breaking v1 changes; publish a new major document with migration guidance
for any such change.
43 changes: 43 additions & 0 deletions asyncapi/corelink-events-v1.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
asyncapi: 3.0.0
info:
title: CoreLink Event Contract
version: 1.0.0-draft
description: Canonical event envelopes for approved public and partner deliveries.
channels:
device.lifecycle:
address: device.lifecycle
messages:
deviceLifecycle:
$ref: '#/components/messages/DeviceLifecycle'
command.status:
address: command.status
messages:
commandStatus:
$ref: '#/components/messages/CommandStatus'
operations:
receiveDeviceLifecycle:
action: receive
channel: {$ref: '#/channels/device.lifecycle'}
receiveCommandStatus:
action: receive
channel: {$ref: '#/channels/command.status'}
Comment on lines +18 to +23

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Publish outbound events with send operations

For the outward public and partner deliveries described by this contract, action: receive declares that the application represented by the AsyncAPI document consumes messages from these channels rather than publishing them. Generators that honor operation direction will consequently produce consumer operations for CoreLink's lifecycle and status notifications; use send for the CoreLink publisher perspective or explicitly redefine the document as a consumer-side contract.

Useful? React with 👍 / 👎.

components:
messages:
DeviceLifecycle:
name: DeviceLifecycle
payload: {$ref: '#/components/schemas/EventEnvelope'}
CommandStatus:
name: CommandStatus
payload: {$ref: '#/components/schemas/EventEnvelope'}
Comment on lines +28 to +31

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Constrain each event message to its own event type

Both channel-specific messages reference the same unconstrained envelope, whose only event_type example is command.completed; generated documentation therefore presents that command event as the payload for device.lifecycle, and contract validation also accepts command events on the device channel (or arbitrary event types on either channel). Give each message an event-specific event_type constraint and example so routing mistakes are detectable.

Useful? React with 👍 / 👎.

Comment on lines +29 to +31

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Define data schemas for each event message

For a command.status delivery, this message accepts any object as data, including {}, because the shared envelope provides no command ID, status, or other channel-specific requirements; the device message has the same problem. Consequently generated consumers receive only an untyped object and contract validation cannot detect malformed event bodies, so each message should specialize data with its actual lifecycle or command-status payload schema.

Useful? React with 👍 / 👎.

schemas:
EventEnvelope:
type: object
additionalProperties: false
required: [event_id, event_type, occurred_at, tenant_id, data]
properties:
event_id: {type: string, format: uuid}
event_type: {type: string, examples: [command.completed]}
occurred_at: {type: string, format: date-time}
tenant_id: {type: string, format: uuid}
correlation_id: {type: string}
data: {type: object, additionalProperties: true}
39 changes: 39 additions & 0 deletions docs/compatibility-policy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# API compatibility policy

`v1` is a public, supported contract. Its canonical source is this repository;
runtime implementation, SDKs, the CLI, documentation and the mock server must
be verified against it before release.
Comment on lines +3 to +5

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Keep the draft outside the supported-v1 promise

This policy labels v1 as a public, supported contract, while the README says the only available 1.0.0-draft is prerelease-only and explicitly not a release claim. A consumer reading the canonical policy can therefore reasonably rely on compatibility and deprecation guarantees that the repository says are not yet active; scope the promise to a released v1 version or mark the current draft unsupported consistently.

Useful? React with 👍 / 👎.


## Compatibility promise

- A `v1` operation, path parameter, required request field, response field or
documented error code is not removed or changed incompatibly within v1.
- New optional fields, optional query parameters, new enum values and new
operations are additive changes. Consumers must ignore unknown response
fields and handle unknown enum values safely.
Comment on lines +11 to +13

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Make enum expansion compatible with old validators

The policy classifies new enum values as additive, but the public schemas model DeviceStatus and CommandStatus with closed enum constraints. After adding a value, any consumer or generated SDK validating a response against the previous v1 schema rejects that value, so the promised change is observably breaking; either model these as extensible strings with documented known values or classify enum expansion as breaking.

Useful? React with 👍 / 👎.

- New required request fields, tighter validation, changed semantics, response
type changes and authentication/authorization expansion are breaking.
- Breaking public changes require a new major contract (`v2`), migration
guidance, a sunset date and compatibility tests. They cannot be hidden behind
a server flag or an SDK-only change.

## Lifecycle and deprecation

Every public operation declares `x-corelink-stability`. Deprecated operations
remain available for at least 180 days after a dated `Deprecation` response
header and replacement documentation are published. Responses for a deprecated
operation include `Sunset` when a removal date is set.

## Error and tenant rules

All non-success responses use `application/problem+json` and include a safe
`correlation_id`. Public resources use canonical CoreLink IDs only. A caller
must be authorized for the path tenant; an unauthorized caller is never given
integration-provider IDs or raw provider payloads.

## Release gate

Each contract PR must validate syntax and references, classify its diff as
additive or breaking, update examples and record the contract version used by
each generated SDK release. A breaking diff without a new major document fails
the release gate.
18 changes: 18 additions & 0 deletions docs/runtime-parity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# P3.1 runtime-parity gate

The `1.0.0-draft` documents establish the target public boundary; they are not
an assertion that the current runtime is already byte-for-byte compatible.
Before a stable SDK release, the platform must close each of these gates.

| Contract decision | Current runtime observation | Required closure |
| --- | --- | --- |
| `corelink_device_id` is the public device field | Device responses currently serialize the persistence attribute `id` | Serialize the canonical public name while retaining the same UUID value; add response compatibility tests. |
| `corelink_device_id` is the command device field | Command responses currently serialize `device_id` | Apply the canonical name at the public boundary and test list/get/create. |
| Provider routing is not a public request concern | Command creation selects an eligible active binding through CoreLink-owned policy | Preserve the policy and its no-eligible/ambiguous conflict tests; never expose provider internals. |
| Problem Details is the error media type | FastAPI defaults currently return `{ "detail": ... }` | Add a correlation-safe exception handler and contract tests for 400/401/403/404/409. |
| `/api/v1` is stable public surface | Runtime routes include administration and internal callbacks under the same prefix | Classify routes and prevent unreviewed routes from entering the public document or generated clients. |

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.
6 changes: 6 additions & 0 deletions openapi/corelink-admin-v1.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
openapi: 3.1.1
info:
title: CoreLink Administrative API
version: 1.0.0-draft
description: Reserved for privileged administrative operations; not a public SDK input.
paths: {}
6 changes: 6 additions & 0 deletions openapi/corelink-internal-v1.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
openapi: 3.1.1
info:
title: CoreLink Internal API
version: 1.0.0-draft
description: Service-to-service contract; never expose or generate public clients from it.
paths: {}
Loading
Loading