Skip to main content
Strata does not check every file the same way. It first sorts your repository into scopes, then applies each scope’s rules using a fixed vocabulary of roles and a domain layout. This page describes that model, which is the shared ground every rule family stands on.

Scopes

A scope is a set of paths that receives a particular policy. Strata has three, each declared in configuration:
ScopeConfig keyPolicy
ProductrootsFull structural rules: layers, roles, shape, naming, hygiene, and annotations.
TeststestsTest-convention rules and annotation rules. Not the product structural rules.
ToolingtoolingThe same structural families as product roots, at one less nesting level.
The scanned set is the union of these three. A file that is not in any scope, such as a top-level setup.py or noxfile.py, is simply skipped. There is no “scanned but unclassified” state.
  • roots is required and its entries must not be nested inside one another, so every scanned file belongs to exactly one root. Independent package trees in a monorepo are expressed as multiple roots.
  • tests defaults to ["tests"].
  • tooling defaults to empty.
A file’s position is computed relative to its matching root. For a root of src/my_package, the file src/my_package/config/main/load.py has relative parts ("config", "main", "load.py"), from which Strata derives its domain, subdomain, and role.

Roles

A role is what a file or directory is for. Strata recognizes a fixed set of role names and enforces what each may contain and where it may live.
RoleFormHolds
main/PackagePublished entry surfaces and orchestrator functions.
helpers/PackagePhase functions supporting the orchestrators.
classes/PackageOne class per module.
models.pyFileData model declarations (frozen dataclasses).
types.pyFileType declarations and aliases.
constants.pyFileConstant declarations.
exceptions.pyFileException class declarations.
These role names are Strata’s opinion and are fixed. A role file may hold only what its role permits: a declaration that belongs in models.py may not live elsewhere, and models.py may not hold unrelated code. Cross-boundary imports go through a package’s published surface (its main/ entries and role files), never into its internals.
Role names are fixed today. User-defined role taxonomies are a planned future addition, not current behavior.

Domains and subdomains

Strata’s default layout for product code is domain, then subdomain, then role, not a flat top level.
  • The package root (for example src/my_package/) holds only its __init__.py public surface and domain packages. No role files live at the root.
  • A domain (src/my_package/config/) holds only __init__.py and subdomains. No role files or loose modules live directly at the domain level.
  • A subdomain holds the actual work: main/, helpers/, classes/, and the role files (models.py, types.py, constants.py, exceptions.py).
A cohesive single-pipeline domain uses one subdomain conventionally named core/. A domain with genuinely independent parts uses several named subdomains.
src/my_package/
├── __init__.py                 # public re-export surface only
├── config/                     # a domain
│   └── core/                   # its subdomain
│       ├── models.py
│       ├── types.py
│       ├── constants.py
│       ├── exceptions.py
│       ├── main/
│       │   └── load_config.py  # published orchestrator
│       └── helpers/
│           ├── discovery.py
│           ├── parse.py
│           └── validate.py
└── discovery/
    └── core/
        └── ...

Vocabulary lives with its owner

Strata has no neutral “shared” or “utils” bucket. Every type lives in the role file of the subdomain that produces it, and other subdomains import it from there. A type that many places use is not ownerless; it has one producer, and that producer publishes it. If you cannot name the owner, that is a signal to look harder, not to create a junk drawer.

Entry modules

A module directly under a main/ package is an entry module. Its shape rule (SFR401) constrains only the module’s top level:
  • exactly one public function (the entry point);
  • at most two private functions in that file;
  • top level contains only imports and function definitions, nothing else.
That is the whole rule. It is a limit on how many functions live in the entry file itself, not on what those functions may do. The public entry function is free to import and call as much as it needs: helper phases from helpers/, classes from classes/, other subdomains’ published surfaces, and anything else it has legitimate access to. The two-private-function cap is not a cap on collaborators; it just says an entry module should not grow its own pile of local glue. When you need more than a couple of local helpers, move them into helpers/ and call them from there.
# config/core/main/load_config.py  (an entry module)
from __future__ import annotations

from pathlib import Path

from strata.config.core.helpers.defaults import build_config
from strata.config.core.helpers.discovery import locate_config
from strata.config.core.helpers.parse import parse_config_source
from strata.config.core.helpers.validate import validate_config
from strata.config.core.models import Config, ConfigSource


def load_config(start: Path | None = None) -> Config:
    """The one public entry point; it may call as many helpers as it needs."""
    source: ConfigSource = locate_config(start)
    raw_config = parse_config_source(source)
    validate_config(raw_config)
    return build_config(raw_config)
Here load_config calls four helper functions and several imported types. That is entirely fine: it is one public function with zero private ones, and all the real work lives in helpers/. The rule would only fire if the file grew a second public function, a third private function, or a top-level statement that is not an import or a def.

Default thresholds

Several shape rules are governed by numeric thresholds. Strata ships these defaults, all overridable in configuration:
ThresholdDefaultApplies to
max_statements40Statements in a main/ orchestrator function.
max_distinct_calls20Distinct callees in a main/ function.
max_locals20Assigned locals in a main/ function.
max_statements_global70Statements in any function (loose global floor).
max_arguments10Arguments to any function.
max_positional_args0Positional (non keyword-only) parameters before * is required.
max_file_lines2000Lines in a source file.
max_flat_helper_modules10Flat modules in a helpers/ package before a subfolder is required.
max_flat_main_modules20Flat entry modules in a main/ package.
max_script_entrypoint_lines80Lines in a direct tooling script entrypoint.
These are measured values from a large, real, self-hosted codebase, presented as documented defaults rather than magic numbers. See Configuration for how to override them globally or per role.

Tooling layout

Tooling code (declared in tooling) follows the same roles as product code but at one less nesting level, because dev scripts rarely need full domain depth.
  • Direct scripts/*.py files are thin command adapters: one public main(), an optional _parse_args() or _build_parser(), imports, the if __name__ == "__main__" guard, and delegation to an imported main/ entry. scripts/__init__.py is exempt.
  • A tool implementation uses one ownership level then a role:
scripts/
├── fetch_data.py               # thin command adapter
└── data_snapshot/
    ├── main/
    ├── helpers/
    ├── classes/
    ├── models.py
    ├── types.py
    ├── constants.py
    └── exceptions.py
The role directories permitted directly under a tool are main/, helpers/, classes/, and rules/. A rules/ module is the home for custom rules; see Custom rules.

Configuration

Declare scopes, override thresholds, and set contracts.

Rule families

The rules that enforce this model.