Skip to main content
The gtm-sdk enforces module boundaries using tach, a Rust-based static checker that prevents layering violations and maintains a clean architecture.

Architecture layers

Tach layers (high → low):
  • toolingscripts/
  • edgecli/, webhooks/
  • orchestrationsrc/
  • adapterslibs/* (utilities marked utility = true)
  • productsdata-gen/
Code placement rules:
  • 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 the oss-linter-trunk-tach plugin), alongside every other linter/formatter in this repo — not as a bare binary.
  1. Locally — Reproduce a finding before pushing:
  2. In CI — The trunk-check.yml workflow’s Trunk Check step runs the normal hold-the-line checks, followed by an explicit full-graph trunk check --filter=tach --all step. 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:
Use --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-commit is not enabled because linting is centralized in Trunk and this repository has no pre-commit configuration.
  • tach sync --add is a manual repair aid only. It can add declarations but does not remove stale dependencies, so it must not run automatically.
  • tach upload is closed beta and is not part of the CI workflow.
  • tach export is not currently reliable with this repository’s configuration: Tach 0.35.0 fails while resolving the excluded tests/conftest.py with Package 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):
  1. Move the shared code to a utility — Declare it in tach.toml with utility = true if it’s cross-cutting (e.g., file operations).
  2. Route through src/ — Have both adapters expose functions, then coordinate them in src/ orchestration.
  3. 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 is libs.* or src. The import path is not in that module’s [[interfaces]].expose list. Either:
  1. Import an already-exposed symbol (prefer package-root / documented API), or
  2. Add the path to expose in tach.toml in the same change if it is intentionally public.

”cli cannot depend on specific libs”

The CLI, webhook, and script layers use src.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)

The tach.toml file at the repo root is configured as strictly as tach allows for this repo:
  • exact — unused depends_on entries fail
  • root_module = "forbid" — every file under source_roots must belong to a configured module
  • layers + layers_explicit_depends_on — directional layers; every cross-module edge still listed in depends_on
  • ignore_type_checking_imports = falseTYPE_CHECKING imports are enforced
  • include_string_imports = true — string/dynamic imports count
  • visibility — who may import each module
  • [[interfaces]] — frozen public import surface for libs.* and src
  • [rules] — unused / unjustified tach-ignore directives are errors
  • exclude — tests, docs, api/ fixtures, and root deploy.py (Modal entrypoint must stay at repo root)
  • external path exclusionsscripts/** and the Dagger workflow tree are operational tooling excluded from runtime external-dependency ownership checks by the CI command, with rationale documented there.
When you add a real import across modules, update 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 documentationhttps://github.com/tach-org/tach
  • AGENTS.md — Architecture rules for this repo (in the root)
  • trunk-check.yml — CI workflow that enforces boundaries
Last modified on August 5, 2026