Phase 3: extract EvidenceService and rewrite the service ownership rule

Decompose SourceService along the aggregate boundary and then correct the
instruction file that caused it to grow, in that order. The refactor is the
empirical test of the rule.

services/evidence.py (new)
  EvidenceService owns ExecutionAttempt: read_latest_execution_attempt,
  list_execution_attempts, promote_machine_attempt, build_evidence_export,
  plus the LatestExecutionAttempt projection. Moved verbatim from sources.py.

services/errors.py (new)
  The five-class error hierarchy (PromptLoadError, TranscriptionError,
  TranscriptionNotFoundError, SourceDeleteBlockedError,
  CandidatePromotionError) moved out of sources.py. evidence.py needs
  TranscriptionNotFoundError, and test_service_boundaries.py correctly
  rejected the sibling import. errors.py defines no *Service class, so it is
  a legal shared home. This was the boundary test doing its job, not an
  obstacle to route around.

sources.py 1,389 -> 885 lines (1,063 after Phase 2).

services/__init__.py
  ServiceBundle and from_session_factory register evidence. Note that
  field-by-field ServiceBundle construction silently binds services to the
  process-global session factory via default_factory; from_session_factory is
  the only safe constructor. Two test bundles were fixed for this.

.github/instructions/services.instructions.md
  Rewritten to describe the boundaries the decomposition actually produced,
  per plan Phase 3 task 7 and review log [59].

  - "1 service class per data model" -> one service class per aggregate.
    The table-shaped rule is the measured cause of sources.py reaching
    1,389 lines; DocumentType has no lifecycle without Document.
  - New Model Ownership section. Junctions are owned by their lifecycle
    owner, the service that creates and deletes the rows: document_person
    to PeopleService (sole writer, measured), job_source to SourceService.
    Two carve-outs are stated rather than left as silent violations:
    cascade deletion when a service deletes its own aggregate root, and
    status transitions that create and delete nothing (cancel_job,
    resubmit_failed_sources), which are Job lifecycle events on the work
    queue. EvidenceService.promote_machine_attempt's two-field write to
    Source is named and scoped.
  - Mandatory CRUD softened to intent. It was already false: five modules
    define no service class, EvidenceService has no create/delete because
    ExecutionAttempt is append-only, RegistryService uses <op>_entry.
  - Separated reading across models via eager loads from the owning root,
    which is allowed, from importing another service, which is not. The old
    line 13 and lines 75-77 read as contradictory.
  - Typo: picutre.

  No code was moved to satisfy the rule.

tests/test_service_boundaries.py
  Docstring no longer cites the instruction file by line number; that anchor
  would desynchronise silently. errors.py added to the neutral-module list.

Verified: 292 passed, 4 skipped, 0 ruff, 0 ty. All 25 /ui/* routes walked
against the live app; 24x 200. /ui/documents/{id}/sources 404s via a 307 that
drops the /ui prefix, confirmed pre-existing (last touched in 6a3ee26) and
left alone as out of scope.

Co-authored-by: Copilot App <[email protected]>
This commit is contained in:
zoltan57
2026-08-18 15:54:27 -05:00
co-authored by Copilot App
parent 11097b9cfe
commit 7dd0d2c9bf
15 changed files with 381 additions and 242 deletions
+81 -14
View File
@@ -7,29 +7,89 @@ applyTo: 'src/transcription/services/*.py'
## Structure
- Project core data models defined in [models](../../src/transcription/db/models.py)
- 1 service class per data model
- Only services directly interact with the database, and only through async methods
- Services are completely independent of one another. Any operation that needs to use more than a single service, which is most of them, needs to have a separate orchestration function.
- Project core data models are defined in [models](../../src/transcription/db/models.py)
- One service class per **aggregate**, not per table. An aggregate is a root model plus
the models that have no independent lifecycle of their own. `DocumentType` has no
meaning without `Document`, so it belongs to `DocumentService`; it does not get its
own service. Splitting per table produces services that must reach across each other
for every real operation, which is what line 13 forbids.
- Only services interact with the database, and only through async methods.
- **A service module must not import another service module.** This is enforced by
[test_service_boundaries](../../tests/test_service_boundaries.py). Shared types go in a
neutral module that defines no service class (see [errors](../../src/transcription/services/errors.py)).
- Not every module in this package is a service. Helper modules that define no `*Service`
class (`base`, `errors`, `normalization`, `prompts`, `quality`, `media_storage`,
`source_media`) are free-function modules and are exempt from the service rules below.
## Model Ownership
Every model has exactly one owning service. The owner defines that model's invariants and
is the only service that may **create or delete** its rows.
| Model | Owner |
| --- | --- |
| `Document`, `DocumentType` | `DocumentService` |
| `Source`, `JobSource` | `SourceService` |
| `Job` | `JobService` |
| `Person`, `PersonRole`, `DocumentPerson` | `PeopleService` |
| `ExecutionAttempt` | `EvidenceService` |
### Junction tables
A junction table is owned by the service that **creates and deletes its rows** — its
lifecycle owner. The service on the other side may read through the junction (via
`selectinload`) but must not create rows in it.
- `document_person` -> `PeopleService`. Every write is there; `DocumentService` only
eager-loads through it.
- `job_source` -> `SourceService`, which creates the row, records each page's outcome,
and deletes it.
Two consequences follow, and both are deliberate:
- **Cascade deletion is not a violation.** A service deleting the aggregate root it owns
may delete junction rows referencing that root, because they cannot outlive it
(`JobService.delete_job_with_guardrails`).
- **Ownership governs creation and deletion, not every state transition.** `job_source` is
both a link and the transcription work queue. `JobService.cancel_job` and
`resubmit_failed_sources` transition `job_source.status` across a whole job, because that
transition is a Job lifecycle event, not a per-page outcome. They create and delete
nothing.
`EvidenceService.promote_machine_attempt` writes two fields on `Source`
(`preferred_execution_attempt_id`, `raw_transcription`). This is allowed on the same
principle: selecting which attempt a Source presents is an evidence decision that happens
to land on `Source`. It is scoped to those two projection fields.
If a new operation cannot be expressed within one owner, it belongs in an orchestration
module, not in a cross-service import.
## Error Handling
- Service-specific errors defined at the top of the respective module and inherit from `AppError`
- Use a context manager for large `try/except` blocks like `handle_transcription_errors` in [sources](../../src/transcription/services/sources.py)
- Errors used by a single service are defined at the top of that module and inherit from `AppError`.
- Errors shared by more than one service go in [errors](../../src/transcription/services/errors.py),
which defines no service class and is therefore importable by any of them.
- Use a context manager for large `try/except` blocks, like `handle_transcription_errors` in
[sources](../../src/transcription/services/sources.py).
## Checklist
- [ ] Uses `ServiceBase` for common logic
- [ ] CRUD methods created at the top
- [ ] Session kwarg for `AsyncSession` to pass in a session object to each method
- [ ] Services use `self._session_scope` in their methods to pass the session thru.
- Multiple operations on the same object(s) require sharing a session between all the methods used.
- [ ] Session kwarg for `AsyncSession` to pass a session object into each method
- [ ] Services use `self._session_scope` in their methods to pass the session through
- Multiple operations on the same object(s) require sharing a session between all the methods used
- [ ] Every model the module touches is either owned by it or reached read-only
## CRUD Methods
- Create, read, update, and delete, created in that order
- Name format `<operation>_<model >`, for example `create_document` or `update_job`
- All services must define these 4 methods first, and in that order
- Name format `<operation>_<model>`, for example `create_document` or `update_job`.
- Where a service exposes create/read/update/delete for its root model, define them at the
top of the class in that order, before derived reads and workflow helpers.
- Not every aggregate needs all four. `ExecutionAttempt` is append-only evidence written by
`workflows.py`, so `EvidenceService` deliberately exposes reads and no create or delete.
Do not add unused CRUD methods to satisfy symmetry.
- `RegistryService` is generic across small lookup models and uses `<operation>_entry`
naming instead.
## Transaction Finalization
@@ -74,4 +134,11 @@ Separation of concerns:
# Service Composition
Some operations, like uploading a picutre, require modifications to multiple tables, which can be done by composing methods from the service object into a separate function.
A service method may read across models it does not own, using eager loads from its own
aggregate root. What it may not do is import another service.
Operations that must **write** models owned by more than one service — uploading a picture,
for example — are composed in an orchestration module
([store](../../src/transcription/services/store.py),
[workflows](../../src/transcription/services/workflows.py)). Orchestration modules define no
service class, may import any service, and own the commit boundary.