refactor(workspace): one definition of the schema, shared by both paths (#1060) - #1103
Conversation
…hs (#1060) `_init_database` (fresh workspace) and `_ensure_schema_upgrades` (existing workspace) carried 28 copy-pasted `CREATE TABLE IF NOT EXISTS` statements, and they had already drifted. Measured before the change: only on the create path : blockers, checkpoints, events, prds, tasks, workspace + 5 indexes only on the upgrade path: keeps So a workspace that reached the upgrade path missing one of those six never got it back — and the difference only ever surfaces as a runtime error on whichever path is less exercised, which is the one nobody tests. `_create_core_schema(cursor)` is now the single definition, called by both. It is the pattern `_create_token_usage_schema` already demonstrated, applied to the rest. Every statement is IF NOT EXISTS, so calling it on an existing database adds what is absent and touches nothing else; column migrations (ALTER TABLE) and data backfill stay in `_ensure_schema_upgrades`, since DDL alone cannot express them. With the table guaranteed to exist, the upgrade path's four `if not cursor.fetchone(): <CREATE> else: <ALTER>` blocks lose their create branch, and six unconditional re-CREATEs become dead. Removing them takes workspace.py from 1206 to 1043 lines and the CREATE TABLE count from 28 to 18 — the 18 being the canonical set. The convergence test is the part that matters: it drops each table in turn, runs the upgrade, and compares sqlite_master against a fresh workspace, so a table added to only one path fails here rather than in production. Both branches are pinned — dropping everything must rebuild everything, and no CREATE TABLE may appear twice in the source. No behaviour change: new workspaces initialise, existing ones reopen with their data, and a legacy workspace upgrades and stays readable.
codex review, two [P1]s — both real, both bugs I introduced by sharing the DDL without thinking about ORDER, and both would have bricked a real legacy workspace rather than merely diverging. Calling the whole shared schema early on the upgrade path put index creation ahead of the migrations those indexes depend on: - `idx_prds_chain` / `idx_prds_depends_on` index columns an older workspace does not have yet. Created before the guarded ALTER TABLEs, `get_workspace()` raises "no such column: chain_id" and the workspace will not open at all. - `idx_tasks_external_url` is UNIQUE. A workspace that imported the same GitHub issue twice needs `_dedupe_external_urls` first, or the index raises IntegrityError with no way back in — regressing the recovery path #943 added on purpose. `_create_core_tables` and `_create_core_indexes` are now separate. `_init_database` runs both back to back (a new database has nothing to migrate). `_ensure_schema_upgrades` creates tables early — pure IF NOT EXISTS, always safe — and calls the indexes LAST, after every column migration and data fixup. Sharing the definitions is preserved; only the sequencing differs, and the docstrings say why. My convergence test could not have caught either: dropping a whole table is too clean a simulation of "legacy", because the table comes back complete. Two tests added for the shapes it cannot reach — a prds table that exists without chain_id, and a tasks table holding duplicate external_urls — both RED before this commit.
|
Claude finished @frankbria's task in 7m 1s —— View job Precision bug-hunting review — completeReviewed PR #1103 for concrete defects only (logic errors, data loss, security, race conditions, breaking contracts). Style/architecture/coverage are covered by a separate review.
Result: 1 finding (posted inline on The two ordering P1s the PR body calls out ( See the inline comment for the full failure trace and the summary comment for the table. |
Third-party review —
|
|
Review The refactor's core idea (one However, I think the second P1 fix is now incomplete and reintroduces a narrower version of the same regression it was meant to close. The unguarded second idx_tasks_external_url attempt can still brick a workspace (codeframe/core/workspace.py:766-796)
...and then, about 25 lines later, If the index already got created by the guarded attempt, the second call is a harmless no-op (IF NOT EXISTS). But if it did not — e.g. This is a corner case (only reached when dedup itself fails to fully clean up), which is presumably why the new Suggested fix: since Minor: other inline index statements are now dead duplicates (not bugs, just leftover cruft) Two more spots in These are non-unique indexes, so the duplication is harmless at runtime (IF NOT EXISTS), just redundant. But it works against the PR's own stated goal of 'one definition of the schema, shared by both paths,' and it's not caught by Everything else
|
GLM precision review — 1 findingScoped to concrete defects only (logic errors, data loss, security, race conditions, breaking contracts). Style, naming, architecture, and test coverage are covered by a separate review.
No Critical findings. The two ordering P1s the PR calls out ( Did not approve or request changes. |
Both reviewers independently found the same defect, and it is the second time this PR reintroduced the #943 brick while trying to fix it. Sharing the index DDL left the upgrade path with TWO attempts at idx_tasks_external_url: the original guarded one right after _dedupe_external_urls, and then an UNGUARDED one at the end via _create_core_indexes. If duplicates outlive the dedupe, the guarded attempt warns and carries on exactly as designed — and then the second attempt raises IntegrityError and the workspace never opens. The fix is to put the guard where the statement is, not where one call site happens to be. `_create_core_indexes` now owns both, so every caller inherits the guard; the upgrade path keeps only the dedupe. On a fresh database the except arm is unreachable, since there are no rows yet. Two tests: one monkeypatches the dedupe into a no-op so duplicates genuinely survive, and asserts the upgrade completes and stamps the version rather than raising; the other asserts the source contains exactly one CREATE UNIQUE INDEX for it, since two attempts is precisely how one of them ends up unguarded.
|
Claude finished @frankbria's task in 4m 18s —— View job Precision bug-hunting review — re-checked on syncThe author pushed a fix moving the
Scoped to concrete defects only (logic errors, data loss, security, race conditions, breaking contracts). Result: ✅ no new defects — the prior finding is resolvedThe defect all three reviewers raised on the previous commit (the unguarded second
Candidate defects I checked and ruled out
One non-defect, already covered by the separate reviewThe inline No Critical/major/minor defects to report on this sync. The fix is sound. |
|
Review Checked the current state of The unguarded second The two original P1s ( Minor, non-blocking:
Test suite ( |
claude-review: five CREATE INDEX statements were still issued both by `_create_core_indexes` and inline in `_ensure_schema_upgrades` — idx_prds_parent, idx_prds_chain, idx_prds_depends_on, idx_run_engine_log_ws_engine, idx_engine_stats_ws. Harmless at runtime (IF NOT EXISTS, none of them UNIQUE), but "each table's DDL exists in exactly one place" is the acceptance criterion, and a duplicated index is the same drift hazard one level down. Leaving it would have shipped an issue about duplicated DDL with duplicated DDL in it. The reviewer also spotted why my own guard missed them: it regexed CREATE TABLE only. `test_no_create_index_statement_appears_twice` closes that — and it is what caught all five, rather than me re-reading the file. Also collapses a single blank line to two before _init_database. ruff does not flag E3xx under this config, so consistency here is by hand.
|
Claude finished @frankbria's task in 2m 29s —— View job Precision bug-hunting review — re-checked on sync (commit
|
|
Both minor items fixed — the first turned out to matter more than "leftover cleanup". Duplicate CREATE INDEX statements. Five of them: Your observation about why my guard missed them is the useful part: it regexed
Blank line — fixed. And thanks for noting Four review rounds on this one, and the pattern across them is consistent enough to name: every finding was about something the shared definition changed other than duplication — ordering against migrations, ordering against data fixups, a guard left behind at a call site, and finally indexes not covered by my own duplication guard. The de-duplication itself was never the risky part; the implicit context each path had accumulated around its copy was. |
|
Review I read through the four prior review rounds (Claude bot, GLM, codex, and the second Claude pass) plus the final synced diff. The two original [P1]s are correctly fixed and I re-verified the trace myself: idx_prds_chain/idx_prds_depends_on are created in _create_core_indexes (workspace.py:465-524), called only at the end of _ensure_schema_upgrades (:791), after the chain_id/depends_on ALTERs (:685-719) and after _dedupe_external_urls (:780). The previously-flagged unguarded second idx_tasks_external_url attempt is also gone: there is now exactly one CREATE UNIQUE INDEX statement in the source, guarded with try/except sqlite3.IntegrityError inside _create_core_indexes itself (:493-504), and the upgrade path only calls _dedupe_external_urls before falling through to the shared indexer. test_undedupable_duplicates_warn_instead_of_bricking exercises exactly the 'dedupe fails to fully clean up' corner case the second Claude review called out as untested. The five previously-duplicated CREATE INDEX statements (idx_prds_parent, idx_prds_chain, idx_prds_depends_on, idx_run_engine_log_ws_engine, idx_engine_stats_ws) are gone too, and test_no_create_index_statement_appears_twice now guards the class of bug that let them slip past the original CREATE TABLE-only regex. Three small things nobody flagged yet, all harmless rather than bugs:
None of these affect correctness — SQLite commits are no-ops with nothing pending, and calling an IF NOT EXISTS schema function twice is safe — but given the PR's explicit thesis is 'one definition, no duplication,' they're worth a quick cleanup pass so the file fully matches its own stated goal. Everything else: the _create_core_tables/_create_core_indexes/_create_core_schema split, the docstrings explaining why tables-then-migrations-then-indexes ordering is load-bearing, and test_schema_convergence_1060.py's four-layer coverage (whole-table-drop convergence, source-level no-duplicate-DDL guards, behavior-unchanged smoke tests, and the two 'shapes a table-drop can't produce' ordering tests) are all solid. SCHEMA_VERSION correctly stays at 5 since this is a pure consolidation with no schema content change. No security concerns — this is local SQLite DDL with no user-controlled input reaching these statements. |
…cation (#1060) claude-review, all harmless, all the same shape as what this PR removes — so leaving them would mean shipping "one definition, no duplication" with three counterexamples in the file. - `_create_token_usage_schema(cursor)` was called twice on the upgrade path: once inside `_create_core_tables`, then again explicitly, under a comment that predates this refactor and now describes what the shared function already does. Neither source guard catches it, because both regex literal CREATE statements rather than function calls. - Two bare back-to-back `conn.commit()` calls with nothing between them, left behind when the inline table blocks that each carried their own commit were removed. - A "# Add indexes for PRD version chain queries" comment followed only by a bare commit — those indexes moved into `_create_core_indexes` in this same PR, so the comment described nothing. No behaviour change: an IF NOT EXISTS schema call is idempotent and a SQLite commit with nothing pending is a no-op. This is the file matching its own stated goal.
|
Claude finished @frankbria's task in 2m 5s —— View job Precision bug-hunting review — re-checked on sync (commit
|
|
All three cleaned up in e03d326. Worth saying why I didn't leave them as a follow-up: each is the same shape as what this PR removes, so shipping "one definition, no duplication" with three counterexamples still in the file would undercut the point of the change. The The other two were pure residue from removing the inline table blocks: two back-to-back No behaviour change — an Five review rounds, every one finding something real. Thanks — the file is meaningfully better than the version I first pushed. |
|
Review I read through the six prior review rounds in this thread (Claude bot x4, GLM x2, One thing none of the six prior rounds caught: the tasks-column ALTER block is now fully duplicated, not just the CREATE TABLE/INDEX statements.
This is harmless at runtime (idempotent, guarded, gated behind the Suggested fix: delete Two minor doc nits, non-blocking:
Everything else matches what the prior rounds already established: ordering is load-bearing and correctly sequenced, the |
…1060) - `_create_core_tables` said "every workspace table and index"; it creates tables only, which is the entire reason `_create_core_indexes` exists. - Adds a test that a legacy tasks table (missing external_url, requirement_ids, and the rest) comes out fully migrated. That test deliberately does not assert WHICH path adds the columns. I first wrote it claiming the ALTER block in `_ensure_schema_upgrades` was load-bearing, then could not reproduce a failure when removing it — and found why: `_create_core_tables` carries 15 ALTER statements of its own, inherited from `_init_database`. So the reviewer's suggestion that the block is now redundant is likely right, and my rationale was wrong. The test asserts the outcome instead, which is true either way. Filed as a follow-up rather than done here: deleting ~45 lines of migration code is a separate change deserving its own verification.
|
Claude finished @frankbria's task in 2m 17s —— View job Precision bug-hunting review — re-checked on sync (commit
|
|
Two doc nits fixed. On the third — you were probably right and I was wrong, and the way I found out is worth recording. Stale docstring — fixed. The The reason, checked directly rather than reasoned about:
I have not deleted the block here. Removing ~45 lines of migration code as the last act of a five-round PR is how the earlier P1s happened; it deserves its own verification pass. Filed as #1104 [P3.1] with the evidence above and the open question of whether What I did change: my new test no longer claims the block is load-bearing. It asserts the outcome — a legacy tasks table comes out fully migrated — deliberately not asserting which path does it, so it stays valid whichever way #1104 goes. A test whose rationale is wrong is worse than no test, even when it's green. Third nit (the |
|
Review — current head I read the diff plus the full review thread (Claude bot x4, GLM x2, The two original [P1]s and the follow-on unguarded-index bug are correctly fixed and still hold. Traced it myself: One small thing none of the prior rounds mentioned: a stale trailing comment. Already-tracked, not re-raising as new: the Tests: No new correctness, security, or performance concerns. Good to merge once the trailing comment is dropped (or even without it — it's cosmetic). |
'# Create indexes for common queries' sat at the end of _create_core_tables with nothing under it — the statements it introduced moved into _create_core_indexes earlier in this PR. Same category as the PRD-index comment removed in e03d326, and I missed it in that pass. A comment describing code that is no longer there is the seed of the next drift bug, which is the whole subject of this issue.
|
Dropped in 92a0c2c — and fair that I missed it in the very pass where I said stale comments are "the seed of the next drift bug". Comment-only change, no code touched. Also noting for the record: you're right that |
|
Claude finished @frankbria's task in 1m 12s —— View job Precision bug-hunting review — re-checked on sync (commit
|
Review — current head
|
Closes #1060.
The drift was already real, and measurable
_init_database(fresh) and_ensure_schema_upgrades(existing) carried 28 copy-pastedCREATE TABLE IF NOT EXISTSstatements. Measured before touching anything:So a workspace reaching the upgrade path without one of those six never got it back — and that only ever surfaces as a runtime error on whichever path is less exercised, which is the one nobody tests.
Fix
_create_core_tables/_create_core_indexesare now the single definition, called by both paths — the pattern_create_token_usage_schemaalready demonstrated, applied to the rest. Column migrations (ALTER TABLE) and data backfill stay in_ensure_schema_upgrades, since DDL alone can't express them.CREATE TABLEstatementsworkspace.pyWith every table guaranteed to exist, the upgrade path's four
if not cursor.fetchone(): <CREATE> else: <ALTER>blocks lose their create branch, and six unconditional re-CREATEs become dead.Ordering is load-bearing — two [P1]s the review caught
My first version called the whole shared schema early on the upgrade path. That put index creation ahead of the migrations those indexes depend on, and both cases brick a real legacy workspace rather than merely diverging:
idx_prds_chain/idx_prds_depends_onindex columns an older workspace doesn't have yet. Created before the guarded ALTER TABLEs,get_workspace()raisesno such column: chain_idand the workspace won't open at all.idx_tasks_external_urlis UNIQUE. A workspace that imported the same GitHub issue twice needs_dedupe_external_urlsfirst, or the index raisesIntegrityErrorwith no way back in — regressing the recovery path [P1.25] Harden workspace DB integrity: foreign keys, safe UNIQUE index upgrade, single DDL, no control-plane contamination #943 added on purpose.So tables and indexes are separate now.
_init_databaseruns both back to back (a new database has nothing to migrate);_ensure_schema_upgradescreates tables early — pureIF NOT EXISTS, always safe — and calls the indexes last, after every column migration and data fixup. Sharing is preserved; only sequencing differs, and the docstrings say why.My convergence test could not have caught either, which is the more useful finding: dropping a whole table is too clean a simulation of "legacy", because the table comes back complete. Real legacy databases have tables that exist with missing columns, or that hold data a newer index rejects.
Verification
test_each_table_dropped_alone_is_restoredis the AC's "valuable part": it drops each table in turn, upgrades, and diffssqlite_masteragainst a fresh workspace — so a table added to only one path fails here rather than in production. Plustest_dropping_everything_rebuilds_the_whole_schemaand a source-level guard that noCREATE TABLEappears twice.prdstable existing withoutchain_id, and ataskstable holding duplicateexternal_urls. Both RED before the ordering fix.ruff checkclean.Known limitations
keeps(previously upgrade-path-only) is intentionally not added to the canonical schema — it isn't in the fresh schema and nothing incodeframe/reads it, so promoting it would have been scope creep dressed as convergence. The convergence test compares against fresh-as-reference, so it does not require it._create_core_indexesmust stay last, which the docstring states and the two legacy-shape tests enforce.