throughline 4.2.0__tar.gz → 4.3.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 (83) hide show
  1. {throughline-4.2.0/src/throughline.egg-info → throughline-4.3.0}/PKG-INFO +16 -4
  2. {throughline-4.2.0 → throughline-4.3.0}/README.md +15 -3
  3. {throughline-4.2.0 → throughline-4.3.0}/pyproject.toml +1 -1
  4. {throughline-4.2.0 → throughline-4.3.0}/src/throughline/apply.py +136 -11
  5. {throughline-4.2.0 → throughline-4.3.0}/src/throughline/brief.py +47 -10
  6. {throughline-4.2.0 → throughline-4.3.0}/src/throughline/cli.py +42 -2
  7. {throughline-4.2.0 → throughline-4.3.0}/src/throughline/composition.py +1 -1
  8. {throughline-4.2.0 → throughline-4.3.0}/src/throughline/dump.py +13 -1
  9. throughline-4.3.0/src/throughline/inherit.py +217 -0
  10. {throughline-4.2.0 → throughline-4.3.0}/src/throughline/items.py +33 -1
  11. {throughline-4.2.0 → throughline-4.3.0}/src/throughline/model.py +36 -2
  12. {throughline-4.2.0 → throughline-4.3.0}/src/throughline/parser.py +11 -0
  13. {throughline-4.2.0 → throughline-4.3.0}/src/throughline/schema_ops.py +79 -9
  14. {throughline-4.2.0 → throughline-4.3.0}/src/throughline/sources.py +36 -2
  15. {throughline-4.2.0 → throughline-4.3.0}/src/throughline/storage.py +197 -10
  16. {throughline-4.2.0 → throughline-4.3.0}/src/throughline/tomledit.py +4 -0
  17. {throughline-4.2.0 → throughline-4.3.0}/src/throughline/union.py +2 -2
  18. {throughline-4.2.0 → throughline-4.3.0}/src/throughline/validate.py +16 -2
  19. {throughline-4.2.0 → throughline-4.3.0/src/throughline.egg-info}/PKG-INFO +16 -4
  20. {throughline-4.2.0 → throughline-4.3.0}/src/throughline.egg-info/SOURCES.txt +3 -0
  21. throughline-4.3.0/tests/test_apply_once.py +197 -0
  22. throughline-4.3.0/tests/test_schema_source.py +316 -0
  23. {throughline-4.2.0 → throughline-4.3.0}/LICENSE +0 -0
  24. {throughline-4.2.0 → throughline-4.3.0}/NOTICE +0 -0
  25. {throughline-4.2.0 → throughline-4.3.0}/setup.cfg +0 -0
  26. {throughline-4.2.0 → throughline-4.3.0}/src/throughline/__init__.py +0 -0
  27. {throughline-4.2.0 → throughline-4.3.0}/src/throughline/collision.py +0 -0
  28. {throughline-4.2.0 → throughline-4.3.0}/src/throughline/diagrams.py +0 -0
  29. {throughline-4.2.0 → throughline-4.3.0}/src/throughline/filters.py +0 -0
  30. {throughline-4.2.0 → throughline-4.3.0}/src/throughline/fingerprint.py +0 -0
  31. {throughline-4.2.0 → throughline-4.3.0}/src/throughline/graph.py +0 -0
  32. {throughline-4.2.0 → throughline-4.3.0}/src/throughline/grounding.py +0 -0
  33. {throughline-4.2.0 → throughline-4.3.0}/src/throughline/identity.py +0 -0
  34. {throughline-4.2.0 → throughline-4.3.0}/src/throughline/inject.py +0 -0
  35. {throughline-4.2.0 → throughline-4.3.0}/src/throughline/links.py +0 -0
  36. {throughline-4.2.0 → throughline-4.3.0}/src/throughline/ratification.py +0 -0
  37. {throughline-4.2.0 → throughline-4.3.0}/src/throughline/resolvers.py +0 -0
  38. {throughline-4.2.0 → throughline-4.3.0}/src/throughline/schema.py +0 -0
  39. {throughline-4.2.0 → throughline-4.3.0}/src/throughline/seam.py +0 -0
  40. {throughline-4.2.0 → throughline-4.3.0}/src/throughline/signatures.py +0 -0
  41. {throughline-4.2.0 → throughline-4.3.0}/src/throughline/signed_links.py +0 -0
  42. {throughline-4.2.0 → throughline-4.3.0}/src/throughline/uid.py +0 -0
  43. {throughline-4.2.0 → throughline-4.3.0}/src/throughline/version.py +0 -0
  44. {throughline-4.2.0 → throughline-4.3.0}/src/throughline/views.py +0 -0
  45. {throughline-4.2.0 → throughline-4.3.0}/src/throughline/worklist.py +0 -0
  46. {throughline-4.2.0 → throughline-4.3.0}/src/throughline.egg-info/dependency_links.txt +0 -0
  47. {throughline-4.2.0 → throughline-4.3.0}/src/throughline.egg-info/entry_points.txt +0 -0
  48. {throughline-4.2.0 → throughline-4.3.0}/src/throughline.egg-info/requires.txt +0 -0
  49. {throughline-4.2.0 → throughline-4.3.0}/src/throughline.egg-info/top_level.txt +0 -0
  50. {throughline-4.2.0 → throughline-4.3.0}/tests/test_amend.py +0 -0
  51. {throughline-4.2.0 → throughline-4.3.0}/tests/test_apply.py +0 -0
  52. {throughline-4.2.0 → throughline-4.3.0}/tests/test_baseline.py +0 -0
  53. {throughline-4.2.0 → throughline-4.3.0}/tests/test_clarify.py +0 -0
  54. {throughline-4.2.0 → throughline-4.3.0}/tests/test_deprecated_names.py +0 -0
  55. {throughline-4.2.0 → throughline-4.3.0}/tests/test_diagram_cli.py +0 -0
  56. {throughline-4.2.0 → throughline-4.3.0}/tests/test_doctor.py +0 -0
  57. {throughline-4.2.0 → throughline-4.3.0}/tests/test_engine.py +0 -0
  58. {throughline-4.2.0 → throughline-4.3.0}/tests/test_filters.py +0 -0
  59. {throughline-4.2.0 → throughline-4.3.0}/tests/test_flag.py +0 -0
  60. {throughline-4.2.0 → throughline-4.3.0}/tests/test_library_api.py +0 -0
  61. {throughline-4.2.0 → throughline-4.3.0}/tests/test_link_guard.py +0 -0
  62. {throughline-4.2.0 → throughline-4.3.0}/tests/test_migrate_withdrawn.py +0 -0
  63. {throughline-4.2.0 → throughline-4.3.0}/tests/test_normative_by_type.py +0 -0
  64. {throughline-4.2.0 → throughline-4.3.0}/tests/test_own_tree.py +0 -0
  65. {throughline-4.2.0 → throughline-4.3.0}/tests/test_ratification_change.py +0 -0
  66. {throughline-4.2.0 → throughline-4.3.0}/tests/test_ratification_withdrawal.py +0 -0
  67. {throughline-4.2.0 → throughline-4.3.0}/tests/test_ratified_content.py +0 -0
  68. {throughline-4.2.0 → throughline-4.3.0}/tests/test_ratified_deletion.py +0 -0
  69. {throughline-4.2.0 → throughline-4.3.0}/tests/test_ratifier_correction.py +0 -0
  70. {throughline-4.2.0 → throughline-4.3.0}/tests/test_ratify_several.py +0 -0
  71. {throughline-4.2.0 → throughline-4.3.0}/tests/test_ratify_status.py +0 -0
  72. {throughline-4.2.0 → throughline-4.3.0}/tests/test_ratify_transition_refused_first.py +0 -0
  73. {throughline-4.2.0 → throughline-4.3.0}/tests/test_register_type.py +0 -0
  74. {throughline-4.2.0 → throughline-4.3.0}/tests/test_resolve_collision.py +0 -0
  75. {throughline-4.2.0 → throughline-4.3.0}/tests/test_resolve_cross_graph.py +0 -0
  76. {throughline-4.2.0 → throughline-4.3.0}/tests/test_schema_ops.py +0 -0
  77. {throughline-4.2.0 → throughline-4.3.0}/tests/test_signatures.py +0 -0
  78. {throughline-4.2.0 → throughline-4.3.0}/tests/test_signed_links.py +0 -0
  79. {throughline-4.2.0 → throughline-4.3.0}/tests/test_spec_lists.py +0 -0
  80. {throughline-4.2.0 → throughline-4.3.0}/tests/test_tombstone_record.py +0 -0
  81. {throughline-4.2.0 → throughline-4.3.0}/tests/test_unset_attrs.py +0 -0
  82. {throughline-4.2.0 → throughline-4.3.0}/tests/test_worklist.py +0 -0
  83. {throughline-4.2.0 → throughline-4.3.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.3.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
+ 247
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
@@ -241,6 +241,18 @@ reports every finding in `namespace:UID` vocabulary. `query`, `dump`, `docs`,
241
241
  answer over the union too; everything else reads your graph alone, and a project
242
242
  that declares no sources is untouched by any of this.
243
243
 
244
+ A graph whose own items follow a shared model — every deck in a series, every plan
245
+ written to one standard — can take its schema from a source instead of copying it.
246
+ Mark that one source `schema = true`; the project is then checked by the source's
247
+ item types, attributes, link types, statuses, transitions, roles and grounding,
248
+ plus whatever it adds itself. It may add an attribute, a type, a status or a link
249
+ type, but never redefine one the source declares: a widened enum or a changed
250
+ attribute is refused when the configuration is read, naming both values.
251
+ `tl migrate` removes a copy the source makes redundant, `tl context`,
252
+ `tl schema show` and `tl dump` mark what was inherited, and `tl schema` refuses
253
+ to change it. Every operation then reads that one source; the rest wait for the
254
+ union.
255
+
244
256
  Sources are read-only, fetched once into a per-user cache and reached again only
245
257
  when a tag or branch has moved; `TL_OFFLINE=1` composes from the cache alone. A
246
258
  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
+ 247
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
@@ -213,6 +213,18 @@ reports every finding in `namespace:UID` vocabulary. `query`, `dump`, `docs`,
213
213
  answer over the union too; everything else reads your graph alone, and a project
214
214
  that declares no sources is untouched by any of this.
215
215
 
216
+ A graph whose own items follow a shared model — every deck in a series, every plan
217
+ written to one standard — can take its schema from a source instead of copying it.
218
+ Mark that one source `schema = true`; the project is then checked by the source's
219
+ item types, attributes, link types, statuses, transitions, roles and grounding,
220
+ plus whatever it adds itself. It may add an attribute, a type, a status or a link
221
+ type, but never redefine one the source declares: a widened enum or a changed
222
+ attribute is refused when the configuration is read, naming both values.
223
+ `tl migrate` removes a copy the source makes redundant, `tl context`,
224
+ `tl schema show` and `tl dump` mark what was inherited, and `tl schema` refuses
225
+ to change it. Every operation then reads that one source; the rest wait for the
226
+ union.
227
+
216
228
  Sources are read-only, fetched once into a per-user cache and reached again only
217
229
  when a tag or branch has moved; `TL_OFFLINE=1` composes from the cache alone. A
218
230
  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.3.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", []):
@@ -106,7 +106,27 @@ def _ctx_working(schema) -> str:
106
106
  )
107
107
 
108
108
 
109
- def _ctx_types(schema) -> str:
109
+ def _from(inheritance, *path: str) -> str:
110
+ """The mark on a declaration the schema source makes (SR-0279)."""
111
+ if inheritance is None or not inheritance.owns(*path):
112
+ return ""
113
+ return f" (from `{inheritance.namespace}`)"
114
+
115
+
116
+ def _ctx_schema_source(inheritance) -> str:
117
+ """Where this project's schema comes from (SR-0279), so an agent changes a rule
118
+ where it is declared rather than in a file that only inherits it."""
119
+ ns = inheritance.namespace
120
+ return (
121
+ "## Schema source\n\n"
122
+ f"This project takes its schema from the source `{ns}` (SR-0275) and declares "
123
+ "only what it adds. Below, every declaration marked _(from "
124
+ f"`{ns}`)_ is the source's: it cannot be redefined here (SR-0276), and "
125
+ "`tl schema` refuses to change it (SR-0279). Change it in the source's own "
126
+ "graph and move this project's pin to take it.")
127
+
128
+
129
+ def _ctx_types(schema, inheritance=None) -> str:
110
130
  out = ["## Item types and their attributes\n"]
111
131
  if not schema.types:
112
132
  out.append("_This project declares no per-type attribute schema; item "
@@ -120,6 +140,8 @@ def _ctx_types(schema) -> str:
120
140
  tags.append("delivery-root")
121
141
  if not schema.is_normative(tname):
122
142
  tags.append("non-normative")
143
+ if _from(inheritance, "type", tname):
144
+ tags.append(f"from `{inheritance.namespace}`")
123
145
  tag = f" _({', '.join(tags)})_" if tags else ""
124
146
  out.append(f"### `{tname}`{tag}\n")
125
147
  specs = schema.attrs_for(tname)
@@ -132,19 +154,30 @@ def _ctx_types(schema) -> str:
132
154
  s = specs[aname]
133
155
  vals = ", ".join(f"`{v}`" for v in s.values) if s.values else "—"
134
156
  out.append(
135
- f"| `{aname}` | {s.kind or 'free'} | "
157
+ f"| `{aname}`{_from(inheritance, 'attr', tname, aname)} | "
158
+ f"{s.kind or 'free'} | "
136
159
  f"{'yes' if s.required else 'no'} | "
137
160
  f"{'yes' if s.normative else 'no'} | {vals} |")
138
161
  out.append("")
139
162
  return "\n".join(out).rstrip()
140
163
 
141
164
 
142
- def _ctx_links(schema) -> str:
165
+ def _inherited_of(inheritance, kind: str, names) -> str:
166
+ """The members of ``names`` the schema source declares, as a brief line."""
167
+ if inheritance is None:
168
+ return ""
169
+ theirs = [n for n in names if inheritance.owns(kind, n)]
170
+ return (f" Of these, {_fmt_set(theirs)} come from the schema source "
171
+ f"`{inheritance.namespace}`." if theirs else "")
172
+
173
+
174
+ def _ctx_links(schema, inheritance=None) -> str:
143
175
  out = ["## Links\n"]
144
176
  if schema.link_types is None:
145
177
  out.append("- **Link vocabulary:** unconstrained (any link type is legal).")
146
178
  else:
147
- out.append(f"- **Link vocabulary:** {_fmt_set(schema.link_types)}.")
179
+ out.append(f"- **Link vocabulary:** {_fmt_set(schema.link_types)}."
180
+ + _inherited_of(inheritance, "link", schema.link_types))
148
181
  out.append(f"- **Grounding links** (these carry justification): "
149
182
  f"{_fmt_set(schema.ground_link_types)}.")
150
183
  if schema.link_rules:
@@ -164,18 +197,20 @@ def _ctx_links(schema) -> str:
164
197
  return "\n".join(out)
165
198
 
166
199
 
167
- def _ctx_status(schema) -> str:
200
+ def _ctx_status(schema, inheritance=None) -> str:
168
201
  out = ["## Status and lifecycle\n"]
169
202
  if schema.statuses is None:
170
203
  out.append("- **Statuses:** unconstrained (any status string is legal).")
171
204
  else:
172
- out.append(f"- **Statuses:** {_fmt_set(schema.statuses)}.")
205
+ out.append(f"- **Statuses:** {_fmt_set(schema.statuses)}."
206
+ + _inherited_of(inheritance, "status", schema.statuses))
173
207
  if schema.transitions:
174
208
  out.append("- **Allowed transitions** (a status may only move along "
175
209
  "these edges; staying put is always allowed):\n")
176
210
  for frm in sorted(schema.transitions):
177
211
  tos = schema.transitions[frm]
178
- out.append(f" - `{frm}` → {_fmt_set(tos)}")
212
+ out.append(f" - `{frm}` → {_fmt_set(tos)}"
213
+ + _from(inheritance, "transition", frm))
179
214
  out.append("\n Render this as a diagram with "
180
215
  "`tl diagram transitions`.")
181
216
  else:
@@ -471,13 +506,15 @@ def context_markdown(project) -> str:
471
506
  "configuration changes, regenerate it with `tl context`. "
472
507
  "If a capability isn't described here, it isn't in the tool."
473
508
  )
509
+ inheritance = project.inheritance
474
510
  sections = [
475
511
  header,
476
512
  _ctx_idd(schema),
477
513
  _ctx_working(schema),
478
- _ctx_types(schema),
479
- _ctx_links(schema),
480
- _ctx_status(schema),
514
+ *([_ctx_schema_source(inheritance)] if inheritance is not None else []),
515
+ _ctx_types(schema, inheritance),
516
+ _ctx_links(schema, inheritance),
517
+ _ctx_status(schema, inheritance),
481
518
  _ctx_grounding(schema),
482
519
  _ctx_coverage(schema),
483
520
  _CTX_FORMAT,
@@ -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