Files
treg/docs/context/architecture/import-boundaries.md
T
SToneX 8031c83d99 docs(agents): contract-first AGENTS.md and a pinned uv floor instead of a lock ban (#335)
* docs(agents): AGENTS.md is the single guide, contract first, no state snapshots

CLAUDE.md now only imports AGENTS.md so Claude Code, Codex and Cursor read one file.

AGENTS.md is rewritten around what an agent cannot learn from the code:

- Non-negotiables move to the top and are corrected against the code. "Own key
  always wins, never metered, never routed or overflowed" is promoted to rule 1.
  The relay rule now scopes to plain /call/ and names routed endpoints and
  overflow as wrappers, since route.py injects `_treg` into the body and overflow
  adds X-Treg-Served-Via; the old wording contradicted both. The hold rule
  defines "hold"; the pool rule says why reserve and settle are two transactions;
  the ledger rule records that there is deliberately no refund entry.
- The dataplane write allowlist becomes guidance that points at
  tests/test_call_architecture.py as the authority instead of a prose copy.
- Enforcement gap inventories, per-file commit lists, contract-by-contract
  recaps and the CLI-module list are removed: they duplicate pyproject, the
  import-boundaries fragment and the tests, and rot without a drift check.
- Endpoint and provider counts are removed (three files carried three values).
- Duplicated statements (own-key, faithful relay, keep-four-in-step, money-only
  path, relay guard, secrets) are stated once each.
- Sections reordered: contract, where the truth lives, architecture,
  development, working agreement, and a closing section for user-facing copy.

* build(uv): pin required-version instead of banning `uv lock`

uv.lock is revision 3, first written by uv 0.8.4. An older uv reads it fine but
rewrites it to revision 2 on any touch, dropping every upload-time field: the
~650-line no-op diff the old "always --frozen, never uv sync or uv lock" rule
worked around. That rule also told agents to hand-edit the lock, which --frozen
would then install unchecked.

- pyproject.toml: `[tool.uv] required-version = ">=0.12"`, so an old uv refuses
  to run instead of rewriting the lock.
- ci.yml: `--frozen` becomes `--locked`, so a stale lock fails CI instead of
  being installed silently. The comment about the team's older uv is gone.
- CONTRIBUTING.md names the floor; AGENTS.md replaces the ban with "change
  dependencies through uv add or uv lock, never by hand".
- docs/context/architecture/import-boundaries.md describes the CI step as it
  now runs.

Verified: uv lock --check, uv sync --locked and uv run --locked lint-imports
(12 kept, 0 broken) on uv 0.12.3.
2026-09-05 14:52:29 +08:00

8.6 KiB

title, status, sources, related
title status sources related
Enforced import boundaries shipped
pyproject.toml
.github/workflows/ci.yml
src/treg/application/__init__.py
src/treg/application/call/__init__.py
src/treg/application/call/access.py
src/treg/application/call/authorize.py
src/treg/application/call/idempotency.py
src/treg/application/call/overflow.py
src/treg/application/call/route.py
src/treg/domain/catalog/routing/__init__.py
src/treg/application/call/intake.py
src/treg/application/call/resolve.py
src/treg/application/call/reserve.py
src/treg/application/call/settle.py
src/treg/application/call/evidence.py
src/treg/application/call/service.py
src/treg/application/call/types.py
src/treg/client_identity.py
src/treg/domain/__init__.py
src/treg/domain/governance/__init__.py
src/treg/domain/governance/access.py
src/treg/domain/governance/budgets.py
src/treg/domain/governance/publicdemo.py
src/treg/domain/governance/teams.py
src/treg/domain/governance/usage.py
src/treg/domain/identity/__init__.py
src/treg/domain/connections/__init__.py
src/treg/domain/connections/authorization.py
src/treg/domain/connections/oauth_flow.py
src/treg/domain/connections/refresh.py
src/treg/domain/money/__init__.py
src/treg/domain/asynctasks/__init__.py
src/treg/domain/capacity/__init__.py
src/treg/infra/upstream/__init__.py
src/treg/infra/upstream/injectors.py
src/treg/infra/upstream/relay.py
src/treg/infra/upstream/aggregators/__init__.py
src/treg/infra/upstream/limiter.py
tests/test_call_architecture.py
tests/test_import_lightness.py
architecture/composition.md
architecture/money.md
interface/cli.md

Enforced import boundaries

Import Linter reads the contracts under tool.importlinter in pyproject.toml. The main CI test job installs the lock with uv sync --locked (failing on a stale lock), then runs uv run --locked lint-imports before the test suite. Keeping the check in that job reuses the development environment and avoids a second install for a fast static architecture check. The separate test-postgres job runs its database-sensitive subset serially against Postgres 16; it uses unbuffered Python output and a 15-minute job budget so a slow test remains diagnosable. The subset includes agent attribution, credential health, local-run reporting and ads-conversion coverage so naive-UTC assumptions are exercised by asyncpg rather than hidden by SQLite's permissive adapter.

Stage 1 activated the first two contracts:

  • The explicit lightweight CLI module list cannot directly import any server-extra package, including FastAPI, SQLModel, SQLAlchemy, Alembic, database drivers, MCP, Stripe, or cryptography. Imports guarded by TYPE_CHECKING are excluded globally because they cannot load at runtime. Indirect imports are allowed by this contract because optional proxy dependencies may appear in lazily executed internal modules; the named CLI modules themselves must remain free of direct server imports.
  • treg.domain.money cannot import treg.audit. Money correctness never flows through the best-effort audit path, whose writes may be shed under load.

Stage 2 adds a third contract: the complete treg.routers package cannot import treg.api, directly or indirectly. as_packages = true makes the source cover every current and future router submodule. api.py remains the compatibility exporter and ordered route-table host, so the allowed direction is API to routers.

The async-task domain has the same inward-only boundary as capacity: it may not import API, routers, application orchestration or best-effort audit. tests/test_call_architecture.py pins the rule and a mutation proving the guard rejects a forbidden edge.

Stage 3 adds domain contracts as packages appear. The complete treg.domain.identity package cannot import treg.api, treg.routers, or treg.application. Identity now owns session signing and validation, MCP token and grant-family primitives, and caller/access resolution as a leaf. Sibling-domain edges are added when the sibling appears; identity therefore also forbids governance. Governance may import identity but cannot import the API, routers, or application layer. Future sibling contracts remain absent until their packages exist, so no placeholder domain makes a future boundary look active. Governance owns shared tool/project ACLs, tag-budget rules, and public-demo rate policy. The package also forbids direct FastAPI and Starlette imports; semantic policy errors are translated by each HTTP interface. The call application package owns the framework-neutral staged use case: request intake, idempotency state, target resolution, catalog pricing and access decisions, authorization, reservation, relay orchestration, and finalization. The HTTP adapter captures a CallInput, translates typed failures, and wraps the returned UpstreamResponse. Client-name normalization lives in a neutral leaf so the application path does not load the Request-aware caller metadata adapter.

The complete treg.domain.connections package is also an inward-facing domain package. It owns provider-neutral authorization-method selection, consent URL construction, and refresh state changes. It cannot import the API, bootstrap, routers, application layer, FastAPI, or Starlette. Provider registry data can call these rules through the legacy compatibility modules, while HTTP exchange adapters stay in treg.infra and workflow coordination stays in treg.application.

Two runtime contracts keep that boundary executable. treg.application.call cannot import the legacy API, bootstrap, routers, FastAPI, or Starlette. treg.infra.upstream cannot import those HTTP adapters or frameworks. Direct imports of the application-owned request and response DTOs remain the port shared by the use case and relay. Mutation tests inject representative forbidden edges and assert detection. The same test module also pins the money transaction boundary: all five domain/money primitives (the reserve/settle/release staged bodies plus grant and topup) are scanned for db.commit / db.rollback and must stage only, with a mutation self-check that an injected commit is detected; the lazy stale-hold reap keeps its documented independent committing boundary.

Two direct edges are precise exceptions. cli.ensure_proxy_dependency imports cryptography only after the user invokes the optional proxy feature and offers to install the proxy extra first. localrun.render_grant imports SQLModel only when the server executes the grant path. Import Linter treats function-local imports as ordinary direct edges, so both appear in ignore_imports; unmatched ignores are errors, ensuring a removed or renamed edge cannot leave a stale exception behind.

An ignore covers an entire module edge and therefore cannot detect someone moving either lazy import to module scope. tests.test_import_lightness closes that gap by starting an isolated Python subprocess, importing every lightweight module, and asserting that no server dependency root appears in sys.modules. Base dependencies such as httpx and questionary remain allowed.

The async task domain (treg.domain.asynctasks) is a stdlib-only leaf shared with the light CLI: cli.await_async_task imports its json_path, classify_terminal and artifact so the awaiter and the settlement worker can never disagree about what "done" means. Two guards keep it light: the module is on test_import_lightness's list, and an import-linter contract forbids it every server root (treg.models, treg.infra, treg.config, SQLModel, pydantic, yaml, httpx).

The capacity domain (treg.domain.capacity, plan step B) is a leaf like identity: it cannot import treg.api, treg.routers, treg.application, treg.bootstrap, treg.audit, FastAPI or Starlette. It reads config and writes only its own tables and ratestore keys, from worker-profile commands (treg-worker, a separate console script so the light treg CLI never gains a DB import). The call application imports the capacity domain inward (resolve → view, settle → signatures/marks); the domain never imports back; application.call.overflow composes the capacity domain, the aggregator envelopes and the money primitives, and the aggregator adapters stay pure envelope code; application.call.route composes the pure domain.catalog.routing package (contracts, adapters, ranking) with the call use case itself. The aggregator envelopes live under treg.infra.upstream.aggregators and inherit the upstream contract (no HTTP adapters, no routers); the capacity domain's verify module may import them because they are pure envelope code, not a web framework.