dec-0127

Vista generada de dec-0127: Modelo de datos: el fichero es la fuente de verdad, el catálogo es un índice derivado

ImplementadoSin versión del tren todavía· generada desde docs/decisions/dec-0127-modelo-de-datos-fichero-ssot.md
track/docsdec-0124track/docs:DC10

Vista generada desde docs/decisions/dec-0127-modelo-de-datos-fichero-ssot.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-0127-modelo-de-datos-fichero-ssot.md

Enmienda a: dec-0126, dec-0114, dec-0117

Por qué importa (del frontmatter del ADR):

Fija dónde vive cada dato del modelo de Styx. La metadata de la obra tiene su fuente de verdad en el propio fichero, como documento estructurado canónico (styx.json, JSON canónico RFC 8785) más su proyección a tags estándar y portada. El catálogo guarda un índice derivado, desechable y reconstruible desde los ficheros, que sirve los listados con cero lecturas de fichero. Fija también: metadataHash = BLAKE3 del documento, para detectar cambios y sincronizar barato; ETag = hash en la API, más un feed de cambios por revisión para las cachés de cliente, que son la única copia fuera del servidor; artwork en un almacén direccionado por contenido, servido por el daemon con variantes precalculadas; ids estables derivados del fichero (huella y workUid); estado de usuario por (actorId, profileId) fuera del fichero; overlay del catálogo sólo para lo no escribible; y el plugin de enriquecimiento first-class, activo por defecto y reemplazable. Enmienda dec-0126 P1/§6/§7. Sin este ADR, el catálogo se convertiría en la autoridad de facto, el fichero sería una exportación más, y un índice perdido dejaría la biblioteca, el estado de usuario o las cachés de cliente sin forma de reconciliarse.

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

Páginas de la documentación que lo citan: Metadatos en el fichero (especificado), Modelo de datos (especificado)

Texto del ADR

Leído de docs/decisions/dec-0127-modelo-de-datos-fichero-ssot.md, el fichero canónico.

dec-0127 — Modelo de datos: el fichero es la fuente de verdad, el catálogo es un índice derivado

  • Fecha: 2026-10-01
  • Estado: LOCKED (2026-10-02), junto con dec-0126. waxin, vía AskUserQuestion: lockear juntos dec-0126 (mecánica de escritura) y dec-0127 (modelo de datos, metadata SSOT en el fichero), con respuesta a las siete preguntas de §13. Las respuestas, lo que el lock elige donde waxin delegó (P2), los criterios que fija (P3, P5) y la identidad por layout y por tipo (dec-0132 M3) están en Lock (2026-10-02, waxin). Donde el texto de §1–§14 y el lock difieren, gana el lock. Las enmiendas a dec-0126, dec-0114 y dec-0117 (§11 y lock) entran en vigor hoy.
  • Propone: workflow de visión (rama w10/adr-datos, desde w10/vision). Número reservado para el modelo de datos en docs/overview/vision-ledger.yml. La implementación cae en track/domain-media (modelo e ids), track/plugin-seams (enriquecimiento y embed), track/media-engine (lectura y escritura del documento en el contenedor), el daemon (artwork) y catalog-svc (índice y feed de cambios). El milestone track/domain-media/file-ssot es un alta propuesta queued en el model, pendiente de ratificar en el lock de este ADR (sin gate hasta entonces).
  • Items del ledger que cubre: V-N01, V-DATA-01, V-DATA-02, V-DATA-03, V-DATA-04 y V-DATA-05. Cubre en parte V-N04 (marcas de intro y créditos como capítulos dentro del fichero, §4.3; la detección sigue sin nodo) y V-N07/V-N10/V-XFER-02 (toda descarga generada lleva su documento, §9).
  • Dirección de waxin que cumple (2026-10-01, N1, respuesta a P7 del plan vertical): "lo q se escribe que trataremos como un json por ejemplo o un yaml/toml o algo asi pero vivira duplicado solo en los files, es decir, la metadata SSOT vive en el fichero lo maximo posible. si tu descarghas ese fichero y lo reproduces donde sea, por airplay, en vlc o en cualquier reproducor ya tiene los metadatos, no es algo que viva solo en nuestra BBDD o de estar colocado junto a un file .nfo"; "si se duplica la info que se duplique en el cliente, en la cache del cliente que la pida, para minimizar accesos, buscar una forma barata de yener sync y detectar cambios, hashear la metadata"; "debe ser rapido el acceso pero tampoo para listar 50 items en una pagian tener que acceder 50 veces a archivos, pero si toca hacerlo, que no sea un suplicio tampoco. esto lo dices a las protadas pero aplica un poco en todo el modo de datos"; y "el plugin para enriquecer/normalizar , sera firsrt class y de los de por defecto, pero desativable/configurable/reemplazable".
  • Cita:
    • r01: los bytes de vídeo no atraviesan JS. Leer o escribir dentro del fichero lo hace el daemon.
    • r04: SeekableMediaSource. Las fuentes remotas son de lectura.
    • r08: catálogo federado Work → Edition → MediaAsset → SourceBinding.
    • r12/r45: caché por tiers y eviction por coste de regeneración.
    • r17: un servicio por authority. Este ADR no añade servicios.
    • r28: cada capa con un consumidor real.
    • r48 §3.2/§3.3 y r49: precedencia de metadatos, MetadataPatch declarativo y authority: local-authoritative|derived.
    • r58: asset contracts del Experience Contract, que fijan los tamaños de artwork.
    • dec-0019: canonicalización y seeding lease.
    • dec-0033: plugin SDK tipado.
    • dec-0109: facts en @styx/domain y parser en media-core.
    • dec-0114: scanner por contenido, huella y ledger de escaneo.
    • dec-0117: I1, I3, I5, I7 e I8.
    • dec-0121: ingesta por conduit.
    • dec-0124 (PROPOSED): docs como contrato y registro headless.
    • dec-0125 (PROPOSED): estado por perfil con clave (actorId, profileId).
    • dec-0126 (PROPOSED): cómo se escribe dentro del fichero. Este ADR decide qué es autoridad y cómo se lee rápido. No toca el cómo se escribe.

1. Contexto

  1. dec-0126 deja hecha la mecánica de escritura: el plugin proyecta, el daemon escribe in-place sobre padding con journal o reescribe a staging, y la huella excluye los metadatos. Pero en §6 declara que "la base de datos sigue siendo la autoridad de lo que sirve la API", y en P1 recomienda "catálogo autoritativo y fichero como proyección". La dirección N1 de waxin es la contraria. El ledger lo registra como conflicto (V-N01, dec-0126:510).
  2. dec-0126 P2 recomienda off por defecto en cada raíz. Waxin quiere el enriquecimiento "de los de por defecto" (V-DATA-04, conflicto con dec-0126:513).
  3. Hoy el catálogo tiene cinco tablas (apps/catalog-svc/src/service/db/schema.ts: works, editions, media_assets, media_indexes e ingest_usage). Los ids son internos y aleatorios. MediaAsset.path guarda una ruta. No hay estado de usuario: "seguir viendo" es un mock (packages/mocks/src/continue-watching.ts). dec-0125 fija su clave pero no su dueño (dec-0125 §13).
  4. El artwork no se sirve: la web pinta poster: '' (plan vertical, fila "Artwork", rama w8/vertical-plan). La P7 de ese plan (quién sirve el artwork, catalog-svc o el daemon) la responde este ADR.
  5. Con el fichero como fuente de verdad aparecen tres riesgos que este ADR tiene que cerrar.
    • Coste: una página de 50 items no puede costar 50 aperturas de fichero.
    • Divergencia: el índice, las cachés de cliente y el fichero tienen que poder compararse sin leer el contenido entero.
    • Identidad: si el índice es desechable, los ids que cuelgan de él (estado de usuario, listas, enlaces compartidos) no pueden ser aleatorios.

2. Referentes y estado del arte

ReferenteQué haceQué tomamos / qué evitamos
beets (música, beet write/beet update)Base de datos más tags en el fichero. write vuelca la base al fichero y update relee los tags. Se salta los ficheros cuyo mtime no cambió: un cambio externo que no toque el mtime no se ve.Tomamos los dos verbos (escribir el fichero, releerlo). Evitamos la detección sólo por mtime: además de la tupla barata, comparamos el hash del documento (§5).
digiKam (Metadata Settings)Deja elegir dónde se escribe: sólo en el fichero, fichero y sidecar XMP, o sidecar sólo para ficheros no escribibles (vídeo, RAW). Opción "leer del sidecar e ignorar lo incrustado".El mismo hueco existe aquí: un fichero no escribible necesita otra casa. La nuestra es el overlay del catálogo (§6.3), no un sidecar, porque waxin descartó los ficheros aparte.
Lightroom Classic (advanced metadata actions)Tres estados por foto: "metadata file needs to be updated", "metadata was changed externally" y error/conflicto cuando cambió en los dos lados. El usuario elige importar o sobrescribir.Tomamos el modelo de estados casi tal cual (fileState, §6.2). La diferencia es de autoridad: aquí, si sólo cambió el fichero, gana el fichero sin preguntar.
Navidrome (navidrome scan)Indexa los tags en su base de datos. El escaneo rápido usa el mtime de las carpetas, el completo lo ignora, y versiones recientes añaden un hash de carpeta.Dos niveles de detección (tupla barata y hash) y el índice como caché reconstruible con un escaneo completo.
Jellyfin / Plex / KodiLa base de datos es la autoridad. NFO y artwork en carpeta son opcionales (Jellyfin sólo los escribe con el saver activado; ver el plan vertical, §1).Es lo que N1 rechaza. El NFO queda como importación (dec-0126 §7).
XMP en MP4 (XMP Part 3)Paquete XMP (RDF/XML) en una caja uuid BE7ACFCB-97A9-42E8-9C71-999491E3AFAC. FFmpeg lo extrae de MOV/MP4.Alternativa considerada para el documento (§12). Se descarta como formato principal por peso y por canonicalización; queda como exportación posible.
Matroska AttachmentsAttachedFile admite cualquier FileMediaType.Llevamos el documento como adjunto propio (§4.1).
HTTP (RFC 9110 ETag/If-None-Match, RFC 8246 immutable) y JSON canónico (RFC 8785, JCS)Validadores fuertes, recursos inmutables por URL y una serialización JSON determinista.ETag = hash del documento (§7). Las URLs de artwork llevan el hash, son inmutables (§8), y el hash se calcula sobre JCS.

Lo que no se afirma: qué campos lee cada reproductor de terceros (VLC, Infuse, AirPlay) de los tags estándar. Se mide con fixtures en el ticket, como dice dec-0126 §2.3.

3. Decisión (resumen)

  1. El fichero manda. La metadata de la obra (descriptiva, ids externos, edición, artwork, locks de curación, marcas de capítulo) tiene su fuente de verdad en un documento estructurado canónico dentro del fichero: styx.json, en JSON canónico RFC 8785. Junto a él va su proyección a tags estándar y la portada, para que VLC, AirPlay o cualquier reproductor la vean (§4).
  2. El catálogo es un índice derivado. catalog-svc guarda una proyección del documento y de los facts del contenedor para listar, buscar, ordenar y filtrar. Es desechable y se reconstruye desde los ficheros. Nunca va por delante del fichero, salvo en el overlay explícito de lo que no se puede escribir (§6).
  3. metadataHash = BLAKE3-256(JCS(documento)). Se guarda en el índice junto a la tupla barata de observación. Detecta cambios, decide si hay que releer y es el validador de las cachés (§5).
  4. Presupuesto de acceso: un listado, una búsqueda o una ficha en caliente cuestan cero lecturas de fichero. En frío, una sola petición por lotes al daemon, con lecturas acotadas a la región de metadatos y sin bytes de artwork ni de vídeo. Ninguna petición de listado espera a una lectura en frío (§7.3).
  5. Duplicados sólo validados: el índice del servidor (derivado) y las cachés de cliente, validadas por hash. Cada respuesta lleva ETag, y hay un feed de cambios por revisión monotónica (§7).
  6. Artwork: almacén direccionado por contenido (sha256, el de dec-0126 §3.1), servido por el daemon con URLs inmutables y variantes precalculadas fuera del camino de servicio. El documento referencia todo el artwork por hash. Sólo la portada viaja en bytes dentro del fichero (§8).
  7. Ids estables derivados del fichero: AssetId de la huella de payload y WorkId de un workUid que vive en el documento. El estado de usuario y los enlaces sobreviven a una reconstrucción del índice y a la migración entre servidores (§6.4).
  8. El estado de usuario nunca va en el fichero. Vive en catalog-svc con clave (actorId, profileId, workKey|assetKey) (§10).
  9. Enriquecimiento first-class, activo por defecto, desactivable y reemplazable, con proveedores configurables por campo. Es un slot con una implementación activa por biblioteca (§9).
  10. Enmienda a dec-0126: P1 se resuelve hacia fichero-SSOT, y §6 y §7 se sustituyen por §5–§7 de este ADR. Su análisis técnico de escritura y huella se conserva entero (§11).

4. Qué se escribe en el fichero

4.1 El documento styx.json

Precisado en el lock: el documento reserva kinds.<id> para el esquema de cada tipo de contenido, y para los formatos que no se pueden escribir vive en un sidecar .styx/ o en el overlay. El audiovisual no cambia. Ver el lock, identidad por layout.

  • Formato: JSON en UTF-8, serializado con JCS (RFC 8785): claves ordenadas, números canónicos y sin espacios. Dos escritores que produzcan el mismo contenido producen los mismos bytes y, por tanto, el mismo hash.
    • YAML y TOML se descartan como forma almacenada, porque no tienen una canonicalización estándar.
    • Las herramientas (CLI, UI, MCP) pueden mostrar el documento en YAML. Lo almacenado y hasheado es siempre JCS.
  • Dónde va:
    • Matroska: un AttachedFile con FileName styx.json y FileMediaType application/vnd.styx.metadata+json. Va detrás de cover.*, para respetar la convención de matroska.org de "portada primero" (dec-0126 §2.1).
    • MP4: un item libre ---- con mean dev.mks2508.styx, name doc y data de tipo 1 (UTF-8), dentro del mismo moov/udta/meta/ilst que dec-0126 §2.2. Así cabe en el mismo padding y lo cubre el mismo journal.
  • Tope: 256 KiB sin contar artwork, un orden de magnitud por debajo del padding de 1 MiB de dec-0126 P4. Lo que no cabe (reparto completo, por ejemplo) se recorta por prioridad declarada en el esquema y se marca truncated.
  • Esquema (styx-metadata/v1, TypeBox en @styx/domain, contract-first, dec-0116). Los nombres finales los fija el ticket con su consumidor:
BloqueContenido
cabeceraschema, workUid (UUIDv7 generado en la primera escritura, §6.4), kind (movie, episode…), edition (key, name, kind de Edition)
obratítulos (con idioma), fecha, géneros, sinopsis corta y larga, clasificación, colección pública (por ejemplo la de TMDb), serie/temporada/episodio, reparto y equipo por nombre e id externo, sin ids internos
idsimdb, tmdb, tvdb2 con los formatos de Matroska (dec-0126 §2.1), y otros proveedores por espacio de nombres
artworklista de {role, sha256, bytes, w, h, mime, embedded, source:{provider, ref}}, donde role es cover, cover_land, backdrop, logo, thumb… embedded: true sólo para la portada (§8.1)
marcasmarkers: [{kind: intro|credits|recap|preview, startMs, endMs, source, confidence}]. Se proyectan a capítulos nativos (§4.3). Detectarlas es V-N04, fuera de este ADR
pistasetiquetas humanas de pista (nombre, idioma, default/forced), que el contenedor también tiene de forma nativa. Las nativas mandan, y el documento sólo guarda lo que el contenedor no sabe expresar (por ejemplo "comentario del director")
curaciónlocks: [campo] (locks manuales, r48 §3.2) y provenance resumida por campo: {provider, at, confidence}. El historial completo se queda en el catálogo
  • Lo que nunca va (se mantiene dec-0126 §3.2, con dos cambios):
    • nunca va estado de usuario, de perfil o de hogar, ni rutas, ni ids internos del catálogo, ni nada de @styx/authz;
    • cambio 1: los locks manuales sí van. Son curación de la biblioteca, no datos personales, y sin ellos un fichero movido a otro servidor perdería sus correcciones y el enriquecimiento las pisaría;
    • cambio 2: el workUid sí va. Es una identidad de obra que no dice nada de nadie.

4.2 La proyección a tags estándar

  • Se deriva del documento de forma pura y determinista (el planEmbed de dec-0126 §3.1 recibe el documento). Usa la tabla fija de dec-0126 §3.2 (©nam, ©day, desc, covr…; TITLE, DATE_RELEASED… en Matroska).
  • Existe para los reproductores de terceros. No es autoridad. Si un tercero la cambia y el documento no, eso es un cambio externo (§6.2), no un cambio de la fuente de verdad.
  • STYX_EMBED_DIGEST (dec-0126 §3.2) pasa a ser el hash de la proyección esperada: BLAKE3(proyección(documento)). Así se detecta una edición de terceros sin comparar campo a campo.

4.3 Capítulos

  • Las marcas de intro y créditos se escriben como capítulos nativos: Chapters/EditionEntry en Matroska, y en MP4 la lista de capítulos que lee el ecosistema (chpl o pista de texto de capítulos; el ticket mide cuál leen Chrome, VLC y Apple, sin afirmarlo aquí).
  • Los capítulos que ya traía el fichero se conservan. Styx sólo añade o edita los suyos, que van marcados por el documento.
  • La huella ya excluye los capítulos (dec-0126 §3.3).

5. metadataHash y la detección de cambios

5.1 Qué se hashea

  • metadataHash = BLAKE3-256(bytes JCS de styx.json). Se usa BLAKE3 por coherencia con la huella de dec-0114. Se muestra con prefijo m1- y en base32.
  • El documento referencia el artwork por sha256. Por tanto, cambiar una imagen cambia el hash sin hashear imágenes en cada comprobación.
  • La proyección no entra en el hash. Su integridad la cubre STYX_EMBED_DIGEST.

5.2 Dos niveles, como Navidrome, pero con hash

  1. Tupla barata del ledger de escaneo de dec-0114 §5: (dev, ino, size, mtimeNs) en local, o etag/mtime/size en remoto. Si no cambió, no se lee nada.
  2. Si cambió, el daemon lee sólo la región de metadatos (§7.4), extrae styx.json, lo recanonicaliza y calcula el hash.
    • Si coincide con el del índice, sólo se actualiza la tupla. Por ejemplo, tras un touch o tras una reescritura de dec-0126 §5.2, que cambia ino pero no la huella.
    • Si no coincide, se aplica §6.2.
  • Watcher (inotify/fanotify sobre las raíces locales) como acelerador opcional: sólo adelanta el nivel 1. El reescaneo periódico sigue siendo la garantía. dec-0126 §13 dejaba el watcher fuera, y aquí se admite sólo como optimización sin semántica propia.
  • Escaneo completo (styx library reindex --full): ignora las tuplas y relee los documentos. Es la reconstrucción de §6.5.

6. Autoridad: quién gana y cuándo

6.1 Clases de dato

Cada dato del modelo pertenece a una clase. El esquema del índice declara la clase de cada columna, y un guard falla si una columna no la declara (§12, gate).

ClaseFuente de verdadEjemplosSobrevive a perder la base de datos
F — ficherostyx.json en el ficheroWork descriptivo, Edition (name, kind, releaseDate, tags), ids externos, artwork (referencias + portada), marcas, locks, workUidSí, con un reindex
X — facts de contenidolos bytes del fichero, recalculablesMediaIndex (pistas, códecs, duración, keyframes), huella de payload, capítulos nativos ajenos, tamañoSí, con un probe (qry.media.probe)
D — derivadouna función de F + X; vive en cachés direccionadas por hashvariantes de artwork, trickplay (V-N04), proyecciones de búsqueda, blurhash o thumbhash si se adoptanSí, se regenera (r12: coste de regeneración)
C — servidorcatalog-svc / sources-svcraíces y su configuración, SourceBinding (ruta, preferencia, capabilities), ledger de escaneo, cola de revisión, historial completo de provenance, colecciones manuales del servidor, overlay (§6.3)No: backup de la base de datos
U — usuariocatalog-svc, por (actorId, profileId) (§10)progreso, visto, favoritos, valoraciones, listas, "seguir viendo", preferencias de pista por perfilNo: backup y exportación por perfil
K — caché de clienteel cliente, validada por metadataHash/ETaglo que la web o la app guardan para no volver a pedirNo aplica: se revalida

Lo que cambia respecto a dec-0126 §6: los locks y el workUid pasan de "no reconstruible" a F. El historial de provenance sigue en C, y en F queda sólo su resumen.

6.2 Estados del fichero (fileState) y quién gana

Estado por asset en el índice, inspirado en Lightroom Classic:

fileStateSignificaQué pasa
syncedel índice refleja el documento (metadataHash igual)nada
pendingWritehay una edición aceptada (usuario, enriquecimiento) que aún no está en el fichero (debounce de dec-0126 §4.2, lease ocupado, reintento)el índice muestra el valor nuevo marcado como pendiente. Es un overlay temporal. Al confirmarse la escritura (evt.media.metadataEmbedded con el hash nuevo) pasa a synced
changedExternallycambió el fichero y el índice no tenía nada pendientegana el fichero. Si cambió styx.json (otra instancia de Styx, una copia retocada, una restauración), el índice se re-deriva sin preguntar y emite evt.catalog.metadataChanged. Si sólo cambió la proyección (un tercero con mp3tag o mkvpropedit), §6.2.1
conflictcambió el fichero y había un pendingWriteno se escribe nada. Va a la cola de revisión con el diff por campo (fichero contra pendiente), y el usuario elige. Es el único caso que pregunta
overlayel fichero no se puede escribir (§6.3)el catálogo guarda el documento completo como autoridad provisional, marcado
absentel fichero no tiene styx.json (nunca incrustado, o raíz con embed off)la autoridad es el overlay si existe, y si no, lo importado (tags ajenos, NFO) con su confianza

6.2.1 Ediciones de terceros sobre la proyección

Sustituido en el lock (P3): el modo por defecto es la adopción inteligente (externalTagEdits: smart), con los criterios del lock. review, adopt e ignore quedan como overrides por raíz.

  • Si alguien cambia el título con una herramienta de tags, el documento queda desfasado de su proyección (STYX_EMBED_DIGEST no cuadra).
  • Por defecto (externalTagEdits: review), el cambio se ofrece en la cola de revisión como MetadataPatch con autoridad local (r48 §3.2). Aceptarlo reescribe el documento.
  • Con externalTagEdits: adopt, por raíz, el patch se aplica solo, salvo en campos con lock.
  • Con ignore, la proyección se regenera desde el documento en la siguiente escritura.

6.3 El overlay: lo que no se puede escribir

  • Fuentes remotas, raíces ro, ficheros en seeding, nlink > 1, formatos sin editor y raíces con embed off: son las exclusiones de dec-0126 §4.3, que siguen vigentes.
  • Para esos assets, el catálogo guarda el documento completo (mismo esquema, mismo hash) como autoridad provisional: fileState: overlay. Es la única excepción a "el fichero manda", y es explícita y visible en la UI y en library.metadata.embedStatus.
  • El overlay se materializa en el fichero en cuanto Styx lo escribe: en la ingesta (dec-0121), en la promoción al Storage Box, en la canonicalización (dec-0019), al expirar el seeding lease, o al activar el embed en la raíz. A partir de ahí el asset pasa a synced, y el overlay se borra.
  • Una fuente Jellyfin o Xtream no es un fichero: su autoridad es el servidor remoto, y el catálogo guarda una proyección de lo que ese servidor dice. La sincronización entre servidores (Styx↔Styx, Styx↔Jellyfin) es dec-0131.

6.4 Ids estables derivados del fichero

Precisado en el lock (P6): assetIdVersion: 1 congelado, y la huella de asset definida por layout (file, tree con raíz de manifiesto sin .styx/, image y firmados con el hash del fichero entero). Ver el lock.

Un índice desechable con ids aleatorios rompería todo lo que cuelga de él. Por eso:

  • AssetId = ast_ + base32(BLAKE3(huella de payload v1)) truncado a 128 bits. La huella es la de dec-0126 §3.3, que no cambia al incrustar.
    • El mismo contenido da el mismo asset en cualquier ruta y en cualquier servidor.
    • Dos copias idénticas son un asset con dos SourceBinding, que es la deduplicación de dec-0114.
  • WorkId = derivado del workUid del documento.
    • El workUid se genera una vez, en la primera escritura, salvo que el índice ya tenga una obra con el mismo id externo: entonces se reutiliza. Así el 4K y el 1080p de una película comparten obra.
    • Dos servidores que identificaron la misma película por separado tienen workUid distintos. Se reconcilian por ids externos, y eso es dec-0131.
  • EditionId = derivado de (workUid, edition.key).
  • SourceBindingId sigue siendo de servidor (clase C).
  • Los assets sin documento (absent) reciben su workUid en el overlay, y lo conservan al materializarlo.
  • Migración: los ids actuales son aleatorios y hay pocos datos reales. El cambio se hace antes de que exista estado de usuario (§10), en la wave del scanner de dec-0114.

6.5 Reconstrucción

  • styx library reindex --full reconstruye las clases F y X del índice desde los ficheros, y la D se regenera de forma perezosa. Las clases C y U salen del backup.
  • Propiedad obligatoria (test de gate): borrar las tablas del índice, reindexar y obtener un índice igual en F y X, con los mismos AssetId/WorkId/EditionId. El estado de usuario restaurado del backup sigue apuntando a sus obras.

7. Acceso rápido: el índice y las cachés

7.1 Qué guarda el índice

Resuelto en el lock (P2): almacén de documentos direccionado por contenido con los bytes JCS exactos, proyección materializada por función pura versionada y estado del fichero como tres punteros de hash. Ver el lock.

  • Columnas de proyección (clase F y X): lo que hace falta para listar, buscar, ordenar y filtrar (títulos, año, géneros, tipo, ids externos, duración, resolución, códecs, referencias de artwork con dimensiones), más metadataHash, fileState, la tupla de observación y indexedAt.
  • El documento completo como JSONB, direccionado por metadataHash. Pesa pocos KB por obra y hace que la ficha cueste cero lecturas. Es caché: si se borra, se relee. Pregunta P2: la alternativa es guardar sólo la proyección y leer la ficha del daemon.
  • Búsqueda: índice de texto completo sobre las columnas de proyección, en Postgres, que ya es el stack. Un motor de búsqueda aparte no entra sin un consumidor que lo pida (r28).
  • Los plugins leen del índice, nunca de ficheros (r48: ningún plugin lee bytes). Su acceso rápido es el ExtensionContext sobre el índice.

7.2 Presupuestos

OperaciónLecturas de ficheroCómo se comprueba
Listado, búsqueda, rails, ficha, "seguir viendo" (caliente)0contador styx_catalog_daemon_reads_total{route} en el test del gate: los handlers de consulta no llaman al daemon. Guard estático: no importan el cliente del daemon
Item aún no indexado dentro de un listado0 en la peticiónel item se devuelve con indexState: pending y llega por el feed de cambios (§7.3). Ninguna petición de listado espera a una lectura en frío
Página en frío (reindex, primer escaneo, índice perdido)1 petición por lotes al daemon (qry.media.readEmbeddedBatch, ≤ 64 items)el daemon lee en paralelo y con presupuesto (clase de E/S de escaneo); por fichero, §7.4
Comprobación de cambios en un reescaneo0 si la tupla no cambió; la región de metadatos si cambióledger de escaneo
Artwork0 lecturas del fichero de vídeosale del almacén direccionado por contenido (§8)

Los tiempos objetivo en frío (por ejemplo, 50 items en disco giratorio frente a NVMe) no se fijan a ojo: se miden en el gate sobre la biblioteca real de waxin y quedan como presupuesto con evidencia (r31).

7.3 Cachés de cliente y sincronización

  • Validadores: toda respuesta de un item lleva ETag: "m1-<metadataHash>". Una lista lleva como ETag el hash de su secuencia de (assetId, metadataHash) más su catalogRevision. Con If-None-Match, la respuesta es 304 sin cuerpo.
  • Feed de cambios: catalog-svc mantiene una catalogRevision monotónica por biblioteca, que avanza con cada cambio de F, X o C visible en la API.
    • GET /library/changes?since=<rev> (operación headless library.changes, dec-0124 §6.1) devuelve [(assetId|workId, metadataHash, op)] y la revisión nueva. Es un resumen: el cliente pide sólo lo que no tiene por hash.
    • realtime-svc empuja evt.catalog.metadataChanged a los clientes conectados con la misma forma.
    • Si since es más antiguo que la retención del feed, la respuesta es 410, y el cliente revalida su caché entera por hash (If-None-Match en lote).
  • Lo que guarda el cliente (web en IndexedDB vía TanStack Query persistido, app Swift en su almacén): items por assetId/workId con su metadataHash. Un hash igual significa contenido igual, sin pedir nada. Es la única copia fuera del servidor, que es lo que pide V-DATA-01.
  • Offline: un fichero descargado lleva su styx.json. El cliente offline lee el documento del propio fichero y no depende de su caché (§9.2).

7.4 Lectura en frío acotada (daemon)

  • MP4 faststart (el caso de la biblioteca de waxin): moov va delante. Se lee la ventana de cabeza que ya lee la huella; si moov es mayor, se hace una lectura más hasta su final. Se recorre udta/meta/ilst saltando el payload de covr, que se lee sólo para extraer la portada al almacén (§8.1) la primera vez que aparece su sha256.
  • MP4 con moov al final: una lectura de cola guiada por el tamaño de las cajas de nivel 0.
  • Matroska: SeekHead → Attachments. Se lee sólo el AttachedFile styx.json y las cabeceras de los demás, nunca su FileData. Lo mismo con Tags y Chapters.
  • Topes: el de dec-0126 §5.1 (región de metadatos ≤ 16 MiB) y styx.json ≤ 256 KiB. Pasar un tope es un error tipado, no una lectura mayor (dec-0117 I5, BoundedReader).
  • Caché del daemon: los documentos leídos van a una caché L1 por (dev, ino, mtimeNs) → (metadataHash, documento), con eviction de r45. Un reindex repetido no relee.

8. Artwork

8.1 Dónde viven los bytes

  • En el fichero: sólo la portada vertical (y cover_land si P3 de dec-0126 lo amplía), como covr/cover.jpg. Es lo que ve un reproductor de terceros.
  • En el almacén de artwork direccionado por contenido (dec-0126 §3.1: volumen propio, escrito por catalog y montado ro en el daemon como raíz I7): todas las imágenes del documento por sha256, incluida una copia de la portada extraída del fichero.
  • Reconstrucción: el documento guarda de cada imagen su sha256 y su origen (source: {provider, ref}).
    • Si el almacén se pierde, la portada sale del fichero, y el resto se vuelve a pedir a su proveedor y se verifica contra el hash.
    • Si el proveedor ya no sirve los mismos bytes, la imagen nueva entra con su hash nuevo y el documento se actualiza: es un cambio más, con provenance.
    • Así el artwork no incrustado también es F por referencia, sin engordar el fichero.

8.2 Quién lo sirve: el daemon (resuelve P7 del plan vertical)

Enmendado en el lock (P4): credencial SCT de scope art por sesión, ligada al actor y verificada por el daemon; Cache-Control: private en vez de public. Ver el lock.

  • El daemon sirve: ya tiene la caché por tiers (r12/r45), el camino HTTP/3 y H2, las raíces I7, la contabilidad por actor y la fuente remota. catalog-svc no sirve bytes.
  • URL: /art/<sha256>/<variante>, con Cache-Control: public, max-age=31536000, immutable y ETag = sha256 de la variante. Un cambio de imagen es un hash nuevo y, por tanto, una URL nueva. Nunca hay que invalidar.
  • Variantes (clase D): las fija el asset contract de r58 (tamaños por superficie, formato AVIF o WebP con fallback JPEG). Se direccionan por (sha256 origen, spec de variante).
  • El camino de servicio nunca decodifica imágenes. Las variantes las genera un job de derivación al indexar, en un proceso aislado y sin red. El daemon sólo copia bytes de una caché direccionada por hash.
    • Decodificar imágenes no confiables es superficie de ataque (dec-0117 I5/I10). Fuera del camino de servicio, un fallo del decodificador no tumba el servicio de vídeo.
    • Librerías permisivas sin GPL. Las fija el ticket.
  • Variante ausente: se sirve la más cercana que exista, nunca un 404, y se encola su generación. Los placeholders (thumbhash o similar) viajan en el índice si se adoptan.
  • Acceso: el hash no es secreto, pero saber qué artwork tiene un servidor dice qué biblioteca tiene. Nada se sirve sin sesión. Pregunta P4: el modo de credencial (SCT de scope art por sesión, ligada al actor, o proxy por el BFF).

8.3 Trickplay y otros derivados

Las miniaturas de trickplay (V-N04) son clase D: derivadas de los keyframes del índice X, direccionadas por (huella de payload, spec), y servidas por el mismo camino que el artwork. No van en el fichero. El nodo y la detección quedan para su propio diseño (GAP de V-N04).

9. Enriquecimiento y descargas

9.1 El plugin de enriquecimiento: first-class, por defecto, reemplazable

  • Dos slots, con los nombres de dec-0126 §3.1:
    • enriquecimiento (kind metadata): busca, normaliza y propone MetadataPatch con evidencia;
    • embed (kind embed): proyecta el documento al contenedor.
    • El primero de Styx es metadata-enrich. El de embed es metadata-embed.
  • Activo por defecto en toda biblioteca nueva (V-DATA-04). Se desactiva por biblioteca (library.root.update --enrich off) y se reemplaza por otro plugin que declare el mismo slot, vía el SDK de dec-0033/r48 con su nivel de confianza.
    • Hay una implementación activa por slot y biblioteca. Dos enriquecedores a la vez no componen: los proveedores sí (siguiente punto).
  • Proveedores configurables por campo: TMDb, TheTVDB, fanart.tv u otros, el NFO local (metadata-local-nfo), los tags incrustados y el parse de nombre. Cada uno declara egress y, si la necesita, su clave (ctx.secrets, r48). El orden por campo es el "orden configurado" de r48 §3.2. Styx trae perfiles preconfigurados (películas, series, anime…) y el usuario edita el orden.
  • Lo que hace el enriquecimiento con el fichero: su resultado se aplica al documento con la precedencia de r48 §3.2 (lock manual > autoridad local > orden configurado > confianza). La autoridad local incluye ahora el styx.json existente. El documento se escribe por el camino de dec-0126. Activo por defecto no significa escribir por defecto en cualquier raíz: §9.3.
  • Normalizar (renombrar, mover, remux) no es parte de este ADR. El enriquecimiento normaliza datos (títulos, fechas, géneros a un vocabulario), no ficheros.

9.2 Descargas y salidas generadas (N7, N10)

  • Toda salida que Styx genera para el usuario (descarga directa, descarga en otro formato o códec generada al vuelo por el byte runtime, paquete offline) lleva el styx.json y su proyección, incluida la portada.
    • El muxer de salida (track/media-engine) recibe el documento y lo escribe en el contenedor de destino con las mismas reglas de §4.
    • Un fichero descargado y abierto en VLC o enviado por AirPlay ya tiene sus metadatos, que es lo que pide N1.
  • La descarga de un original sin documento (absent u overlay) inyecta el documento del overlay en la salida si el formato lo permite sin reescribir el payload (MP4 con moov delante se emite con su moov reescrito en streaming). Si no lo permite, se entrega tal cual y se avisa. El diseño de transferencias es dec-0129.
  • Los segmentos de streaming (fMP4/CMAF, HLS, MoQT) no llevan el documento: los metadatos llegan al reproductor por la API.

9.3 Valor por defecto de escritura por raíz (enmienda a dec-0126 P2)

dec-0126 §4.1 recomendaba metadataEmbed: off. Con el fichero como fuente de verdad, off significa que esa raíz vive en overlay. La propuesta:

  • Raíces que Styx posee (ingesta de dec-0121, raíces canónicas o normalizadas de dec-0019, raíces creadas desde la UI de Styx): merge, in-place, allowRewrite: false.
  • Raíces existentes que se añaden (por ejemplo, la biblioteca que ya usa Jellyfin): el asistente propone merge y explica los riesgos de dec-0126 §10. No se activa sin un sí explícito. Mientras tanto, la raíz funciona en overlay, sin perder nada.
  • Las exclusiones duras de dec-0126 §4.3 no cambian.
  • Pregunta P1.

10. Estado de usuario (clase U)

Enmendado en el lock (P5): el estado de usuario se reparte entre playback-svc y catalog-svc con clave común (actorId, profileId). El "dueño propuesto: catalog-svc" de abajo queda sustituido por el reparto del lock.

  • No va en el fichero: por privacidad (el fichero viaja), por E/S (el progreso cambia cada pocos segundos) y por integridad (seeding, backups que re-suben decenas de GB). Ya lo decía dec-0126 §11, y se mantiene.
  • Dueño propuesto: catalog-svc. Los rails ("seguir viendo", "porque viste…") son joins entre el índice y el estado del perfil. Si viven en el mismo servicio, una página de rails es una consulta, sin ir y venir por el bus.
    • playback-svc, que es quien conoce la posición, emite evt.playback.progress con debounce (al pausar, al cerrar y cada N segundos, con N en el ticket). catalog-svc lo persiste.
    • Pregunta P5: la alternativa es que el dueño sea playback-svc.
  • Clave: (actorId, profileId, workKey | assetKey), con workKey = WorkId y assetKey = AssetId. Los dos son ids estables (§6.4), así que el estado sobrevive a un reindex y a la migración de la biblioteca a otro servidor con el mismo contenido.
  • Granularidad: el progreso es por asset (posición en esa versión) y se proyecta a la obra ("viste el 80 %" se mantiene al cambiar de 1080p a 4K por la duración relativa). Visto, favoritos, valoraciones y listas son por obra.
  • Borrado y transferencia: cumple evt.identity.profileDeleted (dec-0125 §12.2). Exportación por perfil en JSON (operación headless profile.state.export).
  • Sincronización con clientes: el mismo patrón de §7.3, con su propia revisión por perfil. Offline: el cliente acumula cambios con marca de tiempo y los reenvía. Gana el último por campo, salvo "visto", que es monótono por sesión. El diseño fino es de dec-0128 (dispositivos) y dec-0129 (offline).
  • Federación (V-N06): compartir estado entre servidores no se decide aquí. Las claves estables lo hacen posible (dec-0131).

11. Enmiendas propuestas (efectivas en el lock conjunto con dec-0126)

  • dec-0126 §12 P1: se resuelve como fichero autoritativo con el catálogo como índice derivado (§3, §6). Se retira la recomendación "catálogo autoritativo".
  • dec-0126 §6: la frase "La base de datos sigue siendo la autoridad de lo que sirve la API. El fichero es una proyección duradera" se sustituye por: "El fichero es la autoridad (clase F). El índice del catálogo es una proyección derivada y reconstruible. El overlay (dec-0127 §6.3) es la autoridad provisional de lo que no se puede escribir". Los locks manuales y el workUid pasan a la lista de lo reconstruible.
  • dec-0126 §7 ("Sincronización fichero ↔ catálogo: pregunta abierta"): lo sustituyen §5 y §6.2 de este ADR. Se conservan STYX_EMBED_DIGEST, ahora definido en §4.2, y la cola de revisión para conflictos.
  • dec-0126 §3.2: el modelo de tags común pasa a ser la proyección de styx.json (§4.2). Se añade el documento como adjunto o item ----:dev.mks2508.styx:doc.
  • dec-0126 §12 P2: la recomendación cambia de off a la de §9.3 de este ADR.
  • dec-0126 §3.1: el almacén de artwork por contenido se mantiene y gana variantes derivadas servidas por el daemon (§8).
  • Lo que no cambia de dec-0126: §2 (investigación de formatos), §3.3 (huella sobre payload, que este ADR usa para AssetId), §3.4 (scope annotate), §4.3 (exclusiones), §5 (in-place con journal, reescritura con allowRewrite, padding), §10 (riesgos) y §11 (alternativas).
  • dec-0114 §5: el ledger de escaneo guarda además metadataHash y fileState. §3 ya lo enmienda dec-0126.

12. Alternativas rechazadas

  • Catálogo autoritativo con el fichero como proyección (la P1 recomendada en dec-0126). Contradice N1: el fichero sería una exportación, y una edición fuera de Styx sería siempre un conflicto en vez de la verdad.
  • Bidireccional por campo sin autoridad declarada. Cada campo necesita su propio reloj y las reglas de fusión no tienen fin. Con un solo documento con hash y una regla ("gana el fichero; si los dos cambiaron, pregunta") basta.
  • Sidecar .styx.json o NFO junto al fichero. Waxin lo descartó: se desincroniza al mover o renombrar, y un fichero descargado no lo lleva. Matiz del lock: sigue rechazado para el audiovisual; se admite sólo para formatos que no se pueden escribir sin romperlos (árboles, imágenes, ficheros firmados; dec-0132 M3).
  • XMP como documento principal. Tiene estándar y FFmpeg lo lee en MOV/MP4, pero es RDF/XML, sin canonicalización práctica para hashear, y no lo lee ningún reproductor de vídeo de los que importan aquí. Queda como exportación posible.
  • YAML o TOML como forma almacenada. Sin canonicalización estándar, dos escritores darían hashes distintos para el mismo contenido. Se pueden usar para mostrar.
  • Leer los ficheros en cada listado con caché sólo en el cliente. Viola el presupuesto de V-DATA-02 y hace la primera página tan lenta como el disco.
  • Ids aleatorios en el índice. Un reindex lo rompería todo (estado de usuario, enlaces, listas). Los ids derivados del fichero son la condición para que el índice sea desechable de verdad.
  • Estado de usuario en el fichero. Ver §10.
  • catalog-svc sirve el artwork. Metería bytes y caché en un servicio de control, duplicaría la caché del daemon y no tendría la fuente remota a mano.
  • Variantes de imagen generadas al vuelo en el daemon, al estilo imgproxy. Es lo más flexible, pero mete un decodificador de imágenes no confiables en el proceso que sirve vídeo. Se reconsidera con aislamiento propio si las variantes fijas no bastan.

13. Preguntas para waxin (bloquean el lock)

  • P1 — Escritura por defecto (§9.3): merge in-place en las raíces que Styx posee y asistente con merge propuesto en las raíces existentes (recomendado), o merge en todas sin preguntar, o off en todas con overlay.
  • P2 — Documento completo en el índice (§7.1): guardar el styx.json entero como JSONB direccionado por hash, para que la ficha cueste 0 lecturas (recomendado), o guardar sólo la proyección y leer la ficha del daemon con su caché L1 (menos duplicado, una lectura en frío por ficha).
  • P3 — Ediciones de terceros sobre los tags estándar (§6.2.1): review por defecto (recomendado), adopt o ignore.
  • P4 — Credencial del artwork (§8.2): SCT de scope art por sesión, ligada al actor (recomendado; el daemon ya verifica SCT), o proxy por el BFF de la web, con un camino aparte para las apps.
  • P5 — Dueño del estado de usuario (§10): catalog-svc (recomendado, por los joins de los rails) o playback-svc.
  • P6 — Ids derivados (§6.4): AssetId desde la huella de payload y WorkId desde un workUid incrustado (recomendado), o mantener ids aleatorios y una tabla de correspondencia que también habría que respaldar.
  • P7 — Proveedores por defecto (§9.1, es dec-0126 P7 reformulada): enriquecimiento activo sólo con fuentes sin clave (documento, tags, NFO, nombre) y un asistente que pide la clave de TMDb en el primer arranque (recomendado), o una clave de Styx incluida (sujeta a los términos de TMDb y a su atribución).

14. Consecuencias

  • @styx/domain gana el esquema styx-metadata/v1 y las funciones puras de JCS, metadataHash e ids derivados, con vectores compartidos TS↔Zig (como los de spire).
  • track/media-engine gana la lectura acotada de styx.json (§7.4), su escritura en el editor de dec-0126 y en el muxer de salida (§9.2), y los capítulos (§4.3).
  • El daemon gana qry.media.readEmbeddedBatch, la caché L1 de documentos y el servicio de /art/<sha256>/<variante>.
  • catalog-svc gana el índice con clases por columna, fileState, el overlay, la catalogRevision, library.changes, el estado de usuario (si P5 se resuelve así) y styx library reindex.
  • realtime-svc gana evt.catalog.metadataChanged hacia los clientes.
  • track/plugin-seams gana el slot de enriquecimiento activo por defecto y reemplazable.
  • Gate mínimo de la wave que lo implemente:
    • la propiedad de reconstrucción de §6.5 (borrar el índice, reindexar y obtener los mismos ids y los mismos valores F/X);
    • styx_catalog_daemon_reads_total en 0 en las rutas de listado, búsqueda y ficha, con un mutante que lea del daemon y ponga el test en rojo;
    • los estados de §6.2 con fault injection (cambio externo del documento, de la proyección y de los dos lados);
    • vectores JCS/hash idénticos en TS y Zig;
    • un fichero descargado de Styx abierto en VLC con título y portada, y su styx.json leído de vuelta sin el catálogo;
    • la medida en frío sobre la biblioteca de waxin (§7.2) guardada como evidencia.
  • Páginas de docs (dec-0124): explicacion/modelo-de-datos (estado especificado) se ata a este ADR. explicacion/metadatos-en-el-fichero cita este ADR en su pendiente de P1.
  • No decide: el diseño de dispositivos y handoff (dec-0128), transferencias y offline (dec-0129), modo público (dec-0130), federación y reconciliación de workUid entre servidores (dec-0131), la detección de intro y créditos y el trickplay (V-N04), los nombres finales de subjects y operaciones, ni el gate de track/domain-media/file-ssot (su alta en styx.model.yml es propuesta queued, pendiente de ratificar con el lock).

Lock (2026-10-02, waxin)

Decisión de waxin vía AskUserQuestion (2026-10-02): lockear juntos dec-0126 (mecánica de escritura de metadatos en el fichero) y este ADR (modelo de datos, metadata SSOT en el fichero). dec-0126 queda enmendado por este donde chocaban: su P1 "catálogo autoritativo" pasa a fichero-SSOT y su P2 "off por defecto" pasa a merge en las raíces propias (§11). Respuestas a §13:

P1 — Escritura por defecto: merge en las raíces propias, asistente en las existentes

Respuesta de waxin: merge in-place en las raíces que gestiona Styx (ingesta, canónica, creadas desde la UI). En raíces existentes, como la biblioteca Jellyfin, el asistente ofrece activar merge. Queda §9.3 tal cual, sin la marca de pregunta:

  • raíces propias: metadataEmbed: merge, in-place, allowRewrite: false, montaje rw-metadata configurado al crearlas;
  • raíces existentes: overlay hasta que el usuario acepte merge en el asistente, que explica los riesgos de dec-0126 §10 y recomienda styx library prepare (padding) antes;
  • las exclusiones duras de dec-0126 §4.3 no cambian.

P2 — Qué guarda el índice (waxin delega: "lo más elegante, aunque cueste más ingeniería")

Elección: almacén de documentos direccionado por contenido + proyección materializada por función pura + estado del fichero como tres punteros de hash.

  1. Almacén de documentos metadata_documents(metadata_hash PK, doc_jcs bytea, size, schema, first_seen_at). Guarda los bytes JCS exactos de styx.json, no un JSONB.

    • Las filas son inmutables: una edición inserta un documento nuevo y nunca actualiza uno viejo. Al insertar, la base de datos comprueba BLAKE3(doc_jcs) = metadata_hash. Un scrub periódico de baja prioridad lo revalida.
    • Por qué bytea y no JSONB. JSONB no conserva los bytes: reordena claves, normaliza números y descarta duplicados. Para comprobar el hash habría que recanonicalizar en cada lectura, y servir el documento exigiría reserializarlo. Con los bytes exactos, la ficha se sirve tal cual, con ETag: "m1-<hash>", sin serializar nada, y la verificación es un hash. Las consultas no entran en el documento: van a la proyección (punto 2).
    • Dos assets con el mismo documento comparten fila. Los documentos sin referencias se recogen con un barrido (mark and sweep) que respeta la retención del feed de cambios.
  2. Proyección materializada (asset_projection, work_projection, la búsqueda de texto completo de §7.1): una función pura y versionada projectStyxDoc@vN(doc, facts) → filas, en @styx/domain, la misma que usan los tests.

    • Cada fila lleva projection_version y el metadata_hash del que sale.
    • Cambiar qué se indexa no relee un solo fichero: se sube vN y se re-proyecta desde el almacén. Esa es la razón principal para guardar el documento entero. Con "sólo la proyección", cada columna nueva, cada cambio de vocabulario de géneros y cada índice nuevo costaría un reescaneo de la biblioteca.
    • Un guard comprueba que toda columna de proyección sale de la función. No hay columnas escritas a mano.
  3. Estado del fichero como tres punteros por asset (sustituye a la columna fileState suelta de §6.2; los estados pasan a ser una función de los punteros, evaluada en el orden de la tabla):

    • observed_hash: el documento que hay ahora en el fichero (null si no tiene);
    • intended_hash: el documento que Styx quiere en el fichero (edición aceptada, enriquecimiento, overlay);
    • base_hash: el observed_hash sobre el que se aceptó la intención.
    EstadoCondición
    syncedintended = observed
    pendingWriteintended ≠ observed y observed = base
    conflictintended ≠ base y observed ≠ base
    changedExternallyintended = base y observed ≠ base: se adopta, intended := observed
    overlayintended no nulo y asset no escribible (§6.3)
    absentobserved e intended nulos

    El caso conflict tiene una base de merge de tres vías por campo (base, observed, intended): el diff por campo de la cola de revisión sale de ahí, y P3 lo usa para no preguntar cuando los dos lados tocaron campos distintos.

    • Escribir en el fichero es mover punteros. evt.media.metadataEmbedded con el hash nuevo hace observed := base := intended. No hay estados que se desincronicen de los datos.
    • El overlay deja de ser un caso aparte: es un intended_hash que no se puede materializar.
  4. Presupuestos que cumple: la ficha cuesta una lectura por clave primaria en el almacén, con 0 lecturas de fichero. Un listado sólo toca la proyección. La caché de cliente valida por el mismo hash que es la clave. El índice sigue siendo desechable: perder las tablas F/X/D es un reindex (§6.5), y el almacén se repuebla con las lecturas por lotes de §7.4.

Alternativa descartada: sólo la proyección y la ficha leída del daemon (caché L1). Duplica menos (pocos KB por obra), pero cada ficha en frío es una lectura de fichero, el ETag de la ficha dependería de la caché del daemon, y cada cambio de proyección exigiría un reescaneo.

P3 — Ediciones externas de tags: adopción inteligente

Respuesta de waxin: adopción inteligente. Si los tags externos se consideran peores, basura o erróneos, no se adoptan. Si no, se toman como decisión del usuario y se adoptan, avisándole sólo cuando haga falta que intervenga. Nuevo valor por defecto externalTagEdits: smart por raíz. review, adopt e ignore quedan como overrides.

Cuándo se activa: el reescaneo ve que la proyección del fichero no cuadra con STYX_EMBED_DIGEST (un tercero editó los tags) y styx.json no cambió. Si cambió styx.json, sigue mandando §6.2: gana el fichero sin preguntar. El mismo filtro se aplica a los tags ajenos de un fichero absent cuando se importan.

Paso 1, diff por campo. Se compara la proyección leída con la esperada del documento, sólo en los campos de la tabla fija de dec-0126 §3.2. Los tags ajenos que no están en la tabla se conservan y no se interpretan.

Paso 2, filtro de validez y basura. Cada campo cambiado se rechaza (no se adopta, sin avisar) si cumple cualquiera de estas condiciones:

  • No valida contra styx-metadata/v1: tipo, formato o rango. Por ejemplo, una fecha que no parsea, un año fuera de [1874, año actual + 5], un IMDB que no es tt + ≥ 7 dígitos, o un TMDB sin movie/ o tv/.
  • Codificación rota: U+FFFD, caracteres de control, o mojibake (UTF-8 leído como Latin-1, que se detecta por pares de bytes típicos).
  • Vacío, sólo espacios, o un valor de relleno conocido (Untitled, Unknown, Movie, Title, Track 1…).
  • Basura de release: el título contiene el nombre del fichero, una URL o un dominio, o tokens de escena (resolución, códec, fuente o grupo: 1080p, 2160p, x265, WEB-DL, BluRay, -GRUPO…), o frases de sitio ("Downloaded from", "www.").
  • Degradación: el valor nuevo es un prefijo truncado del actual (sinopsis cortada), o un género fuera del vocabulario que no mapea a ninguno.
  • Imagen: la portada nueva no decodifica en el job aislado de §8.2, mide menos de 300 px en el lado menor, o su relación de aspecto cae fuera del rango de su rol (portada vertical entre 0,6 y 0,75).

Paso 3, contraste con proveedores para los campos de identidad. Son los ids externos, kind y serie/temporada/episodio. Cambiarlos es reidentificar, no retocar. Se adoptan sólo si el proveedor configurado confirma el id nuevo y este cuadra con el contenido: la duración del payload coincide con el runtime del proveedor dentro de ±2 % o ±90 s, y el título o el año coinciden. Si no hay proveedor con clave (P7), o no cuadra, el cambio va a revisión. Nunca se rechaza en silencio: un id nuevo puede ser la corrección de una identificación mala.

Paso 4, adopción. Los campos descriptivos que pasan el paso 2 se adoptan aunque difieran del proveedor: son decisión del usuario.

  • Cada campo adoptado entra en el documento con provenance {provider: external-edit, confidence: 1} y con lock manual, para que el enriquecimiento no lo revierta.
  • Se escribe un styx.json nuevo por el camino normal (intended := doc nuevo). Esa escritura regenera también la proyección de los campos rechazados.

Paso 5, aviso sólo si hace falta intervenir. Va a la cola de revisión y se notifica cuando:

  • un campo de identidad no se puede confirmar (paso 3);
  • el campo cambiado ya tenía lock manual en Styx: chocan dos decisiones del usuario;
  • hay conflicto de tres vías (P2) en un mismo campo, porque Styx tenía una escritura pendiente de ese campo;
  • el styx.json del fichero no valida contra el esquema. El documento se pone en cuarentena, el índice conserva el último bueno, y se avisa una vez por fichero.

Lo adoptado y lo rechazado no notifican. Quedan en el historial de provenance (clase C) y en library.metadata.embedStatus, con un resumen agregado por escaneo.

Los umbrales (±2 %, ±90 s, 300 px, aspecto, año) son valores iniciales reversibles. El ticket los calibra con fixtures de ediciones reales (mp3tag, mkvpropedit, AtomicParsley) y con el corpus de SCAN-01 (dec-0114 §4).

P4 — Credencial del artwork: SCT de scope art

Respuesta de waxin: SCT con scope art por sesión, ligada al actor y verificada por el daemon.

  • Emisor: playback-svc, único firmante de SCT (dec-0117 A4). La entrega el BFF a la web, o la API a las apps nativas, por sesión de usuario y no por sesión de reproducción. Se renueva por el mismo camino que la SCT read.
  • Forma (enmienda a dec-0117 §4.1/I3): scope nuevo art, sólo lectura. resource = el almacén de artwork del nodo, no un assetId. Va ligada al actor (actor = hash del actorId) y a su acv (dec-0125). Un evt.identity.accessChanged deja de renovarla. Es multiuso, a diferencia de annotate e ingest.
  • Verificación: el verificador de spire-zig en el daemon (dec-0119 §5), como el resto de scopes. Transporte: cap= en la URL desde el navegador y Authorization en apps nativas (dec-0117 §4.1).
  • Autorización por obra: el daemon no sabe qué hash pertenece a qué biblioteca. La barrera es que el sha256 sólo llega al cliente dentro de respuestas de la API filtradas por el permiso del actor (dec-0125), y que 256 bits no se adivinan. Riesgo residual aceptado: un actor que filtra un hash con su SCT a otro actor autenticado del mismo servidor le da una imagen, nunca vídeo ni metadatos.
  • Caché: Cache-Control: private, max-age=31536000, immutable (no public: va con credencial). Como cap= forma parte de la URL, la web guarda las imágenes por sha256 en su propia caché (clase K, §7.3) y no depende de que la URL coincida entre sesiones.

P5 — Estado de usuario: reparto entre playback-svc y catalog-svc

Respuesta de waxin: se reparte entre catalog-svc y playback-svc, unidos por usuario y perfil, con una capa SQL bien pensada. Sustituye a "dueño propuesto: catalog-svc" de §10.

Clave común: (actorId, profileId), de dec-0125. Allí la cuenta es el actor ("Cuenta = quien inicia sesión. Es el actor de hoy"), así que accountId ≡ actorId y no se introduce un nombre nuevo. En @styx/domain es IProfileRef { actorId, profileId }.

El sujeto es una unión discriminada alineada con dec-0107 (LOCKED en w11/locks): {kind: 'asset', assetId} | {kind: 'work', workId} | {kind: 'service', liveServiceId}. AssetId y WorkId son los ids derivados de P6. LiveServiceId es de servidor (clase C, dec-0107).

Regla de reparto: cada dato lo posee el servicio en cuyo flujo nace.

DatoDueñoPor qué
Sesiones de reproducción (historial de reproducciones)playback-svcnacen al reproducir; playback ya emite las SCT y conoce el dispositivo
Progreso por asset (posición, duración, rev)playback-svclo escribe el reproductor cada pocos segundos; el resume lo lee playback al crear la sesión
Preferencias de pista por perfil (idioma de audio y subtítulos)playback-svcse aplican en el PlaybackPlan (r05) y se aprenden de lo que el usuario elige al reproducir
Visto (marca, fecha, número de reproducciones), incluido el manualcatalog-svces estado de la obra para los rails y para "marcar como visto"; se alimenta de playback
Favoritos, valoraciones, listas y ocultar de "seguir viendo"catalog-svcson curación del usuario sobre el catálogo
Proyección de progreso por obra (fracción, último asset)catalog-svcderivada de los eventos de playback; existe para que los rails sean una consulta local

Capa SQL. Cada servicio tiene su esquema y su rol de Postgres. Ningún rol tiene grants sobre el esquema de otro servicio y no hay joins entre bases de servicios (r17).

  • playback-svc:
    • playback_sessions(session_id uuidv7 PK, actor_id, profile_id, subject_kind, asset_id, live_service_id, device_id, started_at, ended_at, end_reason);
    • playback_progress(actor_id, profile_id, asset_id, position_ms, duration_ms, rev bigint, session_id, updated_at, PK(actor_id, profile_id, asset_id));
    • playback_track_prefs(actor_id, profile_id, scope_kind, scope_id, audio_lang, sub_lang, sub_mode, updated_at, PK(actor_id, profile_id, scope_kind, scope_id)), donde scope_kind es global, work o series;
    • playback_outbox: outbox transaccional. El cambio de estado y el evento se escriben en la misma transacción, y un relay publica por @styx/bus. Nunca se escribe sin evento, ni se emite un evento sin escritura.
  • catalog-svc (esquema user_state, separado de las tablas del índice; un reindex no lo toca):
    • profile_subject_state(actor_id, profile_id, subject_kind, subject_id, favorite, rating, watched_at, play_count, hidden_from_continue, rev, updated_at, PK(actor_id, profile_id, subject_kind, subject_id));
    • user_lists(list_id, actor_id, profile_id, name, created_at) y user_list_items(list_id, subject_kind, subject_id, position, added_at);
    • progress_projection(actor_id, profile_id, asset_id, work_id, position_ms, duration_ms, fraction, source_rev, updated_at): proyección local reconstruible;
    • profile_state_revision(actor_id, profile_id, rev): la revisión por perfil del feed de §10;
    • consumed_events(event_id PK): inbox de idempotencia.
    • Su propio outbox para evt.catalog.* del estado de usuario.

Cómo se cruzan sin acoplarse (por bus, con proyecciones locales):

  • playback-svc emite evt.playback.progressed (con debounce: al pausar, al cerrar y cada N segundos) y evt.playback.completed. "Completado" = la posición pasa el inicio de la marca credits del documento (§4.3) o el 90 % si no hay marca. catalog-svc consume los dos: actualiza progress_projection, y con completed fija watched_at y suma play_count.
  • "Marcar como visto o no visto" a mano es cmd.catalog.markWatched. catalog-svc emite evt.catalog.watchedChanged, y playback-svc lo consume para poner a cero el progreso de los assets de esa obra.
  • Idempotencia y orden: entrega al menos una vez (JetStream con consumidor durable), deduplicación por event_id en el inbox, y last-writer-wins por rev monotónico por (actorId, profileId, assetId). Un evento con rev menor que source_rev se descarta.
  • Reconstrucción: progress_projection es derivada. Si se pierde, se repuebla con qry.playback.progressSince(rev), paginado. Las tablas de las que catalog-svc es dueño salen de su backup.
  • Lecturas: "seguir viendo" y los rails son una consulta en catalog-svc (proyección de progreso ⋈ índice ⋈ profile_subject_state), con 0 llamadas a playback y 0 lecturas de fichero (§7.2). El resume de una sesión es una lectura local de playback-svc.
  • Ciclo de vida de perfil (dec-0125 §12.2): los dos servicios consumen evt.identity.profileDeleted y purgan su parte. profile.state.export compone las dos exportaciones en el BFF o el CLI, no en un servicio.

Los nombres de subjects y tablas son provisionales: los fija el ticket con su consumidor, como el resto de este ADR. Lo que fija el lock es el reparto, la clave y el patrón outbox → bus → inbox → proyección.

P6 — Ids derivados del fichero

Respuesta de waxin: AssetId de la huella del payload y WorkId de un workUid guardado en el fichero. Queda §6.4 tal cual para el audiovisual, con dos precisiones que fija el lock:

  • El algoritmo de AssetId queda congelado. Se fija como assetIdVersion: 1.
    • Una fingerprintVersion posterior (dec-0114 §5) sirve para identificar y deduplicar, pero no vuelve a calcular AssetId. Si cambiara, cambiarían todos los ids y se rompería el estado de usuario.
    • Un assetIdVersion: 2 sería un ADR con su propia migración.
  • La huella de asset se define por layout y por tipo, no sólo con la huella de vídeo. Es la sección siguiente.

Identidad y fuente de verdad por layout y por tipo (puerta abierta a dec-0132)

waxin quiere un Styx multi-tipo: juegos de PS4 y PS5 (dumps en carpeta, imagen exFAT o .pkg), copias de PC, música y libros. El diseño está en dec-0132 (PROPOSED, rama w12/multikind, punto M3). Para no cerrar esa puerta, el lock fija tres cosas. Para el audiovisual (MKV, MP4) nada cambia respecto a §4–§9. Para los tipos no audiovisuales, este apartado prevalece sobre el resto del texto.

  1. Huella de asset y AssetId por layout. AssetId = ast_ + base32(BLAKE3(huella de asset)), truncado a 128 bits, donde la huella depende del layout del asset:

    layoutHuella de asset
    file audiovisualla huella de payload de dec-0126 §3.3 (mdat / Clusters + facts en lista blanca). Es la de hoy
    file de otro tipola que declare el plugin del tipo, con la misma regla: los metadatos que Styx escribe no entran. Si el tipo no declara ninguna, el hash del fichero entero
    tree (directorio con N ficheros)la raíz del manifiesto canónico: BLAKE3 del JCS de [{relPath, size, mode, raíz Merkle del fichero}], ordenado por relPath en bytes UTF-8, con relPath en NFC, sin .. ni rutas absolutas, y sin seguir symlinks. .styx/ queda fuera del manifiesto: escribir metadatos no cambia la identidad
    image, y formatos firmados o de sólo lectura (un .pkg de PS4, una imagen exFAT, una .iso)el hash del fichero entero (la raíz de transferencia de dec-0129 §3.2 cuando exista). El árbol interior que saque una etapa de desempaquetado es una faceta, no la identidad

    Cada variante se versiona en assetIdVersion con su layout. La regla de no volver a calcular el id es la misma para todas.

  2. styx.json reserva kinds.<id>. La cabecera y el bloque común de §4.1 (schema, workUid, kind, edition, títulos, fecha, ids externos, artwork y curación) valen para todos los tipos. Lo propio de cada uno va en kinds.<id>, con el esquema versionado que registre su plugin. Por ejemplo, kinds["game.ps5"] o kinds["book"]. Los ids externos siguen yendo por espacio de nombres (igdb, ps.titleId, isbn, musicbrainz…). styx-metadata/v1 declara kinds como un mapa abierto validado por registro: un bloque de un tipo desconocido se conserva intacto, se ignora al proyectar y entra en el hash del documento.

  3. La fuente de verdad, por formato. La metadata vive en el fichero cuando el formato lo permite. Cuando no, vive en un sidecar .styx/ o en el overlay del catálogo:

    truthCuándoDónde vive styx.json
    embedel formato tiene sitio y Styx sabe escribirlo sin romperlo (MKV y MP4 hoy; otros, con el tipo que lo traiga)dentro del fichero, por el camino de dec-0126
    sidecarun árbol escribible, o un fichero que no admite escritura segura en una raíz escribible.styx/styx.json en la raíz del árbol (fuera del manifiesto), o el sidecar junto al fichero que fije dec-0132
    overlaylo que no se puede tocar: fichero firmado (un .pkg de PS4 se invalida si se escribe dentro), fuente remota, raíz ro, seeding, nlink > 1en el catálogo (§6.3); se materializa en sidecar en cuanto el asset pase a una raíz escribible
    • Styx nunca escribe dentro de una imagen ni de un fichero firmado.
    • El sidecar sigue rechazado para el audiovisual (§12): ahí el fichero admite el documento, y la orden de waxin es que viaje con él. Para los formatos que no lo admiten, el sidecar es el sitio más cercano al contenido que no lo rompe.
    • El sidecar sigue el modelo de §5–§7: tiene metadataHash, los tres punteros de P2 y la adopción de P3.

Lo demás de dec-0132 (registro de kinds, facetas, acciones, pipelines, enmienda a dec-0107) sigue PROPOSED y no lo decide este lock.

P7 — Proveedores por defecto

Respuesta de waxin: proveedores sin clave por defecto (documento incrustado, tags, NFO y nombre de fichero) más un asistente de primer arranque que pide la clave de TMDb. El enriquecimiento (§9.1) está activo desde el primer arranque solo con esos proveedores. Con la clave, TMDb entra en el orden por campo del perfil de biblioteca. Sin ella, nada sale a la red. Styx no incluye una clave propia.

Encaje con otros ADRs

  • dec-0125 (cuentas, PROPOSED): compatible en la clave ((actorId, profileId), con accountId ≡ actorId). Choca en un punto: §12.2 y §7.2 hablan de un "dueño del estado por perfil" (cmd.<dueño>.transferProfileState, evt.identity.profileDeleted hacia un consumidor). Con el reparto de P5 hay dos dueños. Enmienda que el lock de dec-0125 debe recoger: transferProfileState se manda a playback-svc y a catalog-svc, e identity-svc borra el perfil de origen sólo cuando los dos confirman (saga con dos pasos idempotentes). profileDeleted ya es un evt, así que sólo cambia la lista de consumidores. Su §19 ("el estado por perfil necesita dueño") queda resuelto por P5.
  • dec-0107 (LOCKED en w11/locks): no hay choque.
    • Work queda como media finita, y styx.json.kind sólo toma valores finitos (movie, episode…). Ningún documento lleva live ni channel.
    • El sujeto del estado de usuario incluye service (LiveServiceId) para canales favoritos, con la diferencia de que su id es de servidor (clase C) y no sobrevive a perder la base de datos como sí lo hacen AssetId y WorkId.
    • La transición de grabación de dec-0107 (un Programme grabado pasa a Work → Edition → MediaAsset) escribe en una raíz propia y, por P1, nace con su styx.json.
  • dec-0114 (LOCKED): enmendado (§11): §3 por dec-0126 §3.3 y §5 (el ledger guarda además metadataHash, y el estado de los tres punteros de P2 vive con el índice). Su "no decide el watcher" se mantiene: aquí el watcher es sólo un acelerador (§5.2). Su "no cambia el modelo del catálogo (r08)" se mantiene: cambian los ids, no las entidades.
  • dec-0117 (LOCKED): enmendado §4.1/I3 con el scope art (P4), además de annotate e I8 de dec-0126.
  • dec-0132 (multi-tipo, PROPOSED en w12/multikind): el lock recoge su M3 (identidad por layout, kinds.<id>, fuente de verdad embed | sidecar | overlay). El sidecar que admite para los formatos no escribibles matiza el rechazo de §12, que se mantiene para el audiovisual. Su enmienda a dec-0107 (kinds como registro validado) no la decide este lock.

Back-refs

  • dec-0126 lleva amendedOrSupersededBy: [dec-0127] y, desde el lock, banners en §4.1, §6 y §7 y su propia sección de lock.
  • dec-0114 y dec-0117 llevan banner de enmienda desde el lock (2026-10-02).
  • docs/overview/vision-ledger.yml: V-N01, V-DATA-01, V-DATA-02, V-DATA-03, V-DATA-04 y V-DATA-05 mapean a este ADR; con el lock pasan a decision-locked.
  • dec-0132 (PROPOSED, w12/multikind): su M3 queda recogido en el lock.
  • Relacionados: dec-0114 (huella y ledger), dec-0121 (ingesta), dec-0125 (clave del estado por perfil), dec-0019 (canonicalización), r58 (asset contracts).
Texto del ADR
dec-0127 — Modelo de datos: el fichero es la fuente de verdad, el catálogo es un índice derivado
1. Contexto
2. Referentes y estado del arte
3. Decisión (resumen)
4. Qué se escribe en el fichero
4.1 El documento styx.json
4.2 La proyección a tags estándar
4.3 Capítulos
5. metadataHash y la detección de cambios
5.1 Qué se hashea
5.2 Dos niveles, como Navidrome, pero con hash
6. Autoridad: quién gana y cuándo
6.1 Clases de dato
6.2 Estados del fichero (fileState) y quién gana
6.2.1 Ediciones de terceros sobre la proyección
6.3 El overlay: lo que no se puede escribir
6.4 Ids estables derivados del fichero
6.5 Reconstrucción
7. Acceso rápido: el índice y las cachés
7.1 Qué guarda el índice
7.2 Presupuestos
7.3 Cachés de cliente y sincronización
7.4 Lectura en frío acotada (daemon)
8. Artwork
8.1 Dónde viven los bytes
8.2 Quién lo sirve: el daemon (resuelve P7 del plan vertical)
8.3 Trickplay y otros derivados
9. Enriquecimiento y descargas
9.1 El plugin de enriquecimiento: first-class, por defecto, reemplazable
9.2 Descargas y salidas generadas (N7, N10)
9.3 Valor por defecto de escritura por raíz (enmienda a dec-0126 P2)
10. Estado de usuario (clase U)
11. Enmiendas propuestas (efectivas en el lock conjunto con dec-0126)
12. Alternativas rechazadas
13. Preguntas para waxin (bloquean el lock)
14. Consecuencias
Lock (2026-10-02, waxin)
P1 — Escritura por defecto: merge en las raíces propias, asistente en las existentes
P2 — Qué guarda el índice (waxin delega: "lo más elegante, aunque cueste más ingeniería")
P3 — Ediciones externas de tags: adopción inteligente
P4 — Credencial del artwork: SCT de scope art
P5 — Estado de usuario: reparto entre playback-svc y catalog-svc
P6 — Ids derivados del fichero
Identidad y fuente de verdad por layout y por tipo (puerta abierta a dec-0132)
P7 — Proveedores por defecto
Encaje con otros ADRs
Back-refs