dec-0126

Vista generada de dec-0126: Metadatos incrustados en el propio fichero: plugin `metadata-embed`, escritura en el daemon y huella que los excluye

ImplementadoSin versión del tren todavía· generada desde docs/decisions/dec-0126-metadatos-incrustados-en-el-fichero.md
track/docsdec-0124track/docs:DC10

Vista generada desde docs/decisions/dec-0126-metadatos-incrustados-en-el-fichero.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-10-01
Ficherodocs/decisions/dec-0126-metadatos-incrustados-en-el-fichero.md

Enmienda a: dec-0114, dec-0117

Enmendado o sustituido por: dec-0127

Por qué importa (del frontmatter del ADR):

Fija cómo Styx guarda los metadatos descriptivos y la portada dentro del propio fichero de vídeo (MP4 moov/udta/meta/ilst + covr; Matroska Tags/SimpleTag + Attachments): un plugin first-class metadata-embed decide QUÉ se escribe, catalog-svc ordena CUÁNDO, y el daemon Zig escribe los bytes con raíz fd-relativa, scope SCT annotate, in-place sobre padding con journal de preimagen o reescritura a staging + rename. Fija también que la huella de contenido de dec-0114 se calcula sobre el payload (mdat / Clusters) y una lista blanca de facts, para que incrustar no cambie la identidad del asset; que es opt-in por raíz; qué se excluye (raíces RO, fuentes remotas, ficheros en seeding o con hardlinks) y qué parte del catálogo es reconstruible desde los ficheros. Sin este ADR un executor escribiría tags desde TS, con la huella atada a los bytes de cabeza del fichero, o corrompería el único ejemplar de una película de la biblioteca.

Nodos del roadmap que lo citan en refs: track/domain-media/file-ssot, track/media-engine/metadata-embed

Páginas de la documentación que lo citan: Servidores conectados y Jellyfin (especificado), Metadatos en el fichero (especificado), Modelo de datos (especificado), Capacidades SCT (implementado), Transferencias (especificado), API para agentes y MCP (especificado), Importar NFO de Jellyfin (especificado), Activar los metadatos incrustados (especificado), Raíces y fuentes (especificado), Storage Box (especificado), Comandos del CLI (especificado), Plugins (implementado), Visión (especificado)

Texto del ADR

Leído de docs/decisions/dec-0126-metadatos-incrustados-en-el-fichero.md, el fichero canónico.

dec-0126 — Metadatos incrustados en el propio fichero: plugin metadata-embed, escritura en el daemon y huella que los excluye

  • Fecha: 2026-10-01
  • Estado: LOCKED (2026-10-02), junto con dec-0127 y enmendado por él. waxin, vía AskUserQuestion: lockear juntos dec-0126 (mecánica de escritura) y dec-0127 (modelo de datos, metadata SSOT en el fichero). Las respuestas y las enmiendas que el lock aplica a este texto están en Lock (2026-10-02, waxin). Donde este ADR y dec-0127 chocan, gana dec-0127: P1 pasa a fichero-SSOT con el catálogo como índice derivado, P2 pasa a merge en las raíces que gestiona Styx, y §6/§7 quedan sustituidos. El análisis de formatos, la huella y la escritura (§2, §3.3–§5, §10, §11) no cambian. Las enmiendas a dec-0114 y dec-0117 (§8) entran en vigor con el lock.
  • Propone: lane docs (rama w9/docs), ticket track/docs#13, item DC9 del gate propuesto de track/docs. La implementación cae en track/plugin-seams (la wave del scanner de dec-0114), track/media-engine (editor de contenedor) y el daemon. Este ADR no crea nodos.
  • Numeración: dec-0123 es de w7/release-eng, dec-0124 es docs como contrato y dec-0125 es el ADR de cuentas e identidad de esta misma lane. Este toma el siguiente libre.
  • Dirección de waxin que cumple (2026-10-01, D4): "hacer todo lo posible en propios metadatos del file, no en ficheros aparte; enriquecemos con un plugin propio first class: cuando se agrega algo a la biblioteca se buscan los metadatos, cover etc. y se incrustan en el fichero; semi stateless por esa parte; ya pensaremos la forma de sincronizar todo". Biblioteca de referencia: x264/x265, 4K y 1080p, bitrates y tamaños variados, normalizada antes de entrar y normalmente no MKV. Navegador: Chrome y Chrome Canary. El remux al vuelo de MKV sigue soportado.
  • Cita:
    • r01: los bytes de vídeo no atraviesan JS. Escribir dentro de un fichero de vídeo es data plane.
    • r04: SeekableMediaSource; las fuentes remotas no son escribibles por este ADR.
    • r08: catálogo Work → Edition → MediaAsset → SourceBinding.
    • r28: cada capa con su consumidor real.
    • r48 §3.2/§3.3 y r49: precedencia de metadatos y MetadataPatch declarativo; un plugin nunca muta.
    • dec-0019: canonical media factory, AcquisitionArtifact con seeding lease.
    • dec-0030: Storage Box como durable primary.
    • dec-0110: MediaEngine dual Zig/libav.
    • dec-0114: scanner por contenido y huella SC1.
    • dec-0117: I1, I3, I5, I7, I8, I11 y I12.
    • dec-0119 y dec-0120: bus y socket de control por spire, BUS_ROUTES.
    • dec-0121: staging y rename fd-relativos de la ingesta.
    • dec-0123 (PROPOSED): el CLI styx.
    • dec-0124 (PROPOSED): la página explicacion/metadatos-en-el-fichero y el registro de operaciones headless.

1. Contexto

  1. Hoy los metadatos viven sólo en la base de datos de catalog-svc. metadata-local-nfo lee <basename>.nfo con node:fs y extrae cuatro campos (plan vertical-vod-web, fila "Metadata NFO de Jellyfin"). No hay ningún escritor de metadatos.
  2. La huella de contenido de dec-0114 §3 no existe en código. grep fingerprint da 0 hits en native/zig y en los servicios; el único hit TS es packages/domain/src/models/ AcquisitionArtifact.ts. Es el momento barato de definirla de forma que no dependa de los metadatos: después de implementarla, cambiarla obliga a re-hashear toda la biblioteca.
  3. dec-0114 §3 define la huella con "hash de una ventana de cabeza y una ventana de cola" y con "tamaño exacto". Sobre un MP4 normalizado con moov delante (faststart), la cabeza es moov. Sobre un MKV con Tags o Attachments al final, la cola es metadato. Incrustar una portada cambiaría las dos ventanas y el tamaño. El asset dejaría de reconocerse a sí mismo.
  4. El daemon ya tiene la base de una escritura segura. Las raíces son fd-relativas (dec-0117 I7, {rootId, relPath} de extremo a extremo). La ingesta hace staging y rename fd-relativos bajo su raíz (dec-0121). Y media-core tiene parsers ISOBMFF y Matroska con BoundedReader y fuzz (native/zig/media-core/container/isobmff.zig, matroska.zig; matroska.zig:359 ya reconoce Tags y Attachments como hijos de Segment).
  5. El motor libav reportaría la portada como una pista de vídeo. facts_from_av.zig:120-150 mapea cada AVStream y no mira AV_DISPOSITION_ATTACHED_PIC. El demuxer MP4 de FFmpeg expone covr como un stream de vídeo con esa disposición. Tras incrustar, engine=libav vería una pista MJPEG/PNG de más y el motor Zig no. Los dos motores divergen y el planner podría elegirla.
  6. La biblioteca de waxin entra normalizada y casi siempre en MP4. El caso principal es, por tanto, ISOBMFF con moov delante. Matroska es el segundo caso, y en él la edición in-place es estructuralmente más fácil (§3.2).

2. Investigación de formatos

2.1 Matroska / WebM

  • Tags (0x1254C367, hijo de Segment): uno o más Tag. Cada Tag lleva Targets y SimpleTag anidables (TagName, TagLanguage o TagLanguageBCP47, TagString o TagBinary, y SimpleTag hijos, como ACTOR con un CHARACTER dentro).
  • TargetTypeValue (matroska.org, Tagging) para vídeo: 70 COLLECTION, 60 SEASON/SEQUEL/VOLUME, 50 MOVIE/EPISODE/CONCERT, 40 PART/SESSION, 30 CHAPTER, 20 SCENE, 10 SHOT. Una película va en 50. Un episodio lleva un Tag a 50 (el episodio), otro a 60 (la temporada) y otro a 70 (la serie).
  • Identificadores externos estándar en la especificación de tags:
    • IMDB: tt seguido de al menos 7 dígitos.
    • TMDB: dígitos con prefijo obligatorio movie/ o tv/.
    • TVDB: Series ID, legado.
    • TVDB2: dígitos con prefijo obligatorio series/, episodes/ o movies/.
    • Styx escribe IMDB, TMDB y TVDB2 con esos formatos exactos, y lee también TVDB.
  • Attachments (0x1941A469): cada AttachedFile lleva FileDescription, FileName, FileMediaType, FileData y FileUID. La convención de portadas de matroska.org es:
    • nombres cover.(jpg|png) (normal, 600 px en el lado menor), small_cover.* (120 px), cover_land.* y small_cover_land.*;
    • nombres sensibles a mayúsculas, sólo JPEG o PNG;
    • la portada normal debería ser el primer AttachedFile en orden de almacenamiento.
  • Escritura in-place como la hace mkvpropedit (MKVToolNix, src/common/kax_analyzer.cpp, update_element):
    • Si el elemento nuevo cabe en el sitio del viejo más los EBML Void contiguos, se sobrescribe ahí.
    • Si no cabe, se escribe en otro Void lo bastante grande o se añade al final del fichero. Para Tags la estrategia es siempre "al final". El hueco viejo pasa a ser Void y los Void contiguos se fusionan.
    • Se actualizan las entradas del SeekHead (meta seek). Si el SeekHead de cabeza no tiene sitio, se crea uno al final y el de cabeza se enlaza a él.
    • Se ajusta el tamaño del Segment cuando se añade al final.
    • Consecuencia: en Matroska, incrustar casi nunca obliga a mover Clusters. Es una escritura al final más parches de pocos bytes en SeekHead, en el tamaño del Segment y en los Void.
    • Los límites: un Segment de tamaño desconocido, un Segment que no es el último elemento de nivel 0, y un SeekHead sin sitio ni Void contiguo. En esos casos hace falta reescribir.
  • CRC-32 (0xBF) es opcional como primer hijo de un elemento maestro. Si el Tags viejo lo lleva, el editor lo recalcula o no lo emite. Nunca deja un CRC inválido.

2.2 MP4 / ISOBMFF (el caso principal)

  • Dónde: moov/udta/meta con hdlr de tipo mdir, e ilst con los items al estilo iTunes. Es lo que leen ffprobe/libavformat, MP4Box, AtomicParsley, mutagen y las aplicaciones de Apple.
    • Trampa conocida: en ISO meta es una FullBox (4 bytes de versión y flags) y en QuickTime clásico no. El parser acepta las dos formas.
    • El escritor emite la forma ISO, que es la que escribe FFmpeg en MP4.
  • Items: los items estándar se escriben con su tipo correcto del átomo data (1 = UTF-8, 21 = entero BE, 13 = JPEG, 14 = PNG):
    • ©nam título; ©day fecha; ©gen género;
    • desc y ldes descripción corta y larga;
    • tvsh, tvsn, tves, tven serie, temporada, episodio e id de episodio;
    • stik tipo de medio (9 película, 10 serie);
    • covr portada, con uno o más átomos data, de tipo 13 (JPEG) o 14 (PNG).
  • Ids externos: MP4 no tiene un estándar. Se usan items libres ---- (mean + name + data) en el espacio dev.mks2508.styx, con los mismos nombres y formatos de valor que Matroska (IMDB, TMDB, TVDB2). Así un solo modelo de tags sirve para los dos contenedores.
  • Coste de crecer moov:
    • Con moov delante (faststart, el caso de la biblioteca de waxin), si udta crece más allá del espacio libre, mdat se desplaza y todos los offsets de stco/co64 cambian. Eso obliga a reescribir el fichero entero: 4K son decenas de GB.
    • La solución estándar es reservar padding: una caja free (o skip) justo detrás de udta dentro de moov, o justo detrás de moov en el nivel 0.
    • AtomicParsley mantiene un padding de este tipo. FFmpeg reserva espacio para moov delante con -moov_size. -movflags +faststart mueve moov delante con una segunda pasada que reescribe el fichero.
    • Con padding suficiente, la edición toca sólo la cola de moov: udta más la cabecera de free, y el tamaño de moov si free está fuera.
    • No cambia ningún offset de mdat ni ningún byte de las tablas de muestras.
  • MP4 fragmentado (CMAF): moov es pequeño y no lleva tablas de muestras. Las mismas reglas aplican sobre su udta. El mfra del final no se toca.
  • Los navegadores (Chrome incluido) ignoran udta. Incrustar no cambia la reproducción directa si la escritura es correcta.

2.3 Lo que este ADR no afirma

No se afirma qué campos leen Jellyfin, Plex o Kodi de los tags incrustados, ni con qué nombre. La compatibilidad con lectores de terceros se mide en el ticket con fixtures escritos por Styx y leídos por cada herramienta. Ningún claim comparativo sin WC-JF12 (dec-0112).

3. Decisión

3.1 Reparto: el plugin decide qué, el core decide cuándo, el daemon escribe

EtapaQuiénQué
Buscar metadatos y artworkplugin metadata-embed (kind metadata, r48)consulta proveedores (TMDb y otros, opcionales y con egress declarado) al añadir a la biblioteca, y devuelve MetadataPatch con evidencia, como cualquier proveedor
Mergecore (catalog-svc)aplica la precedencia de r48 §3.2: lock manual > autoridad local (incluidos los tags ya incrustados y el NFO) > orden configurado > confianza
Proyección a contenedorplugin metadata-embed (kind nuevo embed)planEmbed(snapshot, containerFacts, embeddedNow) → EmbedPlan: función pura y declarativa. Dice qué tags y adjuntos deben quedar, en el modelo de tags común de §2, y no ve bytes de vídeo
Orden de escrituracore (orquestador de embed en catalog-svc)decide si y cuándo se escribe (§4), pide la capability y manda el comando. Lleva un ledger idempotente por (assetId, embedRevision)
Escrituradaemon Zigtraduce EmbedPlan a operaciones de contenedor, verifica precondiciones, escribe con journal o con reescritura, y verifica después (§5)
  • El kind embed se añade a PluginExportsByKind en el mismo cambio que su primer consumidor (el propio metadata-embed), como exige manifest.ts. Nombres finales en el ticket.
  • El plugin nunca escribe ni recibe fds (dec-0117 I11). Devuelve datos y el core los aplica.
  • Los artworks son imágenes, no vídeo. Pueden pasar por TS, pero no viajan inline por el socket de control. El core los deja en un almacén de artwork direccionado por contenido: un volumen propio, que catalog escribe y el daemon monta ro como una raíz más de I7. El EmbedPlan los referencia por sha256 y tamaño. El daemon verifica el hash antes de incrustar.

3.2 Modelo de tags común (un solo vocabulario, dos serializaciones)

  • Campos de obra: título, título original, fecha de estreno, géneros, sinopsis corta y larga, clasificación, y en series: serie, temporada, episodio y título del episodio. Los nombres siguen la especificación de Matroska (TITLE, DATE_RELEASED, GENRE, SYNOPSIS, SUMMARY, LAW_RATING…) y su correspondencia con los items ilst de §2.2 es una tabla fija y versionada en media-core.
  • Ids externos: IMDB, TMDB, TVDB2, con los formatos de §2.1. Son la clave de reconstrucción (§6).
  • Portada: cover (vertical) y, opcional, cover_land. En MKV se escriben como Attachments con la convención de nombres de matroska.org y la normal primero. En MP4 van en covr: el primer data es la vertical y el segundo, si existe, la horizontal. Tamaño de la normal: 600 px en el lado menor. El fondo (backdrop) y el resto del artwork se quedan en el catálogo.
  • Tags de control de Styx (STYX_* en MKV, ----:dev.mks2508.styx:* en MP4):
    • STYX_SCHEMA: versión del modelo de tags.
    • STYX_KIND: movie o episode.
    • STYX_EDITION: etiqueta de edición (r08), por ejemplo "Director's Cut".
    • STYX_EMBED_DIGEST: hash de los valores que Styx escribió. Detecta ediciones de terceros (§7).
  • Lo que nunca va en el fichero: datos de usuario, de perfil o de hogar (visto, progreso, valoraciones, listas), rutas, ids internos del catálogo, ids de actor ni nada de @styx/authz. El fichero viaja: se copia, se comparte, se siembra. Sólo lleva metadatos públicos de la obra.
  • Tags ajenos: los que no son de Styx se conservan siempre. Un campo estándar ya presente y no escrito por Styx (o escrito por Styx y cambiado después, según STYX_EMBED_DIGEST) no se pisa en el modo por defecto merge. Se lleva a la cola de revisión. El modo overwrite es explícito por raíz o por petición.

3.3 La huella excluye los metadatos (enmienda a dec-0114 §3)

Precisado en el lock (dec-0127, identidad por layout; dec-0132 M3): esta huella es la del layout: file audiovisual. Un árbol se identifica por la raíz de su manifiesto canónico (sin .styx/), y una imagen o un fichero firmado por el hash del fichero entero.

La huella fingerprintVersion: 1 se define sobre el payload y sobre una lista blanca de facts. No usa los bytes de cabeza y cola del fichero.

  • Payload:
    • MP4: la concatenación lógica de los payloads de mdat (en fragmentado, los de cada par moof/mdat).
    • MKV: la concatenación de los elementos Cluster completos, desde el primero hasta el último.
  • Componentes:
    • payloadSize, que sustituye a "tamaño exacto";
    • BLAKE3 de la ventana de cabeza y de la de cola del payload (mismos tamaños que fije el ticket de dec-0114, del orden de 64 KiB);
    • digest de facts canónico sobre una lista blanca: por pista, códec, perfil, nivel, extradata/codec_private, timescale, número de muestras, duración, dimensiones, frecuencia de muestreo y canales. Más la duración global.
  • Fuera de la huella:
    • tags, adjuntos, capítulos, udta/meta/free/skip, Void, SeekHead, CRC-32;
    • Info/Title, MuxingApp, WritingApp y DateUTC; mvhd/tkhd creation y modification time;
    • nombre, idioma y flags default/forced de pista, que mkvpropedit también edita;
    • las pistas attached_pic y de adjunto;
    • cualquier offset absoluto. Así una reescritura que mueve mdat y reescribe stco, o un MKV al que se le añade un elemento al final, conservan la huella.
    • La lista es blanca a propósito: lo que no está listado no entra.
  • Hash completo opcional de dec-0114: pasa a ser el hash del payload, no del fichero.
  • Hash compatible OpenSubtitles: por definición es de bytes del fichero. Se calcula y se guarda antes de la primera escritura, que es cuando se usa para buscar fuera, y se marca preEmbed. Deja de ser una señal tras incrustar, y no se recalcula.
  • Propiedad obligatoria (test de propiedad y fuzz en media-core): para todo fichero válido F y todo EmbedPlan P, huella(aplicar(P, F)) == huella(F), y facts_zig(aplicar(P, F)) ≡ facts_zig(F) en la lista blanca. Lo mismo con engine=libav, una vez filtradas las pistas attached_pic (§1.5).

3.4 Capability: scope SCT nuevo annotate (enmienda a dec-0117 I3)

  • La escritura de metadatos es una escritura del daemon. Por I1 exige SCT, y por I3 un scope de escritura propio. Se añade el scope annotate:
    • Recurso ligado: {rootId, relPath, dev, ino, size, mtimeNs, planDigest}. La precondición es la observación del fichero que produjo el plan, y planDigest es el hash del EmbedPlan exacto.
    • Un solo uso por jti y TTL corto.
    • Sólo autoriza a cambiar elementos de metadatos. El daemon lo hace cumplir comprobando antes y después que la huella de payload no cambia (§5). Un annotate nunca escribe bytes de mdat ni de Cluster.
  • Emisor: playback-svc, el único firmante de SCT (A4 de dec-0117). Firma a petición de catalog-svc por un comando de bus (cmd.playback.authorizeAnnotate, nombre final en el ticket) que BUS_ROUTES sólo permite a la identidad de servicio de catalog. Comprueba que el actor que originó la orden (usuario o automatismo de la raíz) tiene un permiso nuevo de @styx/authz (provisional asset:metadata-write, junto a asset:play en policy.ts).
  • Transporte: cmd.media.embedMetadata por el socket de control de spire (dec-0120), el mismo camino que openIngest, con la SCT annotate, el EmbedPlan y las referencias de artwork. Respuestas y eventos: evt.media.metadataEmbedded y evt.media.metadataEmbedFailed con motivo tipado. Lectura sin capability de escritura: qry.media.readEmbedded devuelve los tags como datos y el artwork al almacén (§3.1). Es metadato, nunca bytes de vídeo.
  • Defensa en profundidad: hacen falta tres cosas a la vez. El par autenticado del socket de control (I2), la SCT annotate ligada al plan, y la raíz marcada como escribible (§4.1). Que un servicio que no sea playback quede comprometido no basta para escribir.
  • I12: cada escritura, rechazo, recuperación o restauración emite evt.security.* o evt.media.* con jti, rootId, hash del path y motivo. El path en claro sólo va en debug local.

4. Cuándo se escribe y cuándo no

4.1 Opt-in por raíz, nunca por defecto

Enmendado en el lock (dec-0127 §9.3 y P1): el valor por defecto deja de ser off en todas las raíces. Las raíces que gestiona Styx nacen en merge; las existentes se ofrecen en merge desde el asistente. Ver Lock, punto 2.

  • Cada raíz del daemon declara access: ro | rw-metadata y cada raíz de biblioteca en el catálogo declara metadataEmbed: off | merge | overwrite (por defecto off) y allowRewrite: false | true (por defecto false, §5.2).
  • Enmienda a dec-0117 I8: las raíces de media siguen montadas ro salvo las que el operador marca rw-metadata. Esas se montan rw y check:deploy exige coherencia entre el compose y la config del daemon.
    • El daemon comprueba al arrancar que una raíz rw-metadata es escribible (statvfs ST_RDONLY y faccessat).
    • Si no lo es, la sirve como ro y reporta salud degradada (embed unavailable). Falla cerrado: nunca reintenta escribir.
  • Exclusiones por glob dentro de una raíz (por ejemplo, la carpeta de descargas de un cliente torrent que comparte disco).

4.2 Disparadores

  • Al añadir a la biblioteca, tras la identificación de dec-0114:
    • sólo con confianza ≥ τ_auto o con confirmación del usuario en la cola de revisión;
    • nunca con una identificación dudosa, porque un metadato equivocado dentro del fichero viaja con él y es peor que uno equivocado en la base de datos.
  • En la ingesta (dec-0121): se incrusta en el fichero de staging antes del rename final. Coste cero: el fichero nunca existe sin metadatos.
  • En la normalización y canonicalización (dec-0019): la receta escribe metadatos y padding en el mismo mux. Es el sitio natural para la biblioteca de waxin, que ya pasa por un paso de normalización.
  • Bajo demanda (UI, API, CLI): library.metadata.embed por asset o por raíz, con --dry-run que muestra el diff entre los tags actuales y el plan sin escribir nada.
  • Ediciones posteriores en el catálogo no reescriben el fichero en cada cambio. Se agrupan con un debounce configurable y se vuelcan por lotes (R6).

4.3 Exclusiones duras (el catálogo es caché y fallback)

CasoPor quéQué pasa
Fuentes remotas (HTTP, S3, WebDAV, Storage Box por SFTP, Jellyfin, Xtream)SeekableMediaSource es de lectura (r04). Reescribir un objeto remoto de decenas de GB no es una ediciónMetadatos sólo en el catálogo. Se incrustan cuando Styx escribe el fichero: ingesta, promoción al Storage Box desde el staging local, canonicalización
Raíz ro, montaje ro o Storage Box montado rono escribibleSólo catálogo, con salud informada
Fichero en seeding: AcquisitionArtifact con seeding lease activo (dec-0019) o binding de swarmcualquier byte cambiado rompe los hashes de pieza del torrentExcluido hasta que expire el lease. Al promover a canonical, se incrusta
st_nlink > 1un hardlink, típico de los flujos *arr que enlazan descargas con biblioteca: in-place modificaría también la copia que se siembra, y reescribir rompe el enlace y duplica el discoExcluido en los dos modos, salvo override explícito por raíz
Propietario o permisos que el uid del daemon no puede escribirel daemon no es root (I8) y no hace chownExcluido, informado como permission
Formato no soportado por el editor (AVI, TS, MKV con Segment de tamaño desconocido sin allowRewrite)—Sólo catálogo

5. Cómo escribe el daemon

El editor vive en native/zig/media-core/container/edit/. Es puro: con la estructura parseada y un EmbedPlan, devuelve una lista de regiones {offset, bytes} o "necesita reescritura". Lo ejecuta un módulo del daemon. El motor es Zig propio: libav sólo sabe escribir un fichero nuevo completo, así que dec-0110 no aporta aquí un segundo motor. Los parsers que usa son los de media-core, con I5 (BoundedReader, aritmética checked, fuzz).

5.1 In-place con journal de preimagen (modo por defecto)

Aplica cuando el plan cabe:

  • en MP4, en udta + padding free;
  • en MKV, en el sitio del elemento viejo más los Void contiguos, o como añadido al final del Segment más los parches de SeekHead y del tamaño del Segment.
  1. openat2(root_fd, relPath, RESOLVE_BENEATH|…) con O_RDWR (I7). Luego fstat: fichero regular, dev de la raíz, nlink == 1, y (dev, ino, size, mtimeNs) igual al de la SCT. Si no coincide, se aborta con PRECONDITION_FAILED: el catálogo re-probea y vuelve a planificar.
  2. Lease de edición por (dev, ino) en el daemon:
    • espera, con plazo, a que no haya lecturas abiertas sobre ese inodo, y bloquea aperturas nuevas mientras dura;
    • si el plazo vence, el job se reintenta más tarde con backoff;
    • además flock(LOCK_EX|LOCK_NB), que sólo protege frente a herramientas que lo respetan.
  3. Lee la preimagen de todas las regiones afectadas, con un tope configurable del orden de 16 MiB (un moov de dos horas ocupa pocos MB).
  4. Escribe un registro de journal en el directorio de estado del daemon (volumen rw propio, fd-relativo, nunca en la raíz de media) y hace fsync del fichero y del directorio. El registro lleva jti, rootId, hash del path, (dev, ino), el tamaño viejo y, por región, offset, preimagen y sha256 de la imagen nueva.
  5. pwrite de las regiones (en MKV, primero el añadido al final y luego los parches) y fdatasync.
  6. Post-verificación: se re-parsea la región de metadatos con el parser de producción y se recalcula la huella de payload, que sólo lee ventanas.
    • Si la huella no es idéntica, o los tags leídos no son los del plan, se restaura la preimagen, se trunca al tamaño viejo y se emite un evento de alarma. Eso es un bug, no un caso esperado.
  7. Borra el registro de journal y hace fsync del directorio. Invalida las entradas de la caché L1/L2 que solapan las regiones escritas. El mtime nuevo se conserva a propósito: no se restaura, porque herramientas como rsync usan tamaño + mtime para detectar cambios y hay que avisarles. El ETag del byte path cambia con él.
  8. Recuperación al arrancar: por cada registro pendiente, abre y verifica (dev, ino) y compara cada región.
    • Si todas tienen la imagen nueva, se da por hecho.
    • Si todas tienen la preimagen, se descarta.
    • Si hay mezcla, se escribe la preimagen, se trunca al tamaño viejo y se hace fsync.
    • Emite evt.media.metadataEmbedRecovered.

Los lectores que ya parsearon moov y están leyendo mdat no se ven afectados por diseño: no cambia ni un byte de las tablas de muestras ni del payload. El lease existe para que nadie lea la cola de moov a medio escribir.

5.2 Reescritura a staging + rename (sólo con allowRewrite)

Aplica cuando no hay padding, el SeekHead de MKV no tiene sitio, o el Segment es de tamaño desconocido.

  1. Mismas precondiciones que en §5.1, y además espacio libre suficiente en el sistema de ficheros de la raíz.
  2. Escribe el fichero nuevo en un staging dentro de la misma raíz (mismo sistema de ficheros, para que el rename sea atómico), con el patrón fd-relativo de la ingesta (dec-0121):
    • metadatos, más padding reservado (por defecto 1 MiB) para que las ediciones siguientes sean in-place;
    • el payload copiado con copy_file_range, que usa reflink o copia en el servidor si el sistema de ficheros lo soporta;
    • en MP4, stco/co64 reescritos con el desplazamiento nuevo.
  3. fsync, verificación de la huella de payload (igual a la original) y del parse completo.
  4. renameat sobre el original y fsync del directorio. El inodo cambia:
    • las lecturas abiertas siguen leyendo el inodo viejo hasta cerrarse;
    • las reaperturas fallan por el (dev, ino) de I7 y playback recrea la sesión;
    • el ledger de escaneo recibe la tupla nueva en la respuesta y no re-identifica, porque la huella es la misma.
  5. Se conservan permisos (fchmod) y los xattrs que el daemon puede leer. Si el propietario no es el uid del daemon, la reescritura no se intenta (§4.3).
  6. Coste: leer y escribir el fichero entero. La operación va a la clase de E/S background del byte runtime, con presupuesto y cancelable, y nunca compite con la reproducción.
  7. fallocate(FALLOC_FL_INSERT_RANGE) (insertar bloques sin copiar, en ext4/XFS) queda como optimización futura: no es atómico y su recuperación exige journal de la cabecera entera. Pregunta P5.

5.3 Normalización con padding reservado

  • La primera escritura con allowRewrite deja padding. A partir de ahí todo es in-place.
  • Para la biblioteca de waxin, que ya se normaliza fuera de Styx, la recomendación es que la normalización deje moov delante y una caja free de padding. Así ni siquiera la primera incrustación reescribe un 4K.
    • La receta exacta (flags y herramienta) la fija y verifica el ticket con ficheros reales. Este ADR no publica flags sin comprobarlos.
    • Styx ofrece la misma operación como styx library prepare --root <id>: una reescritura con padding, con presupuesto, --dry-run y avance reanudable.
  • Para Chrome: la normalización a MP4 con moov delante es también lo que hace barata la reproducción directa por byte-range, que es el camino principal de la ola A del plan vertical. Incrustar no cambia esa propiedad.

6. Catálogo semi-stateless

Sustituida en el lock por dec-0127 §5–§7 (fichero = autoridad, catálogo = índice derivado, overlay para lo no escribible). Se conserva como registro de la propuesta.

  • Reconstruible desde los ficheros:
    • identidad de la obra (ids externos), tipo, edición y metadatos descriptivos;
    • la portada;
    • el vínculo fichero → asset, por la huella de payload.
    • Un catálogo perdido se reconstruye con un reescaneo. El scanner de dec-0114 ya cuenta "tags embebidos" como señal. Con STYX_SCHEMA presente y un id externo válido, la propuesta llega con confianza 1 y sin red.
  • No reconstruible (necesita la base de datos y su backup): el estado de usuario por perfil (visto, progreso, valoraciones), colecciones manuales, la cola de revisión, el historial de provenance, el artwork que no se incrusta y los locks manuales.
    • Esto no es una carencia: el estado de usuario no debe viajar con el fichero (§3.2).
  • La base de datos sigue siendo la autoridad de lo que sirve la API. El fichero es una proyección duradera que sobrevive a la base de datos y a la migración entre servidores.

7. NFO de Jellyfin y sincronización

La parte de sincronización queda sustituida en el lock por dec-0127 §5 y §6.2 y por la adopción inteligente de su lock (P3). La parte de NFO como importación sigue vigente.

  • NFO = vía de importación. metadata-local-nfo importa movie.nfo, tvshow.nfo, season.nfo y el NFO de episodio. El uniqueid tmdb/imdb/tvdb es una señal de confianza 1 (plan vertical, B4). En una raíz con embed activo, lo importado se incrusta y el NFO deja de hacer falta. Styx no borra ni reescribe el NFO: no toca ficheros que no son suyos.
  • Exportar NFO para convivir con Jellyfin en la misma biblioteca: posible como plugin aparte. Fuera de este ADR.
  • Sincronización fichero ↔ catálogo: pregunta abierta (P1). Lo que este ADR sí deja hecho para cualquier respuesta:
    • STYX_EMBED_DIGEST permite detectar que un tercero cambió los tags escritos por Styx (por ejemplo con mkvpropedit o con mp3tag).
    • La detección ocurre en el reescaneo, cuando la tupla barata del ledger cambia.
    • Hoy una diferencia va a la cola de revisión y no se resuelve sola.

8. Enmiendas propuestas (efectivas en el lock)

  • dec-0114 §3: la huella se calcula sobre el payload con facts en lista blanca (§3.3 de este ADR). "Tamaño exacto" pasa a ser payloadSize. El hash completo opcional pasa a ser hash del payload. El OpenSubtitles se toma antes de la primera escritura.
    • Como la huella no está implementada, no hay migración. Si la wave del scanner la implementa antes de este lock, tiene que usar ya esta definición o versionarla como 0 desechable.
  • dec-0117 I3: nuevo scope annotate (§3.4).
  • dec-0117 I8: raíces rw-metadata opt-in (§4.1).
  • dec-0117 I7 no cambia: se aplica tal cual a la apertura O_RDWR y al staging.

9. Superficie headless (dec-0124 §6.1)

Operaciones del registro (ids provisionales; la paridad la comprueba check:headless-parity):

OperaciónQuéCLI
library.root.updatemetadataEmbed, allowRewrite, exclusionesstyx library root set <id> --embed merge
library.metadata.embedincrustar un asset o una raíz; dryRun devuelve el diffstyx library embed <asset|--root id> [--dry-run]
library.metadata.embedStatusestado por asset: sin escribir, escrito rev N, pendiente, excluido(motivo), conflictostyx library embed status … --json
library.preparereescritura con padding (§5.3)styx library prepare --root <id>
library.metadata.importimportar NFO de Jellyfinstyx library import nfo --root <id>
  • Sin --dry-run las operaciones son destructivas: agentSafe: false, y piden confirmación en el CLI y en MCP.
  • UX:
    • el interruptor por raíz explica los riesgos de §10;
    • la ficha muestra el estado del fichero y el diff;
    • los conflictos se resuelven en la cola de revisión.

10. Riesgos

#RiesgoMitigación
R1Corromper el único ejemplar de una películaopt-in por raíz; journal con preimagen y recuperación; post-verificación con restauración; propiedad de §3.3 con fuzz; --dry-run; reescritura sólo con allowRewrite. Recomendar backup de la raíz antes de activarla
R2Incrustar una identificación equivocadasólo con ≥ τ_auto o confirmación; STYX_* marca lo escrito por Styx; quitar lo incrustado es una operación más del editor
R3Que la huella cambie al incrustarpayload + lista blanca (§3.3); test de propiedad en el gate. El riesgo real es que la huella se implemente antes del lock con la definición vieja
R4La portada aparece como pista de vídeo en engine=libav (facts_from_av.zig:120-150 no filtra attached_pic) y el planner la eligefiltrar AV_DISPOSITION_ATTACHED_PIC y las pistas de adjunto en facts, y test diferencial Zig/libav sobre un fixture con covr y otro con Attachments antes de la primera escritura
R5Romper el seeding o un hardlinklease de seeding (dec-0019), nlink > 1 excluido, globs. Un cliente torrent que siembra el mismo fichero sin hardlink no se puede detectar: lo cubre el opt-in, con aviso en la doc
R6Backup y sincronización externos (rclone/SFTP al Storage Box, Time Machine) re-suben un fichero de decenas de GB por cada ediciónescribir una vez al añadir; agrupar ediciones con debounce; documentar el coste en operacion/
R7Relajar I8 da a un RCE del daemon escritura sobre la media de esas raícessólo raíces opt-in; I5/I6/I10 siguen aplicando; el resto de raíces sigue ro. Se acepta como riesgo residual documentado
R8Carrera con otro escritor externo durante la ediciónprecondición (dev, ino, size, mtimeNs) en la SCT, re-verificada tras el lease; flock como mejor esfuerzo. Queda una ventana residual entre el último fstat y la escritura
R9Variantes de formato: meta FullBox o no, CRC-32, Segment de tamaño desconocido, SeekHead sin sitio, moov con largesizeel parser acepta todas; el editor declara "necesita reescritura" en vez de improvisar; fixtures de FFmpeg, MP4Box, AtomicParsley, Apple y mkvmerge
R10Términos de los proveedores (atribución de TMDb, redistribución de imágenes): un fichero con portada incrustada que se comparte o se federa (r08) lleva el artwork consigoproveedores opcionales y declarados; por defecto, sólo portada; la federación de ficheros con artwork incrustado se revisa en su propio ADR
R11Privacidad: lo incrustado viaja con el ficherolista cerrada de campos públicos de obra (§3.2); test que falla si un STYX_* o un item contiene ids de usuario, de hogar o rutas
R12Memoria y E/S: preimagen de moov grande, reescrituras de 4Ktope de preimagen; clase background con presupuesto; cancelación por request

11. Alternativas rechazadas

  • Sidecars (NFO + poster.jpg) como almacén. Rechazada por waxin: son ficheros aparte que se desincronizan al mover o renombrar. Quedan como vía de importación.
  • Escribir los tags desde TS con una librería de tags. Viola r01 (abre y escribe un fichero de vídeo desde JS), salta I7 y no tiene journal ni lease frente a las lecturas del daemon.
  • Huella sobre bytes de cabeza y cola del fichero (dec-0114 §3 literal). Cada incrustación cambiaría la identidad del asset.
  • Reescribir siempre a staging + rename. Es seguro, pero cuesta un fichero entero por edición y 2x de disco transitorio. Queda como fallback y como paso único de preparación.
  • ffmpeg -c copy -metadata … en un worker. Es lo mismo que reescribir siempre, con un proceso externo (r07) y sin control de padding ni de huella.
  • Catálogo 100 % stateless (todo en el fichero, incluido el estado de usuario). El estado de usuario es por perfil y cambia en cada reproducción: escribirlo en el fichero es incorrecto (privacidad) y destructivo (E/S, seeding, backups).

12. Preguntas para waxin (bloquean el lock)

  • P1 — Sincronización: catálogo autoritativo y fichero como proyección, con detección de ediciones externas que van a revisión (recomendado para empezar). O fichero autoritativo con el catálogo como caché. O bidireccional por campo.
  • P2 — Valor por defecto por raíz: off (recomendado, opt-in explícito) o merge en las raíces locales nuevas.
  • P3 — Qué artwork se incrusta: sólo la portada vertical (recomendado), o también la horizontal y versiones pequeñas.
  • P4 — Padding: 1 MiB (recomendado; despreciable frente a un 1080p o un 4K) u otro valor; y si styx library prepare debe recomendarse antes de activar el embed.
  • P5 — Reescritura: permitir allowRewrite en raíces con copia (recomendado sólo opt-in) y si se investiga FALLOC_FL_INSERT_RANGE.
  • P6 — Seeding: excluir siempre los ficheros con nlink > 1 (recomendado) o permitir el override por raíz.
  • P7 — Proveedores: cuáles se activan por defecto en el plugin (TMDb requiere clave de API y atribución) o ninguno hasta que el usuario lo configure.

13. Consecuencias

  • track/plugin-seams gana el kind embed y el plugin metadata-embed, en la wave del scanner de dec-0114. track/media-engine gana el editor de contenedor y el filtro attached_pic. El daemon gana cmd.media.embedMetadata, qry.media.readEmbedded, el journal y el lease de edición. playback-svc gana el scope annotate. BUS_ROUTES gana las rutas de §3.4.
  • El gate de la wave que lo implemente incluye como mínimo:
    • la propiedad de §3.3 (fuzz incluido);
    • la recuperación del journal con fault injection: un corte tras cada paso de §5.1;
    • el diferencial Zig/libav con portada;
    • el rechazo de las exclusiones de §4.3;
    • una incrustación real sobre un 4K x265 normalizado de la biblioteca de waxin, reproducido en Chrome antes y después con huella idéntica.
  • La página explicacion/metadatos-en-el-fichero (estado especificado) y las guías de guias/biblioteca/ se atan a este ADR y a esos nodos (dec-0124).
  • No decide: la sincronización (P1); los proveedores concretos; el watcher en tiempo real; la exportación a NFO; la federación de ficheros con artwork; nombres finales de interfaces y subjects (los fija el ticket con su consumidor); nodos ni milestones en styx.model.yml.

Lock (2026-10-02, waxin)

Decisión de waxin vía AskUserQuestion (2026-10-02): lockear juntos este ADR (cómo se escribe dentro del fichero) y dec-0127 (qué es autoridad y cómo se lee rápido). Los dos describen una misma escritura y desde hoy se leen juntos. Donde chocaban, gana dec-0127.

  1. P1, sincronización → fichero autoritativo. Se retira la recomendación "catálogo autoritativo y fichero como proyección". La metadata de la obra tiene su fuente de verdad en el styx.json incrustado (dec-0127 §4.1). El catálogo es un índice derivado y reconstruible, y el overlay es la única autoridad provisional, sólo para lo que no se puede escribir (dec-0127 §6.3). En consecuencia:
    • §6 queda sustituido por dec-0127 §5–§7. La frase "La base de datos sigue siendo la autoridad de lo que sirve la API" deja de valer: lo que sirve la API sale del índice, que es una proyección del fichero.
    • Los locks manuales y el workUid pasan a la lista de lo reconstruible (van en el documento, dec-0127 §4.1).
    • La parte de sincronización de §7 la sustituyen dec-0127 §5, §6.2 y la adopción inteligente de su lock (P3). STYX_EMBED_DIGEST se conserva con la definición de dec-0127 §4.2.
  2. P2, valor por defecto por raíz → merge en las raíces propias. Las raíces que gestiona Styx (ingesta de dec-0121, raíces canónicas o normalizadas de dec-0019, raíces creadas desde la UI) nacen con metadataEmbed: merge, in-place y allowRewrite: false. En una raíz existente que se añade (por ejemplo, la biblioteca de Jellyfin) el asistente ofrece activar merge y explica los riesgos de §10. No se activa sin un sí explícito, y mientras tanto la raíz vive en overlay sin perder nada. El montaje rw-metadata de §4.1 (enmienda a dec-0117 I8) sigue siendo explícito por raíz: una raíz propia lo trae configurado; una existente lo gana al aceptar el asistente.
  3. §3.2, modelo de tags común → proyección de styx.json. La tabla fija de §3.2 pasa a ser la proyección pura y determinista del documento (dec-0127 §4.2), y se añade el propio documento (adjunto styx.json en Matroska, item ----:dev.mks2508.styx:doc en MP4).
  4. §3.1, almacén de artwork por contenido. Se mantiene y gana variantes derivadas servidas por el daemon con la SCT de scope art (lock de dec-0127, P4).
  5. P7, proveedores → sin clave por defecto + asistente. El enriquecimiento arranca con los proveedores que no necesitan clave (documento incrustado, tags, NFO y nombre de fichero). El asistente de primer arranque pide la clave de TMDb. Mismo texto que dec-0127 P7.
  6. P3–P6, sin respuesta separada. waxin lockeó el ADR entero sin contestar estas cuatro por separado. Se lockean con la opción recomendada por el propio ADR, como parámetros reversibles sin ADR nuevo:
    • P3 (corregido por waxin, 2026-10-02: "incrustar más artwork"): van en bytes dentro del fichero la portada vertical, la horizontal (backdrop/fanart principal), el logo (clearlogo) y la miniatura de episodio cuando aplica. Matroska: un attachment por pieza con los nombres de la convención (cover, cover_land, logo, thumb); MP4: covr múltiple más referencias en styx.json. Variantes y tamaños derivados siguen siendo caché del daemon (dec-0127 §8.1), nunca bytes en el fichero. Presupuesto por defecto: 4 MiB de artwork por fichero, reversible por raíz.
    • P4: padding de 4 MiB (sube desde 1 MiB por el artwork adicional de P3), y styx library prepare recomendado antes de activar el embed en una raíz existente.
    • P5: allowRewrite sólo opt-in por raíz. FALLOC_FL_INSERT_RANGE no se investiga en esta wave.
    • P6: nlink > 1 excluido siempre, con override explícito por raíz.
  7. Identidad por layout (dec-0132 M3). La huella de §3.3 es la del layout: file audiovisual y no cambia. Para árboles, imágenes y ficheros firmados manda la sección de identidad por layout del lock de dec-0127.
  8. Lo que no cambia: §2 (formatos), §3.3 (huella sobre payload y lista blanca, que dec-0127 usa para AssetId), §3.4 (scope annotate), §4.2 (disparadores; la ingesta escribe el documento antes del rename), §4.3 (exclusiones duras), §5 (in-place con journal, reescritura con allowRewrite, padding), §9 (superficie headless), §10 (riesgos) y §11 (alternativas).
  9. Enmiendas a ADRs locked que entran en vigor hoy (§8): dec-0114 §3 (huella sobre payload y facts en lista blanca), dec-0117 I3 (scope annotate) e I8 (raíces rw-metadata). Los dos llevan banner de enmienda desde este lock.

El lock desbloquea el código de producción que depende de este ADR (editor de contenedor en media-core, cmd.media.embedMetadata, scope annotate, kind embed). No crea nodos ni gates: la wave que lo implemente los propone en styx.model.yml con el gate mínimo de §13 y el de dec-0127 §14.

Back-refs

  • dec-0114 y dec-0117 llevan banner de enmienda desde el lock (2026-10-02).
  • dec-0127 (LOCKED el mismo día) enmienda P1, P2, §3.2, §6 y §7.
  • dec-0124 §6.3 nombra este ADR como el que cierra D4.
  • Relacionados: dec-0019 (seeding lease, canonicalización), dec-0110 (motores), dec-0121 (staging fd-relativo), dec-0030 (Storage Box).