550c98f275
Reorganización física de crates/: - core/ (mezclaba 6 propósitos) se divide en protocol/, init/, runtime/, compat/ - shared/ (3 crates) se redistribuye en protocol/ e init/ - lapaloma (sub-módulo de ui_engine) se promueve a modules/pineal/ Renames de proyectos: - shipote → shuma (runtime de sandboxes) - nouser → akasha (explorador de Mónadas) - yahweh → nahual (motor GPUI, antes ui_engine/) - lapaloma → pineal (data-viz agnóstica) Fraccionamiento UI → core agnóstico: - vista-core (DeckState + snap, 175 LOC, 5 tests verdes) - barra-core (Task + render_html + sanitize, 90 LOC, 5 tests verdes) - vista-web y barra-web ahora son thin DOM bindings Documentación nueva: - 16 SDDs por subdirectorio (≤80 LOC c/u): protocol/init/runtime/compat + 10 módulos + apps/ - docs/STATUS.md con cifras reales por proyecto - docs/ROADMAP.md con plan a finalización (6 hitos, ~6-8 semanas) - CHANGELOG.md particionado en docs/changelog/<proyecto>.md (7 buckets) Automatización: - scripts/reorg.py — script idempotente que: git mv directorios, renombra package names, recomputa path = refs, reescribe imports rust, actualiza workspace Cargo.toml. Soporta --dry-run. - scripts/split-changelog.py — particiona CHANGELOG por componente. Validación: - cargo check --workspace pasa (124 crates + 2 nuevos cores). - 10 tests adicionales (5 en vista-core + 5 en barra-core) verdes. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1581 lines
75 KiB
Markdown
1581 lines
75 KiB
Markdown
# Changelog — protocol/
|
|
|
|
Contratos canónicos + routing entre módulos. Antes: `core/brahman-*` + `shared/brahman-*`.
|
|
|
|
### feat(brahman-demo): bootstrap script reproducible — broker + producer + consumer + 4 explorers
|
|
Iter 22. Cierra el set de iteraciones de hoy: cualquier persona (o
|
|
future-me retomando el repo) puede levantar el escenario completo
|
|
con un comando.
|
|
|
|
Crate nuevo `crates/apps/brahman-demo/` con 3 binarios:
|
|
- **`brahman-demo-broker`**: standalone `Server::bind` con un Broker
|
|
configurado, escucha en el socket default. Reemplaza a
|
|
`ente-zero` para fines de demo (ente-zero pesa kernel surface +
|
|
child subreaper + bus + brain + audit; el demo no lo necesita).
|
|
- **`brahman-demo-producer`**: registra una Card con `flow.output[demo-stream:json]`
|
|
y queda pingueando.
|
|
- **`brahman-demo-consumer`**: registra una Card con `flow.input[demo-feed:json]`
|
|
(mismo type → matchea con el producer) y queda escuchando
|
|
`MatchEvent`s.
|
|
|
|
Variables de entorno respetadas en los 3: `BRAHMAN_INIT_SOCKET`,
|
|
`BRAHMAN_BROKER_CONTEXT` (sólo broker), `BRAHMAN_DEMO_LABEL/FLOW/TYPE`,
|
|
`RUST_LOG`.
|
|
|
|
Script nuevo `scripts/bootstrap-demo.sh`:
|
|
- Modos: `all` (default — broker + producer + consumer + 4 explorers),
|
|
`broker` (sin GUIs, sólo backend), `only` (sólo broker, sin
|
|
producer/consumer ni GUIs).
|
|
- Cleanup-safe: trap `EXIT INT TERM` mata todos los procesos
|
|
spawneados (con SIGTERM grace + SIGKILL fallback) y borra el socket.
|
|
- Espera activa hasta 5s a que el socket aparezca antes de spawnear
|
|
los siguientes (evita ENOENT en el handshake).
|
|
- Logs separados por proceso bajo `$BRAHMAN_DEMO_LOG_DIR` (default
|
|
`/tmp/brahman-demo`). Re-invocaciones limpian los logs viejos.
|
|
- Re-build automático opcional (comentado por default — asume
|
|
`cargo build --workspace` ya hecho).
|
|
|
|
Smoke verificado end-to-end (sin DISPLAY, sólo backend):
|
|
- Broker arranca, bind del socket OK.
|
|
- Consumer conecta, asigna session.
|
|
- Producer conecta, asigna session.
|
|
- Consumer recibe `MatchEvent { Available, demo-feed ← demo-stream,
|
|
via: Exact, pinned: false }` automáticamente — el broker computó
|
|
el match y lo pusheó por el push channel.
|
|
|
|
Stack tests: brahman-demo (0 unit), workspace verde.
|
|
|
|
### feat(brahman-handshake): ListMatches endpoint + timeline en broker-explorer
|
|
Iter 21. Cierra el loop de observabilidad iniciado en iter 20: ahora
|
|
se ven no sólo las sesiones registradas sino también qué matches
|
|
consumer↔producer está computando el broker en cada momento, y la
|
|
historia de cómo cambian.
|
|
|
|
`brahman-handshake/messages.rs`:
|
|
- **`Frame::ListMatches(ListMatches{session})`**: pedido (mismo
|
|
patrón de validación session-id).
|
|
- **`Frame::MatchList(MatchList{matches: Vec<brahman_broker::Match>})`**:
|
|
respuesta. Cada `Match` ya es serializable y lleva `consumer`,
|
|
`consumer_label`, `producer`, `producer_label`, `ty`, `via`, `pinned`.
|
|
|
|
`brahman-handshake/server.rs`:
|
|
- `run_post_handshake` ahora pasa también `broker_for_match: Option<&SharedBroker>`
|
|
al `handle_inbound_frame`.
|
|
- Si el server tiene broker configurado, `ListMatches` responde con
|
|
`broker.all_matches()`. Si no (server sin broker), responde
|
|
`MatchList { matches: vec![] }` — refleja "no hay matching activo",
|
|
no es un error.
|
|
|
|
`brahman-handshake/client.rs`: `Client::list_matches()` análogo a
|
|
`list_sessions()`, drena `MatchEvent`s intermedios al buffer.
|
|
|
|
`brahman-sidecar/discovery.rs`: `list_matches` y `list_matches_blocking`
|
|
con la misma forma de Card observer minimalista.
|
|
|
|
`brahman-broker-explorer`:
|
|
- Cada poll-tick ahora pide TANTO `list_sessions` COMO `list_matches`.
|
|
- `Explorer.last_match_keys: HashSet<MatchKey>` mantiene el estado
|
|
del último snapshot. La key es `(consumer.session, consumer.flow,
|
|
producer.session, producer.flow)`.
|
|
- `Explorer.timeline: VecDeque<TimelineEntry>` con cap `TIMELINE_CAP=50`.
|
|
- Función pura `diff_matches(last_keys, list) -> (entries, new_keys)`:
|
|
emite `Available` para keys nuevas y `Lost` para keys desaparecidas.
|
|
Primer tick (last_keys vacío) marca todo como Available — cubre
|
|
el boot sin que la UI quede vacía.
|
|
- Render: `stat_card` "Timeline de matches" con count + 20 entries
|
|
formateadas como `HH:MM:SS {+/-} consumer.flow ← producer.flow [via]`.
|
|
Más reciente arriba.
|
|
|
|
Tests broker-explorer: 5 totales.
|
|
- `diff_matches_first_snapshot_marks_everything_available`
|
|
- `diff_matches_emits_lost_when_match_disappears`
|
|
- `diff_matches_no_change_emits_nothing`
|
|
- `pending_is_default_state_at_boot` (existente)
|
|
- `poll_and_probe_constants_are_sane` (existente)
|
|
|
|
Decisión: timeline polled (cada `POLL_INTERVAL=5s`), no push.
|
|
Razón: los `MatchEvent` push del broker son consumer-céntricos
|
|
(cada session sólo ve sus propios matches). Para "system-wide
|
|
timeline" haría falta una API broker-level "subscribe a todos" —
|
|
mucho más scope. Polling cada 5s es suficiente para observabilidad.
|
|
|
|
### feat(brahman-handshake): ListSessions endpoint + cliente + UI broker-explorer
|
|
Iter 20. Nuevo flujo end-to-end para observabilidad: cualquier
|
|
módulo conectado puede preguntar al broker la lista de sesiones
|
|
activas y mostrar labels + flows in/out por cada una.
|
|
|
|
`brahman-handshake/messages.rs`:
|
|
- **`Frame::ListSessions(ListSessions { session })`**: request del
|
|
cliente (server valida que `session` coincida con la sesión vigente,
|
|
mismo patrón que Ping/Farewell).
|
|
- **`Frame::SessionList(SessionList { entries })`**: respuesta.
|
|
Cada `SessionEntry` lleva: `session`, `label`, `schema_version`,
|
|
`outputs` (nombres de flow.output), `inputs` (nombres de
|
|
flow.input), `conscious` (`true` si la Card vino con WIT).
|
|
|
|
`brahman-handshake/server.rs`:
|
|
- `run_post_handshake` ahora pasa `SessionRegistry` a
|
|
`handle_inbound_frame` (necesario para consultar el snapshot de
|
|
sesiones en respuesta a `ListSessions`).
|
|
- Helper `build_session_list(sessions)` que toma el snapshot bajo
|
|
el lock, lo proyecta a `SessionList`, y suelta el lock antes de
|
|
escribir el frame al wire.
|
|
- Validación `session_id` mismatched → `HandshakeError::Unauthorized`.
|
|
|
|
`brahman-handshake/client.rs`:
|
|
- `Client::list_sessions()` async: envía el request, drena
|
|
`MatchEvent`s intermedios al `pending_events` buffer (mismo patrón
|
|
que `ping`), retorna el `SessionList`.
|
|
|
|
`brahman-sidecar/discovery.rs`:
|
|
- `pub async fn list_sessions(observer_label)` y
|
|
`pub fn list_sessions_blocking(observer_label)`: arman una Card
|
|
observer mínima (sin flow.input/output), conectan, piden la lista,
|
|
Farewell. Para CLIs y módulos std-thread.
|
|
|
|
`brahman-broker-explorer`:
|
|
- Cada poll-tick (cuando el broker está UP*) ahora también pide
|
|
`list_sessions_blocking` y guarda el snapshot en `Explorer.sessions`.
|
|
- Render extiende el body con un `stat_card` "Sesiones activas" que
|
|
muestra el count + lista ordenada por `session` (Ulid temporal),
|
|
cada item: `label · in:[flows] out:[flows] (wit?)`.
|
|
|
|
Tests:
|
|
- `list_sessions_returns_currently_registered`: levanta server con
|
|
broker, conecta 3 clientes (alpha, beta, observer), observer pide
|
|
`list_sessions`, verifica los 3 labels presentes y que la entry
|
|
del observer reporte `conscious=false` y el `schema_version`
|
|
esperado.
|
|
- Stack: handshake suite (24 tests), sidecar (3+8 unit + integ),
|
|
broker-explorer (4 tests). Todo verde.
|
|
|
|
### feat(brahman-broker-explorer): nueva app probe del broker brahman
|
|
Iter 14. Cierra otro frente: visibilidad del broker brahman (el
|
|
broker handshake que matchea Cards consumer/producer). Hasta ahora
|
|
no había forma de "ver" si el broker estaba up sin invocar otro
|
|
binario CLI. Ahora hay una app GUI que probe cada 5s y reporta 3
|
|
estados claros.
|
|
|
|
Crate nuevo `crates/apps/brahman-broker-explorer/`:
|
|
- **Deps**: `brahman-handshake`, `brahman-sidecar` + el stack
|
|
yahweh themed (theme + 3 widgets). Consume el mismo
|
|
`await_provider_blocking` que usa `nouser-explorer`.
|
|
- **`ProbeState` enum** con 4 variants:
|
|
- `Pending` (estado inicial al boot, antes del primer probe).
|
|
- `Down { reason }` — connect failed, broker no escucha.
|
|
- `UpNoProvider { flow }` — broker reachable + sin productor
|
|
para el flow probado dentro del timeout.
|
|
- `UpWithProvider { flow, producer_socket }` — broker reachable
|
|
+ matcheó algo, devuelve el socket del provider.
|
|
- **Polling loop** en `cx.spawn` cada 5s; el probe (que es
|
|
bloqueante porque internamente usa tokio runtime) se ejecuta en
|
|
`cx.background_executor().spawn(...)` para no congelar el main
|
|
thread del UI.
|
|
- **Configuración via env**:
|
|
- `BRAHMAN_INIT_SOCKET` — path del broker (default resuelto por
|
|
`brahman_handshake::transport`).
|
|
- `BRAHMAN_BROKER_PROBE_FLOW` — flow del Card observer
|
|
(default `broker-health`).
|
|
- `BRAHMAN_BROKER_PROBE_TYPE` — type name (default `ping`).
|
|
- **UI**: header con probe info + theme switcher; banner permanente
|
|
(Error/Warning/Success/none según estado) debajo del header;
|
|
stat card con accent color por estado y descripción.
|
|
- 2 tests sanity (default state es Pending; constants coherentes:
|
|
PROBE_TIMEOUT < POLL_INTERVAL >= 2s).
|
|
|
|
Smoke run del binario verificado: bootstrap completo OK, panic
|
|
esperado en open_window por falta de display.
|
|
|
|
Beneficio operativo:
|
|
- Si tenés un broker corriendo en `~/.local/share/brahman/init.sock`,
|
|
el explorer lo detecta + reporta estado verde con su socket.
|
|
- Si no hay broker, banner rojo + msg claro indicando el path
|
|
probado.
|
|
- Si hay broker pero ningún Card produce el flow probado, banner
|
|
amber — útil para distinguir "broker down" de "broker up,
|
|
no productor del tipo X".
|
|
- Apuntando el flow/type via env, podés monitor productores
|
|
específicos: ej. `BRAHMAN_BROKER_PROBE_FLOW=monad-list
|
|
BRAHMAN_BROKER_PROBE_TYPE=json` para ver si nouser está sirviendo.
|
|
|
|
Apps GUI integradas al stack themed: **5** (nakui-ui, nakui-explorer,
|
|
nouser-explorer, minga-explorer, brahman-broker-explorer).
|
|
|
|
Limitaciones documentadas:
|
|
- El observer registra una Card temporal en cada probe (cada 5s).
|
|
Eso ensucia un poco las estadísticas del broker (Cards
|
|
registradas/desregistradas). No impacta funcionalidad pero
|
|
inflama el log si el broker tiene observability habilitada.
|
|
- No muestra la **lista global** de Cards registradas en el broker
|
|
— el protocolo handshake actual no expone esa API. Para eso
|
|
habría que agregar un endpoint `ListSessions` al broker server.
|
|
- No mantiene un buffer de MatchEvents. Cada probe es independiente.
|
|
Para timeline de matches, hace falta mantener el Client vivo
|
|
entre probes — scope futuro.
|
|
|
|
### feat(brahman-cards): templates Nickel canónicos para cada body kind
|
|
Materializa el patrón "import + override" del brazo: hasta ahora
|
|
`BRAHMAN_CARDS_TEMPLATES_DIR` existía como mecanismo pero el repo
|
|
no shippeaba ningún template. Ahora hay 3 templates basic (uno
|
|
por body kind del CardBody) bajo
|
|
`crates/core/brahman-cards/templates/`:
|
|
|
|
- **`ente_basic.ncl`** — Card runtime mínima: `payload="Virtual"`,
|
|
`supervision="OneShot"`, `schema_version=1`. Override típico:
|
|
`id` + `label`.
|
|
- **`monad_basic.ncl`** — agrupación semántica de archivos
|
|
(Mónada Nouser): metadata vacía, `dominant_lens="grid"` (lowercase
|
|
por convención serde rename_all). Override típico: `id`, `label`,
|
|
`members`, `cardinality`.
|
|
- **`ui_module_basic.ncl`** — descriptor UI con `entities=[]`,
|
|
`menu=[]`, `views={}`. Override típico: `id`, `label` y los
|
|
3 payloads.
|
|
|
|
Cada field override-able marcada `| default` (sin eso Nickel
|
|
rebota merge de strings/numbers no-iguales).
|
|
|
|
API nueva en `lib.rs`:
|
|
- **`pub fn canonical_templates_dir() -> PathBuf`**: devuelve el
|
|
directorio de templates del crate (resuelto via
|
|
`CARGO_MANIFEST_DIR`). Útil para apuntar el env
|
|
`BRAHMAN_CARDS_TEMPLATES_DIR` en runtime/tests sin hardcoding
|
|
del path.
|
|
- Doc explica que para distribución del binary standalone (cuando
|
|
emerja), incluir templates como recursos via `include_dir!` o
|
|
instalar el directorio junto al ejecutable.
|
|
|
|
5 tests E2E (`tests/templates.rs`) que cubren:
|
|
- `ente_basic` import + override `id`+`label` → Card body Ente
|
|
con `payload=Virtual` (default preserved).
|
|
- `monad_basic` import + override `id`+`label`+`cardinality` →
|
|
Card body Monad con members=[] y summary="" (defaults).
|
|
- `ui_module_basic` import + override de `id`+`label`+menu+views
|
|
→ Card body UiModule con entities=[] (default).
|
|
- Sanity: import sin override → defaults `"TEMPLATE_ID"` /
|
|
`"TEMPLATE_LABEL"` pasan al wrapper sin error.
|
|
- Sanity: el path de `canonical_templates_dir()` apunta a un
|
|
directorio existente con los 3 archivos esperados.
|
|
|
|
Helper de test `with_canonical_templates(F)` setea/restaura el
|
|
env localmente; cada test single-thread-safe.
|
|
|
|
Tests suite brahman-cards: 26 → **31** verdes (+5). El resto del
|
|
workspace intacto.
|
|
|
|
Beneficio operativo:
|
|
- Un usuario que quiera declarar un Card nuevo puede empezar con
|
|
un override de 2 líneas (`id` + `label`) en lugar de copiar el
|
|
shape full desde cero.
|
|
- Templates auto-documentan la convención `| default` para que
|
|
copiar uno y agregar fields propios "just works" en merge.
|
|
- El brazo sigue siendo agnostic — los templates son sólo
|
|
archivos `.ncl` resueltos via el import resolver Nickel; nada
|
|
hardcoded en código Rust.
|
|
|
|
Limitaciones:
|
|
- No hay templates "ricos" tipo `crud_basic.ncl` que parametricen
|
|
por entity name. Nickel no expone funciones-templates de la
|
|
forma típica de templating engines; lo más cercano sería un
|
|
template con un field `entity_name | String` y references
|
|
internas via `me.entity_name`. Cuando aparezca el caso de uso
|
|
real (e.g., un módulo donde el patrón list+form es repetitivo),
|
|
se diseña el template paramétrico.
|
|
- `canonical_templates_dir()` resuelve via `CARGO_MANIFEST_DIR` —
|
|
funciona en `cargo` (test/run/build) pero no para un binary
|
|
instalado fuera del workspace. Para release distribution la API
|
|
necesitará un fallback (resources embedded o convención de
|
|
install path).
|
|
|
|
### feat(brahman-cards): Nickel reader + templates con merge nativo (V2)
|
|
Sigue al V1 (readers JSON). Ahora el brazo acepta inputs `.ncl`:
|
|
los evalúa via `nickel-lang` 2.0, exporta a JSON, y dispatcha por
|
|
los mismos readers JSON estándar. Un `.ncl` puede producir
|
|
cualquier `CardBody` siempre que su shape sea reconocida. Los
|
|
templates funcionan con los `import` + `&` merge nativos de
|
|
Nickel — el brazo no inventa una mecánica paralela.
|
|
|
|
Cambios:
|
|
- **Dep `nickel-lang = "2.0.0"`** (interfaz estable, no
|
|
`nickel-lang-core` que es internal/inestable). Compila clean
|
|
pero suma ~1 min al build cold del crate.
|
|
- **Nuevo módulo `nickel_eval.rs`** con `eval_nickel_file(path) ->
|
|
Result<Value, NickelEvalError>`. Errores tipados:
|
|
`Io`, `Eval`, `Export`, `JsonReparse` — el mensaje de Nickel se
|
|
formatea como texto plano (sin ANSI) para que sea legible en
|
|
logs y toasts.
|
|
- **`load_card_with` añade `"ncl"`**: lee archivo → eval Nickel →
|
|
exporta a JSON → parsea de vuelta a Value → dispatch a los
|
|
readers JSON. Pipeline simétrico a `"json"`.
|
|
- **`CardLoadError::Nickel(NickelEvalError)`**: el error de
|
|
Nickel se propaga limpio al error público del brazo.
|
|
- **Resolución de imports**:
|
|
- El parent dir del input se agrega como import path → `import
|
|
"./template.ncl"` resuelve sin config.
|
|
- El env `BRAHMAN_CARDS_TEMPLATES_DIR` (constante exportada
|
|
`BRAHMAN_CARDS_TEMPLATES_ENV`) agrega un registry global →
|
|
`import "ui_module_minimal.ncl"` desde cualquier ubicación.
|
|
- No hay magic resolución por kind. El autor del Card decide
|
|
qué template importa.
|
|
|
|
**Convención obligatoria de templates** (documentada en
|
|
`nickel_eval.rs`): las fields que el usuario va a sobrescribir
|
|
deben marcarse `| default` (o `| optional`). Sin ese marker
|
|
Nickel rechaza el merge de strings/numbers no-iguales con la
|
|
misma prioridad. Patrón canónico:
|
|
|
|
```nickel
|
|
# template ui_module_basic.ncl
|
|
{
|
|
id | String | default = "TEMPLATE_ID",
|
|
label | String | default = "TEMPLATE_LABEL",
|
|
...
|
|
}
|
|
|
|
# uso concreto
|
|
let base = import "ui_module_basic.ncl" in
|
|
base & { id = "my_id", label = "Mi Label" }
|
|
```
|
|
|
|
9 tests nuevos en `tests/nickel.rs`:
|
|
- `eval_nickel_file_returns_value_for_valid_input` — happy path.
|
|
- `eval_nickel_file_surfaces_evaluation_error` — variant `Eval`
|
|
con path + message.
|
|
- `load_card_dispatches_ncl_to_ui_module_variant` — pipeline
|
|
e2e a UiModule.
|
|
- `load_card_dispatches_ncl_to_ente_variant` — pipeline e2e a
|
|
Ente.
|
|
- `template_merge_overrides_id_and_label_only` — el caso del
|
|
user: template + override de id+label, resto del template
|
|
intacto.
|
|
- `template_resolves_via_env_registry` — uso del env como
|
|
registry global.
|
|
- `load_card_wraps_nickel_error_in_card_load_error` — wrap
|
|
limpio del error.
|
|
- `nickel_contract_violation_caught_at_eval_time` — value-add
|
|
concreto: `id | String = 42` falla en eval, no en deserialize
|
|
ni aguas abajo.
|
|
- `ncl_evaluating_to_unknown_shape_returns_no_matching_reader`
|
|
— sanity de coherencia con dispatcher JSON.
|
|
|
|
22 tests en total en `brahman-cards` (13 JSON V1 + 9 Nickel V2).
|
|
Workspace build verde tras la dep nueva.
|
|
|
|
**Lo que NO hace V2** (sigue pendiente):
|
|
- No migra consumers — `nakui-ui` sigue cargando con
|
|
`nakui_ui_schema::load_modules_from_dir`. La migración a
|
|
`brahman_cards::load_card` queda para después.
|
|
- No define un set canonical de templates en el repo (algo
|
|
como `templates/ente_basic.ncl`, `templates/ui_module_minimal.ncl`).
|
|
Eso emerge cuando aparezcan los primeros casos de uso reales
|
|
donde dos cards comparten estructura.
|
|
- No hace cross-validation entre template + override (ej:
|
|
detectar que un override saca un campo required del template).
|
|
Nickel ya lo hace via contracts si el template tiene un schema.
|
|
- No expone una API streaming (load N cards en paralelo). El
|
|
use case actual es one-shot al boot.
|
|
|
|
**Pendientes para próximos commits** (orden):
|
|
1. Migrar consumers (`nakui-ui` consume `brahman_cards::load_card`).
|
|
2. Yahweh refactor: lift del MetaUi runtime a `crates/modules/ui_engine/`.
|
|
3. KCL → Nickel: kcl_wrapper reemplazado por evaluación de Nickel
|
|
contracts; los 3 schemas .k de nakui modules pasan a .ncl.
|
|
4. card.k eliminado (es REFERENCE ONLY documentado).
|
|
|
|
### feat(brahman-cards): brazo unificado V1 — readers JSON + estructura canónica
|
|
**Pivote arquitectónico** decidido en charla: Brahman maneja varios
|
|
formatos legítimos de "Card" (cada formato vive en su crate origen y
|
|
conserva su shape público), y un **único brazo** los lee, completa
|
|
desde templates si vienen simplificados, y los proyecta a UNA sola
|
|
estructura interna canónica que consumen UI runtime / storage / DHT /
|
|
wire. Agregar un formato nuevo = agregar un reader, sin tocar
|
|
consumers.
|
|
|
|
**V1 en este commit**: estructura canónica + readers para los 3
|
|
formatos JSON existentes en el monorepo. Sin Nickel todavía (aislado
|
|
para próximo commit).
|
|
|
|
Crate nuevo `crates/core/brahman-cards/`:
|
|
- **`Card { id, schema_version, lineage, label, extensions, body }`**:
|
|
wrapper común con identidad legible + extensiones forward-compat.
|
|
`id` como String (no `Ulid`) porque cada body variant usa un tipo
|
|
de id distinto (Ulid para Ente/Monad, slug human-friendly para
|
|
UiModule). PartialEq omitido del derive porque `MonadManifest` y
|
|
`nakui_ui_schema::Module` no lo implementan en sus crates origen.
|
|
- **`CardBody`** enum etiquetado `kind`:
|
|
- `Ente(brahman_card::Card)` — entidad runtime con
|
|
payload/soma/supervision.
|
|
- `Monad(nouser_card::MonadManifest)` — agrupación semántica de
|
|
archivos.
|
|
- `UiModule(nakui_ui_schema::Module)` — descriptor de UI con
|
|
entities/views/menu.
|
|
- Convención: agregar variant nuevo + reader; los consumers que
|
|
sólo manejen algunos hacen `match { Ente(..) => ..., _ => skip }`.
|
|
- **`trait CardReader`**: `name()` + `can_read(&Value) -> bool` +
|
|
`read(Value) -> Result<Card>`. El dispatcher prueba en orden y
|
|
delega al primero que matchee.
|
|
- **3 readers concretos** (en `readers.rs`):
|
|
- `EnteJsonReader` — heurística: `payload` Y `supervision`
|
|
presentes simultáneamente.
|
|
- `MonadJsonReader` — heurística: `members` Y `cardinality`.
|
|
- `UiModuleJsonReader` — heurística: `entities` Y `views` Y
|
|
`menu`. El más específico, va primero en `default_readers()`.
|
|
- **Entry points**:
|
|
- `load_card(path)` — abre archivo, dispatcha por extensión, dentro
|
|
de JSON prueba los readers default.
|
|
- `load_card_with(path, readers)` — variante con set custom para
|
|
apps que quieren restringir formatos.
|
|
- **Errores tipados** vía `CardLoadError`: `Io`, `JsonParse`,
|
|
`NoMatchingReader`, `ReaderFailed { reader, message }`,
|
|
`UnsupportedExtension { ext, supported }`.
|
|
|
|
13 tests integration:
|
|
- 3 detection tests (cada reader matchea sólo su shape, rechaza los
|
|
otros 2 + non-object).
|
|
- 3 dispatch+projection tests (cada formato JSON cargado produce el
|
|
variant esperado con campos del wrapper bien derivados).
|
|
- 2 negative cases (NoMatchingReader, non-object input).
|
|
- 1 sanity de orden (UiModule gana cuando el shape acepta múltiples
|
|
readers — defiende el contrato de orden documentado).
|
|
- 1 e2e desde disco con `load_card_with`.
|
|
- 1 unsupported extension.
|
|
- 1 custom reader set (restringir a sólo Ente).
|
|
- 1 documented invariant (extensions vacío en V1; si cambia, este
|
|
test se rompe como signal).
|
|
|
|
13/13 verdes. Workspace build verde tras agregar el crate al
|
|
`members[]` del workspace Cargo.toml.
|
|
|
|
**Lo que NO hace V1** (explícito):
|
|
- No carga Nickel — próximo commit. La dep `nickel-lang-core` queda
|
|
aislada para no inflar este commit.
|
|
- No define templates — los templates Nickel se diseñan junto al
|
|
reader Nickel (necesitan `merge` nativo de Nickel para fusionar
|
|
override + base).
|
|
- No migra consumers. `nakui-ui` sigue cargando `module.json` con
|
|
`nakui_ui_schema::load_modules_from_dir` directo. La migración a
|
|
`brahman_cards::load_card` viene cuando V1 + Nickel + templates
|
|
estén estables.
|
|
- No mueve los `extensions` del input a `Card.extensions` — los crates
|
|
origen ya tienen sus propios `extensions` internos (`#[serde(flatten)]`).
|
|
Documentado como decisión consciente.
|
|
|
|
**Pendientes para próximos commits** (orden):
|
|
1. Reader Nickel + template merge.
|
|
2. Migrar consumers (`nakui-ui` consume `brahman_cards::load_card`).
|
|
3. Yahweh refactor: lift del MetaUi runtime a `crates/modules/ui_engine/`
|
|
(esperando hasta que el brazo + canónico estén estables).
|
|
4. KCL → Nickel: kcl_wrapper reemplazado por evaluación de Nickel
|
|
contracts; los 3 schemas .k de nakui modules pasan a .ncl.
|
|
5. card.k eliminado (es REFERENCE ONLY documentado).
|
|
|
|
### feat(brahman-handshake): multi-key identity — rotación de session sin perder peer_id lógico
|
|
Cierra el último pendiente del plan de red P2P. Hasta ahora, rotar
|
|
la keypair libp2p de un nodo cambiaba su `peer_id`, lo que
|
|
invalidaba todas las allowlists/denylists remotas que lo
|
|
referenciaban. Imposible rotar sin coordinar con todos los pares.
|
|
|
|
Solución: separar **identity master** (Ed25519 persistente forever,
|
|
identifica al nodo como entidad lógica) de **session libp2p**
|
|
(Ed25519 efímera, rotable). El master firma certs de session con
|
|
expiración. La política de admisión se evalúa contra el
|
|
`master_peer_id` del cert — el session peer_id puede cambiar
|
|
libremente sin tocar las allowlists.
|
|
|
|
API nueva en `brahman_handshake::identity`:
|
|
- `Identity::from_keypair(master)` — wrapper sobre la master kp.
|
|
- `Identity::master_peer_id()` — el peer_id estable del nodo.
|
|
- `Identity::issue_session_cert(session_kp, ttl) -> SessionCert` —
|
|
firma un cert que vincula session_pubkey + expires_at_ms.
|
|
- `SessionCert::verify()` — chequea versión, firma criptográfica,
|
|
no expiración. Devuelve `(master_peer_id, session_peer_id)`.
|
|
- `SessionCert::verify_against_session(expected_session_pk)` — verify
|
|
+ exige que el cert vincule esa session pubkey (previene reuso de
|
|
certs ajenos con keypairs distintas).
|
|
- `CertError` tipado: `UnknownVersion`, `DecodeMaster`,
|
|
`DecodeSession`, `InvalidSignature`, `Expired`, `SessionMismatch`,
|
|
`Sign`.
|
|
- `DEFAULT_SESSION_TTL = 24h`.
|
|
|
|
Wire:
|
|
- `Hello.identity_cert: Option<SessionCert>` agregado (default None,
|
|
back-compat).
|
|
- `Client::connect_with_stream_signed_with_cert(stream, card, wit,
|
|
session_kp, cert)` — variante que adjunta el cert.
|
|
- `network::connect_libp2p_with_cert(net, peer, card, wit,
|
|
session_kp, cert)` — paralelo a `connect_libp2p`.
|
|
|
|
Server (`do_handshake`):
|
|
- Nuevo paso ANTES del policy gate: si `Hello.identity_cert.is_some()`,
|
|
se verifica con `verify_against_session(&hello.signature.public_key)`.
|
|
El `logical_peer` que se evalúa contra la policy es el
|
|
`master_peer_id` derivado, NO el session peer_id.
|
|
- Sin cert (path Fase 3): `logical_peer = expected_peer` (compat).
|
|
- Si el cert es inválido (firma rota, expirado, session mismatch),
|
|
rechazo con `Unauthorized` antes de evaluar policy.
|
|
- Migración gradual: clientes sin cert siguen funcionando contra
|
|
servers con policy basada en session peer_ids.
|
|
|
|
Canonicalización del payload firmado:
|
|
```
|
|
[u8 version][b"sess"][u32 LE session_pubkey_len][session_pubkey][u64 LE expires_at_ms]
|
|
```
|
|
`SESSION_CERT_VERSION = 1` documenta el esquema; cualquier cambio
|
|
fuerza bump (clientes viejos no validan certs nuevos).
|
|
|
|
Sobre el swarm-level deny:
|
|
- El `block_list` del swarm sigue operando con session peer_ids
|
|
(Noise sólo conoce eso). Si la operatoria lista master_peer_ids
|
|
en deny, el handshake-level gate los para; el swarm-level no.
|
|
El operador elige granularity: listar masters = robust a
|
|
rotaciones; listar sessions = rechazo más temprano.
|
|
|
|
Tests: 8 unit en `identity::tests`:
|
|
- `issue_and_verify_cert` — roundtrip básico, peer_ids derivados.
|
|
- `verify_against_session_admits_matching` y
|
|
`_rejects_mismatch` — el cert vincula 1 sola session pubkey.
|
|
- `cert_with_zero_ttl_is_expired` — expiración chequeada con tiempo
|
|
real.
|
|
- `tampered_signature_rejected` y `tampered_expires_at_rejected` —
|
|
cualquier mutación del cert post-firma falla.
|
|
- `unknown_version_rejected` — schema versionado defensivamente.
|
|
- `rotated_session_with_same_master_yields_same_master_peer_id` —
|
|
la propiedad fundamental: rotar session NO cambia master_peer_id.
|
|
|
|
Plus 1 E2E definitivo en `network_libp2p.rs`:
|
|
`identity_cert_allows_session_rotation_without_policy_change`.
|
|
- A configura `policy = allowlist[B.master_peer_id]` (master, no
|
|
session).
|
|
- B se conecta con session1 + cert(master, session1) → admitido.
|
|
Sesión registrada, farewell limpio.
|
|
- B "rota": genera session2 ≠ session1, mismo master, emite cert2.
|
|
- B se conecta con session2 + cert2 → admitido también, **sin que
|
|
A toque su allowlist**.
|
|
- Sanity: una session sin cert (cuyo session_peer NO está en allow)
|
|
es rechazada.
|
|
|
|
40 tests verdes en brahman-handshake + brahman-net (24 unit
|
|
incluyendo identity + 7 handshake + 3 discovery + 6 libp2p
|
|
incluyendo rotation E2E). Ningún regreso.
|
|
|
|
Wire en Arje queda como follow-up: ente-zero hoy es server-only y
|
|
no necesita identity (su keypair libp2p ya es estable vía
|
|
keypair_store). Cuando algún módulo de Arje haga conexiones
|
|
salientes con cert, se cargará la identity master separada de la
|
|
session vía nueva env `BRAHMAN_IDENTITY_PATH`. La API ya está
|
|
lista.
|
|
|
|
### feat(brahman-net+handshake): swarm-level deny — la denylist se proyecta al block_list de libp2p
|
|
Optimización de seguridad: la denylist ya no espera al handshake
|
|
brahman para rechazar — ahora se proyecta al `block_list` behaviour
|
|
del swarm libp2p. Conexiones desde peers baneados son rechazadas
|
|
**antes del Noise handshake**, ahorrando el round-trip TCP+Noise
|
|
por cada intento denegado.
|
|
|
|
Wire de bajo nivel (`brahman-net`):
|
|
- Nuevo behaviour `block_list: allow_block_list::Behaviour<BlockedPeers>`
|
|
añadido al `BrahmanBehaviour` derivado. Vive junto a `stream`,
|
|
`kad`, `identify`. Default vacío al construir.
|
|
- Nuevos comandos `BlockPeer(PeerId)` y `UnblockPeer(PeerId)` en el
|
|
enum interno + handlers que llaman
|
|
`swarm.behaviour_mut().block_list.{block_peer,unblock_peer}`.
|
|
- API pública: `BrahmanNet::block_peer(peer)` y
|
|
`BrahmanNet::unblock_peer(peer)`. Idempotentes.
|
|
- Dep nueva: `libp2p-allow-block-list = "0.6"` (sub-crate, no es
|
|
feature de `libp2p` en 0.56).
|
|
|
|
Wire en la política (`brahman_handshake::peer_policy`):
|
|
- `PeerPolicy` gana campo opcional `net: Arc<RwLock<Option<Arc<BrahmanNet>>>>`.
|
|
Default `None` para preservar callers existentes.
|
|
- Nuevo método `attach_to_net(net: Arc<BrahmanNet>)`:
|
|
- Sincronización inicial: itera la deny actual y llama
|
|
`net.block_peer(p)` por cada uno.
|
|
- Guarda el net para diff-sync en cada `reload`.
|
|
- `reload()` extendido: snapshot de `prev_deny` ANTES de mutar el
|
|
inner. Tras la mutación, llama `sync_deny_to_swarm(prev, new)`
|
|
que aplica `block_peer` por cada added y `unblock_peer` por cada
|
|
removed.
|
|
- Atomicidad preservada: si un archivo falla al parsear, el sync
|
|
no ocurre y la versión anterior persiste tanto en la policy
|
|
como en el block_list del swarm.
|
|
|
|
Wire en Arje (`ente-zero`):
|
|
- Tras setup_brahman_net + setup_brahman_policy, si AMBOS están
|
|
presentes se llama `policy.attach_to_net(net.clone())` con un log
|
|
informativo. Sin policy o sin net, no hay attach (modo abierto
|
|
o solo gate-level deny).
|
|
|
|
Tests: 1 nuevo E2E en `network_libp2p.rs`:
|
|
`swarm_level_deny_blocks_before_noise`. A configura policy con
|
|
deny de un peer + attach_to_net. Cliente baneado intenta
|
|
`connect_libp2p`; en lugar del `HandshakeError::Unauthorized` que
|
|
recibíamos antes (que requería completar Noise primero), ahora
|
|
falla con error de transporte/stream (o timeout, según timing) —
|
|
el dial nunca completa porque el swarm rechaza la conexión.
|
|
|
|
5 tests verdes en `network_libp2p.rs` (roundtrip, mismatched signing,
|
|
allowlist, denylist handshake-level, denylist swarm-level). 31 tests
|
|
totales en brahman-handshake + brahman-net. Sin regresión en
|
|
ente-zero.
|
|
|
|
Trade-offs:
|
|
- **Más eficiente** contra DoS: un atacante que prueba miles de
|
|
peer_ids no consume CPU del Noise handshake.
|
|
- **Misma fuente de verdad**: la denylist sigue viviendo en
|
|
`PeerPolicy` (un solo archivo, hot-reloadable). El swarm es un
|
|
cache derivado que se actualiza vía diff. No hay drift posible —
|
|
cada reload re-sincroniza atómicamente.
|
|
- **El handshake-level gate sigue activo** como segunda línea: si
|
|
por alguna razón un peer baneado pasa el block_list (race entre
|
|
reload y nueva conexión, o bug del crate), el handshake brahman
|
|
igual lo rechaza con `Unauthorized`. Defensa en profundidad.
|
|
|
|
Pendientes futuros del changelog:
|
|
- Rotación de keypair sin perder peer_id (multi-key identity).
|
|
|
|
### feat(brahman-handshake+ente-zero): denylist + hot reload de la política de peers
|
|
Consolida `PeerAllowlist` + nueva `PeerDenylist` en un único
|
|
`PeerPolicy` con allow + deny + hot reload vía `notify`. Cubre los
|
|
dos pendientes documentados en el commit anterior y simplifica la
|
|
API hacia un sólo punto de entrada.
|
|
|
|
API consolidada en `brahman_handshake::peer_policy`:
|
|
- `PeerPolicy::open()` — todo permitido (default).
|
|
- `PeerPolicy::from_sets(allow: Option<BTreeSet<PeerId>>, deny: BTreeSet<PeerId>)`
|
|
— política inline para tests.
|
|
- `PeerPolicy::from_files(allow_path?, deny_path?)` — carga ambos
|
|
archivos opcionales.
|
|
- `PeerPolicy::evaluate(peer) -> Decision` — `Admit |
|
|
DeniedByDenylist | NotInAllowlist`. Decision lleva su `reason()`
|
|
para logging consistente.
|
|
- `PeerPolicy::reload()` — recarga atómica desde los paths
|
|
asociados. **Si un archivo falla, conserva la versión anterior**
|
|
(un typo no debe tirar al Init en modo inseguro).
|
|
- `PeerPolicy::spawn_watcher() -> JoinHandle` — vigila los
|
|
archivos vía `notify`, debounce 250ms (coalesce de los varios
|
|
eventos típicos de un save), recarga atómica al detectar cambio.
|
|
|
|
Orden de evaluación (deny-first):
|
|
1. Si `peer ∈ denylist` → `DeniedByDenylist`.
|
|
2. Si hay allowlist y `peer ∉ allowlist` → `NotInAllowlist`.
|
|
3. Resto → `Admit`.
|
|
|
|
Esto significa que **deny gana sobre allow**: un peer en ambas listas
|
|
es rechazado. Diseño explícito para que la denylist sea la primitiva
|
|
de "kill switch" — agregar un peer al deny lo banea inmediatamente
|
|
sin importar dónde más esté listado.
|
|
|
|
Watcher: vigila el **directorio padre** del archivo, no el archivo
|
|
mismo. Razón: editores típicos hacen rename-and-replace (escriben
|
|
a tmp y rename al destino), lo que rompe el watch del archivo pero
|
|
no el del dir. Filtra eventos por path al procesar.
|
|
|
|
Wire en server:
|
|
- `ServerConfig.allowlist` → `ServerConfig.policy: Option<PeerPolicy>`
|
|
(breaking rename, scope local al monorepo). Gate en `do_handshake`
|
|
llama `policy.evaluate(&peer)` y usa `decision.reason()` para el
|
|
mensaje de error tipado.
|
|
|
|
Wire en Arje (`ente-zero`):
|
|
- Nueva env `BRAHMAN_PEER_DENYLIST` complementa
|
|
`BRAHMAN_PEER_ALLOWLIST`. Cualquiera (o ambas) activa la política.
|
|
- `setup_brahman_policy()` carga + arranca watcher. Devuelve
|
|
`(policy, JoinHandle)`; el handle se guarda en main para que el
|
|
thread no se aborte.
|
|
- Failure modes degradan a "modo abierto" (sin política) con log,
|
|
preservando la doctrina PID 1.
|
|
|
|
Activación end-to-end con todas las capas activas:
|
|
```sh
|
|
BRAHMAN_LISTEN_MULTIADDR=/ip4/0.0.0.0/tcp/4101 \
|
|
BRAHMAN_PEER_ALLOWLIST=/etc/brahman/allow.txt \
|
|
BRAHMAN_PEER_DENYLIST=/etc/brahman/deny.txt \
|
|
ente-zero
|
|
# El operador puede editar deny.txt en caliente y la nueva regla
|
|
# entra en efecto en ~250ms sin restart del Init.
|
|
```
|
|
|
|
Tests: 10 unit en `peer_policy::tests`:
|
|
- `open_admits_anyone`, `allow_only_admits_listed`,
|
|
`deny_overrides_open`, `deny_overrides_allow` (deny-first
|
|
semantics).
|
|
- `from_files_with_both_lists`, `from_files_only_deny`,
|
|
`invalid_file_rejected_at_load`.
|
|
- `reload_picks_up_changes` — manualmente recarga y verifica.
|
|
- `reload_failure_preserves_previous_state` — invariante de
|
|
seguridad: archivo roto NO baja la política activa.
|
|
- `watcher_reloads_on_file_change` — E2E del watcher con notify
|
|
real: muta archivo, espera < debounce + margen, verifica que
|
|
la política refleja el cambio sin haber llamado reload manualmente.
|
|
|
|
Plus 1 E2E nuevo en `network_libp2p.rs`:
|
|
`libp2p_handshake_denylist_blocks_listed_peer` — A configura
|
|
`policy = PeerPolicy::from_sets(None, [banned_peer])`. Cliente
|
|
con keypair baneada es rechazado; cliente con keypair distinta
|
|
pasa el handshake.
|
|
|
|
30 tests verdes en brahman-handshake (16 unit + 7 handshake + 3
|
|
discovery + 4 libp2p incluyendo allowlist + denylist E2E). Sin
|
|
regresión en ente-zero.
|
|
|
|
Lo que cierra esta entrega:
|
|
- Política completa de admisión: open / allow-only / deny-only /
|
|
allow+deny.
|
|
- Hot reload sin restart del Init — el operador puede banear/admitir
|
|
peers en caliente editando archivos.
|
|
- Atomicidad: la recarga es del paquete `(allow, deny)` completo, no
|
|
de cada lista por separado. No hay momento donde una lista esté
|
|
vieja y la otra nueva.
|
|
- Resiliencia: errores de parseo NO bajan la política activa.
|
|
|
|
Pendientes futuros del changelog:
|
|
- Aplicar la política a nivel de swarm vía `libp2p_allow_block_list::
|
|
Behaviour` (rechazar ANTES del Noise handshake, ahorrar el
|
|
round-trip TCP+Noise por intento denegado).
|
|
- Rotación de keypair sin perder peer_id (multi-key identity).
|
|
|
|
### feat(brahman-handshake+ente-zero): allowlist explícita de peers libp2p
|
|
Capa de política sobre el trust criptográfico de Fase 3. Hasta ahora
|
|
cualquier peer con keypair Ed25519 válida pasaba el handshake remoto;
|
|
con allowlist activa, sólo los peers explícitamente listados. Aplica
|
|
únicamente al path libp2p — el path Unix sigue usando SO_PEERCRED
|
|
del kernel, que es autenticación de proceso local, no de red.
|
|
|
|
API nueva en `brahman_handshake::peer_allowlist`:
|
|
- `PeerAllowlist::from_iter([peer_id, ...])` para tests/inline.
|
|
- `PeerAllowlist::from_file(path)` parsea texto plano: un PeerId
|
|
base58 por línea, `#` para comentarios (línea entera o inline),
|
|
líneas vacías ignoradas. Errores de parseo incluyen número de
|
|
línea para debug rápido.
|
|
- `is_allowed(peer)`, `len()`, `is_empty()`, `iter()`.
|
|
- `AllowlistError { Io, InvalidPeerId }`.
|
|
|
|
Wire en el server:
|
|
- `ServerConfig.allowlist: Option<PeerAllowlist>`. `None` = modo
|
|
abierto (compat con todo lo anterior). `Some` = sólo los listados.
|
|
- Gate en `do_handshake` ANTES de la verificación de firma — la
|
|
comparación O(log n) en BTreeSet es más barata que crypto, así
|
|
que rechazamos peers inválidos antes de gastar ciclos. Se devuelve
|
|
`HandshakeError::Unauthorized("peer X no está en la allowlist")`.
|
|
|
|
Wire en Arje (`ente-zero`):
|
|
- Nueva env var `BRAHMAN_PEER_ALLOWLIST` apuntando a un archivo.
|
|
- `setup_brahman_allowlist()` carga al startup; degrada a `None`
|
|
(modo abierto) si el archivo falla, consistente con la doctrina
|
|
PID 1 de no romper por subsistemas opcionales.
|
|
|
|
Ejemplo de archivo de allowlist:
|
|
```text
|
|
# Peers permitidos en la malla brahman de prod-eu-1
|
|
# Generados con: ente-zero (peer_id loggeado al arrancar)
|
|
12D3KooWFooBarBazFooBarBazFooBarBazFooBarBazFooBarBaz
|
|
12D3KooWQuxQuxQuxQuxQuxQuxQuxQuxQuxQuxQuxQuxQuxQuxQux # operador 2
|
|
```
|
|
|
|
Activación end-to-end:
|
|
```sh
|
|
BRAHMAN_LISTEN_MULTIADDR=/ip4/0.0.0.0/tcp/4101 \\
|
|
BRAHMAN_PEER_ALLOWLIST=/etc/brahman/allowlist.txt \\
|
|
ente-zero
|
|
```
|
|
|
|
Tests:
|
|
- 6 unit en `peer_allowlist::tests`: from_iter, parse limpio, parse
|
|
con comentarios inline, parse rechaza PeerId inválido (y reporta
|
|
número de línea), I/O error en archivo faltante, empty list
|
|
rechaza todo.
|
|
- 1 E2E en `network_libp2p.rs`:
|
|
`libp2p_handshake_allowlist_admits_listed_rejects_others`. A
|
|
configura `allowlist = [allowed_peer]`. Cliente con keypair
|
|
permitida pasa el handshake (sesión registrada, farewell limpio).
|
|
Segundo cliente con keypair distinta es rechazado con error
|
|
ANTES de que se le verifique la firma. Sanidad: el conteo de
|
|
sesiones del server queda en 0 tras el rechazo.
|
|
|
|
25 tests verdes en brahman-handshake (12 unit + 7 handshake legacy
|
|
+ 3 discovery + 3 libp2p). Ningún regreso en ente-zero (4/4
|
|
keypair_store).
|
|
|
|
Pendiente futuro:
|
|
- Denylist explícita (negada — banear peers específicos sin tener
|
|
que listar a todos los demás).
|
|
- Hot reload de la allowlist sin restart del Init (signal SIGHUP o
|
|
watch del archivo).
|
|
- Aplicar la política a nivel de swarm vía
|
|
`libp2p_allow_block_list::Behaviour` para rechazar conexiones
|
|
ANTES del Noise handshake (hoy se rechaza después, gastando un
|
|
round-trip TCP+Noise por cada intento denegado).
|
|
|
|
### feat(brahman-net+handshake): stop_providing automático en cleanup de sesión
|
|
Cierra el pendiente conocido del DHT: hasta ahora cuando una sesión
|
|
con outputs cerraba (Farewell, EOF, error), el record que la
|
|
anunciaba en el DHT seguía vivo hasta su TTL natural (~24h en kad
|
|
default). Consumers remotos podían descubrir un peer "vivo" que ya
|
|
no servía nada.
|
|
|
|
Cambios:
|
|
- **`BrahmanNet::stop_providing(key)`** (nuevo): contraparte simétrica
|
|
de `start_providing`. Manda `Command::StopProviding` al swarm que
|
|
llama `kad.stop_providing(&key)`. Borra el record del provider
|
|
store local al instante; replicas en peers remotos siguen
|
|
expirando por TTL (kad no expone retracción cross-peer, simétrico
|
|
al hecho de que `start_providing` también propaga eventualmente).
|
|
- **`brahman_handshake::network::withdraw_outputs(net, card)`**
|
|
(nuevo): contraparte de `announce_outputs`. Itera `card.flow.output`
|
|
y llama `net.stop_providing(flow_dht_key(...))` por cada uno.
|
|
- **`server::cleanup`**: extrae la `ResolvedCard` removida del registro
|
|
de sesiones (en lugar de descartarla con `remove`) y, si
|
|
`config.net` está set, llama `withdraw_outputs(net, &card)` antes
|
|
de `broadcast_match_diffs`.
|
|
|
|
Tests: nuevo E2E `dht_discovery_withdraws_on_session_cleanup`:
|
|
1. A registra Card con `flow.output = monad-list:json`.
|
|
2. B descubre a A vía `find_remote_providers` — confirma
|
|
`before.contains(&a_peer)`.
|
|
3. Cliente local de A hace `farewell` → cleanup → withdraw_outputs.
|
|
4. Espera a que la sesión salga del registro (señal de cleanup
|
|
completado) + 100ms para que el swarm procese el Command.
|
|
5. Nueva query desde B: `after` NO debe contener `a_peer`.
|
|
|
|
3 tests verdes en `network_discovery.rs` (positivo, negativo,
|
|
withdraw). 18 tests totales en handshake + net.
|
|
|
|
Pendiente futuro: retracción cross-peer en kad (requeriría extensión
|
|
del protocolo libp2p, no soportada hoy). Aceptable: simétrico al
|
|
modelo de propagación eventual del DHT.
|
|
|
|
### feat(brahman-handshake): Fase 3 — trust remoto vía firma Ed25519 anclada al peer libp2p
|
|
Cuarto y último paso del plan "el encuentro entre Entes no se
|
|
restringe a local". Cierra la falla de seguridad que dejaba la red
|
|
P2P abierta: hasta ahora, un atacante que pudiera dial-ar al
|
|
multiaddr del Init podía registrar cualquier Card con cualquier
|
|
label/flow. Fase 3 cierra esto exigiendo que el Hello vía libp2p
|
|
venga firmado con la **misma keypair Ed25519 que produce el
|
|
`peer_id` autenticado por Noise**.
|
|
|
|
Modelo:
|
|
- **Local Unix**: SO_PEERCRED del kernel autentica al cliente. Firma
|
|
opcional. Si está presente, igual se verifica (defensa en
|
|
profundidad).
|
|
- **Remoto libp2p**: firma obligatoria. La public key del Hello debe
|
|
derivar al `peer_id` que Noise ya autenticó. Si falta o no coincide
|
|
→ `HandshakeError::Unauthorized`.
|
|
|
|
Wire (`brahman_handshake::messages`):
|
|
- `Hello.signature: Option<HelloSignature>` (nuevo, default None).
|
|
- `HelloSignature { public_key: Vec<u8>, signature: Vec<u8> }` —
|
|
public_key en formato canónico libp2p (`encode_protobuf`), firma
|
|
Ed25519 sobre `(SIGNATURE_VERSION, WireCard, Option<WitInterface>)`
|
|
serializado postcard.
|
|
- `SIGNATURE_VERSION = 1` documenta el esquema del payload firmado;
|
|
bump al cambiar.
|
|
|
|
Nuevo módulo `brahman_handshake::signature`:
|
|
- `sign_hello(keypair, card, wit) -> HelloSignature`.
|
|
- `verify_hello(sig, card, wit, expected_peer) -> Result<(), SignatureError>`.
|
|
- `SignatureError` tipado (`DecodeKey`, `EncodePayload`, `Invalid`,
|
|
`PeerMismatch`, `Missing`, `Unexpected`).
|
|
|
|
Server:
|
|
- `Session<S>` gana `expected_peer: Option<PeerId>`.
|
|
- `Server::session_from_libp2p_stream(stream, peer)` (nuevo)
|
|
construye Session con `expected_peer = Some(peer)`.
|
|
`session_from_stream` (Unix/in-memory) sigue con `None`.
|
|
- `do_handshake` exige firma + verifica peer match cuando
|
|
`expected_peer.is_some()`. Si no, verifica firma presente por
|
|
consistencia interna pero no exige que esté.
|
|
- `network::run_libp2p_accept_loop` ahora usa
|
|
`session_from_libp2p_stream(stream.compat(), peer)` para propagar
|
|
la identidad libp2p al gate de trust.
|
|
|
|
Client:
|
|
- `Client::connect_with_stream_signed(stream, card, wit, &Keypair)`
|
|
(nuevo) firma el Hello antes de mandarlo.
|
|
- `Client::connect_with_stream` sigue existiendo sin firma (path
|
|
Unix / tests).
|
|
- `Client::connect`/`connect_with` (Unix) no cambian — siguen sin
|
|
firma porque SO_PEERCRED autentica.
|
|
- `network::connect_libp2p(net, peer, card, wit, keypair)`
|
|
**breaking change**: gana parámetro `keypair: &Keypair`.
|
|
|
|
BrahmanNet:
|
|
- Almacena la `Keypair` en `Arc<Keypair>` (libp2p Keypair no es
|
|
Clone; el truco es duplicar el `ed25519::Keypair` interno que sí
|
|
es Clone, una copia para Noise/swarm y otra para signing).
|
|
- `BrahmanNet::keypair() -> Arc<Keypair>` accessor para que callers
|
|
puedan firmar con la misma identidad libp2p del nodo sin tener
|
|
que mantener la keypair por separado.
|
|
- `with_keypair` rechaza keypairs no-Ed25519 (RSA/ECDSA/Secp256k1
|
|
vendrían a futuro si se necesitan).
|
|
|
|
Tests:
|
|
- 4 unit en `signature::tests`: roundtrip propio, peer mismatch,
|
|
card tampered, signature flipped.
|
|
- 1 E2E nuevo en `tests/network_libp2p.rs`:
|
|
`libp2p_handshake_rejects_mismatched_signing_key` — el cliente
|
|
intenta firmar con keypair distinta a la del net; server rechaza.
|
|
- E2E positivo (`libp2p_handshake_roundtrip`) ahora pasa la keypair
|
|
del client_net y debe verificar OK.
|
|
- Discovery + handshake legacy + signature: 90+ tests verdes en
|
|
brahman-handshake/brahman-net/brahman-card/minga-p2p.
|
|
|
|
Lo que esto cierra:
|
|
- Brahman-net es una malla públicamente dial-able **con
|
|
autenticación criptográfica end-to-end**: Noise para el transport,
|
|
Ed25519 para las Cards.
|
|
- La cadena completa de discovery + connect + trust funciona
|
|
cross-machine sin paths hardcodeados ni confianza implícita.
|
|
- El plan original ("el encuentro entre Entes no se restringe a
|
|
local, la ejecución remota está pensada desde el principio")
|
|
está implementado y testeado.
|
|
|
|
Pendientes (futuro, no de hoy):
|
|
- `stop_providing` al cleanup de sesión (records DHT viven hasta
|
|
TTL ~24h).
|
|
- Wire de Arje (`ente-zero`) para arrancar opcionalmente con
|
|
`BrahmanNet` configurado y `ServerConfig.net = Some(...)`.
|
|
- Allowlist/Denylist de peers (hoy cualquier peer Ed25519-válido
|
|
pasa el trust gate; producción podría querer un PKI explícito).
|
|
- Persistencia de la keypair de identidad del nodo entre reboots.
|
|
|
|
### feat(brahman-handshake): Fase 2 — discovery remoto vía DHT por flow type
|
|
Tercer paso del plan "el encuentro entre Entes no se restringe a
|
|
local". Cuando un Init local acepta una sesión cuya Card declara
|
|
outputs, anuncia al DHT (Kademlia, vía `brahman-net`) que él provee
|
|
esos flow types. Cualquier nodo conectado al mismo DHT puede
|
|
consultar y obtener la lista de `PeerId`s que sirven el flow.
|
|
|
|
API nueva en `brahman_handshake::network`:
|
|
- `flow_dht_key(flow_name, type_ref) -> [u8; 32]`: blake3 hash de
|
|
`"brahman-flow|v1|{flow}|{type_canon}"`. Determinístico cross-host.
|
|
Cambiar la canonicalización rompe compatibilidad — el prefijo `v1`
|
|
documenta la versión del esquema y obliga a bump al modificar.
|
|
- `announce_outputs(net, card)`: llama `start_providing` en el DHT
|
|
por cada `Flow` en `card.flow.output`. Idempotente, fire-and-forget.
|
|
- `find_remote_providers(net, flow_name, type_ref) -> Vec<PeerId>`:
|
|
query DHT por la key derivada. Lista vacía si nadie anuncia o si
|
|
la query no resuelve dentro del timeout interno de Kad.
|
|
|
|
Wire en el server:
|
|
- `ServerConfig` gana `pub net: Option<Arc<BrahmanNet>>`. Si está set,
|
|
cada Card registrada con outputs se anuncia automáticamente al DHT
|
|
desde `register_session`. `None` = server "ciego al DHT" (correcto
|
|
cuando no hay conectividad o el operador no quiere exponer).
|
|
- `ServerConfig` ahora tiene `Debug` manual (BrahmanNet no implementa
|
|
Debug; loggeamos sólo presencia/ausencia).
|
|
|
|
Canonicalización del TypeRef:
|
|
- `Primitive { name }` → `prim:{name}`
|
|
- `Wit { package, interface, name }` → `wit:{package}#{interface_or_empty}#{name}`
|
|
|
|
Tests: 2 nuevos en `tests/network_discovery.rs`:
|
|
- `dht_discovery_finds_remote_provider`: dos nodos, A registra Card
|
|
con `flow.output = monad-list:json`, B dial-ea a A y descubre el
|
|
`peer_id` de A vía `find_remote_providers`. Asserts contains.
|
|
- `dht_discovery_negative_unknown_flow`: B busca un flow que nadie
|
|
anunció, devuelve lista vacía sin colgarse.
|
|
|
|
Lo que esto desbloquea:
|
|
- Un `nouser daemon` corriendo en máquina A puede ser descubierto por
|
|
un `nouser-explorer` en máquina B sin conocimiento previo del peer
|
|
— sólo necesitan compartir DHT (vía bootstrap inicial).
|
|
- La cadena completa "explorer → daemon → llm-provider" puede cruzar
|
|
máquinas, no sólo procesos.
|
|
|
|
Lo que queda para Fase 3 (trust):
|
|
- Cards remotas se aceptan hoy sin verificación. Para producción se
|
|
necesita firma Ed25519 sobre la Card y verificación antes de
|
|
aceptar el Hello remoto. Local sigue confiando en SO_PEERCRED.
|
|
- Stop-providing al cleanup de sesión (hoy records DHT viven hasta
|
|
TTL ~24h aunque la sesión cierre).
|
|
|
|
### feat(brahman-handshake): Fase 1 — handshake brahman sobre stream libp2p
|
|
Segundo paso del plan "el encuentro entre Entes no se restringe a
|
|
local". El protocolo brahman (Hello / HelloAck / Ping / Pong /
|
|
MatchEvent / Farewell, frames postcard length-prefixed) ahora también
|
|
viaja sobre streams libp2p de la malla `brahman-net` — el mismo Init
|
|
acepta sesiones por Unix socket Y por libp2p indistintamente, y un
|
|
consumer remoto puede dial-ar al multiaddr y completar handshake.
|
|
|
|
Cambios:
|
|
- **`Session<S>` y `Client<S>` genéricos**: ambos dejan de estar atados
|
|
a `UnixStream` y pasan a ser genéricos sobre `S: AsyncRead +
|
|
AsyncWrite + Unpin + Send + 'static`. El path Unix queda como
|
|
`Client = Client<UnixStream>` (default genérico). Constructores
|
|
nuevos: `Server::session_from_stream(stream)`,
|
|
`Client::connect_with_stream(stream, card, wit)`.
|
|
- **Refactor del post-handshake con split**: `tokio::select!` sobre
|
|
`&mut self.stream` requería `S: Sync` indirectamente, y
|
|
`libp2p::Stream` no es Sync. Reemplazado por
|
|
`tokio::io::split(stream)` → reader loop principal + writer task
|
|
separada que drena el push channel. Writer compartido bajo
|
|
`Arc<Mutex<WriteHalf<S>>>` para serializar Pong/Error inline con
|
|
los MatchEvents pusheados. Cleanup garantizado en todas las ramas.
|
|
La lógica del post-handshake migra a funciones libres
|
|
(`run_post_handshake`, `handle_inbound_frame`, `cleanup`,
|
|
`broadcast_match_diffs`, `do_handshake`, `register_session`,
|
|
`validate_hello`).
|
|
- **Nuevo módulo `brahman-handshake::network`**:
|
|
- `BRAHMAN_HANDSHAKE_PROTOCOL = "/brahman/handshake/1.0.0"`.
|
|
- `LibP2pHandshakeStream = Compat<libp2p::Stream>` (alias del
|
|
stream una vez convertido al mundo `tokio::io`).
|
|
- `run_libp2p_accept_loop(server, net)`: bucle accept sobre el
|
|
protocolo que delega cada stream entrante a una `Session`
|
|
construida vía `server.session_from_stream(stream.compat())`.
|
|
Sesiones libp2p y Unix conviven en el mismo `Server` —
|
|
comparten broker, push table, last_matches.
|
|
- `connect_libp2p(net, peer, card, wit)`: abre stream libp2p al
|
|
`peer` y arranca handshake.
|
|
- `NetworkError` tipado (`OpenStream`, `Handshake`, `AcceptStream`).
|
|
|
|
Deps: `brahman-handshake` gana `brahman-net`, `futures`, `tokio-util`.
|
|
`brahman-net` re-exporta `Multiaddr`, `PeerId`, `Stream`,
|
|
`StreamProtocol`, `Protocol`, `OpenStreamError` para que callers no
|
|
necesiten dep directa a libp2p.
|
|
|
|
Tests:
|
|
- 9 tests unit + integration verdes (sin regresión en el path Unix).
|
|
- Nuevo `tests/network_libp2p.rs`: test E2E que arma server con
|
|
Unix socket + BrahmanNet, hace listen TCP, monta el accept loop;
|
|
cliente con su propio BrahmanNet dial-ea al peer_id, completa
|
|
handshake remoto, pinguea, farewell. Verifica que la sesión se
|
|
registró durante la conversación y se removió tras farewell.
|
|
|
|
Próximo: Fase 2 (discovery remoto vía DHT — anunciar Cards bajo
|
|
flow type, broker query local + remoto).
|
|
|
|
### feat(brahman-net): capa P2P compartida — Fase 0 (extracción del swarm libp2p)
|
|
Primer paso del plan "el encuentro entre Entes no se restringe a local".
|
|
El swarm libp2p que vivía dentro de `minga-p2p::network` (282 LOC) sale
|
|
a una crate compartida `brahman-net` para que cualquier protocolo de la
|
|
familia (handshake brahman remoto en Fase 1, sync minga, futuros) reuse
|
|
una sola malla TCP+Noise+Yamux+Kad+Identify+Stream.
|
|
|
|
Diseño:
|
|
- `BrahmanNet::{new, with_keypair}` arma el swarm con DHT en modo
|
|
Server, Identify auto-poblando el routing table de Kad, y un
|
|
`stream::Control` accesible para que cada protocolo registre su
|
|
`StreamProtocol` y abra/acepte streams sin acoplarse al swarm.
|
|
- API de comandos uniforme: `dial`, `listen`, `add_dht_peer`,
|
|
`find_closest_peers`, `start_providing`, `find_providers`.
|
|
- Pública: `peer_id` (libp2p) + `control` (stream::Control).
|
|
- Re-exporta `Stream` y `StreamProtocol` para que callers no necesiten
|
|
importar libp2p directo.
|
|
|
|
Migración:
|
|
- `minga-p2p::network` reduce de 282 LOC a 22: ahora sólo re-exporta
|
|
`BrahmanNet` bajo el alias histórico `LibP2pNode` (zero churn en
|
|
`MingaPeer`) y declara la const `SYNC_PROTOCOL = "/minga/sync/1.0.0"`
|
|
específica del sub-protocolo Minga.
|
|
- Cualquier consumer que necesite armar un nodo P2P puede importar
|
|
`brahman_net::BrahmanNet` directo sin pasar por minga.
|
|
- Deps de `minga-p2p` ganan `brahman-net`; el resto del grafo
|
|
(libp2p, libp2p-stream, futures, tokio-util) sigue igual porque
|
|
`MingaPeer` aún los usa para la lógica específica de sync.
|
|
|
|
Aclaración semántica anclada por el usuario: **Arje** es el init
|
|
(PID 1, runtime, ente-zero/kernel/soma); **Brahman** es el encuentro
|
|
entre Entes (handshake/broker/card/sidecar/ahora también net). El
|
|
nombre de la crate refleja que la malla pertenece al encuentro, no
|
|
al runtime — Arje puede usar la malla, Minga usa la malla, cualquier
|
|
futuro módulo (Nakui remoto, p.ej.) la usa, sin acoplarse a Minga.
|
|
|
|
Tests: minga-p2p completo verde (58 tests, sin regresión). Behavior
|
|
verificado idéntico — sólo se movió código, ningún cambio funcional.
|
|
Próximo: Fase 1 (handshake brahman sobre libp2p stream).
|
|
|
|
### refactor(explorer+card): independencia jerárquica enforced — cliente con los wire types + fallback al default path
|
|
Cierra el único debt estructural detectado en el audit de
|
|
independencia: `nouser-explorer` ya no arrastra `nouser-core`
|
|
(que aportaba `notify`/`walkdir`/`sled`/`blake3` al grafo de
|
|
compilación de una UI que sólo habla JSON contra un socket).
|
|
|
|
Cambios:
|
|
- **Cliente movido**: `engine_socket::client::list_monads` (~60 LOC,
|
|
std + serde_json puros) emigra de `nouser_core::engine_socket` a
|
|
`nouser_card::query::client`. Vive donde viven los wire types,
|
|
consistente con el principio "un consumer importa el contrato,
|
|
no el runtime del productor".
|
|
- **Drop dep**: `nouser-explorer` deja de dependener de
|
|
`nouser-core`. Verificado con `cargo tree`: `notify`, `sled`,
|
|
`blake3` desaparecen del grafo del binario. (`walkdir` sigue
|
|
pero llega vía `gpui_util` → `rust-embed`, fuera de nuestro
|
|
control y pre-existente.)
|
|
- **Fallback "falla hacia la simplicidad"**: nueva función
|
|
`resolve_socket()` en el explorer intenta primero broker
|
|
discovery; si el broker no responde / no hay init vivo,
|
|
fallback directo a `nouser_card::query::transport::default_socket_path()`.
|
|
El explorer queda funcional contra un daemon "huérfano"
|
|
(corriendo standalone sin init) — completa la cadena
|
|
"consciente cuando hay ecosistema, soberano cuando está solo".
|
|
- `socket_source` en el header gana un tercer estado
|
|
`"default-path"` para que el usuario vea por dónde se conectó.
|
|
|
|
Audit estructural confirmó que el resto del ecosistema ya
|
|
respeta el principio: todos los `yahweh-*` viewers, `minga-cli`,
|
|
`minga-core`, `nouser-card`, `nouser-nous`, los providers
|
|
`nouser-nous-{mock,real}` y `nakui-core` corren standalone con
|
|
soft-fail hacia infra brahman cuando está ausente. Brahman es
|
|
"pegamento opcional, no chasis obligatorio" — y ahora el grafo
|
|
de Cargo lo enforcea, no sólo la convención.
|
|
|
|
Tests: 4 (sidecar) + 10 (nouser-card) + 27 (nouser-core) verdes.
|
|
El cliente movido se ejercita end-to-end por los 3 tests integración
|
|
de `engine_socket` (importa ahora `nouser_card::query::client`).
|
|
|
|
### feat(card): `Card::new(label)` — alternativa segura a `Default::default()`
|
|
Cierra la trap documentada de `Card::default()` que devuelve `id =
|
|
Ulid::nil()`. Usar `Card::default()` "viva" colisionaba con cualquier
|
|
otra Card default-construida bajo el mismo id `00000000…`. La fix no
|
|
es romper `Default` (sigue siendo determinista, requerido por callers
|
|
que lo usan como template para deserialización), sino agregar un
|
|
constructor explícito:
|
|
|
|
let card = Card {
|
|
kind: CardKind::Data,
|
|
payload: Payload::Embedded(json),
|
|
..Card::new("mi-modulo.algo")
|
|
};
|
|
|
|
`Card::new(label)` asigna `id = Ulid::new()` (único) + `label`
|
|
provisto, dejando el resto en defaults seguros (Virtual / OneShot /
|
|
Ente). Pensado para usarse en struct-literals con override parcial,
|
|
igual sintaxis que el patrón viejo pero sin la trap.
|
|
|
|
Refactor de call sites:
|
|
- `brahman_sidecar::discovery::build_consumer_card` → `..Card::new(label)`
|
|
- `nouser daemon::build_engine_card` → `..Card::new("brahman.nouser_engine")`
|
|
|
|
`Default` se mantiene tal cual con docstring expandida que advierte
|
|
explícitamente sobre el uso "vivo" y apunta a `Card::new`. Tests
|
|
existentes y el patrón `nouser_card::MonadManifest::to_brahman_card`
|
|
(que asigna el id estable de la Mónada, no uno fresco) NO se
|
|
modifican — `Default` sigue siendo correcto cuando el caller
|
|
sobreescribe `id` explícitamente.
|
|
|
|
Tests: 3 unitarios nuevos en brahman-card (`new_assigns_real_ulid_and_label`,
|
|
`new_yields_distinct_ids_per_call`, `default_keeps_nil_id_for_struct_update_pattern`).
|
|
15 tests verdes (era 12).
|
|
|
|
### feat(sidecar): API reusable de discovery vía broker
|
|
Promueve el patrón ad-hoc `discover_producer_socket` (que vivía
|
|
inline en `nouser attract --remote`) a un módulo público
|
|
`brahman_sidecar::discovery`. Cualquier consumer puede ahora
|
|
preguntar al broker "¿quién provee este TypeRef?" con dos llamadas:
|
|
|
|
// Construir un consumer Card mínimo (Ente, Oneshot, Virtual)
|
|
let card = brahman_sidecar::build_consumer_card(
|
|
"mi-cli",
|
|
"embed-result", // flow.input.name
|
|
"json", // TypeRef::Primitive { name }
|
|
);
|
|
|
|
// Bloqueante (CLIs, std-thread loops):
|
|
let socket: PathBuf = brahman_sidecar::await_provider_blocking(
|
|
card, Duration::from_secs(3),
|
|
)?;
|
|
// O async (módulos con runtime tokio propio):
|
|
let socket = brahman_sidecar::await_provider(card, timeout).await?;
|
|
|
|
API:
|
|
- `build_consumer_card(label, flow_name, type_name) -> Card`
|
|
abstrae la verbosidad del struct-literal repetido en cada caller.
|
|
Genera un `id: Ulid::new()` real (no nil → seguro contra
|
|
colisiones en el broker).
|
|
- `await_provider(card, timeout) -> Result<PathBuf, ConsumerError>`
|
|
conecta al init, espera `MatchEvent::Available`, devuelve
|
|
`producer_service_socket`, manda Farewell. Ignora eventos
|
|
`Lost` durante el await (no aplican al arranque).
|
|
- `await_provider_blocking(card, timeout)` arma su propio
|
|
runtime `current_thread` para mundos no-async.
|
|
- `ConsumerError` con variantes tipadas: `Connect { socket, source }`,
|
|
`NoProvider { flow, type_ref, timeout }`, `Client(ClientError)`,
|
|
`Runtime(String)`. Adiós al `Box<dyn Error>` de antes.
|
|
|
|
Refactor en `nouser daemon`:
|
|
- `discover_producer_socket` (60 LOC inline en `bin/nouser.rs`) → 5
|
|
líneas que delegan en el helper.
|
|
- `remote_embed` ya no construye su propio runtime tokio.
|
|
|
|
Próximo consumer natural: `nouser-explorer`. Hoy renderea
|
|
`StatusSnapshot` vía socket admin (introspección pura). El día que
|
|
quiera **interactuar** con un Ente — p. ej., disparar un re-embed
|
|
desde la UI — usa este helper para resolver el socket del provider
|
|
sin hardcodear paths.
|
|
|
|
Nota sobre identidad: este commit fuerza `Ulid::new()` para los
|
|
consumer Cards generados, evitando la trampa documentada del
|
|
`Card::default()` que devuelve `Ulid::nil()`. La fijación global de
|
|
`Default` queda como cleanup separado (requiere auditar que ningún
|
|
caller dependa del determinismo de `nil`).
|
|
|
|
Tests: 4 unitarios nuevos en `discovery::tests` (id no-nil, id
|
|
único por llamada, formateo de TypeRef::Wit, fallback sin input).
|
|
Workspace verde.
|
|
|
|
### feat(sidecar): Phase B-3 — SidecarPool consolida en un runtime
|
|
Antes: cada `spawn(card)` creaba un thread + tokio runtime propio.
|
|
Para módulos que publican muchas sesiones (nouser daemon con 50+
|
|
Mónadas) eso es 50 threads + 50 runtimes. Ahora: **un thread + un
|
|
runtime tokio current_thread** que hostea N tasks de sidecar.
|
|
|
|
API nueva (aditiva, no rompe `spawn`/`spawn_with_handle`):
|
|
|
|
let pool = SidecarPool::new()?;
|
|
pool.spawn(card1);
|
|
pool.spawn(card2);
|
|
pool.spawn_conscious(card_wit, wit);
|
|
pool.spawn_with_config(SidecarConfig::new(c).with_wit(w));
|
|
// pool drop = todas las sesiones cierran.
|
|
|
|
`run_client` se hace pública para que el pool pueda enqueuar tasks
|
|
externos al runtime con `handle.spawn(run_client(config))`.
|
|
|
|
`nouser daemon` migrado al pool. Verificación con `ps -L`:
|
|
|
|
$ ps -L -p $(pidof nouser)
|
|
LWP CMD
|
|
28817 nouser # main thread
|
|
28819 brahman-sidecar # pool thread (todas las sesiones)
|
|
|
|
Antes serían 6+ LWP (1 main + N sesiones); ahora 2 fijos sin importar
|
|
cuántas Mónadas se publiquen.
|
|
|
|
### feat: Crossreferencia — Card.references como grafo del fractal
|
|
Las Cards ahora declaran sus relaciones con otras Cards. El Engine
|
|
posee Mónadas; las Mónadas declaran que son poseídas por el Engine.
|
|
La UI puede cruzar el grafo sin discovery especial.
|
|
|
|
- `brahman-card`:
|
|
- `RelationshipKind { Owns, OwnedBy, Processes, ProcessedBy, Sibling }`.
|
|
- `CardReference { kind, target_id, target_label }` — `target_label`
|
|
es cache del label en el momento de declarar (la UI puede pintar
|
|
sin resolver).
|
|
- `Card.references: Vec<CardReference>` y espejo en `WireCard`.
|
|
Conversiones `From` propagan.
|
|
- `brahman-broker::BrokeredCard` propaga `references`.
|
|
- `brahman-status` imprime cada referencia: `ref OwnedBy → label (id)`.
|
|
- **nouser daemon**: cada Mónada que publica añade
|
|
`RelationshipKind::OwnedBy` apuntando al engine. La declaración es
|
|
unilateral; el engine no necesita conocer las IDs de antemano.
|
|
|
|
Validación end-to-end:
|
|
|
|
$ ente-zero & nouser daemon crates/core
|
|
$ brahman-status
|
|
Sessions (6):
|
|
[ente] ... brahman.nouser_engine
|
|
[data] ... brahman-handshake/src
|
|
ref OwnedBy → brahman.nouser_engine (01K...)
|
|
summary: 6 archivos...
|
|
[data] ... ente-brain/src
|
|
ref OwnedBy → brahman.nouser_engine (01K...)
|
|
...
|
|
|
|
### feat: Phase D-3 + D-4 — service_socket en Card, providers coexisten
|
|
Cierra el ciclo del swap automático de Nous (mock↔real):
|
|
|
|
- **Schema** (`brahman-card`): `Card.service_socket: Option<PathBuf>` y
|
|
espejo en `WireCard`. Conversiones `From` propagan. Es el path del
|
|
**data plane** (distinto del socket del Init); cualquier consumer
|
|
que matchee con esta Card puede conectar directo sin discovery
|
|
adicional.
|
|
- **Broker** (`brahman-broker`): `BrokeredCard` propaga
|
|
`service_socket` desde la Card. Sin participación en el matching —
|
|
sólo metadata para los observadores.
|
|
- **MatchEvent** (`brahman-handshake`): nuevo campo
|
|
`producer_service_socket: Option<PathBuf>`. Cuando el server emite
|
|
`Available`, busca la `BrokeredCard` del productor en el broker y
|
|
copia su `service_socket`. El consumer recibe la ruta completa para
|
|
conectar.
|
|
- **Transport** (`nouser-nous`): `provider_socket_path(provider: &str)`
|
|
devuelve `nouser-nous-{provider}.sock` por default — mock y real
|
|
coexisten en sockets distintos (Phase D-4). `default_socket_path()`
|
|
conserva el comportamiento single-provider.
|
|
- **Providers**: mock declara `service_socket =
|
|
/run/user/X/nouser-nous-mock.sock`; real declara
|
|
`nouser-nous-real.sock`. La Card se construye DESPUÉS del bind para
|
|
que el path declarado sea el real.
|
|
- **Status**: `brahman-status` imprime `socket:` por sesión cuando
|
|
está presente.
|
|
|
|
Validación end-to-end:
|
|
|
|
$ ente-zero & nouser-nous-mock & nouser-nous-real &
|
|
$ ls /run/user/1001/nouser-nous-*.sock
|
|
nouser-nous-mock.sock
|
|
nouser-nous-real.sock
|
|
|
|
$ brahman-status
|
|
Sessions (2):
|
|
[ente] ... nouser.nous_real
|
|
socket: /run/user/1001/nouser-nous-real.sock
|
|
in embed-request: Primitive { name: "json" }
|
|
out embed-result: Primitive { name: "json" }
|
|
[ente] ... nouser.nous_mock
|
|
socket: /run/user/1001/nouser-nous-mock.sock
|
|
in embed-request, out embed-result
|
|
|
|
Pendientes para futuro (no críticos):
|
|
- nouser-core attract --remote todavía usa NOUSER_NOUS_SOCKET hardcoded
|
|
o `default_socket_path()`. El siguiente paso es subscribirse al
|
|
MatchEvent del broker y usar `producer_service_socket` directo —
|
|
con eso `BRAHMAN_BROKER_CONTEXT=test/prod` swapea provider sin
|
|
tocar al consumer.
|
|
|
|
### feat(broker): priority contexts — biases per-contexto operativo
|
|
- `brahman-card::ContextBias { pin_to: Option<String>, priority_offset: i8 }`
|
|
declara un override per-contexto.
|
|
- `Card.priority_contexts: BTreeMap<String, ContextBias>` y mismo en
|
|
`WireCard` (cruza el wire). Las conversiones `From` lo propagan.
|
|
- `BrokerConfig.current_context: Option<String>`. Cuando el broker corre
|
|
bajo un contexto y una Card declara biases para ese nombre, se aplican:
|
|
- Como **consumidor**: `pin_to` sobreescribe el `Flow.pin_to` estático.
|
|
- Como **productor**: `priority_offset` se suma a la priority base
|
|
(clamp en `[Low=0, Critical=3]`) para el ranking.
|
|
- `BrokeredCard` propaga `priority_contexts`. `find_producer_for` usa
|
|
`effective_priority(card)` y `effective_pin(card, input)` antes de
|
|
los tiebreaks.
|
|
- `brahman-admin::AdminConfig.current_context` + `StatusSnapshot.current_context`
|
|
espejan el contexto activo. `brahman-status` lo imprime como
|
|
`Context: <nombre>` justo debajo de `Init: ...`.
|
|
- `ente-zero` lee `BRAHMAN_BROKER_CONTEXT` env var y la propaga al
|
|
broker y al admin. Sin var, biases per-contexto inactivos.
|
|
- 4 tests nuevos en brahman-broker:
|
|
`context_priority_offset_lifts_producer_above_alphabetic_winner`,
|
|
`context_pin_to_overrides_static_pin`, `unknown_context_no_op`,
|
|
`priority_offset_clamps_to_critical`.
|
|
- Validación end-to-end: `BRAHMAN_BROKER_CONTEXT=test ente-zero` →
|
|
`brahman-status` muestra `Context: test`.
|
|
|
|
### feat(card): WireCard + extensions — forward-compat sin romper postcard
|
|
- `Card.extensions: BTreeMap<String, serde_json::Value>` restaurado con
|
|
`#[serde(flatten, default, skip_serializing_if = is_empty)]`. Los
|
|
campos JSON/TOML desconocidos sobreviven el roundtrip de archivos.
|
|
- Nuevo `WireCard`: proyección postcard-friendly (sin `extensions`,
|
|
`genesis: Vec<WireCard>` recursivo). Conversiones `From<Card>` y
|
|
`From<WireCard>` con descarte/recreación de extensions.
|
|
- `brahman-handshake::Hello.card` pasa de `Card` a `WireCard`. Client
|
|
hace `card.into()` antes de enviar; Server hace `hello.card.into()`
|
|
para volver a Card antes de validar/registrar.
|
|
- 3 tests nuevos en brahman-card:
|
|
`extensions_preserved_in_json_roundtrip`,
|
|
`wire_card_roundtrip_strips_extensions`,
|
|
`wire_card_postcard_friendly` (postcard encode/decode efectivo).
|
|
- brahman-card gana `postcard` como dev-dep para el último test.
|
|
- Contrato documentado: extensions = anotaciones locales que NO cruzan
|
|
al Init; sólo viven en archivos.
|
|
|
|
### `9420eae` chore: limpia warnings dead-code en arje (commit del usuario)
|
|
- `ente-zero/src/events.rs`: `#![allow(dead_code)]` a nivel módulo —
|
|
es vocabulario de eventos con variantes/campos reservados para flujos
|
|
no cableados aún (CapabilityRequested, ShutdownReason::Signal,
|
|
CapabilityGrant::{Granted, Denied, QuotaExceeded}, ExitStatus
|
|
fields).
|
|
- `ente-zero/src/graph/mod.rs`: comentado el re-export ahora innecesario
|
|
de `SHUTDOWN_GRACE`. `DEFAULT_GRANT_TTL` con `#[allow(dead_code)]`
|
|
+ nota "reservado para capability granting".
|
|
- `ente-zero/src/graph/capabilities.rs`: `renew_grant` con
|
|
`#[allow(dead_code)]` (capability renewal pendiente).
|
|
- `ente-kernel/src/surface.rs`: drop de `use anyhow::Context` (no se
|
|
usaba).
|
|
- `ente-hostnamed-compat/src/main.rs`: drop de `Connection` (no se
|
|
usaba).
|
|
- `ente-polkit-compat/src/main.rs`: `PolicyDecision.source` con
|
|
`#[allow(dead_code)]` (sólo aparece en `Debug` para logging).
|
|
- `cargo check --workspace`: 17 warnings → 0.
|
|
|
|
### feat(sidecar): WIT al sidecar — módulos conscientes vivos
|
|
- `brahman-card::WitInterface` deriva `Serialize`, `Deserialize`,
|
|
`PartialEq`, `Eq` para cruzar el wire postcard.
|
|
- `brahman-handshake::Hello` lleva `wit: Option<WitInterface>`. Server
|
|
usa `ResolvedCard::from_conscious` cuando viene presente, `from_agnostic`
|
|
cuando no.
|
|
- `brahman-handshake::Client::connect` queda como wrapper agnóstico de
|
|
`connect_with(path, card, wit: Option<WitInterface>)`.
|
|
- `brahman-broker::Broker::register` ahora toma `Option<WitInterface>`
|
|
como tercer arg. `BrokeredCard` guarda el wit. 25 sitios de tests
|
|
actualizados con `, None`.
|
|
- `brahman-sidecar::SidecarConfig` con campo `wit`. Helpers nuevos:
|
|
`SidecarConfig::new(card).with_wit(wit)` y `spawn_conscious(card, wit)`.
|
|
El log `attached` reporta `conscious=true|false`.
|
|
- `brahman-status` muestra marker 🧠 + sección `wit:` (package/world,
|
|
imports, exports) por sesión consciente.
|
|
- Example nuevo `crates/shared/brahman-sidecar/examples/presence-conscious.rs`:
|
|
toma label + path .wit (default `shared_wit/protocol.wit`), parsea
|
|
con brahman-card-wit, spawna sidecar consciente.
|
|
- Validado end-to-end:
|
|
```
|
|
$ presence-conscious demo.conscious shared_wit/protocol.wit &
|
|
$ brahman-status
|
|
Sessions (1):
|
|
01K... demo.conscious 🧠 lifecycle=Daemon
|
|
wit: brahman:protocol@0.1.0 / module
|
|
imports: types, handshake, lifecycle
|
|
exports: run
|
|
```
|
|
|
|
### feat(core): brahman-card-wit — extractor opcional de contratos WIT
|
|
- Crate nuevo `crates/core/brahman-card-wit` con `wit-parser = "0.230"`.
|
|
- API: `parse_wit(source)` y `parse_wit_file(path)` devuelven
|
|
`Vec<WitInterface>` (uno por `world` declarado).
|
|
- Interfaces importadas/exportadas (no sólo funciones) se resuelven
|
|
por nombre via `resolve.interfaces[id].name`.
|
|
- Example `crates/core/brahman-card-wit/examples/brahman-wit-info.rs`
|
|
CLI: `brahman-wit-info shared_wit/protocol.wit` → lista paquete,
|
|
worlds, imports y exports.
|
|
- 4 tests: inline, archivo real (`shared_wit/protocol.wit`), parse
|
|
error, world vacío.
|
|
- Validado contra `protocol.wit`: detecta worlds `module` y
|
|
`admin-host` con sus imports/exports correctos.
|
|
|
|
### `7b589b8` chore: agrega CHANGELOG.md retroactivo
|
|
- `CHANGELOG.md` en la raíz con los 11 commits previos documentados
|
|
acción por acción. A partir de este punto, cada cambio sustantivo
|
|
actualiza también este archivo en el mismo commit.
|
|
|
|
### `8a83a26` feat(handshake): notificación push de matches
|
|
- Frame `MatchEvent { kind: Available | Lost, ... }` añadido al protocolo.
|
|
- `Session::run_post_handshake` usa `tokio::select!` para multiplexar
|
|
reads del cliente y un canal `mpsc` push del server.
|
|
- Server: `SessionTxTable` (Arc<Mutex<HashMap<SessionId, Sender<Frame>>>>)
|
|
y `LastMatches` para diff por sesión. `broadcast_match_diffs` corre
|
|
tras cada `register` y `unregister`, emite sólo los cambios.
|
|
- Capacity del canal push: 32 (ephemeral, `try_send` non-blocking).
|
|
- Client: `VecDeque<MatchEvent>` interno, `take_event()` (non-blocking)
|
|
y `await_event(timeout)`. `ping()` ahora drena MatchEvents intermedios
|
|
hasta encontrar el Pong.
|
|
- Example `crates/core/brahman-handshake/examples/subscriber.rs`.
|
|
- Test `match_event_pushed_on_producer_arrival` (handshake integ 6→7).
|
|
|
|
### `70a7a0d` feat: segundo módulo (nakui) + admin API + brahman-status
|
|
- Crate nuevo `crates/shared/brahman-sidecar` (DRY del thread + tokio +
|
|
ping loop). API: `spawn(card)` / `spawn_with_handle(config)`.
|
|
- `nakui` cmd_run llama `brahman_sidecar::spawn` antes de `run_server`.
|
|
Card: lifecycle Daemon, supervision Restart, flow `command` (json) /
|
|
`report` (json).
|
|
- Crate nuevo `crates/core/brahman-admin` con `StatusSnapshot` JSON
|
|
line-delim, `AdminServer` y `client::query`.
|
|
- ente-zero levanta también el AdminServer en `primordial_loop`.
|
|
- Example `crates/shared/brahman-sidecar/examples/presence.rs`
|
|
(módulo dummy long-lived parametrizable por label).
|
|
- Example `crates/core/brahman-admin/examples/brahman-status.rs`
|
|
(CLI que pretty-printa el snapshot).
|
|
- `brahman-broker`: `BrokeredCard` ahora incluye `lifecycle`. `Endpoint`
|
|
y `Match` derivan `Serialize`/`Deserialize`. Nuevo `Broker::cards()`
|
|
iterador.
|
|
- `brahman-card`: `pub use ::ulid` para que módulos no dependan de ulid.
|
|
- yahweh-shell migrado al sidecar compartido (96→53 LOC).
|
|
|
|
### `595f68e` feat(yahweh-shell): primer módulo brahman vivo
|
|
- yahweh-shell spawnea sidecar antes de `Application::new()`.
|
|
- Card declarada: label `brahman.ui_engine`, lifecycle Widget,
|
|
supervision Delegate, payload Virtual, flow input `render-data`
|
|
(json) / output `user-intent` (json).
|
|
- Sidecar en thread aparte con tokio current_thread runtime,
|
|
desacoplado del runtime GPUI.
|
|
|
|
### `df9d10c` feat(ente-zero): enchufa el handshake server al Init real
|
|
- ente-zero levanta `brahman_handshake::server::Server::bind` en
|
|
`primordial_loop` después del ente-bus, con degradación grácil
|
|
si bind falla (mismo patrón que uevents).
|
|
- Nuevo módulo `brahman-handshake/src/transport.rs`: helper
|
|
`default_socket_path()` con resolución `BRAHMAN_INIT_SOCKET` →
|
|
`XDG_RUNTIME_DIR` → `TMPDIR`.
|
|
- Example `crates/core/brahman-handshake/examples/probe.rs`.
|
|
- Validación end-to-end manual: probe contra ente-zero vivo
|
|
imprime `HelloAck: session=... init_attached=true`.
|
|
|
|
### `07d77a3` feat(handshake): integra el broker con el ciclo de sesiones
|
|
- `ServerConfig` acepta `Option<Arc<Mutex<Broker>>>`.
|
|
- `register_session` indexa la Card en el broker y la `SessionRegistry`
|
|
antes de emitir HelloAck.
|
|
- `Session::handle` refactor a `do_handshake → run_post_handshake →
|
|
cleanup` con cleanup unificado (broker + sessions).
|
|
- Tests integ nuevos: `broker_registers_and_unregisters_with_session`
|
|
y `broker_matches_two_live_modules`.
|
|
- Fix colateral: `brahman-card::TypeRef` pasa de internally-tagged
|
|
(`#[serde(tag = "kind")]`) a externally-tagged. Postcard no soporta
|
|
internally-tagged en formatos no self-describing. JSON cambia de
|
|
`{"kind":"primitive","name":"x"}` a `{"primitive":{"name":"x"}}`.
|
|
|
|
### `5091106` feat(core): brahman-broker — matching híbrido
|
|
- Crate nuevo `crates/core/brahman-broker`.
|
|
- 3 estrategias de matching: `Exact`, `Structural`, `ExactThenStructural`
|
|
(default). Devuelven `Match::via` con la estrategia que ganó.
|
|
- Override `pin_to`: el consumer pide un productor por label; si la
|
|
pista no resuelve, cae en type-search.
|
|
- Tiebreak por `Card.priority` desc, luego `label` asc (estable y
|
|
determinista).
|
|
- API: `register`, `unregister`, `find_producer_for`, `all_matches`,
|
|
`cards`, `sessions`, `len`, `is_empty`.
|
|
- 11 tests (matching, pin_to, priority, no-self-loops, all-matches).
|
|
|
|
### `814390f` feat(core): brahman-handshake — protocolo runtime
|
|
- Crate nuevo `crates/core/brahman-handshake` con server y client
|
|
Rust↔Rust sobre Unix socket.
|
|
- Frames length-prefixed (4 bytes LE) + cuerpo postcard.
|
|
- Mensajes: `Hello`, `HelloAck`, `Ping`, `Pong`, `Farewell`, `Error`.
|
|
- `MAX_FRAME_BYTES = 4 MiB` para evitar reservas absurdas.
|
|
- Tradeoff: drop `extensions`/`extra` de Card por incompat
|
|
postcard ↔ `serde_json::Value`. Forward-compat queda en
|
|
`schema_version` + `protocol_version` negotiation.
|
|
- 4 tests integ + 1 unit en codec.
|
|
|
|
### `ed0e973` refactor(arje): migra ente-card a re-export de brahman-card
|
|
- `ente-card/src/lib.rs` reescrito como crate-shim de re-export
|
|
(327 LOC → 25 LOC).
|
|
- `EntityCard` ≡ `brahman_card::Card` por type alias.
|
|
- `ente-card/Cargo.toml`: deps reducidas a `brahman-card`.
|
|
- `Card` impl `Default` (Ulid::nil(), label vacío) para que
|
|
`..Default::default()` funcione en struct-literals.
|
|
- 4 sitios en `ente-zero/src/seed.rs` actualizados con
|
|
`..Default::default()` para los campos aditivos.
|
|
- Los 21 consumidores arje compilan sin tocar fuente.
|
|
|
|
### `0feba74` feat(core): brahman-card — Tarjeta canónica híbrida
|
|
- Crate nuevo `crates/core/brahman-card`.
|
|
- Hereda de arje: `id: Ulid`, `lineage`, `Capability` tipado,
|
|
`Payload::{Wasm, Native, Virtual, Legacy}`, `SomaSpec`
|
|
(namespaces, cgroups, rlimits, cpu_affinity), `Supervision`
|
|
(Restart con backoff, OneShot, Delegate), `genesis` recursivo.
|
|
- Aditivo brahman: `Permissions` enumerados (`NetworkingPolicy`,
|
|
`FsPolicy`, `IpcPolicy`), `Lifecycle` ortogonal a Supervision,
|
|
`Priority` de scheduling, `Flows` con `TypeRef` discriminado
|
|
(Primitive | Wit), `pin_to` opcional.
|
|
- `TrustLevel` derivado de `Permissions` (no declarado).
|
|
- `ResolvedCard { card, wit: Option<WitInterface>, trust }`.
|
|
- Soporta JSON (canónico) + TOML (auto-detectado por extensión).
|
|
- 8 tests incluido `arje_seed_format_compatible` que valida que
|
|
el JSON de arje sigue parseando con defaults para los aditivos.
|
|
|
|
### `4d50bfc` chore: absorbe nakui (ERP matemático) en modules/nakui
|
|
- `~/nakui` → `crates/modules/nakui/{core,modules}`.
|
|
- `core/`: el crate `nakui-core` con 4 bins (nakui, demo,
|
|
inventory_demo, sales_demo) y tests.
|
|
- `modules/{inventory,sales,treasury}/`: data declarativa
|
|
(`nsmc.json`, `schema.k`, `morphisms/`) que el crate consume.
|
|
No son crates Cargo.
|
|
- Deps directas (no `workspace = true`): thiserror v1, surrealdb,
|
|
rhai, petgraph. No conflicto con el resto del workspace.
|
|
|
|
### `53dbdf0` chore: monorepo inicial con arje + minga + yahweh absorbidos
|
|
- 45 crates absorbidos en 4 ejes:
|
|
- `crates/core/`: 24 crates de arje (Init systemd-compatible:
|
|
`ente-card`, `ente-zero`, `ente-kernel`, `ente-bus`, `ente-cas`,
|
|
`ente-soma`, `ente-wasm`, `ente-snapshot`, `ente-brain`,
|
|
`ente-echo`, `ente-policy-provider`, + 12 `*-compat`).
|
|
- `crates/modules/semantic_dht/`: 5 crates de minga (`minga-core`
|
|
con AST/CAS/MST, `minga-p2p` con libp2p Kad, `minga-store`,
|
|
`minga-vfs`, `minga-cli`).
|
|
- `crates/modules/ui_engine/`: 11 crates de yahweh (libs/{core,
|
|
theme, bus, providers}, widgets/{tree, splitter, tabs, tiled,
|
|
container_core, text_input}).
|
|
- `crates/apps/`: 5 crates de yahweh (file_explorer,
|
|
database_explorer, text_viewer, image_viewer, yahweh-shell).
|
|
- `shared_wit/protocol.wit` con handshake/lifecycle inicial.
|
|
- `Cargo.toml` unificado: thiserror bumped a 2 (transparente para
|
|
arje), tokio "full", paths intra-workspace de yahweh redirigidos.
|
|
- `cargo check --workspace`: 0 errores (sólo dead-code warnings
|
|
preexistentes en ente-zero).
|
|
|