---
phase: 43-metadata-foundation
plan: 02
type: execute
wave: 2
depends_on: ["43-01"]
files_modified:
  - src/body_parser.rs
  - src/parse.rs
  - src/expand/types.rs
  - src/expand/sql_gen.rs
  - test/sql/phase43_metadata.test
autonomous: true
requirements:
  - META-01
  - META-02
  - META-03
  - META-04
  - META-05

must_haves:
  truths:
    - "User can write COMMENT = '...' between view name and AS keyword and the comment is stored"
    - "User can write COMMENT = '...' on individual table, dimension, metric, and fact entries"
    - "User can write WITH SYNONYMS = ('a', 'b') on individual table, dimension, metric, and fact entries"
    - "COMMENT and SYNONYMS can appear in either order after the expression"
    - "User can write PRIVATE before a fact or metric entry name"
    - "PRIVATE on a dimension produces a parse error"
    - "Querying a PRIVATE metric produces a clear ExpandError"
    - "Querying a PRIVATE fact produces a clear ExpandError (when fact querying exists)"
    - "Derived metrics that reference PRIVATE base metrics still work"
    - "All metadata survives DDL -> parse -> JSON -> persist -> deserialize roundtrip"
  artifacts:
    - path: "src/body_parser.rs"
      provides: "Trailing annotation parsing for COMMENT/SYNONYMS, leading keyword parsing for PRIVATE/PUBLIC"
      contains: "parse_trailing_annotations"
    - path: "src/parse.rs"
      provides: "View-level COMMENT extraction, KeywordBody-to-SemanticViewDefinition metadata mapping"
      contains: "comment:"
    - path: "src/expand/types.rs"
      provides: "ExpandError::PrivateMetric variant"
      contains: "PrivateMetric"
    - path: "src/expand/sql_gen.rs"
      provides: "Access check after find_metric resolution"
      contains: "AccessModifier::Private"
    - path: "test/sql/phase43_metadata.test"
      provides: "Integration tests for COMMENT, SYNONYMS, PRIVATE DDL and query behavior"
      contains: "phase43"
  key_links:
    - from: "src/body_parser.rs parse_trailing_annotations"
      to: "KeywordBody metadata fields"
      via: "ParsedAnnotations struct returned by helper"
      pattern: "ParsedAnnotations"
    - from: "src/parse.rs rewrite_ddl_keyword_body"
      to: "SemanticViewDefinition.comment"
      via: "view_comment field passed through"
      pattern: "comment: view_comment"
    - from: "src/expand/sql_gen.rs expand()"
      to: "ExpandError::PrivateMetric"
      via: "access check after find_metric"
      pattern: "met\\.access.*Private"
---

<objective>
Implement DDL parsing for COMMENT, SYNONYMS, and PRIVATE/PUBLIC annotations on all semantic view objects, plus PRIVATE enforcement at query expansion time and full integration tests.

Purpose: Complete the metadata pipeline from DDL text through parsing, persistence, and query-time enforcement. After this plan, users can annotate their semantic views with metadata and encapsulate internal metrics behind PRIVATE.

Output: Updated body_parser.rs and parse.rs with annotation parsing, updated expansion engine with PRIVATE enforcement, and sqllogictest integration tests covering all META requirements.
</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/43-metadata-foundation/43-RESEARCH.md
@.planning/phases/43-metadata-foundation/43-01-SUMMARY.md

<interfaces>
<!-- After Plan 01, model.rs will have these new types/fields -->
From src/model.rs (after Plan 01):
```rust
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
pub enum AccessModifier {
    #[default]
    Public,
    Private,
}

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

// All structs have:
// TableRef: + comment: Option<String>, synonyms: Vec<String>
// Dimension: + comment: Option<String>, synonyms: Vec<String>
// Metric: + comment: Option<String>, synonyms: Vec<String>, access: AccessModifier
// Fact: + comment: Option<String>, synonyms: Vec<String>, access: AccessModifier
// SemanticViewDefinition: + comment: Option<String>
```

<!-- Current body_parser.rs KeywordBody struct -->
From src/body_parser.rs:
```rust
pub struct KeywordBody {
    pub tables: Vec<TableRef>,
    pub relationships: Vec<Join>,
    pub facts: Vec<Fact>,
    pub dimensions: Vec<Dimension>,
    pub metrics: Vec<Metric>,
}
```

<!-- Current parse.rs rewrite_ddl_keyword_body function (lines 821-876) -->
From src/parse.rs:
```rust
fn rewrite_ddl_keyword_body(kind, name, body_text, body_offset) -> Result<Option<String>, ParseError> {
    let mut keyword_body = parse_keyword_body(body_text, body_offset)?;
    infer_cardinality(&keyword_body.tables, &mut keyword_body.relationships)?;
    let base_table = keyword_body.tables.first().map(|t| t.table.clone()).unwrap_or_default();
    let def = SemanticViewDefinition {
        base_table, tables, dimensions, metrics, joins: relationships, facts,
        column_type_names: vec![], column_types_inferred: vec![],
        created_on: None, database_name: None, schema_name: None,
    };
    // ... serialize to JSON, build function call
}
```

<!-- Current parse.rs validate_create_body (lines 757-814) -->
From src/parse.rs:
```rust
fn validate_create_body(query, trimmed_no_semi, trim_offset, plen, kind) -> Result<Option<String>, ParseError> {
    let after_prefix = trimmed_no_semi[plen..].trim_start();
    // extract view name
    let name = &after_prefix[..name_end];
    let after_name = &after_prefix[name_end..];
    // detect "AS" keyword
    let after_name_trimmed = after_name.trim_start();
    let is_as_body = ...;
    if is_as_body {
        return rewrite_ddl_keyword_body(kind, name, after_name_trimmed, body_offset);
    }
}
```

<!-- Current expand/types.rs ExpandError variants -->
From src/expand/types.rs:
```rust
pub enum ExpandError {
    EmptyRequest { view_name: String },
    UnknownDimension { view_name, name, available, suggestion },
    UnknownMetric { view_name, name, available, suggestion },
    DuplicateDimension { view_name, name },
    DuplicateMetric { view_name, name },
    FanTrap { view_name, metric_name, metric_table, dimension_name, dimension_table, relationship_name },
    AmbiguousPath { view_name, dimension_name, dimension_table, available_relationships },
}
```

<!-- Current expand/sql_gen.rs expand() metric resolution (lines 59-80) -->
From src/expand/sql_gen.rs:
```rust
for name in &req.metrics {
    // ... duplicate check ...
    let met = find_metric(def, name).ok_or_else(|| { ... })?;
    resolved_mets.push(met);
}
```

<!-- Key parser helpers available in body_parser.rs -->
From src/body_parser.rs:
```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
fn split_at_depth0_commas(body: &str) -> Vec<(usize, &str)>;  // comma split respecting parens/quotes
fn parse_single_metric_entry(entry, entry_offset) -> Result<(Option<String>, String, String, Vec<String>), ParseError>;
fn parse_single_qualified_entry(entry, entry_offset) -> Result<(String, String, String), ParseError>;
fn parse_single_table_entry(entry, entry_offset) -> Result<TableRef, ParseError>;
```
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Body parser trailing annotations (COMMENT, SYNONYMS) and leading keyword (PRIVATE/PUBLIC)</name>
  <files>src/body_parser.rs</files>
  <read_first>
    - src/body_parser.rs (full file -- understand parse_single_metric_entry, parse_single_qualified_entry, parse_single_table_entry, parse_keyword_body, find_keyword_ci, extract_paren_content, split_at_depth0_commas)
    - src/model.rs (see AccessModifier enum and all struct fields after Plan 01)
  </read_first>
  <behavior>
    - Test 1: `parse_keyword_body` with `DIMENSIONS (o.date AS o.created_at COMMENT = 'Order date')` produces Dimension with comment=Some("Order date")
    - Test 2: `parse_keyword_body` with `DIMENSIONS (o.date AS o.created_at WITH SYNONYMS = ('purchase_date', 'order_date'))` produces Dimension with synonyms=["purchase_date", "order_date"]
    - Test 3: COMMENT and SYNONYMS in either order: `COMMENT = 'test' WITH SYNONYMS = ('a')` and `WITH SYNONYMS = ('a') COMMENT = 'test'` both produce same result
    - Test 4: `METRICS (PRIVATE o.cost AS SUM(o.cost))` produces Metric with access=Private
    - Test 5: `METRICS (PUBLIC o.revenue AS SUM(o.amount))` produces Metric with access=Public (explicit)
    - Test 6: `METRICS (o.revenue AS SUM(o.amount))` produces Metric with access=Public (default, no keyword)
    - Test 7: `FACTS (PRIVATE o.raw_cost AS o.cost)` produces Fact with access=Private
    - Test 8: `DIMENSIONS (PRIVATE o.date AS o.created_at)` produces ParseError containing "PRIVATE" and "not supported" and "dimensions"
    - Test 9: Escaped quotes in COMMENT: `COMMENT = 'It''s a test'` produces comment=Some("It's a test")
    - Test 10: Expression containing `comment` as identifier: `o.flag AS CASE WHEN comment_count > 0 THEN 1 ELSE 0 END` parses without matching COMMENT annotation
    - Test 11: TABLES entry with COMMENT and SYNONYMS: `o AS orders PRIMARY KEY (id) COMMENT = 'Orders table' WITH SYNONYMS = ('sales')` parses correctly
    - Test 12: Metric with USING + COMMENT: `o.rev USING (order_to_customer) AS SUM(o.amount) COMMENT = 'Revenue'` parses with using_relationships and comment
    - Test 13: PRIVATE keyword disambiguation: entry starting with table alias `private_schema.metric_name AS ...` is NOT treated as PRIVATE keyword (because `private_schema` is followed by `.`)
    - Test 14: KeywordBody carries metadata through -- full parse_keyword_body with COMMENT, SYNONYMS, PRIVATE on various entries produces correct KeywordBody
  </behavior>
  <action>
Implement metadata annotation parsing in body_parser.rs:

**Step 1: Add a ParsedAnnotations helper struct** (near top of file, after KeywordBody):

```rust
/// Trailing metadata annotations parsed from a DDL entry.
/// Used internally to collect COMMENT and SYNONYMS from entry text.
struct ParsedAnnotations {
    comment: Option<String>,
    synonyms: Vec<String>,
}
```

**Step 2: Add `parse_trailing_annotations` function** (place near other helper functions):

This function takes the raw expression text (everything after AS in an entry) and separates the actual SQL expression from trailing COMMENT and WITH SYNONYMS annotations. The challenge is distinguishing `COMMENT` the annotation keyword from `comment` the SQL identifier.

Strategy: Scan BACKWARD from end of text for annotation keywords at depth-0. The key insight: annotations always follow the expression, and the expression may contain nested parens/strings. Scan backward looking for the keywords `COMMENT` and `WITH SYNONYMS` outside of quoted strings and parenthesized groups.

More robust approach: Scan FORWARD through the expression tracking depth (parens, single quotes). Once at depth-0, look for `COMMENT` or `WITH` as word-boundary keywords. The LAST such occurrence at depth-0 marks where annotations begin.

Implementation:

```rust
/// Separate the SQL expression from trailing COMMENT / WITH SYNONYMS annotations.
///
/// Input: the text after "AS" in an entry (e.g., "SUM(o.amount) COMMENT = 'test' WITH SYNONYMS = ('a')")
/// Output: (clean_expression, ParsedAnnotations)
///
/// Handles:
/// - COMMENT = 'string with ''escaped'' quotes'
/// - WITH SYNONYMS = ('syn1', 'syn2')
/// - Either order (COMMENT then SYNONYMS or vice versa)
/// - No annotations at all (returns original expression with empty annotations)
/// - COMMENT as an identifier inside expressions (only matches at depth-0 with word boundaries)
fn parse_trailing_annotations(text: &str) -> Result<(String, ParsedAnnotations), ParseError> {
    let text = text.trim();
    let upper = text.to_ascii_uppercase();
    
    // Find the LAST occurrence of COMMENT or WITH at depth-0 with word boundaries.
    // Scan forward tracking depth to find annotation region start.
    let mut depth: i32 = 0;
    let mut in_string = false;
    let bytes = text.as_bytes();
    let mut annotation_start: Option<usize> = None;
    let mut i = 0;
    
    while i < bytes.len() {
        let ch = bytes[i] as char;
        if ch == '\'' {
            if in_string && i + 1 < bytes.len() && bytes[i + 1] == b'\'' {
                i += 2;
                continue;
            }
            in_string = !in_string;
        } else if !in_string {
            match ch {
                '(' | '[' | '{' => depth += 1,
                ')' | ']' | '}' => depth -= 1,
                _ => {}
            }
        }
        
        // At depth 0, outside string, check for COMMENT or WITH keyword
        if depth == 0 && !in_string {
            // Check for COMMENT keyword with word boundaries
            if i + 7 <= bytes.len() && upper[i..].starts_with("COMMENT") {
                let before_ok = i == 0 || {
                    let c = bytes[i - 1];
                    !c.is_ascii_alphanumeric() && c != b'_'
                };
                let after_ok = i + 7 == bytes.len() || {
                    let c = bytes[i + 7];
                    !c.is_ascii_alphanumeric() && c != b'_'
                };
                if before_ok && after_ok {
                    annotation_start = Some(annotation_start.unwrap_or(i).min(i));
                }
            }
            // Check for WITH keyword (for WITH SYNONYMS)
            if i + 4 <= bytes.len() && upper[i..].starts_with("WITH") {
                let before_ok = i == 0 || {
                    let c = bytes[i - 1];
                    !c.is_ascii_alphanumeric() && c != b'_'
                };
                let after_ok = i + 4 == bytes.len() || {
                    let c = bytes[i + 4];
                    !c.is_ascii_alphanumeric() && c != b'_'
                };
                if before_ok && after_ok {
                    // Verify it's WITH SYNONYMS, not just any WITH
                    let after_with = upper[i + 4..].trim_start();
                    if after_with.starts_with("SYNONYMS") {
                        annotation_start = Some(annotation_start.unwrap_or(i).min(i));
                    }
                }
            }
        }
        i += 1;
    }
    
    let (expr_text, annotation_text) = if let Some(start) = annotation_start {
        (text[..start].trim(), &text[start..])
    } else {
        return Ok((text.to_string(), ParsedAnnotations { comment: None, synonyms: vec![] }));
    };
    
    // Parse annotation_text for COMMENT = '...' and WITH SYNONYMS = ('...', '...')
    let mut comment: Option<String> = None;
    let mut synonyms: Vec<String> = Vec::new();
    let ann_upper = annotation_text.to_ascii_uppercase();
    
    // Extract COMMENT = '...'
    if let Some(comment_pos) = find_keyword_ci(&ann_upper, "COMMENT") {
        let after_comment = annotation_text[comment_pos + 7..].trim_start();
        if !after_comment.starts_with('=') {
            return Err(ParseError {
                message: "Expected '=' after COMMENT keyword.".to_string(),
                position: None,
            });
        }
        let after_eq = after_comment[1..].trim_start();
        if !after_eq.starts_with('\'') {
            return Err(ParseError {
                message: "Expected single-quoted string after COMMENT =.".to_string(),
                position: None,
            });
        }
        // Extract string handling '' escaping
        comment = Some(extract_single_quoted_string(after_eq)?);
    }
    
    // Extract WITH SYNONYMS = ('...', '...')
    if let Some(with_pos) = find_keyword_ci(&ann_upper, "WITH") {
        let after_with = annotation_text[with_pos + 4..].trim_start();
        let aw_upper = after_with.to_ascii_uppercase();
        if aw_upper.starts_with("SYNONYMS") {
            let after_syn = after_with[8..].trim_start();
            if !after_syn.starts_with('=') {
                return Err(ParseError {
                    message: "Expected '=' after WITH SYNONYMS keyword.".to_string(),
                    position: None,
                });
            }
            let after_eq = after_syn[1..].trim_start();
            let content = extract_paren_content(after_eq).ok_or_else(|| ParseError {
                message: "Expected parenthesized list after WITH SYNONYMS =.".to_string(),
                position: None,
            })?;
            // Parse comma-separated single-quoted strings
            synonyms = parse_synonym_list(content)?;
        }
    }
    
    Ok((expr_text.to_string(), ParsedAnnotations { comment, synonyms }))
}
```

**Step 3: Add `extract_single_quoted_string` helper:**

```rust
/// Extract a single-quoted string value, handling '' escape sequences.
/// Input starts with the opening quote: 'text here'
/// Returns the unescaped string content.
fn extract_single_quoted_string(s: &str) -> Result<String, ParseError> {
    if !s.starts_with('\'') {
        return Err(ParseError {
            message: "Expected single-quoted string.".to_string(),
            position: None,
        });
    }
    let bytes = s.as_bytes();
    let mut result = String::new();
    let mut i = 1; // skip opening quote
    while i < bytes.len() {
        if bytes[i] == b'\'' {
            if i + 1 < bytes.len() && bytes[i + 1] == b'\'' {
                result.push('\'');
                i += 2;
                continue;
            }
            // Closing quote
            return Ok(result);
        }
        result.push(bytes[i] as char);
        i += 1;
    }
    Err(ParseError {
        message: "Unclosed single-quoted string.".to_string(),
        position: None,
    })
}
```

**Step 4: Add `parse_synonym_list` helper:**

```rust
/// Parse comma-separated single-quoted strings from inside parentheses.
/// Input: "'syn1', 'syn2'" (already extracted from parens)
fn parse_synonym_list(content: &str) -> Result<Vec<String>, ParseError> {
    let entries = split_at_depth0_commas(content);
    let mut result = Vec::new();
    for (_, entry) in entries {
        let trimmed = entry.trim();
        if trimmed.is_empty() {
            continue;
        }
        result.push(extract_single_quoted_string(trimmed)?);
    }
    Ok(result)
}
```

**Step 5: Update `parse_single_qualified_entry`** to parse trailing annotations:

Currently returns `(String, String, String)` -- change to return `(String, String, String, Option<String>, Vec<String>)` where the new tuple fields are (comment, synonyms). The function currently extracts `expr` as everything after `AS`. Change it to:
1. Take everything after `AS` as the raw candidate
2. Call `parse_trailing_annotations(raw_candidate)` to get `(expr, annotations)`
3. Return `(source_alias, bare_name, expr, annotations.comment, annotations.synonyms)`

Update `parse_qualified_entries` return type to match: `Vec<(String, String, String, Option<String>, Vec<String>)>`.

**Step 6: Update `parse_single_metric_entry`** similarly:

Currently returns `(Option<String>, String, String, Vec<String>)` (source_alias, name, expr, using_rels). Change to return `(Option<String>, String, String, Vec<String>, Option<String>, Vec<String>, AccessModifier)` where new fields are (comment, synonyms, access).

Add PRIVATE/PUBLIC leading keyword detection at the start:
```rust
// Check for PRIVATE/PUBLIC leading keyword
let (access, remaining_entry) = {
    let entry_upper = entry.to_ascii_uppercase();
    if entry_upper.starts_with("PRIVATE") {
        let after = &entry["PRIVATE".len()..];
        // Disambiguate: if next non-whitespace is '.', it's a table alias, not a keyword
        let trimmed_after = after.trim_start();
        if trimmed_after.starts_with('.') {
            // 'PRIVATE' is a table alias like private_table.metric
            (AccessModifier::Public, entry)
        } else if after.starts_with(char::is_whitespace) || after.is_empty() {
            (AccessModifier::Private, after.trim_start())
        } else {
            // e.g., "PRIVATEMETRIC" -- not a keyword
            (AccessModifier::Public, entry)
        }
    } else if entry_upper.starts_with("PUBLIC") {
        let after = &entry["PUBLIC".len()..];
        let trimmed_after = after.trim_start();
        if trimmed_after.starts_with('.') {
            (AccessModifier::Public, entry)
        } else if after.starts_with(char::is_whitespace) || after.is_empty() {
            (AccessModifier::Public, after.trim_start())
        } else {
            (AccessModifier::Public, entry)
        }
    } else {
        (AccessModifier::Public, entry)
    }
};
```

Then after extracting the expression, call `parse_trailing_annotations` on it.

**Step 7: Update `parse_single_table_entry`** for COMMENT and SYNONYMS:

Table entries have a different structure (`alias AS table PRIMARY KEY (cols) [UNIQUE (cols)]`). The trailing annotations come AFTER all constraints. Add annotation parsing after the constraint parsing is complete, before returning the TableRef. Apply `parse_trailing_annotations` to whatever text remains after PRIMARY KEY and UNIQUE parsing. Set the `comment` and `synonyms` fields on the returned `TableRef`.

**Step 8: Add PRIVATE rejection for DIMENSIONS clause:**

In `parse_single_qualified_entry`, add a check: if the function is being called for DIMENSIONS (add a `clause_name: &str` parameter or a boolean flag), reject PRIVATE. Since `parse_qualified_entries` is called for both DIMENSIONS and FACTS, add a `clause_name` parameter to `parse_qualified_entries` and pass it through. For FACTS, accept PRIVATE/PUBLIC. For DIMENSIONS, if PRIVATE is detected, return a ParseError: "PRIVATE is not supported on dimensions. Only facts and metrics can have access modifiers."

Alternatively (simpler): Add PRIVATE/PUBLIC handling to `parse_single_qualified_entry` with a `allow_access_modifier: bool` parameter. When called for DIMENSIONS, pass `false`. When called for FACTS, pass `true`.

Update the return type of `parse_single_qualified_entry` to include `AccessModifier` and update `parse_qualified_entries` similarly.

**Step 9: Update `parse_keyword_body`** to map new fields:

Update the struct literal constructions in `parse_keyword_body` (around lines 328-365) to pass through the new metadata fields from the raw tuples:

For facts_raw: type changes to `Vec<(String, String, String, Option<String>, Vec<String>, AccessModifier)>`. Map to `Fact { name, expr, source_table, output_type: None, comment, synonyms, access }`.

For dimensions_raw: type changes to `Vec<(String, String, String, Option<String>, Vec<String>)>`. Map to `Dimension { name, expr, source_table, output_type: None, comment, synonyms }`.

For metrics_raw: type changes to `Vec<(Option<String>, String, String, Vec<String>, Option<String>, Vec<String>, AccessModifier)>`. Map to `Metric { name, expr, source_table, output_type: None, using_relationships, comment, synonyms, access }`.

Tables already return `TableRef` with comment/synonyms populated directly.

**Step 10: Update `KeywordBody`** -- no change needed since it already contains `Vec<TableRef>`, `Vec<Fact>`, `Vec<Dimension>`, `Vec<Metric>` and these structs now carry metadata fields.

**Step 11: Add imports:**
Add `use crate::model::AccessModifier;` to the imports at the top of body_parser.rs.

**Step 12: Write unit tests in body_parser.rs** for all behaviors listed above. Place them in the existing `mod tests` section. Test via `parse_keyword_body` with full AS-body text that exercises each annotation type.
  </action>
  <verify>
    <automated>cd /Users/paul/Documents/Dev/Personal/duckdb-semantic-views && cargo test body_parser::tests 2>&1 | tail -30</automated>
  </verify>
  <acceptance_criteria>
    - body_parser.rs contains `fn parse_trailing_annotations` that returns `(String, ParsedAnnotations)`
    - body_parser.rs contains `fn extract_single_quoted_string` handling '' escaping
    - body_parser.rs contains `fn parse_synonym_list` parsing comma-separated quoted strings
    - body_parser.rs contains `use crate::model::AccessModifier`
    - parse_single_qualified_entry returns tuple including Option<String> (comment), Vec<String> (synonyms), and AccessModifier (when called for FACTS)
    - parse_single_metric_entry returns tuple including Option<String> (comment), Vec<String> (synonyms), and AccessModifier
    - parse_keyword_body maps comment, synonyms, and access fields onto Fact, Dimension, and Metric structs
    - A DIMENSIONS entry with PRIVATE prefix produces ParseError containing "not supported" and "dimensions"
    - A metric entry "PRIVATE o.cost AS SUM(o.cost)" parses with access=Private
    - A table alias starting with "private" (e.g., "private_schema.metric") does NOT trigger PRIVATE keyword
    - COMMENT = 'It''s a test' produces comment containing "It's a test"
    - An expression containing `comment_count` as an identifier does NOT trigger COMMENT annotation parsing
    - `cargo test body_parser::tests` exits 0
  </acceptance_criteria>
  <done>Body parser recognizes COMMENT, WITH SYNONYMS, and PRIVATE/PUBLIC annotations on all entry types. All metadata flows through KeywordBody into model structs. PRIVATE rejected on dimensions with clear error. Edge cases (escaped quotes, identifier collision, alias disambiguation) handled correctly.</done>
</task>

<task type="auto">
  <name>Task 2: View-level COMMENT in parse.rs + PRIVATE enforcement in expansion engine</name>
  <files>src/parse.rs, src/expand/types.rs, src/expand/sql_gen.rs</files>
  <read_first>
    - src/parse.rs (lines 757-876 -- validate_create_body and rewrite_ddl_keyword_body functions)
    - src/expand/types.rs (ExpandError enum and Display impl)
    - src/expand/sql_gen.rs (lines 24-100 -- expand() function, especially metric resolution at lines 59-80)
    - src/model.rs (AccessModifier enum and SemanticViewDefinition.comment field from Plan 01)
  </read_first>
  <action>
**Part A: View-level COMMENT extraction in parse.rs**

In `validate_create_body` (line 757), after extracting the view name and before detecting the `AS` keyword, add view-level COMMENT extraction:

1. After `let after_name = &after_prefix[name_end..];` (line 783), add:

```rust
    // --- View-level COMMENT extraction (Phase 43) ---
    let after_name_trimmed_pre = after_name.trim_start();
    let (view_comment, remaining_after_comment) = extract_view_comment(after_name_trimmed_pre)?;
```

2. Replace `after_name_trimmed` on line 788 with `remaining_after_comment.trim_start()`:

```rust
    let after_name_trimmed = remaining_after_comment.trim_start();
```

3. Pass `view_comment` to `rewrite_ddl_keyword_body`:

Change the call signature and invocation:
```rust
    return rewrite_ddl_keyword_body(kind, name, after_name_trimmed, body_offset, view_comment);
```

4. Add `extract_view_comment` function in parse.rs:

```rust
/// Extract an optional COMMENT = '...' between the view name and the AS keyword.
/// Returns (comment_option, remaining_text_after_comment).
fn extract_view_comment(text: &str) -> Result<(Option<String>, &str), ParseError> {
    let upper = text.to_ascii_uppercase();
    if upper.starts_with("COMMENT") {
        // Verify word boundary
        if text.len() > 7 && text.as_bytes()[7].is_ascii_alphanumeric() {
            // Not a COMMENT keyword (e.g., COMMENTARY) -- just return as-is
            return Ok((None, text));
        }
        let after_kw = text[7..].trim_start();
        if !after_kw.starts_with('=') {
            return Err(ParseError {
                message: "Expected '=' after COMMENT keyword.".to_string(),
                position: None,
            });
        }
        let after_eq = after_kw[1..].trim_start();
        if !after_eq.starts_with('\'') {
            return Err(ParseError {
                message: "Expected single-quoted string after COMMENT =.".to_string(),
                position: None,
            });
        }
        // Extract the quoted string -- reuse the same logic as body_parser
        // Inline a small extractor here since body_parser's is not pub
        let bytes = after_eq.as_bytes();
        let mut i = 1; // skip opening quote
        let mut value = String::new();
        while i < bytes.len() {
            if bytes[i] == b'\'' {
                if i + 1 < bytes.len() && bytes[i + 1] == b'\'' {
                    value.push('\'');
                    i += 2;
                    continue;
                }
                // Closing quote found
                let remaining = &after_eq[i + 1..];
                return Ok((Some(value), remaining));
            }
            value.push(bytes[i] as char);
            i += 1;
        }
        Err(ParseError {
            message: "Unclosed single-quoted string in view-level COMMENT.".to_string(),
            position: None,
        })
    } else {
        Ok((None, text))
    }
}
```

5. Update `rewrite_ddl_keyword_body` signature and SemanticViewDefinition construction:

```rust
fn rewrite_ddl_keyword_body(
    kind: DdlKind,
    name: &str,
    body_text: &str,
    body_offset: usize,
    view_comment: Option<String>,  // NEW parameter
) -> Result<Option<String>, ParseError> {
    // ... existing code ...
    let def = crate::model::SemanticViewDefinition {
        base_table,
        tables: keyword_body.tables,
        dimensions: keyword_body.dimensions,
        metrics: keyword_body.metrics,
        joins: keyword_body.relationships,
        facts: keyword_body.facts,
        column_type_names: vec![],
        column_types_inferred: vec![],
        created_on: None,
        database_name: None,
        schema_name: None,
        comment: view_comment,  // NEW field
    };
    // ... rest unchanged ...
}
```

**Part B: PRIVATE enforcement in expansion engine**

1. Add new ExpandError variants in `src/expand/types.rs`:

After the `AmbiguousPath` variant, add:

```rust
    /// A requested metric is marked PRIVATE and cannot be queried directly.
    PrivateMetric {
        view_name: String,
        name: String,
    },
    /// A requested fact is marked PRIVATE and cannot be queried directly.
    PrivateFact {
        view_name: String,
        name: String,
    },
```

Add Display impl arms:

```rust
            Self::PrivateMetric { view_name, name } => {
                write!(
                    f,
                    "semantic view '{view_name}': metric '{name}' is private and cannot be queried directly. \
                     Private metrics can only be used in derived metric expressions."
                )
            }
            Self::PrivateFact { view_name, name } => {
                write!(
                    f,
                    "semantic view '{view_name}': fact '{name}' is private and cannot be queried directly. \
                     Private facts can only be used in derived expressions."
                )
            }
```

2. Add access import to `src/expand/sql_gen.rs`:

```rust
use crate::model::AccessModifier;
```

3. Add PRIVATE access check in `expand()` in `src/expand/sql_gen.rs`, after metric resolution (after `let met = find_metric(def, name).ok_or_else(...)? ;` on line ~79):

```rust
        let met = find_metric(def, name).ok_or_else(|| { ... })?;
        // Phase 43: PRIVATE access check
        if met.access == AccessModifier::Private {
            return Err(ExpandError::PrivateMetric {
                view_name: view_name.to_string(),
                name: name.clone(),
            });
        }
        resolved_mets.push(met);
```

Note: `inline_derived_metrics` (line ~85) operates on ALL metrics regardless of access modifier. This is correct -- derived metrics CAN reference private base metrics. The access check only applies to user-requested metrics, not internal resolution.

4. Add PRIVATE access check for facts in `src/expand/facts.rs` -- check if the expand_with_facts function exists and where fact queries are resolved. If fact querying is implemented in a later phase (Phase 46), skip this for now and only add the ExpandError::PrivateFact variant. The variant is needed for Phase 46 to use.

5. Add unit tests in expand/sql_gen.rs `mod tests`:

```rust
#[test]
fn private_metric_rejected() {
    let def = make_def_with_private_metric(); // helper that creates a def with a PRIVATE metric
    let req = QueryRequest {
        dimensions: vec!["region".to_string()],
        metrics: vec!["secret_cost".to_string()],
    };
    match expand("test_view", &def, &req) {
        Err(ExpandError::PrivateMetric { name, .. }) => {
            assert_eq!(name, "secret_cost");
        }
        other => panic!("Expected PrivateMetric error, got: {:?}", other),
    }
}

#[test]
fn derived_metric_referencing_private_base_works() {
    // A derived metric like "profit AS revenue - secret_cost" should work
    // even though secret_cost is PRIVATE, because derived metric inlining
    // resolves expressions, not access modifiers.
    let def = make_def_with_private_and_derived(); // def with PRIVATE base + PUBLIC derived
    let req = QueryRequest {
        dimensions: vec!["region".to_string()],
        metrics: vec!["profit".to_string()],
    };
    let sql = expand("test_view", &def, &req).expect("should succeed");
    assert!(sql.contains("profit"), "SQL should contain profit metric");
}
```

Create the helper functions `make_def_with_private_metric` and `make_def_with_private_and_derived` following the existing test helper patterns in expand/test_helpers.rs or inline in the test module.

6. Add unit tests in parse.rs for view-level COMMENT extraction:

Test that `CREATE SEMANTIC VIEW my_view COMMENT = 'My view' AS TABLES (...) DIMENSIONS (...) METRICS (...)` produces a JSON that includes `"comment":"My view"`.

Test that `CREATE SEMANTIC VIEW my_view AS TABLES (...) ...` (no COMMENT) produces JSON without a comment field.

Test that `CREATE SEMANTIC VIEW my_view COMMENT = 'It''s great' AS TABLES (...)` handles escaped quotes.
  </action>
  <verify>
    <automated>cd /Users/paul/Documents/Dev/Personal/duckdb-semantic-views && cargo test 2>&1 | tail -10</automated>
  </verify>
  <acceptance_criteria>
    - parse.rs contains `fn extract_view_comment` that returns `(Option<String>, &str)`
    - parse.rs `rewrite_ddl_keyword_body` accepts `view_comment: Option<String>` parameter and sets `comment: view_comment` on SemanticViewDefinition
    - expand/types.rs contains `PrivateMetric { view_name: String, name: String }` variant in ExpandError
    - expand/types.rs contains `PrivateFact { view_name: String, name: String }` variant in ExpandError
    - expand/types.rs Display impl contains "is private and cannot be queried directly" for PrivateMetric
    - expand/sql_gen.rs contains `use crate::model::AccessModifier`
    - expand/sql_gen.rs contains `met.access == AccessModifier::Private` check in expand() function
    - `cargo test parse::tests` includes view-level COMMENT parsing tests that pass
    - `cargo test expand::sql_gen::tests::private` passes (PRIVATE metric rejection + derived metric passthrough)
    - `cargo test` exits 0 (full suite green)
  </acceptance_criteria>
  <done>View-level COMMENT extracted in parse.rs before AS keyword. PRIVATE metrics rejected at expansion time with clear error. Derived metrics that reference PRIVATE bases still expand correctly. All unit tests pass.</done>
</task>

<task type="auto">
  <name>Task 3: Integration tests via sqllogictest</name>
  <files>test/sql/phase43_metadata.test</files>
  <read_first>
    - test/sql/phase25_keyword_body.test (existing sqllogictest patterns for CREATE SEMANTIC VIEW DDL testing)
    - test/sql/phase29_facts.test (FACTS clause testing patterns)
    - test/sql/phase30_derived_metrics.test (derived metric patterns)
    - test/sql/phase2_restart.test (persistence/restart testing pattern)
  </read_first>
  <action>
Create `test/sql/phase43_metadata.test` with sqllogictest format tests. Follow the exact patterns from existing test files (statement ok, query, statement error).

The file should cover:

**Section 1: Setup -- create base tables**
```sql
statement ok
CREATE TABLE orders (id INTEGER, amount DECIMAL(10,2), cost DECIMAL(10,2), region VARCHAR, created_at DATE);

statement ok
INSERT INTO orders VALUES (1, 100.00, 60.00, 'US', '2026-01-01'), (2, 200.00, 120.00, 'EU', '2026-01-02'), (3, 150.00, 90.00, 'US', '2026-01-03');

statement ok
CREATE TABLE customers (id INTEGER, name VARCHAR);

statement ok
INSERT INTO customers VALUES (1, 'Alice'), (2, 'Bob');
```

**Section 2: View-level COMMENT (META-01)**
```sql
statement ok
CREATE SEMANTIC VIEW revenue_view COMMENT = 'Revenue analysis for Q1' AS TABLES (o AS orders PRIMARY KEY (id)) DIMENSIONS (o.region AS o.region) METRICS (o.total_revenue AS SUM(o.amount))
```

Verify the view was created successfully by querying it:
```sql
query TR
SELECT * FROM semantic_view('revenue_view', dimensions := ['region'], metrics := ['total_revenue']) ORDER BY region;
----
EU	200.00
US	250.00
```

**Section 3: Object-level COMMENT on entries (META-02)**
```sql
statement ok
DROP SEMANTIC VIEW revenue_view;

statement ok
CREATE SEMANTIC VIEW revenue_view COMMENT = 'Revenue analysis' AS TABLES (o AS orders PRIMARY KEY (id) COMMENT = 'Order records' WITH SYNONYMS = ('sales_orders')) DIMENSIONS (o.region AS o.region COMMENT = 'Geographic region' WITH SYNONYMS = ('territory', 'area')) METRICS (o.total_revenue AS SUM(o.amount) COMMENT = 'Total revenue')
```

Query to verify it still works:
```sql
query TR
SELECT * FROM semantic_view('revenue_view', dimensions := ['region'], metrics := ['total_revenue']) ORDER BY region;
----
EU	200.00
US	250.00
```

**Section 4: WITH SYNONYMS (META-03)**
Already covered in Section 3. Add a standalone test:
```sql
statement ok
DROP SEMANTIC VIEW revenue_view;

statement ok
CREATE SEMANTIC VIEW revenue_view AS TABLES (o AS orders PRIMARY KEY (id) WITH SYNONYMS = ('order_table', 'sales')) DIMENSIONS (o.region AS o.region WITH SYNONYMS = ('territory')) METRICS (o.total_revenue AS SUM(o.amount) WITH SYNONYMS = ('gross_revenue', 'total_sales'))
```

**Section 5: PRIVATE/PUBLIC on metrics (META-04)**
```sql
statement ok
DROP SEMANTIC VIEW revenue_view;

statement ok
CREATE SEMANTIC VIEW revenue_view AS TABLES (o AS orders PRIMARY KEY (id)) FACTS (o.amount_fact AS o.amount, PRIVATE o.cost_fact AS o.cost) DIMENSIONS (o.region AS o.region) METRICS (o.total_revenue AS SUM(o.amount), PRIVATE o.total_cost AS SUM(o.cost), o.profit AS total_revenue - total_cost)
```

**Section 6: PRIVATE exclusion at query time (META-05)**

Querying a PRIVATE metric directly should fail:
```sql
statement error
SELECT * FROM semantic_view('revenue_view', metrics := ['total_cost']);
----
private
```

Querying a PUBLIC derived metric that references PRIVATE base should succeed:
```sql
query R
SELECT * FROM semantic_view('revenue_view', metrics := ['profit']);
----
-20.00
```

(profit = SUM(amount) - SUM(cost) = 450 - 270 = 180... verify actual values with test data. 100+200+150=450 revenue, 60+120+90=270 cost, profit=180)

Actually compute: total_revenue = SUM(amount) = 100+200+150 = 450, total_cost = SUM(cost) = 60+120+90 = 270, profit = 450 - 270 = 180.

```sql
query R
SELECT * FROM semantic_view('revenue_view', metrics := ['profit']);
----
180.00
```

Querying profit WITH a dimension:
```sql
query TR
SELECT * FROM semantic_view('revenue_view', dimensions := ['region'], metrics := ['profit']) ORDER BY region;
----
EU	80.00
US	100.00
```

(EU: 200-120=80, US: (100-60)+(150-90)=40+60=100)

PUBLIC metric still queryable:
```sql
query TR
SELECT * FROM semantic_view('revenue_view', dimensions := ['region'], metrics := ['total_revenue']) ORDER BY region;
----
EU	200.00
US	250.00
```

**Section 7: PRIVATE on dimensions rejected (META-04 negative test)**
```sql
statement error
CREATE SEMANTIC VIEW bad_view AS TABLES (o AS orders PRIMARY KEY (id)) DIMENSIONS (PRIVATE o.region AS o.region) METRICS (o.total AS SUM(o.amount))
----
PRIVATE
```

(The error message should contain "PRIVATE" indicating it's not supported on dimensions.)

**Section 8: COMMENT with escaped quotes**
```sql
statement ok
DROP SEMANTIC VIEW revenue_view;

statement ok
CREATE SEMANTIC VIEW revenue_view COMMENT = 'It''s a revenue view' AS TABLES (o AS orders PRIMARY KEY (id)) DIMENSIONS (o.region AS o.region COMMENT = 'Region where it''s sold') METRICS (o.total_revenue AS SUM(o.amount))
```

**Section 9: COMMENT and SYNONYMS order flexibility**
```sql
statement ok
DROP SEMANTIC VIEW revenue_view;

statement ok
CREATE SEMANTIC VIEW revenue_view AS TABLES (o AS orders PRIMARY KEY (id)) DIMENSIONS (o.region AS o.region WITH SYNONYMS = ('territory') COMMENT = 'The region') METRICS (o.total_revenue AS SUM(o.amount) COMMENT = 'Total rev' WITH SYNONYMS = ('gross'))
```

Verify query still works:
```sql
query TR
SELECT * FROM semantic_view('revenue_view', dimensions := ['region'], metrics := ['total_revenue']) ORDER BY region;
----
EU	200.00
US	250.00
```

**Section 10: Persistence across restart (META-06)**

Follow the pattern from phase2_restart.test -- verify that after creating a view with metadata, the view survives (the restart test is handled by the sqllogictest framework if supported, otherwise just verify the view is queryable after creation which implicitly tests persistence through the JSON roundtrip).

**Section 11: Explicit PUBLIC keyword**
```sql
statement ok
DROP SEMANTIC VIEW revenue_view;

statement ok
CREATE SEMANTIC VIEW revenue_view AS TABLES (o AS orders PRIMARY KEY (id)) METRICS (PUBLIC o.total_revenue AS SUM(o.amount))

query R
SELECT * FROM semantic_view('revenue_view', metrics := ['total_revenue']);
----
450.00
```
  </action>
  <verify>
    <automated>cd /Users/paul/Documents/Dev/Personal/duckdb-semantic-views && just build && just test-sql 2>&1 | tail -30</automated>
  </verify>
  <acceptance_criteria>
    - test/sql/phase43_metadata.test exists and contains at minimum 10 test cases
    - Test file contains "COMMENT =" in at least 5 test cases
    - Test file contains "WITH SYNONYMS =" in at least 3 test cases
    - Test file contains "PRIVATE" in at least 3 test cases (both CREATE and error case)
    - Test file contains "statement error" for querying PRIVATE metric
    - Test file contains "statement error" for PRIVATE on dimension
    - Test file verifies derived metric referencing PRIVATE base works (query with profit)
    - Test file verifies escaped quotes in COMMENT ('It''s')
    - Test file verifies COMMENT and SYNONYMS in both orders
    - `just build && just test-sql` exits 0
    - `just test-all` exits 0
  </acceptance_criteria>
  <done>Comprehensive sqllogictest integration tests verify all META requirements through the full DDL -> parse -> persist -> query pipeline. All tests pass via `just test-all`.</done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| DDL text -> body parser | User-supplied SQL text containing COMMENT strings is parsed |
| COMMENT string -> JSON persistence | COMMENT value embedded in JSON stored via parameterized SQL |
| Query request -> expansion engine | User-requested metric names checked against access modifiers |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-43-01 | Tampering | body_parser.rs COMMENT parsing | mitigate | COMMENT string extracted via `extract_single_quoted_string` with proper '' escape handling. Value stored in struct field, serialized to JSON, persisted via parameterized prepared statements in persist.rs (existing pattern). No raw string interpolation into SQL. |
| T-43-02 | Information Disclosure | expand/sql_gen.rs PRIVATE enforcement | mitigate | Access check added immediately after `find_metric` resolution in `expand()`. Private metrics produce `ExpandError::PrivateMetric` error. `inline_derived_metrics` intentionally bypasses access check for internal expression resolution. |
| T-43-03 | Spoofing | body_parser.rs PRIVATE keyword | accept | A table alias named "PRIVATE" (e.g., `PRIVATE.metric AS ...`) is disambiguated by checking if the next non-whitespace char is '.'. Edge case is unlikely in practice and handled correctly. |
</threat_model>

<verification>
```bash
cargo test 2>&1 | tail -5
just build && just test-sql 2>&1 | tail -10
just test-all 2>&1 | tail -5
```
Full test suite green including: body_parser annotation tests, parse.rs view-level comment tests, expand PRIVATE enforcement tests, and sqllogictest integration tests.
</verification>

<success_criteria>
- Users can write COMMENT = '...' on views and all object types in DDL
- Users can write WITH SYNONYMS = ('a', 'b') on all object types
- COMMENT and SYNONYMS work in either order
- PRIVATE keyword works on metrics and facts, rejected on dimensions
- Querying a PRIVATE metric produces clear error
- Derived metrics referencing PRIVATE bases still work
- Escaped quotes in COMMENT handled correctly
- `just test-all` passes with all new tests green
</success_criteria>

<output>
After completion, create `.planning/phases/43-metadata-foundation/43-02-SUMMARY.md`
</output>
