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

OP OpenVibers committed 23 Sept 2026 · 70fd5a04ea · 36 files, +949 −4

The request names 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. The answer is resolved, with the canonical channel (id, slug, owner_subject, legacy_ids), the stream/vod/clip/media_object the inputs reached, resolved_by, rule, confidence (exact|derived|legacy_map), via and checked; or 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; every other one that resolves must name the same channel or the answer is conflict; an unreadable source makes it source_unavailable. Inside a record the first link that answers wins: clip -> its stream -> its VOD's lineage -> the channel recorded on it (never the clipper); VOD -> its stream -> its slot -> the owner recorded on it; Media object -> the record its legacy_ref names -> its owner subject -> its owner user.

live.lineage.resolve (Live, internal): GET|POST /internal/lineage/resolve. Fixtures for both contracts; test/run.js checks that a display name can never be what resolved, that resolved answers name a decider and unresolved ones a reason and no channel, and that resolved_by and checked cover exactly the request's inputs. Array bounds are 32 so the generated types stay plain arrays. README gains the precedence table; the capability count is 172.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 08945547..35c60bdf 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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,
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
Additive: `compat.js` reports no breaking change against v0.30.2.
ddiff --git a/README.md b/README.md
iindex 1d899f7e..77feb76d 100644
---- a/README.md
++++ b/README.md
@@@ -60,7 +60,7 @@ TypeScript types: `generated/typescript/index.d.ts` (`"types"` in package.json).
| `events.event-envelope` | events | durable event shape for OpenVibe.Events (Wave 3) |
| `media.media-ref` | media | `med_<ULID>` (Wave 4) or transitional `legacy:<app>:<kind>:<id>` |
--Manifests: `manifests/services/` (30 services as of v0.30.0: 23 `alpha`, 1 `beta`, 5 `stable` and 1 `placeholder`, realtime; SDK and Shared are released libraries and Examples a repository with CI, none of them a runtime), `manifests/capabilities/` (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_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.
++## 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.
ddiff --git a/contracts/catalog.json b/contracts/catalog.json
iindex 639572ca..c5363566 100644
---- a/contracts/catalog.json
++++ b/contracts/catalog.json
@@@ -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",
ddiff --git a/contracts/lineage/resolution.v1.json b/contracts/lineage/resolution.v1.json
nnew file mode 100644
iindex 00000000..917567d4
---- /dev/null
++++ b/contracts/lineage/resolution.v1.json
@@@ -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"] }] } }
++}
ddiff --git a/contracts/lineage/resolve-request.v1.json b/contracts/lineage/resolve-request.v1.json
nnew file mode 100644
iindex 00000000..cc68a6b0
---- /dev/null
++++ b/contracts/lineage/resolve-request.v1.json
@@@ -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."
++ }
++ }
++}
ddiff --git a/fixtures/lineage.resolution/invalid/local-owner-subject.json b/fixtures/lineage.resolution/invalid/local-owner-subject.json
nnew file mode 100644
iindex 00000000..bd48715f
---- /dev/null
++++ b/fixtures/lineage.resolution/invalid/local-owner-subject.json
@@@ -0,0 +1,7 @@
++{
++ "status": "resolved",
++ "channel": { "id": "12", "slug": "alice", "owner_subject": "17" },
++ "resolved_by": "slug",
++ "rule": "explicit_slug",
++ "confidence": "exact"
++}
ddiff --git a/fixtures/lineage.resolution/invalid/resolved-without-channel.json b/fixtures/lineage.resolution/invalid/resolved-without-channel.json
nnew file mode 100644
iindex 00000000..630864b6
---- /dev/null
++++ b/fixtures/lineage.resolution/invalid/resolved-without-channel.json
@@@ -0,0 +1 @@
++{ "status": "resolved", "resolved_by": "slug", "rule": "explicit_slug", "confidence": "exact" }
ddiff --git a/fixtures/lineage.resolution/invalid/unknown-confidence.json b/fixtures/lineage.resolution/invalid/unknown-confidence.json
nnew file mode 100644
iindex 00000000..450d5f0f
---- /dev/null
++++ b/fixtures/lineage.resolution/invalid/unknown-confidence.json
@@@ -0,0 +1,7 @@
++{
++ "status": "resolved",
++ "channel": { "id": "12", "slug": "alice", "owner_subject": null },
++ "resolved_by": "display_name",
++ "rule": "explicit_slug",
++ "confidence": "guessed"
++}
ddiff --git a/fixtures/lineage.resolution/invalid/unresolved-with-channel.json b/fixtures/lineage.resolution/invalid/unresolved-with-channel.json
nnew file mode 100644
iindex 00000000..2455a64f
---- /dev/null
++++ b/fixtures/lineage.resolution/invalid/unresolved-with-channel.json
@@@ -0,0 +1 @@
++{ "status": "unresolved", "reason": "conflict", "channel": { "id": "12", "slug": "alice", "owner_subject": null } }
ddiff --git a/fixtures/lineage.resolution/invalid/unresolved-without-reason.json b/fixtures/lineage.resolution/invalid/unresolved-without-reason.json
nnew file mode 100644
iindex 00000000..5ec7deba
---- /dev/null
++++ b/fixtures/lineage.resolution/invalid/unresolved-without-reason.json
@@@ -0,0 +1 @@
++{ "status": "unresolved" }
ddiff --git a/fixtures/lineage.resolution/valid/clip-vod-stream-channel.json b/fixtures/lineage.resolution/valid/clip-vod-stream-channel.json
nnew file mode 100644
iindex 00000000..780452c7
---- /dev/null
++++ b/fixtures/lineage.resolution/valid/clip-vod-stream-channel.json
@@@ -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" }]
++}
ddiff --git a/fixtures/lineage.resolution/valid/deleted-stream-surviving-vod.json b/fixtures/lineage.resolution/valid/deleted-stream-surviving-vod.json
nnew file mode 100644
iindex 00000000..48ec0aee
---- /dev/null
++++ b/fixtures/lineage.resolution/valid/deleted-stream-surviving-vod.json
@@@ -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"]
++}
ddiff --git a/fixtures/lineage.resolution/valid/legacy-media-ref.json b/fixtures/lineage.resolution/valid/legacy-media-ref.json
nnew file mode 100644
iindex 00000000..97237a81
---- /dev/null
++++ b/fixtures/lineage.resolution/valid/legacy-media-ref.json
@@@ -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"]
++}
ddiff --git a/fixtures/lineage.resolution/valid/slug.json b/fixtures/lineage.resolution/valid/slug.json
nnew file mode 100644
iindex 00000000..89003f76
---- /dev/null
++++ b/fixtures/lineage.resolution/valid/slug.json
@@@ -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" }]
++}
ddiff --git a/fixtures/lineage.resolution/valid/unresolved-conflict.json b/fixtures/lineage.resolution/valid/unresolved-conflict.json
nnew file mode 100644
iindex 00000000..fb83f744
---- /dev/null
++++ b/fixtures/lineage.resolution/valid/unresolved-conflict.json
@@@ -0,0 +1,8 @@
++{
++ "status": "unresolved",
++ "reason": "conflict",
++ "checked": [
++ { "input": "slug", "outcome": "decided", "channel_slug": "mallory" },
++ { "input": "vod_id", "outcome": "conflict", "channel_slug": "alice" }
++ ]
++}
ddiff --git a/fixtures/lineage.resolution/valid/unresolved-display-name.json b/fixtures/lineage.resolution/valid/unresolved-display-name.json
nnew file mode 100644
iindex 00000000..e62d38da
---- /dev/null
++++ b/fixtures/lineage.resolution/valid/unresolved-display-name.json
@@@ -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" }]
++}
ddiff --git a/fixtures/lineage.resolve-request/invalid/display-name-as-slug.json b/fixtures/lineage.resolve-request/invalid/display-name-as-slug.json
nnew file mode 100644
iindex 00000000..65c44c00
---- /dev/null
++++ b/fixtures/lineage.resolve-request/invalid/display-name-as-slug.json
@@@ -0,0 +1 @@
++{ "slug": "Alice Smith" }
ddiff --git a/fixtures/lineage.resolve-request/invalid/empty-legacy-ids.json b/fixtures/lineage.resolve-request/invalid/empty-legacy-ids.json
nnew file mode 100644
iindex 00000000..96eede04
---- /dev/null
++++ b/fixtures/lineage.resolve-request/invalid/empty-legacy-ids.json
@@@ -0,0 +1 @@
++{ "legacy_ids": {} }
ddiff --git a/fixtures/lineage.resolve-request/invalid/empty.json b/fixtures/lineage.resolve-request/invalid/empty.json
nnew file mode 100644
iindex 00000000..0967ef42
---- /dev/null
++++ b/fixtures/lineage.resolve-request/invalid/empty.json
@@@ -0,0 +1 @@
++{}
ddiff --git a/fixtures/lineage.resolve-request/invalid/local-owner-subject.json b/fixtures/lineage.resolve-request/invalid/local-owner-subject.json
nnew file mode 100644
iindex 00000000..5f04218c
---- /dev/null
++++ b/fixtures/lineage.resolve-request/invalid/local-owner-subject.json
@@@ -0,0 +1 @@
++{ "owner_subject": "42" }
ddiff --git a/fixtures/lineage.resolve-request/invalid/numeric-id.json b/fixtures/lineage.resolve-request/invalid/numeric-id.json
nnew file mode 100644
iindex 00000000..a92a1969
---- /dev/null
++++ b/fixtures/lineage.resolve-request/invalid/numeric-id.json
@@@ -0,0 +1 @@
++{ "vod_id": 42 }
ddiff --git a/fixtures/lineage.resolve-request/invalid/unknown-input.json b/fixtures/lineage.resolve-request/invalid/unknown-input.json
nnew file mode 100644
iindex 00000000..c3f936fa
---- /dev/null
++++ b/fixtures/lineage.resolve-request/invalid/unknown-input.json
@@@ -0,0 +1 @@
++{ "channel_name": "Alice" }
ddiff --git a/fixtures/lineage.resolve-request/valid/clip-on-channel-page.json b/fixtures/lineage.resolve-request/valid/clip-on-channel-page.json
nnew file mode 100644
iindex 00000000..d8e68e95
---- /dev/null
++++ b/fixtures/lineage.resolve-request/valid/clip-on-channel-page.json
@@@ -0,0 +1 @@
++{ "slug": "alice", "clip_id": "7" }
ddiff --git a/fixtures/lineage.resolve-request/valid/display-name-only.json b/fixtures/lineage.resolve-request/valid/display-name-only.json
nnew file mode 100644
iindex 00000000..f91006d2
---- /dev/null
++++ b/fixtures/lineage.resolve-request/valid/display-name-only.json
@@@ -0,0 +1 @@
++{ "display_name": "Alice ✨" }
ddiff --git a/fixtures/lineage.resolve-request/valid/every-input.json b/fixtures/lineage.resolve-request/valid/every-input.json
nnew file mode 100644
iindex 00000000..581f2428
---- /dev/null
++++ b/fixtures/lineage.resolve-request/valid/every-input.json
@@@ -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"
++}
ddiff --git a/fixtures/lineage.resolve-request/valid/legacy-media-ref.json b/fixtures/lineage.resolve-request/valid/legacy-media-ref.json
nnew file mode 100644
iindex 00000000..203cbdc0
---- /dev/null
++++ b/fixtures/lineage.resolve-request/valid/legacy-media-ref.json
@@@ -0,0 +1 @@
++{ "media_object_id": "legacy:live:vod:42" }
ddiff --git a/fixtures/lineage.resolve-request/valid/nested-slug.json b/fixtures/lineage.resolve-request/valid/nested-slug.json
nnew file mode 100644
iindex 00000000..a58410ae
---- /dev/null
++++ b/fixtures/lineage.resolve-request/valid/nested-slug.json
@@@ -0,0 +1 @@
++{ "slug": "alice/garage-cam" }
ddiff --git a/fixtures/lineage.resolve-request/valid/parent-slug.json b/fixtures/lineage.resolve-request/valid/parent-slug.json
nnew file mode 100644
iindex 00000000..256760c8
---- /dev/null
++++ b/fixtures/lineage.resolve-request/valid/parent-slug.json
@@@ -0,0 +1 @@
++{ "parent_slug": "alice", "slug": "garage-cam" }
ddiff --git a/fixtures/lineage.resolve-request/valid/slug.json b/fixtures/lineage.resolve-request/valid/slug.json
nnew file mode 100644
iindex 00000000..7cb40a1d
---- /dev/null
++++ b/fixtures/lineage.resolve-request/valid/slug.json
@@@ -0,0 +1 @@
++{ "slug": "@alice" }
ddiff --git a/generated/json-schema/bundle.json b/generated/json-schema/bundle.json
iindex 044d5664..bc3c8933 100644
---- a/generated/json-schema/bundle.json
++++ b/generated/json-schema/bundle.json
@@@ -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",
ddiff --git a/generated/typescript/index.d.ts b/generated/typescript/index.d.ts
iindex 99f4103f..298bb582 100644
---- a/generated/typescript/index.d.ts
++++ b/generated/typescript/index.d.ts
@@@ -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).
ddiff --git a/manifests/capabilities/live.lineage.resolve.json b/manifests/capabilities/live.lineage.resolve.json
nnew file mode 100644
iindex 00000000..676eb426
---- /dev/null
++++ b/manifests/capabilities/live.lineage.resolve.json
@@@ -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"
++ ]
++}
ddiff --git a/manifests/services/live.json b/manifests/services/live.json
iindex 10942658..5d15c2d3 100644
---- a/manifests/services/live.json
++++ b/manifests/services/live.json
@@@ -19,6 +19,7 @@
"live.chat_effects.write",
"live.chat_mirror.write",
"live.follower.read",
++ "live.lineage.resolve",
"live.tips_delivery.write"
],
"eventsProduced": [
ddiff --git a/package-lock.json b/package-lock.json
iindex bea6c8ab..bdaec06e 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex 296b860c..445e6ec2 100644
---- a/package.json
++++ b/package.json
@@@ -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",
ddiff --git a/test/run.js b/test/run.js
iindex 670b068f..c8769e1e 100644
---- a/test/run.js
++++ b/test/run.js
@@@ -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');
}
++// ── 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);