dec-0107

Vista generada de dec-0107: Discriminante live/VOD: `LiveService` paralelo a `Work`, y `Work` queda como media finita

ImplementadoSin versión del tren todavía· generada desde docs/decisions/dec-0107-discriminante-live-vod.md
track/docsdec-0124track/docs:DC10

Vista generada desde docs/decisions/dec-0107-discriminante-live-vod.md. No se edita a mano: bun run docs:gen la regenera y bun run docs:check falla si difiere. El estado aquí es el del model: si discrepa con otra página, manda el model.

CampoValor
EstadoLOCKED
Fecha2026-09-28
Ficherodocs/decisions/dec-0107-discriminante-live-vod.md

Enmendado o sustituido por: dec-0132

Por qué importa (del frontmatter del ADR):

Decide cómo entra lo live (canales IPTV/Xtream/DVB, parrilla EPG, catch-up) en el modelo de dominio: si como un WorkKind más (packages/domain/src/models/Work.ts:26) o como un agregado paralelo a Work. Sin este ADR, el primer executor que conecte /tv (hoy mock, packages/mocks/src/live-channels.ts) o que ingiera XMLTV en track/epg escogería la vía de menor esfuerzo, que es añadir 'channel' a WorkKind, y el catálogo quedaría cementado con obras sin duración ni ediciones.

Nodos del roadmap que lo citan en refs: outcome/federated-storage/remote-backends

Páginas de la documentación que lo citan: Catálogo federado (implementado), Byte runtime (implementado)

Texto del ADR

Leído de docs/decisions/dec-0107-discriminante-live-vod.md, el fichero canónico.

dec-0107 — Discriminante live/VOD: LiveService paralelo a Work, y Work queda como media finita

  • Fecha: 2026-09-28
  • Estado: LOCKED (2026-10-01). Opción (B). waxin, vía AskUserQuestion: "Lockear dec-0107 y dec-0109 ya", aceptando la recomendación del ADR y la propuesta de cada pregunta abierta. Las respuestas, con las enmiendas que el lock registra, están en Lock (2026-10-01, waxin). Hasta el lock el ADR estuvo PROPOSED (decisión #4 del plan de tandas) y no se escribió código en packages/domain. El lock desbloquea la implementación; no cierra DM2.
  • Propone: builder-services (lane domain-adr, rama w2/domain-adr).
  • Nodo: track/domain-media (DM2 live-vod-discriminant).
  • Cita: r08 (catálogo federado), r04/r20 §3.6 (SeekableMediaSource), r05/r20 §1 (PlaybackPlan composicional), r18/r20 §2.3 (contract-first Arktype), r28 §5 (discriminante pre-F1, runtime live en F-EPG), r28 §3 (anti-especulativo), r39 + dec-0104 (perfiles .vod / .live del daemon MOQT), r19 (fusión con la app IPTV).

Enmendado por dec-0132 (LOCKED, 2026-10-02): el "conjunto cerrado de media finita" de WorkKind se implementa como registro validado y no como unión de literales. Cada kind registrado declara finite: true; channel y live están vetados. El reply HTTP cierra kind con pattern más validación contra el registro, no con Type.Union. Lo que este ADR protege no cambia: lo live no entra en Work. El resto (opción B, LiveService, P1–P6) sigue igual (dec-0132 §4.2 y lock, punto 6).

Contexto

r28 §5 detectó, y hoy sigue siendo cierto, que el modelo es sólo VOD:

// packages/domain/src/models/Work.ts:26
export type WorkKind = 'movie' | 'series' | 'episode' | 'concert' | 'extra' | 'collection';

La prosa de F-DOMAIN-MEDIA (roadmap.spec.yml, FROZEN, sólo lectura) dejó la pregunta abierta: "Work.kind live/channel o LiveService vs FiniteMedia discriminant + channel identity + BroadcastService + EPGChannelBinding + programme/event identity + live vs canonical representation". El runtime live (ventana rodante, catch-up, grabación, redundancia de fuente) no es de este ADR: va en track/epg, que depende de track/domain-media.

La pregunta tiene que cerrarse antes de que outcome/first-vertical cemente el catálogo. Todo el camino de playback está hoy direccionado por assetId y, si live entra por WorkKind, esa decisión se contagia a cada contrato de la cadena.

Qué asume hoy el código sobre "una obra"

Cada una de estas suposiciones es falsa para un canal:

SuposiciónDónde
Una obra tiene ediciones con variantes de corte (original, director-cut, remastered…)packages/domain/src/models/Edition.ts:17-23
Un asset tiene una duración finita y obligatoriapackages/domain/src/models/MediaIndex.ts:22 (durationMs: number), :71 ('number.integer') y columna duration_ms integer NOT NULL en apps/catalog-svc/migrations/0000_nice_night_nurse.sql
Un binding de fuente cuelga de un assetpackages/domain/src/models/SourceBinding.ts:18 (assetId: AssetId)
El byte source tiene tamaño y se lee por offsetpackages/source-sdk/src/source.ts:97-103 (sizeBytes, "0 si desconocido, ej: streams live") y readAt(offset)
La reproducción se pide por assetpackages/api-contracts/src/playback.ts:59 (intent.assetId), :159 (ISessionDescriptor.assetId); packages/api-contracts/src/catalog.ts:155-165 (resolvePlaybackSource por assetId); packages/api-contracts/src/sources.ts:60
El scan crea obras movie por defectoapps/catalog-svc/src/core/handlers/CatalogHandler.ts:185-190

Además, el propio fixture de UI ya tomó posición sin ADR detrás:

"Los canales NO son obras (Work → Edition → …): son flujo, sin estado de copia ni entrada en la línea de certeza de la Biblioteca." — packages/mocks/src/live-channels.ts:4-6

/tv es un consumer real que existe hoy (apps/web/src/server/queries/live-channels.ts:4, "Mock interim … hasta que sources-svc aterrice"). Es el primer consumer que exige r28 §3 para que la implementación que sigue a este ADR no sea especulativa.

Del lado del data plane, el discriminante ya existe: native/zig/media-daemon/moqt/profile.zig:33 declara Profile = enum { vod, live } y dec-0104 bifurca la política de colas por perfil (VOD sin pérdida, live con drop-oldest). Hoy nadie en el control plane decide qué perfil pide una sesión: grep -rni profile apps/playback-svc/src packages/api-contracts/src sólo devuelve el IResolveSourceClientProfile de sources.ts:45, que es el perfil del cliente y no el de entrega.

Opciones

(A) Extender WorkKind con live / channel

type WorkKind = 'movie' | 'series' | 'episode' | 'concert' | 'extra' | 'collection' | 'channel';

Un canal es un Work con una Edition sintética y un MediaAsset cuyo MediaIndex tiene durationMs = 0. La parrilla (Programme) cuelga del Work canal.

  • A favor: cambio mínimo hoy (un literal en Work.ts:26 y :37); la cadena assetId de playback no se toca. Es lo que hace Jellyfin: LiveTvChannel y LiveTvProgram heredan de BaseItem, como las películas.
  • En contra:
    • Edition deja de significar algo. Para un canal, director-cut/remastered no tienen sentido. La variante real de un canal (HD/SD/UHD, regional, proveedor A frente a B) es otra dimensión y acabaría codificada en EditionKind: 'custom' + tags.
    • durationMs = 0 es un centinela, no un dato. Cada consumer que ordena, filtra o calcula progreso (apps/web/src/server/queries/home-rails.ts:56 filtra por runtimeMinutes < 10) tiene que acordarse de excluir canales. Es la misma clase de bug que d08: un valor falso que parece real.
    • La biblioteca se contamina. library-fetch.ts:120 y library-queries.mock.ts:58 filtran por work.kind === 'movie'; un channel en works entra en "Todo", en búsquedas y en "Seguir viendo" salvo que cada consulta lo excluya a mano.
    • Un programme no es un episode, pero lo parecerá. Con A, la tentación natural es modelar el programa emitido como Work(kind: 'episode'), y entonces cada ingest XMLTV (miles de filas al día, efímeras) crea obras en el catálogo durable.
    • El precedente de Jellyfin es un argumento en contra. Su BaseItem único obliga a que canales y programas arrastren campos y ramas de código de obras. Styx está aquí para superarlo, no para copiar su jerarquía.

(B) LiveService paralelo a Work + Programme / EPGChannelBinding; Work = media finita (RECOMENDADA)

Work conserva su semántica (media finita: duración, ediciones, copias) y live es un agregado propio con la misma forma federada que r08:

VOD  (r08, sin cambios)   Work ─→ Edition ─→ MediaAsset ─→ SourceBinding
LIVE (nuevo, paralelo)    LiveService ─→ BroadcastService ─→ ServiceSourceBinding
                              ▲   │
       EPGChannelBinding ─────┘   └─→ Programme ─(opcional: workRef)─→ Work
  • LiveService es la identidad lógica del canal ("La 1"), federada entre proveedores igual que Work federa copias.

  • BroadcastService es una emisión concreta de ese canal: proveedor, calidad, región. Es el equivalente de Edition + MediaAsset, sin ediciones de corte.

  • ServiceSourceBinding apunta a la fuente de bytes con el SourceKind canónico unificado (ver "Dos SourceKind divergentes", más abajo) y lo puntúa el selector de sources-svc (qry.sources.resolve), pero cuelga de un BroadcastService y no de un AssetId. Ese selector no está hoy en el camino de playback VOD: playback-svc resuelve un asset con qry.catalog.resolvePlaybackSource, que devuelve asset.path sin puntuar candidatos (ver "Resolución de un sujeto service"). Su uri no transporta credenciales (ver "Credenciales de proveedor").

  • EPGChannelBinding casa el id de canal de una fuente EPG (XMLTV channel@id, Xtream epg_channel_id) con un LiveService, con procedencia y confianza. Sin él no hay matching EPG que auditar.

  • Programme es un evento de parrilla con una ventana [startsAt, endsAt) sobre un LiveService. Su workRef opcional (una película emitida enlazada al Work ya catalogado) permite a la UI decir "en tu biblioteca / en antena a las 21:00 en La 1 / en catch-up".

  • Transición de grabación: un Programme grabado se convierte en media finita. Crea (o enlaza vía workRef) un Work → Edition → MediaAsset con procedencia del servicio. Es el único punto donde live cruza a VOD, y es explícito.

  • A favor:

    • Work, Edition, MediaIndex.durationMs y la biblioteca no se tocan ni cambian de semántica. Cero centinelas.
    • La migración es aditiva (tablas nuevas, sin backfill). works queda intacta.
    • Xtream encaja de forma natural. Su API tiene tres familias: live va a LiveService, y vod y series van a Work con SourceBinding(sourceKind: 'xtream'), igual que cualquier fuente VOD.
    • El discriminante que falta en el control plane (qué perfil pide la sesión) se deriva del tipo del sujeto: asset da .vod y service da .live (profile.zig:33), en vez de ser un flag suelto.
    • Coincide con la posición que el fixture de UI ya tomó (live-channels.ts:4-6).
    • Otros tipos de media finita que vengan después (libros o audiolibros, que el análisis de Jellyfin 12 señala como gap) serán WorkKind nuevos sin ninguna interacción con live.
  • En contra:

    • Más tipos (4 entidades de dominio + ids branded) y más superficie de contrato.
    • La cadena de playback direccionada por assetId tiene que aprender un sujeto discriminado. Es un cambio de contrato en playback.ts, catalog.ts y sources.ts, aunque retrocompatible (ver "Contrato").
    • El selector de fuentes tiene que aceptar candidatos de dos orígenes (asset o broadcast).

Variante descartada: supertipo Playable con timeline: 'finite' | 'rolling'

Unificar Work y LiveService bajo una entidad común con un campo de línea temporal es A con otro nombre: los campos específicos de cada rama vuelven a ser opcionales en la misma fila. El discriminante útil vive en el contrato de playback (el sujeto de la sesión), no en el agregado de catálogo. B lo recoge como PlaybackSubject (abajo).

Recomendación

(B). El análisis no la refuta. Un canal no es una obra finita, y meterlo en WorkKind contamina Edition y la duración, y obliga a cada consumer de la biblioteca a excluirlo a mano. El coste extra de B es superficie de tipos, y es lineal y aditivo. El coste de A es semántico, está repartido por todos los consumers y no tiene vuelta atrás una vez haya datos reales en works.

Esquema tipo propuesto (NO implementado)

Referencia para quien implemente tras el lock. No existe en packages/domain y no se crea hasta que waxin lockee y haya consumer.

// packages/domain/src/types/ids.ts (+)
export type LiveServiceId       = string & IBrand<'LiveServiceId'>;
export type BroadcastServiceId  = string & IBrand<'BroadcastServiceId'>;
export type ServiceBindingId    = string & IBrand<'ServiceBindingId'>;
export type EpgChannelBindingId = string & IBrand<'EpgChannelBindingId'>;
export type ProgrammeId         = string & IBrand<'ProgrammeId'>;

// packages/domain/src/models/LiveService.ts
export interface ILiveService {
  readonly id: LiveServiceId;
  readonly name: string;                 // "La 1"
  readonly logicalNumber?: number;       // LCN / número de dial
  readonly country?: string;             // ISO 3166-1
  readonly languages: ReadonlyArray<string>;
  readonly createdAt: number;
  readonly updatedAt: number;
}

// packages/domain/src/models/BroadcastService.ts
export interface IBroadcastService {
  readonly id: BroadcastServiceId;
  readonly serviceId: LiveServiceId;
  readonly provider: string;             // id del proveedor IPTV/DVB (provenance)
  readonly providerServiceRef: string;   // stream_id Xtream, DVB triplet…
  readonly variant: BroadcastVariant;    // eje real de variación de un canal
  readonly catchUpWindowMs?: number;     // capacidad declarada por el proveedor, no runtime
  readonly createdAt: number;
  readonly updatedAt: number;
}
export type BroadcastVariant = 'sd' | 'hd' | 'uhd' | 'regional' | 'timeshift' | 'custom';

// packages/domain/src/models/ServiceSourceBinding.ts
export interface IServiceSourceBinding {
  readonly id: ServiceBindingId;
  readonly broadcastId: BroadcastServiceId;
  readonly sourceKind: SourceKind;       // el SourceKind unificado (paso 0), no ninguno de los dos actuales
  readonly uri: string;                  // sin secretos: xtream://<accountId>/live/<stream_id>, nunca /live/<user>/<pass>/…
  readonly credentialRef?: string;       // referencia opaca a la credencial de la cuenta de proveedor; nunca el valor
  readonly preference: number;           // 0..1, misma semántica que ISourceBinding
  readonly capabilities: ReadonlyArray<string>;
  readonly createdAt: number;
  readonly updatedAt: number;
}

// packages/domain/src/models/EpgChannelBinding.ts
export interface IEpgChannelBinding {
  readonly id: EpgChannelBindingId;
  readonly epgSource: string;            // id de la fuente EPG (XMLTV url, proveedor Xtream)
  readonly epgChannelRef: string;        // XMLTV channel@id / epg_channel_id
  readonly serviceId: LiveServiceId;
  readonly matchedBy: 'manual' | 'exact-ref' | 'name' | 'fuzzy';
  readonly confidence: number;           // 0..1; 'manual' ⇒ 1
  readonly createdAt: number;
  readonly updatedAt: number;
}

// packages/domain/src/models/Programme.ts
export interface IProgramme {
  readonly id: ProgrammeId;              // determinista: hash(serviceId, epgSource, startsAt, epgProgrammeRef?)
  readonly serviceId: LiveServiceId;
  readonly epgSource: string;
  readonly startsAt: number;             // epoch ms, inclusivo
  readonly endsAt: number;               // epoch ms, exclusivo; invariante endsAt > startsAt
  readonly title: string;
  readonly description?: string;
  readonly category?: ReadonlyArray<string>;
  readonly episodeRef?: { readonly season?: number; readonly episode?: number };
  readonly workRef?: WorkId;             // enlace opcional a media finita ya catalogada (r08)
}

Programme.id es determinista para que reingerir la misma parrilla sea idempotente (upsert, no duplicado). Programme no lleva createdAt/updatedAt porque es una proyección reingerible de la fuente EPG, no un agregado editado.

Contrato (@styx/api-contracts, contract-first Arktype, r20 §2.3)

El discriminante vive en el sujeto de la sesión de playback:

export type PlaybackSubject =
  | { readonly kind: 'asset';   readonly assetId: string }
  | { readonly kind: 'service'; readonly serviceId: string;
      readonly at?: { readonly mode: 'live-edge' } | { readonly mode: 'catch-up'; readonly startsAt: number } };
  • Retrocompatible en el wire: IPlaybackIntent conserva assetId? durante una versión de schema; el validador acepta assetId o subject, nunca ambos, y normaliza assetId a subject: { kind: 'asset' }. Cuando no quede ningún emisor de assetId suelto (0 callers por grep), se borra el campo. DELETE > deprecated.
  • ISessionDescriptor pasa de assetId a subject con la misma regla.
  • IDeliveryPlan (playback-plan.ts:108-115) gana profile: 'vod' | 'live' derivado del sujeto. El cliente no lo envía, igual que mode. Es lo que el control plane pasa al daemon para elegir Profile (profile.zig:33).
  • IGetWorkQueryReply.work.kind (catalog.ts:135, :146) hoy es 'string'. Se cierra a type.enumerated(...WorkKind). Es un agujero contract-first independiente de A/B, pero B lo hace explícito: WorkKind es un conjunto cerrado de media finita.
  • Queries nuevas: qry.catalog.getLiveService, qry.catalog.listLiveServices y qry.catalog.listProgrammes (ventana [from, to) por servicio o conjunto de servicios). Sólo se crean las que tengan consumer (/tv y la guía de track/epg). Sus replies no incluyen uri, credentialRef ni ningún otro campo de IServiceSourceBinding: proyectan LiveService, BroadcastService sin bindings y Programme. Ninguna de ellas resuelve a una fuente concreta.

Resolución de un sujeto service

Hoy playback-svc no llama a sources-svc. Resuelve siempre por catalog: qry.catalog.resolvePlaybackSource (apps/playback-svc/src/service/nats/catalog-client.ts), cuyo reply lleva sourceUri = asset.path (apps/catalog-svc/src/service/nats/query-server.ts:294). El selector de sources-svc (qry.sources.resolve) existe, pero su único camino es el propio sources-svc; en VOD no hay puntuación de fuentes múltiples en el camino de playback.

Propuesta para B:

  • asset: sin cambios. Sigue por qry.catalog.resolvePlaybackSource. Mover VOD al selector de sources-svc es una convergencia de r08 que este ADR no decide.
  • service: playback-svc pide a sources-svc qry.sources.resolve con subject: { kind: 'service', serviceId }. sources-svc es el dueño de ServiceSourceBinding y de las credenciales (secretsFor), así que es el único que puede puntuar candidatos de cuentas de proveedor. catalog-svc no interviene en la resolución de un service: sólo proyecta LiveService/BroadcastService/Programme para /tv y la guía.
  • El reply de qry.sources.resolve con sujeto service devuelve la URI canónica sin secreto (xtream://<accountId>/live/<stream_id>) y el credentialRef opaco. La URL con credencial se materializa sólo en la frontera hacia el daemon, y no viaja como assetPath: assetPath alimenta hoy diez logs y dos mensajes de error de createSession (ver "Credenciales de proveedor"). La URL se construye en una variable local de createSession que sólo se usa al serializar el frame CreateSession (hoy media-daemon-client.ts:323-328), en un campo propio distinto de assetPath. Todos los logs y mensajes de error de createSession usan la URI canónica sin secreto o el requestId. Quién resuelve credentialRef en ese punto es la pregunta abierta 6.
  • Eso no se puede hacer sin tocar protocols/ ni el daemon (ver "La frontera del daemon hoy sólo acepta un path local"): el daemon no tiene hoy dónde recibir ni cómo abrir una URL de red.

La frontera del daemon hoy sólo acepta un path local

  • Protocolo. CreateSession sólo lleva {requestId, assetPath} (protocols/session-ipc/SESSION_PROTOCOL.md:23-33, control.zig:10), y assetPath es un path del filesystem del daemon (ejemplo en SESSION_PROTOCOL.md:31; JSDoc del puerto en apps/playback-svc/src/core/ports/index.ts:68-69, "Path absoluto al archivo de media").
  • Fuente. El daemon convierte assetPath en file://<path> y abre un LocalFileSource (native/zig/media-daemon/session/media_session.zig:144, :153). Es la única fuente de bytes del data plane: git ls-files native/zig | grep -i source sólo devuelve media-core/source/local_file.zig y dos ficheros de buffer. Una URL de red en assetPath falla en LocalFileSource.open, el daemon responde Error{code: ASSET_OPEN_FAILED} y la loguea antes (control.zig:1327-1334).
  • Tamaño. Tras el reply del daemon, el adapter hace stat(assetPath) para rellenar fileSize (media-daemon-client.ts:396-420). Para una fuente de red, stat da ENOENT y el adapter devuelve DAEMON_STAT_FAILED con la URL en el mensaje (:409) y en el log (:405); el PlaybackHandler lo convierte en delivery degradado (PlaybackHandler.ts:222-233). Así que un sujeto service degradaría siempre. Además, a esas alturas el daemon ya abrió la sesión, y playback-svc no tiene camino de cierre (grep -rn "CloseSession\|closeSession" apps/playback-svc/src → 0): la sesión del daemon queda huérfana.

Lo que exige B en esta frontera, cualquiera que sea la respuesta a la pregunta 6:

  1. CreateSession gana un descriptor de fuente discriminado en vez de assetPath suelto: {kind:'file', path} (el caso actual) o {kind:'remote', ref, …} donde ref es la URI canónica sin secreto que el daemon usa en todos sus logs. Es un cambio de protocolo versionado (protocols/schema-versioning), zona protocols/ (axon), no de builder-services.
  2. El daemon gana una fuente remota que implemente el contrato r04 con sizeBytes = 0 (packages/source-sdk/src/source.ts:102-103, "0 si desconocido, ej: streams live"). Zona native/zig/ (builder-dataplane). Hoy no existe. Su campo uri (el source.uri que hoy alimentan los logs por chunk del transporte, la clave de cache y el CacheSim, ver tabla de "Credenciales de proveedor") es la ref canónica sin secreto, nunca la URL materializada. La URL materializada vive sólo en un campo privado de conexión de la fuente remota, que no se loguea y no es entrada de cacheKeyForChunk ni de CacheSim.observe.
  3. El puerto IMediaDaemonClient.createSession (ports/index.ts:73-75) recibe el mismo discriminado. Con kind:'file' hace el stat de hoy; con kind:'remote' no hace stat y devuelve fileSize ausente (el campo ya es opcional, ports/index.ts:49), que el cliente interpreta como tamaño desconocido, igual que sizeBytes = 0 en r04. Si el tamaño hiciera falta, lo informa el daemon en el reply, que es quien abre la fuente; nunca un stat en el control plane sobre algo que no es un fichero local.

Dos SourceKind divergentes (paso previo a implementar B)

La versión anterior de este ADR decía que IServiceSourceBinding usaría "el mismo SourceKind" que el selector. Es falso: hoy hay dos uniones distintas y el selector no usa la del dominio.

UniónValoresQuién la usa
Dominio, packages/domain/src/models/SourceBinding.ts:34-43 (+ schema :49-59)local, http-range, s3, webdav, sftp, torrent, jellyfin, xtream, compositeISourceBinding persistido; apps/sources-svc/src/service/db/sources-repo.ts:103, :145 castea el text de la columna source_kind (migrations/0000_sources.sql:16, sin CHECK) a esta unión
Contrato, packages/api-contracts/src/sources.ts:29local, http, s3, webdav, torrent, jellyfin, xtream, compositeEl selector: IResolveCandidate (apps/sources-svc/src/core/ports/index.ts:21, :90), el testigo CONTRACT_SOURCE_KINDS (source-resolver.ts:75-84) y el provider HTTP, que declara 'http' (apps/sources-svc/src/service/plugins/source-http.ts:44)

El puente entre ambas es un cast sin mapeo, SourcesHandler.ts:245 (binding.sourceKind as IResolveCandidate['sourceKind']), documentado como "KNOWN GAP M2.A" en :235-244. Consecuencia real hoy: un binding de dominio con sourceKind: 'http-range' nunca llega al provider HTTP, cae en SOURCES_INVALID_KIND (fijado por el test source-resolver.test.ts:205-216). Y el sourceKind del contrato vuelve al dominio con el cast inverso en SourcesHandler.ts:338. Además, en el contrato los schemas Arktype validan sourceKind/preferredKind como 'string' (sources.ts:71, :110, :132), así que el wire tampoco cierra la unión.

Qué adopta el binding live: ninguna de las dos tal cual. IServiceSourceBinding usa la unión canónica que salga del paso 0. Si se implementase B sin ese paso, el binding live heredaría el cast de SourcesHandler.ts:245 y un segundo origen de candidatos multiplicaría el problema.

Paso 0 propuesto (reversible, no requiere este lock, sí un ticket propio en sources-svc):

  1. Una sola unión, definida en @styx/domain y reexportada por @styx/api-contracts (el mismo patrón que ya sigue SourcesErrorCode, sources.ts:35), con schema type.enumerated(...) en todos los payloads del wire.
  2. http gana sobre http-range: es el kind que declaran el provider y el ISeekableMediaSource (packages/source-sdk/src/source.ts:101), y http-range ya significa otra cosa en el sistema, el transporte de entrega (PlaybackHandler.ts:66, playback-plan.ts:109). Mantenerlo como kind de fuente es una colisión de vocabulario.
  3. sftp sale de la unión: 0 providers y 0 adapters; fuera de su declaración sólo aparece en los comentarios del KNOWN GAP y en el test del cast. Se añade cuando llegue su provider (r28 §3).
  4. Se borran los casts de SourcesHandler.ts:245 y :338 y el testigo CONTRACT_SOURCE_KINDS deja de necesitar el caso "kind del dominio que no está en el contrato". Una migración de datos de source_kind sólo hace falta si hay filas http-range/sftp (el scan local sólo escribe local, SourcesHandler.ts:277).

Si waxin prefiere no hacer el paso 0 antes del lock, queda como pregunta abierta 4 y el lock de B lo tiene que resolver.

Credenciales de proveedor (invariante de B)

Las URLs de Xtream llevan la credencial en el path: /live/<user>/<pass>/<stream_id>.<ext> (y /movie/…, /series/… para su VOD). El camino actual trataría esa URL como un dato cualquiera: el uri de un binding sale sin filtrar en cuatro sitios de sources-svc, y la URI de fuente del camino de playback (sourceUri) sale en el reply de catalog, en el plan que recibe el cliente, en logs y mensajes de error de playback-svc (incluido cada log y mensaje de createSession del adapter del daemon) y en los logs del propio daemon Zig.

SalidaEvidencia
Reply de qry.sources.resolve: uri del ganador y reasons[].uri de cada candidatoSourcesHandler.ts:378, :389; sources.ts:79, :89-92
Evento evt.sources.availabilityChangedSourcesHandler.ts:352-356; sources.ts:123
Log warn al fallar el publishSourcesHandler.ts:360-364
Fila persistida source_bindings.uriSourcesHandler.ts:339, sources-repo.ts:42, :62
Reply de qry.catalog.resolvePlaybackSource: sourceUripackages/api-contracts/src/catalog.ts:175; apps/catalog-svc/src/service/nats/query-server.ts:294
IPlaybackPlan.source.uri, devuelto al cliente en ISessionDescriptor.planapps/playback-svc/src/core/handlers/PlaybackHandler.ts:199 (buildSourceSelection(reply)), :357-362 (uri: reply.sourceUri); packages/playback-policy/src/planner.ts:107 (source: sourceSelection); packages/api-contracts/src/playback-plan.ts:158
Log error y mensaje de error del reply de playback cuando faltan capabilitiesPlaybackHandler.ts:290-292 (log con sourceUri), :297 (mensaje con sourceUri) que llega al cliente por apps/playback-svc/src/service/nats/query-server.ts:290
Logs de createSession en la frontera del daemon (assetPath)apps/playback-svc/src/service/daemon/media-daemon-client.ts: debug en :292 y :346; error en :341, :354 y :364; warn en :379, :382, :405 y :413; info en :390-394, en cada sesión abierta con éxito
Mensajes de error que devuelve createSessionmedia-daemon-client.ts:409 (asset not found: ${assetPath}) y :417 (cannot stat asset ${assetPath}: …), ambos DAEMON_STAT_FAILED
Logs del daemon Zig en CreateSessionnative/zig/media-daemon/ipc/control.zig:1328 (err, "failed to open asset '{s}'"), :1367-1369 (info, assetPath={s} en cada CreateSession); native/zig/media-daemon/session/media_session.zig:156-158 (info, la uri file://<assetPath>) y :501 (info, "created session … for '{s}'")
Logs de LocalFileSource.open cuando llega una URL como assetPath (antes de control.zig:1328)native/zig/media-core/source/local_file.zig:435 (warn, "path traversal blocked '{s}'"), :454 (err, "realpath failed for '{s}'") y :488 (err, "open failed for '{s}'"): según en qué paso falle la apertura sale uno de los tres, siempre con la ruta tal cual. Con apertura correcta, :525 ("opened '{s}'") y :540 ("closed '{s}'") loguean la uri de la fuente
Logs del data path por chunk servido (WebTransport) con source.urinative/zig/media-daemon/transport/quiczig_transport.zig:898-900 (info, "WT: cache HIT (scheduled) uri={s}"), :1083-1085 (warn, "cache put (scheduled) failed (uri={s} …)"), :1183-1185 (info, "WT: cache HIT uri={s}") y :1212-1214 (warn, "cache put failed (uri={s} …)"). source.uri es el file://<assetPath> de media_session.zig:144, así que se loguea en cada HIT de cache a partir del segundo pedido de un chunk
Usos de source.uri que no son logs pero fijan identidadClave de cache: cacheKeyForChunk(source.uri, …) en quiczig_transport.zig:872 y :1167 (source_hash = fnv64(uri), :1381). CacheSim.observe(source.uri, …) en native/zig/media-daemon/moqt/fetch_runtime.zig:180 y observe(backend.sourceUri(), …) en native/zig/media-daemon/session/scheduler.zig:661 (vía range_producer.zig:258 y scheduler.zig:873). Si source.uri fuera la URL materializada, la identidad de cache dependería de la credencial

La lista de createSession es la salida completa de grep -n "assetPath" apps/playback-svc/src/service/daemon/media-daemon-client.ts quitando el JSDoc (:15), el tipo del frame (:112), la firma (:289), el propio frame (:326) y el stat (:400). Resumen: esa lista cubre todos los logs y mensajes de error de createSession que llevan assetPath: diez logs y dos mensajes. No son todos los de la función: los logs de :306, :316, :329, :331 y :334, los mensajes de :310-311, :358 y :367 y los de mapFrameError (:447) no llevan la ruta.

Las filas del daemon Zig salen de grep -rn 'source\.uri\|sourceUri()\|assetPath\|asset_path' native/zig/media-daemon --include=*.zig y grep -n 'log\.' native/zig/media-core/source/local_file.zig, leídas una a una. Quedan fuera los uri de otel/metrics_exporter.zig (endpoint OTLP, no la fuente) y el stub de test de quiczig_transport.zig:2227; h3z_transport.zig no usa la uri de la fuente. Esto es un barrido del árbol de hoy, no una garantía: por eso el paso 4b exige el invariante por construcción (source.uri = ref) y un test de stderr que sirve bytes, no una lista de líneas redactadas.

Dos salidas de la misma función que hoy no llevan la ruta, y el test las cubre igualmente: :354 (raw) y :358 (mensaje de DAEMON_REPLY_INVALID) vuelcan el reply del daemon, que no repite assetPath (campos del reply en control.zig:1371-1378; el frame Error sólo lleva @errorName, :1329-1332). Y los mensajes de error de createSession no llegan hoy al cliente: PlaybackHandler sólo registra daemonResult.error.code (PlaybackHandler.ts:232) y el descriptor degradado no copia el mensaje. Esa es una barrera de un caller, no del adapter: el invariante se exige en el adapter.

plan.delivery.url también nace de sourceUri (PlaybackHandler.ts:200, :378-381), pero hoy no sale al cliente: reconcilePlanDelivery lo sustituye por el endpoint efectivo del daemon o por el degradado (PlaybackHandler.ts:463-473). El test del invariante lo comprueba igualmente, porque esa sustitución es la única barrera.

Y hoy no existe almacén de secretos al que referenciar: los dos hosts de plugins devuelven secretsFor: () => ({}) (apps/sources-svc/src/service/plugins/registry.ts:44, apps/catalog-svc/src/bootstrap.ts:73), aunque el seam existe (packages/plugin-sdk/src/context.ts:164).

Invariantes que B adopta (se aplican también a SourceBinding(xtream) de VOD, que tiene el mismo problema):

  1. uri no transporta secretos. Se persiste una URI canónica sin credencial (xtream://<accountId>/live/<stream_id>) y la credencial va por referencia (credentialRef) a la cuenta de proveedor. El valor vive en el almacén de secretos del servicio dueño (sources-svc) y llega al provider sólo por secretsFor del ExtensionContext.
  2. La URL materializada con credencial no cruza el bus ni llega al cliente. Se materializa sólo en la frontera hacia el daemon (session-ipc, frame CreateSession), no en un reply NATS. No la llevan IResolveSourceReply.uri, reasons[].uri, ningún evento, el reply de qry.catalog.resolvePlaybackSource (sourceUri) ni IPlaybackPlan.source.uri (ni, por tanto, ISessionDescriptor.plan). source.uri lleva la URI canónica sin secreto o una referencia opaca, y plan.delivery.url el endpoint del daemon.
  3. Redacción en logs y trazas. Las queries y operaciones nuevas (listLiveServices, getLiveService, listProgrammes, upsertLiveService, ingestProgrammes), el resolve de sources-svc, resolvePlaybackSource y PlaybackHandler.resolvePlan no ponen uri materializada ni credentialRef resuelta en logs, atributos de span ni mensajes de error (incluidos PlaybackHandler.ts:290-297). Un test lo comprueba sobre la salida real del logger y del exporter en memoria de @styx/observability/testing.
    • Adapter del daemon (media-daemon-client.ts, createSession): el invariante cubre todos sus logs y mensajes de error, no sólo el debug de :292. Son los de la tabla de arriba: :292, :341, :346, :354, :364, :379, :382, :390-394 (info), :405, :413 y los mensajes de :409 y :417. Se cumple por construcción: la URL materializada vive en una variable local que sólo se lee al serializar el campo de fuente remota del frame CreateSession, y todos esos sitios usan la URI canónica sin secreto o el requestId. Con fuente remota no hay stat, así que :405-417 no se alcanzan.
    • Daemon Zig (data plane): recibe la URL materializada, porque es quien abre la conexión. El invariante se extiende a sus logs: control.zig:1328, :1367-1369, media_session.zig:156-158, :501, los de LocalFileSource (local_file.zig:435, :454, :488, :525, :540) y los del data path por chunk (quiczig_transport.zig:898-900, :1083-1085, :1183-1185, :1212-1214) usan la ref canónica del descriptor de fuente, nunca el campo materializado, y el frame Error sigue sin repetir la fuente (hoy sólo @errorName, control.zig:1329-1332). Se cumple por construcción: el uri de la fuente remota (el source.uri que leen esos logs, cacheKeyForChunk y CacheSim.observe) es la ref canónica; la URL materializada vive sólo en un campo privado de conexión que no se loguea ni entra en la clave de cache. Consecuencia: la clave de cache es canónica e independiente de la credencial (rotar la contraseña de la cuenta no invalida la cache, y el mismo canal alcanzado con dos credenciales de la misma cuenta comparte clave). Es redacción en Zig (zona builder-dataplane), no algo que el control plane pueda garantizar.
  4. Alcance y autorización: pregunta abierta 5.
  5. Quién resuelve credentialRef al escribir CreateSession: pregunta abierta 6.

Tabla de impacto por consumer

"Implementación" = qué cambia cuando se implemente B tras el lock. En la columna A se indica qué exigiría la alternativa, para comparar.

ConsumerEvidencia (file:line)Con (A)Con (B) — implementación
@styx/domain Workpackages/domain/src/models/Work.ts:26, :37+ 'channel' en tipo y schema; cada consumer de WorkKind tiene que excluirloSin cambios de forma; JSDoc de WorkKind declara "media finita"
@styx/domain EditionEdition.ts:17-23Canal con Edition sintética custom + tagsSin cambios
@styx/domain MediaIndexMediaIndex.ts:22, :71durationMs: 0 como centinela o durationMs? (rompe NOT NULL de la migración 0000)Sin cambios (live no tiene MediaIndex de asset; sus caps van en IServiceSourceBinding.capabilities y en el probe del runtime live de track/epg)
@styx/domain SourceBindingSourceBinding.ts:18, :34-43, :49-59assetId del asset sintético del canal. La divergencia de SourceKind con el contrato sigue ahíPaso 0: SourceKind pasa a ser la unión canónica única (http en vez de http-range, sin sftp) y api-contracts la reexporta. IServiceSourceBinding usa esa unión, no la actual. uri sin credenciales también para SourceBinding(xtream) de VOD
@styx/domain nuevospackages/domain/src/models/index.ts:1-10, types/ids.ts:17-41Programme colgado de WorkId+ LiveService, BroadcastService, ServiceSourceBinding, EpgChannelBinding, Programme + 5 ids branded + tests en packages/domain/test/models.test.ts
@styx/api-contracts playbackplayback.ts:57-63 (ResolvePlanPayloadSchema), :155-159, :186-189Sin cambios (todo es assetId)PlaybackSubject discriminado + normalización retrocompatible de assetId
@styx/api-contracts playback-planplayback-plan.ts:108-115, :148profile tendría que inferirse del WorkKind en un lookup extra+ IDeliveryPlan.profile derivado; PlaybackMode sin cambios
@styx/api-contracts catalogcatalog.ts:130-147 (kind: 'string'), :155-165Sin cambios de forma; kind sigue abiertokind cerrado a WorkKind; + queries de live/EPG con consumer
@styx/api-contracts sourcessources.ts:60, :66, :122-130Sin cambiosPayload de resolve acepta subject; el candidato lleva origin: 'asset' | 'broadcast'
catalog-svc schema/migrationsapps/catalog-svc/src/service/db/schema.ts:19-27; migrations/0000_nice_night_nurse.sql; 0001_editions_aggregate.sqlworks.kind admite channel; media_indexes.duration_ms 0 o nullable; queries de biblioteca excluyen channelMigración 0002 aditiva: live_services, broadcast_services, service_source_bindings, epg_channel_bindings, programmes (PK determinista, índice (service_id, starts_at)). works intacta
catalog-svc handler/operationsCatalogHandler.ts:185-190; operations/registry.ts:57 (kind: 'movie' | 'series' | 'episode')createWork acepta channel+ operaciones catalog:upsertLiveService / catalog:ingestProgrammes con OperationState; registry.ts:57 pasa a WorkKind (hoy es un subconjunto a mano)
playback-svcapps/playback-svc/src/core/handlers/PlaybackHandler.ts:190 (buildSourceCapabilities(reply))El canal pasa por el mismo camino y el planner recibe caps de un asset sintéticoRama por subject.kind; service resuelve por qry.sources.resolve (no por catalog) y deriva profile: 'live'; source.uri sin URL materializada (hoy PlaybackHandler.ts:357-362 copia sourceUri tal cual). El planner (packages/playback-policy/src/planner.ts) no cambia: sigue siendo codec-agnostic
playback-svc adapter del daemonapps/playback-svc/src/service/daemon/media-daemon-client.ts:288-431 (createSession(assetPath)): assetPath en todos los logs (:292, :341, :346, :354, :364, :379, :382, :390-394 info, :405, :413) y mensajes de error (:409, :417); stat(assetPath) para fileSize en :396-420; puerto en core/ports/index.ts:73-75El canal entra como assetPath = URL de proveedor: fuga en los diez logs y en los dos mensajes, y stat falla siempre (ENOENT), así que el canal degrada siemprecreateSession recibe un descriptor de fuente discriminado (file / remote). La URL materializada es una variable local que sólo se serializa en el campo de fuente remota del frame; logs y mensajes usan la ref canónica o el requestId. Con remote no hay stat y fileSize queda ausente (tamaño desconocido, sizeBytes = 0 en r04)
session-ipc + daemon Zigprotocols/session-ipc/SESSION_PROTOCOL.md:23-33 (CreateSession{requestId, assetPath}, path local); native/zig/media-daemon/session/media_session.zig:144, :153 (sólo LocalFileSource, file://); logs con la fuente en control.zig:1328, :1367-1369, media_session.zig:156-158, :501, local_file.zig:435, :454, :488, :525, :540 y, por chunk servido, quiczig_transport.zig:898-900, :1083-1085, :1183-1185, :1212-1214; source.uri es además la clave de cache (quiczig_transport.zig:872, :1167) y la entrada de CacheSim.observe (fetch_runtime.zig:180, scheduler.zig:661)Igual que B: una URL de red no se puede abrir hoyCambio de protocolo versionado (descriptor de fuente discriminado en CreateSession, zona protocols/), fuente remota r04 en el daemon (zona native/zig/, hoy 0 código) cuyo uri es la ref canónica (la URL materializada en un campo privado de conexión, fuera de logs, clave de cache y CacheSim), y redacción en los logs del daemon (usa la ref canónica). Hace falta con (a) y con (b) de la pregunta 6
playback-policypackages/playback-policy/src/planner.ts:69-112Sin cambiosSin cambios; sólo recibe delivery.profile ya derivado
source-sdkpackages/source-sdk/src/source.ts:97-103Igual que BIgual que A: el feed live sigue siendo una fuente r04 con sizeBytes = 0. La semántica de ventana rodante (cancelOutside sobre offsets que avanzan) es de track/epg y puede pedir enmendar r04, lo que requiere su propio AskUserQuestion
sources-svcsource-resolver.ts:75-84 (CONTRACT_SOURCE_KINDS); core/handlers/SourcesHandler.ts:245, :338 (casts entre uniones); :352-364, :378, :389 (uri en evento, log y reply); service/plugins/registry.ts:44 (secretsFor: () => ({}))Sin cambios de forma; hereda el cast y la fuga de uriPaso 0: casts borrados sobre la unión única. El selector puntúa candidatos de ServiceSourceBinding con el mismo registry de providers. Almacén de credenciales por cuenta de proveedor servido por secretsFor; el provider materializa la URL con credencial y ésta no sale en reply, evento, log ni span
Xtream (integración)integrations/xtream/README.md (sólo README, 0 código); AcquisitionArtifact.ts:30live → Work(channel); vod/series → Worklive → LiveService + BroadcastService + ServiceSourceBinding; epg_channel_id → EpgChannelBinding(matchedBy: 'exact-ref'); vod/series → Work + SourceBinding(xtream)
EPG (track/epg)styx.model.yml nodo track/epg (dependsOn: [outcome/first-vertical, track/domain-media]); 0 códigoProgramme cuelga de WorkId; cada ingest toca worksIngest XMLTV → Programme idempotente; matching → EpgChannelBinding auditado. Rolling window, catch-up y grabación se quedan en track/epg
data plane MOQTnative/zig/media-daemon/moqt/profile.zig:33; dec-0104profile se infiere de work.kind === 'channel'profile = f(subject.kind), explícito en IDeliveryPlan
web /tvpackages/mocks/src/live-channels.ts:4-6, :31-42 (ILiveChannelView); apps/web/src/server/queries/live-channels.ts:4Contradice el fixture ("Los canales NO son obras")ILiveChannelView se proyecta desde LiveService + Programme actual; el fixture ya tiene esa forma
web bibliotecaapps/web/src/server/library-fetch.ts:120; server/queries/library-queries.mock.ts:58; server/queries/home-rails.ts:56Cada filtro tiene que excluir channelSin cambios
mockspackages/mocks/src/blender-catalog.ts:38 (WorkKind)Fixtures de canal como WorkFixtures nuevos en live-channels.ts sobre tipos de dominio

Ficheros que tocaría la implementación de (B) (NO implementados)

Orden sugerido, cada paso con su consumer real (r28 §3):

  1. Unificación de SourceKind (ver "Dos SourceKind divergentes"): packages/domain/src/models/SourceBinding.ts, packages/api-contracts/src/sources.ts (reexport + type.enumerated en los tres schemas), apps/sources-svc/src/core/handlers/SourcesHandler.ts, apps/sources-svc/src/service/resolvers/source-resolver.ts, apps/sources-svc/src/service/db/sources-repo.ts + apps/sources-svc/test/service/resolvers/source-resolver.test.ts. Consumer: el resolver actual. No depende del lock.
  2. packages/domain/src/types/ids.ts, packages/domain/src/models/{LiveService,BroadcastService,ServiceSourceBinding,EpgChannelBinding,Programme}.ts, packages/domain/src/models/index.ts, packages/domain/test/models.test.ts. Consumer: el paso 3.
  3. packages/api-contracts/src/{playback,playback-plan,catalog,sources}.ts + packages/api-contracts/test/api-contracts.test.ts (casos válido/inválido de PlaybackSubject, assetId legado, assetId + subject a la vez rechazado, kind cerrado).
  4. apps/catalog-svc/src/service/db/schema.ts, apps/catalog-svc/migrations/0002_live_services.sql, apps/catalog-svc/src/service/db/catalog-repo.ts, apps/catalog-svc/src/core/handlers/CatalogHandler.ts, apps/catalog-svc/src/operations/registry.ts, apps/catalog-svc/src/service/nats/query-server.ts + tests contra Postgres real.
  5. apps/playback-svc/src/core/handlers/PlaybackHandler.ts (rama subject.kind: service va a qry.sources.resolve; profile derivado; buildSourceSelection sin URL materializada; logs y mensajes de error sin sourceUri materializada), apps/playback-svc/src/core/ports/index.ts (createSession recibe el descriptor de fuente discriminado), apps/playback-svc/src/service/daemon/media-daemon-client.ts (materialización en una variable local que sólo se serializa en el campo de fuente remota de CreateSession; todos los logs y mensajes de error con la ref canónica o el requestId, no sólo :292; sin stat para remote, porque stat sobre una URL da ENOENT, devuelve la URL en el mensaje de :409 y degrada la sesión, :396-420) + apps/playback-svc/test/core/handlers/playback-handler.test.ts + apps/playback-svc/test/service/daemon/media-daemon-client.test.ts. Depende de 4a y 4b.
    • Test por el production path de resolvePlan con sujeto service y credentialRef a un secreto conocido: ni el ISessionDescriptor serializado (plan.source.uri, plan.delivery.url), ni el reply de catalog, ni los logs, ni los spans contienen el literal; el frame CreateSession sí lo contiene. Mutante: copiar la URL materializada en source.uri pone el test en rojo.
    • Test del adapter real (createMediaDaemonClient, no un fake del puerto) contra el servidor de Unix socket que ya usa media-daemon-client.test.ts (habla el frame protocol real): logs capturados a nivel debug (el mínimo, para que entren :292 y :346) y el message de cada Err que devuelve createSession, recorriendo happy path, timeout de reply, reply no JSON, tipo de reply inesperado y eventos inesperados. El literal del secreto no aparece en ninguno; sí en el frame que recibe el servidor. Se comprueba además que con remote no hay stat (una ruta inexistente no produce DAEMON_STAT_FAILED). Mutante: añadir la URL materializada al info de :390-394 pone el test en rojo.
    • 4a (zona protocols/, axon): descriptor de fuente discriminado en CreateSession con versionado de schema.
    • 4b (zona native/zig/, builder-dataplane): fuente remota r04 (sizeBytes = 0) en el daemon cuyo uri es la ref canónica (la URL materializada sólo en un campo privado de conexión, nunca logueado ni entrada de cacheKeyForChunk ni de CacheSim.observe), y redacción de sus logs con la ref canónica: control.zig:1328, :1367-1369, media_session.zig:156-158, :501, local_file.zig:435, :454, :488, :525, :540 y los del data path por chunk quiczig_transport.zig:898-900, :1083-1085, :1183-1185, :1212-1214. Test: el daemon real (binario, no un mock) recibe un CreateSession remoto con un secreto conocido, en el camino feliz y en el de error de apertura, y, con la cache activa, sirve por el transporte el mismo rango alineado de 256 KiB dos veces (MISS con write-through y luego HIT) y un tercer rango con el put de cache forzado a fallar (cache sin presupuesto); el stderr capturado no contiene el literal en ninguno de los tres. Se comprueba también que la clave de cache del chunk es fnv64(ref). Mutantes que ponen el test en rojo: (i) copiar la URL materializada en source.uri; (ii) loguear la URL materializada en el info de cache HIT del data path.
  6. apps/sources-svc/src/service/resolvers/source-resolver.ts + tests (candidato origin: 'broadcast'), almacén de credenciales por cuenta de proveedor detrás de secretsFor (apps/sources-svc/src/service/plugins/registry.ts:44), y SourcesHandler.ts sin uri materializada en reply, evento ni log. Test: un binding con credentialRef resuelve y ni el reply, ni el evento, ni los logs capturados, ni los spans del exporter en memoria contienen el secreto.
  7. packages/mocks/src/live-channels.ts sobre tipos de dominio y apps/web/src/server/queries/live-channels.ts contra el query real (zona client: builder-client).

Fuera de esta implementación: ingest XMLTV, matching fuzzy, ventana rodante, catch-up y grabación (track/epg); adapter Xtream (integrations/xtream); runtime .live del daemon (ventana rodante, perfil MOQT .live; dataplane). La fuente remota mínima y la redacción de logs del paso 4b sí son parte de B: sin ellas un sujeto service no abre sesión.

Preguntas abiertas para el lock

  1. Authority de live/EPG. La propuesta es que catalog-svc posea LiveService, BroadcastService, EpgChannelBinding y Programme, y sources-svc la resolución de bindings, igual que en VOD, sin servicio nuevo. La alternativa es un epg-svc, que r17 no lista y requeriría enmendarlo.
  2. Nombres. Esta propuesta usa LiveService, que r28 §5 deja abierto frente a Channel. BroadcastService es la emisión concreta.
  3. r04 y live. ¿Basta con sizeBytes = 0 + availability() para un feed rodante, o track/epg necesita una sub-interfaz de ventana? Si es lo segundo, es una enmienda a r04, fuera de este ADR.
  4. SourceKind canónico. Si el paso 0 no se hace antes del lock, el lock tiene que fijar la unión única (propuesta: la del contrato, dueña en @styx/domain, http en vez de http-range, sin sftp) y quién la posee. Sin eso, IServiceSourceBinding.sourceKind no tiene tipo definido.
  5. Alcance y autorización de live. Hoy ninguna query de catalog-svc ni de sources-svc lee envelope.actor (grep -rn "actor\|userId" apps/catalog-svc/src apps/sources-svc/src sólo devuelve el actor: { service } que ambos servicios ponen al emitir, más coincidencias dentro de "Factory"): el catálogo VOD es global a la instancia. Live no puede heredar eso, porque una cuenta Xtream es una suscripción de pago de alguien. Propuesta: LiveService y Programme son globales (identidad de canal y parrilla son datos públicos, como Work); BroadcastService y ServiceSourceBinding cuelgan de una cuenta de proveedor, y la cuenta pertenece a un usuario u hogar de identity-svc. listLiveServices devuelve sólo servicios con al menos un BroadcastService en una cuenta a la que el actor tiene derecho, y qry.sources.resolve con sujeto service rechaza un broadcast de una cuenta ajena. Las alternativas son "global a la instancia" (más simple; en un despliegue multiusuario cualquier usuario consume la suscripción de otro) o "por usuario" también para LiveService (duplica la identidad de canal y rompe la federación entre proveedores). Decide waxin.
  6. Quién resuelve credentialRef en la frontera del daemon. Hoy el único escritor de session-ipc es playback-svc (media-daemon-client.ts, createSession(assetPath)). Ninguna de las dos opciones evita tocar protocols/ ni el daemon: CreateSession sólo transporta un path local y el daemon sólo abre LocalFileSource (ver "La frontera del daemon hoy sólo acepta un path local"). Las dos necesitan el descriptor de fuente discriminado en el protocolo y una fuente remota en el daemon. Y en las dos el daemon acaba teniendo la URL con credencial en memoria, porque es quien abre la conexión, así que en las dos hace falta redacción en los logs de Zig. Lo que las distingue es quién lee el almacén de secretos:
    • (a) playback-svc lee el secreto por un puerto de almacén de secretos (acceso acotado al credentialRef que le devolvió sources-svc), materializa la URL en una variable local de createSession y la manda en el campo de fuente remota de CreateSession, junto a la ref canónica que el daemon usa para loguear. El secreto cruza el Unix socket local; el data plane no accede al almacén.
    • (b) CreateSession lleva el credentialRef y el daemon resuelve el secreto. El secreto no cruza el socket, pero el data plane necesita un cliente del almacén de secretos y pasa a decidir qué credencial usar. Recomendación: (a). No por ahorrar el cambio de protocolo (no lo ahorra), sino por r01: resolver una credencial es una decisión de control plane, y (b) mete en Zig un cliente de secretos y su autorización. Dónde se queda corta (a): no mantiene el secreto fuera del data plane (ninguna opción puede), y su invariante depende de dos redacciones independientes, la del adapter TS y la de los logs del daemon (pasos 4 y 4b), cada una con su test. Decide waxin.

Lock (2026-10-01, waxin)

Decisión de waxin vía AskUserQuestion (2026-10-01): "Lockear dec-0107 y dec-0109 ya", con las recomendaciones del ADR. Se lockea la opción (B): LiveService paralelo a Work, y Work queda como media finita. WorkKind es desde hoy un conjunto cerrado de media finita: añadirle live o channel viola este ADR.

Cada pregunta abierta se resuelve con la propuesta o la recomendación que el propio ADR da:

  1. P1, authority. catalog-svc posee LiveService, BroadcastService, EpgChannelBinding y Programme. sources-svc resuelve los bindings: es dueño de ServiceSourceBinding y de las credenciales de las cuentas de proveedor, y atiende qry.sources.resolve con sujeto service. No hay epg-svc y r17 no se enmienda.
    • Corrección interna. La fila "catalog-svc schema/migrations" de la tabla de impacto metía service_source_bindings en la migración 0002 de catalog-svc. Eso contradice esta respuesta y la sección "Resolución de un sujeto service". Gana la propiedad explícita: la tabla service_source_bindings vive en una migración de sources-svc, junto a source_bindings, y la 0002 de catalog-svc crea live_services, broadcast_services, epg_channel_bindings y programmes.
  2. P2, nombres. LiveService (no Channel) para la identidad lógica del canal y BroadcastService para la emisión concreta.
  3. P3, r04 y live. No se decide aquí. De entrada, un feed live es una fuente r04 con sizeBytes = 0 y availability(). Si track/epg necesita una sub-interfaz de ventana rodante, será una enmienda a r04 en ese track, con su propio AskUserQuestion. Queda anotado como pendiente de track/epg.
  4. P4, SourceKind canónico. Una sola unión: la del contrato, con dueña en @styx/domain y reexportada por @styx/api-contracts (el patrón de SourcesErrorCode). Lleva http en vez de http-range y no lleva sftp. IServiceSourceBinding.sourceKind usa esa unión.
    • Enmienda que registra este lock: fuentes remotas en plural. waxin decidió el mismo 2026-10-01 (respuesta a P5 de docs/track/byte-runtime/plans/vertical-vod-web.md, rama w8/vertical-plan) que las fuentes remotas son plurales: la ola D implementa al menos WebDAV y SFTP, y evalúa SMB como tercera. "Sin sftp" significa sólo "sin sftp mientras no tenga provider ni fuente en el daemon", que es lo que el paso 0 ya decía (r28 §3). SourceKind admite las fuentes remotas que entren en la ola D: sftp vuelve, y smb entra si la evaluación lo aprueba, cada uno en el mismo cambio que su provider o su fuente r04 del daemon. Esa adición es aditiva: un literal nuevo en la unión de @styx/domain y en su schema, sin tocar los kinds que ya existen ni las filas guardadas. No necesita otro lock.
    • Follow-up explícito: el paso 0 sigue sin hacer. El wire ya cierra la unión (los schemas de packages/api-contracts/src/sources.ts son Type.Union de literales desde dec-0116), pero siguen las dos definiciones (packages/domain/src/models/SourceBinding.ts:34 y packages/api-contracts/src/sources.ts:30). Siguen también los casts de apps/sources-svc/src/core/handlers/SourcesHandler.ts:250, :349 y :366, el "KNOWN GAP M2.A" (:240) y el testigo CONTRACT_SOURCE_KINDS. Es el criterio 8 de DM2. Necesita su ticket en sources-svc antes de que IServiceSourceBinding exista.
  5. P5, alcance y autorización. La propuesta del ADR:
    • LiveService y Programme son globales a la instancia, como Work.
    • BroadcastService y ServiceSourceBinding cuelgan de una cuenta de proveedor. Esa cuenta pertenece a un usuario o a un hogar de identity-svc.
    • listLiveServices devuelve sólo los servicios con al menos un BroadcastService en una cuenta a la que el actor tiene derecho.
    • qry.sources.resolve con sujeto service rechaza un broadcast de una cuenta ajena.
    • Hoy identity-svc no tiene hogares: los propone dec-0125 (PROPOSED). Este lock no adelanta ese ADR. Mientras no exista el hogar, la cuenta de proveedor pertenece a un usuario. La variante "hogar" se engancha cuando dec-0125 se lockee.
  6. P6, quién resuelve credentialRef. La opción (a):
    • playback-svc lee el secreto por un puerto de almacén de secretos, con el acceso acotado al credentialRef que le devolvió sources-svc.
    • Materializa la URL en una variable local de createSession.
    • La manda en el campo de fuente remota de CreateSession, junto a la ref canónica que el daemon usa para loguear.
    • El data plane no accede al almacén. Las dos redacciones (adapter TS y logs del daemon, pasos 4 y 4b) siguen siendo obligatorias, cada una con su test.

Desfase del texto con el árbol de hoy (no cambia la decisión)

El análisis se escribió sobre el árbol del 2026-09-28. Tres cosas han cambiado desde entonces y mandan sobre la letra del ADR al implementar:

  • TypeBox, no Arktype. dec-0116 (LOCKED) sustituyó Arktype por TypeBox 1.x. Donde el ADR dice "contract-first Arktype" o type.enumerated(...), léase un schema TypeBox (Type.Union de Type.Literal) en @styx/api-contracts.
  • session-ipc v2. CreateSession ya no lleva assetPath. Lleva {rootId, relPath} bajo STYX_MEDIA_ROOTS (SEC-Z07, dec-0117 I7; protocols/session-ipc/version-history.md) y viaja sobre spire (dec-0120). El descriptor discriminado del paso 4a queda así: {kind:'file', rootId, relPath} o {kind:'remote', ref, …}. Los file:line de media-daemon-client.ts, control.zig, media_session.zig y local_file.zig citados arriba son de aquella fecha. El invariante sigue igual: ningún log, mensaje de error, clave de caché ni CacheSim ve la URL materializada.
  • Fuente remota del daemon. El paso 4b coincide con D1 y D2 de la ola D del plan vertical-vod-web.md, que cuelga D1 de DM2. El backend concreto (WebDAV, SFTP o SMB) lo elige la medición D0 en su propio ADR, no éste.

Qué desbloquea y qué no

  • Desbloquea:
    • escribir código en packages/domain y packages/api-contracts, pasos 1 y 2;
    • la migración aditiva de catalog-svc, paso 3;
    • la rama service de playback-svc, paso 4;
    • el selector con origen broadcast en sources-svc, paso 5;
    • el consumer /tv, paso 6.
  • DM2 sigue open. El lock cumple los criterios 1 y 9 (este último en la parte de decisión; el test de autorización sigue pendiente) de docs/track/domain-media/plans/gate-proposal.md. Los criterios 2 a 8 exigen código y tests que hoy no existen. El 7 depende además de protocols/ (paso 4a) y del data plane (paso 4b).

Consecuencias si se lockea (B)

  • DM2 de track/domain-media se cierra con: los tipos de dominio + el contrato PlaybackSubject implementados, un test por el production path en el que un service produce delivery.profile = 'live' y un asset produce 'vod', y la migración 0002 aplicada contra Postgres real sin tocar works, un test que demuestra que listLiveServices no devuelve credenciales, un test por PlaybackHandler.resolvePlan con sujeto service que demuestra que ni el descriptor ni el reply de catalog ni logs y spans llevan el secreto, un test del adapter real media-daemon-client que demuestra lo mismo para todos los logs (desde debug) y mensajes de error de createSession, y un test del daemon real que lo demuestra para su stderr al abrir la sesión y al servir bytes con la cache activa (MISS, HIT y put fallido; ver "Credenciales de proveedor"). Los dos últimos dependen de trabajo en protocols/ y en el data plane (pasos 4a y 4b).
  • track/epg arranca sobre un modelo cerrado (r28 §5).
  • WorkKind queda documentado como conjunto cerrado de media finita. Añadirle live/channel pasa a ser una violación de este ADR.

Si se elige (A)

Se revierte sin coste hoy (0 código escrito). Habría que añadir a DM2 un guard que impida que channel aparezca en cualquier consulta de biblioteca y resolver el durationMs de la migración 0000 antes de que haya datos reales.