openvibers/openvibe.contracts Commits

Publish Media placement events in the media namespace (#17)

OP OpenVibers committed 3 Oct 2026 · 76b3444f03 · 31 files, +1049 −0

local checks ✓ 3/3 · CI ✓ 3/3 · review SHIP by DeepSeek harness

https://github.com/OpenVibers/OpenVibe.Contracts/pull/17

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 1caea47b..68c92320 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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,
ddiff --git a/contracts/catalog.json b/contracts/catalog.json
iindex 4eb3b39c..61b41a6c 100644
---- a/contracts/catalog.json
++++ b/contracts/catalog.json
@@@ -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",
ddiff --git a/contracts/events/payloads/media.provider.capacity_warning.v1.json b/contracts/events/payloads/media.provider.capacity_warning.v1.json
nnew file mode 100644
iindex 00000000..943a6bf6
---- /dev/null
++++ b/contracts/events/payloads/media.provider.capacity_warning.v1.json
@@@ -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."
++ }
++ }
++}
ddiff --git a/contracts/events/payloads/media.provider.health_degraded.v1.json b/contracts/events/payloads/media.provider.health_degraded.v1.json
nnew file mode 100644
iindex 00000000..85588413
---- /dev/null
++++ b/contracts/events/payloads/media.provider.health_degraded.v1.json
@@@ -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."
++ }
++ }
++}
ddiff --git a/contracts/events/payloads/media.replica.draining.v1.json b/contracts/events/payloads/media.replica.draining.v1.json
nnew file mode 100644
iindex 00000000..dc1ffc17
---- /dev/null
++++ b/contracts/events/payloads/media.replica.draining.v1.json
@@@ -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."
++ }
++ }
++}
ddiff --git a/contracts/events/payloads/media.replica.evicted.v1.json b/contracts/events/payloads/media.replica.evicted.v1.json
nnew file mode 100644
iindex 00000000..3edbbadb
---- /dev/null
++++ b/contracts/events/payloads/media.replica.evicted.v1.json
@@@ -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."
++ }
++ }
++}
ddiff --git a/contracts/events/payloads/media.replica.ready.v1.json b/contracts/events/payloads/media.replica.ready.v1.json
nnew file mode 100644
iindex 00000000..1fbaa65e
---- /dev/null
++++ b/contracts/events/payloads/media.replica.ready.v1.json
@@@ -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."
++ }
++ }
++}
ddiff --git a/contracts/events/payloads/media.replica.requested.v1.json b/contracts/events/payloads/media.replica.requested.v1.json
nnew file mode 100644
iindex 00000000..42e73ee5
---- /dev/null
++++ b/contracts/events/payloads/media.replica.requested.v1.json
@@@ -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."
++ }
++ }
++}
ddiff --git a/fixtures/media.provider.capacity_warning/invalid/leaks-reason.json b/fixtures/media.provider.capacity_warning/invalid/leaks-reason.json
nnew file mode 100644
iindex 00000000..d2e895b1
---- /dev/null
++++ b/fixtures/media.provider.capacity_warning/invalid/leaks-reason.json
@@@ -0,0 +1 @@
++{ "app_id": null, "provider": "r2", "classes": [], "previous_classes": ["online-hot"], "reason": "probe lost range_206" }
ddiff --git a/fixtures/media.provider.capacity_warning/invalid/unknown-class.json b/fixtures/media.provider.capacity_warning/invalid/unknown-class.json
nnew file mode 100644
iindex 00000000..1a7177f4
---- /dev/null
++++ b/fixtures/media.provider.capacity_warning/invalid/unknown-class.json
@@@ -0,0 +1 @@
++{ "app_id": null, "provider": "r2", "classes": [], "previous_classes": ["online-warm"] }
ddiff --git a/fixtures/media.provider.capacity_warning/valid/r2-loses-hot.json b/fixtures/media.provider.capacity_warning/valid/r2-loses-hot.json
nnew file mode 100644
iindex 00000000..b420ec90
---- /dev/null
++++ b/fixtures/media.provider.capacity_warning/valid/r2-loses-hot.json
@@@ -0,0 +1 @@
++{ "app_id": null, "provider": "r2", "classes": [], "previous_classes": ["online-hot"] }
ddiff --git a/fixtures/media.provider.health_degraded/invalid/healthy-true.json b/fixtures/media.provider.health_degraded/invalid/healthy-true.json
nnew file mode 100644
iindex 00000000..ec7b02c0
---- /dev/null
++++ b/fixtures/media.provider.health_degraded/invalid/healthy-true.json
@@@ -0,0 +1 @@
++{ "app_id": null, "provider": "r2", "healthy": true, "last_error": "HeadBucket 503 Service Unavailable" }
ddiff --git a/fixtures/media.provider.health_degraded/invalid/unknown-provider.json b/fixtures/media.provider.health_degraded/invalid/unknown-provider.json
nnew file mode 100644
iindex 00000000..774d9fab
---- /dev/null
++++ b/fixtures/media.provider.health_degraded/invalid/unknown-provider.json
@@@ -0,0 +1 @@
++{ "app_id": null, "provider": "s3", "healthy": false, "last_error": "HeadBucket 503 Service Unavailable" }
ddiff --git a/fixtures/media.provider.health_degraded/valid/r2-outage.json b/fixtures/media.provider.health_degraded/valid/r2-outage.json
nnew file mode 100644
iindex 00000000..b848b986
---- /dev/null
++++ b/fixtures/media.provider.health_degraded/valid/r2-outage.json
@@@ -0,0 +1 @@
++{ "app_id": null, "provider": "r2", "healthy": false, "last_error": "HeadBucket 503 Service Unavailable" }
ddiff --git a/fixtures/media.replica.draining/invalid/leaks-bytes.json b/fixtures/media.replica.draining/invalid/leaks-bytes.json
nnew file mode 100644
iindex 00000000..bd9a9abc
---- /dev/null
++++ b/fixtures/media.replica.draining/invalid/leaks-bytes.json
@@@ -0,0 +1 @@
++{ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ", "app_id": "live", "class": "video", "action": "demote", "provider": "r2", "key": "vods/med_01JAB2C3D4E5F6G7H8J9K0MNPQ.mp4", "bytes": 1048576 }
ddiff --git a/fixtures/media.replica.draining/invalid/promote-action.json b/fixtures/media.replica.draining/invalid/promote-action.json
nnew file mode 100644
iindex 00000000..9b5d7e01
---- /dev/null
++++ b/fixtures/media.replica.draining/invalid/promote-action.json
@@@ -0,0 +1 @@
++{ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ", "app_id": "live", "class": "video", "action": "promote", "provider": "r2", "key": "vods/med_01JAB2C3D4E5F6G7H8J9K0MNPQ.mp4" }
ddiff --git a/fixtures/media.replica.draining/valid/drain-r2.json b/fixtures/media.replica.draining/valid/drain-r2.json
nnew file mode 100644
iindex 00000000..457cd450
---- /dev/null
++++ b/fixtures/media.replica.draining/valid/drain-r2.json
@@@ -0,0 +1 @@
++{ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ", "app_id": "live", "class": "video", "action": "demote", "provider": "r2", "key": "vods/med_01JAB2C3D4E5F6G7H8J9K0MNPQ.mp4" }
ddiff --git a/fixtures/media.replica.evicted/invalid/missing-provider.json b/fixtures/media.replica.evicted/invalid/missing-provider.json
nnew file mode 100644
iindex 00000000..cb277a65
---- /dev/null
++++ b/fixtures/media.replica.evicted/invalid/missing-provider.json
@@@ -0,0 +1 @@
++{ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ", "app_id": "live", "class": "video", "action": "demote", "key": "vods/med_01JAB2C3D4E5F6G7H8J9K0MNPQ.mp4" }
ddiff --git a/fixtures/media.replica.evicted/invalid/promote-action.json b/fixtures/media.replica.evicted/invalid/promote-action.json
nnew file mode 100644
iindex 00000000..9b5d7e01
---- /dev/null
++++ b/fixtures/media.replica.evicted/invalid/promote-action.json
@@@ -0,0 +1 @@
++{ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ", "app_id": "live", "class": "video", "action": "promote", "provider": "r2", "key": "vods/med_01JAB2C3D4E5F6G7H8J9K0MNPQ.mp4" }
ddiff --git a/fixtures/media.replica.evicted/valid/evict-r2.json b/fixtures/media.replica.evicted/valid/evict-r2.json
nnew file mode 100644
iindex 00000000..457cd450
---- /dev/null
++++ b/fixtures/media.replica.evicted/valid/evict-r2.json
@@@ -0,0 +1 @@
++{ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ", "app_id": "live", "class": "video", "action": "demote", "provider": "r2", "key": "vods/med_01JAB2C3D4E5F6G7H8J9K0MNPQ.mp4" }
ddiff --git a/fixtures/media.replica.ready/invalid/negative-bytes.json b/fixtures/media.replica.ready/invalid/negative-bytes.json
nnew file mode 100644
iindex 00000000..b31b279b
---- /dev/null
++++ b/fixtures/media.replica.ready/invalid/negative-bytes.json
@@@ -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" }
ddiff --git a/fixtures/media.replica.ready/invalid/no-key.json b/fixtures/media.replica.ready/invalid/no-key.json
nnew file mode 100644
iindex 00000000..26eb504b
---- /dev/null
++++ b/fixtures/media.replica.ready/invalid/no-key.json
@@@ -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" }
ddiff --git a/fixtures/media.replica.ready/valid/promoted-video.json b/fixtures/media.replica.ready/valid/promoted-video.json
nnew file mode 100644
iindex 00000000..1e7351df
---- /dev/null
++++ b/fixtures/media.replica.ready/valid/promoted-video.json
@@@ -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" }
ddiff --git a/fixtures/media.replica.requested/invalid/leaks-title.json b/fixtures/media.replica.requested/invalid/leaks-title.json
nnew file mode 100644
iindex 00000000..3f2aacb5
---- /dev/null
++++ b/fixtures/media.replica.requested/invalid/leaks-title.json
@@@ -0,0 +1 @@
++{ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ", "app_id": "live", "class": "video", "action": "promote", "from": "local", "to": "r2", "provider": "r2", "title": "My stream" }
ddiff --git a/fixtures/media.replica.requested/invalid/unknown-action.json b/fixtures/media.replica.requested/invalid/unknown-action.json
nnew file mode 100644
iindex 00000000..a946ce15
---- /dev/null
++++ b/fixtures/media.replica.requested/invalid/unknown-action.json
@@@ -0,0 +1 @@
++{ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ", "app_id": "live", "class": "video", "action": "move", "from": "local", "to": "r2", "provider": "r2" }
ddiff --git a/fixtures/media.replica.requested/valid/no-canonical-copy.json b/fixtures/media.replica.requested/valid/no-canonical-copy.json
nnew file mode 100644
iindex 00000000..7e3f5ad0
---- /dev/null
++++ b/fixtures/media.replica.requested/valid/no-canonical-copy.json
@@@ -0,0 +1 @@
++{ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ", "app_id": "prj_01JAB2C3D4E5F6G7H8J9K0MNPQ", "class": "image", "action": "promote", "from": null, "to": "r2", "provider": "r2" }
ddiff --git a/fixtures/media.replica.requested/valid/promote-local-to-r2.json b/fixtures/media.replica.requested/valid/promote-local-to-r2.json
nnew file mode 100644
iindex 00000000..abc90dd8
---- /dev/null
++++ b/fixtures/media.replica.requested/valid/promote-local-to-r2.json
@@@ -0,0 +1 @@
++{ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ", "app_id": "live", "class": "video", "action": "promote", "from": "local", "to": "r2", "provider": "r2" }
ddiff --git a/generated/json-schema/bundle.json b/generated/json-schema/bundle.json
iindex 35822d17..c9eb87db 100644
---- a/generated/json-schema/bundle.json
++++ b/generated/json-schema/bundle.json
@@@ -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",
ddiff --git a/generated/typescript/index.d.ts b/generated/typescript/index.d.ts
iindex 4c326115..55ed6ec7 100644
---- a/generated/typescript/index.d.ts
++++ b/generated/typescript/index.d.ts
@@@ -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).
ddiff --git a/manifests/services/media.json b/manifests/services/media.json
iindex 8cc13307..76243977 100644
---- a/manifests/services/media.json
++++ b/manifests/services/media.json
@@@ -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",
ddiff --git a/test/run.js b/test/run.js
iindex 001e4eeb..55ef7c21 100644
---- a/test/run.js
++++ b/test/run.js
@@@ -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');
}
++// ── 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