Vista generada de dec-0107: Discriminante live/VOD: `LiveService` paralelo a `Work`, y `Work` queda como media finita
docs/decisions/dec-0107-discriminante-live-vod.mdVista generada desde
docs/decisions/dec-0107-discriminante-live-vod.md. No se edita a mano:bun run docs:genla regenera ybun run docs:checkfalla si difiere. El estado aquí es el del model: si discrepa con otra página, manda el model.
| Campo | Valor |
|---|---|
| Estado | LOCKED |
| Fecha | 2026-09-28 |
| Fichero | docs/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
WorkKindmás (packages/domain/src/models/Work.ts:26) o como un agregado paralelo aWork. 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'aWorkKind, 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)
Leído de docs/decisions/dec-0107-discriminante-live-vod.md, el fichero canónico.
LiveService paralelo a Work, y Work queda como media finitaAskUserQuestion: "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.domain-adr, rama w2/domain-adr).track/domain-media (DM2 live-vod-discriminant).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" deWorkKindse implementa como registro validado y no como unión de literales. Cada kind registrado declarafinite: true;channelyliveestán vetados. El reply HTTP cierrakindconpatternmás validación contra el registro, no conType.Union. Lo que este ADR protege no cambia: lo live no entra enWork. El resto (opción B,LiveService, P1–P6) sigue igual (dec-0132§4.2 y lock, punto 6).
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.
Cada una de estas suposiciones es falsa para un canal:
| Suposición | Dó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 obligatoria | packages/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 asset | packages/domain/src/models/SourceBinding.ts:18 (assetId: AssetId) |
| El byte source tiene tamaño y se lee por offset | packages/source-sdk/src/source.ts:97-103 (sizeBytes, "0 si desconocido, ej: streams live") y readAt(offset) |
| La reproducción se pide por asset | packages/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 defecto | apps/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.
WorkKind con live / channeltype 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.
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.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.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.Work(kind: 'episode'), y entonces cada ingest XMLTV (miles de filas al
día, efímeras) crea obras en el catálogo durable.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.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)─→ WorkLiveService 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.works queda intacta.live va a LiveService, y vod
y series van a Work con SourceBinding(sourceKind: 'xtream'), igual que cualquier fuente
VOD.asset da .vod y service da .live (profile.zig:33), en vez de ser un flag
suelto.live-channels.ts:4-6).WorkKind nuevos sin ninguna interacción con live.En contra:
assetId tiene que aprender un sujeto discriminado.
Es un cambio de contrato en playback.ts, catalog.ts y sources.ts, aunque retrocompatible
(ver "Contrato").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).
(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.
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.
@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 } };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.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.serviceHoy 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.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.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.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").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).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:
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.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.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.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ón | Valores | Quién la usa |
|---|---|---|
Dominio, packages/domain/src/models/SourceBinding.ts:34-43 (+ schema :49-59) | local, http-range, s3, webdav, sftp, torrent, jellyfin, xtream, composite | ISourceBinding 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:29 | local, http, s3, webdav, torrent, jellyfin, xtream, composite | El 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):
@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.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.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).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.
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.
| Salida | Evidencia |
|---|---|
Reply de qry.sources.resolve: uri del ganador y reasons[].uri de cada candidato | SourcesHandler.ts:378, :389; sources.ts:79, :89-92 |
Evento evt.sources.availabilityChanged | SourcesHandler.ts:352-356; sources.ts:123 |
Log warn al fallar el publish | SourcesHandler.ts:360-364 |
Fila persistida source_bindings.uri | SourcesHandler.ts:339, sources-repo.ts:42, :62 |
Reply de qry.catalog.resolvePlaybackSource: sourceUri | packages/api-contracts/src/catalog.ts:175; apps/catalog-svc/src/service/nats/query-server.ts:294 |
IPlaybackPlan.source.uri, devuelto al cliente en ISessionDescriptor.plan | apps/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 capabilities | PlaybackHandler.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 createSession | media-daemon-client.ts:409 (asset not found: ${assetPath}) y :417 (cannot stat asset ${assetPath}: …), ambos DAEMON_STAT_FAILED |
Logs del daemon Zig en CreateSession | native/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.uri | native/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 identidad | Clave 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):
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.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.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.
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.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.credentialRef al escribir CreateSession: pregunta abierta 6."Implementación" = qué cambia cuando se implemente B tras el lock. En la columna A se indica qué exigiría la alternativa, para comparar.
| Consumer | Evidencia (file:line) | Con (A) | Con (B) — implementación |
|---|---|---|---|
@styx/domain Work | packages/domain/src/models/Work.ts:26, :37 | + 'channel' en tipo y schema; cada consumer de WorkKind tiene que excluirlo | Sin cambios de forma; JSDoc de WorkKind declara "media finita" |
@styx/domain Edition | Edition.ts:17-23 | Canal con Edition sintética custom + tags | Sin cambios |
@styx/domain MediaIndex | MediaIndex.ts:22, :71 | durationMs: 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 SourceBinding | SourceBinding.ts:18, :34-43, :49-59 | assetId 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 nuevos | packages/domain/src/models/index.ts:1-10, types/ids.ts:17-41 | Programme colgado de WorkId | + LiveService, BroadcastService, ServiceSourceBinding, EpgChannelBinding, Programme + 5 ids branded + tests en packages/domain/test/models.test.ts |
@styx/api-contracts playback | playback.ts:57-63 (ResolvePlanPayloadSchema), :155-159, :186-189 | Sin cambios (todo es assetId) | PlaybackSubject discriminado + normalización retrocompatible de assetId |
@styx/api-contracts playback-plan | playback-plan.ts:108-115, :148 | profile tendría que inferirse del WorkKind en un lookup extra | + IDeliveryPlan.profile derivado; PlaybackMode sin cambios |
@styx/api-contracts catalog | catalog.ts:130-147 (kind: 'string'), :155-165 | Sin cambios de forma; kind sigue abierto | kind cerrado a WorkKind; + queries de live/EPG con consumer |
@styx/api-contracts sources | sources.ts:60, :66, :122-130 | Sin cambios | Payload de resolve acepta subject; el candidato lleva origin: 'asset' | 'broadcast' |
| catalog-svc schema/migrations | apps/catalog-svc/src/service/db/schema.ts:19-27; migrations/0000_nice_night_nurse.sql; 0001_editions_aggregate.sql | works.kind admite channel; media_indexes.duration_ms 0 o nullable; queries de biblioteca excluyen channel | Migració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/operations | CatalogHandler.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-svc | apps/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ético | Rama 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 daemon | apps/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-75 | El 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 siempre | createSession 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 Zig | protocols/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 hoy | Cambio 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-policy | packages/playback-policy/src/planner.ts:69-112 | Sin cambios | Sin cambios; sólo recibe delivery.profile ya derivado |
| source-sdk | packages/source-sdk/src/source.ts:97-103 | Igual que B | Igual 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-svc | source-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 uri | Paso 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:30 | live → Work(channel); vod/series → Work | live → 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ódigo | Programme cuelga de WorkId; cada ingest toca works | Ingest XMLTV → Programme idempotente; matching → EpgChannelBinding auditado. Rolling window, catch-up y grabación se quedan en track/epg |
| data plane MOQT | native/zig/media-daemon/moqt/profile.zig:33; dec-0104 | profile se infiere de work.kind === 'channel' | profile = f(subject.kind), explícito en IDeliveryPlan |
web /tv | packages/mocks/src/live-channels.ts:4-6, :31-42 (ILiveChannelView); apps/web/src/server/queries/live-channels.ts:4 | Contradice el fixture ("Los canales NO son obras") | ILiveChannelView se proyecta desde LiveService + Programme actual; el fixture ya tiene esa forma |
| web biblioteca | apps/web/src/server/library-fetch.ts:120; server/queries/library-queries.mock.ts:58; server/queries/home-rails.ts:56 | Cada filtro tiene que excluir channel | Sin cambios |
| mocks | packages/mocks/src/blender-catalog.ts:38 (WorkKind) | Fixtures de canal como Work | Fixtures nuevos en live-channels.ts sobre tipos de dominio |
Orden sugerido, cada paso con su consumer real (r28 §3):
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.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.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).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.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.
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.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.protocols/, axon): descriptor de fuente discriminado en CreateSession con versionado de schema.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.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.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.
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.LiveService, que r28 §5 deja abierto frente a Channel.
BroadcastService es la emisión concreta.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.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.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.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:
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.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.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:
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.
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.LiveService (no Channel) para la identidad lógica del canal y
BroadcastService para la emisión concreta.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.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.
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.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.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.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.credentialRef. La opción (a):
credentialRef que le devolvió sources-svc.createSession.CreateSession, junto a la ref canónica que el
daemon usa para loguear.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:
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.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.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.packages/domain y packages/api-contracts, pasos 1 y 2;service de playback-svc, paso 4;broadcast en sources-svc, paso 5;/tv, paso 6.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).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).WorkKind queda documentado como conjunto cerrado de media finita. Añadirle live/channel
pasa a ser una violación de este ADR.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.