23 KiB
Implementation Plan (Version 4.6)
Goal
Pay down the defects, duplication, and structural drift identified in the Architecture & Code Review Report without changing any observable behavior. Re-level the database schema from current SQLModel metadata, correct read amplification and missing indexes, consolidate duplicated service and UI code, restore the project's own documented boundaries, and make ty a real quality gate.
Planning Status
- V4.5 is the completed implementation baseline.
- The V4.6 scope is frozen and sufficiently detailed to begin implementation.
- Every task traces to a review finding ID. A change without a finding ID is a scope addition and requires an explicit amendment.
- The
SourceServicesplit ([MED-14]) is deferred to V4.7 by decision, not by omission.
Planning Constraints
- Behavior is preserved exactly. All 264 pre-existing tests must still pass. A test that must change is evidence the change is not remediation.
- The application targets SQLite only in V4.6. PostgreSQL is unblocked but not enabled.
- The deployment is single user, single process, single worker. Forward-compatible code is written where cheap and dialect-guarded.
- The schema is re-leveled from metadata. No Alembic, no revision directory, no history table, no down path.
- All schema-affecting changes land in one pass; partial application is not a valid state.
- The data migration script is authored last, against the final schema and final loading strategy.
- Uploaded Source files, portraits, and artifact files on disk are never modified.
- Original Source files remain immutable; every V4.2–V4.5 evidence and provenance contract is preserved.
- Provider network work continues to occur outside database transactions.
- Database, integration, and UI tests use confirmed isolated data and never modify
data/transcription.db. - Potentially destructive tests run only through
tools/run_destructive_tests.py.
Expected Project Impact
| Area | Expected impact |
|---|---|
| Dead code | Remove app_state.py, services/transcription.py, legacy aliases, ServiceBase.queue, and a duplicate queued-job query. |
| Persistence | Delete the hand-rolled DDL chain; generate schema from metadata with correct indexes, FK ordering, and loading strategy. |
| Query behavior | Bounded queued-job poll, SQL-side filtering, bounded navigation queries, explicit eager loads. |
| Worker | Provider client and service bundle live for the worker's lifetime rather than per job. |
| Configuration | Remove the timeout cap and the silently-ignored DATABASE_URL; resolve dead settings; replace frozen-model mutation. |
| Service layer | Generic registry service, shared not-found guard, single media-storage implementation (~400 lines removed). |
| UI layer | Fix three boundary violations; extract duplicated components (~500 lines removed); externalize the SVG asset. |
| Async I/O | Move filesystem, hashing, and image work off the event loop. |
| Tooling | ruff and ty both reach zero and gate on pre-commit. |
| Data | One-time migration of backed-up V4.5 data into the re-leveled schema. |
| Tests and documentation | Add index, FK-cycle, claim-boundedness, client-reuse, and registry-parity coverage; correct the stale instruction path. |
Implementation Phases
1. Deletions and Quick Wins
Independent of every other phase. Land first to shrink the surface everything else must consider.
- Delete
src/transcription/app_state.pyand confirm zero importers remain insrc,tests, andtools([HIGH-01]). - Delete
src/transcription/services/transcription.pyand standardize everybuild_prompt_executionimport onservices/sources.py([MED-05]). - Delete the legacy compatibility aliases in
services/store.py:35,382,383([MED-05]). - Delete
ServiceBase.queueand its unparameterizedasyncio.Queue([MED-07]). - Delete
db/operations.py:get_next_queued_jobas a divergent duplicate of the live implementation ([CRIT-01]). - Resolve
sqlite_check_same_threadandworker_retry_backoff_seconds: wire each to real behavior or delete it together with its test ([MED-02]). - Remove
DATABASE_URLfromdocker-compose.ymland document the realDATABASE__DRIVER/DATABASE__PATHnested names in.env.example([MED-10]). - Correct the stale path in
.github/instructions/services.instructions.md:10tosrc/transcription/db/models.py([LOW-02]). - Remove the discarded
load_docsparameter fromlist_jobs([LOW-03]). - Validate the
getattrresult inresolve_worker_notifier([LOW-04]). - Move
VIBESCRIBE_LOGO_SVGtoui/static/vibescribe_logo.svgand load it through aread_svgsibling ofui/resources.py:read_css([MED-09]). - Run
ruff check --fixand resolve the remainder by hand ([LOW-01]). - Route
people_page.py:504througherror_presenter.show_error([LOW-07]). - Cancel the auto-refresh timer rather than only deactivating it, and name its interval constant ([LOW-06]).
Verification: full suite green, ruff check reports zero, no import of a deleted symbol remains.
2. Schema Re-Level — Single Pass
This phase is atomic. Every task below regenerates the same schema and must be verified together.
- Delete
upgrade_schemaand_upgrade_*(db/operations.py:25-109) and their tests (tests/test_db.py:109-172) ([HIGH-05]). - Confirm
create_all()remains gated bySettings.should_bootstrap_schema(config.py:140-145) ([HIGH-05]). - Declare the composite index in the model:
Index("ix_job_status_date_created", "status", "date_created"), plusindex=Trueon the foreign keys the worker and detail pages filter on ([HIGH-04]). - Declare
Source.preferred_execution_attempt_id's foreign key withuse_alter=Trueand an explicit constraint name, breaking thesource/job_source/execution_attemptcycle ([HIGH-08]). - Flip relationship loading from bidirectional
lazy="selectin"tolazy="raise", model by model ([CRIT-02]):- Work one model at a time with the suite as the safety net.
- Where a test fails with a lazy-load error, add an explicit
selectinload()to the service query that feeds it — never restore the model-level default. - Where an existing explicit
selectinload()proves redundant, delete it; this is the primary source of the ~160tydiagnostics addressed in Phase 6. - Follow the two correct precedents already in the codebase:
Source.processing_artifacts:279andJobSource.execution_attempts:336.
- Rebuild the development database from empty. Do not attempt to upgrade the existing file.
Verification:
- A test asserts the composite
Jobindex and the hot foreign-key indexes exist in a freshly created schema. - A test compiles the metadata against the PostgreSQL dialect and asserts no unresolvable-cycle warning is emitted.
- A test asserts
preferred_execution_attempt_id's column type matches the model declaration. - Full suite green under
lazy="raise". - No raw
ALTER TABLEorCREATE INDEXstring remains anywhere insrc.
Rollback: this phase reverts as a unit. A partially applied schema pass is not a valid state.
3. Worker and Provider Reliability
Depends on Phase 2, because the claim query's cost profile is only correct once eager-loading defaults are fixed.
- Add
.limit(1)to the queued-job selection and remove its eager-load options from the hot poll ([CRIT-01]). - Convert the read-then-write claim into an atomic
QUEUED→PROCESSINGtransition in one transaction ([CRIT-01]):- Write the dialect-guarded
with_for_update(skip_locked=True)branch for the multi-user direction. - On SQLite, the claim executes as a bounded single-writer transaction.
- Load the eager relationships in a second query after the claim succeeds.
- Update the stale comment at
workflows.py:193-194to describe the actual guarantee rather than the known hazard.
- Write the dialect-guarded
- Hoist
ServiceBundleand the provider client out of the per-job body inworker.py:157-174to worker-loop scope;aclose()the client once at loop shutdown, not once per job ([HIGH-02]). - Add
ServiceBundle.from_session_factory(...), replacing the three duplicated instantiation blocks atapp.py:45-50,worker.py:160-165, andservices/__init__.py:19-22. Have_recover_stale_processing_jobs(app.py:73-84) use the bundle built five lines earlier ([MED-06]). - Remove
le=20.0fromworker_provider_timeout_seconds(config.py:110), raise the default to a realistic vision-transcription duration, and pass an explicithttpx.Timeoutto the OpenRouterAsyncClient(openrouter.py:198) ([HIGH-03]). - Extend the
TranscriptionProviderProtocol to declareacloseand the evidence attributes; delete the per-callinspect.signature(adapter.transcribe).parametersreflection atsources.py:1237and the associated untyped kwargs dict ([MED-03]).
Verification:
- A test asserts the emitted claim SQL contains
LIMITand noselectinloadjoin. - A test asserts the worker processes two consecutive jobs against the same provider client instance.
- A test asserts a timeout value above 20 seconds is accepted by
Settings. - A test asserts the transcription call path resolves
requested_modelthrough the Protocol without reflection.
4. Service Layer Consolidation
Depends on Phase 2 only for the loading strategy; otherwise independent of Phase 3.
- Introduce
services/registry.pywith a genericRegistryService[ModelT]owning list, summaries with counts, create withIntegrityError→ conflict mapping, read with not-found, update, delete with built-in and referenced guards, andis_referenced([MED-11]):- Define label normalization, the casefold key, and the summary shape once.
- Reduce
DocumentService's document-type methods (documents.py:350-500) andPeopleService's person-role methods (people.py:214-378) to subclasses declaring model, error class, reference query, and noun. - Preserve every existing user-facing message, error category, and suggestion string verbatim; template the noun only.
- Add
ServiceBase._get_or_raise(...)and adopt it at all 38 not-found sites, includingdocuments.py:174,210,291, which currently bypass the local_get_document_or_raisehelper. Delete the now-redundant local helper ([MED-12]). - Introduce
services/media_storage.pyas the single validate → hash →mkdir→ write → wrap-OSErrorimplementation, replacingstore.py:319-379,people.py:596-631, andui/homepage_store.py:31-44. Wrap the write inasyncio.to_thread([MED-13], [MED-01]). - Move
source_mime_typeout ofservices/sources.pyinto a shared module sodocuments.py:24no longer imports a sibling service, restoring the independence rule atservices.instructions.md:13([MED-14], partial). - Correct the four query inefficiencies in
sources.py([LOW-08]):list_sources_detail:338-343— move thejob_idfilter from Python into a SQL join onJobSource.read_source_navigation:233-244— replace the full ordered-id scan with twoLIMIT 1queries.list_processing_artifacts:961— add alimitparameter matching its summary sibling.build_evidence_export:1012-1013— move artifact integrity hashing intoasyncio.to_thread.
Verification:
- Existing
DocumentTypeandPersonRoletests pass unchanged against the shared implementation. This is the primary proof that behavior is preserved. - A test asserts
list_sources_detailfiltered byjob_idemits a join rather than loading the full table. - No module in
services/imports another concrete service module.
5. UI Boundaries and Duplication
Independent of Phases 2–4 except where a service signature changes.
- Fix the three
ui.instructions.mdviolations ([HIGH-07]):- Add a
JobServiceor workflow method that ownssession_scopeinternally; remove the import and transaction management fromjobs_page.py:17,185-192. - Have
SourceServicereturn a plaintransport_body_deferred: boolon a read model; removesqlalchemy.inspectfromsources_page.py:13,439. - Pass a ready media URL into
document_panzoom, or delete the component — it is exported fromcomponents/__init__.pybut used by no page ([HIGH-07]).
- Add a
- Extract the duplication catalogued in review §4, highest value first:
ui/components/confirm_delete.py— the blocked-deps card plus confirm/cancel row, from four pages (~120 lines).ui/components/media_urls.py— pure upload-URL resolution takingupload_dirandbase_url, from three call sites (~110 lines).ui/components/guards.py— parse → error label → return, from nine call sites (~90 lines).build_tableadoption for the remaining hand-rolledui.tableinstances, adding selection and no-search options as needed (~70 lines).ui/components/upload_panel.py— file-picker wiring, from three pages (~50 lines).ui/components/formatters.py—_parse_uuid(five copies) and_parse_iso_date(two copies) (~49 lines).- A shared page-helper for
_resolve_runtime_settings(request)(three copies, ~18 lines).
- Annotate untyped handler parameters and replace loosely-typed dict returns with read models ([LOW-05]).
Verification: UI page tests pass unchanged; no page module imports session_scope, sqlalchemy.inspect, or get_settings.
6. Async I/O and Configuration Hygiene
- Wrap the remaining blocking work in
asyncio.to_thread: Pillow orientation normalization, artifact writes, and evidence hashing not already covered by Phase 4 ([MED-01]). - Replace
functools.cacheon the engine and session factories with an explicit URL-keyed registry supporting targeted eviction, removing the cross-test and cross-tenant coupling and restoring a visible call signature ([MED-04]). - Replace
object.__setattr__innormalize_provider_models(config.py:130,137) withmodel_copy(update=...)or a computed property. - Add
onupdateto theupdated_at/date_updatedcolumns that are expected to track modification, so they stop being stale on the update paths that do not set them by hand. Remove the now-redundant manual assignment atjobs.py:166and its siblings. - Surface the exception currently swallowed to
Nonein the ORM model property atmodels.py:227([MED-08]).
Note: the onupdate change is schema-affecting in principle but not in emitted DDL, since onupdate is a Python-side default. If implementation reveals it alters generated DDL, it moves into Phase 2 and Phase 2 is re-verified.
Verification: a test asserts an update through a service advances updated_at; a test asserts two different database URLs produce two distinct engines and that evicting one leaves the other intact.
7. Type Checking and Tooling Gate
Depends on Phase 2, which is expected to remove most diagnostics by deleting redundant eager loads.
- Re-baseline
ty checkafter Phase 2 and measure the remaining diagnostic count ([HIGH-06]). - Convert every surviving
# pyright: ignore[...]to# ty: ignore[...], sincetydoes not honor pyright directives ([HIGH-06]). - Fix the two real bugs currently hidden in the noise ([HIGH-06]):
tests/ui/test_sources_page.py:25constructsSource(...)without the requireddocument_id.tools/run_destructive_tests.py:76,80usesfcntl, which does not exist on Windows; use a cross-platform lock or guard by platform.
- Drive
ty checkto zero diagnostics and wire it into the existing pre-commit setup as a blocking gate. - Configure
asyncio_default_fixture_loop_scopeexplicitly so pytest-asyncio behavior does not change on upgrade.
Verification: ty check and ruff check both report zero; pre-commit fails when either regresses; tools/run_destructive_tests.py runs on Windows.
8. Data Migration
The final phase. Authored against the completed schema and the completed loading strategy.
- Write a one-time script under
tools/that reads the backed-up V4.5 database and writes into the re-leveled schema (review §1a, "Items Added During Scoping"). - Because
lazy="raise"is in force, every relationship traversal in the script carries an explicit eager load. This is the reason the script is written last. - Preserve identity: UUIDs, digests, timestamps, attempt numbers, and
preferred_execution_attempt_idselections carry across unchanged. - Do not reinterpret, normalize, or regenerate any
ExecutionAttemptorProcessingArtifactevidence. - Do not modify any on-disk Source file, portrait, or artifact file.
- The script is idempotent, is never invoked from application startup, and never runs in the test suite.
Verification: post-migration row counts match the backup for every table (document 8, document_person 11, document_type 7, execution_attempt 80, job 11, job_source 79, person 5, person_role 3, processing_artifact 2, source 76); artifact integrity verification passes for every migrated artifact; on-disk file hashes are unchanged.
Sequencing Constraint
graph TD
P1[1. Deletions & Quick Wins]
P2[2. Schema Re-Level<br/>SINGLE ATOMIC PASS]
P3[3. Worker & Provider]
P4[4. Service Consolidation]
P5[5. UI Boundaries & Duplication]
P6[6. Async I/O & Config]
P7[7. Type-Check Gate]
P8[8. Data Migration]
P1 --> P2
P2 --> P3
P2 --> P4
P2 --> P7
P1 --> P5
P4 --> P5
P4 --> P6
P3 --> P8
P5 --> P8
P6 --> P8
P7 --> P8
The binding constraints are:
- Phase 2 is indivisible.
create_allfrom metadata, the indexes,use_alter, and thelazyflip all regenerate the same schema. They land together or not at all. - Phase 7 follows Phase 2. Measuring the
tybaseline before the redundant eager loads are deleted would chase diagnostics that Phase 2 removes for free. - Phase 8 is last. The migration script must be written against the final schema and the final loading strategy.
Test Strategy
-
The existing suite is the contract. 264 tests pass today and must pass at every phase boundary. A test that requires modification is treated as a defect in that test, justified individually in the commit, and never as license to change behavior.
-
Registry parity is the key proof. The
DocumentTypeandPersonRoletests must pass unchanged against the sharedRegistryService. If they need edits, the abstraction is wrong. -
New tests are structural, not behavioral. They assert schema shape, emitted SQL, dialect compatibility, and object lifetime — properties the current suite does not cover and that the review found were the reason these defects survived.
-
New coverage to add:
Assertion Finding Composite Jobindex and hot FK indexes exist in a fresh schema[HIGH-04] PostgreSQL-dialect metadata compilation emits no cycle warning [HIGH-08] preferred_execution_attempt_idcolumn type matches the model[HIGH-05] Full suite passes under lazy="raise"[CRIT-02] Claim SQL contains LIMITand no eager-load join[CRIT-01] Provider client instance is reused across two consecutive jobs [HIGH-02] Settingsaccepts a provider timeout above 20 seconds[HIGH-03] list_sources_detailemits a join rather than a full-table load[LOW-08] An update through a service advances updated_at[SQLModel §3] Distinct database URLs yield distinct, individually evictable engines [MED-04] Post-migration row counts match the backup [Phase 8] -
All database, integration, and UI tests continue to use isolated data and never touch
data/transcription.db. -
Destructive tests continue to run only through
tools/run_destructive_tests.py, which must first be made to run on Windows.
Risks
| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
The lazy="raise" flip surfaces load paths the tests do not cover, breaking a UI page at runtime |
High | Medium | Flip one model at a time; exercise every page manually at the phase boundary; lazy="raise" fails loudly rather than silently, which is the point |
| Phase 2 is partially applied and leaves an inconsistent schema | Medium | High | Treat Phase 2 as one commit; rebuild from empty rather than upgrading; verify all four schema assertions before proceeding |
RegistryService generalization subtly changes a user-facing message or error category |
Medium | Medium | Preserve message strings verbatim, templating only the noun; require the existing registry tests to pass unchanged |
The atomic claim behaves differently on SQLite than the FOR UPDATE SKIP LOCKED path it is written to support |
Medium | Low | Single worker in V4.6 means the SQLite path is the only one exercised; the Postgres branch is dialect-guarded and explicitly unverified until the cutover |
| Removing the timeout cap allows a pathological hang | Low | Medium | Pair the removal with an explicit httpx.Timeout so the client, not the config bound, enforces the ceiling |
| The migration script loses or reinterprets evidence | Low | High | Verify row counts per table, verify artifact integrity hashes post-migration, and never touch on-disk files |
| Remediation quietly becomes feature work | Medium | Medium | Every commit cites a finding ID; anything without one is recorded for a later revision |
ty cannot reach zero without unsound suppressions |
Medium | Low | Suppressions are acceptable where SQLModel typing is genuinely unrepresentable, but each must be # ty: ignore[<rule>] with a specific rule, never blanket |
Delivery Order
- Phase 1 — Deletions and Quick Wins
- Phase 2 — Schema Re-Level (single atomic pass)
- Phase 3 — Worker and Provider Reliability
- Phase 4 — Service Layer Consolidation
- Phase 5 — UI Boundaries and Duplication
- Phase 6 — Async I/O and Configuration Hygiene
- Phase 7 — Type Checking and Tooling Gate
- Phase 8 — Data Migration
Done Criteria
V4.6 is complete when every acceptance criterion in the V4.6 Scope Boundary is satisfied, specifically:
- All 264 pre-existing tests pass, with every modified test individually justified.
ruff checkandty checkboth report zero and gate on pre-commit.- No hand-rolled DDL, dead module, dead setting, or duplicate implementation identified in the review remains.
- The schema is generated from metadata, correctly indexed, cycle-free under the PostgreSQL dialect, and free of bidirectional
lazy="selectin". - Roughly 900 lines of duplication are removed across the service and UI layers.
- The backed-up V4.5 data is restored into the re-leveled schema with matching row counts and unmodified on-disk files.
- No new user-facing feature exists that did not exist in V4.5.