From 091645708d0800c6638284dbe28ed5af14d2a221 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 12:25:40 -0600 Subject: [PATCH 1/2] =?UTF-8?q?feat(m3):=20superficie=20INativeVersioning?= =?UTF-8?q?=20de=20Loro=20=E2=80=94=20probes=20nativos=20(CHARTER-10)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cierra FU-006 (hallazgo G1 de la auditoría CHARTER-02): materializa la capacidad opcional INativeVersioning para Loro. LoroEngine.NativeVersioning pasa de null a una implementación real — 3 probes DEMOSTRATIVOS del versionado nativo de Loro (diff/fork/shallow snapshot) que yrs no tiene. - Shim weft-loro-ffi (ABI v1→v2): weft_loro_shallow_snapshot, weft_loro_native_diff_probe, weft_loro_native_branch_merge_probe (fork+merge en copia aparte, NO muta el caller). JSON armado a mano (sin serde_json). - Header weft_loro_ffi.h creado (el shim Loro no tenía; paridad con yrs). - Binding: NativeMethods + ExpectedAbiVersion 2 + métodos internal en LoroDoc + LoroNativeVersioning (cast ICrdtDoc→LoroDoc, guard de doc no-Loro). - Tests: LoroNativeVersioningTests 5/5 (round-trip, diff, convergencia, guard). - Probes NO son content-addressing: salida no determinista, no alimenta VersionId (que usa ExportState). Quickstart §US5 reconciliado. - FU-006 closed; FU-017 registrado (test de paridad header↔binding del shim Loro). AILOG-2026-07-15-002 + AIDEC-2026-07-15-002 (risk medium, revisados). Verificación: dotnet 131/131, cargo 5/5 (probes bajo ASan). Co-Authored-By: Claude Opus 4.8 (1M context) --- ...er-10-superficie-inativeversioning-loro.md | 103 +++++++++++ ...semantica-de-los-probes-nativos-de-loro.md | 109 ++++++++++++ ...rficie-inativeversioning-de-loro-probes.md | 164 ++++++++++++++++++ .straymark/follow-ups-backlog.md | 16 +- native/weft-loro-ffi/include/weft_loro_ffi.h | 86 +++++++++ native/weft-loro-ffi/src/lib.rs | 138 ++++++++++++++- native/weft-loro-ffi/tests/mem_asan.rs | 57 +++++- specs/001-weft-crdt-versioning/quickstart.md | 9 +- .../Interop/NativeLibraryResolver.cs | 3 +- src/Weft.Loro/Interop/NativeMethods.cs | 12 ++ src/Weft.Loro/LoroDoc.cs | 32 ++++ src/Weft.Loro/LoroEngine.cs | 9 +- src/Weft.Loro/LoroNativeVersioning.cs | 35 ++++ .../LoroNativeVersioningTests.cs | 74 ++++++++ 14 files changed, 833 insertions(+), 14 deletions(-) create mode 100644 .straymark/07-ai-audit/agent-logs/AILOG-2026-07-15-002-charter-10-superficie-inativeversioning-loro.md create mode 100644 .straymark/07-ai-audit/decisions/AIDEC-2026-07-15-002-charter-10-forma-y-semantica-de-los-probes-nativos-de-loro.md create mode 100644 .straymark/charters/10-superficie-inativeversioning-de-loro-probes.md create mode 100644 native/weft-loro-ffi/include/weft_loro_ffi.h create mode 100644 src/Weft.Loro/LoroNativeVersioning.cs create mode 100644 tests/Weft.Versioning.Tests/LoroNativeVersioningTests.cs diff --git a/.straymark/07-ai-audit/agent-logs/AILOG-2026-07-15-002-charter-10-superficie-inativeversioning-loro.md b/.straymark/07-ai-audit/agent-logs/AILOG-2026-07-15-002-charter-10-superficie-inativeversioning-loro.md new file mode 100644 index 0000000..86c3b13 --- /dev/null +++ b/.straymark/07-ai-audit/agent-logs/AILOG-2026-07-15-002-charter-10-superficie-inativeversioning-loro.md @@ -0,0 +1,103 @@ +--- +id: AILOG-2026-07-15-002 +title: "CHARTER-10: superficie INativeVersioning de Loro — probes nativos diff/fork/shallow (cierra G1)" +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, loro, native-versioning, probes, engine-abstraction, g1] +related: [AIDEC-2026-07-15-002, AILOG-2026-07-10-002] +originating_charter: CHARTER-10-superficie-inativeversioning-de-loro-probes +--- + +# AILOG: CHARTER-10 — superficie INativeVersioning de Loro (probes nativos, cierra G1) + +## Summary + +Despacho de CHARTER-10 (FU-006, hallazgo **G1** de la auditoría CHARTER-02): materializa la capacidad +**opcional** `INativeVersioning` para el motor **Loro** — tres probes **demostrativos** del versionado +nativo de Loro (`ShallowSnapshot`, `NativeDiffProbe`, `NativeBranchMergeProbe`) que yrs no tiene. Cierra G1: +`LoroEngine.NativeVersioning` pasa de `null` a una implementación real. Los probes **no** son +content-addressing (salida no determinista, no alimentan `VersionId`); exhiben la capacidad nativa (P-IV). +Sin auditoría externa (no cierra hito). + +## Actions Performed + +1. **Shim `weft-loro-ffi` — 3 probes (ABI v1→v2)**: `weft_loro_shallow_snapshot` (export + `ExportMode::shallow_snapshot(&state_frontiers())`), `weft_loro_native_diff_probe` (JSON de + `doc.diff(Frontiers::default(), state_frontiers)` — containers + text_len), `weft_loro_native_branch_merge_probe` + (fork → editar el fork → `import` en una copia aparte → JSON con convergencia; **no muta el caller**). + `WEFT_ABI_VERSION` 1→2; `catch_unwind` en cada entrada; JSON armado a mano (sin `serde_json`) con + `json_escape`. `mem_asan.rs`: assert ABI 1→2 + test de reachability/no-fugas/no-mutación de los 3 probes. +2. **Header `weft_loro_ffi.h` (NEW)**: el shim Loro no tenía header; se crea espejando `weft_ffi.h` (todas + las funciones + las 3 nuevas + el contrato de ownership). +3. **Binding**: 3 P/Invokes en `Interop/NativeMethods.cs`; `ExpectedAbiVersion` 1→2 en + `Interop/NativeLibraryResolver.cs`; 3 métodos `internal` en `LoroDoc.cs` (delegan vía `HandleLease`). +4. **`LoroNativeVersioning.cs` (NEW)** + **`LoroEngine.NativeVersioning`** no-nulo (singleton). Castea + `ICrdtDoc → LoroDoc` con `ArgumentException` clara si se pasa un doc no-Loro. +5. **Tests** (`LoroNativeVersioningTests`, NEW, 5/5): shallow no-vacío y recargable; diff refleja ediciones + (JSON parseado); branch/merge converge y NO muta el caller; guard de doc no-Loro lanza; yrs `null`. +6. **Quickstart §US5** reconciliado (G1 cerrado, `NativeVersioning` ya no null). **FU-006** → `closed`. + +## Modified Files + +**Nativo**: `native/weft-loro-ffi/src/lib.rs` (3 probes + ABI v2 + json_escape), +`native/weft-loro-ffi/include/weft_loro_ffi.h` (NEW), `native/weft-loro-ffi/tests/mem_asan.rs` (ABI + probes). +**Binding**: `src/Weft.Loro/Interop/NativeMethods.cs`, `NativeLibraryResolver.cs`, `LoroDoc.cs`, +`LoroNativeVersioning.cs` (NEW), `LoroEngine.cs`. **Tests/spec**: +`tests/Weft.Versioning.Tests/LoroNativeVersioningTests.cs` (NEW), `specs/001-weft-crdt-versioning/quickstart.md`. +**Gobernanza**: `.straymark/follow-ups-backlog.md` (FU-006 closed, FU-017), `.straymark/charters/10-*.md`, +AIDEC-2026-07-15-002 (NEW). + +## Risk + +- **R1 (medio, del Charter) — API nativa de Loro**: RESUELTO. `ExportMode::shallow_snapshot(&Frontiers)`, + `diff(a,b)→DiffBatch`, `fork()`, `state_frontiers()` confirmados y usados; los 3 probes compilan y pasan. +- **R2 (bajo, del Charter) — salida no determinista**: aceptado por diseño. Los probes son demostrativos, NO + content-addressing (documentado en docstrings/header/quickstart/AIDEC). Tests asertan round-trip/convergencia. +- **R3 (bajo) — ABI bump v1→v2 del shim Loro**: bump atómico Rust + .NET; exports aditivos; `mem_asan.rs` + actualizado. Desalineación → `NativeLibraryResolver` lanza explícito. +- **R4 (bajo) — cast ICrdtDoc→LoroDoc**: guard con `ArgumentException`; test cubre un doc yrs → lanza. + +## Verification + +```bash +# Shim Loro: compila + símbolos + ABI v2 + cargo test (incl. probes bajo ASan) +cd native/weft-loro-ffi && cargo build --release && cargo test # 5/5 +nm -D ../target/release/libweft_loro_ffi.so | grep -E "weft_loro_shallow_snapshot|native_diff_probe|native_branch_merge_probe" + +# Suite .NET completa incl. LoroNativeVersioningTests (5/5) +cd ../.. && dotnet test Weft.sln -c Release +``` + +## Follow-ups + +Derivado de que el shim Loro no tenía header (a diferencia de yrs). No bloquea: + +- **Follow-up (test infra, baja)**: añadir un test de paridad **header↔binding** para el shim Loro + (`weft_loro_ffi.h` ↔ `Weft.Loro/Interop/NativeMethods.cs`), como el que ya valida el shim yrs + (`weft_ffi.h` ↔ `Weft.Core`). El header se creó en CHARTER-10 pero ningún test automatizado verifica que + las declaraciones `[LibraryImport]` coincidan con él. **Trigger**: ready (mejora de robustez). **Destination**: + chore. **Cost**: S. + +## Additional Notes + +- El shallow snapshot ES recargable (`LoadDoc`/`import`) — capacidad real de Loro, solo que no citable (no + determinista). El diff probe reporta `containers_changed` (≥1 tras editar) + `text_len_utf16`. +- El branch/merge probe forkea DOS veces (branch para editar + target para mergear), dejando el doc del caller + intacto — verificado por test (`Assert.Equal("base", doc.GetText("body"))`). + +## Approval + +Trabajo de frontera nativa (`risk_level: medium`, `review_required: true`) con ABI bump. El operador autorizó +ejecución continua y el alcance demostrativo de los probes (ex-ante en el Charter). Verificación local citada; +el CI del PR valida en toda la matriz. Compañero de AIDEC-2026-07-15-002. diff --git a/.straymark/07-ai-audit/decisions/AIDEC-2026-07-15-002-charter-10-forma-y-semantica-de-los-probes-nativos-de-loro.md b/.straymark/07-ai-audit/decisions/AIDEC-2026-07-15-002-charter-10-forma-y-semantica-de-los-probes-nativos-de-loro.md new file mode 100644 index 0000000..a21c60e --- /dev/null +++ b/.straymark/07-ai-audit/decisions/AIDEC-2026-07-15-002-charter-10-forma-y-semantica-de-los-probes-nativos-de-loro.md @@ -0,0 +1,109 @@ +--- +id: AIDEC-2026-07-15-002 +title: "CHARTER-10: forma y semántica de los probes nativos de Loro (demostrativos, sin mutación, JSON a mano)" +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, loro, native-versioning, probes, engine-abstraction, determinism] +related: [AILOG-2026-07-15-002] +originating_charter: CHARTER-10-superficie-inativeversioning-de-loro-probes +--- + +# AIDEC: forma y semántica de los probes nativos de Loro (INativeVersioning) + +> Registra las decisiones de diseño de CHARTER-10 (FU-006) sobre los tres probes de `INativeVersioning` +> para Loro, anticipadas en el Charter §Tasks como candidatas a AIDEC. + +## Context + +`INativeVersioning` es una capacidad **opcional** (probes de paridad) que un motor con versionado nativo +puede exponer. Loro lo tiene (`fork`, `diff`, `ExportMode::shallow_snapshot`); yrs no. G1 (auditoría +CHARTER-02) pidió materializar la superficie para Loro (hoy `NativeVersioning == null`). Las firmas del +interface están fijas (`NativeDiffProbe`/`NativeBranchMergeProbe` → `string` JSON; `ShallowSnapshot` → +`byte[]`); lo abierto era la **semántica** de cada probe y su forma concreta. + +--- + +## Decisión 1 — Probes DEMOSTRATIVOS, no content-addressing + +### Problem + +¿Qué son estos probes: un contrato de versionado nativo (bytes citables, deterministas) o una +demostración de la capacidad? + +### Alternatives Considered + +- **A1 — Probes como fuente de content-addressing** (el shallow snapshot alimenta un `VersionId` nativo, + el diff es un delta citable). Requeriría determinismo byte a byte, pero el shallow snapshot de Loro lleva + metadata de réplica (peer-ids, orden interno) — NO es determinista entre réplicas convergidas (el mismo + motivo por el que `weft_loro_doc_export_state` usa `all_updates`, no `Snapshot`). **Rechazada.** +- **A2 (elegida) — Probes DEMOSTRATIVOS**: exhiben que Loro PUEDE versionar nativamente (fork/diff/shallow). + Su salida es informativa, **no** determinista y **no** alimenta `VersionId` (que sigue con `ExportState`/ + `all_updates`, content-addressed engine-agnóstico). Cierra G1 sin prometer garantías que Loro no da. + +### Rationale + +FU-006 y el docstring del interface los llaman "probes de paridad" — el objetivo es materializar la +superficie diferida, no construir un segundo sistema de versionado. Prometer content-addressing sobre bytes +no deterministas sería falso. Documentado en el docstring de cada probe, en `LoroNativeVersioning`, en el +header y en el quickstart §US5. Los tests asertan reachability/round-trip/convergencia, **no** +byte-determinismo. Una API de versionado nativo rica (branches con nombre, time-travel) sería un charter +aparte si alguna vez se requiere (out of scope). + +### Consequences + +- `VersionStore`/`VersionId` intactos; los probes son una superficie lateral opcional. +- El shallow snapshot ES recargable (`LoadDoc`/`import`) — útil como capacidad, aunque no citable. + +--- + +## Decisión 2 — El branch/merge probe NO muta el doc del caller; JSON a mano + +### Problem + +El probe de fork/merge necesita editar y mergear. ¿Muta el documento del caller? ¿Cómo se serializa el +resultado a JSON si el shim no tiene `serde_json`? + +### Alternatives Considered + +- **B1 — fork + editar + mergear DE VUELTA al doc original.** Simple, pero **muta el doc del caller** con una + edición sintética — efecto secuandario sorpresa e inaceptable para un probe. **Rechazada.** +- **B2 (elegida) — fork + editar el fork + `import` en una COPIA aparte (`doc.fork()`), reportar + convergencia.** El doc del caller queda intacto (verificado por test: su texto no cambia). Demuestra el + ciclo nativo fork→editar→merge sin efectos secundarios. +- Serialización: **añadir `serde_json` al shim** (dep nueva, contra la minimalidad de la frontera nativa) vs + **armar el JSON a mano**. Elegido **a mano** con un `json_escape` para el nombre del campo — los probes + emiten solo campos numéricos/booleanos + el field escapado; sin dep nueva, `DiffBatch` no necesita ser + `Serialize` (no lo es). + +### Rationale + +Un probe no debe mutar su entrada. Armar el JSON a mano evita una dependencia nativa por una salida trivial +(3-4 campos). El `json_escape` cubre comillas/backslash/controles del nombre del campo (única entrada de +texto que se incrusta). El diff probe reporta `containers_changed` (nº de containers en el `DiffBatch` de +`doc.diff(Frontiers::default(), state_frontiers)`) + `text_len_utf16` — demostrativo y estable. + +### Consequences + +- Placement (como CHARTER-09): `LoroNativeVersioning` es `internal`, castea `ICrdtDoc → LoroDoc` (excepción + `ArgumentException` clara si se pasa un doc no-Loro), y delega en métodos `internal` de `LoroDoc` (el handle + nativo queda encapsulado, como el resto del binding). +- ABI del shim Loro **v1→v2** (aditivo); `mem_asan.rs` actualiza su assert de ABI + prueba los 3 probes bajo + ASan (reachability + sin fugas + sin mutación del caller). +- El shim Loro no tenía header (a diferencia de yrs); se **crea** `weft_loro_ffi.h`. El test automatizado de + paridad header↔binding es **FU-017** (el shim yrs lo tiene; el Loro aún no). + +## Approval + +**Approved**: 2026-07-15 por `Jose Villaseñor Montfort`, en revisión interactiva. El operador autorizó la +ejecución continua de CHARTER-10 y el alcance demostrativo de los probes (declarado ex-ante en el Charter +§Context/§Scope). Compañero de AILOG-2026-07-15-002. diff --git a/.straymark/charters/10-superficie-inativeversioning-de-loro-probes.md b/.straymark/charters/10-superficie-inativeversioning-de-loro-probes.md new file mode 100644 index 0000000..d6edb65 --- /dev/null +++ b/.straymark/charters/10-superficie-inativeversioning-de-loro-probes.md @@ -0,0 +1,164 @@ +--- +charter_id: CHARTER-10-superficie-inativeversioning-de-loro-probes +status: in-progress +effort_estimate: M +trigger: "FU-006 (backlog, charter-triggered): implementar la superficie opcional INativeVersioning de Loro (probes nativos), diferida en CHARTER-02 (auditoría G1). Disparado por decisión del operador (2026-07-15) de despacharla tras CHARTER-09. Cierra G1: LoroEngine.NativeVersioning pasa de null a una implementación real." +originating_spec: specs/001-weft-crdt-versioning/spec.md +work_verb: implement +design_provenance: new +--- + +# Charter: Superficie INativeVersioning de Loro — probes nativos diff/fork/shallow (cierra G1) + +> **Status (mirrored from frontmatter — source of truth is above):** declared. Effort: M. +> +> **Origin:** Follow-up **FU-006** (auditoría externa CHARTER-02, hallazgo **G1**), sobre la spec 001 +> (constitución **P-IV** abstracción de motor viva). Materializa la capacidad **opcional** `INativeVersioning` +> para el motor **Loro**, diferida a post-M0. Ningún gate depende de ella. + +## Context + +`INativeVersioning` (`src/Weft.Core/Abstractions/INativeVersioning.cs`) es una capacidad **opcional** que un motor +CRDT puede exponer si tiene versionado nativo — tres probes de paridad: `NativeDiffProbe(doc, field) → string` +(JSON), `NativeBranchMergeProbe(doc, field) → string` (JSON) y `ShallowSnapshot(doc) → byte[]`. yrs **no** tiene +versionado nativo (`YrsEngine.NativeVersioning == null`, permanente); **Loro sí** (`fork`/`fork_at`, `diff`, +`ExportMode::shallow_snapshot`), pero la superficie se **difirió** en CHARTER-02 (auditoría G1) → hoy +`LoroEngine.NativeVersioning == null` y el quickstart §US5 lo documenta como diferido. + +Este Charter cierra G1: implementa los tres probes en el shim `weft-loro-ffi` (ABI **v1→v2**) + binding, hace +`LoroEngine.NativeVersioning` no-nulo, y reconcilia el quickstart. Los probes son **demostrativos** (exhiben la +capacidad nativa de Loro que el versionado engine-agnóstico de `Weft.Versioning` no usa), **no** un contrato de +content-addressing: sus bytes/JSON son informativos, no alimentan `VersionId` (que sigue usando `export_state` +determinista vía `all_updates`). No es una API de versionado nativo completa — es la superficie de paridad que G1 +pedía materializar; una API más rica sería un charter aparte si alguna vez se requiere. + +## Scope + +**In scope:** + +1. **Shim `weft-loro-ffi` — 3 probes nativos (ABI bump v1→v2):** + - `weft_loro_shallow_snapshot(doc, out_ptr, out_len)` → `doc.export(ExportMode::shallow_snapshot(&doc.state_frontiers()))` (snapshot GC'd al estado actual); bytes por `hand_out_buffer`. + - `weft_loro_native_diff_probe(doc, field, field_len, out_ptr, out_len)` → JSON que describe `doc.diff(Frontiers::default(), doc.state_frontiers())` para el campo (containers cambiados / resumen del diff del texto). + - `weft_loro_native_branch_merge_probe(doc, field, field_len, out_ptr, out_len)` → JSON de un ciclo fork→editar→merge (`fork()` + op en el fork + `import` de vuelta), reportando frontiers antes/después y convergencia. + - Incrementa `WEFT_ABI_VERSION` **1→2** en `lib.rs`; `catch_unwind` en cada entrada (P-I). +2. **Header `include/weft_loro_ffi.h` (NEW)**: crear el header C del shim Loro (no existía) — declara TODAS las + funciones (ciclo de vida, texto, estado, memoria, diagnóstico) + las 3 nuevas. Paridad con `weft_ffi.h` (yrs). +3. **Binding .NET**: 3 P/Invokes en `Interop/NativeMethods.cs`; `ExpectedAbiVersion` **1→2** en + `Interop/NativeLibraryResolver.cs`; 3 métodos `internal` en `LoroDoc.cs` (delegan al FFI vía `HandleLease`, + encapsulando el handle como el resto). +4. **`LoroNativeVersioning.cs` (NEW)**: implementa `INativeVersioning` — castea `ICrdtDoc → LoroDoc` (excepción + clara si se pasa un doc no-Loro) y delega en los métodos de `LoroDoc`. +5. **`LoroEngine.NativeVersioning`**: de `null` a una instancia de `LoroNativeVersioning` (singleton sin estado). +6. **Tests** (`tests/Weft.Versioning.Tests/LoroNativeVersioningTests.cs`, NEW): los 3 probes ejercitados — + `ShallowSnapshot` no-vacío y recargable (`LoadDoc` round-trip); `NativeDiffProbe` refleja ediciones (JSON no + trivial tras insertar); `NativeBranchMergeProbe` reporta convergencia; guard de doc no-Loro lanza. +7. **Reconciliar quickstart §US5**: `LoroEngine.NativeVersioning` ya **no** es null; actualizar la nota de + diferimiento (G1 cerrado). +8. **Backlog**: FU-006 → `closed`. + +**Out of scope:** + +- **API de versionado nativo completa** (branches con nombre, time-travel, checkout persistente) — los probes son + demostrativos; una API rica sería un charter aparte, sin demanda hoy. +- **Content-addressing sobre los bytes de los probes**: `VersionId` sigue con `export_state` determinista + (`all_updates`); los probes NO son deterministas byte a byte (el shallow snapshot lleva metadata de réplica). +- **`INativeVersioning` para yrs**: yrs no tiene versionado nativo → `YrsEngine.NativeVersioning == null` permanente. +- **Test de paridad header↔binding para el shim Loro**: el shim yrs lo tiene, el Loro no; crear el header aquí, el + test de paridad automatizado es un FU aparte si se quiere (no bloquea). + +## Files to modify + + + +| File | Change | +|---|---| +| `native/weft-loro-ffi/src/lib.rs` | 3 probes (shallow_snapshot, native_diff_probe, native_branch_merge_probe) + `WEFT_ABI_VERSION` 1→2 | +| `native/weft-loro-ffi/include/weft_loro_ffi.h` | New — header C del shim Loro (todas las fns + las 3 nuevas) | +| `native/weft-loro-ffi/tests/mem_asan.rs` | ABI assert 1→2 + test de los 3 probes bajo ASan (reachability + sin fugas + no muta el caller) | +| `src/Weft.Loro/Interop/NativeMethods.cs` | 3 P/Invokes | +| `src/Weft.Loro/Interop/NativeLibraryResolver.cs` | `ExpectedAbiVersion` 1→2 | +| `src/Weft.Loro/LoroDoc.cs` | 3 métodos `internal` (delegan al FFI vía `HandleLease`) | +| `src/Weft.Loro/LoroNativeVersioning.cs` | New — implementa `INativeVersioning` (cast ICrdtDoc→LoroDoc + delega) | +| `src/Weft.Loro/LoroEngine.cs` | `NativeVersioning` => instancia de `LoroNativeVersioning` (era `null`) | +| `tests/Weft.Versioning.Tests/LoroNativeVersioningTests.cs` | New — tests de los 3 probes + guard de doc no-Loro | +| `specs/001-weft-crdt-versioning/quickstart.md` | Reconciliar §US5 (NativeVersioning ya no null; G1 cerrado) | +| `.straymark/follow-ups-backlog.md` | FU-006 → `closed` | +| `.straymark/07-ai-audit/agent-logs/AILOG-*.md` | New, `risk_level: medium` (frontera FFI Loro, ABI bump) | +| `.straymark/07-ai-audit/decisions/AIDEC-*.md` | New — forma/semántica de los 3 probes (JSON + shallow) | + +## Verification + +### Local checks + +```bash +# Shim Loro: compila + símbolos exportados + ABI v2 +cd native/weft-loro-ffi && cargo build --release && cargo test +nm -D ../target/release/libweft_loro_ffi.so | grep -E "weft_loro_shallow_snapshot|native_diff_probe|native_branch_merge_probe" + +# Suite .NET completa incl. los tests de los probes nativos de Loro +cd ../.. && dotnet test Weft.sln -c Release # LoroNativeVersioningTests + dual-engine intactos +``` + +### Production smoke (after deploy) + +No aplica — librería sin despliegue. Los auditores externos deben saltar esta sección. + +## Risks + +- **R1 — la API nativa de Loro difiere de lo asumido** (firma exacta de `ExportMode::shallow_snapshot`, forma de + `DiffBatch`): severidad **media**. Mitigación: `fork`/`fork_at`/`diff`/`export`/`state_frontiers` confirmados en + loro 1.13.6 por código; el constructor exacto de `ExportMode::shallow_snapshot` se resuelve en la implementación. + Si un probe no se expresa limpio, documentar la limitación honesta (es un probe, no un contrato) — no forzar. +- **R2 — los bytes/JSON de los probes NO son deterministas**: severidad **baja**. El shallow snapshot lleva + metadata de réplica (peer-ids, orden interno), como el `Snapshot` que `export_state` evita a propósito. Los + probes son **demostrativos**, no content-addressing → NO alimentan `VersionId`. Los tests asertan + round-trip/reachability, NO byte-determinismo. Documentado en el AIDEC y en el docstring. +- **R3 — el ABI bump v1→v2 del shim Loro rompe consumidores**: severidad **baja**. Bump atómico Rust + (`WEFT_ABI_VERSION`) + .NET (`ExpectedAbiVersion`) en el mismo PR; exports aditivos; desalineación → + `NativeLibraryResolver` lanza explícito. Nota: el shim Loro no tiene test de paridad header↔binding (a diferencia + de yrs); se crea el header, el test automatizado es un FU si se quiere. +- **R4 — el cast `ICrdtDoc → LoroDoc` falla con un doc de otro motor**: severidad **baja**. Mitigación: + `LoroNativeVersioning` solo es alcanzable vía `LoroEngine.NativeVersioning`; el cast lanza una excepción clara + (`ArgumentException`) si se le pasa un doc no-Loro. Test cubre el guard. + +## Tasks + +1. Sync main, branch `charter/10-loro-native-versioning` (**ya creada**). Flip `declared` → `in-progress`. +2. Re-evaluar **Constitution Check**: **P-IV** (capacidad opcional del motor, alcance Loro), **P-I** (catch_unwind + en las 3 entradas nuevas), **P-II** (buffers vía `hand_out_buffer`/`weft_loro_buf_free`). Sin violaciones. +3. **(1)** FFI: los 3 probes + ABI bump + `cargo test`; verificar símbolos exportados. +4. **(2)** Header `weft_loro_ffi.h` (NEW, todas las fns). +5. **(3)** Binding: NativeMethods + `ExpectedAbiVersion` 2 + métodos internal en `LoroDoc`. +6. **(4,5)** `LoroNativeVersioning.cs` + `LoroEngine.NativeVersioning` no-nulo. +7. **(6)** Tests de los 3 probes + guard. **(7)** Reconciliar quickstart §US5. +8. **(8)** Backlog: FU-006 → `closed` + `recount`. +9. **AILOG** (`risk_level: medium`, `review_required: true`) + **AIDEC** (forma de los probes). Verificación local. +10. `straymark charter drift CHARTER-10` (posible FP del parser #354 en `.h`/`.rs`/`.cs`). Commit + push + PR; + CI verde. + +## Charter Closure + +**No cierra hito** (capacidad opcional; sin auditoría externa multi-modelo). Al cerrar: + +1. **Atomic update (format v4)**: si el drift reveló divergencias (p. ej. un probe no expresable → limitación + documentada), edita `## Scope`/`## Files to modify` + `## Closing notes` en el mismo PR. +2. `straymark charter drift CHARTER-10 --range origin/main..HEAD` → limpio o documentado (incl. FP del parser #354). +3. `straymark charter close CHARTER-10` (telemetría). +4. **No borrar** este archivo. +5. Backlog: **FU-006 `closed`**. Restan open: FU-010 (durabilidad relay), FU-015 (adopción R6 bump), FU-016 (Loro + client-id cross-engine), FU-017 (test paridad header↔binding Loro). Para cerrar **M3**: publish real + operador-gated + Polish (T061–T063). + +## Closing notes + +Drift (`origin/main..HEAD`) reportó 1 archivo modificado no declarado; reconciliado atómicamente (format v4): + +- `native/weft-loro-ffi/tests/mem_asan.rs` — **añadido a la tabla**. Consecuencia del ABI bump (assert + `weft_loro_abi_version() == 1` → `2`) + un test de los 3 probes bajo ASan (reachability + sin fugas + que el + branch/merge NO muta el doc del caller). Ref: AILOG-2026-07-15-002 §Verification. diff --git a/.straymark/follow-ups-backlog.md b/.straymark/follow-ups-backlog.md index 9b50044..fb02a11 100644 --- a/.straymark/follow-ups-backlog.md +++ b/.straymark/follow-ups-backlog.md @@ -3,7 +3,7 @@ last_scan: 2026-07-15 schema_version: v1 total_open: 4 total_promoted: 0 -total_closed_in_session: 12 +total_closed_in_session: 13 total_phase_blocked: 0 total_suspected_closed: 0 buckets: @@ -17,6 +17,7 @@ fully_extracted_ailogs: - AILOG-2026-07-10-002 - AILOG-2026-07-14-002 - AILOG-2026-07-15-001 + - AILOG-2026-07-15-002 --- # Follow-ups Backlog @@ -92,6 +93,15 @@ fully_extracted_ailogs: - **Cost**: XS - **Notes**: Corregido a `ExportMode::all_updates()` + AIDEC-2026-07-10-001 (aprobado). No es follow-up pendiente. +### FU-017 — test de paridad header↔binding para el shim Loro +- **Origin**: AILOG-2026-07-15-002 §Follow-ups · CHARTER-10 (se creó el header, sin test de paridad) +- **Source-hash**: be38c88a4e9c +- **Status**: open +- **Trigger**: ready +- **Destination**: chore +- **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. + ## Bucket: time-triggered ## Bucket: charter-triggered @@ -125,11 +135,11 @@ fully_extracted_ailogs: ### FU-006 — G1: implementar la superficie `INativeVersioning` de Loro (diferida) - **Origin**: AILOG-2026-07-10-002 §Follow-ups (auditoría G1, gpt-5-5 + qwen3-7-max) · review.md §4 · Charter-02 Closing notes -- **Status**: open +- **Status**: closed - **Trigger**: when se requiera versionado nativo de Loro (probes de paridad) - **Destination**: mini-charter - **Cost**: M -- **Notes**: Diferido en CHARTER-02. Implementar probes `native_diff`/`native_branch`/`shallow_snapshot` en `weft-loro-ffi` + header `include/weft_loro_ffi.h` + `LoroNativeVersioning.cs` (`LoroEngine.NativeVersioning` pasaría de `null` a la implementación). Capacidad opcional; ningún gate depende. Reconciliar quickstart §US5 al implementarlo. +- **Notes**: Diferido en CHARTER-02. Implementar probes `native_diff`/`native_branch`/`shallow_snapshot` en `weft-loro-ffi` + header `include/weft_loro_ffi.h` + `LoroNativeVersioning.cs` (`LoroEngine.NativeVersioning` pasaría de `null` a la implementación). Capacidad opcional; ningún gate depende. Reconciliar quickstart §US5 al implementarlo. **CERRADO 2026-07-15 (CHARTER-10, AILOG-2026-07-15-002)**: los 3 probes en `weft-loro-ffi` (ABI v2) + header `weft_loro_ffi.h` (creado) + binding + `LoroNativeVersioning` (cast+delega) + `LoroEngine.NativeVersioning` no-nulo + `LoroNativeVersioningTests` (5/5). Probes demostrativos (no content-addressing). Quickstart §US5 reconciliado. FU-017 registrado (test de paridad header↔binding del shim Loro). ### FU-010 — endurecimiento de durabilidad del relay: persist-before-broadcast (opcional) - **Origin**: AIDEC-2026-07-13-001 §5 (CHARTER-05) · review.md F3 (auditoría gpt-5-5 + glm-5-2) diff --git a/native/weft-loro-ffi/include/weft_loro_ffi.h b/native/weft-loro-ffi/include/weft_loro_ffi.h new file mode 100644 index 0000000..6737f91 --- /dev/null +++ b/native/weft-loro-ffi/include/weft_loro_ffi.h @@ -0,0 +1,86 @@ +/* + * weft_loro_ffi.h — contrato C-ABI del shim `weft-loro-ffi` (Weft, Apache-2.0). + * + * Réplica de la ABI de `weft-yrs-ffi` con prefijo `weft_loro_`, mapeada sobre `loro`. Fuente de + * verdad del contrato de ownership. La ABI es propia y estable: un bump de `loro` cambia lib.rs, + * jamás este header sin incrementar weft_loro_abi_version(). + * + * ── Reglas transversales (no negociables) ────────────────────────────────────────────── + * 1. Panics: cada función envuelve su cuerpo en catch_unwind; un panic retorna + * WEFT_ERR_PANIC, jamás cruza la frontera (sería UB). + * 2. Ownership de buffers: + * - Salida (out_ptr/out_len): asignados por el shim (Box<[u8]>); el llamador los libera + * SOLO con weft_loro_buf_free(ptr, len), exactamente una vez, nunca con el GC/Marshal. + * - Entrada (ptr+len): prestados; el shim no toma posesión ni retiene el puntero. + * - WeftLoroDoc*: se libera SOLO con weft_loro_doc_free, exactamente una vez. + * 3. Thread-safety: LoroDoc ES Send+Sync (locking interno); aun así, el contrato de Weft serializa + * por documento (el broker es single-reader). weft_loro_buf_free es thread-safe. + * 4. Strings: entradas de texto son UTF-8 (ptr+len, sin NUL); UTF-8 inválido -> WEFT_ERR_UTF8. + * 5. Índices: uint32_t en UTF-16 code units (consistente con .NET/Yjs); fuera de rango -> + * WEFT_ERR_OUT_OF_BOUNDS. + * + * Postcondiciones: en error, los out-params quedan sin escribir; en éxito con contenido vacío, + * out_ptr puede ser válido con out_len == 0 (liberar igual). + */ +#ifndef WEFT_LORO_FFI_H +#define WEFT_LORO_FFI_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* Puntero opaco al documento CRDT de Loro. Se libera SOLO con weft_loro_doc_free. */ +typedef struct WeftLoroDoc WeftLoroDoc; + +/* ── Códigos de estado (int32_t, idénticos a weft-yrs-ffi) ─────────────────────────────── */ +#define WEFT_OK 0 +#define WEFT_ERR_NULL_ARG -1 /* puntero requerido nulo */ +#define WEFT_ERR_DECODE -2 /* blob/update no decodificable */ +#define WEFT_ERR_APPLY -3 /* fallo aplicando update */ +#define WEFT_ERR_UTF8 -4 /* texto de entrada no UTF-8 */ +#define WEFT_ERR_OUT_OF_BOUNDS -5 /* índice/longitud fuera de rango */ +#define WEFT_ERR_PANIC -127 /* panic capturado en el shim */ + +/* ── Ciclo de vida del documento ──────────────────────────────────────────────────────── */ +int32_t weft_loro_doc_new(WeftLoroDoc** out_doc); +int32_t weft_loro_doc_load(const uint8_t* blob, size_t blob_len, WeftLoroDoc** out_doc); +void weft_loro_doc_free(WeftLoroDoc* doc); + +/* ── Texto por campo nombrado (índices UTF-16) ────────────────────────────────────────── */ +int32_t weft_loro_text_insert(WeftLoroDoc* doc, const uint8_t* field, size_t field_len, + uint32_t index, const uint8_t* text, size_t text_len); +int32_t weft_loro_text_delete(WeftLoroDoc* doc, const uint8_t* field, size_t field_len, + uint32_t index, uint32_t len); +int32_t weft_loro_text_read(WeftLoroDoc* doc, const uint8_t* field, size_t field_len, + uint8_t** out_ptr, size_t* out_len); + +/* ── Estado y sincronización ──────────────────────────────────────────────────────────── */ +int32_t weft_loro_doc_export_state(WeftLoroDoc* doc, uint8_t** out_ptr, size_t* out_len); +int32_t weft_loro_doc_state_vector(WeftLoroDoc* doc, uint8_t** out_ptr, size_t* out_len); +int32_t weft_loro_doc_export_since(WeftLoroDoc* doc, const uint8_t* sv, size_t sv_len, + uint8_t** out_ptr, size_t* out_len); +int32_t weft_loro_doc_apply_update(WeftLoroDoc* doc, const uint8_t* update, size_t update_len); + +/* ── Versionado nativo (INativeVersioning, capacidad opcional — CHARTER-10/FU-006) ────── + * Probes DEMOSTRATIVOS de la capacidad nativa de Loro (diff/fork/shallow). Su salida NO es + * byte-determinista y NO alimenta VersionId (usar weft_loro_doc_export_state para eso). */ +int32_t weft_loro_shallow_snapshot(WeftLoroDoc* doc, uint8_t** out_ptr, size_t* out_len); +int32_t weft_loro_native_diff_probe(WeftLoroDoc* doc, const uint8_t* field, size_t field_len, + uint8_t** out_ptr, size_t* out_len); +int32_t weft_loro_native_branch_merge_probe(WeftLoroDoc* doc, const uint8_t* field, size_t field_len, + uint8_t** out_ptr, size_t* out_len); + +/* ── Memoria ──────────────────────────────────────────────────────────────────────────── */ +void weft_loro_buf_free(uint8_t* ptr, size_t len); + +/* ── Diagnóstico ──────────────────────────────────────────────────────────────────────── */ +uint32_t weft_loro_abi_version(void); + +#ifdef __cplusplus +} +#endif + +#endif /* WEFT_LORO_FFI_H */ diff --git a/native/weft-loro-ffi/src/lib.rs b/native/weft-loro-ffi/src/lib.rs index b10587d..094b766 100644 --- a/native/weft-loro-ffi/src/lib.rs +++ b/native/weft-loro-ffi/src/lib.rs @@ -8,7 +8,7 @@ use std::os::raw::c_uchar; use std::panic::{catch_unwind, AssertUnwindSafe}; -use loro::{ExportMode, LoroDoc, VersionVector}; +use loro::{ExportMode, Frontiers, LoroDoc, VersionVector}; // ── Códigos de estado (idénticos a weft-yrs-ffi) ── pub const WEFT_OK: i32 = 0; @@ -19,7 +19,7 @@ pub const WEFT_ERR_UTF8: i32 = -4; pub const WEFT_ERR_OUT_OF_BOUNDS: i32 = -5; pub const WEFT_ERR_PANIC: i32 = -127; -const WEFT_ABI_VERSION: u32 = 1; +const WEFT_ABI_VERSION: u32 = 2; fn guard i32>(f: F) -> i32 { match catch_unwind(AssertUnwindSafe(f)) { @@ -309,6 +309,140 @@ pub unsafe extern "C" fn weft_loro_doc_apply_update( }) } +// ── Versionado nativo (INativeVersioning, capacidad opcional — CHARTER-10/FU-006) ── +// +// Probes DEMOSTRATIVOS de la capacidad de versionado nativo de Loro (diff/fork/shallow snapshot) que +// yrs no tiene. NO son content-addressing: su salida NO es byte-determinista entre réplicas (el shallow +// snapshot y los frontiers llevan metadata de réplica) y NO alimenta VersionId (que usa export_state / +// all_updates). Exhiben que Loro PUEDE versionar nativamente; el JSON se arma a mano (sin serde). + +/// Escapa una cadena para incrustarla como valor JSON (comillas, backslash y controles). +fn json_escape(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 2); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)), + c => out.push(c), + } + } + out +} + +/// Snapshot **shallow** (GC'd al estado actual) del documento. Bytes opacos, NO deterministas — +/// probe de la capacidad nativa, no un blob citable. Liberar con `weft_loro_buf_free`. +/// +/// # Safety +/// `out_ptr`/`out_len` escribibles no nulos; `doc` válido. +#[no_mangle] +pub unsafe extern "C" fn weft_loro_shallow_snapshot( + doc: *mut LoroDoc, + out_ptr: *mut *mut c_uchar, + out_len: *mut usize, +) -> i32 { + guard(|| { + let Some(doc) = doc_ref(doc) else { + return WEFT_ERR_NULL_ARG; + }; + doc.commit(); + match doc.export(ExportMode::shallow_snapshot(&doc.state_frontiers())) { + Ok(bytes) => hand_out_buffer(bytes, out_ptr, out_len), + Err(_) => WEFT_ERR_APPLY, + } + }) +} + +/// Probe del **diff nativo**: describe (JSON) el diff de Loro entre frontiers vacíos y el estado +/// actual para el campo dado (nº de containers cambiados + longitud del texto). Demostrativo. +/// +/// # Safety +/// Punteros válidos por sus longitudes; `doc` válido. +#[no_mangle] +pub unsafe extern "C" fn weft_loro_native_diff_probe( + doc: *mut LoroDoc, + field: *const c_uchar, + field_len: usize, + out_ptr: *mut *mut c_uchar, + out_len: *mut usize, +) -> i32 { + guard(|| { + let Some(doc) = doc_ref(doc) else { + return WEFT_ERR_NULL_ARG; + }; + let Some(field) = borrow_str(field, field_len) else { + return WEFT_ERR_UTF8; + }; + doc.commit(); + let batch = match doc.diff(&Frontiers::default(), &doc.state_frontiers()) { + Ok(b) => b, + Err(_) => return WEFT_ERR_APPLY, + }; + let containers_changed = batch.iter().count(); + let text_len_utf16 = doc.get_text(field).len_utf16(); + let json = format!( + "{{\"field\":\"{}\",\"containers_changed\":{},\"text_len_utf16\":{}}}", + json_escape(field), + containers_changed, + text_len_utf16 + ); + hand_out_buffer(json.into_bytes(), out_ptr, out_len) + }) +} + +/// Probe de **fork/merge nativo**: forkea el doc, edita el fork, y lo mergea (import) en una copia +/// aparte — SIN mutar el `doc` del caller — reportando (JSON) si el merge convergió. Demostrativo. +/// +/// # Safety +/// Punteros válidos por sus longitudes; `doc` válido. +#[no_mangle] +pub unsafe extern "C" fn weft_loro_native_branch_merge_probe( + doc: *mut LoroDoc, + field: *const c_uchar, + field_len: usize, + out_ptr: *mut *mut c_uchar, + out_len: *mut usize, +) -> i32 { + guard(|| { + let Some(doc) = doc_ref(doc) else { + return WEFT_ERR_NULL_ARG; + }; + let Some(field) = borrow_str(field, field_len) else { + return WEFT_ERR_UTF8; + }; + doc.commit(); + + // Branch: fork + edición local (marcador ① fuera del texto del caller). + const MARK: &str = "\u{2460}"; + let branch = doc.fork(); + if branch.get_text(field).insert_utf16(0, MARK).is_err() { + return WEFT_ERR_APPLY; + } + branch.commit(); + + // Merge: importar la edición del branch en una copia independiente del doc (no toca al caller). + let target = doc.fork(); + let Ok(update) = branch.export(ExportMode::all_updates()) else { + return WEFT_ERR_APPLY; + }; + if target.import(&update).is_err() { + return WEFT_ERR_APPLY; + } + target.commit(); + + let converged = target.get_text(field).to_string().contains(MARK); + let json = format!( + "{{\"field\":\"{}\",\"forked\":true,\"merged\":true,\"converged\":{}}}", + json_escape(field), + converged + ); + hand_out_buffer(json.into_bytes(), out_ptr, out_len) + }) +} + // ── Memoria ── /// # Safety diff --git a/native/weft-loro-ffi/tests/mem_asan.rs b/native/weft-loro-ffi/tests/mem_asan.rs index af1a071..430afc4 100644 --- a/native/weft-loro-ffi/tests/mem_asan.rs +++ b/native/weft-loro-ffi/tests/mem_asan.rs @@ -174,7 +174,62 @@ fn stress_all_functions_2000_iterations() { weft_loro_doc_free(reloaded); weft_loro_doc_free(doc); } - assert_eq!(weft_loro_abi_version(), 1); + assert_eq!(weft_loro_abi_version(), 2); // ABI v2: + probes de versionado nativo (CHARTER-10) + } +} + +/// Probes nativos (INativeVersioning, FU-006): reachability + sin fugas (ASan) + sin mutar el caller. +#[test] +fn native_versioning_probes_reachable_and_nonleaking() { + unsafe { + let field = b"body"; + let text = "hola".as_bytes(); + + let mut doc: *mut LoroDoc = ptr::null_mut(); + assert_eq!(weft_loro_doc_new(&mut doc), WEFT_OK); + weft_loro_text_insert(doc, field.as_ptr(), field.len(), 0, text.as_ptr(), text.len()); + + // Shallow snapshot: no vacío, recargable. + let (mut sp, mut sl) = (ptr::null_mut(), 0usize); + assert_eq!(weft_loro_shallow_snapshot(doc, &mut sp, &mut sl), WEFT_OK); + assert!(sl > 0); + let mut reloaded: *mut LoroDoc = ptr::null_mut(); + assert_eq!( + weft_loro_doc_load(sp, sl, &mut reloaded), + WEFT_OK, + "el shallow snapshot debe ser recargable" + ); + weft_loro_doc_free(reloaded); + weft_loro_buf_free(sp, sl); + + // Diff probe: JSON con el campo y el conteo. + let (mut dp, mut dl) = (ptr::null_mut(), 0usize); + assert_eq!( + weft_loro_native_diff_probe(doc, field.as_ptr(), field.len(), &mut dp, &mut dl), + WEFT_OK + ); + let diff_json = std::str::from_utf8(std::slice::from_raw_parts(dp, dl)).unwrap(); + assert!(diff_json.contains("\"containers_changed\"")); + weft_loro_buf_free(dp, dl); + + // Branch/merge probe: reporta convergencia y NO muta el doc del caller. + let (mut bp, mut bl) = (ptr::null_mut(), 0usize); + assert_eq!( + weft_loro_native_branch_merge_probe(doc, field.as_ptr(), field.len(), &mut bp, &mut bl), + WEFT_OK + ); + let branch_json = std::str::from_utf8(std::slice::from_raw_parts(bp, bl)).unwrap(); + assert!(branch_json.contains("\"converged\":true"), "el merge nativo debe converger"); + weft_loro_buf_free(bp, bl); + + // El texto del caller sigue intacto (el probe forkea, no muta el original). + let (mut rp, mut rl) = (ptr::null_mut(), 0usize); + weft_loro_text_read(doc, field.as_ptr(), field.len(), &mut rp, &mut rl); + let caller_text = std::str::from_utf8(std::slice::from_raw_parts(rp, rl)).unwrap(); + assert_eq!(caller_text, "hola", "el probe no debe mutar el doc del caller"); + weft_loro_buf_free(rp, rl); + + weft_loro_doc_free(doc); } } diff --git a/specs/001-weft-crdt-versioning/quickstart.md b/specs/001-weft-crdt-versioning/quickstart.md index e706602..1f58c67 100644 --- a/specs/001-weft-crdt-versioning/quickstart.md +++ b/specs/001-weft-crdt-versioning/quickstart.md @@ -84,9 +84,12 @@ dotnet test tests/Weft.Versioning.Tests -c Release # theory: YrsEngine Y Loro ``` **Esperado**: la MISMA suite de versionado verde sobre ambos motores (SC-008); -`YrsEngine.NativeVersioning == null` sin romper ningún flujo. **Nota (M0)**: la superficie -`INativeVersioning` de Loro (probes nativos) está **diferida a post-M0** (auditoría CHARTER-02, G1) — -`LoroEngine.NativeVersioning == null` en M0; es capacidad opcional y ningún gate depende de ella. +`YrsEngine.NativeVersioning == null` sin romper ningún flujo. **Superficie `INativeVersioning` de Loro +(G1 CERRADO, CHARTER-10/FU-006)**: `LoroEngine.NativeVersioning` ya **no** es null — expone tres probes +**demostrativos** del versionado nativo de Loro (`ShallowSnapshot`, `NativeDiffProbe`, +`NativeBranchMergeProbe`; `LoroNativeVersioningTests`). Son opcionales y **no** content-addressing: su +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) diff --git a/src/Weft.Loro/Interop/NativeLibraryResolver.cs b/src/Weft.Loro/Interop/NativeLibraryResolver.cs index 000caf5..8ae18e0 100644 --- a/src/Weft.Loro/Interop/NativeLibraryResolver.cs +++ b/src/Weft.Loro/Interop/NativeLibraryResolver.cs @@ -7,7 +7,8 @@ namespace Weft.Loro.Interop; /// Resuelve el cdylib weft_loro_ffi por RID y verifica su ABI (igual que Weft.Yrs). internal static class NativeLibraryResolver { - private const uint ExpectedAbiVersion = 1; + // ABI v2 (CHARTER-10): + probes de versionado nativo (INativeVersioning, FU-006). + private const uint ExpectedAbiVersion = 2; private static int _registered; [System.Diagnostics.CodeAnalysis.SuppressMessage( diff --git a/src/Weft.Loro/Interop/NativeMethods.cs b/src/Weft.Loro/Interop/NativeMethods.cs index 1905bb5..b024d0b 100644 --- a/src/Weft.Loro/Interop/NativeMethods.cs +++ b/src/Weft.Loro/Interop/NativeMethods.cs @@ -42,6 +42,18 @@ internal static partial int weft_loro_doc_export_since( [LibraryImport(Lib)] internal static partial int weft_loro_doc_apply_update(nint doc, ReadOnlySpan update, nuint updateLen); + // ── Versionado nativo (INativeVersioning, capacidad opcional — CHARTER-10/FU-006) ── + [LibraryImport(Lib)] + internal static partial int weft_loro_shallow_snapshot(nint doc, out nint outPtr, out nuint outLen); + + [LibraryImport(Lib)] + internal static partial int weft_loro_native_diff_probe( + nint doc, ReadOnlySpan field, nuint fieldLen, out nint outPtr, out nuint outLen); + + [LibraryImport(Lib)] + internal static partial int weft_loro_native_branch_merge_probe( + nint doc, ReadOnlySpan field, nuint fieldLen, out nint outPtr, out nuint outLen); + [LibraryImport(Lib)] internal static partial void weft_loro_buf_free(nint ptr, nuint len); diff --git a/src/Weft.Loro/LoroDoc.cs b/src/Weft.Loro/LoroDoc.cs index d44ac52..ccccfb9 100644 --- a/src/Weft.Loro/LoroDoc.cs +++ b/src/Weft.Loro/LoroDoc.cs @@ -93,6 +93,38 @@ public void ApplyUpdate(ReadOnlySpan update) FfiStatus.ThrowIfError(NativeMethods.weft_loro_doc_apply_update(lease.Ptr, update, (nuint)update.Length)); } + // ── Versionado nativo (INativeVersioning vía LoroNativeVersioning — CHARTER-10/FU-006) ── + // Probes DEMOSTRATIVOS de la capacidad nativa de Loro. Su salida NO es determinista y NO alimenta + // VersionId (usar ExportState para eso). No mutan este documento (el branch/merge forkea aparte). + + internal byte[] ShallowSnapshotNative() + { + ThrowIfDisposed(); + using var lease = new HandleLease(_handle); + FfiStatus.ThrowIfError(NativeMethods.weft_loro_shallow_snapshot(lease.Ptr, out nint ptr, out nuint len)); + return TakeOwnedBuffer(ptr, len); + } + + internal string NativeDiffProbeJson(string field) + { + ArgumentException.ThrowIfNullOrEmpty(field); + ThrowIfDisposed(); + byte[] f = Encoding.UTF8.GetBytes(field); + using var lease = new HandleLease(_handle); + FfiStatus.ThrowIfError(NativeMethods.weft_loro_native_diff_probe(lease.Ptr, f, (nuint)f.Length, out nint ptr, out nuint len)); + return Encoding.UTF8.GetString(TakeOwnedBuffer(ptr, len)); + } + + internal string NativeBranchMergeProbeJson(string field) + { + ArgumentException.ThrowIfNullOrEmpty(field); + ThrowIfDisposed(); + byte[] f = Encoding.UTF8.GetBytes(field); + using var lease = new HandleLease(_handle); + FfiStatus.ThrowIfError(NativeMethods.weft_loro_native_branch_merge_probe(lease.Ptr, f, (nuint)f.Length, out nint ptr, out nuint len)); + return Encoding.UTF8.GetString(TakeOwnedBuffer(ptr, len)); + } + public void Dispose() => _handle.Dispose(); private void ThrowIfDisposed() => ObjectDisposedException.ThrowIf(_handle.IsClosed, this); diff --git a/src/Weft.Loro/LoroEngine.cs b/src/Weft.Loro/LoroEngine.cs index 194d20a..ae93643 100644 --- a/src/Weft.Loro/LoroEngine.cs +++ b/src/Weft.Loro/LoroEngine.cs @@ -20,11 +20,12 @@ private LoroEngine() { } /// /// - /// Loro ofrece versionado nativo (diff/branch/shallow-snapshot); esas capacidades se exponen como - /// opcional en una iteración posterior. El versionado del núcleo - /// (content-addressed, engine-agnóstico) no depende de ellas. + /// Loro ofrece versionado nativo (diff/branch/shallow-snapshot); expuesto como + /// opcional vía probes demostrativos (CHARTER-10/FU-006). El + /// versionado del núcleo (content-addressed, engine-agnóstico) NO depende de estos probes; su salida + /// no es determinista y no alimenta VersionId. /// - public INativeVersioning? NativeVersioning => null; + public INativeVersioning? NativeVersioning => LoroNativeVersioning.Instance; /// public ICrdtDoc CreateDoc() => LoroDoc.Create(); diff --git a/src/Weft.Loro/LoroNativeVersioning.cs b/src/Weft.Loro/LoroNativeVersioning.cs new file mode 100644 index 0000000..92dc888 --- /dev/null +++ b/src/Weft.Loro/LoroNativeVersioning.cs @@ -0,0 +1,35 @@ +namespace Weft.Loro; + +/// +/// Implementación de para Loro (CHARTER-10/FU-006). Probes +/// demostrativos de la capacidad de versionado nativo de Loro (diff/fork/shallow snapshot), +/// que yrs no tiene. No son content-addressing: su salida no es byte-determinista entre +/// réplicas y no alimenta VersionId (que usa el export determinista de ICrdtDoc.ExportState). +/// Sin estado; se expone como singleton vía . +/// +internal sealed class LoroNativeVersioning : INativeVersioning +{ + internal static readonly LoroNativeVersioning Instance = new(); + + private LoroNativeVersioning() { } + + /// + public string NativeDiffProbe(ICrdtDoc doc, string field) => AsLoro(doc).NativeDiffProbeJson(field); + + /// + public string NativeBranchMergeProbe(ICrdtDoc doc, string field) => AsLoro(doc).NativeBranchMergeProbeJson(field); + + /// + public byte[] ShallowSnapshot(ICrdtDoc doc) => AsLoro(doc).ShallowSnapshotNative(); + + // El versionado nativo de Loro solo opera sobre documentos de Loro. Un doc de otro motor + // (p. ej. yrs) es un error de uso claro, no un fallo en la frontera nativa. + private static LoroDoc AsLoro(ICrdtDoc doc) + { + ArgumentNullException.ThrowIfNull(doc); + return doc as LoroDoc + ?? throw new ArgumentException( + $"El versionado nativo de Loro requiere un documento de Loro, no '{doc.GetType().Name}' (motor '{doc.EngineName}').", + nameof(doc)); + } +} diff --git a/tests/Weft.Versioning.Tests/LoroNativeVersioningTests.cs b/tests/Weft.Versioning.Tests/LoroNativeVersioningTests.cs new file mode 100644 index 0000000..d9756e6 --- /dev/null +++ b/tests/Weft.Versioning.Tests/LoroNativeVersioningTests.cs @@ -0,0 +1,74 @@ +using System.Text.Json; +using Weft; +using Weft.Loro; +using Weft.Yrs; + +namespace Weft.Versioning.Tests; + +/// +/// Superficie opcional de Loro (CHARTER-10/FU-006, hallazgo G1): +/// LoroEngine expone probes DEMOSTRATIVOS del versionado nativo (diff/fork/shallow snapshot). No son +/// content-addressing (no deterministas, no alimentan VersionId); estos tests asertan reachability, +/// round-trip, convergencia del merge nativo, y que los probes NO mutan el doc del caller. +/// +public sealed class LoroNativeVersioningTests +{ + private static INativeVersioning Native => + LoroEngine.Instance.NativeVersioning + ?? throw new Xunit.Sdk.XunitException("LoroEngine.NativeVersioning no debe ser null (G1 cerrado)."); + + [Fact] + public void Yrs_engine_has_no_native_versioning() + { + // Contraste: yrs no tiene versionado nativo → la capacidad opcional es null (permanente). + Assert.Null(YrsEngine.Instance.NativeVersioning); + } + + [Fact] + public void ShallowSnapshot_is_nonempty_and_reloadable() + { + using ICrdtDoc doc = LoroEngine.Instance.CreateDoc(); + doc.InsertText("body", 0, "contenido áéí 🦀"); + + byte[] snapshot = Native.ShallowSnapshot(doc); + Assert.NotEmpty(snapshot); + + using ICrdtDoc reloaded = LoroEngine.Instance.LoadDoc(snapshot); + Assert.Equal("contenido áéí 🦀", reloaded.GetText("body")); + } + + [Fact] + public void NativeDiffProbe_reflects_edits_as_json() + { + using ICrdtDoc doc = LoroEngine.Instance.CreateDoc(); + doc.InsertText("body", 0, "hola mundo"); + + using JsonDocument json = JsonDocument.Parse(Native.NativeDiffProbe(doc, "body")); + JsonElement root = json.RootElement; + Assert.Equal("body", root.GetProperty("field").GetString()); + Assert.True(root.GetProperty("containers_changed").GetInt32() >= 1, "tras editar debe haber ≥1 container en el diff"); + Assert.Equal(10, root.GetProperty("text_len_utf16").GetInt32()); + } + + [Fact] + public void NativeBranchMergeProbe_converges_and_does_not_mutate_caller() + { + using ICrdtDoc doc = LoroEngine.Instance.CreateDoc(); + doc.InsertText("body", 0, "base"); + + using JsonDocument json = JsonDocument.Parse(Native.NativeBranchMergeProbe(doc, "body")); + Assert.True(json.RootElement.GetProperty("converged").GetBoolean(), "el merge nativo debe converger"); + + // El probe forkea aparte: el doc del caller no cambia. + Assert.Equal("base", doc.GetText("body")); + } + + [Fact] + public void Probes_reject_a_non_loro_doc() + { + using ICrdtDoc yrsDoc = YrsEngine.Instance.CreateDoc(); + Assert.Throws(() => Native.ShallowSnapshot(yrsDoc)); + Assert.Throws(() => Native.NativeDiffProbe(yrsDoc, "body")); + Assert.Throws(() => Native.NativeBranchMergeProbe(yrsDoc, "body")); + } +} From 508071a8b544c78cedfbc5ff43b823622bd4a274 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 12:31:07 -0600 Subject: [PATCH 2/2] =?UTF-8?q?chore(charter):=20cerrar=20CHARTER-10=20?= =?UTF-8?q?=E2=80=94=20INativeVersioning=20de=20Loro=20(FU-006/G1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Telemetría de cierre validada + status → closed. Los 8 entregables confirmados; LoroEngine.NativeVersioning no-nulo con 3 probes demostrativos. FU-006 closed, FU-017 registrado (test paridad header↔binding Loro). No cierra hito. CI del PR #26 verde. Verificación: dotnet 131/131, cargo 5/5. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...rficie-inativeversioning-de-loro-probes.md | 5 +- .straymark/charters/CHARTER-10.telemetry.yaml | 92 +++++++++++++++++++ 2 files changed, 95 insertions(+), 2 deletions(-) create mode 100644 .straymark/charters/CHARTER-10.telemetry.yaml diff --git a/.straymark/charters/10-superficie-inativeversioning-de-loro-probes.md b/.straymark/charters/10-superficie-inativeversioning-de-loro-probes.md index d6edb65..d4ca755 100644 --- a/.straymark/charters/10-superficie-inativeversioning-de-loro-probes.md +++ b/.straymark/charters/10-superficie-inativeversioning-de-loro-probes.md @@ -1,6 +1,7 @@ --- charter_id: CHARTER-10-superficie-inativeversioning-de-loro-probes -status: in-progress +status: closed +closed_at: 2026-07-15 effort_estimate: M trigger: "FU-006 (backlog, charter-triggered): implementar la superficie opcional INativeVersioning de Loro (probes nativos), diferida en CHARTER-02 (auditoría G1). Disparado por decisión del operador (2026-07-15) de despacharla tras CHARTER-09. Cierra G1: LoroEngine.NativeVersioning pasa de null a una implementación real." originating_spec: specs/001-weft-crdt-versioning/spec.md @@ -10,7 +11,7 @@ design_provenance: new # Charter: Superficie INativeVersioning de Loro — probes nativos diff/fork/shallow (cierra G1) -> **Status (mirrored from frontmatter — source of truth is above):** declared. Effort: M. +> **Status (mirrored from frontmatter — source of truth is above):** closed. Effort: M. > > **Origin:** Follow-up **FU-006** (auditoría externa CHARTER-02, hallazgo **G1**), sobre la spec 001 > (constitución **P-IV** abstracción de motor viva). Materializa la capacidad **opcional** `INativeVersioning` diff --git a/.straymark/charters/CHARTER-10.telemetry.yaml b/.straymark/charters/CHARTER-10.telemetry.yaml new file mode 100644 index 0000000..517c8f2 --- /dev/null +++ b/.straymark/charters/CHARTER-10.telemetry.yaml @@ -0,0 +1,92 @@ +# StrayMark Charter telemetry — fill at Charter close. +# +# Schema: .straymark/schemas/charter-telemetry.schema.v0.json +# Storage path: .straymark/charters/CHARTER-10.telemetry.yaml + +charter_telemetry: + # ---------- Identification ---------- + charter_id: "CHARTER-10" + charter_title: "Superficie INativeVersioning de Loro — probes nativos diff/fork/shallow (cierra G1)" + closed_at: "2026-07-15" + + # ---------- Origin & activation ---------- + originating_ailogs: + - ailog_id: "AILOG-2026-07-15-002" + still_relevant_at_execution: true + relevance_notes: "AILOG de ejecución. El Charter se originó de FU-006 (hallazgo G1 de la auditoría externa CHARTER-02, diferido a post-M0). La reconnaissance de declaración fue precisa y completa (todas las rutas + la API de loro 1.13.6 leídas antes de declarar)." + + trigger: + declared_kind: "event_trigger" + declared_description: "FU-006 (charter-triggered, G1): materializar la superficie opcional INativeVersioning de Loro. Decisión del operador (2026-07-15) de despacharla tras CHARTER-09." + fired_at: "2026-07-15" + fire_clarity: "manually_decided" + fire_clarity_notes: "El operador eligió despachar FU-006 ahora (siguiente en la secuencia de mini-charters) y autorizó ejecución continua." + + # ---------- 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ó el shim loro (lib.rs, hand_out_buffer, doc_ref, HandleLease), la ausencia de header, INativeVersioning (3 firmas), LoroEngine/LoroDoc/NativeMethods/resolver, y la API de loro 1.13.6 en el registry (fork/fork_at/diff/export/state_frontiers/ExportMode::shallow_snapshot). Sin paths asumidos; validate --include-charters verde (26 docs)." + + # ---------- 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 (~2h)" + actual_effort: "M (~2h)" + estimation_drift_factor: 1.0 + estimation_drift_reason: "Sin sorpresas. Los 3 probes compilaron al primer intento (la reconnaissance de la API de loro fue precisa); el ABI bump + el header + el binding + los tests fueron mecánicos. El único ajuste (esperado) fue el assert de ABI en mem_asan.rs, idéntico al patrón de CHARTER-09." + + # ---------- 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: capacidad opcional, no cierra hito → 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-loro-ffi/tests/mem_asan.rs (assert ABI 1->2 + test de los 3 probes bajo ASan) — consecuencia mecánica del ABI bump, no declarada al inicio, añadida a §Files to modify. Los 8 entregables se completaron como se planeó; ningún probe fue inexpresable (R1 resuelto)." + 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: + - "El shim Loro NO tenía header C (a diferencia de yrs), ni test de paridad header<->binding. Se creó el header en este Charter; el test automatizado de paridad quedó como FU-017 (deuda de simetría entre los dos shims que la reconnaissance destapó)." + - "El assert de ABI en mem_asan.rs (== 1) rompe con el bump a 2 — 2a vez del mismo patrón (idéntico a CHARTER-09 en el shim yrs). Consecuencia esperada, resuelta en línea." + - "loro expone `ExportMode::shallow_snapshot` desde loro-internal (re-export), no directamente en la raíz — la reconnaissance tuvo que seguir el re-export; sin impacto (se resolvió antes de escribir)." + wins: + - "Los 3 probes compilaron y pasaron al primer intento (reconnaissance de la API de loro 1.13.6 precisa: fork/diff/export/shallow_snapshot). R1 (API difiere) resuelto sin fricción." + - "El branch/merge probe NO muta el doc del caller (forkea DOS veces: branch para editar + target para mergear) — verificado por test .NET y por el test ASan del shim. Evita el anti-patrón de un probe con efecto secundario." + - "Alcance honesto: probes DEMOSTRATIVOS, no content-addressing (documentado en docstrings/header/quickstart/AIDEC). Cierra G1 sin prometer determinismo que Loro no da sobre el shallow snapshot." + - "JSON armado a mano con json_escape en vez de añadir serde_json al shim — mantiene mínima la superficie de la frontera nativa por una salida trivial (3-4 campos)." + overall_satisfaction: 5 + would_repeat_format: true + proposed_format_changes: "Ninguno nuevo. El patrón 'reconnaissance de la API del crate nativo en el registry antes de declarar' probó su valor otra vez (0 sorpresas de API). La deuda de simetría entre shims (header/test de paridad) sugiere un checklist de 'paridad yrs<->loro' al tocar cualquiera de los dos."