design-playbook 0.22.0 → 0.22.1

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.
package/codex/AGENTS.md CHANGED
@@ -1,4 +1,4 @@
1
- <!-- generated-by design-playbook v0.22.0 -->
1
+ <!-- generated-by design-playbook v0.22.1 -->
2
2
  # design-playbook for Codex
3
3
 
4
4
  ## Install (path of record)
@@ -16,7 +16,7 @@ python <plugin>/scripts/run_status.py [.scratch/<run>] [--json] [--list] [--scra
16
16
  - `--json` emits machine-readable stages + `next` action, plus the continuation block: selected run, current phase, blocker, run integrity, and the Console capability receipt (`implementation` / `validation` / `availability` / `publicClaim`).
17
17
  - `--list` only lists discovered runs.
18
18
 
19
- For an eligible run the continuation names an explicit `open-console` command for the local, experimental, trial-gated Run Console — no authorized external trial or public release is claimed until the separately authorized read-only trial gate passes (ADR-0043). `run-status` never starts a server, daemon, or background process itself; an ineligible run reports the blocking reason and a safe fallback instead.
19
+ For an eligible run the continuation names an explicit `open-console` command for the local, experimental, trial-gated Run Console — no authorized external trial or public release is claimed until the separately authorized read-only trial gate passes (ADR-0043). `run-status` never starts a server, daemon, or background process itself; an ineligible run reports the blocking reason and a safe fallback instead. On Windows the emitted command line is PowerShell syntax (single-quoted literals behind the `&` call operator) — paste it into PowerShell, not cmd.exe.
20
20
 
21
21
  ## Done when
22
22
 
@@ -134,14 +134,22 @@ def _point_back_verdict(run_root: Path) -> str | None:
134
134
  decision 4). Reading goes through the single verdict-syntax parser
135
135
  (ADR-0025) that G3 and run status already share - never a second
136
136
  parser here. ``None`` (missing, malformed, ambiguous, or repeated
137
- verdict text) is never a Pass.
137
+ verdict text) is never a Pass. An ``audited:`` marker that is
138
+ false or ambiguous withholds the verdict too - the same fence
139
+ ``verdict_of`` applies on the run-status surface, so the two
140
+ authorities can never disagree on one run. Legacy point-backs
141
+ without a marker stay readable on both surfaces.
138
142
  """
143
+ from design_playbook.scripts.audit_preferences import parse_audit_marker
139
144
  from design_playbook.scripts.verdict_syntax import parse_verdict
140
145
 
141
146
  try:
142
147
  text = (run_root / "point-back.md").read_text(encoding="utf-8")
143
148
  except (OSError, UnicodeError):
144
149
  return None
150
+ marker = parse_audit_marker(text)
151
+ if marker.present and marker.audited is not True:
152
+ return None
145
153
  return parse_verdict(text).canonical
146
154
 
147
155
 
@@ -311,6 +311,57 @@ class BuildStaticHandoffTests(unittest.TestCase):
311
311
  ["pass"] * 5 + ["not-applicable"] * 3,
312
312
  )
313
313
 
314
+ def test_unaudited_marker_withholds_the_delivery_pass(self) -> None:
315
+ """The audit-marker fence is shared with run status (``verdict_of``):
316
+ an ``audited: false`` or duplicated marker withholds the point-back
317
+ verdict here too, so the handoff credential and run status can
318
+ never disagree on one run. Legacy point-backs without a marker and
319
+ a single ``audited: true`` stay readable."""
320
+ import tempfile
321
+
322
+ faces = (
323
+ (
324
+ "audited-false",
325
+ "# point-back\n\naudited: false\n\n## Verdict\n\n**Pass.**\n",
326
+ ),
327
+ (
328
+ "duplicated-marker",
329
+ "# point-back\n\naudited: true\naudited: false\n\n"
330
+ "## Verdict\n\n**Pass.**\n",
331
+ ),
332
+ )
333
+ with tempfile.TemporaryDirectory() as tmp_s:
334
+ tmp = Path(tmp_s)
335
+ for label, point_back in faces:
336
+ with self.subTest(face=label):
337
+ run_root = _make_run(tmp, name=f"run-{label}")
338
+ (run_root / "point-back.md").write_text(
339
+ point_back, encoding="utf-8"
340
+ )
341
+ result = self._build(tmp, run_root)
342
+ payload = result.payload
343
+ # Confirmed round, complete capture, resolved gates -
344
+ # still no Pass: the marker fence outranks the verdict
345
+ # text.
346
+ self.assertEqual(payload["verdict"], "Recirculate")
347
+ self.assertEqual(payload["authority"], "confirmed-user")
348
+
349
+ def test_audited_marker_true_keeps_the_delivery_pass(self) -> None:
350
+ """A single well-formed ``audited: true`` marker does not withhold
351
+ the verdict - only false/ambiguous markers do (legacy runs carry
352
+ no marker at all and stay readable on both surfaces)."""
353
+ import tempfile
354
+
355
+ with tempfile.TemporaryDirectory() as tmp_s:
356
+ tmp = Path(tmp_s)
357
+ run_root = _make_run(tmp)
358
+ (run_root / "point-back.md").write_text(
359
+ "# point-back\n\naudited: true\n\n## Verdict\n\n**Pass.**\n",
360
+ encoding="utf-8",
361
+ )
362
+ result = self._build(tmp, run_root)
363
+ self.assertEqual(result.payload["verdict"], "Pass")
364
+
314
365
  def test_floor_failing_record_is_never_confirmed(self) -> None:
315
366
  import tempfile
316
367
 
@@ -95,6 +95,16 @@ def test_invalid_explicit_locale_fails_before_writing(tmp_path):
95
95
  assert list(tmp_path.iterdir()) == []
96
96
 
97
97
 
98
+ @pytest.mark.parametrize("blank", ["", " "])
99
+ def test_blank_explicit_locale_falls_back_like_unspecified(blank):
100
+ """A blank explicit locale encodes absence (form/MCP callers), not a
101
+ bad locale: it must follow the env policy, not raise."""
102
+ with patch.dict("os.environ", {"LANG": "en_US.UTF-8"}, clear=True):
103
+ assert resolve_ui_locale(blank) == "en"
104
+ with patch.dict("os.environ", {}, clear=True):
105
+ assert resolve_ui_locale(blank) == "zh-CN"
106
+
107
+
98
108
  def test_cli_exposes_explicit_locale(capsys):
99
109
  with pytest.raises(SystemExit) as exit_info:
100
110
  main(["--help"])
@@ -12,19 +12,13 @@ from design_playbook.mcp.ui_locale import EN, ZH, resolve_ui_locale
12
12
 
13
13
  _STRINGS: dict[str, dict[str, str]] = {
14
14
  ZH: {
15
- "region_label": "预览确认",
16
15
  "round_n": "第 {n} 轮",
17
16
  "annotate": "批注",
18
- "pill_open": "评审确认",
19
17
  "ready": "可确认",
20
- "not_ready": "写意见或批注后再确认",
21
- "drawer_aria": "批注与确认",
22
18
  "drawer_title": "批注与确认",
23
19
  "drawer_empty_title": "还没有批注",
24
20
  "drawer_empty_desc": "用点选工具放置 Pin,按 B 框选区域,或按 D 圈画问题;也可在下方写整体意见",
25
21
  "collapse": "收起",
26
- "pin_toggle": "点选批注",
27
- "pin_toggle_desc": "开启后点击页面元素添加批注锚点;再点一次或按 Esc 退出",
28
22
  "duplicate_anchor": "该元素已是批注 {n},未重复添加",
29
23
  "onboard_title": "标注速览(仅出现一次)",
30
24
  "onboard_pick": "点「批注」后点选元素,按 B 拖拽框选,或按 R 临时测距",
@@ -32,18 +26,10 @@ _STRINGS: dict[str, dict[str, str]] = {
32
26
  "onboard_submit": "提交确认",
33
27
  "onboard_undo": "撤销批注(Shift 重做)",
34
28
  "onboard_close": "知道了",
35
- "pin_count": "已选 {n} 处",
36
- "pin_on": "点选中 · 再点关闭",
37
- "pin_off": "点选批注",
38
29
  "anchor_num_pre": "锚点 ",
39
30
  "anchor_num_post": " 的修改意见",
40
31
  "anchor_placeholder": "这条要改什么",
41
32
  "remove_num_pre": "移除锚点 ",
42
- "pin_count_pre": "已选 ",
43
- "pin_count_post": " 处",
44
- # JS-side dynamic anchors (split pre/post because idx is a JS value):
45
- "anchors_head": "选中批注",
46
- "anchors_empty": "尚未选中元素。点上方「点选批注」后,再点页面上的元素。",
47
33
  "field_label": "整体批注",
48
34
  "field_hint": "请写整体意见或点选元素加批注后确认;空反馈会被拦下",
49
35
  "field_placeholder": "对整页的总意见;页面元素的局部问题用「点选批注」",
@@ -55,14 +41,9 @@ _STRINGS: dict[str, dict[str, str]] = {
55
41
  "terminate_desc": "终止整次预览会话(不是通过/修改);需在弹出层再次确认",
56
42
  "abort_cancelled": "已取消终止",
57
43
  "abort_popover_aria": "确认终止预览",
58
- "quick_feedback_placeholder": "一句话反馈(宽屏)",
59
- "ready_hint": "点击聚焦反馈区",
60
44
  "draft": "保留批注,暂不决定",
61
45
  "draft_desc": "关闭抽屉并保留批注,不提交本轮决定",
62
- "locate": "定位到该元素",
63
- "locate_anchor": "定位到该元素",
64
46
  "remove": "移除",
65
- "selected_n": "已选 {n} 处",
66
47
  "anchor_note_label": "锚点批注 ({n}):",
67
48
  "no_text": "(无文字)",
68
49
  "done_title": "已记录",
@@ -144,31 +125,20 @@ _STRINGS: dict[str, dict[str, str]] = {
144
125
  "toast_drawer_open": "展开批注抽屉",
145
126
  "toast_drawer_closed": "已收起批注抽屉(点击右侧把手或按 [ 展开)",
146
127
  "toast_lang": "已切换至中文模式",
147
- "toast_undo": "已撤销上一笔圈画",
148
128
  "toast_focus": "聚焦批注 #{n}",
149
129
  "toast_vp": "已切换视口宽度",
150
130
  "confirm_desc": "确认本轮通过并进入实现(Fill);需有批注或整体意见",
151
131
  "revise": "需要修改",
152
132
  "revise_desc": "提交修改意见,进入下一轮原型",
153
- "floor_failure_empty": "confirm with no substantive feedback: empty feedback and no anchor with a non-empty comment",
154
- "floor_failure_selector": "anchor missing non-empty selector and comment: selector={sel!r} comment={note!r}",
155
- "log_anchor_missing": "锚点 #{n} 缺 selector 或 comment",
156
- "self_check_passed": "FLOOR SELF-CHECK PASSED",
157
133
  },
158
134
  EN: {
159
- "region_label": "Preview confirm",
160
135
  "round_n": "Round {n}",
161
136
  "annotate": "Annotate",
162
- "pill_open": "Review & confirm",
163
137
  "ready": "Ready",
164
- "not_ready": "Add notes or pin to confirm",
165
- "drawer_aria": "Annotate & confirm",
166
138
  "drawer_title": "Annotations & confirm",
167
139
  "drawer_empty_title": "No annotations yet",
168
140
  "drawer_empty_desc": "Use pin mode to place a note, press B for a box, or D to draw; you can also write overall feedback below",
169
141
  "collapse": "Collapse",
170
- "pin_toggle": "Pick to annotate",
171
- "pin_toggle_desc": "When on, click page elements to add annotation anchors; click again or press Esc to exit",
172
142
  "duplicate_anchor": "Already annotated as #{n}; not added again",
173
143
  "onboard_title": "Annotation quick tour (shown once)",
174
144
  "onboard_pick": "Click Annotate to pick elements, press B to draw a box, or press R to measure spacing",
@@ -176,17 +146,10 @@ _STRINGS: dict[str, dict[str, str]] = {
176
146
  "onboard_submit": "Submit confirm",
177
147
  "onboard_undo": "Undo annotation (Shift to redo)",
178
148
  "onboard_close": "Got it",
179
- "pin_count": "{n} selected",
180
- "pin_on": "Picking · click again to close",
181
- "pin_off": "Pick to annotate",
182
149
  "anchor_num_pre": "Anchor ",
183
150
  "anchor_num_post": " revision note",
184
151
  "anchor_placeholder": "What to change here",
185
152
  "remove_num_pre": "Remove anchor ",
186
- "pin_count_pre": "",
187
- "pin_count_post": " selected",
188
- "anchors_head": "Selected annotations",
189
- "anchors_empty": "No element selected. Click \"Pick to annotate\" above, then click an element on the page.",
190
153
  "field_label": "Overall feedback",
191
154
  "field_hint": "Write overall notes or pick an element to annotate before confirming; empty feedback is blocked",
192
155
  "field_placeholder": "Overall notes for the page; for a specific element use \"Pick to annotate\"",
@@ -198,14 +161,9 @@ _STRINGS: dict[str, dict[str, str]] = {
198
161
  "terminate_desc": "Stops the whole preview session (not pass/fail); confirm again in the popover",
199
162
  "abort_cancelled": "Abort cancelled",
200
163
  "abort_popover_aria": "Confirm abort preview",
201
- "quick_feedback_placeholder": "Quick note (wide layout)",
202
- "ready_hint": "Click to focus feedback",
203
164
  "draft": "Keep notes, decide later",
204
165
  "draft_desc": "Close drawer and keep notes; do not submit a decision yet",
205
- "locate": "Locate this element",
206
- "locate_anchor": "Locate this element",
207
166
  "remove": "Remove",
208
- "selected_n": "{n} selected",
209
167
  "anchor_note_label": "Anchor notes ({n}):",
210
168
  "no_text": "(no text)",
211
169
  "done_title": "Recorded",
@@ -287,16 +245,11 @@ _STRINGS: dict[str, dict[str, str]] = {
287
245
  "toast_drawer_open": "Drawer opened",
288
246
  "toast_drawer_closed": "Drawer collapsed (click the right tab or press [ )",
289
247
  "toast_lang": "Switched to English",
290
- "toast_undo": "Undone last drawing",
291
248
  "toast_focus": "Focused note #{n}",
292
249
  "toast_vp": "Viewport width switched",
293
250
  "confirm_desc": "Approve this round and proceed to Fill; needs notes or annotations",
294
251
  "revise": "Needs changes",
295
252
  "revise_desc": "Submit revision notes and open the next prototype round",
296
- "floor_failure_empty": "confirm with no substantive feedback: empty feedback and no anchor with a non-empty comment",
297
- "floor_failure_selector": "anchor missing non-empty selector and comment: selector={sel!r} comment={note!r}",
298
- "log_anchor_missing": "anchor #{n} missing selector or comment",
299
- "self_check_passed": "FLOOR SELF-CHECK PASSED",
300
253
  },
301
254
  }
302
255
 
@@ -1867,6 +1867,29 @@
1867
1867
  );
1868
1868
  }
1869
1869
 
1870
+ function packetNextActionField(primary, field, missingMessage) {
1871
+ var projected = packetFromAssertion(primary);
1872
+ var result = projected.value && typeof projected.value === "object" ? projected.value : null;
1873
+ if (!result) return projected;
1874
+ if (result[field] !== undefined && result[field] !== null) {
1875
+ return packetFact(
1876
+ projected.availability,
1877
+ result[field],
1878
+ projected.reason,
1879
+ projected.sourceId
1880
+ );
1881
+ }
1882
+ if (projected.availability === "known") {
1883
+ return packetGap(missingMessage, projected.sourceId);
1884
+ }
1885
+ return packetFact(
1886
+ projected.availability,
1887
+ null,
1888
+ projected.reason || packetReason(PACKET_NOT_PRODUCED, missingMessage),
1889
+ projected.sourceId
1890
+ );
1891
+ }
1892
+
1870
1893
  function deriveRepairPacket(snapshot) {
1871
1894
  var evaluation = snapshot.evaluation || {};
1872
1895
  var selection = selectBlockingFinding(evaluation.findings);
@@ -1881,12 +1904,16 @@
1881
1904
  declarationOwner: findingFields.declarationOwner,
1882
1905
  repairIntent: findingFields.repairIntent,
1883
1906
  nextOwner: packetNextOwner(primary),
1884
- invalidatedEvidence: packetGap(PACKET_MSG_NO_INVALIDATED),
1885
- /* Snapshot v1 has no explicit resume-stage fact; the latest
1886
- observed stage is progress, not a resume target (spec rule 19). */
1887
- resumeStage: packetGap(PACKET_MSG_NO_RESUME_STAGE),
1907
+ invalidatedEvidence: packetNextActionField(
1908
+ primary, "invalidatedEvidence", PACKET_MSG_NO_INVALIDATED
1909
+ ),
1910
+ resumeStage: packetNextActionField(
1911
+ primary, "resumeStage", PACKET_MSG_NO_RESUME_STAGE
1912
+ ),
1888
1913
  nextCommand: packetNextCommand(primary),
1889
- recaptureRequirement: packetGap(PACKET_MSG_NO_RECAPTURE),
1914
+ recaptureRequirement: packetNextActionField(
1915
+ primary, "recaptureRequirement", PACKET_MSG_NO_RECAPTURE
1916
+ ),
1890
1917
  };
1891
1918
  }
1892
1919
 
@@ -61,6 +61,12 @@ _DYNAMIC_ASSERTION_PATHS = (
61
61
  ("nextActions", "alternatives"),
62
62
  ("limitations", "items"),
63
63
  )
64
+ _RECIRCULATE_ACTION_ID = "action.repair-after-recirculate"
65
+ _RECIRCULATE_ACTION_FIELDS = (
66
+ "invalidatedEvidence",
67
+ "resumeStage",
68
+ "recaptureRequirement",
69
+ )
64
70
 
65
71
 
66
72
  class SnapshotContractError(ValueError):
@@ -393,6 +399,17 @@ def _validate_invariants(document: dict[str, Any]) -> None:
393
399
  _require_sorted_unique([item["id"] for item in _at(document, path)])
394
400
  for assertion in assertions:
395
401
  _validate_assertion(assertion, source_records)
402
+ primary = document["nextActions"]["primary"]
403
+ if (
404
+ primary["availability"] == "known"
405
+ and primary["result"]["actionId"] == _RECIRCULATE_ACTION_ID
406
+ ):
407
+ result = primary["result"]
408
+ if any(field not in result for field in _RECIRCULATE_ACTION_FIELDS):
409
+ raise _SchemaViolation
410
+ invalidated = result["invalidatedEvidence"]
411
+ if invalidated is not None and len(invalidated) != len(set(invalidated)):
412
+ raise _SchemaViolation
396
413
 
397
414
  source_set_hash = _source_set_hash(document["sources"]["items"])
398
415
  if document["sources"]["sourceSetHash"] != source_set_hash:
@@ -19,6 +19,13 @@ disposition: blocking
19
19
 
20
20
  **Recirculate.** A Pass is not earned until the blocking finding is closed.
21
21
 
22
+ ## Invalidated evidence
23
+
24
+ invalidated:
25
+ - criterion: L6.3
26
+ artifacts: [evidence/L6.3-error.png]
27
+ reason: Fill repair changes the destructive-action confirmation surface
28
+
22
29
  ## Evidence ledger
23
30
 
24
31
  ```text
@@ -292,6 +292,39 @@ def _next_command(primary: object) -> dict[str, Any]:
292
292
  )
293
293
 
294
294
 
295
+ def _next_action_field(
296
+ primary: object, field: str, missing_message: str
297
+ ) -> dict[str, Any]:
298
+ """Project one optional owner fact from the primary action."""
299
+ projected = _from_assertion(primary)
300
+ result = projected["value"] if isinstance(projected["value"], dict) else None
301
+ if result is None:
302
+ return projected
303
+ value = result.get(field)
304
+ if isinstance(value, (list, str)) and value:
305
+ return _fact(
306
+ availability=projected["availability"],
307
+ value=value,
308
+ reason=projected["reason"],
309
+ source_id=projected["sourceId"],
310
+ )
311
+ if isinstance(value, list):
312
+ return _fact(
313
+ availability=projected["availability"],
314
+ value=[],
315
+ reason=projected["reason"],
316
+ source_id=projected["sourceId"],
317
+ )
318
+ if projected["availability"] == "known":
319
+ return _gap(missing_message, source_id=projected["sourceId"])
320
+ return _fact(
321
+ availability=projected["availability"],
322
+ value=None,
323
+ reason=projected["reason"] or _reason(NOT_PRODUCED, missing_message),
324
+ source_id=projected["sourceId"],
325
+ )
326
+
327
+
295
328
  def _copy_line(label: str, fact: Mapping[str, Any]) -> str:
296
329
  availability = str(fact.get("availability") or "unknown")
297
330
  reason = fact.get("reason")
@@ -316,6 +349,8 @@ def _copy_line(label: str, fact: Mapping[str, Any]) -> str:
316
349
  else:
317
350
  parts.append(f"{key}={item}")
318
351
  rendered = "; ".join(parts) if parts else "(none)"
352
+ elif isinstance(value, list):
353
+ rendered = ", ".join(str(entry) for entry in value)
319
354
  else:
320
355
  rendered = str(value)
321
356
  stale = ""
@@ -368,12 +403,16 @@ def derive_repair_packet(snapshot: Mapping[str, Any]) -> dict[str, Any]:
368
403
  "verdict": _from_assertion(evaluation["verdict"]),
369
404
  **finding_fields,
370
405
  "nextOwner": _next_owner(primary),
371
- "invalidatedEvidence": _gap(MSG_NO_INVALIDATED),
372
- # Snapshot v1 has no explicit resume-stage fact; the latest
373
- # observed stage is progress, not a resume target (spec rule 19).
374
- "resumeStage": _gap(MSG_NO_RESUME_STAGE),
406
+ "invalidatedEvidence": _next_action_field(
407
+ primary, "invalidatedEvidence", MSG_NO_INVALIDATED
408
+ ),
409
+ "resumeStage": _next_action_field(
410
+ primary, "resumeStage", MSG_NO_RESUME_STAGE
411
+ ),
375
412
  "nextCommand": _next_command(primary),
376
- "recaptureRequirement": _gap(MSG_NO_RECAPTURE),
413
+ "recaptureRequirement": _next_action_field(
414
+ primary, "recaptureRequirement", MSG_NO_RECAPTURE
415
+ ),
377
416
  }
378
417
  packet["copyText"] = format_packet_copy_text(packet)
379
418
  return packet
@@ -536,20 +536,29 @@ class _Builder:
536
536
  preview_snapshot=facts.preview,
537
537
  run_facts=facts,
538
538
  ).primary
539
+ action_result = {
540
+ "actionId": action.action_id,
541
+ "kind": action.kind.value,
542
+ "label": action.label,
543
+ "owner": {
544
+ "actor": action.owner.actor.value,
545
+ "role": action.owner.role,
546
+ },
547
+ "copyableAgentCommand": action.copyable_agent_command,
548
+ }
549
+ if action.action_id == "action.repair-after-recirculate":
550
+ action_result["invalidatedEvidence"] = (
551
+ list(action.invalidated_evidence)
552
+ if action.invalidated_evidence is not None
553
+ else None
554
+ )
555
+ action_result["resumeStage"] = action.resume_stage
556
+ action_result["recaptureRequirement"] = action.recapture_requirement
539
557
  self._draft(
540
558
  "next-actions.primary",
541
559
  (_REF_STATUS,),
542
560
  "known",
543
- {
544
- "actionId": action.action_id,
545
- "kind": action.kind.value,
546
- "label": action.label,
547
- "owner": {
548
- "actor": action.owner.actor.value,
549
- "role": action.owner.role,
550
- },
551
- "copyableAgentCommand": action.copyable_agent_command,
552
- },
561
+ action_result,
553
562
  )
554
563
  # The owner emits no alternatives; the empty list is owner-known.
555
564
 
@@ -506,7 +506,12 @@
506
506
  },
507
507
  "label": { "type": "string", "minLength": 1 },
508
508
  "owner": { "$ref": "#/$defs/nextActionOwner" },
509
- "copyableAgentCommand": { "$ref": "#/$defs/nullableString" }
509
+ "copyableAgentCommand": { "$ref": "#/$defs/nullableString" },
510
+ "invalidatedEvidence": {
511
+ "anyOf": [{ "$ref": "#/$defs/stringArray" }, { "type": "null" }]
512
+ },
513
+ "resumeStage": { "$ref": "#/$defs/nullableString" },
514
+ "recaptureRequirement": { "$ref": "#/$defs/nullableString" }
510
515
  }
511
516
  },
512
517
  "limitationResult": {
@@ -283,6 +283,34 @@ class SnapshotContractShapeTests(unittest.TestCase):
283
283
  self.assertEqual(validated, document)
284
284
  self.assertIsNot(validated, document)
285
285
 
286
+ def test_recirculate_action_requires_owner_continuation_fields(self) -> None:
287
+ def recirculate() -> dict[str, object]:
288
+ document = _valid_snapshot()
289
+ result = document["nextActions"]["primary"]["result"] # type: ignore[index]
290
+ result.update(
291
+ actionId="action.repair-after-recirculate",
292
+ kind="agent-command",
293
+ label="Verdict is Recirculate — repair from point-back findings.",
294
+ invalidatedEvidence=["L6.3"],
295
+ resumeStage="ui-evaluator",
296
+ recaptureRequirement=(
297
+ "Recapture only invalidated evidence, then re-run ui-evaluator."
298
+ ),
299
+ )
300
+ return document
301
+
302
+ self.assertEqual(validate_snapshot(recirculate()), recirculate())
303
+
304
+ missing = recirculate()
305
+ del missing["nextActions"]["primary"]["result"]["resumeStage"] # type: ignore[index]
306
+ duplicated = recirculate()
307
+ duplicated["nextActions"]["primary"]["result"]["invalidatedEvidence"] = [ # type: ignore[index]
308
+ "L6.3", "L6.3"
309
+ ]
310
+ for document in (missing, duplicated):
311
+ with self.subTest(document=document):
312
+ _assert_contract_invalid(self, document)
313
+
286
314
  def test_s03_rejects_missing_and_unknown_fields_at_fixed_boundaries(self) -> None:
287
315
  cases: list[dict[str, object]] = []
288
316
  missing = _valid_snapshot()
@@ -414,18 +414,29 @@ class PassParityTest(_ParityTestCase):
414
414
  )
415
415
 
416
416
  # nextActions: structured next-action owner, alternatives owner-known.
417
+ expected_primary = {
418
+ "actionId": primary.action_id,
419
+ "kind": primary.kind.value,
420
+ "label": primary.label,
421
+ "owner": {
422
+ "actor": primary.owner.actor.value,
423
+ "role": primary.owner.role,
424
+ },
425
+ "copyableAgentCommand": primary.copyable_agent_command,
426
+ }
427
+ if primary.invalidated_evidence is not None:
428
+ expected_primary["invalidatedEvidence"] = list(
429
+ primary.invalidated_evidence
430
+ )
431
+ if primary.resume_stage is not None:
432
+ expected_primary["resumeStage"] = primary.resume_stage
433
+ if primary.recapture_requirement is not None:
434
+ expected_primary["recaptureRequirement"] = (
435
+ primary.recapture_requirement
436
+ )
417
437
  self.assertEqual(
418
438
  document["nextActions"]["primary"]["result"],
419
- {
420
- "actionId": primary.action_id,
421
- "kind": primary.kind.value,
422
- "label": primary.label,
423
- "owner": {
424
- "actor": primary.owner.actor.value,
425
- "role": primary.owner.role,
426
- },
427
- "copyableAgentCommand": primary.copyable_agent_command,
428
- },
439
+ expected_primary,
429
440
  )
430
441
  self.assertEqual(document["nextActions"]["alternatives"], [])
431
442