Skip to content
Draft
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
3 changes: 3 additions & 0 deletions .github/workflows/pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,9 @@ jobs:
run: uv lock --check
- name: Test the rules registry tool
run: uv run --frozen --extra dev python -m unittest discover -s scripts/rules/tests -t .
# The v2 import contracts in [tool.importlinter] in pyproject.toml.
- name: Check v2 import contracts with Import Linter
run: uv run --frozen --extra dev lint-imports --config pyproject.toml
- name: Install shellcheck (for the ci-scripts guard)
run: pip install "shellcheck-py==0.11.0.1"
- name: Run quality guards
Expand Down
3 changes: 1 addition & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,10 +87,9 @@ To add or change a rule, read `docs/engineering/skills/rules-registry.md`.

### API v2 layer boundaries

- Checked: `api.v2.connectors-mutations-no-reads`, `api.v2.connectors-reads-no-mutations`, `api.v2.crud-checks-cover-every-resource`, `api.v2.session-modules-no-queries`, `api.v2.user-services-no-sql`, `api.v2.validators-transformations-no-db`
- Checked: `api.v2.connectors-mutations-no-reads`, `api.v2.connectors-no-cross-calls`, `api.v2.connectors-reads-no-mutations`, `api.v2.crud-checks-cover-every-resource`, `api.v2.import-layers`, `api.v2.pure-modules-no-database`, `api.v2.routes-import-no-database-access`, `api.v2.session-modules-no-queries`, `api.v2.types-transformations-no-validators`, `api.v2.user-services-no-sql`, `api.v2.validators-transformations-no-db`
- `api.routes.shared-query-parameters`: Routes that accept query parameters define them through the shared typed query models, never with a second manual parser. (doc-only, not checked: follow it yourself)
- `api.v2.connectors-never-commit`: v2 connectors receive a session from the service and never open, commit, roll back or close it. (doc-only, not checked: follow it yourself)
- `api.v2.connectors-no-cross-calls`: v2 connector modules never call one another to sequence a workflow; the service does the sequencing. (doc-only, not checked: follow it yourself)
- `api.v2.routes-no-sessions-or-sql`: v2 route modules never open database sessions or construct SQL; they call one service method and map typed failures to HTTP. (doc-only, not checked: follow it yourself)
- `api.v2.services-no-sql`: v2 services sequence work but never construct SQL expressions, call Session.exec, or define HTTP response models. (doc-only, not checked: follow it yourself)

Expand Down
1 change: 1 addition & 0 deletions changelog.d/4181.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Check the v2 import architecture with Import Linter in the quality-guards CI job: v2 routes, services and data import each other in one direction only, v2 validators, transformations and types reach no database code except the table models, v2 routes import no connector or session module, types and transformations import no validators, and connectors import no other connector. Today's exceptions are listed as shrink-only baselines.
34 changes: 33 additions & 1 deletion docs/engineering/skills/rules-registry.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,8 @@ gap is visible, rather than leaving it out.
| `ruff` | `mode` = `format`, `lint` (with `codes`) or `per-file-ignore` (with `pattern`, `codes`) | Codes are selected / the ignore entry exists and covers only [files allowed file-level exceptions](#file-level-exceptions) |
| `mypy` | `paths` | Each path is in `[tool.mypy].files` |
| `workflow-job` | optional `step` | The CI job exists (the job itself is the check) |
| `pyright`, `import-linter` | `config` (+ `contract` for Import Linter) | The config file exists |
| `pyright` | `config` | The config file exists |
| `import-linter` | `config`, `contract`, `contract_sha256`, optional `allowed_ignore_imports` | The contract with that `id` exists; its definition hashes to `contract_sha256`; and its `ignore_imports` equals the rule's baseline entries plus `allowed_ignore_imports`, with no wildcards and with `unmatched_ignore_imports_alerting = "error"` (see "Baselines") |
| `docs-guard` | `doc`, `marker` | The doc exists |
| `llm-review` | `prompt`, `model` | The prompt exists; judgment tier only |
| `none` | | Nothing; only for `doc-only` and `advisory` rules |
Expand Down Expand Up @@ -124,6 +125,36 @@ fail the guard yet.
- Other enforcers (mypy, Pyright, Import Linter, custom guards) may start
`ratcheted` with a baseline file of tolerated violations. Baselines may only
shrink: the guard compares counts against the base branch.
- **Import Linter** tolerates a violation through the contract's
`ignore_imports` in `pyproject.toml`. The rule's baseline file lists the same
entries, one per line, so adding one needs a change under the code-owned
`rules/` directory; imports the rule allows by design, rather than tolerates,
go in `enforcer.allowed_ignore_imports`. The guard fails when the contract's
`ignore_imports` and those two lists differ, and
`unmatched_ignore_imports_alerting = "error"` makes Import Linter fail on an
entry that no longer matches, so fixing an import forces the entry out.
No entry may use a wildcard, so each names one importer and one imported
module, and a new module's own imports never match an old entry (it can
still reach a forbidden module through a tolerated import; see below). CI
runs exactly `lint-imports --config pyproject.toml`, which the guard checks,
so a `setup.cfg` or `.importlinter` file can't take the contracts' place and
`--contract` or `|| true` can't skip them.
- `enforcer.allowed_ignore_imports` has no count limit. Moving a tolerated
import from the baseline into it lowers the baseline's count without fixing
anything, so code-owner review of `rules/registry.toml` is the check on it.
- An Import Linter rule also pins the rest of its contract: the SHA-256 of the
top-level `[tool.importlinter]` settings and every contract field except
`ignore_imports` (`scripts/rules/import_linter.py`, `contract_fingerprint`)
must equal `enforcer.contract_sha256`. Narrowing a contract's modules, or a
setting such as `exclude_type_checking_imports`, therefore needs a registry
change too; the guard's message gives the new value.
- Ignoring an import removes it from the graph the contract checks, so the
exception also covers every chain from another module that passes through
it, including from modules added later. Each such chain goes away when the
tolerated import is fixed.
- Each rule's proof fixture breaks the contract once per source and forbidden
module pattern, and lists those violations in `expected_violations`; the
proof requires Import Linter to report exactly them.
- Moving a rule from enforced/ratcheted to advisory/doc-only needs an explicit
`[rule.downgrade] reason`.

Expand Down Expand Up @@ -186,6 +217,7 @@ describe:
- a ruff format check in CI, an explicitly selected ruff code, or a ruff
per-file ignore;
- a `[tool.mypy].files` entry;
- an Import Linter contract in `pyproject.toml`;
- a test matching `[discovery].guard_test_globs` or listed in
`extra_guard_tests`;
- a job in a `[discovery].workflows` file.
Expand Down
36 changes: 34 additions & 2 deletions docs/engineering/skills/v2-code-organization.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,11 @@ database connectors. Names should describe the represented data, such as
`PolicyCreationInput`, not an architectural pattern such as “command.”

Types may enforce their own field-level invariants through Pydantic validation,
but multi-record or catalog validation belongs in `validators.py`.
but multi-record or catalog validation belongs in `validators.py`. Types and
transformations never import a validators module, directly or through other
modules; validators import types (rule
`api.v2.types-transformations-no-validators`, with today's exceptions in its
baseline).

### `database_session.py`

Expand Down Expand Up @@ -146,7 +150,8 @@ Connector functions receive a session from the service. They do not open,
commit, roll back, or close it. They do not call route functions, perform
request-level or business validation, or convert rows into public response
types. Connector modules do not call one another to sequence a workflow; the
service makes that ordering explicit.
service makes that ordering explicit. They never import one another (rule
`api.v2.connectors-no-cross-calls`, with today's exceptions in its baseline).

The module name also decides which table privileges the connector needs
(rule `api.v2.runtime-privileges-declared`): `reads` modules need SELECT;
Expand All @@ -173,6 +178,33 @@ route -> service -> database session + database connectors -> SQLModel tables
-> types
```

Import Linter checks these directions in CI with the contracts in
`[tool.importlinter]` in `pyproject.toml`. Imports made only under
`TYPE_CHECKING` count:

- v2 routes may import v2 services and v2 data modules, and v2 services may
import v2 data modules, never the reverse, directly or through other modules
(rule `api.v2.import-layers`);
- v2 route modules never import a connector, a `database_session` module or
`policyengine_api.data.v2.database` themselves (rule
`api.v2.routes-import-no-database-access`). They reach them through
`fastapi_routes/dependencies.py` and the services they call, so this
contract checks direct imports only;
- validators, transformations and types never reach SQLAlchemy, SQLModel, a
connector, a `database_session` module or the v2 database module, directly
or through other modules, except through the table models (rule
`api.v2.pure-modules-no-database`).

The table models' own imports of SQLAlchemy and SQLModel are allowed, one
model module at a time, so a new module in `policyengine_api/data/v2/models/`
needs its two imports added to the rule. Import Linter reads import
statements. It does not see a name that a module re-exports from another, and
a tolerated exception covers every import between its two modules and every
chain through them: today `identity.types -> sqlmodel` also covers
`identity.transformations`, `user_accounts.types`,
`user_accounts.transformations` and `user_accounts.validators`, which reach
SQLModel only through it.

Catalog version validation, its error types and `SelectedCatalog` live in
`policyengine_api/data/v2/catalog/catalog_selection_validation.py`, which
loads no database code, so validators, transformations, types and routes may
Expand Down
30 changes: 23 additions & 7 deletions docs/generated/rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@ Every rule that governs `policyengine-api` is listed here with how it is enforce

| Tier | enforced | ratcheted | advisory | doc-only | Total |
| --- | ---: | ---: | ---: | ---: | ---: |
| mechanical | 61 | 0 | 1 | 12 | 74 |
| mechanical | 63 | 3 | 1 | 11 | 78 |
| metric | 1 | 0 | 0 | 0 | 1 |
| judgment | 0 | 0 | 0 | 1 | 1 |

**Blocking:** 62 of 76 rules.
**Blocking:** 67 of 80 rules.

## Gaps

Expand All @@ -28,7 +28,6 @@ Every rule that governs `policyengine-api` is listed here with how it is enforce
- `api.process.rule-machinery-code-owned`: Changes to the rule machinery (rules/, scripts/rules/, scripts/guards/, scripts/run_quality_guards.py and CODEOWNERS) need approval from a core developer other than the author.
- `api.routes.shared-query-parameters`: Routes that accept query parameters define them through the shared typed query models, never with a second manual parser.
- `api.v2.connectors-never-commit`: v2 connectors receive a session from the service and never open, commit, roll back or close it.
- `api.v2.connectors-no-cross-calls`: v2 connector modules never call one another to sequence a workflow; the service does the sequencing.
- `api.v2.routes-no-sessions-or-sql`: v2 route modules never open database sessions or construct SQL; they call one service method and map typed failures to HTTP.
- `api.v2.services-no-sql`: v2 services sequence work but never construct SQL expressions, call Session.exec, or define HTTP response models.

Expand Down Expand Up @@ -65,7 +64,9 @@ Every rule that governs `policyengine-api` is listed here with how it is enforce

### Ratcheted (existing violations still tolerated)

- None.
- `api.v2.connectors-no-cross-calls`: 7 remaining in `rules/baselines/import-linter-api-v2-connectors-independent.txt`
- `api.v2.pure-modules-no-database`: 1 remaining in `rules/baselines/import-linter-api-v2-pure-modules-no-database.txt`
- `api.v2.types-transformations-no-validators`: 4 remaining in `rules/baselines/import-linter-api-v2-types-transformations-no-validators.txt`

### Exceptions and review dates

Expand All @@ -84,12 +85,16 @@ Every rule that governs `policyengine-api` is listed here with how it is enforce
| `api.routes.shared-query-parameters` | Routes that accept query parameters define them through the shared typed query models, never with a second manual parser. | mechanical | doc-only | no | nothing | — | — |
| `api.v2.connectors-mutations-no-reads` | v2 mutation connectors (creates, updates, deletes) never issue select or get reads. | mechanical | enforced | yes | pytest `tests/unit/v2/test_data_crud_boundaries.py` | `pr.yml:test` | **unproven** |
| `api.v2.connectors-never-commit` | v2 connectors receive a session from the service and never open, commit, roll back or close it. | mechanical | doc-only | no | nothing | — | — |
| `api.v2.connectors-no-cross-calls` | v2 connector modules never call one another to sequence a workflow; the service does the sequencing. | mechanical | doc-only | no | nothing | — | — |
| `api.v2.connectors-no-cross-calls` | v2 connector modules never import one another, so they cannot call one another to sequence a workflow; the service does the sequencing. | mechanical | ratcheted | yes | import-linter `pyproject.toml` | `pr.yml:quality-guards` | yes |
| `api.v2.connectors-reads-no-mutations` | v2 read connectors never insert, add or delete rows. | mechanical | enforced | yes | pytest `tests/unit/v2/test_data_crud_boundaries.py` | `pr.yml:test` | **unproven** |
| `api.v2.crud-checks-cover-every-resource` | The v2 CRUD boundary checks find modules by filename, so every resource package is covered, and every connector module is named as a create, read, update or delete module. | mechanical | enforced | yes | pytest `tests/unit/v2/test_data_crud_boundaries.py` | `pr.yml:test` | **unproven** |
| `api.v2.import-layers` | v2 route modules may import v2 services and v2 data modules, and v2 services may import v2 data modules, never the reverse, directly or through other modules. | mechanical | enforced | yes | import-linter `pyproject.toml` | `pr.yml:quality-guards` | yes |
| `api.v2.pure-modules-no-database` | v2 validators, transformations and types never reach SQLAlchemy, SQLModel, a connector, a database_session module or the v2 database module, directly or through other modules, except through the v2 table models; imports under TYPE_CHECKING count. | mechanical | ratcheted | yes | import-linter `pyproject.toml` | `pr.yml:quality-guards` | yes |
| `api.v2.routes-import-no-database-access` | v2 route modules never import a connector, a database_session module or the v2 database module; they call a service, which does. | mechanical | enforced | yes | import-linter `pyproject.toml` | `pr.yml:quality-guards` | yes |
| `api.v2.routes-no-sessions-or-sql` | v2 route modules never open database sessions or construct SQL; they call one service method and map typed failures to HTTP. | mechanical | doc-only | no | nothing | — | — |
| `api.v2.services-no-sql` | v2 services sequence work but never construct SQL expressions, call Session.exec, or define HTTP response models. | mechanical | doc-only | no | nothing | — | — |
| `api.v2.session-modules-no-queries` | v2 database_session modules own session lifetime only and never build select, insert, update, delete or exec calls. | mechanical | enforced | yes | pytest `tests/unit/v2/test_data_crud_boundaries.py` | `pr.yml:test` | **unproven** |
| `api.v2.types-transformations-no-validators` | v2 types and transformations modules never import a validators module, directly or through other modules; validators import types, not the reverse. | mechanical | ratcheted | yes | import-linter `pyproject.toml` | `pr.yml:quality-guards` | yes |
| `api.v2.user-services-no-sql` | v2 user services (identity, accounts, legacy references and user associations) never build or run SQL themselves or import SQLAlchemy; their connectors do. | mechanical | enforced | yes | pytest `tests/unit/v2/test_data_crud_boundaries.py` | `pr.yml:test` | yes |
| `api.v2.validators-transformations-no-db` | v2 validators and transformations never import SQLAlchemy or SQLModel query or session APIs. | mechanical | enforced | yes | pytest `tests/unit/v2/test_data_crud_boundaries.py` | `pr.yml:test` | yes |

Expand All @@ -101,24 +106,35 @@ flowchart LR
e_pytest_tests_unit_v2_test_data_crud_boundaries_py["pytest tests/unit/v2/test_data_crud_boundaries.py"]
j_CI_pr_yml_test["CI pr.yml:test"]
r_api_v2_connectors_never_commit("api.v2.connectors-never-commit (doc-only)")
r_api_v2_connectors_no_cross_calls("api.v2.connectors-no-cross-calls (doc-only)")
r_api_v2_connectors_no_cross_calls("api.v2.connectors-no-cross-calls (ratcheted)")
e_import_linter_pyproject_toml["import-linter pyproject.toml"]
j_CI_pr_yml_quality_guards["CI pr.yml:quality-guards"]
r_api_v2_connectors_reads_no_mutations("api.v2.connectors-reads-no-mutations (enforced)")
r_api_v2_crud_checks_cover_every_resource("api.v2.crud-checks-cover-every-resource (enforced)")
r_api_v2_import_layers("api.v2.import-layers (enforced)")
r_api_v2_pure_modules_no_database("api.v2.pure-modules-no-database (ratcheted)")
r_api_v2_routes_import_no_database_access("api.v2.routes-import-no-database-access (enforced)")
r_api_v2_routes_no_sessions_or_sql("api.v2.routes-no-sessions-or-sql (doc-only)")
r_api_v2_services_no_sql("api.v2.services-no-sql (doc-only)")
r_api_v2_session_modules_no_queries("api.v2.session-modules-no-queries (enforced)")
r_api_v2_types_transformations_no_validators("api.v2.types-transformations-no-validators (ratcheted)")
r_api_v2_user_services_no_sql("api.v2.user-services-no-sql (enforced)")
r_api_v2_validators_transformations_no_db("api.v2.validators-transformations-no-db (enforced)")
r_api_routes_shared_query_parameters -.-> gap_unenforced
r_api_v2_connectors_mutations_no_reads --> e_pytest_tests_unit_v2_test_data_crud_boundaries_py
e_pytest_tests_unit_v2_test_data_crud_boundaries_py --> j_CI_pr_yml_test
r_api_v2_connectors_never_commit -.-> gap_unenforced
r_api_v2_connectors_no_cross_calls -.-> gap_unenforced
r_api_v2_connectors_no_cross_calls --> e_import_linter_pyproject_toml
e_import_linter_pyproject_toml --> j_CI_pr_yml_quality_guards
r_api_v2_connectors_reads_no_mutations --> e_pytest_tests_unit_v2_test_data_crud_boundaries_py
r_api_v2_crud_checks_cover_every_resource --> e_pytest_tests_unit_v2_test_data_crud_boundaries_py
r_api_v2_import_layers --> e_import_linter_pyproject_toml
r_api_v2_pure_modules_no_database --> e_import_linter_pyproject_toml
r_api_v2_routes_import_no_database_access --> e_import_linter_pyproject_toml
r_api_v2_routes_no_sessions_or_sql -.-> gap_unenforced
r_api_v2_services_no_sql -.-> gap_unenforced
r_api_v2_session_modules_no_queries --> e_pytest_tests_unit_v2_test_data_crud_boundaries_py
r_api_v2_types_transformations_no_validators --> e_import_linter_pyproject_toml
r_api_v2_user_services_no_sql --> e_pytest_tests_unit_v2_test_data_crud_boundaries_py
r_api_v2_validators_transformations_no_db --> e_pytest_tests_unit_v2_test_data_crud_boundaries_py
```
Expand Down
Loading
Loading