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
techtree/fs.py ADDED
@@ -0,0 +1,234 @@
1
+ """Safe filesystem primitives. Spec section 10.9.
2
+
3
+ Two properties matter here and both are about what a reader can observe.
4
+
5
+ *Atomicity.* A reader must never see a half-written file. Writes go to a
6
+ temporary file in the same directory, are flushed and fsynced, and then replace
7
+ the target with ``os.replace``, which is atomic within a filesystem. The
8
+ containing directory is fsynced afterwards so the rename itself survives a
9
+ crash.
10
+
11
+ *Immutability.* Campaign, Climb, DataPolicy, manifest, lock, and receipt
12
+ artifacts are written exactly once. :func:`open_exclusive` creates them with
13
+ ``O_EXCL``, so a second attempt to write one is reported as a conflict instead
14
+ of quietly replacing evidence.
15
+
16
+ *Privacy.* Techtree state is single-user: files are ``0600`` and directories
17
+ ``0700``. Both helpers that create a directory create *every* directory they
18
+ need at that mode, because a private leaf at the end of a world-readable path
19
+ is not private — a run directory nobody can list is still a run directory
20
+ whose parent tells you it exists.
21
+
22
+ Nothing here follows symlinks into a location the caller did not name.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import os
29
+ import shutil
30
+ import tempfile
31
+ from contextlib import suppress
32
+ from pathlib import Path
33
+ from typing import BinaryIO, cast
34
+
35
+ from techtree.canonical import to_json_value
36
+ from techtree.errors import ConflictError, NotFoundError, ValidationError
37
+ from techtree.models.base import JsonValue
38
+
39
+ __all__ = [
40
+ "atomic_write_bytes",
41
+ "atomic_write_json",
42
+ "atomic_write_text",
43
+ "ensure_private_directory",
44
+ "fsync_directory",
45
+ "open_exclusive",
46
+ "read_json",
47
+ "realpath_within",
48
+ "remove_tree",
49
+ ]
50
+
51
+ #: Owner read and write only. Techtree state is single-user by design.
52
+ _FILE_MODE = 0o600
53
+ #: Owner traversal only, so a run directory is not world-readable.
54
+ _DIRECTORY_MODE = 0o700
55
+
56
+
57
+ def _make_private_directory(path: Path) -> None:
58
+ """Create a directory and every missing parent, each one private.
59
+
60
+ ``mkdir(parents=True)`` creates the intermediate directories under the
61
+ process umask and leaves them there, so hardening only the leaf hardens
62
+ the room and not the corridor. Inside a Techtree home every ancestor
63
+ happens to be ``0700`` already, which made this a latent defect rather
64
+ than a live leak — and the distance between those two is one caller
65
+ passing a path whose parent does not exist yet.
66
+
67
+ The mode is applied at creation rather than after it, so there is no
68
+ instant in which the directory exists and anyone else can read it. A
69
+ umask can only take bits away from that mode, never add them, and the
70
+ ``chmod`` afterwards makes the result exact on a machine whose umask is
71
+ unusual.
72
+
73
+ Only directories this call creates are touched. A directory that was
74
+ already there belongs to whoever made it, and quietly tightening
75
+ somebody's home directory to ``0700`` would be a worse bug than the one
76
+ being fixed here.
77
+ """
78
+ missing: list[Path] = []
79
+ current = path
80
+ while not current.is_dir():
81
+ missing.append(current)
82
+ if current.parent == current:
83
+ break
84
+ current = current.parent
85
+
86
+ for directory in reversed(missing):
87
+ try:
88
+ directory.mkdir(mode=_DIRECTORY_MODE)
89
+ except FileExistsError:
90
+ if directory.is_dir():
91
+ # Another process created it between the walk and here. It is
92
+ # a Techtree directory either way, made by this same code.
93
+ continue
94
+ # The path exists and is not a directory, which is what the
95
+ # caller needs to hear about.
96
+ raise
97
+ # Windows and some network filesystems have no POSIX mode bits. The
98
+ # directory still exists, which is what the caller asked for.
99
+ with suppress(NotImplementedError, OSError):
100
+ os.chmod(directory, _DIRECTORY_MODE)
101
+
102
+
103
+ def atomic_write_bytes(path: Path, data: bytes, *, mode: int = _FILE_MODE) -> None:
104
+ """Write, fsync, chmod, and atomically replace."""
105
+ directory = path.parent
106
+ _make_private_directory(directory)
107
+
108
+ handle, temporary_name = tempfile.mkstemp(
109
+ dir=directory, prefix=f".{path.name}.", suffix=".tmp"
110
+ )
111
+ temporary = Path(temporary_name)
112
+ try:
113
+ with os.fdopen(handle, "wb") as stream:
114
+ stream.write(data)
115
+ stream.flush()
116
+ os.fsync(stream.fileno())
117
+ os.chmod(temporary, mode)
118
+ os.replace(temporary, path)
119
+ except BaseException:
120
+ temporary.unlink(missing_ok=True)
121
+ raise
122
+ fsync_directory(directory)
123
+
124
+
125
+ def atomic_write_text(path: Path, text: str, *, mode: int = _FILE_MODE) -> None:
126
+ """UTF-8 wrapper."""
127
+ atomic_write_bytes(path, text.encode("utf-8"), mode=mode)
128
+
129
+
130
+ def atomic_write_json(path: Path, value: object, *, mode: int = _FILE_MODE) -> None:
131
+ """Write readable JSON atomically.
132
+
133
+ This is the human-readable spelling, indented and newline-terminated. It is
134
+ not the canonical spelling; anything that is going to be hashed is
135
+ serialized with :func:`techtree.canonical.canonical_json_bytes`.
136
+ """
137
+ rendered = json.dumps(
138
+ to_json_value(value),
139
+ indent=2,
140
+ sort_keys=True,
141
+ ensure_ascii=False,
142
+ )
143
+ atomic_write_text(path, f"{rendered}\n", mode=mode)
144
+
145
+
146
+ def read_json(path: Path) -> JsonValue:
147
+ """Read UTF-8 JSON and raise typed malformed-data errors."""
148
+ try:
149
+ raw = path.read_bytes()
150
+ except FileNotFoundError as error:
151
+ raise NotFoundError(
152
+ f"no such file: {path}",
153
+ details={"path": str(path)},
154
+ ) from error
155
+
156
+ try:
157
+ text = raw.decode("utf-8")
158
+ except UnicodeDecodeError as error:
159
+ raise ValidationError(
160
+ f"file is not valid UTF-8: {path}",
161
+ details={"path": str(path)},
162
+ ) from error
163
+
164
+ try:
165
+ return cast(JsonValue, json.loads(text))
166
+ except json.JSONDecodeError as error:
167
+ raise ValidationError(
168
+ f"file is not valid JSON: {path} ({error.msg} at line {error.lineno})",
169
+ details={"path": str(path), "line": error.lineno},
170
+ ) from error
171
+
172
+
173
+ def ensure_private_directory(path: Path) -> None:
174
+ """Create a directory, and every parent it needs, 0700 where supported."""
175
+ if path.is_symlink():
176
+ raise ValidationError(
177
+ f"refusing to use a symlinked directory: {path}",
178
+ details={"path": str(path)},
179
+ )
180
+ _make_private_directory(path)
181
+ # An existing directory is re-hardened, because this function's promise to
182
+ # its caller is about the directory it returns, not only about the case
183
+ # where it had to make one.
184
+ with suppress(NotImplementedError, OSError):
185
+ os.chmod(path, _DIRECTORY_MODE)
186
+
187
+
188
+ def fsync_directory(path: Path) -> None:
189
+ """Best-effort directory fsync."""
190
+ try:
191
+ descriptor = os.open(path, os.O_RDONLY)
192
+ except OSError:
193
+ return
194
+ try:
195
+ os.fsync(descriptor)
196
+ except OSError:
197
+ # Directory fsync is unsupported on some platforms and filesystems.
198
+ pass
199
+ finally:
200
+ os.close(descriptor)
201
+
202
+
203
+ def remove_tree(path: Path) -> None:
204
+ """Remove run-owned data without following links."""
205
+ if path.is_symlink():
206
+ # Unlink the link itself; never descend into whatever it points at.
207
+ path.unlink()
208
+ return
209
+ if not path.exists():
210
+ return
211
+ if path.is_dir():
212
+ shutil.rmtree(path)
213
+ return
214
+ path.unlink()
215
+
216
+
217
+ def realpath_within(path: Path, root: Path) -> bool:
218
+ """Return whether a resolved path is contained in root."""
219
+ return path.resolve().is_relative_to(root.resolve())
220
+
221
+
222
+ def open_exclusive(path: Path, mode: int = _FILE_MODE) -> BinaryIO:
223
+ """Create an immutable file with O_EXCL semantics."""
224
+ flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
225
+ if hasattr(os, "O_NOFOLLOW"):
226
+ flags |= os.O_NOFOLLOW
227
+ try:
228
+ descriptor = os.open(path, flags, mode)
229
+ except FileExistsError as error:
230
+ raise ConflictError(
231
+ f"refusing to overwrite an immutable artifact: {path}",
232
+ details={"path": str(path)},
233
+ ) from error
234
+ return cast(BinaryIO, os.fdopen(descriptor, "wb"))
techtree/harness.py ADDED
@@ -0,0 +1,108 @@
1
+ """The tool surface a pinned harness offers. Decisions 0007 R9 item 4.
2
+
3
+ Mounting a Skill changes what the subject is offered. Hermes 0.19.0 renders the
4
+ index of visible Skills into the description of its own Skill-management tool,
5
+ so the candidate variant is handed one tool description the baseline was not,
6
+ and a comparison that demanded one identical tool surface would reject every
7
+ correct run.
8
+
9
+ That leaves a question a comparison cannot answer from the run alone: is a
10
+ description difference *the* expected one, or is it evidence that the harness
11
+ pin moved underneath the Campaign? Both look the same from inside a single
12
+ comparison — one description differing on one tool.
13
+
14
+ This module holds the other half of the answer: a conformance fixture recording
15
+ the tool surface the pinned harness offers, tool by tool, schema by schema. The
16
+ ratified rule (decisions document 0007, release ratifications) is an exact
17
+ derived difference — the tool count, the tool names, the parameter schemas and
18
+ the capabilities are unchanged, and only the known skill-aware description field
19
+ differs. Everything in that sentence except the description is pinned here, so a
20
+ future harness that adds a tool, drops one, renames one or reshapes a schema
21
+ fails the comparison instead of being absorbed into "the Skill index changed".
22
+
23
+ Descriptions are deliberately *not* pinned. A description holds the names of the
24
+ Skills currently mounted, so its digest is a property of the Campaign's Skill
25
+ rather than of the harness, and pinning it would make the fixture wrong the
26
+ first time a different Skill was measured.
27
+
28
+ The fixture is recorded from real evaluations, never authored. Each file says
29
+ which ones in its own ``recorded_from`` field.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import json
35
+ from functools import cache
36
+ from importlib import resources
37
+ from typing import Final
38
+
39
+ from techtree.models.base import Digest, NonEmptyString, ProtocolModel
40
+
41
+ __all__ = [
42
+ "HARNESS_CONFORMANCE_SCHEMA_VERSION",
43
+ "ConformanceTool",
44
+ "HarnessConformance",
45
+ "harness_conformance",
46
+ ]
47
+
48
+ #: The schema every conformance fixture declares.
49
+ HARNESS_CONFORMANCE_SCHEMA_VERSION: Final = "techtree.harness-conformance.v1alpha1"
50
+
51
+ _RESOURCE_DIRECTORY: Final = "harness"
52
+
53
+
54
+ class ConformanceTool(ProtocolModel):
55
+ """One tool the pinned harness offers, by name and parameter schema."""
56
+
57
+ name: NonEmptyString
58
+ parameters_digest: Digest
59
+
60
+
61
+ class HarnessConformance(ProtocolModel):
62
+ """The tool surface one pinned harness build offers."""
63
+
64
+ schema_version: NonEmptyString
65
+ harness_id: NonEmptyString
66
+ harness_version: NonEmptyString
67
+ skill_index_tool: NonEmptyString
68
+ recorded_from: NonEmptyString
69
+ tools: list[ConformanceTool]
70
+
71
+ @property
72
+ def tool_names(self) -> list[str]:
73
+ """Every tool name, in the order the fixture records them."""
74
+ return [tool.name for tool in self.tools]
75
+
76
+ @property
77
+ def parameters_by_tool(self) -> dict[str, Digest]:
78
+ """Each tool's parameter-schema digest, by tool name."""
79
+ return {tool.name: tool.parameters_digest for tool in self.tools}
80
+
81
+
82
+ @cache
83
+ def harness_conformance(harness_id: str, harness_version: str) -> HarnessConformance:
84
+ """Load the conformance fixture for one pinned harness build.
85
+
86
+ Raises :class:`FileNotFoundError` when no fixture was ever recorded for that
87
+ build, which is the correct outcome: a harness nobody measured has no
88
+ expected tool surface, and inventing one would be the opposite of a
89
+ conformance fixture.
90
+ """
91
+ resource = (
92
+ resources.files("techtree")
93
+ / "resources"
94
+ / _RESOURCE_DIRECTORY
95
+ / f"{harness_id}-{harness_version}.json"
96
+ )
97
+ if not resource.is_file():
98
+ raise FileNotFoundError(
99
+ f"no tool surface was recorded for {harness_id} {harness_version}"
100
+ )
101
+ document = json.loads(resource.read_text(encoding="utf-8"))
102
+ conformance = HarnessConformance.model_validate(document)
103
+ if conformance.schema_version != HARNESS_CONFORMANCE_SCHEMA_VERSION:
104
+ raise ValueError(
105
+ f"{resource} declares {conformance.schema_version}, not "
106
+ f"{HARNESS_CONFORMANCE_SCHEMA_VERSION}"
107
+ )
108
+ return conformance
@@ -0,0 +1,41 @@
1
+ """The one local key that binds a participant to their own receipts.
2
+
3
+ Spec section 7.5. Decisions document 0005, amendment 4.
4
+
5
+ WP0 built the Ed25519 primitives and froze them shut: :mod:`techtree.crypto`
6
+ knows how to make a key and check a signature, and nothing in the package knew
7
+ where a key lived or when to use one. This package is the activation, and it is
8
+ deliberately the *whole* of it — every other module asks this one to sign, so
9
+ there is exactly one place that touches private material.
10
+
11
+ Three rules hold everywhere below.
12
+
13
+ *The private half never leaves the identities directory.* It is written once,
14
+ owner-readable only, and loaded into memory to sign a digest. It is never
15
+ serialized into a document, never logged, never carried in a typed error's
16
+ details, and never written into a proof bundle. Only the public half travels.
17
+
18
+ *The key is self-issued, and the vocabulary says so.* Nothing registers it,
19
+ nothing uploads it, and no authority vouches for it. What a signature over
20
+ these receipts proves is that the participant's own key vouches for bytes that
21
+ verify against each other — which is what ``proof_grade: P1`` is permitted to
22
+ mean and nothing more.
23
+
24
+ *What is signed is the digest string.* :func:`techtree.crypto.sign_digest`
25
+ signs the ASCII ``sha256:...`` spelling of the canonical bytes, so a verifier
26
+ holding the digest can check the signature, and the thing attested to is
27
+ exactly the value that appears in the protocol document.
28
+
29
+ The division of labour:
30
+
31
+ ``models``
32
+ The public identity, and the shape a verification verdict is reported in.
33
+ ``store``
34
+ Where the key material lives on disk, and how it is created and loaded.
35
+ ``service``
36
+ Signing objects into envelopes, and verifying envelopes against a key.
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ __all__: list[str] = []
@@ -0,0 +1,113 @@
1
+ """The local identity, and the shape of a verification verdict.
2
+
3
+ Spec sections 7.5 and 7.12.
4
+
5
+ Two vocabularies live here because they are used together everywhere and
6
+ neither one belongs to the frozen v0.1 protocol.
7
+
8
+ :class:`ExecutorIdentity` is the public half of the participant's key, written
9
+ into the identities directory and copied into every proof bundle. It is a
10
+ :class:`~techtree.models.base.ProtocolModel` so that it is frozen, canonically
11
+ serializable, and digestible like everything else a bundle commits to. The
12
+ private half has no model at all: it is raw bytes on disk and an in-memory key
13
+ object, and giving it a document shape would be the first step towards it
14
+ appearing in one.
15
+
16
+ :class:`VerificationResult` is how every check in this subsystem reports. A
17
+ verifier that raised on the first broken thing would tell a reader which rule
18
+ failed first rather than what is wrong with what they are holding, so checks
19
+ are collected: each one named, each one with its own stable code, and the
20
+ verdict computed from them rather than asserted alongside them.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from typing import Final, Literal, Self
26
+
27
+ from pydantic import model_validator
28
+
29
+ from techtree.models.base import (
30
+ Base64String,
31
+ NonEmptyString,
32
+ ProtocolModel,
33
+ UtcDateTime,
34
+ )
35
+
36
+ __all__ = [
37
+ "LOCAL_IDENTITY_INVALID",
38
+ "SIGNATURE_VERIFICATION_FAILED",
39
+ "ExecutorIdentity",
40
+ "VerificationMessage",
41
+ "VerificationResult",
42
+ "VerificationStatus",
43
+ ]
44
+
45
+ type VerificationStatus = Literal["passed", "failed", "warning"]
46
+ """What one check found. ``warning`` is a claim that holds more weakly than it
47
+ wanted to, never a failure that someone decided to be relaxed about."""
48
+
49
+ #: Stable error codes. Spec section 15.
50
+ LOCAL_IDENTITY_INVALID: Final = "local_identity_invalid"
51
+ SIGNATURE_VERIFICATION_FAILED: Final = "signature_verification_failed"
52
+
53
+
54
+ class ExecutorIdentity(ProtocolModel):
55
+ """The public half of one participant-controlled local signing key.
56
+
57
+ ``created_at`` is operational identity metadata: it says when this machine
58
+ made a key, and it is deliberately not part of any scientific comparison.
59
+ Two participants running the same Campaign produce the same measurements
60
+ and different identities.
61
+ """
62
+
63
+ kind: Literal["local_ed25519"]
64
+ key_id: NonEmptyString
65
+ algorithm: Literal["ed25519"]
66
+ public_key: Base64String
67
+ created_at: UtcDateTime
68
+
69
+
70
+ class VerificationMessage(ProtocolModel):
71
+ """One named check and what it found.
72
+
73
+ ``code`` is the spec section 15 error code a failure of this check reports
74
+ under, so a machine reading a failed verification gets the same vocabulary
75
+ whether it read the envelope or the exception.
76
+ """
77
+
78
+ id: NonEmptyString
79
+ status: VerificationStatus
80
+ code: NonEmptyString
81
+ detail: NonEmptyString
82
+
83
+
84
+ class VerificationResult(ProtocolModel):
85
+ """Every check a verification ran, and whether the thing verified."""
86
+
87
+ verified: bool
88
+ messages: list[VerificationMessage]
89
+
90
+ @property
91
+ def failures(self) -> list[VerificationMessage]:
92
+ """Return every check that failed."""
93
+ return [message for message in self.messages if message.status == "failed"]
94
+
95
+ @property
96
+ def warnings(self) -> list[VerificationMessage]:
97
+ """Return every check that passed with a weaker claim than it wanted."""
98
+ return [message for message in self.messages if message.status == "warning"]
99
+
100
+ @model_validator(mode="after")
101
+ def _check_the_verdict_is_the_one_the_checks_support(self) -> Self:
102
+ """Reject a result whose verdict disagrees with its own checks."""
103
+ if self.verified and self.failures:
104
+ raise ValueError(
105
+ "a verification that reports success cannot carry a failed check"
106
+ )
107
+ if not self.verified and not self.failures:
108
+ raise ValueError(
109
+ "a verification that reports failure must name the check that failed"
110
+ )
111
+ if not self.messages:
112
+ raise ValueError("a verification result records the checks it ran")
113
+ return self
@@ -0,0 +1,199 @@
1
+ """Signing objects, and checking signed ones. Spec sections 7.5 and 7.12.
2
+
3
+ Everything that gets signed in Techtree goes through :meth:`IdentityService.
4
+ sign_object`, and everything that gets checked goes through
5
+ :func:`verify_signed_object`. Keeping both on one page is what makes the two
6
+ halves impossible to drift apart: the signature is made over the digest of the
7
+ payload's canonical bytes, and the check recomputes that digest from the
8
+ payload it was handed rather than trusting the one the envelope records.
9
+
10
+ That recomputation is the entire point of the envelope. A stored envelope
11
+ carries ``payload_digest`` and never recalculates it during validation, so an
12
+ edited payload keeps its old digest and looks fine to a parser. Verification is
13
+ where the two are compared, and it is why a receipt that was changed after it
14
+ was written cannot pass.
15
+
16
+ Verification takes the identity it should check against as an argument rather
17
+ than reading the local store. A proof bundle carries the public key that signed
18
+ it, and a bundle is verified against *that* key — offline, on another machine,
19
+ with no Techtree identity of its own. Checking a stranger's bundle against this
20
+ machine's key would fail every bundle for the wrong reason.
21
+
22
+ What a verified signature proves is bounded and worth stating plainly: these
23
+ bytes have not changed since the holder of that private key signed them. The
24
+ key is self-issued and local, so nothing here says who ran the evaluation, and
25
+ nothing here is a third party's word for anything.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ from base64 import b64decode
31
+ from typing import Final
32
+
33
+ from pydantic import BaseModel
34
+
35
+ from techtree.canonical import digest_object
36
+ from techtree.crypto import load_public_key, sign_digest, verify_signature
37
+ from techtree.errors import ValidationError
38
+ from techtree.identity.models import (
39
+ LOCAL_IDENTITY_INVALID,
40
+ SIGNATURE_VERIFICATION_FAILED,
41
+ ExecutorIdentity,
42
+ VerificationMessage,
43
+ VerificationResult,
44
+ )
45
+ from techtree.identity.store import IdentityStore
46
+ from techtree.models.base import ObjectEnvelope
47
+
48
+ __all__ = [
49
+ "IdentityService",
50
+ "verify_signed_object",
51
+ ]
52
+
53
+ _PASSED: Final = "passed"
54
+ _FAILED: Final = "failed"
55
+
56
+
57
+ class IdentityService:
58
+ """The local identity, ready to sign and to check its own signatures."""
59
+
60
+ def __init__(self, store: IdentityStore) -> None:
61
+ self._store = store
62
+
63
+ @property
64
+ def store(self) -> IdentityStore:
65
+ """Return the store this service signs with."""
66
+ return self._store
67
+
68
+ def ensure(self) -> ExecutorIdentity:
69
+ """Return the existing valid identity, creating one if there is none.
70
+
71
+ Creation happens when a person has asked for something that needs a
72
+ key — ``techtree setup``, or a run they started reaching the point
73
+ where its receipts are sealed. Nothing creates a key at import time and
74
+ nothing creates one on a machine that is only being inspected.
75
+
76
+ An identity whose two halves no longer describe each other is not
77
+ silently replaced. The old key signed real receipts; replacing it would
78
+ make those signatures unverifiable without saying so.
79
+ """
80
+ if not self._store.exists():
81
+ return self._store.create()
82
+
83
+ identity = self._store.load_public()
84
+ if self._store.verify_pair():
85
+ return identity
86
+ raise ValidationError(
87
+ "the two halves of this machine's local signing identity do not "
88
+ "describe the same key, so nothing may be signed with it",
89
+ code=LOCAL_IDENTITY_INVALID,
90
+ details={"key_id": identity.key_id},
91
+ )
92
+
93
+ def sign_object[T: BaseModel](self, value: T) -> ObjectEnvelope[T]:
94
+ """Canonicalize, digest, and sign one protocol object."""
95
+ identity = self._store.load_public()
96
+ digest = digest_object(value)
97
+ signature = sign_digest(
98
+ self._store.load_private(), digest, key_id=identity.key_id
99
+ )
100
+ return ObjectEnvelope[T](
101
+ payload=value, payload_digest=digest, signature=signature
102
+ )
103
+
104
+ def verify_envelope[T: BaseModel](
105
+ self, envelope: ObjectEnvelope[T]
106
+ ) -> VerificationResult:
107
+ """Verify one envelope against this machine's own public identity."""
108
+ return verify_signed_object(
109
+ identity=self._store.load_public(), envelope=envelope
110
+ )
111
+
112
+
113
+ def verify_signed_object[T: BaseModel](
114
+ *,
115
+ identity: ExecutorIdentity,
116
+ envelope: ObjectEnvelope[T],
117
+ subject: str = "object",
118
+ ) -> VerificationResult:
119
+ """Verify one envelope's digest and signature against a public identity.
120
+
121
+ ``subject`` names what is being checked — a bundle path, usually — so that
122
+ a reader of a failed bundle verification learns which of forty receipts is
123
+ the problem rather than that "a signature" failed.
124
+ """
125
+ messages: list[VerificationMessage] = []
126
+
127
+ computed = digest_object(envelope.payload)
128
+ digest_matches = computed == envelope.payload_digest
129
+ messages.append(
130
+ VerificationMessage(
131
+ id=f"{subject}.payload_digest",
132
+ status=_PASSED if digest_matches else _FAILED,
133
+ code=SIGNATURE_VERIFICATION_FAILED,
134
+ detail=(
135
+ "the payload still matches the digest it was signed under"
136
+ if digest_matches
137
+ else (
138
+ "the payload no longer matches the digest it was signed "
139
+ f"under: sealed {envelope.payload_digest}, computed {computed}"
140
+ )
141
+ ),
142
+ )
143
+ )
144
+
145
+ signature = envelope.signature
146
+ if signature is None:
147
+ messages.append(
148
+ VerificationMessage(
149
+ id=f"{subject}.signature_present",
150
+ status=_FAILED,
151
+ code=SIGNATURE_VERIFICATION_FAILED,
152
+ detail="this object carries no signature, so nothing vouches for it",
153
+ )
154
+ )
155
+ return VerificationResult(verified=False, messages=messages)
156
+
157
+ named_key = signature.key_id == identity.key_id
158
+ messages.append(
159
+ VerificationMessage(
160
+ id=f"{subject}.signature_key",
161
+ status=_PASSED if named_key else _FAILED,
162
+ code=LOCAL_IDENTITY_INVALID,
163
+ detail=(
164
+ f"signed by key {identity.key_id}"
165
+ if named_key
166
+ else (
167
+ f"signed by key {signature.key_id}, which is not the key "
168
+ f"{identity.key_id} this proof carries"
169
+ )
170
+ ),
171
+ )
172
+ )
173
+
174
+ verified = named_key and digest_matches and _signature_verifies(identity, envelope)
175
+ messages.append(
176
+ VerificationMessage(
177
+ id=f"{subject}.signature",
178
+ status=_PASSED if verified else _FAILED,
179
+ code=SIGNATURE_VERIFICATION_FAILED,
180
+ detail=(
181
+ "the signature verifies against the public key carried with it"
182
+ if verified
183
+ else "the signature does not verify against the public key "
184
+ "carried with it"
185
+ ),
186
+ )
187
+ )
188
+ return VerificationResult(verified=verified, messages=messages)
189
+
190
+
191
+ def _signature_verifies[T: BaseModel](
192
+ identity: ExecutorIdentity, envelope: ObjectEnvelope[T]
193
+ ) -> bool:
194
+ """Check the detached signature against the identity's public key."""
195
+ signature = envelope.signature
196
+ if signature is None:
197
+ return False
198
+ public_key = load_public_key(b64decode(identity.public_key, validate=True))
199
+ return verify_signature(public_key, envelope.payload_digest, signature)