---
phase: 63
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - src/lib.rs
  - src/catalog.rs
requirements: [RO-01, RO-02, RO-03, RO-04, RO-05]
autonomous: true

must_haves:
  truths:
    - "On a fresh read-only DuckDB DB, `LOAD semantic_views` succeeds (no error)."
    - "On a previously-bootstrapped DB reopened read-only, `LOAD semantic_views` succeeds and `list_semantic_views()` returns the bootstrapped rows."
    - "On a fresh read-only DB, `list_semantic_views()` returns zero rows (not a catalog error)."
    - "On a fresh read-only DB, `describe_semantic_view('missing')` and `FROM semantic_view('missing', ...)` return `'semantic view 'missing' does not exist'`."
    - "On a bootstrapped read-only DB, `CREATE`/`DROP`/`ALTER SEMANTIC VIEW` fail with DuckDB's `\"...read-only mode!\"` error."
    - "All five Rust unit tests in `src/{lib,catalog}.rs::tests` pass under `cargo test`."
  artifacts:
    - path: "src/lib.rs"
      provides: "is_read_only detection at LOAD; catalog_table_present probe; CatalogReader::new(conn, catalog_table_present)"
      contains: "current_setting('access_mode')"
    - path: "src/catalog.rs"
      provides: "init_catalog short-circuit on is_read_only; CatalogReader.catalog_table_present field; lookup/list_all/list_names short-circuit; 5 new unit tests"
      contains: "catalog_table_present"
  key_links:
    - from: "src/lib.rs::init_extension"
      to: "src/catalog.rs::init_catalog"
      via: "is_read_only argument"
      pattern: "init_catalog\\(con, &db_path, is_read_only\\)"
    - from: "src/lib.rs::init_extension"
      to: "src/catalog.rs::CatalogReader::new"
      via: "catalog_table_present argument"
      pattern: "CatalogReader::new\\(catalog_conn, catalog_table_present\\)"
    - from: "src/catalog.rs::CatalogReader::lookup"
      to: "src/catalog.rs::prepared_lookup"
      via: "catalog_table_present short-circuit returning Ok(None)"
      pattern: "if !self.catalog_table_present"
---

<objective>
Implement the core read-only LOAD support in Rust:

1. Detect read-only access mode at LOAD via `current_setting('access_mode')` (lowercased — see RESEARCH §3 Q1).
2. Skip ALL writes inside `init_catalog` when `is_read_only=true` (CREATE SCHEMA/TABLE plus the v0.1.0 companion-file migration; see RESEARCH §3 Q2).
3. Probe `information_schema.tables` for `semantic_layer._definitions` once at LOAD when `is_read_only=true`; stash the result on `CatalogReader` (see RESEARCH §3 Q3).
4. Short-circuit `CatalogReader::{lookup, list_all, list_names}` when `catalog_table_present=false` so reader paths surface clean "does not exist" errors and empty lists rather than raw catalog errors (see RESEARCH §3 Q4).

`src/parse.rs` is UNCHANGED — DDL emissions are routed through DuckDB's caller-connection execution path, which surfaces DuckDB's standard read-only error verbatim for bootstrapped DBs (RESEARCH §3 Q5).

Purpose: Allow `LOAD semantic_views` on read-only DuckDB DBs so previously-defined semantic views can be queried, while DDL fails naturally with DuckDB's standard read-only error — directly satisfies the v0.9.0 milestone goal.

Output: Two modified Rust files plus 5 new unit tests covering the access_mode detection contract, the init_catalog short-circuit, and the three reader-path short-circuits.
</objective>

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

<context>
@.planning/PROJECT.md
@.planning/STATE.md
@.planning/REQUIREMENTS.md
@.planning/phases/63-readonly-database-load-support/63-RESEARCH.md
@.planning/phases/63-readonly-database-load-support/63-VALIDATION.md
@CLAUDE.md
@src/lib.rs
@src/catalog.rs

<interfaces>
<!-- Existing signatures the executor must preserve / update.
     Source: src/catalog.rs (read this session) and src/lib.rs:339-482. -->

From src/catalog.rs (current — this plan modifies):
```rust
pub fn init_catalog(con: &Connection, db_path: &str) -> Result<()>;

#[cfg(feature = "extension")]
mod reader {
    #[derive(Clone, Copy)]
    pub struct CatalogReader { conn: ffi::duckdb_connection }
    impl CatalogReader {
        pub fn new(conn: ffi::duckdb_connection) -> Self;
        pub fn raw(&self) -> ffi::duckdb_connection;
        pub fn lookup(&self, name: &str) -> Result<Option<String>, String>;
        pub fn exists(&self, name: &str) -> Result<bool, String>;
        pub fn list_all(&self) -> Result<Vec<(String, String)>, String>;
        pub fn list_names(&self) -> Result<Vec<String>, String>;
    }
}
```

After this plan:
```rust
pub fn init_catalog(con: &Connection, db_path: &str, is_read_only: bool) -> Result<()>;

// reader::CatalogReader gains a `catalog_table_present: bool` field; remains Copy.
impl CatalogReader {
    pub fn new(conn: ffi::duckdb_connection, catalog_table_present: bool) -> Self;
    // existing methods unchanged in signature; bodies short-circuit on the new flag.
}
```

From src/lib.rs (current call sites this plan updates):
- line 359: `init_catalog(con, &db_path)?;`
- line 371: `let catalog_reader = crate::catalog::CatalogReader::new(catalog_conn);`
- The new `is_read_only` and `catalog_table_present` locals are computed in `init_extension` (declared at `src/lib.rs:339`).

Existing FFI helpers consumed verbatim (no changes):
```rust
unsafe fn prepared_lookup(conn: ffi::duckdb_connection, name: &str) -> Result<Option<String>, String>;
unsafe fn execute_list_all(conn: ffi::duckdb_connection) -> Result<Vec<(String, String)>, String>;
unsafe fn execute_list_names(conn: ffi::duckdb_connection) -> Result<Vec<String>, String>;
```
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Detect read-only access mode and probe catalog table presence in init_extension</name>
  <files>src/lib.rs</files>
  <read_first>
    - src/lib.rs (lines 320-485 — full init_extension body and the C_STRUCT entrypoint, so the executor sees the exact placement of init_catalog and CatalogReader::new)
    - src/catalog.rs (lines 25-64 for init_catalog signature reference)
    - .planning/phases/63-readonly-database-load-support/63-RESEARCH.md §3 Q1 (lowercased access_mode rationale, future-proofing rules) and §3 Q3 (probe SQL + placement)
  </read_first>
  <behavior>
    - Test: opening a read-only DuckDB connection via `Connection::open_with_flags(path, Config::default().access_mode(AccessMode::ReadOnly).unwrap())` and querying `current_setting('access_mode')` returns the lowercased string `"read_only"` (NOT `"READ_ONLY"`).
    - Test: opening the same DB read-write returns either `"automatic"` or `"read_write"` (lowercased).
    - The detection logic in `init_extension` must use case-insensitive comparison (`eq_ignore_ascii_case("read_only")`) and must fail-open to `false` (writable) on any error — so a future DuckDB rename surfaces only at LOAD time as DuckDB's own CREATE SCHEMA error, never as a silent miss.
  </behavior>
  <action>
    Inside `init_extension` (`src/lib.rs:339-482`), insert the following AFTER the `db_path` Arc construction (currently ending at line 356) and BEFORE the `init_catalog(con, &db_path)?;` call at line 359:

    ```rust
    // Phase 63: Detect read-only access mode for THIS database.
    // AccessModeSetting::GetSetting (duckdb.cpp:301163-301167) calls
    // StringUtil::Lower(EnumUtil::ToString(AccessMode)), so the value is
    // lowercased: "read_only" / "read_write" / "automatic" / "undefined".
    // We match case-insensitively to be future-proof across DuckDB minor
    // bumps. Fail-open: if the setting is renamed/removed in a future
    // version, treat as writable; init_catalog will then surface DuckDB's
    // own read-only error from CREATE SCHEMA, which is strictly better
    // than a silent miss.
    let is_read_only: bool = con
        .query_row(
            "SELECT current_setting('access_mode')",
            [],
            |row| row.get::<_, String>(0),
        )
        .map(|s| s.eq_ignore_ascii_case("read_only"))
        .unwrap_or(false);
    ```

    Then change line 359 from `init_catalog(con, &db_path)?;` to:
    ```rust
    init_catalog(con, &db_path, is_read_only)?;
    ```

    Then, AFTER the `duckdb_connect` call that produces `catalog_conn` (currently at lines 366-370) and BEFORE the `let catalog_reader = ...` at line 371, insert the probe:

    ```rust
    // Phase 63: When read-only, probe for semantic_layer._definitions
    // ONCE so reader-path methods can short-circuit cleanly. Writable
    // path always has the table (init_catalog just CREATE'd it), so we
    // skip the probe to avoid an extra query on the hot path.
    let catalog_table_present: bool = if is_read_only {
        con.query_row(
            "SELECT 1 FROM information_schema.tables \
             WHERE table_schema = 'semantic_layer' AND table_name = '_definitions' LIMIT 1",
            [],
            |row| row.get::<_, i32>(0),
        )
        .is_ok()
    } else {
        true
    };
    ```

    Finally change line 371 from `let catalog_reader = crate::catalog::CatalogReader::new(catalog_conn);` to:
    ```rust
    let catalog_reader = crate::catalog::CatalogReader::new(catalog_conn, catalog_table_present);
    ```

    Add a `#[cfg(test)] mod tests` block at the end of `src/lib.rs` (or extend the existing one if present) with this unit test. Use the `bundled` feature (`cargo test` default) — `Connection::open_with_flags` is available there.

    ```rust
    #[cfg(test)]
    mod tests {
        use duckdb::{AccessMode, Config, Connection};

        #[test]
        fn access_mode_lowercased_on_readonly_open() {
            // Pin DuckDB's contract: current_setting('access_mode') returns
            // the lowercased enum form ("read_only"), not "READ_ONLY".
            // If a future DuckDB version changes this rendering, this test
            // catches it at CI bump time rather than in production.
            let tmp = std::env::temp_dir().join("phase63_access_mode_pin.duckdb");
            let _ = std::fs::remove_file(&tmp);
            // Bootstrap an empty file with valid header bytes by opening
            // writable then closing.
            {
                let con = Connection::open(&tmp).expect("open writable");
                con.execute_batch("SELECT 1").unwrap();
            }
            let cfg = Config::default()
                .access_mode(AccessMode::ReadOnly)
                .expect("set access_mode");
            let con = Connection::open_with_flags(&tmp, cfg).expect("open read-only");
            let mode: String = con
                .query_row("SELECT current_setting('access_mode')", [], |r| r.get(0))
                .expect("query access_mode");
            assert_eq!(
                mode.to_ascii_lowercase(),
                "read_only",
                "Phase 63: current_setting('access_mode') must return 'read_only' (lowercased) for read-only DBs; got: {mode:?}"
            );
            let _ = std::fs::remove_file(&tmp);
        }

        #[test]
        fn access_mode_writable_returns_automatic_or_read_write() {
            // Sibling test: confirm writable connections do NOT match "read_only".
            let con = Connection::open_in_memory().expect("in-memory");
            let mode: String = con
                .query_row("SELECT current_setting('access_mode')", [], |r| r.get(0))
                .expect("query access_mode");
            assert!(
                !mode.eq_ignore_ascii_case("read_only"),
                "Phase 63: in-memory DB must NOT report read_only; got: {mode:?}"
            );
        }
    }
    ```

    Notes for the executor:
    - `duckdb::AccessMode` and `duckdb::Config` are exposed by the `duckdb` crate (re-exported from `libduckdb-sys`). If the import path turns out to be `duckdb::Config` only and `AccessMode` is at `duckdb::AccessMode`, those are the canonical paths in duckdb-rs `=1.10502.0`. Adjust the `use` statement if compilation reveals otherwise.
    - Do NOT change `is_file_backed` (currently at line 380) — it is independent of `is_read_only`.
    - Do NOT touch the `cpp/src/shim.cpp` `SetOption("allow_parser_override_extension", ...)` call — RESEARCH §9 Risk 1 confirmed it is safe on read-only DBs (no I/O, in-memory config write only).
  </action>
  <verify>
    <automated>cargo test --lib --no-default-features --features bundled access_mode_ -- --nocapture</automated>
  </verify>
  <acceptance_criteria>
    - `grep -n "current_setting('access_mode')" src/lib.rs` returns at least one match (the new detection block).
    - `grep -n "is_read_only" src/lib.rs` returns at least 3 matches (let binding, init_catalog arg, catalog_table_present branch).
    - `grep -n "init_catalog(con, &db_path, is_read_only)" src/lib.rs` returns exactly 1 match.
    - `grep -n "CatalogReader::new(catalog_conn, catalog_table_present)" src/lib.rs` returns exactly 1 match.
    - `grep -n "information_schema.tables" src/lib.rs` returns at least 1 match (the probe SQL).
    - `cargo test --lib access_mode_lowercased_on_readonly_open` exits 0 and prints `test ... ok`.
    - `cargo test --lib access_mode_writable_returns_automatic_or_read_write` exits 0.
    - `cargo build --no-default-features --features bundled` succeeds (no compile errors).
    - `cargo build --features extension` succeeds (extension build still compiles — proves the FFI struct change in Task 2 lines up).
  </acceptance_criteria>
  <done>
    - Read-only detection happens before `init_catalog` is called.
    - Catalog-table-presence probe happens after `duckdb_connect` for the catalog connection, only when `is_read_only=true`.
    - Both new bool locals are passed into the call sites of `init_catalog` and `CatalogReader::new`.
    - Two new unit tests pin the `current_setting('access_mode')` contract.
    - Both `bundled` and `extension` builds compile (this task introduces the new `init_catalog` and `CatalogReader::new` signatures, which Task 2 implements — Task 2 must land in the same plan execution to keep the build green; commit Task 1 + Task 2 together if the build breaks at the boundary).
  </done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: Wire is_read_only short-circuits into init_catalog and CatalogReader (with unit tests)</name>
  <files>src/catalog.rs</files>
  <read_first>
    - src/catalog.rs (full file — current init_catalog body lines 25-64, CatalogReader struct lines 88-132, existing test patterns lines 299-483)
    - .planning/phases/63-readonly-database-load-support/63-RESEARCH.md §3 Q2 (init_catalog short-circuit rationale; companion-file migration is write-only by construction) and §3 Q3 (CatalogReader Copy semantics; new field placement) and §3 Q4 (reader-path patterns and reuse of existing "does not exist" error)
  </read_first>
  <behavior>
    - Test `init_catalog_skips_writes_on_readonly`: calling `init_catalog(&con, ":memory:", true)` against a fresh in-memory connection MUST return `Ok(())` AND must NOT create the `semantic_layer` schema. Verified by querying `information_schema.schemata` for the schema name and asserting zero rows.
    - Test `init_catalog_writes_when_writable`: calling `init_catalog(&con, ":memory:", false)` MUST create the schema + table (preserves existing behaviour). Verified by `SELECT count(*) FROM semantic_layer._definitions` returning 0 (table exists, empty).
    - Test `lookup_returns_none_when_table_missing` (extension feature): a `CatalogReader` constructed with `catalog_table_present=false` returns `Ok(None)` from `lookup("any_view")` WITHOUT querying the connection.
    - Test `list_all_returns_empty_when_table_missing` (extension feature): same flag, `list_all()` returns `Ok(vec![])`.
    - Test `list_names_returns_empty_when_table_missing` (extension feature): same flag, `list_names()` returns `Ok(vec![])`.
  </behavior>
  <action>
    1. **Modify `init_catalog` (`src/catalog.rs:25-64`)** to accept the new bool and short-circuit:

    ```rust
    /// Create the `semantic_layer` schema and `_definitions` table if they do not
    /// exist, and run the v0.1.0 companion-file migration once for file-backed
    /// databases.
    ///
    /// Idempotent: safe to call on every extension load.
    ///
    /// Phase 63 (v0.9.0): when `is_read_only=true`, skips the entire body —
    /// the host DB is read-only so neither the schema/table CREATE nor the
    /// companion-file migration (which INSERTs into the table) can run.
    /// Reader-path code in `CatalogReader` short-circuits separately when
    /// the table is genuinely absent. See 63-RESEARCH.md §3 Q2.
    pub fn init_catalog(con: &Connection, db_path: &str, is_read_only: bool) -> Result<()> {
        if is_read_only {
            return Ok(());
        }
        con.execute_batch(
            "CREATE SCHEMA IF NOT EXISTS semantic_layer;
             CREATE TABLE IF NOT EXISTS semantic_layer._definitions (
                 name       VARCHAR PRIMARY KEY,
                 definition VARCHAR
             );",
        )?;
        // ... existing companion-file migration body unchanged (lines 36-61) ...
        Ok(())
    }
    ```

    Update the existing call sites in the test module (`src/catalog.rs:378` and `:443`) — change `init_catalog(&con, ":memory:")` to `init_catalog(&con, ":memory:", false)` and `init_catalog(&con, db_path)` to `init_catalog(&con, db_path, false)` to preserve existing test behaviour.

    2. **Modify `CatalogReader` struct and constructor** in the `#[cfg(feature = "extension")] mod reader` block (`src/catalog.rs:88-101`):

    ```rust
    #[derive(Clone, Copy)]
    pub struct CatalogReader {
        conn: ffi::duckdb_connection,
        // Phase 63 (v0.9.0): when false (only possible on a read-only host
        // DB whose semantic_layer._definitions table was never created),
        // reader methods short-circuit to "empty" / "not found" without
        // hitting the DB. On a writable host this is always true (the
        // table is CREATE'd in init_catalog).
        catalog_table_present: bool,
    }

    impl CatalogReader {
        pub fn new(conn: ffi::duckdb_connection, catalog_table_present: bool) -> Self {
            Self { conn, catalog_table_present }
        }

        pub fn raw(&self) -> ffi::duckdb_connection {
            self.conn
        }
        // ... lookup/exists/list_all/list_names below ...
    }
    ```

    3. **Add short-circuits to `lookup`, `list_all`, `list_names`** (lines 111-131). `exists` calls `lookup` so is auto-covered:

    ```rust
    pub fn lookup(&self, name: &str) -> Result<Option<String>, String> {
        if !self.catalog_table_present {
            return Ok(None);
        }
        unsafe { prepared_lookup(self.conn, name) }
    }

    pub fn exists(&self, name: &str) -> Result<bool, String> {
        Ok(self.lookup(name)?.is_some())
    }

    pub fn list_all(&self) -> Result<Vec<(String, String)>, String> {
        if !self.catalog_table_present {
            return Ok(Vec::new());
        }
        unsafe { execute_list_all(self.conn) }
    }

    pub fn list_names(&self) -> Result<Vec<String>, String> {
        if !self.catalog_table_present {
            return Ok(Vec::new());
        }
        unsafe { execute_list_names(self.conn) }
    }
    ```

    4. **Add 5 unit tests** in the existing `#[cfg(test)] mod tests` block at the end of `src/catalog.rs`. Tests 3-5 require the `extension` feature (CatalogReader is feature-gated); use `#[cfg(feature = "extension")]` and run them via `cargo test --features extension`.

    ```rust
    #[test]
    fn init_catalog_skips_writes_on_readonly() {
        // Phase 63 (RO-01): is_read_only=true must early-return without
        // creating the schema. Verified by checking information_schema.
        let con = in_memory_con();
        init_catalog(&con, ":memory:", true).unwrap();
        let count: i64 = con
            .query_row(
                "SELECT count(*) FROM information_schema.schemata WHERE schema_name = 'semantic_layer'",
                [],
                |row| row.get(0),
            )
            .unwrap();
        assert_eq!(
            count, 0,
            "init_catalog with is_read_only=true must NOT create the semantic_layer schema"
        );
    }

    #[test]
    fn init_catalog_writes_when_writable() {
        // Sibling: writable path still creates the schema + table.
        let con = in_memory_con();
        init_catalog(&con, ":memory:", false).unwrap();
        let count: i64 = con
            .query_row(
                "SELECT count(*) FROM semantic_layer._definitions",
                [],
                |row| row.get(0),
            )
            .unwrap();
        assert_eq!(count, 0, "writable init_catalog must create empty _definitions table");
    }

    #[cfg(feature = "extension")]
    #[test]
    fn lookup_returns_none_when_table_missing() {
        // Phase 63 (RO-04): catalog_table_present=false must short-circuit
        // BEFORE hitting the DB. Use std::ptr::null_mut() for the conn —
        // the short-circuit must return Ok(None) before any FFI call, so
        // the null pointer is never dereferenced.
        use crate::catalog::CatalogReader;
        let reader = CatalogReader::new(std::ptr::null_mut(), false);
        let result = reader.lookup("any_view");
        assert!(matches!(result, Ok(None)), "expected Ok(None), got: {:?}", result);
    }

    #[cfg(feature = "extension")]
    #[test]
    fn list_all_returns_empty_when_table_missing() {
        // Phase 63 (RO-03): catalog_table_present=false must short-circuit.
        use crate::catalog::CatalogReader;
        let reader = CatalogReader::new(std::ptr::null_mut(), false);
        let result = reader.list_all();
        assert!(matches!(result, Ok(ref v) if v.is_empty()), "expected Ok(empty), got: {:?}", result);
    }

    #[cfg(feature = "extension")]
    #[test]
    fn list_names_returns_empty_when_table_missing() {
        // Phase 63 (RO-03): catalog_table_present=false must short-circuit.
        use crate::catalog::CatalogReader;
        let reader = CatalogReader::new(std::ptr::null_mut(), false);
        let result = reader.list_names();
        assert!(matches!(result, Ok(ref v) if v.is_empty()), "expected Ok(empty), got: {:?}", result);
    }
    ```

    Notes:
    - The null-pointer trick in tests 3-5 is sound BECAUSE the short-circuit returns BEFORE the unsafe `prepared_lookup` / `execute_list_*` is reached. If the executor accidentally reorders the short-circuit AFTER the unsafe call, the test will segfault — which is the failure signal we want.
    - The two non-extension tests run under `cargo test` (default `bundled` feature). The three extension tests require `cargo test --features extension`. Both invocations must be run.
    - Existing tests at lines 314-483 must continue to pass. The only changes to existing code are the two `init_catalog(&con, ..., false)` call-site updates (line 378, line 443).
  </action>
  <verify>
    <automated>cargo test --lib catalog::tests::init_catalog_skips_writes_on_readonly catalog::tests::init_catalog_writes_when_writable && cargo test --lib --features extension catalog::tests::lookup_returns_none_when_table_missing catalog::tests::list_all_returns_empty_when_table_missing catalog::tests::list_names_returns_empty_when_table_missing</automated>
  </verify>
  <acceptance_criteria>
    - `grep -nE "pub fn init_catalog\\(con: &Connection, db_path: &str, is_read_only: bool\\)" src/catalog.rs` returns exactly 1 match.
    - `grep -n "if is_read_only" src/catalog.rs` returns at least 1 match (the early return).
    - `grep -n "catalog_table_present: bool" src/catalog.rs` returns exactly 1 match (the struct field).
    - `grep -nE "if !self\\.catalog_table_present" src/catalog.rs` returns exactly 3 matches (lookup, list_all, list_names).
    - `grep -n "#\\[derive(Clone, Copy)\\]" src/catalog.rs` returns at least 1 match (Copy preserved on CatalogReader).
    - `cargo test --lib catalog::tests::init_catalog_skips_writes_on_readonly` exits 0.
    - `cargo test --lib catalog::tests::init_catalog_writes_when_writable` exits 0.
    - `cargo test --lib --features extension catalog::tests::lookup_returns_none_when_table_missing` exits 0.
    - `cargo test --lib --features extension catalog::tests::list_all_returns_empty_when_table_missing` exits 0.
    - `cargo test --lib --features extension catalog::tests::list_names_returns_empty_when_table_missing` exits 0.
    - All pre-existing tests in `cargo test --lib` continue to pass (no regressions in `init_catalog_creates_schema_and_table`, `persist_02_rollback_leaves_catalog_unchanged`, etc.).
    - `cargo build --features extension` succeeds.
  </acceptance_criteria>
  <done>
    - `init_catalog` short-circuits to `Ok(())` when `is_read_only=true` (no schema/table created, no companion-file migration attempted).
    - `CatalogReader` carries a `catalog_table_present: bool`; remains `Copy`.
    - `lookup`, `list_all`, `list_names` short-circuit before the unsafe FFI call when the flag is false.
    - 5 new unit tests pass; pre-existing tests continue to pass.
    - Both `bundled` and `extension` builds compile cleanly.
  </done>
</task>

<task type="auto">
  <name>Task 3: Run cargo test --all-features and just build to confirm extension build still loads</name>
  <files></files>
  <read_first>
    - src/lib.rs (post-Task 1)
    - src/catalog.rs (post-Task 2)
    - 63-RESEARCH.md §6 (build commands)
  </read_first>
  <action>
    Confirm both build targets compile cleanly and all Rust tests pass — Plan 02 (sqllogictest + Python integration) and Plan 03 (docs + example) depend on a working extension binary.

    Run, in order, IN FOREGROUND (per CLAUDE.md / feedback_no_parallel_builds):

    1. `cargo test --lib` (bundled, default features) — must exit 0.
    2. `cargo test --lib --features extension --no-default-features` — must exit 0 (extension-feature-gated tests like the three short-circuit tests run here).
    3. `just build` — produces `build/debug/semantic_views.duckdb_extension`. Must exit 0.

    If any step fails, fix the underlying issue (likely a typo in the new struct field or a missing import in the new tests) and re-run from step 1. Do NOT short-circuit by skipping a step.
  </action>
  <verify>
    <automated>cargo test --lib && cargo test --lib --features extension --no-default-features && just build && test -f build/debug/semantic_views.duckdb_extension</automated>
  </verify>
  <acceptance_criteria>
    - `cargo test --lib` exits 0; output contains all 5 new unit test names from Tasks 1-2 with `ok` status (`access_mode_lowercased_on_readonly_open`, `access_mode_writable_returns_automatic_or_read_write`, `init_catalog_skips_writes_on_readonly`, `init_catalog_writes_when_writable` — the three extension-gated tests run in step 2 instead).
    - `cargo test --lib --features extension --no-default-features` exits 0; output contains `lookup_returns_none_when_table_missing`, `list_all_returns_empty_when_table_missing`, `list_names_returns_empty_when_table_missing` with `ok` status.
    - `just build` exits 0.
    - `test -f build/debug/semantic_views.duckdb_extension` exits 0 (binary exists for downstream plans).
  </acceptance_criteria>
  <done>
    - All Rust tests pass under both feature configurations.
    - Extension binary built and ready for Plan 02's sqllogictest + integration test runs.
  </done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| User SQL → extension parser hook | Untrusted DDL crosses here; parser_override pre-checks catalog before emitting DML. UNCHANGED in this plan. |
| Read-only flag derivation (`current_setting`) → init_catalog branch | A wrong bool here causes either spurious LOAD failure (false negative) or attempted write on read-only DB (false positive — DuckDB rejects it). |
| `catalog_table_present` flag → reader short-circuit | Wrong false → reader claims views absent on a bootstrapped DB; wrong true → first reader call surfaces raw catalog error instead of clean "does not exist". |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-63-01 | Tampering | `current_setting('access_mode')` rendering changes in a future DuckDB version | mitigate | Task 1 unit test `access_mode_lowercased_on_readonly_open` pins the lowercased contract — fails at CI bump time, not in production. Detection logic uses `eq_ignore_ascii_case` for additional resilience. (RESEARCH §9 Risk 4) |
| T-63-02 | Information Disclosure | `catalog_table_present=false` cached forever even after writable second connection bootstraps the table | accept | Read-only DBs cannot transition to read-write at runtime (DuckDB attach-mode conflict at duckdb.cpp:277364 rejects it). Stale-false is guaranteed-correct for the LOAD lifetime. Documented behaviour in DOC-02 (Plan 03). (RESEARCH §3 Q4) |
| T-63-03 | Information Disclosure (CWE-209) | DuckDB read-only error wrapping leaks paths/SQL | accept | RO-05 explicitly requires NOT wrapping the DuckDB error. We leave it verbatim. The error already includes attached database name; this is the same exposure as any other DML on read-only and matches DuckDB's own behaviour. |
| T-63-04 | Tampering | Probe SQL injection via `information_schema.tables` query | mitigate | The probe SQL is a fixed string constant in `init_extension` — no user input is concatenated. Reviewable by `grep -n "information_schema.tables" src/lib.rs` showing exactly one literal. |
| T-63-05 | Denial of Service | Null-pointer dereference if reader short-circuit logic is reordered after the unsafe FFI call | mitigate | Task 2 unit tests use `std::ptr::null_mut()` as the conn; if a future refactor reorders the short-circuit AFTER the unsafe call, the tests segfault — clear failure signal. |
</threat_model>

<verification>
After all tasks complete, the executor MUST run, in order:

1. `cargo test --lib` — all bundled-feature tests pass (exit 0).
2. `cargo test --lib --features extension --no-default-features` — all extension-feature tests pass (exit 0).
3. `just build` — extension binary built (exit 0).
4. `cargo clippy --all-targets -- -D warnings` — no clippy regressions from the new code.
5. `cargo fmt --check` — formatting clean.

NOTE: `just test-all` is NOT run here — Plan 02 lands the new sqllogictest fixture and Python integration test that `test-all` depends on. Full `just test-all` runs at the end of Plan 02.
</verification>

<success_criteria>
- All 5 new unit tests (`access_mode_lowercased_on_readonly_open`, `access_mode_writable_returns_automatic_or_read_write`, `init_catalog_skips_writes_on_readonly`, `init_catalog_writes_when_writable`, plus the three extension-gated short-circuit tests) pass.
- All pre-existing `cargo test --lib` tests continue to pass.
- `just build` produces a loadable extension binary.
- No clippy or fmt regressions.
- Branch is still `milestone/v0.9.0` (verify via `git branch --show-current` before commit).
</success_criteria>

<output>
After completion, create `.planning/phases/63-readonly-database-load-support/63-01-SUMMARY.md` documenting:
- Files modified with line ranges.
- All 5 new unit tests by name and what each pins.
- Confirmation that `cargo test --lib`, `cargo test --features extension`, and `just build` all green.
- Branch verified as `milestone/v0.9.0`.
- Hand-off: Plan 02 can now build sqllogictest + Python integration tests against the working extension binary.
</output>
