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.
- techtree/__init__.py +35 -0
- techtree/__main__.py +14 -0
- techtree/canonical.py +239 -0
- techtree/catalog/__init__.py +25 -0
- techtree/catalog/repository.py +400 -0
- techtree/catalog/service.py +419 -0
- techtree/cli/__init__.py +1 -0
- techtree/cli/app.py +416 -0
- techtree/cli/commands/__init__.py +1 -0
- techtree/cli/commands/climb.py +1223 -0
- techtree/cli/commands/doctor.py +147 -0
- techtree/cli/commands/engine.py +207 -0
- techtree/cli/commands/proof.py +556 -0
- techtree/cli/commands/publish.py +447 -0
- techtree/cli/commands/release.py +303 -0
- techtree/cli/commands/run.py +1067 -0
- techtree/cli/commands/setup.py +181 -0
- techtree/cli/commands/skill.py +221 -0
- techtree/cli/commands/uplift.py +698 -0
- techtree/cli/commands/withdraw.py +212 -0
- techtree/cli/confirm.py +47 -0
- techtree/cli/context.py +96 -0
- techtree/cli/invoke.py +220 -0
- techtree/cli/output.py +280 -0
- techtree/constants.py +138 -0
- techtree/crypto.py +128 -0
- techtree/doctor/__init__.py +1 -0
- techtree/doctor/checks.py +675 -0
- techtree/doctor/execution_checks.py +435 -0
- techtree/doctor/service.py +326 -0
- techtree/drafts/__init__.py +32 -0
- techtree/drafts/source.py +146 -0
- techtree/drafts/store.py +992 -0
- techtree/engines/__init__.py +1 -0
- techtree/engines/bundle.py +251 -0
- techtree/engines/installer.py +679 -0
- techtree/engines/registry.py +235 -0
- techtree/engines/runner.py +170 -0
- techtree/errors.py +262 -0
- techtree/fs.py +234 -0
- techtree/harness.py +108 -0
- techtree/identity/__init__.py +41 -0
- techtree/identity/models.py +113 -0
- techtree/identity/service.py +199 -0
- techtree/identity/store.py +263 -0
- techtree/ids.py +85 -0
- techtree/manifests/__init__.py +39 -0
- techtree/manifests/builder.py +433 -0
- techtree/manifests/compare.py +376 -0
- techtree/models/__init__.py +282 -0
- techtree/models/base.py +201 -0
- techtree/models/campaign.py +484 -0
- techtree/models/catalog.py +227 -0
- techtree/models/cli.py +151 -0
- techtree/models/climb.py +254 -0
- techtree/models/data_policy.py +130 -0
- techtree/models/engine.py +156 -0
- techtree/models/episode_receipt.py +130 -0
- techtree/models/evaluation_backend.py +113 -0
- techtree/models/experiment.py +154 -0
- techtree/models/run.py +214 -0
- techtree/models/skill.py +156 -0
- techtree/models/uplift_report.py +158 -0
- techtree/models/validation.py +299 -0
- techtree/paths.py +116 -0
- techtree/presentation/__init__.py +31 -0
- techtree/presentation/build.py +1242 -0
- techtree/presentation/compact.py +246 -0
- techtree/presentation/evidence.py +169 -0
- techtree/presentation/models.py +358 -0
- techtree/presentation/rich.py +312 -0
- techtree/presentation/sanitize.py +156 -0
- techtree/publication/__init__.py +44 -0
- techtree/publication/address.py +180 -0
- techtree/publication/coordinates.py +26 -0
- techtree/publication/journal.py +212 -0
- techtree/publication/keccak.py +183 -0
- techtree/publication/models.py +209 -0
- techtree/publication/offer.py +35 -0
- techtree/publication/service.py +618 -0
- techtree/publication/transport.py +296 -0
- techtree/publication/verify.py +242 -0
- techtree/publication/withdraw.py +156 -0
- techtree/py.typed +0 -0
- techtree/receipts/__init__.py +52 -0
- techtree/receipts/bundle.py +578 -0
- techtree/receipts/compare.py +1065 -0
- techtree/receipts/episode.py +672 -0
- techtree/receipts/execution.py +630 -0
- techtree/receipts/observed.py +474 -0
- techtree/receipts/set.py +336 -0
- techtree/receipts/uplift.py +655 -0
- techtree/receipts/verify.py +1055 -0
- techtree/release/__init__.py +9 -0
- techtree/release/bootstrap.py +509 -0
- techtree/release/checks.py +376 -0
- techtree/release/document.py +125 -0
- techtree/release/generate.py +221 -0
- techtree/release/models.py +293 -0
- techtree/release/provenance.py +109 -0
- techtree/resources/catalog/campaigns/hello-world-climb.json +1 -0
- techtree/resources/catalog/catalog.json +32 -0
- techtree/resources/catalog/climbs/hello-world-climb.json +1 -0
- techtree/resources/catalog/data-policies/hello-world-climb.json +1 -0
- techtree/resources/catalog/taskset-validations/hello-world-climb.json +1 -0
- techtree/resources/catalog/validation-evidence/hello-world-climb.json +1 -0
- techtree/resources/engines/default/engine.json +20 -0
- techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/__init__.py +7 -0
- techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/algorithm.py +136 -0
- techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/dataset.py +156 -0
- techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/env.py +48 -0
- techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/taskset.py +163 -0
- techtree/resources/engines/default/packages/procedure-transfer-v1/pyproject.toml +13 -0
- techtree/resources/engines/default/pyproject.toml +23 -0
- techtree/resources/engines/default/tools/inspect_taskset.py +124 -0
- techtree/resources/engines/default/tools/normalize_eval_output.py +470 -0
- techtree/resources/engines/default/tools/normalize_validation.py +222 -0
- techtree/resources/engines/default/uv.lock +1758 -0
- techtree/resources/harness/hermes-agent-0.19.0.json +69 -0
- techtree/resources/release/build-provenance.json +4 -0
- techtree/resources/release/release-core.json +24 -0
- techtree/runs/__init__.py +31 -0
- techtree/runs/artifacts.py +750 -0
- techtree/runs/child_registry.py +228 -0
- techtree/runs/events.py +478 -0
- techtree/runs/executor.py +140 -0
- techtree/runs/fake.py +741 -0
- techtree/runs/launcher.py +253 -0
- techtree/runs/machine.py +489 -0
- techtree/runs/real.py +789 -0
- techtree/runs/service.py +616 -0
- techtree/runs/store.py +555 -0
- techtree/runs/validation.py +259 -0
- techtree/runs/variants.py +684 -0
- techtree/settings.py +143 -0
- techtree/skills/__init__.py +14 -0
- techtree/skills/archive.py +282 -0
- techtree/skills/policy.py +62 -0
- techtree/skills/scanner.py +394 -0
- techtree/skills/service.py +752 -0
- techtree/skills/starter.py +434 -0
- techtree/tasksets/__init__.py +1 -0
- techtree/tasksets/membership.py +269 -0
- techtree/tasksets/provider.py +207 -0
- techtree/tasksets/resolver.py +311 -0
- techtree/tasksets/service.py +484 -0
- techtree/tasksets/verifiers_cli.py +538 -0
- techtree/uplift/__init__.py +20 -0
- techtree/uplift/context.py +544 -0
- techtree/uplift/derive.py +203 -0
- techtree/uplift/public_tasks.py +151 -0
- techtree/uplift/service.py +719 -0
- techtree/uplift/source.py +160 -0
- techtree/verifiers/__init__.py +31 -0
- techtree/verifiers/budget.py +219 -0
- techtree/verifiers/child.py +633 -0
- techtree/verifiers/compiler.py +432 -0
- techtree/verifiers/config.py +365 -0
- techtree/verifiers/credentials.py +321 -0
- techtree/verifiers/image.py +126 -0
- techtree/verifiers/models.py +527 -0
- techtree/verifiers/outputs.py +368 -0
- techtree/verifiers/progress.py +192 -0
- techtree/verifiers/supervisor.py +341 -0
- techtree/verifiers/verify.py +782 -0
- techtree/version.py +39 -0
- techtree/worker/__init__.py +18 -0
- techtree/worker/execute.py +487 -0
- techtree/worker/main.py +57 -0
- techtree-0.1.0.dist-info/METADATA +344 -0
- techtree-0.1.0.dist-info/RECORD +174 -0
- techtree-0.1.0.dist-info/WHEEL +4 -0
- techtree-0.1.0.dist-info/entry_points.txt +3 -0
- 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
|