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.
- {throughline-3.0.0/src/throughline.egg-info → throughline-3.2.0}/PKG-INFO +8 -3
- {throughline-3.0.0 → throughline-3.2.0}/README.md +7 -2
- {throughline-3.0.0 → throughline-3.2.0}/pyproject.toml +1 -1
- {throughline-3.0.0 → throughline-3.2.0}/src/throughline/cli.py +93 -5
- {throughline-3.0.0 → throughline-3.2.0}/src/throughline/grounding.py +114 -6
- {throughline-3.0.0 → throughline-3.2.0}/src/throughline/identity.py +23 -0
- {throughline-3.0.0 → throughline-3.2.0}/src/throughline/ratification.py +208 -11
- {throughline-3.0.0 → throughline-3.2.0}/src/throughline/validate.py +11 -1
- {throughline-3.0.0 → throughline-3.2.0/src/throughline.egg-info}/PKG-INFO +8 -3
- {throughline-3.0.0 → throughline-3.2.0}/src/throughline.egg-info/SOURCES.txt +2 -0
- {throughline-3.0.0 → throughline-3.2.0}/tests/test_ratification_change.py +121 -0
- throughline-3.2.0/tests/test_ratification_withdrawal.py +266 -0
- throughline-3.2.0/tests/test_ratifier_correction.py +212 -0
- {throughline-3.0.0 → throughline-3.2.0}/LICENSE +0 -0
- {throughline-3.0.0 → throughline-3.2.0}/NOTICE +0 -0
- {throughline-3.0.0 → throughline-3.2.0}/setup.cfg +0 -0
- {throughline-3.0.0 → throughline-3.2.0}/src/throughline/__init__.py +0 -0
- {throughline-3.0.0 → throughline-3.2.0}/src/throughline/dump.py +0 -0
- {throughline-3.0.0 → throughline-3.2.0}/src/throughline/filters.py +0 -0
- {throughline-3.0.0 → throughline-3.2.0}/src/throughline/fingerprint.py +0 -0
- {throughline-3.0.0 → throughline-3.2.0}/src/throughline/graph.py +0 -0
- {throughline-3.0.0 → throughline-3.2.0}/src/throughline/inject.py +0 -0
- {throughline-3.0.0 → throughline-3.2.0}/src/throughline/model.py +0 -0
- {throughline-3.0.0 → throughline-3.2.0}/src/throughline/schema.py +0 -0
- {throughline-3.0.0 → throughline-3.2.0}/src/throughline/schema_ops.py +0 -0
- {throughline-3.0.0 → throughline-3.2.0}/src/throughline/storage.py +0 -0
- {throughline-3.0.0 → throughline-3.2.0}/src/throughline/tomledit.py +0 -0
- {throughline-3.0.0 → throughline-3.2.0}/src/throughline/uid.py +0 -0
- {throughline-3.0.0 → throughline-3.2.0}/src/throughline/version.py +0 -0
- {throughline-3.0.0 → throughline-3.2.0}/src/throughline.egg-info/dependency_links.txt +0 -0
- {throughline-3.0.0 → throughline-3.2.0}/src/throughline.egg-info/entry_points.txt +0 -0
- {throughline-3.0.0 → throughline-3.2.0}/src/throughline.egg-info/requires.txt +0 -0
- {throughline-3.0.0 → throughline-3.2.0}/src/throughline.egg-info/top_level.txt +0 -0
- {throughline-3.0.0 → throughline-3.2.0}/tests/test_amend.py +0 -0
- {throughline-3.0.0 → throughline-3.2.0}/tests/test_doctor.py +0 -0
- {throughline-3.0.0 → throughline-3.2.0}/tests/test_engine.py +0 -0
- {throughline-3.0.0 → throughline-3.2.0}/tests/test_filters.py +0 -0
- {throughline-3.0.0 → throughline-3.2.0}/tests/test_ratify_status.py +0 -0
- {throughline-3.0.0 → throughline-3.2.0}/tests/test_schema_ops.py +0 -0
- {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.
|
|
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
|
-
|
|
39
|
+
164
|
|
40
40
|
<!-- tl:end --> system requirements,
|
|
41
41
|
<!-- tl:count type == 'user_requirement' -->
|
|
42
|
-
|
|
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
|
-
|
|
11
|
+
164
|
|
12
12
|
<!-- tl:end --> system requirements,
|
|
13
13
|
<!-- tl:count type == 'user_requirement' -->
|
|
14
|
-
|
|
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.
|
|
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
|
-
|
|
1827
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|