Architecture layers
Tach layers (high → low):- tooling —
scripts/ - edge —
cli/,webhooks/ - orchestration —
src/ - adapters —
libs/*(utilities markedutility = true) - products —
data-gen/
- cli/ — User-facing commands. Parses flags, runs preflight checks, calls orchestration, renders output. No business logic.
- src/ — Orchestration. Multi-step workflows, side effects, Modal decorators. Depends on adapters.
- libs/ — Adapters wrapping external SDKs/APIs. Each
libs/<service>/is self-contained and idiomatic to one external service. - webhooks/ — Standalone Modal apps for event handling. Depend on libs and src.
- scripts/ — Operator/maintainer tooling. May depend on cli/src/libs.
- data-gen/ — Independent data products.
Boundary rules
No cross-lib imports
libs/<x> must not import from libs/<y>. If two adapters must coordinate, route through src/ orchestration instead.Exception: Shared utilities (libs.telemetry, libs.logging, libs.filesystem) are importable from anywhere.No orchestration in libs/
Adapters must be callable in isolation. No side effects, no Modal decorators, no orchestration logic.
libs/<x> exposes idiomatic Python functions and types.No business logic in cli/
The CLI layer parses arguments, validates input, and calls
src/ workflows. It does not contain application logic.Public interfaces are frozen for libs.* and src
Imports into
libs.* and src must match an [[interfaces]] expose entry in tach.toml. Other modules (cli, webhooks, scripts, data-gen) are constrained by depends_on, layers, and visibility only. New deep imports into a frozen-interface module fail until the interface (or call site) is updated.Closed orchestration layer
The
orchestration layer is closed. CLI commands, webhook handlers, and
operator scripts reach adapters through src.edge; they do not import adapter
packages or utility implementations directly. This keeps the edge contract
small and makes all adapter coordination visible in src/.Enforcing boundaries with tach
tach runs as a trunk custom linter (see theoss-linter-trunk-tach plugin), alongside every other linter/formatter in this repo — not as a bare binary.
-
Locally — Reproduce a finding before pushing:
-
In CI — The
trunk-check.ymlworkflow’sTrunk Checkstep runs the normal hold-the-line checks, followed by an explicit full-graphtrunk check --filter=tach --allstep. This ensures module boundaries are checked even when a change does not touch a Python file. Violations block merges.
Run only impacted tests
For a fast local feedback loop, Tach can pass only tests belonging to modules affected since the target branch. Keep the repository’s normal full test gates as the final authority:--disable-cache when validating selection behavior or after changing the
module graph. --collect-only is useful for inspecting the selected set
without executing tests:
Investigate dependencies and blast radius
Use reports when diagnosing a boundary or deciding where a change belongs:report shows module dependencies, usages, and external dependencies. map
is useful for a file-level dependency or dependent closure. The Mermaid and
DOT outputs are intentionally generated on demand; do not commit graph files
or hand-maintained module inventories.
The JSON form of tach check is useful when a local script or editor needs
machine-readable diagnostics. Trunk remains the CI integration point:
Commands intentionally not used here
tach install pre-commitis not enabled because linting is centralized in Trunk and this repository has no pre-commit configuration.tach sync --addis a manual repair aid only. It can add declarations but does not remove stale dependencies, so it must not run automatically.tach uploadis closed beta and is not part of the CI workflow.tach exportis not currently reliable with this repository’s configuration: Tach 0.35.0 fails while resolving the excludedtests/conftest.pywithPackage root not found. Do not make modularity-report artifacts a required check until this is resolved upstream or through a tested configuration change.
Common violations and fixes
”libs.<x> cannot depend on libs.<y>”
If two adapters genuinely share code (beyond the utilities exceptions above):
- Move the shared code to a utility — Declare it in
tach.tomlwithutility = trueif it’s cross-cutting (e.g., file operations). - Route through src/ — Have both adapters expose functions, then coordinate them in
src/orchestration. - Duplicate the type — For small, self-contained types, duplication can be simpler than introducing a dependency.
”src cannot depend on libs.<x>”
This is expected — src/ depends on declared libs adapters only. If a libs/ module imports from src/, invert the dependency:
- Remove the import from
libs/ - Pass the needed value (e.g., an app name) as a parameter or environment variable
- Move the Modal-aware logic into
src/
”not part of the public interface”
This applies when the target islibs.* or src. The import path is not in that module’s [[interfaces]].expose list. Either:
- Import an already-exposed symbol (prefer package-root / documented API), or
- Add the path to
exposeintach.tomlin the same change if it is intentionally public.
”cli cannot depend on specific libs”
The CLI, webhook, and script layers usesrc.edge for adapter types, utility
functions, and protocol objects. If you’re writing workflow code in cli/,
move it to src/ instead. New facade symbols require a package-root
__all__ entry and a matching src interface entry.
Configuration (tach.toml)
Thetach.toml file at the repo root is configured as strictly as tach allows for this repo:
exact— unuseddepends_onentries failroot_module = "forbid"— every file undersource_rootsmust belong to a configured modulelayers+layers_explicit_depends_on— directional layers; every cross-module edge still listed independs_onignore_type_checking_imports = false—TYPE_CHECKINGimports are enforcedinclude_string_imports = true— string/dynamic imports countvisibility— who may import each module[[interfaces]]— frozen public import surface forlibs.*andsrc[rules]— unused / unjustifiedtach-ignoredirectives are errorsexclude— tests, docs,api/fixtures, and rootdeploy.py(Modal entrypoint must stay at repo root)- external path exclusions —
scripts/**and the Dagger workflow tree are operational tooling excluded from runtime external-dependency ownership checks by the CI command, with rationale documented there.
depends_on for every enforced form (including TYPE_CHECKING and string imports; or run uv run tach sync --add). If the target is libs.* or src, also add the path to that module’s [[interfaces]].expose list. Add adapter symbols to the package root’s __all__ and import them through src.edge when the caller is outside libs/.
To regenerate the public surface safely, inspect each adapter’s __all__,
then update its matching tach interface in the same change. Validate with:
Resources
- Tach documentation — https://github.com/tach-org/tach
- AGENTS.md — Architecture rules for this repo (in the root)
- trunk-check.yml — CI workflow that enforces boundaries