claude-sonnet-5 review: Phase 2 by gpt-5.3-codex
Quality Gate / gate (push) Failing after 11s

This commit is contained in:
Jim Lancaster
2026-08-20 15:22:23 -05:00
parent afd1dba4d4
commit 796216087c
10 changed files with 172 additions and 74 deletions
-33
View File
@@ -1,33 +0,0 @@
You are working in the `transcription` repository (Python 3.12+, FastAPI, NiceGUI, SQLModel/SQLAlchemy, Pydantic V2, asyncio). Follow `.github/instructions/ui.instructions.md` and `.github/instructions/services.instructions.md` for any code you touch, and keep `docs/ver4/*` as canonical authority for intended behavior. Do not modify unrelated code.
**Prerequisite:** This prompt assumes Phase 1 (`docs/phase1-codex-prompt.md`) is already merged — the SQLite atomic job claim and the `JobSource(job_id, source_id)` uniqueness constraint should already exist. If they do not, stop and flag this before proceeding, since this phase's regression tests depend on that groundwork.
## Goal
Implement **Phase 2 (Enforcement hardening)** from `docs/architecture-code-review-2026-08-20.md`:
### 1. [HIGH-02] General UI media resolution falls back to basename instead of failing closed
**Location:** `src/transcription/ui/components/media_urls.py:41-74`, `src/transcription/ui/pages/sources_page.py:261-265`
**Problem:** When a stored path is not provably under `upload_dir` and does not match an approved prefix, `resolve_media_url` still returns `/uploads/{path_obj.name}`. The source detail page passes that URL directly to the viewer. If an unmanaged or stale DB path shares a basename with another upload, the UI can render the wrong file instead of the recorded source — violating record fidelity and the "controlled resolver paths" rule (`REQ-4-031`). This is distinct from print/export media, which is already correctly record-validated in `src/transcription/api/v4_print.py:31-54`.
**Required fix:**
- In `resolve_media_url` (`src/transcription/ui/components/media_urls.py`), remove the basename fallback. Return `None` (or an explicit "unavailable" sentinel/token consistent with how the rest of the UI layer signals missing/invalid media — check `error_presenter.py` and existing viewer components for the established pattern) unless the path is validated as either (a) safely resolvable under the configured `upload_dir`, or (b) an already-approved upload-relative form recognized elsewhere in the codebase (mirror the validation used by `v4_print.py`).
- Update `src/transcription/ui/pages/sources_page.py:261-265` (and any other call site relying on the old fallback behavior) to handle the `None`/unavailable case gracefully — e.g. show a clear "media unavailable" state in the viewer rather than crashing or rendering a blank/broken image.
- Add a dedicated resolver test suite (new or extended, e.g. `tests/ui/test_media_urls.py`) covering: a validated path under `upload_dir` (should resolve), an unmanaged absolute path outside `upload_dir` (should fail closed), a stale/nonexistent relative path (should fail closed), and a basename-collision scenario where an unmanaged path shares a filename with a legitimate upload (must NOT resolve to the wrong file). This directly satisfies the meta-tooling recommendation in `docs/architecture-code-review-2026-08-20.md` section 8: "Add a deterministic resolver test suite for `resolve_media_url`, not just `public_media_path_label`."
### 2. Deterministic regression tests for Phase 1 fixes
**Rationale:** Phase 1 fixed the underlying atomicity/uniqueness issues; this phase locks them in with deterministic, always-run tests so no future change can silently regress them (per the review's Action Plan, item 2, and section 8 meta-tooling recommendations).
**Required work:**
- Confirm (or add if missing) a concurrency regression test that races two concurrent `claim_next_queued_job` calls against the same queued job on SQLite and asserts exactly one caller wins. If Phase 1 already added this test, review it for robustness (e.g. does it actually force a race rather than relying on incidental ordering?) and strengthen it if needed — for example by using two independent sessions/connections and asserting via `asyncio.gather` that exactly one result is non-`None`.
- Confirm (or add if missing) a deterministic test asserting that creating a second `JobSource` for an existing `(job_id, source_id)` pair is rejected/handled predictably (not a silent duplicate). Ensure this test exercises the actual insertion code path used by `services/sources.py`, not just the raw model constraint.
- Both tests should live alongside the existing service test files for `jobs.py`/`sources.py` (check `tests/services/` for the correct location and naming convention).
## Validation
- Run `pytest` (via the project's normal invocation, e.g. `uv run pytest`, potentially through `tools/run_destructive_tests.py --auto-restore -- pytest` if that's how destructive/DB tests are run in this repo — check `pyproject.toml`/README) and ensure all tests pass, including new/strengthened tests.
- Run `ruff check` and `ty check` and ensure no new issues are introduced by your changes (do not attempt to fix the pre-existing baseline debt — that is Phase 5's responsibility).
- Do not touch error taxonomy (Phase 3), UI/service consolidation (Phase 4), or provenance/env config and ruff/ty baseline cleanup (Phase 5) — those are out of scope for this task.
Report back with: files changed, the fail-closed validation logic chosen for `resolve_media_url`, the new/updated test coverage, and final `pytest`/`ruff`/`ty` results.
+25 -7
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
from pathlib import Path
from urllib.parse import quote
from urllib.parse import unquote
_ABSOLUTE_SCHEMES = ("http://", "https://", "data:")
_UPLOAD_ROUTE_PREFIX = "/uploads/"
@@ -33,7 +34,8 @@ def resolve_media_url(path: str | None, *, upload_dir: Path, base_url: str) -> s
if lowered.startswith(_ABSOLUTE_SCHEMES):
return normalized
if normalized.startswith(_UPLOAD_ROUTE_PREFIX):
return absolute_upload_url(normalized, base_url=base_url)
upload_relative = normalized.removeprefix(_UPLOAD_ROUTE_PREFIX)
return _resolve_upload_relative(upload_relative, upload_dir=upload_dir, base_url=base_url)
resolved_upload_dir = upload_dir.resolve()
path_obj = Path(candidate)
@@ -51,6 +53,7 @@ def resolve_media_url(path: str | None, *, upload_dir: Path, base_url: str) -> s
relative = absolute_candidate.relative_to(resolved_upload_dir).as_posix()
except ValueError:
continue
if absolute_candidate.is_file():
return absolute_upload_url(f"/uploads/{quote(relative)}", base_url=base_url)
upload_name = resolved_upload_dir.name.casefold()
@@ -60,18 +63,33 @@ def resolve_media_url(path: str | None, *, upload_dir: Path, base_url: str) -> s
index = lowered_parts.index(upload_name)
relative = Path(*normalized_parts[index + 1 :]).as_posix()
if relative:
return absolute_upload_url(f"/uploads/{quote(relative)}", base_url=base_url)
return _resolve_upload_relative(relative, upload_dir=upload_dir, base_url=base_url)
if lowered.startswith("uploads/"):
return absolute_upload_url(f"/{normalized}", base_url=base_url)
upload_relative = normalized.split("/", 1)[1] if "/" in normalized else ""
return _resolve_upload_relative(upload_relative, upload_dir=upload_dir, base_url=base_url)
if lowered.startswith("data/"):
relative = normalized.split("/", 1)[1] if "/" in normalized else ""
if relative:
return absolute_upload_url(f"/uploads/{quote(relative)}", base_url=base_url)
return _resolve_upload_relative(relative, upload_dir=upload_dir, base_url=base_url)
if lowered.startswith(("documents/", "persons/")):
return absolute_upload_url(f"/uploads/{quote(normalized)}", base_url=base_url)
return _resolve_upload_relative(normalized, upload_dir=upload_dir, base_url=base_url)
return absolute_upload_url(f"/uploads/{quote(path_obj.name)}", base_url=base_url)
return None
def _resolve_upload_relative(relative: str, *, upload_dir: Path, base_url: str) -> str | None:
candidate = unquote(relative).strip().replace("\\", "/")
if not candidate:
return None
resolved_upload_dir = upload_dir.resolve()
try:
absolute_candidate = (resolved_upload_dir / Path(candidate)).resolve()
safe_relative = absolute_candidate.relative_to(resolved_upload_dir).as_posix()
except ValueError:
return None
if not absolute_candidate.is_file():
return None
return absolute_upload_url(f"/uploads/{quote(safe_relative)}", base_url=base_url)
def public_media_path_label(path: str | None, *, upload_dir: Path) -> str:
+5 -3
View File
@@ -259,10 +259,12 @@ def register_page() -> None: # noqa: PLR0915
def _render_source_viewer_zone(source: Source, *, settings: Settings, request: Request) -> None:
dark_room_viewer(
resolve_media_url(source.file_path, upload_dir=settings.upload_dir, base_url=str(request.base_url)),
count_label=f"Page {source.page_number}",
media_url = resolve_media_url(
source.file_path,
upload_dir=settings.upload_dir,
base_url=str(request.base_url),
)
dark_room_viewer(media_url, count_label=f"Page {source.page_number}")
def _render_source_navigation(previous_id: UUID | None, next_id: UUID | None) -> None:
@@ -7,12 +7,12 @@ BY WAY OF INTRODUCTION:-
These few paragraphs of introduction may help you read BOOK 2 which covers a wider range than did BOOK 1 (Pioneer Days).
BOOK 1 had 54 pages; 14 chapters. BOOK 2 has 70 pages; 18 chapters. BOOK 1 consisted largely of first generation family history. BOOK 2 throws more light on the second generation. Sidney promises a BOOK 3 and that may begin to do justice to the third generation. We suggest that Sidney get the help of Louis Shinn who has a chapter in this book (Chapter 16 - The Last 25 Years on the Doumecq Plains. Louis has the gift of seeing, recalling and telling. One sentence in his chapter gives a great tribute to the Doumecqers--so far as he knows no one on the Doumecq Plains went on relief during the depression. That in a nutshell shows the sturdy character of the residents of the Doumecq Plains.
BOOK 1 had 54 pages; 14 chapters. BOOK 2 has 70 pages; 18 chapters. BOOK 1 consisted largely of first generation family history. BOOK 2 throws more light on the second generation. Sidney promises a BOOK 3 and that may begin to do justice to the third generation. We suggest that Sidney get the help of Louis Shinn who has a chapter in this book (Chapter 16 - The Last 25 Years on the Doumecq Plains. Louis has the gift of seeing, recalling and telling. One sentence in his chapter gives a great tribute to the Doumecqers--so far as he knows no one on the Doumecq Plains went on relief during the depression. That in a nutshell shows the sturdy character of the residents of the Doumeoq Plains.
We promised in BOOK 1 that in BOOK 2 we would give the story of the trip of John E. Cochran and wife to Tennessee, Cuba and the Panama Canal. You will see by the Table of Contents that the first four chapters have been given to those trips. Those chapters are worth reading and re-reading. Mr. Cochran has eyes to see and a pen to tell. We think the people in Tennessee will read with great pleasure the comments he makes on conditions today.
Some who get this book will consider the group picture the best thing in the book. It took a lot of preliminary photographing to reduce some pictures, enlarge others and bring out the tin types. We wish that instead of 44 faces we could have given 88. Do not blame Ethel Cochran-Shinn for the selection. She furnished enough pictures but we had to take only part of them. We think there are great possibilities in reproducing old pictures. We wish we had a Pickard group. Some Pickard descendant may wish to make a collection.
We are much impressed with the future possibilities of getting a complete geneology [sic] of the Pickard family. Mr. Cochran has a fine chapter on the Pickards but to date we have not had the pleasure of finding all of the family dates. We had intended to give more family data in this book but it takes time to get the correct dates. Often times it requires trips to cemeteries to get dates on the tombstones. Winter is no time to collect dates on tombstones.
We are much impressed with the future possibilities of getting a complete geneology[sic] of the Pickard family. Mr. Cochran has a fine chapter on the Pickards but to date we have not had the pleasure of finding all of the family dates. We had intended to give more family data in this book but it takes time to get the correct dates. Often times it requires trips to cemeteries to get dates on the tombstones. Winter is no time to collect dates on tombstones.
-2-
~2~
@@ -19,6 +19,8 @@ Nome Alaska August 26, 1923
My Dear Ethel et al.
[photograph of a group of people standing on snow with poles/sled gear]
I don't know when I did write or when you did
but I am going to write now however and never
the less. But I wish I could talk (I can yet but I
@@ -38,7 +40,6 @@ along the icebergs and the walrus were 'heisted' on board by the use of the cran
tons of freight and the beasts were so huge that they made the pulleys just creak. They were
over 12 feet long, as big around as three cows, had no feet but toenails on their flippers or
flappers, no head but their body just suddenly ended with a hole for a mouth and big bristles
[photograph inserted at right of paragraph]
all around it similar to a currycomb in coarseness; no ears but huge tusks of ivory. They are
the most repulsive looking animals imaginable and tho I have always read about them I never
expect such disagreeable looking creatures. They had a rough brown hairy skin and some of
@@ -46,8 +47,8 @@ them looked warty. They must have weighed two ton at least. Ere we got them back
to the natives they were getting extremely odiferous—in fact, you could scarcely stay on the
ship with any degree of comfort unless you had per chance lost your sense of smell.
Then we went north to a few minutes beyond the 70th degree of latitude and thot [sic] for awhile
we would go to Wrangell Island where some men from Steffonsons [sic] ship were supposed to be
Then we went north to a few minutes beyond the 70th degree of latitude and thot for awhile
we would go to Wrangell Island where some men from Steffonsons ship were supposed to be
stranded but we didn't get there and instead stopped at a small native village at Cape Serdz in
Siberia. These Eskimo were very primitive. One white squaw man lived there and had for 23
years. He was a Swede--who else could. Their houses were circular and built up with dirt 2 or
@@ -61,7 +62,7 @@ bears with faces. I guess they had never seen white women, not so many at one ti
We went ashore in 2 life boats and a launch pulling them. On the launch was a six piece band
playing and the rear of the last life boat was the movie man. 'Twas very thrilling.
The other place we stopped was at Whalen, a trading post in Siberia. There these 'towerists' [sic]
The other place we stopped was at Whalen, a trading post in Siberia. There these 'towerists'
went wild. They rushed helter-skelter, hither and thither, here and there, trying to find
something to buy. Prices raised right before your eyes. One would [buy?] something for $1.00
and the next might have to pay $2.00, $4.00 or $10.00. That made no difference. They had to
@@ -70,7 +71,7 @@ bought 2 ivory tusks, 1 pup, 2 moccasins, 3 or 4 billikens, 6 or 8 ivory and sil
fishing line, hooks, floats, etc. and two bird slings. The slings have rocks at the end and the
little natives throw them at the flocks of geese and ducks which fly close over the village and
the slings entangle their wings and legs, sometimes more than one, and they can't fly. They
come down and the natives capture them. There was more junk brot [sic] aboard than baggage, I
come down and the natives capture them. There was more junk brot aboard than baggage, I
do believe. And they say that at the first stop it was worse than here. The red flag was flying
over Whalen and the Russian soldiers were there—a few, one or two or three, I forget the
number.
@@ -80,7 +81,7 @@ until 2:30. The girls had prepared a big meal for us and invited up the Hartford
us talk. Miss Saville talked quite a bit. Any how if you folks don't like this I don't care, it is
all I had to write about and I know Buster'ud listen anyway and I'd soak ole Peter's head if he
didn't and Polly would in my lap and I don't know much about the youngest one of yours so
likely he would be squawling. But we did surely enjoy our trip and were gone just long enuf [sic].
likely he would be squawling. But we did surely enjoy our trip and were gone just long enuf.
I expect there were 150 passengers on board and almost or more of the crew and helpers. We
had a stateroom down next to the kitchen and 'twas pretty fierce for odor at times.
@@ -9,25 +9,24 @@ ISBILL & MOSER
DEALERS IN
GENERAL MERCHANDISE
Vonore, Tenn., [handwritten: Aug 27-] 191[handwritten: 2]
Vonore, Tenn., [handwritten: July 27-] 191[handwritten:2]
[handwritten: Dear [Nuch?] Aunt & Cousin
[handwritten: Dear Uncle Aunt & Cousins
I was at home a
few nights ago & saw a
letter from your folks, so
I decided to write you
a few lines in regard of
I am contemplating a
a few lines myself if
I can [contributeful?] a
trip out west next summer
& want [lots?] of [places?] to go
where I am [from?].
I want [illegible] to go
where I am from.
Am getting
up in years & [unmarried?].
So you see the object of
up in years & wondering.
So youl [sic] see the object of
my trip, is to get a wife
If there is any old maid
or widow out there I
want you to [hiev?] them
at one [find?] me at them
want you to hire them
at our [illegible] at them
as soon as I get there.]
+3 -3
View File
@@ -176,10 +176,10 @@ class TestJobService:
claimed = await job_service.claim_next_queued_job()
return claimed.id if claimed is not None else None
first_claim, second_claim = await asyncio.gather(claim_once(), claim_once())
claim_results = await asyncio.gather(*(claim_once() for _ in range(8)))
assert [first_claim, second_claim].count(queued_job.id) == 1
assert [first_claim, second_claim].count(None) == 1
assert claim_results.count(queued_job.id) == 1
assert claim_results.count(None) == 7
assert await job_service.claim_next_queued_job() is None
@pytest.mark.asyncio
+81 -2
View File
@@ -1,6 +1,85 @@
from pathlib import Path
from transcription.ui.components.media_urls import public_media_path_label
from transcription.ui.components.media_urls import resolve_media_url
def test_resolve_media_url_maps_managed_absolute_path_to_upload_route(tmp_path):
upload_dir = tmp_path / "uploads"
managed_path = upload_dir / "documents" / "abc" / "page.jpg"
managed_path.parent.mkdir(parents=True, exist_ok=True)
managed_path.write_bytes(b"x")
resolved = resolve_media_url(str(managed_path), upload_dir=upload_dir, base_url="http://localhost:8000")
assert resolved == "http://localhost:8000/uploads/documents/abc/page.jpg"
def test_resolve_media_url_rejects_unmanaged_absolute_path(tmp_path):
upload_dir = tmp_path / "uploads"
managed_path = upload_dir / "documents" / "abc" / "page.jpg"
managed_path.parent.mkdir(parents=True, exist_ok=True)
managed_path.write_bytes(b"x")
unmanaged_path = tmp_path / "other-root" / "secret" / "page.jpg"
unmanaged_path.parent.mkdir(parents=True, exist_ok=True)
unmanaged_path.write_bytes(b"x")
resolved = resolve_media_url(str(unmanaged_path), upload_dir=upload_dir, base_url="http://localhost:8000")
assert resolved is None
def test_resolve_media_url_rejects_stale_relative_path(tmp_path):
upload_dir = tmp_path / "uploads"
resolved = resolve_media_url(
"documents/missing/page.jpg",
upload_dir=upload_dir,
base_url="http://localhost:8000",
)
assert resolved is None
def test_resolve_media_url_rejects_basename_collision_from_unmanaged_path(tmp_path):
upload_dir = tmp_path / "uploads"
managed_path = upload_dir / "documents" / "abc" / "shared-name.jpg"
managed_path.parent.mkdir(parents=True, exist_ok=True)
managed_path.write_bytes(b"managed")
unmanaged_path = tmp_path / "scratch" / "shared-name.jpg"
unmanaged_path.parent.mkdir(parents=True, exist_ok=True)
unmanaged_path.write_bytes(b"unmanaged")
resolved = resolve_media_url(
str(unmanaged_path),
upload_dir=upload_dir,
base_url="http://localhost:8000",
)
assert resolved is None
def test_resolve_media_url_accepts_existing_upload_relative_path(tmp_path):
upload_dir = tmp_path / "uploads"
managed_path = upload_dir / "documents" / "abc" / "page.jpg"
managed_path.parent.mkdir(parents=True, exist_ok=True)
managed_path.write_bytes(b"x")
resolved = resolve_media_url(
"documents/abc/page.jpg",
upload_dir=upload_dir,
base_url="http://localhost:8000",
)
assert resolved == "http://localhost:8000/uploads/documents/abc/page.jpg"
def test_resolve_media_url_accepts_existing_portraits_relative_path(tmp_path):
upload_dir = tmp_path / "uploads"
managed_path = upload_dir / "portraits" / "person" / "seeded.png"
managed_path.parent.mkdir(parents=True, exist_ok=True)
managed_path.write_bytes(b"x")
resolved = resolve_media_url(
"portraits/person/seeded.png",
upload_dir=upload_dir,
base_url="http://localhost:8000",
)
assert resolved == "http://localhost:8000/uploads/portraits/person/seeded.png"
def test_public_media_path_label_maps_managed_absolute_path_to_upload_route(tmp_path):
+8 -2
View File
@@ -6,6 +6,7 @@ from uuid import uuid4
import pytest
from sqlmodel import select
from transcription.config import get_settings
from transcription.db import session_scope
from transcription.db.models import Document
from transcription.db.models import DocumentPerson
@@ -102,7 +103,12 @@ class TestPeoplePageRendering:
@pytest.mark.asyncio
async def test_person_detail_page_resolves_relative_portrait_path(self, app_client):
_, client = app_client
app, client = app_client
upload_dirs = {app.state.settings.upload_dir, get_settings().upload_dir}
for upload_dir in upload_dirs:
portrait_file = upload_dir / "portraits" / "person" / "seeded.png"
portrait_file.parent.mkdir(parents=True, exist_ok=True)
portrait_file.write_bytes(b"portrait")
async with session_scope() as session:
person = Person(
@@ -116,7 +122,7 @@ class TestPeoplePageRendering:
response = client.get(f"/ui/people/{person_id}")
assert response.status_code == 200
assert "/uploads/portraits/person/seeded.png" in response.text
assert "No source media available for inspection." not in response.text
@pytest.mark.asyncio
async def test_person_detail_page_renders_linked_documents(self, app_client):
+26
View File
@@ -225,6 +225,32 @@ class TestSourcesPageRendering:
assert "finish_reason" in response.text
assert "response-123" in response.text
@pytest.mark.asyncio
async def test_source_detail_page_shows_unavailable_placeholder_for_unmanaged_media_path(self, app_client):
_, client = app_client
async with session_scope() as session:
document = Document(name="Missing media document")
session.add(document)
await session.flush()
source = Source(
document_id=document.id,
page_number=1,
upload_name="missing-media.png",
filename="missing-media.png",
file_path=str((Path.cwd().parent / "external" / "missing-media.png").resolve()),
file_hash="f" * 64,
file_size_bytes=1,
)
session.add(source)
await session.commit()
source_id = str(source.id)
response = client.get(f"/ui/sources/{source_id}")
assert response.status_code == 200
assert "No source media available for inspection." in response.text
@pytest.mark.asyncio
async def test_source_detail_separates_v42_evidence_layers(self, app_client, seed_job):
app, client = app_client