generated from john/python-template
V4.6 Scope & Implementation Plan
This commit is contained in:
@@ -0,0 +1,295 @@
|
||||
# Implementation Plan (Version 4.6)
|
||||
|
||||
## Goal
|
||||
|
||||
Pay down the defects, duplication, and structural drift identified in the [Architecture & Code Review Report](../architecture_code_review_2026-08-17.md) 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 `SourceService` split ([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.py` and confirm zero importers remain in `src`, `tests`, and `tools` ([HIGH-01]).
|
||||
- Delete `src/transcription/services/transcription.py` and standardize every `build_prompt_execution` import on `services/sources.py` ([MED-05]).
|
||||
- Delete the legacy compatibility aliases in `services/store.py:35,382,383` ([MED-05]).
|
||||
- Delete `ServiceBase.queue` and its unparameterized `asyncio.Queue` ([MED-07]).
|
||||
- Delete `db/operations.py:get_next_queued_job` as a divergent duplicate of the live implementation ([CRIT-01]).
|
||||
- Resolve `sqlite_check_same_thread` and `worker_retry_backoff_seconds`: wire each to real behavior or delete it together with its test ([MED-02]).
|
||||
- Remove `DATABASE_URL` from `docker-compose.yml` and document the real `DATABASE__DRIVER` / `DATABASE__PATH` nested names in `.env.example` ([MED-10]).
|
||||
- Correct the stale path in `.github/instructions/services.instructions.md:10` to `src/transcription/db/models.py` ([LOW-02]).
|
||||
- Remove the discarded `load_docs` parameter from `list_jobs` ([LOW-03]).
|
||||
- Validate the `getattr` result in `resolve_worker_notifier` ([LOW-04]).
|
||||
- Move `VIBESCRIBE_LOGO_SVG` to `ui/static/vibescribe_logo.svg` and load it through a `read_svg` sibling of `ui/resources.py:read_css` ([MED-09]).
|
||||
- Run `ruff check --fix` and resolve the remainder by hand ([LOW-01]).
|
||||
- Route `people_page.py:504` through `error_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_schema` and `_upgrade_*` (`db/operations.py:25-109`) and their tests (`tests/test_db.py:109-172`) ([HIGH-05]).
|
||||
- Confirm `create_all()` remains gated by `Settings.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")`, plus `index=True` on the foreign keys the worker and detail pages filter on ([HIGH-04]).
|
||||
- Declare `Source.preferred_execution_attempt_id`'s foreign key with `use_alter=True` and an explicit constraint name, breaking the `source` / `job_source` / `execution_attempt` cycle ([HIGH-08]).
|
||||
- Flip relationship loading from bidirectional `lazy="selectin"` to `lazy="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 ~160 `ty` diagnostics addressed in Phase 6.
|
||||
- Follow the two correct precedents already in the codebase: `Source.processing_artifacts:279` and `JobSource.execution_attempts:336`.
|
||||
- Rebuild the development database from empty. Do **not** attempt to upgrade the existing file.
|
||||
|
||||
**Verification:**
|
||||
- A test asserts the composite `Job` index 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 TABLE` or `CREATE INDEX` string remains anywhere in `src`.
|
||||
|
||||
**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` → `PROCESSING` transition 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-194` to describe the actual guarantee rather than the known hazard.
|
||||
- Hoist `ServiceBundle` and the provider client out of the per-job body in `worker.py:157-174` to 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 at `app.py:45-50`, `worker.py:160-165`, and `services/__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.0` from `worker_provider_timeout_seconds` (`config.py:110`), raise the default to a realistic vision-transcription duration, and pass an explicit `httpx.Timeout` to the OpenRouter `AsyncClient` (`openrouter.py:198`) ([HIGH-03]).
|
||||
- Extend the `TranscriptionProvider` Protocol to declare `aclose` and the evidence attributes; delete the per-call `inspect.signature(adapter.transcribe).parameters` reflection at `sources.py:1237` and the associated untyped kwargs dict ([MED-03]).
|
||||
|
||||
**Verification:**
|
||||
- A test asserts the emitted claim SQL contains `LIMIT` and no `selectinload` join.
|
||||
- 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_model` through 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.py` with a generic `RegistryService[ModelT]` owning list, summaries with counts, create with `IntegrityError` → conflict mapping, read with not-found, update, delete with built-in and referenced guards, and `is_referenced` ([MED-11]):
|
||||
- Define label normalization, the casefold key, and the summary shape once.
|
||||
- Reduce `DocumentService`'s document-type methods (`documents.py:350-500`) and `PeopleService`'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, including `documents.py:174,210,291`, which currently bypass the local `_get_document_or_raise` helper. Delete the now-redundant local helper ([MED-12]).
|
||||
- Introduce `services/media_storage.py` as the single validate → hash → `mkdir` → write → wrap-`OSError` implementation, replacing `store.py:319-379`, `people.py:596-631`, and `ui/homepage_store.py:31-44`. Wrap the write in `asyncio.to_thread` ([MED-13], [MED-01]).
|
||||
- Move `source_mime_type` out of `services/sources.py` into a shared module so `documents.py:24` no longer imports a sibling service, restoring the independence rule at `services.instructions.md:13` ([MED-14], partial).
|
||||
- Correct the four query inefficiencies in `sources.py` ([LOW-08]):
|
||||
- `list_sources_detail:338-343` — move the `job_id` filter from Python into a SQL join on `JobSource`.
|
||||
- `read_source_navigation:233-244` — replace the full ordered-id scan with two `LIMIT 1` queries.
|
||||
- `list_processing_artifacts:961` — add a `limit` parameter matching its summary sibling.
|
||||
- `build_evidence_export:1012-1013` — move artifact integrity hashing into `asyncio.to_thread`.
|
||||
|
||||
**Verification:**
|
||||
- Existing `DocumentType` and `PersonRole` tests pass **unchanged** against the shared implementation. This is the primary proof that behavior is preserved.
|
||||
- A test asserts `list_sources_detail` filtered by `job_id` emits 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.md` violations ([HIGH-07]):
|
||||
- Add a `JobService` or workflow method that owns `session_scope` internally; remove the import and transaction management from `jobs_page.py:17,185-192`.
|
||||
- Have `SourceService` return a plain `transport_body_deferred: bool` on a read model; remove `sqlalchemy.inspect` from `sources_page.py:13,439`.
|
||||
- Pass a ready media URL into `document_panzoom`, or delete the component — it is exported from `components/__init__.py` but used by no page ([HIGH-07]).
|
||||
- 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 taking `upload_dir` and `base_url`, from three call sites (~110 lines).
|
||||
- `ui/components/guards.py` — parse → error label → return, from nine call sites (~90 lines).
|
||||
- `build_table` adoption for the remaining hand-rolled `ui.table` instances, 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.cache` on 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__` in `normalize_provider_models` (`config.py:130,137`) with `model_copy(update=...)` or a computed property.
|
||||
- Add `onupdate` to the `updated_at` / `date_updated` columns 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 at `jobs.py:166` and its siblings.
|
||||
- Surface the exception currently swallowed to `None` in the ORM model property at `models.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 check` after Phase 2 and measure the remaining diagnostic count ([HIGH-06]).
|
||||
- Convert every surviving `# pyright: ignore[...]` to `# ty: ignore[...]`, since `ty` does not honor pyright directives ([HIGH-06]).
|
||||
- Fix the two real bugs currently hidden in the noise ([HIGH-06]):
|
||||
- `tests/ui/test_sources_page.py:25` constructs `Source(...)` without the required `document_id`.
|
||||
- `tools/run_destructive_tests.py:76,80` uses `fcntl`, which does not exist on Windows; use a cross-platform lock or guard by platform.
|
||||
- Drive `ty check` to zero diagnostics and wire it into the existing pre-commit setup as a blocking gate.
|
||||
- Configure `asyncio_default_fixture_loop_scope` explicitly 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_id` selections carry across unchanged.
|
||||
- Do not reinterpret, normalize, or regenerate any `ExecutionAttempt` or `ProcessingArtifact` evidence.
|
||||
- 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
|
||||
|
||||
```mermaid
|
||||
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:
|
||||
|
||||
1. **Phase 2 is indivisible.** `create_all` from metadata, the indexes, `use_alter`, and the `lazy` flip all regenerate the same schema. They land together or not at all.
|
||||
2. **Phase 7 follows Phase 2.** Measuring the `ty` baseline before the redundant eager loads are deleted would chase diagnostics that Phase 2 removes for free.
|
||||
3. **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 `DocumentType` and `PersonRole` tests must pass *unchanged* against the shared `RegistryService`. 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 `Job` index and hot FK indexes exist in a fresh schema | [HIGH-04] |
|
||||
| PostgreSQL-dialect metadata compilation emits no cycle warning | [HIGH-08] |
|
||||
| `preferred_execution_attempt_id` column type matches the model | [HIGH-05] |
|
||||
| Full suite passes under `lazy="raise"` | [CRIT-02] |
|
||||
| Claim SQL contains `LIMIT` and no eager-load join | [CRIT-01] |
|
||||
| Provider client instance is reused across two consecutive jobs | [HIGH-02] |
|
||||
| `Settings` accepts a provider timeout above 20 seconds | [HIGH-03] |
|
||||
| `list_sources_detail` emits 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
|
||||
|
||||
1. Phase 1 — Deletions and Quick Wins
|
||||
2. Phase 2 — Schema Re-Level (single atomic pass)
|
||||
3. Phase 3 — Worker and Provider Reliability
|
||||
4. Phase 4 — Service Layer Consolidation
|
||||
5. Phase 5 — UI Boundaries and Duplication
|
||||
6. Phase 6 — Async I/O and Configuration Hygiene
|
||||
7. Phase 7 — Type Checking and Tooling Gate
|
||||
8. Phase 8 — Data Migration
|
||||
|
||||
## Done Criteria
|
||||
|
||||
V4.6 is complete when every acceptance criterion in the [V4.6 Scope Boundary](scope_boundary_v4_6.md) is satisfied, specifically:
|
||||
|
||||
- All 264 pre-existing tests pass, with every modified test individually justified.
|
||||
- `ruff check` and `ty check` both 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.
|
||||
|
||||
## Related Local References
|
||||
|
||||
- [V4.6 Scope Boundary](scope_boundary_v4_6.md)
|
||||
- [Architecture & Code Review Report](../architecture_code_review_2026-08-17.md)
|
||||
- [V4.5 Scope Boundary](../ver4.5/scope_boundary_v4_5.md)
|
||||
- [V4.5 Implementation Plan](../ver4.5/implementation_plan_v4_5.md)
|
||||
- [V4.2 Evidence and Provenance Scope](../ver4.2/scope_boundary_v4_2.md)
|
||||
- [V4 Architecture](../ver4/architecture_v4.md)
|
||||
- [V4 Schema](../ver4/schema_v4.md)
|
||||
- [Transcription Methodology](../invariant/transcription_methodology.md)
|
||||
- [AI Evidence and Provenance Invariant](../invariant/ai_evidence_and_provenance.md)
|
||||
@@ -0,0 +1,205 @@
|
||||
# V4.6 Scope Boundary
|
||||
|
||||
This document defines the frozen boundary for V4.6, a **pure remediation release**. V4 through V4.5 remain the architecture and behavioral baseline. V4.6 introduces **no new user-facing features**; it pays down the defects, duplication, and structural drift catalogued in [Architecture & Code Review Report](../architecture_code_review_2026-08-17.md).
|
||||
|
||||
Every item in scope is traceable to a review finding ID. Any change that cannot be traced to a finding ID is out of scope.
|
||||
|
||||
## Purpose
|
||||
|
||||
- Remove dead code, dead configuration, and duplicate implementations that create maintenance drift.
|
||||
- Re-level the database schema from current SQLModel metadata, ending hand-rolled DDL while the schema is still pre-production.
|
||||
- Correct read amplification, missing indexes, and query patterns that scale with table size rather than result size.
|
||||
- Restore the boundaries the project already wrote down in `.github/instructions/services.instructions.md` and `ui.instructions.md`.
|
||||
- Make `ty` usable as a real quality gate.
|
||||
- Preserve every existing behavior, evidence guarantee, and provenance contract established in V4 through V4.5.
|
||||
|
||||
## Confirmed Operating Context
|
||||
|
||||
These answers are frozen for V4.6 and govern every decision below.
|
||||
|
||||
| Question | Answer |
|
||||
| :--- | :--- |
|
||||
| Database | **SQLite only.** PostgreSQL remains the intended destination but is deferred beyond V4.6. `JSONBCompat` and the Postgres drivers are retained. |
|
||||
| Topology | **Single user, single process, single worker.** A multi-user server is the stated direction, so forward-compatibility work is retained where it is cheap. |
|
||||
| Schema evolution | **Re-level from current metadata.** No Alembic, no migration framework, no `_upgrade_*` chain. |
|
||||
| Existing data | The development database is rebuilt from scratch during implementation and migrated from backup as the final step. |
|
||||
| Release character | **Pure remediation.** No new features. |
|
||||
| Scope band | Critical through Low, inclusive. |
|
||||
|
||||
## In Scope
|
||||
|
||||
### 1. Dead Code and Dead Configuration Removal
|
||||
|
||||
- `src/transcription/app_state.py` is deleted. It has zero importers and contains a guaranteed `TypeError` ([HIGH-01]).
|
||||
- `src/transcription/services/transcription.py` is deleted; `build_prompt_execution` has exactly one import path ([MED-05]).
|
||||
- The legacy compatibility aliases in `services/store.py` are deleted ([MED-05]).
|
||||
- `ServiceBase.queue` is deleted; no service allocates an unused `asyncio.Queue` ([MED-07]).
|
||||
- `sqlite_check_same_thread` and `worker_retry_backoff_seconds` are either wired to real behavior or deleted, along with their tests ([MED-02]).
|
||||
- `db/operations.py:get_next_queued_job` is deleted as a divergent duplicate ([CRIT-01]).
|
||||
- `DATABASE_URL` is removed from `docker-compose.yml`, and the real nested `DATABASE__*` names are documented. The application never silently ignores a database configuration variable ([MED-10]).
|
||||
- `document_panzoom` is either fixed or deleted; it is exported but referenced by no page ([HIGH-07]).
|
||||
|
||||
### 2. Schema Re-Level
|
||||
|
||||
The following changes are schema-affecting and land as **one single pass** against a database rebuilt from empty.
|
||||
|
||||
- `upgrade_schema` and the three `_upgrade_*` functions (`db/operations.py:25-109`) are deleted, along with their tests (`tests/test_db.py:109-172`) ([HIGH-05]).
|
||||
- The schema is generated exclusively from SQLModel metadata via `create_all()`, gated by the existing `Settings.should_bootstrap_schema` ([HIGH-05]).
|
||||
- The hand-written `CHAR(32)` column for `preferred_execution_attempt_id` ceases to exist; the column type is whatever the model declares ([HIGH-05]).
|
||||
- A composite index on `Job.status, Job.date_created` is declared in the model, plus `index=True` on the foreign keys the worker and detail pages filter on ([HIGH-04]).
|
||||
- `Source.preferred_execution_attempt_id` declares its foreign key with `use_alter=True`, resolving the `source` / `job_source` / `execution_attempt` cycle so `create_all` will succeed on PostgreSQL when that cutover is taken ([HIGH-08]).
|
||||
- Relationship loading defaults change from bidirectional `lazy="selectin"` to `lazy="raise"`, with per-query `selectinload()` retained or added where a load path genuinely requires it ([CRIT-02]).
|
||||
|
||||
No migration script runs against a populated database. No history table, revision directory, or down path is introduced.
|
||||
|
||||
### 3. Data Migration
|
||||
|
||||
- A one-time script under `tools/` migrates the user's backed-up V4.5 data into the re-leveled schema.
|
||||
- The script is authored **after** the `lazy="raise"` flip is complete, so that every relationship it traverses carries an explicit eager load.
|
||||
- The script is idempotent, is never invoked automatically at startup, and never runs as part of the test suite.
|
||||
- Uploaded Source files, portraits, and artifact files on disk are preserved unchanged; only database rows are rewritten.
|
||||
- This is the **final** step of V4.6.
|
||||
|
||||
### 4. Worker and Provider Reliability
|
||||
|
||||
- `read_next_queued_job` gains `LIMIT 1` and stops materializing the entire queue plus its eager graph on every poll ([CRIT-01]).
|
||||
- The claim becomes an atomic `QUEUED` → `PROCESSING` transition. On SQLite this is a bounded single-writer transaction; the `FOR UPDATE SKIP LOCKED` path is written and dialect-guarded for the multi-user direction but is not exercised in V4.6 ([CRIT-01]).
|
||||
- Eager relationships are loaded in a second query after the claim succeeds, keeping the hot poll a single narrow row ([CRIT-01]).
|
||||
- `ServiceBundle` and the provider client are hoisted to worker-loop scope so the HTTP connection pool and TLS session survive across jobs ([HIGH-02], [MED-06]).
|
||||
- `ServiceBundle` gains a `from_session_factory` constructor, replacing three duplicated instantiation blocks ([MED-06]).
|
||||
- The `le=20.0` cap on `worker_provider_timeout_seconds` is removed, the default is raised, and an explicit `httpx.Timeout` is passed to the OpenRouter client ([HIGH-03]).
|
||||
- The `TranscriptionProvider` Protocol is extended to cover `aclose` and the evidence attributes; the per-call `inspect.signature` reflection at `sources.py:1237` is deleted ([MED-03]).
|
||||
|
||||
### 5. Service Layer Consolidation
|
||||
|
||||
- A generic `RegistryService[ModelT]` owns list, summaries, create, read, update, delete, and reference-check for semantic-key registries. `DocumentType` and `PersonRole` become thin subclasses declaring their model, error class, reference query, and noun ([MED-11]).
|
||||
- Label normalization, the casefold key, and the registry summary shape are defined once ([MED-11]).
|
||||
- `ServiceBase` gains `_get_or_raise`, and all 38 hand-written not-found guards adopt it, including the three in `documents.py` that already bypass the local helper ([MED-12]).
|
||||
- `services/media_storage.py` becomes the single implementation of validate → hash → write → wrap-error, replacing `store_source_file`, `store_person_portrait`, and the homepage image writer ([MED-13]).
|
||||
- `source_mime_type` moves out of `services/sources.py` to a shared module so `documents.py` no longer imports a sibling service ([MED-14], partial).
|
||||
- The four query inefficiencies in `sources.py` are corrected: the `job_id` filter moves into SQL, navigation uses two bounded queries, `list_processing_artifacts` gains a `limit`, and artifact re-hashing moves off the event loop ([LOW-08]).
|
||||
|
||||
### 6. Async I/O and Configuration Hygiene
|
||||
|
||||
- Blocking filesystem and CPU work — media writes, artifact writes, integrity hashing, and Pillow orientation normalization — is wrapped in `asyncio.to_thread` ([MED-01]).
|
||||
- `functools.cache` on the engine and session factories is replaced with an explicit URL-keyed registry supporting targeted eviction ([MED-04]).
|
||||
- The `object.__setattr__` mutation of a frozen `Settings` model in `normalize_provider_models` is replaced with `model_copy(update=...)` or a computed property ([Pydantic V2 §3]).
|
||||
- `models.py` timestamp columns that are expected to track modification gain `onupdate`, so `updated_at` and `date_updated` stop being stale on paths that do not set them by hand ([SQLModel §3]).
|
||||
- The exception swallowed to `None` in an ORM model property is surfaced ([MED-08]).
|
||||
|
||||
### 7. UI Boundary and Duplication
|
||||
|
||||
- The three `ui.instructions.md` violations are corrected ([HIGH-07]):
|
||||
- `jobs_page.py` no longer imports `session_scope` or manages transactions; a service or workflow method owns the session.
|
||||
- `sources_page.py` no longer imports `sqlalchemy.inspect`; the service returns a plain `transport_body_deferred` flag on a read model.
|
||||
- `document_panzoom` no longer calls `get_settings()`; a ready media URL is passed in.
|
||||
- The duplication catalogued in the review's §4 is extracted, highest value first: `confirm_delete`, `media_urls`, `guards`, `formatters`, `upload_panel`, and the hand-rolled tables that should use `build_table` (~500 lines).
|
||||
- The 23KB inline SVG moves to `ui/static/` and is loaded through an `importlib.resources` reader alongside the existing `read_css` ([MED-09]).
|
||||
- `people_page.py:504` routes its error through `error_presenter.show_error` like every sibling handler ([LOW-07]).
|
||||
- Untyped handler parameters and loosely-typed dict returns are annotated ([LOW-05]).
|
||||
- The auto-refresh timer is cancelled rather than only deactivated, and its interval becomes a named constant ([LOW-06]).
|
||||
|
||||
### 8. Type Checking and Tooling Gate
|
||||
|
||||
- The codebase standardizes on `ty`. Remaining suppressions are converted from `# pyright: ignore[...]` to `# ty: ignore[...]` ([HIGH-06]).
|
||||
- The `lazy="raise"` flip in §2 is expected to eliminate most of the ~160 `selectinload` diagnostics by removing redundant eager loads.
|
||||
- `ty check` reaches zero diagnostics and is wired into the existing pre-commit setup as a gate ([HIGH-06]).
|
||||
- The two real bugs currently hidden in the diagnostic noise are fixed: `tests/ui/test_sources_page.py:25` constructs `Source(...)` without the required `document_id`, and `tools/run_destructive_tests.py:76,80` uses `fcntl`, which does not exist on the Windows development platform ([HIGH-06]).
|
||||
- `ruff check` reaches zero errors ([LOW-01]).
|
||||
- `asyncio_default_fixture_loop_scope` is configured explicitly so pytest-asyncio behavior does not change on upgrade ([Testing §3]).
|
||||
- The stale path in `.github/instructions/services.instructions.md:10` is corrected to `src/transcription/db/models.py` ([LOW-02]).
|
||||
- `list_jobs` stops accepting and discarding `load_docs` ([LOW-03]).
|
||||
- `resolve_worker_notifier` validates its `getattr` result ([LOW-04]).
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Any new user-facing feature, page, action, or field.
|
||||
- PostgreSQL enablement, Postgres-backed CI, or a Postgres cutover. The `use_alter` fix unblocks it; it does not perform it.
|
||||
- Alembic or any migration framework, revision directory, history table, or down path.
|
||||
- Multi-worker or multi-process execution. Forward-compatible code paths are written but not enabled or exercised.
|
||||
- Concurrency limits, backpressure, or parallel job processing. Jobs remain strictly serial.
|
||||
- **Splitting `SourceService` into per-model services and relocating `update_job_source_transcription` to `workflows.py` ([MED-14]). Deferred to V4.7.** It touches the transcription write path and cannot safely share a release with the schema re-level.
|
||||
- Any change to transcription prompt content, medium markers, quality-warning rules, or the retranscription workflow established in V4.5.
|
||||
- Any change to the evidence, provenance, or immutability contracts established in V4.2 through V4.5.
|
||||
- Deleting, rewriting, or reinterpreting existing `ExecutionAttempt` or `ProcessingArtifact` evidence during data migration.
|
||||
- Rewriting the UI table architecture, theme system, or CSS conventions beyond removing duplication.
|
||||
- Performance work not traceable to a review finding.
|
||||
|
||||
## Locked Design Decisions
|
||||
|
||||
### A. Remediation Only
|
||||
|
||||
Every change traces to a review finding ID. A desirable improvement discovered during implementation that has no finding ID is recorded for a later revision rather than absorbed.
|
||||
|
||||
### B. Re-Level, Do Not Migrate
|
||||
|
||||
The schema is pre-production and the data is disposable and backed up. Deleting the hand-rolled upgrade chain and regenerating from metadata is correct precisely because this window will not exist again. A migration framework is the right answer once the schema stabilizes, and V4.6 deliberately does not pretend that moment has arrived.
|
||||
|
||||
### C. One Schema Pass
|
||||
|
||||
The re-level, the indexes, the `use_alter` fix, and the `lazy="raise"` flip all regenerate the same schema. They land together, are verified together, and are reverted together if verification fails. Partial application is not a valid state.
|
||||
|
||||
### D. Data Migration Is Last
|
||||
|
||||
The migration script is written against the final schema and the final loading strategy. Writing it earlier guarantees rework and risks it carrying implicit lazy loads that `lazy="raise"` will later reject.
|
||||
|
||||
### E. Forward Compatibility Where It Is Cheap
|
||||
|
||||
Single-process operation makes the atomic job claim non-urgent, not wrong. Where the correct multi-user implementation costs little more than the single-user one, V4.6 writes the correct one and guards it by dialect. Where it costs substantially more, V4.6 defers it and documents the assumption.
|
||||
|
||||
### F. Behavior Is Preserved Exactly
|
||||
|
||||
A pure-remediation release that changes observable behavior has failed. The existing test suite is the contract: 264 passing tests must still pass, and any test that must change is treated as evidence that the change is not remediation.
|
||||
|
||||
### G. The Instruction Files Are the Standard
|
||||
|
||||
Most findings are deviations from rules the project already wrote down. V4.6 restores conformance to `services.instructions.md` and `ui.instructions.md` rather than inventing new conventions — except where a rule is itself wrong, in which case the rule is corrected explicitly.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
1. `app_state.py`, `services/transcription.py`, the `store.py` aliases, `ServiceBase.queue`, and `db/operations.py:get_next_queued_job` no longer exist, and the full suite passes without them.
|
||||
2. `upgrade_schema` and the three `_upgrade_*` functions no longer exist; no raw `ALTER TABLE` or `CREATE INDEX` string appears in `src`.
|
||||
3. A database created from empty by `create_all()` contains the composite `Job` index, indexed hot foreign keys, and a `preferred_execution_attempt_id` column whose type matches the model declaration.
|
||||
4. Compiling the metadata against the PostgreSQL dialect emits **no** unresolvable-cycle warning.
|
||||
5. No `Relationship` in `db/models.py` uses `lazy="selectin"` as a bidirectional default; every load path that requires eager loading declares it per query, and the suite passes under `lazy="raise"`.
|
||||
6. `read_next_queued_job` returns at most one row and issues no eager-load queries; a test asserts the emitted SQL contains `LIMIT`.
|
||||
7. The worker processes two consecutive jobs against a single provider client instance; a test asserts the client is not reconstructed between jobs.
|
||||
8. `worker_provider_timeout_seconds` accepts a value above 20 seconds, and the OpenRouter client receives an explicit `httpx.Timeout`.
|
||||
9. `inspect.signature` no longer appears in the transcription call path.
|
||||
10. `DocumentType` and `PersonRole` CRUD is served by one shared implementation; the existing registry tests for both pass unchanged.
|
||||
11. `ServiceBase._get_or_raise` is the only place a `NOT_FOUND` guard is written for an entity fetched by id.
|
||||
12. One media-storage implementation serves Source files, portraits, and homepage images, and its write is off the event loop.
|
||||
13. `list_sources_detail` filters by `job_id` in SQL; `read_source_navigation` issues bounded queries; `list_processing_artifacts` accepts a `limit`.
|
||||
14. No page imports `session_scope`, `sqlalchemy.inspect`, or `get_settings`.
|
||||
15. The 23KB SVG literal no longer appears in any `.py` file.
|
||||
16. `ruff check` reports zero errors.
|
||||
17. `ty check` reports zero diagnostics and runs as a pre-commit gate.
|
||||
18. `tools/run_destructive_tests.py` runs on Windows.
|
||||
19. All 264 pre-existing tests still pass. Any test modified during V4.6 is individually justified as a test defect rather than a behavior change.
|
||||
20. The migration script restores the backed-up V4.5 data into the re-leveled schema with row counts matching the backup, and no on-disk Source file, portrait, or artifact is modified.
|
||||
21. No new user-facing feature, page, action, or field exists in V4.6 that did not exist in V4.5.
|
||||
22. Database, integration, and UI verification uses isolated test data and never modifies `data/transcription.db`.
|
||||
|
||||
## Scope Freeze Gate
|
||||
|
||||
V4.6 is sufficiently frozen to begin implementation:
|
||||
|
||||
- The operating context — SQLite, single process, disposable data — is confirmed and its consequences for severity are resolved.
|
||||
- The schema strategy is resolved: re-level, no Alembic, one pass, migration last.
|
||||
- The severity band is resolved: Critical through Low, inclusive.
|
||||
- The service-layer consolidation set is resolved, and the `SourceService` split is explicitly deferred to V4.7.
|
||||
- The release character is resolved: pure remediation, no new features.
|
||||
|
||||
Any expansion into PostgreSQL enablement, multi-worker execution, a migration framework, the `SourceService` split, or any new feature requires an explicit V4.6 scope amendment or a later revision.
|
||||
|
||||
## Related Local References
|
||||
|
||||
- [V4.6 Implementation Plan](implementation_plan_v4_6.md)
|
||||
- [Architecture & Code Review Report](../architecture_code_review_2026-08-17.md)
|
||||
- [V4.5 Scope Boundary](../ver4.5/scope_boundary_v4_5.md)
|
||||
- [V4.5 Implementation Plan](../ver4.5/implementation_plan_v4_5.md)
|
||||
- [V4.2 Evidence and Provenance Scope](../ver4.2/scope_boundary_v4_2.md)
|
||||
- [V4 Architecture](../ver4/architecture_v4.md)
|
||||
- [V4 Schema](../ver4/schema_v4.md)
|
||||
- [Transcription Methodology](../invariant/transcription_methodology.md)
|
||||
- [AI Evidence and Provenance Invariant](../invariant/ai_evidence_and_provenance.md)
|
||||
Reference in New Issue
Block a user