terp-cli 0.22.0__tar.gz → 0.23.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 (36) hide show
  1. {terp_cli-0.22.0 → terp_cli-0.23.0}/PKG-INFO +8 -8
  2. {terp_cli-0.22.0 → terp_cli-0.23.0}/pyproject.toml +8 -8
  3. {terp_cli-0.22.0 → terp_cli-0.23.0}/src/terp/cli/__init__.py +88 -4
  4. {terp_cli-0.22.0 → terp_cli-0.23.0}/src/terp/cli/access.py +10 -1
  5. terp_cli-0.23.0/src/terp/cli/authz_surface.py +169 -0
  6. {terp_cli-0.22.0 → terp_cli-0.23.0}/src/terp/cli/docker.py +20 -0
  7. terp_cli-0.23.0/src/terp/cli/ports.py +996 -0
  8. {terp_cli-0.22.0 → terp_cli-0.23.0}/src/terp/cli/scaffold.py +41 -4
  9. {terp_cli-0.22.0 → terp_cli-0.23.0}/src/terp/cli/verify.py +174 -3
  10. {terp_cli-0.22.0 → terp_cli-0.23.0}/src/terp/cli/version.py +166 -43
  11. {terp_cli-0.22.0 → terp_cli-0.23.0}/.gitignore +0 -0
  12. {terp_cli-0.22.0 → terp_cli-0.23.0}/src/terp/cli/_appref.py +0 -0
  13. {terp_cli-0.22.0 → terp_cli-0.23.0}/src/terp/cli/_subjects.py +0 -0
  14. {terp_cli-0.22.0 → terp_cli-0.23.0}/src/terp/cli/apidocs.py +0 -0
  15. {terp_cli-0.22.0 → terp_cli-0.23.0}/src/terp/cli/capabilities.py +0 -0
  16. {terp_cli-0.22.0 → terp_cli-0.23.0}/src/terp/cli/deploy_safety.py +0 -0
  17. {terp_cli-0.22.0 → terp_cli-0.23.0}/src/terp/cli/dev.py +0 -0
  18. {terp_cli-0.22.0 → terp_cli-0.23.0}/src/terp/cli/envfile.py +0 -0
  19. {terp_cli-0.22.0 → terp_cli-0.23.0}/src/terp/cli/envschema.py +0 -0
  20. {terp_cli-0.22.0 → terp_cli-0.23.0}/src/terp/cli/envseams.py +0 -0
  21. {terp_cli-0.22.0 → terp_cli-0.23.0}/src/terp/cli/fmt.py +0 -0
  22. {terp_cli-0.22.0 → terp_cli-0.23.0}/src/terp/cli/grants.py +0 -0
  23. {terp_cli-0.22.0 → terp_cli-0.23.0}/src/terp/cli/jobs.py +0 -0
  24. {terp_cli-0.22.0 → terp_cli-0.23.0}/src/terp/cli/leases.py +0 -0
  25. {terp_cli-0.22.0 → terp_cli-0.23.0}/src/terp/cli/module_roles.py +0 -0
  26. {terp_cli-0.22.0 → terp_cli-0.23.0}/src/terp/cli/openapi.py +0 -0
  27. {terp_cli-0.22.0 → terp_cli-0.23.0}/src/terp/cli/outbox.py +0 -0
  28. {terp_cli-0.22.0 → terp_cli-0.23.0}/src/terp/cli/profiles.py +0 -0
  29. {terp_cli-0.22.0 → terp_cli-0.23.0}/src/terp/cli/py.typed +0 -0
  30. {terp_cli-0.22.0 → terp_cli-0.23.0}/src/terp/cli/routes.py +0 -0
  31. {terp_cli-0.22.0 → terp_cli-0.23.0}/src/terp/cli/schema.py +0 -0
  32. {terp_cli-0.22.0 → terp_cli-0.23.0}/src/terp/cli/seed.py +0 -0
  33. {terp_cli-0.22.0 → terp_cli-0.23.0}/src/terp/cli/service_accounts.py +0 -0
  34. {terp_cli-0.22.0 → terp_cli-0.23.0}/src/terp/cli/smoke.py +0 -0
  35. {terp_cli-0.22.0 → terp_cli-0.23.0}/src/terp/cli/users.py +0 -0
  36. {terp_cli-0.22.0 → terp_cli-0.23.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.22.0
3
+ Version: 0.23.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.22.0
11
- Requires-Dist: terp-core==0.22.0
12
- Requires-Dist: terp-migrations==0.22.0
10
+ Requires-Dist: terp-arch==0.23.0
11
+ Requires-Dist: terp-core==0.23.0
12
+ Requires-Dist: terp-migrations==0.23.0
13
13
  Provides-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'
14
+ Requires-Dist: terp-cap-outbox==0.23.0; extra == 'jobs'
15
+ Requires-Dist: terp-cap-scheduler-apscheduler==0.23.0; extra == 'jobs'
16
16
  Provides-Extra: scheduler
17
- Requires-Dist: terp-cap-scheduler-apscheduler==0.22.0; extra == 'scheduler'
17
+ Requires-Dist: terp-cap-scheduler-apscheduler==0.23.0; extra == 'scheduler'
18
18
  Provides-Extra: worker
19
- Requires-Dist: terp-cap-outbox==0.22.0; extra == 'worker'
19
+ Requires-Dist: terp-cap-outbox==0.23.0; extra == 'worker'
@@ -4,18 +4,18 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "terp-cli"
7
- version = "0.22.0"
7
+ version = "0.23.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.22.0",
12
+ "terp-core==0.23.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.22.0",
15
+ "terp-migrations==0.23.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.22.0",
18
+ "terp-arch==0.23.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.22.0"]
33
- scheduler = ["terp-cap-scheduler-apscheduler==0.22.0"]
32
+ worker = ["terp-cap-outbox==0.23.0"]
33
+ scheduler = ["terp-cap-scheduler-apscheduler==0.23.0"]
34
34
  jobs = [
35
- "terp-cap-outbox==0.22.0",
36
- "terp-cap-scheduler-apscheduler==0.22.0",
35
+ "terp-cap-outbox==0.23.0",
36
+ "terp-cap-scheduler-apscheduler==0.23.0",
37
37
  ]
38
38
 
39
39
  [project.scripts]
@@ -60,6 +60,7 @@ from terp.cli.service_accounts import create_service_account_command
60
60
  from terp.cli.users import create_user_command
61
61
  from terp.cli.envfile import run_env_command
62
62
  from terp.cli.outbox import render_backlog
63
+ from terp.cli.ports import run_ports_command
63
64
  from terp.cli.verify import (
64
65
  profile_ids,
65
66
  run_verify_command,
@@ -575,7 +576,17 @@ Stored references (Ref + OnDelete)
575
576
  RESTRICT the parent must not vanish underneath this row (a ledger entry)
576
577
  SET NULL a pointer allowed to go slack (an optional assignee) -- nullable
577
578
  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 the database corrects nothing; something above it owns this
580
+ lifecycle -- it still REFUSES a delete that would orphan a row
581
+ - NO_ACTION STILL REFUSES. It means the database takes no corrective action, not that
582
+ it stands aside: the foreign key is enforced either way, and NO ACTION differs from
583
+ RESTRICT only in deferrability, never in whether the parent delete is blocked. So
584
+ against a HARD-deletable parent, expect the refusal and plan for it -- BaseService
585
+ turns it into the uniform 409 ConflictError, the same envelope as any other
586
+ integrity conflict, so you do not map it. Clear the children first; the _after_write
587
+ recipe below runs BEFORE the parent row is deleted, which is exactly what makes that
588
+ ordering work. (Against a soft-deletable target none of this can fire at all -- see
589
+ the SOFT-DELETE note below, where NO_ACTION really does mean nothing happens.)
579
590
  - NO_ACTION is a real answer, not a cop-out, and it emits NO clause at all. Two
580
591
  consequences worth knowing: adopting the declaration on an existing schema costs
581
592
  no migration (the DDL is unchanged, only the source now says the silence was
@@ -1375,6 +1386,21 @@ Database migrations (terp migrate)
1375
1386
  - Destructive DDL (drop table/column or alter-column type changes) is refused by
1376
1387
  `terp check` unless the operation carries `# arch-allow-no-destructive-migrations:
1377
1388
  <reason>` on (or immediately above) its line, budgeted by the escape-hatch ratchet.
1389
+ - ADDING A NOT NULL COLUMN is the revision that passes everything you can run and then
1390
+ fails in production. For a new non-nullable field autogenerate writes
1391
+ `add_column(sa.Column('rank', sa.Integer(), nullable=False))`, and that statement
1392
+ succeeds against an empty database and fails against one that holds rows - the rows
1393
+ already there need a value it never supplies. Nothing local catches it, because a
1394
+ migration test upgrades a FRESH scratch database, where the statement is correct.
1395
+ Give the column a server_default and the database back-fills the existing rows as it
1396
+ adds it; new rows still take the model-side default:
1397
+ op.add_column('note', sa.Column('rank', sa.Integer(), nullable=False,
1398
+ server_default='0'))
1399
+ Where no literal default is right, expand/contract instead - add the column nullable,
1400
+ back-fill it in the same release, tighten it in a LATER revision. `terp check`
1401
+ refuses the unbackfilled form (`not_null_columns_are_backfilled`); a table that
1402
+ genuinely holds no rows yet opts out with
1403
+ `# arch-allow-not-null-columns-are-backfilled: <reason>`.
1378
1404
  - MOVING A MODEL TO ANOTHER MODULE is the one edit that looks free and is not. Just
1379
1405
  moving the class emits NO ddl at all - the losing package stops owning the table so
1380
1406
  its scoped autogenerate cannot propose a drop, and the gaining package diffs against
@@ -1859,6 +1885,28 @@ not concatenate sequences), so both files have to be listed.
1859
1885
  _GENERATED_TOPICS: tuple[str, ...] = ("changelog", "rules")
1860
1886
 
1861
1887
  _RULE_GUIDE_DETAILS: dict[str, str] = {
1888
+ "not_null_columns_are_backfilled": """\
1889
+ Compliant decision path for a new NOT NULL column
1890
+
1891
+ 1. Decide what the rows that already exist should hold. That is the whole question;
1892
+ the statement fails precisely because it never answers it.
1893
+ 2. When a literal answers it, give the column a server_default. The database writes it
1894
+ into every existing row as it adds the column, and new rows still take the
1895
+ model-side default:
1896
+ op.add_column('note', sa.Column('rank', sa.Integer(), nullable=False,
1897
+ server_default='0'))
1898
+ 3. When no literal is right - the value has to be computed, or looked up - use
1899
+ expand/contract across two releases: add the column NULLABLE, back-fill it (a data
1900
+ migration or a backfill job), and tighten it to NOT NULL in a LATER revision, once
1901
+ nothing writes a NULL.
1902
+ 4. Do not make the column nullable in the model merely to silence this. The model is
1903
+ the contract; if the field is genuinely optional, say so there and the finding goes
1904
+ away on its own.
1905
+ 5. Opt out only for a table that genuinely holds no rows in any environment - a table
1906
+ this release introduces, or one seeded from empty - with
1907
+ `# arch-allow-not-null-columns-are-backfilled: <reason>` naming which. A table
1908
+ created in the same upgrade() needs no marker; it is already exempt.
1909
+ """,
1862
1910
  "no_raw_outbound_http": """\
1863
1911
  Compliant decision path for outbound HTTP
1864
1912
 
@@ -2627,10 +2675,11 @@ def _build_parser() -> argparse.ArgumentParser:
2627
2675
  )
2628
2676
  access_parser.add_argument(
2629
2677
  "--format",
2630
- choices=("text", "json"),
2678
+ choices=("text", "json", "surface"),
2631
2679
  default="text",
2632
- help="Output format: text (human) or json (structured, for any tool or agent "
2633
- "reading this; default: text)",
2680
+ help="Output format: text (human), json (structured, for any tool or agent "
2681
+ "reading this), or surface (the authority baseline the authz-surface check "
2682
+ "pins — redirect it to authz-surface.json; default: text)",
2634
2683
  )
2635
2684
  capabilities_parser = inspect_subcommands.add_parser(
2636
2685
  "capabilities",
@@ -2816,6 +2865,33 @@ def _build_parser() -> argparse.ArgumentParser:
2816
2865
  env_unset_parser.add_argument(
2817
2866
  "--root", default=".", help="Project root (default: .)"
2818
2867
  )
2868
+ ports_parser = subcommands.add_parser(
2869
+ "ports",
2870
+ help="The host ports this checkout owns, recorded where every starter reads them",
2871
+ )
2872
+ ports_subcommands = ports_parser.add_subparsers(
2873
+ dest="ports_command", required=True
2874
+ )
2875
+ for _name, _help in (
2876
+ ("show", "This checkout's claim, and whether .env publishes it"),
2877
+ ("release", "Drop the claim and remove the published block"),
2878
+ ("list", "Every host-port claim recorded on this machine"),
2879
+ ):
2880
+ _sub = ports_subcommands.add_parser(_name, help=_help)
2881
+ _sub.add_argument("--root", default=".", help="Project root (default: .)")
2882
+ ports_assign_parser = ports_subcommands.add_parser(
2883
+ "assign",
2884
+ help="Claim a free pair and publish it into .env, in one act",
2885
+ )
2886
+ ports_assign_parser.add_argument(
2887
+ "--root", default=".", help="Project root (default: .)"
2888
+ )
2889
+ ports_assign_parser.add_argument(
2890
+ "--reassign",
2891
+ action="store_true",
2892
+ help="Pick a new pair even if one is already claimed or published",
2893
+ )
2894
+
2819
2895
  outbox_parser = subcommands.add_parser(
2820
2896
  "outbox",
2821
2897
  help="Report the durable outbox's backlog - whether anything is draining it",
@@ -3375,6 +3451,14 @@ def main(argv: Sequence[str] | None = None) -> None:
3375
3451
  declare=getattr(args, "declare", False),
3376
3452
  )
3377
3453
  )
3454
+ if args.command == "ports":
3455
+ raise SystemExit(
3456
+ run_ports_command(
3457
+ action=args.ports_command,
3458
+ root=args.root,
3459
+ reassign=getattr(args, "reassign", False),
3460
+ )
3461
+ )
3378
3462
  if args.command == "outbox" and args.outbox_command == "backlog":
3379
3463
  print(
3380
3464
  render_backlog(
@@ -402,7 +402,16 @@ def _render_access_text(graph: dict[str, object]) -> str:
402
402
 
403
403
 
404
404
  def render_access_graph(graph: dict[str, object], fmt: str = "text") -> str:
405
- """Render a prebuilt access *graph* as ``text`` or ``json``."""
405
+ """Render a prebuilt access *graph* as ``text``, ``json`` or ``surface``.
406
+
407
+ ``surface`` is the authority baseline the ``authz-surface`` verify check pins
408
+ (:mod:`terp.cli.authz_surface`): the same projection reduced to what is an
409
+ authority claim, sorted at every level so the committed file is diffable.
410
+ """
411
+ if fmt == "surface":
412
+ from terp.cli.authz_surface import render_authz_surface
413
+
414
+ return render_authz_surface(graph)
406
415
  if fmt == "json":
407
416
  return json.dumps(graph, indent=2)
408
417
  return _render_access_text(graph)
@@ -0,0 +1,169 @@
1
+ """``authz-surface`` — the authority baseline, and the drift check that pins it.
2
+
3
+ The access graph already replays enforcement rather than describing it: every allowance
4
+ in it is :func:`terp.core.module_spec.decide`'s own answer, computed by the same function
5
+ the kernel guard runs (ADR 0121 §4). So the platform can already *say* who may reach
6
+ what, exactly and truthfully, for any composed app.
7
+
8
+ What nothing did was **notice when that answer changed**. Widening a module's policy from
9
+ a named permission to a role tier, dropping a ``require_permission`` off a route, adding
10
+ an endpoint under a mount whose policy is public — each is a one-line edit, each changes
11
+ who can reach what, and each left every gate green. The projection was emitted for a pane
12
+ and for a CLI, and read by no check.
13
+
14
+ This is the check. It reduces the graph to the part that is an authority claim, compares
15
+ it against a committed baseline, and reports the difference in the vocabulary of the
16
+ change rather than as a JSON diff: *this route's requirement moved from X to Y*, *this
17
+ module became public*, *this endpoint is new and nothing has reviewed what it requires*.
18
+
19
+ **Adoption is opt-in and half-adoption is impossible**, the shape ``api-docs-drift``
20
+ settled on: with no committed baseline the check skips with a note naming the command
21
+ that writes one, because upgrading the framework must not turn an app's gate red for a
22
+ feature it never wired. Once the file is tracked, drift is red.
23
+
24
+ **A widening is never "fixed" by regenerating.** The baseline is a review artifact: the
25
+ diff belongs in a pull request where somebody looks at it and says yes. Regenerating to
26
+ make a red go away converts the control into a formality, which is why the writer is a
27
+ separate, explicit command and never a ``--fix`` on the checker.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import json
33
+ import pathlib
34
+ from typing import Any
35
+
36
+ #: The committed baseline, at the project root beside the other tracked artifacts.
37
+ SURFACE_ARTIFACT = "authz-surface.json"
38
+
39
+ #: What an app runs once to adopt the check, and again — deliberately, with the diff in
40
+ #: front of a reviewer — to accept a change.
41
+ ADOPT_HINT = "terp inspect access --app app.main:build --format surface > authz-surface.json"
42
+
43
+
44
+ def authz_surface(graph: dict[str, Any]) -> dict[str, Any]:
45
+ """Reduce an access graph to the part that is an authority claim.
46
+
47
+ Deliberately **not** the whole graph. The graph carries things that move for honest
48
+ reasons — a model's traits, the reconciliation's ``omitted_routes``, whatever a
49
+ future field adds — and a baseline that churned on those would be regenerated
50
+ reflexively until nobody read the diff. What survives here is what answers "who may
51
+ reach what": the role ladder, each permission's floor, and per module its policy plus
52
+ each endpoint's requirement, extra permissions, and per-rung outcome.
53
+
54
+ Sorted at every level, because the baseline is a **diffable** artifact: a set
55
+ rendered in registration order would produce a reviewable diff only by luck.
56
+ """
57
+ roles = [
58
+ {"name": role["name"], "rank": role["rank"]}
59
+ for role in sorted(graph.get("roles", ()), key=lambda item: item["rank"])
60
+ ]
61
+ permissions = [
62
+ {"name": permission["name"], "min_role": permission["min_role"]}
63
+ for permission in sorted(
64
+ graph.get("permissions", ()), key=lambda item: item["name"]
65
+ )
66
+ ]
67
+ modules = [
68
+ {
69
+ "name": module["name"],
70
+ "policy": module.get("policy"),
71
+ "access": module.get("access"),
72
+ "endpoints": [
73
+ {
74
+ "path": endpoint["path"],
75
+ "methods": sorted(endpoint.get("methods", ())),
76
+ "requirement": endpoint["requirement"],
77
+ "extra_permissions": sorted(endpoint.get("extra_permissions", ())),
78
+ "by_role": {
79
+ rung["role"]: rung["reason"]
80
+ for rung in endpoint.get("by_role", ())
81
+ },
82
+ }
83
+ for endpoint in sorted(
84
+ module.get("endpoints", ()),
85
+ key=lambda item: (item["path"], sorted(item.get("methods", ()))),
86
+ )
87
+ ],
88
+ }
89
+ for module in sorted(graph.get("modules", ()), key=lambda item: item["name"])
90
+ ]
91
+ return {"authz_surface": 1, "roles": roles, "permissions": permissions, "modules": modules}
92
+
93
+
94
+ def render_authz_surface(graph: dict[str, Any]) -> str:
95
+ """The baseline as the bytes a project commits (trailing newline, stable key order)."""
96
+ return json.dumps(authz_surface(graph), indent=2, sort_keys=True) + "\n"
97
+
98
+
99
+ def _endpoints(surface: dict[str, Any]) -> dict[tuple[str, str], dict[str, Any]]:
100
+ """Every endpoint keyed by ``(path, methods)`` — the identity a diff compares on."""
101
+ found: dict[tuple[str, str], dict[str, Any]] = {}
102
+ for module in surface.get("modules", ()):
103
+ for endpoint in module.get("endpoints", ()):
104
+ key = (endpoint["path"], ",".join(endpoint.get("methods", ())) or "WS")
105
+ found[key] = endpoint
106
+ return found
107
+
108
+
109
+ def diff_authz_surface(baseline: dict[str, Any], current: dict[str, Any]) -> list[str]:
110
+ """Every authority difference, each phrased as the change a reviewer has to approve.
111
+
112
+ Ordered most-alarming first — a route that lost its requirement before one that
113
+ gained a new rung — because the first lines are the ones that get read.
114
+ """
115
+ differences: list[str] = []
116
+
117
+ before, after = _endpoints(baseline), _endpoints(current)
118
+ for key in sorted(before.keys() & after.keys()):
119
+ was, now = before[key], after[key]
120
+ path, methods = key
121
+ if was["requirement"] != now["requirement"]:
122
+ differences.append(
123
+ f"{methods} {path}: requirement {was['requirement']!r} -> "
124
+ f"{now['requirement']!r}"
125
+ )
126
+ if was.get("extra_permissions") != now.get("extra_permissions"):
127
+ differences.append(
128
+ f"{methods} {path}: required grants "
129
+ f"{was.get('extra_permissions')} -> {now.get('extra_permissions')}"
130
+ )
131
+ widened = [
132
+ role
133
+ for role, reason in now.get("by_role", {}).items()
134
+ if reason in {"allowed", "allowed_in_module", "public"}
135
+ and was.get("by_role", {}).get(role)
136
+ not in {"allowed", "allowed_in_module", "public"}
137
+ ]
138
+ if widened:
139
+ differences.append(
140
+ f"{methods} {path}: now reachable by {sorted(widened)} "
141
+ "(it was not before)"
142
+ )
143
+
144
+ for key in sorted(after.keys() - before.keys()):
145
+ path, methods = key
146
+ differences.append(
147
+ f"{methods} {path}: NEW endpoint, requirement "
148
+ f"{after[key]['requirement']!r} - nothing has reviewed what it requires"
149
+ )
150
+ for key in sorted(before.keys() - after.keys()):
151
+ path, methods = key
152
+ differences.append(f"{methods} {path}: gone from the surface")
153
+
154
+ # The ladder itself, last: a changed rank silently re-scores every floor above it.
155
+ if baseline.get("roles") != current.get("roles"):
156
+ differences.append(
157
+ f"the role ladder changed: {baseline.get('roles')} -> {current.get('roles')}"
158
+ )
159
+ if baseline.get("permissions") != current.get("permissions"):
160
+ differences.append("the declared permissions or their floors changed")
161
+ return differences
162
+
163
+
164
+ def read_baseline(root: pathlib.Path) -> dict[str, Any] | None:
165
+ """The committed baseline, or ``None`` when the project has not adopted the check."""
166
+ path = root / SURFACE_ARTIFACT
167
+ if not path.is_file():
168
+ return None
169
+ return json.loads(path.read_text(encoding="utf-8"))
@@ -28,6 +28,8 @@ import shutil
28
28
  import subprocess
29
29
  from collections.abc import Callable, Sequence
30
30
 
31
+ from terp.cli import ports
32
+
31
33
  _DEFAULT_COMPOSE = "docker-compose.yml"
32
34
 
33
35
  #: Lines of a failed service's log to show. Enough for a traceback and the line above it;
@@ -209,6 +211,24 @@ def run_docker_dev_command(
209
211
  raise SystemExit(
210
212
  f"compose file not found: {path} (looked under --root {root!r})"
211
213
  )
214
+ # Before the stack, not after it: the compose file requires its host ports to
215
+ # be assigned, and this is what makes that requirement invisible to somebody
216
+ # who has never heard of `terp ports`. One command from a checkout to a
217
+ # running app, which is the promise this command already makes and the
218
+ # property ADR 0134 decision 1 refuses to spend in order to enforce decision
219
+ # 3. Never fatal — `ensure_assigned` reports instead of raising, because a
220
+ # start that dies over a ledger is worse than one that lets compose speak.
221
+ #
222
+ # Assigned beside the COMPOSE FILE, not beside --root. Compose's project
223
+ # directory defaults to the directory of the first `-f`, and that is the only
224
+ # `.env` it reads — so publishing into --root would write the assignment into
225
+ # a file this very command then ignores, and the start would fail on a
226
+ # required variable that had just been "set". The two coincide in the ordinary
227
+ # case and part company for an absolute or nested --compose-file, which is
228
+ # exactly where the mistake would have been silent.
229
+ _, note = ports.ensure_assigned(path.parent)
230
+ if note:
231
+ print(note)
212
232
  status = (runner or _run)(docker_dev_argv(path, project_name=project_name))
213
233
  message = f"docker compose watch exited with status {status}"
214
234
  if status == 0: