design-playbook 0.21.1 → 0.21.2

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.21.1 -->
1
+ <!-- generated-by design-playbook v0.21.2 -->
2
2
  # design-playbook for Codex
3
3
 
4
4
  ## Install (path of record)
@@ -2,7 +2,7 @@
2
2
  description: Dual-track UI review emitting the six-block point-back report (ledger / findings / positives / coverage / limitations / verdict)
3
3
  ---
4
4
 
5
- Run skill **ui-evaluator** (pull craft-guard checks when AI slop/motion/loading is in scope). Output issue/source/fix/severity; blocking first.
5
+ Run skill **ui-evaluator** (pull craft-guard checks when AI slop/motion/loading is in scope). Output issue/source/fix/severity/track; blocking first.
6
6
 
7
7
  Scope:
8
8
  $ARGUMENTS
@@ -17,3 +17,12 @@ Registry: `skills/design-playbook/references/rules.md`, full catalog (P3 run:
17
17
  | I18N-01@1 | not-applicable | 单语控制台(zh-CN),无 i18n 声明(无 i18n.* 契约字段,L1 未声明多语言用户群) | - | - | - | 单语声明成立 | - |
18
18
  | PERF-01@1 | applicable | - | clear | 长运行有持续进度感(feed 条目级进度逐 tick 更新;全局暂停 busy 即时反馈) | evidence/L6.2-pause-trace.json 反馈序列 | 反馈相称性未承诺耗时阈值(契约无阈值声明) | - |
19
19
  | SEC-01@1 | not-applicable | 声明范围无敏感操作新增(敏感模拟参数默认脱敏沿用;全局暂停非敏感操作) | - | - | - | 无敏感面可查 | - |
20
+ | COPY-01@1 | blocked | 条目 2026-08-28 注册,晚于本 run 存档(2026-08-14);主动语态与动作命名一致性审查所需的全流程文案清单未采集 | - | - | - | - | - |
21
+ | COPY-02@1 | blocked | 条目 2026-08-28 注册,晚于本 run 存档;用户侧命名审查所需的界面名词与实现命名对照未采集 | - | - | - | - | - |
22
+ | COPY-03@1 | blocked | 条目 2026-08-28 注册,晚于本 run 存档;错误信息语气审查所需的错误态文案样本未采集 | - | - | - | - | - |
23
+ | A11Y-02@1 | blocked | 条目 2026-08-28 注册,晚于本 run 存档;可见键盘焦点判定所需的聚焦态截图与键盘走查未采集(a11y 树无法证明视觉属性) | - | - | - | - | - |
24
+ | CRAFT-09@1 | blocked | 条目 2026-08-28 注册,晚于本 run 存档;归档缺审计面样式源——filled-ui.md 为声明性产物索引不含样式源,candidates/preview 为一次性原型资产而非填充面源码 | - | - | - | - | - |
25
+ | CRAFT-10@1 | blocked | 条目 2026-08-28 注册,晚于本 run 存档;归档缺审计面源标记与结构装置的视觉捕捉——evidence 仅交互轨迹与 a11y 树 JSON,无填充面标记源归档 | - | - | - | - | - |
26
+ | DECIDE-01@1 | applicable | - | clear | 选中方向为全局 run console 构成重组(candidates/console-region.html 草图 + preview round 1/2 用户确认),非未审视的默认外观收敛 | DD-0001 理由可回溯 l1.scenes(切页续读)与 PERF-01 比较轴及经用户确认的布局段突破;DD-0002 理由可回溯 l6.c4(跨视图状态闭环)与运行中心第一步方向——均引用 brief 具体事实 | 常规方向经比较矩阵沿 brief 轴证成,非未审视默认;无基线默认方向身份声明 | - |
27
+
28
+ 注:2026-08-28 注册批(COPY-01/02/03、A11Y-02、CRAFT-09/10、DECIDE-01)晚于本 run 存档;按三态谓词补记——blocked 行在理由列点名缺失的证据面;DECIDE-01 依归档内可读的决策报告求值为 applicable。
@@ -17,3 +17,12 @@ Registry: `skills/design-playbook/references/rules.md`, full catalog (P2 run). S
17
17
  | I18N-01@1 | not-applicable | 单语控制台,无 i18n 声明(无 i18n.* 契约字段,L1 未声明多语言用户群) | - | - | - | 单语声明成立 | - |
18
18
  | PERF-01@1 | blocked | 性能感知需运行时度量,本 run provider 缺度量面(measurement 层不可采) | - | 导出等待仅观察到 busy 态 | 度量面缺席,无法判定反馈与耗时的相称性 | 无法在不承诺阈值的情况下检查例外 | 补采运行时度量后重评;缺口的证据语义见 point-back 覆盖声明 |
19
19
  | SEC-01@1 | not-applicable | 声明范围无敏感操作新增(导出非敏感数据;隐藏敏感列由 column_scope 假设排除) | - | - | - | 无敏感面可查 | - |
20
+ | COPY-01@1 | blocked | 条目 2026-08-28 注册,晚于本 run 存档(2026-08-14);主动语态与动作命名一致性审查所需的全流程文案清单未采集 | - | - | - | - | - |
21
+ | COPY-02@1 | blocked | 条目 2026-08-28 注册,晚于本 run 存档;用户侧命名审查所需的界面名词与实现命名对照未采集 | - | - | - | - | - |
22
+ | COPY-03@1 | blocked | 条目 2026-08-28 注册,晚于本 run 存档;错误信息语气审查所需的错误态文案样本未采集 | - | - | - | - | - |
23
+ | A11Y-02@1 | blocked | 条目 2026-08-28 注册,晚于本 run 存档;可见键盘焦点判定所需的聚焦态截图与键盘走查未采集(a11y 树无法证明视觉属性) | - | - | - | - | - |
24
+ | CRAFT-09@1 | blocked | 条目 2026-08-28 注册,晚于本 run 存档;归档缺审计面样式源——filled-ui.md 为静态替身描述,无填充面样式源文件归档 | - | - | - | - | - |
25
+ | CRAFT-10@1 | blocked | 条目 2026-08-28 注册,晚于本 run 存档;归档缺审计面源标记——filled-ui.md 为静态替身描述;既有截图仅覆盖错误态,无结构装置的视觉捕捉 | - | - | - | - | - |
26
+ | DECIDE-01@1 | applicable | - | clear | 选中方向为按周命名模式(compare 档轻量比较),不落入 2026-08 记录的自默认外观样貌;DD-0001 为 record 档,不在本规则范围 | DD-0002 理由可回溯 l1.target_user 比较轴(周频归档场景下检索是主任务)——引用 brief 具体事实而非通用措辞 | 常规方向经比较矩阵沿 brief 轴证成,非未审视默认;无基线默认方向身份声明 | - |
27
+
28
+ 注:2026-08-28 注册批(COPY-01/02/03、A11Y-02、CRAFT-09/10、DECIDE-01)晚于本 run 存档;按三态谓词补记——blocked 行在理由列点名缺失的证据面;DECIDE-01 依归档内可读的决策报告求值为 applicable。
@@ -17,3 +17,12 @@ Registry: `skills/design-playbook/references/rules.md`. Seven-column rows; after
17
17
  | I18N-01@1 | not-applicable | 单语控制台,无 i18n 声明(无 i18n.* 契约字段,L1 未声明多语言用户群) | - | - | - | 单语声明成立 | - |
18
18
  | PERF-01@1 | blocked | 性能感知需运行时度量,本 run provider 缺度量面(measurement 层不可采) | - | 导出等待仅观察到 busy 态 | 度量面缺席,无法判定反馈与耗时的相称性 | 无法在不承诺阈值的情况下检查例外 | 补采运行时度量后重评;缺口的证据语义见 point-back 覆盖声明 |
19
19
  | SEC-01@1 | not-applicable | 声明范围无敏感操作新增(导出非敏感数据;隐藏敏感列由 column_scope 假设排除) | - | - | - | 无敏感面可查 | - |
20
+ | COPY-01@1 | blocked | 条目 2026-08-28 注册,晚于本 run 存档(2026-08-14);主动语态与动作命名一致性审查所需的全流程文案清单未采集 | - | - | - | - | - |
21
+ | COPY-02@1 | blocked | 条目 2026-08-28 注册,晚于本 run 存档;用户侧命名审查所需的界面名词与实现命名对照未采集 | - | - | - | - | - |
22
+ | COPY-03@1 | blocked | 条目 2026-08-28 注册,晚于本 run 存档;错误信息语气审查所需的错误态文案样本未采集 | - | - | - | - | - |
23
+ | A11Y-02@1 | blocked | 条目 2026-08-28 注册,晚于本 run 存档;可见键盘焦点判定所需的聚焦态截图与键盘走查未采集(a11y 树无法证明视觉属性) | - | - | - | - | - |
24
+ | CRAFT-09@1 | blocked | 条目 2026-08-28 注册,晚于本 run 存档;选择器优先级冲突审查所需的样式源走查未执行 | - | - | - | - | - |
25
+ | CRAFT-10@1 | blocked | 条目 2026-08-28 注册,晚于本 run 存档;结构装置与内容属性对应关系的走查未执行 | - | - | - | - | - |
26
+ | DECIDE-01@1 | not-applicable | 决策报告仅含 record 档 DD-0101,无 compare/explore 档方向决策条目 | - | - | - | - | - |
27
+
28
+ 注:2026-08-28 注册批(COPY-01/02/03、A11Y-02、CRAFT-09/10、DECIDE-01)晚于本 run 存档;按三态谓词补记,blocked 行在理由列记缺失证据面。
@@ -17,3 +17,12 @@ Registry: `skills/design-playbook/references/rules.md`, full catalog (P3 run:
17
17
  | I18N-01@1 | not-applicable | 单语控制台,无 i18n 声明(无 i18n.* 契约字段,L1 未声明多语言用户群) | - | - | - | 单语声明成立 | - |
18
18
  | PERF-01@1 | applicable | - | clear | 长导出有持续进度感(30s 窗口 5 次采样,条目级进度持续更新) | evidence/L6.1-status-trace.json 进度采样序列 | 反馈相称性未承诺耗时阈值(契约无阈值声明) | - |
19
19
  | SEC-01@1 | not-applicable | 声明范围无敏感操作新增(导出非敏感数据;隐藏敏感列由 column_scope 假设排除) | - | - | - | 无敏感面可查 | - |
20
+ | COPY-01@1 | blocked | 条目 2026-08-28 注册,晚于本 run 存档(2026-08-14);主动语态与动作命名一致性审查所需的全流程文案清单未采集 | - | - | - | - | - |
21
+ | COPY-02@1 | blocked | 条目 2026-08-28 注册,晚于本 run 存档;用户侧命名审查所需的界面名词与实现命名对照未采集 | - | - | - | - | - |
22
+ | COPY-03@1 | blocked | 条目 2026-08-28 注册,晚于本 run 存档;错误信息语气审查所需的错误态文案样本未采集 | - | - | - | - | - |
23
+ | A11Y-02@1 | blocked | 条目 2026-08-28 注册,晚于本 run 存档;可见键盘焦点判定所需的聚焦态截图与键盘走查未采集(a11y 树无法证明视觉属性) | - | - | - | - | - |
24
+ | CRAFT-09@1 | blocked | 条目 2026-08-28 注册,晚于本 run 存档;归档缺审计面样式源——无填充产物源文件归档,candidates/preview 为一次性原型资产而非填充面源码 | - | - | - | - | - |
25
+ | CRAFT-10@1 | blocked | 条目 2026-08-28 注册,晚于本 run 存档;归档缺审计面源标记与结构装置的视觉捕捉——evidence 仅交互轨迹 JSON,无填充产物源文件归档 | - | - | - | - | - |
26
+ | DECIDE-01@1 | applicable | - | clear | 选中方向为启用既有 status region 收纳导出任务(candidates/B.html 草图 + preview round 1/2 用户确认),非未审视的默认外观收敛 | DD-0003 理由可回溯 l1.scenes(导出中切页全局可查)与 PERF-01 比较轴;DD-0004 理由可回溯 l6.c2(跨视图状态闭环)与基线 status region 惯例声明——均引用 brief 具体事实 | 常规方向经比较矩阵沿 brief 轴证成,非未审视默认;基线声明的是 status region 惯例而非默认外观身份 | - |
27
+
28
+ 注:2026-08-28 注册批(COPY-01/02/03、A11Y-02、CRAFT-09/10、DECIDE-01)晚于本 run 存档;按三态谓词补记——blocked 行在理由列点名缺失的证据面;DECIDE-01 依归档内可读的决策报告求值为 applicable。
@@ -662,7 +662,7 @@ def matrix_viewport(name: str) -> dict[str, Any]:
662
662
  def capture_delivery_matrix(
663
663
  *,
664
664
  url: str,
665
- out_dir: Path | None = None,
665
+ out_dir: Path,
666
666
  freeze: dict[str, Any] | None = None,
667
667
  browser_adapter: BrowserAdapter | None = None,
668
668
  ) -> dict[str, dict[str, Any]]:
@@ -674,19 +674,19 @@ def capture_delivery_matrix(
674
674
  can assemble the ``disclosure-review.json`` matrix and the ``/export-zip``
675
675
  package from one pass.
676
676
 
677
- ``out_dir`` defaults to the spec's canonical delivery path
678
- ``output/playwright/static-handoff/`` (spec §5). ``browser_adapter`` uses
679
- the same ``BrowserAdapter`` seam as ``execute_capture_plan`` (real
680
- Playwright by default, injected fake in tests). Production adapters should
681
- expose ``capture_and_probe`` so the snapshot and metrics share one page;
682
- adapters without that seam remain explicitly ``unmeasured``.
677
+ ``out_dir`` is required and carries no default: handoff artifacts live
678
+ under the run tree (``<run_root>/evidence/static-handoff/``, ADR-0034 §5),
679
+ never the process CWD, so the caller must pass the run-tree destination
680
+ explicitly. ``browser_adapter`` uses the same ``BrowserAdapter`` seam as
681
+ ``execute_capture_plan`` (real Playwright by default, injected fake in
682
+ tests). Production adapters should expose ``capture_and_probe`` so the
683
+ snapshot and metrics share one page; adapters without that seam remain
684
+ explicitly ``unmeasured``.
683
685
  """
684
686
  freeze = freeze or {"enabled": True, "waitFonts": True, "networkIdle": False}
685
687
  if browser_adapter is None:
686
688
  browser_adapter = PlaywrightBrowserAdapter()
687
689
 
688
- if out_dir is None:
689
- out_dir = Path("output/playwright/static-handoff")
690
690
  out_dir = Path(out_dir)
691
691
  out_dir.mkdir(parents=True, exist_ok=True)
692
692
  results: dict[str, dict[str, Any]] = {}
@@ -19,8 +19,10 @@ Two seams keep the contract builder pure and testable without a browser:
19
19
  It only normalizes caller-supplied facts into the §4.2 shape.
20
20
 
21
21
  ``build_handoff_zip()`` packages the disclosure credential plus any caller-
22
- supplied snapshot artifacts into a single ZIP for the local ``/export-zip``
23
- endpoint (Stage 9 delivery mount). It never reads outside the caller-provided
22
+ supplied snapshot artifacts into a single ZIP. The Evidence-side builder
23
+ (``handoff.py``) writes it to disk as ``static-handoff.zip`` next to the
24
+ delivery page under ``<run_root>/evidence/static-handoff/`` (ADR-0034); no
25
+ HTTP delivery endpoint exists. It never reads outside the caller-provided
24
26
  file list.
25
27
  """
26
28
 
@@ -59,6 +59,7 @@ class StaticHandoffResult:
59
59
  json_path: Path
60
60
  zip_path: Path
61
61
  index_html: Path
62
+ deliverable_html: Path
62
63
 
63
64
 
64
65
  def _iso_now() -> str:
@@ -691,7 +692,8 @@ def build_static_handoff(
691
692
  output (``filled-ui.html``) - the page the five-viewport matrix and the
692
693
  layout probe actually target (ADR-0034 §4). Everything is written under
693
694
  ``<run_root>/evidence/static-handoff/``: snapshots, the disclosure JSON,
694
- the ZIP package, and a self-contained index page.
695
+ the ZIP package, a same-directory ``deliverable.html`` copy (the page's
696
+ relative link target, spec A5), and a self-contained index page.
695
697
  """
696
698
  run_root = Path(run_root)
697
699
  deliverable = Path(deliverable)
@@ -707,6 +709,14 @@ def build_static_handoff(
707
709
  if gate_runner is None:
708
710
  gate_runner = _run_gate_validation
709
711
 
712
+ # Read the deliverable source up front and fail fast: its bytes are the
713
+ # run identity (hash), the ZIP's prototype member, and the on-disk copy
714
+ # the delivery page links relatively (#107). A missing or undecodable
715
+ # source aborts before any capture launches or artifact is written, so a
716
+ # delivery page can never exist without its link target.
717
+ deliverable_bytes = deliverable.read_bytes()
718
+ deliverable_text = deliverable_bytes.decode("utf-8")
719
+
710
720
  # Sample conditional-gate preconditions BEFORE writing anything: this
711
721
  # builder's own output lives under evidence/, and a precondition sampled
712
722
  # afterwards would be one this run manufactured for itself.
@@ -769,7 +779,6 @@ def build_static_handoff(
769
779
  else:
770
780
  verdict = "Pending"
771
781
 
772
- deliverable_bytes = deliverable.read_bytes()
773
782
  run_id = (
774
783
  f"static-handoff-{round_n}-{hashlib.sha256(deliverable_bytes).hexdigest()[:12]}"
775
784
  )
@@ -808,6 +817,13 @@ def build_static_handoff(
808
817
  json_path = out_dir / "disclosure-review.json"
809
818
  json_path.write_text(disclosure_json(payload), encoding="utf-8")
810
819
 
820
+ # The delivery page links "deliverable.html" as a same-directory relative
821
+ # anchor (spec A5: disk artifacts, same-directory relative links); the copy
822
+ # must exist beside index.html, byte-identical to the ZIP member, or the
823
+ # delivery surface ships a dead link (#107).
824
+ deliverable_copy = out_dir / "deliverable.html"
825
+ deliverable_copy.write_bytes(deliverable_bytes)
826
+
811
827
  artifacts: dict[str, str] = {}
812
828
  if snap_dir.is_dir():
813
829
  for vp in VIEWPORT_ORDER:
@@ -820,7 +836,7 @@ def build_static_handoff(
820
836
  artifact_files=artifacts,
821
837
  # spec §4.1: the handoff ships "snapshots and prototype code". PNGs
822
838
  # alone do not let the recipient rebuild the reviewed page.
823
- text_members={"deliverable.html": deliverable_bytes.decode("utf-8")},
839
+ text_members={"deliverable.html": deliverable_text},
824
840
  zip_target=str(zip_path),
825
841
  )
826
842
 
@@ -835,4 +851,5 @@ def build_static_handoff(
835
851
  json_path=json_path,
836
852
  zip_path=zip_path,
837
853
  index_html=index_html,
854
+ deliverable_html=deliverable_copy,
838
855
  )
@@ -195,8 +195,16 @@ class BuildStaticHandoffTests(unittest.TestCase):
195
195
  result = self._build(tmp, run_root)
196
196
  base = run_root / "evidence" / "static-handoff"
197
197
  self.assertEqual(result.out_dir, base)
198
- for path in (result.json_path, result.zip_path, result.index_html):
198
+ for path in (
199
+ result.json_path,
200
+ result.zip_path,
201
+ result.index_html,
202
+ result.deliverable_html,
203
+ ):
199
204
  self.assertTrue(path.is_file(), path)
205
+ # #107: the page's relative "deliverable.html" anchor must resolve
206
+ # in the same directory as index.html (spec A5).
207
+ self.assertEqual(result.deliverable_html, base / "deliverable.html")
200
208
  self.assertTrue((base / "snapshots" / "viewport-1280x900.png").is_file())
201
209
  # nothing outside the run tree
202
210
  self.assertFalse((tmp / "output").exists())
@@ -390,6 +398,30 @@ class BuildStaticHandoffTests(unittest.TestCase):
390
398
  result = self._build(tmp, run_root)
391
399
  self.assertEqual(result.payload["profile"], "unknown")
392
400
 
401
+ def test_missing_deliverable_fails_before_any_artifact_is_written(self) -> None:
402
+ """#107 coherence: a delivery page must never exist without its link
403
+ target. A missing Stage 7 source aborts the build up front, so nothing
404
+ lands under evidence/static-handoff/ - no page, no dead link."""
405
+ import tempfile
406
+
407
+ with tempfile.TemporaryDirectory() as tmp_s:
408
+ tmp = Path(tmp_s)
409
+ run_root = _make_run(tmp)
410
+ missing = tmp / "filled-ui.html" # never written
411
+ with self.assertRaises(OSError):
412
+ handoff.build_static_handoff(
413
+ run_root,
414
+ missing,
415
+ round_n=1,
416
+ summary="s",
417
+ capture_runner=_fake_capture_runner,
418
+ gate_runner=_passing_gate_runner,
419
+ )
420
+ self.assertFalse(
421
+ (run_root / "evidence" / "static-handoff").exists(),
422
+ "missing source must fail before any artifact is written",
423
+ )
424
+
393
425
 
394
426
  class HandoffPageTests(unittest.TestCase):
395
427
  def test_page_template_is_own_content_with_no_cdn(self) -> None:
@@ -430,6 +462,38 @@ class HandoffPageTests(unittest.TestCase):
430
462
  # No unsanitized injection from run-controlled text.
431
463
  self.assertNotIn("<script>", html[start:end])
432
464
 
465
+ def test_page_deliverable_link_target_exists_beside_the_page(self) -> None:
466
+ """#107: "Everything below sits next to it on disk" must be true.
467
+
468
+ The page links ``deliverable.html`` as a same-directory relative
469
+ anchor (spec A5), so the builder must write that copy beside
470
+ index.html, byte-identical to the ZIP member and the Stage 7 source.
471
+ """
472
+ import tempfile
473
+
474
+ with tempfile.TemporaryDirectory() as tmp_s:
475
+ tmp = Path(tmp_s)
476
+ run_root = _make_run(tmp)
477
+ deliverable = tmp / "filled-ui.html"
478
+ deliverable.write_text(DELIVERABLE_HTML, encoding="utf-8")
479
+ result = handoff.build_static_handoff(
480
+ run_root, deliverable, round_n=1, summary="s",
481
+ capture_runner=_fake_capture_runner,
482
+ gate_runner=_passing_gate_runner,
483
+ )
484
+ html = result.index_html.read_text(encoding="utf-8")
485
+ self.assertIn('href="deliverable.html"', html)
486
+ on_disk = result.index_html.parent / "deliverable.html"
487
+ self.assertTrue(
488
+ on_disk.is_file(),
489
+ "the page's relative deliverable.html link must not dangle",
490
+ )
491
+ self.assertEqual(result.deliverable_html, on_disk)
492
+ with zipfile.ZipFile(result.zip_path) as zf:
493
+ member = zf.read("deliverable.html")
494
+ self.assertEqual(on_disk.read_bytes(), member)
495
+ self.assertEqual(on_disk.read_bytes(), deliverable.read_bytes())
496
+
433
497
 
434
498
  class GateNormalizationTests(unittest.TestCase):
435
499
  """Ported from the review-session surface; the logic now lives in handoff."""
@@ -308,18 +308,21 @@ def inspect_preview(preview_dir: Path) -> PreviewSnapshot:
308
308
  current_records: list[ConfirmRecord] = []
309
309
  for path, data, record_round in parsed:
310
310
  valid = _is_confirmed_valid(data)
311
- if (
311
+ is_canonical = (
312
312
  current_round is not None
313
313
  and path == preview_dir / confirm_name(current_round)
314
- ):
315
- canonical_current_confirm = ConfirmRecord(
316
- path=path,
317
- data=data,
318
- round=record_round,
319
- valid=valid,
320
- prototype_status="unchecked",
321
- )
314
+ )
322
315
  if current_round is not None and record_round != current_round:
316
+ if is_canonical:
317
+ # The canonical filename exists but its JSON round disagrees,
318
+ # so no prototype check runs for it: status stays unchecked.
319
+ canonical_current_confirm = ConfirmRecord(
320
+ path=path,
321
+ data=data,
322
+ round=record_round,
323
+ valid=valid,
324
+ prototype_status="unchecked",
325
+ )
323
326
  continue
324
327
  prototype_status = "unchecked"
325
328
  expected_digest = ""
@@ -375,17 +378,22 @@ def inspect_preview(preview_dir: Path) -> PreviewSnapshot:
375
378
  actual=actual_digest,
376
379
  )
377
380
  )
378
- current_records.append(
379
- ConfirmRecord(
380
- path=path,
381
- data=data,
382
- round=record_round,
383
- valid=valid,
384
- prototype_status=prototype_status,
385
- expected_digest=expected_digest,
386
- actual_digest=actual_digest,
387
- )
381
+ record = ConfirmRecord(
382
+ path=path,
383
+ data=data,
384
+ round=record_round,
385
+ valid=valid,
386
+ prototype_status=prototype_status,
387
+ expected_digest=expected_digest,
388
+ actual_digest=actual_digest,
388
389
  )
390
+ current_records.append(record)
391
+ if is_canonical:
392
+ # The canonical confirm carries the owner-computed prototype
393
+ # status (match/mismatch/missing_*): ``valid`` stays flags-only,
394
+ # but projections must never upgrade a hash mismatch to a
395
+ # confirmed state (ADR-0013; run-snapshot parity section 2).
396
+ canonical_current_confirm = record
389
397
 
390
398
  return PreviewSnapshot(
391
399
  preview_dir=preview_dir,
@@ -48,9 +48,10 @@ __all__ = [
48
48
  "collect_review",
49
49
  ]
50
50
 
51
- # Stage 9 static-handoff disclosure contract (imported lazily inside the
52
- # /export-zip + /disclosure-review.json handlers so the stdio server does not
53
- # pay the import cost and tests can stub it).
51
+ # Stage 9 static handoff is Evidence-owned (ADR-0034): the builder in
52
+ # mcp/evidence/handoff.py writes its artifacts to disk under the run tree
53
+ # (<run_root>/evidence/static-handoff/). This preview server serves only
54
+ # the review page at "/" and hosts no delivery route.
54
55
 
55
56
 
56
57
  def _generate_decision_token() -> str:
@@ -126,6 +126,35 @@ class PreviewIntegritySnapshotTests(unittest.TestCase):
126
126
  self.assertEqual(snapshot.current_confirms[0].prototype_status, "mismatch")
127
127
  self.assertEqual([fact.code for fact in snapshot.facts], ["hash_mismatch"])
128
128
  self.assertNotIn("G5", snapshot.facts[0].detail)
129
+
130
+ def test_canonical_confirm_carries_owner_prototype_status(self) -> None:
131
+ with tempfile.TemporaryDirectory() as tmp:
132
+ preview = Path(tmp)
133
+ (preview / "round-1.html").write_text("changed", encoding="utf-8")
134
+ (preview / "confirm-round-1.json").write_text(
135
+ json.dumps(
136
+ {
137
+ "round": 1,
138
+ "confirmed": True,
139
+ "floor_pass": True,
140
+ "prototype_html_hash": prototype_html_digest(b"original"),
141
+ }
142
+ ),
143
+ encoding="utf-8",
144
+ )
145
+
146
+ snapshot = inspect_preview(preview)
147
+
148
+ canonical = snapshot.canonical_current_confirm
149
+ self.assertIsNotNone(canonical)
150
+ # ``valid`` stays flags-only (ADR-0008 confirm/floor flags); the
151
+ # integrity outcome surfaces as the owner-computed prototype
152
+ # status on the same canonical current-round record, so a
153
+ # projection can never upgrade a hash mismatch to confirmed.
154
+ self.assertTrue(canonical.valid)
155
+ self.assertEqual(canonical.prototype_status, "mismatch")
156
+ self.assertEqual(snapshot.current_confirms, (canonical,))
157
+
129
158
  def test_malformed_confirm_becomes_fact_without_aborting_snapshot(self) -> None:
130
159
  with tempfile.TemporaryDirectory() as tmp:
131
160
  preview = Path(tmp)
@@ -26,12 +26,17 @@ from .session import (
26
26
  RunConsoleSessionError,
27
27
  )
28
28
 
29
- # A code owned here (request_security.py is frozen outside this change):
29
+ # Codes owned here (request_security.py is frozen outside this change):
30
30
  # a non-JSON content type on the one action route is a 415, not a 400.
31
31
  CONTENT_TYPE_UNSUPPORTED = "CONTENT_TYPE_UNSUPPORTED"
32
32
  CONTENT_TYPE_UNSUPPORTED_MESSAGE = (
33
33
  "The typed action requires an application/json request body."
34
34
  )
35
+ # Spec section 13 keeps a body that does not decode as JSON at all
36
+ # distinct from valid JSON with the wrong fields: MALFORMED_JSON is the
37
+ # pre-dispatch decode rejection, ACTION_PAYLOAD_INVALID stays field-level.
38
+ MALFORMED_JSON = "MALFORMED_JSON"
39
+ MALFORMED_JSON_MESSAGE = "The request body is not well-formed JSON."
35
40
 
36
41
  JSON_CONTENT_TYPE = "application/json"
37
42
  ACTION_REFRESH = "refresh"
@@ -64,6 +69,21 @@ class ActionPayloadError(ValueError):
64
69
  self.code = ACTION_PAYLOAD_INVALID
65
70
 
66
71
 
72
+ class MalformedJSONError(ValueError):
73
+ """A typed rejection of a body that does not decode as JSON at all.
74
+
75
+ A deliberate sibling of :class:`ActionPayloadError`, never a subclass:
76
+ spec section 13 keeps ``MALFORMED_JSON`` (rejected before action
77
+ dispatch) distinct from the field-level ``ACTION_PAYLOAD_INVALID``,
78
+ and a subclass would let one ``except ActionPayloadError`` fold the
79
+ two codes back together.
80
+ """
81
+
82
+ def __init__(self, message: str = MALFORMED_JSON_MESSAGE) -> None:
83
+ super().__init__(message)
84
+ self.code = MALFORMED_JSON
85
+
86
+
67
87
  def content_type_is_json(value: object) -> bool:
68
88
  """True only for an ``application/json`` Content-Type value.
69
89
 
@@ -78,13 +98,20 @@ def content_type_is_json(value: object) -> bool:
78
98
 
79
99
 
80
100
  def parse_json_action_body(raw: object) -> object:
81
- """Decode one UTF-8 JSON request body or raise the typed rejection."""
101
+ """Decode one UTF-8 JSON request body or raise the typed rejection.
102
+
103
+ Every decode failure — a body that is not a byte sequence, not
104
+ UTF-8, or not one well-formed JSON document — is the pre-dispatch
105
+ ``MALFORMED_JSON`` rejection. Valid JSON that is not the closed
106
+ payload is judged later by :func:`validate_refresh_payload` and
107
+ keeps ``ACTION_PAYLOAD_INVALID``.
108
+ """
82
109
  if not isinstance(raw, (bytes, bytearray)):
83
- raise ActionPayloadError() from None
110
+ raise MalformedJSONError() from None
84
111
  try:
85
112
  return json.loads(bytes(raw).decode("utf-8"))
86
113
  except (UnicodeDecodeError, ValueError):
87
- raise ActionPayloadError() from None
114
+ raise MalformedJSONError() from None
88
115
 
89
116
 
90
117
  def validate_refresh_payload(payload: object) -> None:
@@ -26,9 +26,12 @@ from urllib.parse import parse_qsl, urlsplit
26
26
  from .actions import (
27
27
  CONTENT_TYPE_UNSUPPORTED,
28
28
  CONTENT_TYPE_UNSUPPORTED_MESSAGE,
29
+ MALFORMED_JSON,
30
+ MALFORMED_JSON_MESSAGE,
29
31
  REFRESH_ALLOWED_METHODS,
30
32
  REFRESH_ROUTE,
31
33
  ActionPayloadError,
34
+ MalformedJSONError,
32
35
  content_type_is_json,
33
36
  parse_json_action_body,
34
37
  perform_refresh,
@@ -91,6 +94,7 @@ _LOCATOR_PATTERN = re.compile(r"^src_[A-Za-z0-9_-]{16,}$")
91
94
  _DUPLICATED = object()
92
95
 
93
96
  _STATUS_BY_CODE = {
97
+ MALFORMED_JSON: 400,
94
98
  ACTION_PAYLOAD_INVALID: 400,
95
99
  SESSION_TOKEN_INVALID: 401,
96
100
  ORIGIN_INVALID: 403,
@@ -416,6 +420,12 @@ class RunConsoleRequestHandler(http.server.BaseHTTPRequestHandler):
416
420
  return
417
421
  try:
418
422
  payload = parse_json_action_body(body)
423
+ except MalformedJSONError:
424
+ # Not one well-formed JSON document: rejected before action
425
+ # dispatch, distinct from the field-level payload code (s13).
426
+ self._send_error(MALFORMED_JSON, message=MALFORMED_JSON_MESSAGE)
427
+ return
428
+ try:
419
429
  validate_refresh_payload(payload)
420
430
  except ActionPayloadError:
421
431
  self._send_error(ACTION_PAYLOAD_INVALID)
@@ -17,8 +17,11 @@ the bound source:
17
17
  (raw bytes for artifacts, normalized decoded text for text sources)
18
18
  and compared with the hash bound into the locator: a changed source is
19
19
  ``SOURCE_HASH_MISMATCH`` and never returns a newer excerpt;
20
- - the excerpt is HTML-escaped and hard-truncated, so source content can
21
- never carry executable markup or an unbounded payload across the seam.
20
+ - the excerpt is stripped of control characters and ANSI escape
21
+ sequences, HTML-escaped, and hard-truncated, so source content can
22
+ never carry executable markup, terminal control bytes, or an
23
+ unbounded payload across the seam (spec section 10: plain text with
24
+ control characters removed).
22
25
 
23
26
  The resolver writes nothing, opens no socket, and runs no process.
24
27
  """
@@ -26,6 +29,7 @@ from __future__ import annotations
26
29
 
27
30
  import hashlib
28
31
  import html
32
+ import re
29
33
  from dataclasses import dataclass
30
34
  from pathlib import Path
31
35
 
@@ -46,6 +50,16 @@ _ERROR_MESSAGES = {
46
50
  _DEFAULT_MAX_CHARS = 4000
47
51
  _MAX_CHARS_LIMIT = 8192
48
52
 
53
+ # A complete ANSI CSI sequence: ESC ``[``, parameter bytes (0x30-0x3F),
54
+ # intermediate bytes (0x20-0x2F), and one final byte (0x40-0x7E). The
55
+ # final byte is optional so a sequence cut off at end-of-source is still
56
+ # removed whole instead of leaving ``[31m``-style residue.
57
+ _ANSI_CSI = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]?")
58
+ # Control characters forbidden in a plain-text excerpt (spec section
59
+ # 10): C0 except tab (0x09) and newline (0x0A), DEL (0x7F), and the C1
60
+ # range (0x80-0x9F). Any ESC left after CSI removal is caught here.
61
+ _CONTROL_CHARS = re.compile(r"[\x00-\x08\x0b-\x1f\x7f-\x9f]")
62
+
49
63
 
50
64
  class SourceViewError(ValueError):
51
65
  """Stable, path-free rejection at the source-view seam.
@@ -110,9 +124,11 @@ def resolve_source_excerpt(
110
124
  return SourceExcerpt(
111
125
  source_ref=binding.source_ref,
112
126
  content_hash=digest,
113
- # Escape first, then hard-truncate: the output is always plain
114
- # text and never longer than the requested bound.
115
- text=html.escape(decoded, quote=False)[:limit],
127
+ # Reduce to plain text, then escape, then hard-truncate: the
128
+ # output is always plain text and never longer than the
129
+ # requested bound. Stripping runs after the hash check so the
130
+ # Snapshot hash parity above stays byte-exact.
131
+ text=html.escape(_plain_text(decoded), quote=False)[:limit],
116
132
  )
117
133
 
118
134
 
@@ -129,6 +145,20 @@ def _normalize_newlines(text: str) -> str:
129
145
  return text.replace("\r\n", "\n").replace("\r", "\n")
130
146
 
131
147
 
148
+ def _plain_text(text: str) -> str:
149
+ """Reduce decoded content to the spec section-10 excerpt plain text.
150
+
151
+ Newlines are normalized first (``\\r\\n``/``\\r`` to ``\\n``), then
152
+ complete ANSI CSI escape sequences are removed whole, and finally
153
+ every remaining control character other than tab and newline --
154
+ C0, DEL (0x7F), and C1 (0x80-0x9F), including any lone ESC -- is
155
+ removed.
156
+ """
157
+ text = _normalize_newlines(text)
158
+ text = _ANSI_CSI.sub("", text)
159
+ return _CONTROL_CHARS.sub("", text)
160
+
161
+
132
162
  def _read_bound_source(
133
163
  registry: SourceRegistry, package_root: Path, binding: LocatorBinding
134
164
  ) -> bytes | None:
@@ -476,7 +476,14 @@ class _Builder:
476
476
  and confirm.data.get("aborted") is True
477
477
  ):
478
478
  preview_state = "aborted"
479
- elif confirm is not None and confirm.valid:
479
+ elif (
480
+ confirm is not None
481
+ and confirm.valid
482
+ # Parity spec section 2 (execution.preview): a prototype
483
+ # hash mismatch reported by the integrity owner can never
484
+ # be upgraded to a confirmed state.
485
+ and confirm.prototype_status != "mismatch"
486
+ ):
480
487
  preview_state = "confirmed"
481
488
  elif confirm is not None:
482
489
  preview_state = "invalid"