openvibers/openvibe.contracts Commits
v0.32.0: lineage.resolve-request@1 and lineage.resolution@1, the canonical channel/owner resolver (roadmap §15.10, D20); capability live.lineage.resolve
Changes
@@@ -4,6 +4,30 @@ All notable changes to `openvibe-contracts`. Releases are git tags (`vX.Y.Z`) thfrom `https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/<tag>`. Before v0.30.0,the notes were in the tag and commit messages (`git tag -n1`).++## 0.32.0 — 2026-09-23++++Additive: `compat.js` reports no breaking change against v0.31.0.++++- **Channel/owner lineage resolver** (roadmap §15.10, requirement D20): one resolver for channel, stream,++ VOD, clip, Pulse and creator-UI callers. OpenVibe.Live implements it.++ - `lineage.resolve-request@1`: any of `slug` (a channel, or `<channel>/<slot>`), `parent_slug`,++ `channel_id`, `stream_id`, `slot_id`, `vod_id`, `clip_id`, `media_object_id` (`med_…` or++ `legacy:<app>:<kind>:<id>`), `owner_subject` and `legacy_ids` (`live_user_id`, `network_user_id`).++ `display_name` is accepted and never used.++ - `lineage.resolution@1`: `status` `resolved` with the canonical `channel` (`id`, `slug`,++ `owner_subject`, optional `legacy_ids`), whatever of `stream`, `vod`, `clip` and `media_object`++ the inputs reached, `resolved_by` (the deciding input), `rule`, `confidence`++ (`exact` | `derived` | `legacy_map`), `via` and `checked`; or `status` `unresolved` with a++ `reason` (`no_input`, `display_name_only`, `not_found`, `conflict`, `ambiguous`,++ `source_unavailable`) and no channel.++ - Precedence, in the roadmap's order: explicit slug, nested or parent slug, channel id, stream++ lookup (stream, slot), the record's own lineage (clip, VOD), Media lineage, owner subject, legacy++ maps. The first input that resolves decides, and every other input that resolves must name the++ same channel or the answer is `conflict`. Inside a record the first link that answers wins: a clip++ 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`.++## 0.31.0 — 2026-09-23Additive: `compat.js` reports no breaking change against v0.30.2.@@@ -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/` (170 capabilities; 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/` (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.**Ids.** Subjects use prefixed ULIDs: `usr_`, `gst_`, `app_`, `mod_`. Services and system actors use slugs (`live`, `media`). Events use `evt_` and Media objects `med_`.@@@ -105,6 +105,23 @@ v1 covers only the body, so a captured delivery verifies forever (only `event_idRollout: (1) Events sends v2 beside v1; (2) each consumer moves to SDK 0.4.0, which verifies v2 whenever it is present; (3) each consumer sets `requireV2: true`, so a v1-only (header-stripped) replay fails; (4) once every consumer requires v2, v1 may be dropped. See ADR-004.++## Channel/owner lineage (v0.32)++++One resolver answers "which channel, and whose, is this?" for channel, stream, VOD, clip, Pulse and creator-UI callers (roadmap §15.10, D20). OpenVibe.Live implements it at `GET|POST /internal/lineage/resolve` (capability `live.lineage.resolve`, loopback). The request is `lineage.resolve-request@1`, the answer `lineage.resolution@1`.++++| Precedence | Input | Rule | Confidence |++|---|---|---|---|++| 1 | `slug` | `explicit_slug` | exact |++| 2 | `slug` `<channel>/<slot>`, `parent_slug` | `nested_slug` | exact |++| 3 | `channel_id` | `channel_id` | exact |++| 4 | `stream_id`, `slot_id` | `stream_lookup` | exact |++| 5 | `clip_id`, `vod_id` | the record's lineage: its stream, its parent VOD (`vod_parent`), the owner recorded on it (`legacy_metadata`) | derived |++| 6 | `media_object_id` | `media_lineage` (`legacy_map` for a `legacy:` reference) | derived / legacy_map |++| 7 | `owner_subject` | `owner_subject` | exact |++| 8 | `legacy_ids` | `legacy_map` | legacy_map |++++The first input that resolves decides. Every other input that resolves must name the same channel, or the answer is `unresolved` with reason `conflict`. A clip belongs to the clipped channel, never to the clipper. A display name never resolves anything: a request holding only one is answered `unresolved`, `display_name_only`.++## Versioning and compatibility- A contract id is permanent. Minor versions only add optional fields.@@@ -89,6 +89,24 @@"status": "active","adr": "ADR-006"},++ {++ "id": "lineage.resolve-request",++ "version": "1.0.0",++ "owner": "live",++ "schema": "lineage/resolve-request.v1.json",++ "visibility": "first-party",++ "compatibility": "backward",++ "status": "active"++ },++ {++ "id": "lineage.resolution",++ "version": "1.0.0",++ "owner": "live",++ "schema": "lineage/resolution.v1.json",++ "visibility": "first-party",++ "compatibility": "backward",++ "status": "active"++ },{"id": "modules.namespace","version": "1.0.0",nnew file mode 100644@@@ -0,0 +1,95 @@++{++ "$schema": "https://json-schema.org/draft/2020-12/schema",++ "$id": "https://openvibe.network/contracts/lineage/resolution.v1.json",++ "title": "LineageResolution",++ "description": "The canonical channel/owner resolver's answer to lineage.resolve-request@1 (roadmap §15.10, requirement D20; capability live.lineage.resolve). One resolver serves channel, stream, VOD, clip, Pulse and creator-UI callers. PRECEDENCE between inputs (the roadmap's order): 1 explicit_slug (slug), 2 nested_slug (slug <channel>/<slot>, or parent_slug), 3 channel_id, 4 stream_lookup (stream_id, then slot_id), 5 the record's own lineage (clip_id, then vod_id), 6 media_lineage (media_object_id), 7 owner_subject, 8 legacy_map (legacy_ids). The first input in that order that resolves decides (resolved_by). Every other input that resolves must name the same channel, otherwise the answer is unresolved with reason conflict. An input whose source could not be asked makes the answer unresolved with reason source_unavailable, since it might have disagreed. An input that names nothing is reported not_found in checked and does not block the others. LINEAGE inside a record, first link that answers wins: a stream belongs to its owner, and a slot too; a VOD goes to its stream, then to its slot when the stream row is gone, then to the owner recorded on the VOD (legacy_metadata); a clip goes to its stream, then to its parent VOD's lineage (vod_parent), then to the channel recorded on the clip (legacy_metadata), and never to the clipper; a Media object goes to the record its legacy_ref names (media_lineage), then to its owner subject, then to the owner user recorded on it (legacy_metadata); a legacy:<app>:<kind>:<id> reference is read as that record (legacy_map). CONFIDENCE is the weakest hop: exact when the input itself names the channel, a stream or slot row, or the owner subject; derived when the answer came through a parent record or an owner recorded on a record; legacy_map when it needed a legacy id map (legacy_ids, a legacy media reference). A display name never resolves anything. Unresolved answers carry no channel.",++ "type": "object",++ "required": ["status"],++ "additionalProperties": false,++ "properties": {++ "status": { "enum": ["resolved", "unresolved"] },++ "reason": {++ "enum": ["no_input", "display_name_only", "not_found", "conflict", "ambiguous", "source_unavailable"],++ "description": "Why nothing resolved. no_input: the request named nothing. display_name_only: only a display name was given. not_found: no input names anything that exists. conflict: two inputs name different channels. ambiguous: an input names more than one channel (an owner subject linked to two accounts). source_unavailable: a record could not be read (Media down); retry later."++ },++ "detail": { "type": "string", "maxLength": 500 },++ "channel": {++ "type": "object", "required": ["id", "slug", "owner_subject"], "additionalProperties": false,++ "properties": {++ "id": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$", "description": "The channel's id at its owner (Live: channels.id)." },++ "slug": { "type": "string", "pattern": "^[A-Za-z0-9_][A-Za-z0-9_.-]{0,63}$", "description": "The channel's current slug (Live: the username, /@<slug>). Slugs can change; id and owner_subject do not." },++ "owner_subject": { "type": ["string", "null"], "pattern": "^usr_[0-9A-HJKMNP-TV-Z]{26}$", "description": "The owner's canonical subject, or null while the owner has not signed in through OpenVibe.Network since subjects were issued." },++ "legacy_ids": {++ "type": "object", "additionalProperties": false,++ "description": "The owner's pre-subject ids, for callers that still key by them.",++ "properties": {++ "live_user_id": { "type": "integer", "minimum": 1 },++ "network_user_id": { "type": "integer", "minimum": 1 }++ }++ }++ }++ },++ "stream": {++ "type": "object", "required": ["id"], "additionalProperties": false,++ "properties": {++ "id": { "type": ["string", "null"], "description": "The broadcast session, or null when only a slot was named." },++ "slot_id": { "type": ["string", "null"] },++ "slot_slug": { "type": ["string", "null"] },++ "missing": { "type": "boolean", "description": "true when a record names this stream but the stream row no longer exists." }++ }++ },++ "vod": {++ "type": "object", "required": ["id"], "additionalProperties": false,++ "properties": {++ "id": { "type": "string" },++ "stream_id": { "type": ["string", "null"] },++ "slot_id": { "type": ["string", "null"] }++ }++ },++ "clip": {++ "type": "object", "required": ["id"], "additionalProperties": false,++ "properties": {++ "id": { "type": "string" },++ "vod_id": { "type": ["string", "null"] },++ "stream_id": { "type": ["string", "null"] }++ }++ },++ "media_object": {++ "type": "object", "required": ["id"], "additionalProperties": false,++ "properties": {++ "id": { "type": "string", "pattern": "^(med_[0-9A-HJKMNP-TV-Z]{26}|legacy:[a-z][a-z0-9-]{1,39}:(vod|clip|file|paste|thumbnail|avatar):[A-Za-z0-9._/-]{1,200})$" },++ "kind": { "type": ["string", "null"] },++ "legacy_ref": { "type": ["string", "null"] }++ }++ },++ "resolved_by": {++ "enum": ["slug", "parent_slug", "channel_id", "stream_id", "slot_id", "clip_id", "vod_id", "media_object_id", "owner_subject", "legacy_ids"],++ "description": "The input that decided."++ },++ "rule": {++ "enum": ["explicit_slug", "nested_slug", "channel_id", "stream_lookup", "vod_parent", "media_lineage", "owner_subject", "legacy_metadata", "legacy_map"],++ "description": "The lineage rule that answered for the deciding input."++ },++ "confidence": { "enum": ["exact", "derived", "legacy_map"] },++ "via": {++ "type": "array", "maxItems": 32,++ "items": { "type": "string", "pattern": "^[a-z_]+:[^\\s]{1,200}$" },++ "description": "The hops the deciding input took, e.g. [\"clip:7\", \"vod:42\", \"slot:3\", \"user:17\"]."++ },++ "checked": {++ "type": "array", "maxItems": 32,++ "description": "What each input given came to.",++ "items": {++ "type": "object", "required": ["input", "outcome"], "additionalProperties": false,++ "properties": {++ "input": { "enum": ["slug", "parent_slug", "channel_id", "stream_id", "slot_id", "clip_id", "vod_id", "media_object_id", "owner_subject", "legacy_ids", "display_name"] },++ "outcome": { "enum": ["decided", "agrees", "conflict", "not_found", "unavailable", "ambiguous", "ignored"] },++ "channel_slug": { "type": "string", "description": "The channel this input named, when it named one." }++ }++ }++ }++ },++ "if": { "properties": { "status": { "const": "resolved" } }, "required": ["status"] },++ "then": { "required": ["channel", "resolved_by", "rule", "confidence"], "not": { "required": ["reason"] } },++ "else": { "required": ["reason"], "not": { "anyOf": [{ "required": ["channel"] }, { "required": ["resolved_by"] }, { "required": ["confidence"] }] } }++}nnew file mode 100644@@@ -0,0 +1,59 @@++{++ "$schema": "https://json-schema.org/draft/2020-12/schema",++ "$id": "https://openvibe.network/contracts/lineage/resolve-request.v1.json",++ "title": "LineageResolveRequest",++ "description": "What a caller knows about a channel, stream, VOD, clip or Media object, sent to the canonical channel/owner resolver (roadmap §15.10, requirement D20): the body of POST /internal/lineage/resolve on OpenVibe.Live (capability live.lineage.resolve). GET takes the same fields as query parameters, with legacy_ids flattened to live_user_id and network_user_id. The answer is lineage.resolution@1, whose description has the precedence rules. Every input given is checked, not only the first. display_name is never a resolution input: a request that carries only a display name resolves nothing (reason display_name_only), and next to other inputs it is ignored.",++ "type": "object",++ "minProperties": 1,++ "additionalProperties": false,++ "properties": {++ "slug": {++ "type": "string", "pattern": "^@?[A-Za-z0-9_][A-Za-z0-9_.-]{0,63}(/[A-Za-z0-9_][A-Za-z0-9_.-]{0,63})?$",++ "description": "Channel slug (Live: the username, as in /@<username>; the @ is optional), or a nested <channel>/<slot> naming a stream slot inside the channel by its slug or id. With parent_slug, a plain slug names the slot inside that parent."++ },++ "parent_slug": {++ "type": "string", "pattern": "^@?[A-Za-z0-9_][A-Za-z0-9_.-]{0,63}$",++ "description": "The channel slug when slug names a slot inside it ({ parent_slug: 'alice', slug: 'garage' } is 'alice/garage'). Alone, it names the channel."++ },++ "channel_id": {++ "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$",++ "description": "The channel's id at its owner (Live: channels.id)."++ },++ "stream_id": {++ "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$",++ "description": "A broadcast session (Live: streams.id)."++ },++ "slot_id": {++ "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$",++ "description": "A persistent stream slot (Live: managed_streams.id; OpenRe stream definitions refer to it as live:managed_stream:<id>)."++ },++ "vod_id": {++ "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$",++ "description": "A VOD (the Media v1 VOD id, the same id Live's VODs had before the Media split)."++ },++ "clip_id": {++ "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$",++ "description": "A clip (the Media v1 clip id). A clip belongs to the clipped channel, never to the person who clipped it."++ },++ "media_object_id": {++ "type": "string", "pattern": "^(med_[0-9A-HJKMNP-TV-Z]{26}|legacy:[a-z][a-z0-9-]{1,39}:(vod|clip|file|paste|thumbnail|avatar):[A-Za-z0-9._/-]{1,200})$",++ "description": "A Media object: med_<ULID>, or a legacy:<app>:<kind>:<id> reference (the media_id forms of media.media-ref@1)."++ },++ "owner_subject": {++ "type": "string", "pattern": "^usr_[0-9A-HJKMNP-TV-Z]{26}$",++ "description": "The canonical subject that owns the channel (identity.subject-ref@1 user id)."++ },++ "legacy_ids": {++ "type": "object", "minProperties": 1, "additionalProperties": false,++ "description": "Pre-subject ids of the channel owner. Resolving through them is confidence legacy_map.",++ "properties": {++ "live_user_id": { "type": "integer", "minimum": 1, "description": "Live's local user id (users.id)." },++ "network_user_id": { "type": "integer", "minimum": 1, "description": "OpenVibe.Network's integer user id, mapped to a Live account by the account link." }++ }++ },++ "display_name": {++ "type": "string", "maxLength": 200,++ "description": "A label the caller holds. Accepted so the answer can say display_name_only; never used to resolve anything, because a display name is not an identity and is never sufficient to establish ownership."++ }++ }++}nnew file mode 100644@@@ -0,0 +1,7 @@++{++ "status": "resolved",++ "channel": { "id": "12", "slug": "alice", "owner_subject": "17" },++ "resolved_by": "slug",++ "rule": "explicit_slug",++ "confidence": "exact"++}nnew file mode 100644@@@ -0,0 +1 @@++{ "status": "resolved", "resolved_by": "slug", "rule": "explicit_slug", "confidence": "exact" }nnew file mode 100644@@@ -0,0 +1,7 @@++{++ "status": "resolved",++ "channel": { "id": "12", "slug": "alice", "owner_subject": null },++ "resolved_by": "display_name",++ "rule": "explicit_slug",++ "confidence": "guessed"++}nnew file mode 100644@@@ -0,0 +1 @@++{ "status": "unresolved", "reason": "conflict", "channel": { "id": "12", "slug": "alice", "owner_subject": null } }nnew file mode 100644@@@ -0,0 +1 @@++{ "status": "unresolved" }nnew file mode 100644@@@ -0,0 +1,12 @@++{++ "status": "resolved",++ "channel": { "id": "12", "slug": "alice", "owner_subject": null, "legacy_ids": { "live_user_id": 17 } },++ "stream": { "id": "9", "slot_id": "3", "slot_slug": "garage-cam" },++ "vod": { "id": "42", "stream_id": "9", "slot_id": "3" },++ "clip": { "id": "7", "vod_id": "42", "stream_id": null },++ "resolved_by": "clip_id",++ "rule": "vod_parent",++ "confidence": "derived",++ "via": ["clip:7", "vod:42", "stream:9", "user:17"],++ "checked": [{ "input": "clip_id", "outcome": "decided", "channel_slug": "alice" }]++}nnew file mode 100644@@@ -0,0 +1,10 @@++{++ "status": "resolved",++ "channel": { "id": "12", "slug": "alice", "owner_subject": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ" },++ "stream": { "id": "9", "slot_id": "3", "slot_slug": "garage-cam", "missing": true },++ "vod": { "id": "42", "stream_id": "9", "slot_id": "3" },++ "resolved_by": "vod_id",++ "rule": "stream_lookup",++ "confidence": "derived",++ "via": ["vod:42", "slot:3", "user:17"]++}nnew file mode 100644@@@ -0,0 +1,10 @@++{++ "status": "resolved",++ "channel": { "id": "12", "slug": "alice", "owner_subject": null },++ "vod": { "id": "42", "stream_id": "9", "slot_id": null },++ "media_object": { "id": "legacy:live:vod:42", "kind": "vod", "legacy_ref": "legacy:live:vod:42" },++ "resolved_by": "media_object_id",++ "rule": "legacy_map",++ "confidence": "legacy_map",++ "via": ["media:legacy:live:vod:42", "vod:42", "stream:9", "user:17"]++}nnew file mode 100644@@@ -0,0 +1,9 @@++{++ "status": "resolved",++ "channel": { "id": "12", "slug": "alice", "owner_subject": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ", "legacy_ids": { "live_user_id": 17, "network_user_id": 57 } },++ "resolved_by": "slug",++ "rule": "explicit_slug",++ "confidence": "exact",++ "via": ["user:17"],++ "checked": [{ "input": "slug", "outcome": "decided", "channel_slug": "alice" }]++}nnew file mode 100644@@@ -0,0 +1,8 @@++{++ "status": "unresolved",++ "reason": "conflict",++ "checked": [++ { "input": "slug", "outcome": "decided", "channel_slug": "mallory" },++ { "input": "vod_id", "outcome": "conflict", "channel_slug": "alice" }++ ]++}nnew file mode 100644@@@ -0,0 +1,6 @@++{++ "status": "unresolved",++ "reason": "display_name_only",++ "detail": "a display name is never sufficient to establish a channel or its owner",++ "checked": [{ "input": "display_name", "outcome": "ignored" }]++}nnew file mode 100644@@@ -0,0 +1 @@++{ "slug": "Alice Smith" }nnew file mode 100644@@@ -0,0 +1 @@++{ "legacy_ids": {} }nnew file mode 100644@@@ -0,0 +1 @@++{}nnew file mode 100644@@@ -0,0 +1 @@++{ "owner_subject": "42" }nnew file mode 100644@@@ -0,0 +1 @@++{ "vod_id": 42 }nnew file mode 100644@@@ -0,0 +1 @@++{ "channel_name": "Alice" }nnew file mode 100644@@@ -0,0 +1 @@++{ "slug": "alice", "clip_id": "7" }nnew file mode 100644@@@ -0,0 +1 @@++{ "display_name": "Alice ✨" }nnew file mode 100644@@@ -0,0 +1,12 @@++{++ "slug": "alice",++ "channel_id": "12",++ "stream_id": "9",++ "slot_id": "3",++ "vod_id": "42",++ "clip_id": "7",++ "media_object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ",++ "owner_subject": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ",++ "legacy_ids": { "live_user_id": 17, "network_user_id": 57 },++ "display_name": "Alice"++}nnew file mode 100644@@@ -0,0 +1 @@++{ "media_object_id": "legacy:live:vod:42" }nnew file mode 100644@@@ -0,0 +1 @@++{ "slug": "alice/garage-cam" }nnew file mode 100644@@@ -0,0 +1 @@++{ "parent_slug": "alice", "slug": "garage-cam" }nnew file mode 100644@@@ -0,0 +1 @@++{ "slug": "@alice" }@@@ -708,6 +708,415 @@}}},++ "lineage.resolve-request": {++ "version": "1.0.0",++ "owner": "live",++ "status": "active",++ "schema": {++ "$schema": "https://json-schema.org/draft/2020-12/schema",++ "$id": "https://openvibe.network/contracts/lineage/resolve-request.v1.json",++ "title": "LineageResolveRequest",++ "description": "What a caller knows about a channel, stream, VOD, clip or Media object, sent to the canonical channel/owner resolver (roadmap §15.10, requirement D20): the body of POST /internal/lineage/resolve on OpenVibe.Live (capability live.lineage.resolve). GET takes the same fields as query parameters, with legacy_ids flattened to live_user_id and network_user_id. The answer is lineage.resolution@1, whose description has the precedence rules. Every input given is checked, not only the first. display_name is never a resolution input: a request that carries only a display name resolves nothing (reason display_name_only), and next to other inputs it is ignored.",++ "type": "object",++ "minProperties": 1,++ "additionalProperties": false,++ "properties": {++ "slug": {++ "type": "string",++ "pattern": "^@?[A-Za-z0-9_][A-Za-z0-9_.-]{0,63}(/[A-Za-z0-9_][A-Za-z0-9_.-]{0,63})?$",++ "description": "Channel slug (Live: the username, as in /@<username>; the @ is optional), or a nested <channel>/<slot> naming a stream slot inside the channel by its slug or id. With parent_slug, a plain slug names the slot inside that parent."++ },++ "parent_slug": {++ "type": "string",++ "pattern": "^@?[A-Za-z0-9_][A-Za-z0-9_.-]{0,63}$",++ "description": "The channel slug when slug names a slot inside it ({ parent_slug: 'alice', slug: 'garage' } is 'alice/garage'). Alone, it names the channel."++ },++ "channel_id": {++ "type": "string",++ "pattern": "^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$",++ "description": "The channel's id at its owner (Live: channels.id)."++ },++ "stream_id": {++ "type": "string",++ "pattern": "^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$",++ "description": "A broadcast session (Live: streams.id)."++ },++ "slot_id": {++ "type": "string",++ "pattern": "^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$",++ "description": "A persistent stream slot (Live: managed_streams.id; OpenRe stream definitions refer to it as live:managed_stream:<id>)."++ },++ "vod_id": {++ "type": "string",++ "pattern": "^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$",++ "description": "A VOD (the Media v1 VOD id, the same id Live's VODs had before the Media split)."++ },++ "clip_id": {++ "type": "string",++ "pattern": "^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$",++ "description": "A clip (the Media v1 clip id). A clip belongs to the clipped channel, never to the person who clipped it."++ },++ "media_object_id": {++ "type": "string",++ "pattern": "^(med_[0-9A-HJKMNP-TV-Z]{26}|legacy:[a-z][a-z0-9-]{1,39}:(vod|clip|file|paste|thumbnail|avatar):[A-Za-z0-9._/-]{1,200})$",++ "description": "A Media object: med_<ULID>, or a legacy:<app>:<kind>:<id> reference (the media_id forms of media.media-ref@1)."++ },++ "owner_subject": {++ "type": "string",++ "pattern": "^usr_[0-9A-HJKMNP-TV-Z]{26}$",++ "description": "The canonical subject that owns the channel (identity.subject-ref@1 user id)."++ },++ "legacy_ids": {++ "type": "object",++ "minProperties": 1,++ "additionalProperties": false,++ "description": "Pre-subject ids of the channel owner. Resolving through them is confidence legacy_map.",++ "properties": {++ "live_user_id": {++ "type": "integer",++ "minimum": 1,++ "description": "Live's local user id (users.id)."++ },++ "network_user_id": {++ "type": "integer",++ "minimum": 1,++ "description": "OpenVibe.Network's integer user id, mapped to a Live account by the account link."++ }++ }++ },++ "display_name": {++ "type": "string",++ "maxLength": 200,++ "description": "A label the caller holds. Accepted so the answer can say display_name_only; never used to resolve anything, because a display name is not an identity and is never sufficient to establish ownership."++ }++ }++ }++ },++ "lineage.resolution": {++ "version": "1.0.0",++ "owner": "live",++ "status": "active",++ "schema": {++ "$schema": "https://json-schema.org/draft/2020-12/schema",++ "$id": "https://openvibe.network/contracts/lineage/resolution.v1.json",++ "title": "LineageResolution",++ "description": "The canonical channel/owner resolver's answer to lineage.resolve-request@1 (roadmap §15.10, requirement D20; capability live.lineage.resolve). One resolver serves channel, stream, VOD, clip, Pulse and creator-UI callers. PRECEDENCE between inputs (the roadmap's order): 1 explicit_slug (slug), 2 nested_slug (slug <channel>/<slot>, or parent_slug), 3 channel_id, 4 stream_lookup (stream_id, then slot_id), 5 the record's own lineage (clip_id, then vod_id), 6 media_lineage (media_object_id), 7 owner_subject, 8 legacy_map (legacy_ids). The first input in that order that resolves decides (resolved_by). Every other input that resolves must name the same channel, otherwise the answer is unresolved with reason conflict. An input whose source could not be asked makes the answer unresolved with reason source_unavailable, since it might have disagreed. An input that names nothing is reported not_found in checked and does not block the others. LINEAGE inside a record, first link that answers wins: a stream belongs to its owner, and a slot too; a VOD goes to its stream, then to its slot when the stream row is gone, then to the owner recorded on the VOD (legacy_metadata); a clip goes to its stream, then to its parent VOD's lineage (vod_parent), then to the channel recorded on the clip (legacy_metadata), and never to the clipper; a Media object goes to the record its legacy_ref names (media_lineage), then to its owner subject, then to the owner user recorded on it (legacy_metadata); a legacy:<app>:<kind>:<id> reference is read as that record (legacy_map). CONFIDENCE is the weakest hop: exact when the input itself names the channel, a stream or slot row, or the owner subject; derived when the answer came through a parent record or an owner recorded on a record; legacy_map when it needed a legacy id map (legacy_ids, a legacy media reference). A display name never resolves anything. Unresolved answers carry no channel.",++ "type": "object",++ "required": [++ "status"++ ],++ "additionalProperties": false,++ "properties": {++ "status": {++ "enum": [++ "resolved",++ "unresolved"++ ]++ },++ "reason": {++ "enum": [++ "no_input",++ "display_name_only",++ "not_found",++ "conflict",++ "ambiguous",++ "source_unavailable"++ ],++ "description": "Why nothing resolved. no_input: the request named nothing. display_name_only: only a display name was given. not_found: no input names anything that exists. conflict: two inputs name different channels. ambiguous: an input names more than one channel (an owner subject linked to two accounts). source_unavailable: a record could not be read (Media down); retry later."++ },++ "detail": {++ "type": "string",++ "maxLength": 500++ },++ "channel": {++ "type": "object",++ "required": [++ "id",++ "slug",++ "owner_subject"++ ],++ "additionalProperties": false,++ "properties": {++ "id": {++ "type": "string",++ "pattern": "^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$",++ "description": "The channel's id at its owner (Live: channels.id)."++ },++ "slug": {++ "type": "string",++ "pattern": "^[A-Za-z0-9_][A-Za-z0-9_.-]{0,63}$",++ "description": "The channel's current slug (Live: the username, /@<slug>). Slugs can change; id and owner_subject do not."++ },++ "owner_subject": {++ "type": [++ "string",++ "null"++ ],++ "pattern": "^usr_[0-9A-HJKMNP-TV-Z]{26}$",++ "description": "The owner's canonical subject, or null while the owner has not signed in through OpenVibe.Network since subjects were issued."++ },++ "legacy_ids": {++ "type": "object",++ "additionalProperties": false,++ "description": "The owner's pre-subject ids, for callers that still key by them.",++ "properties": {++ "live_user_id": {++ "type": "integer",++ "minimum": 1++ },++ "network_user_id": {++ "type": "integer",++ "minimum": 1++ }++ }++ }++ }++ },++ "stream": {++ "type": "object",++ "required": [++ "id"++ ],++ "additionalProperties": false,++ "properties": {++ "id": {++ "type": [++ "string",++ "null"++ ],++ "description": "The broadcast session, or null when only a slot was named."++ },++ "slot_id": {++ "type": [++ "string",++ "null"++ ]++ },++ "slot_slug": {++ "type": [++ "string",++ "null"++ ]++ },++ "missing": {++ "type": "boolean",++ "description": "true when a record names this stream but the stream row no longer exists."++ }++ }++ },++ "vod": {++ "type": "object",++ "required": [++ "id"++ ],++ "additionalProperties": false,++ "properties": {++ "id": {++ "type": "string"++ },++ "stream_id": {++ "type": [++ "string",++ "null"++ ]++ },++ "slot_id": {++ "type": [++ "string",++ "null"++ ]++ }++ }++ },++ "clip": {++ "type": "object",++ "required": [++ "id"++ ],++ "additionalProperties": false,++ "properties": {++ "id": {++ "type": "string"++ },++ "vod_id": {++ "type": [++ "string",++ "null"++ ]++ },++ "stream_id": {++ "type": [++ "string",++ "null"++ ]++ }++ }++ },++ "media_object": {++ "type": "object",++ "required": [++ "id"++ ],++ "additionalProperties": false,++ "properties": {++ "id": {++ "type": "string",++ "pattern": "^(med_[0-9A-HJKMNP-TV-Z]{26}|legacy:[a-z][a-z0-9-]{1,39}:(vod|clip|file|paste|thumbnail|avatar):[A-Za-z0-9._/-]{1,200})$"++ },++ "kind": {++ "type": [++ "string",++ "null"++ ]++ },++ "legacy_ref": {++ "type": [++ "string",++ "null"++ ]++ }++ }++ },++ "resolved_by": {++ "enum": [++ "slug",++ "parent_slug",++ "channel_id",++ "stream_id",++ "slot_id",++ "clip_id",++ "vod_id",++ "media_object_id",++ "owner_subject",++ "legacy_ids"++ ],++ "description": "The input that decided."++ },++ "rule": {++ "enum": [++ "explicit_slug",++ "nested_slug",++ "channel_id",++ "stream_lookup",++ "vod_parent",++ "media_lineage",++ "owner_subject",++ "legacy_metadata",++ "legacy_map"++ ],++ "description": "The lineage rule that answered for the deciding input."++ },++ "confidence": {++ "enum": [++ "exact",++ "derived",++ "legacy_map"++ ]++ },++ "via": {++ "type": "array",++ "maxItems": 32,++ "items": {++ "type": "string",++ "pattern": "^[a-z_]+:[^\\s]{1,200}$"++ },++ "description": "The hops the deciding input took, e.g. [\"clip:7\", \"vod:42\", \"slot:3\", \"user:17\"]."++ },++ "checked": {++ "type": "array",++ "maxItems": 32,++ "description": "What each input given came to.",++ "items": {++ "type": "object",++ "required": [++ "input",++ "outcome"++ ],++ "additionalProperties": false,++ "properties": {++ "input": {++ "enum": [++ "slug",++ "parent_slug",++ "channel_id",++ "stream_id",++ "slot_id",++ "clip_id",++ "vod_id",++ "media_object_id",++ "owner_subject",++ "legacy_ids",++ "display_name"++ ]++ },++ "outcome": {++ "enum": [++ "decided",++ "agrees",++ "conflict",++ "not_found",++ "unavailable",++ "ambiguous",++ "ignored"++ ]++ },++ "channel_slug": {++ "type": "string",++ "description": "The channel this input named, when it named one."++ }++ }++ }++ }++ },++ "if": {++ "properties": {++ "status": {++ "const": "resolved"++ }++ },++ "required": [++ "status"++ ]++ },++ "then": {++ "required": [++ "channel",++ "resolved_by",++ "rule",++ "confidence"++ ],++ "not": {++ "required": [++ "reason"++ ]++ }++ },++ "else": {++ "required": [++ "reason"++ ],++ "not": {++ "anyOf": [++ {++ "required": [++ "channel"++ ]++ },++ {++ "required": [++ "resolved_by"++ ]++ },++ {++ "required": [++ "confidence"++ ]++ }++ ]++ }++ }++ }++ },"modules.namespace": {"version": "1.0.0","owner": "network",@@@ -208,6 +208,185 @@ export interface MediaRef {variant?: string;}++/** [email protected] (owner: live) */++/**++ * What a caller knows about a channel, stream, VOD, clip or Media object, sent to the canonical channel/owner resolver (roadmap §15.10, requirement D20): the body of POST /internal/lineage/resolve on OpenVibe.Live (capability live.lineage.resolve). GET takes the same fields as query parameters, with legacy_ids flattened to live_user_id and network_user_id. The answer is lineage.resolution@1, whose description has the precedence rules. Every input given is checked, not only the first. display_name is never a resolution input: a request that carries only a display name resolves nothing (reason display_name_only), and next to other inputs it is ignored.++ */++export interface LineageResolveRequest {++ /**++ * Channel slug (Live: the username, as in /@<username>; the @ is optional), or a nested <channel>/<slot> naming a stream slot inside the channel by its slug or id. With parent_slug, a plain slug names the slot inside that parent.++ */++ slug?: string;++ /**++ * The channel slug when slug names a slot inside it ({ parent_slug: 'alice', slug: 'garage' } is 'alice/garage'). Alone, it names the channel.++ */++ parent_slug?: string;++ /**++ * The channel's id at its owner (Live: channels.id).++ */++ channel_id?: string;++ /**++ * A broadcast session (Live: streams.id).++ */++ stream_id?: string;++ /**++ * A persistent stream slot (Live: managed_streams.id; OpenRe stream definitions refer to it as live:managed_stream:<id>).++ */++ slot_id?: string;++ /**++ * A VOD (the Media v1 VOD id, the same id Live's VODs had before the Media split).++ */++ vod_id?: string;++ /**++ * A clip (the Media v1 clip id). A clip belongs to the clipped channel, never to the person who clipped it.++ */++ clip_id?: string;++ /**++ * A Media object: med_<ULID>, or a legacy:<app>:<kind>:<id> reference (the media_id forms of media.media-ref@1).++ */++ media_object_id?: string;++ /**++ * The canonical subject that owns the channel (identity.subject-ref@1 user id).++ */++ owner_subject?: string;++ /**++ * Pre-subject ids of the channel owner. Resolving through them is confidence legacy_map.++ */++ legacy_ids?: {++ /**++ * Live's local user id (users.id).++ */++ live_user_id?: number;++ /**++ * OpenVibe.Network's integer user id, mapped to a Live account by the account link.++ */++ network_user_id?: number;++ };++ /**++ * A label the caller holds. Accepted so the answer can say display_name_only; never used to resolve anything, because a display name is not an identity and is never sufficient to establish ownership.++ */++ display_name?: string;++}++++/** [email protected] (owner: live) */++/**++ * The canonical channel/owner resolver's answer to lineage.resolve-request@1 (roadmap §15.10, requirement D20; capability live.lineage.resolve). One resolver serves channel, stream, VOD, clip, Pulse and creator-UI callers. PRECEDENCE between inputs (the roadmap's order): 1 explicit_slug (slug), 2 nested_slug (slug <channel>/<slot>, or parent_slug), 3 channel_id, 4 stream_lookup (stream_id, then slot_id), 5 the record's own lineage (clip_id, then vod_id), 6 media_lineage (media_object_id), 7 owner_subject, 8 legacy_map (legacy_ids). The first input in that order that resolves decides (resolved_by). Every other input that resolves must name the same channel, otherwise the answer is unresolved with reason conflict. An input whose source could not be asked makes the answer unresolved with reason source_unavailable, since it might have disagreed. An input that names nothing is reported not_found in checked and does not block the others. LINEAGE inside a record, first link that answers wins: a stream belongs to its owner, and a slot too; a VOD goes to its stream, then to its slot when the stream row is gone, then to the owner recorded on the VOD (legacy_metadata); a clip goes to its stream, then to its parent VOD's lineage (vod_parent), then to the channel recorded on the clip (legacy_metadata), and never to the clipper; a Media object goes to the record its legacy_ref names (media_lineage), then to its owner subject, then to the owner user recorded on it (legacy_metadata); a legacy:<app>:<kind>:<id> reference is read as that record (legacy_map). CONFIDENCE is the weakest hop: exact when the input itself names the channel, a stream or slot row, or the owner subject; derived when the answer came through a parent record or an owner recorded on a record; legacy_map when it needed a legacy id map (legacy_ids, a legacy media reference). A display name never resolves anything. Unresolved answers carry no channel.++ */++export interface LineageResolution {++ status: "resolved" | "unresolved";++ /**++ * Why nothing resolved. no_input: the request named nothing. display_name_only: only a display name was given. not_found: no input names anything that exists. conflict: two inputs name different channels. ambiguous: an input names more than one channel (an owner subject linked to two accounts). source_unavailable: a record could not be read (Media down); retry later.++ */++ reason?: "no_input" | "display_name_only" | "not_found" | "conflict" | "ambiguous" | "source_unavailable";++ detail?: string;++ channel?: {++ /**++ * The channel's id at its owner (Live: channels.id).++ */++ id: string;++ /**++ * The channel's current slug (Live: the username, /@<slug>). Slugs can change; id and owner_subject do not.++ */++ slug: string;++ /**++ * The owner's canonical subject, or null while the owner has not signed in through OpenVibe.Network since subjects were issued.++ */++ owner_subject: string | null;++ /**++ * The owner's pre-subject ids, for callers that still key by them.++ */++ legacy_ids?: {++ live_user_id?: number;++ network_user_id?: number;++ };++ };++ stream?: {++ /**++ * The broadcast session, or null when only a slot was named.++ */++ id: string | null;++ slot_id?: string | null;++ slot_slug?: string | null;++ /**++ * true when a record names this stream but the stream row no longer exists.++ */++ missing?: boolean;++ };++ vod?: {++ id: string;++ stream_id?: string | null;++ slot_id?: string | null;++ };++ clip?: {++ id: string;++ vod_id?: string | null;++ stream_id?: string | null;++ };++ media_object?: {++ id: string;++ kind?: string | null;++ legacy_ref?: string | null;++ };++ /**++ * The input that decided.++ */++ resolved_by?:++ | "slug"++ | "parent_slug"++ | "channel_id"++ | "stream_id"++ | "slot_id"++ | "clip_id"++ | "vod_id"++ | "media_object_id"++ | "owner_subject"++ | "legacy_ids";++ /**++ * The lineage rule that answered for the deciding input.++ */++ rule?:++ | "explicit_slug"++ | "nested_slug"++ | "channel_id"++ | "stream_lookup"++ | "vod_parent"++ | "media_lineage"++ | "owner_subject"++ | "legacy_metadata"++ | "legacy_map";++ confidence?: "exact" | "derived" | "legacy_map";++ /**++ * The hops the deciding input took, e.g. ["clip:7", "vod:42", "slot:3", "user:17"].++ *++ * @maxItems 32++ */++ via?: string[];++ /**++ * What each input given came to.++ *++ * @maxItems 32++ */++ checked?: {++ input:++ | "slug"++ | "parent_slug"++ | "channel_id"++ | "stream_id"++ | "slot_id"++ | "clip_id"++ | "vod_id"++ | "media_object_id"++ | "owner_subject"++ | "legacy_ids"++ | "display_name";++ outcome: "decided" | "agrees" | "conflict" | "not_found" | "unavailable" | "ambiguous" | "ignored";++ /**++ * The channel this input named, when it named one.++ */++ channel_slug?: string;++ }[];++}++/** [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).nnew file mode 100644@@@ -0,0 +1,22 @@++{++ "id": "live.lineage.resolve",++ "version": "1.0",++ "owner": "live",++ "status": "active",++ "visibility": "internal",++ "description": "Resolve the canonical channel and owner behind a slug, nested slug, channel, stream, slot, VOD, clip, Media object, owner subject or legacy id, with the stream/VOD/clip lineage and how it was decided (roadmap §15.10, D20). A display name alone resolves nothing. Loopback only.",++ "inputSchema": "lineage.resolve-request@1",++ "outputSchema": "lineage.resolution@1",++ "permissions": [++ "live:lineage"++ ],++ "resourceConstraints": [++ "none"++ ],++ "quotaClass": "standard",++ "events": [],++ "implementedBy": [++ "GET /internal/lineage/resolve",++ "POST /internal/lineage/resolve"++ ]++}@@@ -19,6 +19,7 @@"live.chat_effects.write","live.chat_mirror.write","live.follower.read",++ "live.lineage.resolve","live.tips_delivery.write"],"eventsProduced": [@@@ -1,12 +1,12 @@{"name": "openvibe-contracts",-- "version": "0.31.0",++ "version": "0.32.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-contracts",-- "version": "0.31.0",++ "version": "0.32.0","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -1,6 +1,6 @@{"name": "openvibe-contracts",-- "version": "0.31.0",++ "version": "0.32.0","description": "Machine-readable contracts for the OpenVibe network: schemas, manifests, ids, error envelope, capability checks.","main": "index.js","types": "generated/typescript/index.d.ts",@@@ -119,6 +119,31 @@ for (const id of ['events.event.publish', 'events.event.read', 'events.subscriptok(!contracts.validate('events.tombstone-payload@1', { redacted: false, redacted_at: tombstone.redacted_at, redacted_by: 'x' }).valid, 'a tombstone is redacted: true');}++// ── Channel/owner lineage resolver (v0.32, roadmap D20) ─────────────────++// A display name never resolves anything, every resolved answer says which input decided and how++// sure it is, and an unresolved answer carries no channel.++{++ const cap = capabilities.get('live.lineage.resolve');++ ok(cap && cap.owner === 'live' && cap.visibility === 'internal' && cap.inputSchema === 'lineage.resolve-request@1' && cap.outputSchema === 'lineage.resolution@1', 'live.lineage.resolve is Live-internal and names the lineage contracts');++ const Q = (v) => contracts.validate('lineage.resolve-request@1', v).valid;++ const A = (v) => contracts.validate('lineage.resolution@1', v).valid;++ const channel = { id: '12', slug: 'alice', owner_subject: null };++ const resolved = { status: 'resolved', channel, resolved_by: 'slug', rule: 'explicit_slug', confidence: 'exact' };++ ok(Q({ display_name: 'Alice' }), 'a request holding only a display name is well formed, so it gets an explicit answer');++ ok(!Q({ slug: 'Alice Smith' }) && !Q({ slug: 'alice/garage/extra' }), 'a display name is not a slug; nesting is one level');++ ok(A({ status: 'unresolved', reason: 'display_name_only' }), 'display_name_only is an unresolved reason');++ ok(!A({ ...resolved, resolved_by: 'display_name' }), 'a display name can never be what resolved');++ for (const confidence of ['exact', 'derived', 'legacy_map']) ok(A({ ...resolved, confidence }), `confidence ${confidence}`);++ ok(!A({ ...resolved, reason: 'conflict' }), 'a resolved answer has no reason');++ for (const k of ['channel', 'resolved_by', 'rule', 'confidence']) ok(!A({ ...resolved, [k]: undefined }), `a resolved answer needs ${k}`);++ for (const reason of ['no_input', 'display_name_only', 'not_found', 'conflict', 'ambiguous', 'source_unavailable']) ok(A({ status: 'unresolved', reason }), `unresolved: ${reason}`);++ ok(!A({ status: 'unresolved', reason: 'not_found', resolved_by: 'slug' }) && !A({ status: 'unresolved', reason: 'conflict', confidence: 'exact' }), 'an unresolved answer names no decider');++ const inputs = Object.keys(contracts.schema('lineage.resolve-request').properties).filter(k => k !== 'display_name').sort();++ const res = contracts.schema('lineage.resolution').properties;++ ok(JSON.stringify([...res.resolved_by.enum].sort()) === JSON.stringify(inputs), 'resolved_by names exactly the request inputs, never display_name');++ ok(JSON.stringify([...res.checked.items.properties.input.enum].sort()) === JSON.stringify([...inputs, 'display_name'].sort()), 'checked reports every request field, display_name included');++}++// ── Ids ──────────────────────────────────────────────────────────────────for (const kind of ['user', 'guest', 'app', 'mod']) {const id = ids.newId(kind);
@@ -4,6 +4,30 @@ All notable changes to `openvibe-contracts`. Releases are git tags (`vX.Y.Z`) th
@@ -4,6 +4,30 @@ All notable changes to `openvibe-contracts`. Releases are git tags (`vX.Y.Z`) th
from `https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/<tag>`. Before v0.30.0,
from `https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/<tag>`. Before v0.30.0,
the notes were in the tag and commit messages (`git tag -n1`).
the notes were in the tag and commit messages (`git tag -n1`).
+## 0.32.0 — 2026-09-23
+
+Additive: `compat.js` reports no breaking change against v0.31.0.
+
+- **Channel/owner lineage resolver** (roadmap §15.10, requirement D20): one resolver for channel, stream,
+ VOD, clip, Pulse and creator-UI callers. OpenVibe.Live implements it.
+ - `lineage.resolve-request@1`: any of `slug` (a channel, or `<channel>/<slot>`), `parent_slug`,
+ `channel_id`, `stream_id`, `slot_id`, `vod_id`, `clip_id`, `media_object_id` (`med_…` or
+ `legacy:<app>:<kind>:<id>`), `owner_subject` and `legacy_ids` (`live_user_id`, `network_user_id`).
+ `display_name` is accepted and never used.
+ - `lineage.resolution@1`: `status` `resolved` with the canonical `channel` (`id`, `slug`,
+ `owner_subject`, optional `legacy_ids`), whatever of `stream`, `vod`, `clip` and `media_object`
+ the inputs reached, `resolved_by` (the deciding input), `rule`, `confidence`
+ (`exact` | `derived` | `legacy_map`), `via` and `checked`; or `status` `unresolved` with a
+ `reason` (`no_input`, `display_name_only`, `not_found`, `conflict`, `ambiguous`,
+ `source_unavailable`) and no channel.
+ - Precedence, in the roadmap's order: explicit slug, nested or parent slug, channel id, stream
+ lookup (stream, slot), the record's own lineage (clip, VOD), Media lineage, owner subject, legacy
+ maps. The first input that resolves decides, and every other input that resolves must name the
+ same channel or the answer is `conflict`. Inside a record the first link that answers wins: a clip
+ 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`.
+
## 0.31.0 — 2026-09-23
## 0.31.0 — 2026-09-23
Additive: `compat.js` reports no breaking change against v0.30.2.
Additive: `compat.js` reports no breaking change against v0.30.2.
@@ -60,7 +60,7 @@ TypeScript types: `generated/typescript/index.d.ts` (`"types"` in package.json).
@@ -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) |
| `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>` |
| `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/` (170 capabilities; 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/` (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.
**Ids.** Subjects use prefixed ULIDs: `usr_`, `gst_`, `app_`, `mod_`. Services and system actors use slugs (`live`, `media`). Events use `evt_` and Media objects `med_`.
**Ids.** Subjects use prefixed ULIDs: `usr_`, `gst_`, `app_`, `mod_`. Services and system actors use slugs (`live`, `media`). Events use `evt_` and Media objects `med_`.
@@ -105,6 +105,23 @@ v1 covers only the body, so a captured delivery verifies forever (only `event_id
@@ -105,6 +105,23 @@ v1 covers only the body, so a captured delivery verifies forever (only `event_id
Rollout: (1) Events sends v2 beside v1; (2) each consumer moves to SDK 0.4.0, which verifies v2 whenever it is present; (3) each consumer sets `requireV2: true`, so a v1-only (header-stripped) replay fails; (4) once every consumer requires v2, v1 may be dropped. See ADR-004.
Rollout: (1) Events sends v2 beside v1; (2) each consumer moves to SDK 0.4.0, which verifies v2 whenever it is present; (3) each consumer sets `requireV2: true`, so a v1-only (header-stripped) replay fails; (4) once every consumer requires v2, v1 may be dropped. See ADR-004.
+## Channel/owner lineage (v0.32)
+
+One resolver answers "which channel, and whose, is this?" for channel, stream, VOD, clip, Pulse and creator-UI callers (roadmap §15.10, D20). OpenVibe.Live implements it at `GET|POST /internal/lineage/resolve` (capability `live.lineage.resolve`, loopback). The request is `lineage.resolve-request@1`, the answer `lineage.resolution@1`.
+
+| Precedence | Input | Rule | Confidence |
+|---|---|---|---|
+| 1 | `slug` | `explicit_slug` | exact |
+| 2 | `slug` `<channel>/<slot>`, `parent_slug` | `nested_slug` | exact |
+| 3 | `channel_id` | `channel_id` | exact |
+| 4 | `stream_id`, `slot_id` | `stream_lookup` | exact |
+| 5 | `clip_id`, `vod_id` | the record's lineage: its stream, its parent VOD (`vod_parent`), the owner recorded on it (`legacy_metadata`) | derived |
+| 6 | `media_object_id` | `media_lineage` (`legacy_map` for a `legacy:` reference) | derived / legacy_map |
+| 7 | `owner_subject` | `owner_subject` | exact |
+| 8 | `legacy_ids` | `legacy_map` | legacy_map |
+
+The first input that resolves decides. Every other input that resolves must name the same channel, or the answer is `unresolved` with reason `conflict`. A clip belongs to the clipped channel, never to the clipper. A display name never resolves anything: a request holding only one is answered `unresolved`, `display_name_only`.
+
## Versioning and compatibility
## Versioning and compatibility
- A contract id is permanent. Minor versions only add optional fields.
- A contract id is permanent. Minor versions only add optional fields.
@@ -89,6 +89,24 @@
@@ -89,6 +89,24 @@
"status": "active",
"status": "active",
"adr": "ADR-006"
"adr": "ADR-006"
},
},
+ {
+ "id": "lineage.resolve-request",
+ "version": "1.0.0",
+ "owner": "live",
+ "schema": "lineage/resolve-request.v1.json",
+ "visibility": "first-party",
+ "compatibility": "backward",
+ "status": "active"
+ },
+ {
+ "id": "lineage.resolution",
+ "version": "1.0.0",
+ "owner": "live",
+ "schema": "lineage/resolution.v1.json",
+ "visibility": "first-party",
+ "compatibility": "backward",
+ "status": "active"
+ },
{
{
"id": "modules.namespace",
"id": "modules.namespace",
"version": "1.0.0",
"version": "1.0.0",
new file mode 100644
new file mode 100644
@@ -0,0 +1,95 @@
@@ -0,0 +1,95 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://openvibe.network/contracts/lineage/resolution.v1.json",
+ "title": "LineageResolution",
+ "description": "The canonical channel/owner resolver's answer to lineage.resolve-request@1 (roadmap §15.10, requirement D20; capability live.lineage.resolve). One resolver serves channel, stream, VOD, clip, Pulse and creator-UI callers. PRECEDENCE between inputs (the roadmap's order): 1 explicit_slug (slug), 2 nested_slug (slug <channel>/<slot>, or parent_slug), 3 channel_id, 4 stream_lookup (stream_id, then slot_id), 5 the record's own lineage (clip_id, then vod_id), 6 media_lineage (media_object_id), 7 owner_subject, 8 legacy_map (legacy_ids). The first input in that order that resolves decides (resolved_by). Every other input that resolves must name the same channel, otherwise the answer is unresolved with reason conflict. An input whose source could not be asked makes the answer unresolved with reason source_unavailable, since it might have disagreed. An input that names nothing is reported not_found in checked and does not block the others. LINEAGE inside a record, first link that answers wins: a stream belongs to its owner, and a slot too; a VOD goes to its stream, then to its slot when the stream row is gone, then to the owner recorded on the VOD (legacy_metadata); a clip goes to its stream, then to its parent VOD's lineage (vod_parent), then to the channel recorded on the clip (legacy_metadata), and never to the clipper; a Media object goes to the record its legacy_ref names (media_lineage), then to its owner subject, then to the owner user recorded on it (legacy_metadata); a legacy:<app>:<kind>:<id> reference is read as that record (legacy_map). CONFIDENCE is the weakest hop: exact when the input itself names the channel, a stream or slot row, or the owner subject; derived when the answer came through a parent record or an owner recorded on a record; legacy_map when it needed a legacy id map (legacy_ids, a legacy media reference). A display name never resolves anything. Unresolved answers carry no channel.",
+ "type": "object",
+ "required": ["status"],
+ "additionalProperties": false,
+ "properties": {
+ "status": { "enum": ["resolved", "unresolved"] },
+ "reason": {
+ "enum": ["no_input", "display_name_only", "not_found", "conflict", "ambiguous", "source_unavailable"],
+ "description": "Why nothing resolved. no_input: the request named nothing. display_name_only: only a display name was given. not_found: no input names anything that exists. conflict: two inputs name different channels. ambiguous: an input names more than one channel (an owner subject linked to two accounts). source_unavailable: a record could not be read (Media down); retry later."
+ },
+ "detail": { "type": "string", "maxLength": 500 },
+ "channel": {
+ "type": "object", "required": ["id", "slug", "owner_subject"], "additionalProperties": false,
+ "properties": {
+ "id": { "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$", "description": "The channel's id at its owner (Live: channels.id)." },
+ "slug": { "type": "string", "pattern": "^[A-Za-z0-9_][A-Za-z0-9_.-]{0,63}$", "description": "The channel's current slug (Live: the username, /@<slug>). Slugs can change; id and owner_subject do not." },
+ "owner_subject": { "type": ["string", "null"], "pattern": "^usr_[0-9A-HJKMNP-TV-Z]{26}$", "description": "The owner's canonical subject, or null while the owner has not signed in through OpenVibe.Network since subjects were issued." },
+ "legacy_ids": {
+ "type": "object", "additionalProperties": false,
+ "description": "The owner's pre-subject ids, for callers that still key by them.",
+ "properties": {
+ "live_user_id": { "type": "integer", "minimum": 1 },
+ "network_user_id": { "type": "integer", "minimum": 1 }
+ }
+ }
+ }
+ },
+ "stream": {
+ "type": "object", "required": ["id"], "additionalProperties": false,
+ "properties": {
+ "id": { "type": ["string", "null"], "description": "The broadcast session, or null when only a slot was named." },
+ "slot_id": { "type": ["string", "null"] },
+ "slot_slug": { "type": ["string", "null"] },
+ "missing": { "type": "boolean", "description": "true when a record names this stream but the stream row no longer exists." }
+ }
+ },
+ "vod": {
+ "type": "object", "required": ["id"], "additionalProperties": false,
+ "properties": {
+ "id": { "type": "string" },
+ "stream_id": { "type": ["string", "null"] },
+ "slot_id": { "type": ["string", "null"] }
+ }
+ },
+ "clip": {
+ "type": "object", "required": ["id"], "additionalProperties": false,
+ "properties": {
+ "id": { "type": "string" },
+ "vod_id": { "type": ["string", "null"] },
+ "stream_id": { "type": ["string", "null"] }
+ }
+ },
+ "media_object": {
+ "type": "object", "required": ["id"], "additionalProperties": false,
+ "properties": {
+ "id": { "type": "string", "pattern": "^(med_[0-9A-HJKMNP-TV-Z]{26}|legacy:[a-z][a-z0-9-]{1,39}:(vod|clip|file|paste|thumbnail|avatar):[A-Za-z0-9._/-]{1,200})$" },
+ "kind": { "type": ["string", "null"] },
+ "legacy_ref": { "type": ["string", "null"] }
+ }
+ },
+ "resolved_by": {
+ "enum": ["slug", "parent_slug", "channel_id", "stream_id", "slot_id", "clip_id", "vod_id", "media_object_id", "owner_subject", "legacy_ids"],
+ "description": "The input that decided."
+ },
+ "rule": {
+ "enum": ["explicit_slug", "nested_slug", "channel_id", "stream_lookup", "vod_parent", "media_lineage", "owner_subject", "legacy_metadata", "legacy_map"],
+ "description": "The lineage rule that answered for the deciding input."
+ },
+ "confidence": { "enum": ["exact", "derived", "legacy_map"] },
+ "via": {
+ "type": "array", "maxItems": 32,
+ "items": { "type": "string", "pattern": "^[a-z_]+:[^\\s]{1,200}$" },
+ "description": "The hops the deciding input took, e.g. [\"clip:7\", \"vod:42\", \"slot:3\", \"user:17\"]."
+ },
+ "checked": {
+ "type": "array", "maxItems": 32,
+ "description": "What each input given came to.",
+ "items": {
+ "type": "object", "required": ["input", "outcome"], "additionalProperties": false,
+ "properties": {
+ "input": { "enum": ["slug", "parent_slug", "channel_id", "stream_id", "slot_id", "clip_id", "vod_id", "media_object_id", "owner_subject", "legacy_ids", "display_name"] },
+ "outcome": { "enum": ["decided", "agrees", "conflict", "not_found", "unavailable", "ambiguous", "ignored"] },
+ "channel_slug": { "type": "string", "description": "The channel this input named, when it named one." }
+ }
+ }
+ }
+ },
+ "if": { "properties": { "status": { "const": "resolved" } }, "required": ["status"] },
+ "then": { "required": ["channel", "resolved_by", "rule", "confidence"], "not": { "required": ["reason"] } },
+ "else": { "required": ["reason"], "not": { "anyOf": [{ "required": ["channel"] }, { "required": ["resolved_by"] }, { "required": ["confidence"] }] } }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,59 @@
@@ -0,0 +1,59 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://openvibe.network/contracts/lineage/resolve-request.v1.json",
+ "title": "LineageResolveRequest",
+ "description": "What a caller knows about a channel, stream, VOD, clip or Media object, sent to the canonical channel/owner resolver (roadmap §15.10, requirement D20): the body of POST /internal/lineage/resolve on OpenVibe.Live (capability live.lineage.resolve). GET takes the same fields as query parameters, with legacy_ids flattened to live_user_id and network_user_id. The answer is lineage.resolution@1, whose description has the precedence rules. Every input given is checked, not only the first. display_name is never a resolution input: a request that carries only a display name resolves nothing (reason display_name_only), and next to other inputs it is ignored.",
+ "type": "object",
+ "minProperties": 1,
+ "additionalProperties": false,
+ "properties": {
+ "slug": {
+ "type": "string", "pattern": "^@?[A-Za-z0-9_][A-Za-z0-9_.-]{0,63}(/[A-Za-z0-9_][A-Za-z0-9_.-]{0,63})?$",
+ "description": "Channel slug (Live: the username, as in /@<username>; the @ is optional), or a nested <channel>/<slot> naming a stream slot inside the channel by its slug or id. With parent_slug, a plain slug names the slot inside that parent."
+ },
+ "parent_slug": {
+ "type": "string", "pattern": "^@?[A-Za-z0-9_][A-Za-z0-9_.-]{0,63}$",
+ "description": "The channel slug when slug names a slot inside it ({ parent_slug: 'alice', slug: 'garage' } is 'alice/garage'). Alone, it names the channel."
+ },
+ "channel_id": {
+ "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$",
+ "description": "The channel's id at its owner (Live: channels.id)."
+ },
+ "stream_id": {
+ "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$",
+ "description": "A broadcast session (Live: streams.id)."
+ },
+ "slot_id": {
+ "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$",
+ "description": "A persistent stream slot (Live: managed_streams.id; OpenRe stream definitions refer to it as live:managed_stream:<id>)."
+ },
+ "vod_id": {
+ "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$",
+ "description": "A VOD (the Media v1 VOD id, the same id Live's VODs had before the Media split)."
+ },
+ "clip_id": {
+ "type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$",
+ "description": "A clip (the Media v1 clip id). A clip belongs to the clipped channel, never to the person who clipped it."
+ },
+ "media_object_id": {
+ "type": "string", "pattern": "^(med_[0-9A-HJKMNP-TV-Z]{26}|legacy:[a-z][a-z0-9-]{1,39}:(vod|clip|file|paste|thumbnail|avatar):[A-Za-z0-9._/-]{1,200})$",
+ "description": "A Media object: med_<ULID>, or a legacy:<app>:<kind>:<id> reference (the media_id forms of media.media-ref@1)."
+ },
+ "owner_subject": {
+ "type": "string", "pattern": "^usr_[0-9A-HJKMNP-TV-Z]{26}$",
+ "description": "The canonical subject that owns the channel (identity.subject-ref@1 user id)."
+ },
+ "legacy_ids": {
+ "type": "object", "minProperties": 1, "additionalProperties": false,
+ "description": "Pre-subject ids of the channel owner. Resolving through them is confidence legacy_map.",
+ "properties": {
+ "live_user_id": { "type": "integer", "minimum": 1, "description": "Live's local user id (users.id)." },
+ "network_user_id": { "type": "integer", "minimum": 1, "description": "OpenVibe.Network's integer user id, mapped to a Live account by the account link." }
+ }
+ },
+ "display_name": {
+ "type": "string", "maxLength": 200,
+ "description": "A label the caller holds. Accepted so the answer can say display_name_only; never used to resolve anything, because a display name is not an identity and is never sufficient to establish ownership."
+ }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,7 @@
@@ -0,0 +1,7 @@
+{
+ "status": "resolved",
+ "channel": { "id": "12", "slug": "alice", "owner_subject": "17" },
+ "resolved_by": "slug",
+ "rule": "explicit_slug",
+ "confidence": "exact"
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{ "status": "resolved", "resolved_by": "slug", "rule": "explicit_slug", "confidence": "exact" }
new file mode 100644
new file mode 100644
@@ -0,0 +1,7 @@
@@ -0,0 +1,7 @@
+{
+ "status": "resolved",
+ "channel": { "id": "12", "slug": "alice", "owner_subject": null },
+ "resolved_by": "display_name",
+ "rule": "explicit_slug",
+ "confidence": "guessed"
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{ "status": "unresolved", "reason": "conflict", "channel": { "id": "12", "slug": "alice", "owner_subject": null } }
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{ "status": "unresolved" }
new file mode 100644
new file mode 100644
@@ -0,0 +1,12 @@
@@ -0,0 +1,12 @@
+{
+ "status": "resolved",
+ "channel": { "id": "12", "slug": "alice", "owner_subject": null, "legacy_ids": { "live_user_id": 17 } },
+ "stream": { "id": "9", "slot_id": "3", "slot_slug": "garage-cam" },
+ "vod": { "id": "42", "stream_id": "9", "slot_id": "3" },
+ "clip": { "id": "7", "vod_id": "42", "stream_id": null },
+ "resolved_by": "clip_id",
+ "rule": "vod_parent",
+ "confidence": "derived",
+ "via": ["clip:7", "vod:42", "stream:9", "user:17"],
+ "checked": [{ "input": "clip_id", "outcome": "decided", "channel_slug": "alice" }]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
+{
+ "status": "resolved",
+ "channel": { "id": "12", "slug": "alice", "owner_subject": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ" },
+ "stream": { "id": "9", "slot_id": "3", "slot_slug": "garage-cam", "missing": true },
+ "vod": { "id": "42", "stream_id": "9", "slot_id": "3" },
+ "resolved_by": "vod_id",
+ "rule": "stream_lookup",
+ "confidence": "derived",
+ "via": ["vod:42", "slot:3", "user:17"]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
+{
+ "status": "resolved",
+ "channel": { "id": "12", "slug": "alice", "owner_subject": null },
+ "vod": { "id": "42", "stream_id": "9", "slot_id": null },
+ "media_object": { "id": "legacy:live:vod:42", "kind": "vod", "legacy_ref": "legacy:live:vod:42" },
+ "resolved_by": "media_object_id",
+ "rule": "legacy_map",
+ "confidence": "legacy_map",
+ "via": ["media:legacy:live:vod:42", "vod:42", "stream:9", "user:17"]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,9 @@
@@ -0,0 +1,9 @@
+{
+ "status": "resolved",
+ "channel": { "id": "12", "slug": "alice", "owner_subject": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ", "legacy_ids": { "live_user_id": 17, "network_user_id": 57 } },
+ "resolved_by": "slug",
+ "rule": "explicit_slug",
+ "confidence": "exact",
+ "via": ["user:17"],
+ "checked": [{ "input": "slug", "outcome": "decided", "channel_slug": "alice" }]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,8 @@
@@ -0,0 +1,8 @@
+{
+ "status": "unresolved",
+ "reason": "conflict",
+ "checked": [
+ { "input": "slug", "outcome": "decided", "channel_slug": "mallory" },
+ { "input": "vod_id", "outcome": "conflict", "channel_slug": "alice" }
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,6 @@
@@ -0,0 +1,6 @@
+{
+ "status": "unresolved",
+ "reason": "display_name_only",
+ "detail": "a display name is never sufficient to establish a channel or its owner",
+ "checked": [{ "input": "display_name", "outcome": "ignored" }]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{ "slug": "Alice Smith" }
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{ "legacy_ids": {} }
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{}
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{ "owner_subject": "42" }
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{ "vod_id": 42 }
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{ "channel_name": "Alice" }
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{ "slug": "alice", "clip_id": "7" }
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{ "display_name": "Alice ✨" }
new file mode 100644
new file mode 100644
@@ -0,0 +1,12 @@
@@ -0,0 +1,12 @@
+{
+ "slug": "alice",
+ "channel_id": "12",
+ "stream_id": "9",
+ "slot_id": "3",
+ "vod_id": "42",
+ "clip_id": "7",
+ "media_object_id": "med_01JAB2C3D4E5F6G7H8J9K0MNPQ",
+ "owner_subject": "usr_01JAB2C3D4E5F6G7H8J9K0MNPQ",
+ "legacy_ids": { "live_user_id": 17, "network_user_id": 57 },
+ "display_name": "Alice"
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{ "media_object_id": "legacy:live:vod:42" }
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{ "slug": "alice/garage-cam" }
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{ "parent_slug": "alice", "slug": "garage-cam" }
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -0,0 +1 @@
+{ "slug": "@alice" }
@@ -708,6 +708,415 @@
@@ -708,6 +708,415 @@
}
}
}
}
},
},
+ "lineage.resolve-request": {
+ "version": "1.0.0",
+ "owner": "live",
+ "status": "active",
+ "schema": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://openvibe.network/contracts/lineage/resolve-request.v1.json",
+ "title": "LineageResolveRequest",
+ "description": "What a caller knows about a channel, stream, VOD, clip or Media object, sent to the canonical channel/owner resolver (roadmap §15.10, requirement D20): the body of POST /internal/lineage/resolve on OpenVibe.Live (capability live.lineage.resolve). GET takes the same fields as query parameters, with legacy_ids flattened to live_user_id and network_user_id. The answer is lineage.resolution@1, whose description has the precedence rules. Every input given is checked, not only the first. display_name is never a resolution input: a request that carries only a display name resolves nothing (reason display_name_only), and next to other inputs it is ignored.",
+ "type": "object",
+ "minProperties": 1,
+ "additionalProperties": false,
+ "properties": {
+ "slug": {
+ "type": "string",
+ "pattern": "^@?[A-Za-z0-9_][A-Za-z0-9_.-]{0,63}(/[A-Za-z0-9_][A-Za-z0-9_.-]{0,63})?$",
+ "description": "Channel slug (Live: the username, as in /@<username>; the @ is optional), or a nested <channel>/<slot> naming a stream slot inside the channel by its slug or id. With parent_slug, a plain slug names the slot inside that parent."
+ },
+ "parent_slug": {
+ "type": "string",
+ "pattern": "^@?[A-Za-z0-9_][A-Za-z0-9_.-]{0,63}$",
+ "description": "The channel slug when slug names a slot inside it ({ parent_slug: 'alice', slug: 'garage' } is 'alice/garage'). Alone, it names the channel."
+ },
+ "channel_id": {
+ "type": "string",
+ "pattern": "^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$",
+ "description": "The channel's id at its owner (Live: channels.id)."
+ },
+ "stream_id": {
+ "type": "string",
+ "pattern": "^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$",
+ "description": "A broadcast session (Live: streams.id)."
+ },
+ "slot_id": {
+ "type": "string",
+ "pattern": "^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$",
+ "description": "A persistent stream slot (Live: managed_streams.id; OpenRe stream definitions refer to it as live:managed_stream:<id>)."
+ },
+ "vod_id": {
+ "type": "string",
+ "pattern": "^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$",
+ "description": "A VOD (the Media v1 VOD id, the same id Live's VODs had before the Media split)."
+ },
+ "clip_id": {
+ "type": "string",
+ "pattern": "^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$",
+ "description": "A clip (the Media v1 clip id). A clip belongs to the clipped channel, never to the person who clipped it."
+ },
+ "media_object_id": {
+ "type": "string",
+ "pattern": "^(med_[0-9A-HJKMNP-TV-Z]{26}|legacy:[a-z][a-z0-9-]{1,39}:(vod|clip|file|paste|thumbnail|avatar):[A-Za-z0-9._/-]{1,200})$",
+ "description": "A Media object: med_<ULID>, or a legacy:<app>:<kind>:<id> reference (the media_id forms of media.media-ref@1)."
+ },
+ "owner_subject": {
+ "type": "string",
+ "pattern": "^usr_[0-9A-HJKMNP-TV-Z]{26}$",
+ "description": "The canonical subject that owns the channel (identity.subject-ref@1 user id)."
+ },
+ "legacy_ids": {
+ "type": "object",
+ "minProperties": 1,
+ "additionalProperties": false,
+ "description": "Pre-subject ids of the channel owner. Resolving through them is confidence legacy_map.",
+ "properties": {
+ "live_user_id": {
+ "type": "integer",
+ "minimum": 1,
+ "description": "Live's local user id (users.id)."
+ },
+ "network_user_id": {
+ "type": "integer",
+ "minimum": 1,
+ "description": "OpenVibe.Network's integer user id, mapped to a Live account by the account link."
+ }
+ }
+ },
+ "display_name": {
+ "type": "string",
+ "maxLength": 200,
+ "description": "A label the caller holds. Accepted so the answer can say display_name_only; never used to resolve anything, because a display name is not an identity and is never sufficient to establish ownership."
+ }
+ }
+ }
+ },
+ "lineage.resolution": {
+ "version": "1.0.0",
+ "owner": "live",
+ "status": "active",
+ "schema": {
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://openvibe.network/contracts/lineage/resolution.v1.json",
+ "title": "LineageResolution",
+ "description": "The canonical channel/owner resolver's answer to lineage.resolve-request@1 (roadmap §15.10, requirement D20; capability live.lineage.resolve). One resolver serves channel, stream, VOD, clip, Pulse and creator-UI callers. PRECEDENCE between inputs (the roadmap's order): 1 explicit_slug (slug), 2 nested_slug (slug <channel>/<slot>, or parent_slug), 3 channel_id, 4 stream_lookup (stream_id, then slot_id), 5 the record's own lineage (clip_id, then vod_id), 6 media_lineage (media_object_id), 7 owner_subject, 8 legacy_map (legacy_ids). The first input in that order that resolves decides (resolved_by). Every other input that resolves must name the same channel, otherwise the answer is unresolved with reason conflict. An input whose source could not be asked makes the answer unresolved with reason source_unavailable, since it might have disagreed. An input that names nothing is reported not_found in checked and does not block the others. LINEAGE inside a record, first link that answers wins: a stream belongs to its owner, and a slot too; a VOD goes to its stream, then to its slot when the stream row is gone, then to the owner recorded on the VOD (legacy_metadata); a clip goes to its stream, then to its parent VOD's lineage (vod_parent), then to the channel recorded on the clip (legacy_metadata), and never to the clipper; a Media object goes to the record its legacy_ref names (media_lineage), then to its owner subject, then to the owner user recorded on it (legacy_metadata); a legacy:<app>:<kind>:<id> reference is read as that record (legacy_map). CONFIDENCE is the weakest hop: exact when the input itself names the channel, a stream or slot row, or the owner subject; derived when the answer came through a parent record or an owner recorded on a record; legacy_map when it needed a legacy id map (legacy_ids, a legacy media reference). A display name never resolves anything. Unresolved answers carry no channel.",
+ "type": "object",
+ "required": [
+ "status"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "status": {
+ "enum": [
+ "resolved",
+ "unresolved"
+ ]
+ },
+ "reason": {
+ "enum": [
+ "no_input",
+ "display_name_only",
+ "not_found",
+ "conflict",
+ "ambiguous",
+ "source_unavailable"
+ ],
+ "description": "Why nothing resolved. no_input: the request named nothing. display_name_only: only a display name was given. not_found: no input names anything that exists. conflict: two inputs name different channels. ambiguous: an input names more than one channel (an owner subject linked to two accounts). source_unavailable: a record could not be read (Media down); retry later."
+ },
+ "detail": {
+ "type": "string",
+ "maxLength": 500
+ },
+ "channel": {
+ "type": "object",
+ "required": [
+ "id",
+ "slug",
+ "owner_subject"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "id": {
+ "type": "string",
+ "pattern": "^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$",
+ "description": "The channel's id at its owner (Live: channels.id)."
+ },
+ "slug": {
+ "type": "string",
+ "pattern": "^[A-Za-z0-9_][A-Za-z0-9_.-]{0,63}$",
+ "description": "The channel's current slug (Live: the username, /@<slug>). Slugs can change; id and owner_subject do not."
+ },
+ "owner_subject": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "pattern": "^usr_[0-9A-HJKMNP-TV-Z]{26}$",
+ "description": "The owner's canonical subject, or null while the owner has not signed in through OpenVibe.Network since subjects were issued."
+ },
+ "legacy_ids": {
+ "type": "object",
+ "additionalProperties": false,
+ "description": "The owner's pre-subject ids, for callers that still key by them.",
+ "properties": {
+ "live_user_id": {
+ "type": "integer",
+ "minimum": 1
+ },
+ "network_user_id": {
+ "type": "integer",
+ "minimum": 1
+ }
+ }
+ }
+ }
+ },
+ "stream": {
+ "type": "object",
+ "required": [
+ "id"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "id": {
+ "type": [
+ "string",
+ "null"
+ ],
+ "description": "The broadcast session, or null when only a slot was named."
+ },
+ "slot_id": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "slot_slug": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "missing": {
+ "type": "boolean",
+ "description": "true when a record names this stream but the stream row no longer exists."
+ }
+ }
+ },
+ "vod": {
+ "type": "object",
+ "required": [
+ "id"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "stream_id": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "slot_id": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ }
+ },
+ "clip": {
+ "type": "object",
+ "required": [
+ "id"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "vod_id": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "stream_id": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ }
+ },
+ "media_object": {
+ "type": "object",
+ "required": [
+ "id"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "id": {
+ "type": "string",
+ "pattern": "^(med_[0-9A-HJKMNP-TV-Z]{26}|legacy:[a-z][a-z0-9-]{1,39}:(vod|clip|file|paste|thumbnail|avatar):[A-Za-z0-9._/-]{1,200})$"
+ },
+ "kind": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "legacy_ref": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ }
+ },
+ "resolved_by": {
+ "enum": [
+ "slug",
+ "parent_slug",
+ "channel_id",
+ "stream_id",
+ "slot_id",
+ "clip_id",
+ "vod_id",
+ "media_object_id",
+ "owner_subject",
+ "legacy_ids"
+ ],
+ "description": "The input that decided."
+ },
+ "rule": {
+ "enum": [
+ "explicit_slug",
+ "nested_slug",
+ "channel_id",
+ "stream_lookup",
+ "vod_parent",
+ "media_lineage",
+ "owner_subject",
+ "legacy_metadata",
+ "legacy_map"
+ ],
+ "description": "The lineage rule that answered for the deciding input."
+ },
+ "confidence": {
+ "enum": [
+ "exact",
+ "derived",
+ "legacy_map"
+ ]
+ },
+ "via": {
+ "type": "array",
+ "maxItems": 32,
+ "items": {
+ "type": "string",
+ "pattern": "^[a-z_]+:[^\\s]{1,200}$"
+ },
+ "description": "The hops the deciding input took, e.g. [\"clip:7\", \"vod:42\", \"slot:3\", \"user:17\"]."
+ },
+ "checked": {
+ "type": "array",
+ "maxItems": 32,
+ "description": "What each input given came to.",
+ "items": {
+ "type": "object",
+ "required": [
+ "input",
+ "outcome"
+ ],
+ "additionalProperties": false,
+ "properties": {
+ "input": {
+ "enum": [
+ "slug",
+ "parent_slug",
+ "channel_id",
+ "stream_id",
+ "slot_id",
+ "clip_id",
+ "vod_id",
+ "media_object_id",
+ "owner_subject",
+ "legacy_ids",
+ "display_name"
+ ]
+ },
+ "outcome": {
+ "enum": [
+ "decided",
+ "agrees",
+ "conflict",
+ "not_found",
+ "unavailable",
+ "ambiguous",
+ "ignored"
+ ]
+ },
+ "channel_slug": {
+ "type": "string",
+ "description": "The channel this input named, when it named one."
+ }
+ }
+ }
+ }
+ },
+ "if": {
+ "properties": {
+ "status": {
+ "const": "resolved"
+ }
+ },
+ "required": [
+ "status"
+ ]
+ },
+ "then": {
+ "required": [
+ "channel",
+ "resolved_by",
+ "rule",
+ "confidence"
+ ],
+ "not": {
+ "required": [
+ "reason"
+ ]
+ }
+ },
+ "else": {
+ "required": [
+ "reason"
+ ],
+ "not": {
+ "anyOf": [
+ {
+ "required": [
+ "channel"
+ ]
+ },
+ {
+ "required": [
+ "resolved_by"
+ ]
+ },
+ {
+ "required": [
+ "confidence"
+ ]
+ }
+ ]
+ }
+ }
+ }
+ },
"modules.namespace": {
"modules.namespace": {
"version": "1.0.0",
"version": "1.0.0",
"owner": "network",
"owner": "network",
@@ -208,6 +208,185 @@ export interface MediaRef {
@@ -208,6 +208,185 @@ export interface MediaRef {
variant?: string;
variant?: string;
}
}
+/** [email protected] (owner: live) */
+/**
+ * What a caller knows about a channel, stream, VOD, clip or Media object, sent to the canonical channel/owner resolver (roadmap §15.10, requirement D20): the body of POST /internal/lineage/resolve on OpenVibe.Live (capability live.lineage.resolve). GET takes the same fields as query parameters, with legacy_ids flattened to live_user_id and network_user_id. The answer is lineage.resolution@1, whose description has the precedence rules. Every input given is checked, not only the first. display_name is never a resolution input: a request that carries only a display name resolves nothing (reason display_name_only), and next to other inputs it is ignored.
+ */
+export interface LineageResolveRequest {
+ /**
+ * Channel slug (Live: the username, as in /@<username>; the @ is optional), or a nested <channel>/<slot> naming a stream slot inside the channel by its slug or id. With parent_slug, a plain slug names the slot inside that parent.
+ */
+ slug?: string;
+ /**
+ * The channel slug when slug names a slot inside it ({ parent_slug: 'alice', slug: 'garage' } is 'alice/garage'). Alone, it names the channel.
+ */
+ parent_slug?: string;
+ /**
+ * The channel's id at its owner (Live: channels.id).
+ */
+ channel_id?: string;
+ /**
+ * A broadcast session (Live: streams.id).
+ */
+ stream_id?: string;
+ /**
+ * A persistent stream slot (Live: managed_streams.id; OpenRe stream definitions refer to it as live:managed_stream:<id>).
+ */
+ slot_id?: string;
+ /**
+ * A VOD (the Media v1 VOD id, the same id Live's VODs had before the Media split).
+ */
+ vod_id?: string;
+ /**
+ * A clip (the Media v1 clip id). A clip belongs to the clipped channel, never to the person who clipped it.
+ */
+ clip_id?: string;
+ /**
+ * A Media object: med_<ULID>, or a legacy:<app>:<kind>:<id> reference (the media_id forms of media.media-ref@1).
+ */
+ media_object_id?: string;
+ /**
+ * The canonical subject that owns the channel (identity.subject-ref@1 user id).
+ */
+ owner_subject?: string;
+ /**
+ * Pre-subject ids of the channel owner. Resolving through them is confidence legacy_map.
+ */
+ legacy_ids?: {
+ /**
+ * Live's local user id (users.id).
+ */
+ live_user_id?: number;
+ /**
+ * OpenVibe.Network's integer user id, mapped to a Live account by the account link.
+ */
+ network_user_id?: number;
+ };
+ /**
+ * A label the caller holds. Accepted so the answer can say display_name_only; never used to resolve anything, because a display name is not an identity and is never sufficient to establish ownership.
+ */
+ display_name?: string;
+}
+
+/** [email protected] (owner: live) */
+/**
+ * The canonical channel/owner resolver's answer to lineage.resolve-request@1 (roadmap §15.10, requirement D20; capability live.lineage.resolve). One resolver serves channel, stream, VOD, clip, Pulse and creator-UI callers. PRECEDENCE between inputs (the roadmap's order): 1 explicit_slug (slug), 2 nested_slug (slug <channel>/<slot>, or parent_slug), 3 channel_id, 4 stream_lookup (stream_id, then slot_id), 5 the record's own lineage (clip_id, then vod_id), 6 media_lineage (media_object_id), 7 owner_subject, 8 legacy_map (legacy_ids). The first input in that order that resolves decides (resolved_by). Every other input that resolves must name the same channel, otherwise the answer is unresolved with reason conflict. An input whose source could not be asked makes the answer unresolved with reason source_unavailable, since it might have disagreed. An input that names nothing is reported not_found in checked and does not block the others. LINEAGE inside a record, first link that answers wins: a stream belongs to its owner, and a slot too; a VOD goes to its stream, then to its slot when the stream row is gone, then to the owner recorded on the VOD (legacy_metadata); a clip goes to its stream, then to its parent VOD's lineage (vod_parent), then to the channel recorded on the clip (legacy_metadata), and never to the clipper; a Media object goes to the record its legacy_ref names (media_lineage), then to its owner subject, then to the owner user recorded on it (legacy_metadata); a legacy:<app>:<kind>:<id> reference is read as that record (legacy_map). CONFIDENCE is the weakest hop: exact when the input itself names the channel, a stream or slot row, or the owner subject; derived when the answer came through a parent record or an owner recorded on a record; legacy_map when it needed a legacy id map (legacy_ids, a legacy media reference). A display name never resolves anything. Unresolved answers carry no channel.
+ */
+export interface LineageResolution {
+ status: "resolved" | "unresolved";
+ /**
+ * Why nothing resolved. no_input: the request named nothing. display_name_only: only a display name was given. not_found: no input names anything that exists. conflict: two inputs name different channels. ambiguous: an input names more than one channel (an owner subject linked to two accounts). source_unavailable: a record could not be read (Media down); retry later.
+ */
+ reason?: "no_input" | "display_name_only" | "not_found" | "conflict" | "ambiguous" | "source_unavailable";
+ detail?: string;
+ channel?: {
+ /**
+ * The channel's id at its owner (Live: channels.id).
+ */
+ id: string;
+ /**
+ * The channel's current slug (Live: the username, /@<slug>). Slugs can change; id and owner_subject do not.
+ */
+ slug: string;
+ /**
+ * The owner's canonical subject, or null while the owner has not signed in through OpenVibe.Network since subjects were issued.
+ */
+ owner_subject: string | null;
+ /**
+ * The owner's pre-subject ids, for callers that still key by them.
+ */
+ legacy_ids?: {
+ live_user_id?: number;
+ network_user_id?: number;
+ };
+ };
+ stream?: {
+ /**
+ * The broadcast session, or null when only a slot was named.
+ */
+ id: string | null;
+ slot_id?: string | null;
+ slot_slug?: string | null;
+ /**
+ * true when a record names this stream but the stream row no longer exists.
+ */
+ missing?: boolean;
+ };
+ vod?: {
+ id: string;
+ stream_id?: string | null;
+ slot_id?: string | null;
+ };
+ clip?: {
+ id: string;
+ vod_id?: string | null;
+ stream_id?: string | null;
+ };
+ media_object?: {
+ id: string;
+ kind?: string | null;
+ legacy_ref?: string | null;
+ };
+ /**
+ * The input that decided.
+ */
+ resolved_by?:
+ | "slug"
+ | "parent_slug"
+ | "channel_id"
+ | "stream_id"
+ | "slot_id"
+ | "clip_id"
+ | "vod_id"
+ | "media_object_id"
+ | "owner_subject"
+ | "legacy_ids";
+ /**
+ * The lineage rule that answered for the deciding input.
+ */
+ rule?:
+ | "explicit_slug"
+ | "nested_slug"
+ | "channel_id"
+ | "stream_lookup"
+ | "vod_parent"
+ | "media_lineage"
+ | "owner_subject"
+ | "legacy_metadata"
+ | "legacy_map";
+ confidence?: "exact" | "derived" | "legacy_map";
+ /**
+ * The hops the deciding input took, e.g. ["clip:7", "vod:42", "slot:3", "user:17"].
+ *
+ * @maxItems 32
+ */
+ via?: string[];
+ /**
+ * What each input given came to.
+ *
+ * @maxItems 32
+ */
+ checked?: {
+ input:
+ | "slug"
+ | "parent_slug"
+ | "channel_id"
+ | "stream_id"
+ | "slot_id"
+ | "clip_id"
+ | "vod_id"
+ | "media_object_id"
+ | "owner_subject"
+ | "legacy_ids"
+ | "display_name";
+ outcome: "decided" | "agrees" | "conflict" | "not_found" | "unavailable" | "ambiguous" | "ignored";
+ /**
+ * The channel this input named, when it named one.
+ */
+ channel_slug?: string;
+ }[];
+}
+
/** [email protected] (owner: network) */
/** [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).
new file mode 100644
new file mode 100644
@@ -0,0 +1,22 @@
@@ -0,0 +1,22 @@
+{
+ "id": "live.lineage.resolve",
+ "version": "1.0",
+ "owner": "live",
+ "status": "active",
+ "visibility": "internal",
+ "description": "Resolve the canonical channel and owner behind a slug, nested slug, channel, stream, slot, VOD, clip, Media object, owner subject or legacy id, with the stream/VOD/clip lineage and how it was decided (roadmap §15.10, D20). A display name alone resolves nothing. Loopback only.",
+ "inputSchema": "lineage.resolve-request@1",
+ "outputSchema": "lineage.resolution@1",
+ "permissions": [
+ "live:lineage"
+ ],
+ "resourceConstraints": [
+ "none"
+ ],
+ "quotaClass": "standard",
+ "events": [],
+ "implementedBy": [
+ "GET /internal/lineage/resolve",
+ "POST /internal/lineage/resolve"
+ ]
+}
@@ -19,6 +19,7 @@
@@ -19,6 +19,7 @@
"live.chat_effects.write",
"live.chat_effects.write",
"live.chat_mirror.write",
"live.chat_mirror.write",
"live.follower.read",
"live.follower.read",
+ "live.lineage.resolve",
"live.tips_delivery.write"
"live.tips_delivery.write"
],
],
"eventsProduced": [
"eventsProduced": [
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-contracts",
"name": "openvibe-contracts",
- "version": "0.31.0",
+ "version": "0.32.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-contracts",
"name": "openvibe-contracts",
- "version": "0.31.0",
+ "version": "0.32.0",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-contracts",
"name": "openvibe-contracts",
- "version": "0.31.0",
+ "version": "0.32.0",
"description": "Machine-readable contracts for the OpenVibe network: schemas, manifests, ids, error envelope, capability checks.",
"description": "Machine-readable contracts for the OpenVibe network: schemas, manifests, ids, error envelope, capability checks.",
"main": "index.js",
"main": "index.js",
"types": "generated/typescript/index.d.ts",
"types": "generated/typescript/index.d.ts",
@@ -119,6 +119,31 @@ for (const id of ['events.event.publish', 'events.event.read', 'events.subscript
@@ -119,6 +119,31 @@ for (const id of ['events.event.publish', 'events.event.read', 'events.subscript
ok(!contracts.validate('events.tombstone-payload@1', { redacted: false, redacted_at: tombstone.redacted_at, redacted_by: 'x' }).valid, 'a tombstone is redacted: true');
ok(!contracts.validate('events.tombstone-payload@1', { redacted: false, redacted_at: tombstone.redacted_at, redacted_by: 'x' }).valid, 'a tombstone is redacted: true');
}
}
+// ── Channel/owner lineage resolver (v0.32, roadmap D20) ─────────────────
+// A display name never resolves anything, every resolved answer says which input decided and how
+// sure it is, and an unresolved answer carries no channel.
+{
+ const cap = capabilities.get('live.lineage.resolve');
+ ok(cap && cap.owner === 'live' && cap.visibility === 'internal' && cap.inputSchema === 'lineage.resolve-request@1' && cap.outputSchema === 'lineage.resolution@1', 'live.lineage.resolve is Live-internal and names the lineage contracts');
+ const Q = (v) => contracts.validate('lineage.resolve-request@1', v).valid;
+ const A = (v) => contracts.validate('lineage.resolution@1', v).valid;
+ const channel = { id: '12', slug: 'alice', owner_subject: null };
+ const resolved = { status: 'resolved', channel, resolved_by: 'slug', rule: 'explicit_slug', confidence: 'exact' };
+ ok(Q({ display_name: 'Alice' }), 'a request holding only a display name is well formed, so it gets an explicit answer');
+ ok(!Q({ slug: 'Alice Smith' }) && !Q({ slug: 'alice/garage/extra' }), 'a display name is not a slug; nesting is one level');
+ ok(A({ status: 'unresolved', reason: 'display_name_only' }), 'display_name_only is an unresolved reason');
+ ok(!A({ ...resolved, resolved_by: 'display_name' }), 'a display name can never be what resolved');
+ for (const confidence of ['exact', 'derived', 'legacy_map']) ok(A({ ...resolved, confidence }), `confidence ${confidence}`);
+ ok(!A({ ...resolved, reason: 'conflict' }), 'a resolved answer has no reason');
+ for (const k of ['channel', 'resolved_by', 'rule', 'confidence']) ok(!A({ ...resolved, [k]: undefined }), `a resolved answer needs ${k}`);
+ for (const reason of ['no_input', 'display_name_only', 'not_found', 'conflict', 'ambiguous', 'source_unavailable']) ok(A({ status: 'unresolved', reason }), `unresolved: ${reason}`);
+ ok(!A({ status: 'unresolved', reason: 'not_found', resolved_by: 'slug' }) && !A({ status: 'unresolved', reason: 'conflict', confidence: 'exact' }), 'an unresolved answer names no decider');
+ const inputs = Object.keys(contracts.schema('lineage.resolve-request').properties).filter(k => k !== 'display_name').sort();
+ const res = contracts.schema('lineage.resolution').properties;
+ ok(JSON.stringify([...res.resolved_by.enum].sort()) === JSON.stringify(inputs), 'resolved_by names exactly the request inputs, never display_name');
+ ok(JSON.stringify([...res.checked.items.properties.input.enum].sort()) === JSON.stringify([...inputs, 'display_name'].sort()), 'checked reports every request field, display_name included');
+}
+
// ── Ids ──────────────────────────────────────────────────────────────────
// ── Ids ──────────────────────────────────────────────────────────────────
for (const kind of ['user', 'guest', 'app', 'mod']) {
for (const kind of ['user', 'guest', 'app', 'mod']) {
const id = ids.newId(kind);
const id = ids.newId(kind);