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,992 @@
1
+ """The draft directory, and the only way anything writes to it. Spec PR6 §6.7.
2
+
3
+ A draft is a self-contained claim. Everything needed to check it — the public
4
+ Climb when there is one, the Campaign, the DataPolicy, the publisher's
5
+ validation receipt, the normalized evidence that receipt was issued from, both
6
+ experiment variants, the controlled comparison, and every skill the pair
7
+ declares as both an archive and an expanded tree — is copied in at prepare
8
+ time. Nothing is left as a reference into the catalog, because a draft that
9
+ needed the catalog to be verifiable would stop being verifiable the moment the
10
+ build shipping that catalog changed. Decisions document 0003 A4 states the
11
+ rule; this module is where it is paid for.
12
+
13
+ Two of those are conditional, and what decides them is the Campaign rather than
14
+ a caller's preference. ``public/climb.json`` exists when a public Climb invited
15
+ the submission; a Skill-replacement Campaign is derived locally (spec section
16
+ 7.19) and no Climb wraps it. ``baseline-skill/`` exists when the mutation kind
17
+ is ``skill_replacement``, because then the baseline variant carries the Skill
18
+ being revised and the subject has to be handed its files.
19
+
20
+ Three properties govern the layout.
21
+
22
+ *Nothing partial ever becomes a draft.* The whole tree is assembled in a
23
+ staging directory beside the drafts, verified there, and only then renamed into
24
+ place. A rename within one directory is atomic, so a crash at any point leaves
25
+ either no draft or a complete one, never a half-written graph that a later
26
+ command would have to guess about.
27
+
28
+ *Written once, or replaced atomically, never edited.* ``draft.json``,
29
+ ``comparison.json``, the manifests, the public snapshot, and the skill artifact
30
+ are created with ``O_EXCL`` in canonical bytes: their digests are their
31
+ identities and a second write is a conflict, not an update. ``start.json`` is
32
+ mutable state and is replaced whole.
33
+
34
+ *The start is claimed exactly once.* :meth:`DraftStore.claim_start` verifies
35
+ the graph and creates the start record under one lock hold. A second call
36
+ returns the record the first one wrote — the same run identifier — so a
37
+ retried or duplicated start cannot produce two runs from one draft.
38
+
39
+ Verification is offline and total. :meth:`DraftStore.verify_snapshot` recomputes
40
+ every digest in the graph and checks every edge between the objects, including
41
+ the ones the catalog already checked. It is deliberately redundant: the catalog
42
+ proved something about the bytes it shipped, and this proves something about
43
+ the bytes on this disk now.
44
+ """
45
+
46
+ from __future__ import annotations
47
+
48
+ import os
49
+ import shutil
50
+ import uuid
51
+ from collections.abc import Iterator
52
+ from contextlib import contextmanager
53
+ from dataclasses import dataclass
54
+ from datetime import UTC, datetime
55
+ from enum import StrEnum
56
+ from pathlib import Path
57
+ from typing import Final
58
+
59
+ from filelock import FileLock, Timeout
60
+ from pydantic import BaseModel
61
+ from pydantic import ValidationError as PydanticValidationError
62
+
63
+ from techtree.canonical import canonical_json_bytes, digest_object, sha256_digest_bytes
64
+ from techtree.drafts.source import CampaignSource, StagedSkill
65
+ from techtree.errors import (
66
+ ConflictError,
67
+ NotFoundError,
68
+ ValidationError,
69
+ VerificationError,
70
+ )
71
+ from techtree.fs import (
72
+ atomic_write_bytes,
73
+ ensure_private_directory,
74
+ fsync_directory,
75
+ open_exclusive,
76
+ remove_tree,
77
+ )
78
+ from techtree.ids import validate_id
79
+ from techtree.manifests.builder import skill_content_digest
80
+ from techtree.models.base import ArtifactRef, JsonValue, StateModel, UtcDateTime
81
+ from techtree.models.campaign import SUBJECT_AGENT, CampaignSpec
82
+ from techtree.models.climb import ClimbManifest, ResolvedClimb
83
+ from techtree.models.data_policy import DataPolicy
84
+ from techtree.models.experiment import ExperimentManifest, ManifestComparison
85
+ from techtree.models.skill import SkillArtifact, SubmissionDraft
86
+ from techtree.models.validation import TasksetValidationReceipt, ValidationEvidence
87
+ from techtree.paths import TechtreePaths
88
+ from techtree.skills.archive import verify_archive
89
+
90
+ __all__ = [
91
+ "DRAFT_ALREADY_EXISTS",
92
+ "DRAFT_WRITE_FAILED",
93
+ "LOCK_TIMEOUT_SECONDS",
94
+ "DraftSnapshot",
95
+ "DraftStartRecord",
96
+ "DraftStartStatus",
97
+ "DraftStore",
98
+ "utc_now",
99
+ ]
100
+
101
+ #: Stable error codes this module reports. Spec PR6 §6.10.
102
+ DRAFT_ALREADY_EXISTS: Final = "draft_already_exists"
103
+ DRAFT_WRITE_FAILED: Final = "draft_write_failed"
104
+ _CATALOG_GRAPH_INVALID: Final = "catalog_graph_invalid"
105
+ _CAMPAIGN_DIGEST_MISMATCH: Final = "campaign_digest_mismatch"
106
+ _DATA_POLICY_DIGEST_MISMATCH: Final = "data_policy_digest_mismatch"
107
+ _PUBLISHER_VALIDATION_MISSING: Final = "publisher_validation_missing"
108
+ _PUBLISHER_EVIDENCE_MISSING: Final = "publisher_validation_evidence_missing"
109
+ _SKILL_INVALID: Final = "skill_invalid"
110
+ _MANIFEST_BUILD_FAILED: Final = "manifest_build_failed"
111
+ _MANIFEST_COMPARISON_INVALID: Final = "manifest_comparison_invalid"
112
+ _DRAFT_NOT_FOUND: Final = "draft_not_found"
113
+ _DRAFT_START_CONFLICT: Final = "draft_start_conflict"
114
+ _DRAFT_NOT_STARTED: Final = "draft_not_started"
115
+
116
+ #: A lock hold is a handful of file operations. Waiting longer than this means
117
+ #: the holder died with the lock, and failing beats hanging.
118
+ LOCK_TIMEOUT_SECONDS: Final = 30.0
119
+
120
+ _LOCK_FILE: Final = ".lock"
121
+ _DRAFT_FILE: Final = "draft.json"
122
+ _COMPARISON_FILE: Final = "comparison.json"
123
+ _START_FILE: Final = "start.json"
124
+
125
+ _PUBLIC_DIR: Final = "public"
126
+ _CLIMB_FILE: Final = "climb.json"
127
+ _CAMPAIGN_FILE: Final = "campaign.json"
128
+ _DATA_POLICY_FILE: Final = "data-policy.json"
129
+ _VALIDATION_FILE: Final = "publisher-validation.json"
130
+ _EVIDENCE_FILE: Final = "publisher-validation-evidence.json"
131
+
132
+ _MANIFESTS_DIR: Final = "manifests"
133
+ _BASELINE_FILE: Final = "baseline.json"
134
+ _CANDIDATE_FILE: Final = "candidate.json"
135
+
136
+ _SKILL_DIR: Final = "skill"
137
+ _BASELINE_SKILL_DIR: Final = "baseline-skill"
138
+ _ARTIFACT_FILE: Final = "artifact.json"
139
+ _BUNDLE_FILE: Final = "bundle.tar"
140
+ _FILES_DIR: Final = "files"
141
+
142
+ #: Staging directories live beside the drafts so that the final placement is a
143
+ #: rename within one directory, which is what makes it atomic.
144
+ _STAGING_PREFIX: Final = ".staging-"
145
+
146
+ _FILE_MODE: Final = 0o600
147
+
148
+
149
+ def utc_now() -> datetime:
150
+ """Return the current instant in UTC."""
151
+ return datetime.now(UTC)
152
+
153
+
154
+ class DraftStartStatus(StrEnum):
155
+ """How far the one-time handover from a draft to a run has got."""
156
+
157
+ CLAIMED = "claimed"
158
+ LAUNCHED = "launched"
159
+ LAUNCH_FAILED = "launch_failed"
160
+
161
+
162
+ class DraftStartRecord(StateModel):
163
+ """The single run this draft was spent on, and what became of the launch.
164
+
165
+ Local operational state, not a protocol artifact. It exists so that a
166
+ retried start returns the run that already exists rather than creating a
167
+ second one, and so that a launch that failed after the draft was spent is
168
+ visible instead of silently lost.
169
+ """
170
+
171
+ draft_id: str
172
+ run_id: str
173
+ status: DraftStartStatus
174
+ claimed_at: UtcDateTime
175
+ launched_at: UtcDateTime | None = None
176
+ launch_error_code: str | None = None
177
+
178
+
179
+ @dataclass(frozen=True)
180
+ class DraftSnapshot:
181
+ """Everything one draft holds, loaded and ready to be checked together."""
182
+
183
+ draft: SubmissionDraft
184
+ source: CampaignSource
185
+ validation_evidence: ValidationEvidence
186
+ baseline: ExperimentManifest
187
+ candidate: ExperimentManifest
188
+ comparison: ManifestComparison
189
+ candidate_skill: StagedSkill
190
+ baseline_skill: StagedSkill | None
191
+
192
+
193
+ class DraftStore:
194
+ """Persists and independently verifies complete draft graphs."""
195
+
196
+ def __init__(self, paths: TechtreePaths) -> None:
197
+ self._paths = paths
198
+
199
+ # -- Placement ---------------------------------------------------------
200
+
201
+ def draft_dir(self, draft_id: str) -> Path:
202
+ """Return the directory one draft occupies."""
203
+ return self._paths.draft_dir(draft_id)
204
+
205
+ def skill_files_dir(self, draft_id: str) -> Path:
206
+ """Return the immutable expanded candidate skill directory."""
207
+ return self.draft_dir(draft_id) / _SKILL_DIR / _FILES_DIR
208
+
209
+ def skill_archive_path(self, draft_id: str) -> Path:
210
+ """Return the deterministic candidate skill archive."""
211
+ return self.draft_dir(draft_id) / _SKILL_DIR / _BUNDLE_FILE
212
+
213
+ def baseline_skill_files_dir(self, draft_id: str) -> Path:
214
+ """Return the expanded Skill a replacement's baseline carries."""
215
+ return self.draft_dir(draft_id) / _BASELINE_SKILL_DIR / _FILES_DIR
216
+
217
+ def baseline_skill_archive_path(self, draft_id: str) -> Path:
218
+ """Return the archive of the Skill a replacement's baseline carries."""
219
+ return self.draft_dir(draft_id) / _BASELINE_SKILL_DIR / _BUNDLE_FILE
220
+
221
+ # -- Creation ----------------------------------------------------------
222
+
223
+ def create(
224
+ self,
225
+ *,
226
+ draft: SubmissionDraft,
227
+ baseline: ExperimentManifest,
228
+ candidate: ExperimentManifest,
229
+ comparison: ManifestComparison,
230
+ source: CampaignSource,
231
+ validation_evidence: ValidationEvidence,
232
+ staged_candidate_skill: StagedSkill,
233
+ staged_baseline_skill: StagedSkill | None = None,
234
+ ) -> None:
235
+ """Write a new draft graph, atomically, or write nothing at all."""
236
+ final = self.draft_dir(draft.id)
237
+ if final.exists():
238
+ raise ConflictError(
239
+ f"a draft called {draft.id} already exists",
240
+ code=DRAFT_ALREADY_EXISTS,
241
+ details={"draft_id": draft.id},
242
+ )
243
+
244
+ ensure_private_directory(self._paths.drafts_dir)
245
+ staging = self._paths.drafts_dir / f"{_STAGING_PREFIX}{uuid.uuid4().hex}"
246
+
247
+ try:
248
+ self._assemble(
249
+ staging,
250
+ draft=draft,
251
+ baseline=baseline,
252
+ candidate=candidate,
253
+ comparison=comparison,
254
+ source=source,
255
+ validation_evidence=validation_evidence,
256
+ staged_candidate_skill=staged_candidate_skill,
257
+ staged_baseline_skill=staged_baseline_skill,
258
+ )
259
+ self.verify_snapshot(
260
+ DraftSnapshot(
261
+ draft=draft,
262
+ source=source,
263
+ validation_evidence=validation_evidence,
264
+ baseline=baseline,
265
+ candidate=candidate,
266
+ comparison=comparison,
267
+ candidate_skill=_staged_at(
268
+ staging / _SKILL_DIR, draft.skill_artifact
269
+ ),
270
+ baseline_skill=(
271
+ None
272
+ if staged_baseline_skill is None
273
+ else _staged_at(
274
+ staging / _BASELINE_SKILL_DIR,
275
+ staged_baseline_skill.artifact,
276
+ )
277
+ ),
278
+ )
279
+ )
280
+ self._place(staging, final, draft.id)
281
+ except BaseException:
282
+ remove_tree(staging)
283
+ raise
284
+
285
+ def _assemble(
286
+ self,
287
+ staging: Path,
288
+ *,
289
+ draft: SubmissionDraft,
290
+ baseline: ExperimentManifest,
291
+ candidate: ExperimentManifest,
292
+ comparison: ManifestComparison,
293
+ source: CampaignSource,
294
+ validation_evidence: ValidationEvidence,
295
+ staged_candidate_skill: StagedSkill,
296
+ staged_baseline_skill: StagedSkill | None,
297
+ ) -> None:
298
+ """Write the complete tree into a staging directory."""
299
+ reference = source.publisher_validation.normalized_evidence
300
+ if reference is None:
301
+ raise VerificationError(
302
+ "the publisher's validation receipt names no normalized "
303
+ "evidence, so this draft could not be checked offline",
304
+ code=_PUBLISHER_EVIDENCE_MISSING,
305
+ details={"draft_id": draft.id},
306
+ )
307
+
308
+ try:
309
+ ensure_private_directory(staging)
310
+ self._write_immutable(staging / _DRAFT_FILE, draft)
311
+ self._write_immutable(staging / _COMPARISON_FILE, comparison)
312
+
313
+ public = staging / _PUBLIC_DIR
314
+ ensure_private_directory(public)
315
+ if source.climb is not None:
316
+ self._write_immutable(public / _CLIMB_FILE, source.climb)
317
+ self._write_immutable(public / _CAMPAIGN_FILE, source.campaign)
318
+ self._write_immutable(public / _DATA_POLICY_FILE, source.data_policy)
319
+ self._write_immutable(
320
+ public / _VALIDATION_FILE, source.publisher_validation
321
+ )
322
+ self._write_immutable(public / _EVIDENCE_FILE, validation_evidence)
323
+
324
+ manifests = staging / _MANIFESTS_DIR
325
+ ensure_private_directory(manifests)
326
+ self._write_immutable(manifests / _BASELINE_FILE, baseline)
327
+ self._write_immutable(manifests / _CANDIDATE_FILE, candidate)
328
+
329
+ self._copy_skill(
330
+ staging / _SKILL_DIR,
331
+ StagedSkill(
332
+ artifact=draft.skill_artifact,
333
+ archive=staged_candidate_skill.archive,
334
+ files=staged_candidate_skill.files,
335
+ ),
336
+ )
337
+ if staged_baseline_skill is not None:
338
+ self._copy_skill(staging / _BASELINE_SKILL_DIR, staged_baseline_skill)
339
+ except OSError as error:
340
+ raise ValidationError(
341
+ f"the draft could not be written: {error.strerror or error}",
342
+ code=DRAFT_WRITE_FAILED,
343
+ details={"draft_id": draft.id},
344
+ ) from error
345
+
346
+ def _copy_skill(self, destination: Path, staged: StagedSkill) -> None:
347
+ """Write one skill's artifact, archive, and exactly the files it lists.
348
+
349
+ The artifact document is written from the object the caller committed
350
+ to rather than copied off disk, so the only artifact a draft can hold
351
+ is the one its own digest covers.
352
+ """
353
+ ensure_private_directory(destination)
354
+ self._write_immutable(destination / _ARTIFACT_FILE, staged.artifact)
355
+ shutil.copyfile(staged.archive, destination / _BUNDLE_FILE)
356
+ os.chmod(destination / _BUNDLE_FILE, _FILE_MODE)
357
+
358
+ files = destination / _FILES_DIR
359
+ ensure_private_directory(files)
360
+ for entry in staged.artifact.files:
361
+ target = files / entry.path
362
+ ensure_private_directory(target.parent)
363
+ shutil.copyfile(staged.files / entry.path, target)
364
+ os.chmod(target, _FILE_MODE)
365
+
366
+ def _place(self, staging: Path, final: Path, draft_id: str) -> None:
367
+ """Move a fully written staging tree into its final name."""
368
+ try:
369
+ os.rename(staging, final)
370
+ except OSError as error:
371
+ raise ConflictError(
372
+ f"a draft called {draft_id} already exists",
373
+ code=DRAFT_ALREADY_EXISTS,
374
+ details={"draft_id": draft_id},
375
+ ) from error
376
+ fsync_directory(self._paths.drafts_dir)
377
+
378
+ # -- Loading -----------------------------------------------------------
379
+
380
+ def get(self, draft_id: str) -> SubmissionDraft:
381
+ """Load and validate ``draft.json``."""
382
+ return self._load(draft_id, _DRAFT_FILE, SubmissionDraft)
383
+
384
+ def get_manifests(
385
+ self, draft_id: str
386
+ ) -> tuple[ExperimentManifest, ExperimentManifest]:
387
+ """Load immutable manifests."""
388
+ return (
389
+ self._load(
390
+ draft_id, f"{_MANIFESTS_DIR}/{_BASELINE_FILE}", ExperimentManifest
391
+ ),
392
+ self._load(
393
+ draft_id, f"{_MANIFESTS_DIR}/{_CANDIDATE_FILE}", ExperimentManifest
394
+ ),
395
+ )
396
+
397
+ def get_comparison(self, draft_id: str) -> ManifestComparison:
398
+ """Load prepared comparison."""
399
+ return self._load(draft_id, _COMPARISON_FILE, ManifestComparison)
400
+
401
+ def get_source(self, draft_id: str) -> CampaignSource:
402
+ """Load the snapshotted Campaign graph.
403
+
404
+ The digests are recomputed from the snapshotted bytes rather than read
405
+ from anywhere, so assembling the graph is itself the check that the
406
+ objects still describe each other. When a public Climb was snapshotted
407
+ the graph is assembled as a :class:`ResolvedClimb` first, so that its
408
+ own validator runs against the bytes on this disk rather than against
409
+ the ones the catalog once shipped.
410
+ """
411
+ campaign = self._load(draft_id, f"{_PUBLIC_DIR}/{_CAMPAIGN_FILE}", CampaignSpec)
412
+ data_policy = self._load(
413
+ draft_id, f"{_PUBLIC_DIR}/{_DATA_POLICY_FILE}", DataPolicy
414
+ )
415
+ receipt = self._load(
416
+ draft_id, f"{_PUBLIC_DIR}/{_VALIDATION_FILE}", TasksetValidationReceipt
417
+ )
418
+
419
+ if not (self.draft_dir(draft_id) / _PUBLIC_DIR / _CLIMB_FILE).exists():
420
+ return CampaignSource.local(
421
+ campaign=campaign,
422
+ data_policy=data_policy,
423
+ publisher_validation=receipt,
424
+ )
425
+
426
+ climb = self._load(draft_id, f"{_PUBLIC_DIR}/{_CLIMB_FILE}", ClimbManifest)
427
+ try:
428
+ resolved = ResolvedClimb(
429
+ climb=climb,
430
+ climb_digest=digest_object(climb),
431
+ campaign=campaign,
432
+ campaign_digest=digest_object(campaign),
433
+ data_policy=data_policy,
434
+ data_policy_digest=digest_object(data_policy),
435
+ publisher_validation=receipt,
436
+ publisher_validation_digest=digest_object(receipt),
437
+ )
438
+ except PydanticValidationError as error:
439
+ raise VerificationError(
440
+ "the public objects snapshotted in this draft no longer "
441
+ f"describe each other: {_first_problem(error)}",
442
+ code=_CATALOG_GRAPH_INVALID,
443
+ details={"draft_id": draft_id},
444
+ ) from error
445
+ return CampaignSource.from_climb(resolved)
446
+
447
+ def get_validation_evidence(self, draft_id: str) -> ValidationEvidence:
448
+ """Load evidence referenced by the publisher receipt."""
449
+ return self._load(
450
+ draft_id, f"{_PUBLIC_DIR}/{_EVIDENCE_FILE}", ValidationEvidence
451
+ )
452
+
453
+ def get_skill(self, draft_id: str) -> SkillArtifact:
454
+ """Load the candidate ``SkillArtifact`` and verify its files and archive."""
455
+ staged = self._staged_skill(draft_id, _SKILL_DIR)
456
+ self._verify_skill(staged)
457
+ return staged.artifact
458
+
459
+ def load_snapshot(self, draft_id: str) -> DraftSnapshot:
460
+ """Load the complete graph and verify it before returning it."""
461
+ baseline, candidate = self.get_manifests(draft_id)
462
+ has_baseline_skill = (
463
+ self.draft_dir(draft_id) / _BASELINE_SKILL_DIR / _ARTIFACT_FILE
464
+ ).exists()
465
+ snapshot = DraftSnapshot(
466
+ draft=self.get(draft_id),
467
+ source=self.get_source(draft_id),
468
+ validation_evidence=self.get_validation_evidence(draft_id),
469
+ baseline=baseline,
470
+ candidate=candidate,
471
+ comparison=self.get_comparison(draft_id),
472
+ candidate_skill=self._staged_skill(draft_id, _SKILL_DIR),
473
+ baseline_skill=(
474
+ self._staged_skill(draft_id, _BASELINE_SKILL_DIR)
475
+ if has_baseline_skill
476
+ else None
477
+ ),
478
+ )
479
+ self.verify_snapshot(snapshot)
480
+ return snapshot
481
+
482
+ def _staged_skill(self, draft_id: str, directory: str) -> StagedSkill:
483
+ """Load one skill's artifact and name the bytes stored beside it."""
484
+ return _staged_at(
485
+ self.draft_dir(draft_id) / directory,
486
+ self._load(draft_id, f"{directory}/{_ARTIFACT_FILE}", SkillArtifact),
487
+ )
488
+
489
+ # -- Verification ------------------------------------------------------
490
+
491
+ def verify_snapshot(self, snapshot: DraftSnapshot) -> None:
492
+ """Verify every digest and cross-object invariant, offline."""
493
+ draft = snapshot.draft
494
+ source = snapshot.source
495
+ campaign = source.campaign
496
+ receipt = source.publisher_validation
497
+
498
+ self._verify_public_context(snapshot)
499
+
500
+ campaign_digest = digest_object(campaign)
501
+ _require(
502
+ campaign_digest == draft.campaign_spec_digest,
503
+ "this draft was prepared against a different Campaign than the one "
504
+ "it snapshotted",
505
+ _CAMPAIGN_DIGEST_MISMATCH,
506
+ expected=draft.campaign_spec_digest,
507
+ computed=campaign_digest,
508
+ )
509
+
510
+ policy_digest = digest_object(source.data_policy)
511
+ _require(
512
+ policy_digest == campaign.data_policy_digest,
513
+ "the snapshotted DataPolicy is not the one the Campaign runs under",
514
+ _DATA_POLICY_DIGEST_MISMATCH,
515
+ expected=campaign.data_policy_digest,
516
+ computed=policy_digest,
517
+ )
518
+ _require(
519
+ policy_digest == draft.data_policy_digest,
520
+ "this draft states rights from a different DataPolicy than the one "
521
+ "it snapshotted",
522
+ _DATA_POLICY_DIGEST_MISMATCH,
523
+ expected=draft.data_policy_digest,
524
+ computed=policy_digest,
525
+ )
526
+ _require(
527
+ draft.policy_acceptance.data_policy_digest == policy_digest,
528
+ "this draft asks for acceptance of a DataPolicy it did not snapshot",
529
+ _DATA_POLICY_DIGEST_MISMATCH,
530
+ )
531
+
532
+ receipt_digest = digest_object(receipt)
533
+ _require(
534
+ receipt_digest == campaign.taskset.validation_receipt_digest,
535
+ "the snapshotted publisher validation is not the one the Campaign "
536
+ "commits to",
537
+ _PUBLISHER_VALIDATION_MISSING,
538
+ expected=campaign.taskset.validation_receipt_digest,
539
+ computed=receipt_digest,
540
+ )
541
+
542
+ reference = receipt.normalized_evidence
543
+ if reference is None:
544
+ raise VerificationError(
545
+ "the publisher's validation receipt names no normalized "
546
+ "evidence, so this draft cannot be checked offline",
547
+ code=_PUBLISHER_EVIDENCE_MISSING,
548
+ details={"draft_id": draft.id},
549
+ )
550
+ evidence_digest = digest_object(snapshot.validation_evidence)
551
+ _require(
552
+ evidence_digest == reference.digest,
553
+ "the snapshotted validation evidence is not what the publisher's "
554
+ "receipt was issued from",
555
+ _PUBLISHER_EVIDENCE_MISSING,
556
+ expected=reference.digest,
557
+ computed=evidence_digest,
558
+ )
559
+
560
+ _require(
561
+ draft.outcome_contract_digest == campaign.context.outcome_contract_digest,
562
+ "this draft names a different OutcomeContract than the Campaign",
563
+ _CATALOG_GRAPH_INVALID,
564
+ )
565
+ _require(
566
+ draft.program_ref == campaign.context.program_ref,
567
+ "this draft names a different improvement program than the Campaign",
568
+ _CATALOG_GRAPH_INVALID,
569
+ )
570
+
571
+ self._verify_manifests(snapshot)
572
+ self._verify_skill(snapshot.candidate_skill)
573
+ candidate_artifact = snapshot.candidate_skill.artifact
574
+ _require(
575
+ list(draft.included_files)
576
+ == [file.path for file in candidate_artifact.files],
577
+ "this draft lists different files than the candidate skill it snapshotted",
578
+ _SKILL_INVALID,
579
+ )
580
+ _require(
581
+ digest_object(draft.skill_artifact) == digest_object(candidate_artifact),
582
+ "this draft carries a different skill artifact than the one "
583
+ "snapshotted beside it",
584
+ _SKILL_INVALID,
585
+ )
586
+ self._verify_baseline_skill(snapshot)
587
+
588
+ def _verify_public_context(self, snapshot: DraftSnapshot) -> None:
589
+ """Require the draft and the snapshot to agree about a public Climb.
590
+
591
+ A draft prepared under a Climb names it and snapshots it. A draft
592
+ prepared against a locally derived Campaign — spec section 7.19's
593
+ Skill replacement — names no public context and snapshots no Climb.
594
+ Either is complete; one of each is a draft that cannot be read.
595
+ """
596
+ draft = snapshot.draft
597
+ climb = snapshot.source.climb
598
+
599
+ if climb is None:
600
+ _require(
601
+ draft.public_context is None,
602
+ "this draft names a public Climb and did not snapshot one",
603
+ _CATALOG_GRAPH_INVALID,
604
+ draft_id=draft.id,
605
+ )
606
+ return
607
+
608
+ public_context = draft.public_context
609
+ if public_context is None:
610
+ raise VerificationError(
611
+ "this draft names no public Climb, and it was prepared under one",
612
+ code=_CATALOG_GRAPH_INVALID,
613
+ details={"draft_id": draft.id},
614
+ )
615
+
616
+ climb_digest = digest_object(climb)
617
+ _require(
618
+ climb_digest == public_context.climb_digest,
619
+ "the snapshotted Climb is not the Climb this draft was prepared for",
620
+ _CATALOG_GRAPH_INVALID,
621
+ expected=public_context.climb_digest,
622
+ computed=climb_digest,
623
+ )
624
+ _require(
625
+ climb_digest == snapshot.source.climb_digest,
626
+ "the snapshotted Climb does not match the digest it was resolved under",
627
+ _CATALOG_GRAPH_INVALID,
628
+ )
629
+ _require(
630
+ digest_object(snapshot.source.campaign) == climb.campaign_spec_digest,
631
+ "the snapshotted Campaign is not the one the Climb wraps",
632
+ _CAMPAIGN_DIGEST_MISMATCH,
633
+ expected=climb.campaign_spec_digest,
634
+ computed=digest_object(snapshot.source.campaign),
635
+ )
636
+
637
+ def _verify_baseline_skill(self, snapshot: DraftSnapshot) -> None:
638
+ """Require the baseline's Skill to be present exactly when it is declared.
639
+
640
+ Which it is follows from the Campaign, not from what happens to be on
641
+ disk: a ``skill_replacement`` baseline carries the Skill being revised
642
+ and the subject has to be handed its files, and an insertion baseline
643
+ carries none, so a stored tree for it would be a file nothing declares.
644
+ """
645
+ declared = _declared_baseline_skill(snapshot.baseline)
646
+ staged = snapshot.baseline_skill
647
+
648
+ if declared is None:
649
+ _require(
650
+ staged is None,
651
+ "this draft snapshotted a baseline skill its baseline variant "
652
+ "does not declare",
653
+ _SKILL_INVALID,
654
+ draft_id=snapshot.draft.id,
655
+ )
656
+ return
657
+
658
+ if staged is None:
659
+ raise VerificationError(
660
+ "this draft's baseline variant declares the skill it replaces "
661
+ "and the draft did not snapshot it",
662
+ code=_SKILL_INVALID,
663
+ details={
664
+ "draft_id": snapshot.draft.id,
665
+ "declared": declared.digest,
666
+ },
667
+ )
668
+
669
+ self._verify_skill(staged)
670
+ _require(
671
+ staged.artifact.root_digest == declared.digest,
672
+ "the snapshotted baseline skill is not the skill the baseline "
673
+ "variant declares",
674
+ _SKILL_INVALID,
675
+ expected=declared.digest,
676
+ computed=staged.artifact.root_digest,
677
+ )
678
+
679
+ def _verify_manifests(self, snapshot: DraftSnapshot) -> None:
680
+ draft = snapshot.draft
681
+ baseline_digest = digest_object(snapshot.baseline)
682
+ candidate_digest = digest_object(snapshot.candidate)
683
+
684
+ _require(
685
+ baseline_digest == draft.baseline_manifest_digest,
686
+ "the snapshotted baseline manifest is not the one this draft names",
687
+ _MANIFEST_BUILD_FAILED,
688
+ expected=draft.baseline_manifest_digest,
689
+ computed=baseline_digest,
690
+ )
691
+ _require(
692
+ candidate_digest == draft.candidate_manifest_digest,
693
+ "the snapshotted candidate manifest is not the one this draft names",
694
+ _MANIFEST_BUILD_FAILED,
695
+ expected=draft.candidate_manifest_digest,
696
+ computed=candidate_digest,
697
+ )
698
+
699
+ comparison = snapshot.comparison
700
+ _require(
701
+ comparison.baseline_configuration_digest
702
+ == snapshot.baseline.configuration_digest
703
+ and comparison.candidate_configuration_digest
704
+ == snapshot.candidate.configuration_digest,
705
+ "the stored comparison was made between different configurations "
706
+ "than the manifests in this draft",
707
+ _MANIFEST_COMPARISON_INVALID,
708
+ )
709
+ _require(
710
+ comparison.controlled,
711
+ "the stored comparison says this candidate differs from its "
712
+ "baseline somewhere the Campaign does not permit",
713
+ _MANIFEST_COMPARISON_INVALID,
714
+ violations=list(comparison.violations),
715
+ )
716
+
717
+ def _verify_skill(self, staged: StagedSkill) -> None:
718
+ """Check one skill's artifact against the bytes stored beside it."""
719
+ artifact = staged.artifact
720
+ recomputed = skill_content_digest(artifact.files)
721
+ _require(
722
+ recomputed == artifact.root_digest,
723
+ "the candidate skill's root digest does not describe the files it lists",
724
+ _SKILL_INVALID,
725
+ expected=artifact.root_digest,
726
+ computed=recomputed,
727
+ )
728
+
729
+ for entry in artifact.files:
730
+ path = staged.files / entry.path
731
+ try:
732
+ data = path.read_bytes()
733
+ except OSError as error:
734
+ raise VerificationError(
735
+ f"the candidate skill is missing {entry.path}",
736
+ code=_SKILL_INVALID,
737
+ details={"path": entry.path},
738
+ ) from error
739
+ _require(
740
+ len(data) == entry.size and sha256_digest_bytes(data) == entry.digest,
741
+ f"the snapshotted candidate skill file {entry.path} is not what "
742
+ "the artifact says it is",
743
+ _SKILL_INVALID,
744
+ path=entry.path,
745
+ )
746
+
747
+ _require(
748
+ verify_archive(staged.archive, artifact),
749
+ "the candidate skill archive does not match the artifact beside it",
750
+ _SKILL_INVALID,
751
+ archive_digest=artifact.archive_digest,
752
+ )
753
+
754
+ # -- The start claim ---------------------------------------------------
755
+
756
+ def claim_start(
757
+ self,
758
+ *,
759
+ draft_id: str,
760
+ run_id: str,
761
+ ) -> DraftStartRecord:
762
+ """Spend this draft on exactly one run, or return the run it was spent on."""
763
+ validate_id(run_id, "run")
764
+ self._require_draft(draft_id)
765
+
766
+ with self._lock(draft_id):
767
+ existing = self._read_start_record(draft_id)
768
+ if existing is not None:
769
+ # A retried start is not a second start. Returning the claim
770
+ # the first one wrote is what makes the operation idempotent
771
+ # rather than merely safe.
772
+ return existing
773
+
774
+ self.load_snapshot(draft_id)
775
+
776
+ record = DraftStartRecord(
777
+ draft_id=draft_id,
778
+ run_id=run_id,
779
+ status=DraftStartStatus.CLAIMED,
780
+ claimed_at=utc_now(),
781
+ launched_at=None,
782
+ launch_error_code=None,
783
+ )
784
+ self._write_immutable(self.draft_dir(draft_id) / _START_FILE, record)
785
+ return record
786
+
787
+ def mark_launched(
788
+ self,
789
+ *,
790
+ draft_id: str,
791
+ run_id: str,
792
+ launched_at: datetime,
793
+ ) -> DraftStartRecord:
794
+ """Record that the claimed run is now running."""
795
+ return self._update_start(
796
+ draft_id,
797
+ run_id,
798
+ status=DraftStartStatus.LAUNCHED,
799
+ launched_at=launched_at,
800
+ launch_error_code=None,
801
+ )
802
+
803
+ def mark_launch_failed(
804
+ self,
805
+ *,
806
+ draft_id: str,
807
+ run_id: str,
808
+ error_code: str,
809
+ ) -> DraftStartRecord:
810
+ """Record that the claimed run could not be launched."""
811
+ return self._update_start(
812
+ draft_id,
813
+ run_id,
814
+ status=DraftStartStatus.LAUNCH_FAILED,
815
+ launched_at=None,
816
+ launch_error_code=error_code,
817
+ )
818
+
819
+ def start_record(self, draft_id: str) -> DraftStartRecord | None:
820
+ """Return the existing start claim, or ``None`` if there is not one."""
821
+ self._require_draft(draft_id)
822
+ return self._read_start_record(draft_id)
823
+
824
+ def _update_start(
825
+ self,
826
+ draft_id: str,
827
+ run_id: str,
828
+ *,
829
+ status: DraftStartStatus,
830
+ launched_at: datetime | None,
831
+ launch_error_code: str | None,
832
+ ) -> DraftStartRecord:
833
+ self._require_draft(draft_id)
834
+ with self._lock(draft_id):
835
+ record = self._read_start_record(draft_id)
836
+ if record is None:
837
+ raise NotFoundError(
838
+ f"draft {draft_id} has not been started, so there is no "
839
+ "claim to update",
840
+ code=_DRAFT_NOT_STARTED,
841
+ details={"draft_id": draft_id},
842
+ )
843
+ if record.run_id != run_id:
844
+ raise ConflictError(
845
+ f"draft {draft_id} was already claimed by run {record.run_id}",
846
+ code=_DRAFT_START_CONFLICT,
847
+ details={
848
+ "draft_id": draft_id,
849
+ "claimed_run_id": record.run_id,
850
+ "requested_run_id": run_id,
851
+ },
852
+ )
853
+
854
+ updated = DraftStartRecord(
855
+ draft_id=record.draft_id,
856
+ run_id=record.run_id,
857
+ status=status,
858
+ claimed_at=record.claimed_at,
859
+ launched_at=launched_at or record.launched_at,
860
+ launch_error_code=launch_error_code,
861
+ )
862
+ self._write_state(self.draft_dir(draft_id) / _START_FILE, updated)
863
+ return updated
864
+
865
+ def _read_start_record(self, draft_id: str) -> DraftStartRecord | None:
866
+ path = self.draft_dir(draft_id) / _START_FILE
867
+ if not path.exists():
868
+ return None
869
+ return self._parse(path, DraftStartRecord, draft_id)
870
+
871
+ # -- Internals ---------------------------------------------------------
872
+
873
+ def _load[ModelT: BaseModel](
874
+ self, draft_id: str, relative: str, model: type[ModelT]
875
+ ) -> ModelT:
876
+ self._require_draft(draft_id)
877
+ return self._parse(self.draft_dir(draft_id) / relative, model, draft_id)
878
+
879
+ def _parse[ModelT: BaseModel](
880
+ self, path: Path, model: type[ModelT], draft_id: str
881
+ ) -> ModelT:
882
+ try:
883
+ raw = path.read_bytes()
884
+ except FileNotFoundError as error:
885
+ raise NotFoundError(
886
+ f"draft {draft_id} is missing {path.name}",
887
+ code=_DRAFT_NOT_FOUND,
888
+ details={"draft_id": draft_id, "file": path.name},
889
+ ) from error
890
+
891
+ try:
892
+ return model.model_validate_json(raw)
893
+ except PydanticValidationError as error:
894
+ raise ValidationError(
895
+ f"draft {draft_id} holds an invalid {path.name}: "
896
+ f"{_first_problem(error)}",
897
+ code=_CATALOG_GRAPH_INVALID,
898
+ details={"draft_id": draft_id, "file": path.name},
899
+ ) from error
900
+
901
+ def _require_draft(self, draft_id: str) -> None:
902
+ if not (self.draft_dir(draft_id) / _DRAFT_FILE).exists():
903
+ raise NotFoundError(
904
+ f"no such draft: {draft_id}",
905
+ code=_DRAFT_NOT_FOUND,
906
+ details={"draft_id": draft_id},
907
+ )
908
+
909
+ def _write_immutable(self, path: Path, value: object) -> None:
910
+ """Create a write-once file holding one object's canonical bytes."""
911
+ data = canonical_json_bytes(value)
912
+ with open_exclusive(path, _FILE_MODE) as handle:
913
+ handle.write(data)
914
+ handle.flush()
915
+ os.fsync(handle.fileno())
916
+ fsync_directory(path.parent)
917
+
918
+ def _write_state(self, path: Path, value: object) -> None:
919
+ """Replace one mutable projection atomically, in canonical bytes."""
920
+ atomic_write_bytes(path, canonical_json_bytes(value), mode=_FILE_MODE)
921
+
922
+ @contextmanager
923
+ def _lock(self, draft_id: str) -> Iterator[None]:
924
+ """Hold the per-draft lock, or report the wait as a conflict."""
925
+ lock = FileLock(
926
+ self.draft_dir(draft_id) / _LOCK_FILE,
927
+ timeout=LOCK_TIMEOUT_SECONDS,
928
+ mode=_FILE_MODE,
929
+ )
930
+ try:
931
+ lock.acquire()
932
+ except Timeout as error:
933
+ raise ConflictError(
934
+ f"another process is holding the lock on draft {draft_id}",
935
+ details={"draft_id": draft_id, "waited_seconds": LOCK_TIMEOUT_SECONDS},
936
+ ) from error
937
+ try:
938
+ yield
939
+ finally:
940
+ lock.release()
941
+
942
+
943
+ def _staged_at(directory: Path, artifact: SkillArtifact) -> StagedSkill:
944
+ """Return one skill placed in the layout every skill directory uses."""
945
+ return StagedSkill(
946
+ artifact=artifact,
947
+ archive=directory / _BUNDLE_FILE,
948
+ files=directory / _FILES_DIR,
949
+ )
950
+
951
+
952
+ def _declared_baseline_skill(baseline: ExperimentManifest) -> ArtifactRef | None:
953
+ """Return the one skill a baseline variant declares, when it declares one.
954
+
955
+ Only the mutation kinds v0.1 defines can occur here: an insertion baseline
956
+ declares none and a replacement baseline declares exactly the skill being
957
+ revised. A baseline naming more than one is refused by the manifest
958
+ builder, the comparison, and the Campaign model before it could reach this.
959
+ """
960
+ subject = baseline.configuration.agents.get(SUBJECT_AGENT)
961
+ if subject is None or not subject.harness.skills:
962
+ return None
963
+ return subject.harness.skills[0]
964
+
965
+
966
+ def _require(
967
+ condition: bool,
968
+ message: str,
969
+ code: str,
970
+ **details: object,
971
+ ) -> None:
972
+ """Raise a typed verification failure unless the condition holds."""
973
+ if condition:
974
+ return
975
+ raise VerificationError(
976
+ message,
977
+ code=code,
978
+ details={key: _as_json(value) for key, value in details.items()},
979
+ )
980
+
981
+
982
+ def _as_json(value: object) -> JsonValue:
983
+ if isinstance(value, list):
984
+ items: list[JsonValue] = [str(item) for item in value]
985
+ return items
986
+ return str(value)
987
+
988
+
989
+ def _first_problem(error: PydanticValidationError) -> str:
990
+ first = error.errors()[0]
991
+ location = ".".join(str(part) for part in first["loc"])
992
+ return f"{location}: {first['msg']}" if location else str(first["msg"])