Skip to content

fix(mcp): a reader that does not migrate must report the gap, not raise on it (#751) - #752

Merged
eaitbrahim merged 2 commits into
mainfrom
fix-751-mcp-unmigrated-guards
Sep 7, 2026
Merged

fix(mcp): a reader that does not migrate must report the gap, not raise on it (#751)#752
eaitbrahim merged 2 commits into
mainfrom
fix-751-mcp-unmigrated-guards

Conversation

@eaitbrahim

Copy link
Copy Markdown
Contributor

Closes #751.

keel mcp's doctor tool raised sqlite3.OperationalError: no such table: venue_cash_postures against a database nobody had migrated — out of the handler, taking every other finding with it, on the first tool a client typically calls.

The rule was already written next door

audit_chain_findings states it in its own docstring: a reader that does not migrate meets an un-upgraded schema as an ordinary deployment state, not an error. It backs that with a table_present check and an OK finding naming keel migrate. Three migrations later, venue_cash_postures (v18) and equity_points (v19) had no such guard.

The check moves from audit.py to db.table_present, where the schema is defined and every reader can reach it, plus Repository.table_present for the reason audit_chain is exposed there: what this record contains is a question about the record.

Checked, not caught. OperationalError covers "no such table" and "database disk image is malformed" under one clause, and that distinction is the difference between keel migrate and stop trading.

An absent table is not an unattested posture

A bare None would have been worse than the crash. cash_posture_findings(None) is FAIL "cash posture never attested — rail 22 vetoes every live ENTRY", which is false about a database that has no posture table: nothing has lapsed, the schema simply predates the rail. It would have sent an operator to fix a rail that is not the problem.

cash_posture_schema_finding says the true thing instead, and OK rather than WARN — the engine that enforces rail 22 migrates on the way in, so a live cycle can never be running against this schema.

Why the existing test could not see it

test_a_database_without_the_table_is_reported_not_crashed_on asserts over a fabricated _chain(table_present=False). A hand-built state object can only exercise the reader that already has the flag, so the readers that lacked one stayed invisible to it.

The new tests open a real database wound back to what 0.13.3 shipped, and the MCP one sweeps every tool rather than doctor alone — a tool added later inherits it, a table added later joins one tuple. Verified by mutation: forcing table_present to True fails all three.

And the docstring that caused it

audit_chain_findings said "both the web server and keel mcp open a repo WITHOUT migrating". Only the per-request open skips it; web/server.serve calls ensure_schema once at bind, so a served database is never behind. That imprecision is what left the sibling readers looking already covered — and it had propagated into v0.14.0's release notes before this was traced. Both are corrected.

Checks

6,308 passed / 3 skipped (6,305 + the three added here), ruff and mypy clean.

Not a blocker for v0.14.0

Present on main before the bump; the release notes already name it and tell operators to run keel migrate before pointing an MCP client at an upgraded deployment.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KZZxmspQXe5qJ9FAsG13s6

eaitbrahim and others added 2 commits September 7, 2026 09:21
…se on it (#751)

`keel mcp`'s `doctor` tool raised `sqlite3.OperationalError: no such table:
venue_cash_postures` against a database nobody had migrated -- out of the
handler, taking every other finding with it, on the first tool a client
typically calls.

THE RULE WAS ALREADY WRITTEN NEXT DOOR

`audit_chain_findings` states it in its own docstring: a reader that does not
migrate meets an un-upgraded schema as an ordinary deployment state, not an
error. It backs that with a `table_present` check and an OK finding naming
`keel migrate`. Three migrations later, `venue_cash_postures` (v18) and
`equity_points` (v19) had no such guard.

The check moved from `audit.py` to `db.table_present`, where the schema is
defined and every reader can reach it, plus `Repository.table_present` for the
same reason `audit_chain` is exposed there: what this record contains is a
question about the record.

Checked, not caught. `OperationalError` covers "no such table" and "database
disk image is malformed" under one clause, and that distinction is the
difference between `keel migrate` and stop trading.

AN ABSENT TABLE IS NOT AN UNATTESTED POSTURE

A bare `None` would have been worse than the crash. `cash_posture_findings(None)`
is FAIL "cash posture never attested -- rail 22 vetoes every live ENTRY", which
is false about a database that has no posture table: nothing has lapsed, the
schema simply predates the rail. It would have sent an operator to fix a rail
that is not the problem. `cash_posture_schema_finding` says the true thing
instead, OK, because the engine that enforces rail 22 migrates on the way in and
so can never be running against this schema.

WHY THE EXISTING TEST COULD NOT SEE IT

`test_a_database_without_the_table_is_reported_not_crashed_on` asserts over a
fabricated `_chain(table_present=False)`. A hand-built state object can only
exercise the reader that already has the flag, so the readers that lacked one
stayed invisible to it. The new tests open a REAL database wound back to what
0.13.3 shipped, and the MCP one sweeps EVERY tool rather than `doctor` alone -- a
tool added later inherits it, a table added later joins one tuple. Verified by
mutation: forcing `table_present` to `True` fails all three.

AND THE DOCSTRING THAT CAUSED IT

`audit_chain_findings` said "both the web server and `keel mcp` open a repo
WITHOUT migrating". Only the PER-REQUEST open skips it; `web/server.serve` calls
`ensure_schema` once at bind, so a served database is never behind. The
imprecision is what left the sibling readers looking already covered -- and it
had propagated into v0.14.0's release notes before this was traced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KZZxmspQXe5qJ9FAsG13s6
…erage in appearance only

Review of this PR's own diff. Four findings, and the first is the failure this
series keeps finding in other people's tests.

TWO OF THE FOUR DROPPED TABLES WERE INERT

`_TABLES_ADDED_SINCE_0_13_3` names four tables and reads as coverage of four.
Instrumented against the fixture, `gather_findings` calls:

    get_series_feeds:  0
    get_equity_points: 0
    table_present:     1

`feed_scope_findings` reads provenance only for a series with `n_candles > 0`,
and the fixture had no candles -- so `candle_series_feed` (v17) was dropped from
a database no reader in this seam ever asked about. `equity_points` (v19) is
read by nothing in `gather_findings` at all.

The fixture now seeds a bar in the product and granularity `gather_findings`
actually asks about, so the v17 reader is on the path, and a new test pins that
it is REACHED rather than merely present -- deleting the seeding line fails it
with "get_series_feeds was never reached". `equity_points` stays dropped for
fidelity and the comment says that is all it is: the reader that would exercise
it lives in `keel/web`, behind `ensure_schema`.

THE NEW DOCSTRING CONTRADICTED A SIBLING TWELVE LINES AWAY

`db.table_present` said catching `OperationalError` "would report an un-upgraded
database where the truth is a corrupted one". `Repository.get_series_feeds`
catches exactly that and re-raises unless the message contains "no such table",
which preserves the distinction the docstring claimed catching destroys. It is a
second correct answer, not a bug.

Narrowed to the claim that survives: the catch costs a dependency on the TEXT of
a sqlite error message, which is not part of sqlite's API. Presence-checking
needs no such match. Preferred for a new reader; the existing catch is left
alone and is now named rather than implicitly contradicted.

THE SWEEP COULD ONLY PROVE "NO OperationalError"

`except sqlite3.OperationalError: raise` over a bare `except Exception: pass`
would have stayed green for a tool that began wrapping its database errors, or
that broke outright for an unrelated reason. Every handler must now RETURN A
DOCUMENT, and the tool set itself is pinned so a new tool joins the sweep
deliberately rather than silently. All eight pass.

AND A COMMENT DESCRIBING THE MECHANISM IT NO LONGER USES

"winding `user_version` back" -- the code does `UPDATE schema_version SET
version = 16`. `user_version` is a PRAGMA this codebase does not use for schema
versioning; the row `migrate` reads is the one that matters.

6,309 passed / 3 skipped. The `table_present -> True` mutant is killed by all
four tests, and the seeded candle by its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KZZxmspQXe5qJ9FAsG13s6
@eaitbrahim
eaitbrahim merged commit 7770062 into main Sep 7, 2026
4 checks passed
@eaitbrahim
eaitbrahim deleted the fix-751-mcp-unmigrated-guards branch September 7, 2026 13:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

keel mcp's doctor tool raises on an un-migrated database, where the audit chain reports and continues

1 participant