#!/usr/bin/env python3 """Apply a revision patch to an anchored draft — two-phase, fail-closed. #89 Item 7 Slice A. Normative source: `docs/design/2026-06-10-390-diff-patch-revision-mode-spec.md` §3.3 (deterministic apply), §3.2 (patch document constraints). **Phase 1 — validate everything, touch nothing.** Schema-validate the patch; verify `base_draft_hash` against the base file's raw bytes; parse the base with the shared parser; check every op (target exists, `old_hash` matches the current normalized block text, each block ID in at most one op in any role — the `DOC-BODY-START` sentinel included, no `\n{seg_text}") return "\n".join(rendered) def apply_patch(base_text: str, base: ParsedDocument, analysis: dict) -> tuple[str, dict]: """Phase 2: splice. Returns (output_text, phase2_report_fields).""" blocks = base.blocks n = len(blocks) text = base_text ops_by_target: dict[str, dict] = {} doc_body_start_op: dict | None = None for a in analysis["analyses"]: if a["op"]["block_id"] == DOC_BODY_START: doc_body_start_op = a else: ops_by_target[a["op"]["block_id"]] = a next_num = base.next_fresh_id_num() fresh_assigned: list[str] = [] seg_id_map: dict[tuple[int, int], str] = {} # (op_index, seg_index) -> fresh id ops_applied: list[dict] = [] def _take_fresh(op_index: int, seg_indexes: list[int]) -> list[str]: nonlocal next_num ids = [] for seg_idx in seg_indexes: fid = BLOCK_ID_FORMAT.format(next_num) next_num += 1 ids.append(fid) fresh_assigned.append(fid) seg_id_map[(op_index, seg_idx)] = fid return ids # Splicing model: a block's unit is [full_start, next.full_start); the # "gap" is the separator bytes between its content end and the next # block's full_start, copied verbatim (§3.3 byte-span splicing). The # gap must be RECOMPUTED instead of copied only where its boundary # disappears (everything after it deleted) or never existed (an # insertion lands where the base had zero inter-block bytes). deleted = { a["op"]["block_id"] for a in analysis["analyses"] if a["op"]["op"] == "delete_block" } # Highest index that survives the patch; `i >= last_kept` ⇔ every # block after i is deleted (O(1) per block instead of a tail scan). last_kept = max( (j for j in range(n) if blocks[j].block_id not in deleted), default=-1, ) out: list[str] = [] head_end = blocks[0].full_start if n else len(text) out.append(text[0:head_end]) if doc_body_start_op is not None: a = doc_body_start_op segments = a["segments"] ids = _take_fresh(a["op_index"], list(range(len(segments)))) rendered = _render_segments( a["op"]["new_text"], segments, first_keeps_marker=False, fresh_ids=ids ) out.append(rendered) if n: out.append("\n") ops_applied.append( { "op_index": a["op_index"], "op": "insert_after", "block_id": DOC_BODY_START, "roadmap_item_ids": a["op"]["roadmap_item_ids"], "new_block_ids": ids, } ) for i, block in enumerate(blocks): unit_end = blocks[i + 1].full_start if i + 1 < n else len(text) content_end = block.span[1] gap = text[content_end:unit_end] a = ops_by_target.get(block.block_id) if block.block_id else None if a is not None and a["op"]["op"] == "delete_block": ops_applied.append( { "op_index": a["op_index"], "op": "delete_block", "block_id": block.block_id, "roadmap_item_ids": a["op"]["roadmap_item_ids"], "new_block_ids": [], } ) continue # marker, content, and following separator all dropped # Suppress the separator when everything after this block is # deleted (a last-block delete must not leave trailing blanks). rest_all_deleted = i + 1 < n and i >= last_kept if a is None: out.append(text[block.full_start : content_end]) out.append("" if rest_all_deleted else gap) continue op = a["op"] if op["op"] == "replace_block": segments = a["segments"] ids = _take_fresh(a["op_index"], list(range(1, len(segments)))) if block.marker_span is not None: out.append(text[block.marker_span[0] : block.marker_span[1]]) rendered = _render_segments( op["new_text"], segments, first_keeps_marker=True, fresh_ids=ids ) content = text[block.span[0] : block.span[1]] if not content.endswith("\n") and rendered.endswith("\n"): rendered = rendered[:-1] # final block without EOL stays EOL-less out.append(rendered) out.append("" if rest_all_deleted else gap) ops_applied.append( { "op_index": a["op_index"], "op": "replace_block", "block_id": block.block_id, "roadmap_item_ids": op["roadmap_item_ids"], "new_block_ids": ids, } ) else: # insert_after segments = a["segments"] ids = _take_fresh(a["op_index"], list(range(len(segments)))) out.append(text[block.full_start : content_end]) content = text[block.span[0] : block.span[1]] prefix = "\n" if content.endswith("\n") else "\n\n" rendered = _render_segments( op["new_text"], segments, first_keeps_marker=False, fresh_ids=ids ) out.append(prefix + rendered) if gap == "" and i + 1 < n and not rest_all_deleted: out.append("\n") # keep a blank line before an adjacent next block out.append("" if rest_all_deleted else gap) ops_applied.append( { "op_index": a["op_index"], "op": "insert_after", "block_id": block.block_id, "roadmap_item_ids": op["roadmap_item_ids"], "new_block_ids": ids, } ) output_text = "".join(out) target_id_by_op_index = { a["op_index"]: a["op"]["block_id"] for a in analysis["analyses"] if a["op"]["op"] == "replace_block" } pure_move_pairs = [] for seed in analysis["pure_move_seeds"]: to_id = seg_id_map.get((seed["op_index"], seed["segment_index"])) if to_id is None and seed["segment_index"] == 0: # replace_block keeps the target's ID on the head segment. to_id = target_id_by_op_index.get(seed["op_index"]) pure_move_pairs.append( { "from_block_id": seed["from_block_id"], "to_block_id": to_id, "op_index": seed["op_index"], } ) ops_applied.sort(key=lambda entry: entry["op_index"]) return output_text, { "ops_applied": ops_applied, "fresh_block_ids": fresh_assigned, "pure_move_pairs": pure_move_pairs, } def run( base_path: Path, patch_path: Path, output_path: Path, report_path: Path, *, acknowledge_structural: bool, touched_ratio_threshold: float | None, ) -> dict: """Full two-phase apply. Raises ApplyRejection / StructuralRefusal / BlockParseError; returns the success report dict. `touched_ratio_threshold`: the CLI defaults this to DEFAULT_TOUCHED_RATIO_THRESHOLD (0.6, the #424 ship decision); a programmatic caller may pass `None` for record-only mode — the ratio is still computed and recorded in the report, but never triggers a structural refusal (for callers that own their own escalation policy). """ resolved = { "base": base_path.resolve(), "output": output_path.resolve(), "report": report_path.resolve(), } collisions = [] if resolved["output"] == resolved["base"]: collisions.append("--output must not name the base draft (the base is never modified)") if resolved["report"] in (resolved["base"], resolved["output"]): collisions.append("--report-out must not name the base draft or the output draft") if collisions: raise ApplyRejection( [ {"op_index": None, "kind": "artifact_path_collision", "message": msg} for msg in collisions ] ) # The output is a NEW versioned artifact (§3.3 supersession): refusing # to overwrite an existing file is what makes the report-failure # cleanup below safe — any file at output_path is one this run created. exists = [ {"op_index": None, "kind": "artifact_already_exists", "message": msg} for p, msg in ( (output_path, "--output already exists; the revised draft must be a new versioned artifact"), (report_path, "--report-out already exists; each apply emits its own report"), ) if p.exists() ] if exists: raise ApplyRejection(exists) base_raw = base_path.read_bytes() base_text = base_raw.decode("utf-8") try: patch = json.loads(patch_path.read_text(encoding="utf-8")) except json.JSONDecodeError as exc: raise ApplyRejection( [{"op_index": None, "kind": "patch_json_invalid", "message": str(exc)}] ) from exc try: base = parse_document(base_text) except BlockParseError as exc: raise ApplyRejection( [{"op_index": None, "kind": f"base_parse_rejected:{exc.kind}", "message": str(exc)}] ) from exc analysis = validate_patch( patch, base_raw, base, touched_ratio_threshold=touched_ratio_threshold ) flags = analysis["structural_flags"] flags["acknowledged"] = acknowledge_structural if flags["any"] and not acknowledge_structural: raise StructuralRefusal(flags) output_text, phase2 = apply_patch(base_text, base, analysis) # Post-write self-check (marker uniqueness + grammar): a failure here # is a splicer bug — no artifact may land. reparsed = parse_document(output_text) ids = [b.block_id for b in reparsed.blocks if b.block_id is not None] if len(ids) != len(set(ids)): # pragma: no cover - parser already rejects raise AssertionError("self-check: duplicate markers in apply output") atomic_write_bytes(output_path, output_text.encode("utf-8")) counters_base = analysis["counters_base"] blocks_total = counters_base["blocks_total"] touched = counters_base["blocks_touched"] preserved = blocks_total - touched report = { "report_format_version": REPORT_FORMAT_VERSION, "mode": "patch", "base_path": str(base_path), "output_path": str(output_path), "base_draft_hash": patch["base_draft_hash"], "revision_round": patch["revision_round"], "ops_applied": phase2["ops_applied"], "fresh_block_ids": phase2["fresh_block_ids"], "pure_move_pairs": phase2["pure_move_pairs"], "structural_flags": flags, "counters": { "blocks_total": blocks_total, "blocks_touched": touched, "blocks_preserved_byte_identical": preserved, "preserved_ratio": round(preserved / blocks_total, 4) if blocks_total else 0.0, }, } try: # allow_nan=False: a non-finite counter would serialize as bare # `NaN`/`Infinity` (invalid JSON for strict readers). The threshold # is range-validated upstream, so this is belt-and-suspenders. atomic_write_bytes( report_path, (json.dumps(report, ensure_ascii=False, indent=2, allow_nan=False) + "\n").encode("utf-8"), ) except BaseException: # The output and its apply report land as a pair: a report-write # failure must not leave a revised draft with no provenance record. output_path.unlink(missing_ok=True) raise return report def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) parser.add_argument("base", type=Path, help="anchored base draft (never modified)") parser.add_argument("patch", type=Path, help="revision patch JSON") parser.add_argument("--output", type=Path, required=True, help="revised draft output path") parser.add_argument( "--report-out", type=Path, default=None, help="apply report path (default: .apply-report.json)", ) parser.add_argument( "--acknowledge-structural", action="store_true", help="proceed despite structural-shape flags (set only after the §3.6 escalation checkpoint)", ) parser.add_argument( "--touched-ratio-threshold", type=_ratio_threshold, default=DEFAULT_TOUCHED_RATIO_THRESHOLD, help="touched-ratio trigger threshold, a finite ratio in [0.0, 1.0] " "(default %(default)s, the #424 ship decision; fires when " "blocks_touched/blocks_total is strictly above it; pass 1.0 to disable)", ) args = parser.parse_args(argv) report_path = args.report_out or Path(str(args.output) + ".apply-report.json") try: report = run( args.base, args.patch, args.output, report_path, acknowledge_structural=args.acknowledge_structural, touched_ratio_threshold=args.touched_ratio_threshold, ) except ApplyRejection as exc: print(json.dumps({"result": "rejected", "phase": 1, "failures": exc.failures}, indent=2)) return 2 except StructuralRefusal as exc: print( json.dumps( { "result": "refused_structural", "structural_flags": exc.flags, "hint": "re-run with --acknowledge-structural only after the " "escalation checkpoint (spec §3.6)", }, indent=2, ) ) return 3 except AssertionError as exc: # pragma: no cover - self-check path print(f"SELF-CHECK FAILED (bug, no artifact written): {exc}", file=sys.stderr) return 4 counters = report["counters"] print( "apply ok: {applied} op(s); {preserved}/{total} blocks preserved byte-identical " "(ratio {ratio}); report {rpath}".format( applied=len(report["ops_applied"]), preserved=counters["blocks_preserved_byte_identical"], total=counters["blocks_total"], ratio=counters["preserved_ratio"], rpath=report_path, ) ) return 0 if __name__ == "__main__": raise SystemExit(main())