openvibers/openvibe.contracts Commits

v0.31.0: registry.release-manifest 1.1.0 (ADR-016, Track R): optional components, assets, schema_generation, schema_compatible_from, contract_ranges and metrics_url

OP OpenVibers committed 23 Sept 2026 · 3a2f68c438 · 10 files, +321 −10


Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 0bbf0912..08945547 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -4,6 +4,28 @@ All notable changes to `openvibe-contracts`. Releases are git tags (`vX.Y.Z`) th
from `https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/<tag>`. Before v0.30.0,
the notes were in the tag and commit messages (`git tag -n1`).
++## 0.31.0 — 2026-09-23
++
++Additive: `compat.js` reports no breaking change against v0.30.2.
++
++- `registry.release-manifest` 1.0.0 → 1.1.0 (ADR-016, roadmap Track R). Six optional fields, so a
++ `/release.json` can say what changed and what it still accepts:
++ - `components`: `{ <id>: { kind: style|content|script|server, version } }`. A tab applies a release
++ in place only when every component that changed is `style`, `content` or `server`. `shell` is the
++ catch-all for client code that no other component lists.
++ - `assets`: `{ "<logical path>": { url, component, integrity? } }`, the content-addressed URL of each
++ asset this release serves.
++ - `schema_generation` and `schema_compatible_from` (integers or null): the database schema generation,
++ and the oldest generation whose code still runs against it. A rollback below the second is not
++ data-safe.
++ - `contract_ranges`: `{ <contract id>: { version, accepts: ">=a.b.c <x.y.z", role?: produces|consumes } }`.
++ A page from release A works against a server on release B when A's version is in B's `accepts`
++ and B's version is in A's `accepts`.
++ - `metrics_url`: where tabs POST their update outcome counts (D46).
++- A consumer that validates manifests against 0.30.x rejects these fields (`additionalProperties: false`).
++ openvibe-shared ≥ 1.5.0 therefore serves only the fields the service's installed openvibe-contracts
++ declares. A service starts serving them by moving its pin to this release.
++
## 0.30.2 — 2026-09-23
- New capability `live.follower.read` (Live, internal): `GET /internal/followers`, used by OpenVibe.Network's
ddiff --git a/contracts/catalog.json b/contracts/catalog.json
iindex f793f87c..639572ca 100644
---- a/contracts/catalog.json
++++ b/contracts/catalog.json
@@@ -110,7 +110,7 @@
},
{
"id": "registry.release-manifest",
-- "version": "1.0.0",
++ "version": "1.1.0",
"owner": "network",
"schema": "registry/release-manifest.v1.json",
"visibility": "public",
ddiff --git a/contracts/registry/release-manifest.v1.json b/contracts/registry/release-manifest.v1.json
iindex 94eb2522..52c94499 100644
---- a/contracts/registry/release-manifest.v1.json
++++ b/contracts/registry/release-manifest.v1.json
@@@ -2,7 +2,7 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://openvibe.network/contracts/registry/release-manifest.v1.json",
"title": "ReleaseManifest",
-- "description": "What a running web surface is serving (GET /release.json, ADR-016). Open clients compare it on focus and reconnect; a client outside the supported window is prompted and reloads only when that is safe.",
++ "description": "What a running web surface is serving (GET /release.json, ADR-016). Open clients compare it on focus and reconnect. A client outside the supported window is prompted and reloads only when that is safe. Since 1.1.0 the manifest can also say which components changed, so a client can apply style and content changes in place, and which contract versions the server still accepts, so a mixed-version population can be checked.",
"type": "object",
"required": ["service", "release", "released_at", "contracts_version", "min_client_release", "mixed_version_window_hours"],
"additionalProperties": false,
@@@ -18,6 +18,68 @@
"additionalProperties": { "type": "string" }
},
"min_client_release": { "type": ["string", "null"], "description": "Oldest client release still supported; null while every client in the window is." },
-- "mixed_version_window_hours": { "type": "integer", "minimum": 0 }
++ "mixed_version_window_hours": { "type": "integer", "minimum": 0 },
++ "components": {
++ "type": "object",
++ "description": "Since 1.1.0. Per-component versions. A client applies a new release in place only when every component that changed is a style, content or server component and the contract ranges still match. Any other change is applied by a reload at a safe moment. The shell component covers every client-facing file that no other component lists.",
++ "propertyNames": { "pattern": "^[a-z][a-z0-9-]{0,39}$" },
++ "additionalProperties": {
++ "type": "object",
++ "required": ["kind", "version"],
++ "additionalProperties": false,
++ "properties": {
++ "kind": {
++ "enum": ["style", "content", "script", "server"],
++ "description": "style: stylesheets, swapped in place. content: server-rendered regions (data-ov-content), re-fetched in place. script: code the page runs, applied by a reload. server: code only the server runs, no client action."
++ },
++ "version": { "type": "string", "pattern": "^[0-9A-Za-z._+-]{1,64}$", "description": "Content hash of the component's files, or a version the service states." }
++ }
++ }
++ },
++ "assets": {
++ "type": "object",
++ "description": "Since 1.1.0. Asset map: each logical URL path the release serves, mapped to its content-addressed URL. HTML from this release references only these URLs.",
++ "propertyNames": { "pattern": "^/[^?#\\s]{0,255}$" },
++ "additionalProperties": {
++ "type": "object",
++ "required": ["url", "component"],
++ "additionalProperties": false,
++ "properties": {
++ "url": { "type": "string", "minLength": 1, "maxLength": 512 },
++ "component": { "type": "string", "pattern": "^[a-z][a-z0-9-]{0,39}$" },
++ "integrity": { "type": "string", "pattern": "^sha(256|384|512)-[A-Za-z0-9+/]+={0,2}$" }
++ }
++ }
++ },
++ "schema_generation": {
++ "type": ["integer", "null"],
++ "minimum": 0,
++ "description": "Since 1.1.0. The database schema generation this release migrates to and runs against; null when the service does not track one."
++ },
++ "schema_compatible_from": {
++ "type": ["integer", "null"],
++ "minimum": 0,
++ "description": "Since 1.1.0. The oldest schema generation whose code still runs against this release's schema, because every migration since then only expanded it. Rolling back to a release whose schema_generation is lower is not data-safe."
++ },
++ "contract_ranges": {
++ "type": "object",
++ "description": "Since 1.1.0. For each contract the service produces (its API, as served to its own pages and clients) or consumes: the version this release speaks and the range of versions it accepts from the other side. A page from release A works against a server on release B when, for every contract A produces, A's version is in B's accepts and B's version is in A's accepts.",
++ "propertyNames": { "pattern": "^[a-z][a-z0-9-]*(\\.[a-z0-9_-]+)+$", "maxLength": 100 },
++ "additionalProperties": {
++ "type": "object",
++ "required": ["version", "accepts"],
++ "additionalProperties": false,
++ "properties": {
++ "version": { "type": "string", "pattern": "^\\d+\\.\\d+\\.\\d+$" },
++ "accepts": { "type": "string", "pattern": "^>=\\d+\\.\\d+\\.\\d+ <\\d+\\.\\d+\\.\\d+$", "description": "A half-open range, always written \">=a.b.c <x.y.z\"." },
++ "role": { "enum": ["produces", "consumes"], "description": "produces when absent." }
++ }
++ }
++ },
++ "metrics_url": {
++ "type": ["string", "null"],
++ "maxLength": 512,
++ "description": "Since 1.1.0. Same-origin URL where clients POST their update outcome counts (applied, reloaded, deferred, failed); null or absent when the service does not collect them."
++ }
}
}
ddiff --git a/fixtures/registry.release-manifest/invalid/range-not-normalised.json b/fixtures/registry.release-manifest/invalid/range-not-normalised.json
nnew file mode 100644
iindex 00000000..254c2c1c
---- /dev/null
++++ b/fixtures/registry.release-manifest/invalid/range-not-normalised.json
@@@ -0,0 +1,11 @@
++{
++ "service": "live",
++ "release": "047f2069a1b2",
++ "released_at": "2026-09-23T18:02:11Z",
++ "contracts_version": "0.31.0",
++ "min_client_release": null,
++ "mixed_version_window_hours": 24,
++ "contract_ranges": {
++ "live.web-api": { "version": "1.4.0", "accepts": "^1.0.0" }
++ }
++}
ddiff --git a/fixtures/registry.release-manifest/invalid/unknown-component-kind.json b/fixtures/registry.release-manifest/invalid/unknown-component-kind.json
nnew file mode 100644
iindex 00000000..71197b9f
---- /dev/null
++++ b/fixtures/registry.release-manifest/invalid/unknown-component-kind.json
@@@ -0,0 +1,11 @@
++{
++ "service": "live",
++ "release": "047f2069a1b2",
++ "released_at": "2026-09-23T18:02:11Z",
++ "contracts_version": "0.31.0",
++ "min_client_release": null,
++ "mixed_version_window_hours": 24,
++ "components": {
++ "shell": { "kind": "layout", "version": "c1d2e3f4a5b6" }
++ }
++}
ddiff --git a/fixtures/registry.release-manifest/valid/live-full.json b/fixtures/registry.release-manifest/valid/live-full.json
nnew file mode 100644
iindex 00000000..2842d983
---- /dev/null
++++ b/fixtures/registry.release-manifest/valid/live-full.json
@@@ -0,0 +1,27 @@
++{
++ "service": "live",
++ "release": "047f2069a1b2",
++ "released_at": "2026-09-23T18:02:11Z",
++ "booted_at": "2026-09-23T18:04:40Z",
++ "contracts_version": "0.31.0",
++ "packages": { "openvibe-shared": "1.5.0", "openvibe-sdk": "0.4.0" },
++ "min_client_release": null,
++ "mixed_version_window_hours": 24,
++ "components": {
++ "styles": { "kind": "style", "version": "3f9a1c07be21" },
++ "docs": { "kind": "content", "version": "a07e44d1c9f0" },
++ "shell": { "kind": "script", "version": "c1d2e3f4a5b6" },
++ "server": { "kind": "server", "version": "047f2069a1b2" }
++ },
++ "assets": {
++ "/css/app.css": { "url": "/css/app.css?v=3f9a1c07be21", "component": "styles", "integrity": "sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC" },
++ "/js/app.js": { "url": "/js/app.js?v=9b8a7c6d5e4f", "component": "shell" }
++ },
++ "schema_generation": 42,
++ "schema_compatible_from": 40,
++ "contract_ranges": {
++ "live.web-api": { "version": "1.4.0", "accepts": ">=1.0.0 <2.0.0" },
++ "media.object": { "version": "1.0.0", "accepts": ">=1.0.0 <2.0.0", "role": "consumes" }
++ },
++ "metrics_url": "/release-metrics"
++}
ddiff --git a/generated/json-schema/bundle.json b/generated/json-schema/bundle.json
iindex 687067b9..044d5664 100644
---- a/generated/json-schema/bundle.json
++++ b/generated/json-schema/bundle.json
@@@ -840,14 +840,14 @@
}
},
"registry.release-manifest": {
-- "version": "1.0.0",
++ "version": "1.1.0",
"owner": "network",
"status": "active",
"schema": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://openvibe.network/contracts/registry/release-manifest.v1.json",
"title": "ReleaseManifest",
-- "description": "What a running web surface is serving (GET /release.json, ADR-016). Open clients compare it on focus and reconnect; a client outside the supported window is prompted and reloads only when that is safe.",
++ "description": "What a running web surface is serving (GET /release.json, ADR-016). Open clients compare it on focus and reconnect. A client outside the supported window is prompted and reloads only when that is safe. Since 1.1.0 the manifest can also say which components changed, so a client can apply style and content changes in place, and which contract versions the server still accepts, so a mixed-version population can be checked.",
"type": "object",
"required": [
"service",
@@@ -901,6 +901,125 @@
"mixed_version_window_hours": {
"type": "integer",
"minimum": 0
++ },
++ "components": {
++ "type": "object",
++ "description": "Since 1.1.0. Per-component versions. A client applies a new release in place only when every component that changed is a style, content or server component and the contract ranges still match. Any other change is applied by a reload at a safe moment. The shell component covers every client-facing file that no other component lists.",
++ "propertyNames": {
++ "pattern": "^[a-z][a-z0-9-]{0,39}$"
++ },
++ "additionalProperties": {
++ "type": "object",
++ "required": [
++ "kind",
++ "version"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "kind": {
++ "enum": [
++ "style",
++ "content",
++ "script",
++ "server"
++ ],
++ "description": "style: stylesheets, swapped in place. content: server-rendered regions (data-ov-content), re-fetched in place. script: code the page runs, applied by a reload. server: code only the server runs, no client action."
++ },
++ "version": {
++ "type": "string",
++ "pattern": "^[0-9A-Za-z._+-]{1,64}$",
++ "description": "Content hash of the component's files, or a version the service states."
++ }
++ }
++ }
++ },
++ "assets": {
++ "type": "object",
++ "description": "Since 1.1.0. Asset map: each logical URL path the release serves, mapped to its content-addressed URL. HTML from this release references only these URLs.",
++ "propertyNames": {
++ "pattern": "^/[^?#\\s]{0,255}$"
++ },
++ "additionalProperties": {
++ "type": "object",
++ "required": [
++ "url",
++ "component"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "url": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 512
++ },
++ "component": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9-]{0,39}$"
++ },
++ "integrity": {
++ "type": "string",
++ "pattern": "^sha(256|384|512)-[A-Za-z0-9+/]+={0,2}$"
++ }
++ }
++ }
++ },
++ "schema_generation": {
++ "type": [
++ "integer",
++ "null"
++ ],
++ "minimum": 0,
++ "description": "Since 1.1.0. The database schema generation this release migrates to and runs against; null when the service does not track one."
++ },
++ "schema_compatible_from": {
++ "type": [
++ "integer",
++ "null"
++ ],
++ "minimum": 0,
++ "description": "Since 1.1.0. The oldest schema generation whose code still runs against this release's schema, because every migration since then only expanded it. Rolling back to a release whose schema_generation is lower is not data-safe."
++ },
++ "contract_ranges": {
++ "type": "object",
++ "description": "Since 1.1.0. For each contract the service produces (its API, as served to its own pages and clients) or consumes: the version this release speaks and the range of versions it accepts from the other side. A page from release A works against a server on release B when, for every contract A produces, A's version is in B's accepts and B's version is in A's accepts.",
++ "propertyNames": {
++ "pattern": "^[a-z][a-z0-9-]*(\\.[a-z0-9_-]+)+$",
++ "maxLength": 100
++ },
++ "additionalProperties": {
++ "type": "object",
++ "required": [
++ "version",
++ "accepts"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "version": {
++ "type": "string",
++ "pattern": "^\\d+\\.\\d+\\.\\d+$"
++ },
++ "accepts": {
++ "type": "string",
++ "pattern": "^>=\\d+\\.\\d+\\.\\d+ <\\d+\\.\\d+\\.\\d+$",
++ "description": "A half-open range, always written \">=a.b.c <x.y.z\"."
++ },
++ "role": {
++ "enum": [
++ "produces",
++ "consumes"
++ ],
++ "description": "produces when absent."
++ }
++ }
++ }
++ },
++ "metrics_url": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "maxLength": 512,
++ "description": "Since 1.1.0. Same-origin URL where clients POST their update outcome counts (applied, reloaded, deferred, failed); null or absent when the service does not collect them."
}
}
}
ddiff --git a/generated/typescript/index.d.ts b/generated/typescript/index.d.ts
iindex 3a46bce2..99f4103f 100644
---- a/generated/typescript/index.d.ts
++++ b/generated/typescript/index.d.ts
@@@ -266,9 +266,9 @@ export interface ModuleRecord {
updated_by?: string;
}
--/** [email protected] (owner: network) */
++/** [email protected] (owner: network) */
/**
-- * What a running web surface is serving (GET /release.json, ADR-016). Open clients compare it on focus and reconnect; a client outside the supported window is prompted and reloads only when that is safe.
++ * What a running web surface is serving (GET /release.json, ADR-016). Open clients compare it on focus and reconnect. A client outside the supported window is prompted and reloads only when that is safe. Since 1.1.0 the manifest can also say which components changed, so a client can apply style and content changes in place, and which contract versions the server still accepts, so a mixed-version population can be checked.
*/
export interface ReleaseManifest {
service: string;
@@@ -296,6 +296,65 @@ export interface ReleaseManifest {
*/
min_client_release: string | null;
mixed_version_window_hours: number;
++ /**
++ * Since 1.1.0. Per-component versions. A client applies a new release in place only when every component that changed is a style, content or server component and the contract ranges still match. Any other change is applied by a reload at a safe moment. The shell component covers every client-facing file that no other component lists.
++ */
++ components?: {
++ [k: string]:
++ | {
++ /**
++ * style: stylesheets, swapped in place. content: server-rendered regions (data-ov-content), re-fetched in place. script: code the page runs, applied by a reload. server: code only the server runs, no client action.
++ */
++ kind: "style" | "content" | "script" | "server";
++ /**
++ * Content hash of the component's files, or a version the service states.
++ */
++ version: string;
++ }
++ | undefined;
++ };
++ /**
++ * Since 1.1.0. Asset map: each logical URL path the release serves, mapped to its content-addressed URL. HTML from this release references only these URLs.
++ */
++ assets?: {
++ [k: string]:
++ | {
++ url: string;
++ component: string;
++ integrity?: string;
++ }
++ | undefined;
++ };
++ /**
++ * Since 1.1.0. The database schema generation this release migrates to and runs against; null when the service does not track one.
++ */
++ schema_generation?: number | null;
++ /**
++ * Since 1.1.0. The oldest schema generation whose code still runs against this release's schema, because every migration since then only expanded it. Rolling back to a release whose schema_generation is lower is not data-safe.
++ */
++ schema_compatible_from?: number | null;
++ /**
++ * Since 1.1.0. For each contract the service produces (its API, as served to its own pages and clients) or consumes: the version this release speaks and the range of versions it accepts from the other side. A page from release A works against a server on release B when, for every contract A produces, A's version is in B's accepts and B's version is in A's accepts.
++ */
++ contract_ranges?: {
++ [k: string]:
++ | {
++ version: string;
++ /**
++ * A half-open range, always written ">=a.b.c <x.y.z".
++ */
++ accepts: string;
++ /**
++ * produces when absent.
++ */
++ role?: "produces" | "consumes";
++ }
++ | undefined;
++ };
++ /**
++ * Since 1.1.0. Same-origin URL where clients POST their update outcome counts (applied, reloaded, deferred, failed); null or absent when the service does not collect them.
++ */
++ metrics_url?: string | null;
}
/** [email protected] (owner: search) */
ddiff --git a/package-lock.json b/package-lock.json
iindex 911e0285..bea6c8ab 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-contracts",
-- "version": "0.30.2",
++ "version": "0.31.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-contracts",
-- "version": "0.30.2",
++ "version": "0.31.0",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex ec248211..296b860c 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-contracts",
-- "version": "0.30.2",
++ "version": "0.31.0",
"description": "Machine-readable contracts for the OpenVibe network: schemas, manifests, ids, error envelope, capability checks.",
"main": "index.js",
"types": "generated/typescript/index.d.ts",