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,156 @@
1
+ """What may appear in a result, enforced rather than intended.
2
+
3
+ Spec section 7.17.
4
+
5
+ The evidence a run produces contains material that must not travel into a
6
+ rendering. Some of it is obvious — an absolute path naming somebody's home
7
+ directory. Some of it is the whole point of the evaluation: the taskset's
8
+ hidden expected answers and the grader that knows them. A presentation that
9
+ leaked either would not be a cosmetic bug; it would be a contaminated
10
+ benchmark.
11
+
12
+ So the rule here is that the payload's *shape* keeps hidden material out and
13
+ these functions keep it out of the free text the shape still allows:
14
+
15
+ ```text
16
+ may appear task ordinal or public name, public prompt summary when the
17
+ policy allows it, score, delta
18
+ never appears hidden expected answer, grader source, absolute private path,
19
+ a traceback carrying environment values
20
+ ```
21
+
22
+ Nothing here inspects a string for credential-shaped text. Decision 0036
23
+ removed that from the whole project: a value's shape is not evidence of what
24
+ it is, in either direction.
25
+
26
+ One decision is worth stating because it was a decision and not an oversight.
27
+ The engine's normalized output carries each rollout's *final reply* — what the
28
+ subject actually answered. It is the participant's own material, it is often
29
+ the most useful thing in a failed task, and it is not hidden verifier material.
30
+ It still does not appear in a presentation payload in this release: a reply is
31
+ free text from a model, a rendering is a place a reader trusts, and the release
32
+ that shows replies should show them where a person asked for them (spec section
33
+ 7.18's improvement context) rather than in the summary a phone message quotes.
34
+ The payload therefore has no field for one.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import re
40
+ from typing import Final
41
+
42
+ from pydantic import BaseModel
43
+
44
+ from techtree.errors import ValidationError
45
+ from techtree.presentation.models import UpliftPresentationPayload
46
+ from techtree.verifiers.models import NormalizedExecutionError
47
+
48
+ __all__ = [
49
+ "PRESENTATION_REDACTION_FAILED",
50
+ "ensure_no_control_or_local_path",
51
+ "ensure_no_hidden_task_material",
52
+ "sanitize_error_summary",
53
+ "sanitize_label",
54
+ ]
55
+
56
+ #: Stable error code. Spec section 15.
57
+ PRESENTATION_REDACTION_FAILED: Final = "presentation_redaction_failed"
58
+
59
+ #: Escape sequences of every shape a terminal would act on. A payload is
60
+ #: channel-neutral, so an escape sequence in it is either an accident or an
61
+ #: attempt to control someone else's terminal.
62
+ _ANSI = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b[@-_]|\x9b[0-9;?]*[@-~]")
63
+
64
+ #: Everything a terminal treats as a command rather than as text, newline and
65
+ #: tab included: a rendering places its own line breaks.
66
+ _CONTROL = re.compile(r"[\x00-\x1f\x7f]")
67
+
68
+ _WHITESPACE_RUN = re.compile(r"\s+")
69
+
70
+ #: A rooted filesystem path with at least two segments, POSIX or Windows.
71
+ #: Digests, identifiers and prose do not look like this; ``~/.techtree/...``
72
+ #: does, and so does a temporary directory naming a person's account.
73
+ _ABSOLUTE_PATH = re.compile(r"(?:[A-Za-z]:\\|/)[\w.-]+[/\\][\w.\-/\\]*")
74
+
75
+ #: What a truncated label ends with. One character, and not an escape.
76
+ _ELLIPSIS: Final = "…"
77
+
78
+
79
+ def sanitize_label(value: str, maximum: int = 120) -> str:
80
+ """Return one line of plain, bounded text.
81
+
82
+ Applied to everything a person or another system supplied — a Climb title,
83
+ a Skill name — because those are the fields that reach a rendering without
84
+ having been through a protocol validator that cared what was in them.
85
+ Escape sequences and control characters go, whitespace is flattened, and
86
+ the result is truncated. The remaining words are left exactly as written.
87
+ """
88
+ flattened = _WHITESPACE_RUN.sub(" ", _CONTROL.sub(" ", _ANSI.sub("", value)))
89
+ label = flattened.strip()
90
+ if len(label) <= maximum:
91
+ return label
92
+ return label[: maximum - 1].rstrip() + _ELLIPSIS
93
+
94
+
95
+ def sanitize_error_summary(error: NormalizedExecutionError, maximum: int = 200) -> str:
96
+ """Summarize one recorded failure in one line.
97
+
98
+ The traceback is dropped rather than shortened. Its diagnostic worth is to
99
+ somebody reading the run directory, not to somebody reading a result.
100
+ """
101
+ summary = f"{error.type}: {error.message}" if error.message else error.type
102
+ return sanitize_label(summary, maximum)
103
+
104
+
105
+ def ensure_no_control_or_local_path(value: str, *, field: str) -> None:
106
+ """Raise when one string would carry a terminal command or a local path.
107
+
108
+ Shared by the presentation payload and by spec section 7.18's improvement
109
+ context, because the two answer the same question about their free text:
110
+ a payload is read by a terminal and a context is read by a model, and
111
+ neither may be handed an escape sequence or somebody's home directory.
112
+ """
113
+ if _ANSI.search(value) or _CONTROL.search(value):
114
+ raise ValidationError(
115
+ "a result rendering may not carry terminal control sequences",
116
+ code=PRESENTATION_REDACTION_FAILED,
117
+ details={"field": field},
118
+ )
119
+ if _ABSOLUTE_PATH.search(value):
120
+ raise ValidationError(
121
+ "a result rendering may not name an absolute local path",
122
+ code=PRESENTATION_REDACTION_FAILED,
123
+ details={"field": field},
124
+ )
125
+
126
+
127
+ def ensure_no_hidden_task_material(payload: UpliftPresentationPayload) -> None:
128
+ """Check every string in a payload before anything renders it.
129
+
130
+ Walks the payload rather than the fields somebody remembered, so a field
131
+ added later is covered the day it is added.
132
+ """
133
+ for path, value in _strings(payload, ""):
134
+ ensure_no_control_or_local_path(value, field=path)
135
+
136
+
137
+ def _strings(value: object, path: str) -> list[tuple[str, str]]:
138
+ """Return every string inside a payload, with the field it came from."""
139
+ if isinstance(value, str):
140
+ return [(path, value)]
141
+ if isinstance(value, BaseModel):
142
+ found: list[tuple[str, str]] = []
143
+ for name in type(value).model_fields:
144
+ found.extend(_strings(getattr(value, name), f"{path}.{name}".lstrip(".")))
145
+ return found
146
+ if isinstance(value, dict):
147
+ found = []
148
+ for key, item in value.items():
149
+ found.extend(_strings(item, f"{path}[{key}]"))
150
+ return found
151
+ if isinstance(value, list | tuple):
152
+ found = []
153
+ for position, item in enumerate(value):
154
+ found.extend(_strings(item, f"{path}[{position}]"))
155
+ return found
156
+ return []
@@ -0,0 +1,44 @@
1
+ """Publishing one finished run to the public log. Decisions document 0038.
2
+
3
+ This is the only package in Techtree that sends anything anywhere, and it sends
4
+ one thing: a proof bundle a person asked it to publish, after that bundle's own
5
+ proof has been checked and found to hold together. Nothing here runs on its own,
6
+ nothing here runs during a run, and nothing here is reachable without somebody
7
+ answering a question first.
8
+
9
+ Four rules shape every module below.
10
+
11
+ *A result that does not verify is never offered.* The verification is the first
12
+ step of publishing, not a check somebody could skip, because the whole of what
13
+ this product is for is that a published number came with its own evidence.
14
+
15
+ *A completed run's files are final.* Publishing adds a countersigned receipt and
16
+ a journal of its own to the run directory. It rewrites nothing, including the
17
+ run's own event log, which closed when the run ended.
18
+
19
+ *The address bar carries nothing.* A submission is a request body. No proof, no
20
+ digest and no volunteered address is ever put in a URL or a query string.
21
+
22
+ *A volunteered address is sent and not kept.* It is optional, it defaults to no,
23
+ its field name says it is unverified because nobody proved control of it, and
24
+ nothing on this machine writes it down.
25
+
26
+ The division of labour:
27
+
28
+ ``address``
29
+ Trimming, shape, the EIP-55 checksum, and the canonical lowercase form.
30
+ ``keccak``
31
+ The one hash EIP-55 needs and no standard library provides.
32
+ ``models``
33
+ What a submission is and what a receipt is.
34
+ ``journal``
35
+ The append-only record of what has been published about one run.
36
+ ``transport``
37
+ The single seam that opens a socket, and the only substitutable part.
38
+ ``service``
39
+ The order the steps happen in, which is the product.
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ __all__: list[str] = []
@@ -0,0 +1,180 @@
1
+ """Checking an EVM address somebody typed. Decisions document 0038.
2
+
3
+ An address is optional, it is volunteered, and it is unverifiable. Nobody has
4
+ proved control of the account, and this module cannot change that — what it can
5
+ do is refuse the two mistakes that are cheap to catch and expensive to make.
6
+
7
+ *The shape.* ``0x`` and exactly forty hexadecimal characters, after the
8
+ surrounding whitespace a paste brings with it has been trimmed off. Anything
9
+ else is a typo or a different kind of identifier, and either way it is not what
10
+ was asked for.
11
+
12
+ *The checksum, where there is one.* EIP-55 hides a checksum in the letter case
13
+ of an address: the hexadecimal is case-insensitive, so the case of each letter
14
+ is free to carry four bits of a Keccak-256 digest of the address itself. An
15
+ address written in one case carries no checksum and there is nothing to check.
16
+ An address written in mixed case does carry one, and checking it costs a hash
17
+ and catches a single mistyped character — which is the difference between a
18
+ recognisable contributor and an address that belongs to nobody and always will,
19
+ because there is no undo and no support desk.
20
+
21
+ What travels is the lowercase spelling. It is the same address, it is the form
22
+ the checksum is computed from, and one canonical form is what makes an address
23
+ usable as a key.
24
+
25
+ Nothing here stores anything. The caller sends the canonical form and keeps no
26
+ copy: an address is a detail somebody volunteered about themselves, not
27
+ evidence, and it belongs in no journal, no proof bundle and no log.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import re
33
+ from typing import Final
34
+
35
+ from techtree.errors import ValidationError
36
+ from techtree.publication.keccak import keccak256
37
+
38
+ __all__ = [
39
+ "CONTRIBUTOR_ADDRESS_INVALID",
40
+ "SKILL_GITHUB_URL_INVALID",
41
+ "canonical_contributor_address",
42
+ "canonical_skill_github_url",
43
+ "eip55_checksum",
44
+ ]
45
+
46
+ #: Stable error code for "that is not an address anybody could have meant".
47
+ CONTRIBUTOR_ADDRESS_INVALID: Final = "contributor_address_invalid"
48
+
49
+ #: Stable error code for an optional public Skill repository URL that is not a
50
+ #: canonical GitHub repository address.
51
+ SKILL_GITHUB_URL_INVALID: Final = "skill_github_url_invalid"
52
+
53
+ _ADDRESS_PATTERN: Final = re.compile(r"\A0x[0-9a-fA-F]{40}\Z")
54
+
55
+ # GitHub user and organization names are at most 39 characters and contain
56
+ # alphanumerics or dashes (never at either edge). Repository names are at most
57
+ # 100 characters and may also contain dots and underscores. The URL is
58
+ # deliberately narrower than what a browser might accept: one spelling, no
59
+ # redirects, no .git suffix, and no extra path or URL components.
60
+ _GITHUB_OWNER_PATTERN: Final = re.compile(
61
+ r"\A[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?\Z"
62
+ )
63
+ _GITHUB_REPOSITORY_PATTERN: Final = re.compile(
64
+ r"\A[A-Za-z0-9][A-Za-z0-9._-]{0,99}\Z"
65
+ )
66
+
67
+ #: A digest nibble of eight or more uppercases the hexadecimal character it
68
+ #: sits beside. EIP-55, in the one sentence it amounts to.
69
+ _UPPERCASE_THRESHOLD: Final = 8
70
+
71
+
72
+ def canonical_contributor_address(typed: str) -> str:
73
+ """Return the lowercase form of a typed address, or refuse it.
74
+
75
+ Refusals carry the reason rather than a generic "invalid": somebody who has
76
+ pasted the wrong forty characters and somebody who has mistyped one of the
77
+ right forty need to be told different things.
78
+ """
79
+ trimmed = typed.strip()
80
+ if not _ADDRESS_PATTERN.match(trimmed):
81
+ raise ValidationError(
82
+ "an address is 0x followed by exactly 40 hexadecimal characters, "
83
+ f"and this one is not: {_shape_of(trimmed)}",
84
+ code=CONTRIBUTOR_ADDRESS_INVALID,
85
+ details={"reason": "shape"},
86
+ )
87
+
88
+ body = trimmed[2:]
89
+ if body == body.lower() or body == body.upper():
90
+ # One case throughout carries no checksum, so there is nothing here to
91
+ # be right or wrong about.
92
+ return trimmed.lower()
93
+
94
+ if trimmed != eip55_checksum(trimmed):
95
+ raise ValidationError(
96
+ "this address is written in mixed case, which carries a checksum, "
97
+ "and the checksum does not match: one character of it is wrong. "
98
+ "An address nobody controls cannot be undone, so it is refused "
99
+ "rather than sent",
100
+ code=CONTRIBUTOR_ADDRESS_INVALID,
101
+ details={"reason": "checksum"},
102
+ )
103
+ return trimmed.lower()
104
+
105
+
106
+ def canonical_skill_github_url(typed: str) -> str:
107
+ """Return a canonical GitHub repository URL, or refuse it.
108
+
109
+ This is descriptive metadata, not proof that a repository exists or that
110
+ its publisher controls it. Keeping the accepted form strict prevents a
111
+ public link from silently becoming a redirect, a different host, or a
112
+ clone URL with credentials and query data attached.
113
+ """
114
+ from urllib.parse import urlsplit
115
+
116
+ if typed != typed.strip():
117
+ raise ValidationError(
118
+ "a Skill GitHub URL has no surrounding whitespace",
119
+ code=SKILL_GITHUB_URL_INVALID,
120
+ details={"reason": "whitespace"},
121
+ )
122
+
123
+ try:
124
+ parts = urlsplit(typed)
125
+ # Accessing ``port`` validates a malformed explicit port and may raise
126
+ # ValueError even when the rest of the URL can be split.
127
+ port = parts.port
128
+ except ValueError:
129
+ parts = None
130
+ port = None
131
+
132
+ path_parts = parts.path.split("/") if parts is not None else []
133
+ if (
134
+ parts is None
135
+ or parts.scheme != "https"
136
+ or parts.netloc != "github.com"
137
+ or parts.username is not None
138
+ or parts.password is not None
139
+ or port is not None
140
+ or parts.query
141
+ or parts.fragment
142
+ or "?" in typed
143
+ or "#" in typed
144
+ or len(path_parts) != 3
145
+ or path_parts[0]
146
+ or not _GITHUB_OWNER_PATTERN.fullmatch(path_parts[1])
147
+ or not _GITHUB_REPOSITORY_PATTERN.fullmatch(path_parts[2])
148
+ or path_parts[2].lower().endswith(".git")
149
+ ):
150
+ raise ValidationError(
151
+ "a Skill GitHub URL must be exactly https://github.com/owner/repo, "
152
+ "with no .git suffix, query, fragment, credentials, or extra path",
153
+ code=SKILL_GITHUB_URL_INVALID,
154
+ details={"reason": "shape"},
155
+ )
156
+ return typed
157
+
158
+
159
+ def eip55_checksum(address: str) -> str:
160
+ """Return the mixed-case EIP-55 spelling of a well-formed address."""
161
+ body = address[2:].lower()
162
+ digest = keccak256(body.encode("ascii")).hex()
163
+ return "0x" + "".join(
164
+ character.upper()
165
+ if int(digest[position], 16) >= _UPPERCASE_THRESHOLD
166
+ else character
167
+ for position, character in enumerate(body)
168
+ )
169
+
170
+
171
+ def _shape_of(typed: str) -> str:
172
+ """Describe what was typed without repeating it back.
173
+
174
+ A refusal that echoed the string would put it in the envelope, in the
175
+ terminal's scrollback, and in whatever a host agent keeps — which is
176
+ exactly where a volunteered address must not end up. The length and whether
177
+ the prefix was there are enough for somebody to see what they did.
178
+ """
179
+ prefix = "starts with 0x" if typed[:2].lower() == "0x" else "no 0x prefix"
180
+ return f"{prefix}, {len(typed)} characters"
@@ -0,0 +1,26 @@
1
+ """Where this build publishes, read off the release it belongs to.
2
+
3
+ Decisions document 0038, founder ruling of 2026-08-27.
4
+
5
+ One function, in one place, because two commands need the same answer and an
6
+ answer that differed between them would be a build that publishes to one address
7
+ and withdraws at another.
8
+
9
+ It is read from the ReleaseCore the wheel carries rather than from a setting,
10
+ which is what makes a stable release able to publish the moment it is installed.
11
+ A build that ships no ReleaseCore was not produced by the release generator, and
12
+ it says so: publishing needs to know not only where the run log is but which
13
+ key's countersignature would be worth anything, and neither is a thing to guess.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from techtree.release.document import packaged_release_core_bytes, parse_release_core
19
+ from techtree.release.models import PublicationCoordinates
20
+
21
+ __all__ = ["packaged_publication_coordinates"]
22
+
23
+
24
+ def packaged_publication_coordinates() -> PublicationCoordinates:
25
+ """Return the publication coordinates this build's release pins."""
26
+ return parse_release_core(packaged_release_core_bytes()).publication
@@ -0,0 +1,212 @@
1
+ """A completed run's publication journal. Decisions document 0038.
2
+
3
+ A run's own event log closes when the run ends. That is not an inconvenience to
4
+ work around: ``events.jsonl`` is the record of an execution, the state machine
5
+ is what makes it derivable, and a file that a later command appends to is a file
6
+ whose earlier bytes were not final. Publishing happens after all of that, is not
7
+ part of the execution, and must not touch a byte the run wrote.
8
+
9
+ So it is recorded in a journal of its own, in the same run directory, created
10
+ the first time somebody publishes and appended to after that. Every property the
11
+ run's own log has, this one has for the same reasons: canonical JSON one line at
12
+ a time, sequence numbers that start at zero and increase by exactly one, and an
13
+ ``O_APPEND`` write followed by an ``fsync``, so a crash can lose a trailing line
14
+ but cannot interleave two.
15
+
16
+ What it does not carry is a contributor address. An address is a detail somebody
17
+ volunteered about themselves rather than evidence about a run; it is sent and
18
+ not kept, so there is nothing here to read it back out of.
19
+
20
+ The publication status of a run is derived from this file and from nowhere else.
21
+ The signed report inside the proof bundle says ``not_requested``, permanently
22
+ and correctly: it was not requested when the report was written, and rewriting a
23
+ signed document to say otherwise would break the proof it is part of.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import os
29
+ from pathlib import Path
30
+ from typing import Final, Literal, Self
31
+
32
+ from pydantic import Field, model_validator
33
+ from pydantic import ValidationError as PydanticValidationError
34
+
35
+ from techtree.canonical import canonical_json_bytes
36
+ from techtree.errors import ValidationError
37
+ from techtree.fs import fsync_directory
38
+ from techtree.models.base import Digest, NonEmptyString, ProtocolModel, UtcDateTime
39
+ from techtree.models.uplift_report import PublicationStatus
40
+
41
+ __all__ = [
42
+ "ATTEMPT_STATUSES",
43
+ "PUBLICATION_JOURNAL_CORRUPT",
44
+ "PUBLICATION_JOURNAL_FILENAME",
45
+ "PublicationJournal",
46
+ "PublicationJournalEntry",
47
+ ]
48
+
49
+ #: The file, beside the run's own records and never inside its proof.
50
+ PUBLICATION_JOURNAL_FILENAME: Final = "publication.jsonl"
51
+
52
+ #: Stable error code for a journal whose lines do not form a history.
53
+ PUBLICATION_JOURNAL_CORRUPT: Final = "publication_journal_corrupt"
54
+
55
+ _LINE_SEPARATOR: Final = b"\n"
56
+ #: Owner read and write only, matching every other file Techtree writes.
57
+ _FILE_MODE: Final = 0o600
58
+
59
+ #: The three statuses a publication attempt moves through. The other two
60
+ #: members of :class:`~techtree.models.uplift_report.PublicationStatus` describe
61
+ #: a report rather than an attempt: ``not_requested`` is what a run with no
62
+ #: journal at all is, and ``blocked`` is a data policy's answer, decided before
63
+ #: anything is attempted.
64
+ ATTEMPT_STATUSES: Final[frozenset[PublicationStatus]] = frozenset(
65
+ {
66
+ PublicationStatus.PENDING,
67
+ PublicationStatus.PUBLISHED,
68
+ PublicationStatus.FAILED,
69
+ }
70
+ )
71
+
72
+
73
+ class PublicationJournalEntry(ProtocolModel):
74
+ """One thing that happened when somebody published this run."""
75
+
76
+ schema_version: Literal["techtree.publication-journal.v1alpha1"]
77
+ sequence: int = Field(ge=0)
78
+ at: UtcDateTime
79
+ run_id: NonEmptyString
80
+ status: PublicationStatus
81
+ #: The bundle this attempt was about, so a journal with several attempts in
82
+ #: it says which proof each one carried.
83
+ bundle_digest: Digest
84
+ #: Where it was sent. An origin and a path; a submission never travels in a
85
+ #: query string, so nothing here can contain one.
86
+ endpoint: NonEmptyString
87
+ file_count: int = Field(gt=0)
88
+ byte_count: int = Field(gt=0)
89
+ #: Set by a published attempt: where the entry now lives, and the log
90
+ #: position the network gave it.
91
+ entry_url: NonEmptyString | None = None
92
+ log_sequence: int | None = Field(default=None, ge=0)
93
+ #: Set by a failed attempt: the stable code of what went wrong. The message
94
+ #: belongs to the envelope the command already returned; a code is what a
95
+ #: later reader can act on.
96
+ error_code: NonEmptyString | None = None
97
+
98
+ @model_validator(mode="after")
99
+ def _check_the_outcome_carries_what_it_means(self) -> Self:
100
+ """Reject an entry that reports one outcome and describes another."""
101
+ if self.status not in ATTEMPT_STATUSES:
102
+ raise ValueError(
103
+ "a journal entry records an attempt, which is pending, "
104
+ "published or failed"
105
+ )
106
+ published = self.status is PublicationStatus.PUBLISHED
107
+ landed = self.entry_url is not None or self.log_sequence is not None
108
+ if published and (self.entry_url is None or self.log_sequence is None):
109
+ raise ValueError(
110
+ "a published entry records where it landed and its log position"
111
+ )
112
+ if not published and landed:
113
+ raise ValueError(
114
+ "only a published entry has somewhere it landed and a log position"
115
+ )
116
+ failed = self.status is PublicationStatus.FAILED
117
+ if failed and self.error_code is None:
118
+ raise ValueError("a failed entry records why it failed")
119
+ if not failed and self.error_code is not None:
120
+ raise ValueError("only a failed entry carries a failure code")
121
+ return self
122
+
123
+
124
+ class PublicationJournal:
125
+ """The append-only record of what has been published about one run."""
126
+
127
+ def __init__(self, run_root: Path) -> None:
128
+ self._path = run_root / PUBLICATION_JOURNAL_FILENAME
129
+
130
+ @property
131
+ def path(self) -> Path:
132
+ """Return the journal file, whether or not it exists yet."""
133
+ return self._path
134
+
135
+ def entries(self) -> list[PublicationJournalEntry]:
136
+ """Return every entry, refusing a history with a hole in it.
137
+
138
+ A sequence that skips is a journal that lost a line, and a status
139
+ derived from it would be quietly wrong. That check also catches a
140
+ half-written trailing line, which fails to parse before its sequence is
141
+ ever considered.
142
+ """
143
+ try:
144
+ raw = self._path.read_bytes()
145
+ except FileNotFoundError:
146
+ return []
147
+
148
+ entries: list[PublicationJournalEntry] = []
149
+ for position, line in enumerate(raw.splitlines()):
150
+ if not line.strip():
151
+ continue
152
+ try:
153
+ entry = PublicationJournalEntry.model_validate_json(line)
154
+ except PydanticValidationError as error:
155
+ raise ValidationError(
156
+ f"line {position + 1} of this run's publication journal "
157
+ "cannot be read",
158
+ code=PUBLICATION_JOURNAL_CORRUPT,
159
+ details={"path": str(self._path), "line": position + 1},
160
+ ) from error
161
+ if entry.sequence != len(entries):
162
+ raise ValidationError(
163
+ f"this run's publication journal jumps from {len(entries) - 1} "
164
+ f"to {entry.sequence}, so a line is missing",
165
+ code=PUBLICATION_JOURNAL_CORRUPT,
166
+ details={"path": str(self._path), "sequence": entry.sequence},
167
+ )
168
+ entries.append(entry)
169
+ return entries
170
+
171
+ def status(self) -> PublicationStatus:
172
+ """Return where this run stands, derived from the journal alone.
173
+
174
+ A run nobody has published has no journal, and that is
175
+ ``not_requested`` — the same thing its report has said since it was
176
+ written.
177
+ """
178
+ entries = self.entries()
179
+ if not entries:
180
+ return PublicationStatus.NOT_REQUESTED
181
+ return entries[-1].status
182
+
183
+ def published(self) -> PublicationJournalEntry | None:
184
+ """Return the entry that published this run, if one did."""
185
+ for entry in reversed(self.entries()):
186
+ if entry.status is PublicationStatus.PUBLISHED:
187
+ return entry
188
+ return None
189
+
190
+ def next_sequence(self) -> int:
191
+ """Return the sequence the next appended entry takes."""
192
+ return len(self.entries())
193
+
194
+ def append(self, entry: PublicationJournalEntry) -> None:
195
+ """Append one entry durably.
196
+
197
+ A single ``O_APPEND`` write of one whole line, then an ``fsync``. The
198
+ entry is serialized before the file is opened, so an entry that cannot
199
+ be written down does not leave a partial line behind it.
200
+ """
201
+ line = canonical_json_bytes(entry) + _LINE_SEPARATOR
202
+ descriptor = os.open(
203
+ self._path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, _FILE_MODE
204
+ )
205
+ try:
206
+ written = 0
207
+ while written < len(line):
208
+ written += os.write(descriptor, line[written:])
209
+ os.fsync(descriptor)
210
+ finally:
211
+ os.close(descriptor)
212
+ fsync_directory(self._path.parent)