Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ ci:
# scripts with no CI counterpart, and letting pre-commit.ci enforce
# them closes the gap where a PR from a contributor who skipped local
# hooks would otherwise introduce a regression unchecked.
skip: [commitizen, gitleaks, hadolint-docker, caddy-validate, no-em-dashes, no-redundant-timeout, mypy, pytest-unit, golangci-lint, go-vet, go-test, eslint-web, check-push-rebased, check-single-migration-per-pr, check-no-modify-migration, forbidden-literals, persistence-boundary, provider-complete-chokepoint, no-new-logger-exception-str-exc, orphan-fixtures, doc-drift-counts, boundary-typed, setting-to-startup-trace]
skip: [commitizen, gitleaks, hadolint-docker, caddy-validate, no-em-dashes, no-redundant-timeout, mypy, pytest-unit, golangci-lint, go-vet, go-test, eslint-web, check-push-rebased, check-single-migration-per-pr, check-no-modify-migration, forbidden-literals, persistence-boundary, provider-complete-chokepoint, no-new-logger-exception-str-exc, orphan-fixtures, doc-drift-counts, boundary-typed, setting-to-startup-trace, domain-error-hierarchy]

default_install_hook_types: [pre-commit, commit-msg, pre-push]

Expand Down Expand Up @@ -274,6 +274,18 @@ repos:
pass_filenames: false
stages: [pre-push]

- id: domain-error-hierarchy
name: domain-error-hierarchy gate (DomainError-rooted exception classes)
entry: uv run python scripts/check_domain_error_hierarchy.py
language: system
# Trigger on any change to src/synthorg Python files, the gate
# script itself, or its baseline so a PR that introduces a new
# plain-Exception class (or weakens the gate by editing the
# script / baseline) cannot bypass the check.
files: ^(src/synthorg/.*\.py|scripts/check_domain_error_hierarchy\.py|scripts/domain_error_hierarchy_baseline\.txt|\.pre-commit-config\.yaml)$
pass_filenames: false
stages: [pre-commit, pre-push]

- id: request-dto-forbid-extra
name: request-DTO extra="forbid" gate (src/synthorg/api)
entry: uv run python scripts/check_request_dto_forbid_extra.py
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ For every mutable setting: **DB > env (`SYNTHORG_<NS>_<KEY>`) > YAML > code defa
- **HTML parsing (SEC-1)**: never call `lxml.html.fromstring` on attacker input; use `HTMLParseGuard`. See [sec-prompt-safety.md](docs/reference/sec-prompt-safety.md).
- **Pluggable subsystems**: protocol + strategy + factory + config discriminator with safe defaults. Services (which wrap repositories) are a distinct pattern. See [pluggable-subsystems.md](docs/reference/pluggable-subsystems.md).
- **Sizes**: line length 88 (ruff); functions <50 lines; files <800 lines.
- **Errors**: handle explicitly, never swallow. Domain error families register a base-class entry in `EXCEPTION_HANDLERS` (`src/synthorg/api/exception_handlers.py`). Use `<Domain><Condition>Error` inheriting from `DomainError`; bare `Exception` / `RuntimeError` at domain boundaries is forbidden. See [errors.md](docs/reference/errors.md) + `src/synthorg/core/domain_errors.py`.
- **Errors**: handle explicitly, never swallow. Domain error families register a base-class entry in `EXCEPTION_HANDLERS` (`src/synthorg/api/exception_handlers.py`). Use `<Domain><Condition>Error` inheriting from `DomainError`; any of `Exception` / `RuntimeError` / `LookupError` / `PermissionError` / `ValueError` / `TypeError` / `KeyError` / `IndexError` / `AttributeError` / `OSError` / `IOError` as a direct base in `src/synthorg/` is forbidden. Enforced by `scripts/check_domain_error_hierarchy.py` (pre-push); per-line opt-out: `# lint-allow: domain-error-hierarchy -- <reason>`. See [errors.md](docs/reference/errors.md) + `src/synthorg/core/domain_errors.py`.
- **Repository CRUD**: `save(entity) -> None` (idempotent), `get(id) -> Entity | None`, `delete(id) -> bool`, `list_items(...) -> tuple[Entity, ...]`, `query(...) -> tuple[Entity, ...]`. Query methods always return tuples. See [conventions.md](docs/reference/conventions.md) §14.
- **Validate** at system boundaries (user input, external APIs, config files).
- **Datetime in persistence**: `parse_iso_utc` / `format_iso_utc` from `synthorg.persistence._shared` (both reject naive); `normalize_utc` for relaxed coercion on already-typed `datetime`.
Expand Down
10 changes: 10 additions & 0 deletions docs/reference/conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,16 @@ Each domain owns `errors.py` with a base error class carrying
change. The HTTP exception handler keys off the base class so a new
subclass automatically inherits the correct status mapping.

Enforced at pre-push by `scripts/check_domain_error_hierarchy.py`,
which AST-walks every `class .*` definition under `src/synthorg/` and
fails the build if a class inherits directly from `Exception` /
`RuntimeError` / `LookupError` / `PermissionError` / `ValueError` /
`TypeError` / `KeyError` / `IndexError` / `AttributeError` / `OSError`
/ `IOError` without reaching `DomainError` via another base. Per-line
opt-out: `# lint-allow: domain-error-hierarchy -- <reason>`. See
[errors.md](errors.md#domain-error-hierarchy-gate) for the full gate
contract.

References:

* `src/synthorg/budget/errors.py`: `BudgetExhaustedError` family.
Expand Down
26 changes: 26 additions & 0 deletions docs/reference/errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,32 @@ When introducing a new domain error family:
4. Add tests in `tests/unit/api/test_exception_handlers.py` covering
each branch and a regression test for the catch-all.

## Domain-error-hierarchy gate

`scripts/check_domain_error_hierarchy.py` enforces the rule at pre-push
and in CI: every class definition under `src/synthorg/` whose direct
base is one of `Exception` / `RuntimeError` / `LookupError` /
`PermissionError` / `ValueError` / `TypeError` / `KeyError` /
`IndexError` / `AttributeError` / `OSError` / `IOError` is a violation
unless the class itself reaches `DomainError` via another base.

Only the *root* of a stdlib-rooted chain is flagged; migrating the root
to `DomainError` automatically corrects every descendant.

Per-line opt-out:

```python
class TsaError(Exception): # lint-allow: domain-error-hierarchy -- RFC 3161 internals; observability stays stdlib-rooted
...
```

The justification after `--` is mandatory and must be non-empty. The
gate also accepts a frozen baseline file
(`scripts/domain_error_hierarchy_baseline.txt`) listing pre-existing
violations a rollout has not yet reached. The baseline shrinks
monotonically: any entry that no longer maps to a real violation is
reported as drift, so the file cannot harbour stale rows.

## Further reading

- [Design: security](../design/security.md): the SEC-1 rules behind the categories
Expand Down
Loading
Loading