---
phase: 47-semi-additive-metrics
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - src/model.rs
  - src/body_parser.rs
  - src/render_ddl.rs
  - src/ddl/describe.rs
  - src/expand/test_helpers.rs
autonomous: true
requirements: [SEMI-01]
must_haves:
  truths:
    - "User can write NON ADDITIVE BY (dim DESC) on a metric in CREATE SEMANTIC VIEW DDL and it parses without error"
    - "NON ADDITIVE BY with ASC/DESC and NULLS FIRST/LAST modifiers all parse correctly"
    - "NON ADDITIVE BY on a derived metric (no dot prefix) produces a clear parse error"
    - "NON ADDITIVE BY dimension references are validated against the view's dimension list at define time"
    - "Pre-v0.6.0 stored views without non_additive_by deserialize correctly (empty Vec)"
    - "GET_DDL round-trips NON ADDITIVE BY correctly"
    - "DESCRIBE SEMANTIC VIEW shows NON_ADDITIVE_BY property for metrics that have it"
  artifacts:
    - path: "src/model.rs"
      provides: "NonAdditiveDim, SortOrder, NullsOrder types on Metric struct"
      contains: "pub struct NonAdditiveDim"
    - path: "src/body_parser.rs"
      provides: "NON ADDITIVE BY parser in metric entries"
      contains: "NON ADDITIVE BY"
    - path: "src/render_ddl.rs"
      provides: "NON ADDITIVE BY emission in GET_DDL output"
      contains: "NON ADDITIVE BY"
    - path: "src/ddl/describe.rs"
      provides: "NON_ADDITIVE_BY property row in DESCRIBE output"
      contains: "NON_ADDITIVE_BY"
    - path: "src/expand/test_helpers.rs"
      provides: "with_non_additive_by builder method for test fixtures"
      contains: "fn with_non_additive_by"
  key_links:
    - from: "src/body_parser.rs"
      to: "src/model.rs"
      via: "MetricEntry tuple extended with Vec<NonAdditiveDim>"
      pattern: "NonAdditiveDim"
    - from: "src/render_ddl.rs"
      to: "src/model.rs"
      via: "emit_metrics reads non_additive_by field"
      pattern: "non_additive_by"
---

<objective>
Add NON ADDITIVE BY model types, DDL parsing, DDL reconstruction, DESCRIBE surfacing, and define-time validation for semi-additive metrics.

Purpose: Establishes the data model and parser surface for semi-additive metrics so that Plan 02 can implement the expansion pipeline against well-defined types.
Output: Model types (NonAdditiveDim, SortOrder, NullsOrder), body parser extension, render_ddl updates, DESCRIBE updates, test helper extension, backward-compat serde.
</objective>

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

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/47-semi-additive-metrics/47-RESEARCH.md

<interfaces>
<!-- Key types and contracts the executor needs. Extracted from codebase. -->

From src/model.rs (current Metric struct, line 60-93):
```rust
pub struct Metric {
    pub name: String,
    pub expr: String,
    #[serde(default)]
    pub source_table: Option<String>,
    #[serde(default)]
    pub output_type: Option<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub using_relationships: Vec<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub comment: Option<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub synonyms: Vec<String>,
    #[serde(default, skip_serializing_if = "AccessModifier::is_default")]
    pub access: AccessModifier,
}
```

From src/body_parser.rs (MetricEntry type alias, line 20-28):
```rust
type MetricEntry = (
    Option<String>,  // source_alias
    String,          // bare_name
    String,          // expr
    Vec<String>,     // using_relationships
    Option<String>,  // comment
    Vec<String>,     // synonyms
    AccessModifier,  // access
);
```

From src/body_parser.rs (conversion at line 391-405):
```rust
let metrics = metrics_raw
    .into_iter()
    .map(
        |(source_alias, bare_name, expr, using_rels, comment, synonyms, access)| Metric {
            name: bare_name,
            expr,
            source_table: source_alias,
            output_type: None,
            using_relationships: using_rels,
            comment,
            synonyms,
            access,
        },
    )
    .collect();
```

From src/body_parser.rs (parse_single_metric_entry pattern, line 1155-1273):
- Strips leading PRIVATE/PUBLIC
- Finds AS keyword
- Splits before_as / AS / expr+annotations
- In before_as: finds USING keyword, extracts parenthesized list
- Remaining portion after USING extraction is name_portion (checked for dot)

From src/render_ddl.rs (emit_metrics, line 141-168):
```rust
fn emit_metrics(out: &mut String, def: &SemanticViewDefinition) {
    // Iterates metrics, emits: [PRIVATE] [alias.]name [USING (...)] AS expr [COMMENT] [SYNONYMS]
}
```

From src/ddl/describe.rs (collect_metric_rows, line 326-401):
- Emits property rows: TABLE, EXPRESSION, DATA_TYPE, COMMENT, SYNONYMS, ACCESS_MODIFIER

From src/body_parser.rs helper functions:
```rust
fn find_keyword_ci(upper_text: &str, keyword: &str) -> Option<usize>;  // word-boundary keyword search
fn extract_paren_content(s: &str) -> Option<&str>;  // extract content inside balanced parens
pub(crate) fn split_at_depth0_commas(body: &str) -> Vec<(usize, &str)>;  // comma-split respecting parens
```
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Model types and MetricEntry extension</name>
  <files>src/model.rs, src/body_parser.rs, src/expand/test_helpers.rs</files>
  <read_first>
    - src/model.rs (full file -- understand Metric struct, AccessModifier, existing serde patterns)
    - src/body_parser.rs (lines 1-30 for MetricEntry type alias, lines 391-405 for conversion, lines 1155-1273 for parse_single_metric_entry)
    - src/expand/test_helpers.rs (full file -- understand builder pattern for TestFixtureExt)
  </read_first>
  <behavior>
    - Test: SortOrder default is Asc, NullsOrder default is Last
    - Test: SortOrder::is_default() returns true for Asc, false for Desc
    - Test: NullsOrder::is_default() returns true for Last, false for First
    - Test: NonAdditiveDim serializes with skip_serializing_if on default order/nulls
    - Test: Metric with empty non_additive_by deserializes from JSON without the field (backward compat)
    - Test: Metric with non_additive_by serializes/deserializes round-trip correctly
    - Test: parse_metrics_clause("a.balance NON ADDITIVE BY (date_dim DESC) AS SUM(a.balance)", 0) returns MetricEntry with non_additive_by containing one NonAdditiveDim { dimension: "date_dim", order: Desc, nulls: Last }
    - Test: parse_metrics_clause("a.bal NON ADDITIVE BY (d1 DESC NULLS FIRST, d2 ASC NULLS LAST) AS SUM(x)", 0) returns two NonAdditiveDim entries
    - Test: parse_metrics_clause("a.bal NON ADDITIVE BY (d1) AS SUM(x)", 0) returns NonAdditiveDim with defaults (Asc, Last)
    - Test: parse_metrics_clause("profit NON ADDITIVE BY (d1) AS revenue - cost", 0) returns error containing "NON ADDITIVE BY" and "derived"
    - Test: with_non_additive_by builder method sets non_additive_by on named metric
  </behavior>
  <action>
1. In src/model.rs, add three new types BEFORE the Metric struct:

```rust
/// Sort order for NON ADDITIVE BY dimension ordering.
/// Default: Asc (matches Snowflake default).
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
pub enum SortOrder {
    #[default]
    Asc,
    Desc,
}

impl SortOrder {
    pub fn is_default(&self) -> bool {
        matches!(self, Self::Asc)
    }
}

/// NULLS placement for NON ADDITIVE BY dimension ordering.
/// Default: Last (matches DuckDB ASC default and Snowflake ASC default).
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
pub enum NullsOrder {
    #[default]
    Last,
    First,
}

impl NullsOrder {
    pub fn is_default(&self) -> bool {
        matches!(self, Self::Last)
    }
}

/// A dimension reference in a NON ADDITIVE BY clause.
/// Specifies which dimension(s) a metric is non-additive by,
/// with sort order and nulls placement for snapshot selection.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
pub struct NonAdditiveDim {
    pub dimension: String,
    #[serde(default, skip_serializing_if = "SortOrder::is_default")]
    pub order: SortOrder,
    #[serde(default, skip_serializing_if = "NullsOrder::is_default")]
    pub nulls: NullsOrder,
}
```

2. Add to the Metric struct (after `access` field):
```rust
    /// Dimensions this metric is non-additive by (snapshot aggregation).
    /// When non-empty, expansion uses ROW_NUMBER CTE for snapshot selection.
    /// Old stored JSON without this field deserializes with empty Vec.
    /// Not serialized when empty to preserve backward-compatible JSON.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub non_additive_by: Vec<NonAdditiveDim>,
```

3. In src/body_parser.rs:

a. Change the MetricEntry type alias from 7-element to 8-element tuple, adding `Vec<NonAdditiveDim>` as the 8th element:
```rust
type MetricEntry = (
    Option<String>,       // source_alias
    String,               // bare_name
    String,               // expr
    Vec<String>,          // using_relationships
    Option<String>,       // comment
    Vec<String>,          // synonyms
    AccessModifier,       // access
    Vec<NonAdditiveDim>,  // non_additive_by
);
```

b. Add `use crate::model::{NonAdditiveDim, SortOrder, NullsOrder};` to the imports (AccessModifier is already imported).

c. In `parse_single_metric_entry`, after the USING extraction block (after `before_as[..upos].trim()` at line ~1215), add NON ADDITIVE BY detection on the remaining `name_portion` (or on `before_as` if no USING). The parse order in `before_as` is: `[PRIVATE] alias.name [USING (...)] [NON ADDITIVE BY (...)]`. After extracting USING, the remainder may still contain `NON ADDITIVE BY (...)`. The approach:

After determining `name_portion` (line ~1218), check for `NON ADDITIVE BY` keyword sequence:
```rust
// Check for NON ADDITIVE BY in name_portion
let upper_name = name_portion.to_ascii_uppercase();
let na_pos = find_non_additive_by_keyword(&upper_name);
let mut non_additive_by: Vec<NonAdditiveDim> = Vec::new();
let final_name_portion = if let Some(na_start) = na_pos {
    let after_na = name_portion[na_start + 16..].trim(); // "NON ADDITIVE BY" = 16 chars
    if !after_na.starts_with('(') {
        return Err(ParseError {
            message: format!("Expected '(' after NON ADDITIVE BY in metric entry '{entry}'."),
            position: Some(entry_offset + na_start + 16),
        });
    }
    let paren_content = extract_paren_content(after_na).ok_or_else(|| ParseError {
        message: format!("Unclosed '(' after NON ADDITIVE BY in metric entry '{entry}'."),
        position: Some(entry_offset + na_start + 16),
    })?;
    non_additive_by = parse_non_additive_dims(paren_content, entry_offset + na_start + 17)?;
    name_portion[..na_start].trim()
} else {
    name_portion
};
```

Then use `final_name_portion` instead of `name_portion` for the dot-check and name extraction.

d. Add the `find_non_additive_by_keyword` helper function. This finds the keyword sequence "NON" + whitespace + "ADDITIVE" + whitespace + "BY" with word boundaries. Use `find_keyword_ci` approach but for a 3-word sequence:
```rust
fn find_non_additive_by_keyword(upper_text: &str) -> Option<usize> {
    // Find "NON" then check "ADDITIVE" and "BY" follow with whitespace
    let mut search_from = 0;
    while let Some(pos) = find_keyword_ci(&upper_text[search_from..], "NON") {
        let abs_pos = search_from + pos;
        let after_non = upper_text[abs_pos + 3..].trim_start();
        if after_non.starts_with("ADDITIVE") {
            let after_additive = after_non[8..].trim_start();
            if after_additive.starts_with("BY") {
                // Verify BY has word boundary
                let after_by = &after_additive[2..];
                if after_by.is_empty() || !after_by.as_bytes()[0].is_ascii_alphanumeric() {
                    return Some(abs_pos);
                }
            }
        }
        search_from = abs_pos + 3;
    }
    None
}
```

e. Add the `parse_non_additive_dims` helper:
```rust
fn parse_non_additive_dims(content: &str, base_offset: usize) -> Result<Vec<NonAdditiveDim>, ParseError> {
    let entries = split_at_depth0_commas(content);
    let mut result = Vec::new();
    for (start, entry_text) in entries {
        let entry_text = entry_text.trim();
        if entry_text.is_empty() {
            continue; // trailing comma
        }
        let parts: Vec<&str> = entry_text.split_whitespace().collect();
        if parts.is_empty() {
            return Err(ParseError {
                message: "Empty dimension in NON ADDITIVE BY clause".to_string(),
                position: Some(base_offset + start),
            });
        }
        let dim_name = parts[0].to_string();
        let upper_parts: Vec<String> = parts.iter().map(|p| p.to_ascii_uppercase()).collect();
        let mut order = SortOrder::Asc;
        let mut nulls = NullsOrder::Last;
        let mut i = 1;
        while i < upper_parts.len() {
            match upper_parts[i].as_str() {
                "ASC" => { order = SortOrder::Asc; i += 1; }
                "DESC" => { order = SortOrder::Desc; i += 1; }
                "NULLS" => {
                    if i + 1 < upper_parts.len() {
                        match upper_parts[i + 1].as_str() {
                            "FIRST" => { nulls = NullsOrder::First; i += 2; }
                            "LAST" => { nulls = NullsOrder::Last; i += 2; }
                            _ => {
                                return Err(ParseError {
                                    message: format!("Expected FIRST or LAST after NULLS, got '{}'", parts[i + 1]),
                                    position: Some(base_offset + start),
                                });
                            }
                        }
                    } else {
                        return Err(ParseError {
                            message: "Expected FIRST or LAST after NULLS".to_string(),
                            position: Some(base_offset + start),
                        });
                    }
                }
                other => {
                    return Err(ParseError {
                        message: format!("Unexpected token '{}' in NON ADDITIVE BY dimension entry", other),
                        position: Some(base_offset + start),
                    });
                }
            }
        }
        // Adjust default nulls based on sort order (DESC defaults to NULLS FIRST)
        // Only if user did not explicitly specify NULLS
        let has_explicit_nulls = upper_parts.iter().any(|p| p == "NULLS");
        if !has_explicit_nulls && order == SortOrder::Desc {
            nulls = NullsOrder::First;
        }
        result.push(NonAdditiveDim { dimension: dim_name, order, nulls });
    }
    Ok(result)
}
```

f. In the two return paths of `parse_single_metric_entry` (qualified at ~line 1241 and unqualified at ~line 1263), add `non_additive_by` as the 8th tuple element. For the unqualified (derived) case, check if `non_additive_by` is non-empty and return an error:
```rust
// Unqualified path:
if !non_additive_by.is_empty() {
    return Err(ParseError {
        message: format!(
            "NON ADDITIVE BY clause not allowed on derived metric '{bare_name}'. \
             Only qualified metrics (alias.name) can use NON ADDITIVE BY.",
        ),
        position: Some(entry_offset),
    });
}
```

g. Update the `.map()` conversion in `parse_keyword_body` (line ~391-405) to handle the 8th tuple element:
```rust
|(source_alias, bare_name, expr, using_rels, comment, synonyms, access, non_additive_by)| Metric {
    name: bare_name,
    expr,
    source_table: source_alias,
    output_type: None,
    using_relationships: using_rels,
    comment,
    synonyms,
    access,
    non_additive_by,
},
```

4. In src/expand/test_helpers.rs:

a. Add import: `use crate::model::{NonAdditiveDim, SortOrder, NullsOrder};`

b. Add `with_non_additive_by` to the TestFixtureExt trait:
```rust
fn with_non_additive_by(
    self,
    metric_name: &str,
    dims: &[(&str, SortOrder, NullsOrder)],
) -> Self;
```

c. Implement it:
```rust
fn with_non_additive_by(
    mut self,
    metric_name: &str,
    dims: &[(&str, SortOrder, NullsOrder)],
) -> Self {
    if let Some(m) = self.metrics.iter_mut().find(|m| m.name == metric_name) {
        m.non_additive_by = dims
            .iter()
            .map(|(dim, order, nulls)| NonAdditiveDim {
                dimension: dim.to_string(),
                order: *order,
                nulls: *nulls,
            })
            .collect();
    }
    self
}
```

d. Update `with_metric` and `with_private_metric` implementations to include `non_additive_by: vec![]` in the Metric struct literal.

5. Add `non_additive_by: vec![]` to the Metric struct literals in `orders_view()` and `minimal_def()` in test_helpers.rs.

6. Add unit tests for the model types in src/model.rs (in a #[cfg(test)] mod tests block if one exists, or create one):
- Backward compat: deserialize JSON without non_additive_by field -> empty Vec
- Round-trip: serialize/deserialize NonAdditiveDim with non-default values
- SortOrder::is_default and NullsOrder::is_default

7. Add unit tests for parsing in src/body_parser.rs (in the existing test module):
- parse_metrics_clause with NON ADDITIVE BY single dim with defaults
- parse_metrics_clause with NON ADDITIVE BY single dim DESC NULLS FIRST
- parse_metrics_clause with NON ADDITIVE BY multiple dims
- parse_metrics_clause with NON ADDITIVE BY on derived metric -> error
- parse_metrics_clause with NON ADDITIVE BY and USING together
- parse_keyword_body full integration with NON ADDITIVE BY
  </action>
  <verify>
    <automated>cd /Users/paul/Documents/Dev/Personal/duckdb-semantic-views && cargo test parse_metrics 2>&1 | tail -5 && cargo test non_additive 2>&1 | tail -5 && cargo test backward_compat 2>&1 | tail -5</automated>
  </verify>
  <acceptance_criteria>
    - src/model.rs contains `pub struct NonAdditiveDim`
    - src/model.rs contains `pub enum SortOrder` with `#[default] Asc`
    - src/model.rs contains `pub enum NullsOrder` with `#[default] Last`
    - src/model.rs Metric struct contains `pub non_additive_by: Vec<NonAdditiveDim>`
    - src/model.rs Metric struct field has `#[serde(default, skip_serializing_if = "Vec::is_empty")]` on non_additive_by
    - src/body_parser.rs MetricEntry type alias has 8 elements (last is Vec<NonAdditiveDim>)
    - src/body_parser.rs contains `fn parse_non_additive_dims`
    - src/body_parser.rs contains `fn find_non_additive_by_keyword`
    - src/body_parser.rs parse_single_metric_entry returns error for NON ADDITIVE BY on derived metric (no dot)
    - src/expand/test_helpers.rs contains `fn with_non_additive_by`
    - `cargo test parse_metrics` exits 0
    - `cargo test non_additive` exits 0
  </acceptance_criteria>
  <done>Model types NonAdditiveDim/SortOrder/NullsOrder exist with correct serde attributes, body parser handles NON ADDITIVE BY syntax, MetricEntry tuple extended, test helpers updated, backward compat verified, all unit tests pass.</done>
</task>

<task type="auto">
  <name>Task 2: GET_DDL reconstruction, DESCRIBE surfacing, and define-time validation</name>
  <files>src/render_ddl.rs, src/ddl/describe.rs, src/body_parser.rs</files>
  <read_first>
    - src/render_ddl.rs (full file -- understand emit_metrics pattern, existing emit_comment/emit_synonyms helpers)
    - src/ddl/describe.rs (lines 320-401 -- collect_metric_rows function)
    - src/body_parser.rs (lines 406-430 -- parse_keyword_body post-processing and validation area)
    - src/model.rs (NonAdditiveDim/SortOrder/NullsOrder types created in Task 1)
  </read_first>
  <action>
1. In src/render_ddl.rs, update `emit_metrics` to emit `NON ADDITIVE BY (...)` between USING and AS:

After the USING block (line ~157) and before `out.push_str(" AS ");` (line ~158), add:
```rust
if !metric.non_additive_by.is_empty() {
    out.push_str(" NON ADDITIVE BY (");
    for (i, na) in metric.non_additive_by.iter().enumerate() {
        if i > 0 {
            out.push_str(", ");
        }
        out.push_str(&na.dimension);
        match na.order {
            SortOrder::Asc => {} // default, omit
            SortOrder::Desc => out.push_str(" DESC"),
        }
        // Always emit explicit NULLS to avoid DuckDB version divergence
        match na.nulls {
            NullsOrder::Last => out.push_str(" NULLS LAST"),
            NullsOrder::First => out.push_str(" NULLS FIRST"),
        }
    }
    out.push(')');
}
```

Add `use crate::model::{SortOrder, NullsOrder};` to the imports (AccessModifier and SemanticViewDefinition already imported).

2. In src/ddl/describe.rs, update `collect_metric_rows` to emit a `NON_ADDITIVE_BY` property row when non-empty. After the ACCESS_MODIFIER row (line ~399), add:
```rust
if !metric.non_additive_by.is_empty() {
    let na_value = metric
        .non_additive_by
        .iter()
        .map(|na| {
            let mut s = na.dimension.clone();
            match na.order {
                crate::model::SortOrder::Desc => s.push_str(" DESC"),
                crate::model::SortOrder::Asc => {}
            }
            match na.nulls {
                crate::model::NullsOrder::First => s.push_str(" NULLS FIRST"),
                crate::model::NullsOrder::Last => {}
            }
            s
        })
        .collect::<Vec<_>>()
        .join(", ");
    rows.push(DescribeRow {
        object_kind: object_kind.to_string(),
        object_name: metric.name.clone(),
        parent_entity: parent.clone(),
        property: "NON_ADDITIVE_BY".to_string(),
        property_value: na_value,
    });
}
```

3. In src/body_parser.rs, add define-time validation of NON ADDITIVE BY dimension references. After the metrics are constructed in `parse_keyword_body` (after the `.collect()` at line ~405), add a validation loop before returning `Ok(KeywordBody { ... })`:

```rust
// Phase 47: Validate NON ADDITIVE BY dimension references
for metric in &metrics {
    for na in &metric.non_additive_by {
        let dim_exists = dimensions.iter().any(|d| d.name.eq_ignore_ascii_case(&na.dimension));
        if !dim_exists {
            let available_dims: Vec<String> = dimensions.iter().map(|d| d.name.clone()).collect();
            let suggestion = crate::util::suggest_closest(&na.dimension, &available_dims);
            let mut msg = format!(
                "NON ADDITIVE BY dimension '{}' on metric '{}' does not match any declared dimension.",
                na.dimension, metric.name
            );
            if let Some(closest) = suggestion {
                msg.push_str(&format!(" Did you mean '{closest}'?"));
            }
            return Err(ParseError {
                message: msg,
                position: None,
            });
        }
    }
}
```

4. Add unit tests for render_ddl in src/render_ddl.rs (in existing test module):
- render_create_ddl with a metric that has NON ADDITIVE BY (single dim, default order) -> output contains "NON ADDITIVE BY (date_dim NULLS LAST)"
- render_create_ddl with NON ADDITIVE BY (dim DESC NULLS FIRST) -> output contains "NON ADDITIVE BY (date_dim DESC NULLS FIRST)"
- render_create_ddl round-trip: render -> parse -> render produces identical output

5. Add unit test for DESCRIBE NON_ADDITIVE_BY property in src/ddl/describe.rs tests (or body_parser.rs integration test):
- Parse a full keyword body with NON ADDITIVE BY, then call collect_metric_rows -> verify NON_ADDITIVE_BY row exists

6. Add unit test for define-time validation in src/body_parser.rs:
- parse_keyword_body with NON ADDITIVE BY referencing a nonexistent dimension -> error containing "does not match any declared dimension"
- parse_keyword_body with NON ADDITIVE BY referencing a valid dimension -> success
  </action>
  <verify>
    <automated>cd /Users/paul/Documents/Dev/Personal/duckdb-semantic-views && cargo test render_ddl 2>&1 | tail -5 && cargo test non_additive 2>&1 | tail -5 && cargo test describe 2>&1 | tail -5</automated>
  </verify>
  <acceptance_criteria>
    - src/render_ddl.rs emit_metrics contains `NON ADDITIVE BY`
    - src/render_ddl.rs emits explicit NULLS LAST or NULLS FIRST (never omits NULLS order)
    - src/ddl/describe.rs collect_metric_rows contains `NON_ADDITIVE_BY` property push
    - src/body_parser.rs parse_keyword_body validates NON ADDITIVE BY dims against dimension list
    - src/body_parser.rs validation error message contains "does not match any declared dimension"
    - `cargo test render_ddl` exits 0
    - `cargo test non_additive` exits 0
  </acceptance_criteria>
  <done>GET_DDL emits NON ADDITIVE BY correctly in round-trip, DESCRIBE shows NON_ADDITIVE_BY property, define-time validation catches bad dimension references with "did you mean" suggestions. All tests pass.</done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| DDL input -> body parser | User-provided SQL text parsed into model types |
| Model -> generated SQL | Dimension names from NON ADDITIVE BY embedded in ORDER BY clause |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-47-01 | Tampering | body_parser.rs parse_non_additive_dims | mitigate | Dimension names validated at define-time against declared dimensions list; no arbitrary strings reach SQL generation |
| T-47-02 | Tampering | expand/semi_additive.rs ORDER BY clause | mitigate | All identifiers quoted via `quote_ident()` in generated SQL (existing codebase pattern) |
| T-47-03 | Denial of Service | parse_non_additive_dims | accept | Large NON ADDITIVE BY lists handled by DuckDB's query complexity limits; parser iterates linearly |
</threat_model>

<verification>
- `cargo test` passes (all unit tests including new model, parser, render_ddl, describe tests)
- New types compile with serde derive and cfg_attr(feature = "arbitrary") without errors
- MetricEntry 8-element tuple compiles without type errors across all call sites
- Note: sqllogictest coverage for SEMI-01 (DDL parsing end-to-end) is provided by Plan 02 Task 2 (Wave 2), which creates phase47_semi_additive.test with DDL, GET_DDL, and DESCRIBE tests
</verification>

<success_criteria>
- NON ADDITIVE BY parses correctly with all modifier combinations (ASC/DESC, NULLS FIRST/LAST)
- Derived metrics with NON ADDITIVE BY produce parse errors
- Invalid dimension references in NON ADDITIVE BY produce define-time errors with suggestions
- GET_DDL round-trips NON ADDITIVE BY faithfully
- DESCRIBE surfaces NON_ADDITIVE_BY property
- Pre-v0.6.0 JSON without non_additive_by field deserializes to empty Vec
- All existing tests continue to pass
</success_criteria>

<output>
After completion, create `.planning/phases/47-semi-additive-metrics/47-01-SUMMARY.md`
</output>
