From 0c71838dbe737aff8528dc9ed02c0c0fa77de741 Mon Sep 17 00:00:00 2001 From: Javid Date: Fri, 24 Jul 2026 16:34:45 +0330 Subject: [PATCH 1/6] feat: establish P3 public contract baseline --- README.md | 15 ++- asyncapi/corelink-events-v1.yaml | 43 ++++++ docs/compatibility-policy.md | 39 ++++++ docs/runtime-parity.md | 18 +++ openapi/corelink-admin-v1.yaml | 6 + openapi/corelink-internal-v1.yaml | 6 + openapi/corelink-public-v1.yaml | 201 ++++++++++++++++++++++++++++ schemas/command.schema.json | 18 +++ schemas/device.schema.json | 19 +++ schemas/event-envelope.schema.json | 16 +++ schemas/problem-details.schema.json | 16 +++ 11 files changed, 392 insertions(+), 5 deletions(-) create mode 100644 docs/compatibility-policy.md create mode 100644 docs/runtime-parity.md diff --git a/README.md b/README.md index 59a8a90..f3474ea 100644 --- a/README.md +++ b/README.md @@ -18,22 +18,27 @@ SDK, CLI, mock server, MCP server and external integration. ## 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 diff --git a/asyncapi/corelink-events-v1.yaml b/asyncapi/corelink-events-v1.yaml index e69de29..59f39c9 100644 --- a/asyncapi/corelink-events-v1.yaml +++ b/asyncapi/corelink-events-v1.yaml @@ -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'} +components: + messages: + DeviceLifecycle: + name: DeviceLifecycle + payload: {$ref: '#/components/schemas/EventEnvelope'} + CommandStatus: + name: CommandStatus + payload: {$ref: '#/components/schemas/EventEnvelope'} + 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} diff --git a/docs/compatibility-policy.md b/docs/compatibility-policy.md new file mode 100644 index 0000000..3396542 --- /dev/null +++ b/docs/compatibility-policy.md @@ -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. + +## 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. +- 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. diff --git a/docs/runtime-parity.md b/docs/runtime-parity.md new file mode 100644 index 0000000..6102fd6 --- /dev/null +++ b/docs/runtime-parity.md @@ -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 currently requires `provider` | Select a supported connector through CoreLink-owned policy or expose an explicitly versioned neutral selector; 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. diff --git a/openapi/corelink-admin-v1.yaml b/openapi/corelink-admin-v1.yaml index e69de29..f6f5b9f 100644 --- a/openapi/corelink-admin-v1.yaml +++ b/openapi/corelink-admin-v1.yaml @@ -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: {} diff --git a/openapi/corelink-internal-v1.yaml b/openapi/corelink-internal-v1.yaml index e69de29..0d3d421 100644 --- a/openapi/corelink-internal-v1.yaml +++ b/openapi/corelink-internal-v1.yaml @@ -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: {} diff --git a/openapi/corelink-public-v1.yaml b/openapi/corelink-public-v1.yaml index e69de29..87b053e 100644 --- a/openapi/corelink-public-v1.yaml +++ b/openapi/corelink-public-v1.yaml @@ -0,0 +1,201 @@ +openapi: 3.1.1 +info: + title: CoreLink Public API + version: 1.0.0-draft + description: | + Versioned public contract for the proven connected-device slice. Integration + details are deliberately excluded. All resource identifiers are canonical + CoreLink identifiers. +servers: + - url: https://api.corelink.example +security: + - bearerAuth: [] +tags: + - name: Devices + - name: Commands +paths: + /health/live: + get: + operationId: getLiveHealth + security: [] + x-corelink-stability: stable + responses: + '200': + description: Process is live. + content: + application/json: + schema: {$ref: '#/components/schemas/Health'} + /health/ready: + get: + operationId: getReadiness + security: [] + x-corelink-stability: stable + responses: + '200': + description: Dependencies required for traffic are ready. + content: + application/json: + schema: {$ref: '#/components/schemas/Health'} + /api/v1/tenants/{tenant_id}/devices: + parameters: + - $ref: '#/components/parameters/TenantId' + get: + tags: [Devices] + operationId: listDevices + x-corelink-stability: stable + parameters: + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Offset' + - name: status + in: query + schema: {$ref: '#/components/schemas/DeviceStatus'} + - name: device_model_id + in: query + schema: {type: string, format: uuid} + responses: + '200': + description: Tenant-scoped device page. + content: {application/json: {schema: {$ref: '#/components/schemas/DevicePage'}}} + '401': {$ref: '#/components/responses/Unauthorized'} + '403': {$ref: '#/components/responses/Forbidden'} + post: + tags: [Devices] + operationId: createDevice + x-corelink-stability: stable + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/DeviceCreate'}}} + responses: + '201': {description: Device created, content: {application/json: {schema: {$ref: '#/components/schemas/Device'}}}} + '400': {$ref: '#/components/responses/BadRequest'} + '401': {$ref: '#/components/responses/Unauthorized'} + '403': {$ref: '#/components/responses/Forbidden'} + '409': {$ref: '#/components/responses/Conflict'} + /api/v1/tenants/{tenant_id}/devices/{corelink_device_id}: + parameters: + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/CoreLinkDeviceId' + get: + tags: [Devices] + operationId: getDevice + x-corelink-stability: stable + responses: + '200': {description: Device, content: {application/json: {schema: {$ref: '#/components/schemas/Device'}}}} + '401': {$ref: '#/components/responses/Unauthorized'} + '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} + patch: + tags: [Devices] + operationId: updateDevice + x-corelink-stability: stable + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/DeviceUpdate'}}} + responses: + '200': {description: Updated device, content: {application/json: {schema: {$ref: '#/components/schemas/Device'}}}} + '400': {$ref: '#/components/responses/BadRequest'} + '401': {$ref: '#/components/responses/Unauthorized'} + '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} + /api/v1/tenants/{tenant_id}/devices/{corelink_device_id}/commands: + parameters: + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/CoreLinkDeviceId' + get: + tags: [Commands] + operationId: listDeviceCommands + x-corelink-stability: stable + parameters: + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Offset' + responses: + '200': {description: Tenant-scoped command page, content: {application/json: {schema: {$ref: '#/components/schemas/CommandPage'}}}} + '401': {$ref: '#/components/responses/Unauthorized'} + '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} + post: + tags: [Commands] + operationId: createDeviceCommand + x-corelink-stability: stable + parameters: + - name: Idempotency-Key + in: header + required: true + schema: {type: string, minLength: 1, maxLength: 255} + requestBody: + required: true + content: {application/json: {schema: {$ref: '#/components/schemas/CommandCreate'}}} + responses: + '201': {description: Command accepted, content: {application/json: {schema: {$ref: '#/components/schemas/Command'}}}} + '400': {$ref: '#/components/responses/BadRequest'} + '401': {$ref: '#/components/responses/Unauthorized'} + '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} + '409': {$ref: '#/components/responses/Conflict'} + /api/v1/tenants/{tenant_id}/devices/{corelink_device_id}/commands/{command_id}: + parameters: + - $ref: '#/components/parameters/TenantId' + - $ref: '#/components/parameters/CoreLinkDeviceId' + - $ref: '#/components/parameters/CommandId' + get: + tags: [Commands] + operationId: getDeviceCommand + x-corelink-stability: stable + responses: + '200': {description: Command, content: {application/json: {schema: {$ref: '#/components/schemas/Command'}}}} + '401': {$ref: '#/components/responses/Unauthorized'} + '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} +components: + securitySchemes: + bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT} + parameters: + TenantId: {name: tenant_id, in: path, required: true, schema: {type: string, format: uuid}} + CoreLinkDeviceId: {name: corelink_device_id, in: path, required: true, schema: {type: string, format: uuid}} + CommandId: {name: command_id, in: path, required: true, schema: {type: string, format: uuid}} + Limit: {name: limit, in: query, schema: {type: integer, minimum: 1, maximum: 100, default: 50}} + Offset: {name: offset, in: query, schema: {type: integer, minimum: 0, default: 0}} + responses: + BadRequest: {description: Invalid request, content: {application/problem+json: {schema: {$ref: '#/components/schemas/Problem'}}}} + Unauthorized: {description: Authentication required, content: {application/problem+json: {schema: {$ref: '#/components/schemas/Problem'}}}} + Forbidden: {description: Tenant access or permission denied, content: {application/problem+json: {schema: {$ref: '#/components/schemas/Problem'}}}} + NotFound: {description: Resource not found, content: {application/problem+json: {schema: {$ref: '#/components/schemas/Problem'}}}} + Conflict: {description: Conflicting request, content: {application/problem+json: {schema: {$ref: '#/components/schemas/Problem'}}}} + schemas: + Health: {type: object, required: [status], properties: {status: {type: string, enum: [ok, ready]}}} + DeviceStatus: {type: string, enum: [active, inactive, retired]} + Device: + type: object + required: [corelink_device_id, tenant_id, device_model_id, status, metadata, created_at, updated_at] + properties: + corelink_device_id: {type: string, format: uuid} + tenant_id: {type: string, format: uuid} + device_model_id: {type: string, format: uuid} + firmware_release_id: {type: [string, 'null'], format: uuid} + name: {type: [string, 'null'], maxLength: 255} + status: {$ref: '#/components/schemas/DeviceStatus'} + metadata: {type: object, additionalProperties: true} + created_at: {type: string, format: date-time} + updated_at: {type: string, format: date-time} + DeviceCreate: + type: object + required: [device_model_id] + properties: + device_model_id: {type: string, format: uuid} + firmware_release_id: {type: string, format: uuid} + name: {type: string, maxLength: 255} + metadata: {type: object, additionalProperties: true} + DeviceUpdate: + type: object + minProperties: 1 + properties: + firmware_release_id: {type: [string, 'null'], format: uuid} + name: {type: [string, 'null'], maxLength: 255} + status: {$ref: '#/components/schemas/DeviceStatus'} + metadata: {type: object, additionalProperties: true} + DevicePage: {type: object, required: [items, total, limit, offset], properties: {items: {type: array, items: {$ref: '#/components/schemas/Device'}}, total: {type: integer}, limit: {type: integer}, offset: {type: integer}}} + CommandStatus: {type: string, enum: [queued, dispatched, acknowledged, completed, failed, cancelled, timed_out]} + CommandCreate: {type: object, required: [command_type, payload], properties: {command_type: {type: string, minLength: 1, maxLength: 150}, payload: {type: object, additionalProperties: true}, timeout_at: {type: string, format: date-time}, metadata: {type: object, additionalProperties: true}}} + Command: {type: object, required: [command_id, tenant_id, corelink_device_id, command_type, payload, status, attempt_count, created_at, updated_at], properties: {command_id: {type: string, format: uuid}, tenant_id: {type: string, format: uuid}, corelink_device_id: {type: string, format: uuid}, command_type: {type: string}, payload: {type: object, additionalProperties: true}, status: {$ref: '#/components/schemas/CommandStatus'}, attempt_count: {type: integer}, timeout_at: {type: [string, 'null'], format: date-time}, acknowledged_at: {type: [string, 'null'], format: date-time}, completed_at: {type: [string, 'null'], format: date-time}, created_at: {type: string, format: date-time}, updated_at: {type: string, format: date-time}}} + CommandPage: {type: object, required: [items, total, limit, offset], properties: {items: {type: array, items: {$ref: '#/components/schemas/Command'}}, total: {type: integer}, limit: {type: integer}, offset: {type: integer}}} + Problem: {type: object, required: [type, title, status, code, correlation_id], properties: {type: {type: string}, title: {type: string}, status: {type: integer}, code: {type: string}, detail: {type: string}, correlation_id: {type: string}}} diff --git a/schemas/command.schema.json b/schemas/command.schema.json index e69de29..c2a0b6c 100644 --- a/schemas/command.schema.json +++ b/schemas/command.schema.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://contracts.corelink.example/schemas/command.schema.json", + "title": "CoreLink Device Command", + "type": "object", + "additionalProperties": false, + "required": ["command_id", "tenant_id", "corelink_device_id", "command_type", "payload", "status", "created_at", "updated_at"], + "properties": { + "command_id": {"type": "string", "format": "uuid"}, + "tenant_id": {"type": "string", "format": "uuid"}, + "corelink_device_id": {"type": "string", "format": "uuid"}, + "command_type": {"type": "string", "minLength": 1, "maxLength": 150}, + "payload": {"type": "object", "additionalProperties": true}, + "status": {"type": "string", "enum": ["queued", "dispatched", "acknowledged", "completed", "failed", "cancelled", "timed_out"]}, + "created_at": {"type": "string", "format": "date-time"}, + "updated_at": {"type": "string", "format": "date-time"} + } +} diff --git a/schemas/device.schema.json b/schemas/device.schema.json index e69de29..f5f6ba3 100644 --- a/schemas/device.schema.json +++ b/schemas/device.schema.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://contracts.corelink.example/schemas/device.schema.json", + "title": "CoreLink Device", + "type": "object", + "additionalProperties": false, + "required": ["corelink_device_id", "tenant_id", "device_model_id", "status", "created_at", "updated_at"], + "properties": { + "corelink_device_id": {"type": "string", "format": "uuid"}, + "tenant_id": {"type": "string", "format": "uuid"}, + "device_model_id": {"type": "string", "format": "uuid"}, + "firmware_release_id": {"type": ["string", "null"], "format": "uuid"}, + "name": {"type": ["string", "null"], "maxLength": 255}, + "status": {"type": "string", "enum": ["active", "inactive", "retired"]}, + "metadata": {"type": "object", "additionalProperties": true}, + "created_at": {"type": "string", "format": "date-time"}, + "updated_at": {"type": "string", "format": "date-time"} + } +} diff --git a/schemas/event-envelope.schema.json b/schemas/event-envelope.schema.json index e69de29..a4bea91 100644 --- a/schemas/event-envelope.schema.json +++ b/schemas/event-envelope.schema.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://contracts.corelink.example/schemas/event-envelope.schema.json", + "title": "CoreLink Event Envelope", + "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", "pattern": "^[a-z]+(\\.[a-z_]+)+$"}, + "occurred_at": {"type": "string", "format": "date-time"}, + "tenant_id": {"type": "string", "format": "uuid"}, + "correlation_id": {"type": "string"}, + "data": {"type": "object", "additionalProperties": true} + } +} diff --git a/schemas/problem-details.schema.json b/schemas/problem-details.schema.json index e69de29..196eab0 100644 --- a/schemas/problem-details.schema.json +++ b/schemas/problem-details.schema.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://contracts.corelink.example/schemas/problem-details.schema.json", + "title": "CoreLink Problem", + "type": "object", + "additionalProperties": false, + "required": ["type", "title", "status", "code", "correlation_id"], + "properties": { + "type": {"type": "string", "format": "uri-reference"}, + "title": {"type": "string"}, + "status": {"type": "integer", "minimum": 400, "maximum": 599}, + "code": {"type": "string", "pattern": "^[a-z][a-z0-9_]*$"}, + "detail": {"type": "string"}, + "correlation_id": {"type": "string", "minLength": 1} + } +} From 97a3ffbda52365814ebfa0cef526ed2d0bc86f3e Mon Sep 17 00:00:00 2001 From: Javid Date: Fri, 24 Jul 2026 16:38:55 +0330 Subject: [PATCH 2/6] fix: align public contract with runtime states --- openapi/corelink-public-v1.yaml | 6 +++--- schemas/command.schema.json | 2 +- schemas/device.schema.json | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/openapi/corelink-public-v1.yaml b/openapi/corelink-public-v1.yaml index 87b053e..3e93d7d 100644 --- a/openapi/corelink-public-v1.yaml +++ b/openapi/corelink-public-v1.yaml @@ -163,7 +163,7 @@ components: Conflict: {description: Conflicting request, content: {application/problem+json: {schema: {$ref: '#/components/schemas/Problem'}}}} schemas: Health: {type: object, required: [status], properties: {status: {type: string, enum: [ok, ready]}}} - DeviceStatus: {type: string, enum: [active, inactive, retired]} + DeviceStatus: {type: string, enum: [inventory, provisioning, active, suspended, retired]} Device: type: object required: [corelink_device_id, tenant_id, device_model_id, status, metadata, created_at, updated_at] @@ -194,8 +194,8 @@ components: status: {$ref: '#/components/schemas/DeviceStatus'} metadata: {type: object, additionalProperties: true} DevicePage: {type: object, required: [items, total, limit, offset], properties: {items: {type: array, items: {$ref: '#/components/schemas/Device'}}, total: {type: integer}, limit: {type: integer}, offset: {type: integer}}} - CommandStatus: {type: string, enum: [queued, dispatched, acknowledged, completed, failed, cancelled, timed_out]} - CommandCreate: {type: object, required: [command_type, payload], properties: {command_type: {type: string, minLength: 1, maxLength: 150}, payload: {type: object, additionalProperties: true}, timeout_at: {type: string, format: date-time}, metadata: {type: object, additionalProperties: true}}} + CommandStatus: {type: string, enum: [queued, dispatching, sent, acknowledged, succeeded, failed, timed_out, cancelled]} + CommandCreate: {type: object, required: [command_type, connector], properties: {command_type: {type: string, minLength: 1, maxLength: 150}, connector: {type: string, minLength: 1, maxLength: 100, description: CoreLink connector selector. Current supported values are tc and or.}, payload: {type: object, additionalProperties: true}, timeout_at: {type: string, format: date-time}, metadata: {type: object, additionalProperties: true}}} Command: {type: object, required: [command_id, tenant_id, corelink_device_id, command_type, payload, status, attempt_count, created_at, updated_at], properties: {command_id: {type: string, format: uuid}, tenant_id: {type: string, format: uuid}, corelink_device_id: {type: string, format: uuid}, command_type: {type: string}, payload: {type: object, additionalProperties: true}, status: {$ref: '#/components/schemas/CommandStatus'}, attempt_count: {type: integer}, timeout_at: {type: [string, 'null'], format: date-time}, acknowledged_at: {type: [string, 'null'], format: date-time}, completed_at: {type: [string, 'null'], format: date-time}, created_at: {type: string, format: date-time}, updated_at: {type: string, format: date-time}}} CommandPage: {type: object, required: [items, total, limit, offset], properties: {items: {type: array, items: {$ref: '#/components/schemas/Command'}}, total: {type: integer}, limit: {type: integer}, offset: {type: integer}}} Problem: {type: object, required: [type, title, status, code, correlation_id], properties: {type: {type: string}, title: {type: string}, status: {type: integer}, code: {type: string}, detail: {type: string}, correlation_id: {type: string}}} diff --git a/schemas/command.schema.json b/schemas/command.schema.json index c2a0b6c..e35f702 100644 --- a/schemas/command.schema.json +++ b/schemas/command.schema.json @@ -11,7 +11,7 @@ "corelink_device_id": {"type": "string", "format": "uuid"}, "command_type": {"type": "string", "minLength": 1, "maxLength": 150}, "payload": {"type": "object", "additionalProperties": true}, - "status": {"type": "string", "enum": ["queued", "dispatched", "acknowledged", "completed", "failed", "cancelled", "timed_out"]}, + "status": {"type": "string", "enum": ["queued", "dispatching", "sent", "acknowledged", "succeeded", "failed", "timed_out", "cancelled"]}, "created_at": {"type": "string", "format": "date-time"}, "updated_at": {"type": "string", "format": "date-time"} } diff --git a/schemas/device.schema.json b/schemas/device.schema.json index f5f6ba3..cd778f4 100644 --- a/schemas/device.schema.json +++ b/schemas/device.schema.json @@ -11,7 +11,7 @@ "device_model_id": {"type": "string", "format": "uuid"}, "firmware_release_id": {"type": ["string", "null"], "format": "uuid"}, "name": {"type": ["string", "null"], "maxLength": 255}, - "status": {"type": "string", "enum": ["active", "inactive", "retired"]}, + "status": {"type": "string", "enum": ["inventory", "provisioning", "active", "suspended", "retired"]}, "metadata": {"type": "object", "additionalProperties": true}, "created_at": {"type": "string", "format": "date-time"}, "updated_at": {"type": "string", "format": "date-time"} From 4c3680c7ed528a92c0ce8b0e8db413e4dfa0208b Mon Sep 17 00:00:00 2001 From: Javid Date: Fri, 24 Jul 2026 16:41:56 +0330 Subject: [PATCH 3/6] fix: match contract with connector routing policy --- docs/runtime-parity.md | 2 +- openapi/corelink-public-v1.yaml | 6 +++--- schemas/command.schema.json | 3 ++- schemas/device.schema.json | 1 + 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/docs/runtime-parity.md b/docs/runtime-parity.md index 6102fd6..5041833 100644 --- a/docs/runtime-parity.md +++ b/docs/runtime-parity.md @@ -8,7 +8,7 @@ Before a stable SDK release, the platform must close each of these gates. | --- | --- | --- | | `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 currently requires `provider` | Select a supported connector through CoreLink-owned policy or expose an explicitly versioned neutral selector; never expose provider internals. | +| 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. | diff --git a/openapi/corelink-public-v1.yaml b/openapi/corelink-public-v1.yaml index 3e93d7d..e3cdbf7 100644 --- a/openapi/corelink-public-v1.yaml +++ b/openapi/corelink-public-v1.yaml @@ -171,9 +171,9 @@ components: corelink_device_id: {type: string, format: uuid} tenant_id: {type: string, format: uuid} device_model_id: {type: string, format: uuid} + partner_sku_id: {type: string, format: uuid} firmware_release_id: {type: [string, 'null'], format: uuid} name: {type: [string, 'null'], maxLength: 255} - status: {$ref: '#/components/schemas/DeviceStatus'} metadata: {type: object, additionalProperties: true} created_at: {type: string, format: date-time} updated_at: {type: string, format: date-time} @@ -195,7 +195,7 @@ components: metadata: {type: object, additionalProperties: true} DevicePage: {type: object, required: [items, total, limit, offset], properties: {items: {type: array, items: {$ref: '#/components/schemas/Device'}}, total: {type: integer}, limit: {type: integer}, offset: {type: integer}}} CommandStatus: {type: string, enum: [queued, dispatching, sent, acknowledged, succeeded, failed, timed_out, cancelled]} - CommandCreate: {type: object, required: [command_type, connector], properties: {command_type: {type: string, minLength: 1, maxLength: 150}, connector: {type: string, minLength: 1, maxLength: 100, description: CoreLink connector selector. Current supported values are tc and or.}, payload: {type: object, additionalProperties: true}, timeout_at: {type: string, format: date-time}, metadata: {type: object, additionalProperties: true}}} - Command: {type: object, required: [command_id, tenant_id, corelink_device_id, command_type, payload, status, attempt_count, created_at, updated_at], properties: {command_id: {type: string, format: uuid}, tenant_id: {type: string, format: uuid}, corelink_device_id: {type: string, format: uuid}, command_type: {type: string}, payload: {type: object, additionalProperties: true}, status: {$ref: '#/components/schemas/CommandStatus'}, attempt_count: {type: integer}, timeout_at: {type: [string, 'null'], format: date-time}, acknowledged_at: {type: [string, 'null'], format: date-time}, completed_at: {type: [string, 'null'], format: date-time}, created_at: {type: string, format: date-time}, updated_at: {type: string, format: date-time}}} + CommandCreate: {type: object, required: [command_type], properties: {command_type: {type: string, minLength: 1, maxLength: 150}, payload: {type: object, additionalProperties: true}, timeout_at: {type: string, format: date-time}, metadata: {type: object, additionalProperties: true}}} + Command: {type: object, required: [command_id, tenant_id, corelink_device_id, command_type, payload, status, attempt_count, metadata, created_at, updated_at], properties: {command_id: {type: string, format: uuid}, tenant_id: {type: string, format: uuid}, corelink_device_id: {type: string, format: uuid}, command_type: {type: string}, payload: {type: object, additionalProperties: true}, status: {$ref: '#/components/schemas/CommandStatus'}, attempt_count: {type: integer}, timeout_at: {type: [string, 'null'], format: date-time}, acknowledged_at: {type: [string, 'null'], format: date-time}, completed_at: {type: [string, 'null'], format: date-time}, metadata: {type: object, additionalProperties: true}, created_at: {type: string, format: date-time}, updated_at: {type: string, format: date-time}}} CommandPage: {type: object, required: [items, total, limit, offset], properties: {items: {type: array, items: {$ref: '#/components/schemas/Command'}}, total: {type: integer}, limit: {type: integer}, offset: {type: integer}}} Problem: {type: object, required: [type, title, status, code, correlation_id], properties: {type: {type: string}, title: {type: string}, status: {type: integer}, code: {type: string}, detail: {type: string}, correlation_id: {type: string}}} diff --git a/schemas/command.schema.json b/schemas/command.schema.json index e35f702..9331d3e 100644 --- a/schemas/command.schema.json +++ b/schemas/command.schema.json @@ -4,7 +4,7 @@ "title": "CoreLink Device Command", "type": "object", "additionalProperties": false, - "required": ["command_id", "tenant_id", "corelink_device_id", "command_type", "payload", "status", "created_at", "updated_at"], + "required": ["command_id", "tenant_id", "corelink_device_id", "command_type", "payload", "status", "metadata", "created_at", "updated_at"], "properties": { "command_id": {"type": "string", "format": "uuid"}, "tenant_id": {"type": "string", "format": "uuid"}, @@ -12,6 +12,7 @@ "command_type": {"type": "string", "minLength": 1, "maxLength": 150}, "payload": {"type": "object", "additionalProperties": true}, "status": {"type": "string", "enum": ["queued", "dispatching", "sent", "acknowledged", "succeeded", "failed", "timed_out", "cancelled"]}, + "metadata": {"type": "object", "additionalProperties": true}, "created_at": {"type": "string", "format": "date-time"}, "updated_at": {"type": "string", "format": "date-time"} } diff --git a/schemas/device.schema.json b/schemas/device.schema.json index cd778f4..780c97f 100644 --- a/schemas/device.schema.json +++ b/schemas/device.schema.json @@ -9,6 +9,7 @@ "corelink_device_id": {"type": "string", "format": "uuid"}, "tenant_id": {"type": "string", "format": "uuid"}, "device_model_id": {"type": "string", "format": "uuid"}, + "partner_sku_id": {"type": ["string", "null"], "format": "uuid"}, "firmware_release_id": {"type": ["string", "null"], "format": "uuid"}, "name": {"type": ["string", "null"], "maxLength": 255}, "status": {"type": "string", "enum": ["inventory", "provisioning", "active", "suspended", "retired"]}, From 701e6932bf5517a9f897f33a6f8d8291b029a3a5 Mon Sep 17 00:00:00 2001 From: Javid Date: Fri, 24 Jul 2026 16:42:17 +0330 Subject: [PATCH 4/6] fix: preserve device lifecycle status in contract --- openapi/corelink-public-v1.yaml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/openapi/corelink-public-v1.yaml b/openapi/corelink-public-v1.yaml index e3cdbf7..b11a932 100644 --- a/openapi/corelink-public-v1.yaml +++ b/openapi/corelink-public-v1.yaml @@ -171,9 +171,10 @@ components: corelink_device_id: {type: string, format: uuid} tenant_id: {type: string, format: uuid} device_model_id: {type: string, format: uuid} - partner_sku_id: {type: string, format: uuid} + partner_sku_id: {type: [string, 'null'], format: uuid} firmware_release_id: {type: [string, 'null'], format: uuid} name: {type: [string, 'null'], maxLength: 255} + status: {$ref: '#/components/schemas/DeviceStatus'} metadata: {type: object, additionalProperties: true} created_at: {type: string, format: date-time} updated_at: {type: string, format: date-time} @@ -182,6 +183,7 @@ components: required: [device_model_id] properties: device_model_id: {type: string, format: uuid} + partner_sku_id: {type: string, format: uuid} firmware_release_id: {type: string, format: uuid} name: {type: string, maxLength: 255} metadata: {type: object, additionalProperties: true} @@ -191,7 +193,6 @@ components: properties: firmware_release_id: {type: [string, 'null'], format: uuid} name: {type: [string, 'null'], maxLength: 255} - status: {$ref: '#/components/schemas/DeviceStatus'} metadata: {type: object, additionalProperties: true} DevicePage: {type: object, required: [items, total, limit, offset], properties: {items: {type: array, items: {$ref: '#/components/schemas/Device'}}, total: {type: integer}, limit: {type: integer}, offset: {type: integer}}} CommandStatus: {type: string, enum: [queued, dispatching, sent, acknowledged, succeeded, failed, timed_out, cancelled]} From ea8836215676f578ded93ae16536e1d6dd5f5623 Mon Sep 17 00:00:00 2001 From: Javid Date: Fri, 24 Jul 2026 22:13:02 +0330 Subject: [PATCH 5/6] ci: enforce public contract compatibility --- .github/workflows/contract-compatibility.yml | 32 ++++ README.md | 5 +- postman/README.md | 14 +- ...corelink-public-v1.postman_collection.json | 71 ++++++++ ...orelink-public-v1.postman_environment.json | 13 ++ scripts/check_openapi_compatibility.rb | 161 ++++++++++++++++++ 6 files changed, 291 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/contract-compatibility.yml create mode 100644 postman/corelink-public-v1.postman_collection.json create mode 100644 postman/corelink-public-v1.postman_environment.json create mode 100644 scripts/check_openapi_compatibility.rb diff --git a/.github/workflows/contract-compatibility.yml b/.github/workflows/contract-compatibility.yml new file mode 100644 index 0000000..e4a710a --- /dev/null +++ b/.github/workflows/contract-compatibility.yml @@ -0,0 +1,32 @@ +name: Contract compatibility + +on: + pull_request: + paths: + - "openapi/corelink-public-v*.yaml" + - "asyncapi/**" + - "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}" }' + ruby -rjson -e 'Dir["schemas/*.json"].each { |path| JSON.parse(File.read(path)); puts "parsed #{path}" }' + - 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 diff --git a/README.md b/README.md index f3474ea..d3f999e 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ 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 @@ -47,3 +47,6 @@ public operation. 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. diff --git a/postman/README.md b/postman/README.md index e84fe6d..d479873 100644 --- a/postman/README.md +++ b/postman/README.md @@ -3,11 +3,17 @@ Postman collections, environments and request examples for the versioned CoreLink public contracts. -## Current status +## Use in the developer sandbox -This directory is a scaffold. No collection or environment file has been added -yet, and the OpenAPI specifications in the parent repository are currently -empty. There is therefore nothing to import or run at this time. +Import `corelink-public-v1.postman_collection.json` and +`corelink-public-v1.postman_environment.json`, then set `tenant_id`, +`corelink_device_id` and an access token obtained through the supported CoreLink +authentication flow. The collection contains readiness, successful device, +validation/authentication and idempotent command examples for the resettable +sandbox tenant; it never needs a vendor identifier or direct integration access. + +The collection is pinned to the reviewed `1.0.0-draft` contract. It remains a +prerelease reference until the public runtime and sandbox are released together. ## When adding a collection diff --git a/postman/corelink-public-v1.postman_collection.json b/postman/corelink-public-v1.postman_collection.json new file mode 100644 index 0000000..443a17f --- /dev/null +++ b/postman/corelink-public-v1.postman_collection.json @@ -0,0 +1,71 @@ +{ + "info": { + "name": "CoreLink Public API v1 (draft)", + "description": "Reference requests for the public CoreLink Device and Command contract. Import with the matching environment. No vendor identifiers or direct integration access are required.", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "variable": [ + { "key": "base_url", "value": "https://api.corelink.example" }, + { "key": "tenant_id", "value": "00000000-0000-0000-0000-000000000001" }, + { "key": "corelink_device_id", "value": "00000000-0000-0000-0000-000000000101" }, + { "key": "access_token", "value": "" } + ], + "item": [ + { + "name": "Health", + "item": [ + { + "name": "Readiness", + "request": { "method": "GET", "url": "{{base_url}}/health/ready" }, + "event": [{ "listen": "test", "script": { "exec": ["pm.test('returns readiness', function () { pm.expect(pm.response.code).to.eql(200); });"] } }] + } + ] + }, + { + "name": "Devices", + "item": [ + { + "name": "List tenant devices", + "request": { + "auth": { "type": "bearer", "bearer": [{ "key": "token", "value": "{{access_token}}", "type": "string" }] }, + "method": "GET", + "url": "{{base_url}}/api/v1/tenants/{{tenant_id}}/devices?limit=20&offset=0" + }, + "event": [{ "listen": "test", "script": { "exec": ["pm.test('returns a device page', function () { pm.expect(pm.response.code).to.eql(200); });"] } }] + }, + { + "name": "Get one device", + "request": { + "auth": { "type": "bearer", "bearer": [{ "key": "token", "value": "{{access_token}}", "type": "string" }] }, + "method": "GET", + "url": "{{base_url}}/api/v1/tenants/{{tenant_id}}/devices/{{corelink_device_id}}" + } + }, + { + "name": "Validation example (missing token)", + "request": { + "method": "GET", + "url": "{{base_url}}/api/v1/tenants/{{tenant_id}}/devices?limit=0" + }, + "event": [{ "listen": "test", "script": { "exec": ["pm.test('returns a public problem response', function () { pm.expect([400, 401]).to.include(pm.response.code); });"] } }] + } + ] + }, + { + "name": "Commands", + "item": [ + { + "name": "Create idempotent command", + "request": { + "auth": { "type": "bearer", "bearer": [{ "key": "token", "value": "{{access_token}}", "type": "string" }] }, + "method": "POST", + "header": [{ "key": "Idempotency-Key", "value": "sandbox-command-{{corelink_device_id}}", "type": "text" }, { "key": "Content-Type", "value": "application/json", "type": "text" }], + "body": { "mode": "raw", "raw": "{\n \"command_type\": \"sandbox.ping\",\n \"payload\": {}\n}", "options": { "raw": { "language": "json" } } }, + "url": "{{base_url}}/api/v1/tenants/{{tenant_id}}/devices/{{corelink_device_id}}/commands" + }, + "event": [{ "listen": "test", "script": { "exec": ["pm.test('accepts the idempotent command', function () { pm.expect(pm.response.code).to.eql(201); });"] } }] + } + ] + } + ] +} diff --git a/postman/corelink-public-v1.postman_environment.json b/postman/corelink-public-v1.postman_environment.json new file mode 100644 index 0000000..cc6a98d --- /dev/null +++ b/postman/corelink-public-v1.postman_environment.json @@ -0,0 +1,13 @@ +{ + "id": "f9518b1c-2c0a-44d8-87d8-6c2fa0dcedb5", + "name": "CoreLink Public API v1 sandbox", + "values": [ + { "key": "base_url", "value": "http://localhost:8000", "enabled": true }, + { "key": "tenant_id", "value": "", "enabled": true }, + { "key": "corelink_device_id", "value": "", "enabled": true }, + { "key": "access_token", "value": "", "enabled": true } + ], + "_postman_variable_scope": "environment", + "_postman_exported_at": "2026-07-24T00:00:00.000Z", + "_postman_exported_using": "CoreLink contracts" +} diff --git a/scripts/check_openapi_compatibility.rb b/scripts/check_openapi_compatibility.rb new file mode 100644 index 0000000..ef09fcf --- /dev/null +++ b/scripts/check_openapi_compatibility.rb @@ -0,0 +1,161 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# A deliberately dependency-free public-contract gate. It compares the public +# OpenAPI document with a base revision and rejects removals or narrowing +# changes within a major version. A breaking change belongs in a new versioned +# document (for example corelink-public-v2.yaml), not behind an allow-list. + +require "yaml" + +abort "usage: #{$PROGRAM_NAME} BASE_SPEC CANDIDATE_SPEC" unless ARGV.length == 2 + +def load_spec(path) + source = File.read(path) + return nil if source.strip.empty? + + YAML.safe_load(source, permitted_classes: [], aliases: false) || {} +rescue Psych::Exception => error + abort "invalid YAML in #{path}: #{error.message}" +end + +def dereference(spec, value) + return value unless value.is_a?(Hash) && value["$ref"] + + pointer = value.fetch("$ref") + return value unless pointer.start_with?("#/") + + pointer.delete_prefix("#/").split("/").reduce(spec) { |node, key| node.fetch(key) } +rescue KeyError + value +end + +def schema_changes(base_spec, candidate_spec, base_schema, candidate_schema, location, changes) + base_schema = dereference(base_spec, base_schema || {}) + candidate_schema = dereference(candidate_spec, candidate_schema || {}) + return unless base_schema.is_a?(Hash) && candidate_schema.is_a?(Hash) + + base_type = base_schema["type"] + candidate_type = candidate_schema["type"] + if base_type && candidate_type && base_type != candidate_type + changes << "#{location}: type changed from #{base_type} to #{candidate_type}" + end + + base_enum = Array(base_schema["enum"]) + candidate_enum = Array(candidate_schema["enum"]) + removed_values = base_enum - candidate_enum + changes << "#{location}: enum values removed (#{removed_values.join(", ")})" unless removed_values.empty? + + base_required = Array(base_schema["required"]) + candidate_required = Array(candidate_schema["required"]) + added_required = candidate_required - base_required + changes << "#{location}: fields made required (#{added_required.join(", ")})" unless added_required.empty? + + base_properties = base_schema.fetch("properties", {}) + candidate_properties = candidate_schema.fetch("properties", {}) + base_properties.each do |name, property| + if !candidate_properties.key?(name) + changes << "#{location}: response/request property removed (#{name})" + else + schema_changes(base_spec, candidate_spec, property, candidate_properties[name], "#{location}.#{name}", changes) + end + end + + schema_changes(base_spec, candidate_spec, base_schema["items"], candidate_schema["items"], "#{location}[]", changes) if base_schema["items"] +end + +def content_schemas(spec, response_or_request) + resolved = dereference(spec, response_or_request || {}) + resolved.fetch("content", {}).transform_values { |media| dereference(spec, media)["schema"] } +end + +base_spec = load_spec(ARGV[0]) +candidate_spec = load_spec(ARGV[1]) +if base_spec.nil? + warn "Base public contract is empty; compatibility comparison starts after this initial version is merged." + exit 0 +end +abort "candidate public contract is empty" if candidate_spec.nil? + +base_major = base_spec.dig("info", "version").to_s.split(".").first +candidate_major = candidate_spec.dig("info", "version").to_s.split(".").first +abort "both contracts must declare info.version" if base_major.empty? || candidate_major.empty? + +changes = [] +base_paths = base_spec.fetch("paths", {}) +candidate_paths = candidate_spec.fetch("paths", {}) +base_paths.each do |path, base_path_item| + candidate_path_item = candidate_paths[path] + unless candidate_path_item + changes << "path removed: #{path}" + next + end + + %w[get put post patch delete head options].each do |method| + base_operation = base_path_item[method] + next unless base_operation + + candidate_operation = candidate_path_item[method] + unless candidate_operation + changes << "operation removed: #{method.upcase} #{path}" + next + end + + base_parameters = Array(base_path_item["parameters"]) + Array(base_operation["parameters"]) + candidate_parameters = Array(candidate_path_item["parameters"]) + Array(candidate_operation["parameters"]) + base_parameters.each do |parameter| + parameter = dereference(base_spec, parameter) + identifier = [parameter["name"], parameter["in"]] + candidate = candidate_parameters.map { |item| dereference(candidate_spec, item) }.find { |item| [item["name"], item["in"]] == identifier } + if candidate.nil? + changes << "parameter removed: #{method.upcase} #{path} #{identifier.join(" in ")}" + elsif !parameter["required"] && candidate["required"] + changes << "parameter made required: #{method.upcase} #{path} #{identifier.first}" + else + schema_changes(base_spec, candidate_spec, parameter["schema"], candidate["schema"], "#{method.upcase} #{path} parameter #{identifier.first}", changes) + end + end + + base_request = content_schemas(base_spec, base_operation["requestBody"]) + candidate_request = content_schemas(candidate_spec, candidate_operation["requestBody"]) + base_request.each do |media_type, schema| + if !candidate_request.key?(media_type) + changes << "request media type removed: #{method.upcase} #{path} #{media_type}" + else + schema_changes(base_spec, candidate_spec, schema, candidate_request[media_type], "#{method.upcase} #{path} request #{media_type}", changes) + end + end + + base_operation.fetch("responses", {}).each do |status, base_response| + next unless status.match?(/^2/) + candidate_response = candidate_operation.fetch("responses", {})[status] + unless candidate_response + changes << "success response removed: #{method.upcase} #{path} #{status}" + next + end + content_schemas(base_spec, base_response).each do |media_type, schema| + candidate_schema = content_schemas(candidate_spec, candidate_response)[media_type] + if candidate_schema.nil? + changes << "success response media type removed: #{method.upcase} #{path} #{status} #{media_type}" + else + schema_changes(base_spec, candidate_spec, schema, candidate_schema, "#{method.upcase} #{path} #{status} #{media_type}", changes) + end + end + end + end +end + +if changes.empty? + puts "Public OpenAPI compatibility check passed." + exit 0 +end + +if candidate_major.to_i > base_major.to_i + warn "Breaking changes are permitted because contract major changed from #{base_major} to #{candidate_major}." + changes.each { |change| warn " - #{change}" } + exit 0 +end + +warn "Breaking public OpenAPI changes require a new major contract (found #{base_major} -> #{candidate_major}):" +changes.each { |change| warn " - #{change}" } +exit 1 From 2fb0dc86a6556b3e1d91775cee6d13b5d9a5e0dd Mon Sep 17 00:00:00 2001 From: Javid Date: Fri, 24 Jul 2026 22:57:16 +0330 Subject: [PATCH 6/6] fix: align public errors with runtime contract --- openapi/corelink-public-v1.yaml | 6 ++++-- schemas/command.schema.json | 1 + schemas/problem-details.schema.json | 1 + 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/openapi/corelink-public-v1.yaml b/openapi/corelink-public-v1.yaml index b11a932..11cd703 100644 --- a/openapi/corelink-public-v1.yaml +++ b/openapi/corelink-public-v1.yaml @@ -36,6 +36,7 @@ paths: content: application/json: schema: {$ref: '#/components/schemas/Health'} + '503': {$ref: '#/components/responses/ServiceUnavailable'} /api/v1/tenants/{tenant_id}/devices: parameters: - $ref: '#/components/parameters/TenantId' @@ -161,6 +162,7 @@ components: Forbidden: {description: Tenant access or permission denied, content: {application/problem+json: {schema: {$ref: '#/components/schemas/Problem'}}}} NotFound: {description: Resource not found, content: {application/problem+json: {schema: {$ref: '#/components/schemas/Problem'}}}} Conflict: {description: Conflicting request, content: {application/problem+json: {schema: {$ref: '#/components/schemas/Problem'}}}} + ServiceUnavailable: {description: Dependency unavailable, content: {application/problem+json: {schema: {$ref: '#/components/schemas/Problem'}}}} schemas: Health: {type: object, required: [status], properties: {status: {type: string, enum: [ok, ready]}}} DeviceStatus: {type: string, enum: [inventory, provisioning, active, suspended, retired]} @@ -197,6 +199,6 @@ components: DevicePage: {type: object, required: [items, total, limit, offset], properties: {items: {type: array, items: {$ref: '#/components/schemas/Device'}}, total: {type: integer}, limit: {type: integer}, offset: {type: integer}}} CommandStatus: {type: string, enum: [queued, dispatching, sent, acknowledged, succeeded, failed, timed_out, cancelled]} CommandCreate: {type: object, required: [command_type], properties: {command_type: {type: string, minLength: 1, maxLength: 150}, payload: {type: object, additionalProperties: true}, timeout_at: {type: string, format: date-time}, metadata: {type: object, additionalProperties: true}}} - Command: {type: object, required: [command_id, tenant_id, corelink_device_id, command_type, payload, status, attempt_count, metadata, created_at, updated_at], properties: {command_id: {type: string, format: uuid}, tenant_id: {type: string, format: uuid}, corelink_device_id: {type: string, format: uuid}, command_type: {type: string}, payload: {type: object, additionalProperties: true}, status: {$ref: '#/components/schemas/CommandStatus'}, attempt_count: {type: integer}, timeout_at: {type: [string, 'null'], format: date-time}, acknowledged_at: {type: [string, 'null'], format: date-time}, completed_at: {type: [string, 'null'], format: date-time}, metadata: {type: object, additionalProperties: true}, created_at: {type: string, format: date-time}, updated_at: {type: string, format: date-time}}} + Command: {type: object, required: [command_id, tenant_id, corelink_device_id, command_type, payload, status, attempt_count, metadata, created_at, updated_at], properties: {command_id: {type: string, format: uuid}, tenant_id: {type: string, format: uuid}, corelink_device_id: {type: string, format: uuid}, command_type: {type: string}, payload: {type: object, additionalProperties: true}, status: {$ref: '#/components/schemas/CommandStatus'}, attempt_count: {type: integer}, timeout_at: {type: [string, 'null'], format: date-time}, acknowledged_at: {type: [string, 'null'], format: date-time}, completed_at: {type: [string, 'null'], format: date-time}, error_code: {type: [string, 'null'], maxLength: 100}, metadata: {type: object, additionalProperties: true}, created_at: {type: string, format: date-time}, updated_at: {type: string, format: date-time}}} CommandPage: {type: object, required: [items, total, limit, offset], properties: {items: {type: array, items: {$ref: '#/components/schemas/Command'}}, total: {type: integer}, limit: {type: integer}, offset: {type: integer}}} - Problem: {type: object, required: [type, title, status, code, correlation_id], properties: {type: {type: string}, title: {type: string}, status: {type: integer}, code: {type: string}, detail: {type: string}, correlation_id: {type: string}}} + Problem: {type: object, required: [type, title, status, code, correlation_id], properties: {type: {type: string}, title: {type: string}, status: {type: integer}, code: {type: string}, detail: {type: string}, diagnostics: {type: object, additionalProperties: true}, correlation_id: {type: string}}} diff --git a/schemas/command.schema.json b/schemas/command.schema.json index 9331d3e..28f89d3 100644 --- a/schemas/command.schema.json +++ b/schemas/command.schema.json @@ -12,6 +12,7 @@ "command_type": {"type": "string", "minLength": 1, "maxLength": 150}, "payload": {"type": "object", "additionalProperties": true}, "status": {"type": "string", "enum": ["queued", "dispatching", "sent", "acknowledged", "succeeded", "failed", "timed_out", "cancelled"]}, + "error_code": {"type": ["string", "null"], "maxLength": 100}, "metadata": {"type": "object", "additionalProperties": true}, "created_at": {"type": "string", "format": "date-time"}, "updated_at": {"type": "string", "format": "date-time"} diff --git a/schemas/problem-details.schema.json b/schemas/problem-details.schema.json index 196eab0..ebb31fc 100644 --- a/schemas/problem-details.schema.json +++ b/schemas/problem-details.schema.json @@ -11,6 +11,7 @@ "status": {"type": "integer", "minimum": 400, "maximum": 599}, "code": {"type": "string", "pattern": "^[a-z][a-z0-9_]*$"}, "detail": {"type": "string"}, + "diagnostics": {"type": "object", "additionalProperties": true}, "correlation_id": {"type": "string", "minLength": 1} } }