dec-0111

Vista generada de dec-0111: ABI v0 de `styx-client-core`: core sin heap, longitudes `u32`, ubicación y bindings

ImplementadoSin versión del tren todavía· generada desde docs/decisions/dec-0111-client-core-abi-v0-modelo-de-memoria.md
track/docsdec-0124track/docs:DC10

Vista generada desde docs/decisions/dec-0111-client-core-abi-v0-modelo-de-memoria.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
EstadoPROPOSED
Fecha2026-09-28
Ficherodocs/decisions/dec-0111-client-core-abi-v0-modelo-de-memoria.md

Por qué importa (del frontmatter del ADR):

Precondición de track/client-platform-foundation/c1 (TKT-044 M0: 'paquete nuevo ... decisión per TKT-040 C0 boundary') y de c2 (C header + TS binding + lifetime tests). docs/design/client-core/04-abi-memory-rules.md aplica este ADR regla a regla.

Nodos del roadmap que lo citan en refs: ninguno.

Páginas de la documentación que lo citan: Clientes y player propio (especificado)

Texto del ADR

Leído de docs/decisions/dec-0111-client-core-abi-v0-modelo-de-memoria.md, el fichero canónico.

dec-0111 — ABI v0 de styx-client-core: core sin heap, longitudes u32, ubicación y bindings

  • Fecha: 2026-09-28
  • Estado: PROPOSED. Pendiente de AskUserQuestion a waxin. Nada de esto está implementado: C0 es diseño y el código empieza en C1. Todas las opciones son reversibles hasta que C2 publique el primer header.
  • Cita: r56 D4.2 (dec-0073), dec-0077 (el allocator no cruza la ABI), dec-0078 (una sola versión de fixtures), dec-0074 (estabilidad), r07 (orden FFI), r58 D6.3 (dec-0084, presupuestos de memoria Pi 3). D8 cita además dec-0070 (handoff), dec-0094 + r61 §D1 (SessionStyxCapsule) y r61 D4 (generation especulativa).
  • Diseño que lo aplica: docs/design/client-core/04-abi-memory-rules.md.

Contexto

r56 D4.2 fija las reglas de la ABI v0 (handles opacos, enteros de ancho fijo, buffers del caller, el allocator no cruza la frontera, negociación de versión) pero deja abiertas cinco cosas que C1 necesita para escribir la primera línea:

  1. Quién aloja el estado de un handle opaco. "Buffers del caller" cubre input/output, pero un RangeSet es estado de tamaño variable que vive entre llamadas. Si el core lo aloja con su propio allocator, el caller tiene que devolvérselo para liberarlo: es exactamente el ownership que dec-0077 prohíbe que sea ambiguo. En wasm32 además hay que decidir quién gestiona la memoria lineal (la POC de bindings usaba un bump sobre __heap_base sin dueño declarado).
  2. El ejemplo de firma de r56 usa size_t, que mide 32 bits en wasm32 y 64 en aarch64/x86_64. Choca con la regla "fixed-width integers" del mismo ADR y con la igualdad byte a byte de fixtures entre targets.
  3. Ubicación del módulo (native/zig/client-core/ o packages/client-core/) y método de bindings TS. 00-INDEX.md los daba como "locked cont. 42 AskUserQuestion", pero no hay registro de ese lock en docs/progress-log.md ni en un ADR. La POC de bindings (08-ts-bindings-poc) dice expresamente que su recomendación no es un lock.
  4. Formato de entrada de styx_plan_validate_v1. r56 dice "bytes" y no dice cuáles.
  5. Qué viaja en un handoff de generation. El contrato de C0 (08) emite cada generation desde last_issued, el mayor id emitido en la sesión, para que una especulativa descartada (r61 D4) no vea reutilizado su id. dec-0070 transfiere sólo la generation activa y SessionStyxCapsule (r61 §D1, dec-0094) lleva un único campo generation. Con sólo active, el receptor de un handoff emite desde active y puede reutilizar el id de una especulativa descartada antes del handoff: sus resultados tardíos se aplicarían como si fueran del receptor.

Opciones

Estado de handles

A. Core sin heap: el caller aporta el storage (recomendada)B. Allocator interno del core + *_destroyC. Allocator del caller inyectado por la ABI
Ownershipúnico: el caller es dueño de toda la memoriapartido: el core aloja y el caller dispara la liberacióncruza la frontera
dec-0077cumple por construccióncumple en letra, deja un dueño doblelo viola
wasm32la memoria lineal la gestiona sólo el callerdos gestores sobre la misma memoria linealcallbacks JS→WASM por cada alloc
Presupuesto Pi 3 (r58 D6.3)el cliente lo calcula antes de llamardepende de la cargadepende del caller
Costeuna llamada *_storage_size(capacity) por tiponinguno visiblecontradice r56

Longitudes

A. uint32_t en toda la ABI (recomendada)B. size_t como en el ejemplo de r56C. uint64_t
Ancho fijosíno (32/64 según target)sí
Fixtures byte a byteidénticosdifieren en structs con longitudesidénticos
Límite práctico4 GiB por llamada; el core sólo ve metadata de control y lotes de chunks—innecesario en wasm32 (memoria lineal de 32 bits)

Qué se propone

  • D1 — Core sin heap (opción A). Ninguna función exportada aloja memoria. Los tipos con estado (StyxRangeSet, StyxIntervalMap) se inicializan dentro de storage que aporta el caller: styx_range_set_storage_size(capacity, *out_size) → el caller reserva out_size bytes con alineación 8 → styx_range_set_init(storage, storage_len, capacity, **out). El handle es un puntero a ese storage; liberar es liberar el storage. Si una operación necesita memoria temporal, el caller pasa un scratch y el core usa un FixedBufferAllocator local a la llamada que muere al retornar: hay allocator dentro de la llamada, pero ni cruza la frontera ni sobrevive a ella. Cada función con scratch tiene una hermana *_scratch_size(input_len, *out_size) que da una cota dependiente sólo de input_len; la función comprueba scratch_len contra esa cota antes de leer la entrada, así que E_BUFFER_TOO_SMALL por scratch no depende del contenido.

  • D2 — Dos únicos estados globales del módulo, ambos de tamaño fijo y escritos sólo bajo la regla de un solo hilo (r56 D4.2 "no concurrent callbacks"): el latch de versión negociada y el registro de último error (styx_read_last_error). No hay más var globales. Los dos son de la capa abi/: el latch se declara en abi/version.zig y el registro, junto al enum StyxStatus, en abi/status.zig; L0, L1 y L2 no los importan.

  • D3 — Longitudes y capacidades uint32_t (opción A), incluida la firma de styx_plan_validate_v1 (uint32_t input_len). Es la lectura de r56 que cumple a la vez sus reglas "fixed-width integers" y "fixtures idénticos". Los punteros siguen siendo punteros: miden 32 o 64 bits según el target y no aparecen dentro de structs POD que viajen en fixtures.

  • D4 — wasm32: memoria importada. El módulo se enlaza con import_memory; la WebAssembly.Memory la crea y la gestiona el binding TS, que es el dueño de toda la región por encima de __heap_base. El core no exporta ningún allocator.

  • D5 — Ubicación native/zig/client-core/ (build propio, sin dependencias del daemon). native/zig/CLAUDE.md ya reserva ese nombre y native/README.md le da la misma misión. packages/ es TS y sus reglas de dependencias no aplican a Zig.

  • D6 — Bindings v0: zig translate-c para verificar el header + glue JS/TS escrito a mano, la opción 1 de la POC (cero dependencias nuevas, cada offset auditable, cubierto por los lifetime tests de C2). jco/Component Model se reevalúa cuando la superficie supere ~30 funciones.

  • D7 — Entrada de styx_plan_validate_v1 = JSON UTF-8 producido por el control plane a partir del plan ya validado por su schema TypeBox de @styx/api-contracts (dec-0116). El core lo tokeniza sin heap (scratch del caller) y comprueba invariantes algebraicas; no re-declara el schema. Un formato binario obligaría a mantener un segundo codificador TS a la par del schema TypeBox, y la validación de plan es una llamada por plan, fuera de toda ruta caliente. Se reabre sólo si el benchmark de C2 lo pone en un camino por chunk.

  • D8 — El handoff y SessionStyxCapsule transportan last_issued además de active (amend propuesto de dec-0070 y dec-0094/r61 §D1). El controller de origen descarta su especulativa pendiente, transfiere active y last_issued, y el receptor emite desde ese last_issued. SessionStyxCapsule añade lastIssuedGeneration: LogicalGenerationId junto a generation. La semántica de dec-0070 (transferir, no crear; el receptor hereda) no cambia: se transfiere un entero más.

    A. Transportar last_issued (recomendada)B. Namespacing por controller
    Toca locksamenda dec-0070 y dec-0094 (un campo)ninguno
    Formau64 monotónico, igual que hoyu64 = controller_epoch (bits altos) ‖ secuencia (bits bajos); cada controller emite en su propio espacio
    Handoffel receptor sigue la misma secuenciael receptor necesita un controller_epoch nuevo y único en la sesión: alguien tiene que asignarlo, que es otra vez un creador de ids
    admittagged > last_issued detecta ids no emitidosel orden entre espacios deja de significar "más reciente"; is_stale sigue funcionando, E_GENERATION_FROM_FUTURE sólo detecta dentro del espacio propio
    Costeun campo en la cápsula y en el mensaje de handoffreparto de bits (límite de handoffs y de ids por controller) y un asignador de epochs

    B evita el amend pero introduce un segundo creador (el asignador de epochs), contra el single-creator de dec-0069. A mantiene un único creador activo en cada momento.

Consecuencias

  • 04-abi-memory-rules.md sustituye "ArenaAllocator interno" por D1 y size_t por uint32_t.
  • C2 añade dos lifetime tests: storage reutilizado tras init sin deinit, y storage más pequeño que *_storage_size → E_BUFFER_TOO_SMALL.
  • El ejemplo literal de r56 D4.2 queda desfasado en un tipo. Si waxin acepta D3, gobernanza añade la nota de amend a dec-0073.
  • Si waxin acepta D8, gobernanza añade la nota de amend a dec-0070 y dec-0094 y el campo a la interfaz de SessionStyxCapsule cuando W1 la cree en @styx/api-contracts. Mientras D8 no tenga lock, el invariante 1b de 08 se garantiza dentro de un controller y no a través de un handoff.

Qué NO decide

  • Qué tipos entran en la ABI v0 (lo decide el inventario de C0 y lo confirma C1 con consumer).
  • La política de estabilidad (ya fijada en dec-0074).
  • L3 (diferida por dec-0072).