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,183 @@
1
+ """Keccak-256, because EIP-55 is defined over it and nothing here provides it.
2
+
3
+ This module exists for one reason and has one caller. The mixed-case spelling
4
+ of an Ethereum address is a checksum, and the checksum is the original Keccak
5
+ submission rather than the NIST standard that came out of it. The two differ by
6
+ one byte of padding and produce entirely different digests, so ``hashlib``'s
7
+ ``sha3_256`` cannot be used and quietly produces a checksum that is wrong for
8
+ every address. ``cryptography`` exposes SHA-3 and SHAKE and does not expose
9
+ Keccak either.
10
+
11
+ The alternative to sixty lines here is a third-party dependency in a package
12
+ whose dependency list is deliberately eight entries long, pulled in so that a
13
+ forty-character string can be checked before it is sent. That trade is not
14
+ worth making, and the permutation is fixed for ever: Keccak-f[1600] has not
15
+ changed since 2011 and cannot, because the digests it produces are what
16
+ everything already published is addressed by.
17
+
18
+ So this is a plain transcription of the permutation, and it is held to the
19
+ published vectors in ``tests/unit/test_contributor_address.py`` rather than to
20
+ a reading of the code.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from typing import Final
26
+
27
+ __all__ = ["keccak256"]
28
+
29
+ #: Keccak-f[1600] operates on twenty-five 64-bit lanes.
30
+ _LANE_BITS: Final = 64
31
+ _LANE_MASK: Final = (1 << _LANE_BITS) - 1
32
+ _LANE_BYTES: Final = _LANE_BITS // 8
33
+
34
+ #: The sponge rate for a 256-bit digest: 1600 bits of state less twice the
35
+ #: capacity, in bytes.
36
+ _RATE_BYTES: Final = (1600 - 2 * 256) // 8
37
+
38
+ _DIGEST_BYTES: Final = 32
39
+
40
+ #: The domain separator Keccak pads with. SHA-3 pads with ``0x06`` instead, and
41
+ #: that single byte is the whole of the difference between this function and
42
+ #: ``hashlib.sha3_256``.
43
+ _PAD_BYTE: Final = 0x01
44
+ _PAD_FINAL_BIT: Final = 0x80
45
+
46
+ #: Rotation offsets, indexed ``[x][y]``.
47
+ _ROTATIONS: Final[tuple[tuple[int, ...], ...]] = (
48
+ (0, 36, 3, 41, 18),
49
+ (1, 44, 10, 45, 2),
50
+ (62, 6, 43, 15, 61),
51
+ (28, 55, 25, 21, 56),
52
+ (27, 20, 39, 8, 14),
53
+ )
54
+
55
+ _ROUND_CONSTANTS: Final[tuple[int, ...]] = (
56
+ 0x0000000000000001,
57
+ 0x0000000000008082,
58
+ 0x800000000000808A,
59
+ 0x8000000080008000,
60
+ 0x000000000000808B,
61
+ 0x0000000080000001,
62
+ 0x8000000080008081,
63
+ 0x8000000000008009,
64
+ 0x000000000000008A,
65
+ 0x0000000000000088,
66
+ 0x0000000080008009,
67
+ 0x000000008000000A,
68
+ 0x000000008000808B,
69
+ 0x800000000000008B,
70
+ 0x8000000000008089,
71
+ 0x8000000000008003,
72
+ 0x8000000000008002,
73
+ 0x8000000000000080,
74
+ 0x000000000000800A,
75
+ 0x800000008000000A,
76
+ 0x8000000080008081,
77
+ 0x8000000000008080,
78
+ 0x0000000080000001,
79
+ 0x8000000080008008,
80
+ )
81
+
82
+ type _State = list[list[int]]
83
+
84
+
85
+ def keccak256(data: bytes) -> bytes:
86
+ """Return the Keccak-256 digest of ``data``.
87
+
88
+ This is the pre-standardisation Keccak that Ethereum addresses, not
89
+ SHA3-256.
90
+ """
91
+ state: _State = [[0] * 5 for _ in range(5)]
92
+ for block in _blocks(data):
93
+ _absorb(state, block)
94
+ _permute(state)
95
+ return _squeeze(state)
96
+
97
+
98
+ def _blocks(data: bytes) -> list[bytes]:
99
+ """Return the padded message as whole rate-sized blocks.
100
+
101
+ Keccak's ``pad10*1``: a one bit, as many zero bits as it takes, and a final
102
+ one bit. Written here in the byte order the lanes are read in, which is why
103
+ the two one bits are the low bit of the first pad byte and the high bit of
104
+ the last.
105
+ """
106
+ padded = bytearray(data)
107
+ padded.append(_PAD_BYTE)
108
+ while len(padded) % _RATE_BYTES != 0:
109
+ padded.append(0)
110
+ padded[-1] |= _PAD_FINAL_BIT
111
+ return [
112
+ bytes(padded[start : start + _RATE_BYTES])
113
+ for start in range(0, len(padded), _RATE_BYTES)
114
+ ]
115
+
116
+
117
+ def _absorb(state: _State, block: bytes) -> None:
118
+ """Exclusive-or one block into the rate portion of the state."""
119
+ for position in range(_RATE_BYTES // _LANE_BYTES):
120
+ lane = int.from_bytes(
121
+ block[position * _LANE_BYTES : (position + 1) * _LANE_BYTES],
122
+ "little",
123
+ )
124
+ state[position % 5][position // 5] ^= lane
125
+
126
+
127
+ def _squeeze(state: _State) -> bytes:
128
+ """Return the first 32 bytes of the state, in lane order.
129
+
130
+ A 256-bit digest is shorter than the rate, so the sponge never squeezes
131
+ twice and there is no second permutation to perform here.
132
+ """
133
+ out = bytearray()
134
+ position = 0
135
+ while len(out) < _DIGEST_BYTES:
136
+ out += state[position % 5][position // 5].to_bytes(_LANE_BYTES, "little")
137
+ position += 1
138
+ return bytes(out[:_DIGEST_BYTES])
139
+
140
+
141
+ def _permute(state: _State) -> None:
142
+ """Apply the twenty-four rounds of Keccak-f[1600] in place."""
143
+ for constant in _ROUND_CONSTANTS:
144
+ _theta(state)
145
+ state[:] = _rho_and_pi(state)
146
+ _chi(state)
147
+ state[0][0] ^= constant
148
+
149
+
150
+ def _theta(state: _State) -> None:
151
+ columns = [
152
+ state[x][0] ^ state[x][1] ^ state[x][2] ^ state[x][3] ^ state[x][4]
153
+ for x in range(5)
154
+ ]
155
+ parities = [
156
+ columns[(x - 1) % 5] ^ _rotate(columns[(x + 1) % 5], 1) for x in range(5)
157
+ ]
158
+ for x in range(5):
159
+ for y in range(5):
160
+ state[x][y] ^= parities[x]
161
+
162
+
163
+ def _rho_and_pi(state: _State) -> _State:
164
+ moved: _State = [[0] * 5 for _ in range(5)]
165
+ for x in range(5):
166
+ for y in range(5):
167
+ moved[y][(2 * x + 3 * y) % 5] = _rotate(state[x][y], _ROTATIONS[x][y])
168
+ return moved
169
+
170
+
171
+ def _chi(state: _State) -> None:
172
+ previous = [list(column) for column in state]
173
+ for x in range(5):
174
+ for y in range(5):
175
+ state[x][y] = previous[x][y] ^ (
176
+ (~previous[(x + 1) % 5][y] & _LANE_MASK) & previous[(x + 2) % 5][y]
177
+ )
178
+
179
+
180
+ def _rotate(lane: int, places: int) -> int:
181
+ """Return one 64-bit lane rotated left."""
182
+ places %= _LANE_BITS
183
+ return ((lane << places) | (lane >> (_LANE_BITS - places))) & _LANE_MASK
@@ -0,0 +1,209 @@
1
+ """What is sent, and what comes back. Decisions document 0038.
2
+
3
+ Four documents cross the one boundary this package opens, and they are separate
4
+ objects because they are made by different parties and prove different things.
5
+
6
+ A :class:`PublicationSubmission` is what the participant sends: the proof
7
+ directory, file by file, exactly as it sits on disk. Nothing is summarised,
8
+ nothing is re-derived, and nothing is added — the bundle already commits to
9
+ every one of those files by digest under the participant's own signature, so
10
+ sending anything but the stored bytes would be sending something nobody signed.
11
+ It carries no episodes and no transcripts, because the proof directory holds
12
+ none: an episode receipt carries digests, task hashes and scores, and the raw
13
+ episodes live outside the directory entirely.
14
+
15
+ The submission is a mapping of path to bytes and it has exactly four members,
16
+ which decisions 0038's wire contract fixes so that the two halves of this
17
+ feature cannot drift. Both of those shapes are refusals rather than
18
+ preferences, and each is written into the model below beside the field it
19
+ constrains.
20
+
21
+ A :class:`PublicationReceiptPayload` is what the network sends back: where the
22
+ entry landed in the log, which bundle it accepted, when, and which of its own
23
+ checks it ran. The participant signed their run and the network countersigns
24
+ that it accepted it, which is what makes an accepted entry checkable by somebody
25
+ who trusts neither party's word about the other.
26
+
27
+ It travels inside an :class:`~techtree.models.base.ObjectEnvelope`, which is how
28
+ every other signed document in this protocol travels: payload, the digest of the
29
+ payload's canonical bytes, and a detached signature over that digest. An earlier
30
+ draft carried the signature flat beside the fields it covered, and that shape
31
+ cannot say which members the digest was taken over — two implementations would
32
+ have had to agree on it by reading the same paragraph the same way, which is
33
+ exactly the drift decisions 0038's wire contract exists to prevent. The envelope
34
+ answers it structurally: the digest is over ``payload`` and over nothing else.
35
+
36
+ A :class:`WithdrawalRequest` is the participant asking that a published entry be
37
+ marked withdrawn, signed with the same key that signed the run. It carries no
38
+ free text: nothing a submitter writes appears on the site, and a reason field
39
+ would be the one string that did. A :class:`WithdrawalReceiptPayload` is the
40
+ network's countersigned record that the entry is now marked withdrawn — the
41
+ entry stays where it is, because a published entry is withdrawn and never
42
+ deleted. Both travel in the same envelope, for the same reason.
43
+
44
+ The one field that is none of them is the contributor address. It is optional,
45
+ it is volunteered, and its name says it is unverified, because a string somebody
46
+ typed is not proof of control of an account. It travels in the
47
+ ``x-techtree-contributor-address`` header, beside the submission and never
48
+ inside it, because the run log serves a stored submission back at a public
49
+ address. It is kept nowhere on this machine.
50
+ """
51
+
52
+ from __future__ import annotations
53
+
54
+ from typing import Literal, Self
55
+
56
+ from pydantic import Field, model_validator
57
+
58
+ from techtree.models.base import (
59
+ Base64String,
60
+ Digest,
61
+ NonEmptyString,
62
+ ProtocolModel,
63
+ PublicKeyRef,
64
+ UtcDateTime,
65
+ )
66
+
67
+ __all__ = [
68
+ "PublicationCheck",
69
+ "PublicationReceiptPayload",
70
+ "PublicationSubmission",
71
+ "WithdrawalReceiptPayload",
72
+ "WithdrawalRequest",
73
+ ]
74
+
75
+
76
+ class PublicationSubmission(ProtocolModel):
77
+ """Everything one publication sends, and nothing else.
78
+
79
+ Four members, fixed by decisions 0038 and by nothing else. The document is
80
+ stored by the run log and served back at a public address, so a member that
81
+ arrived here without being in the contract would be published without
82
+ anybody having agreed to publish it. The receiving side refuses a body that
83
+ carries more; ``extra="forbid"`` on :class:`~techtree.models.base.
84
+ ProtocolModel` means this side cannot build one.
85
+ """
86
+
87
+ schema_version: Literal["techtree.publication-submission.v1alpha1"]
88
+ run_id: NonEmptyString
89
+ #: The digest of the bundle's own signed manifest, which commits to every
90
+ #: file below. One value identifies the whole submission.
91
+ bundle_digest: Digest
92
+ #: Where each file sits inside the proof directory, in POSIX spelling,
93
+ #: against the base64 of its bytes. The key is the same string the bundle
94
+ #: manifest places the file under, so the receiving side can find each file
95
+ #: in the manifest that arrived with it.
96
+ #:
97
+ #: A mapping, and deliberately nothing richer. An earlier shape put a
98
+ #: digest and a size beside each file's content, and both were the
99
+ #: submitter's own claims about the submitter's own bytes: worth nothing if
100
+ #: honest and dangerous if believed. Every digest and every length the
101
+ #: receiving side works with has to come from the bundle's own signed
102
+ #: manifest, which is inside this mapping under ``bundle.json`` and is
103
+ #: signed by the key the bundle carries. Sending a digest beside the
104
+ #: content invites somebody downstream to trust it instead, and the only
105
+ #: reliable way to stop that is to have none to trust. Base64 because every
106
+ #: file in a proof bundle is canonical JSON, and base64 is what carries
107
+ #: bytes through JSON without a second encoding decision.
108
+ files: dict[NonEmptyString, Base64String]
109
+
110
+ @model_validator(mode="after")
111
+ def _check_the_files_are_a_bundle(self) -> Self:
112
+ """Reject a submission that places no file.
113
+
114
+ A mapping cannot place one file twice, which is the second reason the
115
+ contract chose one: the shape refuses a whole class of malformed
116
+ submission rather than a validator having to notice it.
117
+ """
118
+ if not self.files:
119
+ raise ValueError("a publication submits at least one file")
120
+ return self
121
+
122
+
123
+ class PublicationCheck(ProtocolModel):
124
+ """One check the network ran on a submission, and how it came out."""
125
+
126
+ id: NonEmptyString
127
+ passed: bool
128
+ detail: NonEmptyString
129
+
130
+
131
+ class PublicationReceiptPayload(ProtocolModel):
132
+ """What the network countersigns when it accepts one submission.
133
+
134
+ The payload of an :class:`~techtree.models.base.ObjectEnvelope`, so the
135
+ digest the network signs is the digest of exactly these members and the two
136
+ halves of this feature need not agree about anything else.
137
+ """
138
+
139
+ schema_version: Literal["techtree.publication-receipt.v1alpha1"]
140
+ id: NonEmptyString
141
+ run_id: NonEmptyString
142
+ #: Where this entry sits in the log. The log is ordered by arrival and by
143
+ #: nothing else, so this is a position in time rather than a rank, and it
144
+ #: may have gaps.
145
+ log_sequence: int = Field(ge=0)
146
+ bundle_digest: Digest
147
+ accepted_at: UtcDateTime
148
+ checks: list[PublicationCheck]
149
+ #: Where the entry can now be read.
150
+ entry_url: NonEmptyString
151
+ #: The network's own key, carried so that the receipt can be checked later
152
+ #: by anybody holding the network's published public half, including by
153
+ #: somebody who was not party to the exchange. The participant's own CLI
154
+ #: does not learn the key from here — it checks this against the key the
155
+ #: release pins, because a key learned from the answer proves nothing about
156
+ #: who wrote the answer.
157
+ public_key: PublicKeyRef
158
+
159
+ @model_validator(mode="after")
160
+ def _check_the_receipt_reports_its_own_checks(self) -> Self:
161
+ """Reject a receipt that accepted a submission it never checked."""
162
+ if not self.checks:
163
+ raise ValueError("a publication receipt names the checks that ran")
164
+ identifiers = [check.id for check in self.checks]
165
+ if len(set(identifiers)) != len(identifiers):
166
+ raise ValueError("a publication receipt reports each check once")
167
+ return self
168
+
169
+ @property
170
+ def failed_checks(self) -> list[PublicationCheck]:
171
+ """Return every check the network ran and did not pass."""
172
+ return [check for check in self.checks if not check.passed]
173
+
174
+
175
+ class WithdrawalRequest(ProtocolModel):
176
+ """The participant asking that one published entry be marked withdrawn.
177
+
178
+ Three members and no fourth. There is no reason field and there will not be
179
+ one: decisions 0038 settled that nothing a submitter writes appears on the
180
+ site, and a free-text reason attached to a public entry is exactly that.
181
+
182
+ The bundle digest is the whole of the subject, because a run is addressed on
183
+ the log by its bundle digest. ``requested_at`` makes the request a dated
184
+ statement rather than a bare assertion, and the signature over the payload's
185
+ digest is what proves it came from the key that signed the run. Nothing here
186
+ carries a public key: the network already holds the participant's, inside
187
+ the bundle it accepted, and looking the key up there rather than believing
188
+ one that arrived with the request is the point.
189
+ """
190
+
191
+ schema_version: Literal["techtree.publication-withdrawal.v1alpha1"]
192
+ bundle_digest: Digest
193
+ requested_at: UtcDateTime
194
+
195
+
196
+ class WithdrawalReceiptPayload(ProtocolModel):
197
+ """The network's countersigned record that an entry is marked withdrawn.
198
+
199
+ The entry itself stays: withdrawal is an appended event, not a deletion, so
200
+ what comes back names where the entry still lives rather than saying it is
201
+ gone.
202
+ """
203
+
204
+ schema_version: Literal["techtree.publication-withdrawal-receipt.v1alpha1"]
205
+ bundle_digest: Digest
206
+ #: Where the entry, now marked withdrawn, can still be read.
207
+ entry_url: NonEmptyString
208
+ withdrawn_at: UtcDateTime
209
+ public_key: PublicKeyRef
@@ -0,0 +1,35 @@
1
+ """The offer to publish, written once. Decisions document 0038.
2
+
3
+ Two surfaces make this offer — the finished result and the proof check — and
4
+ they are the two places somebody has just been told their run holds together.
5
+ An offer that read differently depending on which of them a person happened to
6
+ be looking at would be two offers, so it is one function and both call it.
7
+
8
+ It carries ``requires_user_confirmation``, which is not advisory. Publishing is
9
+ the one thing this product does that leaves the machine, and the flag is how a
10
+ host agent is told to ask rather than act. The plugin itself publishes nothing
11
+ and can open no network connection at all; what it may do is put this command in
12
+ front of a person.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from techtree.models.cli import NextAction
18
+
19
+ __all__ = ["publish_action"]
20
+
21
+
22
+ def publish_action(run_id: str) -> NextAction:
23
+ """Return the offer to publish one verified run."""
24
+ return NextAction(
25
+ id="publish_run",
26
+ label="Publish this run to the public run log",
27
+ reason=(
28
+ "The proof just verified, so the run's own evidence travels with "
29
+ "it. It shows what would be sent and asks before sending anything."
30
+ ),
31
+ cli=["techtree", "publish", run_id],
32
+ hermes_tool=None,
33
+ hermes_args=None,
34
+ requires_user_confirmation=True,
35
+ )