# Task 03: Error Types and Schema Validation

## Context

**Milestone:** 1 -- Signal Engine
**Phase:** m1p1 -- Core Type System and Schema
**Depends On:** Task 01 (EntityId for NotFound), Task 02 (SignalTypeDef, DecayModel, Window for validation)
**Blocks:** m1p2 (WAL), m1p3 (Storage/fjall)
**Complexity:** S

## Objective

Deliver the error hierarchy (`LumenError` with 6 variants per CODING_GUIDELINES.md) and the `SchemaBuilder` that validates and produces an immutable `Schema`. The SchemaBuilder is the single construction path for signal type definitions -- it validates inputs, computes derived values (lambda from half-life), and produces an immutable schema that every other module receives.

This task delivers the `DecaySpec` type (user-facing, e.g., `DecaySpec::Exponential { half_life }`) which is separate from `DecayModel` (internal, carries pre-computed lambda). The builder validates the spec and converts it to the model.

## Requirements

- `LumenError` must have exactly 6 variants: Storage, NotFound, Schema, Durability, Query, Internal
- All error types must implement `std::fmt::Display` and `std::error::Error`
- `SchemaError` must have variants for every validation rule
- `Schema` must be immutable after construction
- `SchemaBuilder` must validate:
  - No duplicate signal names
  - Signal names are valid identifiers (lowercase alphanumeric + underscore)
  - Positive half-life for exponential decay
  - Positive lifetime for linear decay
  - Non-empty windows for non-permanent signals
  - No velocity without windows
- `Result<T>` type alias exported from crate root
- `From` impls for ergonomic `?` operator usage
- No new dependencies (hand-implement Display/Error)

## Technical Design

### Module Structure

```
tidal/src/schema/
  error.rs          -- LumenError, SchemaError, StorageError, DurabilityError, QueryError
  validation.rs     -- Schema, SchemaBuilder, DecaySpec, SignalBuilder
tidal/src/
  lib.rs            -- pub type Result<T> = std::result::Result<T, LumenError>;
```

### Public API

```rust
// === error.rs ===

/// Top-level error type. Every public API method returns Result<T, LumenError>.
#[derive(Debug)]
pub enum LumenError {
    /// Storage engine failure. Retry may succeed.
    Storage(StorageError),
    /// Entity not found. Caller should handle.
    NotFound { kind: EntityKind, id: EntityId },
    /// Schema violation. Caller's fault -- fix the input.
    Schema(SchemaError),
    /// Signal write failed durability check. Retry required.
    Durability(DurabilityError),
    /// Query malformed. Parse error with details.
    Query(QueryError),
    /// Internal invariant violated. This is a bug in Lumen.
    Internal(String),
}

impl fmt::Display for LumenError { /* variant-specific messages */ }
impl std::error::Error for LumenError { /* source() delegates to inner errors */ }

/// Schema validation errors. Exhaustive for m1p1.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SchemaError {
    DuplicateSignalName(String),
    InvalidSignalName(String),
    InvalidHalfLife { signal_name: String, half_life_secs: f64 },
    InvalidLifetime { signal_name: String, lifetime_secs: f64 },
    EmptyWindows { signal_name: String },
    VelocityWithoutWindows { signal_name: String },
    NoSignalsDefined,
}

impl fmt::Display for SchemaError { /* actionable messages per variant */ }
impl std::error::Error for SchemaError {}

/// Stub for m1p2+. Single message field.
#[derive(Debug)]
pub struct StorageError { pub message: String }
/// Stub for m1p2+.
#[derive(Debug)]
pub struct DurabilityError { pub message: String }
/// Stub for Milestone 2+.
#[derive(Debug)]
pub struct QueryError { pub message: String }

// From impls for ? operator
impl From<SchemaError> for LumenError { ... }
impl From<StorageError> for LumenError { ... }
impl From<DurabilityError> for LumenError { ... }
impl From<QueryError> for LumenError { ... }


// === validation.rs ===

/// A validated, immutable schema.
#[derive(Debug, Clone)]
pub struct Schema { /* HashMap<String, SignalTypeDef> */ }

impl Schema {
    pub fn signal(&self, name: &str) -> Option<&SignalTypeDef>;
    pub fn signals(&self) -> impl Iterator<Item = &SignalTypeDef>;
    pub fn signal_count(&self) -> usize;
}

/// Builder for constructing a validated Schema.
pub struct SchemaBuilder { /* Vec<SignalEntry> */ }

impl SchemaBuilder {
    pub fn new() -> Self;
    pub fn signal(&mut self, name: &str, target: EntityKind, decay: DecaySpec) -> SignalBuilder<'_>;
    pub fn build(self) -> Result<Schema, SchemaError>;
}

impl Default for SchemaBuilder { fn default() -> Self { Self::new() } }

/// User-facing decay specification (before validation computes lambda).
#[derive(Debug, Clone)]
pub enum DecaySpec {
    Exponential { half_life: Duration },
    Linear { lifetime: Duration },
    Permanent,
}

/// Intermediate builder for a single signal type.
pub struct SignalBuilder<'a> { /* &mut SchemaBuilder + SignalEntry */ }

impl<'a> SignalBuilder<'a> {
    pub fn windows(self, windows: &[Window]) -> Self;
    pub fn velocity(self, enabled: bool) -> Self;
    pub fn add(self) -> &'a mut SchemaBuilder;
}
```

### Internal Design

**Validation rules in `SchemaBuilder::build()`:**

1. **Empty schema** -- `self.signals.is_empty()` -> `SchemaError::NoSignalsDefined`
2. **For each signal entry:**
   a. **Name validation** -- must be non-empty, ASCII, lowercase alphanumeric + underscore, start with a letter -> `SchemaError::InvalidSignalName`
   b. **Duplicate check** -- `signals.contains_key(&name)` -> `SchemaError::DuplicateSignalName`
   c. **Half-life validation** -- for `DecaySpec::Exponential`, `half_life.as_secs_f64() <= 0.0 || !finite` -> `SchemaError::InvalidHalfLife`
   d. **Lifetime validation** -- for `DecaySpec::Linear`, same check -> `SchemaError::InvalidLifetime`
   e. **Window check** -- for non-Permanent decay, empty windows -> `SchemaError::EmptyWindows`
   f. **Velocity check** -- `velocity && windows.is_empty()` -> `SchemaError::VelocityWithoutWindows`
3. **Convert** `DecaySpec` to `DecayModel` (computing lambda for exponential)
4. **Construct** `WindowSet` (dedup and sort)
5. **Create** `SignalTypeDef` via `pub(crate)` constructor
6. **Insert** into `HashMap<String, SignalTypeDef>`

**Signal name validation function:**

```rust
fn is_valid_signal_name(name: &str) -> bool {
    !name.is_empty()
        && name.is_ascii()
        && name.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_')
        && name.as_bytes()[0].is_ascii_lowercase()
}
```

Names must be safe for use as key components in storage (`[entity_id]\x00SIG:{name}:{window}`) and as identifiers in the query language.

**`DecaySpec` vs `DecayModel` separation:** Users specify `DecaySpec::Exponential { half_life: Duration::from_secs(604_800) }` -- no lambda. The builder validates the duration and computes `DecayModel::Exponential { half_life, lambda }`. This means users never touch lambda, and the hot-path code never recomputes it.

**Error stubs (`StorageError`, `DurabilityError`, `QueryError`)** have a single `message: String` field. They exist so that `LumenError` can be fully defined now. Later phases replace the stub with detailed variants (e.g., `StorageError::IoError`, `StorageError::Corruption`).

### Error Handling

The `SchemaBuilder::build()` method returns `Result<Schema, SchemaError>`. It does NOT return `LumenError` directly -- the caller converts via `From<SchemaError>`:

```rust
let schema = SchemaBuilder::new()
    .signal("view", EntityKind::Item, DecaySpec::Exponential {
        half_life: Duration::from_secs(604_800),
    })
        .windows(&[Window::OneHour, Window::TwentyFourHours, Window::SevenDays])
        .velocity(true)
        .add()
    .build()?; // SchemaError auto-converts to LumenError via From impl
```

## Test Strategy

### Unit Tests (Validation Logic)

```rust
// Valid schema construction
#[test]
fn valid_schema_round_trip() {
    let schema = SchemaBuilder::new()
        .signal("view", EntityKind::Item, DecaySpec::Exponential {
            half_life: Duration::from_secs(604_800),
        })
            .windows(&[Window::OneHour, Window::TwentyFourHours, Window::SevenDays, Window::AllTime])
            .velocity(true)
            .add()
        .signal("hide", EntityKind::Item, DecaySpec::Permanent)
            .add()
        .build()
        .expect("valid schema");

    assert_eq!(schema.signal_count(), 2);
    let view = schema.signal("view").unwrap();
    assert_eq!(view.name(), "view");
    assert_eq!(view.target(), EntityKind::Item);
    assert!(view.velocity_enabled());
    assert_eq!(view.windows().len(), 4);
    assert!(view.decay().lambda().is_some());

    let hide = schema.signal("hide").unwrap();
    assert_eq!(hide.windows().len(), 0);
    assert!(!hide.velocity_enabled());
    assert_eq!(*hide.decay(), DecayModel::Permanent);
}

// Duplicate signal name rejected
#[test]
fn rejects_duplicate_signal_name() {
    let result = SchemaBuilder::new()
        .signal("view", EntityKind::Item, DecaySpec::Exponential {
            half_life: Duration::from_secs(604_800),
        })
            .windows(&[Window::AllTime]).add()
        .signal("view", EntityKind::Item, DecaySpec::Exponential {
            half_life: Duration::from_secs(86_400),
        })
            .windows(&[Window::AllTime]).add()
        .build();
    assert_eq!(result, Err(SchemaError::DuplicateSignalName("view".into())));
}

// Zero half-life rejected
#[test]
fn rejects_zero_half_life() {
    let result = SchemaBuilder::new()
        .signal("bad", EntityKind::Item, DecaySpec::Exponential {
            half_life: Duration::ZERO,
        })
            .windows(&[Window::AllTime]).add()
        .build();
    assert!(matches!(result, Err(SchemaError::InvalidHalfLife { .. })));
}

// Zero linear lifetime rejected
#[test]
fn rejects_zero_linear_lifetime() {
    let result = SchemaBuilder::new()
        .signal("bad", EntityKind::Item, DecaySpec::Linear {
            lifetime: Duration::ZERO,
        })
            .windows(&[Window::AllTime]).add()
        .build();
    assert!(matches!(result, Err(SchemaError::InvalidLifetime { .. })));
}

// Empty windows on exponential signal rejected
#[test]
fn rejects_empty_windows_on_exponential() {
    let result = SchemaBuilder::new()
        .signal("bad", EntityKind::Item, DecaySpec::Exponential {
            half_life: Duration::from_secs(3600),
        })
            .add()  // no windows
        .build();
    assert!(matches!(result, Err(SchemaError::EmptyWindows { .. })));
}

// Permanent signal with empty windows accepted (the "hide" pattern)
#[test]
fn accepts_permanent_with_empty_windows() {
    let result = SchemaBuilder::new()
        .signal("hide", EntityKind::Item, DecaySpec::Permanent)
            .add()
        .build();
    assert!(result.is_ok());
}

// Velocity without windows rejected
#[test]
fn rejects_velocity_without_windows() {
    let result = SchemaBuilder::new()
        .signal("bad", EntityKind::Item, DecaySpec::Permanent)
            .velocity(true)
            .add()
        .build();
    assert!(matches!(result, Err(SchemaError::VelocityWithoutWindows { .. })));
}

// Empty schema rejected
#[test]
fn rejects_empty_schema() {
    let result = SchemaBuilder::new().build();
    assert_eq!(result, Err(SchemaError::NoSignalsDefined));
}

// Invalid signal names rejected
#[test]
fn rejects_invalid_signal_names() {
    // Empty
    let r = SchemaBuilder::new()
        .signal("", EntityKind::Item, DecaySpec::Permanent).add().build();
    assert!(matches!(r, Err(SchemaError::InvalidSignalName(_))));

    // Uppercase
    let r = SchemaBuilder::new()
        .signal("View", EntityKind::Item, DecaySpec::Permanent).add().build();
    assert!(matches!(r, Err(SchemaError::InvalidSignalName(_))));

    // Starts with digit
    let r = SchemaBuilder::new()
        .signal("1view", EntityKind::Item, DecaySpec::Permanent).add().build();
    assert!(matches!(r, Err(SchemaError::InvalidSignalName(_))));

    // Contains space
    let r = SchemaBuilder::new()
        .signal("view count", EntityKind::Item, DecaySpec::Permanent).add().build();
    assert!(matches!(r, Err(SchemaError::InvalidSignalName(_))));

    // Contains hyphen
    let r = SchemaBuilder::new()
        .signal("view-count", EntityKind::Item, DecaySpec::Permanent).add().build();
    assert!(matches!(r, Err(SchemaError::InvalidSignalName(_))));
}

// Valid signal names accepted
#[test]
fn accepts_valid_signal_names() {
    let names = ["view", "like", "skip", "hide", "search_click", "autoplay_accept", "view_24h"];
    for name in names {
        let r = SchemaBuilder::new()
            .signal(name, EntityKind::Item, DecaySpec::Permanent).add().build();
        assert!(r.is_ok(), "should accept signal name '{name}'");
    }
}

// LumenError Display formatting
#[test]
fn lumen_error_display() {
    let e = LumenError::NotFound {
        kind: EntityKind::Item,
        id: EntityId::new(42),
    };
    assert_eq!(e.to_string(), "item 42 not found");

    let e = LumenError::Schema(SchemaError::DuplicateSignalName("view".into()));
    assert!(e.to_string().contains("duplicate signal name"));

    let e = LumenError::Internal("something broke".into());
    assert!(e.to_string().contains("internal error"));
}

// Error source chain
#[test]
fn lumen_error_source() {
    use std::error::Error;
    let e = LumenError::Schema(SchemaError::NoSignalsDefined);
    assert!(e.source().is_some());

    let e = LumenError::Internal("bug".into());
    assert!(e.source().is_none());
}

// From conversions for ? operator
#[test]
fn schema_error_converts_to_lumen_error() {
    let schema_err = SchemaError::NoSignalsDefined;
    let lumen_err: LumenError = schema_err.into();
    assert!(matches!(lumen_err, LumenError::Schema(SchemaError::NoSignalsDefined)));
}
```

### Property Tests

```rust
// Any valid schema can be queried for all its signals
proptest! {
    #[test]
    fn schema_contains_all_defined_signals(
        count in 1usize..10,
    ) {
        let mut builder = SchemaBuilder::new();
        let names: Vec<String> = (0..count)
            .map(|i| format!("signal_{i}"))
            .collect();

        for name in &names {
            builder = *builder.signal(
                name,
                EntityKind::Item,
                DecaySpec::Permanent,
            ).add();
        }

        let schema = builder.build().unwrap();
        prop_assert_eq!(schema.signal_count(), count);
        for name in &names {
            prop_assert!(schema.signal(name).is_some());
        }
    }
}
```

### Full UAT-Style Schema (Integration Test)

```rust
// Build the exact schema from the Milestone 1 UAT scenario
#[test]
fn milestone_1_uat_schema() {
    let schema = SchemaBuilder::new()
        .signal("view", EntityKind::Item, DecaySpec::Exponential {
            half_life: Duration::from_secs(7 * 24 * 3600), // 7 days
        })
            .windows(&[Window::OneHour, Window::TwentyFourHours, Window::SevenDays])
            .velocity(true)
            .add()
        .signal("like", EntityKind::Item, DecaySpec::Exponential {
            half_life: Duration::from_secs(14 * 24 * 3600), // 14 days
        })
            .windows(&[Window::TwentyFourHours, Window::SevenDays, Window::AllTime])
            .velocity(true)
            .add()
        .signal("skip", EntityKind::Item, DecaySpec::Exponential {
            half_life: Duration::from_secs(24 * 3600), // 1 day
        })
            .windows(&[Window::OneHour, Window::TwentyFourHours])
            .velocity(false)
            .add()
        .build()
        .expect("UAT schema should be valid");

    assert_eq!(schema.signal_count(), 3);

    // Verify view signal
    let view = schema.signal("view").unwrap();
    assert_eq!(view.windows().len(), 3);
    assert!(view.velocity_enabled());
    let lambda = view.decay().lambda().unwrap();
    let expected_lambda = std::f64::consts::LN_2 / (7.0 * 24.0 * 3600.0);
    assert!((lambda - expected_lambda).abs() < 1e-15);

    // Verify like signal
    let like = schema.signal("like").unwrap();
    assert_eq!(like.windows().len(), 3);
    assert!(like.windows().contains(&Window::AllTime));

    // Verify skip signal
    let skip = schema.signal("skip").unwrap();
    assert!(!skip.velocity_enabled());
    let skip_lambda = skip.decay().lambda().unwrap();
    let expected_skip_lambda = std::f64::consts::LN_2 / (24.0 * 3600.0);
    assert!((skip_lambda - expected_skip_lambda).abs() < 1e-15);
}
```

## Acceptance Criteria

- [ ] `LumenError` has exactly 6 variants: Storage, NotFound, Schema, Durability, Query, Internal
- [ ] All error types implement `Display` and `Error`
- [ ] `From<SchemaError>`, `From<StorageError>`, `From<DurabilityError>`, `From<QueryError>` into `LumenError`
- [ ] `Result<T>` alias exported from `tidal/src/lib.rs`
- [ ] `SchemaBuilder::build()` rejects duplicate signal names (`SchemaError::DuplicateSignalName`)
- [ ] `SchemaBuilder::build()` rejects invalid signal names (`SchemaError::InvalidSignalName`)
- [ ] `SchemaBuilder::build()` rejects zero/negative half-life (`SchemaError::InvalidHalfLife`)
- [ ] `SchemaBuilder::build()` rejects zero/negative linear lifetime (`SchemaError::InvalidLifetime`)
- [ ] `SchemaBuilder::build()` rejects empty windows on non-permanent signals (`SchemaError::EmptyWindows`)
- [ ] `SchemaBuilder::build()` rejects velocity without windows (`SchemaError::VelocityWithoutWindows`)
- [ ] `SchemaBuilder::build()` rejects empty schema (`SchemaError::NoSignalsDefined`)
- [ ] `SchemaBuilder::build()` accepts permanent signals with empty windows (the "hide" pattern)
- [ ] Validated `Schema` is immutable and queryable by signal name
- [ ] The M1 UAT schema (view/like/skip from the roadmap) builds successfully
- [ ] `cargo clippy -- -D warnings` passes
- [ ] All unit tests, property tests, and integration tests pass
- [ ] `cargo test --lib` exits cleanly

## Research References

- [docs/research/phase1_1_type_system.md](../../../research/phase1_1_type_system.md) -- Section 3 (error handling: thiserror vs hand-implement, recommendation to hand-implement for <100 lines), Section 4 (schema validation pattern: typestate vs runtime builder vs struct+validate, recommendation for struct-with-validation / builder pattern)
- [docs/research/tidaldb_signal_ledger.md](../../../research/tidaldb_signal_ledger.md) -- validates that lambda = ln(2)/half_life is the correct formula, EntityState struct showing the fields the schema must declare
- [CODING_GUIDELINES.md](../../../../CODING_GUIDELINES.md) -- Section 7 (Error Handling: `Result<T>` everywhere, typed errors, `LumenError` enum definition with exactly 6 variants)
- [API.md](../../../../API.md) -- Schema Definition section (SchemaBuilder usage pattern, Decay enum, Window constructors)
- [ROADMAP.md](../../ROADMAP.md) -- m1p1 acceptance criteria, Milestone 1 UAT scenario (schema definition)

## Spec References

- [docs/specs/11-schema.md](../../../specs/11-schema.md) -- Schema definition API (type system, validation rules, builder pattern), schema versioning and migration, signal name uniqueness constraints, DecaySpec vs DecayModel separation
- [docs/specs/03-signal-system.md](../../../specs/03-signal-system.md) -- Signal type declaration fields (name, target_kind, decay, windows, velocity_enabled), validation constraints (positive half-life, non-empty windows for non-permanent), 40 signal types reference for UAT validation
- [docs/specs/00-architecture-overview.md](../../../specs/00-architecture-overview.md) -- System architecture showing Schema as input to all subsystems (storage, query, ranking), code module map showing `schema/` layout with error.rs and validation.rs
- [docs/specs/01-storage-engine.md](../../../specs/01-storage-engine.md) -- Section 5 (key encoding: signal names used in `SIG:{name}:{window}` storage keys, constraining valid signal name characters)
- [docs/specs/09-ranking-scoring.md](../../../specs/09-ranking-scoring.md) -- Ranking profiles reference signal names and windows by string, confirming signal names must be valid identifiers for query language use

## Implementation Notes

- **No thiserror.** CODING_GUIDELINES.md Section 10 says "Every dependency must justify its existence against 'could we write this in 200 lines?'" The error types are ~100 lines of hand-implemented Display/Error. Adding thiserror would save ~40 lines but add a compile-time dependency. Hand-implement for now; add thiserror if the error hierarchy grows significantly in later milestones.
- **SchemaError derives PartialEq + Eq** for test assertions. This is unusual for errors but justified: these are validation errors, not I/O errors, so equality comparison is meaningful and deterministic.
- **Signal names are globally unique** regardless of target entity kind. There is no `item.view` vs `user.view`. The query language references signals by name alone (`view.velocity(24h)`). This simplifies the schema, storage keys, and query parser.
- **`Schema` is Clone.** In m1p5, when the `Lumen` struct is built, the schema will be wrapped in `Arc<Schema>` for shared ownership. For now, direct ownership and Clone suffice.
- The builder returns `&mut SchemaBuilder` from `add()`, enabling method chaining. This is a common Rust builder pattern (see `reqwest::ClientBuilder`, `tantivy::SchemaBuilder`).
