terp-cli 0.20.0__tar.gz → 0.22.0__tar.gz

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.
Files changed (34) hide show
  1. {terp_cli-0.20.0 → terp_cli-0.22.0}/PKG-INFO +8 -8
  2. {terp_cli-0.20.0 → terp_cli-0.22.0}/pyproject.toml +8 -8
  3. {terp_cli-0.20.0 → terp_cli-0.22.0}/src/terp/cli/__init__.py +135 -2
  4. {terp_cli-0.20.0 → terp_cli-0.22.0}/src/terp/cli/verify.py +255 -3
  5. {terp_cli-0.20.0 → terp_cli-0.22.0}/src/terp/cli/version.py +131 -27
  6. {terp_cli-0.20.0 → terp_cli-0.22.0}/.gitignore +0 -0
  7. {terp_cli-0.20.0 → terp_cli-0.22.0}/src/terp/cli/_appref.py +0 -0
  8. {terp_cli-0.20.0 → terp_cli-0.22.0}/src/terp/cli/_subjects.py +0 -0
  9. {terp_cli-0.20.0 → terp_cli-0.22.0}/src/terp/cli/access.py +0 -0
  10. {terp_cli-0.20.0 → terp_cli-0.22.0}/src/terp/cli/apidocs.py +0 -0
  11. {terp_cli-0.20.0 → terp_cli-0.22.0}/src/terp/cli/capabilities.py +0 -0
  12. {terp_cli-0.20.0 → terp_cli-0.22.0}/src/terp/cli/deploy_safety.py +0 -0
  13. {terp_cli-0.20.0 → terp_cli-0.22.0}/src/terp/cli/dev.py +0 -0
  14. {terp_cli-0.20.0 → terp_cli-0.22.0}/src/terp/cli/docker.py +0 -0
  15. {terp_cli-0.20.0 → terp_cli-0.22.0}/src/terp/cli/envfile.py +0 -0
  16. {terp_cli-0.20.0 → terp_cli-0.22.0}/src/terp/cli/envschema.py +0 -0
  17. {terp_cli-0.20.0 → terp_cli-0.22.0}/src/terp/cli/envseams.py +0 -0
  18. {terp_cli-0.20.0 → terp_cli-0.22.0}/src/terp/cli/fmt.py +0 -0
  19. {terp_cli-0.20.0 → terp_cli-0.22.0}/src/terp/cli/grants.py +0 -0
  20. {terp_cli-0.20.0 → terp_cli-0.22.0}/src/terp/cli/jobs.py +0 -0
  21. {terp_cli-0.20.0 → terp_cli-0.22.0}/src/terp/cli/leases.py +0 -0
  22. {terp_cli-0.20.0 → terp_cli-0.22.0}/src/terp/cli/module_roles.py +0 -0
  23. {terp_cli-0.20.0 → terp_cli-0.22.0}/src/terp/cli/openapi.py +0 -0
  24. {terp_cli-0.20.0 → terp_cli-0.22.0}/src/terp/cli/outbox.py +0 -0
  25. {terp_cli-0.20.0 → terp_cli-0.22.0}/src/terp/cli/profiles.py +0 -0
  26. {terp_cli-0.20.0 → terp_cli-0.22.0}/src/terp/cli/py.typed +0 -0
  27. {terp_cli-0.20.0 → terp_cli-0.22.0}/src/terp/cli/routes.py +0 -0
  28. {terp_cli-0.20.0 → terp_cli-0.22.0}/src/terp/cli/scaffold.py +0 -0
  29. {terp_cli-0.20.0 → terp_cli-0.22.0}/src/terp/cli/schema.py +0 -0
  30. {terp_cli-0.20.0 → terp_cli-0.22.0}/src/terp/cli/seed.py +0 -0
  31. {terp_cli-0.20.0 → terp_cli-0.22.0}/src/terp/cli/service_accounts.py +0 -0
  32. {terp_cli-0.20.0 → terp_cli-0.22.0}/src/terp/cli/smoke.py +0 -0
  33. {terp_cli-0.20.0 → terp_cli-0.22.0}/src/terp/cli/users.py +0 -0
  34. {terp_cli-0.20.0 → terp_cli-0.22.0}/src/terp/cli/workbench.py +0 -0
@@ -1,19 +1,19 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: terp-cli
3
- Version: 0.20.0
3
+ Version: 0.22.0
4
4
  Summary: Terp command-line tool — inspect, scaffolding, migrations, checks, api-docs.
5
5
  Project-URL: Repository, https://github.com/AITT-NL/terp-framework
6
6
  Project-URL: Changelog, https://github.com/AITT-NL/terp-framework/blob/main/CHANGELOG.md
7
7
  License-Expression: Apache-2.0
8
8
  Requires-Python: >=3.13
9
9
  Requires-Dist: pyyaml>=6.0
10
- Requires-Dist: terp-arch==0.20.0
11
- Requires-Dist: terp-core==0.20.0
12
- Requires-Dist: terp-migrations==0.20.0
10
+ Requires-Dist: terp-arch==0.22.0
11
+ Requires-Dist: terp-core==0.22.0
12
+ Requires-Dist: terp-migrations==0.22.0
13
13
  Provides-Extra: jobs
14
- Requires-Dist: terp-cap-outbox==0.20.0; extra == 'jobs'
15
- Requires-Dist: terp-cap-scheduler-apscheduler==0.20.0; extra == 'jobs'
14
+ Requires-Dist: terp-cap-outbox==0.22.0; extra == 'jobs'
15
+ Requires-Dist: terp-cap-scheduler-apscheduler==0.22.0; extra == 'jobs'
16
16
  Provides-Extra: scheduler
17
- Requires-Dist: terp-cap-scheduler-apscheduler==0.20.0; extra == 'scheduler'
17
+ Requires-Dist: terp-cap-scheduler-apscheduler==0.22.0; extra == 'scheduler'
18
18
  Provides-Extra: worker
19
- Requires-Dist: terp-cap-outbox==0.20.0; extra == 'worker'
19
+ Requires-Dist: terp-cap-outbox==0.22.0; extra == 'worker'
@@ -4,18 +4,18 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "terp-cli"
7
- version = "0.20.0"
7
+ version = "0.22.0"
8
8
  description = "Terp command-line tool — inspect, scaffolding, migrations, checks, api-docs."
9
9
  requires-python = ">=3.13"
10
10
  license = "Apache-2.0"
11
11
  dependencies = [
12
- "terp-core==0.20.0",
12
+ "terp-core==0.22.0",
13
13
  # `terp migrate` delegates to terp-migrations (lazily imported), keeping Alembic
14
14
  # off the path for `terp inspect` / `terp guide` while making migrations work.
15
- "terp-migrations==0.20.0",
15
+ "terp-migrations==0.22.0",
16
16
  # `terp guide rules` projects the live rule registry; terp-arch is imported lazily
17
17
  # (only when that topic is rendered), so it stays off the common `terp guide` path.
18
- "terp-arch==0.20.0",
18
+ "terp-arch==0.22.0",
19
19
  # `terp verify --only env-seams` and `terp smoke` read the compose profiles as data
20
20
  # (anchors and `<<:` merge keys resolved), so both answer without a Docker daemon or
21
21
  # even the `docker` binary. Declared explicitly rather than leant on: every app
@@ -29,11 +29,11 @@ dependencies = [
29
29
  # or a scheduler engine into apps that only use inspect/check/migrate. Deployments can select
30
30
  # one process role, while `jobs` is the convenient complete jobs-process bundle.
31
31
  [project.optional-dependencies]
32
- worker = ["terp-cap-outbox==0.20.0"]
33
- scheduler = ["terp-cap-scheduler-apscheduler==0.20.0"]
32
+ worker = ["terp-cap-outbox==0.22.0"]
33
+ scheduler = ["terp-cap-scheduler-apscheduler==0.22.0"]
34
34
  jobs = [
35
- "terp-cap-outbox==0.20.0",
36
- "terp-cap-scheduler-apscheduler==0.20.0",
35
+ "terp-cap-outbox==0.22.0",
36
+ "terp-cap-scheduler-apscheduler==0.22.0",
37
37
  ]
38
38
 
39
39
  [project.scripts]
@@ -559,6 +559,107 @@ Recoverable deletes (SoftDeleteMixin)
559
559
  your own would mean touching deleted_at, which the gate refuses. Today the trait
560
560
  preserves the row and the history that cites it; bringing one back is an operator
561
561
  action, not an app one.
562
+ """,
563
+ "references": """\
564
+ Stored references (Ref + OnDelete)
565
+
566
+ - A foreign key HAS a referential action whether or not you chose one, and the one
567
+ you get by not choosing (NO ACTION) looks exactly like the one you chose. So
568
+ declare it. on_delete is a required keyword, so the decision cannot be skipped:
569
+ from terp.core import BaseTable, OnDelete, Ref
570
+ class InvoiceLine(BaseTable, table=True):
571
+ invoice_id: uuid.UUID = Ref("invoice.id", on_delete=OnDelete.CASCADE)
572
+ - WHICH action is yours to pick; the platform does not prefer one, because the right
573
+ answer is a property of what the reference MEANS:
574
+ CASCADE this row is part of its parent (an invoice line, an address)
575
+ RESTRICT the parent must not vanish underneath this row (a ledger entry)
576
+ SET NULL a pointer allowed to go slack (an optional assignee) -- nullable
577
+ SET_DEFAULT point at the column default instead (that default must exist)
578
+ NO_ACTION the database takes none; something above it owns this lifecycle
579
+ - NO_ACTION is a real answer, not a cop-out, and it emits NO clause at all. Two
580
+ consequences worth knowing: adopting the declaration on an existing schema costs
581
+ no migration (the DDL is unchanged, only the source now says the silence was
582
+ chosen), and a database reports its default action as absent rather than as the
583
+ words NO ACTION -- which is why emitting the literal would make every
584
+ model-versus-database comparison report drift on that constraint forever.
585
+ - Ref() forwards everything else to Field(), and indexes by default: an unindexed
586
+ foreign key turns every parent delete and every join into a table scan.
587
+ owner_id: uuid.UUID | None = Ref("user.id", on_delete=OnDelete.SET_NULL,
588
+ default=None)
589
+ - SOFT-DELETE CHANGES THE ANSWER. A SoftDeleteMixin row is never DELETEd, only
590
+ stamped, so no action declared against it can fire at all: CASCADE never cascades,
591
+ SET DEFAULT never defaults, and SET NULL is the worst of them -- the children keep
592
+ a live, non-null pointer to a row the read scope now hides from every query, so the
593
+ reference reads as broken rather than absent. Those three are refused by the
594
+ references_declare_delete_behaviour rule. RESTRICT and NO_ACTION are accepted, and
595
+ the difference is not that they are passive: it is that they only ever described
596
+ the hard-delete path no request can reach, so they promise nothing that fails to
597
+ happen. Declare one of those and cascade the STAMP from the owning service, which
598
+ is the only layer that can see it.
599
+ - A service-owned cascade goes in _after_write, inside the same write unit, so every
600
+ removal is audited and a failure anywhere rolls the whole thing back. Drain in
601
+ batches until nothing is left: each pass flushes into the same transaction, so it
602
+ sees the previous pass's deletes, and no parent size is too large.
603
+ def _after_write(self, session, entity, action):
604
+ super()._after_write(session, entity, action)
605
+ if action is not AuditAction.DELETED:
606
+ return
607
+ while True:
608
+ lines, _total = self._lines.list(session, skip=0, limit=1_000,
609
+ filters={"invoice_id": entity.id})
610
+ if not lines:
611
+ break
612
+ for line in lines:
613
+ self._lines._remove(session, line)
614
+ (`invoice_id` has to be on the child service's `filterable` declaration for that
615
+ filter to be accepted -- see `terp guide service`.) The hook runs with raw session
616
+ writes re-forbidden, so `session.delete(...)` there fails closed on purpose -- `_save` / `_remove` re-open the scope for their own
617
+ audited write, and they are the only way through. Prefer this over a database
618
+ CASCADE whenever the removals must appear in the audit trail: the database deletes
619
+ silently and leaves the trail with a hole in it.
620
+ - AUDIT LIVE METADATA (a consumer test, or a boot check where the model set is known):
621
+ from terp.core import assert_references_declare_delete_behaviour
622
+ def test_references_declare_delete_behaviour() -> None:
623
+ import app.models # noqa: F401 -- populate the metadata
624
+ assert_references_declare_delete_behaviour()
625
+ It is deliberately not wired into create_app: SQLModel.metadata is process-global
626
+ and accumulates every table any test ever declared, so an unconditional walk at
627
+ boot would make one ad-hoc test model fail an unrelated later test (ADR 0133).
628
+ - WHAT THIS DOES NOT COVER: ON UPDATE. BaseTable mandates a UUID surrogate primary
629
+ key, so a key never changes and there is nothing for it to cascade.
630
+ """,
631
+ "append-only": """\
632
+ Rows that cannot change, and rows that cannot go (append_only)
633
+
634
+ - A ledger row, an immutable revision, a captured snapshot: the guarantee is real
635
+ only when it is STATED. Without this, immutability is achieved by not exposing an
636
+ update route, which is a guarantee made of missing code -- it holds until someone
637
+ adds a line, and no rule and no review step notices when they do.
638
+ class LedgerEntryService(BaseService[LedgerEntry, LedgerEntryCreate, Never]):
639
+ model = LedgerEntry
640
+ append_only = True
641
+ - The write chokepoint then refuses every non-CREATED write -- update(), delete(),
642
+ and any bespoke _save() -- with the uniform 409. There is nothing to remember at
643
+ the call sites, and no route can opt back in.
644
+ - It is a property of the SERVICE, so it covers the API, a worker, a job and a
645
+ fixture equally. It is not a database grant: a migration or a psql session can
646
+ still write the table. Pair it with the audit trail if you need to know that
647
+ nothing did.
648
+ - append_only is ALL OR NOTHING per table. "Editable until the invoice is posted"
649
+ and "set once, then fixed" are narrower questions with no built-in seam yet; they
650
+ live in the service's own update path today. Write the refusal as a typed
651
+ ConflictError so it reads like the built-in one:
652
+ def update(self, session, entity_id, data):
653
+ entity = self.get(session, entity_id)
654
+ if entity.posted_at is not None:
655
+ raise ConflictError("Een geboekte factuur kan niet meer wijzigen.")
656
+ return super().update(session, entity_id, data)
657
+ - The neighbouring guarantees, so you pick the right one:
658
+ append_only the ROW cannot change after insert
659
+ @read_only the ROUTE writes nothing, though its verb is unsafe
660
+ SoftDeleteMixin the row survives its own delete (see: soft-delete)
661
+ OnDelete.RESTRICT another row cannot be deleted while this one points at it
662
+ BaseUpdateSchema a concurrent writer cannot lose your edit (OCC, 409)
562
663
  """,
563
664
  "package-boundaries": """\
564
665
  Boundaries for a second top-level package (an ungated worker)
@@ -1070,6 +1171,14 @@ Idempotency (the Idempotency-Key header, terp.core.idempotency)
1070
1171
  CLIENT opts in per request by sending a header:
1071
1172
  Idempotency-Key: <client-generated unique key>
1072
1173
  A request without the header behaves exactly as it did before.
1174
+ - GENERATE THE KEY WITH randomUuid() from @terpjs/react-core, not crypto.randomUUID().
1175
+ The browser method exists only in a SECURE CONTEXT while lib.dom declares it
1176
+ unconditionally, so on an http origin -- which is what the compose files publish, so
1177
+ any deployment reached by hostname rather than localhost -- the call is a synchronous
1178
+ TypeError. It type-checks cleanly and it never fires on localhost, so neither tsc nor
1179
+ the test suite ever sees it; the retry-safety this whole feature exists for is what
1180
+ breaks, and only in the deployment. The seam falls back to crypto.getRandomValues,
1181
+ which is not secure-context-gated, for the same entropy.
1073
1182
  - The semantics, all typed envelopes:
1074
1183
  first call -> executes; the response is stored under the key
1075
1184
  retry, same body -> the stored response is replayed
@@ -1419,8 +1528,32 @@ Forms (react-core primitives)
1419
1528
  is the layout — never style={} / className / a module stylesheet.
1420
1529
  - Submit through the typed client: const client = useTerpClient();
1421
1530
  await unwrap(client.POST("/api/v1/invoices/", { body })); a failure throws ApiError
1422
- ({code, status, requestId}) — map codes to copy with useErrorMessage, show transient
1423
- success/failure with useToast(), and confirm destructive actions with ConfirmDialog.
1531
+ ({code, status, requestId, fields}) — map codes to copy with useErrorMessage, show
1532
+ transient success/failure with useToast(), and confirm destructive actions with
1533
+ ConfirmDialog.
1534
+ - WHERE `errors.number` ABOVE COMES FROM: ApiError.fields. The backend's typed envelope
1535
+ carries per-reason `loc` (AppError details, and FastAPI's own 422 entries, which share
1536
+ the shape on purpose), and `unwrap` turns those into `fields` — a plain object keyed by
1537
+ dotted field path, ready to hand straight to Field's `error` prop. That is the whole
1538
+ loop the error envelope opens: a rejected field lights up under its own input instead of
1539
+ degrading to a generic toast. `Object.keys(error.fields).length > 0` is the test for
1540
+ "this belongs on the form".
1541
+ const RENDERED = ["number", "status"];
1542
+ try {
1543
+ await unwrap(client.POST("/api/v1/invoices/", { body }));
1544
+ } catch (error) {
1545
+ const { shown, leftover } = routeFieldErrors(error, RENDERED);
1546
+ setErrors(shown);
1547
+ if (leftover || Object.keys(shown).length === 0) {
1548
+ toast.error(message(error));
1549
+ }
1550
+ }
1551
+ - USE routeFieldErrors RATHER THAN A LOOP OF YOUR OWN, and the reason is the `leftover`
1552
+ branch. The naive version — "if fields is non-empty, set them and return" — has a hole
1553
+ that is invisible until it happens: a reason naming a field this form does not render
1554
+ sets state nobody reads AND suppresses the toast on the way out, so the user presses
1555
+ Save and nothing appears. No field lights up, no message, no navigation. A failed write
1556
+ that reports nothing is worse than the toast it replaced.
1424
1557
  - Updates carry the row's `version` (optimistic concurrency): send the version you
1425
1558
  read; a 409 version_conflict means reload-and-retry, surfaced via ErrorState copy.
1426
1559
  - Mirror the backend's input caps client-side (maxLength on Input matching the schema's
@@ -38,6 +38,7 @@ required lanes.
38
38
 
39
39
  from __future__ import annotations
40
40
 
41
+ import importlib
41
42
  import json
42
43
  import os
43
44
  import pathlib
@@ -183,6 +184,7 @@ class VerifyCheck:
183
184
  # "subprocess" | "architecture" | "api-docs-drift" | "routes-drift"
184
185
  # | "platform-install" | "env-seams" | "api-client" | "package-boundaries"
185
186
  # | "dependency-hygiene" | "workbench" | "deploy-safety"
187
+ # | "production-readiness"
186
188
  runner: str = "subprocess"
187
189
 
188
190
 
@@ -259,6 +261,20 @@ _DEPLOY_SAFETY = VerifyCheck(
259
261
  runner="deploy-safety",
260
262
  )
261
263
 
264
+ # Runs beside deploy-safety and for the same reason, one layer in. That check reads
265
+ # the deployment artifact for properties a declaration cannot excuse; this one reads
266
+ # the declaration itself for the states the platform's own boot refuses. Both answer
267
+ # "would this actually come up where it matters", which is the question a green gate
268
+ # is otherwise silent about — and both cost a file read or an import, so they can run
269
+ # in every profile including the cheapest.
270
+ _PRODUCTION_READINESS = VerifyCheck(
271
+ id="production-readiness",
272
+ category="architecture",
273
+ command="terp verify --only production-readiness",
274
+ scope=("control_plane/**/*.py",),
275
+ runner="production-readiness",
276
+ )
277
+
262
278
  _ARCHITECTURE = VerifyCheck(
263
279
  id="architecture",
264
280
  category="architecture",
@@ -416,6 +432,7 @@ PROFILES: dict[str, tuple[VerifyCheck, ...]] = {
416
432
  _ENV_SEAMS,
417
433
  _WORKBENCH,
418
434
  _DEPLOY_SAFETY,
435
+ _PRODUCTION_READINESS,
419
436
  _ARCHITECTURE,
420
437
  _PACKAGE_BOUNDARIES,
421
438
  _FRONTEND_BOUNDARIES,
@@ -428,6 +445,7 @@ PROFILES: dict[str, tuple[VerifyCheck, ...]] = {
428
445
  _ENV_SEAMS,
429
446
  _WORKBENCH,
430
447
  _DEPLOY_SAFETY,
448
+ _PRODUCTION_READINESS,
431
449
  _ARCHITECTURE,
432
450
  _PACKAGE_BOUNDARIES,
433
451
  _DEPENDENCY_HYGIENE,
@@ -444,6 +462,7 @@ PROFILES: dict[str, tuple[VerifyCheck, ...]] = {
444
462
  _ENV_SEAMS,
445
463
  _WORKBENCH,
446
464
  _DEPLOY_SAFETY,
465
+ _PRODUCTION_READINESS,
447
466
  _ARCHITECTURE,
448
467
  _PACKAGE_BOUNDARIES,
449
468
  _DEPENDENCY_HYGIENE,
@@ -1006,6 +1025,92 @@ def _frontend_lockstep_problems(project_root: pathlib.Path, platform: str) -> li
1006
1025
  return problems
1007
1026
 
1008
1027
 
1028
+ #: A ``terp-*`` requirement string, split into the distribution and its version
1029
+ #: specifier. Extras and environment markers are tolerated because the template
1030
+ #: writes both (``terp-core[secrets]==0.20.0``, a marker on a dev-group entry), and a
1031
+ #: parser that choked on them would silently read such a line as "no Terp dependency
1032
+ #: declared here" — a green over exactly the pin this check exists to police.
1033
+ _PY_REQUIREMENT = re.compile(
1034
+ r"^(?P<name>[A-Za-z0-9._-]+)\s*(?:\[[^\]]*\])?\s*(?P<spec>[^;]*)"
1035
+ )
1036
+ #: The one accepted specifier shape: an exact pin. Terp moves in lockstep, so a range
1037
+ #: is a resolver invitation rather than a pin.
1038
+ _EXACT_PIN = re.compile(r"^==\s*(?P<version>[A-Za-z0-9._+!-]+)$")
1039
+
1040
+
1041
+ def _declared_python_requirements(project_root: pathlib.Path) -> list[tuple[str, str]]:
1042
+ """``(where, requirement)`` for every dependency this app's manifest declares.
1043
+
1044
+ Both halves of the manifest, because a forgotten pin in the dev group is the same
1045
+ mixed install as one in the runtime dependencies — ``terp-arch`` lives there, and
1046
+ it is the gate itself.
1047
+ """
1048
+ manifest = project_root / "pyproject.toml"
1049
+ try:
1050
+ data = tomllib.loads(manifest.read_text(encoding="utf-8"))
1051
+ except (OSError, tomllib.TOMLDecodeError):
1052
+ # Unreadable or absent: `_run_dependency_hygiene` and the package-graph check
1053
+ # already report a manifest nobody can parse, and a second voice saying it
1054
+ # would bury the one that explains it.
1055
+ return []
1056
+ found: list[tuple[str, str]] = []
1057
+ for requirement in data.get("project", {}).get("dependencies", []) or []:
1058
+ if isinstance(requirement, str):
1059
+ found.append(("dependencies", requirement))
1060
+ for group, requirements in (data.get("dependency-groups", {}) or {}).items():
1061
+ for requirement in requirements or []:
1062
+ if isinstance(requirement, str):
1063
+ found.append((f"dependency-groups.{group}", requirement))
1064
+ return found
1065
+
1066
+
1067
+ def _backend_pin_problems(project_root: pathlib.Path, platform: str) -> list[str]:
1068
+ """Every ``terp-*`` pin in ``pyproject.toml`` that disagrees with what is installed.
1069
+
1070
+ The half of the lockstep that was missing, and it was the more consequential half.
1071
+ This check read the *environment* for backend packages and compared it only against
1072
+ itself, while reading *declarations* for the frontend and comparing them against the
1073
+ platform. So a tree whose manifest said ``terp-core==0.20.0`` over an environment
1074
+ installed at 0.13.0 was internally consistent, passed here, and passed the whole
1075
+ profile — a verdict about code that is not the code that will run.
1076
+
1077
+ Two ways an app arrives there, and neither is exotic. Someone repins and has not run
1078
+ ``uv sync`` yet, which is the ordinary middle of an upgrade. Or a container bakes the
1079
+ packages into its image and bind-mounts the source over them, so a rebuilt checkout
1080
+ reloads new code against old libraries and dies on an import nowhere near the cause.
1081
+
1082
+ Silent on a requirement carrying **no** specifier: that is a workspace member or an
1083
+ editable install (the platform's own tree declares all twenty-odd of its packages
1084
+ that way), where the manifest is not where the version lives. A declared specifier,
1085
+ however, is a claim about a version, and this holds it to the installed one.
1086
+ """
1087
+ from terp.cli.version import _INDEPENDENTLY_VERSIONED, _PREFIX
1088
+
1089
+ problems: list[str] = []
1090
+ for where, requirement in _declared_python_requirements(project_root):
1091
+ match = _PY_REQUIREMENT.match(requirement.strip())
1092
+ if match is None:
1093
+ continue
1094
+ name = match.group("name").lower().replace("_", "-")
1095
+ if not name.startswith(_PREFIX) or name in _INDEPENDENTLY_VERSIONED:
1096
+ continue
1097
+ spec = match.group("spec").strip()
1098
+ if not spec:
1099
+ continue # a workspace source or an editable install; see the docstring
1100
+ pinned = _EXACT_PIN.match(spec)
1101
+ if pinned is None:
1102
+ problems.append(
1103
+ f"{where}: {name} is declared {spec!r} — Terp moves in lockstep, so "
1104
+ f"pin =={platform}"
1105
+ )
1106
+ elif pinned.group("version") != platform:
1107
+ problems.append(
1108
+ f"{where}: {name} is pinned =={pinned.group('version')} but "
1109
+ f"{platform} is installed"
1110
+ )
1111
+ return problems
1112
+
1113
+
1009
1114
  def _run_platform_install(project_root: pathlib.Path) -> tuple[int, str]:
1010
1115
  """Fail when the installed platform disagrees with itself — either half.
1011
1116
 
@@ -1016,7 +1121,10 @@ def _run_platform_install(project_root: pathlib.Path) -> tuple[int, str]:
1016
1121
  either direction, so this refuses rather than reports.
1017
1122
 
1018
1123
  The backend half reads the live environment (never a declared list), so a
1019
- capability adopted after this was written is policed too. The frontend half
1124
+ capability adopted after this was written is policed too, and then holds that
1125
+ environment against the pins ``pyproject.toml`` declares — a manifest and an
1126
+ install that disagree is the same mixed platform arriving by a third route, and
1127
+ the one that used to pass here green. The frontend half
1020
1128
  reads every app manifest that declares a ``@terpjs/*`` package, plus the
1021
1129
  installed copy under its ``node_modules`` when present — because a frontend
1022
1130
  package left behind is the same mixed install by another route, and a check
@@ -1057,6 +1165,20 @@ def _run_platform_install(project_root: pathlib.Path) -> tuple[int, str]:
1057
1165
  f" Fix: rename each to its @terpjs/* spelling pinned at ^{platform}, "
1058
1166
  "then reinstall the node_modules of that manifest."
1059
1167
  )
1168
+ backend_problems = _backend_pin_problems(project_root, platform)
1169
+ if backend_problems:
1170
+ return 1, (
1171
+ f"the environment is consistent at terp {platform}, but this app's "
1172
+ "pyproject.toml declares something else:\n"
1173
+ + "".join(f" {problem}\n" for problem in backend_problems)
1174
+ + "A gate run against packages the manifest does not ask for proves "
1175
+ "nothing about the code that will ship: the tree reloads new source "
1176
+ "against old libraries and fails on an import nowhere near its cause.\n"
1177
+ f" Fix: uv sync --refresh (or repin to =={platform} if the manifest is "
1178
+ "the half that is wrong).\n"
1179
+ "If this is a container, its image bakes the packages in — rebuild it "
1180
+ "rather than reloading into it (`terp docker dev` does)."
1181
+ )
1060
1182
  frontend_problems = _frontend_lockstep_problems(project_root, platform)
1061
1183
  if frontend_problems:
1062
1184
  return 1, (
@@ -1070,13 +1192,141 @@ def _run_platform_install(project_root: pathlib.Path) -> tuple[int, str]:
1070
1192
  "that declares one, then reinstall that manifest's node_modules."
1071
1193
  )
1072
1194
  manifest_count = len(_terp_frontend_manifests(project_root))
1195
+ pin_count = sum(
1196
+ 1
1197
+ for _, requirement in _declared_python_requirements(project_root)
1198
+ if requirement.lower().replace("_", "-").startswith("terp-")
1199
+ )
1073
1200
  return (
1074
1201
  0,
1075
- f"terp {platform} ({len(versions)} distributions and "
1076
- f"{manifest_count} frontend manifest(s), consistent)",
1202
+ f"terp {platform} ({len(versions)} distributions, {pin_count} declared "
1203
+ f"pin(s) and {manifest_count} frontend manifest(s), consistent)",
1077
1204
  )
1078
1205
 
1079
1206
 
1207
+ #: The reference every other Terp surface already uses for the authority aggregate:
1208
+ #: `terp jobs list`, `terp inspect control-plane` and the template's own composition
1209
+ #: root all resolve `control_plane:control_plane`. Named here rather than made an
1210
+ #: option, because a check that has to be told where to look is a check an app can
1211
+ #: leave unaimed.
1212
+ _CONTROL_PLANE_REF = "control_plane:control_plane"
1213
+
1214
+
1215
+ #: The conventional address a job's system principal arrives at (ADR 0129). An app
1216
+ #: that declares this variable has said the value will be supplied, which is the
1217
+ #: evidence this check can actually have: the id belongs to a service principal in a
1218
+ #: particular database, so the gate's own environment is the last place it would be.
1219
+ _JOB_ACTOR_VARIABLE = "JOB_SYSTEM_ACTOR_ID"
1220
+
1221
+
1222
+ def _job_actor_arrives_from_the_environment(project_root: pathlib.Path) -> bool:
1223
+ """Whether the app *declares* the job actor as a runtime variable.
1224
+
1225
+ The declaration is the proof, never the value. `create_app` fills an unset
1226
+ `job_system_actor_id` from `JOB_SYSTEM_ACTOR_ID`, so an app whose principal is a
1227
+ deployment fact leaves the field empty on purpose — and reddening it here would
1228
+ punish exactly the delivery mechanism ADR 0129 added. What the app must not be
1229
+ able to do is stay silent: `environment.schema.json` is where a runtime variable
1230
+ is declared, `env-seams` already refuses a declaration the deployment does not
1231
+ deliver, and `terp env file` renders it. So a declared variable is a promise with
1232
+ a gate behind it, which an undeclared one is not.
1233
+ """
1234
+ from terp.cli.envschema import declared_variables
1235
+
1236
+ return _JOB_ACTOR_VARIABLE in declared_variables(project_root)
1237
+
1238
+
1239
+ def _run_production_readiness(project_root: pathlib.Path) -> tuple[int, str]:
1240
+ """Refuse a tree whose declared control plane cannot boot in production.
1241
+
1242
+ Three of the platform's boot refusals are decided entirely by what the control
1243
+ plane declares — no environment, no database, no request. `create_app` raises
1244
+ `BootError` on each of them under `ENVIRONMENT == "production"`: an unsafe
1245
+ security config, a password policy with no strength floor, and background work
1246
+ that names no actor to stamp its writes with (ADR 0125). Outside production the
1247
+ same states log a warning and keep booting, on purpose, because a developer who
1248
+ has not wired a system principal yet should not be blocked by one.
1249
+
1250
+ Nothing gated the gap between those two behaviours. An app could declare a job,
1251
+ never set `job_system_actor_id`, and take a green `--profile full` all the way to
1252
+ a deployment that refuses to start — the gate, the machine envelope and CI all
1253
+ agreeing, because none of them asked. This asks, and it is the same argument
1254
+ `platform-install` already makes about mixed installs: a warning inside a command
1255
+ nobody runs before shipping is not a control, so the verdict belongs where it can
1256
+ fail.
1257
+
1258
+ Reads the *declared* plane and never builds the app, so it costs one module import
1259
+ and can sit in every profile. The audit refusal is deliberately not among the
1260
+ three: it turns on `create_app(audit_sink=...)`, a runtime argument this check
1261
+ cannot see, and a check that pretended to cover it would be worse than the gap.
1262
+
1263
+ Skips with a note for a tree with no importable control plane — the platform's own
1264
+ checkout, and an app whose authority surface predates the module. A plane that
1265
+ imports and yields no `ControlPlane`, however, is a red: every other Terp command
1266
+ reads the same reference, so the app has adopted the pattern and the file the
1267
+ tooling depends on has stopped answering.
1268
+
1269
+ The job-actor half is satisfied by a *declaration* as well as by a value, because
1270
+ ADR 0129 made `JOB_SYSTEM_ACTOR_ID` the conventional way the principal arrives and
1271
+ the gate's own environment is the last place a production principal's id would be.
1272
+ See `_job_actor_arrives_from_the_environment`.
1273
+ """
1274
+ from terp.core import ControlPlane
1275
+
1276
+ if not (project_root / "control_plane").is_dir():
1277
+ return (
1278
+ 0,
1279
+ f"{NOTE_PREFIX}no control_plane/ package - production readiness not "
1280
+ "applicable (the authority surface every other terp command reads)",
1281
+ )
1282
+ root = str(project_root.resolve())
1283
+ restore = root not in sys.path
1284
+ if restore:
1285
+ sys.path.insert(0, root)
1286
+ module_name, _, attr = _CONTROL_PLANE_REF.partition(":")
1287
+ try:
1288
+ importlib.invalidate_caches()
1289
+ module = importlib.import_module(module_name)
1290
+ plane = getattr(module, attr, None)
1291
+ except Exception as exc: # noqa: BLE001 - any import failure is the app's answer
1292
+ return 1, (
1293
+ f"{_CONTROL_PLANE_REF} could not be imported, so what this app declares "
1294
+ f"cannot be established: {type(exc).__name__}: {exc}"
1295
+ )
1296
+ finally:
1297
+ if restore and root in sys.path:
1298
+ sys.path.remove(root)
1299
+ if not isinstance(plane, ControlPlane):
1300
+ return 1, (
1301
+ f"{_CONTROL_PLANE_REF} did not resolve to a terp.core.ControlPlane "
1302
+ f"(found {type(plane).__name__}). Every terp command reads that reference "
1303
+ "- `terp jobs list`, `terp inspect control-plane` and the app's own "
1304
+ "composition root - so this is not a layout choice, it is a surface that "
1305
+ "has stopped answering."
1306
+ )
1307
+ problems = [
1308
+ *(f"security: {problem}" for problem in plane.security.production_problems()),
1309
+ *(f"passwords: {problem}" for problem in plane.passwords.production_problems()),
1310
+ *(
1311
+ f"jobs: {problem}"
1312
+ for problem in plane.production_problems()
1313
+ if not _job_actor_arrives_from_the_environment(project_root)
1314
+ ),
1315
+ ]
1316
+ if problems:
1317
+ return 1, (
1318
+ "this app's control plane refuses a production boot:\n"
1319
+ + "".join(f" {problem}\n" for problem in problems)
1320
+ + "Each of these raises BootError under ENVIRONMENT=production and only "
1321
+ "logs a warning outside it, so a green gate over this state is a gate "
1322
+ "that agrees with a deployment that will not start.\n"
1323
+ " A job actor that is a deployment fact rather than a source constant is "
1324
+ "declared, not hard-coded: put JOB_SYSTEM_ACTOR_ID in "
1325
+ "environment.schema.json and create_app will resolve it (ADR 0129)."
1326
+ )
1327
+ return 0, "the declared control plane boots in production (security, passwords, jobs)"
1328
+
1329
+
1080
1330
  def _run_routes_drift(root: pathlib.Path) -> tuple[int, str]:
1081
1331
  """Refuse a committed route table that no longer matches the module manifests.
1082
1332
 
@@ -1492,6 +1742,8 @@ def run_verify_command(
1492
1742
  from terp.cli.deploy_safety import run_deploy_safety_check
1493
1743
 
1494
1744
  exit_code, output = run_deploy_safety_check(project_root)
1745
+ elif check.runner == "production-readiness":
1746
+ exit_code, output = _run_production_readiness(project_root)
1495
1747
  else:
1496
1748
  exit_code, output = _run_subprocess(check, project_root)
1497
1749
  reports = _reports_in(output)
@@ -223,6 +223,7 @@ def scaffold_ref(root: pathlib.Path) -> str | None:
223
223
  #: ``test_the_app_owned_scaffold_list_matches_copier`` — the same treatment the theme
224
224
  #: bootstrap's three duplicated facts get, so this list cannot rot into a wrong answer.
225
225
  _APP_OWNED_SCAFFOLD_FILES = (
226
+ "control_plane/app_operations.py",
226
227
  "environment.schema.json",
227
228
  "escape-hatch-budget.json",
228
229
  "frontend/layout-contract.json",
@@ -233,7 +234,9 @@ _APP_OWNED_SCAFFOLD_FILES = (
233
234
  )
234
235
 
235
236
 
236
- def _scaffold_lines(root: pathlib.Path, platform: str) -> list[str]:
237
+ def _scaffold_lines(
238
+ root: pathlib.Path, platform: str, *, include_command: bool = True
239
+ ) -> list[str]:
237
240
  """Report how far the app's *scaffolding* is behind its *packages*.
238
241
 
239
242
  The two move independently and only one of them is gated. Package drift already fails
@@ -268,15 +271,124 @@ def _scaffold_lines(root: pathlib.Path, platform: str) -> list[str]:
268
271
  f"{platform}.",
269
272
  "A re-render rewrites EVERY file the template owns — main.tsx, index.html,",
270
273
  "AGENTS.md, the Dockerfiles, docker-compose.yml, the CI workflows — so any fix a",
271
- "release made to one of them is still waiting here. Nothing gates this, so it",
272
- "stays green; a stale AGENTS.md in particular briefs every agent from the wrong",
273
- "rulebook, and a stale docker-compose.yml can serve a dev stack that disagrees",
274
- "with the checkout the boundary lint reads.",
274
+ "release made to one of them may still be waiting here. WHICH of them actually",
275
+ "differ is not something this can say: it compares two version numbers, and a",
276
+ "file no release has touched since is already current. Nothing gates any of it,",
277
+ "so it stays green either way — and the two that cost the most when they are",
278
+ "behind are AGENTS.md, which briefs every agent working here, and",
279
+ "docker-compose.yml, which can serve a dev stack that disagrees with the",
280
+ "checkout the boundary lint reads.",
275
281
  "These are seeded once and then the app's, so a re-render leaves them alone:",
276
282
  *(f" {name}" for name in _APP_OWNED_SCAFFOLD_FILES),
283
+ # Suppressed when a recipe above already numbered the re-render as a step: the
284
+ # same command printed twice in one report reads as two different things to do,
285
+ # and the recipe's copy is the one with the tree-cleaning step before it.
286
+ *(
287
+ [
288
+ "",
289
+ " Re-render: copier update (or the Studio's upgrade flow, which "
290
+ "records the",
291
+ " answers file it needs).",
292
+ ]
293
+ if include_command
294
+ else []
295
+ ),
296
+ ]
297
+
298
+
299
+ def _has_template_provenance(root: pathlib.Path) -> bool:
300
+ """Whether ``copier update`` can run here at all.
301
+
302
+ It needs the answers file copier writes at render time; without one there is no
303
+ recorded template and no answers to re-render from, so the only way to move an app
304
+ forward is by hand. That is the whole reason the recipe below has two shapes.
305
+ """
306
+ return scaffold_ref(root) is not None
307
+
308
+
309
+ def _rerender_recipe(target: str, current: str, count: int) -> list[str]:
310
+ """The upgrade for an app the template rendered: re-render first, sync once.
311
+
312
+ The order used to be the other way round and could not be followed as printed.
313
+ Steps 2 to 4 were "edit the pins, uv sync, npm install", and the re-render came
314
+ after them — but ``copier update`` refuses a dirty tree, so the recipe's own
315
+ earlier steps made its last step impossible, and whoever followed it had to stash
316
+ halfway through.
317
+
318
+ Worse, those pin edits were work the re-render does. The template owns
319
+ ``pyproject.toml``, ``frontend/package.json`` and ``conformance/package.json``, so
320
+ a re-render writes every one of those pins itself. The old recipe even warned that
321
+ "a recipe that names only one is how the other goes stale" — which is an admission
322
+ that the hand-pinning step was a footgun, for a job already done one step later.
323
+
324
+ So: read, clean the tree, re-render, resolve, sync once, confirm, verify.
325
+ """
326
+ return [
327
+ "",
328
+ f"All {count} packages can move to {target} together. Re-render FIRST — the",
329
+ "template owns pyproject.toml and both npm manifests, so the re-render writes",
330
+ "every pin itself, and it refuses to run on a tree with uncommitted changes:",
331
+ "",
332
+ f" 1. Read what changed: uvx --from terp-cli=={target} terp guide changelog",
333
+ f" (the {target} notes; the copy installed here ends at {current}).",
334
+ " 2. Commit or discard what you have. A re-render on a dirty tree is refused,",
335
+ " and its own diff is much easier to review on its own.",
336
+ " 3. copier update (or the Studio's upgrade flow, which records the",
337
+ " answers file it needs). This rewrites EVERY file the template owns and",
338
+ " writes the terp-* and @terpjs/* pins for you.",
339
+ " 4. Resolve what it reports. Two conflicts are structural rather than bad luck,",
340
+ " because the template owns the file and your app also writes to it:",
341
+ " pyproject.toml keep your dependencies, take the terp-* pins.",
342
+ " control_plane/operations.py the capability folding is the template's;",
343
+ " your own operations belong in",
344
+ " control_plane/app_operations.py, which no",
345
+ " re-render touches (ADR 0130).",
346
+ " 5. uv sync --refresh && npm --prefix frontend install",
347
+ " (once, now that the manifests are final — not before the re-render.)",
348
+ " 6. uv run terp --version (confirm the set agrees)",
349
+ " 7. uv run terp verify --profile full",
350
+ "",
351
+ " Running the dev stack in containers?",
352
+ " Rebuild it rather than reloading into it: the images bake the terp packages",
353
+ " in while the source is bind-mounted, so correct new code reloads against old",
354
+ " libraries and dies on an import nowhere near its cause. `terp docker dev`",
355
+ " rebuilds on a pyproject.toml change; a plain `docker compose up` does not,",
356
+ " and `terp verify` now refuses the skew either way.",
277
357
  "",
278
- " Re-render: copier update (or the Studio's upgrade flow, which records the",
279
- " answers file it needs).",
358
+ "A green gate proves the upgrade did not break this app. It cannot prove the",
359
+ "release did not change something this app should adopt — step 1 is the only",
360
+ "thing that answers that.",
361
+ ]
362
+
363
+
364
+ def _hand_pin_recipe(target: str, current: str, count: int) -> list[str]:
365
+ """The upgrade for an app with no recorded template: every pin by hand.
366
+
367
+ No answers file means ``copier update`` has nothing to re-render from, so the pins
368
+ the template would have written have to be written here instead. Kept in full for
369
+ exactly that case and printed nowhere else — an app the template rendered is told
370
+ to re-render, because doing both is what produced two needless installs.
371
+ """
372
+ return [
373
+ "",
374
+ f"All {count} packages can move to {target} together. This app records no",
375
+ "template answers file, so `copier update` has nothing to re-render from and",
376
+ "the pins have to be written by hand:",
377
+ "",
378
+ f" 1. Read what changed: uvx --from terp-cli=={target} terp guide changelog",
379
+ f" (the {target} notes; the copy installed here ends at {current}).",
380
+ f" 2. Pin every terp-* dependency to =={target} in pyproject.toml",
381
+ " (including the dev group — a forgotten pin is a mixed install).",
382
+ f" 3. Pin every @terpjs/* package to ^{target} in EVERY manifest that",
383
+ " declares one — frontend/package.json AND conformance/package.json",
384
+ " (a recipe that names only one is how the other goes stale).",
385
+ " 4. uv sync --refresh && npm --prefix frontend install",
386
+ " 5. uv run terp --version (confirm the set agrees)",
387
+ " 6. uv run terp verify --profile full",
388
+ "",
389
+ "A green gate proves the upgrade did not break this app. It cannot prove the",
390
+ "release did not change something this app should adopt — step 1 is the only",
391
+ "thing that answers that.",
280
392
  ]
281
393
 
282
394
 
@@ -337,24 +449,16 @@ def render_upgrade_check(root: pathlib.Path | None = None) -> str:
337
449
  # to help judge. `uvx --from terp-cli==target` resolves an ephemeral CLI from
338
450
  # the same index (terp-cli pins terp-core exactly, so the right CHANGELOG
339
451
  # comes with it) without touching this app's environment or its pins.
340
- lines += [
341
- "",
342
- f"All {len(landing)} packages can move to {target} together:",
343
- "",
344
- f" 1. Read what changed: uvx --from terp-cli=={target} terp guide changelog",
345
- f" (the {target} notes; the copy installed here ends at {current}).",
346
- f" 2. Pin every terp-* dependency to =={target} in pyproject.toml",
347
- " (including the dev group — a forgotten pin is a mixed install).",
348
- f" 3. Pin every @terpjs/* package to ^{target} in EVERY manifest that",
349
- " declares one — frontend/package.json AND conformance/package.json",
350
- " (a recipe that names only one is how the other goes stale).",
351
- " 4. uv sync --refresh && npm --prefix frontend install",
352
- " 5. uv run terp --version (confirm the set agrees)",
353
- " 6. uv run terp verify --profile full",
354
- "",
355
- "A green gate proves the upgrade did not break this app. It cannot prove the",
356
- "release did not change something this app should adopt — step 1 is the only",
357
- "thing that answers that.",
358
- ]
359
- lines += _scaffold_lines(project_root, target)
452
+ # Which recipe depends on whether copier can run here at all: an app the
453
+ # template rendered re-renders and gets its pins written for it, and an app with
454
+ # no answers file writes them by hand. Printing both, or the hand-pin one to an
455
+ # app that could re-render, is what produced two needless installs and a stash
456
+ # halfway through.
457
+ if _has_template_provenance(project_root):
458
+ lines += _rerender_recipe(target, current, len(landing))
459
+ else:
460
+ lines += _hand_pin_recipe(target, current, len(landing))
461
+ lines += _scaffold_lines(
462
+ project_root, target, include_command=not _has_template_provenance(project_root)
463
+ )
360
464
  return "\n".join(lines)
File without changes
File without changes
File without changes