dec-0121

Vista generada de dec-0121: Ingesta de ficheros con el SDK Zig de conduit

ImplementadoSin versión del tren todavía· generada desde docs/decisions/dec-0121-ingesta-conduit-sdk-zig.md
track/docsdec-0124track/docs:DC10

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

CampoValor
EstadoLOCKED
Fecha2026-09-30
Ficherodocs/decisions/dec-0121-ingesta-conduit-sdk-zig.md

Enmienda a: r26, dec-0103, dec-0117, dec-0120

Enmendado o sustituido por: dec-0129

Por qué importa (del frontmatter del ADR):

Abre el lado de escritura de styx: los clientes suben ficheros a un nodo con el wire de conduit (SDK Zig de MKS2508/conduit), el daemon los recibe en su IngestSink con SCT de scope ingest obligatoria, playback-svc decide quién sube y firma la capability, y catalog-svc da de alta el asset cuando playback-svc se lo pide por el bus (cmd.catalog.registerIngested). Fija el reparto Bun decide / Zig ejecuta de la ingesta, la semántica de la SCT ingest (verificada por spire.cap), los contratos cmd.media.openIngest/pollIngestEvents/closeIngest del socket de control de spire, la cota de abandono de una sesión que sube y la entrega al menos una vez hasta el catálogo.

Nodos del roadmap que lo citan en refs: track/byte-runtime/ingest, track/byte-runtime/egress, track/byte-runtime/ingest-web, track/oss-extraction

Páginas de la documentación que lo citan: Puesta en marcha de un nodo (implementado), Arquitectura (implementado), Catálogo federado (implementado), Servidores conectados y Jellyfin (especificado), Metadatos en el fichero (especificado), Modo público y Styx como plataforma (especificado), Protocolos binarios (implementado), Seguridad del data plane (implementado), Modelo de amenazas (implementado), Capacidades SCT (implementado), zkit, spire y conduit (implementado), Transferencias (especificado), Subir ficheros (especificado), Visión (especificado)

Texto del ADR

Leído de docs/decisions/dec-0121-ingesta-conduit-sdk-zig.md, el fichero canónico.

dec-0121 — Ingesta de ficheros con el SDK Zig de conduit

  • Fecha: 2026-09-30
  • Estado: LOCKED. Decisiones de waxin del 2026-09-28 (notas de entorno de la tanda 3):
    • "conduit y spire SE PASAN A ZIG Y STYX LOS CONSUME (decisión tomada, locked) … primer consumidor = IngestSink del daemon (servidor) + CLI de styx (cliente) … máquina de estados de upload vive en el SDK conduit (no en client-core L2, r56 R-D4.1) … cerrar escrituras no autenticadas del daemon ANTES de abrir ingesta".
    • "conduit: su wire = protocolo de ingesta de styx; IngestSink en Zig dentro del daemon como productor de ingress del Byte Runtime (r26 ya lockea bytes en todas direcciones); auth obligatoria, cuotas, SHA-256, path safety, ReorderBuffer de zkit … conduit conserva spec+vectores de conformidad+CLI".
    • La crítica del análisis previo (scratchpad integration-design.md) se usa como lista de requisitos, no como bloqueo.
  • Enmendado por dec-0129 (LOCKED, 2026-10-02):
    • cmd.catalog.registerIngested se generaliza con origin: upload | acquisition | import | federation y la raíz de transferencia: todo lo que entra al nodo usa la misma alta (dec-0129 §3.7);
    • la raíz de transferencia es BLAKE3 con outboard de bao a 16 KiB, calculada en la misma pasada que verifica cada chunk (dec-0129 §3.2, P3). El SHA-256 de este ADR sigue siendo el del wire v1; el paso de la ingesta a wire v2 y a claves BLAKE3 es CV2-03 (docs/track/byte-runtime/plans/conduit-wire-v2.md, rama w13/conduit-v2);
    • el dedup antes de transferir sólo responde dentro del dominio de dedup que da el Authorizer (configurable, household por defecto; lock de dec-0129, punto 3);
    • la web y la app suben con el mismo wire de conduit (WASM y C-ABI del SDK). No hay un segundo protocolo de subida.
  • Numeración: spire toma dec-0120 (SDK de mensajería) y conduit el siguiente.
  • Ronda 5 (2026-09-30): la ingesta se apila sobre spire (w4/conduit mergea w4/spire): la autoridad que verifica la SCT es la de spire.cap (styx no tiene copia, dec-0119 §9), el IPC va por el socket de control de spire con contratos generados (§4) y el alta en catálogo pasa de evt.media.ingestCompleted en JetStream a cmd.catalog.registerIngested por el bus (spire no tiene consumidor durable, y el único consumidor es catalog). Enmienda de paso dec-0120: tres rutas nuevas del daemon y dos de NATS en BUS_ROUTES.
  • Ronda 6 (2026-09-30): dos afirmaciones de la ronda 5 eran falsas y se corrigen en §2 y §3. El tope [0, max) de la SCT no impedía llenar el staging: acota un fichero, no cuántos abre un actor (con STYX_INGEST_SESSIONS_PER_ACTOR sesiones, un solo usuario podía reservar el staging entero y dejar a los demás en 507). Y una sesión cerrada o abandonada seguía ocupando en el daemon su hueco de subida y su reserva de staging hasta la expiración por inactividad de conduit (1 h): closeIngest sólo desligaba la autoridad. Ahora hay tope de staging por actor y tope de fichero en el daemon, closeIngest aborta las subidas de la sesión, y playback-svc cierra las abandonadas de un usuario antes de abrirle otra (conduit 0ad6ecc: Limits.staging_bytes_per_subject, Server.abortOwner, Authorizer.stillValidFn).
  • Nota de la rama w13/conduit-v2 (2026-10-02; la integración la decide waxin): la ingesta pasa a conduit wire v2 y sólo v2, con la raíz BLAKE3 como identidad de lo transferido.
    • Reserva del staging activa (conduit spec v2 §8.1: fallocate del fichero entero al crear; desde conduit 41743fe, publicado en master, fuera del lock del servidor, con vuelta atrás completa si falla). El presupuesto de staging pasa a ser disco real, así que el daemon comprueba al arrancar que el volumen de STYX_INGEST_ROOT tiene libre STYX_INGEST_STAGING_MIB más STYX_INGEST_MIN_FREE_MIB (2048 por defecto) con zkit Root.availableBytes (fstatfs sobre el fd de la raíz); si no, la ingesta no arranca. Donde zkit no puede medirlo (fuera de Linux de 64 bits) sólo avisa. Es una comprobación al arrancar; el tope total de la raíz y el suelo en cada create son la capa 2 de TS5-P2-01 (abajo, en las cuotas).
    • Sólo wire v2 (Limits.min_protocol = max_protocol = 2): un create v1 recibe 400 protocol_unsupported. Ningún consumidor necesita v1: styx-upload es el único cliente y habla v2; la web y Swift no tienen flujo de subida (§5); la C-ABI de conduit sigue en v1 y el host que la use tendrá que pasar a v2.
    • Identidad BLAKE3 (coherente con la enmienda propuesta a dec-0129 P3, docs/track/byte-runtime/plans/conduit-wire-v2.md): el evento de cmd.media.pollIngestEvents y cmd.catalog.registerIngested (los dos en versión 2) llevan blake3 en lugar de sha256, y el nombre final bajo la raíz es esa raíz en hex (lo que imprime b3sum). Es la identidad de los bytes transferidos, no la del asset: los ids work_ing_/edition_ing_/asset_ing_<32 hex de la raíz> de catalog son claves de idempotencia de la entrega al menos una vez, y el AssetId de dec-0127 (huella del payload, que no cambia al reescribir metadatos) lo asignará el scanner de dec-0114, con la migración de ids que dec-0127 §6.4 ya prevé. Lo subido antes en v1 conserva su nombre SHA-256 y sus ids; el mismo contenido subido de nuevo en v2 tiene otro nombre y otras filas hasta esa migración.
    • Medido de punta a punta (styx-upload → IngestSink, 256 MiB): evidencia en docs/track/byte-runtime/ingest/evidence/conduit-v2-2026-10-02/.
  • Enmienda:
    • r26: la ingesta de clientes es un productor más de ingress del Byte Runtime, pero con escritores no confiables: nace una frontera de confianza nueva (la crítica tenía razón), que este ADR cierra con capability, cuotas y fd-relativo. No se toca la lista de prioridades del GlobalByteScheduler (no existe en código); cuando exista, la clase de la ingesta será un ADR aparte.
    • dec-0103: styx consume el SDK de conduit por arista .zon pinneada (el commit lo fija native/zig/build.zig.zon); no reimplementa ni el codec ni el servidor, ni el barrido de staging huérfano (Server.init del SDK).
    • dec-0117: I3 aplicado a la ingesta (scope ingest, un recurso concreto), semántica de un solo uso por jti con re-autorización por petición, e I7 en el staging.

Contexto

  • styx no tenía ninguna dirección de escritura en código. El único escritor era el PUBLISH MoQT, cerrado con SCT publish (SEC-Z01).
  • conduit era un CLI TS en producción contra cloudreve-mirror y un Zig muerto. El paso 1 de esta lane (repo conduit, rama feat/zig-sdk) lo convirtió en un SDK Zig: wire v1 normativo con vectores de conformidad, máquina de upload sans-IO, servidor transport-agnostic con autorización obligatoria, cuotas, staging fd-relativo, hash en streaming sobre el prefijo en orden (zkit.ReorderBuffer) y handles generacionales (zkit.safety), C-ABI y WASM.

Decisión

1. Quién hace qué

PiezaDóndeHace
Clientestyx-upload (native/zig/cli/), cliente del SDKPide la sesión a playback-svc con el access token de identity, sube directo al daemon con el wire de conduit, renueva la capability antes de que caduque (hilo propio, §2), reanuda tras 401, corte o SIGKILL (estado en $XDG_STATE_HOME/styx)
Decisiónplayback-svc, rutas /ingest/* con policy sessionResuelve el principal (qry.identity.session por el bus, revocation-aware), liga la sesión en el daemon y firma la SCT; sesiones visibles sólo para quien las abrió
EjecuciónIngestSink del daemon (media-daemon/ingest/)Sirve el conduit.server.Server por HTTP/1.1 (conduit.http.Service), autoriza cada petición contra el Authority de spire.cap, escribe en staging y deja el hecho en su cola
Catálogocatalog-svc, cmd.catalog.registerIngestedDa de alta Work/Edition/MediaAsset con ids derivados del hash de la transferencia (SHA-256; raíz BLAKE3 en la rama w13/conduit-v2) y ffprobe por workers-svc

Los bytes nunca atraviesan Bun ni NATS (r01): el IPC y el bus llevan sólo la sesión, la capability y el hecho de que un fichero verificado existe.

2. Capability de ingesta (SCT v1, scope ingest)

  • La verifica el Authority de spire.cap (spire e2276c8+): verifyBase64 (firma, clave, audiencia, ventana, vida; sin lock, no gasta nada) y consume (sesión ligada y jti). El Verified que pasa de una a otra lleva un sello HMAC-SHA256 de las claims con una clave aleatoria de esa autoridad: uno construido a mano (con las claims de peekBase64), editado tras verificar o sellado por otra autoridad se rechaza como BadSignature.
  • sid = los 16 bytes de ing_<32 hex>, que el daemon liga por IPC (cmd.media.openIngest) al actor y a resource = sha256("styx.sct.res.ingest\0" ‖ rootId). El daemon rechaza ligar otra raíz.
  • range_kind = bytes, [0, max) = tope del fichero. Sin tope, o con inicio ≠ 0, el Authority lo rechaza en cada petición (RangeDenied). Ese tope acota un fichero, no lo que un actor reserva: con varias sesiones (o varias subidas por sesión) un solo token o usuario podría reservar el staging entero. Eso lo cierran los topes del daemon (§3): fichero como mucho STYX_INGEST_MAX_FILE_MIB diga lo que diga la capability, y como mucho STYX_INGEST_STAGING_PER_ACTOR_MIB reservado entre todas las subidas abiertas de un actor, siempre por debajo del total (STYX_INGEST_STAGING_MIB; si no, el daemon no arranca). playback-svc rechaza antes un sizeBytes por encima de su STYX_INGEST_MAX_FILE_MIB (el mismo valor; PLAYBACK_INGEST_FILE_TOO_LARGE, 413, sin firmar) y el contrato lo acota a MAX_INGEST_FILE_BYTES = 1 TiB (422).
  • Bytes guardados (enmienda TS5-P2-01): los topes anteriores acotan subidas abiertas; una terminada devuelve su hueco y su staging y deja el fichero en la raíz. catalog-svc anota cada alta en ingest_usage (una fila por raíz, actor y fichero; antes del alta, también si el alta falla) y qry.catalog.ingestUsage (sólo playback-svc) devuelve lo guardado por el actor y por todos. openSession suma a eso lo que aún no está en el ledger (subidas terminadas sin registrar, sesiones vivas y aperturas en vuelo, cada una por su tope) y rechaza con PLAYBACK_INGEST_QUOTA_EXCEEDED (507) por encima de STYX_INGEST_QUOTA_PER_ACTOR_MIB (64 GiB por defecto) y con PLAYBACK_INGEST_UNAVAILABLE (503) por encima de STYX_INGEST_ROOT_MAX_MIB (128 GiB) entre todos o si el ledger no responde (falla cerrado). Segunda capa, en el daemon (TS5-P2-01 capa 2): conduit pregunta al sink en la admisión del create, bajo su lock y antes de contar o reservar staging (Config.capacity, conduit a137617), y el create recibe 507 insufficient_storage si lo terminado bajo la raíz más lo reservado por las subidas abiertas más su tamaño pasa de STYX_INGEST_ROOT_MAX_MIB (la misma variable y el mismo defecto que playback-svc), o si el volumen se quedaría con menos de STYX_INGEST_MIN_FREE_MIB libres (Root.availableBytes de zkit, fstatfs sobre el fd de la raíz; si no se puede leer, se rechaza). La comprobación al arrancar cubre el staging; ésta cubre lo que los ficheros terminados van sumando después, porque un fichero terminado se queda en el volumen y su staging se libera. Lo terminado se cuenta al arrancar (los ficheros con nombre de raíz BLAKE3) y en cada finalize de un fichero nuevo (el mismo contenido otra vez no ocupa más). Así un fallo de playback-svc o catalog-svc, o un relay reiniciado con eventos sin registrar, ya no llena el disco que el nodo comparte con sus bases de datos.
  • Un solo uso por jti: la primera petición al sink admite el token (consume el jti); las siguientes con el mismo token se re-autorizan por petición (caducidad, sesión aún ligada, scope, recurso). Reanudar tras caducar = token nuevo para la misma sesión.
  • La caché de tokens admitidos tiene un hueco por sesión de ingesta: un token renovado sustituye al que la sesión tenía (el anterior deja de pasar) y uno más viejo que ése se rechaza sin gastarse. Topes: STYX_INGEST_SESSIONS_PER_ACTOR huecos por actor y 1024 en total. El hueco se comprueba antes de que el Authority gaste el jti (las claims sin verificar de Authority.peekBase64 sólo enrutan; todo lo que concede pasa por la verificación), así que un token rechazado por falta de sitio se puede presentar otra vez. Llena, se niega en vez de olvidar. Renovar en bucle cuesta a su sesión un hueco, no la caché.
  • Orden de trabajo en el sink: el token que una sesión ya tiene se re-autoriza bajo el mutex del sink comparando su SHA-256, sin firma. Cualquier otro paga Ed25519 (Authority.verifyBase64: firma, clave, audiencia, ventana, vida; sin lock y sin gastar nada) con el sink suelto; sólo un token verificado llega al hueco y a Authority.consume (sesión ligada y jti), ya bajo el mutex. Una riada de firmas malas no hace esperar a las sesiones admitidas ni llega a la poda. La poda de huecos muertos corre sólo si pudo morir alguno desde la anterior (avanzó el reloj o se desligó una sesión, Authority.unbindCount): repetir un token sin sitio no poda cada vez.
  • playback-svc, que emite: como mucho STYX_INGEST_SESSIONS_PER_ACTOR sesiones abiertas por usuario (las que se están abriendo cuentan; PLAYBACK_INGEST_TOO_MANY_SESSIONS, 429, sin llegar al daemon) y un cubo de 4 renovaciones por sesión que se repone una por minuto (PLAYBACK_INGEST_RENEW_RATE_LIMITED, 429, sin firmar nada). Un cliente legítimo renueva una vez por vida de capability (styx-upload, al 60 %: cada 180 s). Entre los dos topes, lo que un usuario ocupa en la caché del sink y en la de jti del Authority depende de él, no de lo que hagan los demás.
  • Una subida que progresa nunca cuenta como abandonada. Dos mecanismos independientes:
    • styx-upload renueva la capability desde un hilo propio al 60 % de su vida (medida con el reloj monotónico desde que llega; expiresAt acotado a 30–300 s por si el reloj local está desviado), aunque haya chunks en vuelo, y cada petición sale con el token vigente. Mientras el proceso vive, la sesión se renueva a cualquier velocidad de subida. Un 401 sólo pide otra si no hay ya una más nueva, y reanuda desde la lista de chunks del nodo.
    • playback-svc da una sesión por abandonada sólo pasado exp + max_skew_s + idleGraceMs de su última capability: el daemon la admite hasta exp + max_skew_s (300 + 30 s), un cliente puede empezar un chunk justo antes y tardar en subirlo lo que tarde un chunk entero antes de renovar. Con el chunk máximo del sink, 16 MiB (Limits.max_chunk que fija el daemon; styx-upload --chunk-size no pasa de ahí), y una subida mínima soportada de 400 kbps para clientes que sólo renuevan al 401, idleGraceMs >= MIN_IDLE_GRACE_MS = ceil(16 MiB × 8 / 400 kbps) = 336 s. Por defecto 360 s (STYX_INGEST_IDLE_GRACE_MS; por debajo del mínimo playback-svc no arranca). Con otro chunk máximo o subida mínima, el mínimo se recalcula con la misma fórmula (constantes de IngestHandler).
    • Abandonada, no cuenta para el tope, no se renueva (404) y el barrido la cierra en el daemon; el tope de 24 h queda como vida máxima absoluta. Tampoco cuenta en el daemon: al abrirle otra sesión a un usuario, playback-svc cierra antes en el daemon las suyas que ya no cuentan (abandonadas, caducadas o terminadas con el cierre fallido) y sólo entonces pide la nueva; cerrar aborta sus subidas (abajo), así que el daemon no la rechaza por un hueco o una reserva de staging que playback-svc ya no cuenta. Si ese cierre falla, la apertura sigue y el barrido lo reintenta. styx-upload no espera a eso ante un fallo definitivo: cierra la sesión con DELETE /ingest/sessions/:id antes de olvidar su registro. Un fallo reintentable agotado conserva la sesión para reanudar.
  • Una sesión sólo se olvida cuando el daemon confirma que no la tiene ligada. El barrido (sweepExpired) cierra abandonadas, caducadas y terminadas cuyo cierre falló; si el cierre falla (daemon caído), la deja para el siguiente. El tope global de playback-svc cuenta sólo sesiones vivas; ya no se olvidan caducadas sin cerrarlas.
  • Vida ≤ 300 s (dec-0117 §4.1). cmd.media.closeIngest revoca: la siguiente petición es 401. Y la revocación llega a los bytes: tras desligar, el daemon suelta el hueco de la sesión en su caché de tokens y aborta en conduit sus subidas (IngestSink.revoke → Server.abortOwner): el hueco del actor, la reserva de staging y el .part vuelven en el acto, un chunk en vuelo acaba en 404 y una subida que ya estaba renombrando su fichero termina y se retira justo después. Un create cuyo cuerpo aún llegaba cuando se revocó se rechaza (conduit re-pregunta al sink bajo su lock antes de confirmarlo, Authorizer.stillValidFn). Lo mismo vale para el barrido de playback-svc, que cierra con el mismo mensaje.
  • Vectores de conformidad TS↔Zig: protocols/capability-token/vectors.json incluye el digest de la raíz de ingesta.

3. IngestSink

  • Apagado por defecto: sin STYX_INGEST_ROOT no hay endpoint de escritura.

  • Listener HTTP/1.1 en 127.0.0.1:8790 por defecto (STYX_INGEST_LISTEN), en claro: se publica detrás de un terminador TLS. STYX_INGEST_PUBLIC_URL es lo que se anuncia al cliente.

  • 401 antes de enrutar o leer el cuerpo si no hay capability válida. Una sesión de otra capability es 404 (el SDK separa la clave de cuota, Grant.subject = actor, de la de visibilidad, Grant.owner = sesión de ingesta).

  • Cuotas: sesiones abiertas por actor (STYX_INGEST_SESSIONS_PER_ACTOR), bytes de staging reservados al crear (STYX_INGEST_STAGING_MIB en total, STYX_INGEST_STAGING_PER_ACTOR_MIB por actor, 16384 por defecto y siempre por debajo del total), fichero más grande (STYX_INGEST_MAX_FILE_MIB, 16384 por defecto, ≤ el tope por actor), memoria de sesiones con BudgetAllocator y ventana de chunks.

  • Conexiones e hilos acotados por identidad, no sólo en total (conduit.http.Service, ZSDK3-P2-01; enmienda: antes eran 64 conexiones globales y 30 s sin progreso, y 64 cabeceras sin token o un actor goteando 64 KiB cada 29 s dejaban la ingesta fuera de servicio). Una conexión sin credencial aceptada espera en un pool pre-auth de 16 con 10 s absolutos para su cabecera; lleno, se cierra la más antigua, así que mantener cabeceras abiertas no deja fuera a nadie. Una petición con cuerpo cuya SCT se aceptó ocupa uno de 64 sitios, como mucho 16 por actor (Grant.subject): por encima, 429 too_many_connections (reintentable) antes de leer el cuerpo. Su cuerpo debe llegar a ≥ 32 KiB/s en cada ventana de 10 s y la petición acabar en 10 s + longitud / 32 KiB/s; si no, se corta y el chunk se descarta. El tope de 4 cabeceras por cliente (una IPv6 cuenta por su /64) cuenta clientes reales, nunca al proxy. El borde se declara, no se deduce (enmiendas DS6-01 y DS4-01: antes se deducía de que STYX_INGEST_LISTEN fuera loopback, y producción escucha en 0.0.0.0 detrás de Traefik, así que todos los usuarios compartían los 4 sitios de la IP del proxy y cualquier cliente anónimo desalojaba las conexiones de los demás). ingest_sink.edgeFrom lo resuelve de STYX_INGEST_PEERS:

    • sin declarar, sólo en un listener loopback sin proxy ni prueba configurados: local, un borde TLS en el mismo host, sin tope por dirección;
    • direct: el socket es el cliente, con el tope del SDK; con proxy o prueba es contradictorio;
    • proxy: exige además STYX_INGEST_TRUSTED_PROXIES (la IP de host del proxy) y STYX_INGEST_PROXY_PROOF_FILE. Conduit (ServeOptions.trusted_proxy) cuenta cada petición contra la última entrada de X-Forwarded-For sólo si llega del socket del proxy con una única x-styx-proxy-proof válida; si no, 500 y cierra (falla cerrado), y entre peticiones la conexión del proxy no cuenta contra nadie.

    Fuera de loopback sin declarar, una configuración a medias o contradictoria, una subred o una prueba corta no arrancan. Detrás del proxy hay dos capas a la vez: el borde limita por cliente real (middleware styx-ingest-limits de deploy/traefik/styx-ingest.yml, check:deploy: rateLimit e inFlightReq por la dirección del socket con ipStrategy depth: 0, IPv6 por /64, y un tope total de 64 peticiones en vuelo, los sitios autenticados del daemon; ninguno con el criterio por defecto de Traefik, enmienda DS7-01: el de inFlightReq es el Host), y el daemon cuenta ese mismo cliente real, así que un borde mal configurado no devuelve el desalojo. check:deploy exige al daemon de styx-edge proxy, la IP fija del proxy y la prueba. Ninguna opción apaga el resto de límites.

  • Staging <uploadId>.part exclusivo 0600, relativo al fd de la raíz y nunca a través de un symlink (I7). El nombre final es el SHA-256 del contenido (la raíz BLAKE3 en wire v2, rama w13/conduit-v2): el cliente nunca elige una ruta y el filename es sólo metadata validada (sin /, \ ni controles). El finalizador hace fsync del fichero, el rename y fsync del directorio. Al arrancar se borran los .part de una ejecución anterior (las sesiones de subida viven en memoria). Todo eso, en el SDK, va por las entradas de zkit.safety.fs.Root (entries, deleteEntry, renameEntry, syncDir: un solo componente, el enlace nunca se sigue); conduit no llama a ninguna primitiva de path cruda en lo que corre en el daemon (ver §5, alcance del audit).

  • SHA-256 por chunk (Content-Digest) y del fichero entero (en orden, con ReorderBuffer), comprobado contra el que declara el cliente. En wire v2 (rama w13/conduit-v2): CV BLAKE3 por chunk (Conduit-Chunk-Cv) en el mismo pase que lo escribe, chunks en cualquier orden y la raíz del fichero combinada de los CVs, sin releer.

  • Una sesión de ingesta, un fichero (enmienda ZSDK4-P2-01: antes un solo token encadenaba create/chunk/complete sin límite mientras playback-svc no cerraba la sesión — 40 ficheros y 40 eventos con una aprobación, fuera de toda cuota porque complete devolvía la sesión y el staging). Dos capas, cada una suficiente por sí sola:

    • conduit: el sink pasa Grant.max_uploads = 1 por sesión. El SDK cuenta por Grant.ownerKey() cada create que se confirma y no lo devuelve ni complete, ni abort, ni el desalojo de terminadas, ni la expiración; sólo abortOwner (el closeIngest, tras el unbind) olvida la cuenta. Pasado el cupo, 409 upload_limit, no reintentable. Una subida que falló la verificación también gasta el cupo: otra subida necesita otra sesión.
    • sink: el finalizador marca terminado el slot de la sesión al encolar su evento, y un slot terminado rechaza todo create (401) en authorize y en stillValidFn, sin esperar a que playback-svc cierre la sesión. Una renovación del mismo sid hereda la marca. El token sigue respondiendo status y complete de su subida (idempotentes).
  • Cola de eventos por actor: como mucho 64 sin confirmar (max_events_per_actor) y bytes de ficheros terminados sin confirmar ≤ su cuota de staging (max_pending_bytes_per_actor). Un actor no llena la cola (antes, EventQueueFull daba 500 al complete de todos) ni apila ficheros finales por encima de su cuota mientras catalog no confirma; su complete responde 500 (reintentable) y su staging sigue reservado.

4. Del daemon al catálogo, al menos una vez

  • Contratos del socket de control (spire, @styx/api-contracts/bus, Zig generado, policy de BUS_ROUTES: sólo playback-svc y styx-ops): cmd.media.openIngest{capActor, capResource}, cmd.media.pollIngestEvents{ackSeq} (hasta 32 eventos por respuesta) y cmd.media.closeIngest{sessionId} (idempotente). Si la respuesta de openIngest no se puede sellar, spire deshace el binding (onReplyFailure).
  • El sink deja un hecho por subida verificada en una cola acotada. playback-svc la drena (pollIngestEvents) y confirma sólo lo que catalog ya registró. Con la cola llena, el complete del cliente responde 500 y se reintenta: nunca se pierde un evento.
  • Al ver una subida terminada, playback-svc cierra su sesión en el acto (sus tokens mueren, deja de contar; lo hace también un temporizador propio, closeFinishedSessions, para que el hueco no espere a que catalog conteste al relay) y pide a catalog-svc cmd.catalog.registerIngested (policy: sólo playback-svc) con el hecho. catalog resuelve rootId con su configuración (STYX_INGEST_ROOTS, nunca una ruta del mensaje) y da de alta el asset con ids deterministas del hash de la transferencia (raíz BLAKE3 en la rama w13/conduit-v2): repetir es idempotente. Respuesta ok → se confirma al daemon; error retryable o bus sin respuesta → no se confirma y se reintenta en la siguiente pasada, como mucho 10 (el max_deliver del consumidor JetStream de antes); error permanente o intentos agotados → se confirma y se descarta con un error en el log (el fichero sigue en la raíz): un fichero que ffprobe no entiende no bloquea a los siguientes.
  • Cada evento va por su cuenta (TS7-P2-01): uno que catalog contesta como reintentable se aparta con sus intentos y la pasada sigue, así que las subidas de detrás se dan de alta en el acto; la confirmación al daemon es acumulativa y avanza por el prefijo ya resuelto (lo resuelto detrás de uno pendiente no se re-envía). Sin respuesta de catalog la pasada se corta (es de catalog entero). En catalog, un medio cuyos números no caben en el índice (MEDIA_INDEX_LIMITS) es CATALOG_MEDIA_UNSUPPORTED, un error determinista de Postgres (SQLSTATE 22/23) es permanente, el alta (work, edition, asset, índice) es una transacción y por el bus sólo viaja el texto fijo del código de error.
  • No hay evt.media.ingestCompleted: su único consumidor era catalog, y un cmd con respuesta da la confirmación de extremo a extremo que el relay necesita (hasta que catalog guardó el asset) sin un stream MEDIA que provisionar.

5. Qué queda fuera (follow-ups)

  • Binding HTTP/3 del sink (el listener H3 del daemon sólo sirve GET/HEAD). Hasta entonces el TLS lo pone el borde.
  • Cliente web (@mks2508/chunk-engine o el WASM del SDK) y Swift (C-ABI del SDK): cuando la web tenga su flujo de subida. Las rutas /ingest/* no están en el CORS de playback-svc (sólo el CLI las usa hoy).
  • Reproducir mientras sube (ExtentMap, dec-0106) y promoción al Storage Box.
  • Clase de prioridad de la ingesta en el GlobalByteScheduler (r26) cuando exista.
  • El código de los SDK queda fuera de audit:safety (dec-0117 §6: zig-pkg/ es non_production, y la especificación del guard está cerrada). Lo que se movió de styx a conduit (barrido de staging, borrado, rename final) no se escapa por ahí: conduit lo hace con zkit.safety.fs.Root, y el mismo safety-audit de styx aplicado al árbol de conduit deja server.zig, http.zig y wire.zig (lo que corre en el daemon) en 0 hallazgos (evidencia en el ticket #01). Quedan, en el cliente de styx-upload, dos S1 declarados en resume_state.zig: el realpath del fichero a subir (sólo forma la clave del registro, no abre nada) y el makeDir del directorio de estado del usuario. Follow-up SEC-Z06-F4: auditar las fuentes pinneadas de los SDK que styx enlaza (conduit, spire) con el mismo guard y su propio baseline, con test, mutante y enmienda de dec-0117 §6.
  • Propiedad persistente de las sesiones en playback-svc: hoy vive en memoria; tras un reinicio una sesión abierta no se renueva por HTTP (el daemon la cierra al terminar o por inactividad) y su evento sale sin actorId.

Consecuencias

  • styx tiene lado de escritura con capability obligatoria de punta a punta, y el primer consumidor real de la policy session de @styx/service-http y de qry.identity.session.
  • conduit gana consumidores Zig reales (el daemon como servidor, styx-upload como cliente), lo que activa el disparador de reapertura del lock lock/conduit-motor-al-core del ledger de zkit (su enmienda sigue en la rama docs/enmienda-conduit-spire-styx de ese repo).
  • El daemon, conduit y styx-upload enlazan un solo zkit: el que fija native/zig/build.zig.zon, el mismo commit que pinnea conduit.

Verificación

  • Zig: zig build test:ingest (sink sobre sockets reales con el cliente del SDK: subida, resume con token renovado, chunks desordenados, digest y hash malos, path traversal, cuotas, cola de eventos, .part huérfanos, una sesión renovando en bucle que no deja fuera a otra, supersesión, topes por actor y total con el token rechazado sin gastar) y test:security (regla del Authority); mutantes en rojo.
  • Zig (daemon): control_test.zig (la ingesta por el socket de control de spire: policy, raíz, binding, revocación); styx_upload.zig (renovación en segundo plano contra un servidor conduit real con un playback-svc falso que abandona: subida lenta que cruza la vida de la capability; mutantes sin renovación y con token fijo en rojo).
  • spire: tests de cap/authority.zig (verify/consume, Verified no falsificable, tope de ingesta) con cuatro mutantes, en el repo spire.
  • TS: ingest-handler.test.ts (cota de abandono con skew y chunk en vuelo, barrido que reintenta, relay que cierra antes de registrar y con intentos acotados), ingest-routes.test.ts, ingest-daemon-client.test.ts, ingest-catalog.test.ts, principal-resolver.test.ts (playback-svc), ingest-completed.test.ts y bus-handlers.test.ts (catalog-svc), policy.test.ts (service-http).
  • E2E: tools/e2e-harness/ingest/run.sh (sobre el bus de spire con ACL; caso I = subida lenta de 17 MiB a ~400 kbps con chunks de 16 MiB que cruza la caducidad de la capability), evidencia en docs/track/byte-runtime/ingest/evidence/e2e/.