terp-cli 0.35.0__tar.gz → 0.36.1__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 (75) hide show
  1. {terp_cli-0.35.0 → terp_cli-0.36.1}/PKG-INFO +9 -9
  2. {terp_cli-0.35.0 → terp_cli-0.36.1}/pyproject.toml +9 -9
  3. {terp_cli-0.35.0 → terp_cli-0.36.1}/signer/Dockerfile +2 -2
  4. {terp_cli-0.35.0 → terp_cli-0.36.1}/src/terp/cli/__init__.py +96 -15
  5. terp_cli-0.36.1/src/terp/cli/api_contract.py +385 -0
  6. terp_cli-0.36.1/src/terp/cli/api_contract_bounds.py +70 -0
  7. terp_cli-0.36.1/src/terp/cli/api_contract_check.py +155 -0
  8. terp_cli-0.36.1/src/terp/cli/api_contract_schemas.py +458 -0
  9. terp_cli-0.36.1/src/terp/cli/api_contract_shapes.py +294 -0
  10. {terp_cli-0.35.0 → terp_cli-0.36.1}/src/terp/cli/openapi.py +58 -8
  11. {terp_cli-0.35.0 → terp_cli-0.36.1}/src/terp/cli/release/_common.py +9 -4
  12. {terp_cli-0.35.0 → terp_cli-0.36.1}/src/terp/cli/release/cli.py +7 -2
  13. terp_cli-0.36.1/src/terp/cli/release/gate.py +293 -0
  14. {terp_cli-0.35.0 → terp_cli-0.36.1}/src/terp/cli/release/manifest.py +20 -2
  15. {terp_cli-0.35.0 → terp_cli-0.36.1}/src/terp/cli/release/sign.py +12 -8
  16. {terp_cli-0.35.0 → terp_cli-0.36.1}/src/terp/cli/reseal.py +132 -37
  17. {terp_cli-0.35.0 → terp_cli-0.36.1}/src/terp/cli/verify.py +116 -22
  18. terp_cli-0.35.0/src/terp/cli/release/gate.py +0 -146
  19. {terp_cli-0.35.0 → terp_cli-0.36.1}/.gitignore +0 -0
  20. {terp_cli-0.35.0 → terp_cli-0.36.1}/signer/terp-arch-releases.json +0 -0
  21. {terp_cli-0.35.0 → terp_cli-0.36.1}/src/terp/cli/_appref.py +0 -0
  22. {terp_cli-0.35.0 → terp_cli-0.36.1}/src/terp/cli/_engine.py +0 -0
  23. {terp_cli-0.35.0 → terp_cli-0.36.1}/src/terp/cli/_output.py +0 -0
  24. {terp_cli-0.35.0 → terp_cli-0.36.1}/src/terp/cli/_subjects.py +0 -0
  25. {terp_cli-0.35.0 → terp_cli-0.36.1}/src/terp/cli/access.py +0 -0
  26. {terp_cli-0.35.0 → terp_cli-0.36.1}/src/terp/cli/apidocs.py +0 -0
  27. {terp_cli-0.35.0 → terp_cli-0.36.1}/src/terp/cli/authz_surface.py +0 -0
  28. {terp_cli-0.35.0 → terp_cli-0.36.1}/src/terp/cli/capabilities.py +0 -0
  29. {terp_cli-0.35.0 → terp_cli-0.36.1}/src/terp/cli/deploy_safety.py +0 -0
  30. {terp_cli-0.35.0 → terp_cli-0.36.1}/src/terp/cli/deployment_adopt.py +0 -0
  31. {terp_cli-0.35.0 → terp_cli-0.36.1}/src/terp/cli/deployment_app.py +0 -0
  32. {terp_cli-0.35.0 → terp_cli-0.36.1}/src/terp/cli/deployment_check.py +0 -0
  33. {terp_cli-0.35.0 → terp_cli-0.36.1}/src/terp/cli/deployment_declare.py +0 -0
  34. {terp_cli-0.35.0 → terp_cli-0.36.1}/src/terp/cli/deployment_render.py +0 -0
  35. {terp_cli-0.35.0 → terp_cli-0.36.1}/src/terp/cli/dev.py +0 -0
  36. {terp_cli-0.35.0 → terp_cli-0.36.1}/src/terp/cli/docker.py +0 -0
  37. {terp_cli-0.35.0 → terp_cli-0.36.1}/src/terp/cli/docker_masks.py +0 -0
  38. {terp_cli-0.35.0 → terp_cli-0.36.1}/src/terp/cli/envfile.py +0 -0
  39. {terp_cli-0.35.0 → terp_cli-0.36.1}/src/terp/cli/envrequired.py +0 -0
  40. {terp_cli-0.35.0 → terp_cli-0.36.1}/src/terp/cli/envschema.py +0 -0
  41. {terp_cli-0.35.0 → terp_cli-0.36.1}/src/terp/cli/envseams.py +0 -0
  42. {terp_cli-0.35.0 → terp_cli-0.36.1}/src/terp/cli/fmt.py +0 -0
  43. {terp_cli-0.35.0 → terp_cli-0.36.1}/src/terp/cli/grants.py +0 -0
  44. {terp_cli-0.35.0 → terp_cli-0.36.1}/src/terp/cli/heartbeat.py +0 -0
  45. {terp_cli-0.35.0 → terp_cli-0.36.1}/src/terp/cli/jobs.py +0 -0
  46. {terp_cli-0.35.0 → terp_cli-0.36.1}/src/terp/cli/leases.py +0 -0
  47. {terp_cli-0.35.0 → terp_cli-0.36.1}/src/terp/cli/locks.py +0 -0
  48. {terp_cli-0.35.0 → terp_cli-0.36.1}/src/terp/cli/module_roles.py +0 -0
  49. {terp_cli-0.35.0 → terp_cli-0.36.1}/src/terp/cli/outbox.py +0 -0
  50. {terp_cli-0.35.0 → terp_cli-0.36.1}/src/terp/cli/ports.py +0 -0
  51. {terp_cli-0.35.0 → terp_cli-0.36.1}/src/terp/cli/profiles.py +0 -0
  52. {terp_cli-0.35.0 → terp_cli-0.36.1}/src/terp/cli/py.typed +0 -0
  53. {terp_cli-0.35.0 → terp_cli-0.36.1}/src/terp/cli/release/__init__.py +0 -0
  54. {terp_cli-0.35.0 → terp_cli-0.36.1}/src/terp/cli/release/_compare.py +0 -0
  55. {terp_cli-0.35.0 → terp_cli-0.36.1}/src/terp/cli/release/_dockerfile.py +0 -0
  56. {terp_cli-0.35.0 → terp_cli-0.36.1}/src/terp/cli/release/_git.py +0 -0
  57. {terp_cli-0.35.0 → terp_cli-0.36.1}/src/terp/cli/release/_image.py +0 -0
  58. {terp_cli-0.35.0 → terp_cli-0.36.1}/src/terp/cli/release/_locks.py +0 -0
  59. {terp_cli-0.35.0 → terp_cli-0.36.1}/src/terp/cli/release/_plan.py +0 -0
  60. {terp_cli-0.35.0 → terp_cli-0.36.1}/src/terp/cli/release/_registry.py +0 -0
  61. {terp_cli-0.35.0 → terp_cli-0.36.1}/src/terp/cli/release/_render.py +0 -0
  62. {terp_cli-0.35.0 → terp_cli-0.36.1}/src/terp/cli/release/_sandbox.py +0 -0
  63. {terp_cli-0.35.0 → terp_cli-0.36.1}/src/terp/cli/release/build.py +0 -0
  64. {terp_cli-0.35.0 → terp_cli-0.36.1}/src/terp/cli/release/dsse.py +0 -0
  65. {terp_cli-0.35.0 → terp_cli-0.36.1}/src/terp/cli/release/promote.py +0 -0
  66. {terp_cli-0.35.0 → terp_cli-0.36.1}/src/terp/cli/routes.py +0 -0
  67. {terp_cli-0.35.0 → terp_cli-0.36.1}/src/terp/cli/scaffold.py +0 -0
  68. {terp_cli-0.35.0 → terp_cli-0.36.1}/src/terp/cli/schema.py +0 -0
  69. {terp_cli-0.35.0 → terp_cli-0.36.1}/src/terp/cli/seed.py +0 -0
  70. {terp_cli-0.35.0 → terp_cli-0.36.1}/src/terp/cli/service_accounts.py +0 -0
  71. {terp_cli-0.35.0 → terp_cli-0.36.1}/src/terp/cli/smoke.py +0 -0
  72. {terp_cli-0.35.0 → terp_cli-0.36.1}/src/terp/cli/users.py +0 -0
  73. {terp_cli-0.35.0 → terp_cli-0.36.1}/src/terp/cli/version.py +0 -0
  74. {terp_cli-0.35.0 → terp_cli-0.36.1}/src/terp/cli/workbench.py +0 -0
  75. {terp_cli-0.35.0 → terp_cli-0.36.1}/src/terp/cli/workbench_adopt.py +0 -0
@@ -1,23 +1,23 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: terp-cli
3
- Version: 0.35.0
3
+ Version: 0.36.1
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.35.0
11
- Requires-Dist: terp-core==0.35.0
12
- Requires-Dist: terp-migrations==0.35.0
10
+ Requires-Dist: terp-arch==0.36.1
11
+ Requires-Dist: terp-core==0.36.1
12
+ Requires-Dist: terp-migrations==0.36.1
13
13
  Provides-Extra: jobs
14
- Requires-Dist: terp-cap-outbox==0.35.0; extra == 'jobs'
15
- Requires-Dist: terp-cap-scheduler-apscheduler==0.35.0; extra == 'jobs'
14
+ Requires-Dist: terp-cap-outbox==0.36.1; extra == 'jobs'
15
+ Requires-Dist: terp-cap-scheduler-apscheduler==0.36.1; extra == 'jobs'
16
16
  Provides-Extra: release
17
17
  Requires-Dist: cryptography>=46; extra == 'release'
18
18
  Requires-Dist: packaging>=24; extra == 'release'
19
- Requires-Dist: terp-cap-egress==0.35.0; extra == 'release'
19
+ Requires-Dist: terp-cap-egress==0.36.1; extra == 'release'
20
20
  Provides-Extra: scheduler
21
- Requires-Dist: terp-cap-scheduler-apscheduler==0.35.0; extra == 'scheduler'
21
+ Requires-Dist: terp-cap-scheduler-apscheduler==0.36.1; extra == 'scheduler'
22
22
  Provides-Extra: worker
23
- Requires-Dist: terp-cap-outbox==0.35.0; extra == 'worker'
23
+ Requires-Dist: terp-cap-outbox==0.36.1; extra == 'worker'
@@ -4,18 +4,18 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "terp-cli"
7
- version = "0.35.0"
7
+ version = "0.36.1"
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.35.0",
12
+ "terp-core==0.36.1",
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.35.0",
15
+ "terp-migrations==0.36.1",
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.35.0",
18
+ "terp-arch==0.36.1",
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.35.0"]
33
- scheduler = ["terp-cap-scheduler-apscheduler==0.35.0"]
32
+ worker = ["terp-cap-outbox==0.36.1"]
33
+ scheduler = ["terp-cap-scheduler-apscheduler==0.36.1"]
34
34
  jobs = [
35
- "terp-cap-outbox==0.35.0",
36
- "terp-cap-scheduler-apscheduler==0.35.0",
35
+ "terp-cap-outbox==0.36.1",
36
+ "terp-cap-scheduler-apscheduler==0.36.1",
37
37
  ]
38
38
  # `terp release` and `terp promote` (ADR 0187): the DSSE signature (cryptography), the lock's
39
39
  # markers (packaging), and the one declared way to a registry, an index and the vulnerability
@@ -42,7 +42,7 @@ jobs = [
42
42
  release = [
43
43
  "cryptography>=46",
44
44
  "packaging>=24",
45
- "terp-cap-egress==0.35.0",
45
+ "terp-cap-egress==0.36.1",
46
46
  ]
47
47
 
48
48
  [project.scripts]
@@ -26,7 +26,7 @@
26
26
  # (tests/release_lane/).
27
27
 
28
28
  # python:3.14-slim, the base-image table's interpreter (terp-arch's base-images.json).
29
- FROM python@sha256:fbecd461fc72d21b2143c6eaba25143738079154a6d0ea3da3aec724f50eed4a AS install
29
+ FROM python@sha256:930e1abba7c4e8f96ed93e8012c35d174d7732752731bbd5e8e757d7ec6f3966 AS install
30
30
  COPY wheels/ /wheels/
31
31
  COPY requirements.txt /requirements.txt
32
32
  # Every distribution by its hash: the third-party ones as uv.lock pins them, the framework's as
@@ -41,7 +41,7 @@ FROM zricethezav/gitleaks@sha256:c00b6bd0aeb3071cbcb79009cb16a60dd9e0a7c60e2be9a
41
41
  # The docker client 29.9.2, by digest (docker:29.9.2-cli, read 2026-10-22).
42
42
  FROM docker@sha256:5e6baf67325a79f64ed3bed6863954d5e05d0f3d4e57cbdb14809346814bbfdb AS docker
43
43
 
44
- FROM python@sha256:fbecd461fc72d21b2143c6eaba25143738079154a6d0ea3da3aec724f50eed4a
44
+ FROM python@sha256:930e1abba7c4e8f96ed93e8012c35d174d7732752731bbd5e8e757d7ec6f3966
45
45
  RUN apt-get update \
46
46
  && apt-get install -y --no-install-recommends git \
47
47
  && rm -rf /var/lib/apt/lists/* \
@@ -41,7 +41,7 @@ from terp.cli.jobs import (
41
41
  run_scheduler_command,
42
42
  run_worker_command,
43
43
  )
44
- from terp.cli.openapi import _load_app, export_openapi
44
+ from terp.cli.openapi import _load_app, export_openapi, run_openapi_command
45
45
  from terp.cli.routes import run_routes_command
46
46
  from terp.cli.profiles import DEFAULT_PROFILE, profile_names
47
47
  from terp.cli.scaffold import new_module, new_module_message
@@ -133,6 +133,9 @@ Then the codegen chain, in this order — each step reads what the one before it
133
133
 
134
134
  `terp verify` runs the drift halves of that chain and names the command to re-run when
135
135
  something is stale, so it is the one to reach for if you are unsure what is out of date.
136
+ An API-only app (rendered with the template's `frontend: false`, ADR 0202) has no
137
+ frontend: its chain is `terp migrate make`, `terp openapi` and `terp check`, and
138
+ `terp new module` writes no frontend slot where there is none.
136
139
  Policy.default() = authenticated; read VIEWER, write EDITOR.
137
140
 
138
141
  FORMATTING: `terp fmt`, NOT `ruff format .`
@@ -339,11 +342,15 @@ Both are comma-separated. The procedure, in this order:
339
342
  1. Put the old key in BOTH lists and the new one in SECRET_KEY. Deploy.
340
343
  2. Run `terp secrets reseal` where the app runs (its own container, its own
341
344
  environment). It rewrites every value sealed under a fallback under SECRET_KEY,
345
+ one nested in a JSON column included (a queued mail in the outbox, ADR 0199),
342
346
  one transaction per table, refuses while a migration is pending, is safe to run
343
347
  twice, and exits 0 only when nothing is left. `--check` counts and writes nothing.
344
348
  3. Drop SECRET_KEY_DECRYPT_FALLBACKS once reseal reports nothing left. Keep the old
345
349
  key itself while a backup sealed under it may still be restored: restoring one
346
- means listing it again and running reseal.
350
+ means listing it again and running reseal. A self-service link already mailed is
351
+ sealed under the key it was sent under, where reseal cannot reach it, so dropping
352
+ the fallback sooner than its lifetime (a day for a sign-up link by default) only
353
+ makes such a link stop working, and its owner asks for a new one.
347
354
  4. Drop SECRET_KEY_FALLBACKS once the longest signature lifetime has passed since
348
355
  step 1: REFRESH_TOKEN_TTL_SECONDS if the app issues refresh sessions, 15 minutes
349
356
  if not. Dropping it sooner loses nothing; it signs out sessions still on the old key.
@@ -1100,9 +1107,27 @@ The security declaration (SecurityConfig, refused at production boot)
1100
1107
  /openapi.json are hidden there because a production API's full schema is not public
1101
1108
  information. Development always serves them, and `terp openapi` exports the document
1102
1109
  either way - so turning this on is a choice to publish, not a way to get the file.
1110
+ Commit that file instead: `openapi.json` is the app's API contract, the one a mobile
1111
+ app or a site in another repository pins, and `terp verify --only api-contract` holds
1112
+ it to the API the app serves, naming every change to its shape, the ones that break a
1113
+ client first (ADR 0194). Who may call a route is not in it: authz-surface pins that.
1103
1114
  - headers ships a safe SecurityHeaders set; max_request_bytes (1 MiB) is the body cap and
1104
1115
  is per-mount overridable (ADR 0067); request_id_header names the correlation header
1105
1116
  ("X-Request-ID") echoed on every response.
1117
+ - A NATIVE MOBILE CLIENT stays signed in through its own routes, opt-in with a reason
1118
+ (ADR 0195): build_login_module(..., native_clients=NativeClients(reason="...")) mounts
1119
+ POST /api/v1/auth/native/login, /native/refresh and /native/logout. The first two answer
1120
+ the refresh token in the JSON body and run the same rotation, reuse detection and
1121
+ revocation as the cookie, its rotation grace window included; /native/logout takes the
1122
+ token and revokes its whole family without minting a successor. The client keeps that
1123
+ token in the OS keychain or keystore and nowhere else, and treats a 401 from
1124
+ /native/refresh as signed out. All three refuse any request with browser provenance
1125
+ (Origin, any Sec-Fetch-* header, the refresh cookie) with 403 native_client_only before
1126
+ the body is validated, so a browser stays on /login, /refresh and /logout. A HYBRID app
1127
+ (a page inside a native shell) calls them from its native layer and hands its page only
1128
+ access tokens: a page that holds the refresh token has given up what the cookie
1129
+ protects. No route that mints a credential (/login, /token, /refresh, /native/login,
1130
+ /native/refresh) accepts an Idempotency-Key; retry without one.
1106
1131
  - No terp.arch check applies - there is no module code shape to police. Enforcement is
1107
1132
  the create_app production fail-fast plus the production-readiness verify lane.
1108
1133
  """,
@@ -1213,6 +1238,21 @@ Password strength (PasswordPolicy, Tier-B)
1213
1238
  The reason is the opt-out: it is stored as `relaxed_reason` and named back to you in the
1214
1239
  BootError, so "why is this off" always has an answer that travels with the declaration.
1215
1240
  PasswordPolicy.relaxed(reason="legacy bulk import")
1241
+ - SELF-SERVICE accounts (ADR 0196) hold sign-up and reset to the same policy. They are
1242
+ opt-in and never discovered: after configure_mail(...), build the module and mount it
1243
+ build_accounts_module(SelfService(reason="members open their own accounts",
1244
+ users=users_service, throttle_store=shared_store,
1245
+ link_base="https://app.example.com"))
1246
+ for POST /api/v1/accounts/sign-up, /verify, /password-reset, /password-reset/confirm
1247
+ and /me/close. Sign-up takes only the address: the account opens when the mailed token
1248
+ is redeemed at /verify with the password, so whoever asked cannot choose it. Every
1249
+ request about an address answers 202 the same way and sends that address exactly one
1250
+ message, whether or not it has an account, and addresses compare case-insensitively.
1251
+ Tokens are sealed, expire, work once, and ride the URL fragment, so your account pages
1252
+ read them from location.hash and post them. Closing needs the password again and ends
1253
+ every session (AccountDeletion.SCRUB by default, or REMOVE: building the module refuses
1254
+ it while a reference to the user table would refuse the delete, so declare CASCADE or
1255
+ SET NULL on that reference; one the build cannot see is scrubbed instead, with a warning).
1216
1256
  - No terp.arch check applies (no module code shape to police) — enforcement is the
1217
1257
  service chokepoint plus the create_app production fail-fast.
1218
1258
  """,
@@ -1716,11 +1756,35 @@ File objects (files capability, ADR 0056/0057)
1716
1756
  objects) in a NAMED-PROFILE registry;
1717
1757
  metadata (name, type, size, sha256, storage_key, storage_profile) lives in the platform
1718
1758
  DB. storage_key/storage_profile never leave the boundary (FileRead omits both).
1719
- - Any provider is an adapter subclass (local ships; S3/Azure/NAS are each a
1720
- StorageBackend). Register each store once at the composition root:
1721
- register_storage_backend("azure-invoices", AzureBlobStorage(container="invoices"))
1722
- register_storage_backend("azure-hr", AzureBlobStorage(container="hr"))
1723
- Keep credentials in settings / sealed config — never in module code.
1759
+ - Any provider is an adapter subclass (local ships; a NAS mount is the local adapter on
1760
+ another root). Two object-store adapters ship behind extras (ADR 0201): S3Storage
1761
+ (terp-cap-files[s3]) takes the SDK's own client and a bucket; AzureBlobStorage
1762
+ (terp-cap-files[azure]) takes the SDK's ContainerClient and, for user-delegation SAS
1763
+ links, a BlobServiceClient under an Entra credential. Register each store once at the
1764
+ composition root:
1765
+ register_storage_backend("s3-media", S3Storage(boto3.client("s3"), bucket="app-media"))
1766
+ register_storage_backend("azure-invoices", AzureBlobStorage(invoices_container))
1767
+ register_storage_backend("azure-hr", AzureBlobStorage(hr_container, delegation=service))
1768
+ Keep credentials in settings / sealed config — never in module code. Building the Azure
1769
+ adapter without its extra fails at boot with the install line (StorageSdkMissingError);
1770
+ the S3 adapter takes the SDK's client, so a missing extra fails where that is built.
1771
+ - A client that cannot send a bearer (an <audio>/<video> element, a PDF viewer, a native
1772
+ client's media stack) gets a short-lived LINK to one file's bytes instead (ADR 0201):
1773
+ GET /api/v1/files/{id}/link?ttl_seconds=600&inline=true -> {url, expires_at}
1774
+ The link is minted where a download is authorized (row scope, then quarantine), by the
1775
+ store the ROW names: an S3 presigned GET or an Azure SAS where the store can sign, else
1776
+ an app-signed token the capability serves itself at GET /api/v1/files/link/{token} —
1777
+ its one public route, rate-limited, answering 404 to an expired, altered or foreign
1778
+ token. The default lifetime is an hour, the longest 24 hours; a link is not single-use
1779
+ (a player makes many range requests), its expiry is the control. inline is honoured only
1780
+ for a type a browser renders without running it (image, audio, video, PDF — not an XML
1781
+ image such as SVG, which is a document with script in it). From a module, mint through
1782
+ your own authorized row and a declared FileRef:
1783
+ link = FileService().link_for(session, invoice, "attachment_file_id", ttl=timedelta(minutes=10))
1784
+ Both content routes honour one bytes= Range (206 / 416); the local adapter seeks, an
1785
+ object store's stream is read forward. A link from a store that signs never streams
1786
+ through the API; the authenticated content route still serves any store's bytes to a
1787
+ caller who asks it to.
1724
1788
  - Pick the store per module (subclass default) or per call — never from a client:
1725
1789
  class InvoiceFileService(FileService):
1726
1790
  storage_profile = "azure-invoices"
@@ -1924,6 +1988,11 @@ Sending e-mail (terp-cap-mail)
1924
1988
  dead-letters` lists it. With the
1925
1989
  in-process default queue (no worker) the mail is sent inline, and a failing relay
1926
1990
  fails the request that asked for it.
1991
+ - The queued message is SEALED at rest (ADR 0199): the outbox row's payload holds the
1992
+ Message-ID and one value sealed under SECRET_KEY, never an address or the text. After a
1993
+ key rotation keep SECRET_KEY_DECRYPT_FALLBACKS until `terp secrets reseal` reports
1994
+ nothing left: a queued mail no configured key opens is never sent, and fails with
1995
+ MailSealError, which names that remedy, until it dead-letters.
1927
1996
  - Test without a relay:
1928
1997
  from terp.capabilities.mail import (
1929
1998
  CapturingMailTransport, MailSettings, configure_mail, reset_mail)
@@ -2757,7 +2826,12 @@ FOUR STATES, EXACTLY ONE AT A TIME
2757
2826
  "build": "generated" the framework renders Dockerfile.prod,
2758
2827
  frontend/Dockerfile.prod and docker-compose.prod.yml
2759
2828
  from this file (`terp deployment render`), and a
2760
- committed copy that is not the render is refused
2829
+ committed copy that is not the render is refused.
2830
+ frontend/Dockerfile.prod only beside a web unit: an api
2831
+ unit with no web unit in front of it is the deployment's
2832
+ only way in and publishes ${API_PORT:-23200}:8000 (ADR
2833
+ 0202) -- the shape an app rendered API-only (the
2834
+ template's `frontend: false`) has from the start
2761
2835
  {"unmanaged": true, "reason"} no tool can drive this deploy; the checks are off and
2762
2836
  a tool shows the reason. Nothing may stand beside it.
2763
2837
 
@@ -2854,7 +2928,8 @@ or an override; externalDatabase beside a database unit; a database or store wit
2854
2928
  or two of a kind; a one-shot that declares `terp migrate upgrade` (drop the command: the
2855
2929
  one-shot without one is the migration step) or a second step; a role with no command of
2856
2930
  its own and none declared; a web unit with a command, with no single api to proxy to, or
2857
- without frontend/package-lock.json; an api without both health paths; a start-order cycle
2931
+ without frontend/package-lock.json; two or more api units with no web unit in front of
2932
+ them (which one callers reach would be a guess); an api without both health paths; a start-order cycle
2858
2933
  or a unit it names that is not declared; a volume without its mount, mounted anywhere but
2859
2934
  var/files, on a unit not on the backend image, or on a unit that already mounts another
2860
2935
  volume there (Compose would mount only the last of them, without a word); any system
@@ -3000,14 +3075,20 @@ build container holds a credential or writes into what is built. Each takes the
3000
3075
  <registry>/<image>:<tag> with the credential your docker configuration holds for this
3001
3076
  release. It writes terp-release-build.json: BuildKit's own digests.
3002
3077
  uv run terp release gate --sha <sha> --build terp-release-build.json
3003
- runs in a clean checkout of the commit: `terp verify --profile release --format
3004
- assurance` with npm's scripts off, then the schedule catalog, read by running the pushed
3005
- image sealed (no network, read-only, its own non-root user). It holds no push credential
3006
- and no key. It writes terp-release-gate.json.
3078
+ runs in a clean checkout of the commit: `terp verify --profile release` once, with npm's
3079
+ scripts off, recording every check's verdict and the release-assurance claim those same
3080
+ results compose (no check's output), then the schedule catalog, read by running the
3081
+ pushed image sealed (no network, read-only, its own non-root user). It holds no push
3082
+ credential and no key. It writes terp-release-gate.json, and exits 1 unless every check
3083
+ passed and the claim holds, naming each red check. Run it from the commit's own
3084
+ environment: sign reads the document only in the version the commit's pinned terp-cli
3085
+ writes (ADR 0200).
3007
3086
  terp release sign --sha <sha> --tag <tag> --registry ... --build ... --gate ... \\
3008
3087
  --hosts separated --signer '["your-kms-signer", "--key", "release"]' --public-key k.pub
3009
3088
  runs from the signer image, never the commit's own toolchain, and runs nothing of the
3010
- app: it repeats the refusals, renders a generated app's files with the terp-arch the
3089
+ app: it repeats the refusals, refuses a gate with a red check or a claim that does not
3090
+ hold (ADR 0200; a commit pinning terp-cli below 0.36.1 has a gate that records the claim
3091
+ alone, and is held to it), renders a generated app's files with the terp-arch the
3011
3092
  commit pins (fetched by hash, run in a process of its own), reads the backend image from
3012
3093
  the registry as data and holds it to the tree and the lock, scans for known
3013
3094
  vulnerabilities and for secrets in the history, and signs the manifest as a DSSE envelope
@@ -5182,7 +5263,7 @@ def main(argv: Sequence[str] | None = None) -> None:
5182
5263
  emit(f"wrote {path}")
5183
5264
  return
5184
5265
  if args.command == "openapi":
5185
- emit(f"wrote {export_openapi(args.app, out=args.out, app_root=args.app_root)}")
5266
+ emit(run_openapi_command(args.app, out=args.out, app_root=args.app_root))
5186
5267
  return
5187
5268
  if args.command == "routes":
5188
5269
  emit(