throughline 3.0.0__tar.gz → 3.2.0__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 (40) hide show
  1. {throughline-3.0.0/src/throughline.egg-info → throughline-3.2.0}/PKG-INFO +8 -3
  2. {throughline-3.0.0 → throughline-3.2.0}/README.md +7 -2
  3. {throughline-3.0.0 → throughline-3.2.0}/pyproject.toml +1 -1
  4. {throughline-3.0.0 → throughline-3.2.0}/src/throughline/cli.py +93 -5
  5. {throughline-3.0.0 → throughline-3.2.0}/src/throughline/grounding.py +114 -6
  6. {throughline-3.0.0 → throughline-3.2.0}/src/throughline/identity.py +23 -0
  7. {throughline-3.0.0 → throughline-3.2.0}/src/throughline/ratification.py +208 -11
  8. {throughline-3.0.0 → throughline-3.2.0}/src/throughline/validate.py +11 -1
  9. {throughline-3.0.0 → throughline-3.2.0/src/throughline.egg-info}/PKG-INFO +8 -3
  10. {throughline-3.0.0 → throughline-3.2.0}/src/throughline.egg-info/SOURCES.txt +2 -0
  11. {throughline-3.0.0 → throughline-3.2.0}/tests/test_ratification_change.py +121 -0
  12. throughline-3.2.0/tests/test_ratification_withdrawal.py +266 -0
  13. throughline-3.2.0/tests/test_ratifier_correction.py +212 -0
  14. {throughline-3.0.0 → throughline-3.2.0}/LICENSE +0 -0
  15. {throughline-3.0.0 → throughline-3.2.0}/NOTICE +0 -0
  16. {throughline-3.0.0 → throughline-3.2.0}/setup.cfg +0 -0
  17. {throughline-3.0.0 → throughline-3.2.0}/src/throughline/__init__.py +0 -0
  18. {throughline-3.0.0 → throughline-3.2.0}/src/throughline/dump.py +0 -0
  19. {throughline-3.0.0 → throughline-3.2.0}/src/throughline/filters.py +0 -0
  20. {throughline-3.0.0 → throughline-3.2.0}/src/throughline/fingerprint.py +0 -0
  21. {throughline-3.0.0 → throughline-3.2.0}/src/throughline/graph.py +0 -0
  22. {throughline-3.0.0 → throughline-3.2.0}/src/throughline/inject.py +0 -0
  23. {throughline-3.0.0 → throughline-3.2.0}/src/throughline/model.py +0 -0
  24. {throughline-3.0.0 → throughline-3.2.0}/src/throughline/schema.py +0 -0
  25. {throughline-3.0.0 → throughline-3.2.0}/src/throughline/schema_ops.py +0 -0
  26. {throughline-3.0.0 → throughline-3.2.0}/src/throughline/storage.py +0 -0
  27. {throughline-3.0.0 → throughline-3.2.0}/src/throughline/tomledit.py +0 -0
  28. {throughline-3.0.0 → throughline-3.2.0}/src/throughline/uid.py +0 -0
  29. {throughline-3.0.0 → throughline-3.2.0}/src/throughline/version.py +0 -0
  30. {throughline-3.0.0 → throughline-3.2.0}/src/throughline.egg-info/dependency_links.txt +0 -0
  31. {throughline-3.0.0 → throughline-3.2.0}/src/throughline.egg-info/entry_points.txt +0 -0
  32. {throughline-3.0.0 → throughline-3.2.0}/src/throughline.egg-info/requires.txt +0 -0
  33. {throughline-3.0.0 → throughline-3.2.0}/src/throughline.egg-info/top_level.txt +0 -0
  34. {throughline-3.0.0 → throughline-3.2.0}/tests/test_amend.py +0 -0
  35. {throughline-3.0.0 → throughline-3.2.0}/tests/test_doctor.py +0 -0
  36. {throughline-3.0.0 → throughline-3.2.0}/tests/test_engine.py +0 -0
  37. {throughline-3.0.0 → throughline-3.2.0}/tests/test_filters.py +0 -0
  38. {throughline-3.0.0 → throughline-3.2.0}/tests/test_ratify_status.py +0 -0
  39. {throughline-3.0.0 → throughline-3.2.0}/tests/test_schema_ops.py +0 -0
  40. {throughline-3.0.0 → throughline-3.2.0}/tests/test_yaml_loader.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: throughline
3
- Version: 3.0.0
3
+ Version: 3.2.0
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
@@ -36,10 +36,10 @@ version control; a `check` command validates the whole graph and gates CI.
36
36
 
37
37
  **Dogfooded:** throughline's own spec is itself a throughline project —
38
38
  <!-- tl:count type == 'system_requirement' -->
39
- 161
39
+ 164
40
40
  <!-- tl:end --> system requirements,
41
41
  <!-- tl:count type == 'user_requirement' -->
42
- 26
42
+ 28
43
43
  <!-- tl:end --> user requirements, and
44
44
  <!-- tl:count type == 'nfr' -->
45
45
  21
@@ -178,6 +178,7 @@ tl docs [FILE ...] --check # CI gate: fail if any document i
178
178
  tl context # agent-facing brief (IDD + this project's model)
179
179
  tl ratify <UID> --by <who> # a human takes accountability
180
180
  tl invalidate <UID> --reason "…" # falsify; cascade suspect
181
+ tl withdraw <UID> [<UID> …] --reason "…" # a signature no longer stands; awaits a human again
181
182
  ```
182
183
 
183
184
  > `tl` and `throughline` are the same command — `tl` is the short alias, and
@@ -251,6 +252,10 @@ demo and the self-host graph are gated in CI and by the pre-commit hook.
251
252
  items: the two states that must never be signed off.
252
253
  - **invalidate** — falsify an assumption (or any node): it is rejected and every
253
254
  transitive dependent is marked `suspect` (its blast radius).
255
+ - **withdraw** — set aside a signature that should no longer stand (wrong name,
256
+ signed off in error), one item or a batch. Content and dependents are untouched;
257
+ the item returns to awaiting a human, and who withdrew it, why, and whose
258
+ signature it was stay on record.
254
259
 
255
260
  ---
256
261
 
@@ -8,10 +8,10 @@ version control; a `check` command validates the whole graph and gates CI.
8
8
 
9
9
  **Dogfooded:** throughline's own spec is itself a throughline project —
10
10
  <!-- tl:count type == 'system_requirement' -->
11
- 161
11
+ 164
12
12
  <!-- tl:end --> system requirements,
13
13
  <!-- tl:count type == 'user_requirement' -->
14
- 26
14
+ 28
15
15
  <!-- tl:end --> user requirements, and
16
16
  <!-- tl:count type == 'nfr' -->
17
17
  21
@@ -150,6 +150,7 @@ tl docs [FILE ...] --check # CI gate: fail if any document i
150
150
  tl context # agent-facing brief (IDD + this project's model)
151
151
  tl ratify <UID> --by <who> # a human takes accountability
152
152
  tl invalidate <UID> --reason "…" # falsify; cascade suspect
153
+ tl withdraw <UID> [<UID> …] --reason "…" # a signature no longer stands; awaits a human again
153
154
  ```
154
155
 
155
156
  > `tl` and `throughline` are the same command — `tl` is the short alias, and
@@ -223,6 +224,10 @@ demo and the self-host graph are gated in CI and by the pre-commit hook.
223
224
  items: the two states that must never be signed off.
224
225
  - **invalidate** — falsify an assumption (or any node): it is rejected and every
225
226
  transitive dependent is marked `suspect` (its blast radius).
227
+ - **withdraw** — set aside a signature that should no longer stand (wrong name,
228
+ signed off in error), one item or a batch. Content and dependents are untouched;
229
+ the item returns to awaiting a human, and who withdrew it, why, and whose
230
+ signature it was stay on record.
226
231
 
227
232
  ---
228
233
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "throughline"
7
- version = "3.0.0"
7
+ version = "3.2.0"
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"
@@ -10,6 +10,8 @@ from __future__ import annotations
10
10
 
11
11
  import argparse
12
12
  import json
13
+ import os
14
+ import shutil
13
15
  import sys
14
16
  import time
15
17
  from pathlib import Path
@@ -24,11 +26,13 @@ from .grounding import (
24
26
  ratify,
25
27
  reaches_root,
26
28
  set_status,
29
+ withdraw,
27
30
  )
28
31
  from .identity import (
29
32
  RATIFICATION_ATTRS,
30
33
  RATIFIED_BY_ATTR,
31
34
  RATIFIED_ID_ATTR,
35
+ WITHDRAWN_RATIFIER_ATTR,
32
36
  IdentityError,
33
37
  default_ratifier,
34
38
  )
@@ -1355,6 +1359,7 @@ _CTX_COMMAND_USAGE = {
1355
1359
  "docs": "tl docs [FILE ...] [--at REF] [--check]",
1356
1360
  "status": "tl status <UID> <STATUS>",
1357
1361
  "invalidate": "tl invalidate <UID> [--reason …]",
1362
+ "withdraw": "tl withdraw <UID> [<UID> …] --reason <why> --by <who>",
1358
1363
  "delete": "tl delete <UID>",
1359
1364
  "query": "tl query [--type T] [--status S] [--format json]",
1360
1365
  "register": "tl register new <PREFIX> <FOLDER> --title <…>",
@@ -1372,6 +1377,9 @@ _CTX_COMMAND_EMPHASIS = {
1372
1377
  "ratify": "a human accepts a proposed item; never run this for a human",
1373
1378
  "migrate": "idempotent repairs; extend this, never a script beside it",
1374
1379
  "invalidate": "retires an item and cascades suspicion — see grounding, below",
1380
+ "withdraw": "removes a signature that should not stand (wrong name, signed in "
1381
+ "error) without touching content or dependents; the item awaits a "
1382
+ "human again — not `invalidate`, which says the item is false",
1375
1383
  "delete": "tombstones an item; the file stays, the item stops counting",
1376
1384
  "amend": "change content through the tool, never by opening the YAML",
1377
1385
  "schema": "change the schema itself — nouns: status, transition, type, attr, "
@@ -1794,7 +1802,8 @@ def cmd_ratify(args) -> int:
1794
1802
  # built once and handed to ratify, so the question asked here and the write
1795
1803
  # below read the same graph.
1796
1804
  idx = Index.build(project)
1797
- obstacle = ratification_obstacle(project.schema, idx, item)
1805
+ obstacle = ratification_obstacle(project.schema, idx, item,
1806
+ replacing=args.replacing)
1798
1807
  if obstacle is not None:
1799
1808
  return _err(obstacle)
1800
1809
  # Ratifying is taking accountability, so the content comes before the signature
@@ -1823,8 +1832,12 @@ def cmd_ratify(args) -> int:
1823
1832
  # failure this guards, arriving by the back door.
1824
1833
  if change.stale:
1825
1834
  if interactive:
1826
- for line in render_change(change,
1827
- ratifier=item.attrs.get(RATIFIED_BY_ATTR)):
1835
+ # Painted as git would paint it: colour on a terminal, none when the
1836
+ # reader has asked for none (NO_COLOR, https://no-color.org).
1837
+ for line in render_change(
1838
+ change, ratifier=item.attrs.get(RATIFIED_BY_ATTR),
1839
+ columns=shutil.get_terminal_size((80, 24)).columns,
1840
+ colour=not os.environ.get("NO_COLOR")):
1828
1841
  print(line, file=sys.stderr)
1829
1842
  print("", file=sys.stderr)
1830
1843
  elif not args.accept_change:
@@ -1839,12 +1852,15 @@ def cmd_ratify(args) -> int:
1839
1852
  # scrolled past. SR-0120 permits it: confirming an act is not prompting for a
1840
1853
  # value. Declining writes nothing and is not an error; the user was asked and
1841
1854
  # answered.
1842
- if interactive and not _confirm(f"ratify {uid} as {by}?"):
1855
+ question = (f"replace the ratifier recorded on {uid} with {by}?"
1856
+ if args.replacing else f"ratify {uid} as {by}?")
1857
+ if interactive and not _confirm(question):
1843
1858
  print("not ratified", file=sys.stderr)
1844
1859
  return OK
1845
1860
  try:
1846
1861
  item = ratify(project, uid, by=by, index=idx,
1847
- by_id=getattr(args, "by_id", None))
1862
+ by_id=getattr(args, "by_id", None),
1863
+ replacing=args.replacing)
1848
1864
  except IdentityError as e:
1849
1865
  return _err(str(e))
1850
1866
  except (ProjectError, GroundingError, SchemaError) as e:
@@ -1891,6 +1907,52 @@ def cmd_invalidate(args) -> int:
1891
1907
  return OK
1892
1908
 
1893
1909
 
1910
+ def cmd_withdraw(args) -> int:
1911
+ """Withdraw the ratification of one or more items (SR-0197). The signature no
1912
+ longer stands; the item goes back to awaiting a human; who withdrew it, why,
1913
+ and whose signature it was are recorded. Content and dependents are untouched —
1914
+ this is not ``invalidate``."""
1915
+ try:
1916
+ project = load_project(args.path)
1917
+ except ProjectError as e:
1918
+ return _err(str(e))
1919
+ uids = list(args.uids)
1920
+ if not uids:
1921
+ # One item through the picker on a terminal; a batch is named explicitly.
1922
+ uid = _resolve_uid(project, None, "withdraw the ratification of", "UID")
1923
+ if uid is None:
1924
+ return USAGE
1925
+ uids = [uid]
1926
+ # The reason is required, not optional: removing a signature is the quieter
1927
+ # attack on an accountability record, and a withdrawal that says why is what
1928
+ # makes it as accountable as the signature it removes. Asked once for the run.
1929
+ reason = _resolve_value(args.reason, "reason", "--reason")
1930
+ if reason is None:
1931
+ return USAGE
1932
+ by = _resolve_value(args.by, "withdrawer", "--by",
1933
+ default=default_ratifier(args.path))
1934
+ if by is None:
1935
+ return USAGE
1936
+ # Confirmed on a terminal for the same reason ratify is (SR-0195): the act is
1937
+ # about people, and a fully specified command is how a batch is run.
1938
+ if _interactive() and not _confirm(
1939
+ f"withdraw the ratification of {', '.join(uids)} as {by}?"):
1940
+ print("not withdrawn", file=sys.stderr)
1941
+ return OK
1942
+ try:
1943
+ items = withdraw(project, uids, by=by, reason=reason,
1944
+ by_id=getattr(args, "by_id", None))
1945
+ except IdentityError as e:
1946
+ return _err(str(e))
1947
+ except (ProjectError, GroundingError, SchemaError) as e:
1948
+ return _err(str(e))
1949
+ for item in items:
1950
+ write_item(item, project.register_of(item.uid))
1951
+ print(f"{item.uid}: ratification by {item.attrs[WITHDRAWN_RATIFIER_ATTR]} "
1952
+ f"withdrawn by {by}; now '{item.status}', awaiting ratification")
1953
+ return OK
1954
+
1955
+
1894
1956
  def cmd_status(args) -> int:
1895
1957
  """The generic, transition-validated status verb (SR-0132). Every status
1896
1958
  move a project's [transitions] table permits is reachable through this
@@ -2281,6 +2343,12 @@ def build_parser() -> argparse.ArgumentParser:
2281
2343
  s.add_argument("--accept-change", action="store_true",
2282
2344
  help="accept a change made since the last ratification without "
2283
2345
  "seeing it (non-interactive sessions only)")
2346
+ # Correcting the identity on a record that was never published (SR-0196). Its
2347
+ # own flag, because the act it permits — a signature over content that has not
2348
+ # moved — is the one SR-0148 otherwise refuses outright.
2349
+ s.add_argument("--replacing", action="store_true",
2350
+ help="replace the ratifier recorded on an uncommitted "
2351
+ "ratification, e.g. to correct a misspelled name")
2284
2352
  s.set_defaults(func=cmd_ratify)
2285
2353
 
2286
2354
  s = sub.add_parser("invalidate", help="falsify an item; cascade suspect")
@@ -2289,6 +2357,26 @@ def build_parser() -> argparse.ArgumentParser:
2289
2357
  s.add_argument("--reason", default="")
2290
2358
  s.set_defaults(func=cmd_invalidate)
2291
2359
 
2360
+ # Withdrawing a signature is its own verb, not a mode of ratify (SR-0197): it
2361
+ # names no new accountable party and is the one act here anyone may perform,
2362
+ # so it must not share a verb with the act that requires a named human.
2363
+ s = sub.add_parser("withdraw",
2364
+ help="withdraw an item's ratification — the signature no "
2365
+ "longer stands; the item awaits a human again")
2366
+ s.add_argument("uids", nargs="*", metavar="UID",
2367
+ help="item(s) whose ratification to withdraw (omit on a "
2368
+ "terminal to pick one)")
2369
+ s.add_argument("--reason", default=None,
2370
+ help="why the signature should no longer stand (required; "
2371
+ "prompted for on a terminal)")
2372
+ s.add_argument("--by", default=None,
2373
+ help="who is withdrawing it (omit on a terminal to be prompted; "
2374
+ "defaults to the identity this repository signs with)")
2375
+ s.add_argument("--by-id", default=None, metavar="SCHEME:VALUE",
2376
+ help="optional stable identifier for the withdrawer, e.g. "
2377
+ "github:octocat or email:ada@example.com")
2378
+ s.set_defaults(func=cmd_withdraw)
2379
+
2292
2380
  s = sub.add_parser("status",
2293
2381
  help="move an item to a status (transition-validated)")
2294
2382
  s.add_argument("uid", nargs="?", default=None,
@@ -18,8 +18,13 @@ from typing import NamedTuple
18
18
  from .fingerprint import fingerprint
19
19
  from .graph import Index
20
20
  from .identity import (
21
+ RATIFICATION_ATTRS,
21
22
  RATIFIED_BY_ATTR,
22
23
  RATIFIED_ID_ATTR,
24
+ WITHDRAWN_BY_ATTR,
25
+ WITHDRAWN_ID_ATTR,
26
+ WITHDRAWN_RATIFIER_ATTR,
27
+ WITHDRAWN_REASON_ATTR,
23
28
  normalise_identifier,
24
29
  )
25
30
  from .model import Item, Link
@@ -67,7 +72,8 @@ def ratification_refusal(schema, idx: Index, item: Item) -> str | None:
67
72
  return None
68
73
 
69
74
 
70
- def ratification_obstacle(schema, idx: Index, item: Item) -> str | None:
75
+ def ratification_obstacle(schema, idx: Index, item: Item, *,
76
+ replacing: bool = False) -> str | None:
71
77
  """Why :func:`ratify` would refuse ``item`` as the graph now stands, or ``None``
72
78
  when it would proceed — the whole precondition set, asked without writing
73
79
  anything (SR-0195).
@@ -88,14 +94,20 @@ def ratification_obstacle(schema, idx: Index, item: Item) -> str | None:
88
94
  if schema.ratify_moves_status
89
95
  else item.attrs.get(RATIFIED_BY_ATTR) is not None)
90
96
  if already and item.attrs.get("ratified_fingerprint") == fingerprint(item, schema):
91
- return (f"{item.uid} is already ratified by "
92
- f"{item.attrs.get('ratified_by', 'a human')} and its content has "
93
- "not changed since — there is nothing to accept")
97
+ # A correction is the one signature over unchanged content that accepts
98
+ # something — the identity on the record (SR-0196). Whether it is allowed
99
+ # rests on the record being unpublished, which only :func:`ratify` can
100
+ # establish, so the refusal is lifted here and reimposed there.
101
+ if not replacing:
102
+ return (f"{item.uid} is already ratified by "
103
+ f"{item.attrs.get('ratified_by', 'a human')} and its content has "
104
+ "not changed since — there is nothing to accept; pass "
105
+ "--replacing to correct the recorded ratifier instead")
94
106
  return None
95
107
 
96
108
 
97
109
  def ratify(project, uid: str, by: str, *, index: Index | None = None,
98
- by_id: str | None = None) -> Item:
110
+ by_id: str | None = None, replacing: bool = False) -> Item:
99
111
  """A human takes accountability. Refused for ambiguous or ungrounded items —
100
112
  the two states that must not be signed off (scope-avalanche briefing §5).
101
113
 
@@ -118,9 +130,31 @@ def ratify(project, uid: str, by: str, *, index: Index | None = None,
118
130
  # who accepted it leaving no trace that it changed (SR-0148). An item ratified
119
131
  # before the stamp existed has none to compare against, so that first call is
120
132
  # allowed through and stamps it.
121
- obstacle = ratification_obstacle(schema, idx, item)
133
+ obstacle = ratification_obstacle(schema, idx, item, replacing=replacing)
122
134
  if obstacle is not None:
123
135
  raise GroundingError(obstacle)
136
+ # A correction replaces the identity on a record that was never published
137
+ # (SR-0196). The test is made here rather than left to the caller, for the
138
+ # reason every other decision in this function is: a front end that could
139
+ # assert its way past it would be able to obtain a record this function would
140
+ # refuse to write. Unestablished is refused, not assumed — a correction is
141
+ # permitted on evidence the record was never shared, and absence of evidence
142
+ # is not that evidence.
143
+ superseded = None
144
+ if replacing:
145
+ from .ratification import SUPERSEDED_ATTR, ratification_is_committed
146
+ published = ratification_is_committed(project, item)
147
+ if published is None:
148
+ raise GroundingError(
149
+ f"{uid}: cannot establish whether this ratification has been "
150
+ "committed, so it may not be replaced — a correction is only "
151
+ "allowed for a record that was never published")
152
+ if published:
153
+ raise GroundingError(
154
+ f"{uid} was ratified by {item.attrs.get(RATIFIED_BY_ATTR)} in a "
155
+ "commit, so that record may not be replaced: a published "
156
+ "signature is not something one caller may take from another")
157
+ superseded = item.attrs.get(RATIFIED_BY_ATTR)
124
158
  current = fingerprint(item, schema)
125
159
  # Advancing is the default, and is transition-validated — an item that cannot
126
160
  # legally reach the ratified status is refused rather than moved illegally. A
@@ -137,6 +171,11 @@ def ratify(project, uid: str, by: str, *, index: Index | None = None,
137
171
  if identifier is not None:
138
172
  item.attrs[RATIFIED_ID_ATTR] = identifier
139
173
  item.attrs["ratified_fingerprint"] = current
174
+ # Kept so a correction never reads as the original record (SR-0148's condition
175
+ # that an accountability record never changes without the graph showing it).
176
+ if superseded is not None and superseded != by:
177
+ from .ratification import SUPERSEDED_ATTR
178
+ item.attrs[SUPERSEDED_ATTR] = superseded
140
179
  return item
141
180
 
142
181
 
@@ -208,6 +247,75 @@ def invalidate(project, uid: str, reason: str = "") -> Invalidation:
208
247
  return Invalidation(affected, marked, refused)
209
248
 
210
249
 
250
+ def withdraw(project, uids, *, by: str, reason: str,
251
+ by_id: str | None = None) -> list[Item]:
252
+ """Withdraw the ratification of each item in ``uids`` (SR-0197): the signature
253
+ no longer stands, the item returns to those awaiting a human, and the record
254
+ says who withdrew it, why, and whose signature it was.
255
+
256
+ Not invalidation, and deliberately its opposite in effect: ``tl invalidate``
257
+ says an item is false and cascades suspicion into everything grounded on it
258
+ (SR-0035); this says nothing about the item. Its words have not moved, so
259
+ nothing resting on them has lost its footing — content is untouched and no
260
+ dependent is restatused. The item moves to the status bound to the suspect
261
+ role (SR-0131), which every live status may reach (SR-0175) and which already
262
+ means what this needs: a human must look again.
263
+
264
+ Anyone may withdraw, because withdrawal takes nothing — the item cannot count
265
+ as ratified again until a real human accepts it — but the act is recorded, not
266
+ silent: stripping a colleague's sign-off and leaving an item that merely looks
267
+ unratified would be the quieter attack on an accountability record.
268
+
269
+ All or nothing: every item is checked before any is written, so a mistyped UID
270
+ in a batch is a failure the caller sees, never a half-applied run. Refused for
271
+ an item that carries no ratification, for an empty reason, and for a status the
272
+ project's lifecycle will not let move to suspect.
273
+ """
274
+ if not by or not by.strip():
275
+ raise GroundingError("a withdrawal must name who is withdrawing it")
276
+ if not reason or not reason.strip():
277
+ raise GroundingError("a withdrawal must state its reason — pass --reason")
278
+ identifier = normalise_identifier(by_id) # IdentityError if malformed
279
+ schema = project.schema
280
+ suspect = schema.status_role("suspect")
281
+ items: list[Item] = []
282
+ seen: set[str] = set()
283
+ for uid in uids:
284
+ if uid in seen:
285
+ raise GroundingError(f"{uid} is named more than once")
286
+ seen.add(uid)
287
+ item = project.get(uid)
288
+ if item is None:
289
+ raise GroundingError(f"{uid} does not exist")
290
+ if not item.attrs.get(RATIFIED_BY_ATTR):
291
+ raise GroundingError(f"{uid} carries no ratification to withdraw")
292
+ if not schema.allows_transition(item.status, suspect):
293
+ raise GroundingError(
294
+ f"{uid}: status change '{item.status}' -> '{suspect}' is not an "
295
+ "allowed transition, so its ratification cannot be withdrawn")
296
+ items.append(item)
297
+ # The record the ratifying verbs own is cleared, and only that record: the
298
+ # withdrawn identity moves into the withdrawal record rather than vanishing,
299
+ # so the graph still shows that a signature was taken and then set aside.
300
+ cleared = [name for name, owner in RATIFICATION_ATTRS.items()
301
+ if owner != "withdraw"]
302
+ for item in items:
303
+ ratifier = item.attrs[RATIFIED_BY_ATTR]
304
+ for name in cleared:
305
+ item.attrs.pop(name, None)
306
+ item.attrs[WITHDRAWN_RATIFIER_ATTR] = ratifier
307
+ item.attrs[WITHDRAWN_BY_ATTR] = by
308
+ if identifier is not None:
309
+ item.attrs[WITHDRAWN_ID_ATTR] = identifier
310
+ else:
311
+ item.attrs.pop(WITHDRAWN_ID_ATTR, None)
312
+ item.attrs[WITHDRAWN_REASON_ATTR] = reason
313
+ set_status(schema, item, suspect)
314
+ item.attrs.setdefault("suspect_reasons", []).append(
315
+ f"ratification by {ratifier} withdrawn by {by}: {reason}")
316
+ return items
317
+
318
+
211
319
  def scout_ingest(project, report: dict) -> dict:
212
320
  """Fold a scout report in. Scout PROPOSES; humans RATIFY.
213
321
 
@@ -29,6 +29,16 @@ from pathlib import Path
29
29
  RATIFIED_BY_ATTR = "ratified_by"
30
30
  RATIFIED_ID_ATTR = "ratified_id"
31
31
 
32
+ # The withdrawal record (SR-0197): a signature that should no longer stand is
33
+ # removed, and what replaces it says who removed it, why, and whose signature it
34
+ # was. Held once — a later withdrawal overwrites an earlier one, as SR-0196 keeps
35
+ # only the immediately preceding identity; the graph is plain text under version
36
+ # control, so the succession is recoverable from history.
37
+ WITHDRAWN_BY_ATTR = "withdrawn_by"
38
+ WITHDRAWN_ID_ATTR = "withdrawn_id"
39
+ WITHDRAWN_REASON_ATTR = "withdrawn_reason"
40
+ WITHDRAWN_RATIFIER_ATTR = "withdrawn_ratifier"
41
+
32
42
  # The ratification record, and the one command entitled to write each part of it
33
43
  # (SR-0170). Any operation that sets attributes generally reaches these too unless
34
44
  # it is stopped, which would make correcting a title and signing a name nobody gave
@@ -49,6 +59,19 @@ RATIFICATION_ATTRS = {
49
59
  # a false difference — but a hand-written one would still be a claim about an
50
60
  # accountability record made by something other than the verb that owns it.
51
61
  "ratified_revision": "migrate",
62
+ # The identity a correction replaced (SR-0196). Ratify writes it, because a
63
+ # correction is itself a ratification; it is guarded like the rest of the
64
+ # record because a hand-written one would claim that a signature succeeded
65
+ # another when none did.
66
+ "ratified_supersedes": "ratify",
67
+ # Withdrawing a signature is as much a claim about people as giving one — who
68
+ # withdrew it, whose it was — so the record is guarded like the rest (SR-0197).
69
+ # Withdraw is also the verb that clears the fields above; a hand edit that
70
+ # removed a signature would leave nothing saying one was ever taken.
71
+ WITHDRAWN_BY_ATTR: "withdraw",
72
+ WITHDRAWN_ID_ATTR: "withdraw",
73
+ WITHDRAWN_REASON_ATTR: "withdraw",
74
+ WITHDRAWN_RATIFIER_ATTR: "withdraw",
52
75
  }
53
76
 
54
77
  # A scheme-qualified identifier: 'github:octocat', 'email:ada@example.com'. The