---
phase: 63
plan: 02
type: execute
wave: 2
depends_on: [63-01]
files_modified:
  - test/sql/readonly_load.test
  - test/integration/test_readonly_load.py
  - justfile
requirements: [TEST-01, TEST-02, TEST-03]
autonomous: true

must_haves:
  truths:
    - "`uv run test/integration/test_readonly_load.py` runs three test functions and exits 0."
    - "`just test-readonly` recipe exists and invokes the Python integration test against `build/debug/semantic_views.duckdb_extension`."
    - "`test-all` recipe in justfile includes `test-readonly` in its dependency list."
    - "`test/sql/readonly_load.test` is parsed by `just test-sql` without runner errors (skipped or stubbed scenarios are fine — see Wave 0 spike outcome)."
    - "`just test-all` exits 0 with the new Python integration test wired in."
  artifacts:
    - path: "test/integration/test_readonly_load.py"
      provides: "Three test functions covering RO-01..RO-05 end-to-end"
      contains: "test_fresh_readonly_empty_list"
    - path: "test/sql/readonly_load.test"
      provides: "Smoke coverage of writable bootstrap; defers RO-only scenarios to TEST-02 (Wave 0 spike confirmed runner lacks `load <path> readonly` directive)"
      contains: "Phase 63"
    - path: "justfile"
      provides: "test-readonly recipe + test-all dependency"
      contains: "test-readonly"
  key_links:
    - from: "justfile::test-all"
      to: "justfile::test-readonly"
      via: "recipe dependency list"
      pattern: "test-readonly"
    - from: "test/integration/test_readonly_load.py"
      to: "build/debug/semantic_views.duckdb_extension"
      via: "duckdb.connect(..., read_only=True).execute('LOAD semantic_views')"
      pattern: "read_only=True"
---

<objective>
Land the test infrastructure that proves Plan 01's read-only LOAD support works end-to-end:

1. **Python integration test** (`test/integration/test_readonly_load.py`) — three test functions covering all five RO requirements, modelled on `test/integration/test_multi_db_isolation.py`.
2. **sqllogictest fixture** (`test/sql/readonly_load.test`) — minimal smoke (Wave 0 spike confirmed our `python_runner/` does NOT support `load <path> readonly`; full read-only scenarios live in TEST-02).
3. **`justfile`** — new `test-readonly` recipe plus thread it into the existing `test-all` chain.

Wave 0 spike outcome: `grep -rn 'readonly' python_runner/` returned zero results. The runner lacks the `load <path> readonly` directive (RESEARCH §3 Q6 documents this contingency). TEST-01 is therefore satisfied by a smoke fixture that pins the writable bootstrap path AND a comment explaining the deferral; TEST-02 (the Python integration) carries full read-only coverage.

Purpose: Provide the automated proof that Plan 01's code changes deliver the v0.9.0 milestone goal. Without these tests, RO-01..RO-05 are unverified.

Output: Three new/modified test artifacts; `just test-all` chains the new integration test; `just test-all` and `just ci` both green at the end of this plan.
</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/REQUIREMENTS.md
@.planning/phases/63-readonly-database-load-support/63-RESEARCH.md
@.planning/phases/63-readonly-database-load-support/63-VALIDATION.md
@.planning/phases/63-readonly-database-load-support/63-01-PLAN.md
@CLAUDE.md
@test/integration/test_multi_db_isolation.py
@test/sql/extension_reload.test
@justfile

<interfaces>
<!-- Templates the executor mirrors. -->

From test/integration/test_multi_db_isolation.py (lines 1-58 — script header + connection helper):
- PEP-723 inline metadata: `dependencies = ["duckdb==1.5.2"]`, `requires-python = ">=3.10"`
- Imports: `sys`, `tempfile`, `traceback`, `pathlib.Path`
- `sys.path.insert(0, str(Path(__file__).resolve().parent))` then `from test_ducklake_helpers import get_ext_dir, get_extension_path`
- `EXT_DIR = get_ext_dir()`; `EXT_PATH = get_extension_path()`
- `make_connection(db_path)` — opens DuckDB with `allow_unsigned_extensions=true` and `extension_directory=EXT_DIR`, then `FORCE INSTALL` + `LOAD semantic_views`.
- `run_test(name, fn)` — try/except wrapper printing PASS/FAIL/ERROR.
- `if __name__ == "__main__":` runs all tests, exits 1 on any failure.

From justfile (existing recipe pattern, lines 91-126):
```
test-multi-db: build
    uv run test/integration/test_multi_db_isolation.py
```
And `test-all` at line 137:
```
test-all: _ensure-test-deps test-rust test-sql test-ducklake-ci test-vtab-crash test-caret test-adbc test-large-view test-multi-db test-concurrent
```
</interfaces>
</context>

<tasks>

<task type="auto">
  <name>Task 1: Create test/integration/test_readonly_load.py with three test functions</name>
  <files>test/integration/test_readonly_load.py</files>
  <read_first>
    - test/integration/test_multi_db_isolation.py (full file — exact template for script header, helpers, and `__main__` driver)
    - test/integration/test_ducklake_helpers.py (so the executor sees `get_ext_dir` and `get_extension_path` signatures)
    - .planning/phases/63-readonly-database-load-support/63-RESEARCH.md §3 Q6 (test scenario outlines + DuckDB Python `read_only=True` kwarg + Risk 5: empty file must be bootstrapped to valid header before reopening read-only)
    - .planning/phases/63-readonly-database-load-support/63-VALIDATION.md (per-task verification map for RO-01..RO-05 sub-coverage)
  </read_first>
  <action>
    Create the new file `test/integration/test_readonly_load.py` with the following content. This file uses PEP-723 inline metadata, mirrors the exact pattern of `test_multi_db_isolation.py`, and contains three test functions plus the standard `__main__` driver.

    ```python
    #!/usr/bin/env python3
    # /// script
    # dependencies = ["duckdb==1.5.2"]
    # requires-python = ">=3.10"
    # ///
    """
    Read-only database LOAD regression tests for the semantic_views extension.

    Phase 63 (v0.9.0) adds support for `LOAD semantic_views` against a read-only
    DuckDB database. Prior to this milestone, LOAD failed at init_catalog's
    `CREATE SCHEMA IF NOT EXISTS semantic_layer` with DuckDB's read-only error,
    making it impossible to query a previously-defined semantic view from a
    read-only DB (Iceberg snapshots, archival files, etc.).

    Test scenarios (cover RO-01 through RO-05):
      (a) test_fresh_readonly_empty_list      — fresh read-only file, no
          _definitions table; LOAD succeeds; list_semantic_views() → [];
          describe_semantic_view('missing') → "does not exist".
          Covers: RO-01, RO-03, RO-04.
      (b) test_bootstrapped_readonly_query_works — bootstrap a view writable,
          close, reopen read-only; LOAD succeeds; list/describe/semantic_view
          all return correct results.
          Covers: RO-01, RO-02.
      (c) test_readonly_ddl_fails             — bootstrapped read-only DB;
          CREATE/DROP/ALTER each fail with DuckDB's "read-only" error.
          Covers: RO-05.

    Exit codes:
        0 = all tests passed
        1 = at least one test failed
    """

    import sys
    import tempfile
    import traceback
    from pathlib import Path

    sys.path.insert(0, str(Path(__file__).resolve().parent))
    from test_ducklake_helpers import get_ext_dir, get_extension_path


    EXT_DIR = get_ext_dir()
    EXT_PATH = get_extension_path()


    def open_writable(db_path: str):
        """Open a writable DuckDB connection with the extension installed and loaded."""
        import duckdb

        conn = duckdb.connect(
            db_path,
            config={
                "allow_unsigned_extensions": "true",
                "extension_directory": EXT_DIR,
            },
        )
        conn.execute(f"FORCE INSTALL '{EXT_PATH}'")
        conn.execute("LOAD semantic_views")
        return conn


    def open_readonly(db_path: str):
        """Open a read-only DuckDB connection and LOAD the extension.

        Note: FORCE INSTALL is unnecessary here — the extension is already
        installed by open_writable() in the same EXT_DIR cache.
        """
        import duckdb

        conn = duckdb.connect(
            db_path,
            read_only=True,
            config={
                "allow_unsigned_extensions": "true",
                "extension_directory": EXT_DIR,
            },
        )
        # Phase 63: this is the line under test. Pre-fix, this raised
        # `Cannot execute statement of type "CREATE_SCHEMA" ... in read-only mode`.
        conn.execute("LOAD semantic_views")
        return conn


    def run_test(name, test_fn):
        print(f"\n{'=' * 60}")
        print(f"TEST: {name}")
        print(f"{'=' * 60}")
        try:
            test_fn()
            print("  RESULT: PASS")
            return True
        except AssertionError as e:
            print(f"  RESULT: FAIL\n  {e}")
            return False
        except Exception as e:
            print(f"  RESULT: ERROR\n  {type(e).__name__}: {e}")
            traceback.print_exc()
            return False


    def test_fresh_readonly_empty_list():
        """RO-01 / RO-03 / RO-04: fresh read-only DB, no _definitions table."""
        import duckdb

        with tempfile.TemporaryDirectory() as tmp:
            db = str(Path(tmp) / "fresh.duckdb")
            # RESEARCH §9 Risk 5: empty zero-byte files cannot be opened
            # read-only. Bootstrap a valid DuckDB header by opening writable
            # and closing — but do NOT load the extension, so semantic_layer
            # schema is never created.
            bootstrap = duckdb.connect(db)
            bootstrap.execute("SELECT 1")
            bootstrap.close()

            ro = open_readonly(db)
            try:
                # RO-03: list_semantic_views() returns empty (not a catalog error).
                rows = ro.execute("SELECT name FROM list_semantic_views()").fetchall()
                assert rows == [], f"expected empty list, got: {rows}"

                # RO-04: describe_semantic_view('missing') → clean "does not exist".
                try:
                    ro.execute("FROM describe_semantic_view('missing')").fetchall()
                    raise AssertionError("describe_semantic_view should have failed for missing view")
                except duckdb.Error as e:
                    msg = str(e)
                    assert "does not exist" in msg, (
                        f"expected 'does not exist' in error, got: {msg}"
                    )

                # RO-04 sub: FROM semantic_view('missing', ...) → clean "does not exist".
                try:
                    ro.execute(
                        "SELECT * FROM semantic_view('missing', dimensions := ['x'])"
                    ).fetchall()
                    raise AssertionError("semantic_view should have failed for missing view")
                except duckdb.Error as e:
                    msg = str(e)
                    assert "does not exist" in msg, (
                        f"expected 'does not exist' in error, got: {msg}"
                    )
            finally:
                ro.close()


    def test_bootstrapped_readonly_query_works():
        """RO-01 / RO-02: bootstrap writable, reopen read-only, query works."""
        with tempfile.TemporaryDirectory() as tmp:
            db = str(Path(tmp) / "bootstrapped.duckdb")
            rw = open_writable(db)
            try:
                rw.execute(
                    "CREATE TABLE orders ("
                    "  id INTEGER PRIMARY KEY,"
                    "  region VARCHAR,"
                    "  amount DECIMAL(10,2)"
                    ")"
                )
                rw.execute(
                    "INSERT INTO orders VALUES (1, 'US', 100), (2, 'EU', 200), (3, 'US', 50)"
                )
                rw.execute(
                    "CREATE SEMANTIC VIEW v AS "
                    "  TABLES (o AS orders PRIMARY KEY (id)) "
                    "  DIMENSIONS (o.region AS o.region) "
                    "  METRICS (o.total AS SUM(o.amount))"
                )
            finally:
                rw.close()

            ro = open_readonly(db)
            try:
                # RO-02: list_semantic_views() returns the bootstrapped view.
                names = [r[0] for r in ro.execute(
                    "SELECT name FROM list_semantic_views()"
                ).fetchall()]
                assert names == ["v"], f"expected ['v'], got: {names}"

                # RO-02: describe_semantic_view returns rows.
                desc = ro.execute("FROM describe_semantic_view('v')").fetchall()
                assert len(desc) > 0, "describe_semantic_view should return metadata rows"

                # RO-02: semantic_view returns aggregated rows.
                rows = ro.execute(
                    "SELECT region, total FROM semantic_view("
                    "  'v', dimensions := ['region'], metrics := ['total']"
                    ") ORDER BY region"
                ).fetchall()
                regions = {r[0] for r in rows}
                assert regions == {"EU", "US"}, f"expected {{EU, US}}, got: {regions}"
            finally:
                ro.close()


    def test_readonly_ddl_fails():
        """RO-05: CREATE / DROP / ALTER on bootstrapped read-only DB → DuckDB read-only error."""
        import duckdb

        with tempfile.TemporaryDirectory() as tmp:
            db = str(Path(tmp) / "ddl_ro.duckdb")
            rw = open_writable(db)
            try:
                rw.execute(
                    "CREATE TABLE orders (id INTEGER PRIMARY KEY, amount DECIMAL(10,2))"
                )
                rw.execute(
                    "CREATE SEMANTIC VIEW v AS "
                    "  TABLES (o AS orders PRIMARY KEY (id)) "
                    "  DIMENSIONS (o.id AS o.id) "
                    "  METRICS (o.t AS SUM(o.amount))"
                )
            finally:
                rw.close()

            ro = open_readonly(db)
            try:
                # RO-05: DROP on existing view → DELETE on caller fails read-only.
                try:
                    ro.execute("DROP SEMANTIC VIEW v")
                    raise AssertionError("DROP should fail on read-only DB")
                except duckdb.Error as e:
                    msg = str(e).lower()
                    assert "read-only" in msg, f"expected 'read-only' substring, got: {e}"

                # RO-05: ALTER RENAME on existing view → UPDATE fails read-only.
                try:
                    ro.execute("ALTER SEMANTIC VIEW v RENAME TO w")
                    raise AssertionError("ALTER should fail on read-only DB")
                except duckdb.Error as e:
                    msg = str(e).lower()
                    assert "read-only" in msg, f"expected 'read-only' substring, got: {e}"

                # RO-05: ALTER SET COMMENT on existing view → UPDATE fails read-only.
                try:
                    ro.execute("ALTER SEMANTIC VIEW v SET COMMENT = 'hi'")
                    raise AssertionError("ALTER SET COMMENT should fail on read-only DB")
                except duckdb.Error as e:
                    msg = str(e).lower()
                    assert "read-only" in msg, f"expected 'read-only' substring, got: {e}"

                # RO-05: CREATE new view → INSERT fails read-only.
                try:
                    ro.execute(
                        "CREATE SEMANTIC VIEW w AS "
                        "  TABLES (o AS orders PRIMARY KEY (id)) "
                        "  DIMENSIONS (o.id AS o.id) "
                        "  METRICS (o.c AS COUNT(*))"
                    )
                    raise AssertionError("CREATE should fail on read-only DB")
                except duckdb.Error as e:
                    msg = str(e).lower()
                    assert "read-only" in msg, f"expected 'read-only' substring, got: {e}"
            finally:
                ro.close()


    if __name__ == "__main__":
        results = [
            run_test("test_fresh_readonly_empty_list", test_fresh_readonly_empty_list),
            run_test("test_bootstrapped_readonly_query_works", test_bootstrapped_readonly_query_works),
            run_test("test_readonly_ddl_fails", test_readonly_ddl_fails),
        ]
        passed = sum(results)
        total = len(results)
        print(f"\n{'=' * 60}")
        print(f"SUMMARY: {passed}/{total} tests passed")
        print(f"{'=' * 60}")
        sys.exit(0 if passed == total else 1)
    ```

    Notes:
    - `test_ducklake_helpers.get_ext_dir()` and `get_extension_path()` are reused across all integration tests (verified against `test_multi_db_isolation.py:38`). No new helper needed.
    - The `read_only=True` kwarg on `duckdb.connect` is documented since DuckDB 0.5.x; pinned `duckdb==1.5.2` in PEP-723 metadata matches the rest of the test suite (see `examples/transactional_ddl.py:4`).
    - The substring assertion `"read-only" in msg.lower()` is intentional per RESEARCH §4 "Coverage strategy — RO-05 acceptance flexibility": tests must NOT pin to the full DuckDB error sentence.
    - The fresh-DB scenario uses `bootstrap.execute("SELECT 1")` to ensure DuckDB writes a valid header (RESEARCH §9 Risk 5).
  </action>
  <verify>
    <automated>just build && uv run test/integration/test_readonly_load.py</automated>
  </verify>
  <acceptance_criteria>
    - File `test/integration/test_readonly_load.py` exists.
    - `grep -n "def test_fresh_readonly_empty_list" test/integration/test_readonly_load.py` returns 1 match.
    - `grep -n "def test_bootstrapped_readonly_query_works" test/integration/test_readonly_load.py` returns 1 match.
    - `grep -n "def test_readonly_ddl_fails" test/integration/test_readonly_load.py` returns 1 match.
    - `grep -nE "read_only\\s*=\\s*True" test/integration/test_readonly_load.py` returns at least 1 match.
    - `grep -nE 'assert .*"read-only"' test/integration/test_readonly_load.py` returns at least 4 matches (DROP, ALTER RENAME, ALTER SET COMMENT, CREATE).
    - `uv run test/integration/test_readonly_load.py` exits 0; final line of output reads `SUMMARY: 3/3 tests passed`.
  </acceptance_criteria>
  <done>
    - All three test functions pass against the Plan 01 extension binary.
    - Test file follows the exact PEP-723 + helper-import + run_test pattern of `test_multi_db_isolation.py`.
    - Exit code is the success/failure signal for `just test-readonly`.
  </done>
</task>

<task type="auto">
  <name>Task 2: Create test/sql/readonly_load.test (smoke fixture per Wave 0 spike outcome)</name>
  <files>test/sql/readonly_load.test</files>
  <read_first>
    - test/sql/extension_reload.test (full file — exact comment-header style and `require semantic_views` + `LOAD semantic_views;` shape)
    - .planning/phases/63-readonly-database-load-support/63-RESEARCH.md §3 Q6 (Wave 0 spike outcome: runner does NOT support `load <path> readonly`; full coverage delegated to TEST-02)
  </read_first>
  <action>
    Wave 0 spike confirmed: `grep -rn 'readonly' python_runner/` returned zero results. Our sqllogictest runner (`python_runner/`) does NOT support a `load <path> readonly` directive. Per RESEARCH §3 Q6, the sqllogictest fixture is therefore a SMOKE fixture documenting the deferral, with full read-only scenario coverage delegated to `test/integration/test_readonly_load.py`.

    Create `test/sql/readonly_load.test`:

    ```
    # Phase 63 (v0.9.0) — Read-only LOAD support smoke fixture.
    #
    # The Python sqllogictest runner in python_runner/ does NOT support
    # a `load <path> readonly` directive (Wave 0 spike: `grep -rn 'readonly'
    # python_runner/` returned zero results). DuckDB's access_mode is a
    # startup-only setting and cannot be changed via SET on an open
    # connection, so we cannot toggle a writable connection into read-only
    # mode mid-fixture either.
    #
    # FULL three-scenario coverage of RO-01..RO-05 lives in
    # test/integration/test_readonly_load.py (run via `just test-readonly`,
    # which is part of `just test-all`). That test uses
    # `duckdb.connect(path, read_only=True)` from the Python API where the
    # read-only kwarg is unambiguously available.
    #
    # This sqllogictest fixture pins the WRITABLE bootstrap path that the
    # read-only scenario depends on: a CREATE SEMANTIC VIEW writes the row
    # to semantic_layer._definitions, and a subsequent reader query returns
    # it. If this smoke fixture breaks, the read-only integration test is
    # also expected to break, surfacing the regression earlier in CI.
    #
    # See .planning/phases/63-readonly-database-load-support/63-RESEARCH.md
    # §3 Q6 for the full deferral analysis.

    require semantic_views

    statement ok
    LOAD semantic_views;

    # --- Writable bootstrap that the read-only scenario consumes ------------

    statement ok
    CREATE TABLE orders (id INTEGER PRIMARY KEY, region VARCHAR, amount DECIMAL(10,2));

    statement ok
    INSERT INTO orders VALUES (1, 'US', 100), (2, 'EU', 200), (3, 'US', 50);

    statement ok
    CREATE SEMANTIC VIEW v_readonly_smoke AS
      TABLES (o AS orders PRIMARY KEY (id))
      DIMENSIONS (o.region AS o.region)
      METRICS (o.total AS SUM(o.amount));

    # The bootstrapped view is visible.
    query I
    SELECT count(*) FROM list_semantic_views() WHERE name = 'v_readonly_smoke';
    ----
    1

    # The semantic_layer._definitions row exists (this is the row a
    # subsequent read-only reopen must surface via list_semantic_views()).
    query I
    SELECT count(*) FROM semantic_layer._definitions WHERE name = 'v_readonly_smoke';
    ----
    1

    # Cleanup so re-runs of the test stay idempotent.
    statement ok
    DROP SEMANTIC VIEW v_readonly_smoke;
    ```

    Notes:
    - The `require semantic_views` directive matches existing fixtures (`extension_reload.test:14`).
    - The `query I` block uses `count(*)` returning a single integer, matching the runner's column-type inference (cf. `extension_reload.test:32-35`).
    - The fixture is fully writable — there is NO attempt to switch to read-only mode. The integration test (Task 1) carries that.
    - If a future change to `python_runner/` adds `load <path> readonly` support, this fixture should be expanded to cover scenarios (a)/(b)/(c) from RESEARCH §3 Q6 directly. Document that as a follow-up TODO comment if desired, but do not gate Phase 63 on it.
  </action>
  <verify>
    <automated>just test-sql</automated>
  </verify>
  <acceptance_criteria>
    - File `test/sql/readonly_load.test` exists.
    - `grep -n "require semantic_views" test/sql/readonly_load.test` returns 1 match.
    - `grep -n "CREATE SEMANTIC VIEW v_readonly_smoke" test/sql/readonly_load.test` returns 1 match.
    - `grep -n "DROP SEMANTIC VIEW v_readonly_smoke" test/sql/readonly_load.test` returns 1 match.
    - `grep -nE "Wave 0 spike|python_runner/" test/sql/readonly_load.test` returns at least 1 match (the deferral comment is present and findable).
    - `just test-sql` exits 0; output includes `readonly_load.test` in the executed set with `OK` / passing status (not skipped).
  </acceptance_criteria>
  <done>
    - sqllogictest fixture exists, documents the runner-support deferral, exercises the writable bootstrap path that the read-only integration test depends on, runs cleanly under `just test-sql`.
  </done>
</task>

<task type="auto">
  <name>Task 3: Add `test-readonly` recipe to justfile and thread into `test-all`</name>
  <files>justfile</files>
  <read_first>
    - justfile (full file — existing recipe pattern at lines 91-126; `test-all` chain at line 137)
    - .planning/phases/63-readonly-database-load-support/63-RESEARCH.md §6 (justfile wiring guidance)
  </read_first>
  <action>
    1. **Add a new recipe `test-readonly`** between the existing `test-multi-db` recipe (ends ~line 126) and the `test-concurrent` recipe (starts ~line 128). Use the EXACT same pattern as `test-multi-db`:

    ```
    # Read-only database LOAD regression: bootstrap a view writable, reopen
    # the file read-only via duckdb.connect(path, read_only=True), and
    # verify LOAD succeeds, list_semantic_views() returns the bootstrapped
    # view, semantic_view(...) queries work, and CREATE/DROP/ALTER fail
    # with DuckDB's standard read-only error. Phase 63 (v0.9.0). See
    # test/sql/readonly_load.test for the writable-side smoke fixture and
    # the explanation of why full read-only coverage lives here rather
    # than in sqllogictest.
    test-readonly: build
        uv run test/integration/test_readonly_load.py
    ```

    2. **Update the `test-all` recipe** at line 137. Current line:

    ```
    test-all: _ensure-test-deps test-rust test-sql test-ducklake-ci test-vtab-crash test-caret test-adbc test-large-view test-multi-db test-concurrent
    ```

    Append `test-readonly` to the dependency list, placing it AFTER `test-multi-db` to mirror the ordering choice in the file:

    ```
    test-all: _ensure-test-deps test-rust test-sql test-ducklake-ci test-vtab-crash test-caret test-adbc test-large-view test-multi-db test-readonly test-concurrent
    ```

    3. **Update the comment block above `test-all`** (lines 134-136) to mention the new recipe. Replace:

    ```
    # Run all tests: Rust unit tests + SQL logic tests + DuckLake integration + vtab crash + caret position + ADBC + large-view + multi-DB + concurrent
    ```

    with:

    ```
    # Run all tests: Rust unit tests + SQL logic tests + DuckLake integration + vtab crash + caret position + ADBC + large-view + multi-DB + read-only + concurrent
    ```

    Notes:
    - Do NOT touch the `release` recipe at lines 203+ (out of scope).
    - Recipe naming convention: `test-<feature>` lowercase with hyphen, matching `test-multi-db`, `test-large-view`, `test-vtab-crash`.
    - The `: build` dependency mirrors `test-multi-db` and ensures the extension binary is fresh before the integration test runs.
  </action>
  <verify>
    <automated>just --list 2>&1 | grep -E "test-readonly\\s+" && just --evaluate 2>/dev/null; just test-readonly</automated>
  </verify>
  <acceptance_criteria>
    - `grep -nE "^test-readonly: build$" justfile` returns 1 match.
    - `grep -nE "uv run test/integration/test_readonly_load.py" justfile` returns 1 match.
    - `grep -nE "^test-all:.*test-readonly" justfile` returns 1 match (test-readonly is in the dependency chain).
    - `just --list` lists `test-readonly` in the available recipes.
    - `just test-readonly` exits 0 (runs the integration test green).
    - `grep -nE "read-only" justfile` returns at least 2 matches (the recipe comment and the `test-all` comment update).
  </acceptance_criteria>
  <done>
    - `just test-readonly` is a working recipe that builds the extension and runs the Python integration test.
    - `just test-all` includes `test-readonly` in its dependency chain.
    - Documentation comments updated.
  </done>
</task>

<task type="auto">
  <name>Task 4: Run full `just test-all` and `just ci` to confirm no regressions</name>
  <files></files>
  <read_first>
    - justfile (post-Task 3)
    - CLAUDE.md (quality gate section: `just test-all` is the gate; `just ci` adds clippy/fmt/cargo-deny/fuzz-compile/docs-check)
    - feedback_no_parallel_builds (run in foreground only)
    - feedback_no_tail_on_long_commands (redirect to $TMPDIR; tail the file after)
  </read_first>
  <action>
    Run the full quality gate, IN FOREGROUND, one at a time. Per `feedback_no_tail_on_long_commands`, redirect long-running commands to a file under `$TMPDIR` and tail the file after — do NOT pipe directly to `tail -N`.

    1. `just test-all > $TMPDIR/phase63_test_all.log 2>&1; STATUS=$?; tail -n 80 $TMPDIR/phase63_test_all.log; exit $STATUS` — must exit 0. This runs Rust tests, sqllogictest (including the new `readonly_load.test`), DuckLake CI, vtab crash, caret, ADBC, large-view, multi-DB, **`test-readonly`** (new), and concurrent.

    2. `just ci > $TMPDIR/phase63_ci.log 2>&1; STATUS=$?; tail -n 80 $TMPDIR/phase63_ci.log; exit $STATUS` — must exit 0. This runs lint (fmt + clippy + cargo-deny) + test-all + check-fuzz + docs-check.

    If either step fails:
    - For `test-all` failures: identify the failing test from the tail output, fix it, rerun the failing recipe directly (`just test-readonly` or `cargo test foo`) for fast iteration, then rerun `just test-all` once green.
    - For `ci` failures: distinguish lint failures (run `cargo fmt` + fix clippy warnings), cargo-deny failures (likely unrelated dep advisory — investigate but do not bypass), or `docs-check` failures (Plan 03 lands the doc changes; if `docs-check` complains about missing references this plan doesn't add, that is Plan 03's responsibility — Plan 03 must be ordered to land before `just ci` if docs are involved).

    NOTE: docs-check may fail in this plan because Plan 03 has not yet added the `_explanation-txn-ddl-readonly` label that DOC-03 reference notes will cross-reference. If that is the only failure: skip `just ci` here, document the partial green state in the SUMMARY, and let Plan 04 (release) be the gate that runs `just ci` after Plan 03 lands.
  </action>
  <verify>
    <automated>just test-all > $TMPDIR/phase63_test_all_verify.log 2>&1; STATUS=$?; tail -n 50 $TMPDIR/phase63_test_all_verify.log; exit $STATUS</automated>
  </verify>
  <acceptance_criteria>
    - `just test-all` exits 0; the log output (tail) shows `test-readonly` ran and emitted `SUMMARY: 3/3 tests passed`.
    - `just test-all` log shows `readonly_load.test` was executed by sqllogictest (not skipped).
    - All pre-existing `test-all` recipes still pass (no regressions in `test-multi-db`, `test-concurrent`, `test-adbc`, etc.).
    - `just ci` exits 0 OR (acceptable, documented in SUMMARY) only `docs-check` fails because Plan 03 has not yet added the doc cross-references.
    - Branch is still `milestone/v0.9.0` (`git branch --show-current` outputs exactly `milestone/v0.9.0`).
  </acceptance_criteria>
  <done>
    - `just test-all` green with the new test-readonly recipe wired in.
    - Either `just ci` is green, or the SUMMARY explicitly notes that docs-check will be re-run after Plan 03.
  </done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| Test fixtures → CI runner | Tests use only `tempfile.TemporaryDirectory()` paths; no shared state between test functions. |
| Python integration test → built extension binary | `EXT_PATH` resolves to `build/debug/semantic_views.duckdb_extension`; FORCE INSTALL writes to `EXT_DIR` cache (test-controlled). |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-63-T1 | Tampering | Test asserts on full DuckDB error sentence; minor DuckDB version bump rewords it; tests go red without an actual regression | mitigate | Tests assert only on `"read-only" in str(e).lower()` per RESEARCH §4 "Coverage strategy — RO-05 acceptance flexibility". Documented inline. |
| T-63-T2 | Denial of Service | `tempfile.TemporaryDirectory()` cleanup fails after a test crash, leaving DB files on disk | accept | Standard `tempfile` semantics; failures auto-clean within the temp dir. CI runners reset between jobs. |
| T-63-T3 | Information Disclosure | Test logs include the full DuckDB error message which may contain DB paths | accept | Test paths are tempdir-only; no production data. The PASS/FAIL log lines are emitted to stdout only when run locally. |
| T-63-T4 | Tampering | sqllogictest smoke fixture leaves `v_readonly_smoke` view in `_definitions` between runs | mitigate | Final `DROP SEMANTIC VIEW v_readonly_smoke;` step in the fixture ensures idempotency. |
</threat_model>

<verification>
After all tasks complete:

1. `just test-readonly` — exits 0; emits `SUMMARY: 3/3 tests passed`.
2. `just test-sql` — exits 0; new `readonly_load.test` runs.
3. `just test-all` — exits 0; full chain including `test-readonly` green.
4. `just ci` — exits 0 (preferred) OR only `docs-check` fails because Plan 03 references are not yet present (acceptable; flag in SUMMARY).
5. `git branch --show-current` — outputs `milestone/v0.9.0`.
</verification>

<success_criteria>
- New Python integration test passes all three scenarios.
- New sqllogictest smoke fixture parses and executes cleanly.
- `justfile` carries the new recipe and `test-all` dependency.
- `just test-all` green.
- `just ci` green (or docs-check deferral documented).
- No changes to Plan 01's Rust files.
</success_criteria>

<output>
After completion, create `.planning/phases/63-readonly-database-load-support/63-02-SUMMARY.md` documenting:
- Three artifacts created/modified with line counts.
- Confirmation of `just test-all` green and the SUMMARY line from the integration test.
- Whether `just ci` was green or only docs-check failed (and why).
- Branch verified as `milestone/v0.9.0`.
- Hand-off: Plan 04 (release) will re-run `just test-all` and `just ci` after Plan 03 (docs) lands.
</output>
