This page restates the project's written engineering rules for Cluster ERP in plain words. Each rule names the code that implements it and the test or gate that enforces it. When nothing in the repository enforces a rule, the page says so. The reasons behind the largest rules are in the decision records; this page is the lookup table.
Scope and source¶
The rules come from the project's written engineering rules. The enforcement column was produced by reading the tests in tests/, the gates in .github/workflows/ci.yml, the configuration in pyproject.toml, and the cited source files at the verified commit.
- Defined in: the project's written engineering rules.
- Enforced in:
tests/test_architecture_boundaries.py,tests/test_metadata_driven_rules.py,tests/test_static_quality.py,tests/test_e2e_db_isolation.py, other files intests/, and.github/workflows/ci.yml. - Count: 34 rules in seven groups, counted as the rows of the tables below.
Architecture and communication¶
These rules keep modules independent of each other. The architecture boundaries page explains the tests in detail.
| Rule | What the code does | Enforced by |
|---|---|---|
Modules never import each other. The only allowed import is the permission door cluster_erp.modules.auth.guards. |
The boundary test parses every Python file under src/cluster_erp/modules/ and lists imports of another module. |
tests/test_architecture_boundaries.py:24, tests/test_architecture_boundaries.py:46-57 |
Communication between modules goes through the EventBus, with frozen dataclass contracts. |
EventBus.emit fans an event out to every subscriber; EventBus.query calls the one handler of a query (src/cluster_erp/core/event_bus.py:83-102). Contracts are @dataclass(frozen=True, kw_only=True) classes, for example PartyRoleSet (src/cluster_erp/core/contracts.py:47-55). |
The import ban above; a second query handler raises DuplicateQueryHandlerError (src/cluster_erp/core/event_bus.py:78-79) |
Every module subclasses ModuleBase and follows its lifecycle. |
ModuleBase declares initialize, register_routes, register_events, shutdown, and get_metadata (src/cluster_erp/core/module_base.py:61-74). The registry calls the first three in that order (src/cluster_erp/core/module_registry.py:49-52). |
tests/test_architecture_boundaries.py:60-72 requires a module.py and at least one route in every module |
| The application layer may orchestrate several module services. It is not a module. | server/routes/party_composite.py creates a party and its role facets in one transaction (src/cluster_erp/server/routes/party_composite.py:1-10). |
Not enforced by a test. The boundary test scans only src/cluster_erp/modules/ (tests/test_architecture_boundaries.py:21). |
Each sub-module keeps its business logic in a service that takes an AsyncSession. Routes create the service and validate with Pydantic. |
Example: UiStateService (src/cluster_erp/modules/auth/ui_state/services.py:12-44) and its routes (src/cluster_erp/modules/auth/ui_state/routes.py:21-39). |
Not determined from code. No test checks the layering. |
| The schema comes from Alembic migrations. A module does not create tables itself. | initialize imports the models only, for example src/cluster_erp/modules/records/module.py:36-37. Alembic imports every module's models.py (migrations/env.py:76-85). |
CI applies the full history and requires one head (.github/workflows/ci.yml:113-117) |
| A shipped migration is never edited. | pyproject.toml calls migrations "frozen historical records" and relaxes only the line-length rule for them (pyproject.toml:134-136). |
Not determined from code. No test compares migrations with a baseline. |
Data, keys, and money¶
These rules fix how values and keys are stored. The platform section documents each mechanism.
| Rule | What the code does | Enforced by |
|---|---|---|
Money is Decimal, Numeric in the database, a string in JSON, and rounded with ROUND_HALF_EVEN. |
Read schemas serialise Decimal fields to strings (src/cluster_erp/core/currencies.py:186-204). No file under src/cluster_erp declares Mapped[float] (searched with grep -rn "Mapped\[float\]"). ROUND_HALF_EVEN appears in 32 files and ROUND_HALF_UP in none (counted with grep -rln). |
tests/test_analytics_contributions.py:120-133 fails when a monetary analytics measure sits on a non-Numeric column |
| Currency, country, and address structure are reference data in the database, not constants. | Country, AddressLevel, and AddressLevelValue are tables (src/cluster_erp/modules/parties/settings/models.py:12, src/cluster_erp/modules/parties/settings/models.py:52, src/cluster_erp/modules/parties/settings/models.py:83). assert_known_currency rejects a code that is not live reference data (src/cluster_erp/core/currencies.py:30-40). |
The currency check in services; no test covers the general rule |
Primary keys are ULIDs stored as UUID. |
BaseModel.id defaults to new_ulid (src/cluster_erp/core/models.py:20-24, src/cluster_erp/core/ulid.py:8-14). |
Inheritance from BaseModel; see the discrepancy below |
| Every record carries audit columns and a version. | created_by, updated_by, deleted_at, deleted_by, deactivation_reason, deactivation_note, and version (src/cluster_erp/core/models.py:30-78). |
Inheritance from BaseModel |
Business keys come from core_sequences: a prefix and a counter, some reset each year. |
next_code returns PREFIX-YEAR-00001 for yearly entities and PREFIX-000001 for the others (src/cluster_erp/core/sequence.py:154-171, src/cluster_erp/core/sequence.py:88-109). |
Not determined from code beyond the function itself |
| The product, model, and version breakdown is mandatory. There is no fallback to the product alone. | ensure_product_breakdown raises HTTP 422 when a product has no model or version (src/cluster_erp/core/product_breakdown.py:112-118). Stock rows key on all three (src/cluster_erp/modules/inventory/stock/models.py:32-46). |
49 service calls in 23 files under src/cluster_erp/modules (counted with grep -rn "ensure_product_breakdown(") |
| Customers, suppliers, organisations, and employees are one party with role facets. | FacetMeta declares a role on the party card (src/cluster_erp/core/metadata.py:357-377). PartyRoleSet sets the role flag (src/cluster_erp/core/contracts.py:47-55, src/cluster_erp/modules/parties/module.py:130). |
tests/test_metadata_driven_rules.py:51-60 checks the guided creation roles |
Records and history¶
These rules keep every record and every change. Details are in soft delete and deactivation and change history.
| Rule | What the code does | Enforced by |
|---|---|---|
| Records are never hard-deleted. Deleting means deactivating with a reason. | deleted_at marks a record as deactivated, with deleted_by, deactivation_reason, and deactivation_note (src/cluster_erp/core/models.py:49-72). bulk_soft_delete deactivates through the ORM (src/cluster_erp/core/bulk_operations.py:20-55). |
tests/test_live_row_uniqueness.py:1-15 keeps uniqueness on live rows only, so a deactivated value is reusable |
| The only general physical deletion is the retention purge. | purge_expired removes deactivated rows and history older than the retention window (src/cluster_erp/modules/records/retention/purge.py:1-6, src/cluster_erp/modules/records/retention/purge.py:33-45). |
Not determined from code. No test scans for DELETE statements outside the purge. |
| Dependent child rows renewed together with their owner are deleted through the ORM, so the history records each removal. | orm_delete_where (src/cluster_erp/core/bulk_operations.py:58-75), used for calendar template lines and exception shifts (src/cluster_erp/modules/calendars/services.py:186-190, src/cluster_erp/modules/calendars/services.py:596-600). |
Not determined from code |
| Change history is automatic. Code never writes history rows by hand. | One before_flush listener records create, update, deactivate, activate, and delete with actor and field diff (src/cluster_erp/core/change_history.py:318-351). |
tests/test_change_history_redaction.py and tests/test_change_history_tables_live.py cover parts of it |
Web interface¶
The web app renders screens from metadata. Behaviour rules for lists, actions, and the home screen apply to every module.
| Rule | What the code does | Enforced by |
|---|---|---|
| The web app is driven by metadata. Generic code has no rule for one module. | The generic engines read /api/v1/_meta (src/cluster_erp/server/app.py:1519-1546). |
tests/test_architecture_boundaries.py:75-87 and tests/test_metadata_driven_rules.py:85-112 |
| A list shows "add new" as its first row, never as a navigation bar button. Selection is a long press. Actions open from a labelled chip. | The add row is built at the top of the list (webapp/js/gen-list.js:703-710). bindLongPress arms after 450 ms (webapp/js/ui.js:71). The action sheet header states the labelled chip (webapp/js/action-sheet.js:1-9). |
Browser suites such as web-client/_e2e_action_sheet.mjs; no pytest test |
| The duplicate-home button exists on the PC device class only and, where it exists, stays in front and is never hidden. | tilesAllowed() limits split screen to the pc class and syncDupHome hides the button elsewhere (webapp/js/tiles.js:60-64, webapp/js/tiles.js:130-137); the style sheet keeps it above the dock band (webapp/css/tiles.css:5-12). |
web-client/_e2e_dup_home_tiles.mjs (PC) and web-client/_e2e_mobile_app.mjs (phone, tablet); no pytest test |
| A module is one app; its pages are tabs of that app. | Every module has its own module.py and API (tests/test_architecture_boundaries.py:60-72). |
web-client/_e2e_procurement_app.mjs:1-3 and other app suites |
| A user's layout lives on the server. Device preferences stay in the browser. | auth_user_ui_state holds one row per user and key (src/cluster_erp/modules/auth/ui_state/models.py:13-35). Theme and time zone use localStorage (webapp/js/prefs.js:5-6). The desktop client keeps its layout in QSettings (src/cluster_erp/desktop/core/settings_store.py:10). |
tests/test_e2e_db_isolation.py:177-200 requires browser suites that touch the layout to use the ui-state guard |
| Device experiences differ: the class comes from hover and pointer, not from width alone. | PC_QUERY, PC_MIN_WIDTH, and PHONE_MAX_WIDTH (webapp/js/device-class.js:16-20). |
Browser suites web-client/_e2e_mobile_home.mjs and web-client/_e2e_touch_sweep.mjs |
Languages and text¶
Every visible text comes from a language file or a metadata label. The data-driven languages record explains the language list.
| Rule | What the code does | Enforced by |
|---|---|---|
No fixed user-visible strings. Every new text gets a key in both en.json and tr.json, with correct Turkish characters. |
Web keys live in webapp/i18n/; desktop keys in src/cluster_erp/desktop/i18n/. |
Partly: tests/test_party_form_contract.py:104-109 checks one error key in all four files; tests/test_metadata_driven_rules.py:77-82 and tests/test_metadata_driven_rules.py:172-173 require English and Turkish labels |
| A new language is a new JSON file plus one manifest line, with no code change. | webapp/js/i18n.js reads languages.json (webapp/js/i18n.js:1-7, webapp/js/i18n.js:32-44). The desktop client lists every *.json in its folder (src/cluster_erp/desktop/i18n/translator.py:92-99). |
web-client/_e2e_lang.mjs:1-6 derives its expectations from the manifest |
| A metadata label without the current language falls back to English. | resolveLabel (webapp/js/meta.js:284-288) and Translator.t (src/cluster_erp/desktop/i18n/translator.py:57-67). |
Not determined from code |
Desktop client¶
The desktop client shares the metadata with the web app. Its own rules concern API calls and signals.
| Rule | What the code does | Enforced by |
|---|---|---|
API calls use a synchronous httpx client in a QThread worker. |
ApiWorker and ApiMixin (src/cluster_erp/desktop/core/api_worker.py:28, src/cluster_erp/desktop/core/api_worker.py:179). |
Not determined from code |
| Mutations name their entity, and the client emits the change signal. Views do not emit it themselves. | post, put, and delete take an EntityDescriptor (src/cluster_erp/desktop/core/api_client.py:223-268). Views subscribe with watch_entity (src/cluster_erp/desktop/core/signal_bus.py:55). |
Not determined from code |
| The known-good PySide6 release is pinned where it matters. | The desktop extra asks for PySide6>=6.7.0 (pyproject.toml:72-74); CI installs PySide6==6.8.3 (.github/workflows/ci.yml:50). |
CI |
Code quality¶
These rules are gates in CI and in the test suite. The lint and type check page lists the configuration.
| Rule | What the code does | Enforced by |
|---|---|---|
| mypy runs in strict mode with the Pydantic plugin, on Python 3.12. | pyproject.toml:99-102 |
.github/workflows/ci.yml:52, tests/test_static_quality.py:38-46 |
Ruff selects E, F, W, I, N, UP, B, A, and SIM, with line length 100. TCH is left out on purpose. |
pyproject.toml:113-123 |
.github/workflows/ci.yml:51, tests/test_static_quality.py:26-35 |
| The product is proprietary. Third-party licences must be compatible. | pyproject.toml:10 declares the licence; dependency comments name licences, for example pyproject.toml:55-57. |
Not determined from code. No licence check runs in CI. |
| Contributors verify before they state or change something, and never present untested work as tested. | Process rule. | Not determined from code |
Constraints and validation¶
The tests above run in two places. pytest collects tests/ (pyproject.toml:151-153), and CI runs it with live tests switched off (.github/workflows/ci.yml:99, .github/workflows/ci.yml:124). The browser rules run only in the e2e battery, which CI does not run (.github/workflows/ci.yml:21-22). See Python tests and e2e battery.
Discrepancies¶
Related pages¶
Verified against commit c0d3217 on 2026-11-14.