throughline 4.2.0__tar.gz → 4.4.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 (84) hide show
  1. {throughline-4.2.0/src/throughline.egg-info → throughline-4.4.0}/PKG-INFO +17 -4
  2. {throughline-4.2.0 → throughline-4.4.0}/README.md +16 -3
  3. {throughline-4.2.0 → throughline-4.4.0}/pyproject.toml +1 -1
  4. {throughline-4.2.0 → throughline-4.4.0}/src/throughline/apply.py +136 -11
  5. {throughline-4.2.0 → throughline-4.4.0}/src/throughline/brief.py +60 -15
  6. {throughline-4.2.0 → throughline-4.4.0}/src/throughline/cli.py +42 -2
  7. {throughline-4.2.0 → throughline-4.4.0}/src/throughline/composition.py +1 -1
  8. {throughline-4.2.0 → throughline-4.4.0}/src/throughline/dump.py +13 -1
  9. throughline-4.4.0/src/throughline/inherit.py +242 -0
  10. {throughline-4.2.0 → throughline-4.4.0}/src/throughline/items.py +37 -1
  11. {throughline-4.2.0 → throughline-4.4.0}/src/throughline/model.py +36 -2
  12. {throughline-4.2.0 → throughline-4.4.0}/src/throughline/parser.py +11 -0
  13. {throughline-4.2.0 → throughline-4.4.0}/src/throughline/schema.py +35 -0
  14. {throughline-4.2.0 → throughline-4.4.0}/src/throughline/schema_ops.py +79 -9
  15. {throughline-4.2.0 → throughline-4.4.0}/src/throughline/sources.py +36 -2
  16. {throughline-4.2.0 → throughline-4.4.0}/src/throughline/storage.py +204 -10
  17. {throughline-4.2.0 → throughline-4.4.0}/src/throughline/tomledit.py +7 -0
  18. {throughline-4.2.0 → throughline-4.4.0}/src/throughline/union.py +2 -2
  19. {throughline-4.2.0 → throughline-4.4.0}/src/throughline/validate.py +24 -3
  20. {throughline-4.2.0 → throughline-4.4.0/src/throughline.egg-info}/PKG-INFO +17 -4
  21. {throughline-4.2.0 → throughline-4.4.0}/src/throughline.egg-info/SOURCES.txt +4 -0
  22. throughline-4.4.0/tests/test_apply_once.py +197 -0
  23. throughline-4.4.0/tests/test_attribute_kinds.py +114 -0
  24. throughline-4.4.0/tests/test_schema_source.py +359 -0
  25. {throughline-4.2.0 → throughline-4.4.0}/LICENSE +0 -0
  26. {throughline-4.2.0 → throughline-4.4.0}/NOTICE +0 -0
  27. {throughline-4.2.0 → throughline-4.4.0}/setup.cfg +0 -0
  28. {throughline-4.2.0 → throughline-4.4.0}/src/throughline/__init__.py +0 -0
  29. {throughline-4.2.0 → throughline-4.4.0}/src/throughline/collision.py +0 -0
  30. {throughline-4.2.0 → throughline-4.4.0}/src/throughline/diagrams.py +0 -0
  31. {throughline-4.2.0 → throughline-4.4.0}/src/throughline/filters.py +0 -0
  32. {throughline-4.2.0 → throughline-4.4.0}/src/throughline/fingerprint.py +0 -0
  33. {throughline-4.2.0 → throughline-4.4.0}/src/throughline/graph.py +0 -0
  34. {throughline-4.2.0 → throughline-4.4.0}/src/throughline/grounding.py +0 -0
  35. {throughline-4.2.0 → throughline-4.4.0}/src/throughline/identity.py +0 -0
  36. {throughline-4.2.0 → throughline-4.4.0}/src/throughline/inject.py +0 -0
  37. {throughline-4.2.0 → throughline-4.4.0}/src/throughline/links.py +0 -0
  38. {throughline-4.2.0 → throughline-4.4.0}/src/throughline/ratification.py +0 -0
  39. {throughline-4.2.0 → throughline-4.4.0}/src/throughline/resolvers.py +0 -0
  40. {throughline-4.2.0 → throughline-4.4.0}/src/throughline/seam.py +0 -0
  41. {throughline-4.2.0 → throughline-4.4.0}/src/throughline/signatures.py +0 -0
  42. {throughline-4.2.0 → throughline-4.4.0}/src/throughline/signed_links.py +0 -0
  43. {throughline-4.2.0 → throughline-4.4.0}/src/throughline/uid.py +0 -0
  44. {throughline-4.2.0 → throughline-4.4.0}/src/throughline/version.py +0 -0
  45. {throughline-4.2.0 → throughline-4.4.0}/src/throughline/views.py +0 -0
  46. {throughline-4.2.0 → throughline-4.4.0}/src/throughline/worklist.py +0 -0
  47. {throughline-4.2.0 → throughline-4.4.0}/src/throughline.egg-info/dependency_links.txt +0 -0
  48. {throughline-4.2.0 → throughline-4.4.0}/src/throughline.egg-info/entry_points.txt +0 -0
  49. {throughline-4.2.0 → throughline-4.4.0}/src/throughline.egg-info/requires.txt +0 -0
  50. {throughline-4.2.0 → throughline-4.4.0}/src/throughline.egg-info/top_level.txt +0 -0
  51. {throughline-4.2.0 → throughline-4.4.0}/tests/test_amend.py +0 -0
  52. {throughline-4.2.0 → throughline-4.4.0}/tests/test_apply.py +0 -0
  53. {throughline-4.2.0 → throughline-4.4.0}/tests/test_baseline.py +0 -0
  54. {throughline-4.2.0 → throughline-4.4.0}/tests/test_clarify.py +0 -0
  55. {throughline-4.2.0 → throughline-4.4.0}/tests/test_deprecated_names.py +0 -0
  56. {throughline-4.2.0 → throughline-4.4.0}/tests/test_diagram_cli.py +0 -0
  57. {throughline-4.2.0 → throughline-4.4.0}/tests/test_doctor.py +0 -0
  58. {throughline-4.2.0 → throughline-4.4.0}/tests/test_engine.py +0 -0
  59. {throughline-4.2.0 → throughline-4.4.0}/tests/test_filters.py +0 -0
  60. {throughline-4.2.0 → throughline-4.4.0}/tests/test_flag.py +0 -0
  61. {throughline-4.2.0 → throughline-4.4.0}/tests/test_library_api.py +0 -0
  62. {throughline-4.2.0 → throughline-4.4.0}/tests/test_link_guard.py +0 -0
  63. {throughline-4.2.0 → throughline-4.4.0}/tests/test_migrate_withdrawn.py +0 -0
  64. {throughline-4.2.0 → throughline-4.4.0}/tests/test_normative_by_type.py +0 -0
  65. {throughline-4.2.0 → throughline-4.4.0}/tests/test_own_tree.py +0 -0
  66. {throughline-4.2.0 → throughline-4.4.0}/tests/test_ratification_change.py +0 -0
  67. {throughline-4.2.0 → throughline-4.4.0}/tests/test_ratification_withdrawal.py +0 -0
  68. {throughline-4.2.0 → throughline-4.4.0}/tests/test_ratified_content.py +0 -0
  69. {throughline-4.2.0 → throughline-4.4.0}/tests/test_ratified_deletion.py +0 -0
  70. {throughline-4.2.0 → throughline-4.4.0}/tests/test_ratifier_correction.py +0 -0
  71. {throughline-4.2.0 → throughline-4.4.0}/tests/test_ratify_several.py +0 -0
  72. {throughline-4.2.0 → throughline-4.4.0}/tests/test_ratify_status.py +0 -0
  73. {throughline-4.2.0 → throughline-4.4.0}/tests/test_ratify_transition_refused_first.py +0 -0
  74. {throughline-4.2.0 → throughline-4.4.0}/tests/test_register_type.py +0 -0
  75. {throughline-4.2.0 → throughline-4.4.0}/tests/test_resolve_collision.py +0 -0
  76. {throughline-4.2.0 → throughline-4.4.0}/tests/test_resolve_cross_graph.py +0 -0
  77. {throughline-4.2.0 → throughline-4.4.0}/tests/test_schema_ops.py +0 -0
  78. {throughline-4.2.0 → throughline-4.4.0}/tests/test_signatures.py +0 -0
  79. {throughline-4.2.0 → throughline-4.4.0}/tests/test_signed_links.py +0 -0
  80. {throughline-4.2.0 → throughline-4.4.0}/tests/test_spec_lists.py +0 -0
  81. {throughline-4.2.0 → throughline-4.4.0}/tests/test_tombstone_record.py +0 -0
  82. {throughline-4.2.0 → throughline-4.4.0}/tests/test_unset_attrs.py +0 -0
  83. {throughline-4.2.0 → throughline-4.4.0}/tests/test_worklist.py +0 -0
  84. {throughline-4.2.0 → throughline-4.4.0}/tests/test_yaml_loader.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: throughline
3
- Version: 4.2.0
3
+ Version: 4.4.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
- 240
39
+ 248
40
40
  <!-- tl:end --> system requirements,
41
41
  <!-- tl:count type == 'user_requirement' -->
42
- 37
42
+ 39
43
43
  <!-- tl:end --> user requirements, and
44
44
  <!-- tl:count type == 'nfr' -->
45
45
  21
@@ -164,7 +164,7 @@ detect items that "violate" a non-goal — that judgement stays with a human.
164
164
  ```
165
165
  tl init [--name NAME] # scaffold a project
166
166
  tl register new <PREFIX> <dir> [--parent P] # add a register
167
- tl new <PREFIX> [--uid U] [--type T] [--ground UID] # allocate + create (grounded at birth)
167
+ tl new <PREFIX> [--uid U] [--type T] [--ground UID] [--key UUID4] # allocate + create (grounded at birth); a held key creates nothing
168
168
  tl link <SRC> <DST> --type <kind> [--stamp] # add a typed link
169
169
  tl delete <UID> --reason "…" # propose a deletion: a tombstone a human ratifies (never erased)
170
170
  tl restore <UID> # decline a proposed deletion
@@ -206,6 +206,7 @@ Upward and downward coverage are independent and both matter:
206
206
  | `uid-filename` | an item's file is not named `<UID>.yml` (git could not see it collide) |
207
207
  | `tombstone-deleted` | a retired UID's tombstone was erased (bad merge / stray `git rm`) |
208
208
  | `schema` | missing required attr or out-of-enum value |
209
+ | `attribute-kind` | an attribute's value is not of its declared kind (`int`, `float`, `bool`, `date` as YYYY-MM-DD, `string`, `text`) |
209
210
  | `suspect-link` | target changed since the link was last confirmed |
210
211
  | `unreviewed` | item content changed since last review |
211
212
  | `unratified` | AI-origin item still `proposed` |
@@ -241,6 +242,18 @@ reports every finding in `namespace:UID` vocabulary. `query`, `dump`, `docs`,
241
242
  answer over the union too; everything else reads your graph alone, and a project
242
243
  that declares no sources is untouched by any of this.
243
244
 
245
+ A graph whose own items follow a shared model — every deck in a series, every plan
246
+ written to one standard — can take its schema from a source instead of copying it.
247
+ Mark that one source `schema = true`; the project is then checked by the source's
248
+ item types, attributes, link types, statuses, transitions, roles, grounding and coverage rules,
249
+ plus whatever it adds itself. It may add an attribute, a type, a status or a link
250
+ type, but never redefine one the source declares: a widened enum or a changed
251
+ attribute is refused when the configuration is read, naming both values.
252
+ `tl migrate` removes a copy the source makes redundant, `tl context`,
253
+ `tl schema show` and `tl dump` mark what was inherited, and `tl schema` refuses
254
+ to change it. Every operation then reads that one source; the rest wait for the
255
+ union.
256
+
244
257
  Sources are read-only, fetched once into a per-user cache and reached again only
245
258
  when a tag or branch has moved; `TL_OFFLINE=1` composes from the cache alone. A
246
259
  program embedding the Tool reaches a source through the `Resolver` it supplies, so
@@ -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
- 240
11
+ 248
12
12
  <!-- tl:end --> system requirements,
13
13
  <!-- tl:count type == 'user_requirement' -->
14
- 37
14
+ 39
15
15
  <!-- tl:end --> user requirements, and
16
16
  <!-- tl:count type == 'nfr' -->
17
17
  21
@@ -136,7 +136,7 @@ detect items that "violate" a non-goal — that judgement stays with a human.
136
136
  ```
137
137
  tl init [--name NAME] # scaffold a project
138
138
  tl register new <PREFIX> <dir> [--parent P] # add a register
139
- tl new <PREFIX> [--uid U] [--type T] [--ground UID] # allocate + create (grounded at birth)
139
+ tl new <PREFIX> [--uid U] [--type T] [--ground UID] [--key UUID4] # allocate + create (grounded at birth); a held key creates nothing
140
140
  tl link <SRC> <DST> --type <kind> [--stamp] # add a typed link
141
141
  tl delete <UID> --reason "…" # propose a deletion: a tombstone a human ratifies (never erased)
142
142
  tl restore <UID> # decline a proposed deletion
@@ -178,6 +178,7 @@ Upward and downward coverage are independent and both matter:
178
178
  | `uid-filename` | an item's file is not named `<UID>.yml` (git could not see it collide) |
179
179
  | `tombstone-deleted` | a retired UID's tombstone was erased (bad merge / stray `git rm`) |
180
180
  | `schema` | missing required attr or out-of-enum value |
181
+ | `attribute-kind` | an attribute's value is not of its declared kind (`int`, `float`, `bool`, `date` as YYYY-MM-DD, `string`, `text`) |
181
182
  | `suspect-link` | target changed since the link was last confirmed |
182
183
  | `unreviewed` | item content changed since last review |
183
184
  | `unratified` | AI-origin item still `proposed` |
@@ -213,6 +214,18 @@ reports every finding in `namespace:UID` vocabulary. `query`, `dump`, `docs`,
213
214
  answer over the union too; everything else reads your graph alone, and a project
214
215
  that declares no sources is untouched by any of this.
215
216
 
217
+ A graph whose own items follow a shared model — every deck in a series, every plan
218
+ written to one standard — can take its schema from a source instead of copying it.
219
+ Mark that one source `schema = true`; the project is then checked by the source's
220
+ item types, attributes, link types, statuses, transitions, roles, grounding and coverage rules,
221
+ plus whatever it adds itself. It may add an attribute, a type, a status or a link
222
+ type, but never redefine one the source declares: a widened enum or a changed
223
+ attribute is refused when the configuration is read, naming both values.
224
+ `tl migrate` removes a copy the source makes redundant, `tl context`,
225
+ `tl schema show` and `tl dump` mark what was inherited, and `tl schema` refuses
226
+ to change it. Every operation then reads that one source; the rest wait for the
227
+ union.
228
+
216
229
  Sources are read-only, fetched once into a per-user cache and reached again only
217
230
  when a tag or branch has moved; `TL_OFFLINE=1` composes from the cache alone. A
218
231
  program embedding the Tool reaches a source through the `Resolver` it supplies, so
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "throughline"
7
- version = "4.2.0"
7
+ version = "4.4.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"
@@ -27,6 +27,14 @@ file the set had touched. Everything the set creates or changes is recorded
27
27
  with a machine origin, so it enters the lifecycle as proposed and a person still
28
28
  decides (SR-0092).
29
29
 
30
+ A set may be applied more than once and changes the graph once (SR-0280, SR-0281).
31
+ A ``new`` operation may carry a ``key``, a UUID version 4 recorded on the item it
32
+ creates; where a live item already carries that key the operation creates nothing,
33
+ and its label names that item. Any other operation whose result the graph already
34
+ holds — a link already there, an unlink of one that is not, an amend whose fields
35
+ already read so, a delete of a tombstone — is skipped and reported as already
36
+ applied, whatever revision it quotes, since nothing would be written.
37
+
30
38
  These are the functions `tl apply` is made of; the command line is one caller
31
39
  (SR-0224, SR-0225). A program embedding the Tool reaches them by name.
32
40
  """
@@ -40,7 +48,8 @@ from typing import Callable
40
48
 
41
49
  from .fingerprint import fingerprint, revision
42
50
  from .grounding import GroundingError, set_status
43
- from .items import amend_item, delete_item, item_type_for, new_item, parse_attrs
51
+ from .items import (amend_item, check_key, delete_item, item_type_for, keyed_item,
52
+ new_item, parse_attrs)
44
53
  from .links import GroundingView, LinkError, add_link, remove_link
45
54
  from .model import Item, Link
46
55
  from .schema import SchemaError
@@ -74,7 +83,7 @@ _DIGEST_RE = re.compile(r"^sha256:[0-9a-f]{64}$")
74
83
  _KEYS: dict[str, tuple[set[str], set[str]]] = {
75
84
  "new": ({"register"},
76
85
  {"op", "register", "type", "title", "text", "rationale", "attrs",
77
- "links", "label"}),
86
+ "links", "label", "key"}),
78
87
  "amend": ({"uid"},
79
88
  {"op", "uid", "title", "text", "rationale", "attrs",
80
89
  "unset"}),
@@ -117,7 +126,10 @@ The five operations:
117
126
  "links": [{{"target": "UR-0003", "type": "implements"}}], "label": "sr1"}}
118
127
  creates an item in the register with that prefix. "type" may be left out
119
128
  where the register declares one. "links" ground the item: a non-root item
120
- must reach a root through them. "label" is optional.
129
+ must reach a root through them. "label" is optional. "key" is optional and
130
+ recommended: a fresh UUID version 4 (e.g. "3f0c1b7e-8a52-4c4e-9d61-2b7f5a9c0e14")
131
+ recorded on the item. Where an item already carries it, nothing is created
132
+ and the label names that item, so the set can be applied again safely.
121
133
 
122
134
  {{"op": "amend", "uid": "SR-0012", "revision": "sha256:...", "title": "...",
123
135
  "text": "...", "rationale": "...", "attrs": {{"priority": "must"}},
@@ -138,6 +150,10 @@ The five operations:
138
150
  "reason": "..."}}
139
151
  tombstones the item: its file stays and its UID is never reused.
140
152
 
153
+ Applying a set twice changes the graph once: an operation whose result the
154
+ graph already holds (a link already there, an amend whose fields already read
155
+ so, a delete of a deleted item) is skipped and reported as already applied.
156
+
141
157
  Rules: no other keys; a "uid", "target" or "label" is written exactly; every
142
158
  value is a string except "attrs" (strings, numbers or booleans), "stamp"
143
159
  (boolean), and "unset" and "links" (lists). The list may also be sent as
@@ -229,6 +245,7 @@ def parse_change_set(text: str) -> list[dict]:
229
245
  problems: list[str] = []
230
246
  ops: list[dict] = []
231
247
  labels: set[str] = set()
248
+ keys: set[str] = set()
232
249
  for i, op in enumerate(data, start=1):
233
250
  where = f"#{i}"
234
251
  if not isinstance(op, dict):
@@ -255,7 +272,7 @@ def parse_change_set(text: str) -> list[dict]:
255
272
  if missing:
256
273
  problems.append(f"{name}: missing {', '.join(missing)}")
257
274
  for key in ("uid", "target", "register", "type", "title", "text",
258
- "rationale", "label", "reason", quoted):
275
+ "rationale", "label", "reason", "key", quoted):
259
276
  if key in op and not isinstance(op[key], str):
260
277
  problems.append(f"{name}: \"{key}\" must be a string")
261
278
  if "stamp" in op and not isinstance(op["stamp"], bool):
@@ -289,6 +306,14 @@ def parse_change_set(text: str) -> list[dict]:
289
306
  elif isinstance(op[quoted], str) and not _DIGEST_RE.match(op[quoted]):
290
307
  problems.append(f"{name} {uid}: \"{quoted}\" must be the sha256:… "
291
308
  "value the export gave")
309
+ if kind == "new" and isinstance(op.get("key"), str):
310
+ try:
311
+ check_key(op["key"])
312
+ except ValueError as e:
313
+ problems.append(f"{name}: {e}")
314
+ if op["key"] in keys:
315
+ problems.append(f"{name}: key {op['key']} is already used in this set")
316
+ keys.add(op["key"])
292
317
  if kind == "new":
293
318
  label = op.get("label")
294
319
  if isinstance(label, str):
@@ -361,25 +386,83 @@ def apply_change_set(project, operations: list[dict], *,
361
386
  ai = _ai_origin(schema)
362
387
  problems: list[str] = []
363
388
  labels: dict[str, str] = {}
364
-
365
- def existing(name: str, op: dict) -> Item | None:
389
+ # The operations whose result the graph already holds (SR-0280, SR-0281), by
390
+ # position: each is skipped rather than carried out.
391
+ held: set[int] = set()
392
+ # Held operations whose quoted version is no longer the item's: the mark of a
393
+ # set that has run before, as (position, name, uid, key, quoted, now).
394
+ stale: list[tuple[int, str, str, str, str, str]] = []
395
+
396
+ def version(op: dict, item: Item) -> tuple[str, str]:
397
+ key = "revision" if "revision" in op else "fingerprint"
398
+ return key, (revision(item) if key == "revision" else fingerprint(item, schema))
399
+
400
+ def resolved(ref: str) -> str | None:
401
+ """A reference as it stands before anything is written: a UID, or the
402
+ existing item a label names, or ``None`` for an item still to be made."""
403
+ if not _is_label(ref):
404
+ return ref
405
+ return labels.get(ref[1:]) or None
406
+
407
+ def existing(name: str, op: dict, i: int) -> Item | None:
366
408
  uid = op["uid"]
367
409
  if _is_label(uid):
410
+ # A label naming an item the set found under its key is that item; an
411
+ # operation on it is judged as on any item created in the set, with
412
+ # no revision, but may already be held.
413
+ known = resolved(uid)
414
+ item = project.get(known) if known else None
415
+ if item is not None and holds(op, item):
416
+ held.add(i)
368
417
  return None
369
418
  item = project.get(uid)
370
419
  if item is None:
371
420
  problems.append(f"{name}: {uid} does not exist")
421
+ elif op["op"] == "delete" and item.is_deleted:
422
+ held.add(i)
372
423
  elif item.is_deleted:
373
424
  problems.append(f"{name}: {uid} is deleted — a tombstone is permanent")
425
+ elif holds(op, item):
426
+ held.add(i)
427
+ key, now = version(op, item)
428
+ if op.get(key) != now:
429
+ stale.append((i, name, uid, key, op.get(key), now))
374
430
  else:
375
- key = "revision" if "revision" in op else "fingerprint"
376
- now = revision(item) if key == "revision" else fingerprint(item, schema)
431
+ key, now = version(op, item)
377
432
  if op.get(key) != now:
378
433
  problems.append(f"{name}: {uid} changed since the export — the set "
379
434
  f"quotes {key} {op.get(key)} but the item now "
380
435
  f"carries {now}")
381
436
  return item
382
437
 
438
+ def holds(op: dict, item: Item) -> bool:
439
+ """Whether ``item`` already shows what ``op`` would make it (SR-0281)."""
440
+ kind = op["op"]
441
+ if kind == "delete":
442
+ return item.is_deleted
443
+ if kind in ("link", "unlink"):
444
+ target = resolved(op["target"])
445
+ if target is None:
446
+ return False
447
+ there = any(l.target == target and (op.get("type") is None
448
+ or l.type == op["type"])
449
+ for l in item.links)
450
+ return there if kind == "link" else not there
451
+ if kind == "amend":
452
+ for field_name in ("title", "text", "rationale"):
453
+ if field_name in op and getattr(item, field_name) != op[field_name]:
454
+ return False
455
+ try:
456
+ pairs = [f"{k}={v}" for k, v in (op.get("attrs") or {}).items()]
457
+ wanted = parse_attrs(schema, item.type, pairs, command="apply",
458
+ declared_only=True)
459
+ except (SchemaError, ValueError):
460
+ return False
461
+ if any(item.attrs.get(k) != v for k, v in wanted.items()):
462
+ return False
463
+ return not any(k in item.attrs for k in op.get("unset", ()))
464
+ return False
465
+
383
466
  def target_known(name: str, ref: str) -> None:
384
467
  if _is_label(ref) or find(ref) is not None:
385
468
  return
@@ -398,15 +481,32 @@ def apply_change_set(project, operations: list[dict], *,
398
481
  if schema.link_types and l["type"] not in schema.link_types:
399
482
  problems.append(f"{name}: '{l['type']}' is not a link type this "
400
483
  f"project declares ({', '.join(sorted(schema.link_types))})")
484
+ found = keyed_item(project, op["key"]) if "key" in op else None
485
+ if found is not None:
486
+ held.add(i)
401
487
  if "label" in op:
402
- labels[op["label"]] = ""
488
+ labels[op["label"]] = found.uid if found is not None else ""
403
489
  continue
404
- existing(name, op)
490
+ existing(name, op, i)
405
491
  if kind in ("link", "unlink"):
406
492
  target_known(name, op["target"])
407
493
  if kind == "link" and schema.link_types and op["type"] not in schema.link_types:
408
494
  problems.append(f"{name}: '{op['type']}' is not a link type this project "
409
495
  f"declares ({', '.join(sorted(schema.link_types))})")
496
+ # A set that has run before can be applied again only where every item it
497
+ # creates carries a key (SR-0280): an unkeyed new operation cannot tell that
498
+ # it already ran, and would make its item a second time.
499
+ unkeyed = [f"#{i}" for i, op in enumerate(operations, start=1)
500
+ if op["op"] == "new" and "key" not in op]
501
+ if stale and unkeyed:
502
+ for _i, name, uid, key, quoted, now in stale:
503
+ problems.append(
504
+ f"{name}: {uid} changed since the export — the set quotes {key} "
505
+ f"{quoted} but the item now carries {now}. It already shows what "
506
+ f"this operation asks, so the set looks applied, and its new "
507
+ f"operation(s) {', '.join(unkeyed)} carry no key: applying it again "
508
+ "would create them twice. Nothing was changed. A set whose new "
509
+ "operations carry keys can be applied again safely (SR-0280)")
410
510
  if problems:
411
511
  raise ChangeSetError(problems)
412
512
 
@@ -445,6 +545,31 @@ def apply_change_set(project, operations: list[dict], *,
445
545
  try:
446
546
  for i, op in enumerate(operations, start=1):
447
547
  kind = op["op"]
548
+ if i in held:
549
+ # Held when judged, and skipped if held still. Where an earlier
550
+ # operation in this set undid it, it is carried out as asked if the
551
+ # version it quoted was current, and otherwise refused: a set
552
+ # applied twice cannot tell that apart from a conflict.
553
+ if kind == "new":
554
+ found = keyed_item(project, op["key"])
555
+ if found is None:
556
+ raise ValueError(f"key {op['key']} no longer names an item")
557
+ if "label" in op:
558
+ labels[op["label"]] = found.uid
559
+ result.operations.append(OperationResult(
560
+ i, kind, found.uid, "already applied", f"key {op['key']}"))
561
+ continue
562
+ item = project.get(resolve(op["uid"]))
563
+ if item is not None and holds(op, item):
564
+ result.operations.append(OperationResult(
565
+ i, kind, item.uid, "already applied", ""))
566
+ continue
567
+ if i in {pos for pos, *_ in stale}:
568
+ raise ValueError("an earlier operation in this set undid what "
569
+ "this one found already done, and the "
570
+ "revision it quotes is not the item's")
571
+ # An earlier operation in the set undid it, and the version it
572
+ # quoted was current: it is carried out as asked.
448
573
  if kind == "new":
449
574
  prefix = op["register"]
450
575
  item_type = item_type_for(project, prefix, op.get("type"))
@@ -452,7 +577,7 @@ def apply_change_set(project, operations: list[dict], *,
452
577
  attrs = parse_attrs(schema, item_type, pairs, command="apply")
453
578
  item = new_item(project, prefix, item_type=item_type,
454
579
  title=op.get("title", ""), text=op.get("text", ""),
455
- origin=ai, attrs=attrs)
580
+ origin=ai, attrs=attrs, key=op.get("key"))
456
581
  if op.get("rationale"):
457
582
  item.rationale = op["rationale"]
458
583
  for l in op.get("links", []):
@@ -14,6 +14,7 @@ from __future__ import annotations
14
14
  import argparse
15
15
  from pathlib import Path
16
16
 
17
+ from .inherit import coverage_path
17
18
  from .graph import Index
18
19
  from .signatures import retired
19
20
  from .parser import build_parser
@@ -106,7 +107,27 @@ def _ctx_working(schema) -> str:
106
107
  )
107
108
 
108
109
 
109
- def _ctx_types(schema) -> str:
110
+ def _from(inheritance, *path: str) -> str:
111
+ """The mark on a declaration the schema source makes (SR-0279)."""
112
+ if inheritance is None or not inheritance.owns(*path):
113
+ return ""
114
+ return f" (from `{inheritance.namespace}`)"
115
+
116
+
117
+ def _ctx_schema_source(inheritance) -> str:
118
+ """Where this project's schema comes from (SR-0279), so an agent changes a rule
119
+ where it is declared rather than in a file that only inherits it."""
120
+ ns = inheritance.namespace
121
+ return (
122
+ "## Schema source\n\n"
123
+ f"This project takes its schema from the source `{ns}` (SR-0275) and declares "
124
+ "only what it adds. Below, every declaration marked _(from "
125
+ f"`{ns}`)_ is the source's: it cannot be redefined here (SR-0276), and "
126
+ "`tl schema` refuses to change it (SR-0279). Change it in the source's own "
127
+ "graph and move this project's pin to take it.")
128
+
129
+
130
+ def _ctx_types(schema, inheritance=None) -> str:
110
131
  out = ["## Item types and their attributes\n"]
111
132
  if not schema.types:
112
133
  out.append("_This project declares no per-type attribute schema; item "
@@ -120,6 +141,8 @@ def _ctx_types(schema) -> str:
120
141
  tags.append("delivery-root")
121
142
  if not schema.is_normative(tname):
122
143
  tags.append("non-normative")
144
+ if _from(inheritance, "type", tname):
145
+ tags.append(f"from `{inheritance.namespace}`")
123
146
  tag = f" _({', '.join(tags)})_" if tags else ""
124
147
  out.append(f"### `{tname}`{tag}\n")
125
148
  specs = schema.attrs_for(tname)
@@ -132,19 +155,30 @@ def _ctx_types(schema) -> str:
132
155
  s = specs[aname]
133
156
  vals = ", ".join(f"`{v}`" for v in s.values) if s.values else "—"
134
157
  out.append(
135
- f"| `{aname}` | {s.kind or 'free'} | "
158
+ f"| `{aname}`{_from(inheritance, 'attr', tname, aname)} | "
159
+ f"{s.kind or 'free'} | "
136
160
  f"{'yes' if s.required else 'no'} | "
137
161
  f"{'yes' if s.normative else 'no'} | {vals} |")
138
162
  out.append("")
139
163
  return "\n".join(out).rstrip()
140
164
 
141
165
 
142
- def _ctx_links(schema) -> str:
166
+ def _inherited_of(inheritance, kind: str, names) -> str:
167
+ """The members of ``names`` the schema source declares, as a brief line."""
168
+ if inheritance is None:
169
+ return ""
170
+ theirs = [n for n in names if inheritance.owns(kind, n)]
171
+ return (f" Of these, {_fmt_set(theirs)} come from the schema source "
172
+ f"`{inheritance.namespace}`." if theirs else "")
173
+
174
+
175
+ def _ctx_links(schema, inheritance=None) -> str:
143
176
  out = ["## Links\n"]
144
177
  if schema.link_types is None:
145
178
  out.append("- **Link vocabulary:** unconstrained (any link type is legal).")
146
179
  else:
147
- out.append(f"- **Link vocabulary:** {_fmt_set(schema.link_types)}.")
180
+ out.append(f"- **Link vocabulary:** {_fmt_set(schema.link_types)}."
181
+ + _inherited_of(inheritance, "link", schema.link_types))
148
182
  out.append(f"- **Grounding links** (these carry justification): "
149
183
  f"{_fmt_set(schema.ground_link_types)}.")
150
184
  if schema.link_rules:
@@ -164,18 +198,20 @@ def _ctx_links(schema) -> str:
164
198
  return "\n".join(out)
165
199
 
166
200
 
167
- def _ctx_status(schema) -> str:
201
+ def _ctx_status(schema, inheritance=None) -> str:
168
202
  out = ["## Status and lifecycle\n"]
169
203
  if schema.statuses is None:
170
204
  out.append("- **Statuses:** unconstrained (any status string is legal).")
171
205
  else:
172
- out.append(f"- **Statuses:** {_fmt_set(schema.statuses)}.")
206
+ out.append(f"- **Statuses:** {_fmt_set(schema.statuses)}."
207
+ + _inherited_of(inheritance, "status", schema.statuses))
173
208
  if schema.transitions:
174
209
  out.append("- **Allowed transitions** (a status may only move along "
175
210
  "these edges; staying put is always allowed):\n")
176
211
  for frm in sorted(schema.transitions):
177
212
  tos = schema.transitions[frm]
178
- out.append(f" - `{frm}` → {_fmt_set(tos)}")
213
+ out.append(f" - `{frm}` → {_fmt_set(tos)}"
214
+ + _from(inheritance, "transition", frm))
179
215
  out.append("\n Render this as a diagram with "
180
216
  "`tl diagram transitions`.")
181
217
  else:
@@ -247,7 +283,7 @@ def _describe_requirement(req) -> str:
247
283
  return " and ".join(parts)
248
284
 
249
285
 
250
- def _ctx_coverage(schema) -> str:
286
+ def _ctx_coverage(schema, inheritance=None) -> str:
251
287
  out = ["## Coverage rules\n"]
252
288
  if not schema.coverage:
253
289
  out.append("_No `[[rules.coverage]]` declared._")
@@ -255,12 +291,19 @@ def _ctx_coverage(schema) -> str:
255
291
  out.append("Each rule requires the matching items to have the stated link "
256
292
  "(unmet → a `coverage` finding). They are numbered as "
257
293
  "`tl schema rule remove` counts them:\n")
258
- for pos, rule in enumerate(schema.coverage, start=1):
294
+ pos = 0
295
+ for rule in schema.coverage:
259
296
  filt = rule.get("filter", "*")
260
297
  needs = rule.get("needs", "?")
261
298
  sev = rule.get("severity", WARNING)
262
- out.append(f"{pos}. items where `{filt}` **need** `{needs}` "
263
- f"(severity: {sev})")
299
+ line = f"items where `{filt}` **need** `{needs}` (severity: {sev})"
300
+ # An inherited rule is the schema source's (SR-0275, SR-0279): it is not
301
+ # this project's to remove, so it takes no number here.
302
+ if inheritance is not None and inheritance.owns(*coverage_path(rule)):
303
+ out.append(f"- {line} (from `{inheritance.namespace}`)")
304
+ continue
305
+ pos += 1
306
+ out.append(f"{pos}. {line}")
264
307
  return "\n".join(out)
265
308
 
266
309
 
@@ -471,15 +514,17 @@ def context_markdown(project) -> str:
471
514
  "configuration changes, regenerate it with `tl context`. "
472
515
  "If a capability isn't described here, it isn't in the tool."
473
516
  )
517
+ inheritance = project.inheritance
474
518
  sections = [
475
519
  header,
476
520
  _ctx_idd(schema),
477
521
  _ctx_working(schema),
478
- _ctx_types(schema),
479
- _ctx_links(schema),
480
- _ctx_status(schema),
522
+ *([_ctx_schema_source(inheritance)] if inheritance is not None else []),
523
+ _ctx_types(schema, inheritance),
524
+ _ctx_links(schema, inheritance),
525
+ _ctx_status(schema, inheritance),
481
526
  _ctx_grounding(schema),
482
- _ctx_coverage(schema),
527
+ _ctx_coverage(schema, inheritance),
483
528
  _CTX_FORMAT,
484
529
  _ctx_commands(),
485
530
  _ctx_snapshot(project),
@@ -83,10 +83,12 @@ from .items import (
83
83
  Amendment,
84
84
  amend_item,
85
85
  birth_item,
86
+ check_key,
86
87
  coerce_attr,
87
88
  delete_item,
88
89
  restore_item,
89
90
  item_type_for,
91
+ keyed_item,
90
92
  new_item,
91
93
  newly_suspect,
92
94
  parse_attrs,
@@ -286,6 +288,7 @@ def cmd_migrate(args) -> int:
286
288
  or result.cached or result.normative or result.contents
287
289
  or result.unprovable or result.registers or result.lifted
288
290
  or result.tombstones or result.unattributed or result.withdrawn
291
+ or result.inherited
289
292
  else " — nothing to migrate"))
290
293
  else:
291
294
  # Already at this major, but missing configuration the major requires —
@@ -388,6 +391,13 @@ def cmd_migrate(args) -> int:
388
391
  "declared source that carries it:")
389
392
  for ns in result.withdrawn:
390
393
  print(f" {ns}")
394
+ # The copy of the schema source's declarations removed (SR-0277), each named
395
+ # so the keeper can read the diff against this list.
396
+ if result.inherited:
397
+ print(f"removed {len(result.inherited)} declaration(s) the schema source "
398
+ "already makes identically:")
399
+ for what in result.inherited:
400
+ print(f" {what}")
391
401
  if result.registers:
392
402
  print(f"declared the item type of {len(result.registers)} register(s), "
393
403
  "which `tl new` now gives items born there (SR-0241):")
@@ -453,6 +463,24 @@ def cmd_schema(args) -> int:
453
463
  return OK
454
464
 
455
465
 
466
+ def cmd_schema_show(args) -> int:
467
+ """List the project's schema as formed, each declaration marked with the schema
468
+ source it came from (SR-0279)."""
469
+ try:
470
+ project = schema_ops.load(Path(args.path))
471
+ except schema_ops.SchemaOpError as e:
472
+ return _err(str(e))
473
+ rows = schema_ops.show(project)
474
+ if args.format == "json":
475
+ print(json.dumps(rows, indent=2, ensure_ascii=False))
476
+ return OK
477
+ for row in rows:
478
+ value = "" if row["value"] is True else f" = {json.dumps(row['value'])}"
479
+ origin = f" (from {row['from']})" if row["from"] else ""
480
+ print(f"{row['declaration']}{value}{origin}")
481
+ return OK
482
+
483
+
456
484
  def cmd_schema_attr_remove(args) -> int:
457
485
  """Withdraw an attribute from an item type, naming the items that still carry
458
486
  it, and with --unset remove it from them as well (SR-0207).
@@ -466,7 +494,7 @@ def cmd_schema_attr_remove(args) -> int:
466
494
  project = schema_ops.load(root)
467
495
  declared = project.schema.attr(args.itype, args.name) is not None
468
496
  carriers = schema_ops.attr_carriers(project, args.itype, args.name)
469
- type_declared = args.itype in (project.config.get("types") or {})
497
+ type_declared = args.itype in (project.formed_config.get("types") or {})
470
498
  if declared:
471
499
  change = schema_ops.attr_remove(project, args.itype, args.name)
472
500
  elif args.unset and carriers and type_declared:
@@ -689,11 +717,23 @@ def cmd_new(args) -> int:
689
717
  # honoured, including for a root type (SR-0091).
690
718
  default_type = args.ground_type or "derives_from"
691
719
  named = list(args.ground or ())
720
+ # The same creation asked for again (SR-0280): the item made under this key
721
+ # is named, and nothing is created or written.
722
+ key = getattr(args, "key", None)
723
+ if key is not None:
724
+ try:
725
+ check_key(key)
726
+ except ValueError as e:
727
+ return _err(str(e))
728
+ held = keyed_item(project, key)
729
+ if held is not None:
730
+ print(f"exists {held.uid} -> {held._path} (key {key}) — nothing created")
731
+ return OK
692
732
  try:
693
733
  item = new_item(project, args.prefix, item_type=item_type, uid=args.uid,
694
734
  title=args.title or "", text=args.text or "",
695
735
  status=args.status, origin=args.origin, attrs=attrs,
696
- ground=named, ground_type=default_type)
736
+ ground=named, ground_type=default_type, key=key)
697
737
  except (ProjectError, GroundingError, SchemaError, UidError) as e:
698
738
  return _err(str(e))
699
739
  grounds = [(target, default_type) for target in named]
@@ -478,7 +478,7 @@ def local_view(view: Project, consumer: Project) -> Project:
478
478
  SR-0040). Ownership is read from the consumer's own register set rather than
479
479
  inferred from the shape of a displayed name, so narrowing cannot disagree with
480
480
  what was loaded."""
481
- out = Project(path=view.path, config=view.config)
481
+ out = view.shell()
482
482
  for prefix, reg in view.registers.items():
483
483
  if prefix in consumer.registers:
484
484
  out.registers[prefix] = reg
@@ -56,7 +56,7 @@ def build_dump(project: Project, tool_version: str) -> dict:
56
56
  d["revision"] = revision(it)
57
57
  return d
58
58
 
59
- return {
59
+ out = {
60
60
  "throughline_dump": {
61
61
  "dump_schema_version": DUMP_SCHEMA_VERSION,
62
62
  "format_version": FORMAT_VERSION,
@@ -66,3 +66,15 @@ def build_dump(project: Project, tool_version: str) -> dict:
66
66
  "registers": registers,
67
67
  "items": [entry(it) for it in items],
68
68
  }
69
+ # A project that takes its schema from a source (SR-0275) says so, with the
70
+ # schema as formed and every declaration it inherited (SR-0279). `config`
71
+ # stays the project's own file. Absent for every other project, whose dump is
72
+ # unchanged.
73
+ inheritance = project.inheritance
74
+ if inheritance is not None:
75
+ out["schema_source"] = {
76
+ "namespace": inheritance.namespace,
77
+ "formed": project.formed_config,
78
+ "inherited": sorted(list(p) for p in inheritance.paths),
79
+ }
80
+ return out