openvibers/openvibe.contracts Commits

v0.58.0 (continued): the release notification is its own event type, host.release.published@1; host.deploy.activated@1 is the tenant shape only

OP OpenVibers committed 26 Sept 2026 · 36d560ea8c · 28 files, +429 −306

The release notification no longer reuses host.deploy.activated.
- host.release.published@1 (catalog visibility public, ADR-016): a network service's release went live. OpenVibe.Host's operator plane publishes it (ovhost deploy/rollback, ovhost announce). Envelope subject { type: release, id: <service>:<release> }, visibility public. Payload exactly the release shape: service, release (the release manifest's pattern), commit or null, origin (an http(s) origin) or null, deployed_at, optional components (kind and version only, at most 32) and rollback: true. Identifiers only.
- host.deploy.activated@1 (now first-party, ADR-004) is one shape, what Host's Stage B already emits for a tenant site's activation: project_id, site_id, site, deploy_id, previous_deploy_id, rollback (subject deploy, visibility internal). No oneOf.

The host manifest lists host.release.published in eventsProduced and its notes name both producers. The release fixtures move to fixtures/host.release.published (plus: a tenant activation and a payload carrying a tenant field are invalid there); host.deploy.activated keeps its tenant fixtures (plus a rollback, and a service release is invalid there). test/run.js: both contracts owned by host and produced by its manifest, public vs first-party, one shape each, neither payload passes for the other, every release field required, identifiers only, the release manifest's release pattern. ADR-016 amendment 1 and the 0.58.0 CHANGELOG entry use the new name. Regenerated; compat vs v0.57.2: no breaking changes. Still 0.58.0, not tagged.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 2eb010ea..5ff8a882 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -6,11 +6,12 @@ the notes were in the tag and commit messages (`git tag -n1`).
## 0.58.0 — 2026-09-26
--**Release notifications** (roadmap WS-P task 9, ADR-016 amendment 1). `host.deploy.activated` gets its payload contract, `host.deploy.activated@1` (catalog visibility public). The type has two shapes, and exactly one matches:
--- **A network service's release went live.** OpenVibe.Host's operator plane publishes it (`ovhost deploy|rollback`, and `ovhost announce` for services deployed by their own scripts). The envelope has subject `{ type: release, id: <service>:<release> }` and visibility public, so signed-out browsers get it over Events realtime and openvibe-shared release-watch checks `/release.json` at once. The payload is `service`, `release` (the `/release.json` release id, the release manifest's pattern), `commit` (or null), `origin` (or null), `deployed_at`, and optionally `components` (kind and version only) and `rollback: true`. Identifiers only.
--- **A Stage B tenant site's active deploy changed.** This is what Host's API already emitted: `project_id`, `site_id`, `site`, `deploy_id`, `previous_deploy_id`, `rollback`. It has subject `deploy` and visibility internal.
++**Release notifications** (roadmap WS-P task 9, ADR-016 amendment 1). A new event type, **`host.release.published`**, with the payload contract `host.release.published@1` (catalog visibility public). It means a network service's release went live.
++- **Who publishes it.** OpenVibe.Host's operator plane: `ovhost deploy|rollback`, and `ovhost announce` for services deployed by their own scripts.
++- **Envelope.** Subject `{ type: release, id: <service>:<release> }` and visibility public. Signed-out browsers get it over Events realtime, and openvibe-shared release-watch (1.17.0, `topics=host.release.published`) checks `/release.json` at once.
++- **Payload.** `service`, `release` (the `/release.json` release id, with the release manifest's pattern), `commit` or null, `origin` (an http(s) origin) or null, `deployed_at`, and optionally `components` (kind and version only, at most 32) and `rollback: true`. Identifiers only.
--It has fixtures for both shapes (mixing them fails), and the host manifest's notes name the operator-plane producer. The event type was already in the host manifest's `eventsProduced`, so nothing is renamed. Additive.
++`host.deploy.activated` also gets its payload contract, `host.deploy.activated@1` (first-party), which is what Host's Stage B already emits for a tenant site's activation: `project_id`, `site_id`, `site`, `deploy_id`, `previous_deploy_id`, `rollback`. It has subject `deploy` and visibility internal. The two types never share a payload, and fixtures check that neither passes for the other. The host manifest lists `host.release.published` in `eventsProduced`. Additive.
## 0.57.2 — 2026-09-26
ddiff --git a/contracts/catalog.json b/contracts/catalog.json
iindex dcd6ccb7..989a2c6a 100644
---- a/contracts/catalog.json
++++ b/contracts/catalog.json
@@@ -4582,6 +4582,16 @@
"version": "1.0.0",
"owner": "host",
"schema": "events/payloads/host.deploy.activated.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active",
++ "adr": "ADR-004"
++ },
++ {
++ "id": "host.release.published",
++ "version": "1.0.0",
++ "owner": "host",
++ "schema": "events/payloads/host.release.published.v1.json",
"visibility": "public",
"compatibility": "backward",
"status": "active",
ddiff --git a/contracts/events/payloads/host.deploy.activated.v1.json b/contracts/events/payloads/host.deploy.activated.v1.json
iindex 891f6618..af9b58d5 100644
---- a/contracts/events/payloads/host.deploy.activated.v1.json
++++ b/contracts/events/payloads/host.deploy.activated.v1.json
@@@ -2,51 +2,46 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://openvibe.network/contracts/events/payloads/host.deploy.activated.v1.json",
"title": "HostDeployActivatedPayload",
-- "description": "host.deploy.activated v1. A deploy became the one that serves. Two producers inside OpenVibe.Host, told apart by the envelope subject and by the payload's fields (exactly one shape matches). (1) A network service's release went live (roadmap WS-P task 9, ADR-016): the operator plane, lib/announce.js, after `ovhost deploy|rollback <service>` went live and for `ovhost announce <service>` (services deployed by their own scripts: Live, Tools, Sites, OpenRe, Games). Envelope: subject { type: release, id: <service>:<release> }, visibility public (signed-out browsers get it over Events realtime), priority low, actor service:host. Open tabs whose /release.json names `service` check it when `release` is not the one they run; the event carries identifiers only, never what changed. At most one per service and release unless an operator forces it; a retry reuses the event_id. (2) A Stage B tenant site's active deploy changed: server/domain/deploys.js, in the transaction that moves the pointer. Envelope: subject { type: deploy, id: <deploy id> }, visibility internal (never streamed to browsers), actor the person or app that switched it.",
++ "description": "host.deploy.activated v1 (OpenVibe.Host server/domain/deploys.js switchPointer, Stage B tenant hosting). A tenant site's active deploy changed, written into Host's event outbox in the transaction that moves the pointer (activate or rollback). Envelope: subject { type: deploy, id: <deploy id> }, visibility internal, priority low, actor the person or app that switched it. A network service's release going live is host.release.published, not this.",
"type": "object",
-- "oneOf": [
-- {
-- "description": "A network service's release went live (subject release, visibility public).",
-- "type": "object",
-- "required": ["service", "release", "commit", "origin", "deployed_at"],
-- "additionalProperties": false,
-- "properties": {
-- "service": { "type": "string", "pattern": "^[a-z][a-z0-9-]{1,39}$", "description": "The service id the pages' /release.json names (registry.release-manifest@1 `service`): live, tools, or a Sites placeholder's id such as news." },
-- "release": { "type": "string", "pattern": "^[0-9a-f]{7,40}$", "description": "The release id that service's /release.json reports now (registry.release-manifest@1 `release`). A client compares it with the release it runs, treating a hex prefix of the other as the same release." },
-- "commit": { "type": ["string", "null"], "pattern": "^[0-9a-f]{7,40}$", "description": "The deployed commit when it is known and `release` is its prefix, or what the operator passed; null otherwise (a Sites placeholder's release is a page hash)." },
-- "origin": { "type": ["string", "null"], "format": "uri", "pattern": "^https?://[^/?#]+$", "description": "The public origin the release serves (https://openvibe.live): the inventory's origin or the service manifest's publicOrigin; null when neither is known." },
-- "deployed_at": { "type": "string", "format": "date-time", "description": "When Host announced it (UTC); after `ovhost deploy`, just after the service answered ready." },
-- "components": {
-- "type": "object",
-- "description": "Optional. The component versions /release.json reported (registry.release-manifest 1.1.0 `components`, kind and version only).",
-- "maxProperties": 32,
-- "propertyNames": { "pattern": "^[a-z][a-z0-9-]{0,39}$" },
-- "additionalProperties": {
-- "type": "object",
-- "required": ["kind", "version"],
-- "additionalProperties": false,
-- "properties": {
-- "kind": { "enum": ["style", "content", "script", "server"] },
-- "version": { "type": "string", "pattern": "^[0-9A-Za-z._+-]{1,64}$" }
-- }
-- }
-- },
-- "rollback": { "const": true, "description": "Present after `ovhost rollback`: the release is an older one." }
-- }
++ "required": [
++ "project_id",
++ "site_id",
++ "site",
++ "deploy_id",
++ "previous_deploy_id",
++ "rollback"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "project_id": {
++ "type": "string",
++ "pattern": "^prj_[0-9A-HJKMNP-TV-Z]{26}$"
},
-- {
-- "description": "A Stage B tenant site's active deploy changed (subject deploy, visibility internal).",
-- "type": "object",
-- "required": ["project_id", "site_id", "site", "deploy_id", "previous_deploy_id", "rollback"],
-- "additionalProperties": false,
-- "properties": {
-- "project_id": { "type": "string", "pattern": "^prj_[0-9A-HJKMNP-TV-Z]{26}$" },
-- "site_id": { "type": "string", "pattern": "^site_[0-9A-HJKMNP-TV-Z]{26}$" },
-- "site": { "type": "string", "pattern": "^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){2,39}$", "description": "The site's name (<site>.openvibe.host)." },
-- "deploy_id": { "type": "string", "pattern": "^dpl_[0-9A-HJKMNP-TV-Z]{26}$" },
-- "previous_deploy_id": { "type": ["string", "null"], "pattern": "^dpl_[0-9A-HJKMNP-TV-Z]{26}$", "description": "The deploy that served before; null for the site's first activation." },
-- "rollback": { "type": "boolean", "description": "true when the switch was a rollback." }
-- }
++ "site_id": {
++ "type": "string",
++ "pattern": "^site_[0-9A-HJKMNP-TV-Z]{26}$"
++ },
++ "site": {
++ "type": "string",
++ "pattern": "^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){2,39}$",
++ "description": "The site's name (<site>.openvibe.host)."
++ },
++ "deploy_id": {
++ "type": "string",
++ "pattern": "^dpl_[0-9A-HJKMNP-TV-Z]{26}$"
++ },
++ "previous_deploy_id": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "pattern": "^dpl_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "The deploy that served before; null for the site's first activation."
++ },
++ "rollback": {
++ "type": "boolean",
++ "description": "true when the switch was a rollback."
}
-- ]
++ }
}
ddiff --git a/contracts/events/payloads/host.release.published.v1.json b/contracts/events/payloads/host.release.published.v1.json
nnew file mode 100644
iindex 00000000..e4680a35
---- /dev/null
++++ b/contracts/events/payloads/host.release.published.v1.json
@@@ -0,0 +1,83 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/host.release.published.v1.json",
++ "title": "HostReleasePublishedPayload",
++ "description": "host.release.published v1 (roadmap WS-P task 9, ADR-016 amendment 1). A network service's release went live. OpenVibe.Host's operator plane (lib/announce.js) publishes it after `ovhost deploy|rollback <service>` went live and for `ovhost announce <service>` (services deployed by their own scripts: Live, Tools, Sites, OpenRe, Games). Envelope: subject { type: release, id: <service>:<release> }, visibility public (signed-out browsers get it over Events realtime), priority low, actor service:host. Open tabs whose /release.json names `service` (openvibe-shared release-watch 1.17.0, topics=host.release.published) check it when `release` is not the one they run. It carries identifiers only, never what changed. At most one per service and release unless an operator forces it; a retry reuses the event_id. Tenant sites' activations are host.deploy.activated, not this.",
++ "type": "object",
++ "required": [
++ "service",
++ "release",
++ "commit",
++ "origin",
++ "deployed_at"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "service": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9-]{1,39}$",
++ "description": "The service id the pages' /release.json names (registry.release-manifest@1 `service`): live, tools, or a Sites placeholder's id such as news."
++ },
++ "release": {
++ "type": "string",
++ "pattern": "^[0-9a-f]{7,40}$",
++ "description": "The release id that service's /release.json reports now (registry.release-manifest@1 `release`). A client compares it with the release it runs, treating a hex prefix of the other as the same release."
++ },
++ "commit": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "pattern": "^[0-9a-f]{7,40}$",
++ "description": "The deployed commit when it is known and `release` is its prefix, or what the operator passed; null otherwise (a Sites placeholder's release is a page hash)."
++ },
++ "origin": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "format": "uri",
++ "pattern": "^https?://[^/?#]+$",
++ "description": "The public origin the release serves (https://openvibe.live): the inventory's origin or the service manifest's publicOrigin; null when neither is known."
++ },
++ "deployed_at": {
++ "type": "string",
++ "format": "date-time",
++ "description": "When Host announced it (UTC); after `ovhost deploy`, just after the service answered ready."
++ },
++ "components": {
++ "type": "object",
++ "description": "Optional. The component versions /release.json reported (registry.release-manifest 1.1.0 `components`, kind and version only).",
++ "maxProperties": 32,
++ "propertyNames": {
++ "pattern": "^[a-z][a-z0-9-]{0,39}$"
++ },
++ "additionalProperties": {
++ "type": "object",
++ "required": [
++ "kind",
++ "version"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "kind": {
++ "enum": [
++ "style",
++ "content",
++ "script",
++ "server"
++ ]
++ },
++ "version": {
++ "type": "string",
++ "pattern": "^[0-9A-Za-z._+-]{1,64}$"
++ }
++ }
++ }
++ },
++ "rollback": {
++ "const": true,
++ "description": "Present after `ovhost rollback`: the release is an older one."
++ }
++ }
++}
ddiff --git a/docs/adr/ADR-016-active-client-updates.md b/docs/adr/ADR-016-active-client-updates.md
iindex feea2928..8df99210 100644
---- a/docs/adr/ADR-016-active-client-updates.md
++++ b/docs/adr/ADR-016-active-client-updates.md
@@@ -42,17 +42,18 @@ The manifest is advisory; removing it returns clients to today's behaviour.
Roadmap WS-P task 9.
--- **Event.** When a network service's release goes live, OpenVibe.Host publishes `host.deploy.activated`
++- **Event.** When a network service's release goes live, OpenVibe.Host publishes `host.release.published`
with subject `{ type: release, id: <service>:<release> }` and visibility public. The payload contract
-- is `host.deploy.activated@1`: `service`, `release`, `commit`, `origin`, `deployed_at`, and optionally
++ is `host.release.published@1`: `service`, `release`, `commit`, `origin`, `deployed_at`, and optionally
`components` and `rollback`. It carries identifiers only; what changed stays in `/release.json`.
- **Sent by:** `ovhost deploy|rollback` after a release went live, and `ovhost announce <service>` for
services deployed by their own scripts.
- **Frequency:** one event per service and release.
- **Delivery:** best effort, and never fails a deploy. Polling is the fallback.
-- - **Stage B:** tenant activations share the type, with subject `deploy` and visibility internal.
++ - **Stage B:** tenant activations stay `host.deploy.activated` (subject `deploy`, visibility internal),
++ a separate event type with its own payload. The two are never mixed.
- **Client.** openvibe-shared `release-watch.js` (1.17.0) opens one anonymous EventSource per tab on
-- `topics=host.deploy.activated`.
++ `topics=host.release.published`.
- It acts only on events whose `payload.service` is the page's service.
- It ignores the release it already runs or already knows (a hex prefix counts as the same release),
and ignores repeats of an event id.
ddiff --git a/fixtures/host.deploy.activated/invalid/a-service-release.json b/fixtures/host.deploy.activated/invalid/a-service-release.json
nnew file mode 100644
iindex 00000000..0ead49de
---- /dev/null
++++ b/fixtures/host.deploy.activated/invalid/a-service-release.json
@@@ -0,0 +1,7 @@
++{
++ "service": "live",
++ "release": "d4e849f2",
++ "commit": null,
++ "origin": "https://openvibe.live",
++ "deployed_at": "2026-09-26T08:00:05.000Z"
++}
ddiff --git a/fixtures/host.deploy.activated/invalid/component-bad-kind.json b/fixtures/host.deploy.activated/invalid/component-bad-kind.json
ddeleted file mode 100644
iindex 8f7a2498..00000000
---- a/fixtures/host.deploy.activated/invalid/component-bad-kind.json
++++ /dev/null
@@@ -1,8 +0,0 @@
--{
-- "service": "live",
-- "release": "d4e849f2",
-- "commit": null,
-- "origin": null,
-- "deployed_at": "2026-09-26T08:00:05.000Z",
-- "components": { "shell": { "kind": "binary", "version": "1" } }
--}
ddiff --git a/fixtures/host.deploy.activated/invalid/mixed-shapes.json b/fixtures/host.deploy.activated/invalid/mixed-shapes.json
ddeleted file mode 100644
iindex 28253eae..00000000
---- a/fixtures/host.deploy.activated/invalid/mixed-shapes.json
++++ /dev/null
@@@ -1,8 +0,0 @@
--{
-- "service": "live",
-- "release": "d4e849f2",
-- "commit": null,
-- "origin": "https://openvibe.live",
-- "deployed_at": "2026-09-26T08:00:05.000Z",
-- "site_id": "site_01JAB2C3D4E5F6G7H8J9K0MNPR"
--}
ddiff --git a/fixtures/host.deploy.activated/invalid/origin-with-path.json b/fixtures/host.deploy.activated/invalid/origin-with-path.json
ddeleted file mode 100644
iindex e625d69f..00000000
---- a/fixtures/host.deploy.activated/invalid/origin-with-path.json
++++ /dev/null
@@@ -1,7 +0,0 @@
--{
-- "service": "live",
-- "release": "d4e849f2",
-- "commit": null,
-- "origin": "https://openvibe.live/updates",
-- "deployed_at": "2026-09-26T08:00:05.000Z"
--}
ddiff --git a/fixtures/host.deploy.activated/invalid/release-not-hex.json b/fixtures/host.deploy.activated/invalid/release-not-hex.json
ddeleted file mode 100644
iindex 60fff612..00000000
---- a/fixtures/host.deploy.activated/invalid/release-not-hex.json
++++ /dev/null
@@@ -1,7 +0,0 @@
--{
-- "service": "live",
-- "release": "v1.2.3",
-- "commit": null,
-- "origin": "https://openvibe.live",
-- "deployed_at": "2026-09-26T08:00:05.000Z"
--}
ddiff --git a/fixtures/host.deploy.activated/invalid/release-rollback-false.json b/fixtures/host.deploy.activated/invalid/release-rollback-false.json
ddeleted file mode 100644
iindex 4a787a7e..00000000
---- a/fixtures/host.deploy.activated/invalid/release-rollback-false.json
++++ /dev/null
@@@ -1,8 +0,0 @@
--{
-- "service": "live",
-- "release": "d4e849f2",
-- "commit": null,
-- "origin": null,
-- "deployed_at": "2026-09-26T08:00:05.000Z",
-- "rollback": false
--}
ddiff --git a/fixtures/host.deploy.activated/valid/live-release.json b/fixtures/host.deploy.activated/valid/live-release.json
ddeleted file mode 100644
iindex 90ef01df..00000000
---- a/fixtures/host.deploy.activated/valid/live-release.json
++++ /dev/null
@@@ -1,11 +0,0 @@
--{
-- "service": "live",
-- "release": "d4e849f2",
-- "commit": "d4e849f2a1b3c5d7e9f0a2b4c6d8e0f1a3b5c7d9",
-- "origin": "https://openvibe.live",
-- "deployed_at": "2026-09-26T08:00:05.000Z",
-- "components": {
-- "shell": { "kind": "script", "version": "3f2a9c1b7d4e" },
-- "server": { "kind": "server", "version": "a1b2c3d4e5f6" }
-- }
--}
ddiff --git a/fixtures/host.deploy.activated/valid/rollback-no-origin.json b/fixtures/host.deploy.activated/valid/rollback-no-origin.json
ddeleted file mode 100644
iindex 57b2d637..00000000
---- a/fixtures/host.deploy.activated/valid/rollback-no-origin.json
++++ /dev/null
@@@ -1,8 +0,0 @@
--{
-- "service": "tools",
-- "release": "858845c0e1d2",
-- "commit": "858845c0e1d2f3a4b5c6d7e8f9a0b1c2d3e4f5a6",
-- "origin": null,
-- "deployed_at": "2026-09-26T08:02:00.000Z",
-- "rollback": true
--}
ddiff --git a/fixtures/host.deploy.activated/valid/rollback.json b/fixtures/host.deploy.activated/valid/rollback.json
nnew file mode 100644
iindex 00000000..26c1a0ac
---- /dev/null
++++ b/fixtures/host.deploy.activated/valid/rollback.json
@@@ -0,0 +1,8 @@
++{
++ "project_id": "prj_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "site_id": "site_01JAB2C3D4E5F6G7H8J9K0MNPR",
++ "site": "my-site",
++ "deploy_id": "dpl_01JAB2C3D4E5F6G7H8J9K0MNPT",
++ "previous_deploy_id": "dpl_01JAB2C3D4E5F6G7H8J9K0MNPS",
++ "rollback": true
++}
ddiff --git a/fixtures/host.deploy.activated/valid/sites-placeholder.json b/fixtures/host.deploy.activated/valid/sites-placeholder.json
ddeleted file mode 100644
iindex 5fd094e4..00000000
---- a/fixtures/host.deploy.activated/valid/sites-placeholder.json
++++ /dev/null
@@@ -1,7 +0,0 @@
--{
-- "service": "news",
-- "release": "56eb30198d36",
-- "commit": null,
-- "origin": "https://openvibe.news",
-- "deployed_at": "2026-09-26T08:01:00.000Z"
--}
ddiff --git a/fixtures/host.release.published/invalid/a-tenant-activation.json b/fixtures/host.release.published/invalid/a-tenant-activation.json
nnew file mode 100644
iindex 00000000..5a433ac7
---- /dev/null
++++ b/fixtures/host.release.published/invalid/a-tenant-activation.json
@@@ -0,0 +1,8 @@
++{
++ "project_id": "prj_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "site_id": "site_01JAB2C3D4E5F6G7H8J9K0MNPR",
++ "site": "my-site",
++ "deploy_id": "dpl_01JAB2C3D4E5F6G7H8J9K0MNPS",
++ "previous_deploy_id": null,
++ "rollback": false
++}
ddiff --git a/fixtures/host.release.published/invalid/carries-a-tenant-field.json b/fixtures/host.release.published/invalid/carries-a-tenant-field.json
nnew file mode 100644
iindex 00000000..28253eae
---- /dev/null
++++ b/fixtures/host.release.published/invalid/carries-a-tenant-field.json
@@@ -0,0 +1,8 @@
++{
++ "service": "live",
++ "release": "d4e849f2",
++ "commit": null,
++ "origin": "https://openvibe.live",
++ "deployed_at": "2026-09-26T08:00:05.000Z",
++ "site_id": "site_01JAB2C3D4E5F6G7H8J9K0MNPR"
++}
ddiff --git a/fixtures/host.release.published/invalid/component-bad-kind.json b/fixtures/host.release.published/invalid/component-bad-kind.json
nnew file mode 100644
iindex 00000000..8f7a2498
---- /dev/null
++++ b/fixtures/host.release.published/invalid/component-bad-kind.json
@@@ -0,0 +1,8 @@
++{
++ "service": "live",
++ "release": "d4e849f2",
++ "commit": null,
++ "origin": null,
++ "deployed_at": "2026-09-26T08:00:05.000Z",
++ "components": { "shell": { "kind": "binary", "version": "1" } }
++}
ddiff --git a/fixtures/host.release.published/invalid/origin-with-path.json b/fixtures/host.release.published/invalid/origin-with-path.json
nnew file mode 100644
iindex 00000000..e625d69f
---- /dev/null
++++ b/fixtures/host.release.published/invalid/origin-with-path.json
@@@ -0,0 +1,7 @@
++{
++ "service": "live",
++ "release": "d4e849f2",
++ "commit": null,
++ "origin": "https://openvibe.live/updates",
++ "deployed_at": "2026-09-26T08:00:05.000Z"
++}
ddiff --git a/fixtures/host.release.published/invalid/release-not-hex.json b/fixtures/host.release.published/invalid/release-not-hex.json
nnew file mode 100644
iindex 00000000..60fff612
---- /dev/null
++++ b/fixtures/host.release.published/invalid/release-not-hex.json
@@@ -0,0 +1,7 @@
++{
++ "service": "live",
++ "release": "v1.2.3",
++ "commit": null,
++ "origin": "https://openvibe.live",
++ "deployed_at": "2026-09-26T08:00:05.000Z"
++}
ddiff --git a/fixtures/host.release.published/invalid/release-rollback-false.json b/fixtures/host.release.published/invalid/release-rollback-false.json
nnew file mode 100644
iindex 00000000..4a787a7e
---- /dev/null
++++ b/fixtures/host.release.published/invalid/release-rollback-false.json
@@@ -0,0 +1,8 @@
++{
++ "service": "live",
++ "release": "d4e849f2",
++ "commit": null,
++ "origin": null,
++ "deployed_at": "2026-09-26T08:00:05.000Z",
++ "rollback": false
++}
ddiff --git a/fixtures/host.release.published/valid/live-release.json b/fixtures/host.release.published/valid/live-release.json
nnew file mode 100644
iindex 00000000..90ef01df
---- /dev/null
++++ b/fixtures/host.release.published/valid/live-release.json
@@@ -0,0 +1,11 @@
++{
++ "service": "live",
++ "release": "d4e849f2",
++ "commit": "d4e849f2a1b3c5d7e9f0a2b4c6d8e0f1a3b5c7d9",
++ "origin": "https://openvibe.live",
++ "deployed_at": "2026-09-26T08:00:05.000Z",
++ "components": {
++ "shell": { "kind": "script", "version": "3f2a9c1b7d4e" },
++ "server": { "kind": "server", "version": "a1b2c3d4e5f6" }
++ }
++}
ddiff --git a/fixtures/host.release.published/valid/rollback-no-origin.json b/fixtures/host.release.published/valid/rollback-no-origin.json
nnew file mode 100644
iindex 00000000..57b2d637
---- /dev/null
++++ b/fixtures/host.release.published/valid/rollback-no-origin.json
@@@ -0,0 +1,8 @@
++{
++ "service": "tools",
++ "release": "858845c0e1d2",
++ "commit": "858845c0e1d2f3a4b5c6d7e8f9a0b1c2d3e4f5a6",
++ "origin": null,
++ "deployed_at": "2026-09-26T08:02:00.000Z",
++ "rollback": true
++}
ddiff --git a/fixtures/host.release.published/valid/sites-placeholder.json b/fixtures/host.release.published/valid/sites-placeholder.json
nnew file mode 100644
iindex 00000000..5fd094e4
---- /dev/null
++++ b/fixtures/host.release.published/valid/sites-placeholder.json
@@@ -0,0 +1,7 @@
++{
++ "service": "news",
++ "release": "56eb30198d36",
++ "commit": null,
++ "origin": "https://openvibe.news",
++ "deployed_at": "2026-09-26T08:01:00.000Z"
++}
ddiff --git a/generated/json-schema/bundle.json b/generated/json-schema/bundle.json
iindex d5f2ca13..61dfcf7b 100644
---- a/generated/json-schema/bundle.json
++++ b/generated/json-schema/bundle.json
@@@ -51553,134 +51553,136 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://openvibe.network/contracts/events/payloads/host.deploy.activated.v1.json",
"title": "HostDeployActivatedPayload",
-- "description": "host.deploy.activated v1. A deploy became the one that serves. Two producers inside OpenVibe.Host, told apart by the envelope subject and by the payload's fields (exactly one shape matches). (1) A network service's release went live (roadmap WS-P task 9, ADR-016): the operator plane, lib/announce.js, after `ovhost deploy|rollback <service>` went live and for `ovhost announce <service>` (services deployed by their own scripts: Live, Tools, Sites, OpenRe, Games). Envelope: subject { type: release, id: <service>:<release> }, visibility public (signed-out browsers get it over Events realtime), priority low, actor service:host. Open tabs whose /release.json names `service` check it when `release` is not the one they run; the event carries identifiers only, never what changed. At most one per service and release unless an operator forces it; a retry reuses the event_id. (2) A Stage B tenant site's active deploy changed: server/domain/deploys.js, in the transaction that moves the pointer. Envelope: subject { type: deploy, id: <deploy id> }, visibility internal (never streamed to browsers), actor the person or app that switched it.",
++ "description": "host.deploy.activated v1 (OpenVibe.Host server/domain/deploys.js switchPointer, Stage B tenant hosting). A tenant site's active deploy changed, written into Host's event outbox in the transaction that moves the pointer (activate or rollback). Envelope: subject { type: deploy, id: <deploy id> }, visibility internal, priority low, actor the person or app that switched it. A network service's release going live is host.release.published, not this.",
"type": "object",
-- "oneOf": [
-- {
-- "description": "A network service's release went live (subject release, visibility public).",
-- "type": "object",
-- "required": [
-- "service",
-- "release",
-- "commit",
-- "origin",
-- "deployed_at"
++ "required": [
++ "project_id",
++ "site_id",
++ "site",
++ "deploy_id",
++ "previous_deploy_id",
++ "rollback"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "project_id": {
++ "type": "string",
++ "pattern": "^prj_[0-9A-HJKMNP-TV-Z]{26}$"
++ },
++ "site_id": {
++ "type": "string",
++ "pattern": "^site_[0-9A-HJKMNP-TV-Z]{26}$"
++ },
++ "site": {
++ "type": "string",
++ "pattern": "^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){2,39}$",
++ "description": "The site's name (<site>.openvibe.host)."
++ },
++ "deploy_id": {
++ "type": "string",
++ "pattern": "^dpl_[0-9A-HJKMNP-TV-Z]{26}$"
++ },
++ "previous_deploy_id": {
++ "type": [
++ "string",
++ "null"
],
-- "additionalProperties": false,
-- "properties": {
-- "service": {
-- "type": "string",
-- "pattern": "^[a-z][a-z0-9-]{1,39}$",
-- "description": "The service id the pages' /release.json names (registry.release-manifest@1 `service`): live, tools, or a Sites placeholder's id such as news."
-- },
-- "release": {
-- "type": "string",
-- "pattern": "^[0-9a-f]{7,40}$",
-- "description": "The release id that service's /release.json reports now (registry.release-manifest@1 `release`). A client compares it with the release it runs, treating a hex prefix of the other as the same release."
-- },
-- "commit": {
-- "type": [
-- "string",
-- "null"
-- ],
-- "pattern": "^[0-9a-f]{7,40}$",
-- "description": "The deployed commit when it is known and `release` is its prefix, or what the operator passed; null otherwise (a Sites placeholder's release is a page hash)."
-- },
-- "origin": {
-- "type": [
-- "string",
-- "null"
-- ],
-- "format": "uri",
-- "pattern": "^https?://[^/?#]+$",
-- "description": "The public origin the release serves (https://openvibe.live): the inventory's origin or the service manifest's publicOrigin; null when neither is known."
-- },
-- "deployed_at": {
-- "type": "string",
-- "format": "date-time",
-- "description": "When Host announced it (UTC); after `ovhost deploy`, just after the service answered ready."
-- },
-- "components": {
-- "type": "object",
-- "description": "Optional. The component versions /release.json reported (registry.release-manifest 1.1.0 `components`, kind and version only).",
-- "maxProperties": 32,
-- "propertyNames": {
-- "pattern": "^[a-z][a-z0-9-]{0,39}$"
++ "pattern": "^dpl_[0-9A-HJKMNP-TV-Z]{26}$",
++ "description": "The deploy that served before; null for the site's first activation."
++ },
++ "rollback": {
++ "type": "boolean",
++ "description": "true when the switch was a rollback."
++ }
++ }
++ }
++ },
++ "host.release.published": {
++ "version": "1.0.0",
++ "owner": "host",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/host.release.published.v1.json",
++ "title": "HostReleasePublishedPayload",
++ "description": "host.release.published v1 (roadmap WS-P task 9, ADR-016 amendment 1). A network service's release went live. OpenVibe.Host's operator plane (lib/announce.js) publishes it after `ovhost deploy|rollback <service>` went live and for `ovhost announce <service>` (services deployed by their own scripts: Live, Tools, Sites, OpenRe, Games). Envelope: subject { type: release, id: <service>:<release> }, visibility public (signed-out browsers get it over Events realtime), priority low, actor service:host. Open tabs whose /release.json names `service` (openvibe-shared release-watch 1.17.0, topics=host.release.published) check it when `release` is not the one they run. It carries identifiers only, never what changed. At most one per service and release unless an operator forces it; a retry reuses the event_id. Tenant sites' activations are host.deploy.activated, not this.",
++ "type": "object",
++ "required": [
++ "service",
++ "release",
++ "commit",
++ "origin",
++ "deployed_at"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "service": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9-]{1,39}$",
++ "description": "The service id the pages' /release.json names (registry.release-manifest@1 `service`): live, tools, or a Sites placeholder's id such as news."
++ },
++ "release": {
++ "type": "string",
++ "pattern": "^[0-9a-f]{7,40}$",
++ "description": "The release id that service's /release.json reports now (registry.release-manifest@1 `release`). A client compares it with the release it runs, treating a hex prefix of the other as the same release."
++ },
++ "commit": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "pattern": "^[0-9a-f]{7,40}$",
++ "description": "The deployed commit when it is known and `release` is its prefix, or what the operator passed; null otherwise (a Sites placeholder's release is a page hash)."
++ },
++ "origin": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "format": "uri",
++ "pattern": "^https?://[^/?#]+$",
++ "description": "The public origin the release serves (https://openvibe.live): the inventory's origin or the service manifest's publicOrigin; null when neither is known."
++ },
++ "deployed_at": {
++ "type": "string",
++ "format": "date-time",
++ "description": "When Host announced it (UTC); after `ovhost deploy`, just after the service answered ready."
++ },
++ "components": {
++ "type": "object",
++ "description": "Optional. The component versions /release.json reported (registry.release-manifest 1.1.0 `components`, kind and version only).",
++ "maxProperties": 32,
++ "propertyNames": {
++ "pattern": "^[a-z][a-z0-9-]{0,39}$"
++ },
++ "additionalProperties": {
++ "type": "object",
++ "required": [
++ "kind",
++ "version"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "kind": {
++ "enum": [
++ "style",
++ "content",
++ "script",
++ "server"
++ ]
},
-- "additionalProperties": {
-- "type": "object",
-- "required": [
-- "kind",
-- "version"
-- ],
-- "additionalProperties": false,
-- "properties": {
-- "kind": {
-- "enum": [
-- "style",
-- "content",
-- "script",
-- "server"
-- ]
-- },
-- "version": {
-- "type": "string",
-- "pattern": "^[0-9A-Za-z._+-]{1,64}$"
-- }
-- }
++ "version": {
++ "type": "string",
++ "pattern": "^[0-9A-Za-z._+-]{1,64}$"
}
-- },
-- "rollback": {
-- "const": true,
-- "description": "Present after `ovhost rollback`: the release is an older one."
}
}
},
-- {
-- "description": "A Stage B tenant site's active deploy changed (subject deploy, visibility internal).",
-- "type": "object",
-- "required": [
-- "project_id",
-- "site_id",
-- "site",
-- "deploy_id",
-- "previous_deploy_id",
-- "rollback"
-- ],
-- "additionalProperties": false,
-- "properties": {
-- "project_id": {
-- "type": "string",
-- "pattern": "^prj_[0-9A-HJKMNP-TV-Z]{26}$"
-- },
-- "site_id": {
-- "type": "string",
-- "pattern": "^site_[0-9A-HJKMNP-TV-Z]{26}$"
-- },
-- "site": {
-- "type": "string",
-- "pattern": "^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){2,39}$",
-- "description": "The site's name (<site>.openvibe.host)."
-- },
-- "deploy_id": {
-- "type": "string",
-- "pattern": "^dpl_[0-9A-HJKMNP-TV-Z]{26}$"
-- },
-- "previous_deploy_id": {
-- "type": [
-- "string",
-- "null"
-- ],
-- "pattern": "^dpl_[0-9A-HJKMNP-TV-Z]{26}$",
-- "description": "The deploy that served before; null for the site's first activation."
-- },
-- "rollback": {
-- "type": "boolean",
-- "description": "true when the switch was a rollback."
-- }
-- }
++ "rollback": {
++ "const": true,
++ "description": "Present after `ovhost rollback`: the release is an older one."
}
-- ]
++ }
}
},
"host.site-manage-result": {
ddiff --git a/generated/typescript/index.d.ts b/generated/typescript/index.d.ts
iindex 1eb5f801..b7cd0d63 100644
---- a/generated/typescript/index.d.ts
++++ b/generated/typescript/index.d.ts
@@@ -41889,63 +41889,67 @@ export type HostSiteManageRequest =
/** [email protected] (owner: host) */
/**
-- * host.deploy.activated v1. A deploy became the one that serves. Two producers inside OpenVibe.Host, told apart by the envelope subject and by the payload's fields (exactly one shape matches). (1) A network service's release went live (roadmap WS-P task 9, ADR-016): the operator plane, lib/announce.js, after `ovhost deploy|rollback <service>` went live and for `ovhost announce <service>` (services deployed by their own scripts: Live, Tools, Sites, OpenRe, Games). Envelope: subject { type: release, id: <service>:<release> }, visibility public (signed-out browsers get it over Events realtime), priority low, actor service:host. Open tabs whose /release.json names `service` check it when `release` is not the one they run; the event carries identifiers only, never what changed. At most one per service and release unless an operator forces it; a retry reuses the event_id. (2) A Stage B tenant site's active deploy changed: server/domain/deploys.js, in the transaction that moves the pointer. Envelope: subject { type: deploy, id: <deploy id> }, visibility internal (never streamed to browsers), actor the person or app that switched it.
++ * host.deploy.activated v1 (OpenVibe.Host server/domain/deploys.js switchPointer, Stage B tenant hosting). A tenant site's active deploy changed, written into Host's event outbox in the transaction that moves the pointer (activate or rollback). Envelope: subject { type: deploy, id: <deploy id> }, visibility internal, priority low, actor the person or app that switched it. A network service's release going live is host.release.published, not this.
*/
--export type HostDeployActivatedPayload =
-- | {
-- /**
-- * The service id the pages' /release.json names (registry.release-manifest@1 `service`): live, tools, or a Sites placeholder's id such as news.
-- */
-- service: string;
-- /**
-- * The release id that service's /release.json reports now (registry.release-manifest@1 `release`). A client compares it with the release it runs, treating a hex prefix of the other as the same release.
-- */
-- release: string;
-- /**
-- * The deployed commit when it is known and `release` is its prefix, or what the operator passed; null otherwise (a Sites placeholder's release is a page hash).
-- */
-- commit: string | null;
-- /**
-- * The public origin the release serves (https://openvibe.live): the inventory's origin or the service manifest's publicOrigin; null when neither is known.
-- */
-- origin: string | null;
-- /**
-- * When Host announced it (UTC); after `ovhost deploy`, just after the service answered ready.
-- */
-- deployed_at: string;
-- /**
-- * Optional. The component versions /release.json reported (registry.release-manifest 1.1.0 `components`, kind and version only).
-- */
-- components?: {
-- [k: string]:
-- | {
-- kind: "style" | "content" | "script" | "server";
-- version: string;
-- }
-- | undefined;
-- };
-- /**
-- * Present after `ovhost rollback`: the release is an older one.
-- */
-- rollback?: true;
-- }
-- | {
-- project_id: string;
-- site_id: string;
-- /**
-- * The site's name (<site>.openvibe.host).
-- */
-- site: string;
-- deploy_id: string;
-- /**
-- * The deploy that served before; null for the site's first activation.
-- */
-- previous_deploy_id: string | null;
-- /**
-- * true when the switch was a rollback.
-- */
-- rollback: boolean;
-- };
++export interface HostDeployActivatedPayload {
++ project_id: string;
++ site_id: string;
++ /**
++ * The site's name (<site>.openvibe.host).
++ */
++ site: string;
++ deploy_id: string;
++ /**
++ * The deploy that served before; null for the site's first activation.
++ */
++ previous_deploy_id: string | null;
++ /**
++ * true when the switch was a rollback.
++ */
++ rollback: boolean;
++}
++
++/** [email protected] (owner: host) */
++/**
++ * host.release.published v1 (roadmap WS-P task 9, ADR-016 amendment 1). A network service's release went live. OpenVibe.Host's operator plane (lib/announce.js) publishes it after `ovhost deploy|rollback <service>` went live and for `ovhost announce <service>` (services deployed by their own scripts: Live, Tools, Sites, OpenRe, Games). Envelope: subject { type: release, id: <service>:<release> }, visibility public (signed-out browsers get it over Events realtime), priority low, actor service:host. Open tabs whose /release.json names `service` (openvibe-shared release-watch 1.17.0, topics=host.release.published) check it when `release` is not the one they run. It carries identifiers only, never what changed. At most one per service and release unless an operator forces it; a retry reuses the event_id. Tenant sites' activations are host.deploy.activated, not this.
++ */
++export interface HostReleasePublishedPayload {
++ /**
++ * The service id the pages' /release.json names (registry.release-manifest@1 `service`): live, tools, or a Sites placeholder's id such as news.
++ */
++ service: string;
++ /**
++ * The release id that service's /release.json reports now (registry.release-manifest@1 `release`). A client compares it with the release it runs, treating a hex prefix of the other as the same release.
++ */
++ release: string;
++ /**
++ * The deployed commit when it is known and `release` is its prefix, or what the operator passed; null otherwise (a Sites placeholder's release is a page hash).
++ */
++ commit: string | null;
++ /**
++ * The public origin the release serves (https://openvibe.live): the inventory's origin or the service manifest's publicOrigin; null when neither is known.
++ */
++ origin: string | null;
++ /**
++ * When Host announced it (UTC); after `ovhost deploy`, just after the service answered ready.
++ */
++ deployed_at: string;
++ /**
++ * Optional. The component versions /release.json reported (registry.release-manifest 1.1.0 `components`, kind and version only).
++ */
++ components?: {
++ [k: string]:
++ | {
++ kind: "style" | "content" | "script" | "server";
++ version: string;
++ }
++ | undefined;
++ };
++ /**
++ * Present after `ovhost rollback`: the release is an older one.
++ */
++ rollback?: true;
++}
/** [email protected] (owner: host) */
/**
ddiff --git a/manifests/services/host.json b/manifests/services/host.json
iindex f71e9568..11aae91a 100644
---- a/manifests/services/host.json
++++ b/manifests/services/host.json
@@@ -36,6 +36,7 @@
"host.deploy.created",
"host.deploy.activated",
"host.deploy.failed",
++ "host.release.published",
"host.domain.verified"
],
"eventsConsumed": [],
@@@ -94,5 +95,5 @@
]
}
},
-- "notes": "Stage A: the ovhost operator CLI (no server). Stage B: the Host API service on port 4910 (unit openvibe-host.service, state /var/lib/openvibe-host-api): tenant projects keyed by project id (prj_, network_project_id once Network has projects, ADR-014), static sites served by Host header at <site>.openvibe.host (a registrable domain separate from every other OpenVibe domain) and on TXT-verified custom domains, immutable content-addressed deploys with rollback, quotas enforced here, upload logs. Consumes OpenVibe.Network (SSO, JWKS, client-credentials) and publishes through OpenVibe.Events (events.event.publish). The ovhost CLI publishes host.deploy.activated too, with subject release and visibility public, when a network service's release goes live (ovhost deploy/rollback/announce; release notifications, WS-P task 9); Stage B's tenant activations are subject deploy, visibility internal. Stage C (sandboxed user code) is not started."
++ "notes": "Stage A: the ovhost operator CLI (no server). Stage B: the Host API service on port 4910 (unit openvibe-host.service, state /var/lib/openvibe-host-api): tenant projects keyed by project id (prj_, network_project_id once Network has projects, ADR-014), static sites served by Host header at <site>.openvibe.host (a registrable domain separate from every other OpenVibe domain) and on TXT-verified custom domains, immutable content-addressed deploys with rollback, quotas enforced here, upload logs. Consumes OpenVibe.Network (SSO, JWKS, client-credentials) and publishes through OpenVibe.Events (events.event.publish). The ovhost CLI publishes host.release.published (subject release, visibility public) when a network service's release goes live (ovhost deploy/rollback/announce; release notifications, WS-P task 9); host.deploy.activated is Stage B's tenant activations only. Stage C (sandboxed user code) is not started."
}
ddiff --git a/test/run.js b/test/run.js
iindex dadc111d..8bbdecb7 100644
---- a/test/run.js
++++ b/test/run.js
@@@ -722,22 +722,25 @@ await assert.rejects(failing.getToken(), /401: invalid_client/);
ok(!M({ ...base, text: 'the post' }) && !M({ ...base, target: { ...base.target, title: 'Hi' } }), 'it never carries the content');
}
--// ── Release notifications (WS-P task 9, ADR-016): host.deploy.activated, two shapes, one public ──
--// A network service's release (subject release, visibility public: browsers get it over Events
--// realtime) and a tenant site's activation (subject deploy, visibility internal). Exactly one shape
--// matches; the release shape carries identifiers only.
++// ── Release notifications (WS-P task 9, ADR-016): host.release.published, public, identifiers only ──
++// A network service's release went live (subject release, visibility public: browsers get it over
++// Events realtime). Tenant hosting's activations stay host.deploy.activated (first-party, its own shape);
++// neither payload passes for the other.
{
-- const H = (p) => contracts.validate('host.deploy.activated@1', p).valid;
-- const cat = contracts.catalog.find(c => c.id === 'host.deploy.activated');
-- ok(cat && cat.owner === 'host' && cat.visibility === 'public' && services.get('host').eventsProduced.includes('host.deploy.activated'), 'host.deploy.activated is a public Host event');
++ const R = (p) => contracts.validate('host.release.published@1', p).valid;
++ const D = (p) => contracts.validate('host.deploy.activated@1', p).valid;
++ const cat = (id) => contracts.catalog.find(c => c.id === id);
++ ok(cat('host.release.published') && cat('host.release.published').owner === 'host' && cat('host.release.published').visibility === 'public', 'host.release.published is a public Host event');
++ ok(cat('host.deploy.activated') && cat('host.deploy.activated').visibility === 'first-party', 'host.deploy.activated stays first-party (tenant hosting)');
++ for (const id of ['host.release.published', 'host.deploy.activated']) ok(services.get('host').eventsProduced.includes(id), `the host manifest produces ${id}`);
const rel = { service: 'live', release: 'd4e849f2', commit: null, origin: 'https://openvibe.live', deployed_at: '2026-09-26T08:00:05.000Z' };
const ten = JSON.parse(fs.readFileSync(path.join(ROOT, 'fixtures/host.deploy.activated/valid/tenant-site.json'), 'utf8'));
-- ok(H(rel) && H(ten), 'a service release and a tenant activation are both host.deploy.activated@1');
-- ok(!H({ ...rel, ...ten }), 'the two shapes never mix');
-- for (const k of ['service', 'release', 'commit', 'origin', 'deployed_at']) ok(!H({ ...rel, [k]: undefined }), `a service release needs ${k} (commit and origin may be null)`);
-- for (const extra of [{ commits: [] }, { notes: 'x' }, { url: 'https://openvibe.live/updates' }]) ok(!H({ ...rel, ...extra }), `a service release carries identifiers only (${Object.keys(extra)[0]})`);
-- ok(H({ ...rel, release: 'd4e849f2a1b3c5d7e9f0a2b4c6d8e0f1a3b5c7d9' }) && !H({ ...rel, release: 'd4e84' }), 'a release is 7 to 40 hex, as registry.release-manifest@1 says');
-- ok(contracts.schema('registry.release-manifest').properties.release.pattern === contracts.schema('host.deploy.activated').oneOf[0].properties.release.pattern, 'the release pattern is the release manifest\'s');
++ ok(R(rel) && D(ten) && !R(ten) && !D(rel) && !R({ ...rel, ...ten }), 'a release and a tenant activation are two contracts that never mix');
++ ok(!contracts.schema('host.deploy.activated').oneOf && !contracts.schema('host.release.published').oneOf, 'one shape each');
++ for (const k of ['service', 'release', 'commit', 'origin', 'deployed_at']) ok(!R({ ...rel, [k]: undefined }), `a release needs ${k} (commit and origin may be null)`);
++ for (const extra of [{ commits: [] }, { notes: 'x' }, { url: 'https://openvibe.live/updates' }]) ok(!R({ ...rel, ...extra }), `a release carries identifiers only (${Object.keys(extra)[0]})`);
++ ok(R({ ...rel, release: 'd4e849f2a1b3c5d7e9f0a2b4c6d8e0f1a3b5c7d9' }) && !R({ ...rel, release: 'd4e84' }), 'a release is 7 to 40 hex, as registry.release-manifest@1 says');
++ ok(contracts.schema('registry.release-manifest').properties.release.pattern === contracts.schema('host.release.published').properties.release.pattern, 'the release pattern is the release manifest\'s');
}
// ── Capability schemas (WS-C task 4): a ratchet over compatibility/capability-schema-gaps.json ──