> ## Documentation Index
> Fetch the complete documentation index at: https://docs.stratalint.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Architecture Model

> The scopes, roles, and default module layout that Strata enforces.

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:

| Scope   | Config key | Policy                                                                         |
| ------- | ---------- | ------------------------------------------------------------------------------ |
| Product | `roots`    | Full structural rules: layers, roles, shape, naming, hygiene, and annotations. |
| Tests   | `tests`    | Test-convention rules and annotation rules. Not the product structural rules.  |
| Tooling | `tooling`  | The 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.

| Role            | Form    | Holds                                                |
| --------------- | ------- | ---------------------------------------------------- |
| `main/`         | Package | Published entry surfaces and orchestrator functions. |
| `helpers/`      | Package | Phase functions supporting the orchestrators.        |
| `classes/`      | Package | One class per module.                                |
| `models.py`     | File    | Data model declarations (frozen dataclasses).        |
| `types.py`      | File    | Type declarations and aliases.                       |
| `constants.py`  | File    | Constant declarations.                               |
| `exceptions.py` | File    | Exception 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.

<Note>
  Role names are fixed today. User-defined role taxonomies are a planned future
  addition, not current behavior.
</Note>

## 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.

```text theme={null}
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.

```python theme={null}
# 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:

| Threshold                     | Default | Applies to                                                           |
| ----------------------------- | ------- | -------------------------------------------------------------------- |
| `max_statements`              | 40      | Statements in a `main/` orchestrator function.                       |
| `max_distinct_calls`          | 20      | Distinct callees in a `main/` function.                              |
| `max_locals`                  | 20      | Assigned locals in a `main/` function.                               |
| `max_statements_global`       | 70      | Statements in any function (loose global floor).                     |
| `max_arguments`               | 10      | Arguments to any function.                                           |
| `max_positional_args`         | 0       | Positional (non keyword-only) parameters before `*` is required.     |
| `max_file_lines`              | 2000    | Lines in a source file.                                              |
| `max_flat_helper_modules`     | 10      | Flat modules in a `helpers/` package before a subfolder is required. |
| `max_flat_main_modules`       | 20      | Flat entry modules in a `main/` package.                             |
| `max_script_entrypoint_lines` | 80      | Lines 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](/concepts/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:

```text theme={null}
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](/concepts/custom-rules).

## Related

<CardGroup cols={2}>
  <Card title="Configuration" icon="gear" href="/concepts/configuration">
    Declare scopes, override thresholds, and set contracts.
  </Card>

  <Card title="Rule families" icon="list-check" href="/concepts/rule-families">
    The rules that enforce this model.
  </Card>
</CardGroup>
