# Tech Debt & Deferred Items (v0.1.0 -> v0.12.0)

This document catalogs all accepted design decisions, deferred requirements, known architectural limitations, and test coverage gaps across milestones v0.1.0 through v0.12.0. It serves as the canonical record of trade-offs so that future work does not need to re-discover decisions made during earlier milestones.

## Accepted Design Decisions

These are intentional trade-offs made during v0.1.0 development. Each was the best available option given constraints at the time.

### 1. ✅ Sidecar file persistence instead of SQL-based catalog writes

- **Origin:** Phase 2, decision [02-04] sidecar-persistence
- **Decision:** DuckDB holds execution locks during scalar `invoke()`, which prevents any SQL execution from within DDL functions (`define_semantic_view`, `drop_semantic_view`). Both `try_clone()` (same-instance locks) and `Connection::open(path)` (file-level lock) deadlock or block. The extension writes catalog changes to a `.semantic_views` sidecar file using plain file I/O with atomic rename (write-to-tmp-then-rename). On next extension load, `init_catalog` reads the sidecar and syncs definitions into the `semantic_layer._definitions` DuckDB table.
- **Action:** Resolved. The sidecar file was eliminated in v0.2.0; the v0.2-era `persist_conn` (`pragma_query_t` write-first pattern) was itself superseded in v0.8.0 by the `parser_override` design, which rewrites catalog writes to native DML that runs on the caller's own connection (`persist_conn` no longer exists). A no-op C++ shim was removed in v0.4.0, and a **new** C++ shim was reintroduced in v0.5.0 for the parser-override hook + read-side Catalog-API table functions — persistence itself is pure-Rust native SQL, but "no C++ shim exists" is no longer true.

### 2. ✅ Catalog table naming: `semantic_layer._definitions`

- **Origin:** Phase 2 audit item (catalog table name deviation)
- **Decision:** REQUIREMENTS.md originally specified `_semantic_views_catalog` as the table name. The implementation uses `semantic_layer._definitions` (a dedicated schema with a prefixed table name). This provides better namespace isolation and follows DuckDB conventions for extension-owned objects. The requirement text (DDL-05) was updated to match the implementation.
- **Action:** None needed. The naming is accepted as correct.

### 3. ✅ All output columns are VARCHAR — RESOLVED

- **Origin:** Phase 4, decision [04-03] varchar-output-columns
- **Decision:** Originally declared all columns as VARCHAR to avoid type mismatch panics. Resolved in v0.2.0 with typed output, then further simplified post-v0.2.0 with zero-copy vector references (`duckdb_vector_reference_vector`). Type mismatches are now handled at SQL generation time via `build_execution_sql` casts.
- **Action:** None needed. Output is fully typed with zero-copy transfer.

### 4. ✅ Manual FFI entrypoint instead of macro

- **Origin:** Phase 4, decision [04-01] manual-ffi-entrypoint
- **Decision:** The extension uses a hand-written FFI entrypoint (`src/lib.rs`, mirroring what `#[duckdb_entrypoint_c_api]` generates) rather than the macro, so it can capture the raw `duckdb_database` handle. The handle is used to register the parser-override hook and the read-side/query table functions via the C++ Catalog API.
- **Note (rationale updated):** The original entry justified the manual entrypoint by "creating an independent `duckdb_connection` for `semantic_query()`." That is no longer how queries run: since Phase 65 `semantic_view()` (and the read-side TFs) execute on a **fresh per-call `Connection(*context.db)` opened inside the C++ bind callback**, not a long-lived independent connection. The extension/runtime path opens no independent connection via `duckdb_connect` (the only `duckdb_connect` call in `src/` is in the `#[cfg(not(feature = "extension"))]` unit-test helper `test_helpers::RawDb`, which never runs in the loaded extension). The manual entrypoint is retained for the database-handle capture the registration path needs.
- **Action:** None needed.

### 5. ❌ Native EXPLAIN deferred to v0.2.0

- **Origin:** Phase 4, QUERY-04 (reworded); tracked as QUERY-V2-03
- **Decision:** The `explain_semantic_view()` table function provides expanded SQL inspection as a workaround. Native `EXPLAIN FROM semantic_query(...)` would show the expanded SQL instead of the DuckDB physical plan, but this would require intercepting the EXPLAIN hook, which is not accessible from a loadable extension (Python DuckDB uses `-fvisibility=hidden`).
- **Action:** The C++ shim was removed in v0.4.0 (it was a no-op stub). Native EXPLAIN interception remains architecturally blocked -- it would require DuckDB to expose EXPLAIN hooks via the C API or extension loading mechanism.

### 6. ✅ ON-clause substring matching for join dependency detection — RESOLVED in v0.5.2

- **Origin:** Phase 3, decision [03-02] on-clause-substring-matching
- **Decision:** Transitive join dependency detection originally checked whether a table name appears as a substring in the ON clause of other joins.
- **Resolution:** v0.5.2 Phase 26 replaced this with graph-based PK/FK join resolution (`src/graph.rs`). `RelationshipGraph` uses an adjacency list with reverse edges, Kahn's algorithm for topological sort, and `synthesize_on_clause()` to generate ON clauses from declared PK/FK columns. Phase 27 deleted the legacy `resolve_joins` code entirely — PK/FK graph resolution is now the only join path.
- **Action:** None needed.

### 7. ✅ Unqualified column names required in expressions — RESOLVED in v0.5.2

- **Origin:** Phase 4, decision [04-03] unqualified-join-expressions
- **Decision:** Dimension and metric expressions originally required unqualified column names because the CTE-based expansion flattened all source tables into a single `_base` namespace.
- **Resolution:** v0.5.2 Phase 27 replaced CTE flattening with direct `FROM base AS alias LEFT JOIN ...` expansion. Dimension/metric expressions now use qualified column references (`alias.column`) which resolve correctly against the aliased tables. The `_base` CTE was removed entirely (CLN-02).
- **Action:** None needed.

### 8. ✅ Statement rewrite approach for native DDL — RESOLVED in v0.5.2

- **Origin:** v0.5.0 Phase 16-17, parser extension spike
- **Decision:** Native DDL (`CREATE SEMANTIC VIEW name (...)`) was originally implemented as syntactic sugar over function-call syntax (`:=` named parameters with struct/list literals).
- **Resolution:** v0.5.2 Phase 25 ("SQL Body Parser") implemented a full SQL keyword body parser (`src/body_parser.rs`) that accepts conventional SQL syntax: `TABLES (...) RELATIONSHIPS (...) DIMENSIONS (...) METRICS (...)`. The translation layer in `src/parse.rs` (`rewrite_ddl_keyword_body()`) converts the parsed SQL body into the underlying function-based execution model. No `:=` parameters or struct literals required — pure SQL DDL grammar.
- **Action:** None needed.

### 9. ✅ DDL connection isolation pattern — SUPERSEDED (historical)

- **Origin:** v0.5.0 Phase 17, DDL execution
- **Decision (historical):** The v0.5-era plan_function executed rewritten DDL SQL on a separate `duckdb_connection` created at extension init time and stored as a file-scope static (`sv_ddl_conn`) in `shim.cpp`, to avoid deadlocking the main connection's ClientContext lock held during the bind phase.
- **Superseded:** This mechanism no longer exists — `sv_ddl_conn` has zero occurrences in the codebase. Since v0.8.0 (the `parser_override` unification), write-side DDL is rewritten to **native DML that runs on the caller's own connection**, so it participates in the caller's transaction (the ADBC `autocommit = false` fix). See the accurate entries #23 and #27 for the current DROP/ALTER/CREATE concurrency semantics, and `src/parse/native_sql.rs` for the rewrite. No separate DDL connection is used or needed.

### 10. ✅ Amalgamation compilation via cc crate

- **Origin:** v0.5.0 Phase 15, C++ shim infrastructure
- **Decision:** `duckdb.cpp` (the DuckDB amalgamation, ~23MB / ~300K lines) is compiled alongside `shim.cpp` via the `cc` crate. First build takes ~2.5 minutes; cached on subsequent builds. This provides ALL DuckDB C++ symbols (constructors, RTTI, vtables) without manual stubs. Symbol visibility restricts exports to the entry point only, preventing ODR conflicts with the host DuckDB process. The amalgamation must be version-pinned to match `TARGET_DUCKDB_VERSION`.
- **Action:** The compilation cost is one-time per clean build. `build.rs` feature-gates the C++ compilation behind `CARGO_FEATURE_EXTENSION`, so `cargo test` (default features) is unaffected.

### 11. ✅ C_STRUCT_UNSTABLE ABI (evaluated, kept)

- **Origin:** v0.5.0 Phase 18, ABI evaluation
- **Decision:** Evaluated switching from `C_STRUCT_UNSTABLE` to `CPP` ABI for community extension registry compatibility. Rejected: CPP entry point failed in Phase 15 because `ExtensionLoader` referenced non-inlined C++ symbols unavailable under Python DuckDB's `-fvisibility=hidden`. `C_STRUCT_UNSTABLE` pins the binary to an exact DuckDB version (same as CPP in practice). Compatible with the community extension registry (`rusty_quack` uses the same approach). The version-pinning cost is mitigated by the DuckDB Version Monitor CI workflow.
- **Action:** No change. Re-evaluate if DuckDB stabilizes the C API or adds a new ABI type for mixed Rust+C++ extensions.

### 12. ✅ DDL pipeline uses all-VARCHAR result forwarding — SUPERSEDED (historical)

- **Origin:** v0.5.1 Phase 20, C++ result forwarding for DESCRIBE/SHOW
- **Decision (historical):** The v0.5-era DDL parser hook pipeline (`sv_ddl_bind`/`sv_ddl_execute` executing on `sv_ddl_conn`) read results via `duckdb_value_varchar` into `vector<vector<string>>` and declared all output columns as VARCHAR.
- **Superseded:** That pipeline no longer exists — `sv_ddl_bind`, `sv_ddl_execute`, and `sv_ddl_conn` have zero occurrences in `shim.cpp`. Since Phase 65 the read-side table functions (DESCRIBE / SHOW / GET_DDL / READ_YAML) are real C++ Catalog-API table functions whose Rust dispatchers assemble rows and serialize them over the AR-3 self-describing wire format (`src/ddl/read_ffi.rs`). DESCRIBE still declares a deliberate 5-column VARCHAR schema (`object_kind, object_name, parent_entity, property, property_value`) — that is the intended Snowflake-shaped property table, not the retired forwarding pipeline. No further action.

## Deferred Requirements

Requirements originally deferred from v0.1.0. Updated to reflect status as of v0.5.0.

| | ID | Description | Status |
|---|---|-------------|--------|
| ✅ | QUERY-V2-01 | Native `CREATE SEMANTIC VIEW` DDL syntax | Resolved in v0.5.0 via statement rewrite (Phase 16-17). |
| ✅ | QUERY-V2-02 | Time dimensions with granularity coarsening | Resolved in v0.4.0 — removed; users write `date_trunc()` directly in dimension `expr`. |
| ❌ | QUERY-V2-03 | Native `EXPLAIN` interception for `semantic_query()` | Architecturally blocked: EXPLAIN hooks not exposed to loadable extensions. |
| ✅ | DIST-V2-01 | Published to DuckDB community extension registry | Published: `INSTALL semantic_views FROM community; LOAD semantic_views;` (see CHANGELOG for the per-DuckDB-version rebuilds). |
| ❓ | DIST-V2-02 | Real-world TPC-H demo with documented example queries | Pending — no TPC-H demo in `examples/` yet (registry publishing, its former blocker, has since landed). |
| ✅ | — | Replace sidecar file persistence with SQL-based catalog writes | Resolved in v0.2.0 (sidecar eliminated); the write path was again reworked in v0.8.0 to native DML on the caller's connection. See Accepted Decision #1. |

## Known Architectural Limitations

Constraints inherent to the current approach that affect users or maintainers.

### 1. ❓ FFI execution layer not fuzz-covered

- **What:** The `execute_sql_raw` function and `duckdb_vector_reference_vector` call in `src/query/table_function.rs` contain the remaining unsafe code in the extension. The query pipeline uses zero-copy vector references to stream result chunks directly into output, replacing the previous binary-read dispatch.
- **Why:** These functions require the DuckDB loadable-extension function-pointer stubs, which are only initialized at runtime when DuckDB loads the extension via `LOAD`. They cannot run in a standalone test binary.
- **Impact:** The unsafe surface area is significantly smaller than v0.2.0's binary-read dispatch — only `execute_sql_raw` (query execution) and `duckdb_vector_reference_vector` (shared vector ownership) remain in the hot path. Type mismatches are handled at SQL generation time via `build_execution_sql` casts, not at read/write time.
- **Mitigation:** SQLLogicTest integration tests exercise these paths with real data. `tests/vector_reference_test.rs` validates the zero-copy mechanism directly (lifetime safety, multi-chunk, complex types). The 36 PBTs in `tests/output_proptest.rs` still validate end-to-end type correctness via `test_helpers`.

### 2. ❓ DuckDB version pinning (exact)

- **What:** The `duckdb` / `libduckdb-sys` crate dependencies are pinned to an exact version in `Cargo.toml` — the duckdb-rs release tracking the DuckDB version held in `.duckdb-version` (the repo's single source of truth). The encoding is `vX.Y.Z` → `= 1.1XY0Z.0`; `tests::duckdb_version_pins_agree` asserts the two agree, so this entry deliberately does not restate the number.
- **Why:** DuckDB's ABI is not stable across minor versions. An extension built against one version may crash or fail to load with a different DuckDB runtime.
- **Impact:** Every DuckDB release requires a version bump, rebuild, and re-test of the extension. The `DuckDBVersionMonitor.yml` CI workflow automates detection and opens a PR when a new DuckDB version is available.
- **Mitigation:** The version monitor workflow (Phase 1, INFRA-03) detects new releases and opens a PR with `@copilot` mention for automated investigation. Manual version bumps follow the process documented in MAINTAINER.md.

### 3. ✅ All output columns are VARCHAR — RESOLVED

- **What:** Originally, the `semantic_query()` table function returned all columns as VARCHAR. This was resolved in v0.2.0 with binary-read dispatch, then further improved post-v0.2.0 with zero-copy vector references.
- **Current state:** Output columns are fully typed. The table function uses `duckdb_vector_reference_vector` to stream result chunks directly into output with zero copying. Type mismatches between bind-time inference and runtime (e.g., HUGEINT→BIGINT from optimizer changes, STRUCT/MAP→VARCHAR) are handled by `build_execution_sql`, which wraps the expanded SQL with explicit casts where needed.
- **Impact:** None — consumers receive correctly typed output. No manual casting required.

### 4. ✅ Unqualified column names required in expressions — RESOLVED in v0.5.2

- **What:** Dimension and metric SQL expressions originally required unqualified column names due to CTE-based `_base` flattening.
- **Resolution:** See Accepted Decision 7. v0.5.2 Phase 27 replaced CTE flattening with direct `FROM base AS alias LEFT JOIN ...` expansion. Qualified column references (`alias.column`) now work correctly.

## Test Coverage Gaps

Areas where test coverage is reduced compared to ideal, with justification.

> **The live gaps are in entry #66 below.**
> The four entries in this section are pre-v0.5 items about *which harness type* covers a
> feature; two are long resolved. They say nothing about the randomized-coverage axes that
> have actually admitted wrong-number bugs (role-playing, FACTS requests, window/semi-additive
> generator pins), which is why review after review has had to rediscover them. New coverage
> gaps go in the numbered ledger below, not here.

### 1. ❓ Iceberg integration test uses Python instead of SQLLogicTest

- **Origin:** Phase 4 audit item; decision [04-03] python-ducklake-test
- **Reason:** The DuckDB SQLLogicTest runner cannot dynamically install extensions (DuckLake, httpfs). The integration test requires loading these extensions to create Iceberg-backed tables.
- **Mitigation:** `test/integration/test_ducklake.py` covers the same semantic query functionality against DuckLake tables. It is run via `just test-ducklake` (local) / `just test-ducklake-ci` (synthetic-data CI variant) and exercises the full round-trip: define semantic view, query with dimensions and metrics, assert correct results.

### 2. ❓ FFI execution layer not fuzz-testable standalone

- **Origin:** Phase 5 audit item (TEST-05 partial scope)
- **Reason:** The loadable-extension function-pointer stubs (`duckdb_query`, `duckdb_value_varchar`, etc.) are only available at runtime when DuckDB loads the extension. A standalone fuzz binary cannot initialize these stubs.
- **Mitigation:** Nine fuzz targets cover the non-FFI attack surface: `fuzz_json_parse`, `fuzz_yaml_parse`, `fuzz_ddl_parse`, `fuzz_keyword_body`, `fuzz_sql_expand`, `fuzz_query_names`, `fuzz_render_roundtrip`, `fuzz_parser_override_ffi`, and `fuzz_where_predicate` (see `fuzz/fuzz_targets/`). SQLLogicTest provides integration coverage of the FFI layer. Post-v0.2.0, the FFI unsafe surface is much smaller — the zero-copy vector reference approach eliminated all per-type binary read/write code; only `execute_sql_raw` and `duckdb_vector_reference_vector` remain in the hot path. `tests/vector_reference_test.rs` validates zero-copy lifetime safety under `cargo test`.

### 3. ✅ Sandbox test portability (resolved in Phase 6)

- **Origin:** Phase 3 audit item (3 catalog sidecar tests failed in sandbox)
- **Reason:** Three Rust tests creating temporary files used hardcoded paths that were inaccessible in sandboxed environments.
- **Resolution:** Phase 6 (decision [06-01] temp-dir-pattern) updated all tests to use `std::env::temp_dir()` for portable temporary file paths. This gap is now closed.

### 4. ✅ DDL prefix whitespace — RESOLVED in Phase 25.1

- **Origin:** Phase 25 proptest surfaced this
- **Resolution:** Phase 25.1 replaced `starts_with_ci` literal prefix matching with
  `match_keyword_prefix` token-based scanning. Every DDL prefix form (the
  `DdlKind` set has since grown to ~15: CREATE / OR REPLACE / IF NOT EXISTS,
  DROP / IF EXISTS, ALTER / IF EXISTS, DESCRIBE, SHOW SEMANTIC
  VIEWS/TERSE/DIMENSIONS/METRICS/FACTS/MATERIALIZATIONS, SHOW COLUMNS) now
  tolerates arbitrary ASCII whitespace (space, tab, newline, carriage return,
  vertical tab, form feed) between keywords. The `prefix_len()` static function
  was replaced by the dynamic byte count returned by `detect_ddl_prefix(query)`.
- **Scope:** ASCII whitespace only (6 characters: 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x20).
  Unicode whitespace is handled by DuckDB before the hook fires.

---

## v0.8.0 additions

### 19. ❓ Read-side table functions see committed state / the primary catalog, not the caller's transaction

- **Origin:** v0.8.0 known limitation; blocker re-evaluated at v0.10.x (AR-6, code-review 2026-07-02).
- **Decision:** The read-side table functions (`describe_semantic_view`, `list_semantic_views`, `show_semantic_*`, `read_yaml_from_semantic_view`, `get_ddl`, and `semantic_view()` itself) read `semantic_layer._definitions` on a **fresh `Connection(*context.db)`** opened inside each C++ bind callback (`cpp/src/shim.cpp`), not on the caller's connection. A fresh connection runs its own transaction, so reads (a) see only **committed** state — a `BEGIN; CREATE SEMANTIC VIEW v ...; SHOW SEMANTIC VIEWS` will not list `v` until the COMMIT lands (**FF-2**), and (b) resolve against the primary/default catalog, ignoring `USE <attached_db>` (the read side of the single-catalog limitation, **TECH-DEBT #26**). Both share this one root cause. Documented for users in CHANGELOG since v0.8.0 and on the transactional-DDL explanation page.
- **Why not the caller's context (AR-6 re-evaluation — corrects the original entry):** The v0.8.0-era note blamed the C API for not exposing `BindInfo`'s connection. That was stale and only half the story. The Phase 65 C++ Catalog-API binds now have the caller's `ClientContext &` in hand, and reads **still** cannot run on it: `ClientContext::context_lock` is a **non-recursive `std::mutex`** the caller holds continuously from parse → bind → exec of the outer query, so the bind callback runs *inside* that held lock. Any re-entrant `context.Query(...)` re-acquires the same mutex and **self-deadlocks** — deterministic, within ~milliseconds, empirically proven by the Phase 65 spikes (`A2-DEADLOCK` at plan time, `EXEC-TIME-RC1` at exec time; the bind phase sits inside the identical held lock; the fresh-connection path is the one that works, `READ-BIND-RC0`). Opening a fresh connection is safe because it takes `connections_lock` (free), not `context_lock`. So exposing the caller's context is **necessary but not sufficient**: no single DuckDB v1.5.4 mechanism provides both liveness and the caller's transaction for a catalog read from inside a bind.
- **Action if DuckDB ever provides both primitives:** Route reads onto the caller's transaction only once upstream provides **both** (a) `ClientContext` / `DatabaseInstance` access through duckdb-rs `BindInfo`, **and** (b) a recursive / relinquishable `context_lock` (or a re-dispatch path that runs the read through the caller's own binder without re-entering `ClientContext::Query`). Note this is not a pure API-surface change: (a) alone is insufficient, because the `context_lock` self-deadlock (b) is the binding constraint. With both, reads move onto the caller's transaction, fixing FF-2 and the read side of #26 together and letting the fresh per-call connection be dropped. Until then this is an accepted limitation grounded in a DuckDB liveness constraint, not an incidental FFI gap.
- **The caller's *session config* is blocked by a SECOND, different wall — do not read this entry as "the only obstacle is `context_lock`" (added 2026-08-02, schema-scoping investigation).** Everything above concerns *executing a query* on the caller's context. Reading a plain **field** off `ClientContext` takes no lock at all and is therefore not subject to the deadlock — so "can the bind see the caller's `search_path` / current schema?" looks like it should be answerable even today. It is not, for an unrelated reason: this project builds against the DuckDB **amalgamation**, and the search path lives in `ClientContext::client_data`, whose type `struct ClientData` is only **forward-declared** there (`cpp/include/duckdb.hpp`), so it has no accessible members. `ClientContext::config` is a by-value `ClientConfig` that *is* defined but carries nothing schema-related, and `ClientContext`'s public API (`Query` / `PendingQuery` / `Prepare` / `TableInfo` / `Append`) exposes no search-path accessor. The only routes past it are building against full DuckDB headers instead of the amalgamation, or hand-declaring the struct layout and reaching in — ABI-fragile UB. Neither is acceptable.
  - **Consequence for schema-scoped resolution:** the read-side table functions cannot resolve an unqualified semantic-view name against the caller's schema on their own. Resolution is therefore threaded in **at rewrite time** — the parser override embeds the caller's search path, which DuckDB evaluates on the caller's connection before the table function is bound.
  - **The injection landed 2026-08-03** (`src/parse/search_path.rs`). Every resolving read table function gained a `search_path` named parameter (`LIST(VARCHAR)`), and the parser override appends `search_path := list_concat(current_schemas(false), [current_schema()])` to each call it sees. What makes this work is that **DuckDB constant-folds table-function arguments on the caller's connection, before the bind callback runs** — so an ordinary scalar expression is the one channel into a bind that the two walls above do not close. (A subquery is not: `Table function cannot contain subqueries`.) `current_schemas(true)` is deliberately not used — it prepends the *temp* schema, itself named `main`, which would win over a real schema of that name.
    - **Not covered: the two scalar functions.** `get_ddl` and `read_yaml_from_semantic_view` are scalars, which have no named parameters, so giving them the path needs a second registered arity. They keep the no-path fallback — the unique match, else an ambiguity error — which is conservative rather than wrong. Recorded under #25.
  - **That injection is forward-compatible and should be deleted when this entry resolves.** The action above would fix the session-config problem incidentally: once reads run on the caller's context, `current_schemas()` inside the catalog query simply evaluates in the caller's session and the injected argument becomes redundant. So the two are the *same* upstream ask, not separate ones — but the injection is what makes DuckDB-conventional resolution possible in the meantime.

### 20. ✅ Bounded LRU evictions are silent at the parser-override site

**Resolved in Phase 62 (v0.8.0), simplified further in Phase 65 / AR-7.** The bounded LRU is gone: multi-DB processes can load the extension into arbitrarily many DuckDB instances without eviction. The Phase-62 design held a per-DB `OverrideContext` (with an intentionally-leaked `duckdb_connection`), but Phase 65 Plan 06's H1 emptied that struct and **AR-7 removed it entirely** — `SemanticViewsParserInfo` is now an empty marker struct (`cpp/src/shim.cpp`), the parser hook carries no per-DB Rust state, and the read side uses a fresh per-call `Connection(*context.db)` instead of any long-lived/leaked connection. So there is no longer any per-DB context to evict *or* leak.

**Original limitation (preserved for archaeology):**

- **Origin:** v0.8.0 B3 (bounded LRU for `parser_override_catalog`).
- **Decision:** The per-extension-load `db_token` → `CatalogReader` map is a 16-entry LRU. A long-lived process that opens more than 16 DuckDB instances will see the oldest token evicted on the 17th load. The next CREATE / DROP / ALTER routed to that token surfaces the friendly error `semantic_views: catalog context for this database has been evicted (process has opened more than 16 databases)`. The eviction itself happens silently inside `parser_override_catalog::set` — there is no log line at the moment of eviction.
- **Why this is acceptable:** The 16-database threshold covers every realistic interactive and CI workload. Daemon processes that load against many databases are the only affected scenario and they get a clear actionable error when they hit the wall.
- **Action if the wall starts to bite:** Either bump the capacity (no other code change needed) or replace the LRU with an explicit registration lifecycle tied to extension-unload (DuckDB does not currently expose an unload hook that we can hook into).

### 21. ❓ `CALL disable_peg_parser()` resets `allow_parser_override_extension`

- **Origin:** v0.8.0 milestone close, surfaced by `peg_compat.test`.
- **Decision:** DuckDB's `disable_peg_parser` pragma resets `allow_parser_override_extension` to its `default` value (`DEFAULT_OVERRIDE`), which silently bypasses our hook entirely. Subsequent semantic DDL on that connection produces the default parser's `Parser Error: syntax error at or near "SEMANTIC"`. Working around this requires the caller to explicitly re-set `allow_parser_override_extension='FALLBACK'` after disabling PEG.
- **Why deferred:** `disable_peg_parser` is a built-in pragma; parser_override does not see it. The cleanest fix would be a DuckDB-side change so that disabling PEG preserves whatever parser_override setting was in effect.
- **Mitigation:** `peg_compat.test` includes the `SET` workaround and CHANGELOG / MAINTAINER document the gotcha.

### 22. ✅ FALLBACK_OVERRIDE silently drops `DISPLAY_EXTENSION_ERROR`

**Resolved in Phase 62 (v0.8.0).** `parse_function` was re-introduced purely as the error-reporting layer. `parser_override` now defers all validation errors with `DISPLAY_ORIGINAL_ERROR` (rc=2); the default parser fails on the unrecognised DDL prefix; DuckDB calls our `sv_parse_stub`; we re-run validation and return `DISPLAY_EXTENSION_ERROR` with `error_location` set to the byte offset of the offending token. `ParserException::SyntaxError` renders `LINE 1: ... ^` for free. The synthesised `SELECT error('...')` workaround (`sql_throwing`) has been deleted. See `.planning/phases/62-caret-restoration-lru-removal/62-RESEARCH.md` §Q1 (position-tracking contract) and §6 rows B1–B7 for the per-DDL caret coverage.

**Original limitation (preserved for archaeology):**

- **Origin:** v0.8.0 milestone close, surfaced when investigating the post-unification sqllogictest failures (see `sql_throwing` helper in `src/parse.rs`).
- **Decision:** DuckDB's `ParseInternal` (verified in the v1.5.2 amalgamation) ignores any `parser_override` result that isn't `PARSE_SUCCESSFUL` when `allow_parser_override_extension` is `FALLBACK`. That means a Rust-side validation error returned via `DISPLAY_EXTENSION_ERROR` (rc=1 on the FFI boundary) is dropped, and the user sees the default parser's syntax error instead of our message. We work around this by synthesising a `SELECT error('<msg>')` statement and returning it as `PARSE_SUCCESSFUL`, so DuckDB raises the message at execution time. The rc=1 path on the FFI boundary is now dead but kept for forward-compat with `STRICT_OVERRIDE`.
- **Why deferred:** Switching to `STRICT_OVERRIDE` would cause every non-semantic SQL statement to round-trip through our hook with `DISPLAY_ORIGINAL_ERROR`, which is fine semantically but slightly costlier. The synthesised-error workaround has zero overhead on success cases and gives identical user experience.
- **Action if DuckDB ever fixes FALLBACK to honour `DISPLAY_EXTENSION_ERROR`:** Replace `sql_throwing` with a direct `write_error_to_buffer` + rc=1 path; one fewer SQL statement to plan.

### 23. ❓ Cross-connection `CREATE IF NOT EXISTS` race surfaces as PK violation

- **Origin:** v0.8.0 PR #29 ultrareview follow-up; surfaced by the new IF NOT EXISTS path in `test/integration/test_concurrent_ddl.py`.
- **Decision:** `CREATE SEMANTIC VIEW IF NOT EXISTS` rewrites to `INSERT OR IGNORE` against `semantic_layer._definitions(name)`. This atomically absorbs duplicates that are visible in the caller's own MVCC snapshot — same-transaction duplicates and any racing committer that landed before the caller's transaction began. It does **not** absorb duplicates from a transaction that committed *after* the caller's snapshot was taken: both connections evaluate INSERT against snapshots in which the row is absent, both attempt the INSERT, and DuckDB's PK constraint raises a write-write conflict on the second commit. The loser sees `Constraint Error: Duplicate key "name: <view>" violates primary key constraint`, the same shape plain `CREATE` produces.
- **Why this is acceptable:** DuckDB's PK enforcement happens at row insert / commit time and is not a hook we can intercept from within `parser_override`. The pragmatic alternatives — application-level retry-on-conflict, a coarse table-level lock, or a serializable isolation upgrade — all sit outside the parser-override SQL path. The current behaviour is no worse than plain `CREATE` and the loser receives a clear, actionable message rather than corrupting data. The in-snapshot silent no-op contract (the case users hit far more often: re-running an idempotent setup script in a single process) is fully preserved.
- **Mitigation for callers writing parallel bootstrap scripts:** wrap the `CREATE IF NOT EXISTS` in a try/except and treat a constraint violation on the target name as success. `test/integration/test_concurrent_ddl.py::test_concurrent_create_if_not_exists_serializes` pins the failure shape so this caller-side workaround stays valid across releases.
- **Action if DuckDB ever exposes a hook to retry-on-conflict from a parser_override callback:** add an automatic retry loop and convert this entry to ✅ resolved.

---

## v0.9.0 additions

### 24. ✅ Body parser's TABLES clause splits on whitespace inside quoted source-table names — RESOLVED in Phase 67

- **Origin:** v0.9.0 Phase 64 RESEARCH §Pitfall 5; surfaced as a known limitation during the quoted-identifier fix.
- **Decision:** `parse_single_table_entry` in `src/body_parser.rs` uses a whitespace-based tokenizer to peel off the alias, the `AS`, and the source-table name from each entry in the `TABLES (...)` clause. If a source-table name has whitespace INSIDE a quoted part (e.g. `TABLES (o AS "my db"."schema"."t" PRIMARY KEY (id))`), the tokenizer truncates mid-name and the parse fails. The Phase 64 quoted-identifier fix tightens identifier handling at every other capture site (CREATE / DROP / ALTER / DESCRIBE / SHOW COLUMNS view-name slot, the runtime `semantic_view()` positional arg, and the expansion-side `quote_table_ref`) but leaves this body-parser path on the legacy whitespace tokenizer.
- **Why deferred:** The bug-report reproduction (`"memory"."main"."orders_sv"` as the VIEW name) doesn't exercise this path. Whitespace inside quoted source-table parts is vanishingly rare — physical-table names almost never contain spaces, and even when they do (`"sales 2024"`), the user had the option of writing them as `"sales_2024"` or aliasing them outside the semantic-views layer. Fixing this requires `src/body_parser.rs` to adopt the `src/ident.rs` `find_identifier_end` helper at multiple parse points, which is non-trivial body-parser surgery for a vanishingly rare case.
- **Action if a user hits this:** Provide an alias in DuckDB itself (e.g. `CREATE VIEW orders_clean AS SELECT * FROM "my db"."schema"."t"`) and reference the alias in the semantic view's `TABLES` clause. If the case becomes common, port `find_identifier_end` into `src/body_parser.rs::parse_single_table_entry` and surrounding helpers.
- **Resolution:** Resolved in Phase 67 Plan 02 (commits `256ae65` body-parser surgery + `5fb2ed4` sqllogictest fixture). `parse_single_table_entry` (now in `src/body_parser/tables.rs`, after the AR-1 split of the former single-file `src/body_parser.rs` into a module directory) consumes the source-table-name slot via dot-separated `find_identifier_end`-driven advancement BEFORE running the `PRIMARY KEY` / `UNIQUE` keyword scan over the post-name slice. The canonical pathological case (`TABLES (o AS "weird PRIMARY KEY name" PRIMARY KEY (id))`) now parses correctly. Regression coverage: Rust unit tests in `src/body_parser/mod.rs::tests::test_parse_single_table_entry_*` + a sqllogictest fixture at `test/sql/phase67_quoted_source_tables.test`. The `src/ident.rs` helper itself was not modified (D-09).

---

## v0.10.0 additions

### 25. ✅ Body parser's `NON ADDITIVE BY` and OVER `ORDER BY` clauses split on whitespace for the dimension/column reference — RESOLVED in Phase 68 B1

- **Origin:** v0.10.0 Phase 67 Plan 02 audit-grep (D-10). Sibling pattern to TECH-DEBT #24.
- **Decision (historical):** `parse_non_additive_dims` and the OVER `ORDER BY` parser both peeled the reference off the entry with `split_whitespace()`, so a quoted reference with internal whitespace (`NON ADDITIVE BY ("my dim" ASC)`) split mid-name.
- **Resolution:** Phase 68 Plan 03 (B1) captures the reference slot via `find_identifier_end` + `is_quoting_balanced` in both sites (`src/body_parser/metrics.rs::parse_non_additive_dims` — its doc comment cites this entry — and the OVER `ORDER BY` parser in `src/body_parser/window.rs`). `split_whitespace` now runs only over the *trailing modifier* slice (ASC/DESC/NULLS FIRST|LAST), which contains no identifiers. Quoted-with-whitespace references parse correctly.
- **Remaining sibling slots in the same whitespace-tokeniser class — RESOLVED 2026-08-01, the last of the family:** the SHOW name slots `IN SCHEMA` / `IN DATABASE`, `IN <view>`, and `FOR METRIC` peeled their identifier on the first whitespace (`str::find(char::is_whitespace)`). All three now capture through a shared `show_clauses::take_identifier`, a thin wrapper over the same `ident::find_identifier_end` #24 and B1 use, so whitespace and `;` inside `"..."` are name bytes rather than terminators.
  - **The whitespace split was only half of it.** These slots also never *stripped* the quotes, and for `IN SCHEMA` / `IN DATABASE` that was the worse half: their value goes straight into a `schema_name = '<literal>'` comparison with nothing downstream to normalise it, so `IN SCHEMA "main"` emitted `schema_name = '"main"'` and **matched nothing, silently** — no whitespace required to trigger it. Those two now strip through `show_clauses::unquote_show_name` (`parse_qualified_identifier_with_quoting`, so the `""` escape unescapes and an unterminated quote is a named error rather than a name). The `IN <view>` and `FOR METRIC` slots deliberately keep their raw text: FF-4 has the read table function normalise once at the catalog-read boundary, and stripping at both ends would double-fold.
  - **Consequent consumer fix (#28 name-field-matcher class):** `show_dims_for_metric.rs` compared metric names with a bare `to_ascii_lowercase()` fold. Because a metric declared `t."total amount"` is *stored with its quotes*, the same-spelling case did match — what could not match was **across** spellings: a metric declared `plain_total` referenced as `FOR METRIC "plain_total"` reported `metric '"plain_total"' not found. Did you mean 'plain_total'?`. It now matches through `ident::ident_matches`.
  - **Case folding — RESOLVED 2026-08-01, and the deferral's premise was wrong.** This was left open on the reasoning that an unquoted `IN SCHEMA Main` is passed through as written, so folding only the quoted spelling would make quoting mean something extra. Sound as far as it went, but it assumed the stored side is a canonical name the user either spells right or wrong. It is not. `schema_name` / `database_name` are stamped from `current_schema()` / `current_database()` at CREATE time (`parse/native_sql.rs`), and DuckDB returns those as **the spelling the caller last wrote in `USE`**, not the catalog's. Verified against DuckDB 1.5.5: `CREATE SCHEMA MySchema` stores `MySchema` (case preserved — unquoted identifiers are not lowercased as in Postgres), DuckDB resolves `USE myschema` to that same schema, and `current_schema()` then echoes back `myschema`.
    - **Consequence, worse than this entry previously recorded:** two semantic views created in **one** schema under different `USE` spellings are stamped differently (`MySchema` and `myschema`), so an exact-match filter returns at most one of them and **no spelling returns both** — including the catalog's own. `IN SCHEMA MYSCHEMA` returned nothing at all. It was not a strictness question about a user typo; the filter was unanswerable.
    - **Fix:** `build_filter_suffix` emits `lower(schema_name) = lower('<literal>')` and the `database_name` twin. Folding **both** sides is also what preserves the invariant the deferral wanted — quoting still means nothing extra.
    - **Why the fold is in SQL, not in Rust:** the stored side is folded by DuckDB's `lower()`, which is Unicode-aware, while `ident::normalize_ident_part` is `to_ascii_lowercase`. A Rust-side fold would emit `'myschÉma'` against a stored `'myschéma'` and **stop** matching a non-ASCII name that matches exactly today — a regression rather than a gap. Pinned by `the_fold_is_left_to_sql_rather_than_applied_in_rust`. It also stays an equality rather than `ILIKE`, because `SqlLit::escape` only doubles single quotes and a `%` / `_` in a schema name would otherwise become a wildcard (pinned by the prefix control).
    - **Coverage:** three independently-reporting unit tests in `parse::rewrite::tests::show_scope_case_folding_tests` (schema, database, and the no-Rust-fold pin), each confirmed red individually, plus one control (`a_quoted_scope_name_folds_exactly_like_an_unquoted_one`) that was green throughout and is labelled as such in the file — it guards the deferral's feared wrong fix (folding the quoted spelling only) rather than evidencing this one. End-to-end: `test/integration/test_show_scope_case_insensitive.py`, which reported **12 distinct red cases alongside 4 green over-matching controls** against the pre-fix binary. That layer reports every case independently, where the sqllogictest runner halts a file at its first failure — and it is the only layer that can vary the *stored* spelling, which needs a `USE` between CREATEs.
    - **No sqllogictest, deliberately.** A `.test` file was written and then removed rather than kept as a weak one. `IN SCHEMA` / `IN DATABASE` exist **only** on `SHOW SEMANTIC VIEWS` — `SHOW SEMANTIC {DIMENSIONS,METRICS,FACTS}` reject them, parsing `SCHEMA` as the `IN <view>` name (verified against the built extension; note the reference pages for those three document the clause anyway, which is a separate doc/implementation mismatch). So the only command carrying the filter is the one whose listing leads with a live `created_on`, and no positive row can be written down. What remained was `statement ok`, which passes whether the answer is every row or none — the exact vacuous shape that let the original bug through `phase44_show_terse_scope.test`. The Python suite loads the extension and runs the real statements, so the extension-load → DDL → query path is still covered end-to-end.
  - **Qualified scope names — RESOLVED 2026-08-02, a third silent no-match in the same family.** Snowflake's SHOW scope clause is `IN { <name> | ACCOUNT | DATABASE [<db>] | SCHEMA [<db>.<schema>] }`, so `IN SCHEMA <db>.<schema>` is the documented way to name a schema in another database. It parsed here — `parse_qualified_identifier_with_quoting` accepts it — and the parts were then **rejoined with a dot** into `schema_name = 'memory.main'`, compared against a column holding a *bare* schema name. No match, no error, empty result. The case-fold fix above could not help: folding does not make two different strings equal.
    - **Fix:** the helper (renamed `unquote_show_name` → `scope_name_parts`) returns the parts instead of rejoining, and `parse_in_scope` dispatches on arity. `IN SCHEMA` takes 1 part (schema only) or 2 (`<db>.<schema>`, populating **both** slots so `build_filter_suffix` ANDs them); `IN DATABASE` takes exactly 1, because a database has nothing to qualify it with. Three-part schema names and qualified database names are now named errors rather than literals that can never match. No type changes were needed downstream — `parse_in_scope` already returned `(remaining, schema, database)`.
    - **Why both predicates rather than matching the last part:** matching only the schema half would return same-named schemas in *other* databases — trading a silent empty result for a silent wrong one. Pinned end-to-end against a second ATTACHed database, since a last-part-only fix passes every single-database test.
    - **Coverage:** four independently-reporting unit tests in `parse::rewrite::tests::show_scope_qualified_name_tests`, each confirmed red individually, plus two controls green throughout — one being `a_quoted_dot_is_not_a_qualifier`, which guards the obvious wrong fix (`split('.')` would break a schema literally named `a.b`). End-to-end: `test/integration/test_show_scope_qualified_name.py`, verified against the pre-fix binary at **5 red / 4 green**.
      - One of those five was a **wrong hit, not a miss**, and was not predicted when the tests were written: unquoted `IN SCHEMA a.b` rejoined to `a.b` and matched the schema *literally named* `a.b`, so a qualifier was answered by a same-spelled name. The quoted spelling `"a.b"` was green throughout, so only the pair together distinguishes a name from a qualifier.
      - The two cross-database cases (`nosuchdb.analytics`, `otherdb.analytics`) were green **before and after**, for different reasons — before, because the rejoined literal matched nothing; after, because the database predicate excludes them. They are therefore not evidence of this fix; they exist to defeat a last-part-only implementation, which would pass every single-database test. That claim is guarded at the unit layer by `a_qualified_schema_name_filters_on_database_and_schema`, which pins the database predicate as emitted and was confirmed red.
  - **The rest of Snowflake's scope grammar — RESOLVED 2026-08-02 (Group A).** Two gaps closed together, because they are the same clause.
    - **The clause reached only `SHOW SEMANTIC VIEWS`.** `SHOW SEMANTIC {DIMENSIONS,METRICS,FACTS}` gated it behind `kind == Show || kind == ShowTerse` in `parse_show_filter_clauses` and fell through to a branch that takes the `IN <view>` slot unconditionally, so `SCHEMA` was read as a view name and `SHOW SEMANTIC DIMENSIONS IN SCHEMA main` failed with `Unexpected tokens: 'main'`. The gate now also admits an `IN` whose next token is a scope keyword (`in_clause_is_scope`). **No rewrite plumbing was needed**: those commands already emit `show_semantic_*_all()` when no view is named, and those functions lead with `database_name, schema_name`, so `build_filter_suffix`'s predicates applied unchanged.
    - **`IN ACCOUNT` and the bare `IN DATABASE` / `IN SCHEMA` forms** all errored. `IN ACCOUNT` is now accepted and contributes no predicate (DuckDB has no account; Snowflake's ACCOUNT scope is "everything I can see"). The bare forms mean the caller's current schema/database and required a representation change: `in_schema` / `in_database` moved from `Option<String>` to `Option<ScopeName>` where `ScopeName::Current` renders as `lower(schema_name) = lower(current_schema())` — a **call**, not a literal, so it resolves on the caller's connection. That form is exact rather than approximate, since `schema_name` is itself stamped from `current_schema()` at CREATE: both sides are the same function.
    - **Behaviour change, deliberate and documented:** on those three commands `IN SCHEMA` previously meant *the view named `SCHEMA`*. It now means the scope. The escape hatch is quoting — `IN "schema"` still selects the view — and `keyword_word` matches whole words only, so `IN schemas` was never affected. Both are pinned by tests. Snowflake has the same collision and resolves it the same way.
    - **Disambiguation the parser now has to do:** `starts_following_clause` distinguishes a missing name from the start of the next clause, so `IN SCHEMA LIMIT 5` is the current schema limited to 5 rows rather than a schema named `LIMIT`.
    - **Coverage:** thirteen unit tests in `parse::rewrite::tests::show_scope_other_commands_tests` — 10 confirmed red individually, 3 controls green throughout (an ordinary view name, a quoted `"schema"`, and `FOR METRIC` composition). End-to-end in `test/sql/show_scope_all_commands.test`, which — unusually for this family — asserts by **row**: `show_semantic_*_all()` leads with database/schema/view columns, where `list_semantic_views` leads with a live `created_on` and forced the VIEWS-side coverage into the Python suites.
    - The three reference pages (`docs/reference/show-semantic-{dimensions,facts,metrics}.rst`) were updated in the same change. They had documented the clause — synopsis, clause list and a worked example each — while the parser rejected it, and they rendered `IN <name>` and `IN SCHEMA …` as two *stackable* clauses, wrong against Snowflake and against this parser, where they are alternatives of one `IN`.
  - **The stamping itself — RESOLVED 2026-08-02, and it was not a display inconsistency.** This entry previously recorded the stamp as cosmetic: `list_semantic_views()` showing `MySchema` for one view and `myschema` for another in the same schema, plus the sharper symptom that a CREATE qualifier was ignored (`CREATE SEMANTIC VIEW analytics.v` while USE-d into `main` recorded `schema_name = 'main'`, filing the view under a schema its own name disclaims). Both were downstream of something larger: **the schema was never part of the view's identity.** `_definitions` keyed on `name` alone, so the schema existed only as a metadata string inside the JSON definition — unenforceable, and a flat namespace in which `CREATE SEMANTIC VIEW staging.v` collided with an existing `analytics.v` for a view that, by name, did not exist.
    - **Semantic views are now scoped to a schema.** `_definitions` keys on `(schema_name, name)`, and the qualifier on a CREATE / DROP / ALTER / read decides which schema the statement means. Pre-scoping catalogs are rebuilt in place on the next LOAD (`migrate_definitions_to_schema_scoped`) — a rebuild rather than an `ALTER`, because DuckDB cannot retrofit a primary key; it cannot collide, since the old key made `name` globally unique. A read-only database whose catalog predates scoping refuses to load with an instruction rather than being served through a second, permanent storage shape.
    - **The stamp is canonical now, and it has to be.** The stored schema is the catalog's own spelling, resolved through `duckdb_schemas()` rather than taken from `current_schema()` — which, as this entry established, echoes the last `USE` spelling and not the catalog's. That is no longer a display nicety: `(schema_name, name)` is a primary key, so one schema must map to exactly one stored string. DuckDB schema names are unique case-insensitively, which makes the catalog spelling exactly that. Naming a schema that does not exist is now an error, as `CREATE TABLE nosuch.t` is, instead of a silent fall back to the current schema.
      - **Migrated rows keep whatever spelling they were written with** — the migration derives the column from the JSON rather than re-resolving it, because a schema recorded on a row may no longer exist and losing the row would be worse than an odd spelling. So a database mixing pre- and post-scoping rows can still display `MySchema` for one view and `myschema` for another. Every comparison folds, so nothing is unreachable; it is cosmetic, and now genuinely so, where the pre-scoping version of this symptom made the filter unanswerable. No ambiguity can arise from it either: the old key made `name` globally unique, so migration produces at most one row per name.
    - **Unqualified references follow DuckDB's search path — RESOLVED 2026-08-03.** This shipped in two steps. The first cut resolved a bare name to the unique match and errored when several schemas held one, deliberately *not* DuckDB's rule, because the write path could follow the search path (`current_schemas()` evaluates on the caller's connection) while the read-side table functions could not (#19) — and preferring it on one side alone would make `DROP SEMANTIC VIEW v` and `semantic_view('v')` disagree about which `v` they mean. The rewrite-time injection described under #19 removed that asymmetry, so both sides now take the first schema on the path that holds the name.
      - **`resolved_schema_expr` mirrors `resolve_in_search_path` branch for branch**, which is the point: writes and reads resolving a bare name differently is the wrong-view-deleted case. At most one candidate resolves on its own (or yields NULL, so the existence guard reports the canonical "does not exist"), so a lone view stays reachable by its bare name even from off the path; otherwise the earliest path position wins, via `list_position` rather than an alphabetical `min(schema_name)`.
      - **A name that exists only off the path is a miss, but not a bare one.** DuckDB would call it "does not exist"; for a view `SHOW SEMANTIC VIEWS` plainly lists that is baffling, so both sides name the schemas it does live in, the path that was searched, and the two remedies (qualify, or extend `search_path`).
      - **`IF EXISTS` does not absorb it.** It means "do not complain if the view is absent", and a name that exists off the path is not absent — silently dropping one would be the wrong-view-deleted case `IF EXISTS` was never meant to license.
    - **Still open — the two scalar functions resolve without a path.** `GET_DDL` and `READ_YAML_FROM_SEMANTIC_VIEW` are scalars, which have no named parameters, so the override cannot hand them the path; they keep the unique-match-else-ambiguity fallback. Closing it means registering a second arity for each. See #19.
    - **`GET_DDL`'s unqualified view name — RESOLVED 2026-08-03 by adding the third argument.** `GET_DDL('SEMANTIC_VIEW', 'analytics.sales')` rendered `CREATE OR REPLACE SEMANTIC VIEW sales AS …`, so re-running the output put the view in whatever schema the executing session was in rather than back in `analytics` — a restore silently relocated every view that did not live in the restoring session's schema. That spelling is Snowflake's *default*, so the default was right and the gap was the missing `use_fully_qualified_names`.
      - **The default did not move.** `GET_DDL(<type>, <name>)` and an explicit `false` both still render a bare name; only `true` qualifies. Both spellings are pinned, because a fix that qualified unconditionally would break every existing caller — that is what the two green controls in `test/sql/get_ddl_qualified_name.test` §1 guard.
      - **Registration is an extra *arity*, not a named parameter.** Scalars have no named parameters (which is why the search path still cannot reach them — see below), so `sv_register_get_ddl` now builds a `ScalarFunctionSet` carrying the 2- and 3-argument overloads and both bind to one exec callback that reads `args.data[2]` only when it was bound. `sv_register_scalar_function`'s catalog write was factored into `sv_register_scalar_function_set` so the single-arity callers keep the same guards and error-buffer contract.
      - **The schema comes from the definition, not the caller's spelling** — `def.schema_name`, which mirrors the `(schema_name, name)` key column — so a bare `sales` living in `analytics` renders `analytics.sales`. It reaches that view by **unique match**, not through `search_path`: this entry's own next bullet is why (a scalar cannot be handed the path), and the ambiguity error is what happens when two schemas hold the name. An earlier draft of this bullet, the CHANGELOG, the reference page and a sqllogictest case all said "resolved through `search_path`" — wrong, and the test case passed only because the name was unique at that point, so it evidenced nothing. Copilot caught all four on PR #188; the test case now pins the unique-match rule and a new §7 pins the ambiguity error under a `SET search_path` that does not rescue it.
      - A definition recording no schema is an **error** rather than a silent fall back to the bare name: falling back would reintroduce the exact relocation the argument exists to prevent. Unreachable in practice, since the schema-scoping migration backfills the JSON, but a hand-written catalog row could hit it.
      - **Each part is quoted on its own account.** Quoting the joined string emits `"my schema.my view"` — one name that happens to contain a dot — which restores into a view called exactly that. Pinned by parsing the emitted header back into `(schema, name)` at the unit layer and by the `no view named with a literal dot` check end-to-end.
      - **Coverage:** seven unit tests in `render_ddl::tests::qualified_name_tests` (five confirmed red individually against a stub carrying the old always-unqualified behaviour, two green controls labelled as such), `test/sql/get_ddl_qualified_name.test` end-to-end — verified per case by neutering the qualification branch and walking the file, which put **three** assertions red and left the rest passing unfixed — and `test/integration/test_get_ddl_qualified_name.py` for the fetch-and-replay round trip, which sqllogictest cannot express because the DDL is produced by one statement and must become the *text* of a later one. That suite carries the negative control the others cannot: on the same fixture, replaying the two-argument dump from `staging` files the view under `staging`. Without it, "qualified restores correctly" proves nothing — both spellings look identical when the restoring session already sits in the right schema.
    - **A `<database>.` prefix is checked, not discarded.** Views being single-catalog (#26), `otherdb.analytics.v` names something this extension cannot manage — but silently dropping the prefix retargeted the statement at the *current* database's `analytics.v`, which is a wrong-object write rather than an unsupported one. Every write DDL now carries a guard comparing the written database against `current_database()` on the caller's connection, and `ALTER … RENAME TO` checks the target's prefix as well as the source's. The read side pins the schema but still ignores a database prefix; that residual belongs to #26.
    - **Column and JSON are kept in lockstep**, not reconciled at read time: CREATE stamps both from one expression, the migration backfills the JSON from the column, and a schema-moving `ALTER … RENAME TO b.v` patches both. So the SHOW / DESCRIBE listings can keep reading the definition metadata without a second source of truth.
    - **Coverage:** `ident::tests::parse_view_ref_tests` (9), `catalog::writes::tests` schema-scoping cases (8), `catalog::tests::schema_scoped_storage_tests` (11, including migration byte-preservation and the JSON backfill), `catalog::tests::search_path_resolution_tests` (9) and `parse::search_path::tests` (13) for the resolution rule and the injection, and `test/sql/schema_scoped_views.test` end-to-end — two same-named views in different schemas, qualified CREATE / DROP / ALTER / read, off-path and missing-schema errors, case-insensitive schema matching against the catalog's spelling, and the four-part-name rejection.
      - The search-path section of that file asserts the path **in both orders** (`analytics,staging` → 350, `staging,analytics` → 2), because a single order still passes if resolution were alphabetical or "whatever the catalog returns first". The write side is asserted in the same block: a bare `DROP` under a set path must remove the view the bare read had just returned, which is the invariant the two sides exist to keep. The three new write-side builder tests were each confirmed red individually against the previous ambiguity rule; the end-to-end block was confirmed against a build carrying the injection but not the write-side change, where the read resolved through the path and the `DROP` still reported the name ambiguous.
  - **Coverage:** twelve independently-reporting unit tests in `parse::rewrite::tests::show_name_slot_quoted_ident_tests` (both failure modes per slot, plus over-stripping and over-rejection controls) and `test/sql/show_quoted_name_slots.test` end-to-end. The sqllogictest was verified per-case by reverting the fix and walking the file: seven assertions went red with distinct causes, and the rest passed unfixed — they are controls, and the file says so. One limit is recorded there rather than papered over: the positive `IN SCHEMA "main"` case is a `statement ok`, because `list_semantic_views` leads with a live `created_on` timestamp that cannot be written down; the row-level proof is the unit test pinning the emitted literal as `schema_name = 'main'`. That gap is precisely why the pre-existing `statement ok` coverage in `phase44_show_terse_scope.test` never caught the silent no-match.
  - **Updated 2026-07-16 (code-review sweep):** the body-parser siblings this entry previously listed — the `TABLES` alias slot, the `MATERIALIZATIONS` name, and the relationship target alias — were **all migrated to the shared `Cursor`/lexer** by the §6.1 lexer/cursor migration (`tables.rs`, `materializations.rs`, `relationships.rs` now lex their name slots), so a quoted-with-whitespace value parses correctly there. The SHOW name slots (which live in `parse/show_clauses.rs`, outside the body-parser cursor) were the last holdouts and are now closed.

### 26. ❓ Single-catalog write guard does not cover an attached DB that has its own catalog table

- **Origin:** v0.10.x FF-3 (code-review 2026-07-02). Raised in the PR review of the ATTACH single-catalog guard (`src/catalog/writes.rs::managed_catalog_guard_select`).
- **Decision:** Semantic views are single-catalog: `semantic_layer._definitions` lives in the primary database, and every read runs on a fresh per-call connection that resolves against the primary. The FF-3 write guard raises an actionable error when the caller is `USE`-d into a database that has **no** semantic-view catalog while another database does — the common "USE-d into the wrong database" case. It does **not** fire when the attached database the caller is `USE`-d into ALSO has its own `semantic_layer._definitions` (e.g. it was itself bootstrapped as a primary at some point): the write lands in that catalog while the primary-pinned reads never see it (a silent cross-catalog write).
- **Why deferred:** Firing correctly for that sub-case requires knowing which catalog the read binds actually use (the primary/default database). DuckDB exposes no primary/default-database signal on the caller's connection — there is no `current_setting`, no flag in `duckdb_databases()`, only fragile database-oid ordering — so the guard cannot distinguish "current is the managed catalog" from "current is a second catalog that also happens to hold the table" without a false-positive risk on legitimately attaching a second semantic-views database read-only. The robust fix is to thread the caller's `ClientContext`/current catalog into the read binds so reads and writes agree on one catalog — the reader-context work. **AR-6 (§3.7) re-evaluated this and found it blocked by the same DuckDB liveness constraint as FF-2: reads run on a fresh per-call connection because re-entering the caller's `ClientContext::Query` from a bind callback self-deadlocks on the non-recursive `context_lock` (see the updated entry #19 for the full analysis).** So this sub-case cannot be closed without an upstream DuckDB change; managing two independent semantic-view catalogs from one session is unsupported until then.
- **Action if a user hits this:** Manage semantic views from the single database the extension was loaded into; do not `USE` into an attached database that carries its own `semantic_layer._definitions`.
- **Updated 2026-08-02 (schema scoping, #25).** Write DDL that spells out a `<database>.` prefix is now checked against `current_database()` and rejected when it names another database, so `DROP SEMANTIC VIEW otherdb.analytics.v` no longer silently deletes the *current* database's `analytics.v`. That closes the explicitly-written half. **The read side still ignores a database prefix** — `semantic_view('otherdb.analytics.v')` pins the schema but not the database, so it reads the catalog's `analytics.v`. The read binds have no way to compare against the caller's database for the same reason the rest of this entry is deferred (a fresh per-call connection, #19), and the extension manages exactly one catalog anyway, so the reference can only be either redundant or unsupported. Worth closing alongside the reader-context work rather than before it.

### 27. ❓ DROP / ALTER existence guards are snapshot-consistent with their DML only inside an explicit transaction

- **Origin:** v0.10.x FF-1 (code-review 2026-07-02, §3.6). The DROP/ALTER sibling of #23 (which covers only CREATE).
- **Decision:** A non-`IF EXISTS` DROP/ALTER rewrites to a multi-statement string — one or more pure-SQL guard `SELECT`s (`existence_guard_select`, `rename_collision_guard_select`) followed by the `DELETE`/`UPDATE` — that DuckDB re-parses and runs on the caller's connection (`src/parse/native_sql.rs::rewrite_drop`/`rewrite_alter_rename`/`rewrite_alter_comment`; guards in `src/catalog/writes.rs`). Whether the guard's check is consistent with the DML depends on the caller's transaction state, and this was verified empirically: **inside an explicit transaction** (`BEGIN … COMMIT`, or an `autocommit = false` ADBC/PG connection) every emitted statement shares the one snapshot, so the check is atomic with the DML; **under autocommit** (the default) DuckDB commits after *each* statement of a multi-statement string, so the guard and the DML run in separate implicit transactions. A different connection that commits in the window between them can invalidate the guard's decision:
  - concurrent DROP — both existence guards pass, both `DELETE`s run; the loser's `DELETE` matches 0 rows and reports success having deleted nothing (a silent no-op, not an error);
  - concurrent RENAME — the loser's collision guard passes, then the `UPDATE` hits DuckDB's primary-key constraint and surfaces a raw `Constraint Error: Duplicate key` instead of the friendly `already exists` wording (the same failure shape as #23).
- **Why this is acceptable:** The behaviour is no worse than the equivalent native-`CREATE`/`DROP` concurrency (#23) and the far more common single-process case (re-running an idempotent setup script) is fully consistent. Closing the window by emitting our own `BEGIN … COMMIT` around the rewrite is **not** viable: DuckDB rejects a nested `BEGIN` (`TransactionContext Error: cannot start a transaction within a transaction`, verified), so the wrapper would fail outright whenever the caller is already in a transaction, and an emitted `COMMIT` would prematurely commit an `autocommit = false` caller's in-flight work — breaking the transaction-participation contract the native-DML rewrite exists to provide (the v0.8.0 ADBC fix). Folding the guards into a single statement (as `CREATE` does via `CASE … error()` inside the `INSERT`) does not generalise to the DROP/ALTER "must (not) exist" pre-checks, which need to raise on the *absence*/*presence* of a row a single `DELETE`/`UPDATE` cannot key off.
- **Mitigation for callers needing atomic check-and-write:** wrap the DDL in an explicit `BEGIN … COMMIT` (or use a connection with `autocommit = false`). All emitted statements then share one snapshot and the guard is consistent with the DML; a concurrent committer instead triggers a serialization/PK conflict at `COMMIT`, which the caller can retry — the same pattern as #23's `CREATE IF NOT EXISTS` mitigation.
- **Action if DuckDB ever exposes the caller's transaction state (or a retry-on-conflict hook) from a `parser_override` callback:** conditionally wrap the rewrite in `BEGIN … COMMIT` only when the caller is under autocommit, and convert this entry to ✅ resolved.

### 28. ❌ Quoted-name *quote-stripping* not yet applied to internal reference sites (deferred to review §6.2)

- **Origin:** v0.11 identifier-contract work for dimension/metric/fact names; code review on that PR (2026-07-12). The original entry flagged a case-sensitivity *split* (a Snowflake-style quoted-case-sensitive query boundary vs case-insensitive internals); that split was **resolved** by adopting DuckDB's uniform case-insensitive rule everywhere (quoted identifiers are case-insensitive too). What remains is narrower and is purely about quote *stripping*.
- **Limitation:** matching is now case-insensitive uniformly, but only the query-facing sites *strip* surrounding quotes before matching — `ident::ident_matches` / `ident::normalize_ident_part` at the resolution boundary (`find_dimension` / `find_metric` / `Fact::find`), the CREATE-time uniqueness key (`graph/names.rs::validate_name_uniqueness`), `alias.*` wildcard de-duplication (`expand/wildcard.rs`), and the `explain_semantic_view()` materialization-header lookup (`query/explain.rs`). The remaining name-comparison / keying sites fold case (correct) but do **not** strip quotes, so a *quoted* name written in one of these internal positions is compared with its quote characters intact and fails to match its unquoted declaration:
  - the materialization set-matcher (`expand/materialization.rs::find_routing_materialization_name`) over a `MATERIALIZATIONS` clause's *declared* names;
  - `NON ADDITIVE BY` dimension references and window inner-metric references;
  - ~~derived-metric / fact expression inlining (identifier references scanned inside expression *text*)~~ — **closed 2026-07-16**, see below;
  - the table-alias slot in the qualified-reference path, output-column aliasing for quoted names, and `CiName`'s `Eq`.
- **Why this is acceptable (interim):** the common case — unquoted names — is entirely unaffected (`ident_matches` reduces to `eq_ignore_ascii_case` when neither side is quoted, and the internal sites already did that). The gap only bites when a name is written *quoted* in one of these internal positions (a `NON ADDITIVE BY` list, a derived-metric expression, a `MATERIALIZATIONS` declaration) — rare, and quoting there buys nothing since matching is case-insensitive regardless.
- **Action:** Route all of these through one quote-aware reference-scanning engine as part of §6.2 of `_notes/code-review-2026-07-11.md` (which also collapses the remaining copies of the word-boundary reference scanner). The expression-text sites specifically need a quote-aware tokenizer, not a name comparison — that engine is the right home. Tracked here so the follow-up is not lost (the "half-migrated abstraction" pattern §7 of that review warns about).
  - **Updated 2026-07-16 (code-review sweep):** the "second duplicated copy" of the materialization set-matcher this entry used to cite was **collapsed by E-6** — `explain` now reuses the single `find_routing_materialization_name` (`query/explain.rs:219`), so there is one matcher, not two.
  - **Slice 1 landed 2026-07-16 (quote-aware reference tokenizer):** the **expression-text inlining** site is now quote-aware. `src/expr_tokens.rs` is the single tokenizer for "identifier references in expression text"; fact/derived-metric FIND (`graph::facts::find_fact_references`, the `expand::facts` dependency scans) and INLINE (`inline_facts`, `inline_derived_metrics`) go through it, delegating matching to `ident::normalize_ident_part`. This kills the **E-2** (case) / **E-3** (`.`-boundary collision + string-literal substitution) class for expression text and closes the quoted-reference gap there (#28): `"Revenue"` now inlines against `revenue`. The four hand-rolled `util` word-boundary primitives (`replace_word_boundary_pairs`/`_any`, `contains_word_boundary_ref`, `find_word_boundary_refs`) are **deleted**; only the single-needle `replace_word_boundary` remains, for the alias-qualifier rewriters (Slice 2 below).
  - **Slice 2 landed 2026-07-16 (all expression-text scanners on the engine):** every remaining site that scans *expression text* now rides `expr_tokens`, and the last hand-rolled scanners are **deleted**:
    - the **alias-qualifier rewriters** (`expand::window`/`sql_gen`/`semi_additive`) call `expr_tokens::rewrite_qualifier`, which rewrites only a chain's *head* part (`a.city` → `scoped.city`) and — unlike the retired quote-blind `util::replace_word_boundary` — skips string / dollar literals, function-call heads, and foreign qualified tails (E-3);
    - the **derived-metric validator** (`graph::derived_metrics::check_derived_metric_references`) scans with `expr_tokens::scan_references` + `IdentRef::is_bare`, matching quoted / spaced metric names identically to the inliner (the `(`-lookahead that used to force a separate scanner is now built into the tokenizer);
    - **aggregate detection** (`contains_aggregate_function`) uses the new `expr_tokens::scan_function_heads` (the exact complement of `scan_references`), inheriting correct literal handling.
    `util::replace_word_boundary` and `derived_metrics::extract_identifiers` are **removed**; `util::is_word_boundary_char` survives only for the `expand::facts` COUNT/name matchers.
  - **Slice 3 (name-field matchers) — mostly landed:** the sites that compare stored **name fields** rather than scanning expression text — a `CiName`/name-matching concern, not an `expr_tokens` one — folded case but did not strip quotes. Now routed through the canonical key (`ident::normalize_ident_part` / `ident_matches`):
    - **`CiName`'s `Eq`/`Hash`** (`expand/types.rs`) key on `normalize_ident_part`, so `"Region"` == `region` (was ASCII-fold only). (In practice these are unused in production — resolution goes through `AsRef<str>` + `ident_matches`, dedup keys on pointer identity — so this is primitive-hygiene, not a behaviour fix.)
    - **Materialization set-matcher** (`expand/materialization.rs::find_matching_materialization`): requested and declared dim/metric name sets are keyed by `normalize_ident_part`, so a quoted name in a `MATERIALIZATIONS` clause routes to its unquoted declaration (was a silently-missed routing / full-expansion fallback).
    - **Window inner-metric** (`expand/window.rs`): the inner-metric CTE key, the def-lookup, and the emitted alias all use the canonical key, so a quoted inner-metric reference aliases and references consistently (a quoted inner metric previously produced a mismatched alias vs. reference).
  - **Slice 3 — dimension-side, semi-additive (#30) landed 2026-07-17:** `expand/semi_additive.rs` now routes **every** NA-dim-vs-dimension comparison through the bare-AND-dotted resolver `resolution::find_dimension` via one shared `na_dim_match_key` helper — classification (`is_active_semi_additive`, now taking `def`), grouping (`collect_na_groups`), partition/order emission, and source-table join (`collect_na_dim_source_tables`) — plus the queried-dim sets at all three predicate call sites (`sql_gen`, `fan_trap` ×2) key on `normalize_ident_part`. A **dotted** NA reference (`o."order date"`) now classifies, partitions, orders, and joins identically to the bare form and expands to a bindable snapshot query end-to-end (was a `quote_ident(&nd.dimension)` bind-time failure). Verified E-1-safe: partition/order still emit the resolved dimension *expression*, never a CTE alias.
  - **Slice 3 — dimension-side, window landed 2026-07-17:** `expand/window.rs` now routes every window `PARTITION BY` / `EXCLUDING` / `ORDER BY` dimension reference through the shared resolver — the required-dimension checks and the EXCLUDING partition-set key on `resolution::dim_ref_key` (the same helper `semi_additive::na_dim_match_key` now delegates to), and the explicit-`PARTITION BY` / `ORDER BY` **emission** resolves each reference to its declared dimension's CTE alias via `window_dim_column`. A **dotted** OVER `ORDER BY` (`o."order date"`, accepted at CREATE via D-08) previously failed the bare-only required-dimension check at query time; a quoted reference previously emitted a doubled-quote non-column in the OVER clause (uncaught because the window sqllogictests inspected only DDL / EXPLAIN text, never executed the window query). Both now resolve to the CTE alias and bind. This completes the Slice-3 dimension-side pass (semi-additive + window) on the EXPANSION side.
  - **CREATE-side completion landed 2026-08-06 (PARSE-8):** the sentence above previously ended "the reference-tokenizer arc (#28) is fully landed", which was wrong and actively misleading — the expansion side was done, but five CREATE-time validators in `body_parser/mod.rs` still compared identifiers with raw `eq_ignore_ascii_case`, which treats the quote characters as data. So `PARTITION BY EXCLUDING "Region"` was REJECTED at CREATE against a `region` dimension while `ORDER BY "Region"` in the same clause resolved, and the emitter would have computed either. The five sites — window `EXCLUDING`, window `PARTITION BY`, `NON ADDITIVE BY` (both the bare and the D-08 dotted halves), materialization dimension refs and materialization metric refs — now use `ident::ident_matches`, joining the ORDER BY and inner-metric slots EXP-12 migrated (code-review 2026-08-03). Covered by eight `body_parser` unit tests, each confirmed red individually with a passing unknown-name control per site, plus `cr20260806_quoted_validation_sites.test` end-to-end. With those, the CREATE side is landed; the only remaining `eq_ignore_ascii_case` in that file matches the `AS` keyword, which is not an identifier comparison.
  - **Sixth site, expansion-side, found BY the above (2026-08-06):** accepting a quoted dotted `NON ADDITIVE BY ("O"."Snap_Date")` at CREATE immediately failed at query time — the snapshot emitted the raw reference as an `ORDER BY` term and `DuckDB` rejected it as an unknown column. Cause: `expand::resolution::source_table_matches` compared the ALIAS half of a qualified reference with raw `eq_ignore_ascii_case` while `find_dimension`/`find_metric` matched the NAME half with `ident_matches`. So `"C"."Region"` resolved its name and then failed on its own qualifier, falling to `dim_ref_key`'s unresolved arm. Now `ident_matches` on both halves, base-alias branch included. SG-14 is unaffected: a quoted reference to the WRONG table still resolves to nothing, pinned by a control test. This one was reachable only through the CREATE-side fix, which is why no earlier slice caught it.

### 29. ✅ `fk_columns.is_empty()` legacy-relationship guards are load-bearing — do NOT remove (E-8)

- **Origin:** code-review 2026-07-16 §6 listed "the 13 unchanged `fk_columns.is_empty()` legacy guards (E-8, untouched)" as removable sediment, on the premise they are "mostly unreachable since SG-7 hard-errors on incomplete relationships." A dedicated reachability analysis (2026-07-16) **refuted that premise**; recording the result here so the guards are not mistaken for dead code and stripped.
- **Finding:** SG-7's hard-error (`model.rs::has_incomplete_relationships` → `fan_trap.rs` `UncheckableDefinition`) is **not** a universal read gate. It fires only through `build_relationship_graph`, which three supported paths reach *without* triggering:
  1. **single-table fact queries** — `validate_fact_table_path` early-returns for ≤1 table, so an empty-FK join flows into role-playing / `resolve_joins_pkfk`;
  2. **`SHOW SEMANTIC DIMENSIONS FOR METRIC`** — calls `from_definition` directly, no SG-7 gate;
  3. **`CREATE`/`ALTER … FROM YAML`** — an incomplete relationship can be deserialized (`#[serde(default)]` on `fk_columns`) from user YAML or a hand-written catalog row.
  An empty-`fk_columns` join means exactly one thing (a legacy/pre-Phase-24 encoding — `Cardinality` has only `ManyToOne`/`OneToOne`, both PK/FK), so these are legacy-skip guards, not join-category selectors, but they are genuinely reachable on the paths above. The load-bearing sites: `graph/relationship.rs:57,126,193,304`, `graph/cardinality.rs:29`, `expand/role_playing.rs:21,43`, `ddl/show_dims_for_metric.rs:167`, `ddl/define.rs:85`, plus the SG-7 detector itself (`model.rs:488`).
- **Also:** three items originally counted under E-8 (`cardinality.rs:47,74`, `join_resolver.rs:45`) are `ref_columns` resolution / count-match logic, not fk-empty guards. Three (`fan_trap.rs:242,311`, `join_resolver.rs:144`) are dead *only* behind an upstream SG-7 gate and are kept as defense-in-depth — the fact path already demonstrated once that such a gate can be bypassed.
- **Decision:** Keep all guards. E-8 is **not** a safe deletion.

### 30. ✅ Dotted-qualified `NON ADDITIVE BY` reference now resolves in semi-additive snapshot expansion (F-18) — RESOLVED 2026-07-17

- **Origin:** code-review 2026-07-16 F-18, extended during the hygiene sweep. `body_parser` Phase 47 validation accepts an NA dim written either bare (`date_dim`) or dotted (`alias.date_dim`), but stores the reference verbatim.
- **Was:** `expand/semi_additive.rs` resolved the NA dim for the snapshot `ORDER BY` by **bare-name** comparison only (`rd.dim.name` / `d.name` vs `nd.dimension`). A dotted reference therefore missed both lookups and fell to the `quote_ident(&nd.dimension)` arm, emitting the quoted qualifier as an `ORDER BY` term — a clean bind-time failure (a quoted non-column), never the aliased-column shape E-1 fixed, but the dotted × semi-additive cell had no test.
- **Resolution (2026-07-17):** every NA-dim-vs-dimension comparison in `semi_additive.rs` is routed through the shared bare-AND-dotted resolver `resolution::find_dimension` via a single `na_dim_match_key(def, &nd.dimension)` helper that returns the resolved dimension's canonical `normalize_ident_part` key (falling back to the normalized raw text only for the unresolvable/fail-clean residual). The interlocking sites all use it: classification (`is_active_semi_additive`, resignatured to take `def`), grouping (`collect_na_groups`), the partition-exclusion filter, the `ORDER BY` expression resolution (queried → CTE expression per E-1, unqueried → the declared dim's own expression), and the snapshot-join source-table collection (`collect_na_dim_source_tables`). The three predicate call sites (`sql_gen`, `fan_trap` ×2) key their queried-dim sets on `normalize_ident_part`. A dotted `o."order date"` now classifies, partitions, orders, and joins identically to the bare form and expands to a bindable snapshot query.
- **Tests:** four Rust unit tests in `semi_additive.rs` (dotted-not-queried orders-by-resolved-expression, dotted-queried effectively-regular, the shared predicate resolving a dotted reference, and a data-level dotted-NA-join execution) plus `phase68_quoted_idents_non_additive.test` Scenario 2/2b upgraded from DDL-only to full end-to-end query + plan-shape assertions.
- **Follow-up landed 2026-07-17:** the symmetric window-metric dimension-side matching (`expand/window.rs` `PARTITION BY` / `EXCLUDING` / `ORDER BY` dimension references) is now routed through the same shared resolver — see the Slice-3 window bullet under #28. This completes the whole dimension-side pass.

### 31. ✅ Graph/validation errors are typed (`ParseError`) but deliberately positionless

- **Origin:** code-review 2026-07-16 §6 item 1 / §7.5: "the typed-error rollout stops at the graph/validation layer (~15 `Result<_, String>` signatures with no positions)… Extend the treatment or write the paragraph declaring the boundary deliberate — currently it reads as an unfinished rollout."
- **Resolution (2026-07-16):** the CREATE-time funnel and the graph module's public validators now speak the shared typed error `crate::errors::ParseError` instead of bare `String`: `validate_name_uniqueness`, `validate_facts`, `validate_derived_metrics`, `validate_graph`, `validate_using_relationships`, and `enrich_definition_for_create` (`ddl/define.rs`) all return `Result<_, ParseError>`; the two CREATE call sites (`parse/native_sql.rs`, `ddl/alter_helpers_ffi.rs`) consume it directly. Errors are constructed via `ParseError::positionless(..)`, a named constructor that makes the absence of a caret explicit. The private per-check helpers (`check_*`) and the externally-shared sub-validators (`RelationshipGraph::from_definition` / `check_no_diamonds` / `check_no_orphans` / `validate_fk_references` / `toposort`, also called by `SHOW … FOR METRIC`) keep their `String` signatures and are wrapped at the public boundary — so nothing `String`-typed propagates upward past the graph module, and no unrelated caller is disturbed.
- **Why `position` is `None` (the deliberate boundary):** these validators receive a fully-built `SemanticViewDefinition` whose members (`Metric`/`Dimension`/`Fact`/`Join`) hold **owned** names and expressions, not byte spans into the original DDL, and the original query text is out of scope by the time they run — so `util::byte_offset_within` (which needs the offending token to be a *subslice of the original query*) cannot be applied here. Additionally, several of these failures are **global/topological** (a derived-metric or fact dependency cycle, an ambiguous join *diamond*, an empty `TABLES` clause) with no single offending token to point a caret at. This mirrors the already-accepted positionless-typed `expand::ExpandError` (fan-trap) and the pervasive `position: None` idiom at the parse layer's own semantic-failure sites.
- **What real carets here would require (not done — out of proportion):** threading the original DDL body **and** a per-item byte-span table down into `SemanticViewDefinition`'s members (or passing them as a side channel into `enrich_definition_for_create`), then, for the *token-shaped* subset — duplicate name, unknown metric/fact reference, unknown source table, unknown/misrouted USING relationship, self-reference, FK/PK mismatch — recovering the offset with `byte_offset_within`. The global/topological subset stays positionless regardless. A future PR can add spans if define-time carets for these become worth the model change.

### 32. ✅ Semi-additive metric snapshotting on a role-playing dimension resolved the wrong role (F-18 / T-15) — RESOLVED 2026-07-17

- **Origin:** code-review 2026-07-16 F-18 ("an unqueried NA dim's ORDER BY uses the raw definition expression with no role-playing scoped-alias rewrite — the semi-additive × role-playing cell is untested (SPECULATIVE)") and §4.2 T-15. The dotted-reference half of F-18 was entry #30; this is the role-playing half, confirmed a real silent-wrong-results bug during the T-15 investigation.
- **Was:** an active semi-additive metric written `m USING (<rel>) NON ADDITIVE BY (<dim>)`, where `<dim>` lives on a role-playing target (a table reached by 2+ distinctly-named relationships from one source) and is not itself queried, resolved the snapshot `RANK() … ORDER BY` reference by **name only** (`find_dimension` → `d.expr`), emitting the **bare** table qualifier. The bare instance's join edge is chosen by the resolver's default (the first-declared relationship), so the snapshot ranked by *that* role regardless of the metric's `USING`. Proven: with `USING (arr_airport)` the metric correctly joined `a__arr_airport ON arrival_code`, but the NA dim still joined a bare `a ON departure_code` and ranked `ORDER BY a.city` by the **departure** city — an arrival-scoped metric snapshotted by the wrong airport, silently (plus a redundant second join). The queried-dimension path never had this gap (it resolves through `role_playing::find_using_context`); the NON ADDITIVE BY path was a separate, later code path that never called it.
- **Resolution (2026-07-17):** `expand/semi_additive.rs` routes every NON ADDITIVE BY dimension through the **same** `find_using_context` the queried-dim path uses, via a new `na_dim_scoped_alias(view_name, def, met, na_dim)` helper (single-metric `USING` context per NA dim). The three outcomes mirror the queried-dim path exactly: not role-playing → `None` → raw expression (unchanged, backward-compatible); role-playing + one `USING` → `Some(scoped)` → the snapshot `ORDER BY` is rewritten to the scoped alias (`a__arr_airport.city`) and the redundant bare join is suppressed (`collect_na_dim_source_tables` skips it — the metric's `USING` already joins the scoped instance); role-playing + no/ambiguous `USING` → `Err(AmbiguousPath)` (fail loud, was silent-wrong). The resolved scoped alias is folded into the `collect_na_groups` group key, so two metrics that share an NA dim but pin different roles land on separate `RANK()` columns (the same defence the #129 polarity fix added for order/nulls).
- **Tests:** four `semi_additive.rs` unit tests (scoped-alias ORDER BY under `arr`/`dep`; the no-`USING` ambiguity error; different-`USING` metrics get separate RANK columns) plus a data-level `execution` test whose fixture makes the arrival-role and departure-role snapshots pick different rows (arrival → 200, departure → 100) so a role regression flips the number, and an end-to-end sqllogictest fixture through `CREATE SEMANTIC VIEW` + `semantic_view()`. This closes the last SPECULATIVE cell from the 2026-07-16 review.

### 33. ✅ Struct-domain fuzz targets tested unreachable inputs — oracles corrected (converge-once + fragment precondition + double-quote balance); full redesign considered and declined

- **Origin:** 2026-07-18. Once CI-1 made the fuzz targets actually run, `fuzz_render_roundtrip` and `fuzz_sql_expand` both flagged non-bugs. Both are symptoms of one design flaw: the targets `#[derive(Arbitrary)]` a whole `SemanticViewDefinition`, but `render`/`expand` are only ever called on **parser-produced** structs. The manufactured input domain is far larger than the reachable one, so each oracle has to re-derive "is this input well-formed?" — and that re-derivation is where the false positives live.
  - `fuzz_render_roundtrip`: the strong fixpoint `render(parse(render(def))) == render(def)` is **unsatisfiable** on arbitrary defs — a free-form `expr` with surrounding whitespace is trimmed by the parser (`" "` → `""`) and cannot be quote-protected. (The e35cf15 identifier quote-protection only ever mattered for such unreachable inputs; it is a no-op for parser-produced defs and is kept as defensive hardening.)
  - `fuzz_sql_expand`: `def_fragments_balanced()` (a precondition guarding the `is_balanced` assertion) omitted the `output_type` and window `window_function` fragments, which `expand` interpolates raw into `CAST(expr AS <type>)` / `FUNC(...)`; an unbalanced type/function name then manufactured unbalanced SQL from "balanced" inputs.
- **Applied (A, merged in #135):** `fuzz_render_roundtrip` switched to the **converge-once** invariant — normalize once via `parse(render(def))` to land on a parser-produced def, then assert `render` is idempotent on it (mirrored by the `assert_render_fixpoint` unit helper + three regression tests in `render_ddl.rs`). `fuzz_sql_expand`'s precondition extended to include `output_type` (dim/metric/fact) and `window_function`. `fuzz_query_names`'s paren-balance oracle extended to skip double-quoted identifiers (a fuzzed name emitted as `quote_ident`ed can legally contain a paren — Copilot review). Coverage is preserved: the strict `parse(render(def)) == def` equality for canonical defs lives in `tests/roundtrip_proptest.rs`.
- **Considered and declined (B):** the from-scratch trust-boundary redesign (fuzz raw text/bytes; derive every struct by parsing; differential oracles; libFuzzer-vs-proptest split; curated seed corpus) was evaluated and **not pursued**, because its premises largely do not hold now that the oracles are correct:
  - Its central claim — "derive structs by parsing, so the balance precondition vanishes (a parsed def's fragments are balanced by construction)" — is **false**: `src/body_parser/entries.rs` rejects unterminated quotes and empty exprs but **not** unbalanced-paren exprs, so a parse-derived def can still carry an unbalanced fragment and `fuzz_sql_expand` would *still* need a balance guard.
  - The "does DuckDB accept the expanded SQL?" differential oracle **already exists**: `tests/differential_proptest.rs` executes `expand`'s output against in-memory DuckDB for canonical defs, so structural validity is already covered where it can be meaningfully checked (needs the base tables to exist — a fuzz target avoids DuckDB for speed).
  - Converting the two struct targets to raw-bytes/text fuzzing would **lose coverage** (most random bytes never parse into a full view) for marginal gain. The hand-rolled balance oracles are the right *kind* of check for a fast, DuckDB-free fuzz target; the fix was completing their preconditions, not replacing them.
  - What narrow B landed instead: two targeted `fuzz_keyword_body` seeds at the recently-fixed constructs (a quoted, comma-/paren-bearing constraint column list; a `MATERIALIZATIONS` clause) and this decision record. `Arbitrary`-on-the-struct stays correct for the serde targets (`fuzz_json_parse` / `fuzz_yaml_parse`), which have no sub-grammar. The text-boundary targets (`fuzz_ddl_parse`, `fuzz_keyword_body`) were already well-seeded, and `Fuzz.yml` / the `just fuzz` recipes already pass both `fuzz/corpus/<t>` and `fuzz/seeds/<t>` to libFuzzer, so seed files are used.

### 34. ✅ Pre-commit hook triggered a ~10-min cold DuckDB build — switched to the no-C++ extension clippy — RESOLVED 2026-07-20

- **Origin:** 2026-07-20. The `cargo-husky` pre-commit hook ran `cargo clippy -- -D warnings` under the **default** feature set (`duckdb/bundled`), which compiles the ~25 MB DuckDB amalgamation from source. That build uses a different cargo profile/fingerprint than `cargo test`, so a green `cargo test` never warmed it; on a cold cache (fresh checkout, or after disk-pressure eviction of the profile) `git commit` stalled for ~10 min, repeatedly blowing agent/CI command timeouts. The `-- -D warnings` clippy and the bundled C++ compile are inherently coupled under default features, and `SV_SKIP_CPP_BUILD` does not help there (it only gates the project `build.rs` extension shim, not `libduckdb-sys`'s bundled build).
- **Applied:** the tracked hook source `.cargo-husky/hooks/pre-commit` (which cargo-husky installs into the untracked `.git/hooks/pre-commit`), plus the new `just lint-fast` recipe, now run `SV_SKIP_CPP_BUILD=1 cargo clippy --no-default-features --features extension -- -D warnings` — the same fast lint CI's `Clippy (extension feature, deny warnings)` step uses. No C++ amalgamation is built (clippy only type-checks): ~1 min cold, ~2 s warm. **No production lint coverage is lost:** every `#[cfg(not(feature = "extension"))]` item in `src/` is either inside a `#[cfg(test)]` module (clippy without `--tests` skips it) or the `src/lib.rs` `test_helpers` module (annotated `#[allow(clippy::pedantic, clippy::doc_markdown)]`). CI's default-features `Clippy (pedantic lints, deny warnings)` step (`just lint`) remains the authoritative full-coverage gate, so any default-only lint still fails CI.
- **Verified:** the fast clippy runs clean on the current tree and, with a deliberately introduced `cast_possible_truncation` / `must_use_candidate` violation, fails `-D warnings` (it has teeth). Documented in MAINTAINER.md (Justfile recipe table + the extension-clippy section) and CLAUDE.md (code editing rules).

### 35. ✅ Fan-trap fix errors on multi-grain queries; per-grain ("own-grain") metric aggregation — the Snowflake-parity real fix — RESOLVED in v0.12.0

- **Origin:** code-review 2026-07-02 §SG-1/6/7/16 (the policy call) and 2026-07-18 §2 / §"design decisions worth documenting" (the root-anchored `FROM` shared root cause of EXP-1/2/3/5). Recorded here so the deferral stops living only in the review notes — it had no TECH-DEBT entry despite being the largest open query-expansion-semantics item.
- **Decision:** the generated query is always anchored `FROM <root table>` with `LEFT JOIN`s outward. A metric whose grain is a parent/ancestor of the root, two metrics (or one derived/window metric) spanning a fan-out edge, or an active semi-additive metric over a fanned join, therefore aggregate over the *multiplied* join. The EXP-1/2/3 work (CHANGELOG `[Unreleased]`; `expand/fan_trap.rs` + the `RootGrainFanTrap` variant) closes the **silent-wrong-answer** half: every such shape now **errors** loudly (`RootGrainFanTrap` / `MetricFanTrap` / the semi-additive fan check), so the project's "error rather than silently inflate" contract holds uniformly. It does **not** make these queries *return* correct numbers.
- **The deferred real fix:** Snowflake computes each metric at its **own grain** — pre-aggregate each metric's source table to its grain in a per-metric CTE, then join the pre-aggregated results — so a parent-table metric alongside a child-table dimension yields the right value instead of erroring. That replaces the single root-anchored `FROM` with per-grain aggregation CTEs and interacts with materialization routing, fan-trap detection, and the semi-additive / window paths, so it was explicitly scoped as a **follow-up milestone**, not part of the correctness-fence work.
- **Why deferred:** erroring is safe (correct-or-nothing) and shippable immediately; the per-grain rewrite touches the core expansion topology and is milestone-sized. Landing the loud error first removed the data-corruption risk without blocking on the larger change.
- **Impact:** a query Snowflake would answer by computing a metric at a coarser grain than the root currently raises a fan-trap error here. Workarounds: query each grain separately, or declare the coarser table as the base.
- **Action:** implement per-grain metric CTEs ("compute each metric at its own grain") as a dedicated milestone; the errored shapes then become answerable and this entry flips to ✅. Until then, the root-anchored `FROM` divergence is worth an explicit statement in the docs/architecture notes (2026-07-18 §2).
- **Resolved (v0.12.0, Phases 69-71):** `src/expand/per_grain.rs` implements the per-grain rewrite. When a base-anchored `FROM` would inflate the query, each metric is aggregated in a CTE anchored at **its own** grain table and the results are joined on the queried dimensions — `FULL OUTER JOIN` with NULL-safe key comparison (a dimension group present at one grain and absent at another survives with a NULL metric), or `CROSS JOIN` when the query has no dimensions. A derived metric spanning grains is decomposed into per-grain component columns and its expression re-evaluated over them. All three shapes this entry named are now answered: the parent/ancestor-grain metric (alone or with dimensions at/above its grain), multi-grain metric combinations (fan and chasm), and the single multi-grain derived metric. `check_fan_traps` gained a per-grain mode that suppresses exactly the two checks per-grain supersedes (metric-grain × root, metric × metric) and keeps the metric × dimension check, which is about a dimension below a metric's grain and is not something per-grain can define. The routing predicate fires only where v0.11.0 errored, so single-grain SQL is byte-identical. The docs statement this entry asked for landed too: `docs/explanation/snowflake-comparison.rst` §"Metric Grain" and `docs/how-to/fan-traps.rst` §"Multi-Grain Queries". Residual gaps are entry #36.

### 36. ✅ Multi-grain queries involving window / role-playing / active semi-additive metrics — RESOLVED in v0.12.0

- **Origin:** v0.12.0 (Phase 69), the residual of entry #35. Recorded separately so #35's resolution is not overstated.
- **Decision:** the per-grain planner (`per_grain::is_eligible`) declines — leaving the v0.11.0 fan-trap error in place — when a queried metric is an *active* semi-additive metric (a `NON ADDITIVE BY` whose snapshot dimension is outside the query) or carries `USING` role-playing context, and when a queried dimension has no source table (an unqualified expression, whose binding would move if the anchor moved). Each of those strategies emits its own base-anchored CTE (`__sv_snapshot`) or needs role context the grain CTEs do not carry; routing them through per-grain emission without designing that interaction would risk exactly the silent-wrong-answer class v0.11.0 closed.
- **Window metrics — RESOLVED:** `per_grain::window_cte_anchor` now picks the grain for an all-window query and `expand_window_metrics` anchors `__sv_agg` there via `push_from_anchor` + `anchor_joins`, instead of `push_from_base`. The window function itself is not grain-sensitive — it runs over the already-grouped CTE — so anchoring the CTE at the **inner** aggregate's own table was the whole fix. The anchor is decided *before* the fan-trap fence and puts it in the same per-grain mode the plan does, which suppresses the two checks the anchor supersedes and keeps the metric × dimension check (a dimension below the metric's grain is still an error, in both engines). The anchor is derived from the inner metric specifically, not from `metric_grain_tables` on the window metric: DDL qualifies a window metric with a source alias, and that helper unions the alias with the inner's for its own conservative purpose, which would report two grains for every DDL-declared window metric.
  - Still declined, deliberately: window metrics whose **inner aggregates sit at different grains** (those grains would have to be joined before the window runs — the next increment), a root-grain inner aggregate (base-anchoring is already correct), role-playing, and unqualified dimensions.
- **Role-playing — NARROWED to a per-query test:** the role-playing gate asked a *definition*-level question ("does any table have two inbound relationships from one source?"), so **every** query against a definition that declared role-playing anywhere lost per-grain emission — including queries whose grains and dimensions never reach the role-played table, whose CTEs join nothing ambiguous and need no role context. It now asks the query-level question via `role_playing_on_path` over the tables the query touches — dimension source tables ∪ `metric_grain_tables` ∪ the `where_clause`'s source tables. The predicate's tables belong in that set because `anchor_joins` chains them into the sources it walks, exactly like a dimension's; omitting them in the first cut let a predicate on a role-played table through, and the grain CTEs then bound it to the first-declared relationship via `edge_between` — a silent wrong answer where the shape had previously raised `MetricFanTrap` (caught in review on PR #176, pinned by `where_clause_reaching_a_role_played_table_declines_per_grain`). Checking the touched tables also covers the joins between them: the relationship graph is a tree apart from the sanctioned role-playing multi-edge, so any node on the path between two touched tables is an ancestor of one of them and the walk to the root passes through it.
- **Role-playing — `USING` now threaded into the grain CTEs:** a co-queried metric's `USING` names which role a dimension on a role-played table means, and `anchor_joins` now emits that hop under its scoped alias (`a__dep`) joined on the NAMED relationship, with `dim_expr` rewriting the dimension's qualifier to match — the same shape the base-anchored path has always produced, so the two agree. `edge_between` is deliberately bypassed for such a hop: it returns whichever of the several edges is declared first, which is the mis-binding the gate exists to keep out of this emitter. `is_eligible`'s blanket "no metric with `USING`" test narrowed to "every relationship its `USING` names is one this path threads", so `USING` on a single-edge relationship (which the base-anchored path scopes itself) still declines rather than being guessed at.
  - Still declined, deliberately, because only a *dimension's* expression is rewritten to the scoped alias: a `where_clause` member on a role-played table, a metric's own grain table on one, and any table reachable only *through* one (`find_using_context` rejects the descendant case outright). The window path stays strict for the same reason — `__sv_agg`'s SELECT list gets no scoped-alias rewrite — and passes an empty role map to `anchor_joins`.
  - Was previously: a query that *does* reach a role-played table. A grain CTE would have to choose among that table's relationship instances, and choosing silently is the declaration-order-dependent mis-binding the fence exists to prevent. Threading `USING` into `anchor_joins` — so a grain CTE joins the *named* role and the dimension is emitted against its scoped alias (`a__dep`) — is the remaining half of this sub-item. The machinery it needs already exists (`role_playing::find_using_context`, `expr_tokens::rewrite_qualifier`, and `ResolvedJoin`'s `scoped` / `emit_alias` fields, which `anchor_joins` currently hardcodes to the bare alias).
- **Impact:** Snowflake answers these; we error. In practice they are the corner where multi-grain meets two features that are themselves uncommon — a single-grain query using either is unaffected.
- **Snowflake behaviour — measured, not inferred (2026-07-31).** The docs state the semi-additive rule ("the rows are sorted by the non-additive dimensions, and the values from the last rows ... are aggregated ... in each group of queried dimensions") and the dimension-grain rule separately, but never their interaction, and every documented semi-additive example is single-table. Probed directly against Snowflake with a two-grain fixture (customers 1 row each; accounts fanned 2-3 per customer, two snapshot dates), querying an *active* semi-additive metric at the account grain together with a plain metric at the customer grain:
  - **Per-grain confirmed.** `customer_count` returned 2/1 — the own-grain answer. A base-anchored plan would have returned 3/1 (customers counted over the rank=1 rows) or 6/2 (over all joined rows); the fixture was built so the three are distinguishable, and each variant was computed in DuckDB beforehand. `total_balance` returned 350/70, the latest-snapshot value, confirming the snapshot rule genuinely fired rather than both dates summing (385/77). So Snowflake computes the snapshot *inside* the metric's own-grain aggregation and joins the pre-aggregated results — the same model per-grain already implements for plain metrics.
  - **An NA dimension may live on ANOTHER table.** A bare `NON ADDITIVE BY (report_date)` naming a dimension declared on a *parent* of the metric's table fails at CREATE with `invalid identifier 'REPORT_DATE'` — bare names resolve in the metric's own logical-table scope — but the **qualified** form `NON ADDITIVE BY (s.report_date)` is accepted and returns the same per-grain answer in a multi-grain query. Our dotted-NA support (#30) is therefore *aligned* with Snowflake, not more permissive.
  - **The dimension-grain fence matches.** Requesting a metric with a finer-grained dimension errors: `The dimension entity 'A' has a higher level of granularity than the base metric entity 'C'. Consider selecting a dimension from an entity that is related to and has a lower level of granularity than 'C'.` (Snowflake's "higher granularity" means *finer*.) That is the rule `fan_trap.rs` already enforces.
- **Consequence for the implementation — a join source the current code does not collect.** An *active* semi-additive metric is by definition one whose NA dimension is **not** queried, so that dimension's table never appears in `resolved_dims` and `per_grain::anchor_joins` will not join it. Combined with the finding above that the NA dim may sit on another table, a grain CTE would emit `RANK() ... ORDER BY s.report_date` against a `FROM` that never binds `s` — the same unbound-alias class as the #177 single-grain defect. `semi_additive::collect_na_dim_source_tables` already computes exactly this set for the base-anchored path; per-grain must feed it into `anchor_joins` alongside the dimension and `where_clause` tables.
- **Increment 1 landed:** `per_grain::snapshot_cte_anchor` re-points `__sv_snapshot` at the metric's own table when every queried metric sits at one non-base grain that the base-anchored join fans, and `semi_additive` emits its FROM/JOINs through `push_from_anchor` + `anchor_joins` in that case. Deliberately the same shape as `window_cte_anchor`, including its direction restriction: only re-anchor onto the "one" side, because for a metric on a CHILD of the base table `FROM base LEFT JOIN child` already yields each child row once and its LEFT JOIN keeps childless parents as NULL-extended rows — flipping the anchor would silently DROP those groups, and this single-CTE path has no FULL OUTER JOIN reassembly to restore them. The NA dims' source tables are passed to `anchor_joins` as extra sources (`semi_additive::na_dim_source_tables`, exposed for the decision `sql_gen` must make *before* the fan-trap fence runs), which is what makes the probed off-table NA dimension bind; without it the re-anchored CTE would emit `ORDER BY d.report_date` over a FROM that never joins `d`.
- **Increment 2 landed — entry closed.** `per_grain` now emits the snapshot as one grain group's CTE, nested as `SELECT <outer> FROM (<inner>)` so the rows are ranked before that grain aggregates them, while sibling groups aggregate normally and the results join as usual. `Group` carries its metrics' canonical names (not just the built expressions) because a snapshot group must decompose the metrics themselves; names rather than `&Metric` keep `Plan` free of a lifetime. `render_snapshot_group` calls the shared `semi_additive::build_snapshot_block`, which numbers its `__sv_semi_{i}` capture columns by position within the metrics it is *given*, so each group gets an independent namespace and two snapshot groups in one query cannot collide.
  - A query whose ONLY group is a snapshot group is deliberately declined by `plan`, leaving it to increment 1's verified single-CTE shape rather than re-routing it through a one-group plan that would emit the same thing inside an extra wrapper.
  - `expand_per_grain` now returns `Result`: `build_snapshot_block` can reject a metric that is not a single bare aggregate (SG-5), and that diagnostic should reach the user rather than be swallowed into a fan-trap error.
  - All four sub-items of this entry are now resolved: window metrics (#175), role-playing eligibility (#176) and `USING` threading (#177), semi-additive own-grain (#179) and multi-grain (this change).

### 38. ✅ `USING` / `NON ADDITIVE BY` written AFTER `AS` were silently swallowed into the metric expression — RESOLVED 2026-08-01

- **Origin:** hit while writing `semi_additive_own_grain.test` during the TECH-DEBT #36 semi-additive work (PR #179); noted in that PR's body and filed here rather than fixed inline, being unrelated to the emitter change.
- **Symptom:** a metric entry with a pre-`AS` clause written in the wrong position is **accepted at CREATE**, its declared semantics silently discarded, and the view is left unqueryable. Both affected clauses, verified against the built extension:

  ```sql
  METRICS (t.m AS sum(t.v) NON ADDITIVE BY (d))   -- CREATE ok, query fails
  METRICS (t.m AS sum(t.v) USING (r))             -- CREATE ok, query fails
  ```

  `DESCRIBE SEMANTIC VIEW` then reports `EXPRESSION = 'sum(t.v) NON ADDITIVE BY (d)'` with **no** `NON_ADDITIVE_BY` property row: the metric is stored as an ordinary additive metric whose expression happens to contain uninterpretable text. `GET_DDL` round-trips that text faithfully, so the malformed definition survives a dump/reload. The failure surfaces only at query time, as a confusing parser error pointing inside generated SQL rather than at the user's DDL:

  ```
  Binder Error: ... type inference failed for query `SELECT sum(t.v) NON ADDITIVE BY (d) AS "m" ...`:
  Parser Error: syntax error at or near "ADDITIVE"
  ```

  The silent discarding is the worse half: a user who mistypes the clause position gets a view that creates cleanly and reports itself as a plain metric, so the semi-additive (or role-scoping) intent is lost with no diagnostic anywhere.
- **Cause:** `body_parser::metrics::parse_metric_entry` parses `USING (...)` and `NON ADDITIVE BY (...)` from the prefix **before** `AS`, then takes everything after the `AS` token as `raw_expr` (`metrics.rs:150`). Text after `AS` is therefore expression by construction, and nothing inspects it for a misplaced clause keyword. `OVER (...)` is not affected — `parse_window_over_clause` runs on `raw_expr`, so a window clause after `AS` is genuinely parsed (confirmed as a control alongside the two cases above).
- **Fixed as sketched.** `parse_single_metric_entry` now scans the post-`AS` text — after trailing annotations and the `OVER` clause have been stripped — for a depth-0 `USING` / `NON ADDITIVE BY` run via the existing quote- and depth-aware `Cursor::find_kw_depth0` / `find_kw_seq_depth0`, and rejects with a message naming the clause and where it belongs. Reuse, not new machinery.
  - Scanning at **depth 0** is what makes it safe: an aggregate call's contents sit at depth >= 1, so a depth-0 keyword can only be OUTSIDE the call — exactly the misplaced position. Quote-awareness covers the other direction: the same words inside a string literal or quoted identifier stay data. `OVER (...)` is unaffected, having already been consumed.
  - **Review follow-up (#181):** the depth-0 scan alone was bypassable by parenthesising the WHOLE expression (`AS (sum(v) USING (r))`), which pushed the keywords to depth 1 — the same bug through another door, silently creating and failing only at query time. `Cursor::is_wholly_parenthesised` now strips redundant outer wrappers before the scan. Deliberately narrow: only a wrapper whose opening bracket matches the LAST token is stripped, so a structural `(...)` keeps its contents nested and a genuinely nested `USING` inside a subquery's JOIN stays legal (guarded both as a unit test and end-to-end). The same review moved the `before_as.is_empty()` check ahead of the scan, so a missing metric name is still reported as such rather than pre-empted by the misplaced-clause error.
  - Both rejections have independently-reporting unit tests in `body_parser::tests`, plus controls in the same module — a legitimate `OVER` after `AS`, and the keywords inside a string literal — so over-rejection would fail as loudly as under-rejection. `misplaced_metric_clause.test` proves it end-to-end at CREATE; its two `statement ok` guards were confirmed to pass with the fix reverted, so they are guards rather than capabilities.
- **Impact:** low severity, low frequency — the query-time error does eventually stop the user, and the correct spelling is what the docs and every example show. It is filed because the failure is *silent at the point of the mistake* and *survives GET_DDL*, which is the shape this project treats as worse than an outright error.

### 39. ✅ Fan-trap fence never checked the `NON ADDITIVE BY` dimension's own table — silent double-count (EXP-9) — RESOLVED 2026-08-03

- **Origin:** code review 2026-08-03 (`_notes/code-review-2026-08-03.md`, EXP-9), reviewing `v0.10.4..HEAD`. Direct descendant of EXP-3 (#—, code review 2026-07-18), which made active semi-additive metrics subject to the fence's metric × dimension and metric × metric checks but did not cover the table the snapshot joins *because of the NA dimension itself*.
- **Symptom:** an ACTIVE semi-additive metric (its `NON ADDITIVE BY` dimension not among the queried dimensions) whose NA dimension sits on a table that fans the metric's grain was aggregated over a join that had already duplicated the metric's rows. `RANK` ties across the fanned duplicates of one source row are indistinguishable from ties across distinct rows, so the snapshot CTE could not dedupe them and the value was added once per duplicate. Wrong number, no error:

  ```sql
  -- orders (base) <- line_items (many side); one order, two line items sharing a ship_ts
  METRICS (o.balance_at NON ADDITIVE BY (ship_ts) AS sum(o.balance))
  semantic_view('v', metrics := ['balance_at'], dimensions := ['region'])  -- returned 200.00 for a 100.00 balance
  ```

  The asymmetry is what makes it unmistakable: *querying* `ship_ts` makes the metric regular, and the fence has always raised `FanTrap` for the identical join. The un-queried case was the permissive one.
- **Cause:** `check_fan_traps`'s metric × dimension loop iterates `resolved_dims` — the *queried* dimensions. An un-queried NA dimension appears there nowhere, while `collect_na_dim_source_tables` joins its table into the snapshot CTE exactly as a queried dimension's table is joined. A metric at the base grain also skips the root-grain loop (`met_table == root`), so nothing checked the join at all. `snapshot_cte_anchor` does not rescue it: it returns `None` when the metric is already at the root grain, and where it *does* re-anchor it checks only that its extra tables are reachable, never that reaching them is non-fanning.
- **Resolution:** `semi_additive::na_dim_fence_members` returns `(metric index, NA dimension name, source table)` for every active NA dimension contributing a bare join — the same entries `collect_na_dim_source_tables` turns into join targets, paired back to the metrics whose rows that join multiplies. `check_fan_traps` runs the existing path/fanning-edge test over them and reports `FanTrap` naming the NA dimension. Role-scoped NA dims are excluded for the same reason they contribute no bare join (T-15). Placed **above** the `per_grain` early return: the join lives inside the snapshot CTE, which the base-anchored and anchored/per-grain emitters both build from the shared `SnapshotBlock`.
- **Coverage:** `expand::tests_fan_trap::{semi_additive_na_dim_on_fanning_table_blocked, semi_additive_na_dim_queried_is_already_blocked, semi_additive_na_dim_on_non_fanning_table_allowed}` (independently reporting, so the controls cannot be masked) and `test/sql/cr20260803_silent_fan_and_role.test` end-to-end with the tie that surfaces the double-count.

### 40. ✅ `where_clause` member on a role-playing table bound to the first-declared relationship (EXP-10) — RESOLVED 2026-08-03

- **Origin:** code review 2026-08-03 (`_notes/code-review-2026-08-03.md`, EXP-10), on the `where_clause` feature added post-v0.12.0. The role-playing seam EXP-4/EXP-5 closed for dimensions and facts, re-opened by a new member-bearing surface.
- **Symptom:** a predicate naming a member on a role-playing table filtered through whichever relationship was declared first, silently. With a co-queried metric's `USING` naming the *other* role, the query grouped by one instance and filtered by the other:

  ```sql
  -- flights -> airports via dep AND arr; metric arr_count USING (arr)
  semantic_view('v', metrics := ['arr_count'], where_clause := 'city = ''NYC''')
  -- emitted: LEFT JOIN airports a ON f.dep_code = a.code
  --          LEFT JOIN airports a__arr ON f.arr_code = a__arr.code
  --          WHERE (a.city) = 'NYC'      <- counting arrivals, filtering departures
  ```
- **Cause:** role-playing ambiguity is checked for queried dimensions (`find_using_context`) and, on the facts path, for queried dimensions and facts (`check_fact_role_playing_path`). A `where_clause` member's table is in neither set: it rides `resolve_joins_pkfk`'s `fact_source_tables` parameter, whose loop — unlike the dimension loop — never consults `role_playing_bare_aliases`, so the bare alias is joined via `tree_parent`, the first-declared edge. The per-grain planner already declined this shape (where-tables are in its "strict" set), but declining routes the query to the base-anchored path, which emitted it silently.
- **Resolution:** `role_playing::check_where_clause_role_playing_path`, the sibling of `check_fact_role_playing_path`, rejects a member whose table is (or is reached only through) a role-playing target via the shared `role_playing_on_path`. Called on both emission paths — beside `check_where_clause_fan_traps` on the metrics path, and as step 3d on the facts path — so it is decided above the per-grain / anchored / base-anchored split. New error `ExpandError::AmbiguousWhereClausePath`.
- **Why not infer the role from a co-queried metric's `USING`:** only a queried *dimension's* expression is rewritten to a scoped alias, so a predicate member has no way to name its role; several co-queried metrics may name several roles, and a predicate is bound to none of them. Picking one would be a guess, and guessing is what produces a wrong number instead of an error — the EXP-4/EXP-5 reasoning.
- **Coverage:** `expand::tests_role_playing::{where_clause_on_role_playing_table_is_rejected, where_clause_role_playing_does_not_filter_through_the_other_role, where_clause_on_non_role_playing_table_still_allowed}` plus `test/sql/cr20260803_silent_fan_and_role.test` (including a control that a `where_clause` on a non-role-playing table still filters pre-aggregation, and that a queried dimension still resolves through `USING`).

### 41. ✅ `where_clause` was pinned to its inert default in every numeric proptest oracle (PBT-6) — RESOLVED 2026-08-04

- **Origin:** code review 2026-08-03 (`_notes/code-review-2026-08-03.md`, PBT-6). All five differential/numeric harnesses in `tests/` pinned `where_clause: None`, so the newest number-changing feature had no randomized coverage on any path — the blind spot EXP-9 (#39) and EXP-10 (#40) reached `main` through.
- **Closed so far (2026-08-03):** `differential_proptest` (base-anchored single-table) and `star_schema_proptest` (per-grain CTEs, plus a new rejection property for a child-side predicate against a parent-grain metric). Both generate a predicate AST rendered twice — member names into the request, raw columns into the oracle — and both declare `LABELS = (FILTER)` members with compound expressions so member substitution and its parenthesization are load-bearing rather than identity. Each carries a generator-coverage guard asserting the parameter actually varies and (in the star harness) that both fence branches are actually reached.
- **Verified by mutation, not observation:** dropping the parenthesization (`where_clause.rs:93`) reds `differential_proptest`; dropping the predicate from the grain CTE (`per_grain.rs:1044`) reds `star_schema_proptest`. A generator that varies but whose assertions cannot fail is the failure mode this entry exists to prevent, so "it passes" was not accepted as evidence.
- **Also closed (2026-08-04): `semi_additive_proptest`**, the highest-priority of the three and the path EXP-9 lived on. Here the predicate is applied *before* the `RANK` inside `__sv_snapshot`, so it does not merely shrink what is summed — it changes **which row wins the snapshot**: filtering away the `MAX(ts)` row promotes the next timestamp. The oracle therefore splices the predicate into *every* reference to the base table, including the subquery that determines the snapshot timestamp; filtering only the outer aggregate would encode post-snapshot filtering and would agree with an implementation making the same mistake.
  - Two verifications, because a passing randomized property is not by itself evidence here. Dropping the predicate from the snapshot CTE (`semi_additive.rs:297`) reds the property. And `predicate_is_applied_before_the_snapshot_not_after` pins the distinction deterministically: on one entity with rows at `ts=2`/`ts=1` under `where_clause := 'ts < 2'`, before-snapshot filtering yields balance 10 while after-snapshot filtering yields no rows — the test asserts the two formulations *disagree* as well as asserting the extension matches the former, so it fails if the comparison ever becomes insensitive to the distinction.
  - The generator-coverage guard additionally asserts that an ACTIVE snapshot combined with a predicate on the NA dimension is actually generated — the only shape that distinguishes before from after, and the one whose absence would make the whole extension inert.
- **Finally closed (2026-08-04): `window_metric_proptest` and `multi_hop_join_proptest`.**
  - **Window:** the predicate is injected into `__sv_agg`, *before* the window function runs, so it does two things at once — it changes each surviving group's inner `SUM`, and when it empties a group it removes that row from the partition the window aggregates over. The oracle filters the `agg` CTE and nothing else; the correlated subquery reads from the already-filtered CTE, so partition membership follows. Its coverage guard asserts predicates on a queried dimension *outside* the effective partition are generated — the shape where the window value moves while the partition key does not.
  - **Multi-hop:** the predicate is evaluated inside every grain's CTE, so the oracle filters each grain's half. It also gains a second fence property mirroring the existing dimension rule: a `where_clause` member **below** a selected metric's grain must be rejected, because joining it into that grain's CTE fans the metric (filtering on `t` while aggregating `sum(u.uw)` inflates the sum). Its guard asserts both branches are reached *and* that an accepted multi-grain query carries a predicate, which is the multi-grain injection the harness exists to cover.
  - Both mutation-verified: dropping the injection at `window.rs:236` reds the window property; dropping it at `per_grain.rs:1044` reds the multi-hop property. Both reverted.
- **Result:** all five numeric harnesses now generate and oracle-check a `where_clause`. The two remaining `where_clause: None` sites in `tests/` are in `expand_proptest.rs`, which checks structural parse/expand invariants rather than comparing numbers against an oracle — a predicate there would not be checked for correctness, so it is deliberately out of scope.
- **What this makes true:** CLAUDE.md's rule that a new query-semantics feature must reach the numeric oracles is only as good as the harnesses it points at. All five can now see a predicate, and each carries a generator-coverage guard so the parameter cannot silently revert to an inert default without a test failing.

### 44. ✅ Schema-scoping migration stored the row's schema spelling, not the catalog's (CAT-1) — RESOLVED 2026-08-04

- **Origin:** code review 2026-08-03 (`_notes/code-review-2026-08-03.md`, CAT-1).
- **Was:** `migrated_row` recorded whatever `schema_name` the legacy definition JSON carried, verbatim. `current_schema()` echoes the spelling last written in `USE`, so a view created after `USE "ANALYTICS"` recorded `ANALYTICS` for a schema the catalog itself calls `analytics` — a spelling the catalog never had. `create_target_schema_expr` already canonicalises at CREATE time (via `duckdb_schemas()`); the migration did not, so the two paths disagreed.
- **Symptom:** every read guard folds case (`row_predicate` uses `lower(schema_name) = lower(…)`), but `INSERT OR REPLACE` / `OR IGNORE` conflict on the **byte-equal** `(schema_name, name)` primary key. So a later `CREATE OR REPLACE analytics.v` did not conflict with the migrated `ANALYTICS` row: it inserted a **second** row. Both then matched the folded lookup, and `resolve_in_search_path` takes `rows[0]` under `ORDER BY schema_name`, which puts the uppercase spelling first — so the CREATE reported success while every subsequent read returned the **pre-replace** definition. Reproduced end-to-end before the fix; the two rows are shown in the integration test's failure output.
- **Resolution:** the migration builds a lowercase→catalog-spelling map from `duckdb_schemas()` for the current database (`catalog_schema_spellings`, mirroring the CREATE-time lookup) and canonicalises each recorded spelling through it. Both the `schema_name` column **and** the `schema_name` inside the stored JSON are rewritten, because the module documents them as being in lockstep and the SHOW / DESCRIBE listings read the JSON.
  - A recorded schema the catalog does not know keeps its spelling: this migration must carry every row across, and dropping or failing on one is unrecoverable. Such a row is no worse off than before.
  - No collision risk: the legacy table is keyed on `name` alone, so there is at most one row per name and canonicalisation cannot merge two rows into one key.
- **Coverage:** `catalog::tests::schema_scoped_storage_tests::{migration_records_the_catalogs_schema_spelling_not_the_rows, migration_keeps_column_and_json_schema_in_lockstep_when_canonicalising, migration_keeps_an_unknown_schema_spelling_rather_than_dropping_the_row, migration_leaves_an_already_canonical_spelling_alone}`, plus `test/integration/test_legacy_catalog_schema_spelling.py` (`just test-legacy-schema-spelling`), which builds a real pre-scoping `.db`, migrates it through a real `LOAD`, and asserts the duplicate-row/stale-read symptom is gone. That integration test also closes part of TC-1's gap — the legacy-catalog migration is no longer unit-simulated only.

### 45. ✅ AR-4 version stamp was schema-blind after schema scoping (CAT-2) — RESOLVED 2026-08-04

- **Origin:** code review 2026-08-03 (`_notes/code-review-2026-08-03.md`, CAT-2).
- **Was:** `upgrade_definitions_schema` selected `(name, definition)` and stamped with `UPDATE … WHERE name = ?`. That was correct while `name` was the whole primary key; under schema scoping the key is `(schema_name, name)`, so the stamp hit **every** same-named row across schemas.
- **Symptom:** the pass deliberately leaves un-upgradeable rows at version 0 "so reads hard-error rather than silently under-checking". Processing an upgradeable `analytics.v` also stamped an unverified `main.v`, claiming a guarantee that row had never been checked for. Kept out of the higher severity bands because the real safety gate is content-based (`has_incomplete_relationships()` in `fan_trap::build_relationship_graph`), not the version integer — so this corrupted the stated invariant and the metadata, not query safety.
- **Resolution:** select `schema_name` alongside and stamp `WHERE schema_name = ? AND name = ?`.
- **Coverage:** `catalog::tests::upgrade_stamps_only_the_row_it_verified_not_its_namesakes_in_other_schemas` — two schemas holding a view of the same name, one upgradeable and one not; the verified row is stamped and the namesake is not. The pre-existing `upgrade_stamps_complete_rows_and_skips_incomplete` is retained unchanged as the single-schema control.

### 58. ✅ The YAML → GET_DDL round-trip contract is now enforced, and the fuzz oracle can no longer look away (RT-5/RT-6) — RESOLVED 2026-08-07

> **Correction 2026-08-08.** The contract statement below was **false when written**, for four classes of field the RT-5/RT-6 enumeration never reached — it covered identifier *slots* only. Free-expression slots admitted comment markers that destroyed sibling members on replay (RT-8); the entire `WindowSpec` payload was emitted raw, so a hostile `frame_clause` injected a metric and a partition dimension named `rows` silently became a frame clause (MODEL-1); the semantic cross-references (`NON ADDITIVE BY` dimensions, window inner metric and partition/order dimensions, materialization names and references) were validated on the DDL path only (MODEL-1); `output_type` was dropped entirely (RT-7, now #68); and a bare derived metric named `private`/`public` re-parsed as an access modifier (RT-9). All are closed as of the 2026-08-08 change, which extended the contract to free-expression slots, the window payload, cross-references (now shared with the parser via `body_parser/cross_refs.rs` rather than duplicated) and entry-initial keyword collisions.

- **Origin:** code-review 2026-08-06 RT-5 (HIGH) / RT-6 (MEDIUM). The review notes no TECH-DEBT entry existed for this contract at all — this is that entry.
- **Was:** YAML is the only surface that reaches a `SemanticViewDefinition` without passing through the clause parsers, and it performed NO identifier-syntax validation. `GET_DDL` then rendered those slots verbatim. Verified against the renderer: a join with no `name` rendered `     AS o(customer_id) REFERENCES c`; a dimension with no `source_table` rendered `region AS c.region` — both DDL this project's own parser rejects. Worse and silent: `source_table: "a.b"` with `name: "region"` rendered `a.b.region AS …`, which re-parses cleanly as source table `a`, name `b.region` — a **different model**, no error anywhere. And `pk_columns: ["a--b"]` rendered `PRIMARY KEY (a--b)`, which the front door's comment-blanking pre-pass truncates to end-of-line.
- **Why no fuzzing caught it:** the contract broke exactly where the oracle stopped looking. `fuzz_render_roundtrip` (mirrored in `render_ddl.rs`) **returned without asserting** when `parse(render(def))` failed — so `Arbitrary` generated `name: None`, render emitted unparseable DDL, and the target classified its own finding as unreachable input and stayed green. Its second escape, on a re-parse of freshly-rendered CANONICAL DDL, deferred to `tests/roundtrip_proptest.rs` — whose generators pinned `name: Some("rel{i}")` and `source_table: Some(...)`, so that harness structurally could not reach the shapes. Each half pointed at the other.
- **Resolution (2026-08-07):**
  - `model::validate_ddl_representable` states the reachability rule for a stored definition and is called from `from_yaml_with_size_cap`, the single choke point both YAML entry points (`rewrite_ddl_yaml_body`, the ALTER FFI path) pass through. Split per clause over four shared slot predicates.
  - Two things were probed against the grammar rather than assumed, and both changed the fix: dimensions and facts REQUIRE a source table but metrics do NOT (a derived metric is legitimately unqualified — a validator written from the finding's text would have rejected every YAML-imported derived metric); and `identifier_slot_error` alone is insufficient, since it accepts `a.b` (a qualified identifier is a valid identifier — it is the SLOT that admits one part) and accepts `a--b` / `a/*x*/b` outright, having no comment awareness. Single-identifier slots therefore also require exactly one qualifier part, and every slot must survive `blank_sql_comments` unchanged.
  - The fuzz oracle's second escape is **gone** and stays gone: `d1` comes from the parser, so `render(d1)` failing to re-parse is a contract break by definition. Its *first* escape was initially replaced by a `validate_ddl_representable` precondition; that attempt was reverted after eight rounds of fuzzing — see **#60** for why, and for what the target asserts now.
  - `tests/common/mod.rs` no longer pins the relationship name to `rel{i}`; it is generated from `arb_stored_ident()`, so quoted and unicode relationship names have round-trip coverage they never had.
- **What removing the escape immediately found (2026-08-07):** CI's `fuzz_render_roundtrip` went red on the first run against the tightened oracle, on a definition with a TABLES clause **and nothing else**. That is not an identifier-slot defect at all: `find_clause_bounds` requires TABLES *and* at least one of DIMENSIONS / METRICS, so `GET_DDL` on such a definition emits a lone TABLES clause — a body its own parser refuses. The validator was checking what every slot *contained* and never that the clause set was one the grammar could produce. It now runs `validate_yaml_structure` first, scoped to `!def.tables.is_empty()` so legacy no-tables definitions (which `render_create_ddl` declines to render at all) stay importable. The function is named `validate_ddl_representable` rather than `validate_identifier_slots` because the rule is no longer only about slots.
  - Hand-probing the same boundary turned up three more, two of them in the *opposite* direction — slots the validator refused that the DDL grammar accepts and `GET_DDL` round-trips byte-identically. A relationship name is validated in DDL by `identifier_slot_error`, which accepts `a.b`; a materialization's TABLE is a *physical* table, where `schema.table` is the ordinary spelling. Both were being sent through the single-identifier predicate, so YAML was rejecting definitions the DDL path accepts — a regression this PR would have introduced. They now use `slot_qualified`. (Raised in review on PR #209; confirmed against the parser and the renderer before being accepted, and each relaxation is pinned by a control asserting the multi-token form is still refused.)
  - **Round two, same oracle:** the next CI run shrank to a metric with an **empty expression**, rendering `e.p  AS ` — "Missing expression after 'AS'". Every member is `name AS expr` and the clause parsers require the expression, but the validator had only ever looked at names and slots, never at the payload. Bisecting that crash field-by-field cleared every other suspect in it — a NUL-bearing alias, a table name full of spaces, and a trailing-space member name all round-trip, because the renderer quotes them — so the fix (`member_expr`) is deliberately narrow, and covers dimensions and facts as well as metrics since all three share the shape. The rest of the member payload was probed in the same batch and left alone: comments containing quotes / newlines / nothing, synonyms containing quotes / parens / nothing, and `output_type` empty, spacey or malformed all round-trip.
  - **Round three — an oracle bug, not a product one.** A local 600s run (cargo-fuzz installed so the target could be reproduced the way CI runs it, instead of discovering each finding a push at a time) failed at the *stage-2* assertion on a definition whose `pk_columns` contained `""`. The precondition already rejected that input — and rejected the parser-produced `d1` derived from it — but RT-5 had wired the precondition in **lazily**, consulting it only inside the stage-1 parse-failure branch. When stage 1 happened to parse, the target went on to assert against a chain rooted in a definition no entry point can store, and reported the result as render/parse drift. The precondition now runs UP FRONT in both the fuzz target and its `render_ddl.rs` mirror, which is what its own doc comment always claimed it did. This is not a restored escape: it applies the same stated rule consistently, where before it applied it in one branch out of two.
  - Diagnosing that one turned up a genuine DDL-side parser defect, unrelated to YAML and out of scope here — recorded as **#59**.
  - All crash artifacts are committed under `fuzz/seeds/fuzz_render_roundtrip/`, byte-identical to CI's uploads.
  - The third: `identifier_slot_error` returns `None` for an all-whitespace slot by design — its DDL call sites report emptiness themselves, from clause context the validator does not have — so a relationship named `"  "` passed and rendered as `      AS o(cid) REFERENCES c`. The emptiness check now lives in `slot_common`, which is that call site. Separately, `TableRef.table` was unvalidated; it is emitted through `emit_table`, which quotes anything that would not re-parse verbatim (a space, a depth-0 comma, a `--` run — each probed and each round-trips), so the one value it cannot protect is the empty string, and that is deliberately the only thing checked there rather than a full slot predicate.
- **Residual:** the generator still pins table aliases to `t{i}` and join FK/ref columns to bare ASCII (deliberate — the harness's hostile-identifier surface lives in member names and payloads), and `arb_payload` contains no newline, so multi-line comments never round-trip. Recorded here rather than left implicit.
- **Residual (strictness, not correctness):** `validate_ddl_representable` is in a few places stricter than the DDL parser on values that *do* round-trip — a materialization TABLE of `a, b`, for instance, is accepted by the MATERIALIZATIONS sub-clause parser (which reads the slot as free text) and refused here by the shared identifier-slot rule. Those are nonsense table names that would fail at query time regardless, so the stricter rule is the intended one; it is recorded because it means YAML import and DDL do not accept exactly the same language, only the same *round-trippable* one.

### 59. ❌ The METRICS parser accepts an entry with NO NAME when `USING (…)` precedes the `AS` — OPEN

- **Origin:** found 2026-08-07 while diagnosing a `fuzz_render_roundtrip` failure on PR #209. Not part of the 2026-08-06 review; a pre-existing DDL-side bug that surfaced because RT-5 tightened the oracle.
- **What it is:** the metric-entry parser has a "Missing metric name before 'AS'" check, and it works — `METRICS ( AS x )` is rejected. But put a `USING (…)` clause in front and the same nameless entry is **accepted**:

  ```
  METRICS (
       USING () AS  COMMENT = ''
  )
  ```

  parses to `Metric { name: "", expr: "", … }`. Confirmed by running that exact body through `parse_keyword_body`; the two spellings differ only in the `USING ()`, and only one is refused. So the name check is not reached on the `USING`-bearing path.
- **Why it matters:** the stored definition is unrepresentable. `GET_DDL` renders it back as `     AS  COMMENT = ''`, which this project's own parser then refuses with "Missing metric name before 'AS'" — the same GET_DDL-emits-unreplayable-DDL class as RT-5/RT-6, but reached through DDL rather than YAML, so the YAML-side `validate_ddl_representable` cannot prevent it. An empty `expr` on the same path is the second half of it.
- **Why it is not fixed here:** PR #209's scope is the YAML → `GET_DDL` contract (RT-5/RT-6). This is a DDL-side parser defect in a different module, with its own test surface, and folding it in would mean a parser change riding along in a validation PR. Recorded rather than silently carried.
- **What would finish it:** move the empty-name check in `body_parser/metrics.rs` so it runs on every path into a metric entry rather than only the no-`USING` one, and pair it with the same check for an empty expression; then extend `validate_ddl_representable`'s member rules with a `.test` case going through real DDL, since the YAML side already covers the equivalent shapes.
- **Not currently caught by fuzzing:** no target reaches it. `fuzz_render_roundtrip` starts from a `SemanticViewDefinition` and only ever *renders* — the defect is on the way IN, in what the DDL parser accepts, so the empty-name definition it would need is one the target's stage 0 discards rather than constructs (see **#60**). Catching it needs a target that fuzzes DDL **text** through `parse_keyword_body` and asserts that whatever parses can be re-rendered and re-parsed; that is the same missing target #60 describes, and it would cover both. The reproducing body is quoted above so this does not depend on the fuzzer to be rediscovered.

### 60. ❌ `fuzz_render_roundtrip` does not assert "storable ⇒ renders to parseable DDL"; the missing target is a DDL-text one — OPEN

> **Correction 2026-08-08.** Two statements below are stale. (1) The claim that the YAML-storable ⇒ parseable-DDL implication "is still *enforced* — `validate_ddl_representable` … still runs at the YAML choke point" was **false** for all six MODEL-1 shapes plus RT-7/RT-8/RT-9; it is true again as of the 2026-08-08 change, for those shapes. (2) The rationale that stage 0's precondition could not be completed because the eighth rule "was a **semantic cross-reference**" no longer holds: those cross-reference rules now live in `body_parser/cross_refs.rs` and are called from **both** `parse_keyword_body` and `validate_ddl_representable`, so the precondition is a shared implementation rather than the "second source of truth free to drift" this entry rejected — and is strictly stronger than when the entry was written. Re-checking whether stage 0's escape can now be removed (and `render_ddl::tests::assert_render_fixpoint`'s matching comment) is the natural next step for this entry.

- **Origin:** PR #209, 2026-08-07. Recorded per the CLAUDE.md rule that a deliberately-degraded interim state gets an entry in the same change rather than only a comment in the code that pins it.
- **What was tried:** RT-5 removed stage 0's escape and gated it on `model::validate_ddl_representable`, so the target would assert that any definition YAML can store renders to DDL the parser accepts. Making that assertion true means the validator must know every rule the grammar enforces. Eight rounds of local fuzzing produced eight of them — blank member expression; `pk_columns` containing `""`; member names and `alias.` qualifiers emitted raw by the renderer (a metric named `bli]ound` desynchronises the entry parser's bracket depth); `USING` / `NON ADDITIVE BY` / `OVER` on a derived metric; and free-form expression well-formedness. All eight are real, are fixed, and are enforced at the YAML entry point. Round eight was different in kind:

  ```
  Window metric '"c "': inner metric 'iu' not found in semantic view metrics
  ```

  a **semantic cross-reference** rule, with `USING` relationship names, `NON ADDITIVE BY` dimensions and materialization targets behind it. Completing the precondition means re-implementing the parser's whole validation surface inside `model.rs`, as a second source of truth free to drift from the real one — a worse defect than the one being closed.
- **What the target asserts now:** the invariant its header always described and that is unconditionally true — render is a **fixpoint on a parser-produced definition**. Stage 0 renders the arbitrary def and parses it back purely to LAND in the parser's image; a failure there is a skip, because an arbitrary `SemanticViewDefinition` is not in that image and never was. From `d1` onward there is no escape. The `render_ddl.rs` mirror test matches, and both carry the reasoning inline.
- **What is given up, explicitly:** the implication "YAML-storable ⇒ renders to parseable DDL" is no longer machine-checked over random definitions. It is still *enforced* — `validate_ddl_representable` is unchanged and still runs at the YAML choke point — and still tested, by the unit tests in `src/model.rs` and `test/sql/cr20260806_yaml_ddl_contract.test`. What is lost is randomized search for the next rule the validator does not yet know.
- **What would finish it:** a target that fuzzes DDL **text**, not a struct — feed arbitrary bytes to `parse_keyword_body`, skip on parse failure (arbitrary bytes legitimately are not DDL), and assert that anything that DOES parse re-renders and re-parses to a fixpoint. That needs no precondition, since everything it asserts on came from the parser, and it searches the direction this target structurally cannot: what the parser *accepts*. It would subsume this entry and **#59**.

### 72. ❌ A disconnected base table is accepted at CREATE and emits unbindable SQL — OPEN

- **Origin:** found 2026-08-08 while closing TC-12(d) — the substring-only assertions in `src/expand/tests_fact_query.rs` were hiding it. Every definition in that file declared a fourth table joined to nothing; because it was declared first it became the graph root, and the tests asserted on SQL text rather than running it, so nobody noticed the SQL could not bind. A vacuous assertion concealing a live defect is exactly the TC-12 pattern, and this is what strengthening it to a numeric assertion found.
- **Symptom:** if the first table in `TABLES` has no relationship edge, `validate_graph` accepts the definition — `check_no_orphans` (`src/graph/relationship.rs:159-181`) exempts the root node, and "root" is just *first in the TABLES clause* — but every query emits `FROM "<root>" AS "<root>" LEFT JOIN … ON … = "<other>"."<col>"`, a forward reference to an alias joined on a later line. DuckDB rejects it at bind time: `Binder Error: Referenced table "o" not found`. Reproduced both halves: `plan_rewrite` returns `Ok(Some(_))` for such a CREATE, and the expanded SQL fails to bind when executed.
- **Why it survived:** the root exemption is correct for the normal case — a base table has no outgoing foreign key, only incoming ones — it simply does not distinguish "root of a connected tree" from "isolated node that happens to be listed first". `check_source_tables_reachable` only tests set membership, not reachability.
- **Loud, not silent:** the failure is a binder error at query time, not a wrong number. The defect is that it is accepted at CREATE, where it could be rejected with a message naming the unreachable alias.
- **What would finish it:** after the orphan check, assert the graph is connected — every declared alias reachable from the root by undirected traversal — and reject naming the alias. Guard with a unit test in `src/graph/relationship.rs` plus a `.test` case; an executable oracle proving the emitted SQL binds would cover the class rather than the instance.

### 71. ❌ HIGH — a metric's VALUE changes depending on which other metrics are queried with it (MM-3) — OPEN

- **Origin:** found 2026-08-08 by `tests/metamorphic_proptest.rs`, the first harness to check "a metric queried alone equals the same metric queried alongside others" (`_notes/proactive-defect-discovery.md` §2.1(a)). Not reachable by any oracled harness, because each of those fixes a request shape and compares against a hand-written expectation for *that* shape.
- **Symptom, on legal data:** a single-grain request is base-anchored (`FROM o LEFT JOIN li`); adding a metric of another grain routes the request through `per_grain`, which anchors each grain's CTE at its own table (`FROM li`, no join). The two disagree in two independent ways:
  - a child row whose **foreign key is NULL** — legal data, not a broken FK — is dropped by the base-anchored join and counted by the child-anchored CTE. Observed: `SUM(li.amount)` = **12** queried alone, **112** queried alongside `SUM(o.rate)`.
  - a base row with **no children** contributes `COUNT(*) = 0` to the base-anchored query but *no row* to the child-anchored CTE, so the FULL OUTER JOIN reports `NULL`. Observed: `n_li` = 0 alone, NULL co-queried.
- **Why it is not covered by #35/#36:** per-grain anchoring is deliberate and those entries are RESOLVED, but what they promise is that *single-grain* SQL is byte-identical to v0.11.0. Neither says that adding a second metric may change the first metric's number. That is the property a user is most likely to assume without checking, and it is the one the two anchorings break.
- **What would finish it:** decide which anchoring is authoritative for a child-grain metric and make both paths agree. The child-anchored answer (count every child row, including orphans) and the base-anchored answer (count only children reachable from a base row) are both defensible; Snowflake's grain rules should settle it, and whichever loses needs its path changed rather than documented. Until then a query's metric set silently selects the semantics.
- **Coverage:** `mm3_metric_value_changes_when_co_queried_across_grains` in `tests/metamorphic_proptest.rs`, `#[ignore]`d and stating the CORRECT invariant, so it goes green when this is fixed. Both symptoms are pinned separately. This entry is also why properties (a) and (b) in that harness are scoped to single-grain metric sets — unscoped they red on this defect constantly and mask everything else.

### 70. ❌ A quoted relationship name on a role-played edge emits SQL that does not parse (MM-2) — OPEN

- **Origin:** found 2026-08-08 by `tests/metamorphic_proptest.rs` (hostile-identifier axis × role-playing).
- **Symptom:** the scoped join alias is built by raw string concatenation, so the join site quotes the whole alias while the reference site does not: `SELECT ap__"de p".city … FROM "o" LEFT JOIN "ap" AS "ap__""de p"""` → `Parser Error: syntax error at or near "."`. Loud, not silent.
- **Why it survived RT-5:** `tests/common/mod.rs` has generated relationship names from `arb_stored_ident()` since RT-5 — but only on the render/parse round-trip, never on a path that *emits a scoped alias*. The name reached a renderer and never reached the query generator.
- **What would finish it:** build the scoped alias from the normalized identifier and quote it once at emission (the alias is an internal construct, so it can simply be sanitized), rather than concatenating user text into an alias that two sites then quote differently.
- **Coverage:** `mm2_role_playing_with_a_quoted_relationship_name`, `#[ignore]`d. Note the harness deliberately draws its role-playing pair from bare/upper styles only so this defect does not mask the rest; every other name varies across all five identifier styles.

### 69. ❌ A metric referencing a FACT on a role-played table emits SQL that does not bind (MM-1) — OPEN

- **Origin:** found 2026-08-08 by `tests/metamorphic_proptest.rs` (role-playing × fact inlining).
- **Symptom:** fact inlining splices the fact's raw expression in *after* role-playing has renamed the table to its scoped alias, so the inlined text names the original alias: `SUM((ap.alt))` against `FROM "o" LEFT JOIN "ap" AS "ap__r_dep"` → `Binder Error: Referenced table "ap" not found! Candidate tables: "ap__r_dep"`. Loud, not silent.
- **The sibling that works:** a *dimension* on the role-played table is fine, because its expression IS rewritten. Only inlined fact text is missed, so the two member kinds disagree on the same topology.
- **What would finish it:** run `expr_tokens::rewrite_qualifier` over the inlined fact expression at each inlining site, as the dimension path already does — the ordering rule established by #54 (inline first, then rewrite) applied to the sites #53 added.
- **Coverage:** `mm1_role_playing_metric_over_a_fact_on_the_role_played_table`, `#[ignore]`d. Related: #53 (PAR-6, which added the join), #52/#54.

### 68. ❌ `output_type` is refused at YAML import because no DDL clause can carry it (RT-7) — OPEN

- **Origin:** code review 2026-08-08 (`_notes/code-review-2026-08-08.md`, RT-7). Filed MEDIUM.
- **Was:** `output_type` (dimension / fact / metric) was settable only from YAML, and `render_ddl` has no emit site for it. `GET_DDL` — the documented dump/restore path — dropped it silently: the replayed view lost the `CAST(expr AS <type>)` at every projection site (`expand/sql_gen.rs:277,287,706,720`, `per_grain.rs:1002,1139,1150,1250`, `window.rs:185,338`, `semi_additive.rs:370,482`, `materialization.rs:124,133`) and the `data_type` every SHOW/DESCRIBE surface reports from it, with no error anywhere. So a definition was "YAML-storable" and not DDL-representable, contradicting #58's contract statement.
- **Resolution taken, and why it is a rejection rather than a fix:** `model::reject_output_type` refuses the field at the YAML choke point and names the equivalent `CAST(<expr> AS T)` in the message. The alternative — rendering the cast into the emitted expression — was **measured** and rejected: `expand::facts::inline_facts` / `inline_derived_metrics` splice a member's RAW `expr` into every referrer and never apply the cast, so moving the cast into `expr` would silently change what referring members compute (pinned by `tests/yaml_proptest.rs::output_type_is_not_applied_when_a_member_is_inlined`, which shows `SUM((o.qty))` versus `SUM((CAST(o.qty AS DOUBLE)))`), and the replayed definition would *still* carry `output_type: None`, leaving the contract false.
- **Why rejecting a documented, released field was judged acceptable:** the field's only semantic effect is reproducible by writing the cast into the expression, which round-trips correctly — so no capability is lost, only a spelling whose restore path was broken. What *is* lost is the sole route to a populated `data_type` column; see the scope note on **#51**, whose read-side probe is now the only fix for that.
- **What would finish it:** DDL grammar + renderer support for a declared member type. There is no Snowflake precedent — Snowflake's semantic-view DDL declares no member types — so this would be a deliberate divergence decision, not a parity gap. Read-side probing (#51) is the alternative that needs no new syntax.
- **Known consequences, both loud where the old behaviour was silent:** (a) a `CREATE … FROM YAML` carrying `output_type` now fails with a message naming the field, the member and the `CAST` spelling; (b) `READ_YAML` on a *legacy* stored view that carries `output_type` exports YAML that can no longer be re-imported. Docs corrected in the same change (`docs/reference/yaml-format.rst`, `show-semantic-dimensions.rst`, `show-semantic-facts.rst`, `docs/explanation/snowflake-comparison.rst`).

### 67. ❌ Three residuals of the generalized phantom-row guard (EXP-25/26/28/29) — OPEN

- **Origin:** code review 2026-08-08 (`_notes/code-review-2026-08-08.md`, EXP-25/26/28/29), opened by the fix for those findings. Supersedes the residual half of #56.
- **What the fix does:** every synthesized join is a LEFT JOIN anchored at the base table, so a base row with no matching child survives as one NULL-extended "phantom" row. Aggregates now fence it by wrapping every aggregate argument in `CASE WHEN <pk> IS NOT NULL THEN … END` (`facts::guard_aggregate_args`), and row-level queries — a FACTS request and a dimensions-only `SELECT DISTINCT` — filter it with `WHERE <pk> IS NOT NULL` when every queried member lives on one table strictly below the base (`sql_gen::phantom_row_filter`).
- **What is therefore missing, (1) NULL-retaining aggregates.** The guard works by making the phantom's argument NULL, and every standard aggregate skips a NULL argument — except `list` / `array_agg`, which keep it as an element, and `first` / `last` / `arbitrary`, which can return it. `LIST(1)` on a childless parent now yields `[NULL]` where it yielded `[1]`: better, not right. Finishing it means emitting `AGG(x) FILTER (WHERE <pk> IS NOT NULL)` for that family (verified: `list(x) FILTER (WHERE FALSE)` is NULL, not `[NULL]`), which requires merging with a user-written `FILTER` clause and checking the `FILTER … OVER` composition on the window path.
- **What is therefore missing, (2) no PRIMARY KEY.** With no PK on the joined table there is no column that distinguishes a phantom, so the guard cannot run. Today that is a loud `CountStarRequiresPrimaryKey` for `COUNT(*)` and for an aggregate over a recognized constant LITERAL, and silence for everything else — `COUNT(1+0)` and `SUM(COALESCE(li.qty, 99))` on a PK-less child are still wrong. Deliberately not widened to "any aggregate on a PK-less joined table", which would newly reject `SUM(li.amount)` views that are correct today. Finishing it means guarding with the relationship's own key columns instead: a matched row necessarily has non-NULL join columns, so `<alias>.<fk-or-ref column> IS NOT NULL` is an exact substitute that needs no PK declaration.
- **What is therefore missing, (4) the AGGREGATE path still manufactures a NULL group under a child dimension (MM-4, found 2026-08-08 by `tests/metamorphic_proptest.rs`).** EXP-29 fenced the row-level shapes with `WHERE <pk> IS NOT NULL`; the aggregate path with the same member set guards each aggregate's *argument* instead, which fixes the value but not the **group key**. Grouping by a child dimension therefore still gains a phantom group: `SELECT li.qty, COUNT(li.id) … GROUP BY 1` gains `(NULL, 0)` when a childless parent is inserted, and `(NULL, NULL)` for `SUM`/`MAX`. Finishing it means applying the same `phantom_row_filter` predicate when every queried *dimension* resolves to one table strictly below the base, which is the aggregate twin of the EXP-29 fix. Pinned by `mm4_childless_parent_manufactures_a_null_group_under_a_child_dimension` (`#[ignore]`d, stating the correct invariant).
- **What is therefore missing, (3) row-level queries whose members span several tables.** `phantom_row_filter` applies only when the queried facts (or, for the dims-only branch, the queried dimensions) all live on ONE table below the base. Facts on two different child tables have no unambiguous grain — the emitted SQL is their cross product through the base — so that cell keeps the old behaviour rather than erroring, because the shape is reachable and its non-phantom rows are meaningful. A dims-only DISTINCT mixing a base and a child dimension is left alone deliberately and is not part of this gap: the base row is a legitimate member of that result even with no child.
- **Cosmetic, deliberately accepted:** the guard is applied once, at the shared base-metric resolution step, so it is also emitted on the per-grain / snapshot / window paths that anchor at the metric's own table and have no phantom to fence. It is a no-op there (the PK is non-NULL in a plain scan) but it makes the SQL noisier and adds a dependency on PK non-nullness where the query had none. Suppressing it needs the emission strategy to be known at resolution time, which today it is not.
- **Coverage:** `src/expand/tests_phantom_row_guard.rs` pins the resolved halves numerically, including both directions (a member ABOVE the base must NOT be filtered) and the no-PK error; `tests/child_grain_proptest.rs` randomizes them.

### 61. ❌ Schema identity in the catalog is byte-equal for writes and case-folded for reads (CAT-5/CAT-6) — OPEN

- **Origin:** code review 2026-08-06 (`_notes/code-review-2026-08-06.md`, CAT-5/CAT-6); re-confirmed unfixed by the 2026-08-08 review (`_notes/code-review-2026-08-08.md` §8). Recorded here because the 2026-08-06 review asked for ledger entries "in the next change that touches the area" and #203–#209 have since touched adjacent areas without opening one.
- **CAT-5 — what is wrong:** the conflict key that makes `CREATE OR REPLACE` replace rather than duplicate is the byte-equal primary key `(schema_name, name)` (`src/parse/native_sql.rs:345,454`), while every read predicate and existence guard folds case (`src/catalog/writes.rs:166`, `src/catalog/mod.rs:692`). Drop a schema and recreate it under a different spelling (`main` → `MAIN`) and `INSERT OR REPLACE` no longer conflicts, so a second row is inserted for what reads consider the same view; qualified lookups then take `rows[0]` (`mod.rs:926-928`) and can resolve the stale definition. DuckDB's case-insensitive identifier convention (see CLAUDE.md) makes the folding side correct, so the PK is the side that is wrong.
- **CAT-6 — what is wrong:** `DROP SCHEMA … CASCADE` is never intercepted, so `_definitions` rows for views in that schema survive the schema itself. They are unreachable but not gone, and a later recreate of the schema resurrects them.
- **What would finish it:** store a folded (normalized) schema-name column as the PK component while keeping the user's spelling in a display column — the same split the catalog already uses for view names — so writes and reads agree on identity by construction; and either intercept `DROP SCHEMA` or make reads join against `duckdb_schemas()` so orphans cannot resolve.
- **Coverage:** none. Both shapes are reachable from ordinary SQL and neither has a test; a fix must add them test-first.

### 62. ❌ Query-path robustness: bind-time recursion and existence-check ordering (QRY-1/QRY-2) — OPEN

- **Origin:** code review 2026-08-06 (QRY-1/QRY-2), re-confirmed unfixed 2026-08-08.
- **QRY-1:** the bind-time `LIMIT 0` probe (`src/query/table_function.rs:243-256`) runs on a fresh connection per level (`cpp/src/shim.cpp:2759,2834`), so a SQL view that has been replaced to select from a semantic view that reads it back recurses at bind time with no depth counter. Constructible, and the failure mode is stack exhaustion rather than a diagnostic. Finishing it means threading a depth/visited-set through the bind context, or refusing to re-enter the bind path re-entrantly.
- **QRY-2:** `EmptyRequest` is checked before the view is looked up (`table_function.rs:170-172`, `explain.rs:167-171`), so `semantic_view('does_not_exist')` reports the empty-request error rather than the missing view — the less useful of the two diagnostics. Finishing it is a reordering plus a test.
- **Coverage:** none for either.

### 63. ❌ FFI/wire defence-in-depth residues (FF-12/FF-13/FF-14) — OPEN

- **Origin:** code review 2026-08-06 (FF-12/13/14), re-confirmed unfixed 2026-08-08. None is reachable from ordinary SQL — they are hardening items, recorded so they are not rediscovered as "new" a fourth time.
- **FF-12:** the C++ wire readers `reserve()` a count read from the buffer before validating it (`cpp/src/shim.cpp:1251,1334,2785`). The Rust encoders never emit a bad count, so this only matters against a corrupted or hostile payload, but the Rust side clamps and the C++ side does not — an asymmetry worth closing.
- **FF-13:** `sv_serialise_string_list` hardcodes the `explain_semantic_view:` error prefix (`shim.cpp:2324-2326`) although it is shared by `semantic_view` and, via `sv_search_path_payload` (`:1436-1442`), every named-parameter table function — so a NULL list element in any of them is attributed to `explain_semantic_view`.
- **FF-14:** `read_column_string` truncates at an interior NUL (`src/catalog/mod.rs:656-668`), so a hand-tampered catalog row silently loses everything after it rather than being rejected.
- **What would finish it:** clamp reserves against remaining buffer length in C++ as Rust already does; pass the caller's function name into the serializer; and reject (not truncate) interior NULs when decoding a stored definition.

### 64. ❌ Identifier-matching residues outside `ident_matches`, and the reference scanner's blind spots (IDENT-1..5, PARSE-9) — OPEN

- **Origin:** code review 2026-08-06 (IDENT-1..5, PARSE-9), re-confirmed unfixed 2026-08-08. Same family as #25/#28: sites that compare or key identifiers without the project's case-insensitive rule, plus scanner positions that mis-classify text as a reference.
- **IDENT-1:** `alias_to_table_map` is exact-string keyed (`src/model.rs:492-497`) while alias matching is case-insensitive everywhere else, so a qualifier `o` misses an alias declared `O`. Re-executed 2026-08-08: still misses.
- **IDENT-2:** `expr_tokens::scan_chain` splits a qualified reference at a spaced (or comment-blanked) dot — `o . region` scans as two chains. Currently *pinned as desired* by `dotted_chain_is_not_split_by_whitespace`, so changing it means changing that test deliberately.
- **IDENT-3:** the scanner has no `::` / `CAST(… AS …)` / `EXTRACT(… FROM …)` context awareness, so a member named `date` or `year` corrupts sibling expressions when inlined. (PARSE-12, fixed 2026-08-08, is the distinct *literal-prefix* sibling of this defect, not this one.)
- **IDENT-4:** a quoted all-digit name (`"0"`) collides with numeric literals, which the scanner assumes "can never be a declared name" (`expr_tokens.rs:541`).
- **IDENT-5:** `normalize_ident_part` re-joins parts with `.` (`ident.rs:407-417`), so a quoted-dot single identifier and a two-part qualified reference produce the same match key.
- **PARSE-9:** name slots accept identifier garbage DuckDB would reject — `RENAME TO x,y` stores `x,y`.
- **What would finish it:** route IDENT-1 through `ident_matches` (mechanical); give the scanner position-awareness for cast/EXTRACT slots (IDENT-3) and a quoted-name-aware literal rule (IDENT-4); make `normalize_ident_part` produce a structured key rather than a re-joined string (IDENT-5); tighten the name-slot grammar (PARSE-9). IDENT-2 needs a decision before a fix.

### 73. ❌ A typed literal separated from its introducer by whitespace still scans as a reference (PARSE-12 residual) — OPEN

- **Origin:** PARSE-12's fix (code review 2026-08-08), narrowed during review of that change. Recorded per the rule that a knowingly-partial behaviour gets an entry in the same change.
- **What the fix does:** `expr_tokens::scan_chains` no longer emits an identifier chain as a *reference* when the chain is immediately followed by `'` — the adjacency rule `util::opens_escape_string` already uses. That covers `e'…'`, `E'…'` and the adjacent typed-literal form `DATE'2020-01-01'`, so a member named `e` or `date` can no longer corrupt those expressions.
- **What is therefore missing:** DuckDB also accepts a typed literal with whitespace between the type name and the string — `DATE '2020-01-01'`, `INTERVAL '1 day'`, `TIMESTAMP '…'` — and the rule is adjacency-only, so the introducer is still scanned as a reference there. A view declaring a member named `date` or `interval` therefore still risks the inline corruption PARSE-12 closed for the adjacent spelling.
- **Why it shipped narrow:** suppressing *every* identifier followed by whitespace and a string literal over-reaches badly — `SELECT foo 'bar'` is an alias, and ordinary expressions put identifiers before quoted text routinely. Doing it correctly needs a type-name registry so only real type introducers are suppressed, which is a larger change than the defect being fixed and carries its own false-positive risk.
- **What would finish it:** a small set of recognized type-name introducers (the SQL standard's plus DuckDB's aliases) consulted when a chain is followed by optional ASCII whitespace and `'`. Guard with a pinned unit test for the spaced form, alongside PARSE-12's adjacent-form tests.

### 65. ❌ `USING` role context is not threaded into grain CTEs for the ambiguous-dimension case — OPEN

- **Origin:** promoted 2026-08-08 from the "Still declined, deliberately" bullets of **resolved** entry #36, per the CLAUDE.md rule that a degraded behaviour's record must not live only in a test comment — and must not live only inside an entry marked ✅, which reads as finished. `test/sql/per_grain_role_playing.test` (Test 3) pins the degraded outcome with the comment "this still errors until USING context is threaded into the grain CTEs"; that comment should reference this entry.
- **What is degraded:** a dimension on a role-played table reachable by two relationships, with no co-queried metric carrying `USING` to disambiguate, errors with `fan trap detected` rather than being answered. Erroring is the correct conservative call — a grain CTE would otherwise pick an edge by declaration order, which is the silent mis-binding the fence exists to prevent — but it is a capability gap, not a neutral choice, and Snowflake answers these.
- **Also still declined for the same reason:** a `where_clause` member on a role-played table, a metric whose own grain table is one, and any table reachable only *through* one; the window path stays strict because `__sv_agg`'s SELECT list gets no scoped-alias rewrite.
- **What would finish it:** the machinery exists — `role_playing::find_using_context`, `expr_tokens::rewrite_qualifier`, and `ResolvedJoin`'s `scoped` / `emit_alias` fields, which `anchor_joins` currently hardcodes to the bare alias. What is missing is a way to name the intended role when no metric supplies `USING`, i.e. per-dimension role context in the request or the definition.

### 66. ❌ The randomized-coverage axis ledger is not maintained, and three axes with wrong-number history are unrandomized (PBT-8/10/11/12, TC-12/13) — OPEN

- **Origin:** code-review test audits 2026-08-06 (PBT-8..12, TC-12/13) and 2026-08-08 (PBT-13/14/15, TC-14/15). Recorded here because the "Test Coverage Gaps" section above lists only four pre-v0.5 items and none of the live gaps — the review's TC-15: the record of what is actually uncovered lives in `_notes/` review documents, which is the "record sits where nobody re-reads" shape this file exists to prevent.
- **The axes with no randomized numeric coverage, ranked by demonstrated risk:**
  - **PBT-10 — role-playing. PARTIALLY CLOSED 2026-08-08.** The blocker was never neglect: an independent oracle for role-playing is genuinely hard to formulate, so three review rounds deferred it. `tests/metamorphic_proptest.rs` removes the blocker by needing no oracle (`_notes/proactive-defect-discovery.md` §2.1) — it declares two named relationships from one source to one target, generates metrics carrying `USING`, and groups by the role-played dimension in 47/256 sampled cases, where the scoped join alias decides the numbers. It found **#69 and #70 immediately**, in the first cell it reached. Still open: a `where_clause` member on a role-played table (refused by design, #65), and role-playing under the semi-additive and window paths.
  - **PBT-8 — row-level FACTS queries.** `QueryRequest.facts` was pinned at `vec![]` in every harness; EXP-28/EXP-29 (2026-08-08) landed in exactly that cell.
  - **PBT-11 — pinned generator fields and fixed topologies:** window `order_by`/`frame_clause`/`extra_args` and a self-referential inner metric; semi-additive `nulls`/single-NA-dim/single-table; `ManyToOne` and single-column keys everywhere; sibling-fan / fan-in / diamond topologies fixed-example-only. (The window-over-dependent numeric cell was closed by PBT-14, 2026-08-08.)
  - **PBT-12 — hostile identifiers × numeric: CLOSED 2026-08-08 for member and physical-table names**, which vary across bare / upper / quoted-with-space / embedded-quote / non-ASCII in `metamorphic_proptest`, in *reference* positions (derived-metric expressions 196/256, `where_clause` 86/256) as well as declarations. Still open: hostile **relationship** names on a role-played edge (blocked by #70), hostile table **aliases** (an alias is spliced into user-written expressions verbatim, so quoting it is the user's responsibility), and hostile **column** names.
- **The vacuity residues (TC-12):** assertion-free match arms in `tests/parse_proptest.rs:831-848` under a comment claiming an invariant; two empty-expectation `statement error` blocks in `test/sql/quick_260430_vdz_leading_comments.test:74-83`; a stale "not yet written" comment in `tests/expand_proptest.rs:326-331`; `src/expand/tests_fact_query.rs:33-44` asserting on SQL substrings rather than numbers.
- **What would finish it:** keep the pinned-field matrix (reproduced per review round in `_notes/code-review-2026-08-*.md` §6/§7) as a checked-in artifact verified by a meta-test, so adding a field to `QueryRequest` without deciding its generator story fails CI; require a `// PIN: <reason>, TECH-DEBT #<n>` comment on any inert generator default. See `_notes/proactive-defect-discovery.md` §2.5.

### 57. ❌ An unqualified `where_clause` member excludes a query from every re-anchoring path (EXP-22) — OPEN

- **Origin:** code review 2026-08-06 (`_notes/code-review-2026-08-06.md`, EXP-22), opened by the fix for that finding.
- **What the fix does:** the three re-anchoring deciders (`per_grain::is_eligible`, `snapshot_cte_anchor`, `window_cte_anchor`) now decline any query whose `where_clause` names a member with no `source_table`, exactly as they already decline a queried *dimension* with no `source_table`. Such a member is an unqualified expression: it is spliced into every CTE, so a re-anchored `FROM` rebinds it — silently, whenever the anchor table carries a column of that name, and to two *different* tables at once in a multi-grain plan.
- **What is therefore missing:** a query that combines an unqualified predicate member with a multi-grain or re-anchored shape now keeps the base-anchored path's outcome, which for genuinely multi-grain shapes is a fan-trap error rather than an answer. That is the correct conservative call — the dimension rule has worked this way since per-grain landed — but it is a capability gap, not a neutral choice. Finishing it means resolving an unqualified member against the base table explicitly (qualifying its expression at resolution time) so its binding stops moving with the anchor; then both the dimension rule and this one could be lifted together.
- **Residual in the same class, deliberately not fenced:** a predicate may also name something that resolves to **no** declared member — a raw column, which `resolve_where_clause` passes through verbatim by design so DuckDB validates it. A raw column binds against whatever the emitted `FROM` exposes and so has the same anchor-sensitivity. It is not fenced because the reference scan cannot distinguish a raw column from ordinary SQL that must survive (`NULL`, `TRUE`, `CURRENT_DATE`, `DATE '1995-01-01'`), so declining on any unresolved chain would decline nearly every predicate. Users who need a raw column in a predicate on a re-anchored query should qualify it.
- **Coverage:** three `tests_per_grain` tests, one per decider, each confirmed red individually, plus two controls pinning that a *qualified* member still re-anchors. `cr20260806_tier1_wrong_numbers.test` pins the end-to-end behaviour, including that the previously-silent case is now a loud ambiguity error.

### 56. ✅ The constant-argument aggregate guard was a whitelist and leaked four ways (EXP-21 residual) — RESOLVED 2026-08-08

- **Origin:** code review 2026-08-06 (`_notes/code-review-2026-08-06.md`, EXP-21), opened by the fix for that finding; closed by code review 2026-08-08 (EXP-25/EXP-26).
- **What the narrow fix did:** `facts::guard_constant_arg_aggregates` rewrote `<AGG>(<constant literal>)` to `<AGG>(CASE WHEN <pk> IS NOT NULL THEN <constant> END)` for five aggregate names (`COUNT`, `SUM`, `PRODUCT`, `LIST`, `ARRAY_AGG`) on a non-base source table.
- **Why the entry's own rationale was wrong.** It recorded two claims that do not hold. (1) `COUNT(DISTINCT <constant>)` "is genuinely multiplicity-invariant, so it needs nothing" — it is multiplicity-invariant and *existence*-sensitive, and the phantom row is not a duplicate but a row that should not exist: `COUNT(DISTINCT 1)` returned 1 for a childless parent where 0 is correct. (2) `MIN`/`MAX`/`AVG` were "outside the list because over a constant they are multiplicity-invariant" — true and irrelevant for the same reason: `MIN(1)` returned 1 where the empty-group answer is NULL. Two further escapes had no rationale at all: a constant *expression* is not a constant *literal* (`COUNT(1+0)` → 1), and any NULL-insensitive expression over the child's own columns resurrects the phantom (`SUM(COALESCE(li.qty, 99))` → 99), including a fact reference reaching another table (`SUM(<parent fact>)` → 25 vs 20). All numerically confirmed.
- **Resolution:** `facts::guard_aggregate_args` drops the per-argument classification entirely. On a non-base source table with a declared PRIMARY KEY, the FIRST argument of EVERY DuckDB aggregate is wrapped as `AGG([DISTINCT ]CASE WHEN <pk> IS NOT NULL THEN <arg> END)`. The wrap is neutral on real rows *whatever the argument is* — the PK is non-NULL there — which is exactly why it no longer needs to recognize the argument, and why the "the aggregate list is the load-bearing part" reasoning above no longer applies to arguments (it still applies to function NAMES, which are now matched as whole identifier words against DuckDB's aggregate catalog rather than as prefixes, so `sum_no_overflow(` is reachable at all). Later arguments, an in-call `ORDER BY`, and an outer `FILTER (WHERE …)` are copied verbatim; `COUNT(*)` is skipped so SG-8's star rewrite still owns it. Multi-argument aggregates — the gap this entry was originally opened for — are covered: every DuckDB aggregate drops a row whose first argument is NULL, so `STRING_AGG(CASE … END, ',')` is sufficient and the separator stays a constant of the call.
- **Coverage:** 28 tests in `src/expand/tests_phantom_row_guard.rs` (12 numeric reds confirmed individually), `test/sql/cr20260808_phantom_row_guard.test` end to end, and `tests/child_grain_proptest.rs` now generates all four escaped shapes against its correlated-subquery oracle (verified red with the fix reverted).
- **Residuals:** see #67.

### 55. ❌ A snapshot cannot be composed with an outer expression: a metric depending on a semi-additive metric errors (EXP-19/EXP-20) — OPEN

- **Origin:** code review 2026-08-06 (`_notes/code-review-2026-08-06.md`, EXP-19/EXP-20), opened by the fix for those findings.
- **What went wrong first:** `is_active_semi_additive`, the routing predicate that decides whether a query takes the `RANK`-CTE snapshot path, inspects only a metric's OWN `non_additive_by`. A metric that merely *depends* on a semi-additive metric — a derived metric referencing it, or a window metric naming it as its inner aggregate — therefore classified as regular, and the dependency's raw aggregate was inlined and evaluated over every row. `NON ADDITIVE BY` was silently discarded: with `balance` snapshotted at the latest `report_date` and `double_balance AS balance * 2`, querying the two returned 150/70 and 500/240 — not a 1:2 ratio, so wrong on its own terms, before any question of Snowflake parity. Verified end to end against DuckDB. Every emission path shared the defect (base-anchored inlining, `__sv_agg`, and per-grain's `is_snapshot_group`, which classifies by a group's own metrics).
- **What ships instead:** `ExpandError::SemiAdditiveThroughDependency`, raised before dispatch for any requested metric that transitively reaches an *active* semi-additive metric. The message names both metrics and the way out (query the semi-additive metric directly, or add its `NON ADDITIVE BY` dimensions to the query, which makes it effectively regular and legal). This mirrors the SG-5 co-query guard, which already errors for shapes the snapshot CTE cannot decompose.
- **What is therefore missing:** composing a snapshot with an outer expression. Snowflake computes the snapshot and then applies the outer expression to it, so `double_balance` is defined there and errors here. Finishing it means teaching the snapshot builder to expose a metric's post-snapshot value as a column that derived-metric inlining and the window path can consume, rather than substituting the raw aggregate — the same decomposition SG-5 performs for co-queried metrics, extended to dependencies.
- **Coverage:** four `tests_semi_additive_dependency` tests (derived, transitively derived, window inner, message content) plus three controls (the metric itself still snapshots, a dependent expands once the NA dimension is queried, a dependent over a regular base is untouched), each confirmed red individually. `semi_additive_proptest::a_dependent_metric_never_silently_drops_the_snapshot` randomizes the dichotomy — error when the NA dimension is unqueried, numerically-oracled expansion when it is — and was verified to fail with the fix reverted.

### 54. ✅ A dimension expression is never fact-inlined, so a fact reference inside one is emitted verbatim — RESOLVED 2026-08-05

- **Origin:** found 2026-08-05 while addressing Copilot's review of #200, which asked whether the per-grain path collected fact tables referenced from *dimension* expressions. It does not — and neither should it, because the reference is never substituted in the first place.
- **Symptom:** `inline_facts` is applied to metric expressions and to other facts' expressions (`facts.rs`, the two call sites in `resolve_metric_exprs`), never to a dimension's. A dimension declared `tier_label AS c.cust_tier`, where `cust_tier` is a fact on `c`, emits `c.cust_tier AS "tier_label"` verbatim — a reference to a column that does not exist — so DuckDB fails on the unknown column whether or not `c` is joined. The DDL is accepted.
- **Why the join collection is metric-only:** PAR-6 (#53) briefly collected the tables reached from dimension expressions too. That joins a relation in order to satisfy a reference that is never inlined: it converts "unknown table `c`" into "unknown column `c.cust_tier`" and nothing more, while making the fan-trap fence report a fan trap for a construct that cannot bind either way. The collection was narrowed to metrics before #200 merged.
- **Resolved by inlining, not by rejecting, and the evidence settled it.** The entry left both options open. Probing first showed that a **same-table** fact reference in a dimension was broken too — `band AS CASE WHEN o.net_line > 100 …` over a fact on the dimension's own table emitted `o.net_line` verbatim. Snowflake's validation rules permit exactly that ("expressions can refer to base table columns **or other expressions** on the same logical table"), so this was a parity defect in the plain case rather than an unsupported cross-table extension, and rejecting it would have written the bug into the spec.
- **Resolution:** `facts::inline_dimension_facts` applies the same pass metrics already got, and every emitter that renders a dimension expression routes through it — the base-anchored SELECT, the grain CTEs, the facts path, the semi-additive snapshot and its `ORDER BY`, the window CTE, and `where_clause` members. Inlining runs *before* role-playing alias rewriting at each site, so a fact's own qualifier is rewritten with the rest of the expression.
- **The cross-table half came with it.** With dimension expressions inlined, the join collection and fan-trap fence that #200 narrowed to metrics are meaningful again and were restored, including the per-grain `anchor_joins` site Copilot flagged while reviewing #200 — correct about where the code was heading, though unreachable until the inlining existed.
- **Coverage:** `star_schema_proptest` grows a `uwd` dimension whose expression is the fact `u.uw1`, mirrored into the oracle as `u.w + 1`; because it is a *grouping key*, a mis-splice changes which rows collapse together, and the harness's parent-grain oracle was generalised from one parent-side dimension to N. Five `td54_*` unit tests cover the same-table, facts-path, `where_clause`, cross-table and grain-CTE shapes, each confirmed red individually. `member_reference_scoping.test` pins the numbers end to end.

### 53. ✅ A metric referencing a named fact on another table inlines the fact but never joins its table (PAR-6) — RESOLVED 2026-08-05

- **Origin:** found 2026-08-05 while verifying PAR-3 against Snowflake's validation rules. Not in the 2026-08-03 review; the review recorded cross-table *named-fact* references as working ("Named facts referenced across tables are inlined, `src/expand/facts.rs:168-195`"), which is true of the inlining and false of the join.
- **Symptom:** `o.mixed_margin AS SUM(o.amount - c.cust_discount)` where `cust_discount` is a fact on `c` expands to `SELECT ... SUM(o.amount - (c.discount)) FROM "orders" AS "o"` — the fact expression is spliced in, `customers` is not joined, and DuckDB raises an unknown-alias binder error at query time. The DDL is accepted: `validate_facts` / `validate_derived_metrics` / `validate_graph` all pass, verified by running the CREATE funnel's validator set over a body-parsed definition.
- **Why the two halves disagree:** `fact_replacement_map` deliberately keys each fact by its own `source_table.name` so that "a fact referenced across tables in its own-qualified form is then actually inlined, not just detected". `join_resolver` never got the matching change: it collects aliases from each member's declared `source_table` and, on the facts path, from *queried* facts (`fact_source_tables`) — never from facts a metric merely references.
- **Why it matters:** this is Snowflake's supported way to cross tables ("define facts on source tables, and finally refer to these expressions from connected logical tables"), and it is the workaround #52 would otherwise point users at. The failure is loud — the alias is unbound, so no query returns a wrong number — but the shape is fully DDL-reachable.
- **Resolution:** `facts::collect_referenced_facts` walks a member's expression for the facts it reaches — transitively, since facts chain and a derived metric reaches them through the base metrics inlined into it — and `join_resolver` adds each reached `source_table` to `needed`, alongside the aliases it already collected from declared `source_table`s and from queried facts.
- **The per-grain path needed the same change, and the numeric oracle is what found it.** A grain CTE inlines the referenced fact into the aggregate it computes, so it must join the fact's table for itself; the base-anchored fix alone left `__sv_grain_0` emitting an unbindable alias. `anchor_joins` now takes those tables through the extra-sources channel it already uses for `where_clause` member tables. This surfaced only after `star_schema_proptest` was taught to generate the construct — no hand-written example in the change had reached a multi-grain topology.
- **The fence had to move with it.** Joining the fact's table converts a bind error into a returned number, so `fan_trap::check_referenced_fact_fan_traps` rejects a fact on a table that *fans* relative to its member's — the same condition already applied to a queried dimension and to a `where_clause` member, and the reason this was filed rather than fixed in the pass that found it. Without the fence a fact on a child table would have been silently joined into a base-anchored aggregate and inflated it, trading a loud failure for a silent wrong number.
- **Two sibling gaps in the same class, found in review of #200 and fixed with it.** A *derived* metric's own expression names metrics rather than facts, so the per-grain `group_fact_tables` — which scanned only the group's own metric expressions — missed facts reached through the base metrics inlined into it, reintroducing the defect inside the grain CTE (Copilot). Probing the neighbourhood then turned up the facts-path equivalent: a *queried* fact whose expression chains to a fact on a third table was inlined without that table being joined, since `fact_source_tables` carries only each queried fact's own declared table. Both now walk the chain.
- **Coverage:** `star_schema_proptest` generates `svf = sum(t.v - u.uw1)`, a child-grain metric reaching a fact on the parent, and mirrors it into the oracle as `sum(t.v - (u.w + 1))` — the fact expression is compound so a splice that loses its parentheses computes a different number. Seven `facts::tests::referenced_facts_*` unit tests cover the collector directly (own-qualified, bare, foreign-qualified, transitive, source-less, cyclic, same-table dedup). `par6_cross_table_fact_reference_pulls_its_join`, `par6_cross_table_fact_on_a_fanning_table_errors`, `par6_derived_metric_fact_reference_joins_inside_the_grain_cte` and `par6_queried_fact_chaining_to_another_table_pulls_its_join` cover the four shapes, and `par6_cross_table_fact_join.test` pins the numbers end to end through the extension, including the per-grain case.
- **Documented at:** `docs/how-to/facts.rst` and `docs/explanation/snowflake-comparison.rst` ("What an Expression May Reference"). #52 (raw cross-table *column* references) is the complement and is now resolved too: a named fact is the supported way to cross tables, a bare column is rejected at CREATE.

### 52. ✅ A raw cross-table column reference is accepted at CREATE and fails as a DuckDB binder error at query time (PAR-3) — RESOLVED 2026-08-05

- **Origin:** code review 2026-08-03 (`_notes/code-review-2026-08-03.md`, PAR-3). Filed MEDIUM as "cross-table column references in member expressions unsupported and undocumented".
- **The review's premise was half wrong, checked against current Snowflake docs.** PAR-3 assumed "Snowflake permits expressions to reference related logical tables". Snowflake's validation rules say the opposite for raw columns: "Expressions cannot refer to base table columns from other tables or expressions from unrelated logical tables." So the *rule* is at parity — a member expression is scoped to its own logical table in both systems. What diverges is enforcement: Snowflake rejects the definition at CREATE, this extension accepts it.
- **Symptom:** `o.margin AS o.amount - c.discount` expands to `SUM(o.amount - c.discount) FROM "orders" AS "o"` with no join for `c`, so DuckDB reports an unknown alias when the member is queried. Never a wrong number — the alias is qualified, so the query cannot bind — but the error names DuckDB's missing alias instead of the semantic-layer rule that was broken, and it arrives at query time rather than at CREATE.
- **Resolution:** `graph::validate_member_references`, last of the graph checks in the CREATE funnel (`ddl/define.rs`) because it needs the fact and metric name sets the earlier ones validated. For every fact, dimension and metric expression it walks the **qualified** reference chains and rejects one whose qualifier names a declared table that is neither the member's own nor the qualifier of a declared fact or metric of that name. The message names the rule and the supported alternative — define a FACT on the other table — rather than an alias.
- **Deliberately conservative in one direction:** a qualifier matching no declared table is left alone (a struct path root, a bound parameter, a plain typo). Rejecting those would turn every expression this layer cannot fully model into a CREATE failure; the check fires only where the *semantic* model can say the reference is out of scope. Function-call heads and string literals never reach it — `expr_tokens::scan_references` excludes both, which is what keeps `SUM(c.udf(x))` and `'c.discount is not a ref'` out of the reject set.
- **Coverage:** ten `graph::member_refs::tests` — the rejection for each member kind, the three legal forms (cross-table fact, derived metric over metrics on other tables, same-table fact in a dimension per #54), the base-table case for a member with no `source_table`, and the four false-positive guards (unknown qualifier, struct path, qualified call, string literal), plus case-insensitive and quoted qualifier matching. `member_reference_scoping.test` covers CREATE-time rejection and both controls end to end.
- **The expander-level pin is retained, retargeted:** `par3_cross_table_column_reference_pulls_no_join` now documents what the expander does with a definition that predates the validator (a legacy catalog row), since the shape is no longer reachable through DDL.
- **Documented at:** `docs/how-to/facts.rst` and `docs/explanation/snowflake-comparison.rst` ("What an Expression May Reference").

### 51. ❌ `SHOW SEMANTIC {DIMENSIONS,METRICS,FACTS}` and `DESCRIBE` report an empty `data_type` for every view (PAR-2) — OPEN

> **Scope widened 2026-08-08.** The entry below says the column is empty for SQL-created views because "only a YAML definition sets `output_type`". That is no longer true in the direction that matters: **#68** withdrew `output_type` from YAML import (no DDL clause can carry it, so `GET_DDL` dropped it and a restored view silently lost the cast). The column is now empty for **every newly created view regardless of ingress**, and populated only for views stored before that change. Read-side probing — this entry's "what would finish it" — is therefore the *only* remaining route to a populated column, which raises its priority rather than changing its design.


- **Origin:** code review 2026-08-03 (`_notes/code-review-2026-08-03.md`, PAR-2). Filed MEDIUM, and filed specifically as a **ledger-discipline** failure rather than a coverage one.
- **What happened:** v0.10.0 Phase 65 (D-16/D-17) removed CREATE-time type inference (`typeof(expr)`) on the promise that read-side bind callbacks would probe on demand at SHOW / DESCRIBE bind time ("Plan 05"). Plan 05 never landed. Since no SQL DDL surface can declare an output type — only a YAML definition sets `output_type` — `data_type` is empty for every view created through SQL DDL, and `DESCRIBE`'s `DATA_TYPE` rows with it. Snowflake documents this column as "Data type of the dimension" and populates it; the 8-column shape is otherwise at parity.
- **Why it survived review for two milestones:** the degraded output is *pinned by a passing test*. `test/sql/phase39_metadata_storage.test` Test 4 asserted `(empty)` under a comment reading "Until Plan 05 lands…" — the only record anywhere that something was still owed. There was no TECH-DEBT entry, no ROADMAP item, and no CHANGELOG framing of the column as a temporary regression, so CI stayed green and the promise expired silently. This entry exists so that record is no longer a test comment; the test comments now point here. CLAUDE.md's rule about deliberately-degraded interim states was written from this case.
- **Scope of the drift it caused:** the reference docs for `SHOW SEMANTIC DIMENSIONS` / `METRICS` / `FACTS`, `SHOW COLUMNS IN SEMANTIC VIEW`, and `DESCRIBE SEMANTIC VIEW` all described an inferred type and showed populated example output (`VARCHAR`, `DOUBLE`, `DECIMAL(10,2)`) that no v0.10.0+ view can produce; `show-semantic-facts.rst` still explained the removed `typeof` pass in detail. All corrected 2026-08-05 in the same change that opened this entry.
- **What would finish it:** probe types on the read path at SHOW / DESCRIBE bind time — the mechanism the query path already uses (`query::table_function::try_infer_schema`, a `LIMIT 0` probe on the per-call connection). Per-member probing has costs the query path does not: one probe per member rather than one per query, a decision about what to report when a member's expression does not bind, and PRIVATE members to keep out of the probe set. Until then the column reports the declared type or nothing.
- **Divergence, if it is never finished:** `data_type` reports what the definition declared rather than what the query would return. Recorded in `docs/explanation/snowflake-comparison.rst` ("Reported Data Types").

### 50. ✅ A root-grain component of a derived metric vanished from its grain set, and the per-grain planner anchored it at another table (EXP-11) — RESOLVED 2026-08-05

- **Origin:** code review 2026-08-03 (`_notes/code-review-2026-08-03.md`, EXP-11). Filed MEDIUM, in the EXP-8 class.
- **Was:** EXP-8 established that a metric with no `source_table` and no base-metric references sits at the **root** grain, and taught the fence to substitute the root for an *entirely empty* grain set. `collect_derived_metric_source_tables` contributes no table for such a metric, so the substitution only ever fired when nothing else contributed one either. A derived metric depending on both a source-less aggregate (root grain) and a base metric on a fanning parent table therefore reported a grain set of `{parent}`: non-empty, so no substitution, and the root component simply disappeared.
  - **Symptom 1 (single grain).** `plan()` saw one grain, took its fast path, and anchored the metric's **whole** resolved expression — both aggregates — at the parent, then ran the fence in per-grain mode, which skips the EXP-1 root-grain and EXP-2 within-metric checks. Emitted `SELECT (SUM(c.balance)) - (SUM(amount)) FROM "customers" AS "c"`: silently wrong if `customers` has an `amount` column, a confusing binder error if not. v0.11.0 raised `RootGrainFanTrap` for the same query.
  - **Symptom 2 (multi-grain).** With two real grains the planner reached `decompose`, which skips every source-less dependency as "derived, inlined by the reference walk". For an aggregate one that spliced the raw `SUM(amount)` into the **outer** SELECT over the grain CTEs, where its table is not in scope.
- **Resolution:** the empty→root substitution is now made per *component*. The dependency walk reports a `DerivedMetricGrain { tables, at_root }` — `at_root` set when it passes a metric with no `source_table` whose expression aggregates — and `MetricGrain::anchored(root)` supplies the caller's root alias for it. The flag is carried separately rather than pushed into the table list because the walk sees only the metric list; the root alias belongs to `GrainGraph`. All five fence sites and the planner now share `anchored`, replacing five hand-written `if tables.is_empty() { push(root) }` copies of the EXP-8 rule. `decompose` declines outright on a source-less aggregate dependency: it needs a component CTE of its own, and there is no table to anchor one at.
- **A window metric is exempt from `at_root`,** and this is the one subtlety worth stating: its expression is `<fn>(<inner>) OVER (…)`, an aggregate over a metric *reference*, evaluated on the inner metric's pre-aggregated row set rather than any table of its own. `metric_grain` already adds the inner metric's grain separately, so counting the outer window function would put every window metric at the root grain **on top of** its real one — a window metric over a child-table aggregate would then fan-trap against its own dimension. Caught by two pre-existing `tests_count_star_rewrite` tests before the exemption was added.
- **Honest scope: this restores an error, it does not compute the answer.** These queries now decline to the base-anchored path and raise `RootGrainFanTrap` — the v0.11.0 answer, and the correct one until the root component can be given a CTE of its own. Giving it one is a real fix and is **not** done here: the per-grain emitter anchors each group at a named table, and a root-grain component would need a group anchored at `GrainGraph::root` with the other components joined to it. That is TECH-DEBT #35/#36 territory (per-grain emission) rather than a fence change, and no DDL-reachable query needs it — see reachability below.
  - **The gap is one qualification wide, and that is the workaround.** Give the same metric a `source_table` — what DDL requires anyway, `o.root_total AS SUM(amount)` — and `decompose` treats it as an ordinary component: verified by probe, the mixed metric emits two grain CTEs (`SUM(c.balance)` over `customers`, `SUM(o.amount)` over `orders`) and returns a number. So the unanswered case is exactly the one whose metric never said where it aggregates, which is also why building the missing anchor was not worth doing speculatively. `derived_metric_with_rootless_aggregate_component_errors` pins the error, and its comment says why the error is the intended answer rather than a placeholder.
- **Reachability — why MEDIUM and not HIGH.** Current DDL cannot declare a source-less *aggregate* metric: `alias.name` qualification is mandatory and `validate_derived_metrics` rejects an aggregate in an unqualified entry. The shape exists only in legacy stored catalog rows and in constructed definitions — exactly the population EXP-8 was added to protect, which is why leaving the planner and the fence inconsistent about it was not acceptable.
- **Coverage:** three `expand`-level tests in `tests_per_grain.rs` (`derived_metric_with_rootless_aggregate_component_errors`, `multi_grain_derived_metric_with_rootless_aggregate_component_errors`, and the control `rootless_aggregate_mixed_with_root_table_component_stays_at_root`, which pins that a metric whose components are *all* at the root still emits `(COUNT(*)) + (SUM(amount)) FROM "orders" AS "o"` with no per-grain CTEs — a fix that over-declined would fail it), plus three unit tests on the walk itself in `facts.rs` (`test_collect_grain_rootless_aggregate_dependency_reports_at_root`, `test_collect_grain_plain_derived_metric_is_not_at_root`, `test_collect_grain_window_metric_outer_aggregate_is_not_at_root`).
- **Each case confirmed red individually.** The two symptom tests were written first and watched fail against the pre-fix code, each printing the exact wrong SQL quoted above. The three unit tests were then mutation-verified one at a time — dropping the `at_root` assignment, dropping the window exemption, and dropping the aggregate check each turned exactly **one** of the three red and left the other two green, so none of them passes vacuously.
- **No numeric-oracle work, and why — checked rather than assumed.** No DDL-reachable query changes: `at_root` can only be set by a metric DDL refuses to create, and for every other metric `anchored()` returns exactly what the five hand-written substitutions returned. The two sites that now call `anchored()` where they previously read the raw table list (`snapshot_cte_anchor`, `anchored_window_anchor`) both matched on a single-element slice and returned `None` on an empty grain; the substituted `[root]` now matches and is immediately rejected by their existing `if anchor == root { return None }`, so the outcome is unchanged there too. The five numeric harnesses build their definitions through DDL, so they cannot generate the shape at all — and for it there is no number to oracle, since the correct behaviour is an error.

### 49. ✅ Two catalog-scoping gaps: the v0.1.0 import skipped the JSON backfill, and reads ignored a foreign `database.` qualifier (CAT-3 / CAT-4) — RESOLVED 2026-08-05

- **Origin:** code review 2026-08-03 (`_notes/code-review-2026-08-03.md`, CAT-3, CAT-4). Filed MEDIUM-LOW and LOW.
- **CAT-3 was:** the `schema_name` column and the `schema_name` inside the stored definition JSON are documented as being in lockstep, because name lookup reads the column while the SHOW / DESCRIBE listings read the JSON. `migrated_row` backfills the JSON for the legacy-table rebuild and says why; CREATE stamps both from one expression; a schema-moving ALTER RENAME patches both. The v0.1.0 companion import is the fourth writer and inserted the definition **verbatim** under `UNRECORDED_SCHEMA_FALLBACK`, writing the column alone.
  - **Symptom:** an imported row lived under column `schema_name = 'main'` with no `schema_name` in its JSON, so `SHOW SEMANTIC VIEWS` listed it with an empty schema (`unwrap_or_default` in `ddl/list.rs`) and `SHOW SEMANTIC VIEWS IN SCHEMA main` missed it entirely. Lookup by name still worked — the schema comes from the column there — which is what kept it quiet.
- **CAT-3 resolution:** the import reuses `backfill_schema_name` with the same fallback it writes to the column, so the two cannot disagree. A definition that is not a JSON object the backfill can safely edit is still carried across byte-for-byte rather than dropped: losing a pre-v0.2 definition here is unrecoverable, which is the same rule `migrated_row` follows.
- **CAT-4 was:** `parse_view_ref` captures a `<database>.` prefix and the read paths then ignored it — nothing between the bind bodies and `prepared_lookup` (which has no database predicate) ever looked at `view.database`. Write DDL rejects an explicit foreign-database prefix via `current_database_guard_select`, on the stated ground that silently dropping it would make `DROP SEMANTIC VIEW otherdb.analytics.v` delete the *current* database's `analytics.v` — a wrong-object write rather than an unsupported one.
  - **Symptom:** the same wrong-object argument applies to reads. `semantic_view('otherdb.analytics.v')` resolved against the managed catalog's `analytics.v` and returned this database's data under another database's name, with nothing to tell the caller the database they named was never consulted.
- **CAT-4 resolution:** `CatalogReader::lookup` rejects a reference whose `database` does not match `current_database()` on the reader's connection. Placed there rather than at each bind site because all nine read surfaces — `semantic_view()`, `explain_semantic_view()`, `DESCRIBE`, `SHOW SEMANTIC COLUMNS` / `ENTITIES` / `MATERIALIZATIONS` / `DIMENSIONS FOR METRIC`, `GET_DDL`, `READ_YAML` — reach the catalog through that one function. The decision itself is the pure `foreign_database_error`, unit-testable without a connection; the reader only supplies `current_database()`, and only when a prefix was actually written, so the common unqualified path costs nothing.
  - Read on the borrowed probe connection, not the caller's, so the comparison is against the **managed** catalog. Reads are pinned to the primary regardless of `USE`, so `adb.main.v` is foreign even to a session sitting in `adb` — pinned by a test.
  - Ahead of the `catalog_table_present` short-circuit: a wrong-object reference is wrong whether or not this database has a catalog, and reporting it as "does not exist" would suggest the qualifier had been honoured and the view was merely absent.
  - Case-folded per `DuckDB`'s identifier rule (CLAUDE.md: dialect questions follow `DuckDB`, not Snowflake), so `MEMORY.main.v` still resolves.
- **Coverage:** CAT-3 — `catalog::tests::migration_companion_import_backfills_schema_name_into_json`, which reads the stored row back and asserts the JSON records the same schema as the column. CAT-4 — `test/sql/cat4_read_foreign_database.test` covers every read surface end-to-end, plus `catalog::tests::foreign_database_tests::*` for the rule (case folding both ways, quoted spelling, and that a two-part `adb.v` is a *schema* reference and not a foreign database).
- **Each case confirmed red individually.** The sqllogictest runner halts a file at its first failing statement, so the eight error cases in one `.test` file would have yielded one observed red and seven unproven. They were split into one file per case and run against the pre-fix binary: all eight red, and the three positive controls (current-database qualifier, unqualified, case-folded) green both before and after, so they cannot pass vacuously. One case is red for a *different* reason and that is the point — `adb.main.nonexistent` failed the pre-fix run with "does not exist" and now reports the single-catalog error, which is what distinguishes the guard from the not-found path.
- **No numeric-oracle work, and why.** Neither change alters emitted SQL for any reference that resolves: CAT-3 touches stored metadata only, and CAT-4 turns a read that was answering the wrong question into an error. The numeric harnesses generate bare view names and no attached catalogs, so no generated case changes.

### 48. ✅ Two silent fallbacks in the fan-trap fence and the semi-additive snapshot (EXP-15 / EXP-18) — RESOLVED 2026-08-05

- **Origin:** code review 2026-08-03 (`_notes/code-review-2026-08-03.md`, EXP-15, EXP-18). Both filed LOW; both are the same shape — a code path that, on reaching a state it has no answer for, returns a *plausible* answer instead of failing.
- **EXP-15 was:** cycle detection is a CREATE-time check — `graph::validate_graph` runs `toposort` — that the query-time rebuild never repeated. `RelationshipGraph::from_definition` rejects only self-references, so a stored cyclic definition reached `fanning_edge_on_path` intact, and that function tests the forward key `(a, b)` first and declares the hop **safe** on finding it, never looking at the reverse `ManyToOne` edge `(b, a)` that fans. The fence certified the definition.
  - Reachable, not hypothetical: cyclic `RELATIONSHIPS` are parser-reachable, and #141 (`d48abee`, found by `fuzz_sql_expand`) proved the shape reaches `expand` — it was an OOM hang there until the `JoinTree` walks got a visited-set guard. That commit fixed the hang and deliberately left the Ok/Err outcome unspecified, so the answer stayed "Ok, certified".
- **EXP-15 resolution:** `build_relationship_graph` re-runs `toposort` and raises `UncheckableDefinition`, whose message carries the cycle path. This is the third check that function performs for the same stated reason (SG-7: an unbuildable graph; AR-4: a relationship missing FK metadata) — a safety check that cannot verify must decline, not pass. Two mutually-referencing `ManyToOne` joins assert "many `a` per `b`" and "many `b` per `a`" at once, which is not a topology with a correct answer, so `fanning_edge_on_path` was deliberately **not** changed to also test the reverse key: there is no fan verdict to invent, and the graph is now guaranteed acyclic at both fence entry points. Its doc records that precondition, and names `GrainGraph` as the one caller that builds without it — the per-grain *planner* can still ask a cyclic graph for a verdict, which is contained because `sql_gen` runs the fence before handing off, so the routing decision is discarded and the query errors before any SQL is emitted.
- **EXP-18 was:** `get_rn_column_for_metric` returned `"__sv_rn"` for a metric in no NA group, under a bare `// fallback` comment — group 1's rank column. The metric would then be snapshotted at another group's NA ordering and return a wrong number with no error. Two metrics sharing one rank column is the exact failure #129/#32 were filed for; `collect_na_groups` folds sort polarity and role-playing alias into the group key precisely to keep them apart. The `na_groups.len() == 1` shortcut was the wider of the two holes: it returned *before* testing membership at all, so in the common single-group case any stray index was aliased without ever reaching the `// fallback` line.
- **EXP-18 resolution:** membership is tested first and the name derived from the same branch, so the two can no longer disagree; a metric in no group raises the new `ExpandError::SemiAdditiveRankColumnUnresolved`.
  - **Honest scope: this is a guard, not a live wrong-number fix.** `collect_na_groups` and the `is_active_semi` closure share `is_active_semi_additive` over the same `resolved_mets` index space, so today every metric reaching here has a group and the emitted SQL is byte-identical for every reachable input. The value is that a future divergence between those two sides fails loudly instead of silently changing a number. The error's Display says "internal error … please report it" rather than blaming the query, and the variant doc records why it is unreachable.
- **Coverage:** five unit tests, each confirmed red independently. EXP-15: `fan_trap::tests::{test_check_fan_traps_cyclic_definition_errors, test_validate_fact_table_path_cyclic_definition_errors}` (separate tests so both fence entry points report on their own) plus the control `test_check_fan_traps_acyclic_definition_still_allowed`, which guards against a cycle check that rejects everything and would make the other two pass vacuously. EXP-18: `semi_additive::tests::{test_get_rn_column_single_group_rejects_non_member, test_get_rn_column_multi_group_rejects_non_member}` — split one per hole, because a single test would have halted on the first assertion and left the second unproven. Verified by restoring the pre-fix body and watching both go red, then reverting.
- **Coverage moved, not lost:** `expand::tests_fan_trap::cyclic_relationships_do_not_hang_expand` asserted only termination (`let _ = expand(...)`), matching d48abee's unspecified outcome. It is now `cyclic_relationships_are_rejected_by_expand` and asserts `UncheckableDefinition`. That strengthens it but **costs** the #141 termination guarantee at this level: the fence short-circuits before the `JoinTree` parent-walk, so the test no longer reaches it. The surviving direct guard is `graph::join_tree::tests::walks_terminate_on_cyclic_parent_map`, which exercises the walk on a cyclic parent map without the fence; both the test and that unit test's closing comment (which named the expand test as "the end-to-end guard") were updated to say so.
- **No numeric-oracle work, and why — checked rather than assumed.** EXP-18 emits byte-identical SQL for every reachable input, so no number moves. EXP-15 turns a number into an error, but only for cyclic definitions, and none of the five numeric harnesses can generate one: `differential`, `semi_additive` and `window_metric` build `joins: vec![]`, `star_schema` a fixed `t → u`, `multi_hop_join` a fixed `t → u → w`. So no generated case changes. Nor would generating cycles help — an incoherent topology has no correct number for an oracle to hold, which is why the coverage is error assertions at the unit and `expand` levels instead.

### 47. ✅ `where_clause` bypassed PRIVATE and never resolved qualified member references (EXP-13 / EXP-14) — RESOLVED 2026-08-04

- **Origin:** code review 2026-08-03 (`_notes/code-review-2026-08-03.md`, EXP-13, EXP-14). Both filed LOW; EXP-14 is materially worse than filed — see below.
- **EXP-13 was:** `resolve_where_clause` built its member lookup from *all* of `def.dimensions` / `def.facts` with no `AccessModifier` check, so a PRIVATE fact that `resolve_names` refuses to let you select could still be named in a predicate — and its values probed by varying the filter and watching the result move. The queried-member path has enforced PRIVATE since Phase 43; the predicate path did not. Only facts are affected: `PRIVATE` on a dimension is rejected at CREATE (`body_parser::entries`), which is why `sql_gen`'s `Resolvable` impl carries `unreachable!("dimensions cannot be private")`. (The 2026-08-03 review filed that rejection as an undocumented divergence, PAR-4. Checked against current Snowflake docs on 2026-08-05 it is **not** a divergence: "You cannot mark a dimension as private. Dimensions are always public." The comparison table now states the parity instead of implying a narrowing.)
- **EXP-14 was:** `scan_references` keys a dotted chain by the whole chain (`o.order_date`), but the member lookup was keyed by bare name only. A qualified reference therefore matched **nothing**.
- **EXP-14's real severity — a fan-trap fence bypass, not just an unsubstituted reference.** The finding predicted a raw column that "fails loud at bind". It does worse. `ResolvedWhere::source_tables` is what feeds the predicate's tables into the fan-out check, and it is populated *only* for references that resolve. An unresolved qualified reference therefore removes its member from the fence's input entirely, so a predicate that reaches a fanning grain is silently accepted. Caught by the extended oracle, whose minimal counterexample is unambiguous:

  ```sql
  SELECT sum(u.uw) AS "su"
  FROM "u" AS "u"
  WHERE ((u.ucat) <= 1 AND t.ftd)
  ```

  `t.ftd` names a filter member on `t`. Pre-fix it resolved to nothing, `t` never entered `source_tables`, the fence never learned the predicate reached `t`, and the query was **accepted** with `t` not even joined. This is the same class as EXP-10, which was rated HIGH. It happens to bind-fail here only because `t.ftd` is not a real column; where a member name coincides with a real column on its own table, it binds and returns a wrong number with no error.
- **Resolution:** every member is keyed both bare and by its own `source_table.name`, mirroring `expand::facts::insert_fact_keys` and the dotted-reference handling at the NA-dim and window sites (#28/#30). Keying by the member's **own** table is what keeps a *foreign* qualifier (`c.order_date`) an ordinary column per E-3. Metrics are keyed both ways too, so `o.revenue > 5` now raises `WhereClauseReferencesMetric` instead of slipping through. PRIVATE facts are deliberately kept *out* of the lookup and recorded separately, so a reference to one raises `PrivateFact` rather than falling through to "unknown reference" — which would have left it in the SQL as a raw column and leaked the very member PRIVATE withholds.
- **Coverage:** seven unit tests, each confirmed red before the fix, plus controls (`a_public_fact_still_resolves_in_the_predicate`, `a_foreign_qualified_reference_is_left_as_a_raw_column` — the latter guarding against over-eager substitution). Randomized: `multi_hop_join_proptest`'s predicate generator now varies each member reference between bare and `<table>.<name>` spellings, with the oracle rendering unchanged so the comparison stays differential. Verified by reverting only the fix and watching the harness go red on the case above. `generator_reaches_both_where_clause_branches` gained an assertion that both spellings occur, so the flag cannot be silently pinned to `false` — the `where_clause: None` mistake (PBT-6) one level down.
- **Not covered:** `differential_proptest` cannot host this — its members carry `source_table: None`, so a qualified spelling does not exist there. Left alone rather than restructured.

### 46. ✅ Three quote scanners each tracked a different subset of the quoting rules (EXP-16 / EXP-17 / PARSE-4) — RESOLVED 2026-08-04

- **Origin:** code review 2026-08-03 (`_notes/code-review-2026-08-03.md`, EXP-16, EXP-17, PARSE-4).
- **Was:** three hand-rolled SQL-text scanners, each with its own subset of the rules. Parse-side scanners learned double-quoted identifiers in PA-6 and `$tag$` regions in PARSE-1; these never did, and no two agreed on which:

  | scanner | `'…'` | `"…"` | `$tag$…$tag$` |
  |---|---|---|---|
  | `expand::facts::rewrite_count_star` | yes | **no** | **no** |
  | `expand::semi_additive::find_matching_paren` | yes | yes | **no** |
  | `parse::search_path::matching_close_paren` | yes | **no** | yes |

- **Symptom:** `rewrite_count_star` rewrote the literal text `count(*)` occurring inside a double-quoted identifier or a dollar-quoted literal as though it were a live call, corrupting the identifier or the literal — the E-3 class TECH-DEBT #28 killed for reference scanning, surviving in a production scanner. `find_matching_paren` let a `)` inside a `$tag$` literal close the match early, mis-slicing the aggregate's argument in `build_snapshot_block`'s SG-5 decomposition. `matching_close_paren` let a `)` inside a double-quoted identifier close the call early; that one is hardening rather than a demonstrated end-to-end defect, since an explicit `search_path` argument short-circuits the injection — but a scanner that disagrees with its siblings about the same bytes is the hazard the other two demonstrate.
- **Resolution:** all three defer to `QuoteState`, which already handled every region plus the `''` / `""` escapes. It moved from `body_parser::scan` to `crate::util`, beside the `read_dollar_tag_len` tag grammar it already depended on; `body_parser::scan` re-exports it so its callers are unchanged. Using it in place would have created an `expand` → `body_parser` dependency, which the move avoids. Incidentally `matching_close_paren`'s `depth -= 1` became `checked_sub` — it would have underflowed and panicked in debug on an unbalanced `)`.
- **Coverage:** four bug tests, each reporting independently and each confirmed red before the fix (`rewrite_count_star_ignores_a_double_quoted_identifier`, `rewrite_count_star_ignores_a_dollar_quoted_literal`, `find_matching_paren_ignores_a_close_paren_inside_a_dollar_quoted_literal`, `matching_close_paren_ignores_a_close_paren_inside_a_double_quoted_identifier`), plus two controls pinning the regions each scanner already handled. Every bug test asserts a genuine construct alongside the inert one, so none can pass by the scanner simply giving up.
- **Residual:** this delivers ARCH-6's substance for these three sites only. The remaining scanners are untouched and **ARCH-6 stays open** for them — the entry is a relocation plus three call sites, not the crate-wide sweep.

### 43. ✅ Window inner-metric / ORDER BY references resolved quote-sensitively in three of four sites (EXP-12) — RESOLVED 2026-08-04

- **Origin:** code review 2026-08-03 (`_notes/code-review-2026-08-03.md`, EXP-12), filed as a *latent* hazard and confirmed on inspection to be an active one.
- **Was:** four sites resolve a window metric's `WindowSpec::inner_metric` (and the `OVER ... ORDER BY` dimension reference). Only the emitter, `expand::window`, used the canonical identifier key (`normalize_ident_part` / `ident_matches`, TECH-DEBT #28 Slice 3). The other three compared raw spellings with `eq_ignore_ascii_case`, which treats the quote characters as data:
  - `body_parser::parse_keyword_body` — the CREATE-time inner-metric and ORDER BY validators;
  - `expand::fan_trap::metric_grain_tables` — the grain derivation the whole fence is built on;
  - `expand::per_grain::window_cte_anchor` — the own-grain anchor decision.
- **Symptom, two layers deep.** Visibly, a legitimate definition was rejected at CREATE: `AVG("total") OVER (…)` against a metric declared `total` failed with `inner metric '"total"' not found` — while helpfully suggesting `total`. Per CLAUDE.md that is a DuckDB-convention violation on its own, since DuckDB matches identifiers case- and quote-insensitively.
  - Underneath, the fence and anchor were *already wrong* for any definition carrying such a reference. Verified directly: with the reference quoted, `__sv_agg` anchored at the BASE table and computed the inner aggregate over a fanned join (a parent's 140 became 340), where the unquoted spelling anchored at the aggregate's own grain and returned 140. Silent wrong number.
  - The CREATE check being the strictest of the four is what kept that unreachable through ordinary DDL — so **migrating it alone would have opened the silent path**, which is precisely why the review flagged the sites as needing to move together.
- **Resolution:** all four migrated to `crate::ident::ident_matches` in one change, matching the emitter. Nothing else about the resolution changed — an unknown name is still rejected, quoted or not.
- **Coverage:** `body_parser::tests::parse_keyword_body_window_quoted_{inner_metric_accepted, case_varied_inner_metric_accepted, order_by_dim_accepted, unknown_inner_metric_still_rejected}`, and at the expansion layer `expand::tests_fan_trap::{window_quoted_inner_metric_reference_behaves_like_unquoted, window_quoted_case_varied_inner_metric_reference_behaves_like_unquoted, window_quoted_inner_metric_contributes_its_grain_to_the_fence}`. The first two assert the quoted and unquoted spellings produce *identical* outcomes rather than pinning one answer, so they keep holding if the anchor/fence decision for this shape is ever revisited — what they forbid is the asymmetry. End-to-end, `test/sql/cr20260803_window_quoted_inner_ref.test` pins the number (140, not 340) across quoted, case-varied-quoted and quoted-ORDER-BY spellings, with rejection controls for unknown names in both slots.

### 42. ✅ `semantic_view()` ignored query interrupts — cancellation never reached the inner query — RESOLVED 2026-08-04

- **Origin:** external bug report, found while implementing the async query surface of a downstream project (Semolina v0.7) whose "a cancelled query must be cancelled at the warehouse, not merely abandoned by the client" requirement could not be demonstrated against semantic views. Not caught by the 2026-08-03 code review, which did not look at the C++ execution shim's cancellation behaviour (`grep -niE "interrupt|cancel"` over that review returns nothing).
- **Was:** `duckdb_interrupt()` — and therefore ADBC's `adbc_cancel()`, an interactive Ctrl-C, and any framework-level timeout built on either — had no effect on a query running through `semantic_view()`. The query ran to full completion and the interrupt surfaced only afterwards. Measured on a ~3.9s aggregate cancelled at 0.4s: 3.9s to stop via `semantic_view()`, 0.41s for the identical aggregate as hand-written SQL.
- **Cause:** DuckDB's `interrupted` flag is a per-`ClientContext` atomic, and `sv_semantic_view_init_global` ran the expanded SQL on a **fresh** `Connection(*context.db)` — a brand-new `ClientContext`. The flag was set on the caller's context and read on the inner one; nothing bridged them. Compounding it, there was no yield point: `Connection::Query` blocks until the inner query finishes, and the outer executor's own interrupt checks live in `PipelineExecutor::Execute`, which does not run until `init_global` returns. That is exactly the observed shape — full runtime, *then* INTERRUPT.
- **Resolution:** `sv_query_interruptible` (`cpp/src/shim.cpp`) unrolls the loop `Connection::Query` runs internally (`PendingQueryResult::ExecuteInternal`) into `PendingQuery` + `ExecuteTask()`, giving a poll point between tasks. The outer `ClientContext &` that `init_global` already receives is checked there; on a cancel the flag is forwarded to the inner connection via `Connection::Interrupt()` and the loop keeps draining, so the inner executor's tasks wind down before the `Connection` leaves scope, and only then is a **bare** `InterruptException` thrown.
  - **Bare is load-bearing.** ADBC maps a failure to `ADBC_STATUS_CANCELLED` only on an exact string match against `InterruptException::INTERRUPT_MESSAGE` (`"Interrupted!"`). The interrupt branch is deliberately taken inside the helper, *ahead* of the `InvalidInputException("semantic_view: SQL execution failed: …")` wrap in `init_global`, or a cancelled query would surface as `ADBC_STATUS_INTERNAL`.
  - Everything else matches `Connection::Query`, including returning an error-carrying result rather than throwing, so `init_global`'s existing `HasError()` handling and the `semantic_view: SQL execution failed: …` wording are unchanged. That wording turned out to have **no** test pinning it — the refactor moves where the error is produced, so `semantic_view_runtime_error.test` was added to pin it. It is a guard rather than a capability (it passes on both sides of this change by design), and non-vacuity was verified by perturbing each of its three assertions in turn and confirming each goes red independently — the runner halts a file at its first failure, so one run could not have proven all three. One behavioural difference is accepted: a *multi*-statement string would now be rejected (`Cannot prepare multiple statements at once!`) rather than executed. `execution_sql` is always a single generated `SELECT`.
  - Guarded by `test/integration/test_query_interrupt.py` (`just test-interrupt`, wired into `test-integration`). It asserts **timing** and **error shape** separately, because the pre-fix build satisfies the latter — the outer pipeline does eventually raise a bare INTERRUPT, just 3.9s too late — so a shape-only assertion would have passed against the bug. A plain-SQL twin runs first through the identical harness as a control: it aborts early on every build, so a control failure means the harness is broken rather than the feature. Confirmed red against the pre-fix build (3.88s vs a 1.56s limit) with every other assertion in the file passing, so the one failure is the defect and not a broken fixture.
- **Residual gaps, stated rather than hidden:**
  - **Bind-time probes stay uninterruptible.** Every `semantic_view()` bind runs a `LIMIT 0` type-inference probe (`src/query/table_function.rs:241`, plus the DECIMAL/LIST `sv_resolve_output_logical_types` probe in the shim). Deliberately left alone: DuckDB's optimizer short-circuits `LIMIT 0` before touching any data — measured at **4ms** against an aggregate whose full run takes **7.9s** — so there is no window worth cancelling in, and the Rust-side probe would need the outer `ClientContext` plumbed across the FFI boundary to reach. Revisit only if that probe ever stops being `LIMIT 0`.
  - **`WaitForTask()` on a `BLOCKED` inner query is not itself a poll point.** `BLOCKED` is an async-I/O state (httpfs and friends); for local aggregates it does not arise. The wait is bounded — `Executor::WAIT_TIME` is 20ms — so it costs at most one such window of latency per poll rather than blocking indefinitely.
- **Not fixed here — the structurally correct shape is `bind_replace`.** DuckDB's own `query()` / `query_table()` parse generated SQL into a `TableRef` that the **outer** binder plans inline, eliminating the nested connection entirely. That would fix cancellation as a side effect, and additionally the full-result materialization (the whole result is buffered before the first scan), the hardcoded `MaxThreads` of 1, and the lost filter/projection pushdown. It also removes the bind-time schema declaration and both `LIMIT 0` probes, requires relaxing the `bind`/`exec`/init-XOR guard, and changes error-message text that sqllogictest matchers pin byte-identically — so it is a separate, larger item, deliberately not coupled to this fix.

### 37. ✅ RESOLVED (v0.12.0) — `JoinTree`'s parent map was degenerate under fan-in onto the base table

- **Origin:** discovered in v0.12.0 (Phase 69) while giving the fan-trap fence a correct path derivation. Recorded rather than fixed, to keep that milestone's diff to the per-grain change plus the fence gap it exposed.
- **Was:** `JoinTree::from_graph` took each node's parent as `graph.reverse[node].first()` — the FIRST table that references it. With fan-in onto the base table (two children of one parent, e.g. `line_items` and `shipments` both referencing the base table `orders`) `reverse["orders"]` has two entries, so the base table itself was given a "parent" (the first-declared child) and chains ran *through* the root and out the far branch. Fan-in onto a non-root node is not affected: `check_no_diamonds` rejects it at define time as an ambiguous join diamond, so root fan-in is the only shape that reached this code.
- **Impact:** a **false rejection**, not a wrong answer. A fact query selecting a fact on `shipments` with a dimension on `customers` (`s → o → c`, all many-to-one) was rejected with `FactPathViolation` though the path is safe, and the `extension`-gated `SHOW SEMANTIC DIMENSIONS … FOR METRIC` filter hid the same dimensions for the same reason.
- **Resolution:** two changes, because the parent map alone was not sufficient.
  1. `JoinTree::from_graph` now derives the map by BFS from the base table over the undirected edges — the same walk `expand::join_resolver::build_tree_parents` performs — so each node's parent is its neighbour *toward* the base table. Nodes with no path to the declared base table keep the legacy reverse-edge parent, preserving degenerate definitions. This fixes the `SHOW … FOR METRIC` filter, which pairs the tree with its own cardinality map and so already checked fan-out direction.
  2. `validate_fact_table_path` no longer asks about ancestry at all. Correctly rooting the tree makes the base table an ancestor of *every* alias, including the fan-in siblings it can only reach by walking a many-to-one edge backwards — so an ancestry test would have swapped the old false rejection for a false acceptance (silently row-multiplied fact output). It now walks the undirected path and rejects the pair unless it is traversable in at least one direction without crossing a many-to-one edge backwards, reusing `find_path` + `fanning_edge_on_path`.
- **Coverage:** `graph::join_tree::tests::from_graph_roots_the_tree_at_the_base_table_under_fan_in`, `expand::tests_fact_query::{fact_with_dimension_across_fan_in_is_allowed, fact_reaches_a_multi_hop_chain_across_fan_in, fact_across_fan_in_siblings_still_violates}`, and `test/sql/fan_in_fact_path.test` (both consumers end-to-end, including the over-widening guard).
- **Left behind:** `JoinTree`'s only remaining production consumer is the `extension`-gated SHOW filter, so it is dead code in the default build and carries `cfg_attr(not(feature = "extension"), allow(dead_code))` accordingly.

---

**Last updated:** 2026-08-01 (v0.12.0) — entry #25's residual RESOLVED: the SHOW name slots (`IN SCHEMA` / `IN DATABASE`, `IN <view>`, `FOR METRIC`) were the last members of the whitespace-tokeniser family and now capture through `ident::find_identifier_end`; the schema/database slots additionally strip their quotes, closing a silent no-match (`IN SCHEMA "main"` emitted `schema_name = '"main"'` and returned nothing), and `show_dims_for_metric` moved off a bare case fold onto `ident::ident_matches` so a quoted reference resolves an unquoted declaration. Case folding for the schema/database slots is deliberately unchanged and noted as still open. Prior same-day: entry #38 RESOLVED: a `USING` / `NON ADDITIVE BY` written after `AS` is now rejected at CREATE, naming the clause and its correct position, instead of being absorbed into the metric expression with its semantics silently discarded. Prior: 2026-07-31 (v0.12.0) — entry #36 RESOLVED: its last sub-item landed, so a multi-grain query may now carry a window metric, a role-played table disambiguated by `USING`, or an active semi-additive metric; the semi-additive snapshot is emitted as one grain group's nested CTE. Prior same-day: entry #38 added: `USING` / `NON ADDITIVE BY` written after `AS` are swallowed into the metric expression, so the clause's semantics are silently discarded at CREATE and the view fails only at query time. Prior: 2026-07-29 (v0.12.0) — entry #37 RESOLVED: `JoinTree::from_graph` now derives the parent map by BFS from the base table over undirected edges, so fan-in onto the base table no longer hands the root a "parent" and runs ancestry chains out the far branch; and `validate_fact_table_path` moved off ancestry entirely onto the direction-aware `find_path` + `fanning_edge_on_path` walk, because a correctly-rooted tree makes the base table an ancestor of every alias and would otherwise have turned the old false rejection into a false acceptance. Prior: 2026-07-28 (v0.12.0) — entry #35 RESOLVED: per-grain ("own-grain") metric aggregation landed in `src/expand/per_grain.rs`, so the multi-grain shapes v0.11.0 could only reject are now computed the way Snowflake computes them; the residual (window / active semi-additive / role-playing metrics in a multi-grain query) is entry #36. Prior: 2026-07-20 (v0.11 unreleased) — entry #35 added: the per-grain ("own-grain") metric-aggregation real fix for the fan-trap class is deferred to a follow-up milestone; the shipped EXP-1/2/3 work errors (`RootGrainFanTrap` / `MetricFanTrap`) rather than computing multi-grain queries, and the deferral now has a TECH-DEBT home instead of living only in the 2026-07-02 / 2026-07-18 review notes. Prior same-day: entry #34 resolved: the
cargo-husky pre-commit hook triggered a ~10 min cold bundled-DuckDB build under
default-features clippy; switched the hook (and a new `just lint-fast` recipe) to
the no-C++ extension-feature clippy CI already runs, which lints the same
production code in seconds. Prior: 2026-07-18 (v0.11 unreleased) — entry #33
resolved: the struct-domain fuzz targets tested inputs the parser can't produce;
oracles
corrected (converge-once idempotence for render_roundtrip; `output_type` /
`window_function` in sql_expand's balance precondition; double-quote skipping in
query_names) and the full trust-boundary redesign was **considered and declined**
(deriving structs by parsing does not remove the balance guard — the parser
accepts unbalanced-paren exprs — and `differential_proptest` already executes
`expand` output). Narrow follow-up landed two targeted `fuzz_keyword_body` seeds.
Prior: 2026-07-17 — semi-additive × role-playing
landed (entry #32, F-18 / T-15): an unqueried `NON ADDITIVE BY` dimension on a
role-playing target now resolves through `role_playing::find_using_context` like
a queried dimension, so the snapshot ranks by the role the metric's `USING`
names (was a silent wrong-role snapshot), and a role-playing NA dim with no
`USING` is rejected as ambiguous rather than snapshotting by an arbitrary edge.
Prior same-day: window dimension-side landed:
`expand/window.rs` routes every `PARTITION BY` / `EXCLUDING` / `ORDER BY`
dimension reference through the shared `resolution::dim_ref_key` /
`window_dim_column`, so a dotted OVER `ORDER BY` (`o."order date"`) binds and a
quoted reference emits the CTE alias — completing the Slice-3 dimension-side
pass (semi-additive + window), so the whole reference-tokenizer arc (#28) is
landed. Prior same-day: entry #30 RESOLVED: dotted
`NON ADDITIVE BY` resolution landed — `semi_additive.rs` routes every
NA-dim-vs-dimension comparison through `resolution::find_dimension` (one shared
`na_dim_match_key`), so a dotted `o."order date"` classifies, partitions,
orders, and joins like the bare form and expands end-to-end. Prior same-day: entry #28: Slice 3 name-field
matchers landed — `CiName` `Eq`/`Hash`, the materialization set-matcher, and
window inner-metric matching now key on `ident::normalize_ident_part` (quote +
case). Prior: 2026-07-16 — added entry #31
(graph/validation errors are now typed `ParseError`, deliberately positionless
— the review §6.1 / §7.5 error-architecture item). Prior same-day: entry #28:
Slice 2 landed —
every expression-text scanner (alias-qualifier rewriters, derived-metric
reference validator, aggregate detection) now rides `expr_tokens`;
`util::replace_word_boundary` and `derived_metrics::extract_identifiers` are
removed; the remaining name-field matchers (materialization set-matcher, NA-dim /
window inner-metric, `CiName` `Eq`) are re-scoped as Slice 3. Prior same-day:
accuracy sweep against the
2026-07-16 code review: refreshed entry #25 (the `TABLES` / `MATERIALIZATIONS` /
relationship-target slots it listed were migrated to the shared cursor by §6.1;
only the SHOW name slots remain) and entry #28 (the duplicated materialization
matcher was collapsed by E-6); added entry #29 (the `fk_columns.is_empty()`
guards are reachable and load-bearing — E-8 is not a safe deletion) and entry #30
(dotted `NON ADDITIVE BY` reference untested in semi-additive expansion, F-18).
Prior: 2026-07-12 (v0.11 unreleased) — added entry #28 (component-name
identifier contract deferred to review §6.2). Prior: 2026-07-11 (v0.10.4) accuracy
sweep against the 2026-07-11
code review: retired the ghost-code descriptions in entries #1, #4, #9, #12, #20
(sidecar `persist_conn`, the independent-query-connection rationale, `sv_ddl_conn`,
the `sv_ddl_bind`/`sv_ddl_execute` VARCHAR-forwarding pipeline, and the
`OverrideContext`, none of which exist in the code anymore); flipped #25 and
DIST-V2-01 to ✅; and corrected the DuckDB pin (v1.5.4, not 1.4.4), the fuzz-target
count (8), and the DuckLake test recipe names.
**Most recent full audit:** v0.8.0 — `.planning/milestones/v0.8.0-MILESTONE-AUDIT.md` (entries #24–#25 added post-audit during v0.10.0)
