openvibers/openvibe.contracts Commits
Publish Media placement events in the media namespace (#17)
Changes
@@@ -30,6 +30,24 @@ lifecycle from.- Presence policy: `bot.robot.online` and `bot.robot.offline` are robot connectivity (ADR-043), not a person's presence,and are allowed. Any other `.online`, `.offline` or presence event type is still refused.++**Media placement events** (follow-up of OpenVibe.Media PR #18, "Record placement decisions for replicas and provider++transitions"; ADR-026). Media records every replica move and provider transition in its placement outbox, but the two++provider events used types starting with `provider.`, which OpenVibe.Events can never accept from source `media`: its++prefix gate (`server/config.js` `sourcePrefixes`, `server/api/publish.js`) requires an `event_type` to start with the++source key, so both were permanently rejected (`403 events.type_not_allowed`) and piled up in `event_outbox`. The types++now live in the media namespace (`media.provider.health_degraded`, `media.provider.capacity_warning`), and all six event++types Media emits today gain active payload contracts owned by `media`, listed in its manifest:++- `media.replica.requested`, `media.replica.ready`, `media.replica.draining`, `media.replica.evicted` (Media's++ `server/objects/tiering.js`: the promote/demote decisions, recorded inside the transaction that starts the copy, upserts++ the verified location, starts the delete and removes it).++- `media.provider.health_degraded` and `media.provider.capacity_warning` (Media's `server/placement/providers.js`: the++ healthy→unhealthy flip and the class loss after a completed probe), each emitted once per transition, not per tick.++++Each payload is deliberately narrow — the object id and tenant, placement class, action, providers, key/size for a++replica, the class set before and after a loss — never the object's bytes, title or metadata. `media.replica.*` was++already inside the streamable namespace; only the provider types changed. Valid and invalid fixtures for each, and++`test/run.js` asserts every type Media produces starts with `media.`, is owned by `media` and is listed in its manifest.++## 0.87.0 — 2026-10-03**User-owned trust class and the estate table** (plan T1 step 3; ADR-034 §5 control plane/data plane; the Fabric ADR,@@@ -4366,6 +4366,60 @@"compatibility": "backward","status": "active"},++ {++ "id": "media.replica.requested",++ "version": "1.0.0",++ "owner": "media",++ "schema": "events/payloads/media.replica.requested.v1.json",++ "visibility": "first-party",++ "compatibility": "backward",++ "status": "active"++ },++ {++ "id": "media.replica.ready",++ "version": "1.0.0",++ "owner": "media",++ "schema": "events/payloads/media.replica.ready.v1.json",++ "visibility": "first-party",++ "compatibility": "backward",++ "status": "active"++ },++ {++ "id": "media.replica.draining",++ "version": "1.0.0",++ "owner": "media",++ "schema": "events/payloads/media.replica.draining.v1.json",++ "visibility": "first-party",++ "compatibility": "backward",++ "status": "active"++ },++ {++ "id": "media.replica.evicted",++ "version": "1.0.0",++ "owner": "media",++ "schema": "events/payloads/media.replica.evicted.v1.json",++ "visibility": "first-party",++ "compatibility": "backward",++ "status": "active"++ },++ {++ "id": "media.provider.health_degraded",++ "version": "1.0.0",++ "owner": "media",++ "schema": "events/payloads/media.provider.health_degraded.v1.json",++ "visibility": "first-party",++ "compatibility": "backward",++ "status": "active"++ },++ {++ "id": "media.provider.capacity_warning",++ "version": "1.0.0",++ "owner": "media",++ "schema": "events/payloads/media.provider.capacity_warning.v1.json",++ "visibility": "first-party",++ "compatibility": "backward",++ "status": "active"++ },{"id": "media.avatar-ingest-request","version": "1.0.0",nnew file mode 100644@@@ -0,0 +1,55 @@++{++ "$schema": "https://json-schema.org/draft/2020-12/schema",++ "$id": "https://openvibe.network/contracts/events/payloads/media.provider.capacity_warning.v1.json",++ "title": "MediaProviderCapacityWarningPayload",++ "description": "media.provider.capacity_warning v1 (OpenVibe.Media server/placement/providers.js setCapabilities(), the announceProviderEvent when a completed probe leaves the provider in fewer classes; PR #18). A provider that was passing lost one or more classes: the probe now fails a capability the class needs (for example R2 no longer serving a 206 range, so it stops being online-hot), and the router removes it from those classes until a later probe passes again. Emitted once per class loss, not once per probe. The event lives in the media namespace because OpenVibe.Events gates a service's event_type to a prefix that starts with its source name; the original provider.* type could never be published by source media (403 events.type_not_allowed). Envelope: subject { type: provider, id: <provider> }, visibility internal, priority low, actor service:media, source media.",++ "type": "object",++ "required": [++ "app_id",++ "provider",++ "classes",++ "previous_classes"++ ],++ "additionalProperties": false,++ "properties": {++ "app_id": {++ "type": [++ "string",++ "null"++ ],++ "description": "Always null: a provider serves every tenant, so the signal belongs to no one app."++ },++ "provider": {++ "enum": [++ "local",++ "b2",++ "r2"++ ],++ "description": "The provider that lost classes."++ },++ "classes": {++ "type": "array",++ "items": {++ "enum": [++ "online-canonical",++ "online-hot",++ "regional-cache"++ ]++ },++ "uniqueItems": true,++ "description": "The classes the provider still serves after the probe; a strict subset of previous_classes."++ },++ "previous_classes": {++ "type": "array",++ "items": {++ "enum": [++ "online-canonical",++ "online-hot",++ "regional-cache"++ ]++ },++ "uniqueItems": true,++ "description": "The classes the provider served before the probe."++ }++ }++}nnew file mode 100644@@@ -0,0 +1,41 @@++{++ "$schema": "https://json-schema.org/draft/2020-12/schema",++ "$id": "https://openvibe.network/contracts/events/payloads/media.provider.health_degraded.v1.json",++ "title": "MediaProviderHealthDegradedPayload",++ "description": "media.provider.health_degraded v1 (OpenVibe.Media server/placement/providers.js liveHealth(), the announceProviderEvent on a healthy→unhealthy flip; PR #18). A storage provider that was healthy failed its per-minute HeadBucket, so the fabric stops routing to it until it answers again. Emitted once per flip, not once per tick: another failing HEAD while it is already unhealthy records nothing, and a recovery is a media.storage.recovered style state change, not another degradation. The event lives in the media namespace because OpenVibe.Events gates a service's event_type to a prefix that starts with its source name; the original provider.* type could never be published by source media (403 events.type_not_allowed). Envelope: subject { type: provider, id: <provider> }, visibility internal, priority low, actor service:media, source media.",++ "type": "object",++ "required": [++ "app_id",++ "provider",++ "healthy",++ "last_error"++ ],++ "additionalProperties": false,++ "properties": {++ "app_id": {++ "type": [++ "string",++ "null"++ ],++ "description": "Always null: a provider serves every tenant, so the signal belongs to no one app."++ },++ "provider": {++ "enum": [++ "local",++ "b2",++ "r2"++ ],++ "description": "The provider that failed its health check."++ },++ "healthy": {++ "const": false,++ "description": "This event exists only for the healthy→unhealthy flip, so it is always false."++ },++ "last_error": {++ "type": "string",++ "minLength": 1,++ "maxLength": 500,++ "description": "The error the failed health check recorded."++ }++ }++}nnew file mode 100644@@@ -0,0 +1,60 @@++{++ "$schema": "https://json-schema.org/draft/2020-12/schema",++ "$id": "https://openvibe.network/contracts/events/payloads/media.replica.draining.v1.json",++ "title": "MediaReplicaDrainingPayload",++ "description": "media.replica.draining v1 (OpenVibe.Media server/objects/tiering.js demote(), the recordPlacement before the replica is deleted; PR #18). Media decided to drain a replica: the canonical copy has been confirmed good, the replica is about to be removed, and reads should stop being routed to it. Recorded before the delete, so a draining with no following media.replica.evicted means the delete failed and the replica is still there. Envelope: subject { type: object, id: <object_id> }, visibility internal, priority low, actor service:media, source media. Sandbox tenants produce none.",++ "type": "object",++ "required": [++ "object_id",++ "app_id",++ "class",++ "action",++ "provider",++ "key"++ ],++ "additionalProperties": false,++ "properties": {++ "object_id": {++ "type": "string",++ "pattern": "^med_[0-9A-HJKMNP-TV-Z]{26}$"++ },++ "app_id": {++ "type": [++ "string",++ "null"++ ],++ "minLength": 1,++ "maxLength": 200,++ "description": "The tenant that owns the object: an app (live, tools…) or a developer project's production tenant (prj_…); null when the object has no tenant."++ },++ "class": {++ "enum": [++ "video",++ "image",++ "download",++ "game-asset",++ "attachment",++ "backup"++ ],++ "description": "The placement class the move was budgeted against (media.storage_policy classes; tiering.classOf)."++ },++ "action": {++ "const": "demote",++ "description": "Draining is always part of a demotion."++ },++ "provider": {++ "enum": [++ "local",++ "b2",++ "r2"++ ],++ "description": "The provider whose replica is being drained."++ },++ "key": {++ "type": "string",++ "minLength": 1,++ "maxLength": 1024,++ "description": "The object key of the replica being removed."++ }++ }++}nnew file mode 100644@@@ -0,0 +1,60 @@++{++ "$schema": "https://json-schema.org/draft/2020-12/schema",++ "$id": "https://openvibe.network/contracts/events/payloads/media.replica.evicted.v1.json",++ "title": "MediaReplicaEvictedPayload",++ "description": "media.replica.evicted v1 (OpenVibe.Media server/objects/tiering.js demote(), the recordPlacement in the transaction that deletes the media_locations row; PR #18). The replica is gone: the bytes were deleted from the provider and the location row removed, so nothing routes reads there any more. Paired with the media.replica.draining that preceded it; draining without evicted is a delete that failed. Envelope: subject { type: object, id: <object_id> }, visibility internal, priority low, actor service:media, source media. Sandbox tenants produce none.",++ "type": "object",++ "required": [++ "object_id",++ "app_id",++ "class",++ "action",++ "provider",++ "key"++ ],++ "additionalProperties": false,++ "properties": {++ "object_id": {++ "type": "string",++ "pattern": "^med_[0-9A-HJKMNP-TV-Z]{26}$"++ },++ "app_id": {++ "type": [++ "string",++ "null"++ ],++ "minLength": 1,++ "maxLength": 200,++ "description": "The tenant that owns the object: an app (live, tools…) or a developer project's production tenant (prj_…); null when the object has no tenant."++ },++ "class": {++ "enum": [++ "video",++ "image",++ "download",++ "game-asset",++ "attachment",++ "backup"++ ],++ "description": "The placement class the move was budgeted against (media.storage_policy classes; tiering.classOf)."++ },++ "action": {++ "const": "demote",++ "description": "Eviction is always the end of a demotion."++ },++ "provider": {++ "enum": [++ "local",++ "b2",++ "r2"++ ],++ "description": "The provider the replica was removed from."++ },++ "key": {++ "type": "string",++ "minLength": 1,++ "maxLength": 1024,++ "description": "The object key that no longer exists in the provider's bucket."++ }++ }++}nnew file mode 100644@@@ -0,0 +1,75 @@++{++ "$schema": "https://json-schema.org/draft/2020-12/schema",++ "$id": "https://openvibe.network/contracts/events/payloads/media.replica.ready.v1.json",++ "title": "MediaReplicaReadyPayload",++ "description": "media.replica.ready v1 (OpenVibe.Media server/objects/tiering.js promote(), the recordPlacement in the transaction that upserts the verified media_locations row; PR #18). A replica finished copying and verified: the bytes read back from the target provider hash to the object's recorded content_hash before this is recorded. The payload carries where the copy lives and how big it is, so consumers and the placement view can act without reading the object API. A media.replica.requested with no ready for the same object is a copy that never verified (the bytes are removed, nothing was recorded). Envelope: subject { type: object, id: <object_id> }, visibility internal, priority low, actor service:media, source media. Sandbox tenants produce none.",++ "type": "object",++ "required": [++ "object_id",++ "app_id",++ "class",++ "action",++ "provider",++ "key",++ "bytes",++ "replica_since"++ ],++ "additionalProperties": false,++ "properties": {++ "object_id": {++ "type": "string",++ "pattern": "^med_[0-9A-HJKMNP-TV-Z]{26}$"++ },++ "app_id": {++ "type": [++ "string",++ "null"++ ],++ "minLength": 1,++ "maxLength": 200,++ "description": "The tenant that owns the object: an app (live, tools…) or a developer project's production tenant (prj_…); null when the object has no tenant."++ },++ "class": {++ "enum": [++ "video",++ "image",++ "download",++ "game-asset",++ "attachment",++ "backup"++ ],++ "description": "The placement class the move was budgeted against (media.storage_policy classes; tiering.classOf)."++ },++ "action": {++ "enum": [++ "promote",++ "demote"++ ],++ "description": "promote placed a copy on the provider, demote removed one."++ },++ "provider": {++ "enum": [++ "local",++ "b2",++ "r2"++ ],++ "description": "The provider that now holds the verified replica."++ },++ "key": {++ "type": "string",++ "minLength": 1,++ "maxLength": 1024,++ "description": "The object key of the replica in the provider's bucket."++ },++ "bytes": {++ "type": "integer",++ "minimum": 0,++ "description": "The size in bytes that was copied and verified."++ },++ "replica_since": {++ "type": "string",++ "format": "date-time",++ "description": "When the verified replica was recorded, ISO 8601 UTC; a fresh row restarts the demotion residency clock."++ }++ }++}nnew file mode 100644@@@ -0,0 +1,75 @@++{++ "$schema": "https://json-schema.org/draft/2020-12/schema",++ "$id": "https://openvibe.network/contracts/events/payloads/media.replica.requested.v1.json",++ "title": "MediaReplicaRequestedPayload",++ "description": "media.replica.requested v1 (OpenVibe.Media server/objects/tiering.js promote(), the recordPlacement inside the transaction that starts the copy; PR #18). Media decided to place a replica of a ready object: the canonical copy stays where it is and the bytes are copied to the target provider (R2, the popularity cache). Recorded before the copy, so a request with no following media.replica.ready means the copy never verified and no replica exists. The payload is deliberately narrow: the object id and its tenant, the placement class, the decision and the two providers. Envelope: subject { type: object, id: <object_id> }, visibility internal, priority low, actor service:media, source media. Sandbox tenants produce none. The event type is inside media.* because OpenVibe.Events gates a service's event_type to a prefix that starts with its source name.",++ "type": "object",++ "required": [++ "object_id",++ "app_id",++ "class",++ "action",++ "from",++ "to",++ "provider"++ ],++ "additionalProperties": false,++ "properties": {++ "object_id": {++ "type": "string",++ "pattern": "^med_[0-9A-HJKMNP-TV-Z]{26}$"++ },++ "app_id": {++ "type": [++ "string",++ "null"++ ],++ "minLength": 1,++ "maxLength": 200,++ "description": "The tenant that owns the object: an app (live, tools…) or a developer project's production tenant (prj_…); null when the object has no tenant."++ },++ "class": {++ "enum": [++ "video",++ "image",++ "download",++ "game-asset",++ "attachment",++ "backup"++ ],++ "description": "The placement class the move was budgeted against (media.storage_policy classes; tiering.classOf)."++ },++ "action": {++ "enum": [++ "promote",++ "demote"++ ],++ "description": "promote copies the object to the target provider, demote removes a replica from it."++ },++ "from": {++ "enum": [++ "local",++ "b2",++ "r2",++ null++ ],++ "description": "The canonical provider the copy is read from; null when the object has no canonical copy at request time."++ },++ "to": {++ "enum": [++ "local",++ "b2",++ "r2"++ ],++ "description": "The provider the replica is placed on."++ },++ "provider": {++ "enum": [++ "local",++ "b2",++ "r2"++ ],++ "description": "The replica provider, named again so the decision is readable on its own."++ }++ }++}nnew file mode 100644@@@ -0,0 +1 @@++{ "app_id": null, "provider": "r2", "classes": [], "previous_classes": ["online-hot"], "reason": "probe lost range_206" }nnew file mode 100644@@@ -0,0 +1 @@++{ "app_id": null, "provider": "r2", "classes": [], "previous_classes": ["online-warm"] }nnew file mode 100644@@@ -0,0 +1 @@++{ "app_id": null, "provider": "r2", "classes": [], "previous_classes": ["online-hot"] }nnew file mode 100644@@@ -0,0 +1 @@++{ "app_id": null, "provider": "r2", "healthy": true, "last_error": "HeadBucket 503 Service Unavailable" }nnew file mode 100644@@@ -0,0 +1 @@++{ "app_id": null, "provider": "s3", "healthy": false, "last_error": "HeadBucket 503 Service Unavailable" }nnew file mode 100644@@@ -0,0 +1 @@++{ "app_id": null, "provider": "r2", "healthy": false, "last_error": "HeadBucket 503 Service Unavailable" }nnew file mode 100644@@@ -0,0 +1 @@++{ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ", "app_id": "live", "class": "video", "action": "demote", "provider": "r2", "key": "vods/med_01JAB2C3D4E5F6G7H8J9K0MNPQ.mp4", "bytes": 1048576 }nnew file mode 100644@@@ -0,0 +1 @@++{ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ", "app_id": "live", "class": "video", "action": "promote", "provider": "r2", "key": "vods/med_01JAB2C3D4E5F6G7H8J9K0MNPQ.mp4" }nnew file mode 100644@@@ -0,0 +1 @@++{ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ", "app_id": "live", "class": "video", "action": "demote", "provider": "r2", "key": "vods/med_01JAB2C3D4E5F6G7H8J9K0MNPQ.mp4" }nnew file mode 100644@@@ -0,0 +1 @@++{ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ", "app_id": "live", "class": "video", "action": "demote", "key": "vods/med_01JAB2C3D4E5F6G7H8J9K0MNPQ.mp4" }nnew file mode 100644@@@ -0,0 +1 @@++{ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ", "app_id": "live", "class": "video", "action": "promote", "provider": "r2", "key": "vods/med_01JAB2C3D4E5F6G7H8J9K0MNPQ.mp4" }nnew file mode 100644@@@ -0,0 +1 @@++{ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ", "app_id": "live", "class": "video", "action": "demote", "provider": "r2", "key": "vods/med_01JAB2C3D4E5F6G7H8J9K0MNPQ.mp4" }nnew file mode 100644@@@ -0,0 +1 @@++{ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ", "app_id": "live", "class": "video", "action": "promote", "provider": "r2", "key": "vods/med_01JAB2C3D4E5F6G7H8J9K0MNPQ.mp4", "bytes": -1, "replica_since": "2026-10-03T12:00:00.000Z" }nnew file mode 100644@@@ -0,0 +1 @@++{ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ", "app_id": "live", "class": "video", "action": "promote", "provider": "r2", "bytes": 1048576, "replica_since": "2026-10-03T12:00:00.000Z" }nnew file mode 100644@@@ -0,0 +1 @@++{ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ", "app_id": "live", "class": "video", "action": "promote", "provider": "r2", "key": "vods/med_01JAB2C3D4E5F6G7H8J9K0MNPQ.mp4", "bytes": 1048576, "replica_since": "2026-10-03T12:00:00.000Z" }nnew file mode 100644@@@ -0,0 +1 @@++{ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ", "app_id": "live", "class": "video", "action": "promote", "from": "local", "to": "r2", "provider": "r2", "title": "My stream" }nnew file mode 100644@@@ -0,0 +1 @@++{ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ", "app_id": "live", "class": "video", "action": "move", "from": "local", "to": "r2", "provider": "r2" }nnew file mode 100644@@@ -0,0 +1 @@++{ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ", "app_id": "prj_01JAB2C3D4E5F6G7H8J9K0MNPQ", "class": "image", "action": "promote", "from": null, "to": "r2", "provider": "r2" }nnew file mode 100644@@@ -0,0 +1 @@++{ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ", "app_id": "live", "class": "video", "action": "promote", "from": "local", "to": "r2", "provider": "r2" }@@@ -47380,6 +47380,402 @@}}},++ "media.replica.requested": {++ "version": "1.0.0",++ "owner": "media",++ "status": "active",++ "schema": {++ "$schema": "https://json-schema.org/draft/2020-12/schema",++ "$id": "https://openvibe.network/contracts/events/payloads/media.replica.requested.v1.json",++ "title": "MediaReplicaRequestedPayload",++ "description": "media.replica.requested v1 (OpenVibe.Media server/objects/tiering.js promote(), the recordPlacement inside the transaction that starts the copy; PR #18). Media decided to place a replica of a ready object: the canonical copy stays where it is and the bytes are copied to the target provider (R2, the popularity cache). Recorded before the copy, so a request with no following media.replica.ready means the copy never verified and no replica exists. The payload is deliberately narrow: the object id and its tenant, the placement class, the decision and the two providers. Envelope: subject { type: object, id: <object_id> }, visibility internal, priority low, actor service:media, source media. Sandbox tenants produce none. The event type is inside media.* because OpenVibe.Events gates a service's event_type to a prefix that starts with its source name.",++ "type": "object",++ "required": [++ "object_id",++ "app_id",++ "class",++ "action",++ "from",++ "to",++ "provider"++ ],++ "additionalProperties": false,++ "properties": {++ "object_id": {++ "type": "string",++ "pattern": "^med_[0-9A-HJKMNP-TV-Z]{26}$"++ },++ "app_id": {++ "type": [++ "string",++ "null"++ ],++ "minLength": 1,++ "maxLength": 200,++ "description": "The tenant that owns the object: an app (live, tools…) or a developer project's production tenant (prj_…); null when the object has no tenant."++ },++ "class": {++ "enum": [++ "video",++ "image",++ "download",++ "game-asset",++ "attachment",++ "backup"++ ],++ "description": "The placement class the move was budgeted against (media.storage_policy classes; tiering.classOf)."++ },++ "action": {++ "enum": [++ "promote",++ "demote"++ ],++ "description": "promote copies the object to the target provider, demote removes a replica from it."++ },++ "from": {++ "enum": [++ "local",++ "b2",++ "r2",++ null++ ],++ "description": "The canonical provider the copy is read from; null when the object has no canonical copy at request time."++ },++ "to": {++ "enum": [++ "local",++ "b2",++ "r2"++ ],++ "description": "The provider the replica is placed on."++ },++ "provider": {++ "enum": [++ "local",++ "b2",++ "r2"++ ],++ "description": "The replica provider, named again so the decision is readable on its own."++ }++ }++ }++ },++ "media.replica.ready": {++ "version": "1.0.0",++ "owner": "media",++ "status": "active",++ "schema": {++ "$schema": "https://json-schema.org/draft/2020-12/schema",++ "$id": "https://openvibe.network/contracts/events/payloads/media.replica.ready.v1.json",++ "title": "MediaReplicaReadyPayload",++ "description": "media.replica.ready v1 (OpenVibe.Media server/objects/tiering.js promote(), the recordPlacement in the transaction that upserts the verified media_locations row; PR #18). A replica finished copying and verified: the bytes read back from the target provider hash to the object's recorded content_hash before this is recorded. The payload carries where the copy lives and how big it is, so consumers and the placement view can act without reading the object API. A media.replica.requested with no ready for the same object is a copy that never verified (the bytes are removed, nothing was recorded). Envelope: subject { type: object, id: <object_id> }, visibility internal, priority low, actor service:media, source media. Sandbox tenants produce none.",++ "type": "object",++ "required": [++ "object_id",++ "app_id",++ "class",++ "action",++ "provider",++ "key",++ "bytes",++ "replica_since"++ ],++ "additionalProperties": false,++ "properties": {++ "object_id": {++ "type": "string",++ "pattern": "^med_[0-9A-HJKMNP-TV-Z]{26}$"++ },++ "app_id": {++ "type": [++ "string",++ "null"++ ],++ "minLength": 1,++ "maxLength": 200,++ "description": "The tenant that owns the object: an app (live, tools…) or a developer project's production tenant (prj_…); null when the object has no tenant."++ },++ "class": {++ "enum": [++ "video",++ "image",++ "download",++ "game-asset",++ "attachment",++ "backup"++ ],++ "description": "The placement class the move was budgeted against (media.storage_policy classes; tiering.classOf)."++ },++ "action": {++ "enum": [++ "promote",++ "demote"++ ],++ "description": "promote placed a copy on the provider, demote removed one."++ },++ "provider": {++ "enum": [++ "local",++ "b2",++ "r2"++ ],++ "description": "The provider that now holds the verified replica."++ },++ "key": {++ "type": "string",++ "minLength": 1,++ "maxLength": 1024,++ "description": "The object key of the replica in the provider's bucket."++ },++ "bytes": {++ "type": "integer",++ "minimum": 0,++ "description": "The size in bytes that was copied and verified."++ },++ "replica_since": {++ "type": "string",++ "format": "date-time",++ "description": "When the verified replica was recorded, ISO 8601 UTC; a fresh row restarts the demotion residency clock."++ }++ }++ }++ },++ "media.replica.draining": {++ "version": "1.0.0",++ "owner": "media",++ "status": "active",++ "schema": {++ "$schema": "https://json-schema.org/draft/2020-12/schema",++ "$id": "https://openvibe.network/contracts/events/payloads/media.replica.draining.v1.json",++ "title": "MediaReplicaDrainingPayload",++ "description": "media.replica.draining v1 (OpenVibe.Media server/objects/tiering.js demote(), the recordPlacement before the replica is deleted; PR #18). Media decided to drain a replica: the canonical copy has been confirmed good, the replica is about to be removed, and reads should stop being routed to it. Recorded before the delete, so a draining with no following media.replica.evicted means the delete failed and the replica is still there. Envelope: subject { type: object, id: <object_id> }, visibility internal, priority low, actor service:media, source media. Sandbox tenants produce none.",++ "type": "object",++ "required": [++ "object_id",++ "app_id",++ "class",++ "action",++ "provider",++ "key"++ ],++ "additionalProperties": false,++ "properties": {++ "object_id": {++ "type": "string",++ "pattern": "^med_[0-9A-HJKMNP-TV-Z]{26}$"++ },++ "app_id": {++ "type": [++ "string",++ "null"++ ],++ "minLength": 1,++ "maxLength": 200,++ "description": "The tenant that owns the object: an app (live, tools…) or a developer project's production tenant (prj_…); null when the object has no tenant."++ },++ "class": {++ "enum": [++ "video",++ "image",++ "download",++ "game-asset",++ "attachment",++ "backup"++ ],++ "description": "The placement class the move was budgeted against (media.storage_policy classes; tiering.classOf)."++ },++ "action": {++ "const": "demote",++ "description": "Draining is always part of a demotion."++ },++ "provider": {++ "enum": [++ "local",++ "b2",++ "r2"++ ],++ "description": "The provider whose replica is being drained."++ },++ "key": {++ "type": "string",++ "minLength": 1,++ "maxLength": 1024,++ "description": "The object key of the replica being removed."++ }++ }++ }++ },++ "media.replica.evicted": {++ "version": "1.0.0",++ "owner": "media",++ "status": "active",++ "schema": {++ "$schema": "https://json-schema.org/draft/2020-12/schema",++ "$id": "https://openvibe.network/contracts/events/payloads/media.replica.evicted.v1.json",++ "title": "MediaReplicaEvictedPayload",++ "description": "media.replica.evicted v1 (OpenVibe.Media server/objects/tiering.js demote(), the recordPlacement in the transaction that deletes the media_locations row; PR #18). The replica is gone: the bytes were deleted from the provider and the location row removed, so nothing routes reads there any more. Paired with the media.replica.draining that preceded it; draining without evicted is a delete that failed. Envelope: subject { type: object, id: <object_id> }, visibility internal, priority low, actor service:media, source media. Sandbox tenants produce none.",++ "type": "object",++ "required": [++ "object_id",++ "app_id",++ "class",++ "action",++ "provider",++ "key"++ ],++ "additionalProperties": false,++ "properties": {++ "object_id": {++ "type": "string",++ "pattern": "^med_[0-9A-HJKMNP-TV-Z]{26}$"++ },++ "app_id": {++ "type": [++ "string",++ "null"++ ],++ "minLength": 1,++ "maxLength": 200,++ "description": "The tenant that owns the object: an app (live, tools…) or a developer project's production tenant (prj_…); null when the object has no tenant."++ },++ "class": {++ "enum": [++ "video",++ "image",++ "download",++ "game-asset",++ "attachment",++ "backup"++ ],++ "description": "The placement class the move was budgeted against (media.storage_policy classes; tiering.classOf)."++ },++ "action": {++ "const": "demote",++ "description": "Eviction is always the end of a demotion."++ },++ "provider": {++ "enum": [++ "local",++ "b2",++ "r2"++ ],++ "description": "The provider the replica was removed from."++ },++ "key": {++ "type": "string",++ "minLength": 1,++ "maxLength": 1024,++ "description": "The object key that no longer exists in the provider's bucket."++ }++ }++ }++ },++ "media.provider.health_degraded": {++ "version": "1.0.0",++ "owner": "media",++ "status": "active",++ "schema": {++ "$schema": "https://json-schema.org/draft/2020-12/schema",++ "$id": "https://openvibe.network/contracts/events/payloads/media.provider.health_degraded.v1.json",++ "title": "MediaProviderHealthDegradedPayload",++ "description": "media.provider.health_degraded v1 (OpenVibe.Media server/placement/providers.js liveHealth(), the announceProviderEvent on a healthy→unhealthy flip; PR #18). A storage provider that was healthy failed its per-minute HeadBucket, so the fabric stops routing to it until it answers again. Emitted once per flip, not once per tick: another failing HEAD while it is already unhealthy records nothing, and a recovery is a media.storage.recovered style state change, not another degradation. The event lives in the media namespace because OpenVibe.Events gates a service's event_type to a prefix that starts with its source name; the original provider.* type could never be published by source media (403 events.type_not_allowed). Envelope: subject { type: provider, id: <provider> }, visibility internal, priority low, actor service:media, source media.",++ "type": "object",++ "required": [++ "app_id",++ "provider",++ "healthy",++ "last_error"++ ],++ "additionalProperties": false,++ "properties": {++ "app_id": {++ "type": [++ "string",++ "null"++ ],++ "description": "Always null: a provider serves every tenant, so the signal belongs to no one app."++ },++ "provider": {++ "enum": [++ "local",++ "b2",++ "r2"++ ],++ "description": "The provider that failed its health check."++ },++ "healthy": {++ "const": false,++ "description": "This event exists only for the healthy→unhealthy flip, so it is always false."++ },++ "last_error": {++ "type": "string",++ "minLength": 1,++ "maxLength": 500,++ "description": "The error the failed health check recorded."++ }++ }++ }++ },++ "media.provider.capacity_warning": {++ "version": "1.0.0",++ "owner": "media",++ "status": "active",++ "schema": {++ "$schema": "https://json-schema.org/draft/2020-12/schema",++ "$id": "https://openvibe.network/contracts/events/payloads/media.provider.capacity_warning.v1.json",++ "title": "MediaProviderCapacityWarningPayload",++ "description": "media.provider.capacity_warning v1 (OpenVibe.Media server/placement/providers.js setCapabilities(), the announceProviderEvent when a completed probe leaves the provider in fewer classes; PR #18). A provider that was passing lost one or more classes: the probe now fails a capability the class needs (for example R2 no longer serving a 206 range, so it stops being online-hot), and the router removes it from those classes until a later probe passes again. Emitted once per class loss, not once per probe. The event lives in the media namespace because OpenVibe.Events gates a service's event_type to a prefix that starts with its source name; the original provider.* type could never be published by source media (403 events.type_not_allowed). Envelope: subject { type: provider, id: <provider> }, visibility internal, priority low, actor service:media, source media.",++ "type": "object",++ "required": [++ "app_id",++ "provider",++ "classes",++ "previous_classes"++ ],++ "additionalProperties": false,++ "properties": {++ "app_id": {++ "type": [++ "string",++ "null"++ ],++ "description": "Always null: a provider serves every tenant, so the signal belongs to no one app."++ },++ "provider": {++ "enum": [++ "local",++ "b2",++ "r2"++ ],++ "description": "The provider that lost classes."++ },++ "classes": {++ "type": "array",++ "items": {++ "enum": [++ "online-canonical",++ "online-hot",++ "regional-cache"++ ]++ },++ "uniqueItems": true,++ "description": "The classes the provider still serves after the probe; a strict subset of previous_classes."++ },++ "previous_classes": {++ "type": "array",++ "items": {++ "enum": [++ "online-canonical",++ "online-hot",++ "regional-cache"++ ]++ },++ "uniqueItems": true,++ "description": "The classes the provider served before the probe."++ }++ }++ }++ },"media.avatar-ingest-request": {"version": "1.0.0","owner": "media",@@@ -46901,6 +46901,176 @@ export interface MediaObjectVisibilityChangedPayload {changed_at: string;}++/** [email protected] (owner: media) */++/**++ * media.replica.requested v1 (OpenVibe.Media server/objects/tiering.js promote(), the recordPlacement inside the transaction that starts the copy; PR #18). Media decided to place a replica of a ready object: the canonical copy stays where it is and the bytes are copied to the target provider (R2, the popularity cache). Recorded before the copy, so a request with no following media.replica.ready means the copy never verified and no replica exists. The payload is deliberately narrow: the object id and its tenant, the placement class, the decision and the two providers. Envelope: subject { type: object, id: <object_id> }, visibility internal, priority low, actor service:media, source media. Sandbox tenants produce none. The event type is inside media.* because OpenVibe.Events gates a service's event_type to a prefix that starts with its source name.++ */++export interface MediaReplicaRequestedPayload {++ object_id: string;++ /**++ * The tenant that owns the object: an app (live, tools…) or a developer project's production tenant (prj_…); null when the object has no tenant.++ */++ app_id: string | null;++ /**++ * The placement class the move was budgeted against (media.storage_policy classes; tiering.classOf).++ */++ class: "video" | "image" | "download" | "game-asset" | "attachment" | "backup";++ /**++ * promote copies the object to the target provider, demote removes a replica from it.++ */++ action: "promote" | "demote";++ /**++ * The canonical provider the copy is read from; null when the object has no canonical copy at request time.++ */++ from: "local" | "b2" | "r2" | null;++ /**++ * The provider the replica is placed on.++ */++ to: "local" | "b2" | "r2";++ /**++ * The replica provider, named again so the decision is readable on its own.++ */++ provider: "local" | "b2" | "r2";++}++++/** [email protected] (owner: media) */++/**++ * media.replica.ready v1 (OpenVibe.Media server/objects/tiering.js promote(), the recordPlacement in the transaction that upserts the verified media_locations row; PR #18). A replica finished copying and verified: the bytes read back from the target provider hash to the object's recorded content_hash before this is recorded. The payload carries where the copy lives and how big it is, so consumers and the placement view can act without reading the object API. A media.replica.requested with no ready for the same object is a copy that never verified (the bytes are removed, nothing was recorded). Envelope: subject { type: object, id: <object_id> }, visibility internal, priority low, actor service:media, source media. Sandbox tenants produce none.++ */++export interface MediaReplicaReadyPayload {++ object_id: string;++ /**++ * The tenant that owns the object: an app (live, tools…) or a developer project's production tenant (prj_…); null when the object has no tenant.++ */++ app_id: string | null;++ /**++ * The placement class the move was budgeted against (media.storage_policy classes; tiering.classOf).++ */++ class: "video" | "image" | "download" | "game-asset" | "attachment" | "backup";++ /**++ * promote placed a copy on the provider, demote removed one.++ */++ action: "promote" | "demote";++ /**++ * The provider that now holds the verified replica.++ */++ provider: "local" | "b2" | "r2";++ /**++ * The object key of the replica in the provider's bucket.++ */++ key: string;++ /**++ * The size in bytes that was copied and verified.++ */++ bytes: number;++ /**++ * When the verified replica was recorded, ISO 8601 UTC; a fresh row restarts the demotion residency clock.++ */++ replica_since: string;++}++++/** [email protected] (owner: media) */++/**++ * media.replica.draining v1 (OpenVibe.Media server/objects/tiering.js demote(), the recordPlacement before the replica is deleted; PR #18). Media decided to drain a replica: the canonical copy has been confirmed good, the replica is about to be removed, and reads should stop being routed to it. Recorded before the delete, so a draining with no following media.replica.evicted means the delete failed and the replica is still there. Envelope: subject { type: object, id: <object_id> }, visibility internal, priority low, actor service:media, source media. Sandbox tenants produce none.++ */++export interface MediaReplicaDrainingPayload {++ object_id: string;++ /**++ * The tenant that owns the object: an app (live, tools…) or a developer project's production tenant (prj_…); null when the object has no tenant.++ */++ app_id: string | null;++ /**++ * The placement class the move was budgeted against (media.storage_policy classes; tiering.classOf).++ */++ class: "video" | "image" | "download" | "game-asset" | "attachment" | "backup";++ /**++ * Draining is always part of a demotion.++ */++ action: "demote";++ /**++ * The provider whose replica is being drained.++ */++ provider: "local" | "b2" | "r2";++ /**++ * The object key of the replica being removed.++ */++ key: string;++}++++/** [email protected] (owner: media) */++/**++ * media.replica.evicted v1 (OpenVibe.Media server/objects/tiering.js demote(), the recordPlacement in the transaction that deletes the media_locations row; PR #18). The replica is gone: the bytes were deleted from the provider and the location row removed, so nothing routes reads there any more. Paired with the media.replica.draining that preceded it; draining without evicted is a delete that failed. Envelope: subject { type: object, id: <object_id> }, visibility internal, priority low, actor service:media, source media. Sandbox tenants produce none.++ */++export interface MediaReplicaEvictedPayload {++ object_id: string;++ /**++ * The tenant that owns the object: an app (live, tools…) or a developer project's production tenant (prj_…); null when the object has no tenant.++ */++ app_id: string | null;++ /**++ * The placement class the move was budgeted against (media.storage_policy classes; tiering.classOf).++ */++ class: "video" | "image" | "download" | "game-asset" | "attachment" | "backup";++ /**++ * Eviction is always the end of a demotion.++ */++ action: "demote";++ /**++ * The provider the replica was removed from.++ */++ provider: "local" | "b2" | "r2";++ /**++ * The object key that no longer exists in the provider's bucket.++ */++ key: string;++}++++/** [email protected] (owner: media) */++/**++ * media.provider.health_degraded v1 (OpenVibe.Media server/placement/providers.js liveHealth(), the announceProviderEvent on a healthy→unhealthy flip; PR #18). A storage provider that was healthy failed its per-minute HeadBucket, so the fabric stops routing to it until it answers again. Emitted once per flip, not once per tick: another failing HEAD while it is already unhealthy records nothing, and a recovery is a media.storage.recovered style state change, not another degradation. The event lives in the media namespace because OpenVibe.Events gates a service's event_type to a prefix that starts with its source name; the original provider.* type could never be published by source media (403 events.type_not_allowed). Envelope: subject { type: provider, id: <provider> }, visibility internal, priority low, actor service:media, source media.++ */++export interface MediaProviderHealthDegradedPayload {++ /**++ * Always null: a provider serves every tenant, so the signal belongs to no one app.++ */++ app_id: string | null;++ /**++ * The provider that failed its health check.++ */++ provider: "local" | "b2" | "r2";++ /**++ * This event exists only for the healthy→unhealthy flip, so it is always false.++ */++ healthy: false;++ /**++ * The error the failed health check recorded.++ */++ last_error: string;++}++++/** [email protected] (owner: media) */++/**++ * media.provider.capacity_warning v1 (OpenVibe.Media server/placement/providers.js setCapabilities(), the announceProviderEvent when a completed probe leaves the provider in fewer classes; PR #18). A provider that was passing lost one or more classes: the probe now fails a capability the class needs (for example R2 no longer serving a 206 range, so it stops being online-hot), and the router removes it from those classes until a later probe passes again. Emitted once per class loss, not once per probe. The event lives in the media namespace because OpenVibe.Events gates a service's event_type to a prefix that starts with its source name; the original provider.* type could never be published by source media (403 events.type_not_allowed). Envelope: subject { type: provider, id: <provider> }, visibility internal, priority low, actor service:media, source media.++ */++export interface MediaProviderCapacityWarningPayload {++ /**++ * Always null: a provider serves every tenant, so the signal belongs to no one app.++ */++ app_id: string | null;++ /**++ * The provider that lost classes.++ */++ provider: "local" | "b2" | "r2";++ /**++ * The classes the provider still serves after the probe; a strict subset of previous_classes.++ */++ classes: ("online-canonical" | "online-hot" | "regional-cache")[];++ /**++ * The classes the provider served before the probe.++ */++ previous_classes: ("online-canonical" | "online-hot" | "regional-cache")[];++}++/** [email protected] (owner: media) *//*** media.avatar-ingest-request@1: the body of POST /internal/avatar-ingest on OpenVibe.Media (capability media.avatar.ingest; Network calls it while adopting an avatar a person pointed at somewhere else on the web). Media fetches the URL once through its egress guard (https only, public addresses only, at most 8 MB and 10 s), re-encodes it to a 512×512 WebP and stores it as an unlisted screenshot paste owned by user_id, so no OpenVibe site ever serves or stores a third-party address. Refusals are { ok: false, error } (400 without url or user_id, 422 when the link is not a usable picture).@@@ -54,6 +54,12 @@"media.object.deleted","media.object.uploaded","media.object.visibility_changed",++ "media.provider.capacity_warning",++ "media.provider.health_degraded",++ "media.replica.draining",++ "media.replica.evicted",++ "media.replica.ready",++ "media.replica.requested","media.storage.alert","media.storage.recovered","media.vod.failed",@@@ -357,6 +357,26 @@ for (const id of ['events.event.publish', 'events.event.read', 'events.subscriptok(services.get('media').eventsProduced.includes('media.object.deleted') && services.get('media').eventsProduced.includes('media.object.visibility_changed'), 'the media manifest lists its object events');}++// ── Media placement events (PR #18 follow-up): replicas and provider transitions ──++// OpenVibe.Events gates a service's event_type to a prefix that starts with its source key++// (OpenVibe.Events server/config.js sourcePrefixes), so a provider.* type can never be published by++// source media: both provider events are media.provider.*. Every type here is emitted by Media's++// placement outbox today, and each carries only the decision, never the object's bytes or metadata.++{++ const placement = ['media.replica.requested', 'media.replica.ready', 'media.replica.draining', 'media.replica.evicted',++ 'media.provider.health_degraded', 'media.provider.capacity_warning'];++ for (const id of placement) {++ const c = contracts.catalog.find(x => x.id === id);++ ok(c && c.owner === 'media' && c.status === 'active', `${id} is an active Media payload contract`);++ ok(services.get('media').eventsProduced.includes(id), `the media manifest produces ${id}`);++ }++ for (const t of services.get('media').eventsProduced) ok(t.startsWith('media.'), `media produces ${t} inside media.*`);++ const warn = contracts.schema('media.provider.capacity_warning');++ ok(JSON.stringify(warn.properties.classes.items.enum) === JSON.stringify(warn.properties.previous_classes.items.enum), 'capacity_warning names the same class set before and after the loss');++ const replica = contracts.schema('media.replica.ready');++ ok(replica.properties.bytes.type === 'integer' && replica.required.includes('key'), 'replica.ready carries the verified copy and its size');++}++// ── Mod manifest 1.1.0 (v0.34.0, ADR-013 amendment) ──────────────────────// Read/write grants, billing hooks and dependencies are optional, so every 1.0.0 manifest stays// valid; the split uses exactly the namespace patterns of the older lists; money goes only through
@@ -30,6 +30,24 @@ lifecycle from.
@@ -30,6 +30,24 @@ lifecycle from.
- Presence policy: `bot.robot.online` and `bot.robot.offline` are robot connectivity (ADR-043), not a person's presence,
- Presence policy: `bot.robot.online` and `bot.robot.offline` are robot connectivity (ADR-043), not a person's presence,
and are allowed. Any other `.online`, `.offline` or presence event type is still refused.
and are allowed. Any other `.online`, `.offline` or presence event type is still refused.
+**Media placement events** (follow-up of OpenVibe.Media PR #18, "Record placement decisions for replicas and provider
+transitions"; ADR-026). Media records every replica move and provider transition in its placement outbox, but the two
+provider events used types starting with `provider.`, which OpenVibe.Events can never accept from source `media`: its
+prefix gate (`server/config.js` `sourcePrefixes`, `server/api/publish.js`) requires an `event_type` to start with the
+source key, so both were permanently rejected (`403 events.type_not_allowed`) and piled up in `event_outbox`. The types
+now live in the media namespace (`media.provider.health_degraded`, `media.provider.capacity_warning`), and all six event
+types Media emits today gain active payload contracts owned by `media`, listed in its manifest:
+- `media.replica.requested`, `media.replica.ready`, `media.replica.draining`, `media.replica.evicted` (Media's
+ `server/objects/tiering.js`: the promote/demote decisions, recorded inside the transaction that starts the copy, upserts
+ the verified location, starts the delete and removes it).
+- `media.provider.health_degraded` and `media.provider.capacity_warning` (Media's `server/placement/providers.js`: the
+ healthy→unhealthy flip and the class loss after a completed probe), each emitted once per transition, not per tick.
+
+Each payload is deliberately narrow — the object id and tenant, placement class, action, providers, key/size for a
+replica, the class set before and after a loss — never the object's bytes, title or metadata. `media.replica.*` was
+already inside the streamable namespace; only the provider types changed. Valid and invalid fixtures for each, and
+`test/run.js` asserts every type Media produces starts with `media.`, is owned by `media` and is listed in its manifest.
+
## 0.87.0 — 2026-10-03
## 0.87.0 — 2026-10-03
**User-owned trust class and the estate table** (plan T1 step 3; ADR-034 §5 control plane/data plane; the Fabric ADR,
**User-owned trust class and the estate table** (plan T1 step 3; ADR-034 §5 control plane/data plane; the Fabric ADR,
@@ -4366,6 +4366,60 @@
@@ -4366,6 +4366,60 @@
"compatibility": "backward",
"compatibility": "backward",
"status": "active"
"status": "active"
},
},
+ {
+ "id": "media.replica.requested",
+ "version": "1.0.0",
+ "owner": "media",
+ "schema": "events/payloads/media.replica.requested.v1.json",
+ "visibility": "first-party",
+ "compatibility": "backward",
+ "status": "active"
+ },
+ {
+ "id": "media.replica.ready",
+ "version": "1.0.0",
+ "owner": "media",
+ "schema": "events/payloads/media.replica.ready.v1.json",
+ "visibility": "first-party",
+ "compatibility": "backward",
+ "status": "active"
+ },
+ {
+ "id": "media.replica.draining",
+ "version": "1.0.0",
+ "owner": "media",
+ "schema": "events/payloads/media.replica.draining.v1.json",
+ "visibility": "first-party",
+ "compatibility": "backward",
+ "status": "active"
+ },
+ {
+ "id": "media.replica.evicted",
+ "version": "1.0.0",
+ "owner": "media",
+ "schema": "events/payloads/media.replica.evicted.v1.json",
+ "visibility": "first-party",
+ "compatibility": "backward",
+ "status": "active"
+ },
+ {
+ "id": "media.provider.health_degraded",
+ "version": "1.0.0",
+ "owner": "media",
+ "schema": "events/payloads/media.provider.health_degraded.v1.json",
+ "visibility": "first-party",
+ "compatibility": "backward",
+ "status": "active"
+ },
+ {
+ "id": "media.provider.capacity_warning",
+ "version": "1.0.0",
+ "owner": "media",
+ "schema": "events/payloads/media.provider.capacity_warning.v1.json",
+ "visibility": "first-party",
+ "compatibility": "backward",
+ "status": "active"
+ },
{
{
"id": "media.avatar-ingest-request",
"id": "media.avatar-ingest-request",
"version": "1.0.0",
"version": "1.0.0",
new file mode 100644
new file mode 100644
@@ -0,0 +1,55 @@
@@ -0,0 +1,55 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://openvibe.network/contracts/events/payloads/media.provider.capacity_warning.v1.json",
+ "title": "MediaProviderCapacityWarningPayload",
+ "description": "media.provider.capacity_warning v1 (OpenVibe.Media server/placement/providers.js setCapabilities(), the announceProviderEvent when a completed probe leaves the provider in fewer classes; PR #18). A provider that was passing lost one or more classes: the probe now fails a capability the class needs (for example R2 no longer serving a 206 range, so it stops being online-hot), and the router removes it from those classes until a later probe passes again. Emitted once per class loss, not once per probe. The event lives in the media namespace because OpenVibe.Events gates a service's event_type to a prefix that starts with its source name; the original provider.* type could never be published by source media (403 events.type_not_allowed). Envelope: subject { type: provider, id: <provider> }, visibility internal, priority low, actor service:media, source media.",
+ "type": "object",
+ "required": [
+ "app_id",
+ "provider",
+ "classes",
+ "previous_classes"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "app_id": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "Always null: a provider serves every tenant, so the signal belongs to no one app."
+ },
+ "provider": {
+ "enum": [
+ "local",
+ "b2",
+ "r2"
+ ],
+ "description": "The provider that lost classes."
+ },
+ "classes": {
+ "type": "array",
+ "items": {
+ "enum": [
+ "online-canonical",
+ "online-hot",
+ "regional-cache"
+ ]
+ },
+ "uniqueItems": true,
+ "description": "The classes the provider still serves after the probe; a strict subset of previous_classes."
+ },
+ "previous_classes": {
+ "type": "array",
+ "items": {
+ "enum": [
+ "online-canonical",
+ "online-hot",
+ "regional-cache"
+ ]
+ },
+ "uniqueItems": true,
+ "description": "The classes the provider served before the probe."
+ }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,41 @@
@@ -0,0 +1,41 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://openvibe.network/contracts/events/payloads/media.provider.health_degraded.v1.json",
+ "title": "MediaProviderHealthDegradedPayload",
+ "description": "media.provider.health_degraded v1 (OpenVibe.Media server/placement/providers.js liveHealth(), the announceProviderEvent on a healthy→unhealthy flip; PR #18). A storage provider that was healthy failed its per-minute HeadBucket, so the fabric stops routing to it until it answers again. Emitted once per flip, not once per tick: another failing HEAD while it is already unhealthy records nothing, and a recovery is a media.storage.recovered style state change, not another degradation. The event lives in the media namespace because OpenVibe.Events gates a service's event_type to a prefix that starts with its source name; the original provider.* type could never be published by source media (403 events.type_not_allowed). Envelope: subject { type: provider, id: <provider> }, visibility internal, priority low, actor service:media, source media.",
+ "type": "object",
+ "required": [
+ "app_id",
+ "provider",
+ "healthy",
+ "last_error"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "app_id": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "Always null: a provider serves every tenant, so the signal belongs to no one app."
+ },
+ "provider": {
+ "enum": [
+ "local",
+ "b2",
+ "r2"
+ ],
+ "description": "The provider that failed its health check."
+ },
+ "healthy": {
+ "const": false,
+ "description": "This event exists only for the healthy→unhealthy flip, so it is always false."
+ },
+ "last_error": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 500,
+ "description": "The error the failed health check recorded."
+ }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,60 @@
@@ -0,0 +1,60 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://openvibe.network/contracts/events/payloads/media.replica.draining.v1.json",
+ "title": "MediaReplicaDrainingPayload",
+ "description": "media.replica.draining v1 (OpenVibe.Media server/objects/tiering.js demote(), the recordPlacement before the replica is deleted; PR #18). Media decided to drain a replica: the canonical copy has been confirmed good, the replica is about to be removed, and reads should stop being routed to it. Recorded before the delete, so a draining with no following media.replica.evicted means the delete failed and the replica is still there. Envelope: subject { type: object, id: <object_id> }, visibility internal, priority low, actor service:media, source media. Sandbox tenants produce none.",
+ "type": "object",
+ "required": [
+ "object_id",
+ "app_id",
+ "class",
+ "action",
+ "provider",
+ "key"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "object_id": {
+ "type": "string",
+ "pattern": "^med_[0-9A-HJKMNP-TV-Z]{26}$"
+ },
+ "app_id": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "minLength": 1,
+ "maxLength": 200,
+ "description": "The tenant that owns the object: an app (live, tools…) or a developer project's production tenant (prj_…); null when the object has no tenant."
+ },
+ "class": {
+ "enum": [
+ "video",
+ "image",
+ "download",
+ "game-asset",
+ "attachment",
+ "backup"
+ ],
+ "description": "The placement class the move was budgeted against (media.storage_policy classes; tiering.classOf)."
+ },
+ "action": {
+ "const": "demote",
+ "description": "Draining is always part of a demotion."
+ },
+ "provider": {
+ "enum": [
+ "local",
+ "b2",
+ "r2"
+ ],
+ "description": "The provider whose replica is being drained."
+ },
+ "key": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 1024,
+ "description": "The object key of the replica being removed."
+ }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,60 @@
@@ -0,0 +1,60 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://openvibe.network/contracts/events/payloads/media.replica.evicted.v1.json",
+ "title": "MediaReplicaEvictedPayload",
+ "description": "media.replica.evicted v1 (OpenVibe.Media server/objects/tiering.js demote(), the recordPlacement in the transaction that deletes the media_locations row; PR #18). The replica is gone: the bytes were deleted from the provider and the location row removed, so nothing routes reads there any more. Paired with the media.replica.draining that preceded it; draining without evicted is a delete that failed. Envelope: subject { type: object, id: <object_id> }, visibility internal, priority low, actor service:media, source media. Sandbox tenants produce none.",
+ "type": "object",
+ "required": [
+ "object_id",
+ "app_id",
+ "class",
+ "action",
+ "provider",
+ "key"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "object_id": {
+ "type": "string",
+ "pattern": "^med_[0-9A-HJKMNP-TV-Z]{26}$"
+ },
+ "app_id": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "minLength": 1,
+ "maxLength": 200,
+ "description": "The tenant that owns the object: an app (live, tools…) or a developer project's production tenant (prj_…); null when the object has no tenant."
+ },
+ "class": {
+ "enum": [
+ "video",
+ "image",
+ "download",
+ "game-asset",
+ "attachment",
+ "backup"
+ ],
+ "description": "The placement class the move was budgeted against (media.storage_policy classes; tiering.classOf)."
+ },
+ "action": {
+ "const": "demote",
+ "description": "Eviction is always the end of a demotion."
+ },
+ "provider": {
+ "enum": [
+ "local",
+ "b2",
+ "r2"
+ ],
+ "description": "The provider the replica was removed from."
+ },
+ "key": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 1024,
+ "description": "The object key that no longer exists in the provider's bucket."
+ }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,75 @@
@@ -0,0 +1,75 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://openvibe.network/contracts/events/payloads/media.replica.ready.v1.json",
+ "title": "MediaReplicaReadyPayload",
+ "description": "media.replica.ready v1 (OpenVibe.Media server/objects/tiering.js promote(), the recordPlacement in the transaction that upserts the verified media_locations row; PR #18). A replica finished copying and verified: the bytes read back from the target provider hash to the object's recorded content_hash before this is recorded. The payload carries where the copy lives and how big it is, so consumers and the placement view can act without reading the object API. A media.replica.requested with no ready for the same object is a copy that never verified (the bytes are removed, nothing was recorded). Envelope: subject { type: object, id: <object_id> }, visibility internal, priority low, actor service:media, source media. Sandbox tenants produce none.",
+ "type": "object",
+ "required": [
+ "object_id",
+ "app_id",
+ "class",
+ "action",
+ "provider",
+ "key",
+ "bytes",
+ "replica_since"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "object_id": {
+ "type": "string",
+ "pattern": "^med_[0-9A-HJKMNP-TV-Z]{26}$"
+ },
+ "app_id": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "minLength": 1,
+ "maxLength": 200,
+ "description": "The tenant that owns the object: an app (live, tools…) or a developer project's production tenant (prj_…); null when the object has no tenant."
+ },
+ "class": {
+ "enum": [
+ "video",
+ "image",
+ "download",
+ "game-asset",
+ "attachment",
+ "backup"
+ ],
+ "description": "The placement class the move was budgeted against (media.storage_policy classes; tiering.classOf)."
+ },
+ "action": {
+ "enum": [
+ "promote",
+ "demote"
+ ],
+ "description": "promote placed a copy on the provider, demote removed one."
+ },
+ "provider": {
+ "enum": [
+ "local",
+ "b2",
+ "r2"
+ ],
+ "description": "The provider that now holds the verified replica."
+ },
+ "key": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 1024,
+ "description": "The object key of the replica in the provider's bucket."
+ },
+ "bytes": {
+ "type": "integer",
+ "minimum": 0,
+ "description": "The size in bytes that was copied and verified."
+ },
+ "replica_since": {
+ "type": "string",
+ "format": "date-time",
+ "description": "When the verified replica was recorded, ISO 8601 UTC; a fresh row restarts the demotion residency clock."
+ }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,75 @@
@@ -0,0 +1,75 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://openvibe.network/contracts/events/payloads/media.replica.requested.v1.json",
+ "title": "MediaReplicaRequestedPayload",
+ "description": "media.replica.requested v1 (OpenVibe.Media server/objects/tiering.js promote(), the recordPlacement inside the transaction that starts the copy; PR #18). Media decided to place a replica of a ready object: the canonical copy stays where it is and the bytes are copied to the target provider (R2, the popularity cache). Recorded before the copy, so a request with no following media.replica.ready means the copy never verified and no replica exists. The payload is deliberately narrow: the object id and its tenant, the placement class, the decision and the two providers. Envelope: subject { type: object, id: <object_id> }, visibility internal, priority low, actor service:media, source media. Sandbox tenants produce none. The event type is inside media.* because OpenVibe.Events gates a service's event_type to a prefix that starts with its source name.",
+ "type": "object",
+ "required": [
+ "object_id",
+ "app_id",
+ "class",
+ "action",
+ "from",
+ "to",
+ "provider"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "object_id": {
+ "type": "string",
+ "pattern": "^med_[0-9A-HJKMNP-TV-Z]{26}$"
+ },
+ "app_id": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "minLength": 1,
+ "maxLength": 200,
+ "description": "The tenant that owns the object: an app (live, tools…) or a developer project's production tenant (prj_…); null when the object has no tenant."
+ },
+ "class": {
+ "enum": [
+ "video",
+ "image",
+ "download",
+ "game-asset",
+ "attachment",
+ "backup"
+ ],
+ "description": "The placement class the move was budgeted against (media.storage_policy classes; tiering.classOf)."
+ },
+ "action": {
+ "enum": [
+ "promote",
+ "demote"
+ ],
+ "description": "promote copies the object to the target provider, demote removes a replica from it."
+ },
+ "from": {
+ "enum": [
+ "local",
+ "b2",
+ "r2",
+ null
+ ],
+ "description": "The canonical provider the copy is read from; null when the object has no canonical copy at request time."
+ },
+ "to": {
+ "enum": [
+ "local",
+ "b2",
+ "r2"
+ ],
+ "description": "The provider the replica is placed on."
+ },
+ "provider": {
+ "enum": [
+ "local",
+ "b2",
+ "r2"
+ ],
+ "description": "The replica provider, named again so the decision is readable on its own."
+ }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{ "app_id": null, "provider": "r2", "classes": [], "previous_classes": ["online-hot"], "reason": "probe lost range_206" }
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{ "app_id": null, "provider": "r2", "classes": [], "previous_classes": ["online-warm"] }
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{ "app_id": null, "provider": "r2", "classes": [], "previous_classes": ["online-hot"] }
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{ "app_id": null, "provider": "r2", "healthy": true, "last_error": "HeadBucket 503 Service Unavailable" }
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{ "app_id": null, "provider": "s3", "healthy": false, "last_error": "HeadBucket 503 Service Unavailable" }
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{ "app_id": null, "provider": "r2", "healthy": false, "last_error": "HeadBucket 503 Service Unavailable" }
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ", "app_id": "live", "class": "video", "action": "demote", "provider": "r2", "key": "vods/med_01JAB2C3D4E5F6G7H8J9K0MNPQ.mp4", "bytes": 1048576 }
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ", "app_id": "live", "class": "video", "action": "promote", "provider": "r2", "key": "vods/med_01JAB2C3D4E5F6G7H8J9K0MNPQ.mp4" }
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ", "app_id": "live", "class": "video", "action": "demote", "provider": "r2", "key": "vods/med_01JAB2C3D4E5F6G7H8J9K0MNPQ.mp4" }
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ", "app_id": "live", "class": "video", "action": "demote", "key": "vods/med_01JAB2C3D4E5F6G7H8J9K0MNPQ.mp4" }
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ", "app_id": "live", "class": "video", "action": "promote", "provider": "r2", "key": "vods/med_01JAB2C3D4E5F6G7H8J9K0MNPQ.mp4" }
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ", "app_id": "live", "class": "video", "action": "demote", "provider": "r2", "key": "vods/med_01JAB2C3D4E5F6G7H8J9K0MNPQ.mp4" }
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ", "app_id": "live", "class": "video", "action": "promote", "provider": "r2", "key": "vods/med_01JAB2C3D4E5F6G7H8J9K0MNPQ.mp4", "bytes": -1, "replica_since": "2026-10-03T12:00:00.000Z" }
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ", "app_id": "live", "class": "video", "action": "promote", "provider": "r2", "bytes": 1048576, "replica_since": "2026-10-03T12:00:00.000Z" }
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ", "app_id": "live", "class": "video", "action": "promote", "provider": "r2", "key": "vods/med_01JAB2C3D4E5F6G7H8J9K0MNPQ.mp4", "bytes": 1048576, "replica_since": "2026-10-03T12:00:00.000Z" }
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ", "app_id": "live", "class": "video", "action": "promote", "from": "local", "to": "r2", "provider": "r2", "title": "My stream" }
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ", "app_id": "live", "class": "video", "action": "move", "from": "local", "to": "r2", "provider": "r2" }
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ", "app_id": "prj_01JAB2C3D4E5F6G7H8J9K0MNPQ", "class": "image", "action": "promote", "from": null, "to": "r2", "provider": "r2" }
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ", "app_id": "live", "class": "video", "action": "promote", "from": "local", "to": "r2", "provider": "r2" }
@@ -47380,6 +47380,402 @@
@@ -47380,6 +47380,402 @@
}
}
}
}
},
},
+ "media.replica.requested": {
+ "version": "1.0.0",
+ "owner": "media",
+ "status": "active",
+ "schema": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://openvibe.network/contracts/events/payloads/media.replica.requested.v1.json",
+ "title": "MediaReplicaRequestedPayload",
+ "description": "media.replica.requested v1 (OpenVibe.Media server/objects/tiering.js promote(), the recordPlacement inside the transaction that starts the copy; PR #18). Media decided to place a replica of a ready object: the canonical copy stays where it is and the bytes are copied to the target provider (R2, the popularity cache). Recorded before the copy, so a request with no following media.replica.ready means the copy never verified and no replica exists. The payload is deliberately narrow: the object id and its tenant, the placement class, the decision and the two providers. Envelope: subject { type: object, id: <object_id> }, visibility internal, priority low, actor service:media, source media. Sandbox tenants produce none. The event type is inside media.* because OpenVibe.Events gates a service's event_type to a prefix that starts with its source name.",
+ "type": "object",
+ "required": [
+ "object_id",
+ "app_id",
+ "class",
+ "action",
+ "from",
+ "to",
+ "provider"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "object_id": {
+ "type": "string",
+ "pattern": "^med_[0-9A-HJKMNP-TV-Z]{26}$"
+ },
+ "app_id": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "minLength": 1,
+ "maxLength": 200,
+ "description": "The tenant that owns the object: an app (live, tools…) or a developer project's production tenant (prj_…); null when the object has no tenant."
+ },
+ "class": {
+ "enum": [
+ "video",
+ "image",
+ "download",
+ "game-asset",
+ "attachment",
+ "backup"
+ ],
+ "description": "The placement class the move was budgeted against (media.storage_policy classes; tiering.classOf)."
+ },
+ "action": {
+ "enum": [
+ "promote",
+ "demote"
+ ],
+ "description": "promote copies the object to the target provider, demote removes a replica from it."
+ },
+ "from": {
+ "enum": [
+ "local",
+ "b2",
+ "r2",
+ null
+ ],
+ "description": "The canonical provider the copy is read from; null when the object has no canonical copy at request time."
+ },
+ "to": {
+ "enum": [
+ "local",
+ "b2",
+ "r2"
+ ],
+ "description": "The provider the replica is placed on."
+ },
+ "provider": {
+ "enum": [
+ "local",
+ "b2",
+ "r2"
+ ],
+ "description": "The replica provider, named again so the decision is readable on its own."
+ }
+ }
+ }
+ },
+ "media.replica.ready": {
+ "version": "1.0.0",
+ "owner": "media",
+ "status": "active",
+ "schema": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://openvibe.network/contracts/events/payloads/media.replica.ready.v1.json",
+ "title": "MediaReplicaReadyPayload",
+ "description": "media.replica.ready v1 (OpenVibe.Media server/objects/tiering.js promote(), the recordPlacement in the transaction that upserts the verified media_locations row; PR #18). A replica finished copying and verified: the bytes read back from the target provider hash to the object's recorded content_hash before this is recorded. The payload carries where the copy lives and how big it is, so consumers and the placement view can act without reading the object API. A media.replica.requested with no ready for the same object is a copy that never verified (the bytes are removed, nothing was recorded). Envelope: subject { type: object, id: <object_id> }, visibility internal, priority low, actor service:media, source media. Sandbox tenants produce none.",
+ "type": "object",
+ "required": [
+ "object_id",
+ "app_id",
+ "class",
+ "action",
+ "provider",
+ "key",
+ "bytes",
+ "replica_since"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "object_id": {
+ "type": "string",
+ "pattern": "^med_[0-9A-HJKMNP-TV-Z]{26}$"
+ },
+ "app_id": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "minLength": 1,
+ "maxLength": 200,
+ "description": "The tenant that owns the object: an app (live, tools…) or a developer project's production tenant (prj_…); null when the object has no tenant."
+ },
+ "class": {
+ "enum": [
+ "video",
+ "image",
+ "download",
+ "game-asset",
+ "attachment",
+ "backup"
+ ],
+ "description": "The placement class the move was budgeted against (media.storage_policy classes; tiering.classOf)."
+ },
+ "action": {
+ "enum": [
+ "promote",
+ "demote"
+ ],
+ "description": "promote placed a copy on the provider, demote removed one."
+ },
+ "provider": {
+ "enum": [
+ "local",
+ "b2",
+ "r2"
+ ],
+ "description": "The provider that now holds the verified replica."
+ },
+ "key": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 1024,
+ "description": "The object key of the replica in the provider's bucket."
+ },
+ "bytes": {
+ "type": "integer",
+ "minimum": 0,
+ "description": "The size in bytes that was copied and verified."
+ },
+ "replica_since": {
+ "type": "string",
+ "format": "date-time",
+ "description": "When the verified replica was recorded, ISO 8601 UTC; a fresh row restarts the demotion residency clock."
+ }
+ }
+ }
+ },
+ "media.replica.draining": {
+ "version": "1.0.0",
+ "owner": "media",
+ "status": "active",
+ "schema": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://openvibe.network/contracts/events/payloads/media.replica.draining.v1.json",
+ "title": "MediaReplicaDrainingPayload",
+ "description": "media.replica.draining v1 (OpenVibe.Media server/objects/tiering.js demote(), the recordPlacement before the replica is deleted; PR #18). Media decided to drain a replica: the canonical copy has been confirmed good, the replica is about to be removed, and reads should stop being routed to it. Recorded before the delete, so a draining with no following media.replica.evicted means the delete failed and the replica is still there. Envelope: subject { type: object, id: <object_id> }, visibility internal, priority low, actor service:media, source media. Sandbox tenants produce none.",
+ "type": "object",
+ "required": [
+ "object_id",
+ "app_id",
+ "class",
+ "action",
+ "provider",
+ "key"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "object_id": {
+ "type": "string",
+ "pattern": "^med_[0-9A-HJKMNP-TV-Z]{26}$"
+ },
+ "app_id": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "minLength": 1,
+ "maxLength": 200,
+ "description": "The tenant that owns the object: an app (live, tools…) or a developer project's production tenant (prj_…); null when the object has no tenant."
+ },
+ "class": {
+ "enum": [
+ "video",
+ "image",
+ "download",
+ "game-asset",
+ "attachment",
+ "backup"
+ ],
+ "description": "The placement class the move was budgeted against (media.storage_policy classes; tiering.classOf)."
+ },
+ "action": {
+ "const": "demote",
+ "description": "Draining is always part of a demotion."
+ },
+ "provider": {
+ "enum": [
+ "local",
+ "b2",
+ "r2"
+ ],
+ "description": "The provider whose replica is being drained."
+ },
+ "key": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 1024,
+ "description": "The object key of the replica being removed."
+ }
+ }
+ }
+ },
+ "media.replica.evicted": {
+ "version": "1.0.0",
+ "owner": "media",
+ "status": "active",
+ "schema": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://openvibe.network/contracts/events/payloads/media.replica.evicted.v1.json",
+ "title": "MediaReplicaEvictedPayload",
+ "description": "media.replica.evicted v1 (OpenVibe.Media server/objects/tiering.js demote(), the recordPlacement in the transaction that deletes the media_locations row; PR #18). The replica is gone: the bytes were deleted from the provider and the location row removed, so nothing routes reads there any more. Paired with the media.replica.draining that preceded it; draining without evicted is a delete that failed. Envelope: subject { type: object, id: <object_id> }, visibility internal, priority low, actor service:media, source media. Sandbox tenants produce none.",
+ "type": "object",
+ "required": [
+ "object_id",
+ "app_id",
+ "class",
+ "action",
+ "provider",
+ "key"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "object_id": {
+ "type": "string",
+ "pattern": "^med_[0-9A-HJKMNP-TV-Z]{26}$"
+ },
+ "app_id": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "minLength": 1,
+ "maxLength": 200,
+ "description": "The tenant that owns the object: an app (live, tools…) or a developer project's production tenant (prj_…); null when the object has no tenant."
+ },
+ "class": {
+ "enum": [
+ "video",
+ "image",
+ "download",
+ "game-asset",
+ "attachment",
+ "backup"
+ ],
+ "description": "The placement class the move was budgeted against (media.storage_policy classes; tiering.classOf)."
+ },
+ "action": {
+ "const": "demote",
+ "description": "Eviction is always the end of a demotion."
+ },
+ "provider": {
+ "enum": [
+ "local",
+ "b2",
+ "r2"
+ ],
+ "description": "The provider the replica was removed from."
+ },
+ "key": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 1024,
+ "description": "The object key that no longer exists in the provider's bucket."
+ }
+ }
+ }
+ },
+ "media.provider.health_degraded": {
+ "version": "1.0.0",
+ "owner": "media",
+ "status": "active",
+ "schema": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://openvibe.network/contracts/events/payloads/media.provider.health_degraded.v1.json",
+ "title": "MediaProviderHealthDegradedPayload",
+ "description": "media.provider.health_degraded v1 (OpenVibe.Media server/placement/providers.js liveHealth(), the announceProviderEvent on a healthy→unhealthy flip; PR #18). A storage provider that was healthy failed its per-minute HeadBucket, so the fabric stops routing to it until it answers again. Emitted once per flip, not once per tick: another failing HEAD while it is already unhealthy records nothing, and a recovery is a media.storage.recovered style state change, not another degradation. The event lives in the media namespace because OpenVibe.Events gates a service's event_type to a prefix that starts with its source name; the original provider.* type could never be published by source media (403 events.type_not_allowed). Envelope: subject { type: provider, id: <provider> }, visibility internal, priority low, actor service:media, source media.",
+ "type": "object",
+ "required": [
+ "app_id",
+ "provider",
+ "healthy",
+ "last_error"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "app_id": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "Always null: a provider serves every tenant, so the signal belongs to no one app."
+ },
+ "provider": {
+ "enum": [
+ "local",
+ "b2",
+ "r2"
+ ],
+ "description": "The provider that failed its health check."
+ },
+ "healthy": {
+ "const": false,
+ "description": "This event exists only for the healthy→unhealthy flip, so it is always false."
+ },
+ "last_error": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 500,
+ "description": "The error the failed health check recorded."
+ }
+ }
+ }
+ },
+ "media.provider.capacity_warning": {
+ "version": "1.0.0",
+ "owner": "media",
+ "status": "active",
+ "schema": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://openvibe.network/contracts/events/payloads/media.provider.capacity_warning.v1.json",
+ "title": "MediaProviderCapacityWarningPayload",
+ "description": "media.provider.capacity_warning v1 (OpenVibe.Media server/placement/providers.js setCapabilities(), the announceProviderEvent when a completed probe leaves the provider in fewer classes; PR #18). A provider that was passing lost one or more classes: the probe now fails a capability the class needs (for example R2 no longer serving a 206 range, so it stops being online-hot), and the router removes it from those classes until a later probe passes again. Emitted once per class loss, not once per probe. The event lives in the media namespace because OpenVibe.Events gates a service's event_type to a prefix that starts with its source name; the original provider.* type could never be published by source media (403 events.type_not_allowed). Envelope: subject { type: provider, id: <provider> }, visibility internal, priority low, actor service:media, source media.",
+ "type": "object",
+ "required": [
+ "app_id",
+ "provider",
+ "classes",
+ "previous_classes"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "app_id": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "Always null: a provider serves every tenant, so the signal belongs to no one app."
+ },
+ "provider": {
+ "enum": [
+ "local",
+ "b2",
+ "r2"
+ ],
+ "description": "The provider that lost classes."
+ },
+ "classes": {
+ "type": "array",
+ "items": {
+ "enum": [
+ "online-canonical",
+ "online-hot",
+ "regional-cache"
+ ]
+ },
+ "uniqueItems": true,
+ "description": "The classes the provider still serves after the probe; a strict subset of previous_classes."
+ },
+ "previous_classes": {
+ "type": "array",
+ "items": {
+ "enum": [
+ "online-canonical",
+ "online-hot",
+ "regional-cache"
+ ]
+ },
+ "uniqueItems": true,
+ "description": "The classes the provider served before the probe."
+ }
+ }
+ }
+ },
"media.avatar-ingest-request": {
"media.avatar-ingest-request": {
"version": "1.0.0",
"version": "1.0.0",
"owner": "media",
"owner": "media",
@@ -46901,6 +46901,176 @@ export interface MediaObjectVisibilityChangedPayload {
@@ -46901,6 +46901,176 @@ export interface MediaObjectVisibilityChangedPayload {
changed_at: string;
changed_at: string;
}
}
+/** [email protected] (owner: media) */
+/**
+ * media.replica.requested v1 (OpenVibe.Media server/objects/tiering.js promote(), the recordPlacement inside the transaction that starts the copy; PR #18). Media decided to place a replica of a ready object: the canonical copy stays where it is and the bytes are copied to the target provider (R2, the popularity cache). Recorded before the copy, so a request with no following media.replica.ready means the copy never verified and no replica exists. The payload is deliberately narrow: the object id and its tenant, the placement class, the decision and the two providers. Envelope: subject { type: object, id: <object_id> }, visibility internal, priority low, actor service:media, source media. Sandbox tenants produce none. The event type is inside media.* because OpenVibe.Events gates a service's event_type to a prefix that starts with its source name.
+ */
+export interface MediaReplicaRequestedPayload {
+ object_id: string;
+ /**
+ * The tenant that owns the object: an app (live, tools…) or a developer project's production tenant (prj_…); null when the object has no tenant.
+ */
+ app_id: string | null;
+ /**
+ * The placement class the move was budgeted against (media.storage_policy classes; tiering.classOf).
+ */
+ class: "video" | "image" | "download" | "game-asset" | "attachment" | "backup";
+ /**
+ * promote copies the object to the target provider, demote removes a replica from it.
+ */
+ action: "promote" | "demote";
+ /**
+ * The canonical provider the copy is read from; null when the object has no canonical copy at request time.
+ */
+ from: "local" | "b2" | "r2" | null;
+ /**
+ * The provider the replica is placed on.
+ */
+ to: "local" | "b2" | "r2";
+ /**
+ * The replica provider, named again so the decision is readable on its own.
+ */
+ provider: "local" | "b2" | "r2";
+}
+
+/** [email protected] (owner: media) */
+/**
+ * media.replica.ready v1 (OpenVibe.Media server/objects/tiering.js promote(), the recordPlacement in the transaction that upserts the verified media_locations row; PR #18). A replica finished copying and verified: the bytes read back from the target provider hash to the object's recorded content_hash before this is recorded. The payload carries where the copy lives and how big it is, so consumers and the placement view can act without reading the object API. A media.replica.requested with no ready for the same object is a copy that never verified (the bytes are removed, nothing was recorded). Envelope: subject { type: object, id: <object_id> }, visibility internal, priority low, actor service:media, source media. Sandbox tenants produce none.
+ */
+export interface MediaReplicaReadyPayload {
+ object_id: string;
+ /**
+ * The tenant that owns the object: an app (live, tools…) or a developer project's production tenant (prj_…); null when the object has no tenant.
+ */
+ app_id: string | null;
+ /**
+ * The placement class the move was budgeted against (media.storage_policy classes; tiering.classOf).
+ */
+ class: "video" | "image" | "download" | "game-asset" | "attachment" | "backup";
+ /**
+ * promote placed a copy on the provider, demote removed one.
+ */
+ action: "promote" | "demote";
+ /**
+ * The provider that now holds the verified replica.
+ */
+ provider: "local" | "b2" | "r2";
+ /**
+ * The object key of the replica in the provider's bucket.
+ */
+ key: string;
+ /**
+ * The size in bytes that was copied and verified.
+ */
+ bytes: number;
+ /**
+ * When the verified replica was recorded, ISO 8601 UTC; a fresh row restarts the demotion residency clock.
+ */
+ replica_since: string;
+}
+
+/** [email protected] (owner: media) */
+/**
+ * media.replica.draining v1 (OpenVibe.Media server/objects/tiering.js demote(), the recordPlacement before the replica is deleted; PR #18). Media decided to drain a replica: the canonical copy has been confirmed good, the replica is about to be removed, and reads should stop being routed to it. Recorded before the delete, so a draining with no following media.replica.evicted means the delete failed and the replica is still there. Envelope: subject { type: object, id: <object_id> }, visibility internal, priority low, actor service:media, source media. Sandbox tenants produce none.
+ */
+export interface MediaReplicaDrainingPayload {
+ object_id: string;
+ /**
+ * The tenant that owns the object: an app (live, tools…) or a developer project's production tenant (prj_…); null when the object has no tenant.
+ */
+ app_id: string | null;
+ /**
+ * The placement class the move was budgeted against (media.storage_policy classes; tiering.classOf).
+ */
+ class: "video" | "image" | "download" | "game-asset" | "attachment" | "backup";
+ /**
+ * Draining is always part of a demotion.
+ */
+ action: "demote";
+ /**
+ * The provider whose replica is being drained.
+ */
+ provider: "local" | "b2" | "r2";
+ /**
+ * The object key of the replica being removed.
+ */
+ key: string;
+}
+
+/** [email protected] (owner: media) */
+/**
+ * media.replica.evicted v1 (OpenVibe.Media server/objects/tiering.js demote(), the recordPlacement in the transaction that deletes the media_locations row; PR #18). The replica is gone: the bytes were deleted from the provider and the location row removed, so nothing routes reads there any more. Paired with the media.replica.draining that preceded it; draining without evicted is a delete that failed. Envelope: subject { type: object, id: <object_id> }, visibility internal, priority low, actor service:media, source media. Sandbox tenants produce none.
+ */
+export interface MediaReplicaEvictedPayload {
+ object_id: string;
+ /**
+ * The tenant that owns the object: an app (live, tools…) or a developer project's production tenant (prj_…); null when the object has no tenant.
+ */
+ app_id: string | null;
+ /**
+ * The placement class the move was budgeted against (media.storage_policy classes; tiering.classOf).
+ */
+ class: "video" | "image" | "download" | "game-asset" | "attachment" | "backup";
+ /**
+ * Eviction is always the end of a demotion.
+ */
+ action: "demote";
+ /**
+ * The provider the replica was removed from.
+ */
+ provider: "local" | "b2" | "r2";
+ /**
+ * The object key that no longer exists in the provider's bucket.
+ */
+ key: string;
+}
+
+/** [email protected] (owner: media) */
+/**
+ * media.provider.health_degraded v1 (OpenVibe.Media server/placement/providers.js liveHealth(), the announceProviderEvent on a healthy→unhealthy flip; PR #18). A storage provider that was healthy failed its per-minute HeadBucket, so the fabric stops routing to it until it answers again. Emitted once per flip, not once per tick: another failing HEAD while it is already unhealthy records nothing, and a recovery is a media.storage.recovered style state change, not another degradation. The event lives in the media namespace because OpenVibe.Events gates a service's event_type to a prefix that starts with its source name; the original provider.* type could never be published by source media (403 events.type_not_allowed). Envelope: subject { type: provider, id: <provider> }, visibility internal, priority low, actor service:media, source media.
+ */
+export interface MediaProviderHealthDegradedPayload {
+ /**
+ * Always null: a provider serves every tenant, so the signal belongs to no one app.
+ */
+ app_id: string | null;
+ /**
+ * The provider that failed its health check.
+ */
+ provider: "local" | "b2" | "r2";
+ /**
+ * This event exists only for the healthy→unhealthy flip, so it is always false.
+ */
+ healthy: false;
+ /**
+ * The error the failed health check recorded.
+ */
+ last_error: string;
+}
+
+/** [email protected] (owner: media) */
+/**
+ * media.provider.capacity_warning v1 (OpenVibe.Media server/placement/providers.js setCapabilities(), the announceProviderEvent when a completed probe leaves the provider in fewer classes; PR #18). A provider that was passing lost one or more classes: the probe now fails a capability the class needs (for example R2 no longer serving a 206 range, so it stops being online-hot), and the router removes it from those classes until a later probe passes again. Emitted once per class loss, not once per probe. The event lives in the media namespace because OpenVibe.Events gates a service's event_type to a prefix that starts with its source name; the original provider.* type could never be published by source media (403 events.type_not_allowed). Envelope: subject { type: provider, id: <provider> }, visibility internal, priority low, actor service:media, source media.
+ */
+export interface MediaProviderCapacityWarningPayload {
+ /**
+ * Always null: a provider serves every tenant, so the signal belongs to no one app.
+ */
+ app_id: string | null;
+ /**
+ * The provider that lost classes.
+ */
+ provider: "local" | "b2" | "r2";
+ /**
+ * The classes the provider still serves after the probe; a strict subset of previous_classes.
+ */
+ classes: ("online-canonical" | "online-hot" | "regional-cache")[];
+ /**
+ * The classes the provider served before the probe.
+ */
+ previous_classes: ("online-canonical" | "online-hot" | "regional-cache")[];
+}
+
/** [email protected] (owner: media) */
/** [email protected] (owner: media) */
/**
/**
* media.avatar-ingest-request@1: the body of POST /internal/avatar-ingest on OpenVibe.Media (capability media.avatar.ingest; Network calls it while adopting an avatar a person pointed at somewhere else on the web). Media fetches the URL once through its egress guard (https only, public addresses only, at most 8 MB and 10 s), re-encodes it to a 512×512 WebP and stores it as an unlisted screenshot paste owned by user_id, so no OpenVibe site ever serves or stores a third-party address. Refusals are { ok: false, error } (400 without url or user_id, 422 when the link is not a usable picture).
* media.avatar-ingest-request@1: the body of POST /internal/avatar-ingest on OpenVibe.Media (capability media.avatar.ingest; Network calls it while adopting an avatar a person pointed at somewhere else on the web). Media fetches the URL once through its egress guard (https only, public addresses only, at most 8 MB and 10 s), re-encodes it to a 512×512 WebP and stores it as an unlisted screenshot paste owned by user_id, so no OpenVibe site ever serves or stores a third-party address. Refusals are { ok: false, error } (400 without url or user_id, 422 when the link is not a usable picture).
@@ -54,6 +54,12 @@
@@ -54,6 +54,12 @@
"media.object.deleted",
"media.object.deleted",
"media.object.uploaded",
"media.object.uploaded",
"media.object.visibility_changed",
"media.object.visibility_changed",
+ "media.provider.capacity_warning",
+ "media.provider.health_degraded",
+ "media.replica.draining",
+ "media.replica.evicted",
+ "media.replica.ready",
+ "media.replica.requested",
"media.storage.alert",
"media.storage.alert",
"media.storage.recovered",
"media.storage.recovered",
"media.vod.failed",
"media.vod.failed",
@@ -357,6 +357,26 @@ for (const id of ['events.event.publish', 'events.event.read', 'events.subscript
@@ -357,6 +357,26 @@ for (const id of ['events.event.publish', 'events.event.read', 'events.subscript
ok(services.get('media').eventsProduced.includes('media.object.deleted') && services.get('media').eventsProduced.includes('media.object.visibility_changed'), 'the media manifest lists its object events');
ok(services.get('media').eventsProduced.includes('media.object.deleted') && services.get('media').eventsProduced.includes('media.object.visibility_changed'), 'the media manifest lists its object events');
}
}
+// ── Media placement events (PR #18 follow-up): replicas and provider transitions ──
+// OpenVibe.Events gates a service's event_type to a prefix that starts with its source key
+// (OpenVibe.Events server/config.js sourcePrefixes), so a provider.* type can never be published by
+// source media: both provider events are media.provider.*. Every type here is emitted by Media's
+// placement outbox today, and each carries only the decision, never the object's bytes or metadata.
+{
+ const placement = ['media.replica.requested', 'media.replica.ready', 'media.replica.draining', 'media.replica.evicted',
+ 'media.provider.health_degraded', 'media.provider.capacity_warning'];
+ for (const id of placement) {
+ const c = contracts.catalog.find(x => x.id === id);
+ ok(c && c.owner === 'media' && c.status === 'active', `${id} is an active Media payload contract`);
+ ok(services.get('media').eventsProduced.includes(id), `the media manifest produces ${id}`);
+ }
+ for (const t of services.get('media').eventsProduced) ok(t.startsWith('media.'), `media produces ${t} inside media.*`);
+ const warn = contracts.schema('media.provider.capacity_warning');
+ ok(JSON.stringify(warn.properties.classes.items.enum) === JSON.stringify(warn.properties.previous_classes.items.enum), 'capacity_warning names the same class set before and after the loss');
+ const replica = contracts.schema('media.replica.ready');
+ ok(replica.properties.bytes.type === 'integer' && replica.required.includes('key'), 'replica.ready carries the verified copy and its size');
+}
+
// ── Mod manifest 1.1.0 (v0.34.0, ADR-013 amendment) ──────────────────────
// ── Mod manifest 1.1.0 (v0.34.0, ADR-013 amendment) ──────────────────────
// Read/write grants, billing hooks and dependencies are optional, so every 1.0.0 manifest stays
// Read/write grants, billing hooks and dependencies are optional, so every 1.0.0 manifest stays
// valid; the split uses exactly the namespace patterns of the older lists; money goes only through
// valid; the split uses exactly the namespace patterns of the older lists; money goes only through