generated from john/python-template
This commit is contained in:
@@ -7,6 +7,11 @@ applyTo: 'src/transcription/**/*.py'
|
||||
|
||||
Keep docs in sync in the same change whenever implementation alters a documented contract, behavior, or roadmap decision.
|
||||
|
||||
Documentation targets below always refer to the **current** baseline. `docs/index.md` states which
|
||||
baseline that is; resolve any version-specific document from there. Never cite a superseded version
|
||||
tree by name in this file or in the docs you update — retired revision trees are not authority, and
|
||||
`tests/test_meta_contract_guards.py` fails active contract files that route authority through them.
|
||||
|
||||
## Update documentation when any of these change
|
||||
|
||||
1. **Schema/Data contract**
|
||||
@@ -27,7 +32,8 @@ Keep docs in sync in the same change whenever implementation alters a documented
|
||||
|
||||
5. **Roadmap/scope decisions**
|
||||
- Version targets, sequencing, deferrals, and accepted alternatives.
|
||||
- **Required doc update:** `docs/roadmap_plan.md` and related backlog docs (for example `docs/ver4.8/feature_backlog_v4_8.md`).
|
||||
- **Required doc update:** `docs/roadmap_plan.md`, plus any backlog or feature document for the
|
||||
current baseline. Locate it through `docs/index.md` rather than assuming a version-named path.
|
||||
|
||||
## Working rule
|
||||
|
||||
|
||||
@@ -75,6 +75,28 @@ Required internal -> canonical mapping:
|
||||
- Include actionable remediation guidance aligned to category.
|
||||
- Keep envelope structure consistent across API endpoints.
|
||||
|
||||
### `AppError.message` vs `AppError.detail`
|
||||
|
||||
`AppError` carries two texts with different audiences, and they must not be collapsed. Getting this
|
||||
wrong has already caused a real defect in this repository, in both directions.
|
||||
|
||||
| Attribute | Audience | Reaches | Rule |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `message` | User and API clients | `ErrorEnvelope.message`, UI notifications | Stays generic. Never embed exception text, provider payloads, or filesystem paths. |
|
||||
| `detail` | Internal only | Logs, and `format_error_detail` -> `ExecutionAttempt.error_detail` and `MaintenanceRun.error_detail` | Carries the root cause. Never rendered to users or serialized into an envelope. |
|
||||
|
||||
- Putting root-cause data in `message` leaks infrastructure detail to users.
|
||||
- Omitting it from `detail` silently degrades the provenance record this system exists to preserve —
|
||||
a failed attempt whose `error_detail` says nothing is an attempt that cannot be diagnosed later.
|
||||
- When you raise from a caught exception, populate **both**: a generic `message` and a `detail`
|
||||
carrying `type(exc).__name__` and the exception text, with `raise ... from exc`.
|
||||
- `detail` is optional (`None`). A read path that assumes it is populated must handle its absence.
|
||||
- Before changing either attribute, or any helper that formats them, enumerate every consumer —
|
||||
evidence writes, maintenance runs, logging, API envelopes, and UI presentation all read these
|
||||
fields, and tests assert on the persisted text.
|
||||
|
||||
Canonical definitions live in `src/transcription/errors.py`; see also `docs/error_handling.md`.
|
||||
|
||||
## Logging and Diagnostics
|
||||
|
||||
- Log operation identifiers and error IDs where available.
|
||||
|
||||
@@ -17,9 +17,17 @@ applyTo: 'src/transcription/services/*.py'
|
||||
- **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.
|
||||
- Not every module in this package is a service. Modules fall into three kinds:
|
||||
- **Aggregate services** own models and define a `*Service` class: `documents.py`, `sources.py`,
|
||||
`jobs.py`, `people.py`, `photos.py`, `maintenance.py`, and `evidence.py` (read/projection only,
|
||||
owns nothing).
|
||||
- **Orchestration modules** define no service class and compose writes across aggregates:
|
||||
`store.py`, `workflows.py`. They are the sanctioned place to create or delete rows owned by more
|
||||
than one service — see [Service Composition](#service-composition).
|
||||
- **Shared infrastructure and free-function helpers** are exempt from the service rules below:
|
||||
`base.py` (`ServiceBase`), `registry.py` (`RegistryService`, a generic base for lookup tables —
|
||||
not an aggregate owner itself), `unit_of_work.py`, `errors.py`, `normalization.py`, `prompts.py`,
|
||||
`quality.py`, `media_storage.py`, `source_media.py`. `__init__.py` exposes `ServiceBundle`.
|
||||
- Cross-cutting error behavior must follow
|
||||
[error-handling instructions](./error-handling.instructions.md).
|
||||
|
||||
@@ -30,11 +38,37 @@ is the only service that may **create or delete** its rows.
|
||||
|
||||
| Model | Owner |
|
||||
| --- | --- |
|
||||
| `Document`, `DocumentType` | `DocumentService` |
|
||||
| `Document`, `DocumentType`, `DocumentTag` | `DocumentService` |
|
||||
| `Source`, `JobSource` | `SourceService` |
|
||||
| `Job` | `JobService` |
|
||||
| `Person`, `PersonRole`, `DocumentPerson` | `PeopleService` |
|
||||
| `Person`, `PersonRole`, `DocumentPerson`, `PersonTag` | `PeopleService` |
|
||||
| `Photo` | `PhotosService` |
|
||||
| `MaintenanceRun` | `MaintenanceService` |
|
||||
| `ExecutionAttempt` | `SourceService` |
|
||||
| `Tag` | shared — see below |
|
||||
|
||||
Keep this table complete: every table in `src/transcription/db/models.py` appears exactly once,
|
||||
except `Tag`. When you add a model, add its owner here in the same change.
|
||||
|
||||
### `Tag` is deliberately shared
|
||||
|
||||
`Tag` is one table reached through two `RegistryService[Tag]` facades that differ only in the
|
||||
reference model they count usage through: `TagRegistry` (`documents.py`, via `DocumentTag`) and
|
||||
`PersonTagRegistry` (`people.py`, via `PersonTag`). Both create and delete `Tag` rows through the
|
||||
generic registry. This is the single sanctioned exception to one-owner-per-model — do not "fix" it by
|
||||
assigning `Tag` to one service, because the other facade would then be creating rows it does not own.
|
||||
Any change to `Tag` semantics, labels, or normalization must be validated against **both** facades
|
||||
and the junction table each one counts.
|
||||
|
||||
`DocumentTag` and `PersonTag` follow the junction rule below: each is created and deleted only by the
|
||||
service on its own side.
|
||||
|
||||
### Registries
|
||||
|
||||
`DocumentTypeRegistry`, `TagRegistry`, `PersonRoleRegistry`, and `PersonTagRegistry` are
|
||||
`RegistryService` subclasses, not independent services. A registry belongs to the aggregate service
|
||||
whose module declares it and shares that service's ownership. Registry CRUD uses `<operation>_entry`
|
||||
naming (see [CRUD Methods](#crud-methods)).
|
||||
|
||||
### Junction tables
|
||||
|
||||
@@ -50,8 +84,14 @@ lifecycle owner. The service on the other side may read through the junction (vi
|
||||
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`).
|
||||
may delete rows referencing that root which cannot outlive it
|
||||
(`JobService.delete_job_with_guardrails` deletes the job's `job_source` rows).
|
||||
- **Evidence deletion is an explicit workflow, not a runtime path.** `JobService.delete_job_and_evidence`
|
||||
deletes `ExecutionAttempt` rows owned by `SourceService`. That is sanctioned because it is the
|
||||
named retention workflow that `delete_job_with_guardrails` refuses to perform implicitly — that
|
||||
method *blocks* deletion when attempts exist. Append-only means runtime code never rewrites or
|
||||
removes history to represent a new outcome; it does not forbid a deliberate, operator-invoked
|
||||
retention operation. Do not add a second path that deletes attempts.
|
||||
- **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
|
||||
|
||||
Reference in New Issue
Block a user