wdi-method 0.6.8 → 0.6.18

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.
@@ -185,6 +185,27 @@ def git(root: Path, *args: str) -> str | None:
185
185
  REQUIREMENT_KEYS = ("goals", "capabilities", "functional", "nonfunctional", "journeys")
186
186
 
187
187
 
188
+ def is_withdrawn(row: dict) -> bool:
189
+ """`status: withdrawn` — the product stopped promising this, and the row STAYED.
190
+
191
+ Deleting it is what a repo used to do, and the cost was measured: two capabilities withdrawn by
192
+ decision, their rows removed, and twelve `refs-resolve` findings — eight `DEC-` rows still named
193
+ them, six of which genuinely served them at the time. `corpus-guide.md` forbids the other repair:
194
+ a retired name in a record of what happened is a fact about the past, and a `DEC-` is exactly
195
+ that record.
196
+
197
+ So a withdrawn row is read TWO ways. It is still **defined** — every old reference resolves and
198
+ `id-allocated-once` still refuses the number to anyone else. It is no longer **promised** — no UC
199
+ is owed, no ticket, no RTM row, and `promise_progress` is not dragged down by something nobody
200
+ promises.
201
+ """
202
+ return str(row.get("status") or "").strip().lower() == "withdrawn"
203
+
204
+
205
+ def promised(items: list[dict]) -> list[dict]:
206
+ return [row for row in items if not is_withdrawn(row)]
207
+
208
+
188
209
  @dataclass
189
210
  class Corpus:
190
211
  root: Path
@@ -253,23 +274,23 @@ class Corpus:
253
274
  # --- shortcuts used repeatedly
254
275
  @property
255
276
  def goals(self) -> list[dict]:
256
- return rows(self.requirements, "goals")
277
+ return promised(rows(self.requirements, "goals"))
257
278
 
258
279
  @property
259
280
  def caps(self) -> list[dict]:
260
- return rows(self.requirements, "capabilities")
281
+ return promised(rows(self.requirements, "capabilities"))
261
282
 
262
283
  @property
263
284
  def frs(self) -> list[dict]:
264
- return rows(self.requirements, "functional")
285
+ return promised(rows(self.requirements, "functional"))
265
286
 
266
287
  @property
267
288
  def nfrs(self) -> list[dict]:
268
- return rows(self.requirements, "nonfunctional")
289
+ return promised(rows(self.requirements, "nonfunctional"))
269
290
 
270
291
  @property
271
292
  def ucs(self) -> list[dict]:
272
- return rows(self.usecases, "usecases")
293
+ return promised(rows(self.usecases, "usecases"))
273
294
 
274
295
  @property
275
296
  def decs(self) -> list[dict]:
@@ -282,6 +303,15 @@ class Corpus:
282
303
  return own
283
304
  return str(self.index.get("mode") or "").strip() or "catalog"
284
305
 
306
+ @property
307
+ def withdrawn_rows(self) -> list[dict]:
308
+ """Every withdrawn requirement row, with the key it came from — the id side of the split."""
309
+ out = []
310
+ for key in REQUIREMENT_KEYS:
311
+ out += [(key, row) for row in rows(self.requirements, key) if is_withdrawn(row)]
312
+ out += [("usecases", row) for row in rows(self.usecases, "usecases") if is_withdrawn(row)]
313
+ return [row for _, row in out]
314
+
285
315
  @property
286
316
  def lcs(self) -> list[dict]:
287
317
  return rows(self.components, "logical_components")
@@ -341,6 +371,18 @@ def _legacy_tickets(spec: dict) -> list[dict]:
341
371
  yet scheduled, and a closed wave has nothing left to schedule.
342
372
  """
343
373
  sid = str(spec.get("id") or "")
374
+
375
+ def scoped(raw: str) -> str:
376
+ """`W7-S2` under wave `W7` stays `W7-S2`, not `W7-W7-S2`.
377
+
378
+ The prefix exists so two waves both naming a story `"1"` cannot collide into one node. A
379
+ story already scoped to its wave has nothing to collide with, and prefixing it again produces
380
+ an id that matches no file, no memlog line, and nothing a person would search for. Measured on
381
+ a live repo whose RTM read `W7-W7-S2`.
382
+ """
383
+ sub = str(raw)
384
+ return sub if sid and sub.startswith(f"{sid}-") else f"{sid}-{sub}"
385
+
344
386
  out = []
345
387
  for epic in spec.get("epics") or []:
346
388
  if not isinstance(epic, dict):
@@ -349,8 +391,8 @@ def _legacy_tickets(spec: dict) -> list[dict]:
349
391
  if not isinstance(story, dict):
350
392
  continue
351
393
  ticket = {k: v for k, v in story.items() if k not in ("id", "depends_on")}
352
- ticket["id"] = f"{sid}-{story.get('id')}"
353
- ticket["blocked_by"] = [f"{sid}-{d}" for d in (story.get("depends_on") or [])]
394
+ ticket["id"] = scoped(story.get("id"))
395
+ ticket["blocked_by"] = [scoped(d) for d in (story.get("depends_on") or [])]
354
396
  # The story's OWN id is kept because the file on disk is named after it, not after the
355
397
  # synthesized ticket id — see `_ticket_files`.
356
398
  ticket["_legacy_story_id"] = str(story.get("id") or "")
@@ -461,6 +503,8 @@ def refs_resolve(c: Corpus, r: Result) -> None: # was V6
461
503
  defined.add(str(spec.get("id")))
462
504
  for _, ticket in c.tickets():
463
505
  defined.add(str(ticket.get("id")))
506
+ # Withdrawn, therefore still defined. This is the whole point of keeping the row.
507
+ defined |= {str(row.get("id")) for row in c.withdrawn_rows if row.get("id") is not None}
464
508
 
465
509
  refs: list[tuple[str, str]] = []
466
510
  for cap in c.caps:
@@ -482,9 +526,21 @@ def refs_resolve(c: Corpus, r: Result) -> None: # was V6
482
526
  refs += [(str(ticket.get("id")), u) for u in listy(ticket, "satisfies")]
483
527
  refs += [(str(ticket.get("id")), b) for b in listy(ticket, "blocked_by")]
484
528
 
529
+ # A promise's id going missing has one likely cause and one wrong-looking-obvious repair. The
530
+ # cause: the row was DELETED when the product stopped promising it. The wrong repair: edit the
531
+ # reference — which `corpus-guide.md` refuses, because a `DEC-` records what happened and it did
532
+ # serve that promise at the time. One repo carried twelve of these before anyone worked out that
533
+ # the row was meant to stay, so the route travels with the finding.
534
+ promise_id = re.compile(r"^(BG|CAP|FR|NFR|UC)-\d+$")
485
535
  for owner, target in sorted(set(refs)):
486
536
  if target and target not in defined:
487
- r.fail("refs-resolve", owner, f"points to `{target}` which does not exist in any registry")
537
+ hint = ""
538
+ if promise_id.match(target):
539
+ hint = (" — if it was withdrawn, the row STAYS with `status: withdrawn` and a "
540
+ "`withdrawn_by`, and deleting it is what broke this reference (corpus-guide.md). "
541
+ "Editing the reference instead rewrites a record of the past")
542
+ r.fail("refs-resolve", owner,
543
+ f"points to `{target}` which does not exist in any registry{hint}")
488
544
 
489
545
 
490
546
  def _cycles(graph: dict[str, list[str]]) -> list[str]:
@@ -1079,18 +1135,64 @@ def _dec_date(c: Corpus, dec: dict) -> dt.date | None:
1079
1135
  return None
1080
1136
 
1081
1137
 
1138
+ # A mandate whose authority ONCE stood. `accepted` and `applied` are live; `superseded` is retired,
1139
+ # and retirement is NOT retroactive — the owner accepted it in person, and what was taken under it
1140
+ # while it stood stays accepted. Reading this status as present tense is what turned a whole run's
1141
+ # decisions red the day the owner changed one setting of the mandate, with no repair available that
1142
+ # does not falsify the record: the decisions are frozen, and the supersession really happened.
1143
+ MANDATE_STOOD = ("accepted", "applied", "superseded")
1144
+
1145
+
1146
+ def _dec_fm(c: Corpus, dec: dict) -> dict:
1147
+ """The frontmatter of a decision's own file — `supersedes`/`superseded_by` live there in the
1148
+ template, and a product that wrote them only in the file is not wrong."""
1149
+ did = str(dec.get("id") or "")
1150
+ if not did:
1151
+ return {}
1152
+ for path in sorted(c.root.glob(f".control/decisions/{did}-*.md")):
1153
+ return frontmatter(path) or {}
1154
+ return {}
1155
+
1156
+
1157
+ def _revoked_on(c: Corpus, mandate: dict, by_id: dict[str, dict]) -> dt.date | None:
1158
+ """The day a superseded mandate stopped delegating: the date of the decision that replaced it.
1159
+
1160
+ For a mandate, supersession IS revocation — `wdi-autopilot` names it as the way a run is ended
1161
+ for good, and the way a setting is changed. So it binds tighter than `expires`, and the window
1162
+ ends at whichever of the two came first.
1163
+ """
1164
+ if str(mandate.get("status") or "") != "superseded":
1165
+ return None
1166
+ ref = str(mandate.get("superseded_by") or _dec_fm(c, mandate).get("superseded_by") or "").strip()
1167
+ if not ref:
1168
+ return None
1169
+ return _dec_date(c, by_id.get(ref) or {})
1170
+
1171
+
1082
1172
  def mandate_accept(c: Corpus, r: Result) -> None:
1083
- """A decision accepted BY DELEGATION points at a real mandate that had not lapsed when it was taken.
1173
+ """A decision accepted BY DELEGATION points at a mandate that stood, and had not ended, when it was taken.
1084
1174
 
1085
1175
  `wdi-autopilot` lets the agent accept decisions the owner would have accepted, and that is legal
1086
1176
  only because the owner accepted the MANDATE in person. So three things hold: a mandate is never
1087
- itself accepted by another decision — the chain of authority has a person at its root; an accepted
1088
- mandate names the day it ends, or it is standing permission; and a decision whose `accepted_by` is
1089
- a `DEC-` names one that is `type: mandate`, accepted, and unexpired on the decision's own date.
1177
+ itself accepted by another decision — the chain of authority has a person at its root; a mandate
1178
+ that stood names the day it ends, or it is standing permission; and a decision whose `accepted_by`
1179
+ is a `DEC-` names one that is `type: mandate`, stood on the decision's own date, and had not ended
1180
+ by then — expired, or superseded, whichever came first.
1181
+
1182
+ Every question it asks is about the PAST, so every answer is read from the past. A mandate's
1183
+ status today says when its authority ENDED, never that it was never granted: `superseded` is a
1184
+ retired mandate, and the decisions taken under it while it stood are still accepted. The one
1185
+ thing supersession does change is the window — see `_revoked_on`.
1090
1186
 
1091
1187
  It says nothing about WHAT was decided — that is the ledger's job and the owner's review.
1092
1188
  """
1093
1189
  by_id = {str(d.get("id")): d for d in c.decs}
1190
+ # Which mandates were actually USED. A retired mandate's obligations are read from what was taken
1191
+ # under it, not from its status: superseding one that delegated nothing owes no account of a run
1192
+ # that never happened, and superseding one that delegated forty decisions owes exactly what it
1193
+ # owed the day before — otherwise supersession is a way to make the ledger demand disappear.
1194
+ delegated_under = {str(d.get("accepted_by") or "").strip() for d in c.decs
1195
+ if str(d.get("type") or "") != "mandate"}
1094
1196
  for dec in c.decs:
1095
1197
  did = str(dec.get("id"))
1096
1198
  ref = str(dec.get("accepted_by") or "").strip()
@@ -1100,7 +1202,7 @@ def mandate_accept(c: Corpus, r: Result) -> None:
1100
1202
  r.fail("mandate-accept", did,
1101
1203
  f"is a mandate accepted by delegation (`accepted_by: {ref}`) — the mandate is the one "
1102
1204
  f"decision the owner accepts in person")
1103
- if status in ("accepted", "applied"):
1205
+ if status in ("accepted", "applied") or (status == "superseded" and did in delegated_under):
1104
1206
  if not ref:
1105
1207
  r.fail("mandate-accept", did, "is an accepted mandate and `accepted_by` names nobody — "
1106
1208
  "a person and a date is enough")
@@ -1121,6 +1223,17 @@ def mandate_accept(c: Corpus, r: Result) -> None:
1121
1223
  # silently disables the lapse comparison for every decision taken under this mandate.
1122
1224
  r.fail("mandate-accept", did, f"`mandate.expires: {raw}` is not a date — write `YYYY-MM-DD`. "
1123
1225
  f"An expiry nothing can read stops nothing")
1226
+ if status == "superseded" and _revoked_on(c, dec, by_id) is None:
1227
+ # Retiring a mandate and not dating the retirement leaves the delegation reading as
1228
+ # good until `expires` — the opposite of what superseding it was for. Same failure as
1229
+ # the unparseable expiry above: a bound that looks present and compares nothing.
1230
+ r.fail("mandate-accept", did,
1231
+ "is a superseded mandate with decisions accepted under it, and nothing dates the "
1232
+ "supersession — `superseded_by` naming the decision that replaced it, and "
1233
+ "`supersedes` back on that one (both sides, decision-guide.md), is what says when "
1234
+ "the delegation was revoked. Until it is there the mandate reads as delegating "
1235
+ "right up to its `expires`. Recording a supersession is the one edit an applied "
1236
+ "decision allows")
1124
1237
  continue
1125
1238
  if not ref.startswith("DEC-"):
1126
1239
  continue
@@ -1132,19 +1245,26 @@ def mandate_accept(c: Corpus, r: Result) -> None:
1132
1245
  r.fail("mandate-accept", did, f"`accepted_by: {ref}` is not a `type: mandate` decision — only a mandate "
1133
1246
  f"delegates acceptance")
1134
1247
  continue
1135
- if str(target.get("status") or "") not in ("accepted", "applied"):
1136
- r.fail("mandate-accept", did, f"`accepted_by: {ref}` is `{target.get('status')}`, not accepted "
1137
- f"nothing was delegated yet")
1248
+ if str(target.get("status") or "") not in MANDATE_STOOD:
1249
+ r.fail("mandate-accept", did, f"`accepted_by: {ref}` is `{target.get('status')}` nothing was ever "
1250
+ f"delegated. A mandate delegates from `accepted` onward, and a "
1251
+ f"`superseded` one still stands for what was taken before it ended")
1138
1252
  continue
1139
1253
  params = target.get("mandate") if isinstance(target.get("mandate"), dict) else {}
1140
1254
  expires = _dec_date(c, {"date": params.get("expires")})
1255
+ revoked = _revoked_on(c, target, by_id)
1141
1256
  when = _dec_date(c, dec)
1257
+ # Whichever end came first is the one that counts.
1258
+ limit, ended, tail = expires, "expired on", "the delegation had lapsed"
1259
+ if revoked and (expires is None or revoked < expires):
1260
+ limit, ended, tail = (revoked, "was superseded on",
1261
+ "the delegation was revoked then, whatever its `expires` still says")
1142
1262
  if when is None:
1143
1263
  r.fail("mandate-accept", did, f"is accepted under `{ref}` but no date says when — `date:` in its "
1144
- f"frontmatter is what the expiry is checked against")
1145
- elif expires and when > expires:
1146
- r.fail("mandate-accept", did, f"was taken on {when.isoformat()}, after `{ref}` expired on "
1147
- f"{expires.isoformat()} — the delegation had lapsed")
1264
+ f"frontmatter is what the mandate's window is checked against")
1265
+ elif limit and when > limit:
1266
+ r.fail("mandate-accept", did, f"was taken on {when.isoformat()}, after `{ref}` {ended} "
1267
+ f"{limit.isoformat()} — {tail}")
1148
1268
 
1149
1269
 
1150
1270
  def defect_root_cause(c: Corpus, r: Result) -> None: # was V20
@@ -1246,18 +1366,62 @@ PRUNE_DIRS = frozenset({
1246
1366
  })
1247
1367
 
1248
1368
 
1369
+ _IGNORED: dict[Path, frozenset[str]] = {}
1370
+
1371
+
1372
+ def _git_ignored(root: Path) -> frozenset[str]:
1373
+ """What git ignores in this tree, repo-relative posix — a whole ignored directory as `name/`.
1374
+
1375
+ ONE call for the whole run. `git check-ignore` per folder inside `os.walk` is one subprocess per
1376
+ folder, and on a big tree that costs more than the walk it is protecting.
1377
+
1378
+ Git answers rather than a hand-written parser, for the reason `_ignore_rule` sets out: nested
1379
+ `.gitignore` files, negation with `!`, and `core.excludesFile` are exactly where a parser of this
1380
+ one repo is wrong. `--directory` collapses a wholly-ignored folder into a single entry, which is
1381
+ the granularity the walker prunes at — and a folder holding TRACKED files is never collapsed, so
1382
+ corpus that is in git cannot be pruned away by this.
1383
+
1384
+ Outside a repo, or with no git, this is empty and PRUNE_DIRS carries the walk alone. That is why
1385
+ PRUNE_DIRS stays: it is the fallback, and a `node_modules/` nobody remembered to ignore still has
1386
+ to be pruned — a dangling symlink in one took a whole run down once.
1387
+ """
1388
+ got = _IGNORED.get(root)
1389
+ if got is None:
1390
+ out = git(root, "ls-files", "--others", "--ignored", "--exclude-standard", "--directory", "-z")
1391
+ got = frozenset(x for x in (out or "").split("\0") if x)
1392
+ _IGNORED[root] = got
1393
+ return got
1394
+
1395
+
1249
1396
  def _walk_corpus(root: Path, suffixes: tuple[str, ...]) -> list[Path]:
1250
- """Every file under `root` with one of `suffixes`, sorted, pruning PRUNE_DIRS as it goes.
1397
+ """Every file under `root` with one of `suffixes`, sorted, pruning PRUNE_DIRS and what git ignores.
1398
+
1399
+ Ignored material is not this product's corpus: it is not in the clone, nobody reviews it, and
1400
+ nothing in it can be repaired by the reader of a finding. A vendored upstream checkout under
1401
+ `.temp/` produced 172 `cites-resolve` findings in one repo, every one of them about somebody
1402
+ else's source tree.
1251
1403
 
1252
1404
  Sorted because determinism is this script's contract: two runs over the same tree MUST report the
1253
1405
  same thing in the same order.
1254
1406
  """
1407
+ ignored = _git_ignored(root)
1408
+
1409
+ def rel(path: Path) -> str:
1410
+ try:
1411
+ return path.relative_to(root).as_posix()
1412
+ except ValueError: # a walk that left the tree — treat it as unignored and let PRUNE_DIRS rule
1413
+ return ""
1414
+
1255
1415
  out: list[Path] = []
1256
1416
  for dirpath, dirnames, filenames in os.walk(root, onerror=lambda _e: None):
1257
- dirnames[:] = sorted(d for d in dirnames if d not in PRUNE_DIRS)
1417
+ here = Path(dirpath)
1418
+ dirnames[:] = sorted(d for d in dirnames
1419
+ if d not in PRUNE_DIRS and f"{rel(here / d)}/" not in ignored)
1258
1420
  for name in filenames:
1259
- if name.endswith(suffixes):
1260
- out.append(Path(dirpath) / name)
1421
+ # An ignored FILE inside a folder that is otherwise corpus: git lists it on its own,
1422
+ # because `--directory` only collapses folders that are ignored whole.
1423
+ if name.endswith(suffixes) and rel(here / name) not in ignored:
1424
+ out.append(here / name)
1261
1425
  return sorted(out)
1262
1426
 
1263
1427
 
@@ -1619,6 +1783,47 @@ def engines_invocable(c: Corpus, r: Result) -> None:
1619
1783
  f"update` restores it; `npx wdi-method engines --fix` strips it back out")
1620
1784
 
1621
1785
 
1786
+ def withdrawn_recorded(c: Corpus, r: Result) -> None:
1787
+ """Two things, and without either one `withdrawn` is just a word that quiets a validator.
1788
+
1789
+ **It names the decision.** Withdrawing a promise is decision-worthy on the method's own terms —
1790
+ `corpus-guide.md` lists "no `BG`/`CAP`/`FR`/`NFR`/`UC`/`LC` id is born, renamed, or retired" as a
1791
+ test for whether something is a `DEC-`. So `withdrawn_by` MUST name one that exists.
1792
+
1793
+ **It does not orphan what is left.** A live `FR` whose capability is withdrawn still promises
1794
+ something whose capability nobody promises any more. Withdrawal that takes half a chain with it
1795
+ silently is worse than the deletion this replaced, because at least deletion went red.
1796
+ """
1797
+ dec_ids = {str(d.get("id")) for d in c.decs}
1798
+ for row in c.withdrawn_rows:
1799
+ rid = str(row.get("id") or "")
1800
+ by = str(row.get("withdrawn_by") or "").strip()
1801
+ if not by:
1802
+ r.fail("withdrawn-recorded", rid, "is `status: withdrawn` and names no `withdrawn_by`. "
1803
+ "Withdrawing a promise is a decision — name the `DEC-` that took it, or the "
1804
+ "word is only silencing a validator")
1805
+ elif by not in dec_ids:
1806
+ r.fail("withdrawn-recorded", rid, f"names `withdrawn_by: {by}`, which is not a decision in "
1807
+ f"`decisions.yaml`")
1808
+
1809
+ withdrawn_ids = {str(row.get("id")) for row in c.withdrawn_rows}
1810
+ if not withdrawn_ids:
1811
+ return
1812
+ for row, parent_key, what in ([(x, "goal", "goal") for x in c.caps]
1813
+ + [(x, "capability", "capability") for x in c.frs]
1814
+ + [(x, "capability", "capability") for x in c.nfrs]):
1815
+ parent = str(row.get(parent_key) or "").strip()
1816
+ if parent and parent in withdrawn_ids:
1817
+ r.fail("withdrawn-recorded", str(row.get("id")),
1818
+ f"is live, and the {what} it hangs off (`{parent}`) is withdrawn. Withdraw this row "
1819
+ f"too, or move it under something still promised")
1820
+ for uc in c.ucs:
1821
+ for fr in listy(uc, "satisfies"):
1822
+ if fr in withdrawn_ids:
1823
+ r.fail("withdrawn-recorded", str(uc.get("id")),
1824
+ f"is live and satisfies `{fr}`, which is withdrawn")
1825
+
1826
+
1622
1827
  def id_allocated_once(c: Corpus, r: Result) -> None: # was V28
1623
1828
  """One id, one row — across every file the requirement registry is split into.
1624
1829
 
@@ -1653,7 +1858,7 @@ def run_checks(c: Corpus, asof: dt.date) -> Result:
1653
1858
  # no two copies left to compare.
1654
1859
  # V19 is REPEALED. It checked one line item — an `RTR-` file in .control/reports/ — and the
1655
1860
  # retrospective it archived was the only thing spec size `L` ever decided. Both went together.
1656
- for fn in (goal_has_fr, fr_has_uc, uc_scheduled, ticket_has_test, nfr_has_enforcer, refs_resolve, no_cycles, applied_dec_touches, locked_gate_passed, parallel_tickets_blocked, lc_registered, review_trace, chain_links, memlog_home, spec_names_release_prd, ticket_status_one_home, defect_root_cause, entity_one_writer, spec_after_g4, high_risk_named, mandate_accept, cites_resolve, container_built, custom_room_declared, corpus_in_git, engines_invocable, id_allocated_once):
1861
+ for fn in (goal_has_fr, fr_has_uc, uc_scheduled, ticket_has_test, nfr_has_enforcer, refs_resolve, no_cycles, applied_dec_touches, locked_gate_passed, parallel_tickets_blocked, lc_registered, review_trace, chain_links, memlog_home, spec_names_release_prd, ticket_status_one_home, defect_root_cause, entity_one_writer, spec_after_g4, high_risk_named, mandate_accept, cites_resolve, container_built, custom_room_declared, corpus_in_git, engines_invocable, withdrawn_recorded, id_allocated_once):
1657
1862
  fn(c, r)
1658
1863
  plan_dates(c, r, asof)
1659
1864
  return r
@@ -1856,7 +2061,7 @@ def gen_status(c: Corpus, rtm: dict, result: Result) -> dict:
1856
2061
  per_spec.append({"spec": wid, "status": spec.get("status"),
1857
2062
  "tickets_done": done, "tickets_total": len(items),
1858
2063
  "work_progress": _pct(done, len(items))})
1859
- applicable = 27 # goal-has-fr..id-allocated-once minus V10 and V19, both repealed
2064
+ applicable = 28 # goal-has-fr..id-allocated-once minus V10 and V19, both repealed
1860
2065
  return {
1861
2066
  "promise_progress": _pct(green, len(counted)),
1862
2067
  "rtm_rows": {"green": green, "counted": len(counted),
@@ -25,7 +25,8 @@ awake to refuse them — and the run would renew its own authority. The lapsed d
25
25
  expiry ends the run instead of restarting it.
26
26
 
27
27
  Typing `/wdi-autopilot` while a mandate is active opens the iteration door, not the preflight. To change a
28
- setting, the owner supersedes the mandate with a new one — `wdi-decision` owns supersession.
28
+ setting, the owner supersedes the mandate with a new one — `wdi-decision` owns supersession. A superseded
29
+ mandate keeps everything it already accepted: the delegation ends that day, it does not unwind.
29
30
 
30
31
  ## Door 1 — Preflight
31
32
 
@@ -237,7 +238,7 @@ Stated on the preflight page, because a run nobody can stop is not a run anybody
237
238
  |---|---|---|
238
239
  | Pause | Cancel the loop, or interrupt the session | The current iteration finishes its step and lands its ledger row. Nothing is left half-written |
239
240
  | Resume | `/wdi-autopilot` again, or start the loop again | The mandate is still active, so it comes in through the iteration door and continues from `## Resume` |
240
- | End it for good | Supersede the mandate through `wdi-decision`, or let `expires` pass | **Cancelling the loop does NOT revoke the mandate.** Until it is superseded or lapses, any later firing resumes the run |
241
+ | End it for good | Supersede the mandate through `wdi-decision`, or let `expires` pass | **Cancelling the loop does NOT revoke the mandate.** Until it is superseded or lapses, any later firing resumes the run. The supersession names its replacement on both sides — that date is what revoked the delegation, and `mandate-accept` asks for it once the mandate has accepted anything |
241
242
 
242
243
  ## The ledger
243
244