Add repository and projection-service tests #6

Closed
opened 2026-07-28 04:52:49 +00:00 by claude-bot · 2 comments

Context

The projection service is the most safety-critical code in the backend: Photo
projection fields must be updated in the same transaction as the history write.
The plan calls this out as the rule that prevents silent data loss, and it is
currently unverified.

Scope

Tests for the repository layer and app/services/projections.py.

Implementation notes

  • Assert the core invariant directly: after a decision write, the Photo projection
    and the DateDecision history row are consistent — and if the transaction is rolled
    back mid-write, neither is persisted. Simulate a failure between the two writes
    and confirm atomicity rather than assuming it.
  • review_version increments exactly once per mutating write.
  • Superseding evidence sets is_active = False and superseded_by, and never deletes a row.
  • The active-evidence query respects is_active and returns UI-ready ordering.
  • Decision history reads back reverse-chronologically.
  • Every mutation emits the expected AuditEvent type — audit logging is append-only,
    so a missing event is unrecoverable after the fact.
  • Repository methods do not leak partially built objects on constraint violations.

Done when

  • Projection and history writes are proven atomic, including the rollback case
  • review_version increment behaviour is covered for every write path
  • Supersede preserves the original row
  • Audit events are asserted for each mutation type

References

  • backend/app/services/projections.py
  • backend/app/repositories/
  • docs/circa-phase1-plan.md section 5.2 ("projection updates must happen in the same transaction as history writes")

Depends on: the backend test harness.

## Context The projection service is the most safety-critical code in the backend: `Photo` projection fields must be updated in the same transaction as the history write. The plan calls this out as the rule that prevents silent data loss, and it is currently unverified. ## Scope Tests for the repository layer and `app/services/projections.py`. ## Implementation notes - Assert the core invariant directly: after a decision write, the `Photo` projection and the `DateDecision` history row are consistent — and if the transaction is rolled back mid-write, **neither** is persisted. Simulate a failure between the two writes and confirm atomicity rather than assuming it. - `review_version` increments exactly once per mutating write. - Superseding evidence sets `is_active = False` and `superseded_by`, and never deletes a row. - The active-evidence query respects `is_active` and returns UI-ready ordering. - Decision history reads back reverse-chronologically. - Every mutation emits the expected `AuditEvent` type — audit logging is append-only, so a missing event is unrecoverable after the fact. - Repository methods do not leak partially built objects on constraint violations. ## Done when - [ ] Projection and history writes are proven atomic, including the rollback case - [ ] `review_version` increment behaviour is covered for every write path - [ ] Supersede preserves the original row - [ ] Audit events are asserted for each mutation type ## References - `backend/app/services/projections.py` - `backend/app/repositories/` - `docs/circa-phase1-plan.md` section 5.2 ("projection updates must happen in the same transaction as history writes") Depends on: the backend test harness.
claude-bot added this to the v0.2.0 milestone 2026-07-28 04:52:49 +00:00
Author

Amended by the audit of 2026-07-28.

These tests need an oracle. #79 adds rebuild_photo_projection(photo_id), which recomputes the
projection purely from decision and evidence history — turning "projections cannot drift" from a
docstring into a property test: after any sequence of operations, stored projection must equal
rebuild output.

That matters because drift is already possible: supersede_evidence sets is_active=False but
never superseded_by (#80), ingest sets photo.status inline rather than through the projection
service, and status-transition rules currently live in three separate places.

Also define the fold explicitly for skip#81 changes it to write history and touch nothing else.

**Amended by the audit of 2026-07-28.** These tests need an oracle. #79 adds `rebuild_photo_projection(photo_id)`, which recomputes the projection purely from decision and evidence history — turning "projections cannot drift" from a docstring into a **property test**: after any sequence of operations, stored projection must equal rebuild output. That matters because drift is already possible: `supersede_evidence` sets `is_active=False` but never `superseded_by` (#80), ingest sets `photo.status` inline rather than through the projection service, and status-transition rules currently live in three separate places. Also define the fold explicitly for `skip` — #81 changes it to write history and touch nothing else.
Author

Done in ed0dbed. CI green. 34 tests; full suite 723 passed.

Done when

  • Projection and history writes are proven atomic, including the rollback case
  • review_version increment behaviour is covered for every write path
  • Supersede preserves the original row
  • Audit events are asserted for each mutation type

The rollback case is the point. The failure plan §5.2 prevents is the worst kind an archive can have — a decision recorded in history while the photo still shows the old date, or the reverse — and neither half looks wrong on its own, so nothing surfaces it. The failure is now injected after both writes are staged and before the commit, which is the window a half-written state would escape through. Neither the history row, the projection, nor the audit event survives it. The event matters particularly: the ledger's triggers refuse deletion (#67), so one written by an abandoned request could never be removed afterwards — it has to not be written in the first place.

On the amendment's ask for an oracle. Implemented, and then made to earn it. The property test passed on the first run, which told me nothing, so I injected #79's drift into recompute() to see whether it would notice.

It did not. Checking only the final state, the mutation left the property test green — because a sequence ending in a decision hides evidence drift entirely: a decision outranks evidence in fold(), so the stored value and the oracle agree for the wrong reason. Checked after every operation instead, the same mutation fails five of twelve seeds. That distinction is now recorded in the test's docstring, since the weaker version looks equally correct on the page.

Worth noting the mutation was caught by one hand-written test either way (test_superseding_the_only_actionable_evidence_demotes_the_photo). The hand-written cases and the property test are not redundant: the first catches what someone thought of, the second catches what nobody did.

review_version per path, with the two asymmetries stated as tests rather than left in comments: a skip does not advance it (#81 — no reviewable state changed), and an evidence append does even when the status is unchanged (#77 — otherwise a reviewer mid-decision never learns the evidentiary basis moved).

Repository orderings are asserted with explicit timestamps, because rows made in one test share a clock to the microsecond. Evidence reads oldest-first because the panel shows how the archive's understanding accumulated; decisions read newest-first because the history tab leads with what holds now. Two opposite orderings, both deliberate.

Done in ed0dbed. CI green. 34 tests; full suite 723 passed. **Done when** - [x] Projection and history writes are proven atomic, including the rollback case - [x] `review_version` increment behaviour is covered for every write path - [x] Supersede preserves the original row - [x] Audit events are asserted for each mutation type **The rollback case is the point.** The failure plan §5.2 prevents is the worst kind an archive can have — a decision recorded in history while the photo still shows the old date, or the reverse — and neither half looks wrong on its own, so nothing surfaces it. The failure is now injected *after* both writes are staged and *before* the commit, which is the window a half-written state would escape through. Neither the history row, the projection, nor the audit event survives it. The event matters particularly: the ledger's triggers refuse deletion (#67), so one written by an abandoned request could never be removed afterwards — it has to not be written in the first place. **On the amendment's ask for an oracle.** Implemented, and then made to earn it. The property test passed on the first run, which told me nothing, so I injected #79's drift into `recompute()` to see whether it would notice. It did not. Checking only the *final* state, the mutation left the property test green — because a sequence ending in a decision hides evidence drift entirely: a decision outranks evidence in `fold()`, so the stored value and the oracle agree for the wrong reason. Checked after **every** operation instead, the same mutation fails five of twelve seeds. That distinction is now recorded in the test's docstring, since the weaker version looks equally correct on the page. Worth noting the mutation was caught by one hand-written test either way (`test_superseding_the_only_actionable_evidence_demotes_the_photo`). The hand-written cases and the property test are not redundant: the first catches what someone thought of, the second catches what nobody did. **`review_version` per path**, with the two asymmetries stated as tests rather than left in comments: a skip does not advance it (#81 — no reviewable state changed), and an evidence append *does* even when the status is unchanged (#77 — otherwise a reviewer mid-decision never learns the evidentiary basis moved). **Repository orderings** are asserted with explicit timestamps, because rows made in one test share a clock to the microsecond. Evidence reads oldest-first because the panel shows how the archive's understanding accumulated; decisions read newest-first because the history tab leads with what holds now. Two opposite orderings, both deliberate.
Sign in to join this conversation.
No description provided.