throughline 3.18.2__tar.gz → 3.19.2__tar.gz

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 (76) hide show
  1. {throughline-3.18.2/src/throughline.egg-info → throughline-3.19.2}/PKG-INFO +1 -1
  2. {throughline-3.18.2 → throughline-3.19.2}/pyproject.toml +1 -1
  3. {throughline-3.18.2 → throughline-3.19.2}/src/throughline/__init__.py +2 -2
  4. {throughline-3.18.2 → throughline-3.19.2}/src/throughline/apply.py +87 -47
  5. {throughline-3.18.2 → throughline-3.19.2}/src/throughline/dump.py +8 -1
  6. {throughline-3.18.2 → throughline-3.19.2}/src/throughline/fingerprint.py +14 -0
  7. {throughline-3.18.2 → throughline-3.19.2/src/throughline.egg-info}/PKG-INFO +1 -1
  8. {throughline-3.18.2 → throughline-3.19.2}/tests/test_apply.py +113 -21
  9. {throughline-3.18.2 → throughline-3.19.2}/LICENSE +0 -0
  10. {throughline-3.18.2 → throughline-3.19.2}/NOTICE +0 -0
  11. {throughline-3.18.2 → throughline-3.19.2}/README.md +0 -0
  12. {throughline-3.18.2 → throughline-3.19.2}/setup.cfg +0 -0
  13. {throughline-3.18.2 → throughline-3.19.2}/src/throughline/brief.py +0 -0
  14. {throughline-3.18.2 → throughline-3.19.2}/src/throughline/cli.py +0 -0
  15. {throughline-3.18.2 → throughline-3.19.2}/src/throughline/collision.py +0 -0
  16. {throughline-3.18.2 → throughline-3.19.2}/src/throughline/composition.py +0 -0
  17. {throughline-3.18.2 → throughline-3.19.2}/src/throughline/diagrams.py +0 -0
  18. {throughline-3.18.2 → throughline-3.19.2}/src/throughline/filters.py +0 -0
  19. {throughline-3.18.2 → throughline-3.19.2}/src/throughline/graph.py +0 -0
  20. {throughline-3.18.2 → throughline-3.19.2}/src/throughline/grounding.py +0 -0
  21. {throughline-3.18.2 → throughline-3.19.2}/src/throughline/identity.py +0 -0
  22. {throughline-3.18.2 → throughline-3.19.2}/src/throughline/inject.py +0 -0
  23. {throughline-3.18.2 → throughline-3.19.2}/src/throughline/items.py +0 -0
  24. {throughline-3.18.2 → throughline-3.19.2}/src/throughline/links.py +0 -0
  25. {throughline-3.18.2 → throughline-3.19.2}/src/throughline/model.py +0 -0
  26. {throughline-3.18.2 → throughline-3.19.2}/src/throughline/parser.py +0 -0
  27. {throughline-3.18.2 → throughline-3.19.2}/src/throughline/ratification.py +0 -0
  28. {throughline-3.18.2 → throughline-3.19.2}/src/throughline/resolvers.py +0 -0
  29. {throughline-3.18.2 → throughline-3.19.2}/src/throughline/schema.py +0 -0
  30. {throughline-3.18.2 → throughline-3.19.2}/src/throughline/schema_ops.py +0 -0
  31. {throughline-3.18.2 → throughline-3.19.2}/src/throughline/seam.py +0 -0
  32. {throughline-3.18.2 → throughline-3.19.2}/src/throughline/signed_links.py +0 -0
  33. {throughline-3.18.2 → throughline-3.19.2}/src/throughline/sources.py +0 -0
  34. {throughline-3.18.2 → throughline-3.19.2}/src/throughline/storage.py +0 -0
  35. {throughline-3.18.2 → throughline-3.19.2}/src/throughline/tomledit.py +0 -0
  36. {throughline-3.18.2 → throughline-3.19.2}/src/throughline/uid.py +0 -0
  37. {throughline-3.18.2 → throughline-3.19.2}/src/throughline/union.py +0 -0
  38. {throughline-3.18.2 → throughline-3.19.2}/src/throughline/validate.py +0 -0
  39. {throughline-3.18.2 → throughline-3.19.2}/src/throughline/version.py +0 -0
  40. {throughline-3.18.2 → throughline-3.19.2}/src/throughline/views.py +0 -0
  41. {throughline-3.18.2 → throughline-3.19.2}/src/throughline/worklist.py +0 -0
  42. {throughline-3.18.2 → throughline-3.19.2}/src/throughline.egg-info/SOURCES.txt +0 -0
  43. {throughline-3.18.2 → throughline-3.19.2}/src/throughline.egg-info/dependency_links.txt +0 -0
  44. {throughline-3.18.2 → throughline-3.19.2}/src/throughline.egg-info/entry_points.txt +0 -0
  45. {throughline-3.18.2 → throughline-3.19.2}/src/throughline.egg-info/requires.txt +0 -0
  46. {throughline-3.18.2 → throughline-3.19.2}/src/throughline.egg-info/top_level.txt +0 -0
  47. {throughline-3.18.2 → throughline-3.19.2}/tests/test_amend.py +0 -0
  48. {throughline-3.18.2 → throughline-3.19.2}/tests/test_baseline.py +0 -0
  49. {throughline-3.18.2 → throughline-3.19.2}/tests/test_clarify.py +0 -0
  50. {throughline-3.18.2 → throughline-3.19.2}/tests/test_deprecated_names.py +0 -0
  51. {throughline-3.18.2 → throughline-3.19.2}/tests/test_diagram_cli.py +0 -0
  52. {throughline-3.18.2 → throughline-3.19.2}/tests/test_doctor.py +0 -0
  53. {throughline-3.18.2 → throughline-3.19.2}/tests/test_engine.py +0 -0
  54. {throughline-3.18.2 → throughline-3.19.2}/tests/test_filters.py +0 -0
  55. {throughline-3.18.2 → throughline-3.19.2}/tests/test_flag.py +0 -0
  56. {throughline-3.18.2 → throughline-3.19.2}/tests/test_library_api.py +0 -0
  57. {throughline-3.18.2 → throughline-3.19.2}/tests/test_link_guard.py +0 -0
  58. {throughline-3.18.2 → throughline-3.19.2}/tests/test_normative_by_type.py +0 -0
  59. {throughline-3.18.2 → throughline-3.19.2}/tests/test_own_tree.py +0 -0
  60. {throughline-3.18.2 → throughline-3.19.2}/tests/test_ratification_change.py +0 -0
  61. {throughline-3.18.2 → throughline-3.19.2}/tests/test_ratification_withdrawal.py +0 -0
  62. {throughline-3.18.2 → throughline-3.19.2}/tests/test_ratified_content.py +0 -0
  63. {throughline-3.18.2 → throughline-3.19.2}/tests/test_ratifier_correction.py +0 -0
  64. {throughline-3.18.2 → throughline-3.19.2}/tests/test_ratify_several.py +0 -0
  65. {throughline-3.18.2 → throughline-3.19.2}/tests/test_ratify_status.py +0 -0
  66. {throughline-3.18.2 → throughline-3.19.2}/tests/test_ratify_transition_refused_first.py +0 -0
  67. {throughline-3.18.2 → throughline-3.19.2}/tests/test_register_type.py +0 -0
  68. {throughline-3.18.2 → throughline-3.19.2}/tests/test_resolve_collision.py +0 -0
  69. {throughline-3.18.2 → throughline-3.19.2}/tests/test_resolve_cross_graph.py +0 -0
  70. {throughline-3.18.2 → throughline-3.19.2}/tests/test_schema_ops.py +0 -0
  71. {throughline-3.18.2 → throughline-3.19.2}/tests/test_signed_links.py +0 -0
  72. {throughline-3.18.2 → throughline-3.19.2}/tests/test_spec_lists.py +0 -0
  73. {throughline-3.18.2 → throughline-3.19.2}/tests/test_tombstone_record.py +0 -0
  74. {throughline-3.18.2 → throughline-3.19.2}/tests/test_unset_attrs.py +0 -0
  75. {throughline-3.18.2 → throughline-3.19.2}/tests/test_worklist.py +0 -0
  76. {throughline-3.18.2 → throughline-3.19.2}/tests/test_yaml_loader.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: throughline
3
- Version: 3.18.2
3
+ Version: 3.19.2
4
4
  Summary: A Git-native requirements management tool with a scope-avalanche grounding layer: permanent UIDs, one file per item, typed links, suspect detection, and a CI-gating check.
5
5
  Author-email: Henry J Grech-Cini <henry.grechcini@gmail.com>
6
6
  License-Expression: Apache-2.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "throughline"
7
- version = "3.18.2"
7
+ version = "3.19.2"
8
8
  description = "A Git-native requirements management tool with a scope-avalanche grounding layer: permanent UIDs, one file per item, typed links, suspect detection, and a CI-gating check."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -30,7 +30,7 @@ from .composition import (
30
30
  )
31
31
  from .diagrams import diagram_transitions, diagram_types
32
32
  from .dump import build_dump
33
- from .fingerprint import fingerprint
33
+ from .fingerprint import fingerprint, revision
34
34
  from .graph import Index
35
35
  from .grounding import (
36
36
  GroundingError,
@@ -179,7 +179,7 @@ __all__ = [
179
179
  "ProjectError", "CONFIG_NAME", "MANIFEST_NAME",
180
180
  # identity of an item
181
181
  "UID_RE", "parse_uid", "format_uid", "next_uid", "collisions",
182
- "Index", "fingerprint",
182
+ "Index", "fingerprint", "revision",
183
183
  # the schema a project declares
184
184
  "Schema", "AttrSpec", "LinkRule", "SchemaError",
185
185
  # creating and changing an item
@@ -2,18 +2,23 @@
2
2
  # SPDX-License-Identifier: Apache-2.0
3
3
  """Carrying out a change set drafted where the Tool cannot run (SR-0252).
4
4
 
5
- An assistant that reads at most a few files, cannot run commands and returns
6
- only text works from an export of the graph (throughline-transform's assistant
7
- form, built on `tl dump` and the fingerprint each item carries there, SR-0251)
8
- and sends its changes back as a *change set*: one JSON document listing the
9
- Tool's own operations — new, amend, link, unlink, delete — each naming the item
10
- it touches and, for an item that already exists, quoting the fingerprint it was
11
- drafted against.
5
+ An assistant or agent that cannot run the Tool — one that reads a capped number
6
+ of files and returns only text, as Microsoft 365 Copilot does in a controlled
7
+ environment — works from an export of the graph, built on `tl dump` and the
8
+ fingerprint and revision each item carries there (SR-0251). The export is the
9
+ business of whatever tool briefs that assistant (rhodium-org/throughline-ms-copilot
10
+ does it for Copilot); the assistant sends its changes back as a *change set*: one JSON
11
+ document listing the Tool's own operations — new, amend, link, unlink, delete —
12
+ each naming the item it touches and, for an item that already exists, quoting
13
+ the revision it was drafted against: the digest of its whole stored record that
14
+ the dump gives beside its fingerprint, so a change to any field since the export
15
+ refuses the set (format 2). A format-1 set quotes the fingerprint instead and is
16
+ still read, with the weaker check that implies.
12
17
 
13
18
  The set is carried out all or nothing. Every operation is checked against the
14
19
  graph as it stands before anything is written: a malformed operation, an
15
20
  operation the Tool does not offer an assistant (a decision, or a change to the
16
- structure), or a fingerprint that is not the item's current one refuses the
21
+ structure), or a revision that is not the item's current one refuses the
17
22
  whole set, naming every offending operation. Each operation then runs through
18
23
  the same function the matching command runs — :func:`new_item`,
19
24
  :func:`amend_item`, :func:`add_link`, :func:`remove_link`, :func:`delete_item`
@@ -33,7 +38,7 @@ from dataclasses import dataclass, field
33
38
  from pathlib import Path
34
39
  from typing import Callable
35
40
 
36
- from .fingerprint import fingerprint
41
+ from .fingerprint import fingerprint, revision
37
42
  from .grounding import GroundingError
38
43
  from .items import amend_item, delete_item, item_type_for, new_item, parse_attrs
39
44
  from .links import GroundingView, LinkError, add_link, remove_link
@@ -42,8 +47,16 @@ from .schema import SchemaError
42
47
  from .storage import ProjectError, write_item
43
48
  from .uid import UidError
44
49
 
45
- #: The version of the change-set form this release reads and documents.
46
- CHANGE_SET_FORMAT_VERSION = 1
50
+ #: The version of the change-set form this release documents and writes about.
51
+ #: Format 2 quotes each existing item's ``revision``; format 1 quoted its
52
+ #: ``fingerprint``, which misses a change to the title, rationale, links, status
53
+ #: or a non-normative attribute, and is still read so a set already drafted
54
+ #: against it is not broken.
55
+ CHANGE_SET_FORMAT_VERSION = 2
56
+
57
+ #: The versions :func:`parse_change_set` reads, and the key each one quotes an
58
+ #: existing item's version under.
59
+ READABLE_FORMATS = {1: "fingerprint", 2: "revision"}
47
60
 
48
61
  #: The operations an assistant may ask for, in the Tool's own vocabulary.
49
62
  OPERATIONS = ("new", "amend", "link", "unlink", "delete")
@@ -54,39 +67,43 @@ _DECISIONS = ("ratify", "withdraw", "clarify", "status", "invalidate", "flag",
54
67
  "review", "schema", "migrate", "resolve", "init", "register")
55
68
 
56
69
  _LABEL_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]*$")
57
- _FINGERPRINT_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
70
+ _DIGEST_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
58
71
 
59
- # The keys each operation may carry, and the ones it must.
72
+ # The keys each operation may carry, and the ones it must. An operation on an
73
+ # existing item may also carry the version key of its format (READABLE_FORMATS).
60
74
  _KEYS: dict[str, tuple[set[str], set[str]]] = {
61
75
  "new": ({"register"},
62
76
  {"op", "register", "type", "title", "text", "rationale", "attrs",
63
77
  "links", "label"}),
64
78
  "amend": ({"uid"},
65
- {"op", "uid", "fingerprint", "title", "text", "rationale", "attrs",
79
+ {"op", "uid", "title", "text", "rationale", "attrs",
66
80
  "unset"}),
67
81
  "link": ({"uid", "target", "type"},
68
- {"op", "uid", "fingerprint", "target", "type", "stamp"}),
82
+ {"op", "uid", "target", "type", "stamp"}),
69
83
  "unlink": ({"uid", "target"},
70
- {"op", "uid", "fingerprint", "target", "type"}),
71
- "delete": ({"uid"}, {"op", "uid", "fingerprint", "reason"}),
84
+ {"op", "uid", "target", "type"}),
85
+ "delete": ({"uid"}, {"op", "uid", "reason"}),
72
86
  }
73
87
 
74
88
  #: The change-set form, as the text handed to an assistant with the export.
75
- #: throughline-transform takes it from here (its SR-0018), so what an assistant
76
- #: is told to write cannot drift from what :func:`parse_change_set` accepts.
89
+ #: A tool that briefs an assistant takes it from here (`tl apply --describe`),
90
+ #: so what the assistant is told to write cannot drift from what
91
+ #: :func:`parse_change_set` accepts.
77
92
  CHANGE_SET_FORMAT = f"""\
78
93
  # A throughline change set — the form `tl apply` accepts (format {CHANGE_SET_FORMAT_VERSION})
79
94
 
80
95
  A change set is one JSON document: a list of operations, carried out in order,
81
96
  all or nothing. `tl apply` refuses the whole set, naming every offending
82
97
  operation, when any operation is malformed, is not one of the five below, or
83
- quotes a fingerprint that is not the item's current one. Nothing is written
98
+ quotes a revision that is not the item's current one. Nothing is written
84
99
  until every operation has been checked, and a refused set changes nothing.
85
100
 
86
- An item that already exists is named by its UID and carries the "fingerprint"
87
- the export gave for it (the `fingerprint` field of the item in `tl dump`). An
88
- item created earlier in the same set is named by its label, written "$label",
89
- and carries no fingerprint.
101
+ An item that already exists is named by its UID and carries the "revision"
102
+ the export gave for it (the `revision` field of the item in `tl dump`). Any
103
+ change to the item since the export — its text, title, rationale, links,
104
+ status or any attribute — refuses the set, so a change is never written over a
105
+ newer one. An item created earlier in the same set is named by its label,
106
+ written "$label", and carries no revision.
90
107
 
91
108
  Everything the set creates or changes is recorded with origin "ai" and waits
92
109
  as proposed: a person decides afterwards. Decisions — ratify, withdraw,
@@ -102,29 +119,29 @@ The five operations:
102
119
  where the register declares one. "links" ground the item: a non-root item
103
120
  must reach a root through them. "label" is optional.
104
121
 
105
- {{"op": "amend", "uid": "SR-0012", "fingerprint": "sha256:...", "title": "...",
122
+ {{"op": "amend", "uid": "SR-0012", "revision": "sha256:...", "title": "...",
106
123
  "text": "...", "rationale": "...", "attrs": {{"priority": "must"}},
107
124
  "unset": ["owner"]}}
108
125
  changes only the fields given; an empty string clears a field. Only an
109
126
  attribute the item's type declares may be set.
110
127
 
111
- {{"op": "link", "uid": "SR-0012", "fingerprint": "sha256:...",
128
+ {{"op": "link", "uid": "SR-0012", "revision": "sha256:...",
112
129
  "target": "UR-0003", "type": "implements"}}
113
130
  adds a typed link from the item to the target.
114
131
 
115
- {{"op": "unlink", "uid": "SR-0012", "fingerprint": "sha256:...",
132
+ {{"op": "unlink", "uid": "SR-0012", "revision": "sha256:...",
116
133
  "target": "UR-0003", "type": "implements"}}
117
134
  removes that link; "type" may be left out when one link joins them. Refused
118
135
  where it would leave an item ungrounded.
119
136
 
120
- {{"op": "delete", "uid": "SR-0012", "fingerprint": "sha256:...",
137
+ {{"op": "delete", "uid": "SR-0012", "revision": "sha256:...",
121
138
  "reason": "..."}}
122
139
  tombstones the item: its file stays and its UID is never reused.
123
140
 
124
141
  Rules: no other keys; a "uid", "target" or "label" is written exactly; every
125
142
  value is a string except "attrs" (strings, numbers or booleans), "stamp"
126
143
  (boolean), and "unset" and "links" (lists). The list may also be sent as
127
- {{"change_set_format": {CHANGE_SET_FORMAT_VERSION}, "operations": [...]}}. Never send rewritten
144
+ {{"change_set_format": {CHANGE_SET_FORMAT_VERSION}, "operations": [...]}}, which is the safer form. Never send rewritten
128
145
  item files: send this change set, and nothing else.
129
146
  """
130
147
 
@@ -172,24 +189,43 @@ def _scalar(value: object) -> bool:
172
189
  or isinstance(value, bool)
173
190
 
174
191
 
192
+ def _inferred_format(data: list) -> int:
193
+ """The format of a set that does not say: 1 only where its operations quote a
194
+ fingerprint and none quotes a revision, so a format-1 set drafted before
195
+ format 2 still reads as it was written. A set mixing the two is read as the
196
+ current format, whose rules then name each fingerprint as an unknown key."""
197
+ ops = [op for op in data if isinstance(op, dict)]
198
+ if any("fingerprint" in op for op in ops) and not any("revision" in op for op in ops):
199
+ return 1
200
+ return CHANGE_SET_FORMAT_VERSION
201
+
202
+
175
203
  def parse_change_set(text: str) -> list[dict]:
176
204
  """Read a change set from its JSON text, refusing it whole when any operation
177
- is malformed or is not one an assistant may take (SR-0252). The operations
205
+ is malformed or is not one an assistant may take (SR-0252). A set that names
206
+ its ``change_set_format`` is read in that format; one that does not is read
207
+ as the current format unless it quotes only fingerprints. The operations
178
208
  come back as given, checked for shape; whether each can be carried out is
179
209
  :func:`apply_change_set`'s to say."""
180
210
  try:
181
211
  data = json.loads(text)
182
212
  except json.JSONDecodeError as e:
183
213
  raise ChangeSetError([f"the change set is not JSON: {e}"]) from None
214
+ version = None
184
215
  if isinstance(data, dict) and "operations" in data:
185
- version = data.get("change_set_format", CHANGE_SET_FORMAT_VERSION)
186
- if version != CHANGE_SET_FORMAT_VERSION:
216
+ version = data.get("change_set_format")
217
+ if version is not None and (isinstance(version, bool)
218
+ or version not in READABLE_FORMATS):
187
219
  raise ChangeSetError([f"change_set_format {version!r} is not one this "
188
- f"release reads ({CHANGE_SET_FORMAT_VERSION})"])
220
+ "release reads ("
221
+ + ", ".join(map(str, READABLE_FORMATS)) + ")"])
189
222
  data = data["operations"]
190
223
  if not isinstance(data, list):
191
224
  raise ChangeSetError(["the change set must be a JSON list of operations "
192
225
  "(or an object whose \"operations\" is that list)"])
226
+ if version is None:
227
+ version = _inferred_format(data)
228
+ quoted = READABLE_FORMATS[version]
193
229
  problems: list[str] = []
194
230
  ops: list[dict] = []
195
231
  labels: set[str] = set()
@@ -209,6 +245,8 @@ def parse_change_set(text: str) -> list[dict]:
209
245
  f"{', '.join(OPERATIONS)}, got {kind!r}")
210
246
  continue
211
247
  required, allowed = _KEYS[kind]
248
+ if kind != "new":
249
+ allowed = allowed | {quoted}
212
250
  name = f"{where} {kind}"
213
251
  extra = sorted(set(op) - allowed)
214
252
  if extra:
@@ -217,7 +255,7 @@ def parse_change_set(text: str) -> list[dict]:
217
255
  if missing:
218
256
  problems.append(f"{name}: missing {', '.join(missing)}")
219
257
  for key in ("uid", "target", "register", "type", "title", "text",
220
- "rationale", "label", "reason", "fingerprint"):
258
+ "rationale", "label", "reason", quoted):
221
259
  if key in op and not isinstance(op[key], str):
222
260
  problems.append(f"{name}: \"{key}\" must be a string")
223
261
  if "stamp" in op and not isinstance(op["stamp"], bool):
@@ -245,12 +283,11 @@ def parse_change_set(text: str) -> list[dict]:
245
283
  if uid[1:] not in labels:
246
284
  problems.append(f"{name}: {uid} names no item created earlier "
247
285
  "in this set")
248
- elif "fingerprint" not in op:
286
+ elif quoted not in op:
249
287
  problems.append(f"{name} {uid}: an operation on an existing item "
250
- "carries the \"fingerprint\" it was based on")
251
- elif isinstance(op["fingerprint"], str) \
252
- and not _FINGERPRINT_RE.match(op["fingerprint"]):
253
- problems.append(f"{name} {uid}: \"fingerprint\" must be the sha256:… "
288
+ f"carries the \"{quoted}\" it was based on")
289
+ elif isinstance(op[quoted], str) and not _DIGEST_RE.match(op[quoted]):
290
+ problems.append(f"{name} {uid}: \"{quoted}\" must be the sha256:… "
254
291
  "value the export gave")
255
292
  if kind == "new":
256
293
  label = op.get("label")
@@ -309,10 +346,10 @@ def apply_change_set(project, operations: list[dict], *,
309
346
  ``project``, all or nothing (SR-0252).
310
347
 
311
348
  Every operation is first checked against the graph as it stands: the item it
312
- names exists and is not deleted, the fingerprint it quotes is the item's
313
- current one, and what it links to can be found. A problem anywhere refuses
314
- the whole set before a file is written. Then each operation runs through the
315
- function the matching command runs, and a refusal from any of them restores
349
+ names exists and is not deleted, the revision it quotes (or, in a format-1
350
+ set, the fingerprint) is the item's current one, and what it links to can be
351
+ found. A problem anywhere refuses the whole set before a file is written.
352
+ Then each operation runs through the function the matching command runs, and a refusal from any of them restores
316
353
  every file and refuses the set the same way. ``view`` and ``find`` let a
317
354
  program that grounds over a wider graph than the one it writes — a union with
318
355
  its sources — judge links and find targets there (SR-0212); left out, they are
@@ -334,10 +371,13 @@ def apply_change_set(project, operations: list[dict], *,
334
371
  problems.append(f"{name}: {uid} does not exist")
335
372
  elif item.is_deleted:
336
373
  problems.append(f"{name}: {uid} is deleted — a tombstone is permanent")
337
- elif op.get("fingerprint") != fingerprint(item, schema):
338
- problems.append(f"{name}: {uid} changed since the export — the set "
339
- f"quotes {op.get('fingerprint')} but the item now "
340
- f"carries {fingerprint(item, schema)}")
374
+ else:
375
+ key = "revision" if "revision" in op else "fingerprint"
376
+ now = revision(item) if key == "revision" else fingerprint(item, schema)
377
+ if op.get(key) != now:
378
+ problems.append(f"{name}: {uid} changed since the export — the set "
379
+ f"quotes {key} {op.get(key)} but the item now "
380
+ f"carries {now}")
341
381
  return item
342
382
 
343
383
  def target_known(name: str, ref: str) -> None:
@@ -14,10 +14,16 @@ content as it stands (SR-0251), under this documented key, so a reader working
14
14
  from the dump can quote back which version of an item it read — what a change
15
15
  set hands to ``tl apply`` (SR-0252). It is computed here by the one fingerprint
16
16
  rule and nowhere else, so a tool built on the dump never hashes an item itself.
17
+
18
+ Each item also carries ``revision``: a digest of its whole stored record, so it
19
+ moves on any change at all — a title, a rationale, a link, a status — where the
20
+ fingerprint, which ratification is taken over, moves only on normative content.
21
+ A change set quotes the revision (SR-0252), so an operation drafted against an
22
+ item that has since changed in any way is refused rather than written over it.
17
23
  """
18
24
  from __future__ import annotations
19
25
 
20
- from .fingerprint import fingerprint
26
+ from .fingerprint import fingerprint, revision
21
27
  from .model import Project
22
28
  from .storage import FORMAT_VERSION
23
29
 
@@ -43,6 +49,7 @@ def build_dump(project: Project, tool_version: str) -> dict:
43
49
  def entry(it) -> dict:
44
50
  d = it.to_dict()
45
51
  d["fingerprint"] = fingerprint(it, schema)
52
+ d["revision"] = revision(it)
46
53
  return d
47
54
 
48
55
  return {
@@ -24,6 +24,7 @@ from __future__ import annotations
24
24
 
25
25
  import copy
26
26
  import hashlib
27
+ import json
27
28
  import unicodedata
28
29
 
29
30
  from .schema import Schema
@@ -119,3 +120,16 @@ def content_fingerprint(uid: str, content: object) -> str | None:
119
120
  return None
120
121
  return _digest(uid, content["type"], content["text"], content["normative"],
121
122
  content["derived"], list(attrs.items()))
123
+
124
+
125
+ def revision(item) -> str:
126
+ """The revision of an item's whole stored record (SR-0251): SHA-256 over the
127
+ record the item's file holds — every field an operation reads or writes,
128
+ title, rationale, status, links and every attribute included — in canonical
129
+ JSON. Unlike :func:`fingerprint`, which a ratification is taken over and so
130
+ reads only normative content, any change to the item moves its revision, so a
131
+ change set quoting it is refused when anything about the item has moved since
132
+ the export it was drafted from (SR-0252)."""
133
+ record = json.dumps(item.to_dict(), sort_keys=True, ensure_ascii=False,
134
+ separators=(",", ":"), default=str)
135
+ return "sha256:" + hashlib.sha256(record.encode("utf-8")).hexdigest()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: throughline
3
- Version: 3.18.2
3
+ Version: 3.19.2
4
4
  Summary: A Git-native requirements management tool with a scope-avalanche grounding layer: permanent UIDs, one file per item, typed links, suspect detection, and a CI-gating check.
5
5
  Author-email: Henry J Grech-Cini <henry.grechcini@gmail.com>
6
6
  License-Expression: Apache-2.0
@@ -1,13 +1,13 @@
1
1
  # Copyright (c) 2026 Henry J Grech-Cini
2
2
  # SPDX-License-Identifier: Apache-2.0
3
- """`tl dump` states each item's fingerprint (SR-0251) and `tl apply` carries out a
4
- change set of the Tool's own operations, all or nothing (SR-0252).
3
+ """`tl dump` states each item's fingerprint and revision (SR-0251) and `tl apply`
4
+ carries out a change set of the Tool's own operations, all or nothing (SR-0252).
5
5
 
6
6
  An assistant that reads a few files, cannot run commands and returns only text
7
- works from an export and sends back a change set quoting the fingerprint each
8
- item carried. These tests hold the two halves to their requirements: the dump
7
+ works from an export and sends back a change set quoting the revision each item
8
+ carried. These tests hold the two halves to their requirements: the dump
9
9
  carries the fingerprint the Tool computes, and apply refuses a set whole — on a
10
- stale fingerprint, a malformed operation, an operation an assistant may not take,
10
+ stale revision, a malformed operation, an operation an assistant may not take,
11
11
  or a refusal from the operation itself — changing nothing, and otherwise carries
12
12
  out every operation through the matching command's own function, recording what
13
13
  it creates or changes with origin ai.
@@ -21,7 +21,7 @@ from throughline import apply_change_set, build_dump, parse_change_set
21
21
  from throughline.apply import ChangeSetError
22
22
  from throughline.cli import main as _cli
23
23
  from throughline.dump import DUMP_SCHEMA_VERSION
24
- from throughline.fingerprint import fingerprint
24
+ from throughline.fingerprint import fingerprint, revision
25
25
  from throughline.storage import load_project
26
26
 
27
27
 
@@ -50,6 +50,12 @@ def _fp(root: Path, uid: str) -> str:
50
50
  return next(i["fingerprint"] for i in dump["items"] if i["uid"] == uid)
51
51
 
52
52
 
53
+ def _rev(root: Path, uid: str) -> str:
54
+ """The revision the export gives, read from the dump as an assistant would."""
55
+ dump = build_dump(load_project(root), "9.9.9")
56
+ return next(i["revision"] for i in dump["items"] if i["uid"] == uid)
57
+
58
+
53
59
  def _tree(root: Path) -> dict[str, bytes]:
54
60
  return {p.relative_to(root).as_posix(): p.read_bytes()
55
61
  for p in root.rglob("*") if p.is_file()}
@@ -66,7 +72,7 @@ def _sound(root: Path) -> list[dict]:
66
72
  {"op": "new", "register": "FR", "type": "requirement", "title": "a second",
67
73
  "text": "The Tool shall do a second thing.", "rationale": "because",
68
74
  "links": [{"target": "INT-0001", "type": "derives_from"}], "label": "fr2"},
69
- {"op": "amend", "uid": "FR-0001", "fingerprint": _fp(root, "FR-0001"),
75
+ {"op": "amend", "uid": "FR-0001", "revision": _rev(root, "FR-0001"),
70
76
  "text": "rewritten by an assistant"},
71
77
  {"op": "link", "uid": "$fr2", "target": "FR-0001", "type": "derives_from"},
72
78
  {"op": "unlink", "uid": "$fr2", "target": "FR-0001", "type": "derives_from"},
@@ -88,12 +94,25 @@ def test_the_dump_states_each_items_fingerprint_under_its_documented_key(tmp_pat
88
94
  project.schema)
89
95
 
90
96
 
97
+ def test_the_dump_states_each_items_revision_beside_its_fingerprint(tmp_path):
98
+ root = _scaffold(tmp_path)
99
+ project = load_project(root)
100
+ for entry in build_dump(project, "9.9.9")["items"]:
101
+ assert entry["revision"] == revision(project.get(entry["uid"]))
102
+ before = _rev(root, "FR-0001"), _fp(root, "FR-0001")
103
+ assert _cli(["-C", str(root), "amend", "FR-0001", "--title", "moved"]) == 0
104
+ # a title is not normative: the fingerprint stands and the revision moves
105
+ assert _fp(root, "FR-0001") == before[1]
106
+ assert _rev(root, "FR-0001") != before[0]
107
+
108
+
91
109
  def test_the_command_line_dump_carries_it_too(tmp_path, capsys):
92
110
  root = _scaffold(tmp_path)
93
111
  capsys.readouterr() # the scaffold's own output
94
112
  assert _cli(["-C", str(root), "dump"]) == 0
95
113
  dump = json.loads(capsys.readouterr().out)
96
114
  assert all(i["fingerprint"].startswith("sha256:") for i in dump["items"])
115
+ assert all(i["revision"].startswith("sha256:") for i in dump["items"])
97
116
 
98
117
 
99
118
  # --------------------------------------------------------------------------- #
@@ -105,7 +124,9 @@ def test_describe_prints_the_form_an_assistant_is_given(tmp_path, capsys):
105
124
  out = capsys.readouterr().out
106
125
  for op in ("new", "amend", "link", "unlink", "delete"):
107
126
  assert f'"op": "{op}"' in out
108
- assert "fingerprint" in out and "all or nothing" in out
127
+ assert '"revision": "sha256:...' in out and "all or nothing" in out
128
+ # what an assistant is told to write is format 2 only: no fingerprint key
129
+ assert '"fingerprint"' not in out
109
130
 
110
131
 
111
132
  def test_a_sound_set_is_carried_out_in_order_and_everything_is_proposed_origin_ai(
@@ -129,13 +150,13 @@ def test_a_sound_set_is_carried_out_in_order_and_everything_is_proposed_origin_a
129
150
  assert first.title == "original title"
130
151
 
131
152
 
132
- def test_a_stale_fingerprint_refuses_the_whole_set_and_names_the_operation(
153
+ def test_a_stale_revision_refuses_the_whole_set_and_names_the_operation(
133
154
  tmp_path, capsys):
134
155
  root = _scaffold(tmp_path)
135
156
  before = _tree(root)
136
157
  stale = "sha256:" + "0" * 64
137
158
  ops = _sound(root)
138
- ops[1]["fingerprint"] = stale
159
+ ops[1]["revision"] = stale
139
160
  path = _set(root, ops)
140
161
  assert _cli(["-C", str(root), "apply", str(path)]) == 1
141
162
  out = capsys.readouterr().out
@@ -146,10 +167,10 @@ def test_a_stale_fingerprint_refuses_the_whole_set_and_names_the_operation(
146
167
  def test_a_malformed_set_names_every_offending_operation(tmp_path, capsys):
147
168
  root = _scaffold(tmp_path)
148
169
  before = _tree(root)
149
- fp = _fp(root, "FR-0001")
170
+ rev = _rev(root, "FR-0001")
150
171
  path = _set(root, [
151
- {"op": "amend", "fingerprint": fp, "text": "no uid"},
152
- {"op": "link", "uid": "FR-0001", "fingerprint": fp, "target": "INT-0001",
172
+ {"op": "amend", "revision": rev, "text": "no uid"},
173
+ {"op": "link", "uid": "FR-0001", "revision": rev, "target": "INT-0001",
153
174
  "type": "derives_from", "colour": "red"},
154
175
  {"op": "ratify", "uid": "FR-0001"},
155
176
  {"op": "delete", "uid": "FR-0001"},
@@ -182,10 +203,10 @@ def test_a_refusal_from_an_operation_restores_every_file(tmp_path, capsys):
182
203
  refused and #1 is undone."""
183
204
  root = _scaffold(tmp_path)
184
205
  before = _tree(root)
185
- fp = _fp(root, "FR-0001")
206
+ rev = _rev(root, "FR-0001")
186
207
  path = _set(root, [
187
- {"op": "amend", "uid": "FR-0001", "fingerprint": fp, "text": "changed"},
188
- {"op": "unlink", "uid": "FR-0001", "fingerprint": fp, "target": "INT-0001"},
208
+ {"op": "amend", "uid": "FR-0001", "revision": rev, "text": "changed"},
209
+ {"op": "unlink", "uid": "FR-0001", "revision": rev, "target": "INT-0001"},
189
210
  ])
190
211
  assert _cli(["-C", str(root), "apply", str(path)]) == 1
191
212
  out = capsys.readouterr().out
@@ -215,7 +236,7 @@ def test_json_output_reports_each_operation_and_the_files(tmp_path, capsys):
215
236
  assert report["created"] == ["FR-0002"] and report["changed"] == ["FR-0001"]
216
237
  assert sorted(report["files"]) == ["features/FR-0001.yml", "features/FR-0002.yml"]
217
238
  # a refusal is reported the same way
218
- path = _set(root, [{"op": "delete", "uid": "FR-0009", "fingerprint": "sha256:" + "a" * 64}])
239
+ path = _set(root, [{"op": "delete", "uid": "FR-0009", "revision": "sha256:" + "a" * 64}])
219
240
  assert _cli(["-C", str(root), "apply", str(path), "--format", "json"]) == 1
220
241
  refusal = json.loads(capsys.readouterr().out)
221
242
  assert refusal["applied"] is False and "FR-0009 does not exist" in refusal["problems"][0]
@@ -223,21 +244,21 @@ def test_json_output_reports_each_operation_and_the_files(tmp_path, capsys):
223
244
 
224
245
  def test_a_delete_tombstones_and_an_operation_on_a_tombstone_is_refused(tmp_path):
225
246
  root = _scaffold(tmp_path)
226
- fp = _fp(root, "FR-0001")
227
- path = _set(root, [{"op": "delete", "uid": "FR-0001", "fingerprint": fp,
247
+ rev = _rev(root, "FR-0001")
248
+ path = _set(root, [{"op": "delete", "uid": "FR-0001", "revision": rev,
228
249
  "reason": "withdrawn by the assistant"}])
229
250
  assert _cli(["-C", str(root), "apply", str(path)]) == 0
230
251
  gone = _item(root, "FR-0001")
231
252
  assert gone.is_deleted and gone.deleted["reason"] == "withdrawn by the assistant"
232
253
  assert gone.attrs["origin"] == "ai"
233
- path = _set(root, [{"op": "amend", "uid": "FR-0001", "fingerprint": fp,
254
+ path = _set(root, [{"op": "amend", "uid": "FR-0001", "revision": rev,
234
255
  "text": "back from the dead"}])
235
256
  assert _cli(["-C", str(root), "apply", str(path)]) == 1
236
257
 
237
258
 
238
259
  def test_the_wrapped_form_is_accepted_and_the_library_needs_no_command_line(tmp_path):
239
260
  root = _scaffold(tmp_path)
240
- text = json.dumps({"change_set_format": 1, "operations": _sound(root)})
261
+ text = json.dumps({"change_set_format": 2, "operations": _sound(root)})
241
262
  operations = parse_change_set(text)
242
263
  project = load_project(root)
243
264
  result = apply_change_set(project, operations)
@@ -251,3 +272,74 @@ def test_the_wrapped_form_is_accepted_and_the_library_needs_no_command_line(tmp_
251
272
  assert any("FR-0001 changed since the export" in p for p in e.problems)
252
273
  else:
253
274
  raise AssertionError("a stale set was applied")
275
+
276
+
277
+ # --------------------------------------------------------------------------- #
278
+ # SR-0252 — a change to any field since the export refuses the set
279
+ # --------------------------------------------------------------------------- #
280
+
281
+ import pytest # noqa: E402
282
+
283
+ _EDITS = {
284
+ "title": ["amend", "FR-0001", "--title", "moved by someone else"],
285
+ "rationale": ["amend", "FR-0001", "--rationale", "a newer reason"],
286
+ "attribute": ["amend", "FR-0001", "--attr", "owner=someone"],
287
+ "link": ["link", "FR-0001", "FR-0002", "--type", "relates"],
288
+ "status": ["status", "FR-0001", "deferred"],
289
+ }
290
+
291
+
292
+ @pytest.mark.parametrize("edit", sorted(_EDITS))
293
+ def test_an_edit_after_the_export_that_moves_no_fingerprint_still_refuses_the_set(
294
+ tmp_path, capsys, edit):
295
+ root = _scaffold(tmp_path)
296
+ assert _cli(["-C", str(root), "new", "FR", "--type", "requirement",
297
+ "--title", "other", "--text", "other", "--ground", "INT-0001"]) == 0
298
+ if edit == "attribute":
299
+ assert _cli(["-C", str(root), "schema", "attr", "add", "requirement",
300
+ "owner", "--kind", "string", "--because", "a test"]) == 0
301
+ exported = _rev(root, "FR-0001"), _fp(root, "FR-0001")
302
+ assert _cli(["-C", str(root), *_EDITS[edit]]) == 0
303
+ assert _fp(root, "FR-0001") == exported[1], "the edit is not normative"
304
+ before = _tree(root)
305
+ path = _set(root, [{"op": "amend", "uid": "FR-0001", "revision": exported[0],
306
+ "title": "the assistant's title"}])
307
+ capsys.readouterr()
308
+ assert _cli(["-C", str(root), "apply", str(path)]) == 1
309
+ out = capsys.readouterr().out
310
+ assert "#1 amend: FR-0001 changed since the export" in out
311
+ assert _tree(root) == before
312
+
313
+
314
+ def test_a_format_1_set_quoting_fingerprints_is_still_read(tmp_path):
315
+ root = _scaffold(tmp_path)
316
+ fp = _fp(root, "FR-0001")
317
+ for text in (json.dumps([{"op": "amend", "uid": "FR-0001", "fingerprint": fp,
318
+ "title": "bare list"}]),
319
+ json.dumps({"change_set_format": 1, "operations": [
320
+ {"op": "amend", "uid": "FR-0001", "fingerprint": fp,
321
+ "title": "wrapped"}]})):
322
+ result = apply_change_set(load_project(root), parse_change_set(text))
323
+ assert result.changed == ["FR-0001"]
324
+ assert _item(root, "FR-0001").title == "wrapped"
325
+
326
+
327
+ def test_format_2_takes_only_the_revision_and_format_1_only_the_fingerprint(tmp_path):
328
+ root = _scaffold(tmp_path)
329
+ fp, rev = _fp(root, "FR-0001"), _rev(root, "FR-0001")
330
+ cases = [
331
+ ({"change_set_format": 2, "operations": [
332
+ {"op": "amend", "uid": "FR-0001", "fingerprint": fp, "title": "x"}]},
333
+ "unknown key(s) fingerprint"),
334
+ ({"change_set_format": 1, "operations": [
335
+ {"op": "amend", "uid": "FR-0001", "revision": rev, "title": "x"}]},
336
+ "unknown key(s) revision"),
337
+ ([{"op": "amend", "uid": "FR-0001", "revision": rev, "title": "x"},
338
+ {"op": "amend", "uid": "FR-0001", "fingerprint": fp, "title": "y"}],
339
+ "#2 amend: unknown key(s) fingerprint"),
340
+ ({"change_set_format": 3, "operations": []}, "is not one this release reads"),
341
+ ]
342
+ for data, expected in cases:
343
+ with pytest.raises(ChangeSetError) as e:
344
+ parse_change_set(json.dumps(data))
345
+ assert any(expected in p for p in e.value.problems), (expected, e.value.problems)
File without changes
File without changes
File without changes
File without changes