# SLM NeoDB — Arquitectura Interna Rust v1.0

**Documento:** Diseño de Módulos y Contratos Internos  
**Motor:** SLMTR1  
**Lenguaje:** Rust Edition 2021  
**Estado:** Referencia de implementación para equipos de desarrollo  

---

## REGLAS PARA TODOS LOS AGENTES Y DESARROLLADORES

1. **Nunca usar `unwrap()` ni `expect()` en código de producción.** Todo error se propaga con `?` o se maneja explícitamente.
2. **Nunca usar `unsafe` fuera del módulo de storage.** Si crees que lo necesitas, escala.
3. **Los tipos internos son diferentes a los tipos de la API.** No reutilices structs de la capa HTTP en la capa de storage.
4. **Toda función pública de un módulo debe tener un contrato documentado con `///`.**
5. **Los tests de integración son obligatorios para todo flujo del punto 3.1 de la spec.**

---

## 1. ESTRUCTURA DE CRATES

```
slm-neodb/
├── Cargo.toml                    (workspace)
├── crates/
│   ├── neodb-core/               (lógica central, sin dependencias HTTP ni storage)
│   ├── neodb-storage/            (RocksDB + Tantivy, implementa traits de neodb-core)
│   ├── neodb-http/               (servidor HTTP, parse de requests, serialización)
│   ├── neodb-query/              (parser y ejecutor del DSL de queries)
│   ├── neodb-schema/             (validación de tipos, mappings, strict mode)
│   ├── neodb-security/           (SLM-KEY, CIDR, field masking)
│   ├── neodb-scripting/          (runtime WASM y Rhai)
│   └── neodb-bin/                (binario final, une todos los crates)
```

### Regla de dependencias entre crates

```
neodb-bin
    ↓ usa
neodb-http ──────────────────────────────→ neodb-core
neodb-query ─────────────────────────────→ neodb-core
neodb-security ──────────────────────────→ neodb-core
neodb-scripting ─────────────────────────→ neodb-core
neodb-schema ────────────────────────────→ neodb-core
neodb-storage ───────────────────────────→ neodb-core
```

`neodb-core` no depende de ningún otro crate interno. Es el contrato puro. Ningún crate que no sea `neodb-storage` puede importar RocksDB o Tantivy directamente.

---

## 2. CRATE: neodb-core

Este crate define los tipos y traits fundamentales. No contiene lógica de implementación, solo contratos.

### 2.1 Tipos de Error

```rust
// crates/neodb-core/src/error.rs

#[derive(Debug, thiserror::Error)]
pub enum NeoDbError {
    #[error("Field '{field}' not in schema for type '{object_type}'")]
    UnknownField { field: String, object_type: String },

    #[error("Field '{field}' expects {expected}, got {received}")]
    TypeMismatch { field: String, expected: String, received: String },

    #[error("Field '{field}' expects number[{scale}], received {received_scale} decimal places")]
    PrecisionExceeded { field: String, scale: u8, received_scale: u8 },

    #[error("Unsupported date format: '{value}'")]
    UnsupportedDateFormat { value: String },

    #[error("Document '{id}' not found in '{index}/{object_type}'")]
    NotFound { id: String, index: String, object_type: String },

    #[error("Index '{index}' not found")]
    IndexNotFound { index: String },

    #[error("Schema not found for '{index}/{object_type}'")]
    SchemaNotFound { index: String, object_type: String },

    #[error("Document '{id}' already exists")]
    DocumentExists { id: String },

    #[error("Snapshot '{id}' has expired or does not exist")]
    SnapshotExpired { id: String },

    #[error("Storage error: {0}")]
    Storage(#[from] StorageError),

    #[error("Internal error: {0}")]
    Internal(String),
}

pub type Result<T> = std::result::Result<T, NeoDbError>;
```

### 2.2 Tipos de Datos del Dominio

```rust
// crates/neodb-core/src/types.rs

use std::collections::HashMap;
use serde::{Deserialize, Serialize};

/// Identificador único de un documento. Siempre UUID v7.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct DocId(pub String);

/// Identificador de una operación para idempotencia.
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct OperationId(pub String);

/// Timestamp en milisegundos UTC. Siempre i64.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct Timestamp(pub i64);

impl Timestamp {
    pub fn now() -> Self {
        use std::time::{SystemTime, UNIX_EPOCH};
        let ms = SystemTime::now()
            .duration_since(UNIX_EPOCH)
            .expect("time went backwards")
            .as_millis() as i64;
        Timestamp(ms)
    }
}

/// Valor de un campo en el dominio interno.
/// Esta es la representación canónica DENTRO del motor.
/// No es lo mismo que el valor JSON que recibe el cliente.
/// NOTA: No existe variante Double. Todo valor numérico usa Number
/// con escala explícita para garantizar precisión exacta.
/// Number con scale=0 es un entero. Number con scale>0 es un decimal.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum FieldValue {
    /// Texto indexable y tokenizable
    Text(String),
    /// Exacto, no tokenizado
    Keyword(String),
    /// Valor numérico de precisión exacta. El i64 ya está escalado por 10^scale.
    /// El u8 es la escala (número de decimales). scale=0 equivale a un entero.
    Number { scaled_value: i64, scale: u8 },
    /// Booleano
    Bool(bool),
    /// Timestamp en milisegundos UTC
    DateTime(i64),
    /// Vector para AI embeddings
    Vector(Vec<f32>),
    /// Geographic coordinate as (latitude, longitude) in degrees.
    Geo { lat: f64, lon: f64 },
    /// Sub-documento anidado
    Object(HashMap<String, FieldValue>),
    /// Hash BLAKE3 del valor original.
    /// El valor original ya fue descartado — esto es lo que se persiste.
    /// String hexadecimal de 64 caracteres.
    Blind(String),
    /// Valor nulo explícito
    Null,
}

/// Documento interno del motor.
/// Contiene los campos del usuario MÁS los campos internos del motor.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Document {
    pub id: DocId,
    pub index: String,
    pub object_type: String,
    pub version: u64,
    pub created_at: Timestamp,
    pub updated_at: Timestamp,
    pub deleted: bool,
    pub deleted_at: Option<Timestamp>,
    /// Campos del documento aportados por el cliente
    pub fields: HashMap<String, FieldValue>,
}

/// Referencia de ubicación de un documento.
#[derive(Debug, Clone)]
pub struct DocRef {
    pub index: String,
    pub object_type: String,
    pub id: DocId,
}
```

### 2.3 Tipos de Schema

```rust
// crates/neodb-core/src/schema.rs

use std::collections::HashMap;
use serde::{Deserialize, Serialize};

/// Tipo de un campo según el mapping.
/// NOTA: No existe Double. number[n] cubre todos los casos numéricos,
/// incluyendo enteros (scale=0) y decimales con alta precisión.
/// Esto elimina los errores de representación IEEE 754.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum FieldType {
    Text,
    Keyword,
    /// n = número de decimales. El campo se almacena como i64 escalado.
    /// Number(0) es un entero. Number(2) para dinero. Number(8) para crypto.
    /// Number(18) para valores científicos de alta precisión.
    Number(u8),
    Boolean,
    /// Se almacena como i64 milisegundos UTC
    DateTime,
    Vector { dimensions: usize },
    /// Geographic coordinate (latitude, longitude).
    Geo,
    Object,
    /// El valor original NUNCA se persiste en disco.
    /// El motor calcula BLAKE3(valor) y guarda solo el hash.
    /// Buscable por valor exacto. Irreversible por diseño.
    /// Para datos sensibles: PANs, CVVs, identificaciones.
    Blind,
    /// Campo con múltiples representaciones.
    /// El primer tipo es el principal. Los siguientes crean sub-campos.
    Multi(Vec<FieldType>),
}

/// Configuración de un campo en el mapping.
///
/// Number and DateTime fields are ALWAYS indexed as fast fields in Tantivy
/// for efficient aggregations. There is no opt-in columnar flag — it is automatic.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FieldConfig {
    pub field_type: FieldType,
}

/// Schema completo de un tipo de objeto.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ObjectSchema {
    pub index: String,
    pub object_type: String,
    pub version: u32,
    pub strict_mode: bool,
    pub fields: HashMap<String, FieldConfig>,
    pub created_at: i64,
    pub updated_at: i64,
}

impl ObjectSchema {
    /// Verifica si un campo existe en el schema.
    pub fn has_field(&self, field: &str) -> bool {
        self.fields.contains_key(field)
    }

    /// Retorna la configuración de un campo, o None si no existe.
    pub fn get_field(&self, field: &str) -> Option<&FieldConfig> {
        self.fields.get(field)
    }
}
```

### 2.4 Traits del Motor

```rust
// crates/neodb-core/src/traits.rs

use async_trait::async_trait;
use crate::{Document, DocId, DocRef, ObjectSchema, Result};

/// Contrato de operaciones de almacenamiento.
/// neodb-storage implementa este trait.
/// neodb-http y neodb-query solo conocen este trait, nunca RocksDB directamente.
#[async_trait]
pub trait StorageEngine: Send + Sync {
    /// Escribe un documento siguiendo el protocolo ACID completo del punto 3.1.
    /// Retorna el documento tal como quedó guardado (con campos internos).
    async fn write(&self, doc: Document, operation_id: OperationId) -> Result<Document>;

    /// Lee un documento por su referencia.
    /// Si el documento está deleted, lo retorna igual (el caller decide qué hacer).
    async fn read(&self, doc_ref: &DocRef) -> Result<Document>;

    /// Actualiza un documento con merge parcial.
    /// Solo los campos presentes en `fields` se modifican.
    async fn update(&self, doc_ref: &DocRef, fields: HashMap<String, FieldValue>, operation_id: OperationId) -> Result<Document>;

    /// Marca un documento como deleted (soft delete).
    async fn delete(&self, doc_ref: &DocRef, operation_id: OperationId) -> Result<Document>;

    /// Verifica si un operation_id ya fue procesado (idempotencia).
    async fn is_operation_complete(&self, op_id: &OperationId) -> Result<bool>;

    /// Guarda o actualiza un schema.
    async fn save_schema(&self, schema: ObjectSchema) -> Result<()>;

    /// Lee el schema de un tipo.
    async fn get_schema(&self, index: &str, object_type: &str) -> Result<Option<ObjectSchema>>;
}

/// Contrato de búsqueda y aggregations.
/// neodb-storage también implementa este trait.
#[async_trait]
pub trait SearchEngine: Send + Sync {
    /// Ejecuta una query y retorna resultados.
    async fn search(&self, request: SearchRequest) -> Result<SearchResponse>;

    /// Ejecuta aggregations sobre un conjunto de documentos.
    async fn aggregate(&self, request: AggRequest) -> Result<AggResponse>;
}
```

---

## 3. CRATE: neodb-storage

Implementa `StorageEngine` y `SearchEngine`. Única capa que conoce RocksDB y Tantivy.

### 3.1 Inicialización

```rust
// crates/neodb-storage/src/lib.rs

use rocksdb::{DB, Options, ColumnFamilyDescriptor};
use tantivy::Index;

pub struct NeoStorage {
    db: Arc<DB>,
    tantivy_indices: Arc<DashMap<String, Index>>,
}

/// Nombres de Column Families. NUNCA cambiar estos strings sin migración.
/// NOTE: CF:COLS and CF:IDX were removed. Tantivy handles all search,
/// filtering, aggregation, and sorting via fast fields.
pub const CF_WAL: &str = "wal";
pub const CF_DOCS: &str = "docs";
pub const CF_META: &str = "meta";

impl NeoStorage {
    pub fn open(path: &Path) -> Result<Self> {
        let mut opts = Options::default();
        opts.create_if_missing(true);
        opts.create_missing_column_families(true);

        // Configuración de RocksDB para escritura durables (core bancario)
        opts.set_wal_bytes_per_sync(0);          // sync en cada write
        opts.set_bytes_per_sync(0);              // sync en cada write
        opts.set_use_fsync(true);                // fsync real, no fdatasync

        let cfs = vec![
            ColumnFamilyDescriptor::new(CF_WAL, Self::wal_options()),
            ColumnFamilyDescriptor::new(CF_DOCS, Self::docs_options()),
            ColumnFamilyDescriptor::new(CF_META, Self::meta_options()),
        ];

        let db = DB::open_cf_descriptors(&opts, path, cfs)?;

        Ok(NeoStorage {
            db: Arc::new(db),
            tantivy_indices: Arc::new(DashMap::new()),
        })
    }

    fn wal_options() -> Options {
        let mut opts = Options::default();
        // WAL necesita escritura secuencial rápida
        opts.set_write_buffer_size(64 * 1024 * 1024); // 64MB
        opts
    }

    fn docs_options() -> Options {
        let mut opts = Options::default();
        // DOCS: compresión LZ4 para JSON
        opts.set_compression_type(rocksdb::DBCompressionType::Lz4);
        opts.set_write_buffer_size(128 * 1024 * 1024); // 128MB
        opts
    }

    fn meta_options() -> Options {
        Options::default() // pequeña, no necesita tuning especial
    }
}
```

### 3.2 Implementación del Protocolo de Escritura ACID

```rust
// crates/neodb-storage/src/write.rs

impl NeoStorage {
    /// Implementación del flujo de escritura del punto 3.1 de la spec.
    /// Este es el método más crítico del sistema. No modificar sin revisión.
    /// Protocol is 10 steps: steps 7-10 are the ACID core.
    pub fn write_document(
        &self,
        doc: Document,
        op_id: OperationId,
    ) -> Result<Document> {

        let cf_wal = self.cf_handle(CF_WAL)?;
        let cf_docs = self.cf_handle(CF_DOCS)?;

        // PASO 7: Escribir en WAL con status=PENDING + fsync
        // Si esto falla, el cliente no recibió ACK. No hay corrupción posible.
        let wal_entry = WalEntry {
            op_id: op_id.0.clone(),
            status: WalStatus::Pending,
            payload: serde_json::to_vec(&doc)?,
            timestamp: Timestamp::now().0,
        };
        let wal_key = op_id.0.as_bytes();
        let wal_value = serde_json::to_vec(&wal_entry)?;

        let sync_opts = self.sync_write_options();
        self.db.put_cf_opt(cf_wal, wal_key, &wal_value, &sync_opts)?;

        // PASO 8: Escribir documento en CF:DOCS
        let doc_key = Self::doc_key(&doc.index, &doc.object_type, &doc.id);
        let doc_value = serde_json::to_vec(&doc)?;
        let mut batch = WriteBatch::default();
        batch.put_cf(cf_docs, &doc_key, &doc_value);
        self.db.write(batch)?;

        // PASO 9: Escribir en Tantivy (full-text, fast fields para aggregations)
        // Tantivy NO es source of truth. Si falla, log y continuar.
        self.index_document(&doc.index, &doc.object_type, &doc)?;

        // PASO 10: Marcar operación como COMPLETE en WAL + fsync
        let complete_entry = WalEntry {
            op_id: op_id.0.clone(),
            status: WalStatus::Complete,
            payload: wal_entry.payload,
            timestamp: Timestamp::now().0,
        };
        let complete_value = serde_json::to_vec(&complete_entry)?;
        self.db.put_cf_opt(cf_wal, wal_key, &complete_value, &sync_opts)?;

        Ok(doc)
    }

    /// Claves de RocksDB. El formato es crítico para los scans secuenciales.
    fn doc_key(index: &str, object_type: &str, doc_id: &DocId) -> Vec<u8> {
        format!("{}/{}/{}", index, object_type, doc_id.0).into_bytes()
    }
}
```

### 3.3 Crash Recovery

```rust
// crates/neodb-storage/src/recovery.rs

impl NeoStorage {
    /// Se ejecuta una sola vez al iniciar el motor.
    /// Rehidrata cualquier operación que quedó en PENDING.
    pub fn recover_from_wal(&self) -> Result<usize> {
        let cf_wal = self.cf_handle(CF_WAL)?;

        let mut pending_ops: Vec<(Vec<u8>, WalEntry)> = Vec::new();

        // Escanear todo el WAL buscando PENDING
        let iter = self.db.iterator_cf(cf_wal, IteratorMode::Start);
        for item in iter {
            let (key, value) = item?;
            let entry: WalEntry = serde_json::from_slice(&value)?;
            if entry.status == WalStatus::Pending {
                pending_ops.push((key.to_vec(), entry));
            }
        }

        let recovered_count = pending_ops.len();

        // Re-ejecutar cada operación PENDING
        for (key, entry) in pending_ops {
            let doc: Document = serde_json::from_slice(&entry.payload)?;

            // Re-ejecutar pasos 8-9 (DOCS + Tantivy)
            // La escritura en WAL ya está, solo completar el resto
            let cf_docs = self.cf_handle(CF_DOCS)?;
            let doc_key = Self::doc_key(&doc.index, &doc.object_type, &doc.id);
            let mut batch = WriteBatch::default();
            batch.put_cf(cf_docs, &doc_key, serde_json::to_vec(&doc)?);
            self.db.write(batch)?;

            // Index in Tantivy
            self.index_document(&doc.index, &doc.object_type, &doc)?;

            // Marcar COMPLETE en WAL (paso 10)
            let complete_entry = WalEntry {
                op_id: entry.op_id,
                status: WalStatus::Complete,
                payload: vec![],
                timestamp: Timestamp::now().0,
            };
            let sync_opts = self.sync_write_options();
            self.db.put_cf_opt(
                cf_wal,
                &key,
                serde_json::to_vec(&complete_entry)?,
                &sync_opts,
            )?;
        }

        Ok(recovered_count)
    }
}
```

---

## 4. CRATE: neodb-schema

Validación de tipos y mappings. No toca RocksDB directamente.

### 4.1 Validador de Tipos

```rust
// crates/neodb-schema/src/validator.rs

use neodb_core::{FieldValue, FieldType, FieldConfig, NeoDbError, Result};
use serde_json::Value;

/// Convierte un valor JSON del cliente al FieldValue interno del motor.
/// Aplica Strict Mode si el schema está definido.
/// Este es el punto de entrada de todos los datos al sistema.
pub fn parse_field_value(
    field_name: &str,
    json_value: &Value,
    field_config: Option<&FieldConfig>,
) -> Result<FieldValue> {
    match field_config {
        Some(config) => parse_with_schema(field_name, json_value, config),
        None => infer_from_value(field_name, json_value),
    }
}

fn parse_with_schema(
    field_name: &str,
    json_value: &Value,
    config: &FieldConfig,
) -> Result<FieldValue> {
    match &config.field_type {
        FieldType::Number(scale) => parse_number(field_name, json_value, *scale),
        FieldType::DateTime => parse_datetime(field_name, json_value),
        FieldType::Text => parse_text(field_name, json_value),
        FieldType::Keyword => parse_keyword(field_name, json_value),
        FieldType::Boolean => parse_boolean(field_name, json_value),
        FieldType::Vector { dimensions } => parse_vector(field_name, json_value, *dimensions),
        FieldType::Geo => parse_geo(field_name, json_value),
        FieldType::Object => Ok(FieldValue::Object(Default::default())),
        FieldType::Blind => parse_blind(field_name, json_value),
        FieldType::Multi(types) => parse_with_schema(field_name, json_value, &FieldConfig {
            field_type: types[0].clone(),
        }),
    }
}

/// parse_blind: recibe cualquier valor, lo convierte a string,
/// calcula BLAKE3 y retorna solo el hash.
/// El valor original NUNCA se almacena ni se retorna.
fn parse_blind(field_name: &str, value: &Value) -> Result<FieldValue> {
    // Convertir el valor a su representación canónica como string
    let canonical = match value {
        Value::String(s) => s.clone(),
        Value::Number(n) => n.to_string(),
        Value::Bool(b) => b.to_string(),
        Value::Null => return Ok(FieldValue::Null),
        _ => return Err(NeoDbError::TypeMismatch {
            field: field_name.to_string(),
            expected: "blind (string, number or boolean)".to_string(),
            received: value_type_name(value).to_string(),
        }),
    };

    // Calcular BLAKE3 del valor canónico
    let hash = blake3::hash(canonical.as_bytes());
    let hash_hex = hash.to_hex().to_string();

    // El valor original (canonical) se descarta aquí al salir del scope
    Ok(FieldValue::Blind(hash_hex))
}

/// parse_number aplica Strict Mode para precisión.
/// Acepta tanto números como strings que representen números válidos.
/// number[0] = entero, number[2] = dinero, number[8] = crypto.
fn parse_number(field_name: &str, value: &Value, scale: u8) -> Result<FieldValue> {
    let decimal_str = match value {
        Value::Number(n) => n.to_string(),
        Value::String(s) => s.clone(),
        _ => return Err(NeoDbError::TypeMismatch {
            field: field_name.to_string(),
            expected: format!("number[{}]", scale),
            received: value_type_name(value).to_string(),
        }),
    };

    // Parsear el string como decimal
    let decimal_value: rust_decimal::Decimal = decimal_str.parse().map_err(|_| {
        NeoDbError::InvalidType {
            field: field_name.to_string(),
            value: decimal_str.clone(),
        }
    })?;

    // Verificar que no tiene más decimales de los permitidos
    let received_scale = decimal_value.scale() as u8;
    if received_scale > scale {
        return Err(NeoDbError::PrecisionExceeded {
            field: field_name.to_string(),
            scale,
            received_scale,
        });
    }

    // Escalar el valor: 25.10 con scale=2 → 2510
    let multiplier = 10i64.pow(scale as u32);
    let scaled = (decimal_value * rust_decimal::Decimal::from(multiplier))
        .to_i64()
        .ok_or_else(|| NeoDbError::Internal(format!("overflow scaling {}", decimal_str)))?;

    Ok(FieldValue::Number { scaled_value: scaled, scale })
}

/// parse_datetime acepta los 6 formatos definidos en la spec punto 4.5.
fn parse_datetime(field_name: &str, value: &Value) -> Result<FieldValue> {
    let ts_ms = match value {
        Value::Number(n) => {
            let n = n.as_i64().ok_or_else(|| NeoDbError::TypeMismatch {
                field: field_name.to_string(),
                expected: "datetime".to_string(),
                received: "float".to_string(),
            })?;
            match n.to_string().len() {
                10 => n * 1000,  // epoch segundos → milisegundos
                13 => n,          // epoch milisegundos
                _ => return Err(NeoDbError::UnsupportedDateFormat {
                    value: n.to_string(),
                }),
            }
        }
        Value::String(s) => parse_datetime_string(field_name, s)?,
        _ => return Err(NeoDbError::TypeMismatch {
            field: field_name.to_string(),
            expected: "datetime".to_string(),
            received: value_type_name(value).to_string(),
        }),
    };

    Ok(FieldValue::DateTime(ts_ms))
}

fn parse_datetime_string(field_name: &str, s: &str) -> Result<i64> {
    use chrono::{DateTime, NaiveDateTime, Utc};

    // ISO 8601 con Z
    if let Ok(dt) = DateTime::parse_from_rfc3339(s) {
        return Ok(dt.timestamp_millis());
    }

    // ISO 8601 sin timezone (asume UTC)
    if let Ok(dt) = NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S%.f") {
        return Ok(dt.and_utc().timestamp_millis());
    }

    // SQL Style: 2026-03-22 19:00:00
    if let Ok(dt) = NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") {
        return Ok(dt.and_utc().timestamp_millis());
    }

    // Short Date: 2026-03-22 (asume 00:00:00 UTC)
    if let Ok(d) = chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d") {
        return Ok(d.and_hms_opt(0, 0, 0).unwrap().and_utc().timestamp_millis());
    }

    Err(NeoDbError::UnsupportedDateFormat { value: s.to_string() })
}

fn value_type_name(v: &Value) -> &'static str {
    match v {
        Value::Null => "null",
        Value::Bool(_) => "boolean",
        Value::Number(_) => "number",
        Value::String(_) => "string",
        Value::Array(_) => "array",
        Value::Object(_) => "object",
    }
}
```

---

## 5. CRATE: neodb-security

Validación de SLM-KEY y CIDR. Se ejecuta ANTES de cualquier otro procesamiento.

### 5.1 Estructura de Datos

```rust
// crates/neodb-security/src/lib.rs

use std::net::IpAddr;
use ipnetwork::IpNetwork;
use dashmap::DashMap;
use serde::{Deserialize, Serialize};

/// Una SLM-KEY registrada en el sistema.
/// Los hashes viven en keys.db (SQLite), no en RocksDB.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApiKey {
    pub key_id: String,
    /// Hash BLAKE3 del key_secret. Nunca almacenar el plaintext.
    pub key_hash: String,
    pub name: String,
    pub allowed_networks: Vec<IpNetwork>,
    /// Array de reglas ABAC evaluadas en orden. Primera que coincide se aplica.
    pub permissions: Vec<PermissionRule>,
    pub quotas: KeyQuotas,
    pub created_at: i64,
    pub expires_at: Option<i64>,
    pub last_used_at: Option<i64>,
    pub active: bool,
}

/// Una regla de permiso ABAC.
/// Define qué puede hacer una key en un índice/tipo específico.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PermissionRule {
    /// Patrón glob del índice: "pagofon-*", "vitae-core", "*"
    pub index_pattern: String,
    /// Tipos permitidos. "*" coincide con cualquier tipo.
    pub types: Vec<String>,
    pub actions: Vec<Action>,
    /// Campos eliminados de la respuesta. Soporta dot notation: "usuario.pin"
    pub denied_fields: Vec<String>,
}

#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum Action {
    Read,
    Write,
    Delete,
    Search,
    Bulk,
    Schema,
    BlobRead,
    BlobWrite,
    Admin,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KeyQuotas {
    pub max_rps: u32,
    pub max_mb_per_day: u64,
}

/// Tabla de keys en memoria. Se carga desde security/keys.db al arrancar.
/// keys.db es un SQLite cifrado con AES-256-GCM, separado de RocksDB.
/// La clave de cifrado se deriva de master.key con HKDF — nunca vive en disco.
/// DashMap es un HashMap thread-safe sin locks globales.
pub struct SecurityManager {
    /// Keys cargadas en memoria para lookup O(1)
    keys: DashMap<String, ApiKey>,
    /// Ruta al archivo SQLite cifrado de keys
    keys_db_path: PathBuf,
    /// Contador de requests por key en la ventana actual de 1 segundo
    rps_counters: DashMap<String, AtomicU32>,
}

impl SecurityManager {
    /// Carga las keys desde keys.db cifrado.
    /// Requiere master_key_secret para derivar la clave de cifrado con HKDF.
    /// La clave derivada se descarta al terminar esta función.
    pub fn load(data_dir: &Path, master_key_secret: &[u8]) -> Result<Self> {
        let keys_db_path = data_dir.join("security/keys.db");

        // Derivar clave de cifrado con HKDF
        // "neodb-keys-db-v1" es el contexto de dominio — evita reutilizar
        // la misma clave derivada para diferentes propósitos
        let encryption_key = hkdf_derive(master_key_secret, b"neodb-keys-db-v1");

        // Descifrar y abrir keys.db
        let conn = open_encrypted_sqlite(&keys_db_path, &encryption_key)?;

        // Cargar todas las keys activas en memoria
        let keys = DashMap::new();
        let mut stmt = conn.prepare(
            "SELECT key_id, key_hash, name, allowed_networks, permissions,
                    max_rps, max_mb_per_day, created_at, expires_at,
                    last_used_at, active FROM keys WHERE active = 1"
        )?;

        // ... mapear filas a ApiKey structs y cargar en DashMap

        // La encryption_key se descarta aquí al salir del scope (Rust drop)
        // No queda en memoria más tiempo del necesario
        Ok(SecurityManager {
            keys,
            keys_db_path,
            rps_counters: DashMap::new(),
        })
    }

    /// Persiste una nueva key en keys.db cifrado.
    /// Se llama después de crear una key por API.
    pub fn persist_key(&self, key: &ApiKey, master_key_secret: &[u8]) -> Result<()> {
        let encryption_key = hkdf_derive(master_key_secret, b"neodb-keys-db-v1");
        let conn = open_encrypted_sqlite(&self.keys_db_path, &encryption_key)?;
        // INSERT en SQLite con los datos de la key
        // encryption_key se descarta al salir del scope
        Ok(())
    }
}

impl SecurityManager {
    /// Valida una request entrante.
    /// Retorna el contexto de seguridad si pasa, o Err si debe rechazarse.
    /// Los rechazos siempre son silenciosos a nivel TCP.
    pub fn validate_request(
        &self,
        key_header: &str,  // formato: "key_id:key_secret"
        source_ip: IpAddr,
        index: &str,
        action: Action,
    ) -> Result<SecurityContext, SecurityReject> {

        // Separar key_id del key_secret
        let (key_id, key_secret) = key_header.split_once(':')
            .ok_or(SecurityReject::SilentDrop)?;

        // Buscar la key por ID — O(1)
        let key = self.keys
            .get(key_id)
            .ok_or(SecurityReject::SilentDrop)?;

        // Verificar que la key esté activa
        if !key.active {
            return Err(SecurityReject::SilentDrop);
        }

        // Verificar expiración
        if let Some(expires_at) = key.expires_at {
            if Timestamp::now().0 > expires_at {
                return Err(SecurityReject::SilentDrop);
            }
        }

        // Verificar hash BLAKE3 del key_secret recibido
        let received_hash = blake3::hash(key_secret.as_bytes());
        let received_hash_str = received_hash.to_hex().to_string();
        if received_hash_str != key.key_hash {
            return Err(SecurityReject::SilentDrop);
        }

        // Verificar IP en CIDR
        let ip_allowed = key.allowed_networks
            .iter()
            .any(|net| net.contains(source_ip));
        if !ip_allowed {
            return Err(SecurityReject::SilentDrop);
        }

        // Verificar quota de RPS
        self.check_rps_quota(&key.key_id, key.quotas.max_rps)?;

        // Encontrar la primera regla de permisos que coincide
        let matching_rule = key.permissions.iter()
            .find(|rule| {
                glob_matches(&rule.index_pattern, index)
                && (rule.types.contains(&"*".to_string())
                    || rule.types.iter().any(|t| glob_matches(t, "placeholder")))
                && rule.actions.contains(&action)
            })
            .ok_or(SecurityReject::SilentDrop)?;

        Ok(SecurityContext {
            key_id: key.key_id.clone(),
            key_name: key.name.clone(),
            denied_fields: matching_rule.denied_fields.clone(),
        })
    }
}

/// Contexto de seguridad que se adjunta al hilo de la request.
/// Viaja por todo el pipeline de procesamiento incluyendo el audit log.
#[derive(Debug, Clone)]
pub struct SecurityContext {
    pub key_id: String,
    pub key_name: String,
    /// Campos que deben eliminarse del JSON antes de responder al cliente.
    /// Soporta notación con punto para campos anidados: "usuario.pin"
    pub denied_fields: Vec<String>,
}

pub enum SecurityReject {
    /// Cierre silencioso de conexión TCP. No hay respuesta HTTP.
    SilentDrop,
    /// Respuesta 403 o 429 con mensaje.
    Forbidden(String),
    TooManyRequests,
}
```

### 5.2 Field Masking

```rust
// crates/neodb-security/src/masking.rs

use serde_json::{Value, Map};

/// Elimina los campos denegados del JSON de respuesta.
/// Se ejecuta JUSTO ANTES de serializar la respuesta para enviar por red.
/// Nunca modifica el documento en storage.
pub fn apply_field_mask(
    mut json: Value,
    denied_fields: &[String],
) -> Value {
    if denied_fields.is_empty() {
        return json;
    }

    if let Value::Object(ref mut map) = json {
        for field in denied_fields {
            map.remove(field);
        }
    }

    json
}
```

---

## 6. CRATE: neodb-http

Servidor HTTP basado en Axum. No contiene lógica de negocio.

### 6.1 Estado del Servidor

```rust
// crates/neodb-http/src/state.rs

use std::sync::Arc;
use neodb_core::{StorageEngine, SearchEngine};
use neodb_security::SecurityManager;
use neodb_schema::SchemaRegistry;
use neodb_query::QueryEngine;

/// Estado compartido entre todos los handlers HTTP.
/// Se crea una sola vez al arrancar y se clona con Arc.
#[derive(Clone)]
pub struct AppState {
    pub storage: Arc<dyn StorageEngine>,
    pub search: Arc<dyn SearchEngine>,
    pub security: Arc<SecurityManager>,
    pub schema: Arc<SchemaRegistry>,
    pub query_engine: Arc<QueryEngine>,
}
```

### 6.2 Middleware de Seguridad

```rust
// crates/neodb-http/src/middleware.rs

use axum::{
    extract::{ConnectInfo, State},
    http::{Request, StatusCode},
    middleware::Next,
    response::Response,
};
use std::net::SocketAddr;
use neodb_security::SecurityReject;

/// Middleware que se ejecuta PRIMERO en todas las requests.
/// Extrae SLM-KEY, valida CIDR y adjunta SecurityContext al request.
pub async fn security_middleware(
    ConnectInfo(addr): ConnectInfo<SocketAddr>,
    State(state): State<AppState>,
    mut request: Request<Body>,
    next: Next,
) -> Result<Response, StatusCode> {
    let key_value = request
        .headers()
        .get("SLM-KEY")
        .and_then(|v| v.to_str().ok())
        .unwrap_or("");

    // Extraer índice y acción del path para validar permisos
    let (index, action) = extract_index_action(request.uri().path(), request.method());

    match state.security.validate_request(key_value, addr.ip(), &index, action) {
        Ok(ctx) => {
            // Adjuntar el contexto de seguridad al request para que
            // los handlers downstream puedan acceder a él
            request.extensions_mut().insert(ctx);
            Ok(next.run(request).await)
        }
        Err(SecurityReject::SilentDrop) => {
            // Cerrar conexión sin respuesta HTTP
            // En Axum esto se logra retornando un error de bajo nivel
            Err(StatusCode::from_u16(499).unwrap()) // conexión cerrada por cliente
        }
        Err(SecurityReject::Forbidden(msg)) => {
            Err(StatusCode::FORBIDDEN)
        }
        Err(SecurityReject::TooManyRequests) => {
            Err(StatusCode::TOO_MANY_REQUESTS)
        }
    }
}
```

### 6.3 Handler de Inserción (Ejemplo Canónico)

```rust
// crates/neodb-http/src/handlers/write.rs

use axum::{
    extract::{Path, State, Extension},
    http::StatusCode,
    Json,
    response::IntoResponse,
};
use neodb_security::SecurityContext;
use uuid::Uuid;

/// POST /{index}/{type}
/// Crea un nuevo documento.
pub async fn create_document(
    Path((index, object_type)): Path<(String, String)>,
    State(state): State<AppState>,
    Extension(security_ctx): Extension<SecurityContext>,
    Json(mut body): Json<serde_json::Value>,
) -> impl IntoResponse {

    // Determinar el ID del documento
    let doc_id = if let Some(id) = body.get("_id").and_then(|v| v.as_str()) {
        DocId(id.to_string())
    } else {
        DocId(Uuid::now_v7().to_string())
    };

    // Remover _id del body si vino ahí (vive en campos internos, no en fields)
    if let serde_json::Value::Object(ref mut map) = body {
        map.remove("_id");
    }

    // Obtener el schema del tipo (puede ser None si no hay schema definido)
    let schema = state.schema.get_schema(&index, &object_type).await;

    // Parsear y validar los campos del body
    let fields = match neodb_schema::parse_document_fields(&body, schema.as_ref()) {
        Ok(fields) => fields,
        Err(e) => return (StatusCode::BAD_REQUEST, Json(error_response(e))).into_response(),
    };

    // Construir el documento interno
    let now = Timestamp::now();
    let doc = Document {
        id: doc_id,
        index: index.clone(),
        object_type: object_type.clone(),
        version: 1,
        created_at: now,
        updated_at: now,
        deleted: false,
        deleted_at: None,
        fields,
    };

    // Generar Operation ID (puede venir del cliente para idempotencia)
    // TODO: extraer del header SLM-OPERATION-ID si existe
    let op_id = OperationId(Uuid::now_v7().to_string());

    // Ejecutar la escritura ACID
    match state.storage.write(doc, op_id).await {
        Ok(saved_doc) => {
            let response = serde_json::json!({
                "status": "created",
                "_id": saved_doc.id.0,
                "_index": saved_doc.index,
                "_type": saved_doc.object_type,
                "_version": saved_doc.version,
                "_created_at": saved_doc.created_at.0,
            });
            (StatusCode::CREATED, Json(response)).into_response()
        }
        Err(NeoDbError::DocumentExists { id }) => {
            (StatusCode::CONFLICT, Json(error_response_code("DOCUMENT_EXISTS", &id))).into_response()
        }
        Err(e) => {
            tracing::error!("write error: {:?}", e);
            (StatusCode::INTERNAL_SERVER_ERROR, Json(error_response(e))).into_response()
        }
    }
}
```

### 6.4 Router Principal

```rust
// crates/neodb-http/src/router.rs

use axum::{
    Router,
    routing::{get, post, put, patch, delete},
    middleware,
};

pub fn build_router(state: AppState) -> Router {
    Router::new()
        // CRUD de documentos
        .route("/:index/:type",           post(handlers::write::create_document))
        .route("/:index/:type/:id",       get(handlers::read::get_document))
        .route("/:index/:type/:id",       put(handlers::write::update_document))
        .route("/:index/:type/:id",       delete(handlers::write::delete_document))
        // Búsqueda (índice simple y glob multi-index)
        .route("/:index/:type/_search",   post(handlers::search::search_documents))
        .route("/:index/:type/_search",   get(handlers::search::search_documents_get))
        // Schema
        .route("/:index/:type/_schema",   post(handlers::schema::create_schema))
        .route("/:index/:type/_schema",   put(handlers::schema::replace_schema))
        .route("/:index/:type/_schema",   patch(handlers::schema::patch_schema))
        .route("/:index/:type/_schema",   get(handlers::schema::get_schema))
        // Bulk
        .route("/:index/:type/_bulk",     post(handlers::bulk::bulk_insert))
        // Blob
        .route("/:index/:type/:id/_blob", get(handlers::blob::download_blob))
        .route("/_blob/stats",            get(handlers::blob::blob_stats))
        // Suggest
        .route("/:index/:type/_suggest",  post(handlers::suggest::suggest))
        // Gestión de índices
        .route("/_indices",               get(handlers::indices::list_indices))
        .route("/_indices/:glob",         get(handlers::indices::list_indices_glob))
        .route("/:index",                 delete(handlers::indices::delete_index))
        .route("/:index/_flush",          post(handlers::indices::flush_index))
        // Aliases
        .route("/_aliases",               post(handlers::aliases::manage_aliases))
        .route("/_aliases",               get(handlers::aliases::list_aliases))
        .route("/_aliases/:glob",         get(handlers::aliases::list_aliases_glob))
        .route("/:alias/_indices",        get(handlers::aliases::get_alias_indices))
        // Stats y Health
        .route("/:index/_stats",          get(handlers::stats::index_stats))
        .route("/_health",                get(handlers::health::health_check))
        // Seguridad
        .route("/_security/keys",              post(handlers::security::create_key))
        .route("/_security/keys",              get(handlers::security::list_keys))
        .route("/_security/keys/:id",          get(handlers::security::get_key))
        .route("/_security/keys/:id",          patch(handlers::security::update_key))
        .route("/_security/keys/:id",          delete(handlers::security::delete_key))
        .route("/_security/keys/:id/_rotate",  post(handlers::security::rotate_key))
        .route("/_security/_explain",          post(handlers::security::explain_security))
        .route("/_audit/_search",              post(handlers::audit::search_audit_log))
        // Scripts
        .route("/_scripts/:name",         post(handlers::scripts::register_script))
        .route("/_scripts/:name",         patch(handlers::scripts::update_script))
        .route("/_scripts/:name",         get(handlers::scripts::get_script))
        .route("/_scripts/:name",         delete(handlers::scripts::delete_script))
        // Tareas de background
        .route("/_tasks/:id",             get(handlers::tasks::get_task))
        .route("/_snapshots/:id",         delete(handlers::snapshots::delete_snapshot))
        // Aplicar middleware de seguridad a TODAS las rutas
        .layer(middleware::from_fn_with_state(state.clone(), security_middleware))
        .with_state(state)
}
```

---

## 7. CRATE: neodb-bin

El binario final. Une todos los crates y maneja el ciclo de vida del proceso.

```rust
// crates/neodb-bin/src/main.rs

use std::path::PathBuf;
use clap::Parser;
use tokio::signal;
use tracing_subscriber;

#[derive(Parser)]
#[command(name = "neodb")]
struct Cli {
    #[arg(long, default_value = "./data")]
    data_dir: PathBuf,

    #[arg(long, default_value = "127.0.0.1:7700")]
    bind: String,

    #[arg(long, default_value = "info")]
    log_level: String,
}

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let cli = Cli::parse();

    // Inicializar tracing
    tracing_subscriber::fmt()
        .with_env_filter(&cli.log_level)
        .init();

    tracing::info!("SLM NeoDB SLMTR1 starting...");

    // PASO 1: Abrir storage (RocksDB + Tantivy)
    tracing::info!("Opening storage at {:?}", cli.data_dir);
    let storage = neodb_storage::NeoStorage::open(&cli.data_dir)?;

    // PASO 2: Crash recovery desde WAL
    tracing::info!("Running WAL recovery...");
    let recovered = storage.recover_from_wal().await?;
    if recovered > 0 {
        tracing::warn!("Recovered {} pending operations from WAL", recovered);
    } else {
        tracing::info!("WAL recovery: no pending operations");
    }

    // PASO 3: Cargar SLM-KEYs desde CF:META
    tracing::info!("Loading security keys...");
    let security = neodb_security::SecurityManager::load_from_storage(&storage).await?;

    // PASO 4: Cargar schemas desde CF:META
    tracing::info!("Loading schemas...");
    let schema_registry = neodb_schema::SchemaRegistry::load_from_storage(&storage).await?;

    // PASO 5: Construir estado de la app
    let state = neodb_http::AppState {
        storage: Arc::new(storage),
        search: Arc::new(neodb_storage::NeoSearch::new()),
        security: Arc::new(security),
        schema: Arc::new(schema_registry),
        query_engine: Arc::new(neodb_query::QueryEngine::new()),
    };

    // PASO 6: Construir router y arrancar servidor HTTP
    let router = neodb_http::build_router(state);
    let listener = tokio::net::TcpListener::bind(&cli.bind).await?;
    tracing::info!("SLM NeoDB listening on {}", cli.bind);
    tracing::info!("Engine: SLMTR1 | Status: READY");

    // Servidor con graceful shutdown
    axum::serve(
        listener,
        router.into_make_service_with_connect_info::<std::net::SocketAddr>(),
    )
    .with_graceful_shutdown(shutdown_signal())
    .await?;

    tracing::info!("SLM NeoDB shutdown complete");
    Ok(())
}

async fn shutdown_signal() {
    let ctrl_c = async {
        signal::ctrl_c().await.expect("failed to install Ctrl+C handler");
    };
    let terminate = async {
        signal::unix::signal(signal::unix::SignalKind::terminate())
            .expect("failed to install signal handler")
            .recv()
            .await;
    };
    tokio::select! {
        _ = ctrl_c => {},
        _ = terminate => {},
    }
    tracing::info!("Shutdown signal received");
}
```

---

## 7bis. BÚSQUEDA VECTORIAL — DÓNDE VIVE EL GRAFO HNSW

Esta sección faltaba y su ausencia costó un incidente: un equipo de RAG diagnosticó mal un
fallo porque no había forma de saber si el grafo se persistía o se reconstruía.

### El grafo NO se deriva de los documentos

El índice KNN es un grafo HNSW propio, en memoria, gestionado por `VectorIndexManager`
(`neodb-query/src/vector_index.rs`). **No se puede reconstruir leyendo CF:DOCS**, porque un
documento solo aporta un vector si su campo estaba declarado `vector[n]` cuando se escribió —
sin la declaración el valor se guarda como un arreglo de números común, que no es un vector.

Esa es la razón de fondo de que el orden importe: declarar el schema después de cargar los
datos deja un corpus del que el grafo no puede alimentarse. Ver la nota de `vector[n]` en
`api-reference.md`.

### Persistencia y arranque

| Momento | Qué pasa |
|---|---|
| Escritura | El vector se normaliza (L2) y se inserta en el grafo en memoria |
| Apagado limpio | El grafo se serializa a `{data-dir}/vector_index/{index}_{tipo}_{campo}.nhn1` junto con una marca de agua (el timestamp de WAL más reciente ya incorporado) y un centinela de cierre limpio |
| Arranque tras apagado limpio | Se carga el snapshot y **se salta la reconstrucción desde el WAL** — el log lo dice: `vector index clean at last shutdown — skipping WAL replay` |
| Arranque tras caída | Se carga el snapshot y se **reproduce el WAL desde la marca de agua**, para recuperar lo escrito después del último guardado |

### Las dos consecuencias que hay que tener presentes

**1. El WAL se poda.** La reproducción solo alcanza lo que el WAL todavía retiene. Si un
snapshot se pierde o queda muy viejo y las entradas correspondientes ya se podaron, esos
vectores no vuelven por sí solos: hay que reescribir los documentos.

**2. Hay una red de seguridad, pero tiene un piso.** Si el grafo no devuelve candidatos, el
handler cae a un recorrido exacto sobre los documentos cargados (`execute_knn`). Eso cubre un
grafo frío o ausente — pero **solo si el campo está guardado como vector**. Cuando no lo está,
fallan los dos caminos a la vez, que es el caso reportado.

### Diagnóstico

Un `knn` que devuelve vacío sobre un tipo con documentos ejecuta `diagnose_empty_knn`
(`neodb-http/src/handlers/search.rs`), que distingue campo inexistente, campo presente pero no
vectorial, y campo vectorial sin vecinos. Solo corre cuando el resultado ya salió vacío, así
que no tiene costo en el camino normal.

---

## 8. CARGO.TOML DEL WORKSPACE

```toml
# Cargo.toml (raíz del workspace)

[workspace]
members = [
    "crates/neodb-core",
    "crates/neodb-storage",
    "crates/neodb-http",
    "crates/neodb-query",
    "crates/neodb-schema",
    "crates/neodb-security",
    "crates/neodb-scripting",
    "crates/neodb-bin",
]
resolver = "2"

[workspace.dependencies]
# Async runtime
tokio = { version = "1", features = ["full"] }
async-trait = "0.1"

# HTTP
axum = { version = "0.7", features = ["macros"] }
tower = "0.4"
tower-http = { version = "0.5", features = ["trace", "cors"] }

# Storage
rocksdb = "0.21"
tantivy = "0.22"

# Serialización
serde = { version = "1", features = ["derive"] }
serde_json = "1"

# Tipos
uuid = { version = "1", features = ["v7"] }
rust_decimal = "1"
chrono = { version = "0.4", features = ["serde"] }
ipnetwork = "0.20"

# Concurrencia
dashmap = "5"
parking_lot = "0.12"

# Errores
thiserror = "1"
anyhow = "1"

# Scripting
rhai = "1"
wasmtime = "17"

# CLI
clap = { version = "4", features = ["derive"] }

# Logging
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }

# Hash
blake3 = "1"
# Cifrado (para keys.db)
aes-gcm = "0.10"
hkdf = "0.12"
sha2 = "0.10"
# SQLite (para keys.db)
rusqlite = { version = "0.31", features = ["bundled"] }
```

---

## 9. CONVENCIONES DE CÓDIGO

### 9.1 Manejo de Errores

```rust
// CORRECTO: propagar con ?
async fn mi_funcion() -> Result<Document> {
    let doc = storage.read(&doc_ref).await?;
    let schema = schema_registry.get_schema(&index, &obj_type).await?;
    Ok(doc)
}

// INCORRECTO: nunca usar unwrap en producción
let doc = storage.read(&doc_ref).await.unwrap(); // PROHIBIDO
```

### 9.2 Logging

```rust
// Usar tracing, no println ni eprintln
tracing::info!("Document created: {}", doc_id);
tracing::warn!("WAL recovery found {} pending ops", count);
tracing::error!("Storage write failed: {:?}", error);

// Para debug que solo aparece en desarrollo
tracing::debug!("Parsed fields: {:?}", fields);
```

### 9.3 Tests

```rust
// Cada módulo tiene su propio test module al final del archivo
#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::TempDir;

    #[tokio::test]
    async fn test_write_and_read_document() {
        let dir = TempDir::new().unwrap();
        let storage = NeoStorage::open(dir.path()).unwrap();

        let doc = Document {
            id: DocId("test-001".to_string()),
            index: "test-index".to_string(),
            object_type: "user".to_string(),
            version: 1,
            created_at: Timestamp::now(),
            updated_at: Timestamp::now(),
            deleted: false,
            deleted_at: None,
            fields: HashMap::new(),
        };

        let op_id = OperationId("op-001".to_string());
        let saved = storage.write(doc.clone(), op_id).await.unwrap();

        assert_eq!(saved.id, doc.id);
        assert_eq!(saved.version, 1);
    }

    #[tokio::test]
    async fn test_crash_recovery() {
        // Simular crash escribiendo en WAL pero no completando
        // Verificar que recovery completa la operación correctamente
        // ESTE TEST ES OBLIGATORIO antes de merge a main
    }
}
```

### 9.4 Formato del Código

Todos los agentes deben ejecutar antes de hacer commit:
```bash
cargo fmt
cargo clippy -- -D warnings
cargo test
```

Un PR con warnings de clippy no se acepta.

---

## 10. ORDEN DE IMPLEMENTACIÓN RECOMENDADO

Para que los equipos puedan trabajar en paralelo sin bloquearse:

### Semana 1-2: Fundamentos (Equipo A)
1. `neodb-core`: tipos, errores, traits (sin implementación)
2. `neodb-storage`: Column Families de RocksDB, protocolo WAL
3. Tests de escritura ACID y crash recovery

### Semana 1-2 en paralelo: Validación (Equipo B)
1. `neodb-schema`: parser de tipos, Strict Mode, number[n], datetime
2. Tests exhaustivos de cada tipo con todos los casos edge

### Semana 2-3: Acceso (Equipo C y D)
1. `neodb-security`: SLM-KEY, CIDR, field masking (Equipo D)
2. `neodb-http`: router, middleware de seguridad, handlers CRUD (Equipo C)
3. `neodb-query`: parser del DSL, búsquedas básicas (Equipo C)

### Semana 3-4: Búsqueda y Aggregations
1. Integración de Tantivy en `neodb-storage`
2. Aggregations via Tantivy fast fields (sum, avg, max, min, stats, percentiles, histogram, range)
3. Query DSL completo con bool queries y ranges

### Semana 4+: Features Avanzados
1. `neodb-scripting`: runtime Rhai y WASM
2. Bulk ingest con streaming
3. Snapshots para paginación
4. Type Morphing con tasks de background

---

*SLM NeoDB Internal Architecture — gonzalo@slm.cloud — Marzo 2026*
