techtree 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. techtree/__init__.py +35 -0
  2. techtree/__main__.py +14 -0
  3. techtree/canonical.py +239 -0
  4. techtree/catalog/__init__.py +25 -0
  5. techtree/catalog/repository.py +400 -0
  6. techtree/catalog/service.py +419 -0
  7. techtree/cli/__init__.py +1 -0
  8. techtree/cli/app.py +416 -0
  9. techtree/cli/commands/__init__.py +1 -0
  10. techtree/cli/commands/climb.py +1223 -0
  11. techtree/cli/commands/doctor.py +147 -0
  12. techtree/cli/commands/engine.py +207 -0
  13. techtree/cli/commands/proof.py +556 -0
  14. techtree/cli/commands/publish.py +447 -0
  15. techtree/cli/commands/release.py +303 -0
  16. techtree/cli/commands/run.py +1067 -0
  17. techtree/cli/commands/setup.py +181 -0
  18. techtree/cli/commands/skill.py +221 -0
  19. techtree/cli/commands/uplift.py +698 -0
  20. techtree/cli/commands/withdraw.py +212 -0
  21. techtree/cli/confirm.py +47 -0
  22. techtree/cli/context.py +96 -0
  23. techtree/cli/invoke.py +220 -0
  24. techtree/cli/output.py +280 -0
  25. techtree/constants.py +138 -0
  26. techtree/crypto.py +128 -0
  27. techtree/doctor/__init__.py +1 -0
  28. techtree/doctor/checks.py +675 -0
  29. techtree/doctor/execution_checks.py +435 -0
  30. techtree/doctor/service.py +326 -0
  31. techtree/drafts/__init__.py +32 -0
  32. techtree/drafts/source.py +146 -0
  33. techtree/drafts/store.py +992 -0
  34. techtree/engines/__init__.py +1 -0
  35. techtree/engines/bundle.py +251 -0
  36. techtree/engines/installer.py +679 -0
  37. techtree/engines/registry.py +235 -0
  38. techtree/engines/runner.py +170 -0
  39. techtree/errors.py +262 -0
  40. techtree/fs.py +234 -0
  41. techtree/harness.py +108 -0
  42. techtree/identity/__init__.py +41 -0
  43. techtree/identity/models.py +113 -0
  44. techtree/identity/service.py +199 -0
  45. techtree/identity/store.py +263 -0
  46. techtree/ids.py +85 -0
  47. techtree/manifests/__init__.py +39 -0
  48. techtree/manifests/builder.py +433 -0
  49. techtree/manifests/compare.py +376 -0
  50. techtree/models/__init__.py +282 -0
  51. techtree/models/base.py +201 -0
  52. techtree/models/campaign.py +484 -0
  53. techtree/models/catalog.py +227 -0
  54. techtree/models/cli.py +151 -0
  55. techtree/models/climb.py +254 -0
  56. techtree/models/data_policy.py +130 -0
  57. techtree/models/engine.py +156 -0
  58. techtree/models/episode_receipt.py +130 -0
  59. techtree/models/evaluation_backend.py +113 -0
  60. techtree/models/experiment.py +154 -0
  61. techtree/models/run.py +214 -0
  62. techtree/models/skill.py +156 -0
  63. techtree/models/uplift_report.py +158 -0
  64. techtree/models/validation.py +299 -0
  65. techtree/paths.py +116 -0
  66. techtree/presentation/__init__.py +31 -0
  67. techtree/presentation/build.py +1242 -0
  68. techtree/presentation/compact.py +246 -0
  69. techtree/presentation/evidence.py +169 -0
  70. techtree/presentation/models.py +358 -0
  71. techtree/presentation/rich.py +312 -0
  72. techtree/presentation/sanitize.py +156 -0
  73. techtree/publication/__init__.py +44 -0
  74. techtree/publication/address.py +180 -0
  75. techtree/publication/coordinates.py +26 -0
  76. techtree/publication/journal.py +212 -0
  77. techtree/publication/keccak.py +183 -0
  78. techtree/publication/models.py +209 -0
  79. techtree/publication/offer.py +35 -0
  80. techtree/publication/service.py +618 -0
  81. techtree/publication/transport.py +296 -0
  82. techtree/publication/verify.py +242 -0
  83. techtree/publication/withdraw.py +156 -0
  84. techtree/py.typed +0 -0
  85. techtree/receipts/__init__.py +52 -0
  86. techtree/receipts/bundle.py +578 -0
  87. techtree/receipts/compare.py +1065 -0
  88. techtree/receipts/episode.py +672 -0
  89. techtree/receipts/execution.py +630 -0
  90. techtree/receipts/observed.py +474 -0
  91. techtree/receipts/set.py +336 -0
  92. techtree/receipts/uplift.py +655 -0
  93. techtree/receipts/verify.py +1055 -0
  94. techtree/release/__init__.py +9 -0
  95. techtree/release/bootstrap.py +509 -0
  96. techtree/release/checks.py +376 -0
  97. techtree/release/document.py +125 -0
  98. techtree/release/generate.py +221 -0
  99. techtree/release/models.py +293 -0
  100. techtree/release/provenance.py +109 -0
  101. techtree/resources/catalog/campaigns/hello-world-climb.json +1 -0
  102. techtree/resources/catalog/catalog.json +32 -0
  103. techtree/resources/catalog/climbs/hello-world-climb.json +1 -0
  104. techtree/resources/catalog/data-policies/hello-world-climb.json +1 -0
  105. techtree/resources/catalog/taskset-validations/hello-world-climb.json +1 -0
  106. techtree/resources/catalog/validation-evidence/hello-world-climb.json +1 -0
  107. techtree/resources/engines/default/engine.json +20 -0
  108. techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/__init__.py +7 -0
  109. techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/algorithm.py +136 -0
  110. techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/dataset.py +156 -0
  111. techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/env.py +48 -0
  112. techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/taskset.py +163 -0
  113. techtree/resources/engines/default/packages/procedure-transfer-v1/pyproject.toml +13 -0
  114. techtree/resources/engines/default/pyproject.toml +23 -0
  115. techtree/resources/engines/default/tools/inspect_taskset.py +124 -0
  116. techtree/resources/engines/default/tools/normalize_eval_output.py +470 -0
  117. techtree/resources/engines/default/tools/normalize_validation.py +222 -0
  118. techtree/resources/engines/default/uv.lock +1758 -0
  119. techtree/resources/harness/hermes-agent-0.19.0.json +69 -0
  120. techtree/resources/release/build-provenance.json +4 -0
  121. techtree/resources/release/release-core.json +24 -0
  122. techtree/runs/__init__.py +31 -0
  123. techtree/runs/artifacts.py +750 -0
  124. techtree/runs/child_registry.py +228 -0
  125. techtree/runs/events.py +478 -0
  126. techtree/runs/executor.py +140 -0
  127. techtree/runs/fake.py +741 -0
  128. techtree/runs/launcher.py +253 -0
  129. techtree/runs/machine.py +489 -0
  130. techtree/runs/real.py +789 -0
  131. techtree/runs/service.py +616 -0
  132. techtree/runs/store.py +555 -0
  133. techtree/runs/validation.py +259 -0
  134. techtree/runs/variants.py +684 -0
  135. techtree/settings.py +143 -0
  136. techtree/skills/__init__.py +14 -0
  137. techtree/skills/archive.py +282 -0
  138. techtree/skills/policy.py +62 -0
  139. techtree/skills/scanner.py +394 -0
  140. techtree/skills/service.py +752 -0
  141. techtree/skills/starter.py +434 -0
  142. techtree/tasksets/__init__.py +1 -0
  143. techtree/tasksets/membership.py +269 -0
  144. techtree/tasksets/provider.py +207 -0
  145. techtree/tasksets/resolver.py +311 -0
  146. techtree/tasksets/service.py +484 -0
  147. techtree/tasksets/verifiers_cli.py +538 -0
  148. techtree/uplift/__init__.py +20 -0
  149. techtree/uplift/context.py +544 -0
  150. techtree/uplift/derive.py +203 -0
  151. techtree/uplift/public_tasks.py +151 -0
  152. techtree/uplift/service.py +719 -0
  153. techtree/uplift/source.py +160 -0
  154. techtree/verifiers/__init__.py +31 -0
  155. techtree/verifiers/budget.py +219 -0
  156. techtree/verifiers/child.py +633 -0
  157. techtree/verifiers/compiler.py +432 -0
  158. techtree/verifiers/config.py +365 -0
  159. techtree/verifiers/credentials.py +321 -0
  160. techtree/verifiers/image.py +126 -0
  161. techtree/verifiers/models.py +527 -0
  162. techtree/verifiers/outputs.py +368 -0
  163. techtree/verifiers/progress.py +192 -0
  164. techtree/verifiers/supervisor.py +341 -0
  165. techtree/verifiers/verify.py +782 -0
  166. techtree/version.py +39 -0
  167. techtree/worker/__init__.py +18 -0
  168. techtree/worker/execute.py +487 -0
  169. techtree/worker/main.py +57 -0
  170. techtree-0.1.0.dist-info/METADATA +344 -0
  171. techtree-0.1.0.dist-info/RECORD +174 -0
  172. techtree-0.1.0.dist-info/WHEEL +4 -0
  173. techtree-0.1.0.dist-info/entry_points.txt +3 -0
  174. techtree-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,296 @@
1
+ """The one place in this package that opens a socket. Decisions document 0038.
2
+
3
+ Everything else about publishing — reading a bundle, checking it, asking a
4
+ person, writing a receipt down, recording the outcome — is local work on local
5
+ files, and it is testable exactly as the rest of this project is. One step is
6
+ not: the request itself. There is no public endpoint yet, and there will never
7
+ be one a unit test may reach.
8
+
9
+ So the request is a seam and nothing else is. :class:`PublicationTransport` takes
10
+ bytes and an address and returns bytes; it knows nothing about what a submission
11
+ is, what a receipt is, or whether either verifies. That keeps the substitutable
12
+ part as small as a thing can be: a test replaces one method, and every decision
13
+ the product makes about publishing is still the real code making it.
14
+
15
+ Two rules hold here rather than at the call site, because they are properties of
16
+ the transport rather than of the product.
17
+
18
+ *Only ``https``.* What travels is a signed proof bundle, and sometimes an
19
+ address somebody typed. Neither goes over a channel anybody can read or rewrite,
20
+ and a scheme that permitted it would be a setting somebody could get wrong once.
21
+
22
+ *Nothing in the address bar.* The submission is a request body. Nothing this
23
+ module sends is ever appended to a URL or a query string, so nothing can end up
24
+ in a proxy log, in an access log, or in a browser history.
25
+
26
+ *A volunteered address travels beside the body, never inside it.* The run log
27
+ stores the submission it was given and serves those exact bytes back at a public
28
+ address, so anything inside the body is public by construction. An address is
29
+ not, so it goes in a header the log reads and does not echo. This is the shape
30
+ the receiving side settled on for the same reason, and the two halves have to
31
+ agree or the log refuses the submission.
32
+
33
+ *No redirect is followed.* This one was a docstring before it was a behaviour.
34
+ ``urlopen``'s default opener follows redirects, so an address that answered
35
+ ``302`` would have had the proof bundle — and the private contributor header —
36
+ re-sent to whatever origin it named, with none of the checks above applying to
37
+ the second request. The opener below is built with a redirect handler that
38
+ declines to build a redirected request at all, so a redirect is reported as a
39
+ refusal and nothing leaves this machine twice.
40
+
41
+ *The answer has to be JSON, and it has to end.* A run log answers with a
42
+ receipt; anything else is a misconfigured address or a captive portal, and
43
+ parsing it would only turn one problem into a confusing one. The size cap is
44
+ read *plus one byte*, because reading exactly the cap proves only that the cap
45
+ was reached — the response may have continued, and a truncated document that
46
+ parses is worse than one that does not.
47
+ """
48
+
49
+ from __future__ import annotations
50
+
51
+ import http.client
52
+ import urllib.error
53
+ import urllib.request
54
+ from typing import Final, Protocol
55
+ from urllib.parse import urlsplit
56
+
57
+ from techtree.errors import TechtreeError, ValidationError
58
+ from techtree.release.models import PublicationCoordinates
59
+
60
+ __all__ = [
61
+ "CONTRIBUTOR_ADDRESS_HEADER",
62
+ "MAX_RESPONSE_BYTES",
63
+ "PUBLICATION_ENDPOINT_INVALID",
64
+ "PUBLICATION_RESPONSE_NOT_JSON",
65
+ "PUBLICATION_RESPONSE_TOO_LARGE",
66
+ "PUBLICATION_TRANSPORT_FAILED",
67
+ "PUBLICATION_TRANSPORT_REDIRECTED",
68
+ "SKILL_GITHUB_URL_HEADER",
69
+ "SKILL_NAME_HEADER",
70
+ "HttpsPublicationTransport",
71
+ "PublicationMetadataTransport",
72
+ "PublicationTransport",
73
+ "resolved_endpoint",
74
+ "validated_endpoint",
75
+ ]
76
+
77
+ #: Stable error code for a configured endpoint that is not one.
78
+ PUBLICATION_ENDPOINT_INVALID: Final = "publication_endpoint_invalid"
79
+
80
+ #: Stable error code for a request that did not come back with a receipt.
81
+ PUBLICATION_TRANSPORT_FAILED: Final = "publication_transport_failed"
82
+
83
+ #: Stable error code for an address that answered by pointing somewhere else.
84
+ PUBLICATION_TRANSPORT_REDIRECTED: Final = "publication_transport_redirected"
85
+
86
+ #: Stable error code for an answer that is not a JSON document.
87
+ PUBLICATION_RESPONSE_NOT_JSON: Final = "publication_response_not_json"
88
+
89
+ #: Stable error code for an answer that did not end inside the size cap.
90
+ PUBLICATION_RESPONSE_TOO_LARGE: Final = "publication_response_too_large"
91
+
92
+ _MEDIA_TYPE: Final = "application/json"
93
+ _TIMEOUT_SECONDS: Final = 120.0
94
+
95
+ #: Where a volunteered address travels. Beside the body, never inside it: the
96
+ #: run log serves a stored submission back at a public address.
97
+ CONTRIBUTOR_ADDRESS_HEADER: Final = "x-techtree-contributor-address"
98
+
99
+ #: Public descriptive metadata travels beside the fixed proof body. The
100
+ #: receiving side may store these headers with the immutable log entry, while
101
+ #: the submission document itself remains the four-member contract.
102
+ SKILL_NAME_HEADER: Final = "x-techtree-skill-name"
103
+ SKILL_GITHUB_URL_HEADER: Final = "x-techtree-skill-github-url"
104
+
105
+ #: Enough for a proof bundle several times over, and small enough that a
106
+ #: misconfigured address answering with something enormous is refused rather
107
+ #: than read into memory.
108
+ MAX_RESPONSE_BYTES: Final = 4 * 1024 * 1024
109
+
110
+
111
+ class PublicationTransport(Protocol):
112
+ """Send one submission and return whatever came back."""
113
+
114
+ def submit(
115
+ self, *, endpoint: str, body: bytes, contributor_address: str | None
116
+ ) -> bytes:
117
+ """Return the response body, or raise a typed failure."""
118
+ ...
119
+
120
+
121
+ class PublicationMetadataTransport(Protocol):
122
+ """Transport seam extended with optional public Skill metadata headers."""
123
+
124
+ def submit(
125
+ self,
126
+ *,
127
+ endpoint: str,
128
+ body: bytes,
129
+ contributor_address: str | None,
130
+ skill_name: str | None = None,
131
+ skill_github_url: str | None = None,
132
+ ) -> bytes:
133
+ """Return the response body, or raise a typed failure."""
134
+ ...
135
+
136
+
137
+ class _RefuseRedirects(urllib.request.HTTPRedirectHandler):
138
+ """A redirect handler that builds no redirected request.
139
+
140
+ Returning ``None`` from :meth:`redirect_request` is urllib's own way of
141
+ saying "this redirect is not to be followed". The handler chain then falls
142
+ through to the default error handler, which raises the ``3xx`` as an
143
+ :class:`urllib.error.HTTPError`, and :class:`HttpsPublicationTransport`
144
+ turns that into a refusal naming the redirect rather than a bare status.
145
+ """
146
+
147
+ def redirect_request(
148
+ self,
149
+ req: urllib.request.Request,
150
+ fp: object,
151
+ code: int,
152
+ msg: str,
153
+ headers: object,
154
+ newurl: str,
155
+ ) -> None:
156
+ """Decline to build the second request."""
157
+ return None
158
+
159
+
160
+ def _opener() -> urllib.request.OpenerDirector:
161
+ """Return the opener every publication request goes through.
162
+
163
+ Built rather than taken from :func:`urllib.request.urlopen`, whose default
164
+ opener follows redirects. ``build_opener`` leaves out the default handler of
165
+ any class an argument is an instance of, so passing the subclass above is
166
+ what replaces redirect-following rather than adding to it.
167
+ """
168
+ return urllib.request.build_opener(_RefuseRedirects())
169
+
170
+
171
+ class HttpsPublicationTransport:
172
+ """The real request: one POST, one response, no redirects followed."""
173
+
174
+ def submit(
175
+ self,
176
+ *,
177
+ endpoint: str,
178
+ body: bytes,
179
+ contributor_address: str | None,
180
+ skill_name: str | None = None,
181
+ skill_github_url: str | None = None,
182
+ ) -> bytes:
183
+ """POST ``body`` to ``endpoint`` and return the response bytes."""
184
+ headers = {
185
+ "Content-Type": _MEDIA_TYPE,
186
+ "Accept": _MEDIA_TYPE,
187
+ "Content-Length": str(len(body)),
188
+ }
189
+ if contributor_address is not None:
190
+ headers[CONTRIBUTOR_ADDRESS_HEADER] = contributor_address
191
+ if skill_name is not None:
192
+ headers[SKILL_NAME_HEADER] = skill_name
193
+ if skill_github_url is not None:
194
+ headers[SKILL_GITHUB_URL_HEADER] = skill_github_url
195
+ request = urllib.request.Request(
196
+ validated_endpoint(endpoint),
197
+ data=body,
198
+ method="POST",
199
+ headers=headers,
200
+ )
201
+ try:
202
+ with _opener().open(request, timeout=_TIMEOUT_SECONDS) as response:
203
+ return _response_bytes(response)
204
+ except urllib.error.HTTPError as error:
205
+ if 300 <= error.code < 400:
206
+ raise TechtreeError(
207
+ f"the run log answered HTTP {error.code} and pointed "
208
+ "somewhere else, and a proof bundle is not re-sent to an "
209
+ "address that was not the one agreed to",
210
+ code=PUBLICATION_TRANSPORT_REDIRECTED,
211
+ retryable=False,
212
+ details={"status": error.code},
213
+ ) from error
214
+ raise TechtreeError(
215
+ f"the run log refused this submission: HTTP {error.code}",
216
+ code=PUBLICATION_TRANSPORT_FAILED,
217
+ retryable=error.code >= 500,
218
+ details={"status": error.code},
219
+ ) from error
220
+ except (urllib.error.URLError, OSError, TimeoutError) as error:
221
+ raise TechtreeError(
222
+ "the run log could not be reached, so nothing was sent",
223
+ code=PUBLICATION_TRANSPORT_FAILED,
224
+ retryable=True,
225
+ details={"reason": type(error).__name__},
226
+ ) from error
227
+
228
+
229
+ def _response_bytes(response: http.client.HTTPResponse) -> bytes:
230
+ """Return the answer, having proved it is a JSON document that ended.
231
+
232
+ Both refusals are here rather than at the call site because both are
233
+ properties of the exchange rather than of what the document turns out to
234
+ say. A receipt is refused later for being the wrong receipt; this is refused
235
+ now for not being an answer at all.
236
+ """
237
+ media_type = response.headers.get_content_type()
238
+ if media_type != _MEDIA_TYPE:
239
+ raise TechtreeError(
240
+ f"the run log answered with {media_type} rather than {_MEDIA_TYPE}, "
241
+ "so what came back is not a publication receipt",
242
+ code=PUBLICATION_RESPONSE_NOT_JSON,
243
+ retryable=False,
244
+ details={"content_type": media_type},
245
+ )
246
+
247
+ # One byte past the cap. Reading exactly the cap and getting exactly the cap
248
+ # back says the answer reached the limit, not that it stopped there.
249
+ raw = bytes(response.read(MAX_RESPONSE_BYTES + 1))
250
+ if len(raw) > MAX_RESPONSE_BYTES:
251
+ raise TechtreeError(
252
+ f"the run log's answer is longer than {MAX_RESPONSE_BYTES} bytes, "
253
+ "which no publication receipt is, so none of it was read further",
254
+ code=PUBLICATION_RESPONSE_TOO_LARGE,
255
+ retryable=False,
256
+ details={"limit": MAX_RESPONSE_BYTES},
257
+ )
258
+ return raw
259
+
260
+
261
+ def resolved_endpoint(coordinates: PublicationCoordinates, override: str | None) -> str:
262
+ """Return the address a publication or a withdrawal is sent to.
263
+
264
+ The override first, then the release coordinate. A stable release publishes
265
+ with nothing configured, because the address is pinned in the ReleaseCore
266
+ the wheel carries (decisions 0038's founder ruling of 2026-08-27): a wheel
267
+ somebody installed can publish the moment it is installed, and nobody has to
268
+ be told to set a variable they could set wrongly.
269
+
270
+ The override stays for development, where a throwaway local instance stands
271
+ in for the deployed one. It is checked as an address and the pinned one is
272
+ not, because the pinned one was already checked when the release document
273
+ was validated and the override is the one a person can get wrong today.
274
+ """
275
+ if override is not None:
276
+ return validated_endpoint(override)
277
+ return coordinates.submission_endpoint
278
+
279
+
280
+ def validated_endpoint(endpoint: str) -> str:
281
+ """Return the endpoint, or refuse an address nothing may be sent to."""
282
+ parts = urlsplit(endpoint)
283
+ if parts.scheme != "https" or not parts.netloc:
284
+ raise ValidationError(
285
+ "a run log address is an https URL, and this one is not",
286
+ code=PUBLICATION_ENDPOINT_INVALID,
287
+ details={"scheme": parts.scheme},
288
+ )
289
+ if parts.query or parts.fragment:
290
+ raise ValidationError(
291
+ "a run log address carries no query string: a submission travels "
292
+ "in the request body and never in a URL",
293
+ code=PUBLICATION_ENDPOINT_INVALID,
294
+ details={"scheme": parts.scheme},
295
+ )
296
+ return endpoint
@@ -0,0 +1,242 @@
1
+ """Checking what the run log answered. Decisions document 0038.
2
+
3
+ A countersignature is only worth the prior knowledge of which key made it. A
4
+ receipt that carries a key and a signature and is checked against *itself*
5
+ proves nothing at all: a server that wanted to lie would generate a key, sign
6
+ its own invention with it, and hand a participant both halves of a consistent
7
+ fiction. So the key is not learned from the answer. It is pinned in the release
8
+ (:class:`~techtree.release.models.PublicationCoordinates`), and everything below
9
+ is checked against the pin.
10
+
11
+ Six things have to hold before a receipt is written into a run directory, and
12
+ they are six rather than one because each closes a different way of being lied
13
+ to.
14
+
15
+ *The digest matches the payload.* An envelope carries the digest it was signed
16
+ under and never recomputes it while parsing, so a payload edited after signing
17
+ keeps a digest that no longer describes it. Recomputing here is what catches
18
+ that, and it is the same check every other signed document in this protocol
19
+ gets.
20
+
21
+ *The signature names the pinned key.* Not a key, the key. A different identifier
22
+ is a different key, including a rotated one: rotation is a new key and a new
23
+ release that pins it.
24
+
25
+ *The receipt carries the key it names.* The identifier is the digest of the
26
+ public key, so a receipt whose carried key does not hash to the identifier it
27
+ names is inconsistent with itself, and it is caught without a rule of its own.
28
+
29
+ *The signature verifies.* Against the pinned public key and the digest, which is
30
+ the only step that involves any cryptography and the only one that would be
31
+ sufficient if the five around it were not needed to make it mean something.
32
+
33
+ *The receipt is for what was sent.* A receipt naming another run or another
34
+ bundle is somebody else's evidence, and filing it in this run's directory would
35
+ put a false record beside a true one.
36
+
37
+ *The entry is on the log this release pins, and every reported check passed.* An
38
+ address on another origin is a link somebody else chose. A receipt that reports
39
+ a check it did not pass has not accepted the submission, whatever else it says.
40
+
41
+ Nothing here contacts anything, and nothing here writes anything. It answers one
42
+ question — is this the run log's own word about the thing I sent — and the caller
43
+ decides what to do about the answer.
44
+ """
45
+
46
+ from __future__ import annotations
47
+
48
+ from base64 import b64decode
49
+ from typing import Final, NoReturn
50
+ from urllib.parse import urlsplit
51
+
52
+ from techtree.canonical import digest_object
53
+ from techtree.crypto import load_public_key, verify_signature
54
+ from techtree.errors import ValidationError
55
+ from techtree.models.base import Digest, JsonValue, ObjectEnvelope, PublicKeyRef
56
+ from techtree.publication.models import (
57
+ PublicationReceiptPayload,
58
+ WithdrawalReceiptPayload,
59
+ )
60
+ from techtree.release.models import PublicationCoordinates
61
+
62
+ __all__ = [
63
+ "PUBLICATION_RECEIPT_INVALID",
64
+ "verify_publication_receipt",
65
+ "verify_withdrawal_receipt",
66
+ ]
67
+
68
+ #: Stable error code for every way an answer fails to be the run log's own word
69
+ #: about what was sent. One code with a named check in its details, rather than
70
+ #: six codes: a caller acts on all of them the same way — it writes nothing down
71
+ #: — and the detail is what a person needs to understand which one it was.
72
+ PUBLICATION_RECEIPT_INVALID: Final = "publication_receipt_invalid"
73
+
74
+
75
+ def verify_publication_receipt(
76
+ envelope: ObjectEnvelope[PublicationReceiptPayload],
77
+ *,
78
+ coordinates: PublicationCoordinates,
79
+ run_id: str,
80
+ bundle_digest: Digest,
81
+ ) -> None:
82
+ """Raise unless this is the pinned run log's receipt for what was sent.
83
+
84
+ Args:
85
+ envelope: what the run log answered, already parsed.
86
+ coordinates: the endpoint, public log origin and network key this
87
+ release pins.
88
+ run_id: the run whose proof was submitted.
89
+ bundle_digest: the bundle digest that was submitted.
90
+
91
+ Raises:
92
+ ValidationError: on the first check that does not hold, naming it.
93
+ """
94
+ receipt = envelope.payload
95
+ _check_countersignature(envelope, coordinates, subject=run_id)
96
+
97
+ if receipt.run_id != run_id or receipt.bundle_digest != bundle_digest:
98
+ _refuse(
99
+ "receipt.subject",
100
+ "the run log's receipt is for a different submission than the one "
101
+ "that was sent",
102
+ run_id=run_id,
103
+ receipt_run_id=receipt.run_id,
104
+ receipt_bundle_digest=receipt.bundle_digest,
105
+ )
106
+
107
+ _check_entry_url(receipt.entry_url, coordinates, subject=run_id)
108
+
109
+ if receipt.failed_checks:
110
+ _refuse(
111
+ "receipt.checks",
112
+ f"the run log accepted nothing: {receipt.failed_checks[0].detail}",
113
+ run_id=run_id,
114
+ failed_checks=[check.id for check in receipt.failed_checks],
115
+ )
116
+
117
+
118
+ def verify_withdrawal_receipt(
119
+ envelope: ObjectEnvelope[WithdrawalReceiptPayload],
120
+ *,
121
+ coordinates: PublicationCoordinates,
122
+ bundle_digest: Digest,
123
+ ) -> None:
124
+ """Raise unless this is the pinned run log's record of this withdrawal.
125
+
126
+ The same countersignature and the same origin rule as a publication
127
+ receipt, because it is the same key making the same kind of statement. What
128
+ it does not check is a list of checks: a withdrawal is a request the network
129
+ either honoured or refused, and a refusal is a status rather than a document.
130
+
131
+ Args:
132
+ envelope: what the run log answered, already parsed.
133
+ coordinates: the endpoint, public log origin and network key this
134
+ release pins.
135
+ bundle_digest: the entry the withdrawal was asked for.
136
+
137
+ Raises:
138
+ ValidationError: on the first check that does not hold, naming it.
139
+ """
140
+ receipt = envelope.payload
141
+ _check_countersignature(envelope, coordinates, subject=bundle_digest)
142
+
143
+ if receipt.bundle_digest != bundle_digest:
144
+ _refuse(
145
+ "withdrawal.subject",
146
+ "the run log's answer withdraws a different entry than the one "
147
+ "that was asked for",
148
+ bundle_digest=bundle_digest,
149
+ receipt_bundle_digest=receipt.bundle_digest,
150
+ )
151
+
152
+ _check_entry_url(receipt.entry_url, coordinates, subject=bundle_digest)
153
+
154
+
155
+ # ---------------------------------------------------------------------------
156
+ # The checks both answers get
157
+ # ---------------------------------------------------------------------------
158
+
159
+
160
+ def _check_countersignature(
161
+ envelope: ObjectEnvelope[PublicationReceiptPayload]
162
+ | ObjectEnvelope[WithdrawalReceiptPayload],
163
+ coordinates: PublicationCoordinates,
164
+ *,
165
+ subject: str,
166
+ ) -> None:
167
+ """Raise unless the pinned network key signed exactly these payload bytes."""
168
+ computed = digest_object(envelope.payload)
169
+ if computed != envelope.payload_digest:
170
+ _refuse(
171
+ "receipt.payload_digest",
172
+ "the run log's answer no longer matches the digest it was signed "
173
+ f"under: sealed {envelope.payload_digest}, computed {computed}",
174
+ subject=subject,
175
+ )
176
+
177
+ signature = envelope.signature
178
+ if signature is None:
179
+ _refuse(
180
+ "receipt.signature_present",
181
+ "the run log's answer carries no signature, so nothing countersigns it",
182
+ subject=subject,
183
+ )
184
+
185
+ pinned = coordinates.network_key
186
+ if signature.key_id != pinned.key_id:
187
+ _refuse(
188
+ "receipt.signature_key",
189
+ f"the run log's answer is signed by key {signature.key_id}, which "
190
+ f"is not the key {pinned.key_id} this release publishes to",
191
+ subject=subject,
192
+ )
193
+
194
+ if not _same_key(envelope.payload.public_key, pinned):
195
+ _refuse(
196
+ "receipt.carried_key",
197
+ "the run log's answer names the pinned key and carries a different one",
198
+ subject=subject,
199
+ )
200
+
201
+ public_key = load_public_key(b64decode(pinned.public_key, validate=True))
202
+ if not verify_signature(public_key, envelope.payload_digest, signature):
203
+ _refuse(
204
+ "receipt.signature",
205
+ "the run log's answer does not verify against the public key this "
206
+ "release pins",
207
+ subject=subject,
208
+ )
209
+
210
+
211
+ def _check_entry_url(
212
+ entry_url: str, coordinates: PublicationCoordinates, *, subject: str
213
+ ) -> None:
214
+ """Raise unless the entry lives on the public log this release pins."""
215
+ pinned = urlsplit(coordinates.public_log_url)
216
+ entry = urlsplit(entry_url)
217
+ if entry.scheme != "https" or entry.netloc != pinned.netloc:
218
+ _refuse(
219
+ "receipt.entry_url",
220
+ f"the run log says this entry lives at {entry_url}, which is not on "
221
+ f"{coordinates.public_log_url}",
222
+ subject=subject,
223
+ entry_url=entry_url,
224
+ )
225
+
226
+
227
+ def _same_key(carried: PublicKeyRef, pinned: PublicKeyRef) -> bool:
228
+ """Return whether two key references describe the same key, field for field."""
229
+ return (
230
+ carried.algorithm == pinned.algorithm
231
+ and carried.key_id == pinned.key_id
232
+ and carried.public_key == pinned.public_key
233
+ )
234
+
235
+
236
+ def _refuse(check: str, message: str, **details: JsonValue) -> NoReturn:
237
+ """Raise the one refusal, naming which check did not hold."""
238
+ raise ValidationError(
239
+ message,
240
+ code=PUBLICATION_RECEIPT_INVALID,
241
+ details={"check": check, **details},
242
+ )
@@ -0,0 +1,156 @@
1
+ """Withdrawing one published entry. Decisions document 0038.
2
+
3
+ The founder settled two things about withdrawal on 2026-08-27 and this module
4
+ is both of them.
5
+
6
+ *A published entry is withdrawn, never deleted.* Withdrawal is an appended
7
+ event: the entry stays where it is, marked, and the address it lives at goes on
8
+ answering. Nothing here asks for removal, and what comes back names where the
9
+ entry still is rather than reporting that it is gone.
10
+
11
+ *It is implemented rather than promised.* A public promise with no executable
12
+ path would be worse than neither, so this is the executable path. The
13
+ participant signs a canonical request with the same key that signed the run —
14
+ the identity store already holds it, and it is the only key this machine has —
15
+ and the network verifies that signature against the participant key inside the
16
+ publication it already accepted. That is why the request carries no public key
17
+ of its own: a key that arrived with the request would be a key the requester
18
+ chose, and looking it up in the accepted bundle instead is the whole of the
19
+ authorisation.
20
+
21
+ Two things are deliberately absent.
22
+
23
+ *No reason.* Nothing a submitter writes appears on the site, and a free-text
24
+ reason attached to a public entry is the one string that would. There is no
25
+ field for one and there will not be one.
26
+
27
+ *No local record.* The public log is the record of a withdrawal, because that is
28
+ where the appended event lives. Writing a second one beside the run would make
29
+ this machine a source of truth about a public log's contents that it cannot
30
+ keep current, and a run directory is addressed by run and a withdrawal by bundle
31
+ digest — a person withdrawing an entry may not have the run on this machine at
32
+ all.
33
+
34
+ The request goes to the same address a submission goes to. Decisions 0038 gives
35
+ the site exactly one write address, and discriminating on ``schema_version``
36
+ inside one address is what keeps that true.
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ from collections.abc import Callable
42
+ from dataclasses import dataclass
43
+ from datetime import datetime
44
+
45
+ from pydantic import ValidationError as PydanticValidationError
46
+
47
+ from techtree.canonical import canonical_json_bytes
48
+ from techtree.constants import PUBLICATION_WITHDRAWAL_SCHEMA_VERSION
49
+ from techtree.errors import ValidationError
50
+ from techtree.identity.service import IdentityService
51
+ from techtree.models.base import Digest, ObjectEnvelope
52
+ from techtree.publication.models import WithdrawalReceiptPayload, WithdrawalRequest
53
+ from techtree.publication.transport import PublicationTransport
54
+ from techtree.publication.verify import (
55
+ PUBLICATION_RECEIPT_INVALID,
56
+ verify_withdrawal_receipt,
57
+ )
58
+ from techtree.release.models import PublicationCoordinates
59
+
60
+ __all__ = [
61
+ "WithdrawalOutcome",
62
+ "WithdrawalService",
63
+ ]
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class WithdrawalOutcome:
68
+ """What the run log said when it marked one entry withdrawn."""
69
+
70
+ bundle_digest: Digest
71
+ entry_url: str
72
+ withdrawn_at: datetime
73
+ #: The participant key the request was signed with, so a person can see that
74
+ #: the entry was withdrawn by the identity that published it.
75
+ key_id: str
76
+
77
+
78
+ class WithdrawalService:
79
+ """Builds, signs and sends one withdrawal, and checks what came back."""
80
+
81
+ def __init__(
82
+ self,
83
+ *,
84
+ coordinates: PublicationCoordinates,
85
+ endpoint: str,
86
+ identity: IdentityService,
87
+ transport: PublicationTransport,
88
+ clock: Callable[[], datetime],
89
+ ) -> None:
90
+ self._coordinates = coordinates
91
+ self._endpoint = endpoint
92
+ self._identity = identity
93
+ self._transport = transport
94
+ self._clock = clock
95
+
96
+ @property
97
+ def endpoint(self) -> str:
98
+ """Return the address this withdrawal is sent to."""
99
+ return self._endpoint
100
+
101
+ def request(self, bundle_digest: Digest) -> ObjectEnvelope[WithdrawalRequest]:
102
+ """Return the signed request this withdrawal would send.
103
+
104
+ Separate from :meth:`withdraw` so that what is signed can be shown to a
105
+ person, and inspected by a test, without anything being sent. Signing is
106
+ local work; only :meth:`withdraw` opens a socket.
107
+ """
108
+ return self._identity.sign_object(
109
+ WithdrawalRequest(
110
+ schema_version=PUBLICATION_WITHDRAWAL_SCHEMA_VERSION,
111
+ bundle_digest=bundle_digest,
112
+ requested_at=self._clock(),
113
+ )
114
+ )
115
+
116
+ def withdraw(self, bundle_digest: Digest) -> WithdrawalOutcome:
117
+ """Send the signed withdrawal and return what the run log answered.
118
+
119
+ No volunteered address travels with a withdrawal. There is nothing to
120
+ volunteer: the request is about an entry that already exists, and the
121
+ header exists for a submission's optional contributor address alone.
122
+ """
123
+ signed = self.request(bundle_digest)
124
+ response = self._transport.submit(
125
+ endpoint=self.endpoint,
126
+ body=canonical_json_bytes(signed),
127
+ contributor_address=None,
128
+ )
129
+ receipt = self._receipt(response, bundle_digest)
130
+ return WithdrawalOutcome(
131
+ bundle_digest=receipt.bundle_digest,
132
+ entry_url=receipt.entry_url,
133
+ withdrawn_at=receipt.withdrawn_at,
134
+ key_id=self._identity.store.load_public().key_id,
135
+ )
136
+
137
+ def _receipt(
138
+ self, response: bytes, bundle_digest: Digest
139
+ ) -> WithdrawalReceiptPayload:
140
+ """Parse the answer and refuse anything that is not this withdrawal's."""
141
+ try:
142
+ envelope = ObjectEnvelope[WithdrawalReceiptPayload].model_validate_json(
143
+ response
144
+ )
145
+ except PydanticValidationError as error:
146
+ raise ValidationError(
147
+ "the run log answered with something that is not a withdrawal "
148
+ "receipt, so nothing is known about what it did",
149
+ code=PUBLICATION_RECEIPT_INVALID,
150
+ details={"bundle_digest": bundle_digest},
151
+ ) from error
152
+
153
+ verify_withdrawal_receipt(
154
+ envelope, coordinates=self._coordinates, bundle_digest=bundle_digest
155
+ )
156
+ return envelope.payload
techtree/py.typed ADDED
File without changes