No projection rebuild path; add one as the test oracle #79

Closed
opened 2026-07-28 06:00:26 +00:00 by claude-bot · 1 comment

Severity: HIGH (architectural keystone)

The problem

Photo carries denormalized projection fields whose docstring contract is that they "must be
derivable from canonical history." Nothing can actually derive them.

  • AuditEventType.projection_rebuilt exists at models/models.py:131 with zero references.
  • Status-transition rules live in three places: inline in services/ingest.py:129-149,
    in services/projections.py, and in PhotoRepository.apply_decision_projection.
  • The projection path is bypassed twice: ingest sets photo.status directly, and the supersede
    route flips is_active with no projection or audit context.

So any bug — like the supersede route's half-update — drifts silently and permanently, with no
way to detect or repair it.

Fix

Write rebuild_photo_projection(photo_id) that recomputes status, current_decision_id, and the
effective date fields purely from decision and evidence history. Define the fold explicitly:

  • latest non-skip decision wins
  • evidence affects only the pendingneeds_review transition
  • skip contributes nothing to the projection (see the skip semantics issue)

Emit projection_rebuilt. Expose it as a CLI or admin action, per-photo and collection-wide.
Route all status changes through projections.py so the rules exist in one place.

Why this is the highest-leverage artifact in the test milestone

It turns "projections cannot drift" from a docstring into a property test: after any sequence of
operations, stored projection must equal rebuild output. It is simultaneously the repair tool for
drift that can already happen today.

Done when

  • rebuild_photo_projection exists and is the single definition of the fold
  • Status transitions happen in exactly one module
  • Rebuild is runnable per-photo and for a whole collection, and emits projection_rebuilt
  • #6's tests property-check stored projection against rebuild output
  • Running rebuild over the existing database reports no drift (or repairs what it finds)

References

  • backend/app/services/projections.py
  • backend/app/repositories/photos.py:80-101
  • backend/app/services/ingest.py:129-149
  • backend/app/models/models.py:131

Related: #6 (projection tests) should use this as its oracle.

## Severity: HIGH (architectural keystone) ## The problem `Photo` carries denormalized projection fields whose docstring contract is that they "must be derivable from canonical history." Nothing can actually derive them. - `AuditEventType.projection_rebuilt` exists at `models/models.py:131` with **zero references**. - Status-transition rules live in **three** places: inline in `services/ingest.py:129-149`, in `services/projections.py`, and in `PhotoRepository.apply_decision_projection`. - The projection path is bypassed twice: ingest sets `photo.status` directly, and the supersede route flips `is_active` with no projection or audit context. So any bug — like the supersede route's half-update — drifts silently and permanently, with no way to detect or repair it. ## Fix Write `rebuild_photo_projection(photo_id)` that recomputes status, `current_decision_id`, and the effective date fields purely from decision and evidence history. Define the fold explicitly: - latest non-skip decision wins - evidence affects only the `pending` → `needs_review` transition - skip contributes nothing to the projection (see the skip semantics issue) Emit `projection_rebuilt`. Expose it as a CLI or admin action, per-photo and collection-wide. Route **all** status changes through `projections.py` so the rules exist in one place. ## Why this is the highest-leverage artifact in the test milestone It turns "projections cannot drift" from a docstring into a property test: after any sequence of operations, stored projection must equal rebuild output. It is simultaneously the repair tool for drift that can already happen today. ## Done when - [ ] `rebuild_photo_projection` exists and is the single definition of the fold - [ ] Status transitions happen in exactly one module - [ ] Rebuild is runnable per-photo and for a whole collection, and emits `projection_rebuilt` - [ ] #6's tests property-check stored projection against rebuild output - [ ] Running rebuild over the existing database reports no drift (or repairs what it finds) ## References - `backend/app/services/projections.py` - `backend/app/repositories/photos.py:80-101` - `backend/app/services/ingest.py:129-149` - `backend/app/models/models.py:131` Related: #6 (projection tests) should use this as its oracle.
claude-bot added this to the v0.2.0 milestone 2026-07-28 06:00:26 +00:00
Author

Done in 5b021a2. All five "done when" items covered, 34 tests in backend/tests/test_projection_rebuild.py.

The fold, stated once (projections.fold):

  • latest non-skip decision wins
  • evidence only moves pendingneeds_review, and only evidence a reviewer could act on
  • with no decision there is no effective date

That middle clause needed a definition the issue didn't specify. I used reliability above low, which is what makes it agree with #74: a scan-date upper bound is recorded at low and is genuinely useful to #19, but it is not a reason to put a photo in someone's queue. Filename (medium), manual and believed EXIF (high) all clear the bar.

The structural choice that matters: every write path recomputes through the fold rather than patching fields incrementally. An incremental update that happens to match a separately-written rebuild is exactly the arrangement that drifts — sharing the implementation means they can't disagree. Ingest no longer decides status at all; it used to write it twice, once per evidence kind, which is how the rules ended up in three modules.

Tie-breaking is on (created_at, id), not created_at alone. Two decisions can share a timestamp, and if "latest" were ambiguous the rebuild could disagree with itself between runs, which would make drift detection worthless.

Writing the property test found real drift immediately — the supersede bypass this issue names. Retiring a photo's only actionable evidence left it in needs_review with nothing left to review. That route now recomputes.

python -m app.cli.rebuild_projections [--dry-run] [--photo ID] [--collection ID] is both detector and repair. It emits projection_rebuilt with both sides, because if the stored value disagreed with history, the record of what it used to claim is the only way to work out which decisions were made while the archive was showing something false. It exits non-zero on drift, so it works as a scheduled integrity check — drift is a defect and should page someone rather than scroll past in a log.

Two tests guard the structure rather than the behaviour: one greps ingest.py and the route modules for photo.status = so the rules cannot quietly move back out of the service, and one asserts apply_decision_projection is gone rather than merely unused.

Not applicable: "Running rebuild over the existing database reports no drift" — there is no existing database, since the previous deployment was removed. The property test covers the equivalent guarantee over synthesised histories.

#6 can now use this as its oracle, as you noted it should.

Done in 5b021a2. All five "done when" items covered, 34 tests in `backend/tests/test_projection_rebuild.py`. **The fold, stated once** (`projections.fold`): - latest non-skip decision wins - evidence only moves `pending` → `needs_review`, and only evidence a reviewer could act on - with no decision there is no effective date That middle clause needed a definition the issue didn't specify. I used **reliability above `low`**, which is what makes it agree with #74: a scan-date upper bound is recorded at `low` and is genuinely useful to #19, but it is not a reason to put a photo in someone's queue. Filename (`medium`), manual and believed EXIF (`high`) all clear the bar. **The structural choice that matters:** every write path *recomputes through the fold* rather than patching fields incrementally. An incremental update that happens to match a separately-written rebuild is exactly the arrangement that drifts — sharing the implementation means they can't disagree. Ingest no longer decides status at all; it used to write it twice, once per evidence kind, which is how the rules ended up in three modules. Tie-breaking is on `(created_at, id)`, not `created_at` alone. Two decisions can share a timestamp, and if "latest" were ambiguous the rebuild could disagree with **itself** between runs, which would make drift detection worthless. **Writing the property test found real drift immediately** — the supersede bypass this issue names. Retiring a photo's only actionable evidence left it in `needs_review` with nothing left to review. That route now recomputes. `python -m app.cli.rebuild_projections [--dry-run] [--photo ID] [--collection ID]` is both detector and repair. It emits `projection_rebuilt` with **both sides**, because if the stored value disagreed with history, the record of what it used to claim is the only way to work out which decisions were made while the archive was showing something false. **It exits non-zero on drift**, so it works as a scheduled integrity check — drift is a defect and should page someone rather than scroll past in a log. Two tests guard the structure rather than the behaviour: one greps `ingest.py` and the route modules for `photo.status =` so the rules cannot quietly move back out of the service, and one asserts `apply_decision_projection` is *gone* rather than merely unused. **Not applicable:** "Running rebuild over the existing database reports no drift" — there is no existing database, since the previous deployment was removed. The property test covers the equivalent guarantee over synthesised histories. #6 can now use this as its oracle, as you noted it should.
Sign in to join this conversation.
No description provided.