From 96738adcd6615ddd48f29e9a21370e57f995795b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jose=20Villase=C3=B1or=20Montfort?= <195970+montfort@users.noreply.github.com> Date: Wed, 15 Jul 2026 21:35:30 -0600 Subject: [PATCH 1/2] =?UTF-8?q?feat(m3):=20Polish=20=E2=80=94=20doc=20de?= =?UTF-8?q?=20arquitectura,=20benchmark=20delta-size=20(SC-004)=20y=20vali?= =?UTF-8?q?daci=C3=B3n=20quickstart=20(CHARTER-11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ejecuta la fase Polish (P8) de specs/001-weft-crdt-versioning: T061, T062 y T063, las tres tareas que CHARTER-07 difirió. Cierra M3 salvo T060 (publish real, operador-gated por diseño). T062 — tests/Weft.Core.Tests/DeltaSizeBenchmark.cs (nuevo) El escenario de referencia de SC-004 no existía como definición: «523 B→29 B» sale de una celda «Rough perf» de un spike cuyo código es desechable por diseño. El benchmark define el escenario por criterio semántico (reconexión: par al día + 1 edición), lo fija en el doc-comment antes de medir, y aserta el ratio de la spec (≥90 %), no los absolutos. Aserta convergencia además del tamaño: sin eso, un delta vacío daría una «reducción» del 100 %. Medido: 479 B → 26 B = 94,6 % (la referencia histórica era 94,5 %). T063 — pase de validación end-to-end + evidencia Primera ejecución completa del runbook contra HEAD. Destapó 9 gaps, todos de «declaración de superficie sin cableado»; ninguno era un fallo del código de producción. Los dos más graves eran verificaciones fantasma —pasaban en verde sin verificar nada—: el filtro Category=Concurrency de US2 no casaba con ningún test (0 → 9 tests, cableado aquí), y el job pack-smoke por-PR es un marcador que sólo hace echo (el gate real vive en release.yml, workflow_dispatch). Los 9 corregidos atómicamente en quickstart.md. Evidencia en checklists/requirements.md con tres estados explícitos: ejecutado / cubierto-por-CI / no-ejecutado-con-motivo. US4 queda sin marcar pese al pack local verde, porque su criterio es instalación en máquina limpia por RID. T061 — docs/architecture.md (nuevo) Módulos, frontera FFI con el contrato público de ownership de memoria, flujo de sync, versionado, concurrencia, gates y límites conocidos (R6). Enlazado desde README.md y docs/api/README.md. Un pase adversarial contra HEAD encontró 5 afirmaciones falsas en el primer borrador, todas corregidas; una de ellas se propagó desde un comentario obsoleto de ci.yml (→ FU-018). Verificado: 132/132 tests, ASan 14/14 sin fugas, paridad Yjs (ascii+unicode), convergencia real de 2 clientes y-websocket contra el relay, LoadTest PASS (45 004 ops, 0 errores). El fuzz reproduce R6 (OOM con 4 bytes): esperado, no regresión — fix aprobado upstream en y-crdt#639, adopción vía FU-015. Follow-ups: FU-018 (comentario de ci.yml con dos afirmaciones falsas), FU-019 (footgun de pack local con test-hooks), FU-020 (guard de CI para verificaciones fantasma). Co-Authored-By: Claude Opus 4.8 (1M context) --- ...6-07-15-003-charter-11-polish-cierre-m3.md | 175 +++++++++ .straymark/charters/11-polish-cierre-m3.md | 183 ++++++++++ .straymark/follow-ups-backlog.md | 29 +- README.md | 7 + docs/api/README.md | 4 + docs/architecture.md | 339 ++++++++++++++++++ .../checklists/requirements.md | 53 +++ specs/001-weft-crdt-versioning/quickstart.md | 45 ++- specs/001-weft-crdt-versioning/tasks.md | 6 +- tests/Weft.Core.Tests/DeltaSizeBenchmark.cs | 104 ++++++ tests/Weft.Core.Tests/DocumentBrokerTests.cs | 6 + 11 files changed, 933 insertions(+), 18 deletions(-) create mode 100644 .straymark/07-ai-audit/agent-logs/AILOG-2026-07-15-003-charter-11-polish-cierre-m3.md create mode 100644 .straymark/charters/11-polish-cierre-m3.md create mode 100644 docs/architecture.md create mode 100644 tests/Weft.Core.Tests/DeltaSizeBenchmark.cs diff --git a/.straymark/07-ai-audit/agent-logs/AILOG-2026-07-15-003-charter-11-polish-cierre-m3.md b/.straymark/07-ai-audit/agent-logs/AILOG-2026-07-15-003-charter-11-polish-cierre-m3.md new file mode 100644 index 0000000..19f24c3 --- /dev/null +++ b/.straymark/07-ai-audit/agent-logs/AILOG-2026-07-15-003-charter-11-polish-cierre-m3.md @@ -0,0 +1,175 @@ +--- +id: AILOG-2026-07-15-003 +title: "CHARTER-11: Polish cierre de M3 — doc de arquitectura, benchmark delta-size (SC-004) y validación quickstart" +status: accepted +created: 2026-07-15 +agent: claude-opus-4-8 +confidence: high +review_required: true +risk_level: low +eu_ai_act_risk: not_applicable +nist_genai_risks: [] +iso_42001_clause: [] +observability_scope: none +tags: [polish, architecture-doc, benchmark, sc-004, quickstart-validation, runbook-drift, m3] +related: [AILOG-2026-07-15-002, AILOG-2026-07-14-002] +originating_charter: CHARTER-11-polish-cierre-m3 +--- + +# AILOG: CHARTER-11 — Polish, cierre de M3 (T061 + T062 + T063) + +## Summary + +Despacho de la fase `Polish` (P8) de `specs/001-weft-crdt-versioning/`: las tres tareas que CHARTER-07 +declaró fuera de su alcance y difirió a este bloque. Ejecutado siguiendo +[`POLISH-CHARTER-PATTERN.md`](../../00-governance/POLISH-CHARTER-PATTERN.md) — el Charter de cierre de +Etapa como **gate de detección de deuda**, no como limpieza cosmética. + +El patrón se validó: el pase de T063 fue la primera ejecución end-to-end de `quickstart.md` contra HEAD +y destapó **9 gaps**, todos del anti-patrón *«declaración de superficie sin cableado»* (sub-clase 1: +el runbook declara comandos y garantías que nadie había ejecutado ni releído). **Ninguno era un fallo +del código de producción** — el código estaba bien; el runbook mentía. Los dos más graves: el paso de +US2 pasaba en verde **ejecutando cero tests** desde que se escribió, y la tabla de gates promete que +cada PR valida el empaquetado cuando el `pack-smoke` por-PR es **un marcador que sólo hace `echo`**. + +Con esto M3 queda cerrado salvo T060 (publish real a NuGet.org), operador-gated por diseño. + +## Actions Performed + +1. **T062 — `tests/Weft.Core.Tests/DeltaSizeBenchmark.cs`** (nuevo). El escenario de referencia de + SC-004 **no existía como definición**: `523 B→29 B` sale de una celda etiquetada «Rough perf» en + `docs/spikes/spike03/hallazgos-spike-03.md:38`, de un spike cuyo código es desechable por diseño y + no vive en el repo — sin protocolo, sin tamaño de doc, sin nº de ediciones. El benchmark **define** + el escenario por criterio semántico (reconexión: par al día + 1 edición pequeña, tomado de la prosa + de SC-004 y de `contracts/server-api.md:116`), lo fija en el doc-comment **antes** de medir, y + aserta el **ratio** de la spec (≥ 90 %), citando 523→29 sólo como contexto histórico. Client-ids + fijos (capacidad de `YrsEngine`, CHARTER-09) para que el varint de un id aleatorio no meta ruido en + la medida. El test aserta **convergencia además del tamaño**: sin eso, un delta vacío daría una + «reducción» del 100 %. Reporta su medición vía `ITestOutputHelper`. + **Medido: 479 B → 26 B = 94,6 %** (la referencia histórica era 94,5 % — corroboración independiente + de que el dato del spike, aunque irreproducible, era del orden correcto). +2. **T063 — cableado del filtro de US2**: `[Trait("Category","Concurrency")]` en `DocumentBrokerTests`. + El filtro pasó de **0 tests a 9**, verdes también en aislamiento (R5 no se materializó). +3. **T063 — pase de validación end-to-end** de US1–US5 + los 6 gates, con evidencia de **tres estados** + (ejecutado aquí / cubierto por CI / no ejecutado con motivo) en `checklists/requirements.md`. Un + ítem sin evidencia se queda sin marcar: `[x]` significa «lo corrí y lo vi». +4. **T063 — corrección atómica de la deriva del runbook** (8 gaps, tabla en `checklists/requirements.md`). +5. **T061 — `docs/architecture.md`** (nuevo): mapa de módulos, frontera FFI con el **contrato público de + ownership de memoria** (las 3 clases de memoria y sus reglas, las postcondiciones que ahorran + depuración, y el único punto del lado .NET donde puede fugarse: `YrsDoc.TakeOwnedBuffer`), flujo de + sync, modelo de versionado, concurrencia, gates, **§Límites conocidos** (R6 y su mitigación para la + ruta directa) e índice R1–R17 → `research.md`. Enlazado desde `README.md` §Arquitectura y + `docs/api/README.md`. Escrito anclando cada afirmación estructural a HEAD y verificado después con + un pase adversarial contra el código. +6. `tasks.md`: T061/T062/T063 marcadas con su desenlace. + +## Gaps detectados por el pase (los 9) + +Todos de deriva del runbook, todos corregidos atómicamente (estaban en `## Scope` punto 7): + +1. `--filter Category=Concurrency` sin ningún `[Trait]` en el repo → **US2 verde con 0 tests**. +2. Build local sin `--features test-hooks` → `PanicSafetyTests` rojo (`EntryPointNotFoundException: + weft_test_panic`) siguiendo el runbook literalmente. El CI sí usa la feature (`ci.yml:44`). +3. `cp` desde `native/weft-yrs-ffi/target/release/` → ruta **inexistente**: `native/` es un workspace + cargo y comparte `native/target/`. +4. Ese `cp` es además **innecesario**: los `.csproj` de test copian desde `native/target/release/` y el + pack lee de `native/target//release/` (`build/Weft.Native.targets`). El destino ni existe. +5. US3 decía «relay en `:5000`»; el sample escucha en **`:5199`** (`WEFT_SAMPLE_URLS`). +6. US3 sólo documentaba `npm run dev` (navegador). `npm run check` —smoke headless de convergencia real + vía `y-websocket`— existía y no estaba en el runbook: no había forma documentada de validar US3 sin + display. **Ejecutado en este pase**: convergencia real de 2 clientes Yjs contra el relay. +7. Gate de determinismo descrito como «no-bloqueante al inicio, promovible». La paridad yrs↔Yjs **sí es + bloqueante** desde CHARTER-09/FU-012 (vive en `Weft.Determinism.Tests`, job `test`); el job Node de + `release.yml` es informativo y caza drift del upstream. La redacción confundía ambos. +8. La tabla de gates promete «un rojo bloquea merge» y lista `fuzz` sin matiz. `fuzz` **bloquea a + medias**: un crash encontrado sólo emite `::warning` (`|| echo` por paso), pero un fallo de + compilación de los targets **sí** pone el job rojo, deliberadamente (`ci.yml:98`). No hay + `continue-on-error` en el job. +9. **El más grave.** La tabla lista `pack-smoke` como gate bloqueante por PR. El job `pack-smoke` de + `ci.yml:229-235` es un **marcador que sólo hace `echo`**; la matriz real (SC-007) y la verificación + de que `weft_test_panic` no está exportado (SC-009, job `native`, `nm` sobre los cdylibs + pre-pack) viven en `release.yml`, que es `workflow_dispatch` **únicamente** — la matriz + cross-compile es cara y se valida en el dry-run del release. El runbook prometía que cada PR valida + el empaquetado; **ningún PR lo valida**. Detectado por el pase adversarial del doc, no por el pase + del runbook: sólo se ve leyendo el YAML. + +## Risk + +Riesgos del Charter (R1–R6) y su desenlace: + +- **R1 (tunear el escenario del benchmark hasta que pase)** — mitigado y no materializado. El escenario + se fijó por criterio semántico y se escribió antes de medir; salió 94,6 % sin ajustar nada. +- **R2 (scope creep por los gaps del pase)** — materializado como se predijo (8 gaps) y contenido: los 8 + son deriva de runbook, explícitamente en scope ex-ante. Lo que **no** era runbook se triagea abajo, no + se absorbe. +- **R3 (el doc duplica y se pudre)** — mitigado: el doc enlaza en vez de copiar; su valor propio es la + frontera FFI y el contrato de ownership, que ningún otro doc público explica. +- **R4 (marcar verde lo no ejecutado)** — mitigado con los 3 estados de evidencia. US4 queda + **sin marcar** pese al pack local verde, porque su criterio es instalación en máquina limpia por RID. +- **R5 (los tests de concurrencia fallan al aislarse)** — no materializado: 9/9 verdes en aislamiento. +- **R6 (el doc afirma cosas que el código ya no hace)** — **se materializó, y la mitigación lo cazó.** + El pase adversarial contra HEAD encontró **5 afirmaciones falsas** en el primer borrador de + `docs/architecture.md`: (a) atribuía la verificación de `weft_test_panic` a `pack-smoke` sobre los + binarios empaquetados, cuando la hace el job `native` con `nm` sobre los cdylibs pre-pack, en un + workflow que ni siquiera corre por PR; (b) acoplaba la retirada de awareness al último cliente, + cuando es por conexión —de ser cierto habría sido un bug de presencia—; (c) decía «un solo sitio» + para `TakeOwnedBuffer` habiendo dos (yrs y Loro), lo que mandaría a un auditor de fugas a la mitad + de la superficie; (d) enumeraba 11 de las 12 funciones omitiendo justo `weft_doc_new_with_client_id`; + (e) llamaba a `fuzz` `continue-on-error`. + + El caso (e) merece constar como lección: **el error se cometió exactamente por el mecanismo que R6 + anticipaba.** No salió de leer el YAML sino de heredar el comentario de `ci.yml:76`, que afirma + `continue-on-error` y es falso. Un doc derivado de un comentario obsoleto propaga la obsolescencia y + le añade autoridad. La regla de «anclar a `archivo:línea` leído en HEAD» se aplicó al código C#/Rust + pero no al YAML de CI, y ahí es donde falló. Todas corregidas antes del commit; el comentario que lo + originó va a follow-up. + +**R7 (nuevo, no en el Charter) — el fuzz reproduce R6 en local y puede leerse como regresión.** +`cargo +nightly fuzz run doc_load` OOMea con un input de **4 bytes** (`f6f4d621`). **No es una +regresión**: es R6, el job es `continue-on-error` por esta razón exacta, el shim es correcto (contiene +panics, sin UB) y el fix vive upstream (y-crdt#639, aprobado) → adopción vía FU-015. El riesgo real es +de **legibilidad**: un contribuidor que corra el gate del quickstart lo lee como rojo nuevo. Mitigado +documentándolo en la tabla de gates (`quickstart.md`) y en `docs/architecture.md` §Límites conocidos. + +## Follow-ups + +- **Comentario obsoleto en `.github/workflows/ci.yml:76-81`** — **dos afirmaciones falsas**, y ya + demostró que hace daño: (1) dice que el job es `continue-on-error`, y **no lo es** (no existe esa + clave; lo informativo son los `|| echo` de los pasos `fuzz run`, mientras que un fallo de compilación + sí pone el job rojo); (2) dice que la mitigación real de R6 «llega en M2, donde entra input de red no + confiable», cuando llegó en CHARTER-08 (M3) como PR upstream + caveat en `GOVERNANCE.md`, y lo que + resta es la adopción vía bump (FU-015). **Este comentario es el origen probado del único error de + hecho que se propagó a la documentación en este Charter** (ver §Risk R6, caso (e)): la + documentación derivada de comentarios de CI hereda su obsolescencia. Severidad: baja en código, media + en efecto — es un comentario que activamente desinforma. Un mismo commit debería corregir el + comentario y `ci.yml:180` (que también afirma, falsamente, que la paridad con Yjs no es bloqueante). + No se corrige aquí para no ampliar el scope del Polish más allá del runbook. +- **Footgun de pack local contaminado con `test-hooks`**: el pack lee de + `native/target//release/`. Quien compile con `cargo build --release --target + --features test-hooks` y luego haga `dotnet pack` empaquetaría `weft_test_panic`. Hoy no ocurre (el + pipeline de release compila sin la feature y `pack-smoke` verifica la ausencia), y en este pase se + verificó que los `.so` que alimentan el pack están limpios. Pero el gate sólo existe en CI: un pack + local no lo caza. Severidad baja. Candidato: extender la verificación del símbolo a un target de pack + local, o documentarlo en `CONTRIBUTING.md`. +- **Guards de CI para las sub-clases del anti-patrón** (paso 5 del walkthrough del patrón de Polish). El + gap #1 (comando del runbook que no casa con ningún test) es mecánicamente detectable: un check que + verifique que cada `--filter Category=X` documentado casa con ≥1 test lo habría cazado el día que se + escribió. Candidato natural tras este Charter, y el más portable de los tres que sugiere el patrón. + +## Verification + +```bash +cargo build --release --features test-hooks --manifest-path native/Cargo.toml +dotnet test Weft.sln -c Release # 132/132 verdes +dotnet test tests/Weft.Core.Tests -c Release --filter Category=Concurrency # 9/9 (antes: 0) +dotnet run --project tests/Weft.LoadTest -c Release # PASS, 0 errores +RUSTFLAGS="-Zsanitizer=address" cargo +nightly test --features test-hooks \ + --target x86_64-unknown-linux-gnu # 14/14, 0 fugas +cd tests/determinism-yjs && npm test # golden ascii + unicode OK +cd samples/tiptap-client && npm run check # convergencia real vs relay +straymark validate --include-charters +``` + +Evidencia completa del pase en +[`checklists/requirements.md`](../../../specs/001-weft-crdt-versioning/checklists/requirements.md) +§Quickstart validation pass. diff --git a/.straymark/charters/11-polish-cierre-m3.md b/.straymark/charters/11-polish-cierre-m3.md new file mode 100644 index 0000000..fa1d0cb --- /dev/null +++ b/.straymark/charters/11-polish-cierre-m3.md @@ -0,0 +1,183 @@ +--- +charter_id: CHARTER-11-polish-cierre-m3 +status: closed +closed_at: 2026-07-15 +execution_ailogs: [AILOG-2026-07-15-003] +effort_estimate: L +trigger: "CHARTER-10 cerró G1/FU-006 (PR #26, merged 2026-07-16): ya no quedan mini-charters de follow-up en la secuencia de M3, y T061–T063 son el último bloque antes del publish operador-gated (T060). Es la primera vez que el runbook de quickstart.md se ejercitará end-to-end contra el árbol de HEAD." +originating_spec: specs/001-weft-crdt-versioning/spec.md +work_verb: implement +design_provenance: new +--- + +# Charter: Polish: cierre de M3 — doc de arquitectura, benchmark delta-size (SC-004) y validación quickstart + +> **Status (mirrored from frontmatter — source of truth is above):** declared. Effort: L. +> +> **Origin:** Fase `Polish` (P8) de `specs/001-weft-crdt-versioning/`; ejecuta T061, T062 y T063, las tres tareas que CHARTER-07 declaró explícitamente fuera de su alcance y difirió a este bloque. + +## Context + +M3 tiene cerrado todo su trabajo de user-story: US4 (empaquetado multi-RID) quedó verde en CHARTER-07 y el dual-path de Loro cerró su último gap (G1/FU-006) en CHARTER-10. Lo que queda es la fase `Polish` de SpecKit: T061 (doc de arquitectura), T062 (benchmark de delta-size que aserta SC-004) y T063 (pase de validación end-to-end de `quickstart.md` US1–US5 + los 6 gates de CI). Sólo T060 —el publish real a NuGet.org, operador-gated— queda fuera, porque su ejecución es una decisión humana, no de código. + +Este Charter se declara como **L** y no como M por la guía de [`POLISH-CHARTER-PATTERN.md`](../00-governance/POLISH-CHARTER-PATTERN.md): el Charter de cierre de una Etapa es un **gate de detección de deuda**, no limpieza cosmética, porque es el primer lugar donde el runbook documentado se ejercita contra el árbol real en vez de contra un harness de tests. Weft cumple los umbrales de adopción del patrón: `quickstart.md` documenta comandos que nunca se han corrido end-to-end desde HEAD, y hay artefactos cuyo sitio de declaración y sitio de cableado viven en módulos distintos (el header C `weft_ffi.h` vs `NativeMethods.cs` vs `lib.rs`; los gates declarados en `quickstart.md` §gates vs los jobs reales de `ci.yml`). + +El patrón ya cobró su primera pieza **antes de empezar**: el reconocimiento previo destapó que `quickstart.md:45` manda `dotnet test --filter Category=Concurrency`, pero no existe ni un solo `[Trait("Category", ...)]` en el repo — el comando de US2 pasa en verde **ejecutando cero tests**. Es la sub-clase 1 del anti-patrón *"surface declaration without wiring"* (runbook declara, código no cablea), y es exactamente el tipo de hallazgo que las suites por-Charter no pueden ver. + +## Scope + +**In scope:** + +1. **T061** — `docs/architecture.md` nuevo: módulos y grafo de dependencias, frontera FFI, flujo de sync, modelo de versionado, y el **contrato público de ownership de memoria** (hoy sólo vive en comentarios de `weft_ffi.h`/`lib.rs` y en `contracts/ffi-abi.md`, que es spec interna, no doc público). Enlaza a `research.md` R1–R17 para las decisiones en vez de reexplicarlas. +2. **T061** — `README.md` y `docs/api/README.md` enlazan el nuevo doc de arquitectura. +3. **T062** — `tests/Weft.Core.Tests/DeltaSizeBenchmark.cs` nuevo: **define** el escenario de referencia de reconexión (hoy inexistente como definición) y aserta reducción ≥ 90 % de `ExportUpdateSince(sv)` vs `ExportState()`, con mensaje diagnóstico que reporta ambos tamaños y el ratio medido. +4. **T063** — `DocumentBrokerTests` (y cualquier otro test de concurrencia) gana `[Trait("Category", "Concurrency")]`, de modo que el comando de US2 del runbook ejecute lo que dice ejecutar. +5. **T063** — Pase de validación end-to-end de `quickstart.md` US1–US5 + los 6 gates, con **triage explícito** de qué se ejecutó, qué no, y por qué. +6. **T063** — `checklists/requirements.md` gana la evidencia de cierre del pase (sección de checkboxes por US y por gate + notas con comando/resultado/fecha). +7. **T063** — Corrección atómica de la deriva de runbook que el pase destape en `quickstart.md` (el runbook es la especificación del test; si está mal, se corrige aquí). +8. `tasks.md` marca T061–T063; AILOG de ejecución. + +**Out of scope:** + +- **T060 (publish real a NuGet.org)** — operador-gated por diseño; el `dry_run` ya validó el pipeline en CHARTER-07. Es una decisión humana, no de este Charter. +- **Los gaps que el pase de T063 destape** — se **triagean** a follow-ups/Charters follow-on, no se absorben aquí. Es la regla explícita del patrón de Polish (paso 2 del walkthrough): el Charter de Polish es el vehículo de descubrimiento, no el de remediación. Única excepción: la deriva de documentación del propio runbook (punto 7), que el patrón manda corregir atómicamente. +- **FU-017 (test de paridad header↔binding del shim Loro)** — es literalmente la sub-clase 5 del anti-patrón y este Charter la deja anotada como tal, pero su implementación tiene Charter propio pendiente. +- **FU-010, FU-015, FU-016** — follow-ups abiertos, ninguno bloqueante para M3. +- **Guards de CI para las sub-clases del anti-patrón** — el patrón los sugiere (paso 5) tras el retrospectivo; candidatos a follow-on, no scope de aquí. + +## Files to modify + +| File | Change | +|---|---| +| `docs/architecture.md` | New — módulos + grafo de dependencias, frontera FFI, flujo de sync, modelo de versionado, contrato público de ownership; decisiones enlazadas a `research.md` | +| `tests/Weft.Core.Tests/DeltaSizeBenchmark.cs` | New — escenario de referencia SC-004 definido + assert de reducción ≥ 90 % | +| `tests/Weft.Core.Tests/DocumentBrokerTests.cs` | `[Trait("Category", "Concurrency")]` a nivel de clase — cablea el filtro que US2 declara | +| `specs/001-weft-crdt-versioning/checklists/requirements.md` | Evidencia de cierre del pase US1–US5 + 6 gates (checkboxes + notas con comando/resultado/fecha) | +| `specs/001-weft-crdt-versioning/quickstart.md` | Corrección atómica de la deriva de runbook detectada durante el pase | +| `specs/001-weft-crdt-versioning/tasks.md` | Marcar T061, T062, T063 | +| `README.md` | Enlace al doc de arquitectura | +| `docs/api/README.md` | Enlace al doc de arquitectura (relación overview-por-paquete ↔ arquitectura) | +| `.straymark/follow-ups-backlog.md` | Follow-ups nuevos que el pase destape (triage, no remediación) | +| `.straymark/07-ai-audit/agent-logs/AILOG-2026-07-16-NNN.md` | New, `risk_level: low` | + +## Verification + +### Local checks + +```bash +# Setup: el nativo debe existir antes de cualquier test .NET +cargo build --release --manifest-path native/weft-yrs-ffi/Cargo.toml +cargo build --release --manifest-path native/weft-loro-ffi/Cargo.toml + +# Build +dotnet build Weft.sln -c Release + +# T062 — el benchmark de delta-size, aislado +dotnet test tests/Weft.Core.Tests -c Release --filter "FullyQualifiedName~DeltaSizeBenchmark" + +# T063 — el filtro de US2 debe ejecutar >0 tests (hoy ejecuta 0: el bug que este Charter cablea) +dotnet test tests/Weft.Core.Tests -c Release --filter Category=Concurrency + +# T063 — suite completa + gates locales +dotnet test Weft.sln -c Release +cargo test --manifest-path native/weft-yrs-ffi/Cargo.toml +cargo test --manifest-path native/weft-loro-ffi/Cargo.toml + +# Gate de memoria (linux-x64 + nightly) +RUSTFLAGS="-Zsanitizer=address" cargo +nightly test \ + --target x86_64-unknown-linux-gnu --manifest-path native/weft-yrs-ffi/Cargo.toml + +# Governance +straymark validate --include-charters +``` + +### Production smoke (after deploy) + +No aplica: Weft es una librería, no un servicio desplegado. **Pero** dos escenarios del runbook no son ejecutables en un shell limpio de esta máquina y NO deben clasificarse como `real_debt` si no se ejecutan aquí: + +```bash +# US3 — validación manual con Tiptap real, 2+ clientes en navegador. +# Requiere interacción humana; el criterio de cierre de M2 la exige explícitamente. +dotnet run --project samples/Weft.Sample.Server +cd samples/tiptap-client && npm install && npm run dev # abrir 2 pestañas + +# US4 — instalación en máquina limpia por RID (win-x64, osx-arm64, linux-arm64). +# Requiere runners/máquinas por RID; en CI lo cubre el job `pack-smoke`. +dotnet new console && dotnet add package Weft.Core --source ./artifacts && dotnet run +``` + +## Risks + +- **R1 — Tunear el escenario del benchmark hasta que pase (fraude de benchmark)**: probabilidad media, severidad alta. SC-004 cita `523 B→29 B`, pero el reconocimiento confirmó que ese dato sale de una celda etiquetada *"Rough perf"* en `docs/spikes/spike03/hallazgos-spike-03.md:38`, de un spike cuyo código es **desechable por diseño** (`docs/spikes/README.md:4-5`) y no está en este repo. No hay protocolo, ni tamaño de doc, ni nº de ediciones. El riesgo es elegir el escenario *después* de ver qué números dan ≥ 90 %. + Mitigación: el escenario se define **primero** y por criterio semántico (reconexión: par al día + una edición incremental pequeña — la forma que SC-004 describe en prosa), y se escribe en el doc-comment **antes** de medir. Si el ratio medido no alcanza el 90 %, **no se ajusta el escenario para que pase**: se reporta el número real, se abre follow-up y se escala a decisión del operador. `523→29` se cita como contexto histórico, nunca como assert. +- **R2 — El pase de T063 destapa gaps y el Charter los absorbe (scope creep)**: probabilidad alta, severidad media. El patrón de Polish predice ~10 gaps en la primera sesión de la Etapa de referencia. Ya hay 1 confirmado antes de empezar (filtro `Category=Concurrency`). + Mitigación: triage estricto — cada gap va a `.straymark/follow-ups-backlog.md` con su sub-clase del anti-patrón, y sólo se remedia aquí (a) la deriva del propio runbook y (b) el cableado del filtro de US2, ambos declarados ex-ante en `## Scope`. Si un gap es tan grave que bloquea el cierre de M3, se para el Charter y se escala al operador en vez de ampliarlo en silencio. +- **R3 — `docs/architecture.md` duplica `docs/api/README.md`/`plan.md` y se pudre**: probabilidad media, severidad media. `plan.md` ya tiene §Summary por paquete y §Project Structure; `docs/api/README.md` ya tiene la tabla de dependencias. + Mitigación: el doc enlaza en vez de copiar (decisiones→`research.md` R1–R17, contratos→`contracts/`, API por paquete→`docs/api/README.md`) y su valor propio es lo que ningún otro doc tiene: la frontera FFI y el contrato público de ownership explicados para un consumidor externo. Si una sección no puede justificar por qué no es un enlace, se borra. +- **R4 — Marcar verde lo que no se ejecutó**: probabilidad media, severidad alta. US3 exige validación manual con Tiptap (2+ clientes) y US4 exige máquinas limpias por RID; ninguna es ejecutable en este shell. La tentación es marcar el checkbox porque «CI lo cubre». + Mitigación: la evidencia de cierre distingue tres estados explícitos — ejecutado aquí (con comando y resultado), cubierto por CI (con el job y el run), y **no ejecutado** (con el motivo). Un ítem sin evidencia se queda sin marcar; `[x]` significa "lo corrí y lo vi", no "debería funcionar". +- **R5 — Añadir `[Trait]` destapa que los tests de concurrencia fallan al aislarse**: probabilidad baja, severidad media. Hoy corren siempre dentro de la suite completa; nunca se han ejecutado solos. + Mitigación: se corre el filtro aislado como check local explícito. Si falla en aislamiento, es un hallazgo real (acoplamiento entre tests) y se trata bajo R2 (triage), no se revierte el `[Trait]` para ocultarlo. +- **R6 — El doc de arquitectura afirma cosas que el código ya no hace**: probabilidad media, severidad alta — un doc de arquitectura equivocado es peor que ninguno. `plan.md:5-11` advierte que sus secciones M0/M1 son inmutables y que «el código shippeado es la verdad, no este plan». + Mitigación: cada afirmación estructural del doc se ancla a `archivo:línea` leído en HEAD, no a `plan.md` ni al brief. Lo que no se pueda anclar, no se afirma. + +## Tasks + +1. Sync main, branch `charter/11-polish-cierre-m3`. +2. T062: definir el escenario en el doc-comment, luego implementar `DeltaSizeBenchmark.cs` y medir (en ese orden — R1). +3. T063: cablear `[Trait("Category", "Concurrency")]` y verificar que el filtro de US2 ejecuta > 0 tests. +4. T063: pase end-to-end US1–US5 + 6 gates, con triage de gaps a follow-ups (R2) y estados de evidencia explícitos (R4). +5. T063: escribir la evidencia de cierre en `checklists/requirements.md`; corregir atómicamente la deriva del runbook. +6. T061: escribir `docs/architecture.md` anclado a HEAD (R6) y enlazarlo desde `README.md` y `docs/api/README.md`. +7. Marcar T061–T063 en `tasks.md`. +8. AILOG (`risk_level: low`, `review_required: false`). +9. Verificación local limpia. +10. `straymark charter drift CHARTER-11 --range origin/main..HEAD` antes de commit; documentar drift en el AILOG. +11. Commit + push + PR. + +## Charter Closure + +Al cerrar este Charter: + +1. **Atomic update (format v4)**: si el drift check reporta deriva no capturada en el AILOG, editar `## Files to modify` y/o añadir `## Closing notes` **en este mismo PR**. +2. **Post-merge drift check**: `straymark charter drift CHARTER-11 --range origin/main..HEAD`. +3. ~~**Mover la fila** en `.straymark/charters/README.md`~~ — no aplica: este repo no mantiene ese + índice (el estado vive en el frontmatter y en `straymark charter list`). +4. **Status frontmatter** `in-progress` → `closed` + `closed_at`. ✔ +5. **Retrospectivo del patrón de Polish** (paso 4 del walkthrough): ver §Retrospectivo. ✔ +6. **No borrar** este archivo. + +## Retrospectivo (patrón de Polish, paso 4) + +**Resultado: 9 gaps, 0 fallos de código de producción.** El pase confirmó la tesis del patrón —el +Charter de Polish como gate de detección de deuda— pero con un perfil de causa raíz muy concentrado. +No hace falta un AIDEC: el volumen es alto, la clasificación es de una sola categoría, y el AILOG ya +la documenta entera. + +| Causa raíz | Gaps | Comentario | +|---|---|---| +| *Surface declaration without wiring* (sub-clase 1: el runbook declara, nada cablea) | **9 de 9** | Todos | +| Ambient dependency rot | 0 | El pinning (`rust-toolchain.toml`, versiones exactas) hizo su trabajo | +| Fallo de código de producción | 0 | El código estaba bien en los 9 casos | + +Tres observaciones que valen para el próximo Polish: + +1. **El runbook fue la única fuente de deuda**, y no es casualidad: es el único artefacto del repo que + *nadie ejecuta* en CI. El código, los tests y los gates se ejercitan en cada PR y por eso no + derivan. Un documento que describe comandos y no los corre es, estructuralmente, donde la verdad se + pudre primero. +2. **Los dos gaps más graves eran promesas de verificación falsas, no comandos rotos.** El filtro de + US2 (#1) y el `pack-smoke` por-PR (#9) no fallaban: **pasaban en verde sin verificar nada**. Un + comando roto se detecta la primera vez que alguien lo corre; una verificación vacía puede vivir + indefinidamente porque su síntoma es idéntico al del éxito. Esta subclase —*verificación fantasma*— + es la que más merece un guard mecánico (→ FU-020). +3. **La documentación derivada de comentarios de CI hereda su obsolescencia.** El único error de hecho + que este Charter propagó a la documentación (afirmar que `fuzz` es `continue-on-error`) no salió de + leer mal el YAML, sino de creerle a un comentario que lo afirma y lleva meses siendo falso. El pase + adversarial lo cazó; el comentario sigue ahí (→ FU-018). La regla de anclar a `archivo:línea` en + HEAD hay que aplicarla también a la infraestructura, no sólo al código. + +**Predicción falsable** (el patrón la pide): el próximo Charter de Polish sobre este repo destapará +**menos gaps de runbook** —acaba de ejecutarse entero y corregirse—, pero seguirá destapando +*verificaciones fantasma* mientras no exista el guard de FU-020, porque hoy nada en CI las distingue +de un verde legítimo. diff --git a/.straymark/follow-ups-backlog.md b/.straymark/follow-ups-backlog.md index fb02a11..e011b2d 100644 --- a/.straymark/follow-ups-backlog.md +++ b/.straymark/follow-ups-backlog.md @@ -1,7 +1,7 @@ --- last_scan: 2026-07-15 schema_version: v1 -total_open: 4 +total_open: 7 total_promoted: 0 total_closed_in_session: 13 total_phase_blocked: 0 @@ -18,6 +18,7 @@ fully_extracted_ailogs: - AILOG-2026-07-14-002 - AILOG-2026-07-15-001 - AILOG-2026-07-15-002 + - AILOG-2026-07-15-003 --- # Follow-ups Backlog @@ -102,6 +103,32 @@ fully_extracted_ailogs: - **Cost**: S - **Notes**: CHARTER-10 creó `native/weft-loro-ffi/include/weft_loro_ffi.h`, pero ningún test automatizado verifica que las declaraciones `[LibraryImport]` de `Weft.Loro/Interop/NativeMethods.cs` coincidan con él. El shim yrs SÍ lo tiene (`weft_ffi.h` ↔ `Weft.Core`). Replicar ese test para Loro (paridad de firmas / regenerable con csbindgen). Mejora de robustez; ningún gate depende. +### FU-018 — **Comentario obsoleto en `.github/workflows/ci.yml:76-81`** — **dos afirmaciones falsas**, y ya +- **Origin**: AILOG-2026-07-15-003 §Follow-ups +- **Source-hash**: 26789333fd7e +- **Status**: open +- **Trigger**: TBD +- **Destination**: TBD +- **Cost**: TBD +- **Notes**: Auto-appended by `straymark followups drift --apply` 2026-07-15. + +### FU-019 — **Footgun de pack local contaminado con `test-hooks`**: el pack lee de +- **Origin**: AILOG-2026-07-15-003 §Follow-ups +- **Source-hash**: d0e84c351361 +- **Status**: open +- **Trigger**: TBD +- **Destination**: TBD +- **Cost**: TBD +- **Notes**: Auto-appended by `straymark followups drift --apply` 2026-07-15. + +### FU-020 — **Guards de CI para las sub-clases del anti-patrón** (paso 5 del walkthrough del patrón de Polish). El +- **Origin**: AILOG-2026-07-15-003 §Follow-ups +- **Source-hash**: fc2f384b719f +- **Status**: open +- **Trigger**: TBD +- **Destination**: TBD +- **Cost**: TBD +- **Notes**: Auto-appended by `straymark followups drift --apply` 2026-07-15. ## Bucket: time-triggered ## Bucket: charter-triggered diff --git a/README.md b/README.md index 3214393..08c2dc6 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,13 @@ weft/ └── .github/workflows/ # CI: build multi-RID, tests, ASan, fuzzing, determinismo ``` +## Arquitectura + +[**docs/architecture.md**](docs/architecture.md) explica cómo encaja todo: mapa de módulos, la +frontera FFI y su **contrato de ownership de memoria**, el flujo de sync, el modelo de versionado +content-addressed y los límites conocidos. Es la lectura recomendada antes de integrar Weft o de +tocar el shim. La referencia por paquete está en [docs/api/](docs/api/README.md). + ## Desarrollo Spec-driven, con [GitHub Spec Kit](https://github.com/github/spec-kit): **Spec → Plan → Tasks → Implement**. El diseño (`/specify`, `/plan`) y las tandas de implementación (`/tasks`, `/implement`) se realizan en Claude Code. Ver el **brief de diseño** en `docs/weft-design-brief.md`. diff --git a/docs/api/README.md b/docs/api/README.md index c5fdcac..0d4fa32 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -5,6 +5,10 @@ Weft se distribuye como varios paquetes NuGet en capas. Referencia solo los que necesites; las dependencias van **hacia el core**, nunca al revés. +> Esta página es la referencia **por paquete**: qué instalar y qué tipos usar. Para cómo encajan entre +> sí —frontera FFI, contrato de ownership de memoria, flujo de sync, modelo de versionado— ver +> [**Arquitectura de Weft**](../architecture.md). + | Paquete | Depende de | Para qué | |---|---|---| | **Weft.Core** | — (trae el motor nativo `yrs`) | Binding seguro + abstracciones + broker de concurrencia | diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..80a9ddd --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,339 @@ +# Arquitectura de Weft + +> Cómo está construido Weft y por qué. Documento de orientación para quien va a consumir la +> librería, integrarla o contribuir a ella. +> +> **Alcance**: este doc explica la **forma** del sistema y el **contrato de memoria** de la +> frontera nativa. No duplica lo que ya vive en otro sitio: la API por paquete está en +> [`docs/api/README.md`](api/README.md), los contratos formales en +> [`specs/001-weft-crdt-versioning/contracts/`](../specs/001-weft-crdt-versioning/contracts/), y el +> *porqué* de cada decisión técnica en +> [`research.md`](../specs/001-weft-crdt-versioning/research.md) (R1–R17), al que se enlaza en vez +> de reexplicarlo. + +## Qué es Weft + +Weft es un **building block**, no una aplicación: da colaboración CRDT en tiempo real y versionado +content-addressed a aplicaciones .NET. El trabajo CRDT real lo hace [`yrs`](https://github.com/y-crdt/y-crdt) +(el core Rust de Yjs); Weft aporta un binding seguro, un modelo de versionado que `yrs` no tiene, un +relay compatible con el ecosistema Yjs, y la disciplina de memoria/determinismo que hace que todo eso +sea sostenible desde .NET. + +Dos consecuencias de diseño que conviene tener claras desde el principio: + +- **Weft no decide tu autenticación, tu almacenamiento ni tu retención.** El relay delega la + autorización en un `IWeftAuthorizer` tuyo (R17), la persistencia en un `IDocumentStore` tuyo (R8), y + las versiones publicadas son inmutables sin `delete` en v1 — la política de retención es dominio + del consumidor. +- **La interoperabilidad con Yjs es un requisito, no un accidente.** El wire es el protocolo y-sync + sobre lib0 (R7) y el encoding es byte-idéntico al de Yjs JS, verificado por un gate bloqueante. + Un cliente Tiptap/y-websocket existente habla con Weft sin adaptadores. + +## Mapa de módulos + +Seis paquetes. Todas las dependencias apuntan **hacia el core**; ninguna al revés. + +```text + ┌───────────────┐ + │ Weft.Core │ binding yrs + abstracciones + broker + └───────┬───────┘ + ┌───────────────┼───────────────┐ + │ │ │ + ┌───────▼──────┐ ┌──────▼──────┐ ┌──────▼─────┐ + │Weft.Versioning│ │ Weft.Loro │ │ │ + └───────┬───────┘ └─────────────┘ │ │ + │ │ │ + └──────────┬───────────────┘ │ + ┌──────▼──────┐ │ + │ Weft.Server │─────────────────────┘ + └──────┬──────┘ + ┌──────────┴──────────┐ + ┌───────▼────────┐ ┌────────▼───────┐ + │ …Persistence │ │ …Persistence │ + │ .Redis │ │ .EFCore │ + └────────────────┘ └────────────────┘ +``` + +| Paquete | Responsabilidad | Depende de | +|---|---|---| +| **Weft.Core** | Binding seguro a `yrs` vía shim C-ABI propio; abstracciones (`ICrdtEngine`, `ICrdtDoc`); concurrencia serializada (`DocumentBroker`) | — | +| **Weft.Versioning** | Versionado content-addressed **engine-agnóstico**: `VersionStore`, `VersionId`, `IBlobStore`, diff por palabras | Weft.Core | +| **Weft.Server** | Relay WebSocket y-sync para ASP.NET Core: `AddWeftServer`/`MapWeft`, awareness, backpressure | Weft.Core, Weft.Versioning | +| **Weft.Loro** | Adaptador dual-path sobre Loro. Existe para mantener honesta la abstracción (P-IV) | Weft.Core | +| **…Persistence.Redis** / **…Persistence.EFCore** | Implementaciones de `IDocumentStore` | Weft.Server | + +La regla que sostiene el grafo: **`Weft.Versioning` no puede referenciar tipos de `yrs` ni de Loro.** +Sólo habla con las abstracciones. Es lo que hace que el versionado funcione igual sobre ambos motores, +y lo verifica un gate (`dual-engine`) que corre la misma suite sobre los dos. + +## La frontera FFI + +Es la parte del sistema donde un error no da una excepción sino corrupción silenciosa, así que es la +que más disciplina lleva. + +### Por qué un shim propio + +Weft no llama a `yrs` directamente ni usa un binding de terceros: mantiene su propio shim C-ABI en +Rust (`native/weft-yrs-ffi`), y .NET habla con él vía `[LibraryImport]` (R1). El shim es la única +superficie que cruza; `yrs` nunca se expone. Eso permite fijar la semántica que .NET necesita +—índices en UTF-16, errores tipados, un contrato de memoria explícito— en vez de heredar la de Rust. + +Un detalle no obvio: **los índices son unidades de código UTF-16**, no bytes UTF-8 (el default de +`yrs`). Es lo que hace que `doc.InsertText("t", 5, …)` signifique lo mismo desde C# que desde Yjs JS, +y coincida con `string.Length` de .NET. + +Las versiones de los motores están **fijadas exactamente** (`yrs = "=0.27.2"`, `loro = "=1.13.6"`): un +bump es un acto deliberado con protocolo propio (R16), porque puede cambiar el encoding y por tanto +la identidad de las versiones ya publicadas. + +### Contrato de ownership de memoria + +**Ésta es la parte que hay que leer si vas a tocar el shim.** La regla de oro: + +> El GC de .NET **nunca** toca memoria nativa. Todo buffer que el shim entrega se libera **sólo** con +> `weft_buf_free`, exactamente una vez, con el mismo `(ptr, len)` que se recibió. + +Tres clases de memoria cruzan la frontera, con reglas distintas: + +| Qué | Quién lo asigna | Quién lo libera | Regla | +|---|---|---|---| +| **Handle de documento** (`WeftDoc*`) | El shim (`weft_doc_new` / `weft_doc_load`) | El llamador, con `weft_doc_free`, **exactamente una vez** | La idempotencia **no** está garantizada: liberar dos veces es UB. Del lado C# lo envuelve un `SafeHandle`, así que no lo haces a mano | +| **Buffers de salida** (`out_ptr` + `out_len`) | El shim (`Box<[u8]>` + `mem::forget`) | El llamador, con `weft_buf_free(ptr, len)` | `len` debe ser el que el shim devolvió: reconstruye el `Box` desde `(ptr, len)`. Un `len` distinto corrompe el allocator | +| **Buffers de entrada** | El llamador | El llamador | Están **prestados**: el shim no toma posesión ni retiene el puntero más allá de la llamada | + +Dos postcondiciones que ahorran depuración: + +- **En error, los out-params no se escriben.** Si el código de retorno no es `WEFT_OK`, no hay nada + que liberar. +- **En éxito, un resultado vacío puede tener `out_ptr` válido con `out_len == 0`.** Hay que liberarlo + igual: «vacío» no es «nulo». + +Del lado .NET esto se concentra en **un punto por motor**: `YrsDoc.TakeOwnedBuffer` y +`LoroDoc.TakeOwnedBuffer` (que llama a `weft_loro_buf_free` — cada shim libera con el suyo, nunca +cruzados). Ambos copian a memoria gestionada y liberan en un `finally`. Si auditas fugas, son los dos +sitios por los que empezar; el resto del código gestionado nunca ve un puntero nativo. + +Los handles usan `SafeHandleZeroOrMinusOneIsInvalid`, que resuelve de una vez fuga, double-free y +use-after-free (R2). Hay una fricción conocida: `[LibraryImport]` no marshala `SafeHandle` +(SYSLIB1051), así que los P/Invoke declaran `nint` crudo y las llamadas prestan el puntero con un +`HandleLease` (`DangerousAddRef`/`DangerousRelease`). Es deliberado y está documentado, no un descuido. + +### Ningún panic cruza la frontera + +Un panic de Rust desenrollando a través de una frontera C es UB. Por eso **toda** entrada del shim que +ejecuta código del motor envuelve su cuerpo en un helper `guard()` con `catch_unwind`: un panic se +convierte en `WEFT_ERR_PANIC` (-127), nunca en un desenrollado que cruza. `weft_doc_free` y +`weft_buf_free` usan `catch_unwind` directo por la misma razón. (La única excepción es +`weft_abi_version`, que devuelve una constante y no puede entrar en pánico.) + +Esta garantía se verifica end-to-end, no se asume: el shim exporta `weft_test_panic` bajo la feature +`test-hooks`, y la suite comprueba que un panic real se contiene y el proceso sigue vivo (SC-009). El +símbolo **nunca viaja en release**: el pipeline compila sin la feature, y el job `native` de +`release.yml` verifica con `nm` que no está exportado en los cdylibs antes de que lleguen al pack. + +**Lo que `catch_unwind` no puede contener**: un fallo de asignación aborta el proceso vía +`handle_alloc_error`, que no es un panic. Es la raíz de R6 — ver [Límites conocidos](#límites-conocidos). + +### Errores + +Códigos `i32` en la frontera, mapeados a la jerarquía `WeftException` del lado gestionado. La +traducción es total: ningún código se filtra a la API pública como número. + +| Código | Valor | Excepción .NET | +|---|---|---| +| `WEFT_OK` | 0 | — | +| `WEFT_ERR_NULL_ARG` | -1 | `WeftException` | +| `WEFT_ERR_DECODE` | -2 | `CorruptUpdateException` | +| `WEFT_ERR_APPLY` | -3 | `WeftEngineException(Apply)` | +| `WEFT_ERR_UTF8` | -4 | `WeftEngineException(Utf8)` | +| `WEFT_ERR_OUT_OF_BOUNDS` | -5 | `ArgumentOutOfRangeException` | +| `WEFT_ERR_PANIC` | -127 | `WeftEngineException(Panic)` | + +### Carga del binario y verificación de ABI + +El resolver se registra solo (`[ModuleInitializer]`) y busca el binario en +`runtimes//native/` → `runtimes//native/` → directorio base, con fallback a +`NATIVE_DLL_SEARCH_DIRECTORIES`. Es el layout estándar de NuGet multi-RID (patrón SkiaSharp, R11), +así que `dotnet add package Weft.Core` resuelve el nativo sin que hagas nada. + +Al cargar, **verifica la ABI antes de usar la librería**: llama a `weft_abi_version` y, si el símbolo +falta o la versión no es la esperada (hoy **2**), libera la librería y lanza una excepción explícita. +Esto convierte un desajuste binario —que si no sería corrupción silenciosa o un crash sin contexto— +en un error legible en el primer uso. La ABI subió a 2 al añadirse `weft_doc_new_with_client_id` +(client-ids deterministas, necesarios para el gate de paridad con Yjs). + +La superficie son **12 funciones** de datos (crear doc, crear doc con client-id fijo, cargar, liberar; +insertar/borrar/leer texto; exportar estado/state-vector/delta; aplicar update; liberar buffer), más +`weft_abi_version`. El contrato formal está en +[`contracts/ffi-abi.md`](../specs/001-weft-crdt-versioning/contracts/ffi-abi.md). + +### El shim de Loro + +`native/weft-loro-ffi` es simétrico, con prefijo `weft_loro_*`, su propio `weft_loro_buf_free` y su +propio `weft_loro_abi_version`. Mismas reglas de ownership y de `catch_unwind`. + +Expone además tres *probes* que `yrs` no tiene (`shallow_snapshot`, `native_diff_probe`, +`native_branch_merge_probe`), superficie de `INativeVersioning`. **Son demostrativos**: su salida no +es byte-determinista entre réplicas, **no** alimentan `VersionId` y ningún gate depende de ellos. +Existen para probar que la abstracción admite capacidades específicas de motor sin que el dominio se +entere; el content-addressing sigue viniendo de `ExportState()` en ambos motores. + +## Flujo de sync + +```text +cliente ──WebSocket──> MapWeft ──> IWeftAuthorizer ──> WeftServer ──> DocumentHub + │ │ + Deny → 403 DocumentSession + (antes del upgrade) │ + DocumentActor + (canal 1-reader) + │ + ICrdtDoc +``` + +El recorrido de un update: + +1. **Endpoint**. `MapWeft` expone `{pattern}/{docId}`. Si falta un `IWeftAuthorizer` o un + `IDocumentStore` registrado, **falla al arrancar**, no en la primera petición. Un `Deny` responde + **403 antes del upgrade** a WebSocket: cero bytes de contenido para quien no tiene acceso. +2. **Handshake**. El **servidor** manda su `SyncStep1` primero; el cliente responde con el suyo y el + servidor contesta con `SyncStep2(delta)`. Incremental en ambas direcciones desde el primer byte: + por eso una reconexión transfiere un delta y no el estado completo (SC-004; medido: 479 B → 26 B, + 94,6 % menos). +3. **Aplicar y persistir**. El update se aplica dentro del turno del actor y se persiste (`AppendUpdateAsync`). +4. **Difundir**. El delta se difunde a **todas** las conexiones del documento, incluido el emisor. El + eco es un no-op CRDT idempotente, y es deliberado: rastrear el origen dentro del turno del actor + costaría más que dejar que el CRDT haga su trabajo. +5. **Cerrar**. Cada desconexión emite la retirada de awareness de ese cliente, para que los demás + dejen de verlo al instante (FR-015). Cuando además era el **último**, se consolida un snapshot + (compaction) y se libera la sesión. + +Cosas del protocolo que conviene saber: + +- **Awareness (presencia) nunca se persiste.** Es efímera por definición; se difunde a los pares y ya. +- **Un cliente `ReadOnly` que manda `SyncStep2` se ignora sin cerrar la conexión** — cerrarla rompería + el handshake y-websocket estándar. Pero si manda un `Update` se cierra con **1008** (PolicyViolation). + La distinción es intencional. +- **Backpressure en dos ejes** (FU-002): tamaño de mensaje (16 MiB por defecto → cierre **1009**) y + cola de envío por conexión (256 → se cierra el consumidor lento). Un cliente lento no puede hacer + crecer la memoria del servidor sin límite. +- Mensaje malformado → cierre **1002** (ProtocolError). El cap de tamaño se aplica **antes** de parsear. + +### Concurrencia + +`ICrdtDoc` **no es thread-safe**, y no se pretende que lo sea. La serialización vive un nivel arriba: + +- Un **`DocumentActor` por documento**, con un canal de un solo lector (R6). Todas las operaciones de + un documento pasan por su turno; no hay locks en el camino caliente. +- El **broker** gestiona el ciclo de vida: desalojo por inactividad, LRU bajo presión de memoria, y + recarga desde lo persistido al reabrir. `MaxActiveDocuments` es un límite **suave**: nunca desaloja + un documento con sesiones vivas. +- La carrera interesante —desalojo en vuelo mientras alguien reabre el mismo documento— se resuelve + esperando a que la persistencia termine antes de recargar (SC-006). Está cubierta por la prueba de + carga, que fuerza cientos de miles de desalojos. + +Fuera del broker, serializar es responsabilidad del dueño del documento (P-V). + +## Modelo de versionado + +Ortogonal al sync: puedes versionar sin servidor, y servir sin versionar. + +- **`VersionId` = SHA-256 del export determinista.** No hay contador ni reloj: la identidad *es* el + contenido (R10). Dos réplicas convergidas publican el mismo id, byte a byte. Esto sólo funciona + porque el export es determinista — de ahí que el determinismo sea un principio constitucional con + gate propio, y no un detalle de implementación. +- **`IBlobStore`** guarda blobs por hash: `Put` es idempotente y la deduplicación sale gratis. **No hay + `delete` en v1**: las versiones publicadas son inmutables y la retención la decide el consumidor. +- **`VersionStore`** publica, hace checkout, diff (LCS por palabras, R9), branch y merge. Al leer, + **re-hashea y compara** antes de devolver: un blob corrupto da `BlobIntegrityException`, no un + documento silenciosamente equivocado. +- **Los merges cross-engine se rechazan** comparando `EngineName`: el formato de update de yrs y el de + Loro no son intercambiables, y fallar temprano y claro es mejor que fallar dentro del FFI. + +Una nota sobre citabilidad: **nunca se usa `skip_gc`** en el motor. La capacidad de citar una versión +antigua no viene de retener basura en el documento vivo, sino de que cada versión publicada es un blob +inmutable direccionado por contenido. + +### Paridad servidor ↔ local + +`IWeftServer.PublishAsync` ejecuta el export **dentro del turno del actor**, lo que garantiza que +publicar desde el servidor da el mismo `VersionId` que publicar en local sobre el mismo estado. Sin +esa garantía, «la versión v1 del documento» significaría cosas distintas según quién la publicó. + +## Persistencia (eje distinto) + +`IDocumentStore` trata el estado como **blobs opacos** (R8): snapshot consolidado + updates +acumulados. La capa de persistencia no sabe de CRDTs, y eso es a propósito — es lo que permite que +haya adaptadores de Redis, EF Core, filesystem y memoria intercambiables, todos validados por la +**misma** suite de contrato. + +La recuperación tolera solapamiento entre snapshot y updates porque aplicar un update es idempotente: +en el peor caso se aplica dos veces lo mismo y converge igual. + +## Gates + +Los gates no son CI decorativo: son la constitución hecha ejecutable. Corren por PR en +`ci.yml` salvo donde se indique. + +| Gate | Qué protege | Bloquea | Principio | +|---|---|---|---| +| `test-{linux,win,mac}` | Build + suite en los 3 SO | Sí | P-VI | +| `asan` | 0 fugas / 0 double-free en ambos shims (nightly + ASan/LSan) | Sí | P-II | +| `determinism` | Export byte-determinista cross-RID **y paridad byte-idéntica con Yjs JS** | Sí | P-III | +| `dual-engine` | La misma suite de versionado verde sobre yrs **y** Loro | Sí | P-IV | +| `fuzz` | La frontera FFI ante bytes arbitrarios | **Parcial** | P-I/P-II | +| `pack-smoke` | Instalar el paquete y correr hello-Weft por RID; símbolo de test ausente | **No** (ver abajo) | P-VI | + +Dos matices que importan si vas a fiarte de estos gates: + +- **`fuzz` bloquea a medias, y es deliberado.** Si los targets no *compilan*, el job se pone rojo. Si + un target *encuentra un crash*, sólo emite un `::warning`. La razón es R6 (ver abajo): hoy los + targets reproducen un fallo conocido de `yrs` aguas abajo del shim, y bloquear merges por él + paralizaría el repo sin arreglar nada. +- **El `pack-smoke` real no corre por PR.** El job de `ci.yml` es un marcador; la matriz + cross-compile de verdad —y la verificación de que `weft_test_panic` no está exportado— vive en + `release.yml`, que es `workflow_dispatch` únicamente, porque la matriz es cara. En la práctica el + empaquetado se valida en el dry-run del release, no en cada PR. + +## Límites conocidos + +Vale más decirlos que descubrirlos en producción: + +- **R6 — amplificación de memoria del decoder de `yrs`.** Un update malformado de pocos bytes puede + declarar una longitud enorme y hacer que `yrs` reserve sin cota; la asignación falla y el proceso + aborta (`handle_alloc_error`, **no** capturable por `catch_unwind`). El shim es correcto —contiene + panics, sin UB—; el fallo está aguas abajo. El fix está enviado upstream + ([y-crdt#639](https://github.com/y-crdt/y-crdt/pull/639), aprobado) y se adoptará vía bump. + **Mientras tanto**: el relay ya se protege con cap de tamaño y límite de memoria; si usas la **ruta + directa** del FFI (`LoadDoc`/`ApplyUpdate`) con bytes **no confiables**, protégela igual. Ver + [`GOVERNANCE.md`](../GOVERNANCE.md) §Seguridad. +- **Sólo texto por campo nombrado en v1.** Sin mapas, arrays ni tipos anidados todavía. +- **Los probes nativos de Loro no son content-addressing** (ver arriba). Si tu código los usa como si + lo fueran, converge a resultados no deterministas. + +## Decisiones + +El *porqué* de cada elección vive en +[`research.md`](../specs/001-weft-crdt-versioning/research.md), no aquí. Índice rápido: + +| | Decisión | | Decisión | +|---|---|---|---| +| **R1** | Shim propio + `[LibraryImport]` | **R10** | Content-addressing SHA-256 | +| **R2** | `SafeHandle` / `HandleLease` | **R11** | NuGet multi-RID (patrón SkiaSharp) | +| **R3** | Ownership de buffers | **R12** | Cross-compile con cargo-zigbuild | +| **R4** | Errores tipados | **R13** | Gate de determinismo vs Yjs | +| **R5** | Índices `int` validados | **R14** | Fuzzing de la frontera | +| **R6** | Actor + Channels | **R15** | Dual-engine como prueba viva | +| **R7** | Protocolo y-protocols | **R16** | Pinning y protocolo de bump | +| **R8** | `IDocumentStore` opaco | **R17** | Authz delegada al consumidor | +| **R9** | Diff LCS por palabras | | | + +Los principios que gobiernan todo esto están en la +[constitución](../.specify/memory/constitution.md) (P-I…P-VI); son vinculantes, no aspiracionales. + +## Por dónde seguir + +- **Consumir la librería**: [`README.md`](../README.md) (quickstart) → [`docs/api/README.md`](api/README.md) +- **Contribuir**: [`CONTRIBUTING.md`](../CONTRIBUTING.md), incluido el protocolo de bump del motor +- **Validar end-to-end**: [`quickstart.md`](../specs/001-weft-crdt-versioning/quickstart.md) +- **Evidencia experimental** que fundó estas decisiones: [`docs/spikes/`](spikes/) diff --git a/specs/001-weft-crdt-versioning/checklists/requirements.md b/specs/001-weft-crdt-versioning/checklists/requirements.md index 90151cd..2aa5646 100644 --- a/specs/001-weft-crdt-versioning/checklists/requirements.md +++ b/specs/001-weft-crdt-versioning/checklists/requirements.md @@ -29,6 +29,59 @@ - [x] Feature meets measurable outcomes defined in Success Criteria - [x] No implementation details leak into specification +## Quickstart validation pass (T063 · CHARTER-11 · 2026-07-15) + +Pase end-to-end de [quickstart.md](../quickstart.md) contra el árbol de `charter/11-polish-cierre-m3` +(HEAD sobre `main`@`603efce`), linux-x64, .NET 10.0.x + Rust stable/nightly. + +**Convención de evidencia** — `[x]` significa *«lo ejecuté y lo vi»*, nunca *«debería funcionar»*: + +- **Ejecutado**: corrido en esta máquina, con su resultado. +- **CI**: no ejecutable aquí (requiere runners por RID / matriz); lo cubre un job, que se nombra. +- **No ejecutado**: sin evidencia. Se queda **sin marcar**, con el motivo. + +### User stories + +- [x] **US1 — Editar y versionar desde .NET**. `dotnet test tests/Weft.Core.Tests tests/Weft.Versioning.Tests -c Release` → 58/58 verdes. `dotnet run --project samples/Weft.Sample.Versioning` → journey completo: `Publish` v1 (`c0d1c698…`) → `Diff(v1,v2)` por palabras → `Checkout(v1)` → `Merge` convergente. +- [x] **US2 — Concurrencia a escala**. `--filter Category=Concurrency` → 9/9 verdes (**antes del cableado de este Charter: 0 tests**). `dotnet run --project tests/Weft.LoadTest -c Release` → `PASS`: 300 docs, 45 004 ops, 449 327 desalojos, 0 errores, managed-heap 1 MB / working-set 101 MB (`consistencia=OK memoria-acotada=OK sin-errores=OK`). +- [x] **US3 — Colaboración en tiempo real**. `dotnet test tests/Weft.Server.Tests -c Release` → 70/70 verdes. Relay real arrancado (`:5199`, `FileSystemDocumentStore`) y smoke headless `npm run check` → convergencia real de 2 clientes Yjs vía `y-websocket` contra el relay: `"Hello from A. And B too."`. *Parcial*: ver «no ejecutado» abajo. +- [x] **US5 — Motor reemplazable**. `dotnet test tests/Weft.Versioning.Tests -c Release` → 30/30 verdes sobre `YrsEngine` **y** `LoroEngine` (SC-008). `LoroEngine.NativeVersioning` expone los 3 probes de CHARTER-10; `YrsEngine.NativeVersioning == null` sin romper ningún flujo. +- [ ] **US4 — Instalación multiplataforma**. Parcial. Ejecutado aquí: `dotnet pack src/Weft.Core -c Release` → `Weft.Core.1.0.0.nupkg` + `.snupkg`, con `runtimes/{linux-x64,linux-arm64}/native/` y **`weft_test_panic` ausente** en ambos `.so` (verificado con `nm -D`). Sin marcar porque el criterio de US4 es *instalación verde en máquina limpia en los 4 RIDs*, que no es ejecutable aquí → job `pack-smoke` / `pack-smoke-arm`. + +### Gates + +- [x] **Memoria (P-II)** — `RUSTFLAGS="-Zsanitizer=address" cargo +nightly test --features test-hooks --target x86_64-unknown-linux-gnu` → 14/14 verdes en ambos shims, **0 fugas / 0 double-free**, incluido `native_versioning_probes_reachable_and_nonleaking`. +- [x] **Determinismo (P-III)** — `dotnet test tests/Weft.Determinism.Tests` → 4/4, incluida la aserción **bloqueante** de paridad `Yrs_export_matches_yjs_golden`. Harness Node (`npm test`) → hash de Yjs coincide con `golden.json` en ascii (`27a84875…`) y unicode (`afd15f9c…`). +- [x] **Dual-engine (P-IV)** — suite de versionado verde sobre ambos motores (ver US5). +- [x] **Build + tests (P-VI)** — `dotnet test Weft.sln -c Release` → **132/132 verdes**; `cargo test --features test-hooks` → 14/14. Ejecutado en linux-x64; win-x64/osx-arm64 → jobs `test-win` / `test-mac`. +- [x] **Fuzzing (P-I/P-II)** — `cargo +nightly fuzz run doc_load -- -max_total_time=45` → **OOM reproducido** con un input de 4 bytes (`f6f4d621`). **Es el resultado esperado, no una regresión**: es R6; el shim es correcto (contiene panics, sin UB) y el fix vive upstream en [y-crdt#639](https://github.com/y-crdt/y-crdt/pull/639) (aprobado), adopción vía FU-015. En CI el job **bloquea a medias**: un crash sólo emite `::warning` (`|| echo` por paso), pero un fallo de compilación de los targets sí lo pone rojo. Caveat de la ruta directa en `GOVERNANCE.md` §Seguridad. +- [x] **Empaquetado (P-VI)** — pack local verde + ausencia del símbolo de test verificada con `nm -D` (ver US4). **El gate real no corre por PR**: el `pack-smoke` de `ci.yml` es un marcador; la matriz por RID y la verificación del símbolo viven en `release.yml` (`workflow_dispatch`) → se validan en el dry-run del release. Ver gap #9. + +### No ejecutado (sin evidencia — deliberadamente sin marcar) + +- **US3, validación manual con Tiptap real (2+ pestañas)**: requiere navegador e interacción humana. Es el criterio de cierre de **M2**, no de M3, y se cubrió en su momento. El `npm run check` headless valida la convergencia del wire, pero **no** sustituye la comprobación visual de presencia/cursores. +- **US4, instalación en máquina limpia por RID** (win-x64, osx-arm64, linux-arm64): requiere runners por RID. +- **T060, publish real a NuGet.org**: operador-gated por diseño; el `dry_run` se validó en CHARTER-07. + +### Deriva del runbook detectada y corregida atómicamente + +El pase fue la primera ejecución end-to-end de `quickstart.md` contra HEAD, y destapó **9 gaps**, todos +de *«declaración de superficie sin cableado»* ([POLISH-CHARTER-PATTERN.md](../../../.straymark/00-governance/POLISH-CHARTER-PATTERN.md)): +el runbook declaraba comandos y garantías que nadie había ejecutado ni releído. Ninguno era un fallo +del código de producción — el código estaba bien; el runbook mentía. + +| # | Gap | Efecto real | Corrección | +|---|---|---|---| +| 1 | `--filter Category=Concurrency` sin ningún `[Trait]` en el repo | El paso de US2 pasaba **en verde ejecutando 0 tests** | `[Trait("Category","Concurrency")]` en `DocumentBrokerTests` → 9 tests | +| 2 | Build local sin `--features test-hooks` | `PanicSafetyTests` rojo (`EntryPointNotFoundException: weft_test_panic`) siguiendo el runbook al pie de la letra | Feature añadida al comando, con la razón y la garantía de que no viaja en release | +| 3 | `cp` desde `native/weft-yrs-ffi/target/release/` | Ruta **inexistente**: `native/` es un workspace cargo y comparte `native/target/` → el comando falla | Ruta corregida | +| 4 | El `cp` a `src/Weft.Core/runtimes/` | **Innecesario**: los `.csproj` de test ya copian desde `native/target/release/`, y el pack lee de `native/target//release/` (`build/Weft.Native.targets`). El directorio destino ni existe | Paso eliminado | +| 5 | US3: «relay en `:5000`» | El sample escucha en **`:5199`** (`WEFT_SAMPLE_URLS`) | Puerto corregido + env var documentada | +| 6 | US3 sólo documentaba `npm run dev` (navegador) | `npm run check` —smoke headless de convergencia real vía `y-websocket`— existía y no estaba en el runbook: no había forma documentada de validar US3 sin display | Documentado | +| 7 | Gate de determinismo descrito como «no-bloqueante al inicio, promovible» | Induce a creer que la paridad yrs↔Yjs no bloquea. **Sí bloquea** desde CHARTER-09/FU-012 (vive en `Weft.Determinism.Tests`, job `test`); el job Node de `release.yml` es informativo y caza drift del upstream | Redacción corregida, separando ambos | +| 8 | Tabla de gates: «un rojo bloquea merge», con `fuzz` listado sin matiz | `fuzz` **bloquea a medias**: un crash sólo emite `::warning` (`\|\| echo` por paso), pero un fallo de compilación de los targets sí lo pone rojo. La promesa era imprecisa en ambos sentidos | Encabezado matizado + fila con la semántica exacta y su porqué (R6/FU-015) | +| 9 | Tabla de gates: `pack-smoke` listado como gate bloqueante por PR | **El más grave.** El `pack-smoke` de `ci.yml` es un **marcador que sólo hace `echo`**: no empaqueta ni valida nada. La matriz real y la verificación de `weft_test_panic` viven en `release.yml`, que es `workflow_dispatch` únicamente. El runbook prometía que cada PR valida el empaquetado; ningún PR lo valida | Fila reescrita diciendo dónde vive el gate real y cuándo corre | + ## Notes - **Lente aplicada a "no implementation details"**: Weft es una librería para desarrolladores — su API y sus contratos SON el producto. Conceptos de contrato que la spec sí nombra deliberadamente: content-addressing con SHA-256 (identidad de versión, decisión ✅ CERRADA del brief), protocolo de sync del ecosistema Yjs sobre WebSocket (requisito de interoperabilidad con clientes de editor existentes) y distribución NuGet multi-RID (requisito de entrega). Las decisiones de implementación **interna** (motor `yrs`, shim C-ABI en Rust, P/Invoke, ASan/LSan concretos) quedan fuera de los FRs y viven solo en Assumptions como contexto firme. diff --git a/specs/001-weft-crdt-versioning/quickstart.md b/specs/001-weft-crdt-versioning/quickstart.md index 1f58c67..9f9ba28 100644 --- a/specs/001-weft-crdt-versioning/quickstart.md +++ b/specs/001-weft-crdt-versioning/quickstart.md @@ -15,12 +15,17 @@ CI protegen la constitución. No contiene implementación; las rutas siguen la e ## Build local ```bash -# 1. Shim nativo (yrs) y copia al árbol de runtimes -cargo build --release --manifest-path native/weft-yrs-ffi/Cargo.toml -cp native/weft-yrs-ffi/target/release/libweft_yrs_ffi.so \ - src/Weft.Core/runtimes/linux-x64/native/ # (.dll/.dylib según plataforma) - -# 2. Solución .NET +# 1. Shims nativos (yrs + loro). `native/` es un workspace cargo: un solo build cubre ambos +# y deja los artefactos en native/target/release/ (NO en native//target/). +# `--features test-hooks` exporta `weft_test_panic`, que la suite de panic-safety (SC-009) +# necesita; sin él, PanicSafetyTests falla con EntryPointNotFoundException. Es lo mismo que +# hace el CI. El símbolo NUNCA viaja en release: el pipeline de release compila sin la feature +# y el gate `pack-smoke` verifica su ausencia en los binarios empaquetados. +cargo build --release --features test-hooks --manifest-path native/Cargo.toml + +# 2. Solución .NET. No hace falta copiar el .so a mano: los .csproj de test lo copian desde +# native/target/release/, y el pack lo toma de native/target//release/ (ver +# build/Weft.Native.targets). dotnet build Weft.sln -c Release ``` @@ -54,9 +59,16 @@ uso tras dispose → `ObjectDisposedException`; nunca corrupción (SC-006). ```bash dotnet test tests/Weft.Server.Tests -c Release # protocolo + 2 clientes simulados -# Manual con editor real: -dotnet run --project samples/Weft.Sample.Server # relay en :5000 + FileSystemDocumentStore -cd samples/tiptap-client && npm install && npm run dev # 2 pestañas → mismo doc + +# Relay real (:5199 por defecto; override con WEFT_SAMPLE_URLS) + FileSystemDocumentStore: +dotnet run --project samples/Weft.Sample.Server + +# Smoke headless de compat del wire: 2 clientes Yjs reales vía y-websocket contra el relay. +# Valida convergencia sin navegador — es el check ejecutable en CI/servidor sin display. +cd samples/tiptap-client && npm install && npm run check + +# Manual con editor real (exigido por el criterio de cierre de M2): +npm run dev # 2 pestañas → mismo doc ``` **Esperado** (postcondiciones de [server-api.md](./contracts/server-api.md)): convergencia en @@ -79,7 +91,7 @@ coincide (si no, excepción clara al cargar); ejemplo mínimo verde al primer in ### US5 (P5 · dual-path) — Motor reemplazable ```bash -cargo build --release --manifest-path native/weft-loro-ffi/Cargo.toml +# El shim de Loro ya está construido por el paso 1 de «Build local» (workspace cargo). dotnet test tests/Weft.Versioning.Tests -c Release # theory: YrsEngine Y LoroEngine ``` @@ -91,16 +103,21 @@ dotnet test tests/Weft.Versioning.Tests -c Release # theory: YrsEngine Y Loro salida no es determinista y no alimenta `VersionId` (que usa `ExportState`); ningún gate depende de ellos. `YrsEngine.NativeVersioning == null` permanente (yrs no tiene versionado nativo). -## Gates de CI (constitución — un rojo bloquea merge) +## Gates de CI (constitución) + +Un rojo bloquea merge, **con dos excepciones** que conviene conocer antes de fiarse de la columna: +`fuzz` bloquea sólo a medias (si los targets no compilan, rojo; si encuentran un crash, sólo +`::warning`) y el `pack-smoke` real **no corre por PR** — vive en `release.yml` +(`workflow_dispatch`). Ambas están detalladas en su fila. | Gate | Job | Comando (esencia) | Principio | |---|---|---|---| | Build+tests multiplataforma | `test-{linux,win,mac}` | `dotnet test Weft.sln` + `cargo test` | P-VI | | Memoria | `asan` (linux, nightly) | `RUSTFLAGS="-Zsanitizer=address" cargo +nightly test --target x86_64-unknown-linux-gnu` en ambos shims → 0 fugas/0 double-free | P-II | -| Determinismo | `determinism` | `dotnet test tests/Weft.Determinism.Tests` cross-RID; job Node compara blobs vs Yjs JS (no-bloqueante al inicio, promovible — research R13) | P-III | +| Determinismo | `determinism` | `dotnet test tests/Weft.Determinism.Tests` cross-RID. La paridad yrs↔Yjs es **bloqueante** y vive aquí (`Yrs_export_matches_yjs_golden`, contra `tests/determinism-yjs/golden.json`) desde CHARTER-09/FU-012 — la promoción que research R13 anticipaba ya ocurrió. El job Node `determinism-yjs` (`release.yml`, `continue-on-error`) es **informativo**: regenera el hash de Yjs para cazar drift del upstream, no es la aserción de paridad | P-III | | Dual-engine | `dual-engine` | suite Versioning con ambos motores | P-IV | -| Fuzzing | `fuzz` (acotado por tiempo en PR; extendido nightly) | `cargo fuzz run doc_load` / `apply_update`; CsCheck convergencia | P-I/P-II | -| Empaquetado | `pack-smoke` | matriz: pack + instalar + hello-Weft por RID | P-VI | +| Fuzzing | `fuzz` (smoke 60 s/target en PR; extendido nightly) — **bloquea a medias** | `cargo fuzz run doc_load` / `apply_update`; CsCheck convergencia. Un fallo de **compilación** de los targets pone el job rojo (deliberado); un **crash encontrado** sólo emite `::warning` — es un `\|\| echo` por paso, **no** `continue-on-error` en el job. Razón: los targets **reproducen R6 hoy** (un input de ~4 B hace que el decoder de yrs reserve sin cota → `handle_alloc_error`, que `catch_unwind` no puede contener). El shim es correcto (contiene panics, sin UB); el fix vive upstream ([y-crdt#639](https://github.com/y-crdt/y-crdt/pull/639), aprobado) y se adopta vía bump (FU-015). Caveat de la ruta directa en `GOVERNANCE.md` §Seguridad (CHARTER-08) | P-I/P-II | +| Empaquetado | `pack-smoke` — **no corre por PR** | El job `pack-smoke` de `ci.yml` es un **marcador** (sólo hace `echo`): no empaqueta ni valida nada. La matriz real (pack + instalar + hello-Weft por RID, SC-007) vive en `release.yml`, que es `workflow_dispatch` únicamente porque la matriz cross-compile es cara → se valida en el **dry-run del release**, no en cada PR. La verificación de que `weft_test_panic` no está exportado (SC-009) la hace el job **`native`** de ese mismo workflow, con `nm` sobre los cdylibs antes del pack | P-VI | ## Criterio de cierre por hito diff --git a/specs/001-weft-crdt-versioning/tasks.md b/specs/001-weft-crdt-versioning/tasks.md index 3be069c..d8c3f5c 100644 --- a/specs/001-weft-crdt-versioning/tasks.md +++ b/specs/001-weft-crdt-versioning/tasks.md @@ -151,9 +151,9 @@ ## Phase 8: Polish & Cross-Cutting Concerns -- [ ] T061 [P] Architecture doc `docs/architecture.md` (módulos, frontera FFI, flujo de sync, decisiones→research.md) + doc público del contrato de ownership -- [ ] T062 Delta-size benchmark in `tests/Weft.Core.Tests/DeltaSizeBenchmark.cs`: medir escenario de referencia y asertar reducción ≥90 % vs estado completo (SC-004; referencia 523 B→29 B) -- [ ] T063 Full quickstart validation pass (quickstart.md US1–US5 + gates) y actualizar `specs/001-weft-crdt-versioning/checklists/requirements.md` con evidencia de cierre +- [X] T061 [P] Architecture doc `docs/architecture.md` (módulos, frontera FFI, flujo de sync, decisiones→research.md) + doc público del contrato de ownership — CHARTER-11 (enlazado desde `README.md` §Arquitectura y `docs/api/README.md`; incluye §Límites conocidos con R6) +- [X] T062 Delta-size benchmark in `tests/Weft.Core.Tests/DeltaSizeBenchmark.cs`: medir escenario de referencia y asertar reducción ≥90 % vs estado completo (SC-004; referencia 523 B→29 B) — CHARTER-11. El escenario de referencia **no existía como definición** (523→29 sale de una celda "Rough perf" de un spike desechable); el benchmark lo define (reconexión: par al día + 1 edición) y aserta el ratio, no los absolutos. **Medido: 479 B → 26 B = 94,6 %** (la referencia histórica era 94,5 %) +- [X] T063 Full quickstart validation pass (quickstart.md US1–US5 + gates) y actualizar `specs/001-weft-crdt-versioning/checklists/requirements.md` con evidencia de cierre — CHARTER-11. Pase completo con evidencia de 3 estados (ejecutado / CI / no-ejecutado). Destapó **8 gaps de deriva del runbook**, todos corregidos atómicamente; el más grave: el filtro `Category=Concurrency` de US2 no casaba con ningún test → pasaba en verde ejecutando 0 (ahora 9) --- diff --git a/tests/Weft.Core.Tests/DeltaSizeBenchmark.cs b/tests/Weft.Core.Tests/DeltaSizeBenchmark.cs new file mode 100644 index 0000000..da71ebd --- /dev/null +++ b/tests/Weft.Core.Tests/DeltaSizeBenchmark.cs @@ -0,0 +1,104 @@ +using Weft; +using Weft.Yrs; +using Xunit.Abstractions; + +namespace Weft.Core.Tests; + +/// +/// Benchmark de tamaño de delta (T062, SC-004): en el escenario de referencia de reconexión, el +/// sync incremental transfiere ≥ 90 % menos bytes que reenviar el estado completo. +/// +/// +/// +/// El escenario de referencia se define aquí porque la spec no lo definía. SC-004 +/// (spec.md:175) cita «523 B → 29 B», pero ese dato sale de una celda etiquetada +/// «Rough perf» en docs/spikes/spike03/hallazgos-spike-03.md:38, de un spike cuyo código +/// es desechable por diseño (docs/spikes/README.md:4-5) y no vive en este repo: no +/// documenta tamaño de documento, ni número de ediciones, ni qué se exportó exactamente. Se cita +/// como contexto histórico —523→29 es un 94,5 %, consistente con el umbral— y NO como expectativa +/// byte a byte: asertar esos absolutos ataría la suite a un spike irreproducible y la rompería +/// cualquier bump de yrs sin que nada estuviera mal. Lo vinculante de SC-004 es el ratio. +/// +/// +/// La forma del escenario sale de la prosa de SC-004 («reconexión») y de la postcondición del +/// relay en contracts/server-api.md:116 («una reconexión con SV previo recibe solo el +/// delta»): +/// +/// +/// Un autor tiene un documento de referencia: un párrafo de prosa, el orden de magnitud +/// (~500 B de estado) de la referencia del spike. +/// Un par se pone al día y captura su state vector — el «qué conozco» que enviará al +/// reconectar. +/// Mientras el par está desconectado, el autor recibe UNA edición pequeña. +/// Al reconectar, el par pide solo lo que le falta. +/// +/// +/// Se mide ExportUpdateSince(sv).Length (lo que viaja) contra ExportState().Length +/// (lo que viajaría sin sync incremental). El escenario se fijó antes de medir y el assert es el +/// umbral de la spec, no un número calibrado a posteriori. +/// +/// +/// Los client-ids son fijos (capacidad de YrsEngine, CHARTER-09/FU-012) para que el tamaño +/// medido no dependa del varint de un id aleatorio: un id de 53 bits ocupa varios bytes más que +/// uno pequeño, y eso es ruido que no pertenece a la medición. +/// +/// +public sealed class DeltaSizeBenchmark +{ + private readonly ITestOutputHelper _output; + + public DeltaSizeBenchmark(ITestOutputHelper output) => _output = output; + + private const ulong AutorClientId = 1; + private const ulong ParClientId = 2; + + /// Documento de referencia: prosa, ~500 B de estado exportado. + private const string ParrafoReferencia = + "El telar levanta la urdimbre y la trama cruza entre los hilos tensados; cada pasada fija " + + "el dibujo que ya no podrá deshacerse sin destejer lo anterior. Quien mira la tela " + + "terminada no ve las decisiones intermedias, sino el patrón que sobrevivió a todas ellas. " + + "Un documento colaborativo se teje igual: muchas manos empujan la lanzadera a la vez, y el " + + "orden final no lo dicta quien llegó primero, sino la regla que todos aceptaron de " + + "antemano."; + + /// La edición que ocurre mientras el par está desconectado. + private const string EdicionDuranteDesconexion = "Nota al margen: "; + + [Fact] + public void Reconnect_delta_is_at_least_90_pct_smaller_than_full_state() + { + using ICrdtDoc autor = YrsEngine.Instance.CreateDoc(AutorClientId); + autor.InsertText("body", 0, ParrafoReferencia); + + // El par se pone al día y captura su SV justo antes de desconectarse. + using ICrdtDoc par = YrsEngine.Instance.CreateDoc(ParClientId); + par.ApplyUpdate(autor.ExportState()); + byte[] svAlDesconectar = par.ExportStateVector(); + + // Mientras el par no está, el documento avanza. + autor.InsertText("body", 0, EdicionDuranteDesconexion); + + // Reconexión: lo que viaja vs lo que viajaría reenviando el estado completo. + byte[] delta = autor.ExportUpdateSince(svAlDesconectar); + byte[] estadoCompleto = autor.ExportState(); + + // Un delta que no converge no es un delta barato, es un delta roto: medir su tamaño sin + // comprobar que sincroniza dejaría pasar un buffer vacío con una reducción del 100 %. + par.ApplyUpdate(delta); + Assert.Equal(autor.ExportState(), par.ExportState()); + Assert.Equal(EdicionDuranteDesconexion + ParrafoReferencia, par.GetText("body")); + + double reduccion = 1.0 - ((double)delta.Length / estadoCompleto.Length); + + // Un benchmark reporta su medición: el número es el entregable, no solo el verde/rojo. + _output.WriteLine( + $"SC-004 · escenario de reconexión: estado completo={estadoCompleto.Length} B, " + + $"delta={delta.Length} B, reducción={reduccion:P1} (umbral ≥ 90 %). " + + $"Referencia histórica del spike03: 523 B → 29 B (94,5 %)."); + + Assert.True( + reduccion >= 0.90, + $"SC-004: delta={delta.Length} B vs estado completo={estadoCompleto.Length} B → " + + $"reducción medida {reduccion:P1}, se exige ≥ 90 %."); + } +} diff --git a/tests/Weft.Core.Tests/DocumentBrokerTests.cs b/tests/Weft.Core.Tests/DocumentBrokerTests.cs index 80ba4e4..120eb36 100644 --- a/tests/Weft.Core.Tests/DocumentBrokerTests.cs +++ b/tests/Weft.Core.Tests/DocumentBrokerTests.cs @@ -10,6 +10,12 @@ namespace Weft.Core.Tests; /// semántica de dispose. Los casos de serialización/fallo usan un motor de prueba que instrumenta la /// concurrencia; los de ciclo de vida usan el motor yrs real. /// +/// +/// El Category=Concurrency es el que selecciona el comando de US2 en quickstart.md; +/// sin él ese filtro no casaba con ningún test y el paso del runbook pasaba en verde ejecutando +/// cero tests (detectado en el pase de validación de T063, CHARTER-11). +/// +[Trait("Category", "Concurrency")] public sealed class DocumentBrokerTests { // -- Serialización (Acceptance Scenario 1): nunca dos operaciones simultáneas del mismo documento -- From 79e030ecd9351ff4e5dc3c50b0cb0a4641bcae11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jose=20Villase=C3=B1or=20Montfort?= <195970+montfort@users.noreply.github.com> Date: Wed, 15 Jul 2026 21:35:59 -0600 Subject: [PATCH 2/2] =?UTF-8?q?chore(charter):=20cerrar=20CHARTER-11=20?= =?UTF-8?q?=E2=80=94=20atomic=20update=20del=20AILOG=20declarado=20+=20ret?= =?UTF-8?q?rospectivo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit El drift check reportó un único desvío: el AILOG se declaró con un placeholder (AILOG-2026-07-16-NNN, fecha estimada) y el real es AILOG-2026-07-15-003. Corregido atómicamente (format v4) + §Closing notes. Los otros 10 archivos declarados se tocaron tal cual, sin omisiones ni expansión de scope. Co-Authored-By: Claude Opus 4.8 (1M context) --- .straymark/charters/11-polish-cierre-m3.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.straymark/charters/11-polish-cierre-m3.md b/.straymark/charters/11-polish-cierre-m3.md index fa1d0cb..8e632e5 100644 --- a/.straymark/charters/11-polish-cierre-m3.md +++ b/.straymark/charters/11-polish-cierre-m3.md @@ -58,7 +58,7 @@ El patrón ya cobró su primera pieza **antes de empezar**: el reconocimiento pr | `README.md` | Enlace al doc de arquitectura | | `docs/api/README.md` | Enlace al doc de arquitectura (relación overview-por-paquete ↔ arquitectura) | | `.straymark/follow-ups-backlog.md` | Follow-ups nuevos que el pase destape (triage, no remediación) | -| `.straymark/07-ai-audit/agent-logs/AILOG-2026-07-16-NNN.md` | New, `risk_level: low` | +| `.straymark/07-ai-audit/agent-logs/AILOG-2026-07-15-003-charter-11-polish-cierre-m3.md` | New, `risk_level: low` | ## Verification @@ -147,6 +147,14 @@ Al cerrar este Charter: 5. **Retrospectivo del patrón de Polish** (paso 4 del walkthrough): ver §Retrospectivo. ✔ 6. **No borrar** este archivo. +## Closing notes + +- `.straymark/07-ai-audit/agent-logs/AILOG-2026-07-16-NNN.md` → renombrado a + `AILOG-2026-07-15-003-charter-11-polish-cierre-m3.md`. La declaración usaba un placeholder con fecha + estimada (`2026-07-16`) y sin slug; el Charter se ejecutó entero el 2026-07-15. Corregido + atómicamente en el mismo PR. Único drift del Charter: los otros 10 archivos declarados se tocaron + tal cual, sin omisiones ni expansión de scope. + ## Retrospectivo (patrón de Polish, paso 4) **Resultado: 9 gaps, 0 fallos de código de producción.** El pase confirmó la tesis del patrón —el