openvibers/openvibe.contracts Commits

v0.32.0: payload contracts for tips.interaction.moderated|erased, media.job.*, live.stream.started|ended, live.release.deployed and network.module.updated; capability tips.interaction.moderate; chat.preferences owned by chat; Host takedown and Wiki import/review routes

OP OpenVibers committed 23 Sept 2026 · ccfecb7d77 · 83 files, +4319 −20

Every new payload contract is read from the producer's code and was checked against payloads that code builds (Media's queue, Live's envelopeFor, Network's buildEnvelope).

Tips (from OpenVibe.Tips docs/events-proposal and docs/capabilities-proposal): tips.interaction.moderated@1 (filtered | held | hidden | restored, by filter | creator | moderator | service, the moderation state after it and the deliveries a hide cancelled; no supporter, message, reason or moderator) and tips.interaction.erased@1 (erased_at and a redacts directive for the interaction's earlier events). Capability tips.interaction.moderate (internal): GET /api/v1/moderation, GET /api/v1/moderation/log, POST /api/v1/interactions/:id/hide|restore. Tips manifest 0.4.0 lists all three; its notes are unchanged.

Media: media.job.proposed|queued|started|retrying|succeeded|failed|cancelled@1, one contract per server/events.js JOB_TRANSITIONS entry, like tools.job.*. The payload is queue.jobPublic (every field always present) with a result over 8 KB replaced by { omitted: true, reason }; times are SQLite UTC 'YYYY-MM-DD HH:MM:SS'. retrying carries status queued, started status running; succeeded has no error, failed and retrying always have one, retrying a run_after. Media manifest 0.2.0 lists them.

Live: live.stream.started@1 and live.stream.ended@1 (public; channel username, display name, URL and user subject only when Live knows it, category null when none, protocol jsmpeg|webrtc|rtmp; ended adds ended_at and duration_seconds) and live.release.deployed@1 (internal; head, short release, previous head, 1-40 commits, deployed_at, notes_url).

Network: network.module.updated@1 (owner user or guest, namespace, namespace_owner, schema_version, revision, change, reason, keys; public only with changed public fields and never on a deleted record; merged_into on the absorbed side's delete, merged_from on the record moved to the survivor). Network manifest 0.3.0 produces it and consumes live.stream.started (its go-live notifications consumer's TOPICS).

chat.preferences is owned by chat, whose manifest (0.4.0) lists it in namespacesOwned; chat.tts_defaults stays with live (Network keeps Live as its owner and writer, Chat does not use it). modules.namespace@1's description says account removal deletes a person's records and a merge keeps the survivor's, apart from onOwnerRemoved.

Routes: host.site.manage adds POST|DELETE /api/v1/projects/:id/takedown and POST|DELETE /api/v1/sites/:id/takedown (staff); wiki.page.create adds POST /api/v1/spaces/:space/import; wiki.revision.publish adds POST /api/v1/pages/:id/revisions/:n/review. test/run.js checks the Media statuses per transition, that the stream events describe the channel alike, that the moderation capability announces its event, that a deleted module record carries no values, and that chat.preferences is Chat's. README: 173 capabilities; User modules mentions the event.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 35c60bdf..d2706921 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -27,6 +27,33 @@ Additive: `compat.js` reports no breaking change against v0.31.0.
goes to its stream, then its VOD, then the channel recorded on it, never the clipper; a VOD goes to
its stream, then its slot, then the owner recorded on it. A display name alone resolves nothing.
- New capability `live.lineage.resolve` (Live, internal): `GET|POST /internal/lineage/resolve`.
++- **Event payload contracts for events already emitted**, each read from the producer's code and checked
++ against payloads it builds:
++ - `tips.interaction.moderated@1` (a paid message filtered, held, hidden or restored, by role; the
++ deliveries a hide cancelled; never the supporter, message, reason or moderator) and
++ `tips.interaction.erased@1` (a supporter erased their data; `redacts` takes back the interaction's
++ earlier events), from OpenVibe.Tips' drafts. New capability `tips.interaction.moderate` (Tips,
++ internal): the moderation queue and log, hide and restore. The Tips manifest (0.4.0) lists all three.
++ - `media.job.proposed|queued|started|retrying|succeeded|failed|cancelled@1`: OpenVibe.Media's job
++ transitions (`server/events.js` `recordJob`), one contract each like `tools.job.*`. The payload is
++ the job as `GET /api/v2/:app/jobs/:id` answers it (`queue.jobPublic`, every field always present,
++ times as SQLite `YYYY-MM-DD HH:MM:SS` UTC); a result over 8 KB is `{ omitted: true, reason }`.
++ `retrying` carries status `queued`, `started` status `running`. The Media manifest (0.2.0) lists them.
++ - `live.stream.started@1` and `live.stream.ended@1` (public: stream id, channel username, display
++ name, URL and user subject when Live knows it, title, category or null, protocol, NSFW flag,
++ times; `ended` adds `ended_at` and `duration_seconds`) and `live.release.deployed@1` (internal:
++ head commit, short release, previous head, up to 40 new commits, `deployed_at`, `notes_url`).
++ - `network.module.updated@1`: one event per change to a user-module record (write, delete by the
++ person or the owning service, account removal, account merge), with the revision after the
++ change, the changed field names and only the new values of changed public fields. A deleted
++ record carries no values; `merged_into` and `merged_from` mark the two sides of a merge. The
++ Network manifest (0.3.0) produces it and consumes `live.stream.started` (go-live notifications).
++- `chat.preferences` is owned by `chat` (OpenVibe.Chat since the Wave 6 cutover); the Chat manifest
++ (0.4.0) lists it in `namespacesOwned`. `chat.tts_defaults` stays with Live. `modules.namespace@1`'s
++ description says what account removal and merge do to a person's records, apart from `onOwnerRemoved`.
++- Routes: `host.site.manage` adds the staff takedowns (`POST|DELETE /api/v1/projects/:id/takedown`,
++ `POST|DELETE /api/v1/sites/:id/takedown`); `wiki.page.create` adds `POST /api/v1/spaces/:space/import`
++ and `wiki.revision.publish` adds `POST /api/v1/pages/:id/revisions/:n/review`.
## 0.31.0 — 2026-09-23
ddiff --git a/README.md b/README.md
iindex 77feb76d..ba89ae17 100644
---- a/README.md
++++ b/README.md
@@@ -60,7 +60,7 @@ TypeScript types: `generated/typescript/index.d.ts` (`"types"` in package.json).
| `events.event-envelope` | events | durable event shape for OpenVibe.Events (Wave 3) |
| `media.media-ref` | media | `med_<ULID>` (Wave 4) or transitional `legacy:<app>:<kind>:<id>` |
--Manifests: `manifests/services/` (30 services as of v0.30.0: 23 `alpha`, 1 `beta`, 5 `stable` and 1 `placeholder`, realtime; SDK and Shared are released libraries and Examples a repository with CI, none of them a runtime), `manifests/capabilities/` (172 capabilities as of v0.32.0; the v0.1 set was Media upload/read, chat send, paste create, coins credit/debit, notifications push, subject resolve and Community post). Each active capability names the route that implements it today. Where a service is reachable today (public, loopback only, library) is Network's observed overlay (`server/registry/exposure.js` there); manifest notes say whether a service is deployed and launched publicly.
++Manifests: `manifests/services/` (30 services as of v0.30.0: 23 `alpha`, 1 `beta`, 5 `stable` and 1 `placeholder`, realtime; SDK and Shared are released libraries and Examples a repository with CI, none of them a runtime), `manifests/capabilities/` (173 capabilities as of v0.32.0; the v0.1 set was Media upload/read, chat send, paste create, coins credit/debit, notifications push, subject resolve and Community post). Each active capability names the route that implements it today. Where a service is reachable today (public, loopback only, library) is Network's observed overlay (`server/registry/exposure.js` there); manifest notes say whether a service is deployed and launched publicly.
**Ids.** Subjects use prefixed ULIDs: `usr_`, `gst_`, `app_`, `mod_`. Services and system actors use slugs (`live`, `media`). Events use `evt_` and Media objects `med_`.
@@@ -68,6 +68,8 @@ Manifests: `manifests/services/` (30 services as of v0.30.0: 23 `alpha`, 1 `beta
`manifests/namespaces/*.json` define per-subject module namespaces (`modules.namespace@1`): owner, data schema, writers (`owner` service and/or the `user`), public fields, quota and what happens when the owner is retired. OpenVibe.Network stores the records (`modules.module-record@1`, revision-checked writes). `contracts.modules.validateData / publicView / canWrite` apply the same rules everywhere. Modules hold portable preferences and summaries, never domain truth, money or authoritative game inventory.
++Every change to a record is announced as `network.module.updated` (v0.32): the revision after the change, the changed field names and only the new values of changed public fields. Removing an account deletes its records; merging two keeps the survivor's record where both have one and moves the rest. `onOwnerRemoved` is about the owning service being retired, not the person.
++
## Developer-app events (v0.28)
Three `public` capabilities let a developer app (ADR-014) use OpenVibe.Events with a scoped app token, without any first-party `events.*` capability (those stay `internal`):
ddiff --git a/contracts/catalog.json b/contracts/catalog.json
iindex c5363566..9232fec7 100644
---- a/contracts/catalog.json
++++ b/contracts/catalog.json
@@@ -539,6 +539,26 @@
"status": "active",
"adr": "ADR-012"
},
++ {
++ "id": "tips.interaction.moderated",
++ "version": "1.0.0",
++ "owner": "tips",
++ "schema": "events/payloads/tips.interaction.moderated.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active",
++ "adr": "ADR-012"
++ },
++ {
++ "id": "tips.interaction.erased",
++ "version": "1.0.0",
++ "owner": "tips",
++ "schema": "events/payloads/tips.interaction.erased.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active",
++ "adr": "ADR-012"
++ },
{
"id": "tips.goal.updated",
"version": "1.0.0",
@@@ -733,6 +753,115 @@
"visibility": "first-party",
"compatibility": "backward",
"status": "planned"
++ },
++ {
++ "id": "live.stream.started",
++ "version": "1.0.0",
++ "owner": "live",
++ "schema": "events/payloads/live.stream.started.v1.json",
++ "visibility": "public",
++ "compatibility": "backward",
++ "status": "active",
++ "adr": "ADR-004"
++ },
++ {
++ "id": "live.stream.ended",
++ "version": "1.0.0",
++ "owner": "live",
++ "schema": "events/payloads/live.stream.ended.v1.json",
++ "visibility": "public",
++ "compatibility": "backward",
++ "status": "active",
++ "adr": "ADR-004"
++ },
++ {
++ "id": "live.release.deployed",
++ "version": "1.0.0",
++ "owner": "live",
++ "schema": "events/payloads/live.release.deployed.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active",
++ "adr": "ADR-004"
++ },
++ {
++ "id": "media.job.proposed",
++ "version": "1.0.0",
++ "owner": "media",
++ "schema": "events/payloads/media.job.proposed.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active",
++ "adr": "ADR-004"
++ },
++ {
++ "id": "media.job.queued",
++ "version": "1.0.0",
++ "owner": "media",
++ "schema": "events/payloads/media.job.queued.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active",
++ "adr": "ADR-004"
++ },
++ {
++ "id": "media.job.started",
++ "version": "1.0.0",
++ "owner": "media",
++ "schema": "events/payloads/media.job.started.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active",
++ "adr": "ADR-004"
++ },
++ {
++ "id": "media.job.retrying",
++ "version": "1.0.0",
++ "owner": "media",
++ "schema": "events/payloads/media.job.retrying.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active",
++ "adr": "ADR-004"
++ },
++ {
++ "id": "media.job.succeeded",
++ "version": "1.0.0",
++ "owner": "media",
++ "schema": "events/payloads/media.job.succeeded.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active",
++ "adr": "ADR-004"
++ },
++ {
++ "id": "media.job.failed",
++ "version": "1.0.0",
++ "owner": "media",
++ "schema": "events/payloads/media.job.failed.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active",
++ "adr": "ADR-004"
++ },
++ {
++ "id": "media.job.cancelled",
++ "version": "1.0.0",
++ "owner": "media",
++ "schema": "events/payloads/media.job.cancelled.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active",
++ "adr": "ADR-004"
++ },
++ {
++ "id": "network.module.updated",
++ "version": "1.0.0",
++ "owner": "network",
++ "schema": "events/payloads/network.module.updated.v1.json",
++ "visibility": "first-party",
++ "compatibility": "backward",
++ "status": "active"
}
]
}
ddiff --git a/contracts/events/payloads/live.release.deployed.v1.json b/contracts/events/payloads/live.release.deployed.v1.json
nnew file mode 100644
iindex 00000000..9045efb2
---- /dev/null
++++ b/contracts/events/payloads/live.release.deployed.v1.json
@@@ -0,0 +1,31 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/live.release.deployed.v1.json",
++ "title": "LiveReleaseDeployedPayload",
++ "description": "live.release.deployed v1 (OpenVibe.Live server/events/release-events.js envelopeFor, called by server/chat/deploy-notice.js announce). The first boot that runs commits never announced before: queued in the transaction that records them as announced (site setting deploy_last_announced), so a restart without new code emits nothing and a crash before that commit announces again on the next boot with the same subject. A consumer keys on subject.id (the head commit) to fold repeats. The commit subjects are public already (Live chat and /updates); the consumer decides what is shown. Envelope: subject { type: release, id: <head commit, 40 hex> }, visibility internal, priority low, actor service:live.",
++ "type": "object",
++ "required": ["service", "release", "commit", "previous", "commit_count", "commits", "deployed_at", "notes_url"],
++ "additionalProperties": false,
++ "properties": {
++ "service": { "const": "live" },
++ "release": { "type": "string", "pattern": "^[0-9a-f]{7,40}$", "description": "The head commit's short sha as git abbreviated it (its first 12 characters when the head is not in the list)." },
++ "commit": { "type": "string", "pattern": "^[0-9a-f]{40}$", "description": "The head commit (the envelope subject id)." },
++ "previous": { "type": ["string", "null"], "pattern": "^[0-9a-f]{40}$", "description": "The head announced before this one; null on the first announcement." },
++ "commit_count": { "type": "integer", "minimum": 1, "maximum": 40, "description": "How many new commits were found (git log previous..head, at most 40; only the head on a first run or after history was rewritten)." },
++ "commits": {
++ "type": "array", "minItems": 1, "maxItems": 40,
++ "description": "The new commits, newest first.",
++ "items": {
++ "type": "object", "required": ["hash", "short", "subject", "date"], "additionalProperties": false,
++ "properties": {
++ "hash": { "type": "string", "pattern": "^[0-9a-f]{40}$" },
++ "short": { "type": "string", "pattern": "^[0-9a-f]{4,40}$" },
++ "subject": { "type": "string", "maxLength": 200, "description": "The commit subject, cut to 200 characters." },
++ "date": { "type": ["string", "null"], "format": "date-time", "description": "Author date (git %aI, with the author's UTC offset)." }
++ }
++ }
++ },
++ "deployed_at": { "type": "string", "format": "date-time", "description": "When this boot announced it (UTC)." },
++ "notes_url": { "const": "https://openvibe.live/updates" }
++ }
++}
ddiff --git a/contracts/events/payloads/live.stream.ended.v1.json b/contracts/events/payloads/live.stream.ended.v1.json
nnew file mode 100644
iindex 00000000..eaae4b7e
---- /dev/null
++++ b/contracts/events/payloads/live.stream.ended.v1.json
@@@ -0,0 +1,32 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/live.stream.ended.v1.json",
++ "title": "LiveStreamEndedPayload",
++ "description": "live.stream.ended v1 (OpenVibe.Live server/events/stream-events.js envelopeFor, fired by server/db/database.js endStream). A live streams row ended (the streamer stopped, ingest dropped, a newer session replaced it on the same slot, or an admin ended it). Ending a row that is not live emits nothing, so each stream ends once. Written to Live's event_outbox in the transaction that ends the row. Everything live.stream.started carries, as it is at the end (the title and category may have changed), plus ended_at and duration_seconds. Carries public channel facts only (the stream is listed publicly already); consumers such as Network's go-live notifications decide who hears about it. Never the stream key, the Live user id or the description. Envelope: subject { type: stream, id: <stream_id as a string>, revision: 2 }, visibility public, priority important, actor the streamer's user subject when Live knows it, else service:live.",
++ "type": "object",
++ "required": ["stream_id", "channel", "title", "category", "protocol", "is_nsfw", "started_at", "ended_at", "duration_seconds"],
++ "additionalProperties": false,
++ "properties": {
++ "stream_id": { "type": "integer", "minimum": 1, "description": "Live's streams row id (the envelope subject id)." },
++ "channel": {
++ "type": "object", "required": ["username", "display_name", "url"], "additionalProperties": false,
++ "properties": {
++ "username": { "type": "string", "minLength": 1, "description": "The streamer's Live username (the channel slug)." },
++ "display_name": { "type": "string", "minLength": 1, "description": "The display name, or the username when there is none." },
++ "url": { "type": "string", "format": "uri", "pattern": "^https://openvibe\\.live/", "description": "The channel page, https://openvibe.live/@<username, URI-encoded>. Events from Live before 4c70332 (2026-09-23) carry https://openvibe.live/<username>, which Live answers with a 301 to the @ address since that commit." },
++ "subject": {
++ "type": "object", "required": ["type", "id"], "additionalProperties": false,
++ "properties": { "type": { "const": "user" }, "id": { "type": "string", "pattern": "^usr_[0-9A-HJKMNP-TV-Z]{26}$" } },
++ "description": "The streamer's canonical subject; absent until Live has seen it (a Network sign-in or the subject backfill)."
++ }
++ }
++ },
++ "title": { "type": "string" },
++ "category": { "type": ["string", "null"], "description": "null when the streamer chose none (the AI classifies the stream from what it shows)." },
++ "protocol": { "enum": ["jsmpeg", "webrtc", "rtmp"], "description": "How the stream is ingested (WHIP is webrtc; OpenRe and RTMP are rtmp)." },
++ "is_nsfw": { "type": "boolean" },
++ "started_at": { "type": ["string", "null"], "format": "date-time", "description": "UTC. null only for a row without a start time, which createStream never writes." },
++ "ended_at": { "type": "string", "format": "date-time", "description": "UTC." },
++ "duration_seconds": { "type": ["integer", "null"], "minimum": 0, "description": "Whole seconds from started_at to ended_at; null when the row has no start time." }
++ }
++}
ddiff --git a/contracts/events/payloads/live.stream.started.v1.json b/contracts/events/payloads/live.stream.started.v1.json
nnew file mode 100644
iindex 00000000..51d88726
---- /dev/null
++++ b/contracts/events/payloads/live.stream.started.v1.json
@@@ -0,0 +1,30 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/live.stream.started.v1.json",
++ "title": "LiveStreamStartedPayload",
++ "description": "live.stream.started v1 (OpenVibe.Live server/events/stream-events.js envelopeFor, fired by server/db/database.js createStream). A streams row went live: WebRTC/JSMPEG from the dashboard, RTMP ingest, WHIP, or an OpenRe.Stream session mirrored into Live. Written to Live's event_outbox in the transaction that inserts the row, so the event exists if and only if the stream did. Carries public channel facts only (the stream is listed publicly already); consumers such as Network's go-live notifications decide who hears about it. Never the stream key, the Live user id or the description. Envelope: subject { type: stream, id: <stream_id as a string>, revision: 1 }, visibility public, priority important, actor the streamer's user subject when Live knows it, else service:live.",
++ "type": "object",
++ "required": ["stream_id", "channel", "title", "category", "protocol", "is_nsfw", "started_at"],
++ "additionalProperties": false,
++ "properties": {
++ "stream_id": { "type": "integer", "minimum": 1, "description": "Live's streams row id (the envelope subject id)." },
++ "channel": {
++ "type": "object", "required": ["username", "display_name", "url"], "additionalProperties": false,
++ "properties": {
++ "username": { "type": "string", "minLength": 1, "description": "The streamer's Live username (the channel slug)." },
++ "display_name": { "type": "string", "minLength": 1, "description": "The display name, or the username when there is none." },
++ "url": { "type": "string", "format": "uri", "pattern": "^https://openvibe\\.live/", "description": "The channel page, https://openvibe.live/@<username, URI-encoded>. Events from Live before 4c70332 (2026-09-23) carry https://openvibe.live/<username>, which Live answers with a 301 to the @ address since that commit." },
++ "subject": {
++ "type": "object", "required": ["type", "id"], "additionalProperties": false,
++ "properties": { "type": { "const": "user" }, "id": { "type": "string", "pattern": "^usr_[0-9A-HJKMNP-TV-Z]{26}$" } },
++ "description": "The streamer's canonical subject; absent until Live has seen it (a Network sign-in or the subject backfill)."
++ }
++ }
++ },
++ "title": { "type": "string" },
++ "category": { "type": ["string", "null"], "description": "null when the streamer chose none (the AI classifies the stream from what it shows)." },
++ "protocol": { "enum": ["jsmpeg", "webrtc", "rtmp"], "description": "How the stream is ingested (WHIP is webrtc; OpenRe and RTMP are rtmp)." },
++ "is_nsfw": { "type": "boolean" },
++ "started_at": { "type": ["string", "null"], "format": "date-time", "description": "UTC. null only for a row without a start time, which createStream never writes." }
++ }
++}
ddiff --git a/contracts/events/payloads/media.job.cancelled.v1.json b/contracts/events/payloads/media.job.cancelled.v1.json
nnew file mode 100644
iindex 00000000..fc050408
---- /dev/null
++++ b/contracts/events/payloads/media.job.cancelled.v1.json
@@@ -0,0 +1,33 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/media.job.cancelled.v1.json",
++ "title": "MediaJobCancelledPayload",
++ "description": "media.job.cancelled v1 (OpenVibe.Media server/jobs/queue.js cancel, markCancelled; server/events.js recordJob). The job was cancelled: a proposal or a queued job at once, by its owner (POST /api/v2/:app/jobs/:id/cancel or DELETE /api/v2/:app/jobs/:id) or by the validator withdrawing a proposal that no longer applies (decided_by system:invariant.scan); a running job once it stopped after its owner asked (cancel_requested true, error_code cancelled). Emitted in the SQLite transaction that records the state change, so the event exists if and only if the change committed; it goes to OpenVibe.Events only (no app webhook). The payload is the job as GET /api/v2/:app/jobs/:id answers it (queue.jobPublic), except that a result larger than 8 KB is replaced by { omitted: true, reason }. It carries the tenant's params, idempotency_key, the free-text error, created_by/decided_by and owner_user_id (a tenant-local integer user id), never the handler's checkpoint, lease or request hash. Times are SQLite UTC timestamps without a zone (YYYY-MM-DD HH:MM:SS). Jobs of developer-project sandbox tenants are not announced. Envelope: subject { type: job, id: <id> }, visibility internal, priority important, actor service:media.",
++ "type": "object",
++ "required": ["id", "app_id", "object_id", "type", "status", "params", "result", "error", "error_code", "attempts", "max_attempts", "run_after", "cancel_requested", "idempotency_key", "created_by", "owner_user_id", "decided_by", "decided_at", "created_at", "updated_at", "started_at", "finished_at"],
++ "additionalProperties": false,
++ "properties": {
++ "id": { "type": "string", "pattern": "^mjob_[0-9A-HJKMNP-TV-Z]{26}$" },
++ "app_id": { "type": "string", "minLength": 1, "maxLength": 200, "description": "The tenant that owns the job: an app (live, tools…) or a developer project's production tenant (prj_…)." },
++ "object_id": { "type": ["string", "null"], "maxLength": 200, "description": "The object the job works on; null for a tenant-wide job (invariant.scan)." },
++ "type": { "type": "string", "pattern": "^[a-z][a-z0-9]*\\.[a-z0-9_.]+$", "maxLength": 80, "description": "Job type: thumbnail.regenerate, invariant.scan, object.split or object.remux." },
++ "status": { "const": "cancelled" },
++ "params": { "type": "object", "description": "The parameters the job type accepted (at most 16 KB). object.split keeps every field the caller sent." },
++ "result": { "type": ["object", "null"], "description": "null until the job finished; a failed or cancelled job may keep what its handler reported. { omitted: true, reason } when larger than 8 KB." },
++ "error": { "type": ["string", "null"], "maxLength": 1000, "description": "The last failure's message (free text), or null." },
++ "error_code": { "type": ["string", "null"], "description": "Stable code of the last failure (media_unavailable, interrupted, cancelled, media.job.invalid…), or null." },
++ "attempts": { "type": "integer", "minimum": 0 },
++ "max_attempts": { "type": "integer", "minimum": 1, "maximum": 10 },
++ "run_after": { "type": ["string", "null"], "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$", "description": "A queued job waits until then (retry backoff); null otherwise." },
++ "cancel_requested": { "type": "boolean", "description": "Its owner asked a running job to stop." },
++ "idempotency_key": { "type": ["string", "null"], "maxLength": 200, "description": "The creator's Idempotency-Key (1-200 visible ASCII characters), invariant:<object_id>:<type> for a validator proposal, or null." },
++ "created_by": { "type": ["string", "null"], "description": "svc:<service> | app:<app> | app:<app>:user:<id> | system:<what>." },
++ "owner_user_id": { "type": ["integer", "null"], "minimum": 1, "description": "The app's user the job was created for (X-OV-User-Id): an id in the tenant's own user space, not a Network subject." },
++ "decided_by": { "type": ["string", "null"], "description": "Who approved or cancelled a proposal or queued job (same forms as created_by)." },
++ "decided_at": { "type": ["string", "null"], "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$" },
++ "created_at": { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$" },
++ "updated_at": { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$" },
++ "started_at": { "type": ["string", "null"], "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$", "description": "null when the job never ran." },
++ "finished_at": { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$" }
++ }
++}
ddiff --git a/contracts/events/payloads/media.job.failed.v1.json b/contracts/events/payloads/media.job.failed.v1.json
nnew file mode 100644
iindex 00000000..2a0acf63
---- /dev/null
++++ b/contracts/events/payloads/media.job.failed.v1.json
@@@ -0,0 +1,33 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/media.job.failed.v1.json",
++ "title": "MediaJobFailedPayload",
++ "description": "media.job.failed v1 (OpenVibe.Media server/jobs/queue.js fail; server/events.js recordJob). The job failed for good: a permanent error, or its last attempt (attempts = max_attempts) failed; error and error_code say why. A cancelled job is not a failure and is announced as media.job.cancelled. Emitted in the SQLite transaction that records the state change, so the event exists if and only if the change committed; it goes to OpenVibe.Events only (no app webhook). The payload is the job as GET /api/v2/:app/jobs/:id answers it (queue.jobPublic), except that a result larger than 8 KB is replaced by { omitted: true, reason }. It carries the tenant's params, idempotency_key, the free-text error, created_by/decided_by and owner_user_id (a tenant-local integer user id), never the handler's checkpoint, lease or request hash. Times are SQLite UTC timestamps without a zone (YYYY-MM-DD HH:MM:SS). Jobs of developer-project sandbox tenants are not announced. Envelope: subject { type: job, id: <id> }, visibility internal, priority important, actor service:media.",
++ "type": "object",
++ "required": ["id", "app_id", "object_id", "type", "status", "params", "result", "error", "error_code", "attempts", "max_attempts", "run_after", "cancel_requested", "idempotency_key", "created_by", "owner_user_id", "decided_by", "decided_at", "created_at", "updated_at", "started_at", "finished_at"],
++ "additionalProperties": false,
++ "properties": {
++ "id": { "type": "string", "pattern": "^mjob_[0-9A-HJKMNP-TV-Z]{26}$" },
++ "app_id": { "type": "string", "minLength": 1, "maxLength": 200, "description": "The tenant that owns the job: an app (live, tools…) or a developer project's production tenant (prj_…)." },
++ "object_id": { "type": ["string", "null"], "maxLength": 200, "description": "The object the job works on; null for a tenant-wide job (invariant.scan)." },
++ "type": { "type": "string", "pattern": "^[a-z][a-z0-9]*\\.[a-z0-9_.]+$", "maxLength": 80, "description": "Job type: thumbnail.regenerate, invariant.scan, object.split or object.remux." },
++ "status": { "const": "failed" },
++ "params": { "type": "object", "description": "The parameters the job type accepted (at most 16 KB). object.split keeps every field the caller sent." },
++ "result": { "type": ["object", "null"], "description": "null until the job finished; a failed or cancelled job may keep what its handler reported. { omitted: true, reason } when larger than 8 KB." },
++ "error": { "type": "string", "maxLength": 1000, "description": "The failure's message, free text from the handler." },
++ "error_code": { "type": ["string", "null"], "description": "Stable code of the last failure (media_unavailable, interrupted, cancelled, media.job.invalid…), or null." },
++ "attempts": { "type": "integer", "minimum": 0 },
++ "max_attempts": { "type": "integer", "minimum": 1, "maximum": 10 },
++ "run_after": { "type": ["string", "null"], "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$", "description": "A queued job waits until then (retry backoff); null otherwise." },
++ "cancel_requested": { "type": "boolean", "description": "Its owner asked a running job to stop." },
++ "idempotency_key": { "type": ["string", "null"], "maxLength": 200, "description": "The creator's Idempotency-Key (1-200 visible ASCII characters), invariant:<object_id>:<type> for a validator proposal, or null." },
++ "created_by": { "type": ["string", "null"], "description": "svc:<service> | app:<app> | app:<app>:user:<id> | system:<what>." },
++ "owner_user_id": { "type": ["integer", "null"], "minimum": 1, "description": "The app's user the job was created for (X-OV-User-Id): an id in the tenant's own user space, not a Network subject." },
++ "decided_by": { "type": ["string", "null"], "description": "Who approved or cancelled a proposal or queued job (same forms as created_by)." },
++ "decided_at": { "type": ["string", "null"], "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$" },
++ "created_at": { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$" },
++ "updated_at": { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$" },
++ "started_at": { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$" },
++ "finished_at": { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$" }
++ }
++}
ddiff --git a/contracts/events/payloads/media.job.proposed.v1.json b/contracts/events/payloads/media.job.proposed.v1.json
nnew file mode 100644
iindex 00000000..d9e73f2b
---- /dev/null
++++ b/contracts/events/payloads/media.job.proposed.v1.json
@@@ -0,0 +1,33 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/media.job.proposed.v1.json",
++ "title": "MediaJobProposedPayload",
++ "description": "media.job.proposed v1 (OpenVibe.Media server/jobs/queue.js enqueue; server/events.js recordJob). A job was proposed and waits for its owner: approving it queues it (media.job.queued), cancelling it ends it (media.job.cancelled); the worker never runs a proposal. The size-invariant validator (invariant.scan, created_by system:invariant.scan) proposes object.split and object.remux this way. Emitted in the SQLite transaction that records the state change, so the event exists if and only if the change committed; it goes to OpenVibe.Events only (no app webhook). The payload is the job as GET /api/v2/:app/jobs/:id answers it (queue.jobPublic), except that a result larger than 8 KB is replaced by { omitted: true, reason }. It carries the tenant's params, idempotency_key, the free-text error, created_by/decided_by and owner_user_id (a tenant-local integer user id), never the handler's checkpoint, lease or request hash. Times are SQLite UTC timestamps without a zone (YYYY-MM-DD HH:MM:SS). Jobs of developer-project sandbox tenants are not announced. Envelope: subject { type: job, id: <id> }, visibility internal, priority important, actor service:media.",
++ "type": "object",
++ "required": ["id", "app_id", "object_id", "type", "status", "params", "result", "error", "error_code", "attempts", "max_attempts", "run_after", "cancel_requested", "idempotency_key", "created_by", "owner_user_id", "decided_by", "decided_at", "created_at", "updated_at", "started_at", "finished_at"],
++ "additionalProperties": false,
++ "properties": {
++ "id": { "type": "string", "pattern": "^mjob_[0-9A-HJKMNP-TV-Z]{26}$" },
++ "app_id": { "type": "string", "minLength": 1, "maxLength": 200, "description": "The tenant that owns the job: an app (live, tools…) or a developer project's production tenant (prj_…)." },
++ "object_id": { "type": ["string", "null"], "maxLength": 200, "description": "The object the job works on; null for a tenant-wide job (invariant.scan)." },
++ "type": { "type": "string", "pattern": "^[a-z][a-z0-9]*\\.[a-z0-9_.]+$", "maxLength": 80, "description": "Job type: thumbnail.regenerate, invariant.scan, object.split or object.remux." },
++ "status": { "const": "proposed" },
++ "params": { "type": "object", "description": "The parameters the job type accepted (at most 16 KB). object.split keeps every field the caller sent." },
++ "result": { "type": ["object", "null"], "description": "null until the job finished; a failed or cancelled job may keep what its handler reported. { omitted: true, reason } when larger than 8 KB." },
++ "error": { "type": ["string", "null"], "maxLength": 1000, "description": "The last failure's message (free text), or null." },
++ "error_code": { "type": ["string", "null"], "description": "Stable code of the last failure (media_unavailable, interrupted, cancelled, media.job.invalid…), or null." },
++ "attempts": { "type": "integer", "minimum": 0 },
++ "max_attempts": { "type": "integer", "minimum": 1, "maximum": 10 },
++ "run_after": { "type": ["string", "null"], "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$", "description": "A queued job waits until then (retry backoff); null otherwise." },
++ "cancel_requested": { "type": "boolean", "description": "Its owner asked a running job to stop." },
++ "idempotency_key": { "type": ["string", "null"], "maxLength": 200, "description": "The creator's Idempotency-Key (1-200 visible ASCII characters), invariant:<object_id>:<type> for a validator proposal, or null." },
++ "created_by": { "type": ["string", "null"], "description": "svc:<service> | app:<app> | app:<app>:user:<id> | system:<what>." },
++ "owner_user_id": { "type": ["integer", "null"], "minimum": 1, "description": "The app's user the job was created for (X-OV-User-Id): an id in the tenant's own user space, not a Network subject." },
++ "decided_by": { "type": ["string", "null"], "description": "Who approved or cancelled a proposal or queued job (same forms as created_by)." },
++ "decided_at": { "type": ["string", "null"], "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$" },
++ "created_at": { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$" },
++ "updated_at": { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$" },
++ "started_at": { "type": "null" },
++ "finished_at": { "type": "null" }
++ }
++}
ddiff --git a/contracts/events/payloads/media.job.queued.v1.json b/contracts/events/payloads/media.job.queued.v1.json
nnew file mode 100644
iindex 00000000..ac9a505a
---- /dev/null
++++ b/contracts/events/payloads/media.job.queued.v1.json
@@@ -0,0 +1,33 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/media.job.queued.v1.json",
++ "title": "MediaJobQueuedPayload",
++ "description": "media.job.queued v1 (OpenVibe.Media server/jobs/queue.js enqueue, approve; server/events.js recordJob). A job was accepted and queued: a new job (POST /api/v2/:app/jobs), or a proposal its owner approved (decided_by, decided_at). An Idempotency-Key replay, or a request joined to an identical job that is still active, creates no job and no event. Emitted in the SQLite transaction that records the state change, so the event exists if and only if the change committed; it goes to OpenVibe.Events only (no app webhook). The payload is the job as GET /api/v2/:app/jobs/:id answers it (queue.jobPublic), except that a result larger than 8 KB is replaced by { omitted: true, reason }. It carries the tenant's params, idempotency_key, the free-text error, created_by/decided_by and owner_user_id (a tenant-local integer user id), never the handler's checkpoint, lease or request hash. Times are SQLite UTC timestamps without a zone (YYYY-MM-DD HH:MM:SS). Jobs of developer-project sandbox tenants are not announced. Envelope: subject { type: job, id: <id> }, visibility internal, priority low, actor service:media.",
++ "type": "object",
++ "required": ["id", "app_id", "object_id", "type", "status", "params", "result", "error", "error_code", "attempts", "max_attempts", "run_after", "cancel_requested", "idempotency_key", "created_by", "owner_user_id", "decided_by", "decided_at", "created_at", "updated_at", "started_at", "finished_at"],
++ "additionalProperties": false,
++ "properties": {
++ "id": { "type": "string", "pattern": "^mjob_[0-9A-HJKMNP-TV-Z]{26}$" },
++ "app_id": { "type": "string", "minLength": 1, "maxLength": 200, "description": "The tenant that owns the job: an app (live, tools…) or a developer project's production tenant (prj_…)." },
++ "object_id": { "type": ["string", "null"], "maxLength": 200, "description": "The object the job works on; null for a tenant-wide job (invariant.scan)." },
++ "type": { "type": "string", "pattern": "^[a-z][a-z0-9]*\\.[a-z0-9_.]+$", "maxLength": 80, "description": "Job type: thumbnail.regenerate, invariant.scan, object.split or object.remux." },
++ "status": { "const": "queued" },
++ "params": { "type": "object", "description": "The parameters the job type accepted (at most 16 KB). object.split keeps every field the caller sent." },
++ "result": { "type": ["object", "null"], "description": "null until the job finished; a failed or cancelled job may keep what its handler reported. { omitted: true, reason } when larger than 8 KB." },
++ "error": { "type": ["string", "null"], "maxLength": 1000, "description": "The last failure's message (free text), or null." },
++ "error_code": { "type": ["string", "null"], "description": "Stable code of the last failure (media_unavailable, interrupted, cancelled, media.job.invalid…), or null." },
++ "attempts": { "type": "integer", "minimum": 0 },
++ "max_attempts": { "type": "integer", "minimum": 1, "maximum": 10 },
++ "run_after": { "type": ["string", "null"], "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$", "description": "A queued job waits until then (retry backoff); null otherwise." },
++ "cancel_requested": { "type": "boolean", "description": "Its owner asked a running job to stop." },
++ "idempotency_key": { "type": ["string", "null"], "maxLength": 200, "description": "The creator's Idempotency-Key (1-200 visible ASCII characters), invariant:<object_id>:<type> for a validator proposal, or null." },
++ "created_by": { "type": ["string", "null"], "description": "svc:<service> | app:<app> | app:<app>:user:<id> | system:<what>." },
++ "owner_user_id": { "type": ["integer", "null"], "minimum": 1, "description": "The app's user the job was created for (X-OV-User-Id): an id in the tenant's own user space, not a Network subject." },
++ "decided_by": { "type": ["string", "null"], "description": "Who approved or cancelled a proposal or queued job (same forms as created_by)." },
++ "decided_at": { "type": ["string", "null"], "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$" },
++ "created_at": { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$" },
++ "updated_at": { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$" },
++ "started_at": { "type": "null" },
++ "finished_at": { "type": "null" }
++ }
++}
ddiff --git a/contracts/events/payloads/media.job.retrying.v1.json b/contracts/events/payloads/media.job.retrying.v1.json
nnew file mode 100644
iindex 00000000..4fcf4854
---- /dev/null
++++ b/contracts/events/payloads/media.job.retrying.v1.json
@@@ -0,0 +1,33 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/media.job.retrying.v1.json",
++ "title": "MediaJobRetryingPayload",
++ "description": "media.job.retrying v1 (OpenVibe.Media server/jobs/queue.js fail with a retry; server/events.js recordJob). An attempt failed and the job is queued again after a backoff (run_after); error and error_code say why. A running job nobody holds any more (the service restarted, or the worker stopped renewing its lease) is retried this way with error_code interrupted while it has attempts left. status is queued. Emitted in the SQLite transaction that records the state change, so the event exists if and only if the change committed; it goes to OpenVibe.Events only (no app webhook). The payload is the job as GET /api/v2/:app/jobs/:id answers it (queue.jobPublic), except that a result larger than 8 KB is replaced by { omitted: true, reason }. It carries the tenant's params, idempotency_key, the free-text error, created_by/decided_by and owner_user_id (a tenant-local integer user id), never the handler's checkpoint, lease or request hash. Times are SQLite UTC timestamps without a zone (YYYY-MM-DD HH:MM:SS). Jobs of developer-project sandbox tenants are not announced. Envelope: subject { type: job, id: <id> }, visibility internal, priority low, actor service:media.",
++ "type": "object",
++ "required": ["id", "app_id", "object_id", "type", "status", "params", "result", "error", "error_code", "attempts", "max_attempts", "run_after", "cancel_requested", "idempotency_key", "created_by", "owner_user_id", "decided_by", "decided_at", "created_at", "updated_at", "started_at", "finished_at"],
++ "additionalProperties": false,
++ "properties": {
++ "id": { "type": "string", "pattern": "^mjob_[0-9A-HJKMNP-TV-Z]{26}$" },
++ "app_id": { "type": "string", "minLength": 1, "maxLength": 200, "description": "The tenant that owns the job: an app (live, tools…) or a developer project's production tenant (prj_…)." },
++ "object_id": { "type": ["string", "null"], "maxLength": 200, "description": "The object the job works on; null for a tenant-wide job (invariant.scan)." },
++ "type": { "type": "string", "pattern": "^[a-z][a-z0-9]*\\.[a-z0-9_.]+$", "maxLength": 80, "description": "Job type: thumbnail.regenerate, invariant.scan, object.split or object.remux." },
++ "status": { "const": "queued" },
++ "params": { "type": "object", "description": "The parameters the job type accepted (at most 16 KB). object.split keeps every field the caller sent." },
++ "result": { "type": ["object", "null"], "description": "null until the job finished; a failed or cancelled job may keep what its handler reported. { omitted: true, reason } when larger than 8 KB." },
++ "error": { "type": "string", "maxLength": 1000, "description": "The failure's message, free text from the handler." },
++ "error_code": { "type": ["string", "null"], "description": "Stable code of the last failure (media_unavailable, interrupted, cancelled, media.job.invalid…), or null." },
++ "attempts": { "type": "integer", "minimum": 0 },
++ "max_attempts": { "type": "integer", "minimum": 1, "maximum": 10 },
++ "run_after": { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$", "description": "Not before this time (the retry backoff)." },
++ "cancel_requested": { "type": "boolean", "description": "Its owner asked a running job to stop." },
++ "idempotency_key": { "type": ["string", "null"], "maxLength": 200, "description": "The creator's Idempotency-Key (1-200 visible ASCII characters), invariant:<object_id>:<type> for a validator proposal, or null." },
++ "created_by": { "type": ["string", "null"], "description": "svc:<service> | app:<app> | app:<app>:user:<id> | system:<what>." },
++ "owner_user_id": { "type": ["integer", "null"], "minimum": 1, "description": "The app's user the job was created for (X-OV-User-Id): an id in the tenant's own user space, not a Network subject." },
++ "decided_by": { "type": ["string", "null"], "description": "Who approved or cancelled a proposal or queued job (same forms as created_by)." },
++ "decided_at": { "type": ["string", "null"], "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$" },
++ "created_at": { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$" },
++ "updated_at": { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$" },
++ "started_at": { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$" },
++ "finished_at": { "type": "null" }
++ }
++}
ddiff --git a/contracts/events/payloads/media.job.started.v1.json b/contracts/events/payloads/media.job.started.v1.json
nnew file mode 100644
iindex 00000000..a52a90ac
---- /dev/null
++++ b/contracts/events/payloads/media.job.started.v1.json
@@@ -0,0 +1,33 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/media.job.started.v1.json",
++ "title": "MediaJobStartedPayload",
++ "description": "media.job.started v1 (OpenVibe.Media server/jobs/queue.js claim; server/events.js recordJob). The worker claimed the job and it is running: attempts counts this attempt, started_at stays the first attempt's start. A job that is retried starts again and is announced again. Emitted in the SQLite transaction that records the state change, so the event exists if and only if the change committed; it goes to OpenVibe.Events only (no app webhook). The payload is the job as GET /api/v2/:app/jobs/:id answers it (queue.jobPublic), except that a result larger than 8 KB is replaced by { omitted: true, reason }. It carries the tenant's params, idempotency_key, the free-text error, created_by/decided_by and owner_user_id (a tenant-local integer user id), never the handler's checkpoint, lease or request hash. Times are SQLite UTC timestamps without a zone (YYYY-MM-DD HH:MM:SS). Jobs of developer-project sandbox tenants are not announced. Envelope: subject { type: job, id: <id> }, visibility internal, priority low, actor service:media.",
++ "type": "object",
++ "required": ["id", "app_id", "object_id", "type", "status", "params", "result", "error", "error_code", "attempts", "max_attempts", "run_after", "cancel_requested", "idempotency_key", "created_by", "owner_user_id", "decided_by", "decided_at", "created_at", "updated_at", "started_at", "finished_at"],
++ "additionalProperties": false,
++ "properties": {
++ "id": { "type": "string", "pattern": "^mjob_[0-9A-HJKMNP-TV-Z]{26}$" },
++ "app_id": { "type": "string", "minLength": 1, "maxLength": 200, "description": "The tenant that owns the job: an app (live, tools…) or a developer project's production tenant (prj_…)." },
++ "object_id": { "type": ["string", "null"], "maxLength": 200, "description": "The object the job works on; null for a tenant-wide job (invariant.scan)." },
++ "type": { "type": "string", "pattern": "^[a-z][a-z0-9]*\\.[a-z0-9_.]+$", "maxLength": 80, "description": "Job type: thumbnail.regenerate, invariant.scan, object.split or object.remux." },
++ "status": { "const": "running" },
++ "params": { "type": "object", "description": "The parameters the job type accepted (at most 16 KB). object.split keeps every field the caller sent." },
++ "result": { "type": ["object", "null"], "description": "null until the job finished; a failed or cancelled job may keep what its handler reported. { omitted: true, reason } when larger than 8 KB." },
++ "error": { "type": ["string", "null"], "maxLength": 1000, "description": "The last failure's message (free text), or null." },
++ "error_code": { "type": ["string", "null"], "description": "Stable code of the last failure (media_unavailable, interrupted, cancelled, media.job.invalid…), or null." },
++ "attempts": { "type": "integer", "minimum": 0 },
++ "max_attempts": { "type": "integer", "minimum": 1, "maximum": 10 },
++ "run_after": { "type": ["string", "null"], "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$", "description": "A queued job waits until then (retry backoff); null otherwise." },
++ "cancel_requested": { "type": "boolean", "description": "Its owner asked a running job to stop." },
++ "idempotency_key": { "type": ["string", "null"], "maxLength": 200, "description": "The creator's Idempotency-Key (1-200 visible ASCII characters), invariant:<object_id>:<type> for a validator proposal, or null." },
++ "created_by": { "type": ["string", "null"], "description": "svc:<service> | app:<app> | app:<app>:user:<id> | system:<what>." },
++ "owner_user_id": { "type": ["integer", "null"], "minimum": 1, "description": "The app's user the job was created for (X-OV-User-Id): an id in the tenant's own user space, not a Network subject." },
++ "decided_by": { "type": ["string", "null"], "description": "Who approved or cancelled a proposal or queued job (same forms as created_by)." },
++ "decided_at": { "type": ["string", "null"], "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$" },
++ "created_at": { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$" },
++ "updated_at": { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$" },
++ "started_at": { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$" },
++ "finished_at": { "type": "null" }
++ }
++}
ddiff --git a/contracts/events/payloads/media.job.succeeded.v1.json b/contracts/events/payloads/media.job.succeeded.v1.json
nnew file mode 100644
iindex 00000000..f75a30f4
---- /dev/null
++++ b/contracts/events/payloads/media.job.succeeded.v1.json
@@@ -0,0 +1,33 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/media.job.succeeded.v1.json",
++ "title": "MediaJobSucceededPayload",
++ "description": "media.job.succeeded v1 (OpenVibe.Media server/jobs/queue.js succeed; server/events.js recordJob). The job finished: result is its type's result (thumbnail.regenerate { url, kind, id }, object.split { source_id, parts, … }, object.remux { source_id, object_id, … }, invariant.scan { thresholds, counts, … }). Emitted in the SQLite transaction that records the state change, so the event exists if and only if the change committed; it goes to OpenVibe.Events only (no app webhook). The payload is the job as GET /api/v2/:app/jobs/:id answers it (queue.jobPublic), except that a result larger than 8 KB is replaced by { omitted: true, reason }. It carries the tenant's params, idempotency_key, the free-text error, created_by/decided_by and owner_user_id (a tenant-local integer user id), never the handler's checkpoint, lease or request hash. Times are SQLite UTC timestamps without a zone (YYYY-MM-DD HH:MM:SS). Jobs of developer-project sandbox tenants are not announced. Envelope: subject { type: job, id: <id> }, visibility internal, priority important, actor service:media.",
++ "type": "object",
++ "required": ["id", "app_id", "object_id", "type", "status", "params", "result", "error", "error_code", "attempts", "max_attempts", "run_after", "cancel_requested", "idempotency_key", "created_by", "owner_user_id", "decided_by", "decided_at", "created_at", "updated_at", "started_at", "finished_at"],
++ "additionalProperties": false,
++ "properties": {
++ "id": { "type": "string", "pattern": "^mjob_[0-9A-HJKMNP-TV-Z]{26}$" },
++ "app_id": { "type": "string", "minLength": 1, "maxLength": 200, "description": "The tenant that owns the job: an app (live, tools…) or a developer project's production tenant (prj_…)." },
++ "object_id": { "type": ["string", "null"], "maxLength": 200, "description": "The object the job works on; null for a tenant-wide job (invariant.scan)." },
++ "type": { "type": "string", "pattern": "^[a-z][a-z0-9]*\\.[a-z0-9_.]+$", "maxLength": 80, "description": "Job type: thumbnail.regenerate, invariant.scan, object.split or object.remux." },
++ "status": { "const": "succeeded" },
++ "params": { "type": "object", "description": "The parameters the job type accepted (at most 16 KB). object.split keeps every field the caller sent." },
++ "result": { "type": ["object", "null"], "description": "The job type's result, or { omitted: true, reason } when it is larger than 8 KB (GET the job for it)." },
++ "error": { "type": "null" },
++ "error_code": { "type": "null" },
++ "attempts": { "type": "integer", "minimum": 0 },
++ "max_attempts": { "type": "integer", "minimum": 1, "maximum": 10 },
++ "run_after": { "type": ["string", "null"], "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$", "description": "A queued job waits until then (retry backoff); null otherwise." },
++ "cancel_requested": { "type": "boolean", "description": "Its owner asked a running job to stop." },
++ "idempotency_key": { "type": ["string", "null"], "maxLength": 200, "description": "The creator's Idempotency-Key (1-200 visible ASCII characters), invariant:<object_id>:<type> for a validator proposal, or null." },
++ "created_by": { "type": ["string", "null"], "description": "svc:<service> | app:<app> | app:<app>:user:<id> | system:<what>." },
++ "owner_user_id": { "type": ["integer", "null"], "minimum": 1, "description": "The app's user the job was created for (X-OV-User-Id): an id in the tenant's own user space, not a Network subject." },
++ "decided_by": { "type": ["string", "null"], "description": "Who approved or cancelled a proposal or queued job (same forms as created_by)." },
++ "decided_at": { "type": ["string", "null"], "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$" },
++ "created_at": { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$" },
++ "updated_at": { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$" },
++ "started_at": { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$" },
++ "finished_at": { "type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$" }
++ }
++}
ddiff --git a/contracts/events/payloads/network.module.updated.v1.json b/contracts/events/payloads/network.module.updated.v1.json
nnew file mode 100644
iindex 00000000..b67cddef
---- /dev/null
++++ b/contracts/events/payloads/network.module.updated.v1.json
@@@ -0,0 +1,52 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/network.module.updated.v1.json",
++ "title": "NetworkModuleUpdatedPayload",
++ "description": "network.module.updated v1 (OpenVibe.Network server/identity/module-events.js buildEnvelope, called from server/identity/modules.js). One event per change to one person's user-module record (modules.module-record@1): a write (created or updated), a delete by the person (delete) or by the owning service (owner_delete, also the retention sweep of a retired owner's delete-after-retention namespace), account removal (subject_removed: every record of the account is deleted) and account merge (subject_merged: the absorbed account's records are deleted, merged_into; a record moved to the surviving account is created there, merged_from; where the survivor already has one, the survivor's is kept and unchanged). Written into Network's event outbox in the transaction that makes the change. revision grows by one on every change to (owner, namespace), deletes included, and never restarts: a consumer keeps the highest revision it applied and ignores anything lower. Values of private fields never leave Network: keys names the changed fields, public carries only the new values of changed public fields. Envelope: subject { type: user_module, id: <owner id>:<namespace>, revision }, visibility internal, actor the person, the owning service or system:network.",
++ "type": "object",
++ "required": ["owner", "namespace", "namespace_owner", "schema_version", "revision", "change", "reason", "keys"],
++ "additionalProperties": false,
++ "properties": {
++ "owner": {
++ "oneOf": [
++ { "type": "object", "required": ["type", "id"], "additionalProperties": false, "properties": { "type": { "const": "user" }, "id": { "type": "string", "pattern": "^usr_[0-9A-HJKMNP-TV-Z]{26}$" } } },
++ { "type": "object", "required": ["type", "id"], "additionalProperties": false, "properties": { "type": { "const": "guest" }, "id": { "type": "string", "pattern": "^gst_[0-9A-HJKMNP-TV-Z]{26}$" } } }
++ ],
++ "description": "The person the record belongs to (identity.subject-ref@1, a user or a guest)."
++ },
++ "namespace": { "type": "string", "pattern": "^[a-z][a-z0-9_]*(\\.[a-z0-9_]+)+$", "description": "e.g. chat.preferences (modules.namespace@1)." },
++ "namespace_owner": { "type": "string", "pattern": "^[a-z][a-z0-9-]{1,39}$", "description": "The service that owns the namespace when the change was made." },
++ "schema_version": { "type": "integer", "minimum": 1, "description": "The namespace version the record was written under." },
++ "revision": { "type": "integer", "minimum": 1, "description": "The record's revision after this change (the envelope subject revision)." },
++ "change": { "enum": ["created", "updated", "deleted"] },
++ "reason": { "enum": ["write", "delete", "owner_delete", "subject_removed", "subject_merged"] },
++ "keys": {
++ "type": "array", "uniqueItems": true,
++ "items": { "type": "string", "minLength": 1 },
++ "description": "Top-level fields added, changed or removed, names only, sorted. A deleted record lists every field it held."
++ },
++ "public": {
++ "type": "object", "minProperties": 1, "additionalProperties": true,
++ "description": "The new values of the changed fields the namespace declares public; null for one that was removed. Absent when no public field changed, for a namespace without public fields and for a deleted record."
++ },
++ "merged_into": {
++ "oneOf": [
++ { "type": "object", "required": ["type", "id"], "additionalProperties": false, "properties": { "type": { "const": "user" }, "id": { "type": "string", "pattern": "^usr_[0-9A-HJKMNP-TV-Z]{26}$" } } },
++ { "type": "object", "required": ["type", "id"], "additionalProperties": false, "properties": { "type": { "const": "guest" }, "id": { "type": "string", "pattern": "^gst_[0-9A-HJKMNP-TV-Z]{26}$" } } }
++ ],
++ "description": "On the absorbed account's deleted record (reason subject_merged): the account that survives."
++ },
++ "merged_from": {
++ "oneOf": [
++ { "type": "object", "required": ["type", "id"], "additionalProperties": false, "properties": { "type": { "const": "user" }, "id": { "type": "string", "pattern": "^usr_[0-9A-HJKMNP-TV-Z]{26}$" } } },
++ { "type": "object", "required": ["type", "id"], "additionalProperties": false, "properties": { "type": { "const": "guest" }, "id": { "type": "string", "pattern": "^gst_[0-9A-HJKMNP-TV-Z]{26}$" } } }
++ ],
++ "description": "On a record moved to the surviving account (created, reason subject_merged): the account it came from."
++ }
++ },
++ "dependentSchemas": {
++ "public": { "properties": { "change": { "enum": ["created", "updated"] } } },
++ "merged_into": { "properties": { "change": { "const": "deleted" }, "reason": { "const": "subject_merged" } } },
++ "merged_from": { "properties": { "change": { "const": "created" }, "reason": { "const": "subject_merged" } } }
++ }
++}
ddiff --git a/contracts/events/payloads/tips.interaction.erased.v1.json b/contracts/events/payloads/tips.interaction.erased.v1.json
nnew file mode 100644
iindex 00000000..d3726311
---- /dev/null
++++ b/contracts/events/payloads/tips.interaction.erased.v1.json
@@@ -0,0 +1,18 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/tips.interaction.erased.v1.json",
++ "title": "TipsInteractionErasedPayload",
++ "description": "tips.interaction.erased v1 (OpenVibe.Tips server/domain/interactions.js erase). The supporter erased their data from this interaction: Tips no longer holds their subject, name, message, text-to-speech text, media link or checkout reference; the money record (amount, creator, Billing transaction, goal contribution) stays so Billing's books and the creator's totals still reconcile. payload.redacts names every earlier Tips event about the interaction, so OpenVibe.Events tombstones them (ADR-026). Envelope: subject { type: interaction, id: tint_… }, visibility internal, priority important, actor service:tips. Carries no supporter data. Never emitted for simulations.",
++ "type": "object",
++ "required": ["interaction_id", "creator", "erased_at", "redacts"],
++ "additionalProperties": false,
++ "properties": {
++ "interaction_id": { "type": "string", "pattern": "^tint_[0-9A-HJKMNP-TV-Z]{26}$" },
++ "creator": { "$ref": "../../identity/subject-ref.v1.json" },
++ "erased_at": { "type": "string", "format": "date-time" },
++ "redacts": {
++ "$ref": "../redaction-directive.v1.json",
++ "description": "{ subject_type: interaction, subject_ids: [interaction_id] }."
++ }
++ }
++}
ddiff --git a/contracts/events/payloads/tips.interaction.moderated.v1.json b/contracts/events/payloads/tips.interaction.moderated.v1.json
nnew file mode 100644
iindex 00000000..39c57ab6
---- /dev/null
++++ b/contracts/events/payloads/tips.interaction.moderated.v1.json
@@@ -0,0 +1,22 @@
++{
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/tips.interaction.moderated.v1.json",
++ "title": "TipsInteractionModeratedPayload",
++ "description": "tips.interaction.moderated v1 (OpenVibe.Tips server/domain/moderation.js record). A moderation outcome for a paid message (and the name that came with it): the creator's word filter starred words out at settlement (filtered) or held it for review before anything was shown (held); the creator, one of their moderators or a service hid it (hidden: queued chat, TTS and media deliveries were cancelled, the overlay alert retracted, public pages leave it out) or showed it again (restored; a held one is released then). The money is never touched: no refund, the goal and totals keep it. Envelope: subject { type: interaction, id: tint_… }, visibility internal, priority important, actor service:tips. Carries no supporter, message, reason text or moderator identity (Tips keeps those in its own moderation log). Never emitted for simulations.",
++ "type": "object",
++ "required": ["interaction_id", "creator", "action", "by", "moderation_state", "cancelled_effects"],
++ "additionalProperties": false,
++ "properties": {
++ "interaction_id": { "type": "string", "pattern": "^tint_[0-9A-HJKMNP-TV-Z]{26}$" },
++ "creator": { "$ref": "../../identity/subject-ref.v1.json" },
++ "action": { "enum": ["filtered", "held", "hidden", "restored"] },
++ "by": { "enum": ["filter", "creator", "moderator", "service"], "description": "Who acted, by role." },
++ "moderation_state": { "enum": ["visible", "held", "hidden"], "description": "The interaction's moderation state after this outcome." },
++ "cancelled_effects": {
++ "type": "array",
++ "uniqueItems": true,
++ "items": { "enum": ["chat_line", "paid_message", "tts", "media_request"] },
++ "description": "Deliveries this outcome cancelled before they happened (hidden only; empty otherwise). A consumer that queued the same paid message itself should drop it."
++ }
++ }
++}
ddiff --git a/contracts/modules/namespace.v1.json b/contracts/modules/namespace.v1.json
iindex 84f3aa45..f2336de8 100644
---- a/contracts/modules/namespace.v1.json
++++ b/contracts/modules/namespace.v1.json
@@@ -2,7 +2,7 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://openvibe.network/contracts/modules/namespace.v1.json",
"title": "ModuleNamespace",
-- "description": "Policy for one user-module namespace: portable per-subject summaries and preferences stored by OpenVibe.Network. Never domain truth, money or authoritative game inventory (roadmap 4.3-4.5).",
++ "description": "Policy for one user-module namespace: portable per-subject summaries and preferences stored by OpenVibe.Network. Never domain truth, money or authoritative game inventory (roadmap 4.3-4.5). The person's account decides what happens to their records in every namespace: when an account is removed, Network deletes all of its records; when two accounts are merged, the surviving account keeps its own record in a namespace where both have one (the other is deleted), and a record only the absorbed account had moves to the survivor. Each of these changes is announced as network.module.updated (reason subject_removed or subject_merged). That is separate from onOwnerRemoved, which says what happens to a namespace's records when the service that owns the namespace is retired.",
"type": "object",
"required": ["namespace", "owner", "version", "schema", "writers", "publicFields", "quotaBytes", "onOwnerRemoved"],
"additionalProperties": false,
ddiff --git a/fixtures/live.release.deployed/invalid/no-commits.json b/fixtures/live.release.deployed/invalid/no-commits.json
nnew file mode 100644
iindex 00000000..d4e7ac36
---- /dev/null
++++ b/fixtures/live.release.deployed/invalid/no-commits.json
@@@ -0,0 +1,10 @@
++{
++ "service": "live",
++ "release": "4c70332",
++ "commit": "4c703326b23d8bc16c857117707e0a5f96eb587c",
++ "previous": "62261cae3b1f0d7c9e2a4b5d6f708192a3b4c5d6",
++ "commit_count": 0,
++ "commits": [],
++ "deployed_at": "2026-09-23T21:20:05.114Z",
++ "notes_url": "https://openvibe.live/updates"
++}
ddiff --git a/fixtures/live.release.deployed/invalid/other-service.json b/fixtures/live.release.deployed/invalid/other-service.json
nnew file mode 100644
iindex 00000000..19c7d2fb
---- /dev/null
++++ b/fixtures/live.release.deployed/invalid/other-service.json
@@@ -0,0 +1,23 @@
++{
++ "service": "chat",
++ "release": "4c70332",
++ "commit": "4c703326b23d8bc16c857117707e0a5f96eb587c",
++ "previous": "62261cae3b1f0d7c9e2a4b5d6f708192a3b4c5d6",
++ "commit_count": 2,
++ "commits": [
++ {
++ "hash": "4c703326b23d8bc16c857117707e0a5f96eb587c",
++ "short": "4c70332",
++ "subject": "Channel links carry the @",
++ "date": "2026-09-23T14:12:56-07:00"
++ },
++ {
++ "hash": "49fe04f1a2b3c4d5e6f708192a3b4c5d6e7f8091",
++ "short": "49fe04f",
++ "subject": "Chat settings also live in the OpenVibe.Network user module",
++ "date": "2026-09-23T13:40:02-07:00"
++ }
++ ],
++ "deployed_at": "2026-09-23T21:20:05.114Z",
++ "notes_url": "https://openvibe.live/updates"
++}
ddiff --git a/fixtures/live.release.deployed/invalid/short-head.json b/fixtures/live.release.deployed/invalid/short-head.json
nnew file mode 100644
iindex 00000000..37346fb6
---- /dev/null
++++ b/fixtures/live.release.deployed/invalid/short-head.json
@@@ -0,0 +1,23 @@
++{
++ "service": "live",
++ "release": "4c70332",
++ "commit": "4c70332",
++ "previous": "62261cae3b1f0d7c9e2a4b5d6f708192a3b4c5d6",
++ "commit_count": 2,
++ "commits": [
++ {
++ "hash": "4c703326b23d8bc16c857117707e0a5f96eb587c",
++ "short": "4c70332",
++ "subject": "Channel links carry the @",
++ "date": "2026-09-23T14:12:56-07:00"
++ },
++ {
++ "hash": "49fe04f1a2b3c4d5e6f708192a3b4c5d6e7f8091",
++ "short": "49fe04f",
++ "subject": "Chat settings also live in the OpenVibe.Network user module",
++ "date": "2026-09-23T13:40:02-07:00"
++ }
++ ],
++ "deployed_at": "2026-09-23T21:20:05.114Z",
++ "notes_url": "https://openvibe.live/updates"
++}
ddiff --git a/fixtures/live.release.deployed/valid/first-announcement.json b/fixtures/live.release.deployed/valid/first-announcement.json
nnew file mode 100644
iindex 00000000..4427772b
---- /dev/null
++++ b/fixtures/live.release.deployed/valid/first-announcement.json
@@@ -0,0 +1,17 @@
++{
++ "service": "live",
++ "release": "4c70332",
++ "commit": "4c703326b23d8bc16c857117707e0a5f96eb587c",
++ "previous": null,
++ "commit_count": 1,
++ "commits": [
++ {
++ "hash": "4c703326b23d8bc16c857117707e0a5f96eb587c",
++ "short": "4c70332",
++ "subject": "Channel links carry the @",
++ "date": "2026-09-23T14:12:56-07:00"
++ }
++ ],
++ "deployed_at": "2026-09-23T21:20:05.114Z",
++ "notes_url": "https://openvibe.live/updates"
++}
ddiff --git a/fixtures/live.release.deployed/valid/two-commits.json b/fixtures/live.release.deployed/valid/two-commits.json
nnew file mode 100644
iindex 00000000..24634864
---- /dev/null
++++ b/fixtures/live.release.deployed/valid/two-commits.json
@@@ -0,0 +1,23 @@
++{
++ "service": "live",
++ "release": "4c70332",
++ "commit": "4c703326b23d8bc16c857117707e0a5f96eb587c",
++ "previous": "62261cae3b1f0d7c9e2a4b5d6f708192a3b4c5d6",
++ "commit_count": 2,
++ "commits": [
++ {
++ "hash": "4c703326b23d8bc16c857117707e0a5f96eb587c",
++ "short": "4c70332",
++ "subject": "Channel links carry the @",
++ "date": "2026-09-23T14:12:56-07:00"
++ },
++ {
++ "hash": "49fe04f1a2b3c4d5e6f708192a3b4c5d6e7f8091",
++ "short": "49fe04f",
++ "subject": "Chat settings also live in the OpenVibe.Network user module",
++ "date": "2026-09-23T13:40:02-07:00"
++ }
++ ],
++ "deployed_at": "2026-09-23T21:20:05.114Z",
++ "notes_url": "https://openvibe.live/updates"
++}
ddiff --git a/fixtures/live.stream.ended/invalid/subject-not-a-user.json b/fixtures/live.stream.ended/invalid/subject-not-a-user.json
nnew file mode 100644
iindex 00000000..68e1f049
---- /dev/null
++++ b/fixtures/live.stream.ended/invalid/subject-not-a-user.json
@@@ -0,0 +1,19 @@
++{
++ "stream_id": 4211,
++ "channel": {
++ "username": "alice",
++ "display_name": "Alice",
++ "url": "https://openvibe.live/@alice",
++ "subject": {
++ "type": "service",
++ "id": "live"
++ }
++ },
++ "title": "Morning walk through Shinjuku",
++ "category": "irl",
++ "protocol": "webrtc",
++ "is_nsfw": false,
++ "started_at": "2026-09-23T18:02:11.000Z",
++ "ended_at": "2026-09-23T19:02:41.000Z",
++ "duration_seconds": 3630
++}
ddiff --git a/fixtures/live.stream.ended/invalid/without-ended-at.json b/fixtures/live.stream.ended/invalid/without-ended-at.json
nnew file mode 100644
iindex 00000000..bd8d82af
---- /dev/null
++++ b/fixtures/live.stream.ended/invalid/without-ended-at.json
@@@ -0,0 +1,18 @@
++{
++ "stream_id": 4211,
++ "channel": {
++ "username": "alice",
++ "display_name": "Alice",
++ "url": "https://openvibe.live/@alice",
++ "subject": {
++ "type": "user",
++ "id": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ"
++ }
++ },
++ "title": "Morning walk through Shinjuku",
++ "category": "irl",
++ "protocol": "webrtc",
++ "is_nsfw": false,
++ "started_at": "2026-09-23T18:02:11.000Z",
++ "duration_seconds": 3630
++}
ddiff --git a/fixtures/live.stream.ended/valid/ended.json b/fixtures/live.stream.ended/valid/ended.json
nnew file mode 100644
iindex 00000000..77df4097
---- /dev/null
++++ b/fixtures/live.stream.ended/valid/ended.json
@@@ -0,0 +1,19 @@
++{
++ "stream_id": 4211,
++ "channel": {
++ "username": "alice",
++ "display_name": "Alice",
++ "url": "https://openvibe.live/@alice",
++ "subject": {
++ "type": "user",
++ "id": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ"
++ }
++ },
++ "title": "Morning walk through Shinjuku",
++ "category": "irl",
++ "protocol": "webrtc",
++ "is_nsfw": false,
++ "started_at": "2026-09-23T18:02:11.000Z",
++ "ended_at": "2026-09-23T19:02:41.000Z",
++ "duration_seconds": 3630
++}
ddiff --git a/fixtures/live.stream.ended/valid/no-start-time.json b/fixtures/live.stream.ended/valid/no-start-time.json
nnew file mode 100644
iindex 00000000..83fadf56
---- /dev/null
++++ b/fixtures/live.stream.ended/valid/no-start-time.json
@@@ -0,0 +1,19 @@
++{
++ "stream_id": 4211,
++ "channel": {
++ "username": "alice",
++ "display_name": "Alice",
++ "url": "https://openvibe.live/@alice",
++ "subject": {
++ "type": "user",
++ "id": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ"
++ }
++ },
++ "title": "Morning walk through Shinjuku",
++ "category": "irl",
++ "protocol": "webrtc",
++ "is_nsfw": false,
++ "started_at": null,
++ "ended_at": "2026-09-23T19:02:41.000Z",
++ "duration_seconds": null
++}
ddiff --git a/fixtures/live.stream.started/invalid/carries-ended-fields.json b/fixtures/live.stream.started/invalid/carries-ended-fields.json
nnew file mode 100644
iindex 00000000..5efd0ace
---- /dev/null
++++ b/fixtures/live.stream.started/invalid/carries-ended-fields.json
@@@ -0,0 +1,19 @@
++{
++ "stream_id": 4211,
++ "channel": {
++ "username": "alice",
++ "display_name": "Alice",
++ "url": "https://openvibe.live/@alice",
++ "subject": {
++ "type": "user",
++ "id": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ"
++ }
++ },
++ "title": "Morning walk through Shinjuku",
++ "category": "irl",
++ "protocol": "webrtc",
++ "is_nsfw": false,
++ "started_at": "2026-09-23T18:02:11.000Z",
++ "ended_at": "2026-09-23T19:02:11.000Z",
++ "duration_seconds": 3600
++}
ddiff --git a/fixtures/live.stream.started/invalid/live-user-id.json b/fixtures/live.stream.started/invalid/live-user-id.json
nnew file mode 100644
iindex 00000000..bbfb3ba1
---- /dev/null
++++ b/fixtures/live.stream.started/invalid/live-user-id.json
@@@ -0,0 +1,18 @@
++{
++ "stream_id": 4211,
++ "channel": {
++ "username": "alice",
++ "display_name": "Alice",
++ "url": "https://openvibe.live/@alice",
++ "subject": {
++ "type": "user",
++ "id": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ"
++ },
++ "user_id": 501
++ },
++ "title": "Morning walk through Shinjuku",
++ "category": "irl",
++ "protocol": "webrtc",
++ "is_nsfw": false,
++ "started_at": "2026-09-23T18:02:11.000Z"
++}
ddiff --git a/fixtures/live.stream.started/invalid/unknown-protocol.json b/fixtures/live.stream.started/invalid/unknown-protocol.json
nnew file mode 100644
iindex 00000000..e2ee2fc1
---- /dev/null
++++ b/fixtures/live.stream.started/invalid/unknown-protocol.json
@@@ -0,0 +1,17 @@
++{
++ "stream_id": 4211,
++ "channel": {
++ "username": "alice",
++ "display_name": "Alice",
++ "url": "https://openvibe.live/@alice",
++ "subject": {
++ "type": "user",
++ "id": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ"
++ }
++ },
++ "title": "Morning walk through Shinjuku",
++ "category": "irl",
++ "protocol": "srt",
++ "is_nsfw": false,
++ "started_at": "2026-09-23T18:02:11.000Z"
++}
ddiff --git a/fixtures/live.stream.started/valid/no-subject-no-category.json b/fixtures/live.stream.started/valid/no-subject-no-category.json
nnew file mode 100644
iindex 00000000..14d01762
---- /dev/null
++++ b/fixtures/live.stream.started/valid/no-subject-no-category.json
@@@ -0,0 +1,13 @@
++{
++ "stream_id": 4212,
++ "channel": {
++ "username": "jp_guy",
++ "display_name": "jp_guy",
++ "url": "https://openvibe.live/@jp_guy"
++ },
++ "title": "jp_guy's Stream",
++ "category": null,
++ "protocol": "rtmp",
++ "is_nsfw": false,
++ "started_at": "2026-09-23T18:02:11.000Z"
++}
ddiff --git a/fixtures/live.stream.started/valid/with-subject.json b/fixtures/live.stream.started/valid/with-subject.json
nnew file mode 100644
iindex 00000000..5a82943e
---- /dev/null
++++ b/fixtures/live.stream.started/valid/with-subject.json
@@@ -0,0 +1,17 @@
++{
++ "stream_id": 4211,
++ "channel": {
++ "username": "alice",
++ "display_name": "Alice",
++ "url": "https://openvibe.live/@alice",
++ "subject": {
++ "type": "user",
++ "id": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ"
++ }
++ },
++ "title": "Morning walk through Shinjuku",
++ "category": "irl",
++ "protocol": "webrtc",
++ "is_nsfw": false,
++ "started_at": "2026-09-23T18:02:11.000Z"
++}
ddiff --git a/fixtures/media.job.cancelled/invalid/missing-fields.json b/fixtures/media.job.cancelled/invalid/missing-fields.json
nnew file mode 100644
iindex 00000000..4b2a2d11
---- /dev/null
++++ b/fixtures/media.job.cancelled/invalid/missing-fields.json
@@@ -0,0 +1,5 @@
++{
++ "id": "mjob_01JAB3C4D5E6F7G8H9J0K1MNPQ",
++ "app_id": "live",
++ "status": "cancelled"
++}
ddiff --git a/fixtures/media.job.cancelled/valid/running-stopped.json b/fixtures/media.job.cancelled/valid/running-stopped.json
nnew file mode 100644
iindex 00000000..7015265d
---- /dev/null
++++ b/fixtures/media.job.cancelled/valid/running-stopped.json
@@@ -0,0 +1,26 @@
++{
++ "app_id": "live",
++ "id": "mjob_01JAB3C4D5E6F7G8H9J0K1MNPQ",
++ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "type": "object.split",
++ "status": "cancelled",
++ "params": {
++ "parts": 3
++ },
++ "result": null,
++ "error": "cancelled by its owner",
++ "error_code": "cancelled",
++ "attempts": 1,
++ "max_attempts": 3,
++ "run_after": null,
++ "cancel_requested": true,
++ "idempotency_key": null,
++ "created_by": "svc:live",
++ "owner_user_id": null,
++ "decided_by": "app:live:user:57",
++ "decided_at": null,
++ "created_at": "2026-09-23 18:02:11",
++ "updated_at": "2026-09-23 18:04:00",
++ "started_at": "2026-09-23 18:02:12",
++ "finished_at": "2026-09-23 18:04:00"
++}
ddiff --git a/fixtures/media.job.cancelled/valid/withdrawn-proposal.json b/fixtures/media.job.cancelled/valid/withdrawn-proposal.json
nnew file mode 100644
iindex 00000000..a2cd3993
---- /dev/null
++++ b/fixtures/media.job.cancelled/valid/withdrawn-proposal.json
@@@ -0,0 +1,32 @@
++{
++ "app_id": "live",
++ "id": "mjob_01JAB3C4D5E6F7G8H9J0K1MNPQ",
++ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "type": "object.split",
++ "status": "cancelled",
++ "params": {
++ "source_size_bytes": 9663676416,
++ "target_bytes": 2147483648,
++ "max_bytes": 4294967296,
++ "reason": "public_object_too_large",
++ "parts": 5,
++ "segment_seconds": 2160,
++ "source_duration_seconds": 10800
++ },
++ "result": null,
++ "error": "the object no longer breaks the public size policy",
++ "error_code": "cancelled",
++ "attempts": 0,
++ "max_attempts": 3,
++ "run_after": null,
++ "cancel_requested": false,
++ "idempotency_key": "invariant:med_01JAB2C3D4E5F6G7H8J9K0MNPQ:object.split",
++ "created_by": "system:invariant.scan",
++ "owner_user_id": null,
++ "decided_by": "system:invariant.scan",
++ "decided_at": "2026-09-24 04:00:03",
++ "created_at": "2026-09-23 18:02:11",
++ "updated_at": "2026-09-24 04:00:03",
++ "started_at": null,
++ "finished_at": "2026-09-24 04:00:03"
++}
ddiff --git a/fixtures/media.job.failed/invalid/unfinished.json b/fixtures/media.job.failed/invalid/unfinished.json
nnew file mode 100644
iindex 00000000..d0ccdc4b
---- /dev/null
++++ b/fixtures/media.job.failed/invalid/unfinished.json
@@@ -0,0 +1,26 @@
++{
++ "app_id": "live",
++ "id": "mjob_01JAB3C4D5E6F7G8H9J0K1MNPQ",
++ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "type": "object.split",
++ "status": "failed",
++ "params": {
++ "parts": 3
++ },
++ "result": null,
++ "error": "The source bytes are unavailable (no local file and no cloud copy)",
++ "error_code": "media_unavailable",
++ "attempts": 3,
++ "max_attempts": 3,
++ "run_after": null,
++ "cancel_requested": false,
++ "idempotency_key": null,
++ "created_by": "svc:live",
++ "owner_user_id": null,
++ "decided_by": null,
++ "decided_at": null,
++ "created_at": "2026-09-23 18:02:11",
++ "updated_at": "2026-09-23 18:02:13",
++ "started_at": "2026-09-23 18:02:12",
++ "finished_at": null
++}
ddiff --git a/fixtures/media.job.failed/valid/media-unavailable.json b/fixtures/media.job.failed/valid/media-unavailable.json
nnew file mode 100644
iindex 00000000..69d27b73
---- /dev/null
++++ b/fixtures/media.job.failed/valid/media-unavailable.json
@@@ -0,0 +1,26 @@
++{
++ "app_id": "live",
++ "id": "mjob_01JAB3C4D5E6F7G8H9J0K1MNPQ",
++ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "type": "object.split",
++ "status": "failed",
++ "params": {
++ "parts": 3
++ },
++ "result": null,
++ "error": "The source bytes are unavailable (no local file and no cloud copy)",
++ "error_code": "media_unavailable",
++ "attempts": 3,
++ "max_attempts": 3,
++ "run_after": null,
++ "cancel_requested": false,
++ "idempotency_key": null,
++ "created_by": "svc:live",
++ "owner_user_id": null,
++ "decided_by": null,
++ "decided_at": null,
++ "created_at": "2026-09-23 18:02:11",
++ "updated_at": "2026-09-23 18:02:13",
++ "started_at": "2026-09-23 18:02:12",
++ "finished_at": "2026-09-23 18:02:13"
++}
ddiff --git a/fixtures/media.job.proposed/invalid/already-started.json b/fixtures/media.job.proposed/invalid/already-started.json
nnew file mode 100644
iindex 00000000..9c02148b
---- /dev/null
++++ b/fixtures/media.job.proposed/invalid/already-started.json
@@@ -0,0 +1,32 @@
++{
++ "app_id": "live",
++ "id": "mjob_01JAB3C4D5E6F7G8H9J0K1MNPQ",
++ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "type": "object.split",
++ "status": "proposed",
++ "params": {
++ "source_size_bytes": 9663676416,
++ "target_bytes": 2147483648,
++ "max_bytes": 4294967296,
++ "reason": "public_object_too_large",
++ "parts": 5,
++ "segment_seconds": 2160,
++ "source_duration_seconds": 10800
++ },
++ "result": null,
++ "error": null,
++ "error_code": null,
++ "attempts": 0,
++ "max_attempts": 3,
++ "run_after": null,
++ "cancel_requested": false,
++ "idempotency_key": "invariant:med_01JAB2C3D4E5F6G7H8J9K0MNPQ:object.split",
++ "created_by": "system:invariant.scan",
++ "owner_user_id": null,
++ "decided_by": null,
++ "decided_at": null,
++ "created_at": "2026-09-23 18:02:11",
++ "updated_at": "2026-09-23 18:02:11",
++ "started_at": "2026-09-23 18:02:12",
++ "finished_at": null
++}
ddiff --git a/fixtures/media.job.proposed/invalid/carries-checkpoint.json b/fixtures/media.job.proposed/invalid/carries-checkpoint.json
nnew file mode 100644
iindex 00000000..f606bf8b
---- /dev/null
++++ b/fixtures/media.job.proposed/invalid/carries-checkpoint.json
@@@ -0,0 +1,35 @@
++{
++ "app_id": "live",
++ "id": "mjob_01JAB3C4D5E6F7G8H9J0K1MNPQ",
++ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "type": "object.split",
++ "status": "proposed",
++ "params": {
++ "source_size_bytes": 9663676416,
++ "target_bytes": 2147483648,
++ "max_bytes": 4294967296,
++ "reason": "public_object_too_large",
++ "parts": 5,
++ "segment_seconds": 2160,
++ "source_duration_seconds": 10800
++ },
++ "result": null,
++ "error": null,
++ "error_code": null,
++ "attempts": 0,
++ "max_attempts": 3,
++ "run_after": null,
++ "cancel_requested": false,
++ "idempotency_key": "invariant:med_01JAB2C3D4E5F6G7H8J9K0MNPQ:object.split",
++ "created_by": "system:invariant.scan",
++ "owner_user_id": null,
++ "decided_by": null,
++ "decided_at": null,
++ "created_at": "2026-09-23 18:02:11",
++ "updated_at": "2026-09-23 18:02:11",
++ "started_at": null,
++ "finished_at": null,
++ "checkpoint": {
++ "part": 2
++ }
++}
ddiff --git a/fixtures/media.job.proposed/valid/invariant-split.json b/fixtures/media.job.proposed/valid/invariant-split.json
nnew file mode 100644
iindex 00000000..02958d45
---- /dev/null
++++ b/fixtures/media.job.proposed/valid/invariant-split.json
@@@ -0,0 +1,32 @@
++{
++ "app_id": "live",
++ "id": "mjob_01JAB3C4D5E6F7G8H9J0K1MNPQ",
++ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "type": "object.split",
++ "status": "proposed",
++ "params": {
++ "source_size_bytes": 9663676416,
++ "target_bytes": 2147483648,
++ "max_bytes": 4294967296,
++ "reason": "public_object_too_large",
++ "parts": 5,
++ "segment_seconds": 2160,
++ "source_duration_seconds": 10800
++ },
++ "result": null,
++ "error": null,
++ "error_code": null,
++ "attempts": 0,
++ "max_attempts": 3,
++ "run_after": null,
++ "cancel_requested": false,
++ "idempotency_key": "invariant:med_01JAB2C3D4E5F6G7H8J9K0MNPQ:object.split",
++ "created_by": "system:invariant.scan",
++ "owner_user_id": null,
++ "decided_by": null,
++ "decided_at": null,
++ "created_at": "2026-09-23 18:02:11",
++ "updated_at": "2026-09-23 18:02:11",
++ "started_at": null,
++ "finished_at": null
++}
ddiff --git a/fixtures/media.job.queued/invalid/iso-timestamps.json b/fixtures/media.job.queued/invalid/iso-timestamps.json
nnew file mode 100644
iindex 00000000..b373b7ed
---- /dev/null
++++ b/fixtures/media.job.queued/invalid/iso-timestamps.json
@@@ -0,0 +1,26 @@
++{
++ "app_id": "live",
++ "id": "mjob_01JAB3C4D5E6F7G8H9J0K1MNPQ",
++ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "type": "object.split",
++ "status": "queued",
++ "params": {
++ "parts": 3
++ },
++ "result": null,
++ "error": null,
++ "error_code": null,
++ "attempts": 0,
++ "max_attempts": 3,
++ "run_after": null,
++ "cancel_requested": false,
++ "idempotency_key": null,
++ "created_by": "svc:live",
++ "owner_user_id": null,
++ "decided_by": null,
++ "decided_at": null,
++ "created_at": "2026-09-23T18:02:11Z",
++ "updated_at": "2026-09-23T18:02:11Z",
++ "started_at": null,
++ "finished_at": null
++}
ddiff --git a/fixtures/media.job.queued/invalid/network-subject-as-owner.json b/fixtures/media.job.queued/invalid/network-subject-as-owner.json
nnew file mode 100644
iindex 00000000..6b3a604f
---- /dev/null
++++ b/fixtures/media.job.queued/invalid/network-subject-as-owner.json
@@@ -0,0 +1,26 @@
++{
++ "app_id": "live",
++ "id": "mjob_01JAB3C4D5E6F7G8H9J0K1MNPQ",
++ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "type": "object.split",
++ "status": "queued",
++ "params": {
++ "parts": 3
++ },
++ "result": null,
++ "error": null,
++ "error_code": null,
++ "attempts": 0,
++ "max_attempts": 3,
++ "run_after": null,
++ "cancel_requested": false,
++ "idempotency_key": null,
++ "created_by": "svc:live",
++ "owner_user_id": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "decided_by": null,
++ "decided_at": null,
++ "created_at": "2026-09-23 18:02:11",
++ "updated_at": "2026-09-23 18:02:11",
++ "started_at": null,
++ "finished_at": null
++}
ddiff --git a/fixtures/media.job.queued/valid/approved-proposal.json b/fixtures/media.job.queued/valid/approved-proposal.json
nnew file mode 100644
iindex 00000000..042fa21f
---- /dev/null
++++ b/fixtures/media.job.queued/valid/approved-proposal.json
@@@ -0,0 +1,32 @@
++{
++ "app_id": "live",
++ "id": "mjob_01JAB3C4D5E6F7G8H9J0K1MNPQ",
++ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "type": "object.split",
++ "status": "queued",
++ "params": {
++ "source_size_bytes": 9663676416,
++ "target_bytes": 2147483648,
++ "max_bytes": 4294967296,
++ "reason": "public_object_too_large",
++ "parts": 5,
++ "segment_seconds": 2160,
++ "source_duration_seconds": 10800
++ },
++ "result": null,
++ "error": null,
++ "error_code": null,
++ "attempts": 0,
++ "max_attempts": 3,
++ "run_after": null,
++ "cancel_requested": false,
++ "idempotency_key": "invariant:med_01JAB2C3D4E5F6G7H8J9K0MNPQ:object.split",
++ "created_by": "system:invariant.scan",
++ "owner_user_id": null,
++ "decided_by": "app:live:user:57",
++ "decided_at": "2026-09-23 18:10:40",
++ "created_at": "2026-09-23 18:02:11",
++ "updated_at": "2026-09-23 18:10:40",
++ "started_at": null,
++ "finished_at": null
++}
ddiff --git a/fixtures/media.job.queued/valid/thumbnail-for-a-user.json b/fixtures/media.job.queued/valid/thumbnail-for-a-user.json
nnew file mode 100644
iindex 00000000..36c33d81
---- /dev/null
++++ b/fixtures/media.job.queued/valid/thumbnail-for-a-user.json
@@@ -0,0 +1,27 @@
++{
++ "app_id": "live",
++ "id": "mjob_01JAB3C4D5E6F7G8H9J0K1MNPQ",
++ "object_id": null,
++ "type": "thumbnail.regenerate",
++ "status": "queued",
++ "params": {
++ "kind": "vod",
++ "id": 4211
++ },
++ "result": null,
++ "error": null,
++ "error_code": null,
++ "attempts": 0,
++ "max_attempts": 2,
++ "run_after": null,
++ "cancel_requested": false,
++ "idempotency_key": "thumb-4211-2026-09-23",
++ "created_by": "app:live:user:57",
++ "owner_user_id": 57,
++ "decided_by": null,
++ "decided_at": null,
++ "created_at": "2026-09-23 18:02:11",
++ "updated_at": "2026-09-23 18:02:11",
++ "started_at": null,
++ "finished_at": null
++}
ddiff --git a/fixtures/media.job.retrying/invalid/no-backoff.json b/fixtures/media.job.retrying/invalid/no-backoff.json
nnew file mode 100644
iindex 00000000..9143f723
---- /dev/null
++++ b/fixtures/media.job.retrying/invalid/no-backoff.json
@@@ -0,0 +1,26 @@
++{
++ "app_id": "live",
++ "id": "mjob_01JAB3C4D5E6F7G8H9J0K1MNPQ",
++ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "type": "object.split",
++ "status": "queued",
++ "params": {
++ "parts": 3
++ },
++ "result": null,
++ "error": "interrupted by a restart",
++ "error_code": "interrupted",
++ "attempts": 1,
++ "max_attempts": 3,
++ "run_after": null,
++ "cancel_requested": false,
++ "idempotency_key": null,
++ "created_by": "svc:live",
++ "owner_user_id": null,
++ "decided_by": null,
++ "decided_at": null,
++ "created_at": "2026-09-23 18:02:11",
++ "updated_at": "2026-09-23 18:05:12",
++ "started_at": "2026-09-23 18:02:12",
++ "finished_at": null
++}
ddiff --git a/fixtures/media.job.retrying/valid/interrupted.json b/fixtures/media.job.retrying/valid/interrupted.json
nnew file mode 100644
iindex 00000000..6bfa053c
---- /dev/null
++++ b/fixtures/media.job.retrying/valid/interrupted.json
@@@ -0,0 +1,26 @@
++{
++ "app_id": "live",
++ "id": "mjob_01JAB3C4D5E6F7G8H9J0K1MNPQ",
++ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "type": "object.split",
++ "status": "queued",
++ "params": {
++ "parts": 3
++ },
++ "result": null,
++ "error": "interrupted by a restart",
++ "error_code": "interrupted",
++ "attempts": 1,
++ "max_attempts": 3,
++ "run_after": "2026-09-23 18:05:42",
++ "cancel_requested": false,
++ "idempotency_key": null,
++ "created_by": "svc:live",
++ "owner_user_id": null,
++ "decided_by": null,
++ "decided_at": null,
++ "created_at": "2026-09-23 18:02:11",
++ "updated_at": "2026-09-23 18:05:12",
++ "started_at": "2026-09-23 18:02:12",
++ "finished_at": null
++}
ddiff --git a/fixtures/media.job.started/invalid/wrong-status.json b/fixtures/media.job.started/invalid/wrong-status.json
nnew file mode 100644
iindex 00000000..f58ab3aa
---- /dev/null
++++ b/fixtures/media.job.started/invalid/wrong-status.json
@@@ -0,0 +1,26 @@
++{
++ "app_id": "live",
++ "id": "mjob_01JAB3C4D5E6F7G8H9J0K1MNPQ",
++ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "type": "object.split",
++ "status": "started",
++ "params": {
++ "parts": 3
++ },
++ "result": null,
++ "error": null,
++ "error_code": null,
++ "attempts": 1,
++ "max_attempts": 3,
++ "run_after": null,
++ "cancel_requested": false,
++ "idempotency_key": null,
++ "created_by": "svc:live",
++ "owner_user_id": null,
++ "decided_by": null,
++ "decided_at": null,
++ "created_at": "2026-09-23 18:02:11",
++ "updated_at": "2026-09-23 18:02:12",
++ "started_at": "2026-09-23 18:02:12",
++ "finished_at": null
++}
ddiff --git a/fixtures/media.job.started/valid/first-attempt.json b/fixtures/media.job.started/valid/first-attempt.json
nnew file mode 100644
iindex 00000000..44b3da94
---- /dev/null
++++ b/fixtures/media.job.started/valid/first-attempt.json
@@@ -0,0 +1,26 @@
++{
++ "app_id": "live",
++ "id": "mjob_01JAB3C4D5E6F7G8H9J0K1MNPQ",
++ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "type": "object.split",
++ "status": "running",
++ "params": {
++ "parts": 3
++ },
++ "result": null,
++ "error": null,
++ "error_code": null,
++ "attempts": 1,
++ "max_attempts": 3,
++ "run_after": null,
++ "cancel_requested": false,
++ "idempotency_key": null,
++ "created_by": "svc:live",
++ "owner_user_id": null,
++ "decided_by": null,
++ "decided_at": null,
++ "created_at": "2026-09-23 18:02:11",
++ "updated_at": "2026-09-23 18:02:12",
++ "started_at": "2026-09-23 18:02:12",
++ "finished_at": null
++}
ddiff --git a/fixtures/media.job.succeeded/invalid/with-error.json b/fixtures/media.job.succeeded/invalid/with-error.json
nnew file mode 100644
iindex 00000000..02f4dc08
---- /dev/null
++++ b/fixtures/media.job.succeeded/invalid/with-error.json
@@@ -0,0 +1,38 @@
++{
++ "app_id": "live",
++ "id": "mjob_01JAB3C4D5E6F7G8H9J0K1MNPQ",
++ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "type": "object.split",
++ "status": "succeeded",
++ "params": {
++ "parts": 3
++ },
++ "result": {
++ "source_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "parts": [
++ {
++ "part": 1,
++ "object_id": "med_01JAB3C4D5E6F7G8H9J0K1MNPR",
++ "start_seconds": 0,
++ "duration_seconds": 3600,
++ "size_bytes": 1932735283
++ }
++ ],
++ "method": "split"
++ },
++ "error": "transient",
++ "error_code": "interrupted",
++ "attempts": 1,
++ "max_attempts": 3,
++ "run_after": null,
++ "cancel_requested": false,
++ "idempotency_key": null,
++ "created_by": "svc:live",
++ "owner_user_id": null,
++ "decided_by": null,
++ "decided_at": null,
++ "created_at": "2026-09-23 18:02:11",
++ "updated_at": "2026-09-23 18:31:02",
++ "started_at": "2026-09-23 18:02:12",
++ "finished_at": "2026-09-23 18:31:02"
++}
ddiff --git a/fixtures/media.job.succeeded/valid/result-omitted.json b/fixtures/media.job.succeeded/valid/result-omitted.json
nnew file mode 100644
iindex 00000000..54dd6368
---- /dev/null
++++ b/fixtures/media.job.succeeded/valid/result-omitted.json
@@@ -0,0 +1,29 @@
++{
++ "app_id": "live",
++ "id": "mjob_01JAB3C4D5E6F7G8H9J0K1MNPQ",
++ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "type": "object.split",
++ "status": "succeeded",
++ "params": {
++ "parts": 3
++ },
++ "result": {
++ "omitted": true,
++ "reason": "larger than the event limit; GET the job"
++ },
++ "error": null,
++ "error_code": null,
++ "attempts": 1,
++ "max_attempts": 3,
++ "run_after": null,
++ "cancel_requested": false,
++ "idempotency_key": null,
++ "created_by": "svc:live",
++ "owner_user_id": null,
++ "decided_by": null,
++ "decided_at": null,
++ "created_at": "2026-09-23 18:02:11",
++ "updated_at": "2026-09-23 18:31:02",
++ "started_at": "2026-09-23 18:02:12",
++ "finished_at": "2026-09-23 18:31:02"
++}
ddiff --git a/fixtures/media.job.succeeded/valid/split.json b/fixtures/media.job.succeeded/valid/split.json
nnew file mode 100644
iindex 00000000..8a6e8cd3
---- /dev/null
++++ b/fixtures/media.job.succeeded/valid/split.json
@@@ -0,0 +1,40 @@
++{
++ "app_id": "live",
++ "id": "mjob_01JAB3C4D5E6F7G8H9J0K1MNPQ",
++ "object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "type": "object.split",
++ "status": "succeeded",
++ "params": {
++ "parts": 3
++ },
++ "result": {
++ "source_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ",
++ "method": "split",
++ "parts": [
++ {
++ "part": 1,
++ "object_id": "med_01JAB3C4D5E6F7G8H9J0K1MNPR",
++ "start_seconds": 0,
++ "duration_seconds": 3600,
++ "size_bytes": 1932735283
++ }
++ ],
++ "oversized_parts": [],
++ "source_unchanged": true
++ },
++ "error": null,
++ "error_code": null,
++ "attempts": 1,
++ "max_attempts": 3,
++ "run_after": null,
++ "cancel_requested": false,
++ "idempotency_key": null,
++ "created_by": "svc:live",
++ "owner_user_id": null,
++ "decided_by": null,
++ "decided_at": null,
++ "created_at": "2026-09-23 18:02:11",
++ "updated_at": "2026-09-23 18:31:02",
++ "started_at": "2026-09-23 18:02:12",
++ "finished_at": "2026-09-23 18:31:02"
++}
ddiff --git a/fixtures/network.module.updated/invalid/carries-data.json b/fixtures/network.module.updated/invalid/carries-data.json
nnew file mode 100644
iindex 00000000..03cb1a2d
---- /dev/null
++++ b/fixtures/network.module.updated/invalid/carries-data.json
@@@ -0,0 +1,23 @@
++{
++ "owner": {
++ "type": "user",
++ "id": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ"
++ },
++ "namespace": "live.profile",
++ "namespace_owner": "live",
++ "schema_version": 1,
++ "revision": 3,
++ "change": "updated",
++ "reason": "write",
++ "keys": [
++ "followers",
++ "stream_minutes_30d"
++ ],
++ "public": {
++ "followers": 4
++ },
++ "data": {
++ "followers": 4,
++ "stream_minutes_30d": 12
++ }
++}
ddiff --git a/fixtures/network.module.updated/invalid/deleted-with-values.json b/fixtures/network.module.updated/invalid/deleted-with-values.json
nnew file mode 100644
iindex 00000000..e7add84d
---- /dev/null
++++ b/fixtures/network.module.updated/invalid/deleted-with-values.json
@@@ -0,0 +1,18 @@
++{
++ "owner": {
++ "type": "user",
++ "id": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ"
++ },
++ "namespace": "live.profile",
++ "namespace_owner": "live",
++ "schema_version": 1,
++ "revision": 5,
++ "change": "deleted",
++ "reason": "delete",
++ "keys": [
++ "followers"
++ ],
++ "public": {
++ "followers": 4
++ }
++}
ddiff --git a/fixtures/network.module.updated/invalid/merged-into-on-a-write.json b/fixtures/network.module.updated/invalid/merged-into-on-a-write.json
nnew file mode 100644
iindex 00000000..a3ce141f
---- /dev/null
++++ b/fixtures/network.module.updated/invalid/merged-into-on-a-write.json
@@@ -0,0 +1,23 @@
++{
++ "owner": {
++ "type": "user",
++ "id": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ"
++ },
++ "namespace": "live.profile",
++ "namespace_owner": "live",
++ "schema_version": 1,
++ "revision": 3,
++ "change": "updated",
++ "reason": "write",
++ "keys": [
++ "followers",
++ "stream_minutes_30d"
++ ],
++ "public": {
++ "followers": 4
++ },
++ "merged_into": {
++ "type": "user",
++ "id": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ"
++ }
++}
ddiff --git a/fixtures/network.module.updated/invalid/revision-zero.json b/fixtures/network.module.updated/invalid/revision-zero.json
nnew file mode 100644
iindex 00000000..24d0c8ec
---- /dev/null
++++ b/fixtures/network.module.updated/invalid/revision-zero.json
@@@ -0,0 +1,19 @@
++{
++ "owner": {
++ "type": "user",
++ "id": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ"
++ },
++ "namespace": "live.profile",
++ "namespace_owner": "live",
++ "schema_version": 1,
++ "revision": 0,
++ "change": "updated",
++ "reason": "write",
++ "keys": [
++ "followers",
++ "stream_minutes_30d"
++ ],
++ "public": {
++ "followers": 4
++ }
++}
ddiff --git a/fixtures/network.module.updated/invalid/service-owner.json b/fixtures/network.module.updated/invalid/service-owner.json
nnew file mode 100644
iindex 00000000..8a7573e0
---- /dev/null
++++ b/fixtures/network.module.updated/invalid/service-owner.json
@@@ -0,0 +1,19 @@
++{
++ "owner": {
++ "type": "service",
++ "id": "live"
++ },
++ "namespace": "live.profile",
++ "namespace_owner": "live",
++ "schema_version": 1,
++ "revision": 3,
++ "change": "updated",
++ "reason": "write",
++ "keys": [
++ "followers",
++ "stream_minutes_30d"
++ ],
++ "public": {
++ "followers": 4
++ }
++}
ddiff --git a/fixtures/network.module.updated/invalid/unknown-reason.json b/fixtures/network.module.updated/invalid/unknown-reason.json
nnew file mode 100644
iindex 00000000..f0f42b5a
---- /dev/null
++++ b/fixtures/network.module.updated/invalid/unknown-reason.json
@@@ -0,0 +1,19 @@
++{
++ "owner": {
++ "type": "user",
++ "id": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ"
++ },
++ "namespace": "live.profile",
++ "namespace_owner": "live",
++ "schema_version": 1,
++ "revision": 3,
++ "change": "updated",
++ "reason": "expired",
++ "keys": [
++ "followers",
++ "stream_minutes_30d"
++ ],
++ "public": {
++ "followers": 4
++ }
++}
ddiff --git a/fixtures/network.module.updated/valid/account-removed.json b/fixtures/network.module.updated/valid/account-removed.json
nnew file mode 100644
iindex 00000000..13efaff1
---- /dev/null
++++ b/fixtures/network.module.updated/valid/account-removed.json
@@@ -0,0 +1,16 @@
++{
++ "owner": {
++ "type": "user",
++ "id": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ"
++ },
++ "namespace": "live.profile",
++ "namespace_owner": "live",
++ "schema_version": 1,
++ "revision": 5,
++ "change": "deleted",
++ "reason": "subject_removed",
++ "keys": [
++ "followers",
++ "is_streamer"
++ ]
++}
ddiff --git a/fixtures/network.module.updated/valid/merge-absorbed-side.json b/fixtures/network.module.updated/valid/merge-absorbed-side.json
nnew file mode 100644
iindex 00000000..14449318
---- /dev/null
++++ b/fixtures/network.module.updated/valid/merge-absorbed-side.json
@@@ -0,0 +1,19 @@
++{
++ "owner": {
++ "type": "guest",
++ "id": "gst_01JAB2C3D4E5F6G7H8J9K0MNPR"
++ },
++ "namespace": "chat.tts_defaults",
++ "namespace_owner": "live",
++ "schema_version": 1,
++ "revision": 2,
++ "change": "deleted",
++ "reason": "subject_merged",
++ "keys": [
++ "rate"
++ ],
++ "merged_into": {
++ "type": "user",
++ "id": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ"
++ }
++}
ddiff --git a/fixtures/network.module.updated/valid/merge-surviving-side.json b/fixtures/network.module.updated/valid/merge-surviving-side.json
nnew file mode 100644
iindex 00000000..539a31a2
---- /dev/null
++++ b/fixtures/network.module.updated/valid/merge-surviving-side.json
@@@ -0,0 +1,19 @@
++{
++ "owner": {
++ "type": "user",
++ "id": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ"
++ },
++ "namespace": "chat.tts_defaults",
++ "namespace_owner": "live",
++ "schema_version": 1,
++ "revision": 1,
++ "change": "created",
++ "reason": "subject_merged",
++ "keys": [
++ "rate"
++ ],
++ "merged_from": {
++ "type": "guest",
++ "id": "gst_01JAB2C3D4E5F6G7H8J9K0MNPR"
++ }
++}
ddiff --git a/fixtures/network.module.updated/valid/private-namespace.json b/fixtures/network.module.updated/valid/private-namespace.json
nnew file mode 100644
iindex 00000000..ed5ed3a7
---- /dev/null
++++ b/fixtures/network.module.updated/valid/private-namespace.json
@@@ -0,0 +1,16 @@
++{
++ "owner": {
++ "type": "user",
++ "id": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ"
++ },
++ "namespace": "chat.preferences",
++ "namespace_owner": "chat",
++ "schema_version": 1,
++ "revision": 1,
++ "change": "created",
++ "reason": "write",
++ "keys": [
++ "compact",
++ "timestamps"
++ ]
++}
ddiff --git a/fixtures/network.module.updated/valid/public-field-changed.json b/fixtures/network.module.updated/valid/public-field-changed.json
nnew file mode 100644
iindex 00000000..9632b361
---- /dev/null
++++ b/fixtures/network.module.updated/valid/public-field-changed.json
@@@ -0,0 +1,19 @@
++{
++ "owner": {
++ "type": "user",
++ "id": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ"
++ },
++ "namespace": "live.profile",
++ "namespace_owner": "live",
++ "schema_version": 1,
++ "revision": 3,
++ "change": "updated",
++ "reason": "write",
++ "keys": [
++ "followers",
++ "stream_minutes_30d"
++ ],
++ "public": {
++ "followers": 4
++ }
++}
ddiff --git a/fixtures/network.module.updated/valid/public-field-removed.json b/fixtures/network.module.updated/valid/public-field-removed.json
nnew file mode 100644
iindex 00000000..8f037ca7
---- /dev/null
++++ b/fixtures/network.module.updated/valid/public-field-removed.json
@@@ -0,0 +1,18 @@
++{
++ "owner": {
++ "type": "user",
++ "id": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ"
++ },
++ "namespace": "live.profile",
++ "namespace_owner": "live",
++ "schema_version": 1,
++ "revision": 4,
++ "change": "updated",
++ "reason": "write",
++ "keys": [
++ "last_live_at"
++ ],
++ "public": {
++ "last_live_at": null
++ }
++}
ddiff --git a/fixtures/tips.interaction.erased/invalid/names-the-supporter.json b/fixtures/tips.interaction.erased/invalid/names-the-supporter.json
nnew file mode 100644
iindex 00000000..2235a8a7
---- /dev/null
++++ b/fixtures/tips.interaction.erased/invalid/names-the-supporter.json
@@@ -0,0 +1,16 @@
++{
++ "interaction_id": "tint_01JAB3C4D5E6F7G8H9J0K1MNPQ",
++ "creator": {
++ "type": "user",
++ "id": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ"
++ },
++ "supporter": {
++ "type": "user",
++ "id": "usr_01JAB2C3D4E5F6G7H8J9K0MNPR"
++ },
++ "erased_at": "2026-09-23T18:04:11.000Z",
++ "redacts": {
++ "subject_type": "interaction",
++ "subject_ids": ["tint_01JAB3C4D5E6F7G8H9J0K1MNPQ"]
++ }
++}
ddiff --git a/fixtures/tips.interaction.erased/invalid/without-redacts.json b/fixtures/tips.interaction.erased/invalid/without-redacts.json
nnew file mode 100644
iindex 00000000..85b0a7fa
---- /dev/null
++++ b/fixtures/tips.interaction.erased/invalid/without-redacts.json
@@@ -0,0 +1,8 @@
++{
++ "interaction_id": "tint_01JAB3C4D5E6F7G8H9J0K1MNPQ",
++ "creator": {
++ "type": "user",
++ "id": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ"
++ },
++ "erased_at": "2026-09-23T18:04:11.000Z"
++}
ddiff --git a/fixtures/tips.interaction.erased/valid/erased.json b/fixtures/tips.interaction.erased/valid/erased.json
nnew file mode 100644
iindex 00000000..777d1dd0
---- /dev/null
++++ b/fixtures/tips.interaction.erased/valid/erased.json
@@@ -0,0 +1,12 @@
++{
++ "interaction_id": "tint_01JAB3C4D5E6F7G8H9J0K1MNPQ",
++ "creator": {
++ "type": "user",
++ "id": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ"
++ },
++ "erased_at": "2026-09-23T18:04:11.000Z",
++ "redacts": {
++ "subject_type": "interaction",
++ "subject_ids": ["tint_01JAB3C4D5E6F7G8H9J0K1MNPQ"]
++ }
++}
ddiff --git a/fixtures/tips.interaction.moderated/invalid/carries-the-message.json b/fixtures/tips.interaction.moderated/invalid/carries-the-message.json
nnew file mode 100644
iindex 00000000..1c5b30cd
---- /dev/null
++++ b/fixtures/tips.interaction.moderated/invalid/carries-the-message.json
@@@ -0,0 +1,12 @@
++{
++ "interaction_id": "tint_01JAB3C4D5E6F7G8H9J0K1MNPQ",
++ "creator": {
++ "type": "user",
++ "id": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ"
++ },
++ "action": "filtered",
++ "by": "filter",
++ "moderation_state": "visible",
++ "cancelled_effects": [],
++ "message": "the words the filter starred out"
++}
ddiff --git a/fixtures/tips.interaction.moderated/invalid/names-the-moderator.json b/fixtures/tips.interaction.moderated/invalid/names-the-moderator.json
nnew file mode 100644
iindex 00000000..00ffd4d8
---- /dev/null
++++ b/fixtures/tips.interaction.moderated/invalid/names-the-moderator.json
@@@ -0,0 +1,15 @@
++{
++ "interaction_id": "tint_01JAB3C4D5E6F7G8H9J0K1MNPQ",
++ "creator": {
++ "type": "user",
++ "id": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ"
++ },
++ "action": "hidden",
++ "by": "moderator",
++ "moderator": {
++ "type": "user",
++ "id": "usr_01JAB2C3D4E5F6G7H8J9K0MNPR"
++ },
++ "moderation_state": "hidden",
++ "cancelled_effects": []
++}
ddiff --git a/fixtures/tips.interaction.moderated/invalid/unknown-effect.json b/fixtures/tips.interaction.moderated/invalid/unknown-effect.json
nnew file mode 100644
iindex 00000000..6014f334
---- /dev/null
++++ b/fixtures/tips.interaction.moderated/invalid/unknown-effect.json
@@@ -0,0 +1,11 @@
++{
++ "interaction_id": "tint_01JAB3C4D5E6F7G8H9J0K1MNPQ",
++ "creator": {
++ "type": "user",
++ "id": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ"
++ },
++ "action": "hidden",
++ "by": "service",
++ "moderation_state": "hidden",
++ "cancelled_effects": ["refund"]
++}
ddiff --git a/fixtures/tips.interaction.moderated/valid/held-by-filter.json b/fixtures/tips.interaction.moderated/valid/held-by-filter.json
nnew file mode 100644
iindex 00000000..50468e52
---- /dev/null
++++ b/fixtures/tips.interaction.moderated/valid/held-by-filter.json
@@@ -0,0 +1,11 @@
++{
++ "interaction_id": "tint_01JAB3C4D5E6F7G8H9J0K1MNPQ",
++ "creator": {
++ "type": "user",
++ "id": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ"
++ },
++ "action": "held",
++ "by": "filter",
++ "moderation_state": "held",
++ "cancelled_effects": []
++}
ddiff --git a/fixtures/tips.interaction.moderated/valid/hidden-by-moderator.json b/fixtures/tips.interaction.moderated/valid/hidden-by-moderator.json
nnew file mode 100644
iindex 00000000..a3430dd2
---- /dev/null
++++ b/fixtures/tips.interaction.moderated/valid/hidden-by-moderator.json
@@@ -0,0 +1,11 @@
++{
++ "interaction_id": "tint_01JAB3C4D5E6F7G8H9J0K1MNPQ",
++ "creator": {
++ "type": "user",
++ "id": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ"
++ },
++ "action": "hidden",
++ "by": "moderator",
++ "moderation_state": "hidden",
++ "cancelled_effects": ["paid_message", "tts"]
++}
ddiff --git a/fixtures/tips.interaction.moderated/valid/restored-by-creator.json b/fixtures/tips.interaction.moderated/valid/restored-by-creator.json
nnew file mode 100644
iindex 00000000..f8d0d3a8
---- /dev/null
++++ b/fixtures/tips.interaction.moderated/valid/restored-by-creator.json
@@@ -0,0 +1,11 @@
++{
++ "interaction_id": "tint_01JAB3C4D5E6F7G8H9J0K1MNPQ",
++ "creator": {
++ "type": "user",
++ "id": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ"
++ },
++ "action": "restored",
++ "by": "creator",
++ "moderation_state": "visible",
++ "cancelled_effects": []
++}
ddiff --git a/generated/json-schema/bundle.json b/generated/json-schema/bundle.json
iindex bc3c8933..18034154 100644
---- a/generated/json-schema/bundle.json
++++ b/generated/json-schema/bundle.json
@@@ -1125,7 +1125,7 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://openvibe.network/contracts/modules/namespace.v1.json",
"title": "ModuleNamespace",
-- "description": "Policy for one user-module namespace: portable per-subject summaries and preferences stored by OpenVibe.Network. Never domain truth, money or authoritative game inventory (roadmap 4.3-4.5).",
++ "description": "Policy for one user-module namespace: portable per-subject summaries and preferences stored by OpenVibe.Network. Never domain truth, money or authoritative game inventory (roadmap 4.3-4.5). The person's account decides what happens to their records in every namespace: when an account is removed, Network deletes all of its records; when two accounts are merged, the surviving account keeps its own record in a namespace where both have one (the other is deleted), and a record only the absorbed account had moves to the survivor. Each of these changes is announced as network.module.updated (reason subject_removed or subject_merged). That is separate from onOwnerRemoved, which says what happens to a namespace's records when the service that owns the namespace is retired.",
"type": "object",
"required": [
"namespace",
@@@ -5745,6 +5745,110 @@
}
}
},
++ "tips.interaction.moderated": {
++ "version": "1.0.0",
++ "owner": "tips",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/tips.interaction.moderated.v1.json",
++ "title": "TipsInteractionModeratedPayload",
++ "description": "tips.interaction.moderated v1 (OpenVibe.Tips server/domain/moderation.js record). A moderation outcome for a paid message (and the name that came with it): the creator's word filter starred words out at settlement (filtered) or held it for review before anything was shown (held); the creator, one of their moderators or a service hid it (hidden: queued chat, TTS and media deliveries were cancelled, the overlay alert retracted, public pages leave it out) or showed it again (restored; a held one is released then). The money is never touched: no refund, the goal and totals keep it. Envelope: subject { type: interaction, id: tint_… }, visibility internal, priority important, actor service:tips. Carries no supporter, message, reason text or moderator identity (Tips keeps those in its own moderation log). Never emitted for simulations.",
++ "type": "object",
++ "required": [
++ "interaction_id",
++ "creator",
++ "action",
++ "by",
++ "moderation_state",
++ "cancelled_effects"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "interaction_id": {
++ "type": "string",
++ "pattern": "^tint_[0-9A-HJKMNP-TV-Z]{26}$"
++ },
++ "creator": {
++ "$ref": "../../identity/subject-ref.v1.json"
++ },
++ "action": {
++ "enum": [
++ "filtered",
++ "held",
++ "hidden",
++ "restored"
++ ]
++ },
++ "by": {
++ "enum": [
++ "filter",
++ "creator",
++ "moderator",
++ "service"
++ ],
++ "description": "Who acted, by role."
++ },
++ "moderation_state": {
++ "enum": [
++ "visible",
++ "held",
++ "hidden"
++ ],
++ "description": "The interaction's moderation state after this outcome."
++ },
++ "cancelled_effects": {
++ "type": "array",
++ "uniqueItems": true,
++ "items": {
++ "enum": [
++ "chat_line",
++ "paid_message",
++ "tts",
++ "media_request"
++ ]
++ },
++ "description": "Deliveries this outcome cancelled before they happened (hidden only; empty otherwise). A consumer that queued the same paid message itself should drop it."
++ }
++ }
++ }
++ },
++ "tips.interaction.erased": {
++ "version": "1.0.0",
++ "owner": "tips",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/tips.interaction.erased.v1.json",
++ "title": "TipsInteractionErasedPayload",
++ "description": "tips.interaction.erased v1 (OpenVibe.Tips server/domain/interactions.js erase). The supporter erased their data from this interaction: Tips no longer holds their subject, name, message, text-to-speech text, media link or checkout reference; the money record (amount, creator, Billing transaction, goal contribution) stays so Billing's books and the creator's totals still reconcile. payload.redacts names every earlier Tips event about the interaction, so OpenVibe.Events tombstones them (ADR-026). Envelope: subject { type: interaction, id: tint_… }, visibility internal, priority important, actor service:tips. Carries no supporter data. Never emitted for simulations.",
++ "type": "object",
++ "required": [
++ "interaction_id",
++ "creator",
++ "erased_at",
++ "redacts"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "interaction_id": {
++ "type": "string",
++ "pattern": "^tint_[0-9A-HJKMNP-TV-Z]{26}$"
++ },
++ "creator": {
++ "$ref": "../../identity/subject-ref.v1.json"
++ },
++ "erased_at": {
++ "type": "string",
++ "format": "date-time"
++ },
++ "redacts": {
++ "$ref": "../redaction-directive.v1.json",
++ "description": "{ subject_type: interaction, subject_ids: [interaction_id] }."
++ }
++ }
++ }
++ },
"tips.goal.updated": {
"version": "1.0.0",
"owner": "tips",
@@@ -8155,6 +8259,1693 @@
}
}
}
++ },
++ "live.stream.started": {
++ "version": "1.0.0",
++ "owner": "live",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/live.stream.started.v1.json",
++ "title": "LiveStreamStartedPayload",
++ "description": "live.stream.started v1 (OpenVibe.Live server/events/stream-events.js envelopeFor, fired by server/db/database.js createStream). A streams row went live: WebRTC/JSMPEG from the dashboard, RTMP ingest, WHIP, or an OpenRe.Stream session mirrored into Live. Written to Live's event_outbox in the transaction that inserts the row, so the event exists if and only if the stream did. Carries public channel facts only (the stream is listed publicly already); consumers such as Network's go-live notifications decide who hears about it. Never the stream key, the Live user id or the description. Envelope: subject { type: stream, id: <stream_id as a string>, revision: 1 }, visibility public, priority important, actor the streamer's user subject when Live knows it, else service:live.",
++ "type": "object",
++ "required": [
++ "stream_id",
++ "channel",
++ "title",
++ "category",
++ "protocol",
++ "is_nsfw",
++ "started_at"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "stream_id": {
++ "type": "integer",
++ "minimum": 1,
++ "description": "Live's streams row id (the envelope subject id)."
++ },
++ "channel": {
++ "type": "object",
++ "required": [
++ "username",
++ "display_name",
++ "url"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "username": {
++ "type": "string",
++ "minLength": 1,
++ "description": "The streamer's Live username (the channel slug)."
++ },
++ "display_name": {
++ "type": "string",
++ "minLength": 1,
++ "description": "The display name, or the username when there is none."
++ },
++ "url": {
++ "type": "string",
++ "format": "uri",
++ "pattern": "^https://openvibe\\.live/",
++ "description": "The channel page, https://openvibe.live/@<username, URI-encoded>. Events from Live before 4c70332 (2026-09-23) carry https://openvibe.live/<username>, which Live answers with a 301 to the @ address since that commit."
++ },
++ "subject": {
++ "type": "object",
++ "required": [
++ "type",
++ "id"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "type": {
++ "const": "user"
++ },
++ "id": {
++ "type": "string",
++ "pattern": "^usr_[0-9A-HJKMNP-TV-Z]{26}$"
++ }
++ },
++ "description": "The streamer's canonical subject; absent until Live has seen it (a Network sign-in or the subject backfill)."
++ }
++ }
++ },
++ "title": {
++ "type": "string"
++ },
++ "category": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "description": "null when the streamer chose none (the AI classifies the stream from what it shows)."
++ },
++ "protocol": {
++ "enum": [
++ "jsmpeg",
++ "webrtc",
++ "rtmp"
++ ],
++ "description": "How the stream is ingested (WHIP is webrtc; OpenRe and RTMP are rtmp)."
++ },
++ "is_nsfw": {
++ "type": "boolean"
++ },
++ "started_at": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "format": "date-time",
++ "description": "UTC. null only for a row without a start time, which createStream never writes."
++ }
++ }
++ }
++ },
++ "live.stream.ended": {
++ "version": "1.0.0",
++ "owner": "live",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/live.stream.ended.v1.json",
++ "title": "LiveStreamEndedPayload",
++ "description": "live.stream.ended v1 (OpenVibe.Live server/events/stream-events.js envelopeFor, fired by server/db/database.js endStream). A live streams row ended (the streamer stopped, ingest dropped, a newer session replaced it on the same slot, or an admin ended it). Ending a row that is not live emits nothing, so each stream ends once. Written to Live's event_outbox in the transaction that ends the row. Everything live.stream.started carries, as it is at the end (the title and category may have changed), plus ended_at and duration_seconds. Carries public channel facts only (the stream is listed publicly already); consumers such as Network's go-live notifications decide who hears about it. Never the stream key, the Live user id or the description. Envelope: subject { type: stream, id: <stream_id as a string>, revision: 2 }, visibility public, priority important, actor the streamer's user subject when Live knows it, else service:live.",
++ "type": "object",
++ "required": [
++ "stream_id",
++ "channel",
++ "title",
++ "category",
++ "protocol",
++ "is_nsfw",
++ "started_at",
++ "ended_at",
++ "duration_seconds"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "stream_id": {
++ "type": "integer",
++ "minimum": 1,
++ "description": "Live's streams row id (the envelope subject id)."
++ },
++ "channel": {
++ "type": "object",
++ "required": [
++ "username",
++ "display_name",
++ "url"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "username": {
++ "type": "string",
++ "minLength": 1,
++ "description": "The streamer's Live username (the channel slug)."
++ },
++ "display_name": {
++ "type": "string",
++ "minLength": 1,
++ "description": "The display name, or the username when there is none."
++ },
++ "url": {
++ "type": "string",
++ "format": "uri",
++ "pattern": "^https://openvibe\\.live/",
++ "description": "The channel page, https://openvibe.live/@<username, URI-encoded>. Events from Live before 4c70332 (2026-09-23) carry https://openvibe.live/<username>, which Live answers with a 301 to the @ address since that commit."
++ },
++ "subject": {
++ "type": "object",
++ "required": [
++ "type",
++ "id"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "type": {
++ "const": "user"
++ },
++ "id": {
++ "type": "string",
++ "pattern": "^usr_[0-9A-HJKMNP-TV-Z]{26}$"
++ }
++ },
++ "description": "The streamer's canonical subject; absent until Live has seen it (a Network sign-in or the subject backfill)."
++ }
++ }
++ },
++ "title": {
++ "type": "string"
++ },
++ "category": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "description": "null when the streamer chose none (the AI classifies the stream from what it shows)."
++ },
++ "protocol": {
++ "enum": [
++ "jsmpeg",
++ "webrtc",
++ "rtmp"
++ ],
++ "description": "How the stream is ingested (WHIP is webrtc; OpenRe and RTMP are rtmp)."
++ },
++ "is_nsfw": {
++ "type": "boolean"
++ },
++ "started_at": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "format": "date-time",
++ "description": "UTC. null only for a row without a start time, which createStream never writes."
++ },
++ "ended_at": {
++ "type": "string",
++ "format": "date-time",
++ "description": "UTC."
++ },
++ "duration_seconds": {
++ "type": [
++ "integer",
++ "null"
++ ],
++ "minimum": 0,
++ "description": "Whole seconds from started_at to ended_at; null when the row has no start time."
++ }
++ }
++ }
++ },
++ "live.release.deployed": {
++ "version": "1.0.0",
++ "owner": "live",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/live.release.deployed.v1.json",
++ "title": "LiveReleaseDeployedPayload",
++ "description": "live.release.deployed v1 (OpenVibe.Live server/events/release-events.js envelopeFor, called by server/chat/deploy-notice.js announce). The first boot that runs commits never announced before: queued in the transaction that records them as announced (site setting deploy_last_announced), so a restart without new code emits nothing and a crash before that commit announces again on the next boot with the same subject. A consumer keys on subject.id (the head commit) to fold repeats. The commit subjects are public already (Live chat and /updates); the consumer decides what is shown. Envelope: subject { type: release, id: <head commit, 40 hex> }, visibility internal, priority low, actor service:live.",
++ "type": "object",
++ "required": [
++ "service",
++ "release",
++ "commit",
++ "previous",
++ "commit_count",
++ "commits",
++ "deployed_at",
++ "notes_url"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "service": {
++ "const": "live"
++ },
++ "release": {
++ "type": "string",
++ "pattern": "^[0-9a-f]{7,40}$",
++ "description": "The head commit's short sha as git abbreviated it (its first 12 characters when the head is not in the list)."
++ },
++ "commit": {
++ "type": "string",
++ "pattern": "^[0-9a-f]{40}$",
++ "description": "The head commit (the envelope subject id)."
++ },
++ "previous": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "pattern": "^[0-9a-f]{40}$",
++ "description": "The head announced before this one; null on the first announcement."
++ },
++ "commit_count": {
++ "type": "integer",
++ "minimum": 1,
++ "maximum": 40,
++ "description": "How many new commits were found (git log previous..head, at most 40; only the head on a first run or after history was rewritten)."
++ },
++ "commits": {
++ "type": "array",
++ "minItems": 1,
++ "maxItems": 40,
++ "description": "The new commits, newest first.",
++ "items": {
++ "type": "object",
++ "required": [
++ "hash",
++ "short",
++ "subject",
++ "date"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "hash": {
++ "type": "string",
++ "pattern": "^[0-9a-f]{40}$"
++ },
++ "short": {
++ "type": "string",
++ "pattern": "^[0-9a-f]{4,40}$"
++ },
++ "subject": {
++ "type": "string",
++ "maxLength": 200,
++ "description": "The commit subject, cut to 200 characters."
++ },
++ "date": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "format": "date-time",
++ "description": "Author date (git %aI, with the author's UTC offset)."
++ }
++ }
++ }
++ },
++ "deployed_at": {
++ "type": "string",
++ "format": "date-time",
++ "description": "When this boot announced it (UTC)."
++ },
++ "notes_url": {
++ "const": "https://openvibe.live/updates"
++ }
++ }
++ }
++ },
++ "media.job.proposed": {
++ "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.job.proposed.v1.json",
++ "title": "MediaJobProposedPayload",
++ "description": "media.job.proposed v1 (OpenVibe.Media server/jobs/queue.js enqueue; server/events.js recordJob). A job was proposed and waits for its owner: approving it queues it (media.job.queued), cancelling it ends it (media.job.cancelled); the worker never runs a proposal. The size-invariant validator (invariant.scan, created_by system:invariant.scan) proposes object.split and object.remux this way. Emitted in the SQLite transaction that records the state change, so the event exists if and only if the change committed; it goes to OpenVibe.Events only (no app webhook). The payload is the job as GET /api/v2/:app/jobs/:id answers it (queue.jobPublic), except that a result larger than 8 KB is replaced by { omitted: true, reason }. It carries the tenant's params, idempotency_key, the free-text error, created_by/decided_by and owner_user_id (a tenant-local integer user id), never the handler's checkpoint, lease or request hash. Times are SQLite UTC timestamps without a zone (YYYY-MM-DD HH:MM:SS). Jobs of developer-project sandbox tenants are not announced. Envelope: subject { type: job, id: <id> }, visibility internal, priority important, actor service:media.",
++ "type": "object",
++ "required": [
++ "id",
++ "app_id",
++ "object_id",
++ "type",
++ "status",
++ "params",
++ "result",
++ "error",
++ "error_code",
++ "attempts",
++ "max_attempts",
++ "run_after",
++ "cancel_requested",
++ "idempotency_key",
++ "created_by",
++ "owner_user_id",
++ "decided_by",
++ "decided_at",
++ "created_at",
++ "updated_at",
++ "started_at",
++ "finished_at"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "id": {
++ "type": "string",
++ "pattern": "^mjob_[0-9A-HJKMNP-TV-Z]{26}$"
++ },
++ "app_id": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 200,
++ "description": "The tenant that owns the job: an app (live, tools…) or a developer project's production tenant (prj_…)."
++ },
++ "object_id": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "maxLength": 200,
++ "description": "The object the job works on; null for a tenant-wide job (invariant.scan)."
++ },
++ "type": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9]*\\.[a-z0-9_.]+$",
++ "maxLength": 80,
++ "description": "Job type: thumbnail.regenerate, invariant.scan, object.split or object.remux."
++ },
++ "status": {
++ "const": "proposed"
++ },
++ "params": {
++ "type": "object",
++ "description": "The parameters the job type accepted (at most 16 KB). object.split keeps every field the caller sent."
++ },
++ "result": {
++ "type": [
++ "object",
++ "null"
++ ],
++ "description": "null until the job finished; a failed or cancelled job may keep what its handler reported. { omitted: true, reason } when larger than 8 KB."
++ },
++ "error": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "maxLength": 1000,
++ "description": "The last failure's message (free text), or null."
++ },
++ "error_code": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "description": "Stable code of the last failure (media_unavailable, interrupted, cancelled, media.job.invalid…), or null."
++ },
++ "attempts": {
++ "type": "integer",
++ "minimum": 0
++ },
++ "max_attempts": {
++ "type": "integer",
++ "minimum": 1,
++ "maximum": 10
++ },
++ "run_after": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$",
++ "description": "A queued job waits until then (retry backoff); null otherwise."
++ },
++ "cancel_requested": {
++ "type": "boolean",
++ "description": "Its owner asked a running job to stop."
++ },
++ "idempotency_key": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "maxLength": 200,
++ "description": "The creator's Idempotency-Key (1-200 visible ASCII characters), invariant:<object_id>:<type> for a validator proposal, or null."
++ },
++ "created_by": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "description": "svc:<service> | app:<app> | app:<app>:user:<id> | system:<what>."
++ },
++ "owner_user_id": {
++ "type": [
++ "integer",
++ "null"
++ ],
++ "minimum": 1,
++ "description": "The app's user the job was created for (X-OV-User-Id): an id in the tenant's own user space, not a Network subject."
++ },
++ "decided_by": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "description": "Who approved or cancelled a proposal or queued job (same forms as created_by)."
++ },
++ "decided_at": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$"
++ },
++ "created_at": {
++ "type": "string",
++ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$"
++ },
++ "updated_at": {
++ "type": "string",
++ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$"
++ },
++ "started_at": {
++ "type": "null"
++ },
++ "finished_at": {
++ "type": "null"
++ }
++ }
++ }
++ },
++ "media.job.queued": {
++ "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.job.queued.v1.json",
++ "title": "MediaJobQueuedPayload",
++ "description": "media.job.queued v1 (OpenVibe.Media server/jobs/queue.js enqueue, approve; server/events.js recordJob). A job was accepted and queued: a new job (POST /api/v2/:app/jobs), or a proposal its owner approved (decided_by, decided_at). An Idempotency-Key replay, or a request joined to an identical job that is still active, creates no job and no event. Emitted in the SQLite transaction that records the state change, so the event exists if and only if the change committed; it goes to OpenVibe.Events only (no app webhook). The payload is the job as GET /api/v2/:app/jobs/:id answers it (queue.jobPublic), except that a result larger than 8 KB is replaced by { omitted: true, reason }. It carries the tenant's params, idempotency_key, the free-text error, created_by/decided_by and owner_user_id (a tenant-local integer user id), never the handler's checkpoint, lease or request hash. Times are SQLite UTC timestamps without a zone (YYYY-MM-DD HH:MM:SS). Jobs of developer-project sandbox tenants are not announced. Envelope: subject { type: job, id: <id> }, visibility internal, priority low, actor service:media.",
++ "type": "object",
++ "required": [
++ "id",
++ "app_id",
++ "object_id",
++ "type",
++ "status",
++ "params",
++ "result",
++ "error",
++ "error_code",
++ "attempts",
++ "max_attempts",
++ "run_after",
++ "cancel_requested",
++ "idempotency_key",
++ "created_by",
++ "owner_user_id",
++ "decided_by",
++ "decided_at",
++ "created_at",
++ "updated_at",
++ "started_at",
++ "finished_at"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "id": {
++ "type": "string",
++ "pattern": "^mjob_[0-9A-HJKMNP-TV-Z]{26}$"
++ },
++ "app_id": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 200,
++ "description": "The tenant that owns the job: an app (live, tools…) or a developer project's production tenant (prj_…)."
++ },
++ "object_id": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "maxLength": 200,
++ "description": "The object the job works on; null for a tenant-wide job (invariant.scan)."
++ },
++ "type": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9]*\\.[a-z0-9_.]+$",
++ "maxLength": 80,
++ "description": "Job type: thumbnail.regenerate, invariant.scan, object.split or object.remux."
++ },
++ "status": {
++ "const": "queued"
++ },
++ "params": {
++ "type": "object",
++ "description": "The parameters the job type accepted (at most 16 KB). object.split keeps every field the caller sent."
++ },
++ "result": {
++ "type": [
++ "object",
++ "null"
++ ],
++ "description": "null until the job finished; a failed or cancelled job may keep what its handler reported. { omitted: true, reason } when larger than 8 KB."
++ },
++ "error": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "maxLength": 1000,
++ "description": "The last failure's message (free text), or null."
++ },
++ "error_code": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "description": "Stable code of the last failure (media_unavailable, interrupted, cancelled, media.job.invalid…), or null."
++ },
++ "attempts": {
++ "type": "integer",
++ "minimum": 0
++ },
++ "max_attempts": {
++ "type": "integer",
++ "minimum": 1,
++ "maximum": 10
++ },
++ "run_after": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$",
++ "description": "A queued job waits until then (retry backoff); null otherwise."
++ },
++ "cancel_requested": {
++ "type": "boolean",
++ "description": "Its owner asked a running job to stop."
++ },
++ "idempotency_key": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "maxLength": 200,
++ "description": "The creator's Idempotency-Key (1-200 visible ASCII characters), invariant:<object_id>:<type> for a validator proposal, or null."
++ },
++ "created_by": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "description": "svc:<service> | app:<app> | app:<app>:user:<id> | system:<what>."
++ },
++ "owner_user_id": {
++ "type": [
++ "integer",
++ "null"
++ ],
++ "minimum": 1,
++ "description": "The app's user the job was created for (X-OV-User-Id): an id in the tenant's own user space, not a Network subject."
++ },
++ "decided_by": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "description": "Who approved or cancelled a proposal or queued job (same forms as created_by)."
++ },
++ "decided_at": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$"
++ },
++ "created_at": {
++ "type": "string",
++ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$"
++ },
++ "updated_at": {
++ "type": "string",
++ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$"
++ },
++ "started_at": {
++ "type": "null"
++ },
++ "finished_at": {
++ "type": "null"
++ }
++ }
++ }
++ },
++ "media.job.started": {
++ "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.job.started.v1.json",
++ "title": "MediaJobStartedPayload",
++ "description": "media.job.started v1 (OpenVibe.Media server/jobs/queue.js claim; server/events.js recordJob). The worker claimed the job and it is running: attempts counts this attempt, started_at stays the first attempt's start. A job that is retried starts again and is announced again. Emitted in the SQLite transaction that records the state change, so the event exists if and only if the change committed; it goes to OpenVibe.Events only (no app webhook). The payload is the job as GET /api/v2/:app/jobs/:id answers it (queue.jobPublic), except that a result larger than 8 KB is replaced by { omitted: true, reason }. It carries the tenant's params, idempotency_key, the free-text error, created_by/decided_by and owner_user_id (a tenant-local integer user id), never the handler's checkpoint, lease or request hash. Times are SQLite UTC timestamps without a zone (YYYY-MM-DD HH:MM:SS). Jobs of developer-project sandbox tenants are not announced. Envelope: subject { type: job, id: <id> }, visibility internal, priority low, actor service:media.",
++ "type": "object",
++ "required": [
++ "id",
++ "app_id",
++ "object_id",
++ "type",
++ "status",
++ "params",
++ "result",
++ "error",
++ "error_code",
++ "attempts",
++ "max_attempts",
++ "run_after",
++ "cancel_requested",
++ "idempotency_key",
++ "created_by",
++ "owner_user_id",
++ "decided_by",
++ "decided_at",
++ "created_at",
++ "updated_at",
++ "started_at",
++ "finished_at"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "id": {
++ "type": "string",
++ "pattern": "^mjob_[0-9A-HJKMNP-TV-Z]{26}$"
++ },
++ "app_id": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 200,
++ "description": "The tenant that owns the job: an app (live, tools…) or a developer project's production tenant (prj_…)."
++ },
++ "object_id": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "maxLength": 200,
++ "description": "The object the job works on; null for a tenant-wide job (invariant.scan)."
++ },
++ "type": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9]*\\.[a-z0-9_.]+$",
++ "maxLength": 80,
++ "description": "Job type: thumbnail.regenerate, invariant.scan, object.split or object.remux."
++ },
++ "status": {
++ "const": "running"
++ },
++ "params": {
++ "type": "object",
++ "description": "The parameters the job type accepted (at most 16 KB). object.split keeps every field the caller sent."
++ },
++ "result": {
++ "type": [
++ "object",
++ "null"
++ ],
++ "description": "null until the job finished; a failed or cancelled job may keep what its handler reported. { omitted: true, reason } when larger than 8 KB."
++ },
++ "error": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "maxLength": 1000,
++ "description": "The last failure's message (free text), or null."
++ },
++ "error_code": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "description": "Stable code of the last failure (media_unavailable, interrupted, cancelled, media.job.invalid…), or null."
++ },
++ "attempts": {
++ "type": "integer",
++ "minimum": 0
++ },
++ "max_attempts": {
++ "type": "integer",
++ "minimum": 1,
++ "maximum": 10
++ },
++ "run_after": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$",
++ "description": "A queued job waits until then (retry backoff); null otherwise."
++ },
++ "cancel_requested": {
++ "type": "boolean",
++ "description": "Its owner asked a running job to stop."
++ },
++ "idempotency_key": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "maxLength": 200,
++ "description": "The creator's Idempotency-Key (1-200 visible ASCII characters), invariant:<object_id>:<type> for a validator proposal, or null."
++ },
++ "created_by": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "description": "svc:<service> | app:<app> | app:<app>:user:<id> | system:<what>."
++ },
++ "owner_user_id": {
++ "type": [
++ "integer",
++ "null"
++ ],
++ "minimum": 1,
++ "description": "The app's user the job was created for (X-OV-User-Id): an id in the tenant's own user space, not a Network subject."
++ },
++ "decided_by": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "description": "Who approved or cancelled a proposal or queued job (same forms as created_by)."
++ },
++ "decided_at": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$"
++ },
++ "created_at": {
++ "type": "string",
++ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$"
++ },
++ "updated_at": {
++ "type": "string",
++ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$"
++ },
++ "started_at": {
++ "type": "string",
++ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$"
++ },
++ "finished_at": {
++ "type": "null"
++ }
++ }
++ }
++ },
++ "media.job.retrying": {
++ "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.job.retrying.v1.json",
++ "title": "MediaJobRetryingPayload",
++ "description": "media.job.retrying v1 (OpenVibe.Media server/jobs/queue.js fail with a retry; server/events.js recordJob). An attempt failed and the job is queued again after a backoff (run_after); error and error_code say why. A running job nobody holds any more (the service restarted, or the worker stopped renewing its lease) is retried this way with error_code interrupted while it has attempts left. status is queued. Emitted in the SQLite transaction that records the state change, so the event exists if and only if the change committed; it goes to OpenVibe.Events only (no app webhook). The payload is the job as GET /api/v2/:app/jobs/:id answers it (queue.jobPublic), except that a result larger than 8 KB is replaced by { omitted: true, reason }. It carries the tenant's params, idempotency_key, the free-text error, created_by/decided_by and owner_user_id (a tenant-local integer user id), never the handler's checkpoint, lease or request hash. Times are SQLite UTC timestamps without a zone (YYYY-MM-DD HH:MM:SS). Jobs of developer-project sandbox tenants are not announced. Envelope: subject { type: job, id: <id> }, visibility internal, priority low, actor service:media.",
++ "type": "object",
++ "required": [
++ "id",
++ "app_id",
++ "object_id",
++ "type",
++ "status",
++ "params",
++ "result",
++ "error",
++ "error_code",
++ "attempts",
++ "max_attempts",
++ "run_after",
++ "cancel_requested",
++ "idempotency_key",
++ "created_by",
++ "owner_user_id",
++ "decided_by",
++ "decided_at",
++ "created_at",
++ "updated_at",
++ "started_at",
++ "finished_at"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "id": {
++ "type": "string",
++ "pattern": "^mjob_[0-9A-HJKMNP-TV-Z]{26}$"
++ },
++ "app_id": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 200,
++ "description": "The tenant that owns the job: an app (live, tools…) or a developer project's production tenant (prj_…)."
++ },
++ "object_id": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "maxLength": 200,
++ "description": "The object the job works on; null for a tenant-wide job (invariant.scan)."
++ },
++ "type": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9]*\\.[a-z0-9_.]+$",
++ "maxLength": 80,
++ "description": "Job type: thumbnail.regenerate, invariant.scan, object.split or object.remux."
++ },
++ "status": {
++ "const": "queued"
++ },
++ "params": {
++ "type": "object",
++ "description": "The parameters the job type accepted (at most 16 KB). object.split keeps every field the caller sent."
++ },
++ "result": {
++ "type": [
++ "object",
++ "null"
++ ],
++ "description": "null until the job finished; a failed or cancelled job may keep what its handler reported. { omitted: true, reason } when larger than 8 KB."
++ },
++ "error": {
++ "type": "string",
++ "maxLength": 1000,
++ "description": "The failure's message, free text from the handler."
++ },
++ "error_code": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "description": "Stable code of the last failure (media_unavailable, interrupted, cancelled, media.job.invalid…), or null."
++ },
++ "attempts": {
++ "type": "integer",
++ "minimum": 0
++ },
++ "max_attempts": {
++ "type": "integer",
++ "minimum": 1,
++ "maximum": 10
++ },
++ "run_after": {
++ "type": "string",
++ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$",
++ "description": "Not before this time (the retry backoff)."
++ },
++ "cancel_requested": {
++ "type": "boolean",
++ "description": "Its owner asked a running job to stop."
++ },
++ "idempotency_key": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "maxLength": 200,
++ "description": "The creator's Idempotency-Key (1-200 visible ASCII characters), invariant:<object_id>:<type> for a validator proposal, or null."
++ },
++ "created_by": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "description": "svc:<service> | app:<app> | app:<app>:user:<id> | system:<what>."
++ },
++ "owner_user_id": {
++ "type": [
++ "integer",
++ "null"
++ ],
++ "minimum": 1,
++ "description": "The app's user the job was created for (X-OV-User-Id): an id in the tenant's own user space, not a Network subject."
++ },
++ "decided_by": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "description": "Who approved or cancelled a proposal or queued job (same forms as created_by)."
++ },
++ "decided_at": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$"
++ },
++ "created_at": {
++ "type": "string",
++ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$"
++ },
++ "updated_at": {
++ "type": "string",
++ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$"
++ },
++ "started_at": {
++ "type": "string",
++ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$"
++ },
++ "finished_at": {
++ "type": "null"
++ }
++ }
++ }
++ },
++ "media.job.succeeded": {
++ "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.job.succeeded.v1.json",
++ "title": "MediaJobSucceededPayload",
++ "description": "media.job.succeeded v1 (OpenVibe.Media server/jobs/queue.js succeed; server/events.js recordJob). The job finished: result is its type's result (thumbnail.regenerate { url, kind, id }, object.split { source_id, parts, … }, object.remux { source_id, object_id, … }, invariant.scan { thresholds, counts, … }). Emitted in the SQLite transaction that records the state change, so the event exists if and only if the change committed; it goes to OpenVibe.Events only (no app webhook). The payload is the job as GET /api/v2/:app/jobs/:id answers it (queue.jobPublic), except that a result larger than 8 KB is replaced by { omitted: true, reason }. It carries the tenant's params, idempotency_key, the free-text error, created_by/decided_by and owner_user_id (a tenant-local integer user id), never the handler's checkpoint, lease or request hash. Times are SQLite UTC timestamps without a zone (YYYY-MM-DD HH:MM:SS). Jobs of developer-project sandbox tenants are not announced. Envelope: subject { type: job, id: <id> }, visibility internal, priority important, actor service:media.",
++ "type": "object",
++ "required": [
++ "id",
++ "app_id",
++ "object_id",
++ "type",
++ "status",
++ "params",
++ "result",
++ "error",
++ "error_code",
++ "attempts",
++ "max_attempts",
++ "run_after",
++ "cancel_requested",
++ "idempotency_key",
++ "created_by",
++ "owner_user_id",
++ "decided_by",
++ "decided_at",
++ "created_at",
++ "updated_at",
++ "started_at",
++ "finished_at"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "id": {
++ "type": "string",
++ "pattern": "^mjob_[0-9A-HJKMNP-TV-Z]{26}$"
++ },
++ "app_id": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 200,
++ "description": "The tenant that owns the job: an app (live, tools…) or a developer project's production tenant (prj_…)."
++ },
++ "object_id": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "maxLength": 200,
++ "description": "The object the job works on; null for a tenant-wide job (invariant.scan)."
++ },
++ "type": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9]*\\.[a-z0-9_.]+$",
++ "maxLength": 80,
++ "description": "Job type: thumbnail.regenerate, invariant.scan, object.split or object.remux."
++ },
++ "status": {
++ "const": "succeeded"
++ },
++ "params": {
++ "type": "object",
++ "description": "The parameters the job type accepted (at most 16 KB). object.split keeps every field the caller sent."
++ },
++ "result": {
++ "type": [
++ "object",
++ "null"
++ ],
++ "description": "The job type's result, or { omitted: true, reason } when it is larger than 8 KB (GET the job for it)."
++ },
++ "error": {
++ "type": "null"
++ },
++ "error_code": {
++ "type": "null"
++ },
++ "attempts": {
++ "type": "integer",
++ "minimum": 0
++ },
++ "max_attempts": {
++ "type": "integer",
++ "minimum": 1,
++ "maximum": 10
++ },
++ "run_after": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$",
++ "description": "A queued job waits until then (retry backoff); null otherwise."
++ },
++ "cancel_requested": {
++ "type": "boolean",
++ "description": "Its owner asked a running job to stop."
++ },
++ "idempotency_key": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "maxLength": 200,
++ "description": "The creator's Idempotency-Key (1-200 visible ASCII characters), invariant:<object_id>:<type> for a validator proposal, or null."
++ },
++ "created_by": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "description": "svc:<service> | app:<app> | app:<app>:user:<id> | system:<what>."
++ },
++ "owner_user_id": {
++ "type": [
++ "integer",
++ "null"
++ ],
++ "minimum": 1,
++ "description": "The app's user the job was created for (X-OV-User-Id): an id in the tenant's own user space, not a Network subject."
++ },
++ "decided_by": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "description": "Who approved or cancelled a proposal or queued job (same forms as created_by)."
++ },
++ "decided_at": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$"
++ },
++ "created_at": {
++ "type": "string",
++ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$"
++ },
++ "updated_at": {
++ "type": "string",
++ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$"
++ },
++ "started_at": {
++ "type": "string",
++ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$"
++ },
++ "finished_at": {
++ "type": "string",
++ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$"
++ }
++ }
++ }
++ },
++ "media.job.failed": {
++ "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.job.failed.v1.json",
++ "title": "MediaJobFailedPayload",
++ "description": "media.job.failed v1 (OpenVibe.Media server/jobs/queue.js fail; server/events.js recordJob). The job failed for good: a permanent error, or its last attempt (attempts = max_attempts) failed; error and error_code say why. A cancelled job is not a failure and is announced as media.job.cancelled. Emitted in the SQLite transaction that records the state change, so the event exists if and only if the change committed; it goes to OpenVibe.Events only (no app webhook). The payload is the job as GET /api/v2/:app/jobs/:id answers it (queue.jobPublic), except that a result larger than 8 KB is replaced by { omitted: true, reason }. It carries the tenant's params, idempotency_key, the free-text error, created_by/decided_by and owner_user_id (a tenant-local integer user id), never the handler's checkpoint, lease or request hash. Times are SQLite UTC timestamps without a zone (YYYY-MM-DD HH:MM:SS). Jobs of developer-project sandbox tenants are not announced. Envelope: subject { type: job, id: <id> }, visibility internal, priority important, actor service:media.",
++ "type": "object",
++ "required": [
++ "id",
++ "app_id",
++ "object_id",
++ "type",
++ "status",
++ "params",
++ "result",
++ "error",
++ "error_code",
++ "attempts",
++ "max_attempts",
++ "run_after",
++ "cancel_requested",
++ "idempotency_key",
++ "created_by",
++ "owner_user_id",
++ "decided_by",
++ "decided_at",
++ "created_at",
++ "updated_at",
++ "started_at",
++ "finished_at"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "id": {
++ "type": "string",
++ "pattern": "^mjob_[0-9A-HJKMNP-TV-Z]{26}$"
++ },
++ "app_id": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 200,
++ "description": "The tenant that owns the job: an app (live, tools…) or a developer project's production tenant (prj_…)."
++ },
++ "object_id": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "maxLength": 200,
++ "description": "The object the job works on; null for a tenant-wide job (invariant.scan)."
++ },
++ "type": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9]*\\.[a-z0-9_.]+$",
++ "maxLength": 80,
++ "description": "Job type: thumbnail.regenerate, invariant.scan, object.split or object.remux."
++ },
++ "status": {
++ "const": "failed"
++ },
++ "params": {
++ "type": "object",
++ "description": "The parameters the job type accepted (at most 16 KB). object.split keeps every field the caller sent."
++ },
++ "result": {
++ "type": [
++ "object",
++ "null"
++ ],
++ "description": "null until the job finished; a failed or cancelled job may keep what its handler reported. { omitted: true, reason } when larger than 8 KB."
++ },
++ "error": {
++ "type": "string",
++ "maxLength": 1000,
++ "description": "The failure's message, free text from the handler."
++ },
++ "error_code": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "description": "Stable code of the last failure (media_unavailable, interrupted, cancelled, media.job.invalid…), or null."
++ },
++ "attempts": {
++ "type": "integer",
++ "minimum": 0
++ },
++ "max_attempts": {
++ "type": "integer",
++ "minimum": 1,
++ "maximum": 10
++ },
++ "run_after": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$",
++ "description": "A queued job waits until then (retry backoff); null otherwise."
++ },
++ "cancel_requested": {
++ "type": "boolean",
++ "description": "Its owner asked a running job to stop."
++ },
++ "idempotency_key": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "maxLength": 200,
++ "description": "The creator's Idempotency-Key (1-200 visible ASCII characters), invariant:<object_id>:<type> for a validator proposal, or null."
++ },
++ "created_by": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "description": "svc:<service> | app:<app> | app:<app>:user:<id> | system:<what>."
++ },
++ "owner_user_id": {
++ "type": [
++ "integer",
++ "null"
++ ],
++ "minimum": 1,
++ "description": "The app's user the job was created for (X-OV-User-Id): an id in the tenant's own user space, not a Network subject."
++ },
++ "decided_by": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "description": "Who approved or cancelled a proposal or queued job (same forms as created_by)."
++ },
++ "decided_at": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$"
++ },
++ "created_at": {
++ "type": "string",
++ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$"
++ },
++ "updated_at": {
++ "type": "string",
++ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$"
++ },
++ "started_at": {
++ "type": "string",
++ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$"
++ },
++ "finished_at": {
++ "type": "string",
++ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$"
++ }
++ }
++ }
++ },
++ "media.job.cancelled": {
++ "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.job.cancelled.v1.json",
++ "title": "MediaJobCancelledPayload",
++ "description": "media.job.cancelled v1 (OpenVibe.Media server/jobs/queue.js cancel, markCancelled; server/events.js recordJob). The job was cancelled: a proposal or a queued job at once, by its owner (POST /api/v2/:app/jobs/:id/cancel or DELETE /api/v2/:app/jobs/:id) or by the validator withdrawing a proposal that no longer applies (decided_by system:invariant.scan); a running job once it stopped after its owner asked (cancel_requested true, error_code cancelled). Emitted in the SQLite transaction that records the state change, so the event exists if and only if the change committed; it goes to OpenVibe.Events only (no app webhook). The payload is the job as GET /api/v2/:app/jobs/:id answers it (queue.jobPublic), except that a result larger than 8 KB is replaced by { omitted: true, reason }. It carries the tenant's params, idempotency_key, the free-text error, created_by/decided_by and owner_user_id (a tenant-local integer user id), never the handler's checkpoint, lease or request hash. Times are SQLite UTC timestamps without a zone (YYYY-MM-DD HH:MM:SS). Jobs of developer-project sandbox tenants are not announced. Envelope: subject { type: job, id: <id> }, visibility internal, priority important, actor service:media.",
++ "type": "object",
++ "required": [
++ "id",
++ "app_id",
++ "object_id",
++ "type",
++ "status",
++ "params",
++ "result",
++ "error",
++ "error_code",
++ "attempts",
++ "max_attempts",
++ "run_after",
++ "cancel_requested",
++ "idempotency_key",
++ "created_by",
++ "owner_user_id",
++ "decided_by",
++ "decided_at",
++ "created_at",
++ "updated_at",
++ "started_at",
++ "finished_at"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "id": {
++ "type": "string",
++ "pattern": "^mjob_[0-9A-HJKMNP-TV-Z]{26}$"
++ },
++ "app_id": {
++ "type": "string",
++ "minLength": 1,
++ "maxLength": 200,
++ "description": "The tenant that owns the job: an app (live, tools…) or a developer project's production tenant (prj_…)."
++ },
++ "object_id": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "maxLength": 200,
++ "description": "The object the job works on; null for a tenant-wide job (invariant.scan)."
++ },
++ "type": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9]*\\.[a-z0-9_.]+$",
++ "maxLength": 80,
++ "description": "Job type: thumbnail.regenerate, invariant.scan, object.split or object.remux."
++ },
++ "status": {
++ "const": "cancelled"
++ },
++ "params": {
++ "type": "object",
++ "description": "The parameters the job type accepted (at most 16 KB). object.split keeps every field the caller sent."
++ },
++ "result": {
++ "type": [
++ "object",
++ "null"
++ ],
++ "description": "null until the job finished; a failed or cancelled job may keep what its handler reported. { omitted: true, reason } when larger than 8 KB."
++ },
++ "error": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "maxLength": 1000,
++ "description": "The last failure's message (free text), or null."
++ },
++ "error_code": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "description": "Stable code of the last failure (media_unavailable, interrupted, cancelled, media.job.invalid…), or null."
++ },
++ "attempts": {
++ "type": "integer",
++ "minimum": 0
++ },
++ "max_attempts": {
++ "type": "integer",
++ "minimum": 1,
++ "maximum": 10
++ },
++ "run_after": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$",
++ "description": "A queued job waits until then (retry backoff); null otherwise."
++ },
++ "cancel_requested": {
++ "type": "boolean",
++ "description": "Its owner asked a running job to stop."
++ },
++ "idempotency_key": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "maxLength": 200,
++ "description": "The creator's Idempotency-Key (1-200 visible ASCII characters), invariant:<object_id>:<type> for a validator proposal, or null."
++ },
++ "created_by": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "description": "svc:<service> | app:<app> | app:<app>:user:<id> | system:<what>."
++ },
++ "owner_user_id": {
++ "type": [
++ "integer",
++ "null"
++ ],
++ "minimum": 1,
++ "description": "The app's user the job was created for (X-OV-User-Id): an id in the tenant's own user space, not a Network subject."
++ },
++ "decided_by": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "description": "Who approved or cancelled a proposal or queued job (same forms as created_by)."
++ },
++ "decided_at": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$"
++ },
++ "created_at": {
++ "type": "string",
++ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$"
++ },
++ "updated_at": {
++ "type": "string",
++ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$"
++ },
++ "started_at": {
++ "type": [
++ "string",
++ "null"
++ ],
++ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$",
++ "description": "null when the job never ran."
++ },
++ "finished_at": {
++ "type": "string",
++ "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}$"
++ }
++ }
++ }
++ },
++ "network.module.updated": {
++ "version": "1.0.0",
++ "owner": "network",
++ "status": "active",
++ "schema": {
++ "$schema": "https://json-schema.org/draft/2020-12/schema",
++ "$id": "https://openvibe.network/contracts/events/payloads/network.module.updated.v1.json",
++ "title": "NetworkModuleUpdatedPayload",
++ "description": "network.module.updated v1 (OpenVibe.Network server/identity/module-events.js buildEnvelope, called from server/identity/modules.js). One event per change to one person's user-module record (modules.module-record@1): a write (created or updated), a delete by the person (delete) or by the owning service (owner_delete, also the retention sweep of a retired owner's delete-after-retention namespace), account removal (subject_removed: every record of the account is deleted) and account merge (subject_merged: the absorbed account's records are deleted, merged_into; a record moved to the surviving account is created there, merged_from; where the survivor already has one, the survivor's is kept and unchanged). Written into Network's event outbox in the transaction that makes the change. revision grows by one on every change to (owner, namespace), deletes included, and never restarts: a consumer keeps the highest revision it applied and ignores anything lower. Values of private fields never leave Network: keys names the changed fields, public carries only the new values of changed public fields. Envelope: subject { type: user_module, id: <owner id>:<namespace>, revision }, visibility internal, actor the person, the owning service or system:network.",
++ "type": "object",
++ "required": [
++ "owner",
++ "namespace",
++ "namespace_owner",
++ "schema_version",
++ "revision",
++ "change",
++ "reason",
++ "keys"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "owner": {
++ "oneOf": [
++ {
++ "type": "object",
++ "required": [
++ "type",
++ "id"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "type": {
++ "const": "user"
++ },
++ "id": {
++ "type": "string",
++ "pattern": "^usr_[0-9A-HJKMNP-TV-Z]{26}$"
++ }
++ }
++ },
++ {
++ "type": "object",
++ "required": [
++ "type",
++ "id"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "type": {
++ "const": "guest"
++ },
++ "id": {
++ "type": "string",
++ "pattern": "^gst_[0-9A-HJKMNP-TV-Z]{26}$"
++ }
++ }
++ }
++ ],
++ "description": "The person the record belongs to (identity.subject-ref@1, a user or a guest)."
++ },
++ "namespace": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9_]*(\\.[a-z0-9_]+)+$",
++ "description": "e.g. chat.preferences (modules.namespace@1)."
++ },
++ "namespace_owner": {
++ "type": "string",
++ "pattern": "^[a-z][a-z0-9-]{1,39}$",
++ "description": "The service that owns the namespace when the change was made."
++ },
++ "schema_version": {
++ "type": "integer",
++ "minimum": 1,
++ "description": "The namespace version the record was written under."
++ },
++ "revision": {
++ "type": "integer",
++ "minimum": 1,
++ "description": "The record's revision after this change (the envelope subject revision)."
++ },
++ "change": {
++ "enum": [
++ "created",
++ "updated",
++ "deleted"
++ ]
++ },
++ "reason": {
++ "enum": [
++ "write",
++ "delete",
++ "owner_delete",
++ "subject_removed",
++ "subject_merged"
++ ]
++ },
++ "keys": {
++ "type": "array",
++ "uniqueItems": true,
++ "items": {
++ "type": "string",
++ "minLength": 1
++ },
++ "description": "Top-level fields added, changed or removed, names only, sorted. A deleted record lists every field it held."
++ },
++ "public": {
++ "type": "object",
++ "minProperties": 1,
++ "additionalProperties": true,
++ "description": "The new values of the changed fields the namespace declares public; null for one that was removed. Absent when no public field changed, for a namespace without public fields and for a deleted record."
++ },
++ "merged_into": {
++ "oneOf": [
++ {
++ "type": "object",
++ "required": [
++ "type",
++ "id"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "type": {
++ "const": "user"
++ },
++ "id": {
++ "type": "string",
++ "pattern": "^usr_[0-9A-HJKMNP-TV-Z]{26}$"
++ }
++ }
++ },
++ {
++ "type": "object",
++ "required": [
++ "type",
++ "id"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "type": {
++ "const": "guest"
++ },
++ "id": {
++ "type": "string",
++ "pattern": "^gst_[0-9A-HJKMNP-TV-Z]{26}$"
++ }
++ }
++ }
++ ],
++ "description": "On the absorbed account's deleted record (reason subject_merged): the account that survives."
++ },
++ "merged_from": {
++ "oneOf": [
++ {
++ "type": "object",
++ "required": [
++ "type",
++ "id"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "type": {
++ "const": "user"
++ },
++ "id": {
++ "type": "string",
++ "pattern": "^usr_[0-9A-HJKMNP-TV-Z]{26}$"
++ }
++ }
++ },
++ {
++ "type": "object",
++ "required": [
++ "type",
++ "id"
++ ],
++ "additionalProperties": false,
++ "properties": {
++ "type": {
++ "const": "guest"
++ },
++ "id": {
++ "type": "string",
++ "pattern": "^gst_[0-9A-HJKMNP-TV-Z]{26}$"
++ }
++ }
++ }
++ ],
++ "description": "On a record moved to the surviving account (created, reason subject_merged): the account it came from."
++ }
++ },
++ "dependentSchemas": {
++ "public": {
++ "properties": {
++ "change": {
++ "enum": [
++ "created",
++ "updated"
++ ]
++ }
++ }
++ },
++ "merged_into": {
++ "properties": {
++ "change": {
++ "const": "deleted"
++ },
++ "reason": {
++ "const": "subject_merged"
++ }
++ }
++ },
++ "merged_from": {
++ "properties": {
++ "change": {
++ "const": "created"
++ },
++ "reason": {
++ "const": "subject_merged"
++ }
++ }
++ }
++ }
++ }
}
}
}
ddiff --git a/generated/typescript/index.d.ts b/generated/typescript/index.d.ts
iindex 298bb582..3ebaf833 100644
---- a/generated/typescript/index.d.ts
++++ b/generated/typescript/index.d.ts
@@@ -389,7 +389,7 @@ export interface LineageResolution {
/** [email protected] (owner: network) */
/**
-- * Policy for one user-module namespace: portable per-subject summaries and preferences stored by OpenVibe.Network. Never domain truth, money or authoritative game inventory (roadmap 4.3-4.5).
++ * Policy for one user-module namespace: portable per-subject summaries and preferences stored by OpenVibe.Network. Never domain truth, money or authoritative game inventory (roadmap 4.3-4.5). The person's account decides what happens to their records in every namespace: when an account is removed, Network deletes all of its records; when two accounts are merged, the surviving account keeps its own record in a namespace where both have one (the other is deleted), and a record only the absorbed account had moves to the survivor. Each of these changes is announced as network.module.updated (reason subject_removed or subject_merged). That is separate from onOwnerRemoved, which says what happens to a namespace's records when the service that owns the namespace is retired.
*/
export interface ModuleNamespace {
namespace: string;
@@@ -5938,6 +5938,39 @@ export interface TipsInteractionCancelledPayload {
code?: string;
}
++/** [email protected] (owner: tips) */
++/**
++ * tips.interaction.moderated v1 (OpenVibe.Tips server/domain/moderation.js record). A moderation outcome for a paid message (and the name that came with it): the creator's word filter starred words out at settlement (filtered) or held it for review before anything was shown (held); the creator, one of their moderators or a service hid it (hidden: queued chat, TTS and media deliveries were cancelled, the overlay alert retracted, public pages leave it out) or showed it again (restored; a held one is released then). The money is never touched: no refund, the goal and totals keep it. Envelope: subject { type: interaction, id: tint_… }, visibility internal, priority important, actor service:tips. Carries no supporter, message, reason text or moderator identity (Tips keeps those in its own moderation log). Never emitted for simulations.
++ */
++export interface TipsInteractionModeratedPayload {
++ interaction_id: string;
++ creator: SubjectRef;
++ action: "filtered" | "held" | "hidden" | "restored";
++ /**
++ * Who acted, by role.
++ */
++ by: "filter" | "creator" | "moderator" | "service";
++ /**
++ * The interaction's moderation state after this outcome.
++ */
++ moderation_state: "visible" | "held" | "hidden";
++ /**
++ * Deliveries this outcome cancelled before they happened (hidden only; empty otherwise). A consumer that queued the same paid message itself should drop it.
++ */
++ cancelled_effects: ("chat_line" | "paid_message" | "tts" | "media_request")[];
++}
++
++/** [email protected] (owner: tips) */
++/**
++ * tips.interaction.erased v1 (OpenVibe.Tips server/domain/interactions.js erase). The supporter erased their data from this interaction: Tips no longer holds their subject, name, message, text-to-speech text, media link or checkout reference; the money record (amount, creator, Billing transaction, goal contribution) stays so Billing's books and the creator's totals still reconcile. payload.redacts names every earlier Tips event about the interaction, so OpenVibe.Events tombstones them (ADR-026). Envelope: subject { type: interaction, id: tint_… }, visibility internal, priority important, actor service:tips. Carries no supporter data. Never emitted for simulations.
++ */
++export interface TipsInteractionErasedPayload {
++ interaction_id: string;
++ creator: SubjectRef;
++ erased_at: string;
++ redacts: RedactionDirective;
++}
++
/** [email protected] (owner: tips) */
/**
* tips.goal.updated v1 (OpenVibe.Tips server/domain/goals.js changed). A goal was created, edited or closed, or its settled total moved (a contribution, the contribution that reached it, a reversal). Envelope: subject { type: goal, id: tgoal_…, revision }, visibility internal, priority important, actor service:tips.
@@@ -6857,3 +6890,708 @@ export interface ToolsJobFailedPayload {
};
retryable: boolean;
}
++
++/** [email protected] (owner: live) */
++/**
++ * live.stream.started v1 (OpenVibe.Live server/events/stream-events.js envelopeFor, fired by server/db/database.js createStream). A streams row went live: WebRTC/JSMPEG from the dashboard, RTMP ingest, WHIP, or an OpenRe.Stream session mirrored into Live. Written to Live's event_outbox in the transaction that inserts the row, so the event exists if and only if the stream did. Carries public channel facts only (the stream is listed publicly already); consumers such as Network's go-live notifications decide who hears about it. Never the stream key, the Live user id or the description. Envelope: subject { type: stream, id: <stream_id as a string>, revision: 1 }, visibility public, priority important, actor the streamer's user subject when Live knows it, else service:live.
++ */
++export interface LiveStreamStartedPayload {
++ /**
++ * Live's streams row id (the envelope subject id).
++ */
++ stream_id: number;
++ channel: {
++ /**
++ * The streamer's Live username (the channel slug).
++ */
++ username: string;
++ /**
++ * The display name, or the username when there is none.
++ */
++ display_name: string;
++ /**
++ * The channel page, https://openvibe.live/@<username, URI-encoded>. Events from Live before 4c70332 (2026-09-23) carry https://openvibe.live/<username>, which Live answers with a 301 to the @ address since that commit.
++ */
++ url: string;
++ /**
++ * The streamer's canonical subject; absent until Live has seen it (a Network sign-in or the subject backfill).
++ */
++ subject?: {
++ type: "user";
++ id: string;
++ };
++ };
++ title: string;
++ /**
++ * null when the streamer chose none (the AI classifies the stream from what it shows).
++ */
++ category: string | null;
++ /**
++ * How the stream is ingested (WHIP is webrtc; OpenRe and RTMP are rtmp).
++ */
++ protocol: "jsmpeg" | "webrtc" | "rtmp";
++ is_nsfw: boolean;
++ /**
++ * UTC. null only for a row without a start time, which createStream never writes.
++ */
++ started_at: string | null;
++}
++
++/** [email protected] (owner: live) */
++/**
++ * live.stream.ended v1 (OpenVibe.Live server/events/stream-events.js envelopeFor, fired by server/db/database.js endStream). A live streams row ended (the streamer stopped, ingest dropped, a newer session replaced it on the same slot, or an admin ended it). Ending a row that is not live emits nothing, so each stream ends once. Written to Live's event_outbox in the transaction that ends the row. Everything live.stream.started carries, as it is at the end (the title and category may have changed), plus ended_at and duration_seconds. Carries public channel facts only (the stream is listed publicly already); consumers such as Network's go-live notifications decide who hears about it. Never the stream key, the Live user id or the description. Envelope: subject { type: stream, id: <stream_id as a string>, revision: 2 }, visibility public, priority important, actor the streamer's user subject when Live knows it, else service:live.
++ */
++export interface LiveStreamEndedPayload {
++ /**
++ * Live's streams row id (the envelope subject id).
++ */
++ stream_id: number;
++ channel: {
++ /**
++ * The streamer's Live username (the channel slug).
++ */
++ username: string;
++ /**
++ * The display name, or the username when there is none.
++ */
++ display_name: string;
++ /**
++ * The channel page, https://openvibe.live/@<username, URI-encoded>. Events from Live before 4c70332 (2026-09-23) carry https://openvibe.live/<username>, which Live answers with a 301 to the @ address since that commit.
++ */
++ url: string;
++ /**
++ * The streamer's canonical subject; absent until Live has seen it (a Network sign-in or the subject backfill).
++ */
++ subject?: {
++ type: "user";
++ id: string;
++ };
++ };
++ title: string;
++ /**
++ * null when the streamer chose none (the AI classifies the stream from what it shows).
++ */
++ category: string | null;
++ /**
++ * How the stream is ingested (WHIP is webrtc; OpenRe and RTMP are rtmp).
++ */
++ protocol: "jsmpeg" | "webrtc" | "rtmp";
++ is_nsfw: boolean;
++ /**
++ * UTC. null only for a row without a start time, which createStream never writes.
++ */
++ started_at: string | null;
++ /**
++ * UTC.
++ */
++ ended_at: string;
++ /**
++ * Whole seconds from started_at to ended_at; null when the row has no start time.
++ */
++ duration_seconds: number | null;
++}
++
++/** [email protected] (owner: live) */
++/**
++ * live.release.deployed v1 (OpenVibe.Live server/events/release-events.js envelopeFor, called by server/chat/deploy-notice.js announce). The first boot that runs commits never announced before: queued in the transaction that records them as announced (site setting deploy_last_announced), so a restart without new code emits nothing and a crash before that commit announces again on the next boot with the same subject. A consumer keys on subject.id (the head commit) to fold repeats. The commit subjects are public already (Live chat and /updates); the consumer decides what is shown. Envelope: subject { type: release, id: <head commit, 40 hex> }, visibility internal, priority low, actor service:live.
++ */
++export interface LiveReleaseDeployedPayload {
++ service: "live";
++ /**
++ * The head commit's short sha as git abbreviated it (its first 12 characters when the head is not in the list).
++ */
++ release: string;
++ /**
++ * The head commit (the envelope subject id).
++ */
++ commit: string;
++ /**
++ * The head announced before this one; null on the first announcement.
++ */
++ previous: string | null;
++ /**
++ * How many new commits were found (git log previous..head, at most 40; only the head on a first run or after history was rewritten).
++ */
++ commit_count: number;
++ /**
++ * The new commits, newest first.
++ *
++ * @minItems 1
++ * @maxItems 40
++ */
++ commits: [
++ {
++ hash: string;
++ short: string;
++ /**
++ * The commit subject, cut to 200 characters.
++ */
++ subject: string;
++ /**
++ * Author date (git %aI, with the author's UTC offset).
++ */
++ date: string | null;
++ },
++ ...{
++ hash: string;
++ short: string;
++ /**
++ * The commit subject, cut to 200 characters.
++ */
++ subject: string;
++ /**
++ * Author date (git %aI, with the author's UTC offset).
++ */
++ date: string | null;
++ }[]
++ ];
++ /**
++ * When this boot announced it (UTC).
++ */
++ deployed_at: string;
++ notes_url: "https://openvibe.live/updates";
++}
++
++/** [email protected] (owner: media) */
++/**
++ * media.job.proposed v1 (OpenVibe.Media server/jobs/queue.js enqueue; server/events.js recordJob). A job was proposed and waits for its owner: approving it queues it (media.job.queued), cancelling it ends it (media.job.cancelled); the worker never runs a proposal. The size-invariant validator (invariant.scan, created_by system:invariant.scan) proposes object.split and object.remux this way. Emitted in the SQLite transaction that records the state change, so the event exists if and only if the change committed; it goes to OpenVibe.Events only (no app webhook). The payload is the job as GET /api/v2/:app/jobs/:id answers it (queue.jobPublic), except that a result larger than 8 KB is replaced by { omitted: true, reason }. It carries the tenant's params, idempotency_key, the free-text error, created_by/decided_by and owner_user_id (a tenant-local integer user id), never the handler's checkpoint, lease or request hash. Times are SQLite UTC timestamps without a zone (YYYY-MM-DD HH:MM:SS). Jobs of developer-project sandbox tenants are not announced. Envelope: subject { type: job, id: <id> }, visibility internal, priority important, actor service:media.
++ */
++export interface MediaJobProposedPayload {
++ id: string;
++ /**
++ * The tenant that owns the job: an app (live, tools…) or a developer project's production tenant (prj_…).
++ */
++ app_id: string;
++ /**
++ * The object the job works on; null for a tenant-wide job (invariant.scan).
++ */
++ object_id: string | null;
++ /**
++ * Job type: thumbnail.regenerate, invariant.scan, object.split or object.remux.
++ */
++ type: string;
++ status: "proposed";
++ /**
++ * The parameters the job type accepted (at most 16 KB). object.split keeps every field the caller sent.
++ */
++ params: {};
++ /**
++ * null until the job finished; a failed or cancelled job may keep what its handler reported. { omitted: true, reason } when larger than 8 KB.
++ */
++ result: {} | null;
++ /**
++ * The last failure's message (free text), or null.
++ */
++ error: string | null;
++ /**
++ * Stable code of the last failure (media_unavailable, interrupted, cancelled, media.job.invalid…), or null.
++ */
++ error_code: string | null;
++ attempts: number;
++ max_attempts: number;
++ /**
++ * A queued job waits until then (retry backoff); null otherwise.
++ */
++ run_after: string | null;
++ /**
++ * Its owner asked a running job to stop.
++ */
++ cancel_requested: boolean;
++ /**
++ * The creator's Idempotency-Key (1-200 visible ASCII characters), invariant:<object_id>:<type> for a validator proposal, or null.
++ */
++ idempotency_key: string | null;
++ /**
++ * svc:<service> | app:<app> | app:<app>:user:<id> | system:<what>.
++ */
++ created_by: string | null;
++ /**
++ * The app's user the job was created for (X-OV-User-Id): an id in the tenant's own user space, not a Network subject.
++ */
++ owner_user_id: number | null;
++ /**
++ * Who approved or cancelled a proposal or queued job (same forms as created_by).
++ */
++ decided_by: string | null;
++ decided_at: string | null;
++ created_at: string;
++ updated_at: string;
++ started_at: null;
++ finished_at: null;
++}
++
++/** [email protected] (owner: media) */
++/**
++ * media.job.queued v1 (OpenVibe.Media server/jobs/queue.js enqueue, approve; server/events.js recordJob). A job was accepted and queued: a new job (POST /api/v2/:app/jobs), or a proposal its owner approved (decided_by, decided_at). An Idempotency-Key replay, or a request joined to an identical job that is still active, creates no job and no event. Emitted in the SQLite transaction that records the state change, so the event exists if and only if the change committed; it goes to OpenVibe.Events only (no app webhook). The payload is the job as GET /api/v2/:app/jobs/:id answers it (queue.jobPublic), except that a result larger than 8 KB is replaced by { omitted: true, reason }. It carries the tenant's params, idempotency_key, the free-text error, created_by/decided_by and owner_user_id (a tenant-local integer user id), never the handler's checkpoint, lease or request hash. Times are SQLite UTC timestamps without a zone (YYYY-MM-DD HH:MM:SS). Jobs of developer-project sandbox tenants are not announced. Envelope: subject { type: job, id: <id> }, visibility internal, priority low, actor service:media.
++ */
++export interface MediaJobQueuedPayload {
++ id: string;
++ /**
++ * The tenant that owns the job: an app (live, tools…) or a developer project's production tenant (prj_…).
++ */
++ app_id: string;
++ /**
++ * The object the job works on; null for a tenant-wide job (invariant.scan).
++ */
++ object_id: string | null;
++ /**
++ * Job type: thumbnail.regenerate, invariant.scan, object.split or object.remux.
++ */
++ type: string;
++ status: "queued";
++ /**
++ * The parameters the job type accepted (at most 16 KB). object.split keeps every field the caller sent.
++ */
++ params: {};
++ /**
++ * null until the job finished; a failed or cancelled job may keep what its handler reported. { omitted: true, reason } when larger than 8 KB.
++ */
++ result: {} | null;
++ /**
++ * The last failure's message (free text), or null.
++ */
++ error: string | null;
++ /**
++ * Stable code of the last failure (media_unavailable, interrupted, cancelled, media.job.invalid…), or null.
++ */
++ error_code: string | null;
++ attempts: number;
++ max_attempts: number;
++ /**
++ * A queued job waits until then (retry backoff); null otherwise.
++ */
++ run_after: string | null;
++ /**
++ * Its owner asked a running job to stop.
++ */
++ cancel_requested: boolean;
++ /**
++ * The creator's Idempotency-Key (1-200 visible ASCII characters), invariant:<object_id>:<type> for a validator proposal, or null.
++ */
++ idempotency_key: string | null;
++ /**
++ * svc:<service> | app:<app> | app:<app>:user:<id> | system:<what>.
++ */
++ created_by: string | null;
++ /**
++ * The app's user the job was created for (X-OV-User-Id): an id in the tenant's own user space, not a Network subject.
++ */
++ owner_user_id: number | null;
++ /**
++ * Who approved or cancelled a proposal or queued job (same forms as created_by).
++ */
++ decided_by: string | null;
++ decided_at: string | null;
++ created_at: string;
++ updated_at: string;
++ started_at: null;
++ finished_at: null;
++}
++
++/** [email protected] (owner: media) */
++/**
++ * media.job.started v1 (OpenVibe.Media server/jobs/queue.js claim; server/events.js recordJob). The worker claimed the job and it is running: attempts counts this attempt, started_at stays the first attempt's start. A job that is retried starts again and is announced again. Emitted in the SQLite transaction that records the state change, so the event exists if and only if the change committed; it goes to OpenVibe.Events only (no app webhook). The payload is the job as GET /api/v2/:app/jobs/:id answers it (queue.jobPublic), except that a result larger than 8 KB is replaced by { omitted: true, reason }. It carries the tenant's params, idempotency_key, the free-text error, created_by/decided_by and owner_user_id (a tenant-local integer user id), never the handler's checkpoint, lease or request hash. Times are SQLite UTC timestamps without a zone (YYYY-MM-DD HH:MM:SS). Jobs of developer-project sandbox tenants are not announced. Envelope: subject { type: job, id: <id> }, visibility internal, priority low, actor service:media.
++ */
++export interface MediaJobStartedPayload {
++ id: string;
++ /**
++ * The tenant that owns the job: an app (live, tools…) or a developer project's production tenant (prj_…).
++ */
++ app_id: string;
++ /**
++ * The object the job works on; null for a tenant-wide job (invariant.scan).
++ */
++ object_id: string | null;
++ /**
++ * Job type: thumbnail.regenerate, invariant.scan, object.split or object.remux.
++ */
++ type: string;
++ status: "running";
++ /**
++ * The parameters the job type accepted (at most 16 KB). object.split keeps every field the caller sent.
++ */
++ params: {};
++ /**
++ * null until the job finished; a failed or cancelled job may keep what its handler reported. { omitted: true, reason } when larger than 8 KB.
++ */
++ result: {} | null;
++ /**
++ * The last failure's message (free text), or null.
++ */
++ error: string | null;
++ /**
++ * Stable code of the last failure (media_unavailable, interrupted, cancelled, media.job.invalid…), or null.
++ */
++ error_code: string | null;
++ attempts: number;
++ max_attempts: number;
++ /**
++ * A queued job waits until then (retry backoff); null otherwise.
++ */
++ run_after: string | null;
++ /**
++ * Its owner asked a running job to stop.
++ */
++ cancel_requested: boolean;
++ /**
++ * The creator's Idempotency-Key (1-200 visible ASCII characters), invariant:<object_id>:<type> for a validator proposal, or null.
++ */
++ idempotency_key: string | null;
++ /**
++ * svc:<service> | app:<app> | app:<app>:user:<id> | system:<what>.
++ */
++ created_by: string | null;
++ /**
++ * The app's user the job was created for (X-OV-User-Id): an id in the tenant's own user space, not a Network subject.
++ */
++ owner_user_id: number | null;
++ /**
++ * Who approved or cancelled a proposal or queued job (same forms as created_by).
++ */
++ decided_by: string | null;
++ decided_at: string | null;
++ created_at: string;
++ updated_at: string;
++ started_at: string;
++ finished_at: null;
++}
++
++/** [email protected] (owner: media) */
++/**
++ * media.job.retrying v1 (OpenVibe.Media server/jobs/queue.js fail with a retry; server/events.js recordJob). An attempt failed and the job is queued again after a backoff (run_after); error and error_code say why. A running job nobody holds any more (the service restarted, or the worker stopped renewing its lease) is retried this way with error_code interrupted while it has attempts left. status is queued. Emitted in the SQLite transaction that records the state change, so the event exists if and only if the change committed; it goes to OpenVibe.Events only (no app webhook). The payload is the job as GET /api/v2/:app/jobs/:id answers it (queue.jobPublic), except that a result larger than 8 KB is replaced by { omitted: true, reason }. It carries the tenant's params, idempotency_key, the free-text error, created_by/decided_by and owner_user_id (a tenant-local integer user id), never the handler's checkpoint, lease or request hash. Times are SQLite UTC timestamps without a zone (YYYY-MM-DD HH:MM:SS). Jobs of developer-project sandbox tenants are not announced. Envelope: subject { type: job, id: <id> }, visibility internal, priority low, actor service:media.
++ */
++export interface MediaJobRetryingPayload {
++ id: string;
++ /**
++ * The tenant that owns the job: an app (live, tools…) or a developer project's production tenant (prj_…).
++ */
++ app_id: string;
++ /**
++ * The object the job works on; null for a tenant-wide job (invariant.scan).
++ */
++ object_id: string | null;
++ /**
++ * Job type: thumbnail.regenerate, invariant.scan, object.split or object.remux.
++ */
++ type: string;
++ status: "queued";
++ /**
++ * The parameters the job type accepted (at most 16 KB). object.split keeps every field the caller sent.
++ */
++ params: {};
++ /**
++ * null until the job finished; a failed or cancelled job may keep what its handler reported. { omitted: true, reason } when larger than 8 KB.
++ */
++ result: {} | null;
++ /**
++ * The failure's message, free text from the handler.
++ */
++ error: string;
++ /**
++ * Stable code of the last failure (media_unavailable, interrupted, cancelled, media.job.invalid…), or null.
++ */
++ error_code: string | null;
++ attempts: number;
++ max_attempts: number;
++ /**
++ * Not before this time (the retry backoff).
++ */
++ run_after: string;
++ /**
++ * Its owner asked a running job to stop.
++ */
++ cancel_requested: boolean;
++ /**
++ * The creator's Idempotency-Key (1-200 visible ASCII characters), invariant:<object_id>:<type> for a validator proposal, or null.
++ */
++ idempotency_key: string | null;
++ /**
++ * svc:<service> | app:<app> | app:<app>:user:<id> | system:<what>.
++ */
++ created_by: string | null;
++ /**
++ * The app's user the job was created for (X-OV-User-Id): an id in the tenant's own user space, not a Network subject.
++ */
++ owner_user_id: number | null;
++ /**
++ * Who approved or cancelled a proposal or queued job (same forms as created_by).
++ */
++ decided_by: string | null;
++ decided_at: string | null;
++ created_at: string;
++ updated_at: string;
++ started_at: string;
++ finished_at: null;
++}
++
++/** [email protected] (owner: media) */
++/**
++ * media.job.succeeded v1 (OpenVibe.Media server/jobs/queue.js succeed; server/events.js recordJob). The job finished: result is its type's result (thumbnail.regenerate { url, kind, id }, object.split { source_id, parts, … }, object.remux { source_id, object_id, … }, invariant.scan { thresholds, counts, … }). Emitted in the SQLite transaction that records the state change, so the event exists if and only if the change committed; it goes to OpenVibe.Events only (no app webhook). The payload is the job as GET /api/v2/:app/jobs/:id answers it (queue.jobPublic), except that a result larger than 8 KB is replaced by { omitted: true, reason }. It carries the tenant's params, idempotency_key, the free-text error, created_by/decided_by and owner_user_id (a tenant-local integer user id), never the handler's checkpoint, lease or request hash. Times are SQLite UTC timestamps without a zone (YYYY-MM-DD HH:MM:SS). Jobs of developer-project sandbox tenants are not announced. Envelope: subject { type: job, id: <id> }, visibility internal, priority important, actor service:media.
++ */
++export interface MediaJobSucceededPayload {
++ id: string;
++ /**
++ * The tenant that owns the job: an app (live, tools…) or a developer project's production tenant (prj_…).
++ */
++ app_id: string;
++ /**
++ * The object the job works on; null for a tenant-wide job (invariant.scan).
++ */
++ object_id: string | null;
++ /**
++ * Job type: thumbnail.regenerate, invariant.scan, object.split or object.remux.
++ */
++ type: string;
++ status: "succeeded";
++ /**
++ * The parameters the job type accepted (at most 16 KB). object.split keeps every field the caller sent.
++ */
++ params: {};
++ /**
++ * The job type's result, or { omitted: true, reason } when it is larger than 8 KB (GET the job for it).
++ */
++ result: {} | null;
++ error: null;
++ error_code: null;
++ attempts: number;
++ max_attempts: number;
++ /**
++ * A queued job waits until then (retry backoff); null otherwise.
++ */
++ run_after: string | null;
++ /**
++ * Its owner asked a running job to stop.
++ */
++ cancel_requested: boolean;
++ /**
++ * The creator's Idempotency-Key (1-200 visible ASCII characters), invariant:<object_id>:<type> for a validator proposal, or null.
++ */
++ idempotency_key: string | null;
++ /**
++ * svc:<service> | app:<app> | app:<app>:user:<id> | system:<what>.
++ */
++ created_by: string | null;
++ /**
++ * The app's user the job was created for (X-OV-User-Id): an id in the tenant's own user space, not a Network subject.
++ */
++ owner_user_id: number | null;
++ /**
++ * Who approved or cancelled a proposal or queued job (same forms as created_by).
++ */
++ decided_by: string | null;
++ decided_at: string | null;
++ created_at: string;
++ updated_at: string;
++ started_at: string;
++ finished_at: string;
++}
++
++/** [email protected] (owner: media) */
++/**
++ * media.job.failed v1 (OpenVibe.Media server/jobs/queue.js fail; server/events.js recordJob). The job failed for good: a permanent error, or its last attempt (attempts = max_attempts) failed; error and error_code say why. A cancelled job is not a failure and is announced as media.job.cancelled. Emitted in the SQLite transaction that records the state change, so the event exists if and only if the change committed; it goes to OpenVibe.Events only (no app webhook). The payload is the job as GET /api/v2/:app/jobs/:id answers it (queue.jobPublic), except that a result larger than 8 KB is replaced by { omitted: true, reason }. It carries the tenant's params, idempotency_key, the free-text error, created_by/decided_by and owner_user_id (a tenant-local integer user id), never the handler's checkpoint, lease or request hash. Times are SQLite UTC timestamps without a zone (YYYY-MM-DD HH:MM:SS). Jobs of developer-project sandbox tenants are not announced. Envelope: subject { type: job, id: <id> }, visibility internal, priority important, actor service:media.
++ */
++export interface MediaJobFailedPayload {
++ id: string;
++ /**
++ * The tenant that owns the job: an app (live, tools…) or a developer project's production tenant (prj_…).
++ */
++ app_id: string;
++ /**
++ * The object the job works on; null for a tenant-wide job (invariant.scan).
++ */
++ object_id: string | null;
++ /**
++ * Job type: thumbnail.regenerate, invariant.scan, object.split or object.remux.
++ */
++ type: string;
++ status: "failed";
++ /**
++ * The parameters the job type accepted (at most 16 KB). object.split keeps every field the caller sent.
++ */
++ params: {};
++ /**
++ * null until the job finished; a failed or cancelled job may keep what its handler reported. { omitted: true, reason } when larger than 8 KB.
++ */
++ result: {} | null;
++ /**
++ * The failure's message, free text from the handler.
++ */
++ error: string;
++ /**
++ * Stable code of the last failure (media_unavailable, interrupted, cancelled, media.job.invalid…), or null.
++ */
++ error_code: string | null;
++ attempts: number;
++ max_attempts: number;
++ /**
++ * A queued job waits until then (retry backoff); null otherwise.
++ */
++ run_after: string | null;
++ /**
++ * Its owner asked a running job to stop.
++ */
++ cancel_requested: boolean;
++ /**
++ * The creator's Idempotency-Key (1-200 visible ASCII characters), invariant:<object_id>:<type> for a validator proposal, or null.
++ */
++ idempotency_key: string | null;
++ /**
++ * svc:<service> | app:<app> | app:<app>:user:<id> | system:<what>.
++ */
++ created_by: string | null;
++ /**
++ * The app's user the job was created for (X-OV-User-Id): an id in the tenant's own user space, not a Network subject.
++ */
++ owner_user_id: number | null;
++ /**
++ * Who approved or cancelled a proposal or queued job (same forms as created_by).
++ */
++ decided_by: string | null;
++ decided_at: string | null;
++ created_at: string;
++ updated_at: string;
++ started_at: string;
++ finished_at: string;
++}
++
++/** [email protected] (owner: media) */
++/**
++ * media.job.cancelled v1 (OpenVibe.Media server/jobs/queue.js cancel, markCancelled; server/events.js recordJob). The job was cancelled: a proposal or a queued job at once, by its owner (POST /api/v2/:app/jobs/:id/cancel or DELETE /api/v2/:app/jobs/:id) or by the validator withdrawing a proposal that no longer applies (decided_by system:invariant.scan); a running job once it stopped after its owner asked (cancel_requested true, error_code cancelled). Emitted in the SQLite transaction that records the state change, so the event exists if and only if the change committed; it goes to OpenVibe.Events only (no app webhook). The payload is the job as GET /api/v2/:app/jobs/:id answers it (queue.jobPublic), except that a result larger than 8 KB is replaced by { omitted: true, reason }. It carries the tenant's params, idempotency_key, the free-text error, created_by/decided_by and owner_user_id (a tenant-local integer user id), never the handler's checkpoint, lease or request hash. Times are SQLite UTC timestamps without a zone (YYYY-MM-DD HH:MM:SS). Jobs of developer-project sandbox tenants are not announced. Envelope: subject { type: job, id: <id> }, visibility internal, priority important, actor service:media.
++ */
++export interface MediaJobCancelledPayload {
++ id: string;
++ /**
++ * The tenant that owns the job: an app (live, tools…) or a developer project's production tenant (prj_…).
++ */
++ app_id: string;
++ /**
++ * The object the job works on; null for a tenant-wide job (invariant.scan).
++ */
++ object_id: string | null;
++ /**
++ * Job type: thumbnail.regenerate, invariant.scan, object.split or object.remux.
++ */
++ type: string;
++ status: "cancelled";
++ /**
++ * The parameters the job type accepted (at most 16 KB). object.split keeps every field the caller sent.
++ */
++ params: {};
++ /**
++ * null until the job finished; a failed or cancelled job may keep what its handler reported. { omitted: true, reason } when larger than 8 KB.
++ */
++ result: {} | null;
++ /**
++ * The last failure's message (free text), or null.
++ */
++ error: string | null;
++ /**
++ * Stable code of the last failure (media_unavailable, interrupted, cancelled, media.job.invalid…), or null.
++ */
++ error_code: string | null;
++ attempts: number;
++ max_attempts: number;
++ /**
++ * A queued job waits until then (retry backoff); null otherwise.
++ */
++ run_after: string | null;
++ /**
++ * Its owner asked a running job to stop.
++ */
++ cancel_requested: boolean;
++ /**
++ * The creator's Idempotency-Key (1-200 visible ASCII characters), invariant:<object_id>:<type> for a validator proposal, or null.
++ */
++ idempotency_key: string | null;
++ /**
++ * svc:<service> | app:<app> | app:<app>:user:<id> | system:<what>.
++ */
++ created_by: string | null;
++ /**
++ * The app's user the job was created for (X-OV-User-Id): an id in the tenant's own user space, not a Network subject.
++ */
++ owner_user_id: number | null;
++ /**
++ * Who approved or cancelled a proposal or queued job (same forms as created_by).
++ */
++ decided_by: string | null;
++ decided_at: string | null;
++ created_at: string;
++ updated_at: string;
++ /**
++ * null when the job never ran.
++ */
++ started_at: string | null;
++ finished_at: string;
++}
++
++/** [email protected] (owner: network) */
++/**
++ * network.module.updated v1 (OpenVibe.Network server/identity/module-events.js buildEnvelope, called from server/identity/modules.js). One event per change to one person's user-module record (modules.module-record@1): a write (created or updated), a delete by the person (delete) or by the owning service (owner_delete, also the retention sweep of a retired owner's delete-after-retention namespace), account removal (subject_removed: every record of the account is deleted) and account merge (subject_merged: the absorbed account's records are deleted, merged_into; a record moved to the surviving account is created there, merged_from; where the survivor already has one, the survivor's is kept and unchanged). Written into Network's event outbox in the transaction that makes the change. revision grows by one on every change to (owner, namespace), deletes included, and never restarts: a consumer keeps the highest revision it applied and ignores anything lower. Values of private fields never leave Network: keys names the changed fields, public carries only the new values of changed public fields. Envelope: subject { type: user_module, id: <owner id>:<namespace>, revision }, visibility internal, actor the person, the owning service or system:network.
++ */
++export interface NetworkModuleUpdatedPayload {
++ /**
++ * The person the record belongs to (identity.subject-ref@1, a user or a guest).
++ */
++ owner:
++ | {
++ type: "user";
++ id: string;
++ }
++ | {
++ type: "guest";
++ id: string;
++ };
++ /**
++ * e.g. chat.preferences (modules.namespace@1).
++ */
++ namespace: string;
++ /**
++ * The service that owns the namespace when the change was made.
++ */
++ namespace_owner: string;
++ /**
++ * The namespace version the record was written under.
++ */
++ schema_version: number;
++ /**
++ * The record's revision after this change (the envelope subject revision).
++ */
++ revision: number;
++ change: "created" | "updated" | "deleted";
++ reason: "write" | "delete" | "owner_delete" | "subject_removed" | "subject_merged";
++ /**
++ * Top-level fields added, changed or removed, names only, sorted. A deleted record lists every field it held.
++ */
++ keys: string[];
++ /**
++ * The new values of the changed fields the namespace declares public; null for one that was removed. Absent when no public field changed, for a namespace without public fields and for a deleted record.
++ */
++ public?: {
++ [k: string]: unknown | undefined;
++ };
++ /**
++ * On the absorbed account's deleted record (reason subject_merged): the account that survives.
++ */
++ merged_into?:
++ | {
++ type: "user";
++ id: string;
++ }
++ | {
++ type: "guest";
++ id: string;
++ };
++ /**
++ * On a record moved to the surviving account (created, reason subject_merged): the account it came from.
++ */
++ merged_from?:
++ | {
++ type: "user";
++ id: string;
++ }
++ | {
++ type: "guest";
++ id: string;
++ };
++}
ddiff --git a/manifests/capabilities/host.site.manage.json b/manifests/capabilities/host.site.manage.json
iindex 1cbb094b..c0a69f14 100644
---- a/manifests/capabilities/host.site.manage.json
++++ b/manifests/capabilities/host.site.manage.json
@@@ -4,7 +4,7 @@
"owner": "host",
"status": "active",
"visibility": "first-party",
-- "description": "Manage OpenVibe.Host tenant projects and static sites: create a project (owned by the person named with X-OV-Subject; apps cannot own projects), read projects, quotas and usage, manage members (owner role), create and delete sites, delete a deploy that is not active. Also judged by the acting principal's project role; a sandbox token (env=sandbox) is refused on production projects. Quota changes additionally need a Network staff user.",
++ "description": "Manage OpenVibe.Host tenant projects and static sites: create a project (owned by the person named with X-OV-Subject; apps cannot own projects), read projects, quotas and usage, manage members (owner role), create and delete sites, delete a deploy that is not active. Also judged by the acting principal's project role; a sandbox token (env=sandbox) is refused on production projects. Quota changes and takedowns (stop serving a site or a whole project while keeping its content for review; lift) additionally need a Network staff user.",
"permissions": [
"host:write"
],
@@@ -26,6 +26,10 @@
"POST /api/v1/projects/:id/sites",
"GET /api/v1/sites/:id",
"DELETE /api/v1/sites/:id",
-- "DELETE /api/v1/deploys/:id"
++ "DELETE /api/v1/deploys/:id",
++ "POST /api/v1/projects/:id/takedown",
++ "DELETE /api/v1/projects/:id/takedown",
++ "POST /api/v1/sites/:id/takedown",
++ "DELETE /api/v1/sites/:id/takedown"
]
}
ddiff --git a/manifests/capabilities/tips.interaction.moderate.json b/manifests/capabilities/tips.interaction.moderate.json
nnew file mode 100644
iindex 00000000..60ac8b43
---- /dev/null
++++ b/manifests/capabilities/tips.interaction.moderate.json
@@@ -0,0 +1,24 @@
++{
++ "id": "tips.interaction.moderate",
++ "version": "1.0",
++ "owner": "tips",
++ "status": "active",
++ "visibility": "internal",
++ "description": "Moderate a creator's paid messages for them: read the review queue, hide a paid message from overlays, chat still to come and public pages, or show again one that was hidden or that the creator's word filter held. Never touches the money (no refund, no uncounting). People moderate as the creator or one of the creator's moderators; a service (e.g. Chat or Live relaying a channel moderator's action) needs this capability. Not in the charter list: proposed for the roadmap's \"privacy/moderation controls\" (Wave 9).",
++ "permissions": [
++ "tips:interactions"
++ ],
++ "resourceConstraints": [
++ "none"
++ ],
++ "quotaClass": "standard",
++ "events": [
++ "tips.interaction.moderated"
++ ],
++ "implementedBy": [
++ "GET /api/v1/moderation",
++ "GET /api/v1/moderation/log",
++ "POST /api/v1/interactions/:id/hide",
++ "POST /api/v1/interactions/:id/restore"
++ ]
++}
ddiff --git a/manifests/capabilities/wiki.page.create.json b/manifests/capabilities/wiki.page.create.json
iindex 521cf1f8..b45c6b31 100644
---- a/manifests/capabilities/wiki.page.create.json
++++ b/manifests/capabilities/wiki.page.create.json
@@@ -4,7 +4,7 @@
"owner": "wiki",
"status": "active",
"visibility": "first-party",
-- "description": "Create pages and write human-authored revisions (text, typed infobox values, citations), move or rename pages (old addresses answer 301), attach OpenVibe.Media objects and check them, on behalf of the person named in X-OV-Subject, who must be an editor of the space. Deleting pages and changing page visibility need the owner role.",
++ "description": "Create pages and write human-authored revisions (text, typed infobox values, citations), move or rename pages (old addresses answer 301), attach OpenVibe.Media objects the person can read in Media and check them, on behalf of the person named in X-OV-Subject, who must be an editor of the space. Deleting pages, changing page visibility and importing a bundle of pages into the space need the owner role.",
"permissions": [
"wiki:page:write"
],
@@@ -23,6 +23,7 @@
"PATCH /api/v1/pages/:id",
"DELETE /api/v1/pages/:id",
"POST /api/v1/pages/:id/media",
-- "POST /api/v1/pages/:id/media/verify"
++ "POST /api/v1/pages/:id/media/verify",
++ "POST /api/v1/spaces/:space/import"
]
}
ddiff --git a/manifests/capabilities/wiki.revision.publish.json b/manifests/capabilities/wiki.revision.publish.json
iindex f30cd16d..8d167f81 100644
---- a/manifests/capabilities/wiki.revision.publish.json
++++ b/manifests/capabilities/wiki.revision.publish.json
@@@ -4,7 +4,7 @@
"owner": "wiki",
"status": "active",
"visibility": "first-party",
-- "description": "Publish a revision now or at a scheduled time (idempotent), unpublish a page, and record a person's approval or rejection of an AI proposal, on behalf of the editor named in X-OV-Subject. AI-generated revisions cannot be published without an approving review by a person.",
++ "description": "Publish a revision now or at a scheduled time (idempotent), unpublish a page, and record a person's review (approved or rejected) of an AI proposal or of an existing AI-assisted revision, on behalf of the editor named in X-OV-Subject. AI-generated revisions cannot be published without an approving review by a person.",
"permissions": [
"wiki:revision:publish"
],
@@@ -23,6 +23,7 @@
"POST /api/v1/pages/:id/publish",
"POST /api/v1/pages/:id/schedule",
"POST /api/v1/pages/:id/unpublish",
-- "POST /api/v1/proposals/:id/review"
++ "POST /api/v1/proposals/:id/review",
++ "POST /api/v1/pages/:id/revisions/:n/review"
]
}
ddiff --git a/manifests/namespaces/chat.preferences.json b/manifests/namespaces/chat.preferences.json
iindex a39262e9..63b70a57 100644
---- a/manifests/namespaces/chat.preferences.json
++++ b/manifests/namespaces/chat.preferences.json
@@@ -1,8 +1,8 @@
{
"namespace": "chat.preferences",
-- "owner": "live",
++ "owner": "chat",
"version": 1,
-- "description": "Portable chat display preferences (moves to OpenVibe.Chat in Wave 6).",
++ "description": "Portable chat display preferences. OpenVibe.Chat owns it since the Wave 6 cutover (2026-09-23): Chat reads and writes the signed-in person's record (GET/PUT /api/chat/preferences) with its service token; Live may still read it.",
"writers": [
"owner",
"user"
ddiff --git a/manifests/services/chat.json b/manifests/services/chat.json
iindex 51728b4c..c6479141 100644
---- a/manifests/services/chat.json
++++ b/manifests/services/chat.json
@@@ -1,5 +1,5 @@
{
-- "version": "0.3.0",
++ "version": "0.4.0",
"contractRanges": {
"openvibe-contracts": ">=0.13.0 <1.0.0"
},
@@@ -20,7 +20,9 @@
"chat.moderation.action"
],
"eventsConsumed": [],
-- "namespacesOwned": [],
++ "namespacesOwned": [
++ "chat.preferences"
++ ],
"notes": "Wave 6: Live's chat (WebSocket, DMs, history, TTS, sounds, moderation) behind a service boundary on 127.0.0.1:4400. Alpha: runs beside Live; authority moves only when Live runs with CHAT_AUTHORITY=chat and nginx routes the chat paths (see OpenVibe.Chat docs/cutover.md).",
"health": "/health",
"ready": "/ready"
ddiff --git a/manifests/services/media.json b/manifests/services/media.json
iindex 8f0830e6..18843c72 100644
---- a/manifests/services/media.json
++++ b/manifests/services/media.json
@@@ -1,5 +1,5 @@
{
-- "version": "0.1.0",
++ "version": "0.2.0",
"contractRanges": {
"openvibe-contracts": ">=0.2.0 <1.0.0"
},
@@@ -19,6 +19,13 @@
"eventsProduced": [
"media.clip.failed",
"media.clip.ready",
++ "media.job.cancelled",
++ "media.job.failed",
++ "media.job.proposed",
++ "media.job.queued",
++ "media.job.retrying",
++ "media.job.started",
++ "media.job.succeeded",
"media.object.uploaded",
"media.storage.alert",
"media.storage.recovered",
ddiff --git a/manifests/services/network.json b/manifests/services/network.json
iindex 1b90c805..5a32c8c8 100644
---- a/manifests/services/network.json
++++ b/manifests/services/network.json
@@@ -1,5 +1,5 @@
{
-- "version": "0.2.0",
++ "version": "0.3.0",
"contractRanges": {
"openvibe-contracts": ">=0.2.0 <1.0.0"
},
@@@ -34,10 +34,12 @@
"network.app.revoked",
"network.credential.rotated",
"network.credential.revoked",
-- "network.grant.changed"
++ "network.grant.changed",
++ "network.module.updated"
],
"eventsConsumed": [
"deals.watch.matched",
++ "live.stream.started",
"trade.alert.triggered"
],
"namespacesOwned": [
ddiff --git a/manifests/services/tips.json b/manifests/services/tips.json
iindex 4396c98a..b324632f 100644
---- a/manifests/services/tips.json
++++ b/manifests/services/tips.json
@@@ -1,5 +1,5 @@
{
-- "version": "0.3.0",
++ "version": "0.4.0",
"contractRanges": {
"openvibe-contracts": ">=0.11.0 <1.0.0"
},
@@@ -30,7 +30,8 @@
"tips.overlay.token.revoke",
"tips.overlay.config.get",
"tips.overlay.config.update",
-- "tips.simulation.run"
++ "tips.simulation.run",
++ "tips.interaction.moderate"
],
"eventsProduced": [
"tips.interaction.ready",
@@@ -38,7 +39,9 @@
"tips.interaction.cancelled",
"tips.goal.updated",
"tips.overlay.delivered",
-- "tips.overlay.failed"
++ "tips.overlay.failed",
++ "tips.interaction.erased",
++ "tips.interaction.moderated"
],
"eventsConsumed": [
"billing.transaction.settled",
ddiff --git a/test/run.js b/test/run.js
iindex c8769e1e..b6182c55 100644
---- a/test/run.js
++++ b/test/run.js
@@@ -144,6 +144,32 @@ for (const id of ['events.event.publish', 'events.event.read', 'events.subscript
ok(JSON.stringify([...res.checked.items.properties.input.enum].sort()) === JSON.stringify([...inputs, 'display_name'].sort()), 'checked reports every request field, display_name included');
}
++// ── Producers' payloads added in v0.32 (Tips moderation, Media jobs, Live, user modules) ──
++{
++ // Media: one contract per server/events.js JOB_TRANSITIONS entry; the payload is queue.jobPublic, every field always present.
++ const status = { proposed: 'proposed', queued: 'queued', started: 'running', retrying: 'queued', succeeded: 'succeeded', failed: 'failed', cancelled: 'cancelled' };
++ for (const [t, st] of Object.entries(status)) {
++ const s = contracts.schema(`media.job.${t}`);
++ ok(s.properties.status.const === st, `media.job.${t} carries status ${st}`);
++ ok(JSON.stringify([...s.required].sort()) === JSON.stringify(Object.keys(s.properties).sort()), `media.job.${t} requires every field it names`);
++ }
++ // Live: live.stream.ended is live.stream.started plus ended_at and duration_seconds.
++ const started = contracts.schema('live.stream.started'), ended = contracts.schema('live.stream.ended');
++ ok(JSON.stringify(Object.keys(ended.properties)) === JSON.stringify([...Object.keys(started.properties), 'ended_at', 'duration_seconds']), 'live.stream.ended = started + ended_at, duration_seconds');
++ ok(JSON.stringify(ended.properties.channel) === JSON.stringify(started.properties.channel), 'both stream events describe the channel the same way');
++ // Tips: the moderation capability announces its outcome; an erasure takes back the interaction's earlier events.
++ const mod = capabilities.get('tips.interaction.moderate');
++ ok(mod && mod.owner === 'tips' && mod.events.includes('tips.interaction.moderated'), 'tips.interaction.moderate announces tips.interaction.moderated');
++ const erased = JSON.parse(fs.readFileSync(path.join(ROOT, 'fixtures/tips.interaction.erased/valid/erased.json'), 'utf8'));
++ ok(erased.redacts.subject_type === 'interaction' && JSON.stringify(erased.redacts.subject_ids) === JSON.stringify([erased.interaction_id]), 'tips.interaction.erased redacts the interaction');
++ // User modules: a deleted record never carries values; chat.preferences belongs to Chat since the Wave 6 cutover.
++ const M = (v) => contracts.validate('network.module.updated@1', v).valid;
++ const upd = { owner: { type: 'user', id: ids.newId('user') }, namespace: 'live.profile', namespace_owner: 'live', schema_version: 1, revision: 2, change: 'updated', reason: 'write', keys: ['followers'], public: { followers: 1 } };
++ ok(M(upd) && !M({ ...upd, change: 'deleted', reason: 'delete' }), 'network.module.updated: public values only on a record that still exists');
++ ok(!M({ ...upd, merged_from: upd.owner }) && M({ ...upd, change: 'created', reason: 'subject_merged', merged_from: { type: 'guest', id: ids.newId('guest') } }), 'merged_from only on a record moved by a merge');
++ ok(contracts.modules.get('chat.preferences').owner === 'chat' && services.get('chat').namespacesOwned.includes('chat.preferences'), 'chat.preferences is owned by chat, and its manifest says so');
++}
++
// ── Ids ──────────────────────────────────────────────────────────────────
for (const kind of ['user', 'guest', 'app', 'mod']) {
const id = ids.newId(kind);