terp-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
terp/cli/__init__.py ADDED
@@ -0,0 +1,1633 @@
1
+ """terp.cli — the ``terp`` command-line tool."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import importlib
7
+ import json
8
+ import pathlib
9
+ import re
10
+ import sys
11
+ from collections.abc import Callable, Sequence
12
+
13
+ from terp.core import ControlPlane, CorsPolicy, ModuleSpec
14
+
15
+ from terp.cli.access import (
16
+ build_access_graph_for_app,
17
+ render_access,
18
+ render_access_graph,
19
+ )
20
+ from terp.cli.apidocs import api_docs
21
+ from terp.cli.dev import dev_plan, run_dev_command
22
+ from terp.cli.docker import run_docker_dev_command
23
+ from terp.cli.jobs import (
24
+ render_jobs,
25
+ run_job_command,
26
+ run_scheduler_command,
27
+ run_worker_command,
28
+ )
29
+ from terp.cli.openapi import _load_app, export_openapi
30
+ from terp.cli.profiles import DEFAULT_PROFILE, profile_names
31
+ from terp.cli.scaffold import new_module, new_module_message
32
+ from terp.cli.schema import (
33
+ build_schema_graph,
34
+ import_declared_models,
35
+ render_schema_graph,
36
+ scan_declared_table_models,
37
+ )
38
+ from terp.cli.seed import run_seed_command
39
+ from terp.cli.users import create_user_command
40
+ from terp.cli.verify import profile_ids, run_verify_command, verify_manifest
41
+
42
+ _GUIDE_TOPICS: dict[str, str] = {
43
+ "module": """\
44
+ Add a module (the "10-minute module")
45
+
46
+ 1) models.py table model (inherit BaseTable; never redeclare id/created_at/version)
47
+ from terp.core import BaseTable
48
+ from sqlmodel import Field
49
+ class Invoice(BaseTable, table=True):
50
+ number: str = Field(max_length=50, index=True, unique=True)
51
+ amount_cents: int
52
+
53
+ 2) schemas.py DTOs (cap every input string; the Read DTO is what the API returns)
54
+ from terp.core import BaseSchema, BaseUpdateSchema
55
+ class InvoiceCreate(BaseSchema):
56
+ number: str = Field(max_length=50)
57
+ amount_cents: int
58
+ class InvoiceUpdate(BaseUpdateSchema): # `version` is required (optimistic concurrency)
59
+ amount_cents: int | None = None
60
+ class InvoiceRead(BaseSchema): # NOT the table model
61
+ id: uuid.UUID; number: str; amount_cents: int; version: int
62
+
63
+ 3) service.py business logic (CRUD is inherited and audited)
64
+ from terp.core import BaseService
65
+ class InvoiceService(BaseService[Invoice, InvoiceCreate, InvoiceUpdate]):
66
+ model = Invoice
67
+
68
+ 4) router.py thin HTTP layer (convert rows to the Read DTO)
69
+ @router.post("/", response_model=InvoiceRead, status_code=201)
70
+ def create_invoice(payload: InvoiceCreate, session: SessionDep) -> InvoiceRead:
71
+ return InvoiceRead.model_validate(_service.create(session, payload))
72
+
73
+ 5) module.py the manifest
74
+ module = ModuleSpec(name="invoices", router=router, policy=Policy.default())
75
+
76
+ Then run `terp check`. Policy.default() = authenticated; read VIEWER, write EDITOR.
77
+ """,
78
+ "service": """\
79
+ Services (BaseService)
80
+
81
+ - Subclass BaseService[Model, Create, Update] and set `model`. You get
82
+ create/update/delete/get/list — all audited, OCC-checked and scope-honored.
83
+ - A bespoke mutation must route through self._save(...) / self._remove(...) (never a
84
+ raw session write), so it stays audited.
85
+ - Add an always-on read filter by overriding business_filters() — it returns
86
+ conditions and CANNOT drop soft-delete / tenant scope:
87
+ def business_filters(self):
88
+ return (Invoice.status == "open",)
89
+ - A per-call filter goes in a custom list() built on base_query():
90
+ def list(self, session, *, skip, limit, status=None):
91
+ q = self.base_query()
92
+ if status is not None:
93
+ q = q.where(Invoice.status == status)
94
+ return self._paginate(session, q, skip=skip, limit=limit)
95
+ - On a large table, prefer keyset pagination: a route takes CursorPaginationDep and
96
+ returns CursorPage[ReadDTO] from self.list_by_cursor(session, pagination=...) —
97
+ no OFFSET scan, and the exact COUNT runs only when the caller asks
98
+ (include_total=true).
99
+ - EVERY read (search, "my items", reports) builds on base_query() — never a raw
100
+ select(Model) and never session.get(Model, id). A scope-trait model
101
+ (SoftDeleteMixin/TenantScopedMixin) read via a bare select() or a primary-key
102
+ get() drops soft-delete/tenant scope (cross-tenant leak); the gate's
103
+ reads_use_base_query rule forbids both, and the request session re-scopes the
104
+ user-facing read methods (exec/scalars/scalar/get) as a backstop. Read a single
105
+ row with self.get(session, id) — NOT session.get(Model, id).
106
+ - Soft-delete: mix SoftDeleteMixin into the model; delete() soft-deletes and reads
107
+ exclude it automatically. Never write deleted_at by hand. Never override base_query.
108
+ - Provenance is automatic: compose ActorStampedMixin and BaseService fills
109
+ created_by_id / modified_by_id from the request actor on every write — never set them
110
+ by hand (the no_manual_actor_stamping rule forbids it). A Read DTO may still expose
111
+ them to surface "who created / last changed this".
112
+ - Dropping to raw SQL? Keep the text(...) argument a STATIC literal and pass data
113
+ through bound parameters — a dynamically built statement (f-string, concatenation,
114
+ .format, %, or a variable) is refused by the no_dynamic_sql rule (SQL injection):
115
+ session.exec(text("SELECT ... WHERE status = :status"), {"status": status})
116
+ """,
117
+ "policy": """\
118
+ Authorization (Policy)
119
+
120
+ - Every ModuleSpec carries a Policy (deny-by-default):
121
+ Policy.default() authenticated; read VIEWER, write EDITOR
122
+ Policy(read=VIEWER, write=ADMIN) typed roles (terp.core: VIEWER/EDITOR/ADMIN)
123
+ Policy.public(reason="health probe") the ONLY way to drop authentication
124
+ - Fine-grained permissions need a per-subject GRANT, not just a role:
125
+ Policy(write=Permission("invoices.approve", min_role=EDITOR))
126
+ Wire create_app(..., permission_enforcer=terp.capabilities.access.enforce_permission)
127
+ or boot fails closed. Grant via the access capability; the caller must clear the
128
+ min_role floor AND hold the grant.
129
+ - Route-level extra check: dependencies=[Depends(require_permission("invoices.approve"))].
130
+ - Authority is always a typed object (Role / Permission), never a bare string.
131
+ """,
132
+ "access": """\
133
+ The access model (three layers) — profiles + the access graph
134
+
135
+ - Effective access is exactly three composable layers, each an existing primitive:
136
+ 1. module access ModuleSpec.policy — may this principal enter the module?
137
+ 2. endpoint access the route's read/write requirement (mutating verb => write),
138
+ plus route-level require_permission(...) dependencies
139
+ 3. data visibility model traits — which rows are readable / mutable?
140
+ OwnedMixin (write gate), TenantScopedMixin (read filter +
141
+ stamped writes), register_scope_predicate / object-authz
142
+ - Pick a PERMISSION PROFILE instead of hand-assembling the layers:
143
+ terp new module invoices --profile <name>
144
+ shared read VIEWER, write EDITOR (Policy.default())
145
+ role-gated read VIEWER, write ADMIN
146
+ owner-private + OwnedMixin: only a row's owner may update/delete
147
+ tenant-private + TenantScopedMixin + TenantScopedService: rows isolated per tenant
148
+ tenant-owner tenant isolation + the per-row owner write gate
149
+ A profile is a preset, never a mechanism: it only decides which primitives the
150
+ scaffold composes, so the output is ordinary gate-checked Terp code you own.
151
+ - SEE the whole graph — who can reach which module, endpoint, and rows:
152
+ uv run terp inspect access --app app.main:build --app-root . --format json
153
+ The --app form reports the WHOLE composed surface — client modules AND every
154
+ discovered capability router (users / groups / audit / files / …) plus the kernel
155
+ health routes — reconciled against app.openapi() so a mounted route can never hide
156
+ (any that is not covered is listed under omitted_routes, fail-visible). Use
157
+ --object/--module instead to inspect a focused, hand-passed subset.
158
+ One document: roles, permissions, every endpoint's method/path/requirement, each
159
+ declared service's model traits (owned / tenant-scoped / soft-delete), read scope,
160
+ write authority, and warnings (e.g. OwnedMixin gates writes only). `--format json`
161
+ is the stable Studio contract; declare services=(InvoiceService,) on the ModuleSpec
162
+ so the data layer is visualizable — an undeclared data layer is a warning.
163
+ """,
164
+ "ownership": """\
165
+ Object-level (per-row) authorization (OwnedMixin)
166
+
167
+ - A Policy gates a whole route (every editor may edit every row). To restrict a write
168
+ to the row's OWNER, compose OwnedMixin into the model — never hand-roll an owner_id
169
+ check (the no_manual_ownership_checks rule forbids it):
170
+ from terp.core import BaseTable, OwnedMixin
171
+ class Journal(BaseTable, OwnedMixin, table=True):
172
+ title: str = Field(max_length=200)
173
+ - BaseService stamps owner_id to the request actor on create, then authorizes every
174
+ update / delete of that row at the audited chokepoint: a non-owner write fails closed
175
+ with 403, with no code in your service. owner_id is stripped from inbound payloads, so
176
+ a client can never seize ownership through the request body.
177
+ - For a richer policy than "owner only" (team membership, a shared-with ACL), register
178
+ an object-authz predicate — the write-side seam — so a capability contributes per-row
179
+ authority without the kernel importing it (predicates compose fail-closed, AND):
180
+ from terp.core import register_object_authz_predicate
181
+ register_object_authz_predicate(my_predicate) # (model, entity, actor, action) -> bool
182
+ - Ownership is the WRITE gate only; read visibility is the separate register_scope_predicate
183
+ seam (ADR 0017) — an OwnedMixin row stays readable by a non-owner unless you also restrict
184
+ reads. An owner-keyed read filter necessarily references the managed owner_id, so (like the
185
+ tenancy capability's tenant filter) it belongs in a governed predicate carrying a justified
186
+ `# arch-allow-no_manual_ownership_checks`; a built-in owner-read filter is planned sugar.
187
+ Endpoint authority (Policy), row-read visibility (register_scope_predicate) and row-write
188
+ authority (OwnedMixin) are the three composable layers.
189
+ """,
190
+ "tenancy": """\
191
+ Multi-tenant rows (tenancy capability)
192
+
193
+ - Mix TenantScopedMixin into the model and give it a TenantScopedService. Importing
194
+ the mixin registers the tenant row predicate, so EVERY read of that model is
195
+ filtered to the current tenant automatically and create stamps tenant_id; a missing
196
+ tenant context fails closed (reads empty, writes raise).
197
+ class Doc(BaseTable, TenantScopedMixin, table=True): ...
198
+ class DocService(TenantScopedService[Doc, DocCreate, DocUpdate]): model = Doc
199
+ - Never filter tenant_id by hand — the framework owns the predicate (the gate forbids it).
200
+ - The current tenant comes from the request (TenantMiddleware binds the JWT `tenant`
201
+ claim); in tests use tenant_context(tenant_id).
202
+ - Wire it through the create_app middleware seam — never add_middleware (the gate
203
+ forbids it):
204
+ from starlette.middleware import Middleware
205
+ from terp.capabilities.auth import tenant_from_bearer
206
+ create_app(specs, principal_provider=get_principal,
207
+ middleware=[Middleware(TenantMiddleware, resolve_tenant=tenant_from_bearer)])
208
+ Sign the tenant into the token at login with
209
+ build_login_module(authenticate, tenant_resolver=...).
210
+ """,
211
+ "passwords": """\
212
+ Password strength (PasswordPolicy, Tier-B)
213
+
214
+ - Provisioning and resets enforce the app's PasswordPolicy at the users-service
215
+ credential boundary: a weak password is refused with a typed 422 (code weak_password,
216
+ the uniform envelope), the max_length cap stays the separate DoS guard.
217
+ - The safe default is 12+ chars, 2+ character classes, and a common-password denylist
218
+ (length over forced complexity, NIST-aligned). Tier-B: override the VALUES, not shape:
219
+ from terp.core import PasswordPolicy, ControlPlane
220
+ control_plane = ControlPlane(passwords=PasswordPolicy(min_length=16, min_character_classes=3))
221
+ - Relaxing strength is an explicit, justified opt-out and is refused at production boot:
222
+ PasswordPolicy.relaxed(reason="legacy bulk import")
223
+ - No terp.arch check applies (no module code shape to police) — enforcement is the
224
+ service chokepoint plus the create_app production fail-fast.
225
+ """,
226
+ "events": """\
227
+ Domain events (eventbus capability)
228
+
229
+ - Declare typed events in your control plane (never bare strings):
230
+ NOTE_CREATED = EventDefinition("note.created", payload_schema=NoteCreatedPayload)
231
+ event_catalog = EventCatalog([NOTE_CREATED])
232
+ - Emit declaratively from a service (atomic with the write):
233
+ class NoteService(EventEmittingService[Note, NoteCreate, NoteUpdate]):
234
+ model = Note
235
+ event_map = LifecycleEventMap(created=NOTE_CREATED)
236
+ - Subscribe with @subscribe(NOTE_CREATED). Reference catalog constants only (the gate
237
+ enforces no-drift). Wire create_app(..., event_dispatcher=dispatch_in_process).
238
+ """,
239
+ "jobs": """\
240
+ Background jobs (terp.core.enqueue + JobCatalog)
241
+
242
+ - Declare typed jobs in your control plane (never bare strings), with a payload SCHEMA
243
+ (cap its strings) and a handler resolved BY NAME:
244
+ class SyncPullPayload(BaseSchema):
245
+ source: str = Field(max_length=100)
246
+ def pull(ctx: JobContext, payload: SyncPullPayload) -> None:
247
+ MyService().create(ctx.session, ...) # writes are audited + actor/tenant-stamped
248
+ SYNC_PULL = JobDefinition(name="sync.customers.pull",
249
+ payload_schema=SyncPullPayload, handler=pull)
250
+ job_catalog = JobCatalog([SYNC_PULL]) # rejects duplicate names
251
+ Put the catalog on the control plane (ControlPlane(jobs=job_catalog)) and list it on the
252
+ module (ModuleSpec(jobs=[SYNC_PULL])) so boot validates it. Reference catalog constants
253
+ only - the jobs_reference_catalog rule forbids a bare string or inline JobDefinition(...).
254
+ - Enqueue through the typed chokepoint (never a raw queue), which rejects an unregistered
255
+ or shadowed job:
256
+ enqueue(session, job=SYNC_PULL, payload=SyncPullPayload(source="crm"),
257
+ idempotency_key="customers-2026-06-29")
258
+ A handler chains follow-up work the same way: enqueue(ctx.session, job=..., payload=...).
259
+ - Pass IDS, not entities - the payload must round-trip JSON (model_dump(mode="json")).
260
+ Delivery is at-least-once, so make handlers idempotent (the idempotency_key + your own
261
+ unique keys). Never read ambient request state in a handler - there is none in a worker;
262
+ use ctx.session / ctx.actor_id / ctx.tenant_id, all re-bound from the envelope.
263
+ - The default InProcessJobQueue runs the handler inline in its own audited unit (dev /
264
+ single-process). A user-less job runs as the control-plane system actor
265
+ (ControlPlane(job_system_actor_id=...)), so its writes are never unstamped. For real
266
+ off-request execution + durability, wire a durable adapter and require it at boot:
267
+ create_app(specs, ..., job_queue=<durable>, require_durable_jobs=settings.is_production)
268
+ - The system actor CANNOT update or delete a user's OwnedMixin row. It remains a
269
+ different actor from the owner. The built-in owner gate and registered object-authz
270
+ predicates compose fail-closed (AND). Predicates can narrow authority but never grant
271
+ an override. Cross-owner maintenance requires a reviewed maintenance-authority
272
+ capability. If none is installed, stop and report the missing capability — never
273
+ remove OwnedMixin or author a destructive owner-column migration.
274
+ - Trigger a scheduled job from any cron / k8s CronJob / systemd or cloud timer:
275
+ terp jobs run sync.customers.pull --payload '{"source": "crm"}'
276
+ Inspect the declared jobs: terp jobs list / terp inspect jobs.
277
+ - Declare a schedule (ScheduleDefinition: a cron + a catalog JobDefinition) on the control
278
+ plane (ControlPlane(schedules=ScheduleCatalog([...]))); boot validates each schedule's job
279
+ against the JobCatalog. Run schedules in-process with `terp jobs scheduler` (APScheduler;
280
+ needs terp-cap-scheduler-apscheduler) or via Celery beat — each cron tick enqueues through
281
+ the same typed seam, so a scheduled job stays audited + system-actor stamped.
282
+ """,
283
+ "files": """\
284
+ File objects (files capability, ADR 0056/0057)
285
+
286
+ - Upload/download/list/rename/delete ride the admin-only discovered router at
287
+ /api/v1/files; File composes OwnedMixin, so rename/delete are owner-gated centrally.
288
+ - Bytes live behind the StorageBackend port (put/open/delete, streamed via file-like
289
+ objects) in a NAMED-PROFILE registry;
290
+ metadata (name, type, size, sha256, storage_key, storage_profile) lives in the platform
291
+ DB. storage_key/storage_profile never leave the boundary (FileRead omits both).
292
+ - Any provider is an adapter subclass (local ships; S3/Azure/NAS are each a
293
+ StorageBackend). Register each store once at the composition root:
294
+ register_storage_backend("azure-invoices", AzureBlobStorage(container="invoices"))
295
+ register_storage_backend("azure-hr", AzureBlobStorage(container="hr"))
296
+ Keep credentials in settings / sealed config — never in module code.
297
+ - Pick the store per module (subclass default) or per call — never from a client:
298
+ class InvoiceFileService(FileService):
299
+ storage_profile = "azure-invoices"
300
+ service.store(session, filename=..., content_type=..., source=..., profile="azure-hr")
301
+ Resolution is FAIL-CLOSED: an unknown profile raises (UnknownStorageProfileError)
302
+ before any byte lands; load/remove always resolve the store the ROW itself names.
303
+ - Uploads stream (never fully buffered) under a 25 MiB default cap; the files spec
304
+ declares its own request-body allowance so the kernel's global max_request_bytes
305
+ (1 MiB default) is lifted for /api/v1/files ONLY (ADR 0067). Retune per deployment
306
+ with two composition-root lines (the request allowance must exceed the stored cap
307
+ by multipart framing headroom):
308
+ configure_upload_limit(100 * 1024 * 1024)
309
+ create_app(..., request_size_overrides={"files": 100 * 1024 * 1024 + 65536})
310
+ - Content types are allowed by default (descriptive metadata); a deployment narrows
311
+ uploads to an allowlist with one composition-root line — enforced in the service
312
+ chokepoint (typed 415, before any byte lands), so no upload path can bypass it:
313
+ configure_allowed_content_types(["application/pdf", "image/*"])
314
+ - Referencing a file from your own model? Declare it — never a bare uuid column (the
315
+ no_raw_file_references rule enforces this on table models):
316
+ class Invoice(BaseTable, table=True):
317
+ attachment_file_id: uuid.UUID | None = FileRef()
318
+ Serve it THROUGH your own already-authorized row (serve-through delegation): load the
319
+ invoice via your own service (its policy + row scope decide visibility), then
320
+ row, data = FileService().load_for(session, invoice, "attachment_file_id")
321
+ load_for fail-closes on an undeclared reference; /api/v1/files itself stays ADMIN-only.
322
+ """,
323
+ "capability": """\
324
+ Using capabilities
325
+
326
+ - Capabilities are opt-in packages (terp-cap-*); the base profile is auth + access +
327
+ identity + users (+ projects). Install the ones you need.
328
+ - A routed capability self-registers: create_app(specs, discover_capabilities=True)
329
+ mounts it at /api/v1/<name> via its entry point — no composition-root edit.
330
+ - A library capability (tenancy, eventbus) ships no router; you import and wire it
331
+ (a mixin/service, a dispatcher) where needed.
332
+ - Compose the app once:
333
+ create_app(specs, principal_provider=..., control_plane=...,
334
+ audit_sink=persist_audit, event_dispatcher=dispatch_in_process,
335
+ permission_enforcer=enforce_permission, discover_capabilities=True)
336
+ - You can always drop to native FastAPI/SQLModel — the same gate rules still apply.
337
+ - Outbound HTTP is a capability concern, never a module concern: importing httpx /
338
+ requests / urllib.request / urllib3 / aiohttp in a module is refused by the
339
+ no_raw_outbound_http rule — SSRF protection, egress allowlists and timeout policy
340
+ belong behind one declared capability, not scattered per call site.
341
+ - Credentials never live in module source: a credential-shaped assignment (password,
342
+ api_key, token, ...) to a string literal — or a recognizable secret-token literal
343
+ anywhere — is refused by the no_hardcoded_credentials rule. Wire secrets through
344
+ settings / sealed config (ADR 0055), never source.
345
+ """, "migrations": """\
346
+ Database migrations (terp migrate)
347
+
348
+ - Each table-owning package (capability or app module) owns an INDEPENDENT, linear
349
+ Alembic history with its own alembic_version_<label> table - no shared graph and no
350
+ CROSS-package merges. Terp discovers them; you never hand-write env.py.
351
+ - Author a revision after changing a model (autogenerated, scoped to that package so
352
+ it never proposes another package's tables):
353
+ terp migrate make <label> -m "add invoice.status" # <label> e.g. invoices
354
+ Cross-module / cross-package foreign keys just work: every package's models are
355
+ imported so an FK target (a sibling module, or identity_user) resolves at make time,
356
+ and upgrade is ordered by FK dependencies so a referenced table is always created
357
+ before the table that references it - regardless of label ordering. (A cross-package
358
+ FK *cycle* cannot be ordered and fails closed; break it with a nullable FK populated
359
+ in a later migration.)
360
+ - Apply / inspect / roll back across every package:
361
+ terp migrate upgrade # each package to head (run on deploy)
362
+ terp migrate upgrade --sql > release.sql # render DBA-reviewable offline SQL
363
+ # instead (nothing connects; flat layout)
364
+ terp migrate status # current-vs-head per package
365
+ terp migrate downgrade # every package back to base (or -N)
366
+ terp migrate downgrade --label notes --revision <rev> # one package only
367
+ A concrete revision is package-specific, so the all-package downgrade takes only
368
+ base or a relative -N; pass --label to roll one package to any of its own revisions.
369
+ - Two developers branched the same package? Resolve the within-package divergence:
370
+ terp migrate heads # more than one head = diverged
371
+ terp migrate merge <label> -m "merge"
372
+ - Destructive DDL (drop table/column or alter-column type changes) is refused by
373
+ `terp check` unless the operation carries `# arch-allow-no-destructive-migrations:
374
+ <reason>` on (or immediately above) its line, budgeted by the escape-hatch ratchet.
375
+ - Adopt Terp on an EXISTING database (built by create_all or by hand) without dropping
376
+ data - baseline each history at head, then only genuinely new migrations apply:
377
+ terp migrate stamp # records head, runs no DDL
378
+ - Want physical per-module separation on PostgreSQL (each package's tables in its own
379
+ schema, the groundwork for per-schema GRANTs)? Set DB_SCHEMA_LAYOUT=per-module for a
380
+ fresh database, or move an existing flat one in place (idempotent, data moves with
381
+ the tables, version tables stay put):
382
+ terp migrate adopt-schemas # one-time; ADR 0070
383
+ - Least-privilege runtime (ADR 0071): migrate as the owning role, run the app as a
384
+ separate login that holds ONLY DML - the database itself then refuses DDL and
385
+ (per-module) any tampering with migration state. Provision the login yourself, then:
386
+ terp migrate grant-runtime <role> # idempotent; run after upgrade/adopt
387
+ Run it as the role that runs `terp migrate` - or pass --owner-role <role> so the
388
+ ALTER DEFAULT PRIVILEGES it emits covers tables that future upgrades create.
389
+ Module-to-module DML is deliberately NOT database-blocked (one runtime role spans
390
+ every write schema; audit/outbox ride the business write's single session).
391
+ - Operate safely: run `terp migrate upgrade` ONCE per deploy (e.g. a release job), not
392
+ on every replica - it takes no lock, so concurrent runs race. The boot guard below is
393
+ read-only and safe on every replica. The migration engine is built from DATABASE_URL,
394
+ so put URL-expressible options (e.g. sslmode) there.
395
+ - Run from your app root so app/ is importable (app modules ship their history in
396
+ app/modules/<name>/migrations/; a capability declares a terp.migrations entry point).
397
+ - Make upgrading non-optional: wire the fail-closed boot guard so the app refuses to
398
+ start against a stale schema (a deploy that skipped the upgrade fails loudly). Pass an
399
+ app_root so it guards your app modules too, not only capabilities:
400
+ from functools import partial
401
+ from pathlib import Path
402
+ from terp.migrations import assert_migrations_current
403
+ create_app(specs, ..., migration_check=partial(
404
+ assert_migrations_current, app_root=Path(__file__).parent))
405
+ Gate it on production if local dev builds the schema with create_all / SQLite.
406
+ - Test the REAL migration path (not only create_all) so a model change with no
407
+ migration fails CI, not production:
408
+ from terp.migrations import upgrade, assert_migrations_match_models
409
+ upgrade(db_url, app_root); assert_migrations_match_models(db_url, app_root)
410
+ """,
411
+ "frontend": """\
412
+ Frontend module screens (@terp/react-core)
413
+
414
+ - A module's frontend slot is frontend/src/modules/<name>/ with a module.tsx manifest;
415
+ everything composes the token-styled @terp/react-core surface. The full catalog (with
416
+ per-export "Use" guidance) is the @terp/react-core README; each export also carries
417
+ JSDoc, so your editor shows the same guidance inline.
418
+ - The boundary lint (@terp/eslint-boundaries) refuses, fail-closed:
419
+ raw <button>/<input>/<select>/<textarea> -> Button / Input / Select / Textarea
420
+ raw <table> -> DataView (terp guide dataview)
421
+ raw <dialog> -> ConfirmDialog
422
+ raw <form> -> Stack as="form" (terp guide forms)
423
+ raw fetch / XMLHttpRequest -> useTerpClient() + unwrap (typed client)
424
+ WebSocket / EventSource / sendBeacon -> the generated client (one egress path)
425
+ style={} / className / module stylesheets -> layout via Stack/DetailList; design tokens
426
+ <a href="/..."> -> the router's Link (role-aware, no reload)
427
+ deep imports (@terp/*/src, @terp/*/dist) -> import from the package root only
428
+ - Frontend security defaults (each its own lint rule, same error-only footing):
429
+ dangerouslySetInnerHTML and DOM HTML-injection sinks (innerHTML/outerHTML/
430
+ insertAdjacentHTML/document.write) are refused — render text, or Markdown from
431
+ @terp/react-core for rich text; eval() / new Function() are refused; javascript:
432
+ URLs in href/src are refused; a static target="_blank" link needs rel="noopener".
433
+ - Every routed view renders a page archetype (Page / OverviewPage / DetailPage / HubPage);
434
+ buildAppRouter refuses an unframed view at runtime, fail closed. An app can ratchet
435
+ further with an opt-in slot-typed layout contract (terp guide layouts).
436
+ - User-facing text props are UiText (a plain string, or {id, message} for localization).
437
+ - Data always flows through the generated client: useTerpClient() (typed from the backend
438
+ OpenAPI export) and unwrap(...) which throws a typed ApiError carrying code/status.
439
+ - The one governed opt-out is a justified `// terp-allow-<rule>: <reason>` marker whose
440
+ counts must exactly match the app's checked-in escape-hatch-budget.json (a ratchet).
441
+ - Run the lint locally: npm --prefix frontend run lint (part of the gate).
442
+ """,
443
+ "dataview": """\
444
+ Data collections (DataView)
445
+
446
+ - DataView is the single sanctioned surface for data collections — a raw <table> is
447
+ refused by the boundary lint. It gives search, sorting, pagination, column management,
448
+ selection + batch actions, row actions, expandable rows, and persisted view
449
+ preferences, driven by a repository port.
450
+ - Client-side (small collections — rows already in memory):
451
+ const repo = useMemo(() => new InMemoryDataViewRepository(rows), [rows]);
452
+ <DataView repository={repo} columns={columns} keyField="id" />
453
+ - Server-side (large collections — let the backend paginate/sort/filter):
454
+ const repo = useMemo(() => new HttpDataViewRepository({...}), [client]);
455
+ and keep query state in the URL with useServerDataView.
456
+ - Columns declare {key, header, render?}; header text is UiText. Row actions and batch
457
+ actions are declared as data (the component renders the token-styled controls).
458
+ - Persist per-user view preferences via the ViewStateRepository seam
459
+ (LocalStorageViewStateRepository for the browser; InMemoryViewStateRepository in tests).
460
+ - For a simple titled CRUD list (no tables), ResourceList over useResource is the
461
+ lighter standard screen; reach for DataView when the collection needs table powers.
462
+ """,
463
+ "forms": """\
464
+ Forms (react-core primitives)
465
+
466
+ - Raw <form>/<input>/<select>/<textarea>/<button> are refused by the boundary lint;
467
+ compose the token-styled primitives instead:
468
+ <Stack as="form" onSubmit={submit}>
469
+ <Field label="Number" error={errors.number}>
470
+ <Input value={number} onChange={...} maxLength={50} />
471
+ </Field>
472
+ <Field label="Status">
473
+ <Select value={status} onChange={...}>...</Select>
474
+ </Field>
475
+ <Button type="submit" variant="primary">Save</Button>
476
+ </Stack>
477
+ - Field wraps label + control + hint/error for one field; Stack (vertical by default)
478
+ is the layout — never style={} / className / a module stylesheet.
479
+ - Submit through the typed client: const client = useTerpClient();
480
+ await unwrap(client.POST("/api/v1/invoices/", { body })); a failure throws ApiError
481
+ ({code, status, requestId}) — map codes to copy with useErrorMessage, show transient
482
+ success/failure with useToast(), and confirm destructive actions with ConfirmDialog.
483
+ - Updates carry the row's `version` (optimistic concurrency): send the version you
484
+ read; a 409 version_conflict means reload-and-retry, surfaced via ErrorState copy.
485
+ - Mirror the backend's input caps client-side (maxLength on Input matching the schema's
486
+ Field(max_length=...)) so users see the limit before the 422 does.
487
+ """,
488
+ "layouts": """\
489
+ Layout contracts (slot-typed layouts, ADR 0079)
490
+
491
+ - A layout contract is an OPT-IN ratchet above the page archetypes: not just "every
492
+ routed view is framed", but "this archetype's body holds only these components".
493
+ It is enforced two-layer and fail-closed, and every failure message tells you the
494
+ contract, the slot, what was found, what is allowed, and the concrete fix — let it
495
+ guide you.
496
+ - Opt in (both halves; keep them in sync — the template generates both):
497
+ frontend/layout-contract.json -> { "contract": "standard" } (lint half)
498
+ renderTerpApp({ layoutContract: "standard", ... }) (runtime half)
499
+ No config = no checks (fully backwards compatible; an existing app can switch later
500
+ and fix screens by following the enforcement messages).
501
+ - The "standard" contract governs the body slot of each archetype:
502
+ HubPage -> HubCard only (a card grid landing)
503
+ OverviewPage -> DataView / ResourceList / ModuleNav / Stack + the framework
504
+ states (EmptyState / ErrorState / LoadingState / Alert) and
505
+ ConfirmDialog
506
+ DetailPage -> DetailList / Stack / Tabs / ModuleNav / DataView + the same
507
+ framework states and ConfirmDialog
508
+ The plain Page stays unconstrained — it is the sanctioned home for a bespoke screen.
509
+ - Enforcement (never lint-only):
510
+ build time -> the terp/layout-contract ESLint rule checks the static JSX
511
+ children of each governed archetype (npm --prefix frontend run lint)
512
+ runtime -> the archetypes verify the rendered DOM children (each sanctioned
513
+ component stamps a data-terp marker) and refuse the view, fail
514
+ closed — so dynamic children a linter cannot see are still governed.
515
+ - The one opt-out is the governed escape hatch: a justified
516
+ `// terp-allow-layout-contract: <reason>` marker on the violating line, counted
517
+ against the app's checked-in escape-hatch-budget.json (a ratchet). A recurring
518
+ legitimate need should become a contract allowance, not an opt-out.
519
+ """,}
520
+
521
+ # Topics whose body is generated from a live registry (not a static recipe above).
522
+ _GENERATED_TOPICS: tuple[str, ...] = ("rules",)
523
+
524
+ _RULE_GUIDE_DETAILS: dict[str, str] = {
525
+ "no_raw_outbound_http": """\
526
+ Compliant decision path for outbound HTTP
527
+
528
+ 1. Preserve the requested integration and its external contract. Removing the live
529
+ call, returning static/local data, or moving the client import to an unscanned
530
+ helper only to make the gate green is not a compliant fix.
531
+ 2. Use a maintained purpose-built capability when its semantics match. For example,
532
+ terp-cap-webhooks owns signed webhook POST delivery; it is not a generic GET client.
533
+ 3. The maintained Terp capability surface currently has no generic outbound-fetch
534
+ capability for arbitrary HTTP GETs. App modules therefore cannot implement a live
535
+ news/feed fetch through a sanctioned generic API today.
536
+ 4. When no matching capability exists, stop and report the missing capability. Leave
537
+ the check red until a human approves an escape hatch or the platform supplies a
538
+ reviewed adapter capability. Do not create an app-local helper package merely to
539
+ move the raw client outside the scanner.
540
+ 5. A new adapter capability must expose a narrow domain API and centrally enforce a
541
+ fixed destination allowlist, HTTPS, SSRF-safe DNS/IP handling, redirect policy,
542
+ bounded timeouts and response sizes, credentials from settings, and egress audit.
543
+ App modules import only that declared capability's public domain seam.
544
+ """,
545
+ }
546
+
547
+
548
+ def guide_topics() -> tuple[str, ...]:
549
+ """Every ``terp guide`` topic, sorted: the static recipes + the generated ones.
550
+
551
+ The single source of truth for the CLI topic ``choices`` *and* the docs-parity
552
+ test, which derives its per-topic coverage from this rather than re-listing topics.
553
+ """
554
+ return tuple(sorted([*_GUIDE_TOPICS, *_GENERATED_TOPICS]))
555
+
556
+
557
+ def guide_choices() -> tuple[str, ...]:
558
+ """Every accepted focused guide name: broad topics plus exact architecture rules."""
559
+ from terp.arch.rules import GUIDE_TOPIC_BY_RULE
560
+
561
+ return tuple(sorted({*guide_topics(), *GUIDE_TOPIC_BY_RULE}))
562
+
563
+
564
+ _TOPIC_NAMES = ", ".join(guide_topics())
565
+
566
+ _GUIDE_OVERVIEW = f"""\
567
+ Terp — secure-by-default application platform (authoring guide)
568
+
569
+ You write small modules; the framework enforces auth, audit, optimistic concurrency,
570
+ pagination, input caps and row scoping for you. A green gate (`terp check` /
571
+ `uv run pytest`) means your code is compliant — the architecture rules fail closed with
572
+ precise, fixable messages, so let them guide you.
573
+
574
+ Canonical module shape (modules/<name>/):
575
+ models.py table models (inherit BaseTable)
576
+ schemas.py request/response DTOs (BaseSchema / BaseUpdateSchema)
577
+ service.py business logic (subclass BaseService)
578
+ router.py thin HTTP layer (APIRouter over the service + SessionDep)
579
+ module.py the ModuleSpec manifest (name + router + Policy)
580
+
581
+ Golden rules (the gate enforces these — follow them and it stays green):
582
+ 1. Table models inherit BaseTable; never redeclare id/created_at/updated_at/version.
583
+ 2. Services subclass BaseService; CRUD is inherited. Add read filters via
584
+ business_filters(); never override base_query (it would drop soft-delete/tenant scope).
585
+ 3. Every write goes through the service (create/update/delete, or self._save/_remove);
586
+ never call session.add/commit/execute yourself — the audit trail is automatic.
587
+ 4. Every module declares a ModuleSpec with a Policy (deny-by-default); a truly public
588
+ route opts in with Policy.public(reason="...").
589
+ 5. Routes set response_model to a Read DTO (never the table model); paginate lists (Page[T]).
590
+ 6. Cap every input string: Field(max_length=...).
591
+ 7. Import only the terp.core public surface + your declared capabilities — never
592
+ terp.core._internal, never a sibling module.
593
+
594
+ More: terp guide <topic> (topics: {_TOPIC_NAMES})
595
+ terp guide <rule> (the exact rule's remediation and related pattern)
596
+ terp guide rules (every architecture rule the gate enforces, generated)
597
+ terp inspect control-plane (your roles / permissions / module authority map)
598
+ terp inspect access (the full access graph: modules, endpoints, data traits)
599
+ terp check (run the full architecture gate locally)
600
+ """
601
+
602
+
603
+ def _clean_doc(text: str) -> str:
604
+ """Strip RST inline markup (``literals`` and ``:role:`targets```) for plain output."""
605
+ text = re.sub(r":[a-zA-Z]+:`~?(?:[\w.]+\.)?(\w+)`", r"\1", text)
606
+ return text.replace("``", "")
607
+
608
+
609
+ def _rule_headline(rule: Callable[..., object]) -> str:
610
+ """The first line of *rule*'s docstring (its one-line summary), RST-normalized."""
611
+ return _clean_doc(rule.__doc__.strip().splitlines()[0]).strip()
612
+
613
+
614
+ def _render_rules_topic() -> str:
615
+ """Generate the enforced-rules list from the live ``terp.arch`` registry.
616
+
617
+ Introspected from ``terp.arch.rules._ALL_RULES`` (each rule's name + its docstring
618
+ headline), so a newly added rule surfaces here automatically — there is no second,
619
+ hand-maintained rule list to drift (ADR 0030). The harness is imported lazily, so
620
+ plain ``terp guide`` / ``terp inspect`` need not load it.
621
+ """
622
+ from terp.arch.rules import _ALL_RULES
623
+
624
+ lines = [
625
+ "Architecture rules the gate enforces",
626
+ "",
627
+ "Generated from the live terp-arch registry, so this list is always complete and",
628
+ "current. Each rule is checked by `terp check` / `uv run pytest` and fails closed",
629
+ "with a precise, fixable message naming the file, line, and fix.",
630
+ "",
631
+ ]
632
+ for rule in sorted(_ALL_RULES, key=lambda item: item.__name__):
633
+ lines.append(f" - {rule.__name__.removeprefix('check_')}")
634
+ lines.append(f" {_rule_headline(rule)}")
635
+ return "\n".join(lines) + "\n"
636
+
637
+
638
+ def _render_rule_guide(rule_name: str) -> str:
639
+ """Render one rule's exact remediation followed by its broader authoring pattern."""
640
+ from terp.arch.rules import GUIDE_TOPIC_BY_RULE, _ALL_RULES
641
+
642
+ topic = GUIDE_TOPIC_BY_RULE[rule_name]
643
+ rules = {
644
+ rule.__name__.removeprefix("check_"): rule
645
+ for rule in _ALL_RULES
646
+ }
647
+ checker = rules.get(rule_name)
648
+ explanation = (
649
+ _clean_doc(checker.__doc__.strip()).strip()
650
+ if checker is not None and checker.__doc__
651
+ else rule_name
652
+ )
653
+ detail = _RULE_GUIDE_DETAILS.get(
654
+ rule_name,
655
+ "Apply the sanctioned construct in the related authoring pattern below at "
656
+ "the exact file and line from the finding. Preserve existing behavior and "
657
+ "rerun the failing check; do not add an opt-out merely to turn it green.",
658
+ )
659
+ return (
660
+ f"Rule: {rule_name}\n"
661
+ f"{explanation}\n\n"
662
+ f"{detail.rstrip()}\n\n"
663
+ f"Related authoring pattern ({topic})\n\n"
664
+ f"{guide(topic)}"
665
+ )
666
+
667
+
668
+ def guide(topic: str | None = None) -> str:
669
+ """Return the Terp authoring guide, or a focused recipe for *topic*.
670
+
671
+ The deterministic, in-terminal instruction surface for agents (and humans): an
672
+ agent can run ``terp guide`` without reading the installed package, learn the
673
+ canonical module shape + the golden rules the architecture gate enforces, then
674
+ ``terp guide <topic>`` for a copy-pasteable recipe. The ``rules`` topic is generated
675
+ from the live ``terp.arch`` registry (ADR 0030), so it never drifts.
676
+ """
677
+ if topic is None:
678
+ return _GUIDE_OVERVIEW
679
+ if topic == "rules":
680
+ return _render_rules_topic()
681
+ if topic in _GUIDE_TOPICS:
682
+ return _GUIDE_TOPICS[topic]
683
+ return _render_rule_guide(topic)
684
+
685
+
686
+ def check_report(
687
+ root: str = ".", *, package: str = "app", budget_path: str | None = None
688
+ ) -> dict[str, object]:
689
+ """The architecture gate as a structured report (the ``terp check --format json`` body).
690
+
691
+ Machine-readable so an agent (or the Studio) never has to parse a prose wall:
692
+ every violation carries its rule, file, line, message, the ``terp guide`` topic
693
+ that teaches the compliant pattern, and a copy-pasteable ``fix`` command. An
694
+ ungoverned ``# arch-allow-*`` marker (the condition ``assert_app_clean`` fails
695
+ closed on) is reported in-band as an ``ungoverned_escape_hatch`` violation.
696
+
697
+ ``rules`` is the evaluated-rule inventory: every rule id this run actually held
698
+ the app to. That is the live registry plus the escape-hatch governance half that
699
+ matches the execution mode: with a *budget_path* the budget ratchet ran (and an
700
+ unbudgeted marker is reported as its drift, subsuming the ungoverned condition);
701
+ without one only the ungoverned-marker condition ran — the ratchet is then left
702
+ OUT of the inventory, so a consumer joining verdicts to the Terp Standard catalog
703
+ can never claim ``escape_hatch_budget`` passed on a run that never enforced it
704
+ (fail closed under version skew and configuration alike).
705
+ """
706
+ from terp.arch import check_app, guide_topic_for, ungoverned_marker_violations
707
+ from terp.arch.rules import GUIDE_TOPIC_BY_RULE
708
+
709
+ violations = list(check_app(root, package=package, budget_path=budget_path))
710
+ if budget_path is None:
711
+ violations.extend(ungoverned_marker_violations(root, package=package))
712
+ violations.sort(key=lambda violation: (violation.path, violation.line, violation.rule))
713
+ rules = set(GUIDE_TOPIC_BY_RULE)
714
+ if budget_path is None:
715
+ rules.discard("escape_hatch_budget")
716
+ return {
717
+ "ok": not violations,
718
+ "rules": sorted(rules),
719
+ "violation_count": len(violations),
720
+ "violations": [
721
+ {
722
+ "rule": violation.rule,
723
+ # Separator-stable ('/') on every OS: the report is a machine
724
+ # contract consumed by agents and the Studio, not display text.
725
+ "path": violation.path.replace("\\", "/"),
726
+ "line": violation.line,
727
+ "message": violation.message,
728
+ "guide_topic": guide_topic_for(violation.rule),
729
+ "fix": f"terp guide {violation.rule}",
730
+ }
731
+ for violation in violations
732
+ ],
733
+ }
734
+
735
+
736
+ def check_report_envelope(
737
+ root: str = ".", *, package: str = "app", budget_path: str | None = None
738
+ ) -> dict[str, object]:
739
+ """The architecture gate as a Terp Standard **check report** (``terp check
740
+ --format check-report``).
741
+
742
+ The spec's ``app-check-report.schema.json`` shape: one self-describing document a
743
+ consumer joins to the catalog without knowing this toolchain — ``spec_version``
744
+ (the standard the rule ids resolve against), the checker identity, the run
745
+ verdict, the evaluated-rule inventory as **catalog ids** (``backend/<rule>``),
746
+ and findings in the finding format's shape (``fix_hint`` = the ``terp guide``
747
+ recipe). The legacy ``--format json`` report keeps its published shape for
748
+ existing consumers; this is the successor surface driving tools migrate to.
749
+ """
750
+ import importlib.metadata
751
+
752
+ from terp.arch import SPEC_VERSION
753
+
754
+ report = check_report(root, package=package, budget_path=budget_path)
755
+ try:
756
+ version = importlib.metadata.version("terp-arch")
757
+ except importlib.metadata.PackageNotFoundError: # a source checkout (the platform repo)
758
+ version = "0"
759
+ findings: list[dict[str, object]] = []
760
+ for violation in report["violations"]: # type: ignore[union-attr]
761
+ finding: dict[str, object] = {
762
+ "rule": f"backend/{violation['rule']}",
763
+ "path": violation["path"],
764
+ "message": violation["message"],
765
+ "fix_hint": violation["fix"],
766
+ }
767
+ # The spec's line is optional and 1-based ("when the checker can locate
768
+ # it") — a whole-tree condition (budget drift) carries line 0 internally.
769
+ if int(violation["line"]) >= 1:
770
+ finding["line"] = violation["line"]
771
+ findings.append(finding)
772
+ return {
773
+ "terp_check_report": 1,
774
+ "spec_version": SPEC_VERSION,
775
+ "checker": {"tool": "terp-arch", "version": version},
776
+ "ok": report["ok"],
777
+ "rules": [f"backend/{rule}" for rule in report["rules"]], # type: ignore[union-attr]
778
+ "findings": findings,
779
+ "unattributed": [],
780
+ }
781
+
782
+
783
+ def _mermaid_id(prefix: str, name: str) -> str:
784
+ """A Mermaid-safe node id (``prefix_`` + non-alphanumerics collapsed to ``_``)."""
785
+ return f"{prefix}_{re.sub(r'[^0-9A-Za-z_]', '_', name)}"
786
+
787
+
788
+
789
+ def _load_control_plane(dotted: str) -> ControlPlane:
790
+ module_name, _, attr = dotted.partition(":")
791
+ module = importlib.import_module(module_name)
792
+ candidate = getattr(module, attr or "control_plane")
793
+ if not isinstance(candidate, ControlPlane):
794
+ raise SystemExit(
795
+ f"{dotted!r} did not resolve to a terp.core.ControlPlane instance"
796
+ )
797
+ return candidate
798
+
799
+
800
+ def _load_module_spec(dotted: str) -> ModuleSpec:
801
+ module_name, _, attr = dotted.partition(":")
802
+ module = importlib.import_module(module_name)
803
+ candidate = getattr(module, attr or "module")
804
+ if not isinstance(candidate, ModuleSpec):
805
+ raise SystemExit(f"{dotted!r} did not resolve to a terp.core.ModuleSpec instance")
806
+ return candidate
807
+
808
+
809
+ def inspect_access(
810
+ dotted: str = "control_plane:control_plane",
811
+ *,
812
+ modules: Sequence[str] = (),
813
+ app: str | None = None,
814
+ app_root: str = ".",
815
+ fmt: str = "text",
816
+ ) -> str:
817
+ """Return the access graph (text or json).
818
+
819
+ With ``app`` (a FastAPI instance or zero-arg factory, e.g. ``app.main:build``) the
820
+ graph covers the WHOLE composed surface — every discovered capability router and the
821
+ kernel routes — reconciled against ``app.openapi()`` so no mounted route can hide.
822
+ Without it, the focused form reports just the hand-passed ``modules``.
823
+
824
+ The three-layer view — module policy, per-endpoint requirement, and the data
825
+ layer's row-visibility / write-authority traits — is JSON-first for Studio
826
+ (``terp inspect access --app app.main:build --format json``).
827
+ """
828
+ if app is not None:
829
+ root = str(pathlib.Path(app_root).resolve())
830
+ if root not in sys.path:
831
+ sys.path.insert(0, root)
832
+ return render_access_graph(build_access_graph_for_app(_load_app(app)), fmt)
833
+ plane = _load_control_plane(dotted)
834
+ specs = [_load_module_spec(module) for module in modules]
835
+ return render_access(plane, specs, fmt=fmt)
836
+
837
+
838
+ def inspect_schema(
839
+ *,
840
+ app_root: str = ".",
841
+ package: str = "app",
842
+ fmt: str = "text",
843
+ ) -> str:
844
+ """Return the schema graph for the app at *app_root* (text or json).
845
+
846
+ Loads every declared migration tree's models module (exactly how ``terp
847
+ migrate`` discovers models), projects the shared metadata as attributed
848
+ tables + kernel traits, and reconciles it against an AST source scan so a
849
+ model can never be silently skipped: unowned / non-canonical / unmapped /
850
+ unimported entries are alarmed, never dropped (JSON-first for Studio).
851
+ """
852
+ root = str(pathlib.Path(app_root).resolve())
853
+ if root not in sys.path:
854
+ sys.path.insert(0, root)
855
+ # Migration-tree discovery expects the app PACKAGE directory (it scans
856
+ # <package>/modules/<name>), mirroring how `terp migrate` is invoked.
857
+ package_dir = pathlib.Path(app_root) / package
858
+ trees = import_declared_models(
859
+ package_dir if package_dir.is_dir() else None, package=package
860
+ )
861
+ graph = build_schema_graph(
862
+ trees, source_models=scan_declared_table_models(app_root, package=package)
863
+ )
864
+ return render_schema_graph(graph, fmt)
865
+
866
+
867
+ def inspect_control_plane(
868
+ dotted: str = "control_plane:control_plane",
869
+ *,
870
+ modules: Sequence[str] = (),
871
+ fmt: str = "text",
872
+ ) -> str:
873
+ """Return an authority map for *dotted* control plane (text or mermaid)."""
874
+ plane = _load_control_plane(dotted)
875
+ specs = [_load_module_spec(module) for module in modules]
876
+ if fmt == "mermaid":
877
+ return _render_mermaid(plane, specs)
878
+ if fmt == "json":
879
+ return _render_json(plane, specs)
880
+ return _render_text(plane, specs)
881
+
882
+
883
+ def _render_json(plane: ControlPlane, specs: Sequence[ModuleSpec]) -> str:
884
+ """Render the authority map as JSON — the structured introspection seam for
885
+ external tooling (e.g. Terp Studio) that must not import ``terp.*``."""
886
+ payload = {
887
+ "roles": [
888
+ {"name": role.name, "rank": role.rank}
889
+ for role in sorted(plane.permissions.roles, key=lambda item: item.rank)
890
+ ],
891
+ "permissions": [
892
+ {"name": permission.name, "min_role": permission.min_role.name}
893
+ for permission in sorted(
894
+ plane.permissions.permissions, key=lambda item: item.name
895
+ )
896
+ ],
897
+ "modules": [_module_json(spec) for spec in sorted(specs, key=lambda item: item.name)],
898
+ "events": [
899
+ {
900
+ "name": event.name,
901
+ "visibility": event.visibility.value,
902
+ "payload_schema": event.payload_schema.__name__,
903
+ }
904
+ for event in sorted(plane.events.events, key=lambda item: item.name)
905
+ ],
906
+ "jobs": [
907
+ {
908
+ "name": job.name,
909
+ "queue": job.queue,
910
+ "visibility": job.visibility.value,
911
+ "max_attempts": job.retry.max_attempts,
912
+ }
913
+ for job in sorted(plane.jobs.jobs, key=lambda item: item.name)
914
+ ],
915
+ # Platform policies: the rest of the ControlPlane aggregate. The redact
916
+ # keys are substring markers (never secret values) and the denylist is
917
+ # summarised as a count (its entries are noise, not policy shape).
918
+ "audit": {
919
+ "enabled": plane.audit.enabled,
920
+ "disabled_reason": plane.audit.disabled_reason,
921
+ "retention_days": plane.audit.retention_days,
922
+ "redact_keys": list(plane.audit.redact_keys),
923
+ },
924
+ "passwords": {
925
+ "min_length": plane.passwords.min_length,
926
+ "min_character_classes": plane.passwords.min_character_classes,
927
+ "denylist_size": len(plane.passwords.denylist),
928
+ "relaxed_reason": plane.passwords.relaxed_reason,
929
+ },
930
+ "security": {
931
+ "cors": _cors_json(plane.security.cors),
932
+ "rate_limit": {
933
+ "enabled": plane.security.rate_limit.enabled,
934
+ "requests": plane.security.rate_limit.requests,
935
+ "window_seconds": plane.security.rate_limit.window_seconds,
936
+ },
937
+ "max_request_bytes": plane.security.max_request_bytes,
938
+ "trusted_proxy_hops": plane.security.trusted_proxy_hops,
939
+ "request_id_header": plane.security.request_id_header,
940
+ },
941
+ "schedules": [
942
+ {"name": schedule.name, "cron": schedule.cron, "job": schedule.job.name}
943
+ for schedule in sorted(
944
+ plane.schedules.schedules, key=lambda item: item.name
945
+ )
946
+ ],
947
+ "job_system_actor": plane.job_system_actor_id is not None,
948
+ }
949
+ return json.dumps(payload, indent=2)
950
+
951
+
952
+ def _cors_json(cors: CorsPolicy) -> dict[str, object]:
953
+ """The CORS declaration as one of three explicit modes (never raw fields)."""
954
+ if cors.disabled_reason is not None:
955
+ return {"mode": "disabled", "reason": cors.disabled_reason}
956
+ if cors.allow_origins:
957
+ return {
958
+ "mode": "allow",
959
+ "origins": list(cors.allow_origins),
960
+ "allow_credentials": cors.allow_credentials,
961
+ }
962
+ return {"mode": "deny-all", "configured": cors.configured}
963
+
964
+
965
+ def _module_json(spec: ModuleSpec) -> dict[str, object]:
966
+ policy: dict[str, object] | None = None
967
+ if spec.policy is not None:
968
+ if spec.policy.is_public:
969
+ policy = {"public": True, "public_reason": spec.policy.public_reason}
970
+ else:
971
+ policy = {
972
+ "public": False,
973
+ "read": spec.policy.read_requirement.label,
974
+ "write": spec.policy.write_requirement.label,
975
+ }
976
+ return {
977
+ "name": spec.name,
978
+ "policy": policy,
979
+ "emits": [event.name for event in spec.emits],
980
+ "subscribes": [event.name for event in spec.subscribes],
981
+ "jobs": [job.name for job in spec.jobs],
982
+ }
983
+
984
+
985
+ def _render_text(plane: ControlPlane, specs: Sequence[ModuleSpec]) -> str:
986
+ lines = ["Roles"]
987
+ for role in sorted(plane.permissions.roles, key=lambda item: item.rank):
988
+ lines.append(f" {role.name} ({role.rank})")
989
+ lines.append("")
990
+ lines.append("Permissions")
991
+ if not plane.permissions.permissions:
992
+ lines.append(" <none declared>")
993
+ for permission in sorted(plane.permissions.permissions, key=lambda item: item.name):
994
+ lines.append(f" {permission.name} {permission.min_role.name}+")
995
+ lines.append("")
996
+ lines.append("Modules")
997
+ if not specs:
998
+ lines.append(" <none provided>")
999
+ for spec in sorted(specs, key=lambda item: item.name):
1000
+ lines.append(f" {spec.name} {_policy_label(spec)}")
1001
+ lines.append("")
1002
+ lines.append("Audit")
1003
+ if plane.audit.enabled:
1004
+ retention = (
1005
+ f"{plane.audit.retention_days} days"
1006
+ if plane.audit.retention_days is not None
1007
+ else "unlimited"
1008
+ )
1009
+ lines.append(
1010
+ f" enabled retention={retention} "
1011
+ f"redact_keys={len(plane.audit.redact_keys)}"
1012
+ )
1013
+ else:
1014
+ lines.append(f" DISABLED ({plane.audit.disabled_reason})")
1015
+ lines.append("")
1016
+ lines.append("Passwords")
1017
+ password_line = (
1018
+ f" min_length={plane.passwords.min_length} "
1019
+ f"min_character_classes={plane.passwords.min_character_classes} "
1020
+ f"denylist={len(plane.passwords.denylist)} entries"
1021
+ )
1022
+ if plane.passwords.relaxed_reason is not None:
1023
+ password_line += f" RELAXED ({plane.passwords.relaxed_reason})"
1024
+ lines.append(password_line)
1025
+ lines.append("")
1026
+ lines.append("Security")
1027
+ lines.append(f" cors {_cors_label(plane.security.cors)}")
1028
+ rate_limit = plane.security.rate_limit
1029
+ lines.append(
1030
+ f" rate_limit "
1031
+ + (
1032
+ f"{rate_limit.requests}/{rate_limit.window_seconds}s"
1033
+ if rate_limit.enabled
1034
+ else "DISABLED"
1035
+ )
1036
+ )
1037
+ lines.append(
1038
+ f" max_request_bytes={plane.security.max_request_bytes} "
1039
+ f"trusted_proxy_hops={plane.security.trusted_proxy_hops}"
1040
+ )
1041
+ lines.append("")
1042
+ lines.append("Schedules")
1043
+ if not plane.schedules.schedules:
1044
+ lines.append(" <none declared>")
1045
+ for schedule in sorted(plane.schedules.schedules, key=lambda item: item.name):
1046
+ lines.append(f" {schedule.name} {schedule.cron} -> {schedule.job.name}")
1047
+ return "\n".join(lines)
1048
+
1049
+
1050
+ def _cors_label(cors: CorsPolicy) -> str:
1051
+ if cors.disabled_reason is not None:
1052
+ return f"disabled ({cors.disabled_reason})"
1053
+ if cors.allow_origins:
1054
+ return "allow " + ", ".join(cors.allow_origins)
1055
+ return "deny-all" + ("" if cors.configured else " (unconfigured)")
1056
+
1057
+
1058
+ def _policy_label(spec: ModuleSpec) -> str:
1059
+ if spec.policy is None:
1060
+ return "policy=<missing>"
1061
+ if spec.policy.is_public:
1062
+ return f"public ({spec.policy.public_reason})"
1063
+ return (
1064
+ f"read={spec.policy.read_requirement.label} "
1065
+ f"write={spec.policy.write_requirement.label}"
1066
+ )
1067
+
1068
+
1069
+ def _render_mermaid(plane: ControlPlane, specs: Sequence[ModuleSpec]) -> str:
1070
+ """Render the authority map as a Mermaid ``flowchart`` for visualization.
1071
+
1072
+ Node ids are sanitized to ``[0-9A-Za-z_]`` and every label is quoted, so
1073
+ permission names containing ``.`` / ``:`` (e.g. ``billing.read``) stay valid
1074
+ Mermaid rather than breaking the diagram.
1075
+ """
1076
+ lines = ["flowchart LR"]
1077
+ ladder = sorted(plane.permissions.roles, key=lambda item: item.rank)
1078
+ lines.append(" subgraph Roles")
1079
+ for lower, higher in zip(ladder, ladder[1:]):
1080
+ lines.append(
1081
+ f' {_mermaid_id("role", lower.name)}["{lower.name}"]'
1082
+ f' --> {_mermaid_id("role", higher.name)}["{higher.name}"]'
1083
+ )
1084
+ if len(ladder) == 1:
1085
+ only = ladder[0]
1086
+ lines.append(f' {_mermaid_id("role", only.name)}["{only.name}"]')
1087
+ lines.append(" end")
1088
+ for spec in sorted(specs, key=lambda item: item.name):
1089
+ module_id = _mermaid_id("module", spec.name)
1090
+ lines.append(f' {module_id}(["{spec.name}"])')
1091
+ if spec.policy is not None and not spec.policy.is_public:
1092
+ for verb, requirement in (
1093
+ ("read", spec.policy.read_requirement),
1094
+ ("write", spec.policy.write_requirement),
1095
+ ):
1096
+ authz_id = _mermaid_id("authz", requirement.label)
1097
+ lines.append(
1098
+ f' {module_id} -- "{verb}:{requirement.name}" '
1099
+ f'--> {authz_id}["{requirement.label}"]'
1100
+ )
1101
+ return "\n".join(lines)
1102
+
1103
+
1104
+ def _build_parser() -> argparse.ArgumentParser:
1105
+ parser = argparse.ArgumentParser(prog="terp")
1106
+ subcommands = parser.add_subparsers(dest="command", required=True)
1107
+
1108
+ inspect_parser = subcommands.add_parser("inspect")
1109
+ inspect_subcommands = inspect_parser.add_subparsers(
1110
+ dest="inspect_command",
1111
+ required=True,
1112
+ )
1113
+ control_plane_parser = inspect_subcommands.add_parser("control-plane")
1114
+ control_plane_parser.add_argument(
1115
+ "--object",
1116
+ default="control_plane:control_plane",
1117
+ help="Dotted object to inspect (default: control_plane:control_plane)",
1118
+ )
1119
+ control_plane_parser.add_argument(
1120
+ "--module",
1121
+ action="append",
1122
+ default=[],
1123
+ help="Dotted ModuleSpec to include (may be repeated)",
1124
+ )
1125
+ control_plane_parser.add_argument(
1126
+ "--format",
1127
+ choices=("text", "mermaid", "json"),
1128
+ default="text",
1129
+ help="Output format (default: text)",
1130
+ )
1131
+ jobs_inspect_parser = inspect_subcommands.add_parser("jobs")
1132
+ jobs_inspect_parser.add_argument(
1133
+ "--object",
1134
+ default="control_plane:control_plane",
1135
+ help="Dotted ControlPlane to inspect (default: control_plane:control_plane)",
1136
+ )
1137
+ access_parser = inspect_subcommands.add_parser(
1138
+ "access",
1139
+ help="The access graph: module policies, per-endpoint requirements, data traits",
1140
+ )
1141
+ access_parser.add_argument(
1142
+ "--object",
1143
+ default="control_plane:control_plane",
1144
+ help="Dotted object to inspect (default: control_plane:control_plane)",
1145
+ )
1146
+ access_parser.add_argument(
1147
+ "--module",
1148
+ action="append",
1149
+ default=[],
1150
+ help="Dotted ModuleSpec to include (may be repeated)",
1151
+ )
1152
+ access_parser.add_argument(
1153
+ "--app",
1154
+ default=None,
1155
+ help="Composed FastAPI app or factory (e.g. app.main:build): report the WHOLE "
1156
+ "mounted surface incl. discovered capabilities, reconciled against app.openapi()",
1157
+ )
1158
+ access_parser.add_argument(
1159
+ "--app-root",
1160
+ default=".",
1161
+ help="Directory placed first on sys.path so --app imports (default: .)",
1162
+ )
1163
+ access_parser.add_argument(
1164
+ "--format",
1165
+ choices=("text", "json"),
1166
+ default="text",
1167
+ help="Output format: text (human) or json (structured, for Studio; default: text)",
1168
+ )
1169
+ schema_parser = inspect_subcommands.add_parser(
1170
+ "schema",
1171
+ help="The schema graph: every table with ownership, traits, and fail-visible "
1172
+ "alarms for models the framework cannot account for",
1173
+ )
1174
+ schema_parser.add_argument(
1175
+ "--app-root",
1176
+ default=".",
1177
+ help="Project root put first on sys.path; its modules' migration trees load "
1178
+ "(default: .)",
1179
+ )
1180
+ schema_parser.add_argument(
1181
+ "--package",
1182
+ default="app",
1183
+ help="The app package owning app/modules/<name> trees (default: app)",
1184
+ )
1185
+ schema_parser.add_argument(
1186
+ "--format",
1187
+ choices=("text", "json"),
1188
+ default="text",
1189
+ help="Output format: text (human) or json (structured, for Studio; default: text)",
1190
+ )
1191
+
1192
+ guide_parser = subcommands.add_parser(
1193
+ "guide", help="Print the Terp authoring guide (or a recipe for a topic)"
1194
+ )
1195
+ guide_parser.add_argument(
1196
+ "topic",
1197
+ nargs="?",
1198
+ default=None,
1199
+ help="Optional topic or exact architecture rule for a focused recipe "
1200
+ "(validated on dispatch, so the rule registry stays off the common CLI path)",
1201
+ )
1202
+
1203
+ migrate_parser = subcommands.add_parser(
1204
+ "migrate",
1205
+ help="Run database migrations (upgrade / downgrade / make / status / check)",
1206
+ )
1207
+ migrate_parser.add_argument(
1208
+ "migrate_args",
1209
+ nargs=argparse.REMAINDER,
1210
+ help="Arguments forwarded to the migration runner (e.g. upgrade --database-url ...)",
1211
+ )
1212
+
1213
+ jobs_parser = subcommands.add_parser(
1214
+ "jobs", help="Run a background job or list the declared jobs (ADR 0043)"
1215
+ )
1216
+ jobs_subcommands = jobs_parser.add_subparsers(dest="jobs_command", required=True)
1217
+ jobs_run_parser = jobs_subcommands.add_parser(
1218
+ "run", help="Enqueue/run one job by name (the external-scheduler trigger)"
1219
+ )
1220
+ jobs_run_parser.add_argument("name", help="Job name (e.g. sync.customers.pull)")
1221
+ jobs_run_parser.add_argument(
1222
+ "--payload", default="{}", help="JSON payload for the job (default: {})"
1223
+ )
1224
+ jobs_run_parser.add_argument(
1225
+ "--app",
1226
+ default="app.main:app",
1227
+ help="Dotted module:attribute of the FastAPI app or factory (default: app.main:app)",
1228
+ )
1229
+ jobs_run_parser.add_argument(
1230
+ "--app-root", default=".", help="App root placed first on sys.path (default: .)"
1231
+ )
1232
+ jobs_list_parser = jobs_subcommands.add_parser(
1233
+ "list", help="List the jobs the control plane declares"
1234
+ )
1235
+ jobs_list_parser.add_argument(
1236
+ "--object",
1237
+ default="control_plane:control_plane",
1238
+ help="Dotted ControlPlane to read (default: control_plane:control_plane)",
1239
+ )
1240
+ jobs_worker_parser = jobs_subcommands.add_parser(
1241
+ "worker", help="Drain the durable outbox: run due jobs/events, retry, dead-letter (ADR 0044)"
1242
+ )
1243
+ jobs_worker_parser.add_argument(
1244
+ "--app",
1245
+ default="app.main:app",
1246
+ help="Dotted module:attribute of the FastAPI app or factory (default: app.main:app)",
1247
+ )
1248
+ jobs_worker_parser.add_argument(
1249
+ "--app-root", default=".", help="App root placed first on sys.path (default: .)"
1250
+ )
1251
+ jobs_worker_parser.add_argument(
1252
+ "--max-cycles",
1253
+ type=int,
1254
+ default=None,
1255
+ help="Drain at most this many batches, else until the outbox is empty (default: until empty)",
1256
+ )
1257
+ jobs_worker_parser.add_argument(
1258
+ "--batch-size", type=int, default=10, help="Rows leased per claim (default: 10)"
1259
+ )
1260
+ jobs_worker_parser.add_argument(
1261
+ "--lease-seconds",
1262
+ type=float,
1263
+ default=30.0,
1264
+ help="Lease duration before a stalled row may be reclaimed (default: 30)",
1265
+ )
1266
+
1267
+ jobs_scheduler_parser = jobs_subcommands.add_parser(
1268
+ "scheduler",
1269
+ help="Run the in-process scheduler: fire declared schedules on their cron (ADR 0047/0048)",
1270
+ )
1271
+ jobs_scheduler_parser.add_argument(
1272
+ "--app",
1273
+ default="app.main:app",
1274
+ help="Dotted module:attribute of the FastAPI app or factory (default: app.main:app)",
1275
+ )
1276
+ jobs_scheduler_parser.add_argument(
1277
+ "--app-root", default=".", help="App root placed first on sys.path (default: .)"
1278
+ )
1279
+
1280
+ new_parser = subcommands.add_parser("new", help="Scaffold a canonical module")
1281
+ new_subcommands = new_parser.add_subparsers(dest="new_command", required=True)
1282
+ module_parser = new_subcommands.add_parser(
1283
+ "module", help="Scaffold a full-stack module (backend slots + frontend slot)"
1284
+ )
1285
+ module_parser.add_argument("name", help="Module name (lowercase identifier, e.g. invoices)")
1286
+ module_parser.add_argument("--root", default=".", help="App root to scaffold into (default: .)")
1287
+ module_parser.add_argument("--package", default="app", help="Module package root (default: app)")
1288
+ module_parser.add_argument(
1289
+ "--no-frontend",
1290
+ action="store_true",
1291
+ help="Skip the frontend slot even when a frontend app is present",
1292
+ )
1293
+ module_parser.add_argument(
1294
+ "--profile",
1295
+ default=DEFAULT_PROFILE,
1296
+ choices=profile_names(),
1297
+ help="Permission profile the slots compile to (default: %(default)s; "
1298
+ "see 'terp guide access')",
1299
+ )
1300
+
1301
+ apidocs_parser = subcommands.add_parser(
1302
+ "api-docs", help="Generate the public-API reference + .pyi from the live kernel"
1303
+ )
1304
+ apidocs_parser.add_argument("--out", default="docs", help="Output directory (default: docs)")
1305
+
1306
+ openapi_parser = subcommands.add_parser(
1307
+ "openapi", help="Export the app's OpenAPI document (the frontend-contract source)"
1308
+ )
1309
+ openapi_parser.add_argument(
1310
+ "--app",
1311
+ default="app.main:app",
1312
+ help="Dotted module:attribute of the FastAPI app or factory (default: app.main:app)",
1313
+ )
1314
+ openapi_parser.add_argument(
1315
+ "--out", default="openapi.json", help="Output file (default: openapi.json)"
1316
+ )
1317
+ openapi_parser.add_argument(
1318
+ "--app-root", default=".", help="App root placed first on sys.path (default: .)"
1319
+ )
1320
+
1321
+ dev_parser = subcommands.add_parser(
1322
+ "dev",
1323
+ help="Run the backend + frontend dev servers together (with an OpenAPI preflight)",
1324
+ )
1325
+ dev_parser.add_argument(
1326
+ "--app",
1327
+ default="app.main:app",
1328
+ help="Dotted module:attribute of the FastAPI app (default: app.main:app)",
1329
+ )
1330
+ dev_parser.add_argument(
1331
+ "--app-root", default=".", help="Project root placed first on sys.path (default: .)"
1332
+ )
1333
+ dev_parser.add_argument(
1334
+ "--frontend-dir", default="frontend", help="Frontend app directory (default: frontend)"
1335
+ )
1336
+ dev_parser.add_argument(
1337
+ "--host", default="127.0.0.1", help="Backend host (default: 127.0.0.1)"
1338
+ )
1339
+ dev_parser.add_argument(
1340
+ "--port", type=int, default=8000, help="Backend port (default: 8000)"
1341
+ )
1342
+ dev_parser.add_argument(
1343
+ "--openapi-out",
1344
+ default="openapi.json",
1345
+ help="Preflight OpenAPI output path, relative to root (default: openapi.json)",
1346
+ )
1347
+ dev_parser.add_argument(
1348
+ "--no-preflight", action="store_true", help="Skip the OpenAPI preflight export"
1349
+ )
1350
+
1351
+ check_parser = subcommands.add_parser("check", help="Run the architecture gate locally")
1352
+ check_parser.add_argument("--root", default=".", help="App root (default: .)")
1353
+ check_parser.add_argument("--package", default="app", help="App package (default: app)")
1354
+ check_parser.add_argument(
1355
+ "--budget", default=None, help="Escape-hatch budget JSON (governs # arch-allow markers)"
1356
+ )
1357
+ check_parser.add_argument(
1358
+ "--format",
1359
+ choices=("text", "json", "check-report"),
1360
+ default="text",
1361
+ help="Output format: text (human), json (the legacy structured report), or "
1362
+ "check-report (the Terp Standard app-check-report envelope; default: text)",
1363
+ )
1364
+
1365
+ verify_parser = subcommands.add_parser(
1366
+ "verify",
1367
+ help="Run the project's whole verification profile (the one-command gate)",
1368
+ )
1369
+ verify_parser.add_argument(
1370
+ "--profile",
1371
+ choices=profile_ids(),
1372
+ default="quick",
1373
+ help="Which checks run: quick (static enforcement), full (+ tests, AppSec "
1374
+ "baseline, build), release (+ docs drift, conformance; default: quick)",
1375
+ )
1376
+ verify_parser.add_argument("--root", default=".", help="Project root (default: .)")
1377
+ verify_parser.add_argument(
1378
+ "--only",
1379
+ action="append",
1380
+ default=[],
1381
+ metavar="CHECK",
1382
+ help="Run only the named check(s) of the profile (repeatable) — the seam a "
1383
+ "driving tool uses for change-scoped reruns",
1384
+ )
1385
+ verify_parser.add_argument(
1386
+ "--list",
1387
+ action="store_true",
1388
+ help="Print the profile's check manifest without running anything",
1389
+ )
1390
+ verify_parser.add_argument(
1391
+ "--format",
1392
+ choices=("text", "json", "assurance"),
1393
+ default="text",
1394
+ help="Output format: text (human), json (the terp_verify envelope), or "
1395
+ "assurance (the release-assurance claim, assurance-profile.schema.json; "
1396
+ "requires --profile release; default: text)",
1397
+ )
1398
+
1399
+ user_parser = subcommands.add_parser(
1400
+ "user", help="Manage users (e.g. bootstrap the first administrator)"
1401
+ )
1402
+ user_subcommands = user_parser.add_subparsers(dest="user_command", required=True)
1403
+ user_create_parser = user_subcommands.add_parser(
1404
+ "create", help="Create (or confirm) a user directly against the app's store"
1405
+ )
1406
+ user_create_parser.add_argument("email", help="The user's email address")
1407
+ user_create_parser.add_argument(
1408
+ "--role",
1409
+ default="admin",
1410
+ help="viewer / editor / admin or an integer rank (default: admin)",
1411
+ )
1412
+ user_create_parser.add_argument(
1413
+ "--app",
1414
+ default="app.main:app",
1415
+ help="Dotted module:attribute of the FastAPI app (default: app.main:app)",
1416
+ )
1417
+ user_create_parser.add_argument(
1418
+ "--app-root", default=".", help="App root placed first on sys.path (default: .)"
1419
+ )
1420
+ user_create_parser.add_argument(
1421
+ "--password-env",
1422
+ default="TERP_USER_PASSWORD",
1423
+ help="Env var holding the new password; prompts if unset (default: TERP_USER_PASSWORD)",
1424
+ )
1425
+
1426
+ seed_parser = subcommands.add_parser(
1427
+ "seed", help="Run the app's seed routine (idempotent demo / bootstrap data; dev only)"
1428
+ )
1429
+ seed_parser.add_argument(
1430
+ "--app",
1431
+ default="app.main:app",
1432
+ help="Dotted module:attribute of the FastAPI app (default: app.main:app)",
1433
+ )
1434
+ seed_parser.add_argument(
1435
+ "--app-root", default=".", help="App root placed first on sys.path (default: .)"
1436
+ )
1437
+ seed_parser.add_argument(
1438
+ "--seed",
1439
+ default="app.seed:seed",
1440
+ help="Dotted module:attribute of the seed callable (default: app.seed:seed)",
1441
+ )
1442
+
1443
+ docker_parser = subcommands.add_parser(
1444
+ "docker", help="Docker workflows (the Compose dev workbench)"
1445
+ )
1446
+ docker_subcommands = docker_parser.add_subparsers(dest="docker_command", required=True)
1447
+ docker_dev_parser = docker_subcommands.add_parser(
1448
+ "dev", help="Run the full-stack workbench via `docker compose watch` (db + api + web)"
1449
+ )
1450
+ docker_dev_parser.add_argument(
1451
+ "--compose-file",
1452
+ default="docker-compose.yml",
1453
+ help="Compose file, resolved under --root (default: docker-compose.yml)",
1454
+ )
1455
+ docker_dev_parser.add_argument(
1456
+ "--root", default=".", help="Directory the compose file is resolved against (default: .)"
1457
+ )
1458
+ docker_dev_parser.add_argument(
1459
+ "--project-name", default=None, help="Compose project name (default: Compose's own)"
1460
+ )
1461
+ return parser
1462
+
1463
+
1464
+ def main(argv: Sequence[str] | None = None) -> None:
1465
+ """Console entry point."""
1466
+ parser = _build_parser()
1467
+ args = parser.parse_args(argv)
1468
+ if args.command == "inspect" and args.inspect_command == "control-plane":
1469
+ print(inspect_control_plane(args.object, modules=args.module, fmt=args.format))
1470
+ return
1471
+ if args.command == "inspect" and args.inspect_command == "jobs":
1472
+ print(render_jobs(args.object))
1473
+ return
1474
+ if args.command == "inspect" and args.inspect_command == "access":
1475
+ print(
1476
+ inspect_access(
1477
+ args.object,
1478
+ modules=args.module,
1479
+ app=args.app,
1480
+ app_root=args.app_root,
1481
+ fmt=args.format,
1482
+ )
1483
+ )
1484
+ return
1485
+ if args.command == "inspect" and args.inspect_command == "schema":
1486
+ print(inspect_schema(app_root=args.app_root, package=args.package, fmt=args.format))
1487
+ return
1488
+ if args.command == "guide":
1489
+ if args.topic is not None and args.topic not in guide_choices():
1490
+ raise SystemExit(
1491
+ f"terp guide: unknown topic or rule {args.topic!r}; run `terp guide` "
1492
+ "for the topic list or `terp guide rules` for every rule name"
1493
+ )
1494
+ print(guide(args.topic))
1495
+ return
1496
+ if args.command == "migrate":
1497
+ from terp.migrations import migrate_main
1498
+
1499
+ migrate_main(args.migrate_args)
1500
+ return
1501
+ if args.command == "jobs" and args.jobs_command == "run":
1502
+ print(
1503
+ run_job_command(
1504
+ args.name, payload=args.payload, app_ref=args.app, app_root=args.app_root
1505
+ )
1506
+ )
1507
+ return
1508
+ if args.command == "jobs" and args.jobs_command == "list":
1509
+ print(render_jobs(args.object))
1510
+ return
1511
+ if args.command == "jobs" and args.jobs_command == "worker":
1512
+ print(
1513
+ run_worker_command(
1514
+ app_ref=args.app,
1515
+ app_root=args.app_root,
1516
+ max_cycles=args.max_cycles,
1517
+ batch_size=args.batch_size,
1518
+ lease_seconds=args.lease_seconds,
1519
+ )
1520
+ )
1521
+ return
1522
+ if args.command == "jobs" and args.jobs_command == "scheduler":
1523
+ print(run_scheduler_command(app_ref=args.app, app_root=args.app_root))
1524
+ return
1525
+ if args.command == "new" and args.new_command == "module":
1526
+ paths = new_module(
1527
+ args.name,
1528
+ root=args.root,
1529
+ package=args.package,
1530
+ frontend=not args.no_frontend,
1531
+ profile=args.profile,
1532
+ )
1533
+ print(new_module_message(args.name, paths, profile=args.profile))
1534
+ return
1535
+ if args.command == "api-docs":
1536
+ for path in api_docs(args.out):
1537
+ print(f"wrote {path}")
1538
+ return
1539
+ if args.command == "openapi":
1540
+ print(f"wrote {export_openapi(args.app, out=args.out, app_root=args.app_root)}")
1541
+ return
1542
+ if args.command == "dev":
1543
+ print(
1544
+ run_dev_command(
1545
+ app_ref=args.app,
1546
+ root=args.app_root,
1547
+ frontend_dir=args.frontend_dir,
1548
+ host=args.host,
1549
+ port=args.port,
1550
+ openapi_out=args.openapi_out,
1551
+ preflight=not args.no_preflight,
1552
+ )
1553
+ )
1554
+ return
1555
+ if args.command == "check":
1556
+ if args.format == "check-report":
1557
+ payload = check_report_envelope(
1558
+ args.root, package=args.package, budget_path=args.budget
1559
+ )
1560
+ print(json.dumps(payload, indent=2))
1561
+ if not payload["ok"]:
1562
+ raise SystemExit(1)
1563
+ return
1564
+ if args.format == "json":
1565
+ payload = check_report(args.root, package=args.package, budget_path=args.budget)
1566
+ print(json.dumps(payload, indent=2))
1567
+ if not payload["ok"]:
1568
+ raise SystemExit(1)
1569
+ return
1570
+ from terp.arch import assert_app_clean
1571
+
1572
+ assert_app_clean(args.root, package=args.package, budget_path=args.budget)
1573
+ print("terp.arch: app is clean")
1574
+ return
1575
+ if args.command == "verify":
1576
+ raise SystemExit(
1577
+ run_verify_command(
1578
+ profile=args.profile,
1579
+ root=args.root,
1580
+ only=args.only,
1581
+ list_only=args.list,
1582
+ fmt=args.format,
1583
+ )
1584
+ )
1585
+ if args.command == "user" and args.user_command == "create":
1586
+ print(
1587
+ create_user_command(
1588
+ args.email,
1589
+ role=args.role,
1590
+ app_ref=args.app,
1591
+ app_root=args.app_root,
1592
+ password_env=args.password_env,
1593
+ )
1594
+ )
1595
+ return
1596
+ if args.command == "seed":
1597
+ print(run_seed_command(app_ref=args.app, app_root=args.app_root, seed_ref=args.seed))
1598
+ return
1599
+ if args.command == "docker" and args.docker_command == "dev":
1600
+ print(
1601
+ run_docker_dev_command(
1602
+ compose_file=args.compose_file, root=args.root, project_name=args.project_name
1603
+ )
1604
+ )
1605
+ return
1606
+ parser.error("unknown command") # pragma: no cover - argparse guards this
1607
+
1608
+
1609
+ __all__ = [
1610
+ "api_docs",
1611
+ "check_report",
1612
+ "check_report_envelope",
1613
+ "create_user_command",
1614
+ "dev_plan",
1615
+ "export_openapi",
1616
+ "guide",
1617
+ "guide_topics",
1618
+ "guide_choices",
1619
+ "inspect_access",
1620
+ "inspect_control_plane",
1621
+ "main",
1622
+ "new_module",
1623
+ "profile_ids",
1624
+ "render_jobs",
1625
+ "run_dev_command",
1626
+ "run_docker_dev_command",
1627
+ "run_job_command",
1628
+ "run_scheduler_command",
1629
+ "run_seed_command",
1630
+ "run_verify_command",
1631
+ "run_worker_command",
1632
+ "verify_manifest",
1633
+ ]