---
phase: 09-time-dimensions
plan: 01
type: tdd
wave: 1
depends_on: []
files_modified:
  - src/model.rs
autonomous: true
requirements: [TIME-01]

must_haves:
  truths:
    - "Dimension struct has optional dim_type and granularity fields that serialize/deserialize correctly as 'type' and 'granularity' JSON keys"
    - "Existing semantic view JSON definitions without type/granularity fields continue to deserialize without error (backward compat)"
    - "from_json returns a descriptive error when a dimension declares type 'time' without a granularity field"
    - "from_json returns a descriptive error when a dimension declares an unknown type value"
  artifacts:
    - path: "src/model.rs"
      provides: "Dimension struct with dim_type + granularity fields and validation in from_json"
      contains: "dim_type: Option<String>"
  key_links:
    - from: "src/model.rs Dimension struct"
      to: "src/expand.rs QueryRequest"
      via: "dim.dim_type field checked in expand() for time dimension codegen (Plan 02)"
      pattern: "dim_type"
---

<objective>
Extend the `Dimension` struct with time-typed fields and add validation to `from_json`.

Purpose: TIME-01 requires users to declare `type: "time"` + `granularity` on a dimension in the view definition JSON. This plan adds those fields to `Dimension` and enforces invariants at define-time so bad definitions fail fast.

Output: Modified `src/model.rs` with new fields + validation; all existing tests still pass; new tests covering time dimension round-trip, error cases.
</objective>

<execution_context>
@/Users/paul/.claude/get-shit-done/workflows/execute-plan.md
@/Users/paul/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/STATE.md
@src/model.rs

<interfaces>
<!-- Key types the executor needs from the current codebase -->

From src/model.rs (current state — executor MUST read this file before modifying):
```rust
#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
pub struct Dimension {
    pub name: String,
    pub expr: String,
    #[serde(default)]
    pub source_table: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
#[serde(deny_unknown_fields)]
pub struct SemanticViewDefinition {
    pub base_table: String,
    pub dimensions: Vec<Dimension>,
    pub metrics: Vec<Metric>,
    #[serde(default)]
    pub filters: Vec<String>,
    #[serde(default)]
    pub joins: Vec<Join>,
}

impl SemanticViewDefinition {
    pub fn from_json(name: &str, json: &str) -> Result<Self, String> {
        serde_json::from_str(json)
            .map_err(|e| format!("invalid definition for semantic view '{name}': {e}"))
    }
}
```

Key constraint: `SemanticViewDefinition` has `#[serde(deny_unknown_fields)]`. This means `Dimension` fields must be declared on the struct — they cannot be "extra" fields. The `deny_unknown_fields` on the PARENT struct enforces strict JSON at the top level (base_table, dimensions, metrics, filters, joins). The CHILD struct (`Dimension`) is a separate struct — adding fields to it is how you accept them in JSON.

Critical Rust issue: `type` is a Rust keyword. Use `dim_type` as the field name with `#[serde(rename = "type")]`.
</interfaces>
</context>

<feature>
  <name>Time-typed Dimension fields and validation</name>
  <files>src/model.rs</files>
  <behavior>
    Existing behavior preserved:
    - Dimension without type/granularity fields deserializes fine (backward compat via #[serde(default)])
    - Unknown top-level fields on SemanticViewDefinition are rejected (existing test unchanged)

    New behavior:
    - `{"name": "order_date", "expr": "order_date", "type": "time", "granularity": "month"}` → deserializes to Dimension with dim_type=Some("time"), granularity=Some("month")
    - Round-trip: serialize then deserialize produces identical struct
    - `type: "time"` with NO granularity → `from_json` returns Err containing "dimension 'order_date' declares type 'time' but is missing required 'granularity' field"
    - `type: "date"` (unknown type) → `from_json` returns Err containing "dimension 'order_date' has unknown type 'date'; only 'time' is supported"
    - `type: "time"` with `granularity: "quarter"` → from_json returns Err containing "'quarter' is not a supported granularity; valid values: day, week, month, year"
    - `type: null` (explicit null) → treated as None (no type), no error — just a regular dimension
    - All four supported granularities (day, week, month, year) deserialize without error when paired with type: "time"
  </behavior>
  <implementation>
    1. Add two fields to `Dimension` struct:
       ```rust
       #[serde(default, rename = "type")]
       pub dim_type: Option<String>,
       #[serde(default)]
       pub granularity: Option<String>,
       ```

    2. Update `SemanticViewDefinition::from_json` to validate after deserialization:
       - Iterate `def.dimensions`
       - If `dim_type == Some("time")`:
         - Check granularity is Some(g) where g is one of ["day","week","month","year"]
         - If granularity is None: return Err with message pattern from CONTEXT.md
         - If granularity is Some but unknown value: return Err listing valid values
       - If `dim_type == Some(other)`: return Err with "unknown type '{other}'; only 'time' is supported"
       - If `dim_type == None`: no validation needed

    3. Update `#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]` attribute:
       The `Arbitrary` derive may need updating for new fields. Since `Option<String>` implements `Arbitrary`, this should work automatically. Verify `cargo test` passes.

    4. Note: Do NOT add `#[serde(deny_unknown_fields)]` to `Dimension` — it's only on `SemanticViewDefinition` (parent). Adding new known fields to `Dimension` is correct.

    Valid granularities constant: `const VALID_GRANULARITIES: &[&str] = &["day", "week", "month", "year"];`
  </implementation>
</feature>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Add dim_type + granularity fields to Dimension and validation to from_json</name>
  <files>src/model.rs</files>
  <behavior>
    RED tests (write these first, run — they MUST fail):
    - `time_dimension_roundtrip`: Deserialize JSON with `type: "time", granularity: "month"` → dim_type == Some("time"), granularity == Some("month")
    - `time_dimension_missing_granularity_error`: `type: "time"` without granularity → Err containing "missing required 'granularity' field"
    - `time_dimension_unknown_type_error`: `type: "date"` → Err containing "unknown type 'date'"
    - `time_dimension_unsupported_granularity_error`: `type: "time", granularity: "quarter"` → Err containing valid values list
    - `old_json_without_type_deserializes`: No type/granularity fields → deserializes to None/None (backward compat)
    - `all_supported_granularities_accepted`: day, week, month, year each deserialize without error

    GREEN: Add the two serde fields + validation loop. All tests pass.
    REFACTOR: Clean up if needed. Run full `cargo test`.
  </behavior>
  <action>
    Modify `src/model.rs`:

    1. Add to `Dimension` struct (after `source_table` field):
    ```rust
    /// Optional dimension type. Only "time" is supported in v0.2.0.
    /// Serde rename needed because `type` is a Rust keyword.
    #[serde(default, rename = "type")]
    pub dim_type: Option<String>,
    /// Required when dim_type is Some("time"). Valid values: day, week, month, year.
    #[serde(default)]
    pub granularity: Option<String>,
    ```

    2. Replace `from_json` with:
    ```rust
    pub fn from_json(name: &str, json: &str) -> Result<Self, String> {
        const VALID_GRANULARITIES: &[&str] = &["day", "week", "month", "year"];

        let def: Self = serde_json::from_str(json)
            .map_err(|e| format!("invalid definition for semantic view '{name}': {e}"))?;

        for dim in &def.dimensions {
            if let Some(ref dt) = dim.dim_type {
                if dt != "time" {
                    return Err(format!(
                        "dimension '{}' has unknown type '{}'; only 'time' is supported",
                        dim.name, dt
                    ));
                }
                match &dim.granularity {
                    None => {
                        return Err(format!(
                            "dimension '{}' declares type 'time' but is missing required 'granularity' field",
                            dim.name
                        ));
                    }
                    Some(g) if !VALID_GRANULARITIES.contains(&g.as_str()) => {
                        return Err(format!(
                            "dimension '{}' has unsupported granularity '{}'; valid values: {}",
                            dim.name,
                            g,
                            VALID_GRANULARITIES.join(", ")
                        ));
                    }
                    _ => {}
                }
            }
        }

        Ok(def)
    }
    ```

    3. In the `mod tests` block, add these test cases under a new `mod time_dimension_tests`:
    ```rust
    mod time_dimension_tests {
        use super::*;

        #[test]
        fn time_dimension_roundtrip() {
            let json = r#"{
                "base_table": "orders",
                "dimensions": [{"name": "order_date", "expr": "order_date", "type": "time", "granularity": "month"}],
                "metrics": [{"name": "revenue", "expr": "sum(amount)"}]
            }"#;
            let def = SemanticViewDefinition::from_json("orders", json).unwrap();
            assert_eq!(def.dimensions[0].dim_type.as_deref(), Some("time"));
            assert_eq!(def.dimensions[0].granularity.as_deref(), Some("month"));
        }

        #[test]
        fn old_json_without_type_deserializes() {
            let json = r#"{
                "base_table": "orders",
                "dimensions": [{"name": "region", "expr": "region"}],
                "metrics": []
            }"#;
            let def = SemanticViewDefinition::from_json("orders", json).unwrap();
            assert!(def.dimensions[0].dim_type.is_none());
            assert!(def.dimensions[0].granularity.is_none());
        }

        #[test]
        fn time_dimension_missing_granularity_error() {
            let json = r#"{
                "base_table": "orders",
                "dimensions": [{"name": "order_date", "expr": "order_date", "type": "time"}],
                "metrics": []
            }"#;
            let err = SemanticViewDefinition::from_json("orders", json).unwrap_err();
            assert!(err.contains("missing required 'granularity' field"), "Got: {err}");
            assert!(err.contains("order_date"), "Got: {err}");
        }

        #[test]
        fn time_dimension_unknown_type_error() {
            let json = r#"{
                "base_table": "orders",
                "dimensions": [{"name": "order_date", "expr": "order_date", "type": "date", "granularity": "month"}],
                "metrics": []
            }"#;
            let err = SemanticViewDefinition::from_json("orders", json).unwrap_err();
            assert!(err.contains("unknown type 'date'"), "Got: {err}");
            assert!(err.contains("only 'time' is supported"), "Got: {err}");
        }

        #[test]
        fn time_dimension_unsupported_granularity_error() {
            let json = r#"{
                "base_table": "orders",
                "dimensions": [{"name": "order_date", "expr": "order_date", "type": "time", "granularity": "quarter"}],
                "metrics": []
            }"#;
            let err = SemanticViewDefinition::from_json("orders", json).unwrap_err();
            assert!(err.contains("'quarter'"), "Got: {err}");
            assert!(err.contains("day, week, month, year"), "Got: {err}");
        }

        #[test]
        fn all_supported_granularities_accepted() {
            for gran in ["day", "week", "month", "year"] {
                let json = format!(r#"{{
                    "base_table": "orders",
                    "dimensions": [{{"name": "order_date", "expr": "order_date", "type": "time", "granularity": "{gran}"}}],
                    "metrics": []
                }}"#);
                SemanticViewDefinition::from_json("orders", &json)
                    .unwrap_or_else(|e| panic!("granularity '{gran}' rejected: {e}"));
            }
        }
    }
    ```

    After writing: run `cargo test --lib model` to confirm all tests pass.
    Run full `cargo test` to confirm no regressions.
  </action>
  <verify>
    <automated>cargo test --lib 2>&1 | tail -20</automated>
  </verify>
  <done>
    `cargo test --lib` passes with all existing tests plus 6 new time_dimension_tests. `cargo test` (full suite) passes with no regressions. `src/model.rs` contains `dim_type: Option&lt;String&gt;` and `granularity: Option&lt;String&gt;` on `Dimension`, and `from_json` validates time dimensions at define-time.
  </done>
</task>

</tasks>

<verification>
Run `cargo test` (full suite). All tests must pass. No compiler warnings about unused fields.
Key check: `grep -n "dim_type\|granularity" src/model.rs` shows both fields present with serde attributes.
</verification>

<success_criteria>
- `Dimension` struct has `dim_type: Option<String>` (with `#[serde(rename = "type")]`) and `granularity: Option<String>`
- Existing JSON definitions (no type/granularity) continue to deserialize correctly
- `from_json` catches: missing granularity for time dims, unknown dim type, unsupported granularity values
- All 6 new tests pass, all existing tests pass
- `cargo test` clean with no warnings
</success_criteria>

<output>
After completion, create `.planning/phases/09-time-dimensions/09-01-SUMMARY.md`
</output>
