diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b22bbd4..498e1b2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -215,7 +215,10 @@ jobs: - uses: actions/setup-node@v5 with: node-version: "22" - - name: Yjs export hash del corpus compartido + # Emite el hash de Yjs de ambos corpus (ascii + unicode) y self-checkea contra golden.json: + # caza drift de Yjs (bump con impacto de encoding). La aserción BLOQUEANTE de paridad yrs↔Yjs + # vive en Weft.Determinism.Tests (job `test`, per-PR); este job es informativo (FU-012/CHARTER-09). + - name: Yjs export hash del corpus compartido (ascii + unicode, self-check golden) working-directory: tests/determinism-yjs run: | npm install diff --git a/.straymark/07-ai-audit/agent-logs/AILOG-2026-07-15-001-charter-09-client-id-determinista-gate-paridad.md b/.straymark/07-ai-audit/agent-logs/AILOG-2026-07-15-001-charter-09-client-id-determinista-gate-paridad.md new file mode 100644 index 0000000..50084cf --- /dev/null +++ b/.straymark/07-ai-audit/agent-logs/AILOG-2026-07-15-001-charter-09-client-id-determinista-gate-paridad.md @@ -0,0 +1,109 @@ +--- +id: AILOG-2026-07-15-001 +title: "CHARTER-09: client-id determinista en el FFI de yrs + gate de paridad cross-impl (determinism-yjs) per-PR" +status: accepted +created: 2026-07-15 +agent: claude-opus-4-8 +confidence: high +review_required: true +reviewed_by: Jose Villaseñor Montfort +reviewed_at: 2026-07-15 +review_outcome: approved +risk_level: medium +eu_ai_act_risk: not_applicable +nist_genai_risks: [] +iso_42001_clause: [] +observability_scope: none +tags: [ffi-boundary, abi-bump, determinism, yrs, yjs, cross-impl-parity, client-id, utf16, gate] +related: [AIDEC-2026-07-15-001, AILOG-2026-07-13-003] +originating_charter: CHARTER-09-client-id-determinista-en-el-ffi-de-yrs-gate-de +--- + +# AILOG: CHARTER-09 — client-id determinista + gate de paridad cross-impl (determinism-yjs) + +## Summary + +Despacho de CHARTER-09 (FU-012): expone la siembra de `client_id` determinista en el FFI de yrs y **promueve el +gate de determinismo cross-implementación (`determinism-yjs`, T058) de informativo a aserción per-PR bloqueante**. +El riesgo central **R1 se cumple**: yrs produce exports **byte-idénticos** a Yjs sobre el corpus compartido +(ASCII y unicode) → el determinismo de Weft es **por formato** (encoding v1 de Yjs/yrs), no un accidente de esta +versión de yrs (constitución **P-III**). Alcance **yrs-only** decidido; la promoción cross-engine (Loro) se +difiere a **FU-016**. Sin auditoría externa (no cierra hito). + +## Actions Performed + +1. **FFI yrs — siembra de client_id (ABI v1→v2)**: `weft_doc_new_with_client_id(u64, out)` en + `native/weft-yrs-ffi/src/lib.rs` (`Options { client_id: ClientID::new(id), offset_kind: Utf16 }`), con + **guard `client_id < 2^53`** → `WEFT_ERR_OUT_OF_BOUNDS` (yrs 0.26+ codifica los client IDs en 53 bits; + `ClientID::new` tiene `debug_assert!(value & MASK == 0)` y corrompería en release sin el guard). `WEFT_ABI_VERSION` + 1→2 + declaración en `include/weft_ffi.h`. Símbolo verificado exportado (`nm -D`). +2. **Binding .NET**: `weft_doc_new_with_client_id` en `NativeMethods.cs`; `YrsDoc.Create(ulong)`; + **`YrsEngine.CreateDoc(ulong)` método CONCRETO** (no en `ICrdtEngine` — ver AIDEC decisión 1); + `ExpectedAbiVersion` 1→2 en `NativeLibraryResolver.cs`. +3. **Golden + corpus unicode**: `apply.mjs` parametrizado (corpus por argumento + self-check contra + `golden.json`); `corpus-unicode.json` (BMP acentuado + CJK + astrales/emoji → índices UTF-16, R6); + `golden.json` comprometido con los hashes de Yjs de ambos corpus. +4. **Aserción de paridad per-PR (BLOQUEANTE)**: `Yrs_export_matches_yjs_golden` (Theory ascii+unicode) en + `Weft.Determinism.Tests` — aplica el corpus con yrs vía `CreateDoc(clientId)`, converge, y asierta + `sha256(ExportState) == golden`. Corre en el job `test` existente (per-PR, costo ~0). **2/2 verde.** +5. **Job Node informativo promovido**: `release.yml determinism-yjs` corre ambos corpus (`npm test`) y + **self-checkea** su hash de Yjs contra `golden.json` (caza drift de Yjs); permanece `continue-on-error`. + README del harness actualizado (estado → aserción per-PR yrs). +6. **Backlog**: FU-012 → `closed`; **FU-016** registrado (promoción cross-engine Loro vía `set_peer_id`). + +## Modified Files + +**Nativo**: `native/weft-yrs-ffi/src/lib.rs` (fn + guard + ABI v2), `native/weft-yrs-ffi/include/weft_ffi.h`. +**Binding**: `src/Weft.Core/Yrs/NativeMethods.cs`, `YrsDoc.cs`, `YrsEngine.cs`, `NativeLibraryResolver.cs`. +**Gate**: `tests/Weft.Determinism.Tests/DeterminismTests.cs` (test de paridad), +`tests/determinism-yjs/{apply.mjs, package.json, golden.json (new), corpus-unicode.json (new), README.md}`, +`.github/workflows/release.yml` (step del job). **Gobernanza**: `.straymark/follow-ups-backlog.md` +(FU-012 closed, FU-016), `.straymark/charters/09-*.md` (status), AIDEC-2026-07-15-001 (new). + +## Risk + +- **R1 (medio-alto, del Charter) — paridad yrs↔Yjs**: **RESUELTO POSITIVO.** El test asierta 2/2 (ascii+unicode): + yrs == Yjs byte-idéntico. El gate se fija bloqueante; no fue necesario el plan B (dejarlo informativo). +- **R2 (medio) — client_id ≥ 2^53**: mitigado con el guard en la frontera (`WEFT_ERR_OUT_OF_BOUNDS`); alinea con + el `debug_assert` de `ClientID::new`. El corpus usa 1/2/3 (seguros). +- **R3 (bajo) — ABI bump v1→v2**: bump atómico Rust (`WEFT_ABI_VERSION`) + .NET (`ExpectedAbiVersion`) en el mismo + PR; el export es aditivo (no cambia `weft_doc_new`); desalineación → error explícito de `NativeLibraryResolver`. +- **R5 (bajo, del Charter) — índices UTF-16 en unicode**: mitigado con evidencia — la variante unicode (surrogate + pairs astrales) pasa la aserción, confirmando la paridad de índices UTF-16 (`OffsetKind::Utf16`). + +## Verification + +```bash +# Shim yrs: compila + símbolo exportado + ABI v2 +cd native/weft-yrs-ffi && cargo build --release && nm -D ../target/release/libweft_yrs_ffi.so | grep weft_doc_new_with_client_id + +# Hash de Yjs de ambos corpus + self-check contra golden.json +cd ../../tests/determinism-yjs && npm install && npm test # ✓ ascii + unicode coinciden con golden + +# Aserción de paridad per-PR (bloqueante) + suite completa +cd ../.. && dotnet test tests/Weft.Determinism.Tests -c Release # Yrs_export_matches_yjs_golden 2/2 +dotnet test Weft.sln -c Release # suite completa intacta +``` + +## Follow-ups + +Derivado del alcance yrs-only de CHARTER-09. No bloquea nada: + +- **Follow-up (cross-engine, baja)**: promover la siembra de client-id de capacidad **concreta de `YrsEngine`** a + capacidad **cross-engine** — `CreateDoc(clientId)` en `ICrdtEngine` (o una interfaz opcional tipo + `INativeVersioning`) + `weft_loro_doc_new_with_peer_id` en `weft-loro-ffi` (Loro vía `set_peer_id`), para + habilitar un gate de determinismo Loro↔referencia si/cuando se quiera. **Trigger**: when se requiera paridad + determinista para el motor Loro. **Destination**: mini-charter. **Cost**: M. + +## Additional Notes + +- El test de paridad localiza `tests/determinism-yjs/` subiendo desde `AppContext.BaseDirectory` hasta la raíz del + repo (no requiere copiar el corpus al output del test); funciona local y en CI (checkout completo). +- La convergencia del test .NET espeja `apply.mjs` **exactamente** (delete sin guard de longitud, `syncPasses` + del corpus, hash del `ExportState` de la réplica 0) — cualquier divergencia de esquema rompería la paridad. + +## Approval + +Trabajo de frontera nativa (`risk_level: medium`, `review_required: true`) con ABI bump. El operador decidió +ex-ante el alcance (yrs-only, aserción per-PR) y autorizó ejecución continua. Verificación local citada; el CI del +PR valida la aserción per-PR bloqueante en toda la matriz. Compañero de AIDEC-2026-07-15-001. diff --git a/.straymark/07-ai-audit/decisions/AIDEC-2026-07-15-001-charter-09-placement-siembra-client-id-y-forma-del-golden.md b/.straymark/07-ai-audit/decisions/AIDEC-2026-07-15-001-charter-09-placement-siembra-client-id-y-forma-del-golden.md new file mode 100644 index 0000000..e91179f --- /dev/null +++ b/.straymark/07-ai-audit/decisions/AIDEC-2026-07-15-001-charter-09-placement-siembra-client-id-y-forma-del-golden.md @@ -0,0 +1,119 @@ +--- +id: AIDEC-2026-07-15-001 +title: "CHARTER-09: placement de la siembra de client-id (YrsEngine concreto vs ICrdtEngine) y forma del golden de paridad" +status: accepted +created: 2026-07-15 +agent: claude-opus-4-8 +confidence: high +review_required: true +reviewed_by: Jose Villaseñor Montfort +reviewed_at: 2026-07-15 +review_outcome: approved +risk_level: medium +eu_ai_act_risk: not_applicable +nist_genai_risks: [] +iso_42001_clause: [] +tags: [ffi-boundary, determinism, yrs, yjs, cross-impl-parity, abi, engine-abstraction, client-id] +related: [AILOG-2026-07-15-001, weft-speckit-estado] +originating_charter: CHARTER-09-client-id-determinista-en-el-ffi-de-yrs-gate-de +--- + +# AIDEC: placement de la siembra de client-id y forma del golden de paridad + +> Registra las dos decisiones sustantivas de CHARTER-09 (FU-012), anticipadas en el Charter §Tasks +> como candidatas a AIDEC, más el resultado del riesgo central **R1** (paridad byte-idéntica yrs↔Yjs). + +## Context + +FU-012 promueve el gate de determinismo cross-implementación (`determinism-yjs`, T058) de informativo a +aserción. La paridad byte-idéntica con Yjs exige **client-ids deterministas**, que el binding de yrs no +exponía. El operador decidió **alcance yrs-only** (el gate es yrs↔Yjs; Loro es otro formato) y **aserción +per-PR barata** (test .NET en el job `test` existente). Dos formas quedaban abiertas: dónde vive la +capacidad de siembra, y cómo se estructura el golden. + +--- + +## Decisión 1 — Placement de la siembra de client-id + +### Problem + +`weft_doc_new_with_client_id` (FFI) necesita una superficie .NET. ¿Va en la interfaz compartida +`ICrdtEngine` (P-IV: abstracción de motor viva) o como capacidad concreta de `YrsEngine`? + +### Alternatives Considered + +- **A1 — `CreateDoc(ulong clientId)` en `ICrdtEngine`, `LoroEngine` lanza `NotSupported`.** Mantiene la + interfaz simétrica, pero introduce una capacidad que un motor **no honra en runtime** — tensiona P-IV + (la abstracción "promete" algo que un impl rechaza) y contradice el patrón ya establecido en el repo + (`INativeVersioning?` como capacidad **opcional** vía propiedad, no un método que lanza). **Rechazada.** +- **A2 — capacidad opcional tipo `INativeVersioning`** (interfaz `ISeedableClientId` expuesta por propiedad). + P-IV-correcta y descubrible, pero sobre-ingeniería para un solo método cuyo único consumidor es el test + de paridad (yrs-específico por naturaleza: el gate es yrs↔Yjs). **Rechazada por ahora.** +- **A3 (elegida) — `CreateDoc(ulong clientId)` como método CONCRETO de `YrsEngine`.** No toca `ICrdtEngine` + (que conserva `CreateDoc()` sin parámetro). El test de paridad usa `YrsEngine` directo. Honesto: la + capacidad es yrs-específica, no se declara en la abstracción algo que Loro no da hoy. + +### Rationale + +El gate de paridad es **intrínsecamente yrs↔Yjs** (misma familia de formato v1). Poner la siembra en la +interfaz compartida obligaría a Loro a implementarla (o a lanzar), sin beneficio para el gate y tensando +P-IV. La capacidad concreta en `YrsEngine` es el mínimo honesto; la promoción a capacidad **cross-engine** +(Loro vía `set_peer_id`) se difiere a **FU-016** — a materializar si/cuando se quiera un gate de determinismo +para Loro. El ABI bump (**v1→v2**, `weft_doc_new_with_client_id` aditivo) y el guard `client_id < 2^53` +(encoding de 53 bits de yrs 0.26+; `ClientID::new` tiene `debug_assert` de ello y corrompería en release) +son mecánicos y quedan documentados en el AILOG. + +### Consequences + +- `ICrdtEngine` intacto; `Weft.Versioning`/broker/relay no cambian. Superficie nueva mínima. +- **FU-016** registrado (promoción cross-engine Loro). No bloquea nada. + +--- + +## Decisión 2 — Forma del golden y dónde asierta (bloqueante) la paridad + +### Problem + +¿Cómo se estructura el hash golden y qué componente **asierta** la paridad de forma bloqueante, respetando +el presupuesto de minutos de CI? + +### Alternatives Considered + +- **B1 — job Node en `release.yml` asertivo (quitar `continue-on-error`), pasar `WEFT_GOLDEN_HASH`.** Fiel al + diseño original del harness, pero la paridad **no se verifica per-PR** (release es `workflow_dispatch`) y + añade un job Node bloqueante. **Rechazada.** +- **B2 (elegida) — golden de Yjs comprometido (`golden.json`) + aserción per-PR en `Weft.Determinism.Tests`.** + `apply.mjs` produce el hash de Yjs de cada corpus → se compromete en `golden.json` (`ascii`/`unicode`). El + **test .NET** (`Yrs_export_matches_yjs_golden`, Theory ascii+unicode) aplica el corpus con yrs y asierta + `sha256(export) == golden` — corre en el job `test` existente (**bloqueante de facto, per-PR, costo ~0**). + El **job Node** de `release.yml` queda `continue-on-error` pero **self-checkea** su hash de Yjs contra el + mismo `golden.json` → caza **drift de Yjs** (bump con impacto de encoding). + +### Rationale + +Un único golden comprometido sirve a **ambos** lados: el test .NET verifica yrs↔golden (la paridad que +importa) donde es barato (job existente); el job Node verifica Yjs↔golden (vigencia del golden) donde el +entorno Node ya existe (release). Separa las dos preguntas —"¿yrs iguala a Yjs?" (bloqueante) y "¿el golden +sigue siendo el hash real de Yjs?" (informativo)— sin duplicar costo de CI. + +### Consequences + +- Regenerar `golden.json` es un paso **deliberado** documentado (README) para cambios de corpus, distinguible + de un drift accidental (que el self-check del job Node destapa). +- El corpus unicode (BMP acentuado + CJK + astrales) ejercita los índices **UTF-16** (R6) en el mismo gate. + +--- + +## Resultado de R1 (riesgo central del Charter) + +**La paridad byte-idéntica yrs↔Yjs SE CUMPLE.** El test `Yrs_export_matches_yjs_golden` pasa **2/2** (ascii + +unicode): yrs produce exactamente los hashes de Yjs (`27a8...3243` ascii, `afd1...9e02` unicode). Por tanto el +gate se fija como **bloqueante** (no fue necesario el plan B de "dejarlo informativo"). Bonus: la variante +unicode (con surrogate pairs astrales) confirma también la **paridad de índices UTF-16** (mitiga R5 del Charter +con evidencia, no argumentación). El determinismo de Weft queda demostrado **por formato**, no por versión de yrs. + +## Approval + +**Approved**: 2026-07-15 por `Jose Villaseñor Montfort`, en revisión interactiva. El operador decidió ex-ante el +alcance yrs-only (Loro diferido) y la aserción per-PR (AskUserQuestion al declarar CHARTER-09), y autorizó la +ejecución continua. Compañero de AILOG-2026-07-15-001. diff --git a/.straymark/charters/09-client-id-determinista-en-el-ffi-de-yrs-gate-de.md b/.straymark/charters/09-client-id-determinista-en-el-ffi-de-yrs-gate-de.md new file mode 100644 index 0000000..e672000 --- /dev/null +++ b/.straymark/charters/09-client-id-determinista-en-el-ffi-de-yrs-gate-de.md @@ -0,0 +1,188 @@ +--- +charter_id: CHARTER-09-client-id-determinista-en-el-ffi-de-yrs-gate-de +status: closed +closed_at: 2026-07-15 +effort_estimate: M +trigger: "FU-012 (backlog, charter-triggered): promover el determinismo cross-implementación (harness determinism-yjs, T058/CHARTER-07) de informativo a gate con aserción. Disparado por decisión del operador (2026-07-15) de cerrar FU-012 tras CHARTER-08; antes del primer bump de motor con impacto de encoding (R16). Alcance yrs-only decidido (Loro diferido)." +originating_spec: specs/001-weft-crdt-versioning/spec.md +work_verb: implement +design_provenance: new +--- + +# Charter: Client-id determinista en el FFI de yrs + gate de paridad cross-impl (determinism-yjs) + +> **Status (mirrored from frontmatter — source of truth is above):** closed. Effort: M. +> +> **Origin:** Follow-up **FU-012**, sobre la spec 001 (constitución **P-III** determinismo / **P-IV** abstracción +> de motor viva). Cierra el gating del harness `determinism-yjs` (T058) sobre client-ids deterministas, para el +> motor **yrs**. Alcance **yrs-only** decidido por el operador; la promoción cross-engine (Loro) se difiere a un FU. + +## Context + +El harness `tests/determinism-yjs/` (T058, CHARTER-07) aplica un **corpus compartido** con **Yjs JS** y emite el +SHA-256 del export v1, para verificar que el determinismo de Weft es **por formato** (el encoding v1 de Yjs/yrs), +no un accidente de esta versión de `yrs` — la garantía que distingue "content-addressing estable" de "estable +hasta el próximo bump" (research **R13**, constitución **P-III**; ver **R16**). Hoy es **no-bloqueante** porque la +paridad byte-idéntica con yrs está **gated en client-ids deterministas**: `ICrdtEngine.CreateDoc()` no toma +parámetro y el shim `weft-yrs-ffi` no expone fijar el `client_id`, así que yrs asigna uno no controlable y su +export no es reproducible cross-implementación. + +Este Charter cierra ese gate **para yrs**: expone la siembra de `client_id` en el FFI + binding, y promueve la +paridad a una **aserción per-PR barata** (test .NET en el job `test` existente, costo marginal ~0, bloqueante de +facto) contra un **hash golden de Yjs comprometido**. **Nota de encoding**: yrs **0.26.0** pasó los client IDs a +**53 bits** (antes 64) — la API debe acotar `client_id < 2^53`. **Alcance yrs-only** (el gate es yrs↔Yjs; Loro es +otro formato, no participa): para no tensar **P-IV**, el método de siembra va como capacidad **concreta de +`YrsEngine`** (no en `ICrdtEngine`), y la promoción a capacidad cross-engine (Loro vía `set_peer_id`) se difiere a +un FU nuevo. + +## Scope + +**In scope:** + +1. **FFI yrs — siembra de client_id (ABI bump v1→v2)**: `weft_doc_new_with_client_id(uint64_t client_id, + WeftDoc** out_doc)` en `weft-yrs-ffi` — crea el `Doc` con `Options { client_id, offset_kind: Utf16, ..default }`. + **Guarda** `client_id < 2^53` (encoding de 53 bits de yrs 0.26+) → `WEFT_ERR_OUT_OF_BOUNDS` si no. Incrementa + `WEFT_ABI_VERSION` **1→2** en `lib.rs` + declara la fn nueva en `include/weft_ffi.h` (comentario de ABI). +2. **Binding .NET — siembra yrs-específica**: `weft_doc_new_with_client_id` en `NativeMethods.cs` (LibraryImport); + `YrsDoc.Create(ulong clientId)` (valida/propaga); `YrsEngine.CreateDoc(ulong clientId)` **método concreto, NO en + `ICrdtEngine`** (yrs-específico; el test usa `YrsEngine` directo). Sube `ExpectedAbiVersion` **1→2** en + `NativeLibraryResolver.cs`. +3. **Golden de Yjs comprometido**: correr `apply.mjs` para producir el SHA-256 de Yjs del corpus ASCII y del + unicode; comprometer en `tests/determinism-yjs/golden.json` (`{ "ascii": "", "unicode": "" }`). +4. **Aserción de paridad per-PR** en `Weft.Determinism.Tests/DeterminismTests.cs`: aplica `corpus.json` con yrs + (réplicas vía `YrsEngine.CreateDoc(clientId)` con los `clientIds` fijos, `InsertText`/`DeleteText` por índice, + `syncPasses`), toma `ExportState()` de la réplica 0, SHA-256, y **asierta** `== golden.ascii`. Ídem para el + corpus unicode `== golden.unicode`. Corre en el job `test` existente (bloqueante de facto, per-PR). +5. **Variante unicode del corpus (UTF-16, R6)**: `tests/determinism-yjs/corpus-unicode.json` (texto no-ASCII que + ejercita los índices UTF-16) + `apply.mjs` acepta el corpus por argumento/env y emite su hash. +6. **Promover el job de `release.yml`**: el job `determinism-yjs` emite los hashes (ascii + unicode) y **compara + contra `golden.json`** (self-check de drift de Yjs); permanece `continue-on-error` (la aserción bloqueante real + es el test .NET per-PR). Actualiza `tests/determinism-yjs/README.md §Estado` (promovido a aserción per-PR yrs). +7. **Backlog**: FU-012 → `closed`; registrar **FU-nuevo** (promover la siembra de client_id a capacidad + cross-engine — `CreateDoc(clientId)` en `ICrdtEngine` + `weft_loro_doc_new_with_peer_id` vía `set_peer_id`). + +**Out of scope:** + +- **Loro `CreateDoc(peer_id)` / promoción cross-engine** de la capacidad de siembra → **FU-nuevo** (parte 7). +- **Hacer bloqueante el job Node de `release.yml`**: la aserción bloqueante es el test .NET per-PR; el job Node + queda informativo con self-check de golden (decisión del operador: presupuesto de minutos CI). +- **Paridad Loro↔Yjs**: formatos distintos; fuera del gate cross-impl. +- Cambiar `ICrdtEngine.CreateDoc()` (sin parámetro) — se conserva; la siembra es capacidad concreta de `YrsEngine`. + +## Files to modify + + + +| File | Change | +|---|---| +| `native/weft-yrs-ffi/src/lib.rs` | `weft_doc_new_with_client_id` (Options.client_id + guard < 2^53) + `WEFT_ABI_VERSION` 1→2 | +| `native/weft-yrs-ffi/include/weft_ffi.h` | Declara `weft_doc_new_with_client_id`; nota de ABI bump | +| `native/weft-yrs-ffi/tests/mem_asan.rs` | ABI assert 1→2 + test `seed_client_id_is_deterministic_and_bounded` (round-trip + guard) | +| `tests/determinism-yjs/package.json` | Script `test` corre ambos corpus (ascii + unicode) | +| `src/Weft.Core/Yrs/NativeMethods.cs` | P/Invoke `weft_doc_new_with_client_id` | +| `src/Weft.Core/Yrs/YrsDoc.cs` | `Create(ulong clientId)` | +| `src/Weft.Core/Yrs/YrsEngine.cs` | `CreateDoc(ulong clientId)` (concreto, yrs-específico) | +| `src/Weft.Core/Yrs/NativeLibraryResolver.cs` | `ExpectedAbiVersion` 1→2 | +| `tests/Weft.Determinism.Tests/DeterminismTests.cs` | Test de paridad cross-impl (asierta yrs SHA-256 == golden Yjs), ASCII + unicode | +| `tests/determinism-yjs/golden.json` | New — hashes golden de Yjs (`ascii`, `unicode`) | +| `tests/determinism-yjs/corpus-unicode.json` | New — corpus con texto no-ASCII (índices UTF-16, R6) | +| `tests/determinism-yjs/apply.mjs` | Acepta corpus por arg/env; emite hash + compara contra golden.json | +| `tests/determinism-yjs/README.md` | Estado → aserción per-PR (yrs); uso del corpus unicode | +| `.github/workflows/release.yml` | Job `determinism-yjs`: emite ascii+unicode, self-check contra golden | +| `.straymark/follow-ups-backlog.md` | FU-012 → `closed`; registrar FU-nuevo (Loro peer_id cross-engine) | +| `.straymark/07-ai-audit/agent-logs/AILOG-*.md` | New, `risk_level: medium` (frontera FFI, ABI bump, gate de determinismo) | +| `.straymark/07-ai-audit/decisions/AIDEC-*.md` | New — placement de la siembra (concreto en YrsEngine vs ICrdtEngine) + forma del golden | + +## Verification + +### Local checks + +```bash +# Shim yrs: compila + tests (incl. el guard de client_id y el round-trip de la siembra) +cd native/weft-yrs-ffi && cargo test && cargo build --release && cd ../.. + +# El hash de Yjs del corpus (fuente del golden); ambos corpus +cd tests/determinism-yjs && npm install +node apply.mjs # emite hash ascii +node apply.mjs --corpus corpus-unicode.json # emite hash unicode +cd ../.. + +# Suite .NET completa incl. la aserción de paridad cross-impl per-PR (bloqueante de facto) +dotnet test Weft.sln -c Release # Weft.Determinism.Tests asierta yrs SHA-256 == golden Yjs (ascii+unicode) +``` + +### Production smoke (after deploy) + +No aplica — librería sin despliegue. Los auditores externos deben saltar esta sección. + +## Risks + +- **R1 — el export de yrs NO es byte-idéntico al de Yjs**: severidad **media-alta** (riesgo central). Si diverge, + la aserción no puede ser bloqueante. Mitigación: producir **ambos hashes temprano** en la implementación; si + divergen, investigar la causa de encoding (orden de bloques, delete sets, offset). Si es irreconciliable, el gate + **permanece informativo** (no se promueve a bloqueante), se documenta como finding + FU, y NO se falsifica la + paridad. La promoción a "bloqueante" de este Charter es **contingente** a que la paridad realmente se cumpla — + verificada durante la ejecución, no asumida. +- **R2 — client_id ≥ 2^53 rompe el encoding de 53 bits de yrs**: severidad **media**. Mitigación: el FFI acota + `client_id < 2^53` → `WEFT_ERR_OUT_OF_BOUNDS`; test cubre el borde (2^53-1 ok, 2^53 rechazado). El corpus usa + 1/2/3 (seguros). Si el guard falla, es un error de decode limpio, no UB. +- **R3 — el ABI bump v1→v2 rompe consumidores**: severidad **baja**. Un shim viejo (v1) fallaría el check de + versión. Mitigación: bump atómico de `WEFT_ABI_VERSION` (Rust) y `ExpectedAbiVersion` (.NET) en el **mismo PR**; + el nuevo export es **aditivo** (no cambia `weft_doc_new`); CI construye el shim fresco. Si desalinea, el error es + explícito (`NativeLibraryResolver` lanza), no un crash. +- **R4 — golden frágil ante drift de Yjs**: severidad **baja**. Si Yjs bumpea y cambia el encoding, el golden + comprometido queda stale. Mitigación: el job Node de `release.yml` recomputa el hash de Yjs y lo compara contra + `golden.json` → caza el drift; la regeneración del golden es un paso documentado en el README. +- **R5 — la variante unicode desalinea índices UTF-16**: severidad **baja**. Mitigación: `new_doc()` ya fija + `OffsetKind::Utf16`; el corpus unicode ejercita exactamente esto (si diverge, revela un bug de offset como el R6 + de CHARTER-02). La aserción unicode es parte del gate. + +## Tasks + +1. Sync main, branch `charter/09-determinism-client-id` (**ya creada**). Flip `declared` → `in-progress` al empezar. +2. Re-evaluar **Constitution Check**: **P-III** (determinismo por formato), **P-IV** (abstracción viva — siembra + como capacidad concreta de yrs, no en la interfaz; Loro diferido). Sin violaciones esperadas. +3. **(1)** FFI: `weft_doc_new_with_client_id` + guard 2^53 + ABI bump v1→v2 + header. `cargo test` con el round-trip + de la siembra y el borde del guard. +4. **(2)** Binding: NativeMethods + `YrsDoc.Create(ulong)` + `YrsEngine.CreateDoc(ulong)` + `ExpectedAbiVersion` 2. +5. **(5)** Corpus unicode + `apply.mjs` parametrizado. **(3)** Correr `apply.mjs` (ambos corpus) → comprometer + `golden.json`. **VERIFICAR R1**: producir el hash de yrs (vía el test .NET) y confirmar que iguala el golden de + Yjs ANTES de fijar el gate como bloqueante. +6. **(4)** Test de paridad en `Weft.Determinism.Tests` (ASCII + unicode), asertivo. +7. **(6)** Promover el job de `release.yml` (emite ambos + self-check golden) + actualizar README. +8. **(7)** Backlog: FU-012 → `closed` + `recount`; registrar el FU-nuevo (Loro peer_id cross-engine). +9. **AILOG** (`risk_level: medium`, `review_required: true`) + **AIDEC** (placement de la siembra + forma del + golden + resultado de R1). Verificación local completa. +10. `straymark charter drift CHARTER-09` (posible FP del parser #354 en `.json`/`.mjs`/`.h`/`.cs` — documentar). + Commit + push + PR contra `main`; CI verde (incl. la nueva aserción per-PR). + +## Charter Closure + +**No cierra hito** (FU-012 es promoción de un gate informativo; no requiere auditoría externa multi-modelo). Al cerrar: + +1. **Atomic update (format v4)**: si el drift reveló divergencias (p. ej. R1 forzó dejar el gate informativo en vez + de bloqueante), edita `## Scope`/`## Files to modify` + `## Closing notes` en el **mismo PR**. +2. `straymark charter drift CHARTER-09 --range origin/main..HEAD` → limpio o documentado (incl. FP del parser #354). +3. `straymark charter close CHARTER-09` (telemetría; registrar el resultado de R1 — paridad cumplida o no). +4. **No borrar** este archivo. +5. Backlog: **FU-012 `closed`**, **FU-016 `open`** (Loro peer_id). Siguiente: **CHARTER-10 (FU-006, Loro nativo)**. + +## Closing notes + +Drift (`origin/main..HEAD`) reportó 3 archivos modificados no declarados; reconciliados atómicamente aquí +(format v4), ninguno cambia el alcance sustantivo: + +- `native/weft-yrs-ffi/tests/mem_asan.rs` — **añadido a la tabla**. Consecuencia del ABI bump (assert + `weft_abi_version() == 1` → `2`) + un test Rust del round-trip de la siembra y el borde del guard 2^53. + Ref: AILOG-2026-07-15-001 §Verification. +- `tests/determinism-yjs/package.json` — **añadido a la tabla**. El script `test` ahora corre ambos corpus + (ascii + unicode); no se anticipó como archivo aparte al declarar. +- `tests/determinism-yjs/apply.mjs` — **falso positivo del parser #354**: SÍ está declarado en §Files to modify, + pero el parser de drift no matchea la extensión `.mjs` (mismo bug que `.csproj`/`.sln`; corroborado aquí para + `.mjs`). No es expansión de alcance. diff --git a/.straymark/charters/CHARTER-09.telemetry.yaml b/.straymark/charters/CHARTER-09.telemetry.yaml new file mode 100644 index 0000000..eba291d --- /dev/null +++ b/.straymark/charters/CHARTER-09.telemetry.yaml @@ -0,0 +1,93 @@ +# StrayMark Charter telemetry — fill at Charter close. +# +# Schema: .straymark/schemas/charter-telemetry.schema.v0.json +# Storage path: .straymark/charters/CHARTER-09.telemetry.yaml + +charter_telemetry: + # ---------- Identification ---------- + charter_id: "CHARTER-09" + charter_title: "Client-id determinista en el FFI de yrs + gate de paridad cross-impl (determinism-yjs)" + closed_at: "2026-07-15" + + # ---------- Origin & activation ---------- + originating_ailogs: + - ailog_id: "AILOG-2026-07-15-001" + still_relevant_at_execution: true + relevance_notes: "AILOG de ejecución. El Charter se originó de FU-012 (CHARTER-07 §Scope T058); la reconnaissance de declaración fue precisa (todas las rutas leídas) y completa — sin undercount de sitios como en CHARTER-08." + + trigger: + declared_kind: "event_trigger" + declared_description: "FU-012 (charter-triggered): promover el gate determinism-yjs de informativo a aserción. Decisión del operador (2026-07-15) de cerrar FU-012 tras CHARTER-08." + fired_at: "2026-07-15" + fire_clarity: "manually_decided" + fire_clarity_notes: "El operador eligió ejecutar FU-012 ahora y decidió ex-ante las dos formas de alcance (yrs-only + aserción per-PR) vía AskUserQuestion al declarar." + + # ---------- Pre-work ---------- + pre_work: + items_declared: 0 + items_completed_before_planning: 0 + items_skipped: 0 + items_discovered_during_planning: 0 + pre_work_quality: "high" + pre_work_notes: "Reconnaissance leyó todas las superficies antes de declarar (weft_doc_new/new_doc/Options, weft_ffi.h, ExpectedAbiVersion=1, YrsDoc/YrsEngine, ICrdtDoc text API, corpus.json/apply.mjs/README/release.yml). Validación --include-charters verde (23 docs). Sin paths asumidos." + + # ---------- Planning session (if any) ---------- + planning_session: + occurred: false + duration_minutes: 0 + participants: 0 + decisions_made: 0 + decisions_deferred: 0 + notes: "" + + # ---------- Effort ---------- + effort: + started_at: "2026-07-15" + finished_at: "2026-07-15" + estimated_effort: "M (~1.5h)" + actual_effort: "M (~2h)" + estimation_drift_factor: 1.3 + estimation_drift_reason: "Sin sorpresas de alcance. El leve drift lo aportaron dos fricciones menores resueltas en línea: yrs expone client_id como newtype ClientID (no u64 crudo → ClientID::new); y un cargo build sin test-hooks sobrescribió la lib local del test de panic. Ninguna cambió el diseño. El riesgo central R1 (paridad) resolvió POSITIVO al primer intento." + + # ---------- Agent quality during execution ---------- + agent_quality: + sessions_count: 1 + hallucinations_caught: 0 + hallucination_categories: [] + decisions_contradicting_prior_adrs: 0 + contradiction_notes: "" + context_loaded_was_sufficient: true + additional_context_loaded_manually: 0 + r_n_plus_one_emergent_count: 0 + skill_prompts_used: ["straymark-charter-new"] + + # ---------- External audit ---------- + # No aplica: Charter que no cierra hito (promoción de un gate informativo) → sin auditoría externa. + external_audit: [] + + # ---------- Outcome & follow-ups ---------- + outcome: + completed_as_planned: true + scope_changes: "menor" + scope_change_notes: "F1 (drift reconciliado en §Closing notes): +native/weft-yrs-ffi/tests/mem_asan.rs (assert ABI 1→2 + test de la siembra) y +tests/determinism-yjs/package.json (script npm de ambos corpus) — no declarados al inicio, añadidos a §Files to modify. F2 (FP): tests/determinism-yjs/apply.mjs SÍ declarado pero el parser de drift #354 no matchea .mjs (corroborado aquí). Los 7 entregables se completaron como se planeó; R1 resolvió positivo (gate bloqueante, no fue necesario el plan B informativo)." + new_followups_generated: 1 + new_charters_created: 0 + charters_invalidated: 0 + associated_stage_id: "M3" + + # ---------- Qualitative (where insight usually lives) ---------- + qualitative: + format_iteration: "v4" + friction_points: + - "yrs expone client_id como newtype `ClientID(CID)` con `ClientID::new(u64)` (no un u64 crudo en Options) — E0308 al asignar; resuelto importando yrs::ClientID. El guard < 2^53 de la frontera alinea con el `debug_assert!(value & MASK == 0)` de ClientID::new (en release corrompería sin él)." + - "Un `cargo build --release` sin `--features test-hooks` sobrescribió la lib nativa local quitando `weft_test_panic` -> EntryPointNotFoundException en el test de panic (falso local, no regresión; en CI el shim se construye con test-hooks). Reconstruir con la feature." + - "El parser de charter drift #354 no matchea `.mjs` (apply.mjs declarado, marcado como no declarado) — 5a+ ocurrencia de la clase, ahora extendida a .mjs. Refuerza la propuesta de no filtrar por extensión." + - "El job `test (windows-latest)` hizo timeout a los 25m00s en 'Test .NET' (hang de infraestructura del runner, no un test roto — Ubuntu/macOS/determinism verdes con el mismo código); resuelto con rerun. Misma flakiness que CHARTER-08 (macOS)." + wins: + - "R1 (paridad byte-idéntica yrs-Yjs) SE CUMPLE al primer intento, ASCII Y unicode — el gate quedó bloqueante, sin plan B informativo. El determinismo de Weft queda demostrado POR FORMATO, no por versión de yrs." + - "La variante unicode (BMP acentuado + CJK + astrales/emoji con surrogate pairs) pasa la aserción -> confirma la paridad de índices UTF-16 (R5/R6) con evidencia medida, no argumentación." + - "El gate bloqueante se ubicó donde es barato (test .NET en el job `test` existente, costo ~0) en vez de un job Node aparte; el job Node queda como self-check informativo del golden (caza drift de Yjs). Un solo golden sirve a ambos lados." + - "Alcance yrs-only honesto: siembra como capacidad concreta de YrsEngine (no un método que lanza NotSupported en ICrdtEngine) -> no tensa P-IV; Loro diferido limpio a FU-016." + overall_satisfaction: 5 + would_repeat_format: true + proposed_format_changes: "El parser de drift #354 debería reconocer .mjs (y en general no filtrar por extensión) — 5a+ ocurrencia de la clase, ahora en un ecosistema Node/JS. Ya rastreado en straymark#354; esta corrida lo extiende a .mjs." diff --git a/.straymark/follow-ups-backlog.md b/.straymark/follow-ups-backlog.md index 3b6f7b1..9b50044 100644 --- a/.straymark/follow-ups-backlog.md +++ b/.straymark/follow-ups-backlog.md @@ -1,9 +1,9 @@ --- -last_scan: 2026-07-14 +last_scan: 2026-07-15 schema_version: v1 total_open: 4 total_promoted: 0 -total_closed_in_session: 11 +total_closed_in_session: 12 total_phase_blocked: 0 total_suspected_closed: 0 buckets: @@ -16,6 +16,7 @@ fully_extracted_ailogs: - AILOG-2026-07-10-001 - AILOG-2026-07-10-002 - AILOG-2026-07-14-002 + - AILOG-2026-07-15-001 --- # Follow-ups Backlog @@ -95,6 +96,15 @@ fully_extracted_ailogs: ## Bucket: charter-triggered +### FU-016 — promover la siembra de client-id a capacidad cross-engine (Loro peer_id) +- **Origin**: AILOG-2026-07-15-001 §Follow-ups · CHARTER-09 (alcance yrs-only) +- **Source-hash**: 346b9c62a979 +- **Status**: open +- **Trigger**: when se requiera paridad determinista para el motor Loro (gate Loro↔referencia) +- **Destination**: mini-charter +- **Cost**: M +- **Notes**: CHARTER-09 expuso la siembra de client-id como capacidad **concreta de `YrsEngine`** (`CreateDoc(ulong)`), no en `ICrdtEngine`, porque el gate `determinism-yjs` es yrs↔Yjs (misma familia de formato). Para un gate de determinismo de Loro: promover a capacidad cross-engine — `CreateDoc(clientId)` en `ICrdtEngine` (o una interfaz opcional tipo `INativeVersioning`) + `weft_loro_doc_new_with_peer_id` en `weft-loro-ffi` (Loro vía `set_peer_id`). Ningún gate depende hoy. + ### FU-015 — adopción del fix de R6 vía bump de yrs (protocolo R16) - **Origin**: AILOG-2026-07-14-002 §Follow-ups · CHARTER-08 (d) · PR upstream y-crdt/y-crdt#639 - **Source-hash**: 72ad54b51cf2 @@ -139,11 +149,11 @@ fully_extracted_ailogs: ### FU-012 — determinism-yjs: exponer client-id determinista + promover a gate de paridad cross-impl - **Origin**: CHARTER-07 §Scope (T058) · AILOG-2026-07-13-003 · tests/determinism-yjs/README.md (registro hand-add + recount, §13) -- **Status**: open +- **Status**: closed - **Trigger**: when se quiera promover el determinismo cross-implementación de informativo a gate bloqueante (o antes del primer bump de motor con impacto de encoding, R16) - **Destination**: mini-charter - **Cost**: M -- **Notes**: El harness `tests/determinism-yjs/` (T058) aplica el corpus compartido con Yjs y emite el SHA-256 del export, pero la paridad byte-idéntica con yrs está **gated en client-ids deterministas**: `ICrdtEngine.CreateDoc()` no toma parámetro y el shim FFI (`weft-yrs-ffi`) no expone fijar `client_id`, así que yrs asigna uno no controlable y su export no es reproducible cross-impl. Promover: (1) añadir `weft_doc_new_with_client_id` al FFI + `CreateDoc(ulong clientId)` al binding (aísla el bump, P-IV); (2) emitir el hash golden de yrs para el corpus; (3) pasar `WEFT_GOLDEN_HASH` al job y promoverlo a comparación con aserción; (4) añadir la variante unicode del corpus (índices UTF-16, R6). Hoy no-bloqueante; ningún gate depende. +- **Notes**: El harness `tests/determinism-yjs/` (T058) aplica el corpus compartido con Yjs y emite el SHA-256 del export, pero la paridad byte-idéntica con yrs está **gated en client-ids deterministas**: `ICrdtEngine.CreateDoc()` no toma parámetro y el shim FFI (`weft-yrs-ffi`) no expone fijar `client_id`, así que yrs asigna uno no controlable y su export no es reproducible cross-impl. Promover: (1) añadir `weft_doc_new_with_client_id` al FFI + `CreateDoc(ulong clientId)` al binding (aísla el bump, P-IV); (2) emitir el hash golden de yrs para el corpus; (3) pasar `WEFT_GOLDEN_HASH` al job y promoverlo a comparación con aserción; (4) añadir la variante unicode del corpus (índices UTF-16, R6). Hoy no-bloqueante; ningún gate depende. **CERRADO 2026-07-15 (CHARTER-09, AILOG-2026-07-15-001)**: entregadas las 4 partes — `weft_doc_new_with_client_id` (ABI v2, guard < 2^53) + `YrsEngine.CreateDoc(clientId)`; golden de Yjs comprometido (`golden.json` ascii+unicode); aserción de paridad **per-PR bloqueante** en `Weft.Determinism.Tests` (`Yrs_export_matches_yjs_golden`, 2/2 verde — **R1 se cumple: yrs == Yjs byte-idéntico**); corpus unicode + `apply.mjs` parametrizado + self-check del golden en `release.yml`. Loro diferido a FU-016. ### FU-013 — bump de GitHub Actions fuera de Node 20 (deprecado) - **Origin**: CHARTER-07 (dry-run release.yml run 29307786498, annotations) · AILOG-2026-07-13-003 (registro hand-add + recount, §13) diff --git a/native/weft-yrs-ffi/include/weft_ffi.h b/native/weft-yrs-ffi/include/weft_ffi.h index 04ca8a1..29d307d 100644 --- a/native/weft-yrs-ffi/include/weft_ffi.h +++ b/native/weft-yrs-ffi/include/weft_ffi.h @@ -47,6 +47,9 @@ typedef struct WeftDoc WeftDoc; /* ── Ciclo de vida del documento ──────────────────────────────────────────────────────── */ int32_t weft_doc_new(WeftDoc** out_doc); +/* Doc nuevo con client_id FIJO (siembra determinista, FU-012). client_id debe caber en 53 bits + * (encoding de yrs 0.26+): client_id >= 2^53 -> WEFT_ERR_OUT_OF_BOUNDS. ABI v2. */ +int32_t weft_doc_new_with_client_id(uint64_t client_id, WeftDoc** out_doc); int32_t weft_doc_load(const uint8_t* blob, size_t blob_len, WeftDoc** out_doc); void weft_doc_free(WeftDoc* doc); diff --git a/native/weft-yrs-ffi/src/lib.rs b/native/weft-yrs-ffi/src/lib.rs index 82463d5..d2f2444 100644 --- a/native/weft-yrs-ffi/src/lib.rs +++ b/native/weft-yrs-ffi/src/lib.rs @@ -26,7 +26,7 @@ use std::panic::{catch_unwind, AssertUnwindSafe}; use yrs::updates::decoder::Decode; use yrs::updates::encoder::Encode; -use yrs::{Doc, GetString, OffsetKind, Options, ReadTxn, StateVector, Text, Transact, Update}; +use yrs::{ClientID, Doc, GetString, OffsetKind, Options, ReadTxn, StateVector, Text, Transact, Update}; /// Crea un `Doc` con índices en **UTF-16 code units** (no el default de yrs, que es bytes UTF-8). /// Consistente con `string` de .NET y con Yjs (clientes de editor); crítico para que @@ -39,6 +39,23 @@ fn new_doc() -> Doc { Doc::with_options(opts) } +/// Como [`new_doc`] pero con un `client_id` FIJO (siembra determinista para paridad +/// cross-implementación; FU-012/CHARTER-09). Mismo `OffsetKind::Utf16`. +fn new_doc_with_client_id(client_id: u64) -> Doc { + // El guard `< 2^53` en la frontera (CLIENT_ID_MAX_EXCLUSIVE) garantiza que `ClientID::new` + // no dispare su `debug_assert!(value & MASK == 0)` ni corrompa el id en release. + let opts = Options { + client_id: ClientID::new(client_id), + offset_kind: OffsetKind::Utf16, + ..Options::default() + }; + Doc::with_options(opts) +} + +/// Cota superior (exclusiva) del `client_id`: yrs 0.26+ codifica los client IDs en **53 bits** +/// (antes 64). Un id `>= 2^53` no round-trippea por el encoding → se rechaza en la frontera. +const CLIENT_ID_MAX_EXCLUSIVE: u64 = 1 << 53; + // ── Códigos de estado (deben coincidir con weft_ffi.h y el mapeo de excepciones en C#) ── pub const WEFT_OK: i32 = 0; pub const WEFT_ERR_NULL_ARG: i32 = -1; @@ -50,7 +67,7 @@ pub const WEFT_ERR_PANIC: i32 = -127; /// Versión de la ABI. Se incrementa ante CUALQUIER cambio de firma o semántica; `Weft.Core` la /// verifica al cargar el cdylib y lanza si no coincide con la esperada. -const WEFT_ABI_VERSION: u32 = 1; +const WEFT_ABI_VERSION: u32 = 2; // ── Helpers internos (no expuestos por la C-ABI) ──────────────────────────────────────────── @@ -138,6 +155,27 @@ pub unsafe extern "C" fn weft_doc_new(out_doc: *mut *mut Doc) -> i32 { }) } +/// Crea un documento CRDT nuevo con un `client_id` **fijo** (siembra determinista para paridad +/// cross-implementación con Yjs; FU-012). Idéntico a [`weft_doc_new`] salvo por el id controlado. +/// `client_id` debe caber en **53 bits** (encoding de yrs 0.26+): `client_id >= 2^53` → +/// `WEFT_ERR_OUT_OF_BOUNDS`. Liberar SOLO con `weft_doc_free`. +/// +/// # Safety +/// `out_doc` debe ser un puntero escribible no nulo. +#[no_mangle] +pub unsafe extern "C" fn weft_doc_new_with_client_id(client_id: u64, out_doc: *mut *mut Doc) -> i32 { + guard(|| { + if out_doc.is_null() { + return WEFT_ERR_NULL_ARG; + } + if client_id >= CLIENT_ID_MAX_EXCLUSIVE { + return WEFT_ERR_OUT_OF_BOUNDS; + } + *out_doc = Box::into_raw(Box::new(new_doc_with_client_id(client_id))); + WEFT_OK + }) +} + /// Reconstruye un documento desde un blob exportado (update v1). Escribe el puntero en `out_doc`. /// /// # Safety diff --git a/native/weft-yrs-ffi/tests/mem_asan.rs b/native/weft-yrs-ffi/tests/mem_asan.rs index 8cf0d5d..c26b501 100644 --- a/native/weft-yrs-ffi/tests/mem_asan.rs +++ b/native/weft-yrs-ffi/tests/mem_asan.rs @@ -190,7 +190,7 @@ fn stress_all_functions_2000_iterations() { weft_doc_free(reloaded); weft_doc_free(doc); } - assert_eq!(weft_abi_version(), 1); + assert_eq!(weft_abi_version(), 2); // ABI v2: + weft_doc_new_with_client_id (CHARTER-09) } } @@ -224,6 +224,53 @@ fn malformed_update_with_huge_declared_length_decodes_cleanly() { /// (assertion en `block.rs`) NO debe cruzar la frontera — `catch_unwind` lo contiene como código /// de error. Verifica el contrato P-I con panic=unwind (igual que producción; el fuzz de CI corre /// con un hook silenciado para ejercitar este mismo camino). +/// Siembra de client_id (FU-012/CHARTER-09): dos docs con el MISMO client_id + las mismas ops +/// exportan bytes idénticos (base de la paridad cross-impl); el guard de 53 bits rechaza en la +/// frontera; el borde superior válido (2^53 - 1) se acepta. +#[test] +fn seed_client_id_is_deterministic_and_bounded() { + unsafe { + let field = b"body"; + let text = b"hola"; + + // Mismo client_id + mismas ops → export byte-idéntico. + let mut a: *mut Doc = ptr::null_mut(); + let mut b: *mut Doc = ptr::null_mut(); + assert_eq!(weft_doc_new_with_client_id(42, &mut a), WEFT_OK); + assert_eq!(weft_doc_new_with_client_id(42, &mut b), WEFT_OK); + weft_text_insert(a, field.as_ptr(), field.len(), 0, text.as_ptr(), text.len()); + weft_text_insert(b, field.as_ptr(), field.len(), 0, text.as_ptr(), text.len()); + + let (mut pa, mut la) = (ptr::null_mut(), 0usize); + let (mut pb, mut lb) = (ptr::null_mut(), 0usize); + assert_eq!(weft_doc_export_state(a, &mut pa, &mut la), WEFT_OK); + assert_eq!(weft_doc_export_state(b, &mut pb, &mut lb), WEFT_OK); + assert_eq!(la, lb); + assert_eq!( + std::slice::from_raw_parts(pa, la), + std::slice::from_raw_parts(pb, lb), + "misma siembra + mismas ops debe exportar bytes idénticos" + ); + weft_buf_free(pa, la); + weft_buf_free(pb, lb); + weft_doc_free(a); + weft_doc_free(b); + + // Guard de 53 bits: 2^53 se rechaza, 2^53 - 1 se acepta. + let mut over: *mut Doc = ptr::null_mut(); + assert_eq!( + weft_doc_new_with_client_id(1u64 << 53, &mut over), + WEFT_ERR_OUT_OF_BOUNDS + ); + assert!(over.is_null()); + + let mut edge: *mut Doc = ptr::null_mut(); + assert_eq!(weft_doc_new_with_client_id((1u64 << 53) - 1, &mut edge), WEFT_OK); + assert!(!edge.is_null()); + weft_doc_free(edge); + } +} + #[test] fn malformed_update_that_panics_yrs_is_contained_not_ub() { unsafe { diff --git a/src/Weft.Core/Yrs/NativeLibraryResolver.cs b/src/Weft.Core/Yrs/NativeLibraryResolver.cs index 4ebefe8..5a2f4c0 100644 --- a/src/Weft.Core/Yrs/NativeLibraryResolver.cs +++ b/src/Weft.Core/Yrs/NativeLibraryResolver.cs @@ -11,7 +11,8 @@ namespace Weft.Yrs; /// internal static class NativeLibraryResolver { - private const uint ExpectedAbiVersion = 1; + // ABI v2 (CHARTER-09): añade weft_doc_new_with_client_id (siembra determinista, FU-012). + private const uint ExpectedAbiVersion = 2; private static int _registered; [System.Diagnostics.CodeAnalysis.SuppressMessage( diff --git a/src/Weft.Core/Yrs/NativeMethods.cs b/src/Weft.Core/Yrs/NativeMethods.cs index c15a836..120a347 100644 --- a/src/Weft.Core/Yrs/NativeMethods.cs +++ b/src/Weft.Core/Yrs/NativeMethods.cs @@ -21,6 +21,9 @@ internal static partial class NativeMethods [LibraryImport(Lib)] internal static partial int weft_doc_new(out nint outDoc); + [LibraryImport(Lib)] + internal static partial int weft_doc_new_with_client_id(ulong clientId, out nint outDoc); + [LibraryImport(Lib)] internal static partial int weft_doc_load(ReadOnlySpan blob, nuint blobLen, out nint outDoc); diff --git a/src/Weft.Core/Yrs/YrsDoc.cs b/src/Weft.Core/Yrs/YrsDoc.cs index 378839e..2f74b69 100644 --- a/src/Weft.Core/Yrs/YrsDoc.cs +++ b/src/Weft.Core/Yrs/YrsDoc.cs @@ -22,6 +22,17 @@ internal static YrsDoc Create() return new YrsDoc(new DocHandle(raw)); } + /// + /// Crea un doc con un FIJO (siembra determinista para paridad + /// cross-implementación con Yjs; FU-012). Debe caber en 53 bits (encoding de yrs 0.26+); + /// un valor mayor lanza vía WEFT_ERR_OUT_OF_BOUNDS. + /// + internal static YrsDoc Create(ulong clientId) + { + FfiStatus.ThrowIfError(NativeMethods.weft_doc_new_with_client_id(clientId, out nint raw)); + return new YrsDoc(new DocHandle(raw)); + } + internal static YrsDoc Load(ReadOnlySpan blob) { FfiStatus.ThrowIfError(NativeMethods.weft_doc_load(blob, (nuint)blob.Length, out nint raw)); diff --git a/src/Weft.Core/Yrs/YrsEngine.cs b/src/Weft.Core/Yrs/YrsEngine.cs index e00dd87..bb67e1c 100644 --- a/src/Weft.Core/Yrs/YrsEngine.cs +++ b/src/Weft.Core/Yrs/YrsEngine.cs @@ -24,6 +24,15 @@ private YrsEngine() { } /// public ICrdtDoc CreateDoc() => YrsDoc.Create(); + /// + /// Crea un documento con un FIJO. Capacidad yrs-específica + /// (no parte de ): habilita la paridad byte-idéntica cross-implementación + /// con Yjs (gate determinism-yjs, FU-012), que exige client-ids deterministas. El id debe + /// caber en 53 bits (encoding de yrs 0.26+). La promoción a capacidad cross-engine (Loro vía + /// set_peer_id) se difiere a un follow-up. + /// + public ICrdtDoc CreateDoc(ulong clientId) => YrsDoc.Create(clientId); + /// public ICrdtDoc LoadDoc(ReadOnlySpan blob) => YrsDoc.Load(blob); } diff --git a/tests/Weft.Determinism.Tests/DeterminismTests.cs b/tests/Weft.Determinism.Tests/DeterminismTests.cs index f35e232..c73faf8 100644 --- a/tests/Weft.Determinism.Tests/DeterminismTests.cs +++ b/tests/Weft.Determinism.Tests/DeterminismTests.cs @@ -1,3 +1,5 @@ +using System.Security.Cryptography; +using System.Text.Json; using Weft; using Weft.Versioning; using Weft.Versioning.Blobs; @@ -93,6 +95,114 @@ public async Task Converged_replicas_share_version_id() } } + // ── Paridad cross-implementación vs Yjs JS (FU-012/CHARTER-09, research R13, P-III) ────────── + // El export v1 de yrs sobre el corpus compartido debe ser BYTE-IDÉNTICO al de Yjs JS — es decir, + // el determinismo de Weft es "por formato" (encoding v1 de Yjs/yrs), no un accidente de esta + // versión de yrs. Requiere client-ids deterministas (YrsEngine.CreateDoc(clientId), FU-012). El + // hash golden de Yjs vive en tests/determinism-yjs/golden.json; el harness Node lo regenera y + // self-checkea en release.yml (caza drift de Yjs). Esta aserción es el gate BLOQUEANTE per-PR. + + [Theory] + [InlineData("corpus.json", "ascii")] + [InlineData("corpus-unicode.json", "unicode")] + public void Yrs_export_matches_yjs_golden(string corpusFile, string goldenKey) + { + string dir = DeterminismCorpusDir(); + CorpusSpec corpus = LoadCorpus(Path.Combine(dir, corpusFile)); + string golden = GoldenHash(Path.Combine(dir, "golden.json"), goldenKey); + + ICrdtDoc[] replicas = [.. corpus.ClientIds.Select(id => YrsEngine.Instance.CreateDoc((ulong)id))]; + try + { + // Aplicar cada op a su réplica (sin guard de longitud: paridad exacta con apply.mjs). + foreach (CorpusOp step in corpus.Ops) + { + ICrdtDoc doc = replicas[step.Replica]; + if (step.Op == "ins") + { + doc.InsertText(corpus.Type, step.Index, step.Text!); + } + else if (step.Op == "del") + { + doc.DeleteText(corpus.Type, step.Index, step.Len); + } + else + { + throw new InvalidOperationException($"op desconocida: {step.Op}"); + } + } + + // Sincronizar todos-contra-todos hasta converger (mismo esquema que apply.mjs). + for (int pass = 0; pass < corpus.SyncPasses; pass++) + { + foreach (ICrdtDoc target in replicas) + { + byte[] sv = target.ExportStateVector(); + foreach (ICrdtDoc source in replicas) + { + if (!ReferenceEquals(source, target)) + { + target.ApplyUpdate(source.ExportUpdateSince(sv)); + } + } + } + } + + byte[] export = replicas[0].ExportState(); + string yrsHash = Convert.ToHexStringLower(SHA256.HashData(export)); + + Assert.Equal(golden, yrsHash); + } + finally + { + foreach (ICrdtDoc r in replicas) + { + r.Dispose(); + } + } + } + + // Localiza tests/determinism-yjs/ subiendo desde el binario del test hasta la raíz del repo. + private static string DeterminismCorpusDir() + { + for (DirectoryInfo? d = new(AppContext.BaseDirectory); d is not null; d = d.Parent) + { + string candidate = Path.Combine(d.FullName, "tests", "determinism-yjs"); + if (Directory.Exists(candidate)) + { + return candidate; + } + } + + throw new DirectoryNotFoundException("No se encontró tests/determinism-yjs/ desde el binario del test."); + } + + private static CorpusSpec LoadCorpus(string path) => + JsonSerializer.Deserialize(File.ReadAllText(path), CorpusJson) + ?? throw new InvalidOperationException($"corpus vacío: {path}"); + + private static string GoldenHash(string path, string key) + { + using JsonDocument doc = JsonDocument.Parse(File.ReadAllText(path)); + return doc.RootElement.GetProperty(key).GetString() + ?? throw new InvalidOperationException($"golden[{key}] ausente en {path}"); + } + + private static readonly JsonSerializerOptions CorpusJson = new(JsonSerializerDefaults.Web); + + private sealed record CorpusSpec( + string Type, + int[] ClientIds, + int SyncPasses, + CorpusOp[] Ops); + + private sealed record CorpusOp( + int Replica, + string Op, + int Index, + string? Text, + int Len); + // El encoding es estable: cargar un blob y re-exportar es byte-idéntico, indefinidamente // (la propiedad que hace que un VersionId sea citable cross-plataforma). [Fact] diff --git a/tests/determinism-yjs/README.md b/tests/determinism-yjs/README.md index 6f2d9c2..b4612d0 100644 --- a/tests/determinism-yjs/README.md +++ b/tests/determinism-yjs/README.md @@ -11,29 +11,38 @@ Si los hashes coinciden, el determinismo de Weft es **por formato** (el encoding accidente de esta versión de `yrs`. Esa es la garantía que distingue "content-addressing estable" de "estable hasta el próximo bump del motor" (ver R16). -## Estado: no-bloqueante / promovible +## Estado: PROMOVIDO a aserción per-PR (yrs), CHARTER-09/FU-012 -Adoptado como **job no-bloqueante primero, promovible a gate** (R13). Hoy la paridad byte-idéntica con yrs -está **gated en client IDs deterministas**: el binding de yrs no expone fijar el `client_id` -(`ICrdtEngine.CreateDoc()` no toma parámetro y el shim FFI no lo soporta), así que yrs asigna un `client_id` -no controlable y su export no es reproducible entre corridas/implementaciones. **Follow-up FU-012**: exponer -`CreateDoc(clientId)` en el FFI + binding, emitir el hash golden de yrs para este corpus, y promover este job -a comparación con aserción. +La paridad byte-idéntica yrs↔Yjs **se cumple** y está **aserida en cada PR** (bloqueante de facto). FU-012 la +habilitó exponiendo client-ids deterministas en el FFI de yrs (`weft_doc_new_with_client_id` + `YrsEngine. +CreateDoc(clientId)`, ABI v2). La aserción vive en **`tests/Weft.Determinism.Tests`** +(`Yrs_export_matches_yjs_golden`): aplica el corpus con yrs y compara el SHA-256 del export contra el **hash +golden de Yjs** comprometido en `golden.json` — corre en el job `test` existente, costo marginal ~0. -Mientras tanto el harness **corre y emite el hash de Yjs** para el corpus compartido (`corpus.json`), y —si -se le pasa `WEFT_GOLDEN_HASH` con el hash de yrs— **compara y reporta** la (dis)paridad **sin fallar** -(informativo, insumo para R16). +Este harness Node es el **complemento informativo** (job `determinism-yjs` en `release.yml`, `continue-on-error`): +emite el hash de Yjs de ambos corpus y lo **self-checkea contra `golden.json`** — así, si Yjs bumpea y cambia el +encoding, el hash deja de coincidir con el golden y se detecta el drift (insumo para R16). La verificación de +paridad real (yrs == Yjs) es el test .NET; este job protege la vigencia del golden. + +**Alcance yrs-only**: el gate es yrs↔Yjs (misma familia de formato). Loro es otro formato y no participa; la +promoción de la siembra de client-id a capacidad cross-engine (Loro vía `set_peer_id`) es un follow-up aparte. ## Uso ```bash npm install -npm test # emite el hash de Yjs (informativo) -WEFT_GOLDEN_HASH= npm test # compara con yrs (no falla ante divergencia) +npm test # emite el hash de Yjs de ambos corpus + self-check vs golden.json +node apply.mjs corpus-unicode.json # solo la variante unicode +WEFT_GOLDEN_HASH= node apply.mjs ... # override manual del golden (debug) + +# Regenerar golden.json tras un cambio DELIBERADO de corpus (no un drift): +node apply.mjs corpus.json # copiar el hash a golden.json["ascii"] +node apply.mjs corpus-unicode.json # copiar el hash a golden.json["unicode"] ``` ## Corpus -`corpus.json` es la fuente única de la secuencia: `clientIds` fijos por réplica, `ops` (`ins`/`del` sobre -un texto `body`) y `syncPasses`. La variante con texto **unicode** (que ejercita los índices UTF-16, ver R6) -es una extensión promovible tras estabilizar la paridad ASCII base. +`corpus.json` (ASCII) y `corpus-unicode.json` (texto no-ASCII: BMP acentuado, CJK y astrales/emoji — ejercita +los índices **UTF-16**, ver R6) son la fuente única de la secuencia: `clientIds` fijos por réplica, `ops` +(`ins`/`del` sobre un texto `body`, índices en UTF-16 code units) y `syncPasses`. `golden.json` guarda el hash +de Yjs de cada uno (`ascii`/`unicode`). diff --git a/tests/determinism-yjs/apply.mjs b/tests/determinism-yjs/apply.mjs index 91f3789..b125d79 100644 --- a/tests/determinism-yjs/apply.mjs +++ b/tests/determinism-yjs/apply.mjs @@ -12,12 +12,18 @@ import * as Y from 'yjs'; import { createHash } from 'node:crypto'; -import { readFileSync } from 'node:fs'; +import { existsSync, readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; -import { dirname, join } from 'node:path'; +import { basename, dirname, join } from 'node:path'; const here = dirname(fileURLToPath(import.meta.url)); -const corpus = JSON.parse(readFileSync(join(here, 'corpus.json'), 'utf8')); + +// Corpus por argumento (`node apply.mjs [corpus-unicode.json]`), default corpus.json (FU-012). +const corpusFile = process.argv[2] ?? 'corpus.json'; +const corpus = JSON.parse(readFileSync(join(here, corpusFile), 'utf8')); + +// Clave del golden por corpus: corpus.json → "ascii", corpus-unicode.json → "unicode". +const goldenKey = basename(corpusFile) === 'corpus-unicode.json' ? 'unicode' : 'ascii'; // Una réplica = un Y.Doc con clientID fijo del corpus. const replicas = corpus.clientIds.map((id) => { @@ -55,18 +61,26 @@ const update = Y.encodeStateAsUpdate(replicas[0]); const hash = createHash('sha256').update(Buffer.from(update)).digest('hex'); const finalText = replicas[0].getText(corpus.type).toString(); +console.log(`Yjs corpus: ${corpusFile}`); console.log(`Yjs texto convergido: "${finalText}"`); console.log(`Yjs export SHA-256: ${hash}`); -const golden = process.env.WEFT_GOLDEN_HASH; +// Self-check contra el golden comprometido (golden.json[goldenKey]). Caza drift de Yjs: si Yjs +// bumpea y cambia el encoding, el hash emitido deja de coincidir con el golden. La aserción +// BLOQUEANTE real de paridad yrs↔Yjs vive en Weft.Determinism.Tests (per-PR); este job es +// informativo (`continue-on-error` en release.yml). WEFT_GOLDEN_HASH sigue soportado como override. +const goldenPath = join(here, 'golden.json'); +const override = process.env.WEFT_GOLDEN_HASH; +const golden = override + ?? (existsSync(goldenPath) ? JSON.parse(readFileSync(goldenPath, 'utf8'))[goldenKey] : undefined); if (golden) { if (golden === hash) { - console.log('✓ PARIDAD cross-implementación: el hash de Yjs coincide con el de yrs.'); + console.log(`✓ El hash de Yjs coincide con el golden comprometido (${goldenKey}).`); } else { - console.log('⚠ DIVERGENCIA (no-bloqueante): Yjs vs yrs difieren.'); - console.log(` yrs (golden): ${golden}`); - console.log(' Insumo para R16 (bump del motor) / promoción del gate. Ver README.'); + console.log(`⚠ DIVERGENCIA (no-bloqueante): Yjs difiere del golden (${goldenKey}).`); + console.log(` golden: ${golden}`); + console.log(' Posible drift de Yjs (bump con impacto de encoding). Regenerar golden. Ver README.'); } } else { - console.log('ℹ Sin WEFT_GOLDEN_HASH (hash de yrs): harness informativo. Paridad con yrs = paso promovible.'); + console.log('ℹ Sin golden.json ni WEFT_GOLDEN_HASH: harness informativo (solo emite el hash de Yjs).'); } diff --git a/tests/determinism-yjs/corpus-unicode.json b/tests/determinism-yjs/corpus-unicode.json new file mode 100644 index 0000000..966c123 --- /dev/null +++ b/tests/determinism-yjs/corpus-unicode.json @@ -0,0 +1,14 @@ +{ + "_comment": "Variante unicode del corpus determinista (CHARTER-09/FU-012). Texto no-ASCII que ejercita los índices en UTF-16 code units (OffsetKind::Utf16 en yrs, clientID de .NET string). Incluye BMP acentuado, CJK y astrales (emoji, surrogate pairs → 2 code units UTF-16). Los índices de las ops están en UTF-16 code units, consistentes con Yjs. client IDs FIJOS → export byte-idéntico entre implementaciones.", + "type": "body", + "clientIds": [1, 2, 3], + "syncPasses": 2, + "ops": [ + { "replica": 0, "op": "ins", "index": 0, "text": "Ĉaŭ mondo " }, + { "replica": 1, "op": "ins", "index": 0, "text": "日本語 " }, + { "replica": 2, "op": "ins", "index": 0, "text": "🦀🧵 " }, + { "replica": 0, "op": "ins", "index": 4, "text": "árbol " }, + { "replica": 1, "op": "ins", "index": 0, "text": "café " }, + { "replica": 2, "op": "del", "index": 0, "len": 2 } + ] +} diff --git a/tests/determinism-yjs/golden.json b/tests/determinism-yjs/golden.json new file mode 100644 index 0000000..86c81be --- /dev/null +++ b/tests/determinism-yjs/golden.json @@ -0,0 +1,5 @@ +{ + "_comment": "Hashes golden de Yjs (SHA-256 del export v1 de la réplica 0 convergida) para el corpus compartido, producidos por apply.mjs (CHARTER-09/FU-012). Fuente de verdad de la paridad cross-implementación: Weft.Determinism.Tests asierta que yrs produce EXACTAMENTE estos hashes (per-PR, bloqueante de facto); el job Node en release.yml recomputa el hash de Yjs y verifica que sigue igualando estos valores (caza drift de Yjs). Regenerar con: node apply.mjs corpus.json && node apply.mjs corpus-unicode.json.", + "ascii": "27a8487564ee087569bb47bd07ddb49aa5db29a419a489902600bc3a62513243", + "unicode": "afd15f9c246102b98dbe084f182b1af8396342a97aa45f365b2f2676f3ff9e02" +} diff --git a/tests/determinism-yjs/package.json b/tests/determinism-yjs/package.json index de4c68d..552a84d 100644 --- a/tests/determinism-yjs/package.json +++ b/tests/determinism-yjs/package.json @@ -5,7 +5,7 @@ "type": "module", "description": "Gate de determinismo cross-implementación (CHARTER-07/T058, research R13): aplica el corpus compartido con Yjs JS y emite el SHA-256 del export, para comparar contra el de yrs. No-bloqueante, promovible a gate.", "scripts": { - "test": "node apply.mjs" + "test": "node apply.mjs corpus.json && node apply.mjs corpus-unicode.json" }, "dependencies": { "yjs": "^13.6.27"