Skip to content

fix(store,bench): a read-only open mode, so a diagnostic cannot mutate its subject (#1328) - #1330

Merged
github-actions[bot] merged 5 commits into
mainfrom
fix/issue-1328-readonly-diagnostics
Aug 4, 2026
Merged

fix(store,bench): a read-only open mode, so a diagnostic cannot mutate its subject (#1328)#1330
github-actions[bot] merged 5 commits into
mainfrom
fix/issue-1328-readonly-diagnostics

Conversation

@robotrocketscience

@robotrocketscience robotrocketscience commented Aug 4, 2026

Copy link
Copy Markdown
Owner

Closes #1328.

MemoryStore(path) is a write open. Two shipped diagnostics point their store argument at the live .git/aelfrice/memory.db in their own usage text and open it that way, so running either mutates the corpus it exists to measure.

Reproduced before fixing

On a .backup() copy of the live store, planting one time-boxed lock whose window had already closed, then doing nothing but constructing a MemoryStore and closing it:

belief ab96e9d3501b…  lock before=user  after=none
lock_expiry_sweep_at written: True
db bytes changed by a bare MemoryStore open: True

No analysis code ran. The constructor did it.

What a bare open does

Not just the #1314 sweep — this is a class of exposure, which is why the fix is a mode rather than three edits:

  • the DDL battery plus any pending _MIGRATIONS / _POST_MIGRATION_INDEXES;
  • the schema_meta store-generation seed and the origin backfill;
  • _resolve_local_scope_id, which mints and persists a federation identity on a store that has none;
  • eleven _run_guarded_migration passes, each able to write a migration_failed: marker;
  • sweep_expired_locks, the one that changes user-visible state.

It also takes write locks, so a diagnostic run alongside a live session contends with it.

The fix

MemoryStore(..., read_only=True) opens file:<path>?mode=ro and skips the whole open-time write window. The engine refuses writes, so the guarantee does not rest on the caller's discipline — which matters here, because the previous guarantee was a sentence in a benchmark docstring and it did not hold.

Three details worth surfacing for review:

  • _run_guarded_migration returns early on a read-only handle. This is not needed for correctness — mode=ro already refuses and the guard already swallows the raise. It is needed for quiet: without it, eleven passes raise and log at ERROR on every read-only open. I only found that because the mutation test for the gate came back green; there is now a log-quiet arm that fails when the gate is removed, because the correctness arms cannot see it.
  • _read_only_scope_id reads the persisted id and returns "" when absent rather than minting one. The value is consumed only by write paths, which cannot run here.
  • A missing file raises rather than being created. The read-write default creates an empty store, which reads as "the corpus is empty" rather than "that is not the corpus".

The schema is taken as found — no migration runs, so an older store is read at whatever shape it has. Correct for a diagnostic (observe, don't upgrade), wrong for anything needing the current schema, hence opt-in.

One revision to the issue's acceptance criteria

I filed AC1 as "neither constructs a MemoryStore". That is wrong for temporal_spine_shadow.py, which has a --backfill flag that legitimately writes. The rule that survives contact is open for write only when writes were asked for, so that script uses read_only=not args.backfill and r3_idf_clip_bound.py — which has no write mode — is read-only outright. A raw-sqlite3 shim would also have meant re-implementing BM25Index.build's store interface, and the next diagnostic would have re-implemented it again.

The guard

The static test enumerates benchmarks/*.py from the directory and parses each with ast, flagging any MemoryStore(...) call without read_only= in a module that names a live-store path. A literal list of the two offenders would pass forever while the directory grew around it, and growth is the failure mode — three benchmarks reached for the convenient call independently, the third caught in review of #1316 (684c0899). Paired with a self-test that feeds the detector a synthetic offender, so a refactor that stops matching fails there rather than turning the real check green.

Verification

Mutations, all verified red:

mutation result
read_only stops opening mode=ro 2 failed, 5 passed
a benchmark reverts to a bare open 1 failed, 6 passed
the guarded-migration gate is removed 1 failed, 7 passed (the log-quiet arm)

Full suite: 7,081 passed, 69 skipped, 71 xfailed. CHANGELOG insert-only at the head of Unreleased/Fixed (104 → 105, dupes gate clean). Four atomic signed commits.

Reproduction run against a read-only .backup() copy of the repo-local live store (44,594 active beliefs), 2026-08-04.

Summary by Sourcery

Introduce a read-only mode for MemoryStore to ensure diagnostics and benchmarks can inspect stores without mutating them, and add guards and tests to prevent future accidental write opens against live data.

New Features:

  • Add a read_only flag to MemoryStore to support opening SQLite stores in engine-enforced read-only mode for diagnostic use.

Bug Fixes:

  • Ensure read-only MemoryStore opens skip migrations, schema seeding, scope-id minting, and lock sweeps so diagnostics do not alter the stores they analyze.
  • Update temporal_spine_shadow and r3_idf_clip_bound benchmarks to open the live store in read-only mode except when an explicit backfill/write operation is requested.
  • Make read-only MemoryStore opens fail on missing files instead of creating empty stores, avoiding silent mis-targeting of diagnostic runs.

Enhancements:

  • Short-circuit guarded migrations on read-only MemoryStore handles to avoid noisy error logging and unnecessary migration-failure markers.
  • Add a helper to read existing scope IDs without minting new ones when a store is opened read-only, tolerating pre-schema stores.

Documentation:

  • Document the diagnostic safety fix in the v4 CHANGELOG, including the new read-only MemoryStore behavior and benchmark changes.

Tests:

  • Add behavioural tests verifying that read-only MemoryStore opens do not sweep expired locks, do not change file bytes, and that writes are refused at the SQLite engine level.
  • Add tests asserting that missing files cause read-only opens to raise instead of creating new stores.
  • Introduce a static AST-based guard that scans benchmark scripts to ensure any MemoryStore use against live store paths includes a read_only argument, with a self-test to validate the detector and prevent regressions.

Summary by CodeRabbit

  • New Features

    • Added read-only mode for opening memory stores during diagnostics and benchmarks.
    • Read-only access avoids database changes, migrations, cleanup, and schema updates.
    • Missing databases now fail clearly instead of being created in read-only mode.
    • Benchmark backfills continue to use write access when explicitly requested.
  • Bug Fixes

    • Prevented diagnostic operations from unintentionally modifying stored data or lock state.
  • Tests

    • Added coverage confirming read-only operations preserve database contents and behavior.

… subject (#1328)

`MemoryStore(path)` is a write open. It runs the DDL battery, any pending
migrations, the `schema_meta` generation seed, `_resolve_local_scope_id`
(which mints and persists a federation identity on a store that has none),
eleven guarded one-shot passes, and -- since #1314 -- `sweep_expired_locks`,
which flips expired user locks back to unlocked.

That is correct for a real open and wrong for a diagnostic. Two shipped
benchmarks point their store argument at the live
`.git/aelfrice/memory.db` in their own usage text, so running them mutated
the corpus they existed to measure. Reproduced on a copy of the live store:
planting one expired time-boxed lock and then doing nothing but
constructing a MemoryStore and closing it flipped `lock_level` from `user`
to `none`, wrote a sweep marker, and changed the file bytes.

`read_only=True` opens `file:<path>?mode=ro` and skips the whole open-time
write window. The engine refuses writes, so the guarantee does not rest on
the caller's discipline -- which matters, because the previous guarantee
was a sentence in a benchmark docstring and it did not hold.

Three details worth stating:

- `_run_guarded_migration` returns early on a read-only handle. Not needed
  for correctness (mode=ro already refuses, and the guard swallows the
  raise), but without it eleven passes raise and log at ERROR on every
  read-only open. Pinned by a log-quiet test, since the correctness tests
  cannot see it.
- `_read_only_scope_id` reads the persisted scope id and returns "" when
  absent rather than minting one. The value is consumed only by write
  paths, which cannot run here.
- A missing file raises instead of being created. The read-write default
  creates an empty store, which reads as "the corpus is empty" rather than
  as "that is not the corpus".

The schema is taken as found: no migration runs, so an older store is read
at whatever shape it has. Correct for a diagnostic, wrong for anything
needing the current schema, hence opt-in.
`r3_idf_clip_bound.py` documents `--store .git/aelfrice/memory.db` and
`temporal_spine_shadow.py` points `--db` at the same file; both opened it
read-write.

`temporal_spine_shadow` keeps a write handle when `--backfill` is passed,
because that flag is a deliberate write and the honest rule is not "never
construct a MemoryStore" but "open for write only when writes were asked
for". `r3_idf_clip_bound` has no write mode and is read-only outright.

`benchmarks/consolidate_blocking_recall.py` was already fixed this way in
684c089 after review; this closes the other two instances of the same
class.
…t regress (#1328)

Behavioural arms prove the specific mutation observed is prevented -- the
expired lock survives a read-only open -- with a read-write control so the
assertion cannot pass on a fixture that never armed. A byte-comparison arm
catches the writes that change nothing a query can see (migrations, the
generation seed, the scope-id mint), and an engine-refusal arm covers the
writes nobody thought of, which is the difference between this and the
docstring it replaces.

The static guard enumerates `benchmarks/*.py` from the directory rather
than from a literal list, and parses with `ast` rather than grepping, so it
flags any `MemoryStore(...)` call lacking `read_only=` in a module that
names a live store path. A literal list would pass forever while the
directory grew around it, and growth is the failure mode: three benchmarks
reached for the convenient call independently. Paired with a
self-test that feeds the detector a synthetic offender, so a refactor that
stops matching fails there instead of turning the real check green.
Insert-only at the head of Unreleased/Fixed; bullet count +1, dupes gate
clean. Carries the reproduction rather than the claim, since the previous
read-only guarantee was also written down and was not true.
@robotrocketscience robotrocketscience added the author-Setr PR coordination mutex label Aug 4, 2026

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry @robotrocketscience, you have reached your weekly rate limit of 500000 diff characters.

Please try again later or upgrade to continue using Sourcery

@robotrocketscience robotrocketscience added the attn:review Needs review (PR open, awaiting reviewer) label Aug 4, 2026
@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@robotrocketscience, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 53 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 01fbe37d-5a0c-4e2b-8f28-925f5486c86f

📥 Commits

Reviewing files that changed from the base of the PR and between ffa7c74 and f288578.

📒 Files selected for processing (1)
  • src/aelfrice/store.py
📝 Walkthrough

Walkthrough

MemoryStore now supports SQLite read-only opens that skip all open-time writes. Diagnostic benchmarks use read-only access by default, while temporal backfill retains write access. Regression tests validate immutability and benchmark enforcement.

Changes

Read-only diagnostic access

Layer / File(s) Summary
MemoryStore read-only initialization
src/aelfrice/store.py
MemoryStore accepts read_only=True, opens existing databases with SQLite mode=ro, reads persisted scope data, and skips schema setup, migrations, backfills, scope generation, and lock sweeps.
Diagnostic wiring and regression validation
benchmarks/r3_idf_clip_bound.py, benchmarks/temporal_spine_shadow.py, tests/test_readonly_diagnostics_1328.py, CHANGELOG/v4.md
Diagnostics use read-only access during evaluation. Temporal backfill requests writable access. Tests validate database immutability, missing-file handling, migration behavior, and explicit benchmark access modes. The changelog records the behavior.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Diagnostic
  participant MemoryStore
  participant SQLite
  Diagnostic->>MemoryStore: open with read_only=True
  MemoryStore->>SQLite: connect using mode=ro
  SQLite-->>MemoryStore: provide existing database data
  MemoryStore-->>Diagnostic: return read-only store
Loading

Possibly related PRs

Suggested labels: author-garsecg

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The PR meets the safety goal but does not meet the issue's explicit criterion that both diagnostics avoid constructing MemoryStore. Update issue #1328 to allow engine-enforced MemoryStore(read_only=True), or change both diagnostics to direct SQLite mode=ro connections.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the read-only store and benchmark fix and the prevented mutation.
Description check ✅ Passed The description explains the defect, solution, linked issue, verification results, tests, and reviewer considerations, despite omitting some template headings.
Out of Scope Changes check ✅ Passed The changes support read-only diagnostic access, backfill preservation, regression coverage, documentation, or enforcement of the linked objective.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/issue-1328-readonly-diagnostics

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@sourcery-ai

sourcery-ai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Reviewer's Guide

Introduce a read-only mode for MemoryStore and ensure shipped benchmarks and tests use it so diagnostics cannot mutate the store they measure, while adding guards around migrations and live-store usage.

Sequence diagram for benchmark store opening with read-only mode

sequenceDiagram
    actor Operator
    participant temporal_spine_shadow as temporal_spine_shadow_main
    participant r3_idf_clip_bound as r3_idf_clip_bound_main
    participant MemoryStore
    participant SQLiteEngine

    Operator->>temporal_spine_shadow: run with argv
    temporal_spine_shadow->>temporal_spine_shadow: parse_args()
    temporal_spine_shadow->>MemoryStore: MemoryStore(args.db, read_only=not args.backfill)
    alt args.backfill is True
        MemoryStore->>SQLiteEngine: sqlite3.connect(path)
        MemoryStore->>SQLiteEngine: PRAGMA journal_mode=WAL
        MemoryStore->>SQLiteEngine: PRAGMA synchronous=NORMAL
        MemoryStore->>MemoryStore: _retry_on_schema_change(_apply_open_schema)
        MemoryStore->>MemoryStore: _run_guarded_migration(pass_*)
        MemoryStore->>MemoryStore: sweep_expired_locks()
    else args.backfill is False (diagnostic)
        MemoryStore->>SQLiteEngine: sqlite3.connect(file_path_mode_ro, uri=True)
        MemoryStore->>MemoryStore: _read_only_scope_id()
        Note over MemoryStore,SQLiteEngine: no DDL, migrations, seeds, or sweeps run
    end

    Operator->>r3_idf_clip_bound: run with argv
    r3_idf_clip_bound->>r3_idf_clip_bound: parse_args()
    r3_idf_clip_bound->>MemoryStore: MemoryStore(args.store, read_only=True)
    MemoryStore->>SQLiteEngine: sqlite3.connect(file_path_mode_ro, uri=True)
    MemoryStore->>MemoryStore: _read_only_scope_id()
    Note over MemoryStore,SQLiteEngine: diagnostic reads without mutating store
Loading

File-Level Changes

Change Details Files
Add a diagnostic-safe read-only open mode to MemoryStore and gate all open-time writes and migrations behind it.
  • Extend MemoryStore.init with a read_only flag and documentation of the diagnostic-safe semantics.
  • Open SQLite connections in mode=ro via URI when read_only=True and require the file to already exist.
  • Skip WAL/synchronous pragmas and the open-time schema/migration/lock-sweep window when read_only is enabled.
  • Store the read_only state on the instance and use it to conditionally run open-time setup.
src/aelfrice/store.py
Ensure read-only opens do not mint or persist a local scope id and add a helper compatible with pre-schema stores.
  • Introduce _read_only_scope_id to read the persisted scope id or return an empty string when absent.
  • Handle missing schema_meta table gracefully for read-only opens by catching sqlite3.DatabaseError.
  • Use _read_only_scope_id instead of _resolve_local_scope_id when opening a store in read-only mode.
src/aelfrice/store.py
Prevent guarded migration passes from running on read-only handles while preserving the success-path semantics.
  • Add an early return in _run_guarded_migration when self._read_only is true so migration passes are skipped on read-only stores.
  • Keep guarded migration behavior unchanged for write-capable stores, including logging and failure markers.
src/aelfrice/store.py
Update shipped benchmarks to open MemoryStore in read-only mode except when explicit writes are requested.
  • Change temporal_spine_shadow.py to pass read_only=not args.backfill so only the backfill path writes.
  • Change r3_idf_clip_bound.py to pass read_only=True unconditionally when opening the store for analysis.
benchmarks/temporal_spine_shadow.py
benchmarks/r3_idf_clip_bound.py
Document the diagnostic mutation bug and the read-only MemoryStore fix in the changelog.
  • Add a detailed entry under v4 Fixed describing the issue where diagnostics opened the live store read-write and the new read_only mode that prevents this.
  • Note the benchmark changes and the static guard that enforces read_only usage for live-store paths.
CHANGELOG/v4.md
Add behavioural and static tests to verify that read-only diagnostics do not mutate stores and that benchmarks use read_only for live stores.
  • Create tests that assert read-only opens do not sweep expired locks or change DB bytes and that writes are refused at the engine level.
  • Add a test that a missing file with read_only=True raises instead of creating an empty store.
  • Implement an AST-based guard that scans benchmarks for MemoryStore calls lacking read_only when modules mention a live store path.
  • Add a self-test that feeds the guard synthetic offenders and clean examples to ensure the detector keeps working.
  • Add a test ensuring no migration failure logs are emitted during read-only opens, validating the _run_guarded_migration gate.
tests/test_readonly_diagnostics_1328.py

Assessment against linked issues

Issue Objective Addressed Explanation
#1328 Ensure the benchmarks r3_idf_clip_bound.py and temporal_spine_shadow.py access the live store in a read-only way when used as diagnostics, so running them does not mutate locks or other store state.
#1328 Add a static test that enumerates all benchmarks/*.py modules and fails if a module both names a live-store path (e.g. aelfrice/memory.db or AELFRICE_DB) and calls MemoryStore(...) without a read_only= argument.
#1328 Add a behavioural test reproducing the reported mutation: create a temp store with an expired time-boxed lock, open it via the diagnostic read path, and assert that the lock level and file bytes remain unchanged under a read-only open.

Possibly linked issues


Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown

PR-size soft cap

This PR is over the advisory size threshold:

  • 382 changed lines (limit: 200)
  • 5 changed files (limit: 3)

Bigger PRs collide with more open work, which under the parallel-session workflow tends to produce repeated attn:merge-conflict cycles (see #602). When practical, split into smaller PRs that each touch a focused surface.

This is advisory only — nothing is blocked. If the size is intentional (large refactor, module removal, generated code), apply the size:override label and this comment will be removed on the next push.

@robotrocketscience

Copy link
Copy Markdown
Owner Author

[claim:review:Kulili:2026-08-04T22:05:19Z]

…ontract

The docstring says True means the pass completed and False means it
raised. The #1328 read-only gate adds a third case — skipped — and
returns True for it, which the contract as written does not cover.

True is the right choice of the two available: False is consumed as
"record a failure marker", and writing that marker is itself a write.
Worth stating, since a reader checking the return value has no way to
tell a skip from a success.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (4)
src/aelfrice/store.py (2)

1719-1726: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Update the Returns line for the read-only branch.

Line 1719 states that True means the pass completed. On a read-only store the method returns True without running the pass. No call site reads the value today, so behavior is unaffected, but the docstring contract is now inaccurate.

♻️ Proposed docstring correction
-        Returns True if the pass completed, False if it raised.
+        Returns True if the pass completed or was skipped (read-only
+        store), False if it raised.
         """
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/aelfrice/store.py` around lines 1719 - 1726, Update the docstring for the
method containing the _read_only guard to state that it returns True when the
pass completes or is skipped for a read-only store, and False when the pass
raises. Leave the existing return behavior unchanged.

1341-1347: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Escape the file path before building the SQLite URI.

file:{path}?mode=ro parses everything after the first ? as query parameters. A path such as .../memory.db#fragment.db drops the mode=ro query, so the handle can be writable. Use Path(path).as_uri() or percent-encode the path before appending the query.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/aelfrice/store.py` around lines 1341 - 1347, Update the read-only
connection branch in the store initializer to construct a properly escaped
SQLite URI from path before appending the mode=ro query, using
Path(path).as_uri() or equivalent percent-encoding. Preserve the existing
read-only behavior for non-memory paths and the current connection options.
tests/test_readonly_diagnostics_1328.py (2)

167-193: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

The guard misses an aliased import and an indirectly resolved store path.

Two shapes bypass this detector.

_bare_memorystore_calls matches only the literal name MemoryStore. An aliased import evades it:

from aelfrice.store import MemoryStore as Store
s = Store(".git/aelfrice/memory.db")   # not reported

_live_store_paths is a substring match on the module text. A benchmark that resolves the live path through db_paths instead of writing the literal is skipped entirely, so its MemoryStore(...) calls are never inspected.

Both gaps sit on the axis the class docstring names: a new benchmark reaching for the convenient call. Resolving the import alias in _bare_memorystore_calls, and adding db_paths to the _live_store_paths trigger, closes them.

♻️ Proposed detector widening
     `@staticmethod`
     def _live_store_paths(module: Path) -> bool:
         text = module.read_text(encoding="utf-8", errors="replace")
-        return "aelfrice/memory.db" in text or "AELFRICE_DB" in text
+        return (
+            "aelfrice/memory.db" in text
+            or "AELFRICE_DB" in text
+            or "db_paths" in text
+        )
 
     `@staticmethod`
     def _bare_memorystore_calls(module: Path) -> list[int]:
         """Line numbers of `MemoryStore(...)` calls with no `read_only=`."""
         try:
             tree = ast.parse(module.read_text(encoding="utf-8"))
         except SyntaxError:  # pragma: no cover - a broken benchmark
             return []
+        # Resolve `import ... as` so an alias cannot slip past the name
+        # match below. The local binding is what the call site uses.
+        names = {"MemoryStore"}
+        for node in ast.walk(tree):
+            if isinstance(node, (ast.Import, ast.ImportFrom)):
+                for alias in node.names:
+                    if alias.name.rsplit(".", 1)[-1] == "MemoryStore":
+                        names.add(alias.asname or alias.name)
         bare: list[int] = []
         for node in ast.walk(tree):
             if not isinstance(node, ast.Call):
                 continue
             fn = node.func
             name = (
                 fn.id if isinstance(fn, ast.Name)
                 else fn.attr if isinstance(fn, ast.Attribute)
                 else None
             )
-            if name != "MemoryStore":
+            if name not in names:
                 continue
             if not any(k.arg == "read_only" for k in node.keywords):
                 bare.append(node.lineno)
         return bare

Extend test_the_guard_can_actually_fire with an aliased offender so the new branch has its own control.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_readonly_diagnostics_1328.py` around lines 167 - 193, Update
_bare_memorystore_calls to resolve imported aliases, including calls such as
Store(...) when Store aliases MemoryStore, while preserving detection of direct
and attribute forms. Expand _live_store_paths to trigger when module text
references db_paths, and extend test_the_guard_can_actually_fire with a distinct
aliased MemoryStore offender covering the new detection branch.

232-263: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add an arm for a store with no schema_meta scope id.

Every arm here seeds the database through _store_with_an_expired_lock, which opens read-write and therefore mints and persists a local scope id. Both defensive branches of _read_only_scope_id stay unexecuted: the or "" fallback for an absent key, and the except sqlite3.DatabaseError for a missing or incompatible schema_meta table.

store.py documents the second branch as the legacy-store case that read-only mode exists to support, because a read-only open runs no DDL. A refactor that narrows the except clause or drops the fallback would pass this file unchanged.

💚 Proposed test arm
def test_a_read_only_open_of_a_store_without_schema_meta_yields_no_scope_id(
    tmp_path: Path,
) -> None:
    """A store written by a pre-schema_meta binary must still open.

    A read-only open runs no DDL, so `schema_meta` may be absent. The
    honest answer for the scope id is then "none", not a mint and not a
    raise.
    """
    db = tmp_path / "legacy.db"
    con = sqlite3.connect(str(db))
    try:
        con.execute("CREATE TABLE beliefs (id TEXT PRIMARY KEY)")
        con.commit()
    finally:
        con.close()

    store = MemoryStore(str(db), read_only=True)
    try:
        assert store.local_scope_id == ""
    finally:
        store.close()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_readonly_diagnostics_1328.py` around lines 232 - 263, Add a test
alongside test_a_read_only_open_logs_no_migration_failures that creates a legacy
SQLite database containing beliefs but no schema_meta table, opens it with
MemoryStore(read_only=True), and asserts local_scope_id is the empty string
without raising. Ensure the setup avoids _store_with_an_expired_lock so both the
missing-key fallback and DatabaseError path in _read_only_scope_id are
exercised.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@src/aelfrice/store.py`:
- Around line 1719-1726: Update the docstring for the method containing the
_read_only guard to state that it returns True when the pass completes or is
skipped for a read-only store, and False when the pass raises. Leave the
existing return behavior unchanged.
- Around line 1341-1347: Update the read-only connection branch in the store
initializer to construct a properly escaped SQLite URI from path before
appending the mode=ro query, using Path(path).as_uri() or equivalent
percent-encoding. Preserve the existing read-only behavior for non-memory paths
and the current connection options.

In `@tests/test_readonly_diagnostics_1328.py`:
- Around line 167-193: Update _bare_memorystore_calls to resolve imported
aliases, including calls such as Store(...) when Store aliases MemoryStore,
while preserving detection of direct and attribute forms. Expand
_live_store_paths to trigger when module text references db_paths, and extend
test_the_guard_can_actually_fire with a distinct aliased MemoryStore offender
covering the new detection branch.
- Around line 232-263: Add a test alongside
test_a_read_only_open_logs_no_migration_failures that creates a legacy SQLite
database containing beliefs but no schema_meta table, opens it with
MemoryStore(read_only=True), and asserts local_scope_id is the empty string
without raising. Ensure the setup avoids _store_with_an_expired_lock so both the
missing-key fallback and DatabaseError path in _read_only_scope_id are
exercised.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 11ea63ed-a8f1-4530-98ad-71aa0137fe31

📥 Commits

Reviewing files that changed from the base of the PR and between 684c089 and ffa7c74.

📒 Files selected for processing (5)
  • CHANGELOG/v4.md
  • benchmarks/r3_idf_clip_bound.py
  • benchmarks/temporal_spine_shadow.py
  • src/aelfrice/store.py
  • tests/test_readonly_diagnostics_1328.py

@robotrocketscience

Copy link
Copy Markdown
Owner Author

Review — approve. One docstring fix pushed (f2885782).

I arrived at this problem independently twice today, from the other end, so I had the opposite of a fresh perspective — which makes the parts I could falsify worth more than the parts I agreed with on sight. I tried to break the guarantee rather than read it.

The guarantee holds at the engine level

Against a .backup copy of the live 44,594-belief store:

reads work        : 45,946 beliefs; 25 locked
scope id          : '356ca51718fa073b…'   (read, not minted)
  refused: UPDATE beliefs SET lock_level=…  -> attempt to write a readonly database
  refused: INSERT INTO schema_meta…         -> attempt to write a readonly database
  refused: CREATE TABLE zz (a INT)          -> attempt to write a readonly database
bytes unchanged   : True
missing file      : raises — unable to open database file
file created?     : False

All three write classes — row update, schema_meta insert, DDL — are refused by SQLite, not by a Python guard. That is the distinction the PR body draws and it is the right one: the previous guarantee was a docstring sentence and it did not hold.

The missing-file behaviour is the detail I'd have been most likely to get wrong myself. A read-write open creates an empty store, so a diagnostic pointed at a typo'd path reports "the corpus is empty" instead of "that is not the corpus". Raising is correct.

Both guards have teeth — verified by mutation, not assumed

mutation result
plant a benchmark that names the live store and opens it read-write test_no_benchmark_opens_a_named_live_store_read_write fails
revert the _run_guarded_migration read-only gate test_a_read_only_open_logs_no_migration_failures fails

The second is the one worth calling out. The gate is not needed for correctness — mode=ro refuses and the guard swallows the raise — so the correctness arms stay green with it removed, and only the log-quiet arm catches it. The PR body says this was found because the mutation came back green. That is the right way round: a mutation that fails to kill anything is a finding about the tests, not a licence to skip the gate.

The AST-over-the-directory approach is also right, and the body names the reason: three benchmarks reached for the convenient call independently. A literal list of the two known offenders would pass forever while the directory grew around it. I can confirm the growth is real — the third instance it mentions (684c0899) was mine, caught in review of #1316 today.

Forward-compatibility with two benchmarks landing now

I checked the guard against the files about to arrive rather than assuming they were fine:

Both use a raw mode=ro connection with no MemoryStore call, so the detector has nothing to flag. Worth noting that this is a second correct answer the guard permits, and permitting it is right: #1331 needs no store interface at all, whereas temporal_spine_shadow.py would have had to re-implement BM25Index.build's. The body's argument against a raw-sqlite3 shim is about that script, not a general rule, and the guard correctly doesn't try to impose one.

Pushed: the return contract now has three cases

_run_guarded_migration's docstring says True means the pass completed and False means it raised. The read-only gate adds skipped and returns True for it, which the contract as written does not cover — a reader checking the return value cannot distinguish a skip from a success. Stated it, with the reason True is the right choice of the two available: False is consumed as "record a failure marker", and writing that marker is itself a write. Docstring only.

On the revised AC1

Agreed, and the revision is better than what was filed. "Neither constructs a MemoryStore" is wrong for temporal_spine_shadow.py, which has a legitimate --backfill write path; read_only=not args.backfill is the rule that survives contact. Worth having recorded on the issue rather than silently satisfied by a weaker criterion.

Verification

  • Full suite on the branch: 7,081 passed, 69 skipped, 71 xfailed. No contention flake this run.
  • FF on main, discretion grep clean on added lines, all five commits G-signed.

One note, no action

The schema is taken as found, so a read-only open of an older store reads whatever shape it has. The docstring says this and says why it is opt-in. The consequence worth remembering downstream: a diagnostic that reads a column added by a pending migration will get a bare sqlite3.DatabaseError, not a graceful degrade. _read_only_scope_id already handles that for its own read; nothing else has to yet.

@robotrocketscience robotrocketscience added the ready-to-merge Trigger merge-train: FF main to this PR's head label Aug 4, 2026
@github-actions
github-actions Bot merged commit f288578 into main Aug 4, 2026
29 checks passed
@github-actions github-actions Bot removed the ready-to-merge Trigger merge-train: FF main to this PR's head label Aug 4, 2026
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown

merge-train: merged f288578main via FF push.

@robotrocketscience

Copy link
Copy Markdown
Owner Author

[release:review:Kulili:2026-08-04T22:14:47Z]

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

attn:review Needs review (PR open, awaiting reviewer) author-Setr PR coordination mutex

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(bench): two diagnostics open the live store read-write, so running them can flip a user's locks (#1314 sweep)

1 participant