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,618 @@
1
+ """Publishing one run to the public log. Decisions document 0038.
2
+
3
+ The order of the steps below is the product, so it is written once, here, and
4
+ the command above it does no deciding of its own.
5
+
6
+ *The proof is checked before anything is offered.* A result whose own proof does
7
+ not hold together is never put in front of somebody as a thing they could
8
+ publish. Offering it and then refusing at the end would be the same code with
9
+ the trust the wrong way round; this way the only results that reach the question
10
+ are the ones that survive it.
11
+
12
+ *What is sent is the run's own bytes.* The proof directory, file by file,
13
+ exactly as it sits on disk, because the bundle manifest already commits to every
14
+ one of those files by digest under the participant's key. Re-deriving them would
15
+ send something nobody signed. The directory holds no transcripts: an episode
16
+ receipt carries digests, task hashes and scores, and the raw episodes are
17
+ outside it.
18
+
19
+ *Nothing about the run is written back.* A completed run's files are final. This
20
+ adds two: the countersigned receipt, and a journal of its own that says what was
21
+ attempted and how it went. Neither is inside the proof, and nothing already in
22
+ the run directory is touched.
23
+
24
+ *The attempt is recorded before it is made.* The pending line goes down first, so
25
+ a process that dies mid-request leaves a run that says an attempt was started
26
+ rather than a run that says nothing happened.
27
+
28
+ *Nothing is written down that has not been checked against the pinned key.* The
29
+ receipt is verified against the network key the release pins — not against the
30
+ key the receipt itself carries — before it becomes a file. An unverified receipt
31
+ is not weaker evidence; it is no evidence, and filing one would put something in
32
+ a run directory that looks exactly like proof of publication and is not.
33
+
34
+ *A retry converges rather than conflicting.* The failure this is for is real and
35
+ narrow: the run log accepts a submission, the answer is lost on the way back, the
36
+ CLI records a failure, and the person quite reasonably runs the command again.
37
+ The log answers the second attempt with the same entry, so the receipt write
38
+ finds the same bytes already there and succeeds. Different bytes under the same
39
+ name is the case that is genuinely wrong, and that is the one it refuses.
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ import base64
45
+ import re
46
+ from collections.abc import Callable
47
+ from dataclasses import dataclass
48
+ from datetime import datetime
49
+ from pathlib import Path
50
+ from typing import Final, TypeVar, cast
51
+
52
+ from pydantic import BaseModel
53
+ from pydantic import ValidationError as PydanticValidationError
54
+
55
+ from techtree.canonical import canonical_json_bytes, sha256_digest_bytes
56
+ from techtree.constants import (
57
+ PUBLICATION_JOURNAL_SCHEMA_VERSION,
58
+ PUBLICATION_SUBMISSION_SCHEMA_VERSION,
59
+ )
60
+ from techtree.errors import (
61
+ ConflictError,
62
+ NotFoundError,
63
+ PolicyError,
64
+ TechtreeError,
65
+ ValidationError,
66
+ VerificationError,
67
+ )
68
+ from techtree.fs import fsync_directory, open_exclusive
69
+ from techtree.identity.models import VerificationResult
70
+ from techtree.manifests.builder import skill_content_digest
71
+ from techtree.models.base import Digest, ObjectEnvelope
72
+ from techtree.models.experiment import ExperimentManifest
73
+ from techtree.models.skill import SkillArtifact, SubmissionDraft
74
+ from techtree.models.uplift_report import PublicationStatus, UpliftReport
75
+ from techtree.publication.journal import PublicationJournal, PublicationJournalEntry
76
+ from techtree.publication.models import (
77
+ PublicationReceiptPayload,
78
+ PublicationSubmission,
79
+ )
80
+ from techtree.publication.transport import (
81
+ PublicationMetadataTransport,
82
+ PublicationTransport,
83
+ resolved_endpoint,
84
+ )
85
+ from techtree.publication.verify import (
86
+ PUBLICATION_RECEIPT_INVALID,
87
+ verify_publication_receipt,
88
+ )
89
+ from techtree.receipts.bundle import (
90
+ BUNDLE_MANIFEST_FILENAME,
91
+ PROOF_BUNDLE_INVALID,
92
+ REPORT_FILENAME,
93
+ LocalProofBundleManifest,
94
+ proof_bundle_dir,
95
+ )
96
+ from techtree.receipts.uplift import publication_eligible_for
97
+ from techtree.receipts.verify import LocalProofVerifier
98
+ from techtree.release.models import PublicationCoordinates
99
+
100
+ __all__ = [
101
+ "PUBLICATION_NOT_ELIGIBLE",
102
+ "PUBLICATION_PROOF_NOT_FOUND",
103
+ "PUBLICATION_RECEIPT_CONFLICT",
104
+ "PUBLICATION_RECEIPT_FILENAME",
105
+ "RUN_ALREADY_PUBLISHED",
106
+ "PublicationOutcome",
107
+ "PublicationPlan",
108
+ "PublicationService",
109
+ ]
110
+
111
+ #: The countersigned receipt, written into the run directory as a new file.
112
+ PUBLICATION_RECEIPT_FILENAME: Final = "publication-receipt.json"
113
+
114
+ #: Stable error codes this module reports.
115
+ PUBLICATION_PROOF_NOT_FOUND: Final = "publication_proof_not_found"
116
+ PUBLICATION_NOT_ELIGIBLE: Final = "publication_not_eligible"
117
+ #: Two different receipts, under one name, for one run. Not a retry: a retry
118
+ #: brings back the same entry and therefore the same bytes.
119
+ PUBLICATION_RECEIPT_CONFLICT: Final = "publication_receipt_conflict"
120
+ RUN_ALREADY_PUBLISHED: Final = "run_already_published"
121
+
122
+ _INPUTS_DIRECTORY: Final = "inputs"
123
+ _DRAFT_FILENAME: Final = "draft.json"
124
+ _SKILL_DIRECTORY: Final = "skill"
125
+ _SKILL_ARTIFACT_FILENAME: Final = "artifact.json"
126
+ _CANDIDATE_MANIFEST_FILENAME: Final = "candidate-experiment.json"
127
+ _SKILL_NAME_PATTERN: Final = re.compile(r"\A[A-Za-z0-9][A-Za-z0-9 ._-]{0,63}\Z")
128
+
129
+ _ModelT = TypeVar("_ModelT", bound=BaseModel)
130
+
131
+
132
+ @dataclass(frozen=True)
133
+ class PublicationPlan:
134
+ """Exactly what one publication would send, worked out before it is asked.
135
+
136
+ The plan is what a person is shown so that they can agree to something they
137
+ have actually seen, so it carries the two numbers that answer "how much of
138
+ my machine is about to leave it" — how many files, and how many bytes — and
139
+ not the files themselves. The bytes on the wire are
140
+ :meth:`PublicationService.submission_bytes`'s answer and nobody else's, so
141
+ there is one place a submission is built and one shape it can have.
142
+ """
143
+
144
+ run_id: str
145
+ bundle_digest: Digest
146
+ endpoint: str
147
+ #: How many files would travel, counted off the proof directory.
148
+ file_count: int
149
+ #: How many bytes of proof would travel, before base64 widens them on the
150
+ #: wire. It is the size of the thing a person recognises — the proof
151
+ #: directory — rather than the size of its encoding.
152
+ byte_count: int
153
+ report: UpliftReport
154
+ verification: VerificationResult
155
+ #: The candidate Skill's prepared public label, when the run still carries
156
+ #: its immutable inputs and that label's root digest matches the verified
157
+ #: candidate experiment. Older runs predate publication metadata and leave
158
+ #: this absent rather than inventing a name.
159
+ skill_name: str | None = None
160
+
161
+
162
+ @dataclass(frozen=True)
163
+ class PublicationOutcome:
164
+ """What one completed publication produced."""
165
+
166
+ run_id: str
167
+ receipt: PublicationReceiptPayload
168
+ receipt_path: Path
169
+ status: PublicationStatus
170
+ #: Whether an address was sent with this submission. The address itself is
171
+ #: not here, and is not anywhere else on this machine either.
172
+ contributor_address_sent: bool
173
+
174
+
175
+ class PublicationService:
176
+ """Plans and performs the publication of one finished run."""
177
+
178
+ def __init__(
179
+ self,
180
+ *,
181
+ runs_dir: Path,
182
+ coordinates: PublicationCoordinates,
183
+ endpoint_override: str | None,
184
+ transport: PublicationTransport,
185
+ clock: Callable[[], datetime],
186
+ ) -> None:
187
+ self._runs_dir = runs_dir
188
+ self._coordinates = coordinates
189
+ self._endpoint_override = endpoint_override
190
+ self._transport = transport
191
+ self._clock = clock
192
+
193
+ # -- planning -----------------------------------------------------------
194
+
195
+ def plan(self, run_id: str) -> PublicationPlan:
196
+ """Return what publishing this run would send, or refuse to publish it.
197
+
198
+ Every refusal that can be reached without a network is reached here, so
199
+ a person is only ever asked about a submission that could actually be
200
+ made.
201
+ """
202
+ directory = self._bundle_dir(run_id)
203
+ verification = LocalProofVerifier().verify_bundle(directory)
204
+ if not verification.verified:
205
+ raise VerificationError(
206
+ f"run {run_id}'s own proof does not verify, so there is nothing "
207
+ "here that could honestly be published: "
208
+ f"{verification.failures[0].detail}",
209
+ code=PROOF_BUNDLE_INVALID,
210
+ details={
211
+ "run_id": run_id,
212
+ "failed_checks": [message.id for message in verification.failures],
213
+ },
214
+ )
215
+
216
+ report = self._report(directory, run_id)
217
+ # Decided from the report's own grade and rights, never from the flag
218
+ # stored beside them. That flag records what the build that WROTE the
219
+ # report allowed, which is a fact about that build: every report signed
220
+ # before publishing existed stores ``false``, so reading it here refused
221
+ # every run that exists, including the certification runs. The offline
222
+ # verifier had the same bug from the other direction and was fixed the
223
+ # same way.
224
+ if not publication_eligible_for(
225
+ grade=report.proof_grade, publication=report.statuses.publication
226
+ ):
227
+ raise PolicyError(
228
+ f"run {run_id}'s report may not be published: "
229
+ + (
230
+ "its rights statement blocks publication"
231
+ if report.statuses.publication is PublicationStatus.BLOCKED
232
+ else f"it is graded {report.proof_grade}, and only a P1 "
233
+ "report is evidence of anything"
234
+ ),
235
+ code=PUBLICATION_NOT_ELIGIBLE,
236
+ details={
237
+ "run_id": run_id,
238
+ "proof_grade": report.proof_grade,
239
+ "publication": report.statuses.publication.value,
240
+ },
241
+ )
242
+
243
+ journal = PublicationJournal(self._run_dir(run_id))
244
+ published = journal.published()
245
+ if published is not None:
246
+ raise ConflictError(
247
+ f"run {run_id} is already in the public log, at "
248
+ f"{published.entry_url}. A published entry stays where it is",
249
+ code=RUN_ALREADY_PUBLISHED,
250
+ details={"run_id": run_id, "entry_url": published.entry_url},
251
+ )
252
+
253
+ stored = self._proof_files(directory)
254
+ return PublicationPlan(
255
+ run_id=run_id,
256
+ bundle_digest=self._bundle_digest(directory, run_id),
257
+ endpoint=self.endpoint,
258
+ file_count=len(stored),
259
+ byte_count=sum(len(data) for data in stored.values()),
260
+ report=report,
261
+ verification=verification,
262
+ skill_name=self._skill_name(run_id, directory),
263
+ )
264
+
265
+ # -- publishing ---------------------------------------------------------
266
+
267
+ def publish(
268
+ self,
269
+ plan: PublicationPlan,
270
+ *,
271
+ contributor_address: str | None = None,
272
+ skill_github_url: str | None = None,
273
+ ) -> PublicationOutcome:
274
+ """Send the planned submission, record what happened, and return it.
275
+
276
+ ``contributor_address`` is already canonical by the time it arrives:
277
+ checking it is :mod:`techtree.publication.address`'s job. This method's
278
+ job is to make sure it travels beside the request and nowhere else at
279
+ all — not into the submission, not into the journal, not into a log
280
+ line. The run log stores what it is sent and serves those exact bytes
281
+ back at a public address, so an address inside the body would be public
282
+ by construction; the receiving side reads it from a header for that
283
+ reason and refuses a body carrying anything but the proof.
284
+ """
285
+ journal = PublicationJournal(self._run_dir(plan.run_id))
286
+ self._record(journal, plan, status=PublicationStatus.PENDING)
287
+
288
+ try:
289
+ envelope = self._submit(plan, contributor_address, skill_github_url)
290
+ path = self._write_receipt(plan.run_id, envelope)
291
+ except TechtreeError as error:
292
+ self._record(
293
+ journal, plan, status=PublicationStatus.FAILED, error_code=error.code
294
+ )
295
+ raise
296
+
297
+ receipt = envelope.payload
298
+ self._record(
299
+ journal,
300
+ plan,
301
+ status=PublicationStatus.PUBLISHED,
302
+ entry_url=receipt.entry_url,
303
+ log_sequence=receipt.log_sequence,
304
+ )
305
+ return PublicationOutcome(
306
+ run_id=plan.run_id,
307
+ receipt=receipt,
308
+ receipt_path=path,
309
+ status=PublicationStatus.PUBLISHED,
310
+ contributor_address_sent=contributor_address is not None,
311
+ )
312
+
313
+ def status(self, run_id: str) -> PublicationStatus:
314
+ """Return where this run stands, read from its journal."""
315
+ return PublicationJournal(self._run_dir(run_id)).status()
316
+
317
+ def publication_eligible(self, run_id: str) -> bool:
318
+ """Return whether this run's own report says it may be published.
319
+
320
+ Asked by the surfaces that decide whether to *offer* publishing, so a
321
+ run with no proof on this machine is simply not eligible rather than an
322
+ error: nothing is being published here, and a missing bundle is a
323
+ complete answer to the question that was asked.
324
+ """
325
+ try:
326
+ directory = self._bundle_dir(run_id)
327
+ except NotFoundError:
328
+ return False
329
+ report = self._report(directory, run_id)
330
+ return publication_eligible_for(
331
+ grade=report.proof_grade, publication=report.statuses.publication
332
+ )
333
+
334
+ # -- the request --------------------------------------------------------
335
+
336
+ def submission_bytes(self, run_id: str) -> bytes:
337
+ """Return the exact bytes a submission for this run puts on the wire.
338
+
339
+ This is the whole of the wire shape and the only place it is built, so
340
+ the request the transport sends and the conformance fixture the
341
+ receiving side is tested against cannot be two different documents that
342
+ happen to look alike. Decisions 0038 fixes the four members; the model
343
+ refuses a fifth, and the canonical encoding fixes the byte order, so
344
+ the same proof directory produces the same bytes on any machine.
345
+
346
+ It describes rather than sends. Whether this run *may* be published is
347
+ :meth:`plan`'s question, asked of the run's own verified proof and its
348
+ rights statement before anybody is offered anything, and :meth:`publish`
349
+ is the only method here that opens a socket.
350
+ """
351
+ directory = self._bundle_dir(run_id)
352
+ submission = PublicationSubmission(
353
+ schema_version=PUBLICATION_SUBMISSION_SCHEMA_VERSION,
354
+ run_id=run_id,
355
+ bundle_digest=self._bundle_digest(directory, run_id),
356
+ files=self._files(directory),
357
+ )
358
+ return canonical_json_bytes(submission)
359
+
360
+ def _submit(
361
+ self,
362
+ plan: PublicationPlan,
363
+ contributor_address: str | None,
364
+ skill_github_url: str | None,
365
+ ) -> ObjectEnvelope[PublicationReceiptPayload]:
366
+ """Send one submission and return the receipt it came back with."""
367
+ body = self.submission_bytes(plan.run_id)
368
+ if plan.skill_name is None and skill_github_url is None:
369
+ # Keep compatibility with transports supplied by callers that
370
+ # predate the optional metadata headers.
371
+ response = self._transport.submit(
372
+ endpoint=plan.endpoint,
373
+ body=body,
374
+ contributor_address=contributor_address,
375
+ )
376
+ else:
377
+ response = cast(PublicationMetadataTransport, self._transport).submit(
378
+ endpoint=plan.endpoint,
379
+ body=body,
380
+ contributor_address=contributor_address,
381
+ skill_name=plan.skill_name,
382
+ skill_github_url=skill_github_url,
383
+ )
384
+ return self._receipt(response, plan)
385
+
386
+ def _receipt(
387
+ self, response: bytes, plan: PublicationPlan
388
+ ) -> ObjectEnvelope[PublicationReceiptPayload]:
389
+ """Parse the answer and refuse everything that is not this run's receipt.
390
+
391
+ Parsing is here; the checks are in :mod:`techtree.publication.verify`,
392
+ because they are about the network's word rather than about this run
393
+ directory, and the withdrawal path needs the same ones.
394
+ """
395
+ try:
396
+ envelope = ObjectEnvelope[PublicationReceiptPayload].model_validate_json(
397
+ response
398
+ )
399
+ except PydanticValidationError as error:
400
+ raise ValidationError(
401
+ "the run log answered with something that is not a publication "
402
+ "receipt, so nothing was recorded",
403
+ code=PUBLICATION_RECEIPT_INVALID,
404
+ details={"run_id": plan.run_id},
405
+ ) from error
406
+
407
+ verify_publication_receipt(
408
+ envelope,
409
+ coordinates=self._coordinates,
410
+ run_id=plan.run_id,
411
+ bundle_digest=plan.bundle_digest,
412
+ )
413
+ return envelope
414
+
415
+ def _write_receipt(
416
+ self, run_id: str, receipt: ObjectEnvelope[PublicationReceiptPayload]
417
+ ) -> Path:
418
+ """Write the receipt into the run directory, converging on a retry.
419
+
420
+ Created with ``O_EXCL``, so nothing that is already there is opened for
421
+ writing. What is found there decides the answer: the same bytes mean the
422
+ run log answered the same way twice, which is what a retry after a lost
423
+ response looks like and is a success; different bytes under the same name
424
+ are two different records of one publication, and there is no version of
425
+ that which is safe to resolve by choosing one.
426
+ """
427
+ path = self._run_dir(run_id) / PUBLICATION_RECEIPT_FILENAME
428
+ data = canonical_json_bytes(receipt)
429
+ try:
430
+ with open_exclusive(path) as handle:
431
+ handle.write(data)
432
+ handle.flush()
433
+ except ConflictError as error:
434
+ if path.read_bytes() == data:
435
+ return path
436
+ raise ConflictError(
437
+ f"run {run_id} already holds a different publication receipt, so "
438
+ "this one was not written: two receipts for one run cannot both "
439
+ "be its record",
440
+ code=PUBLICATION_RECEIPT_CONFLICT,
441
+ details={"run_id": run_id, "path": str(path)},
442
+ ) from error
443
+ fsync_directory(path.parent)
444
+ return path
445
+
446
+ # -- recording ----------------------------------------------------------
447
+
448
+ def _record(
449
+ self,
450
+ journal: PublicationJournal,
451
+ plan: PublicationPlan,
452
+ *,
453
+ status: PublicationStatus,
454
+ entry_url: str | None = None,
455
+ log_sequence: int | None = None,
456
+ error_code: str | None = None,
457
+ ) -> None:
458
+ """Append one line to the run's publication journal."""
459
+ journal.append(
460
+ PublicationJournalEntry(
461
+ schema_version=PUBLICATION_JOURNAL_SCHEMA_VERSION,
462
+ sequence=journal.next_sequence(),
463
+ at=self._clock(),
464
+ run_id=plan.run_id,
465
+ status=status,
466
+ bundle_digest=plan.bundle_digest,
467
+ endpoint=plan.endpoint,
468
+ file_count=plan.file_count,
469
+ byte_count=plan.byte_count,
470
+ entry_url=entry_url,
471
+ log_sequence=log_sequence,
472
+ error_code=error_code,
473
+ )
474
+ )
475
+
476
+ # -- reading the run ----------------------------------------------------
477
+
478
+ def _run_dir(self, run_id: str) -> Path:
479
+ return self._runs_dir / run_id
480
+
481
+ def _skill_name(self, run_id: str, directory: Path) -> str | None:
482
+ """Read a verified candidate Skill's public name from run inputs.
483
+
484
+ The proof verifies the candidate experiment, while the prepared Skill
485
+ artifact remains in the run's immutable inputs. The candidate manifest
486
+ is the bridge between them: only a draft artifact whose root digest is
487
+ exactly the candidate reference may contribute a public name. Metadata
488
+ is descriptive and optional, so an old or incomplete run simply has no
489
+ name rather than making its otherwise valid proof unpublishable.
490
+ """
491
+ inputs = self._run_dir(run_id) / _INPUTS_DIRECTORY
492
+ try:
493
+ draft = self._load_json_model(inputs / _DRAFT_FILENAME, SubmissionDraft)
494
+ candidate = self._load_json_model(
495
+ directory / _CANDIDATE_MANIFEST_FILENAME, ExperimentManifest
496
+ )
497
+ skill = draft.skill_artifact
498
+ subject = candidate.configuration.agents.get("subject")
499
+ if subject is None or len(subject.harness.skills) != 1:
500
+ return None
501
+ if subject.harness.skills[0].digest != skill.root_digest:
502
+ return None
503
+ if skill_content_digest(skill.files) != skill.root_digest:
504
+ return None
505
+
506
+ # The copied artifact is part of the immutable prepared input. If
507
+ # present, require it to agree with the draft before exposing its
508
+ # label; this catches a stale or hand-edited input tree without
509
+ # affecting runs created before this metadata existed.
510
+ stored = inputs / _SKILL_DIRECTORY / _SKILL_ARTIFACT_FILENAME
511
+ if stored.is_file():
512
+ stored_skill = self._load_json_model(stored, SkillArtifact)
513
+ if stored_skill != skill:
514
+ return None
515
+
516
+ files = inputs / _SKILL_DIRECTORY / "files"
517
+ if files.is_dir():
518
+ for entry in skill.files:
519
+ raw = (files / entry.path).read_bytes()
520
+ if (
521
+ len(raw) != entry.size
522
+ or sha256_digest_bytes(raw) != entry.digest
523
+ ):
524
+ return None
525
+
526
+ if _SKILL_NAME_PATTERN.fullmatch(skill.name) is None:
527
+ return None
528
+ return skill.name
529
+ except (OSError, PydanticValidationError, KeyError, TypeError, ValueError):
530
+ return None
531
+
532
+ @staticmethod
533
+ def _load_json_model(path: Path, model: type[_ModelT]) -> _ModelT:
534
+ """Load a strict protocol model from its exact stored JSON bytes."""
535
+ raw = path.read_bytes()
536
+ # The concrete model types are supplied by the two callers above. A
537
+ # tiny helper keeps both paths on model_validate_json rather than
538
+ # decoding to an untyped mapping first.
539
+ return model.model_validate_json(raw)
540
+
541
+ def _bundle_dir(self, run_id: str) -> Path:
542
+ directory = proof_bundle_dir(self._run_dir(run_id))
543
+ if not (directory / BUNDLE_MANIFEST_FILENAME).is_file():
544
+ raise NotFoundError(
545
+ f"run {run_id} has no proof to publish on this machine",
546
+ code=PUBLICATION_PROOF_NOT_FOUND,
547
+ details={"run_id": run_id},
548
+ )
549
+ return directory
550
+
551
+ def _bundle_digest(self, directory: Path, run_id: str) -> Digest:
552
+ """Return the digest of the signed manifest that commits to the bundle.
553
+
554
+ One value identifies the whole submission, and it is the one the
555
+ verification that just passed checked every file against.
556
+ """
557
+ raw = (directory / BUNDLE_MANIFEST_FILENAME).read_bytes()
558
+ try:
559
+ envelope = ObjectEnvelope[LocalProofBundleManifest].model_validate_json(raw)
560
+ except PydanticValidationError as error:
561
+ raise VerificationError(
562
+ f"run {run_id}'s proof manifest cannot be read",
563
+ code=PROOF_BUNDLE_INVALID,
564
+ details={"run_id": run_id},
565
+ ) from error
566
+ return envelope.payload_digest
567
+
568
+ def _report(self, directory: Path, run_id: str) -> UpliftReport:
569
+ """Return the report this bundle carries."""
570
+ raw = (directory / REPORT_FILENAME).read_bytes()
571
+ try:
572
+ envelope = ObjectEnvelope[UpliftReport].model_validate_json(raw)
573
+ except PydanticValidationError as error:
574
+ raise VerificationError(
575
+ f"run {run_id}'s report cannot be read out of its own proof",
576
+ code=PROOF_BUNDLE_INVALID,
577
+ details={"run_id": run_id},
578
+ ) from error
579
+ return envelope.payload
580
+
581
+ def _proof_files(self, directory: Path) -> dict[str, bytes]:
582
+ """Return every file in the proof directory against its stored bytes.
583
+
584
+ The whole directory rather than the manifest's list: the manifest does
585
+ not commit to itself, and a submission without the signed manifest is a
586
+ submission nothing can be checked against.
587
+ """
588
+ return {
589
+ path.relative_to(directory).as_posix(): path.read_bytes()
590
+ for path in sorted(directory.rglob("*"))
591
+ if path.is_file()
592
+ }
593
+
594
+ def _files(self, directory: Path) -> dict[str, str]:
595
+ """Return the proof directory in the shape the wire carries it.
596
+
597
+ Path against base64 of the stored bytes, and nothing else. No digest
598
+ and no size travel beside a file, because both would be the submitter's
599
+ own arithmetic over the submitter's own bytes: they prove nothing, and
600
+ a receiving side that read them instead of the bundle's signed manifest
601
+ would be trusting the one party the manifest exists to avoid trusting.
602
+ Decisions 0038 fixes this, and the reason is worth more than the eight
603
+ bytes it saves.
604
+ """
605
+ return {
606
+ path: base64.b64encode(data).decode("ascii")
607
+ for path, data in self._proof_files(directory).items()
608
+ }
609
+
610
+ @property
611
+ def endpoint(self) -> str:
612
+ """Return the address this service publishes to, override first.
613
+
614
+ Whichever wins is what the plan carries and what the review prints, so
615
+ somebody agreeing to a publication is agreeing to the address it is
616
+ actually going to.
617
+ """
618
+ return resolved_endpoint(self._coordinates, self._endpoint_override)