python-hwpx-automation 6.0.3__py3-none-any.whl

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 (217) hide show
  1. hwpx_automation/__init__.py +61 -0
  2. hwpx_automation/__init__.pyi +27 -0
  3. hwpx_automation/__main__.py +8 -0
  4. hwpx_automation/agent_document.py +392 -0
  5. hwpx_automation/api.py +136 -0
  6. hwpx_automation/blind_eval.py +407 -0
  7. hwpx_automation/capabilities.py +110 -0
  8. hwpx_automation/compat.py +48 -0
  9. hwpx_automation/configuration.py +60 -0
  10. hwpx_automation/core/__init__.py +2 -0
  11. hwpx_automation/core/content.py +762 -0
  12. hwpx_automation/core/context.py +111 -0
  13. hwpx_automation/core/diff.py +53 -0
  14. hwpx_automation/core/document.py +37 -0
  15. hwpx_automation/core/formatting.py +513 -0
  16. hwpx_automation/core/handles.py +24 -0
  17. hwpx_automation/core/locations.py +205 -0
  18. hwpx_automation/core/locator.py +162 -0
  19. hwpx_automation/core/plan.py +680 -0
  20. hwpx_automation/core/resources.py +42 -0
  21. hwpx_automation/core/search.py +296 -0
  22. hwpx_automation/core/transactions.py +434 -0
  23. hwpx_automation/core/txn.py +48 -0
  24. hwpx_automation/document_state.py +95 -0
  25. hwpx_automation/errors.py +174 -0
  26. hwpx_automation/execution_lock.py +15 -0
  27. hwpx_automation/fastmcp_adapter.py +672 -0
  28. hwpx_automation/form_fill.py +1177 -0
  29. hwpx_automation/form_output_models.py +223 -0
  30. hwpx_automation/handlers/__init__.py +2 -0
  31. hwpx_automation/handlers/_shared.py +377 -0
  32. hwpx_automation/handlers/agent_document.py +257 -0
  33. hwpx_automation/handlers/authoring.py +750 -0
  34. hwpx_automation/handlers/content_edit.py +1078 -0
  35. hwpx_automation/handlers/form_fill.py +607 -0
  36. hwpx_automation/handlers/layout_style.py +660 -0
  37. hwpx_automation/handlers/quality_render.py +566 -0
  38. hwpx_automation/handlers/read_export.py +1295 -0
  39. hwpx_automation/handlers/specialized.py +624 -0
  40. hwpx_automation/handlers/tracked_changes.py +589 -0
  41. hwpx_automation/handlers/workflow.py +105 -0
  42. hwpx_automation/hwp_converter.py +227 -0
  43. hwpx_automation/hwp_support.py +94 -0
  44. hwpx_automation/hwpx_ops.py +1439 -0
  45. hwpx_automation/identity.json +263 -0
  46. hwpx_automation/identity.py +18 -0
  47. hwpx_automation/ingest_adapters.py +85 -0
  48. hwpx_automation/markdown_plan.py +216 -0
  49. hwpx_automation/mcp_cli.py +29 -0
  50. hwpx_automation/metadata/tools_meta.py +40 -0
  51. hwpx_automation/mixed_form.py +3007 -0
  52. hwpx_automation/mutation_models.py +401 -0
  53. hwpx_automation/network_policy.py +232 -0
  54. hwpx_automation/office/__init__.py +14 -0
  55. hwpx_automation/office/agent/__init__.py +125 -0
  56. hwpx_automation/office/agent/_batch_verification.py +383 -0
  57. hwpx_automation/office/agent/blueprint/__init__.py +58 -0
  58. hwpx_automation/office/agent/blueprint/bundle.py +282 -0
  59. hwpx_automation/office/agent/blueprint/catalog.py +136 -0
  60. hwpx_automation/office/agent/blueprint/dump.py +520 -0
  61. hwpx_automation/office/agent/blueprint/mapping.py +312 -0
  62. hwpx_automation/office/agent/blueprint/model.py +722 -0
  63. hwpx_automation/office/agent/blueprint/native.py +621 -0
  64. hwpx_automation/office/agent/blueprint/replay.py +622 -0
  65. hwpx_automation/office/agent/catalog.py +252 -0
  66. hwpx_automation/office/agent/cli.py +647 -0
  67. hwpx_automation/office/agent/commands.py +1383 -0
  68. hwpx_automation/office/agent/document.py +801 -0
  69. hwpx_automation/office/agent/form_plan.py +1760 -0
  70. hwpx_automation/office/agent/model.py +808 -0
  71. hwpx_automation/office/agent/path.py +155 -0
  72. hwpx_automation/office/agent/query.py +230 -0
  73. hwpx_automation/office/agent/story.py +207 -0
  74. hwpx_automation/office/authoring/__init__.py +3542 -0
  75. hwpx_automation/office/authoring/advanced_generators.py +154 -0
  76. hwpx_automation/office/authoring/builder/__init__.py +52 -0
  77. hwpx_automation/office/authoring/builder/core.py +996 -0
  78. hwpx_automation/office/authoring/builder/report.py +195 -0
  79. hwpx_automation/office/authoring/design/__init__.py +30 -0
  80. hwpx_automation/office/authoring/design/_support.py +144 -0
  81. hwpx_automation/office/authoring/design/composer.py +282 -0
  82. hwpx_automation/office/authoring/design/harvest.py +305 -0
  83. hwpx_automation/office/authoring/design/plan.py +69 -0
  84. hwpx_automation/office/authoring/design/profile.py +88 -0
  85. hwpx_automation/office/authoring/design/profiles/application_form/fragments/body.xml +1 -0
  86. hwpx_automation/office/authoring/design/profiles/application_form/fragments/heading.xml +1 -0
  87. hwpx_automation/office/authoring/design/profiles/application_form/fragments/info_table.xml +1 -0
  88. hwpx_automation/office/authoring/design/profiles/application_form/fragments/title.xml +1 -0
  89. hwpx_automation/office/authoring/design/profiles/application_form/profile.json +25 -0
  90. hwpx_automation/office/authoring/design/profiles/application_form/template.hwpx +0 -0
  91. hwpx_automation/office/authoring/design/profiles/home_notice/fragments/body.xml +1 -0
  92. hwpx_automation/office/authoring/design/profiles/home_notice/fragments/heading.xml +1 -0
  93. hwpx_automation/office/authoring/design/profiles/home_notice/fragments/title.xml +1 -0
  94. hwpx_automation/office/authoring/design/profiles/home_notice/profile.json +24 -0
  95. hwpx_automation/office/authoring/design/profiles/home_notice/template.hwpx +0 -0
  96. hwpx_automation/office/authoring/design/profiles/official_notice/fragments/body.xml +1 -0
  97. hwpx_automation/office/authoring/design/profiles/official_notice/fragments/heading.xml +1 -0
  98. hwpx_automation/office/authoring/design/profiles/official_notice/fragments/info_table.xml +1 -0
  99. hwpx_automation/office/authoring/design/profiles/official_notice/fragments/title.xml +1 -0
  100. hwpx_automation/office/authoring/design/profiles/official_notice/profile.json +25 -0
  101. hwpx_automation/office/authoring/design/profiles/official_notice/template.hwpx +0 -0
  102. hwpx_automation/office/authoring/design/profiles/report/fragments/body.xml +1 -0
  103. hwpx_automation/office/authoring/design/profiles/report/fragments/heading.xml +1 -0
  104. hwpx_automation/office/authoring/design/profiles/report/fragments/info_table.xml +1 -0
  105. hwpx_automation/office/authoring/design/profiles/report/fragments/title.xml +1 -0
  106. hwpx_automation/office/authoring/design/profiles/report/profile.json +25 -0
  107. hwpx_automation/office/authoring/design/profiles/report/template.hwpx +0 -0
  108. hwpx_automation/office/authoring/design/validator.py +107 -0
  109. hwpx_automation/office/authoring/presets/__init__.py +22 -0
  110. hwpx_automation/office/authoring/presets/proposal.py +538 -0
  111. hwpx_automation/office/authoring/report_parser.py +141 -0
  112. hwpx_automation/office/authoring/style_profile.py +437 -0
  113. hwpx_automation/office/authoring/template_analyzer.py +657 -0
  114. hwpx_automation/office/compliance/__init__.py +38 -0
  115. hwpx_automation/office/compliance/official_lint.py +478 -0
  116. hwpx_automation/office/compliance/pii.py +388 -0
  117. hwpx_automation/office/document_ops/__init__.py +13 -0
  118. hwpx_automation/office/document_ops/comparison.py +62 -0
  119. hwpx_automation/office/document_ops/mail_merge.py +73 -0
  120. hwpx_automation/office/document_ops/redline.py +35 -0
  121. hwpx_automation/office/evalplan/__init__.py +36 -0
  122. hwpx_automation/office/evalplan/runtime.py +2762 -0
  123. hwpx_automation/office/exam/__init__.py +44 -0
  124. hwpx_automation/office/exam/compose.py +282 -0
  125. hwpx_automation/office/exam/ir.py +44 -0
  126. hwpx_automation/office/exam/measure.py +163 -0
  127. hwpx_automation/office/exam/parser.py +150 -0
  128. hwpx_automation/office/exam/profile.py +123 -0
  129. hwpx_automation/office/form_fill/__init__.py +66 -0
  130. hwpx_automation/office/form_fill/classification.py +108 -0
  131. hwpx_automation/office/form_fill/fill_residue.py +242 -0
  132. hwpx_automation/office/form_fill/fit/__init__.py +36 -0
  133. hwpx_automation/office/form_fill/fit/apply.py +24 -0
  134. hwpx_automation/office/form_fill/fit/engine.py +24 -0
  135. hwpx_automation/office/form_fill/fit/measure.py +50 -0
  136. hwpx_automation/office/form_fill/fit/policy.py +28 -0
  137. hwpx_automation/office/form_fill/fit/report.py +28 -0
  138. hwpx_automation/office/form_fill/fit/seal.py +457 -0
  139. hwpx_automation/office/form_fill/fit/wordbox.py +1343 -0
  140. hwpx_automation/office/form_fill/guidance.py +704 -0
  141. hwpx_automation/office/form_fill/quality.py +961 -0
  142. hwpx_automation/office/form_fill/split_run.py +333 -0
  143. hwpx_automation/office/form_fill/template_formfit.py +656 -0
  144. hwpx_automation/office/house_style/__init__.py +196 -0
  145. hwpx_automation/office/house_style/composition.py +68 -0
  146. hwpx_automation/office/house_style/data/bank.json +625 -0
  147. hwpx_automation/office/house_style/data/genres.json +43 -0
  148. hwpx_automation/office/quality/__init__.py +14 -0
  149. hwpx_automation/office/quality/page_guard.py +277 -0
  150. hwpx_automation/office/rendering/__init__.py +145 -0
  151. hwpx_automation/office/rendering/_hancom_open_rate.ps1 +374 -0
  152. hwpx_automation/office/rendering/_refresh_hwpx_mac.applescript +162 -0
  153. hwpx_automation/office/rendering/_render_hwpx.ps1 +72 -0
  154. hwpx_automation/office/rendering/_render_hwpx_mac.applescript +249 -0
  155. hwpx_automation/office/rendering/block_splits.py +76 -0
  156. hwpx_automation/office/rendering/detectors.py +151 -0
  157. hwpx_automation/office/rendering/diff.py +153 -0
  158. hwpx_automation/office/rendering/fixture_corpus.py +215 -0
  159. hwpx_automation/office/rendering/oracle.py +909 -0
  160. hwpx_automation/office/rendering/page_qa.py +245 -0
  161. hwpx_automation/office/rendering/qa_contracts.py +293 -0
  162. hwpx_automation/office/rendering/qa_metrics.py +241 -0
  163. hwpx_automation/office/rendering/worker.py +290 -0
  164. hwpx_automation/office/utilities/__init__.py +12 -0
  165. hwpx_automation/office/utilities/table_compute.py +477 -0
  166. hwpx_automation/ops_services/__init__.py +1 -0
  167. hwpx_automation/ops_services/_border_fill.py +283 -0
  168. hwpx_automation/ops_services/composition.py +55 -0
  169. hwpx_automation/ops_services/content_layout.py +322 -0
  170. hwpx_automation/ops_services/context.py +213 -0
  171. hwpx_automation/ops_services/form_fields.py +557 -0
  172. hwpx_automation/ops_services/media.py +178 -0
  173. hwpx_automation/ops_services/memo_style.py +477 -0
  174. hwpx_automation/ops_services/package_validation.py +166 -0
  175. hwpx_automation/ops_services/planning.py +201 -0
  176. hwpx_automation/ops_services/preview_export.py +585 -0
  177. hwpx_automation/ops_services/read_query.py +601 -0
  178. hwpx_automation/ops_services/save_policy.py +604 -0
  179. hwpx_automation/ops_services/tables.py +539 -0
  180. hwpx_automation/ops_services/transactions.py +616 -0
  181. hwpx_automation/preview_output_models.py +69 -0
  182. hwpx_automation/public-modules.json +206 -0
  183. hwpx_automation/py.typed +1 -0
  184. hwpx_automation/quality.py +351 -0
  185. hwpx_automation/quality_generation.py +725 -0
  186. hwpx_automation/runtime.py +321 -0
  187. hwpx_automation/runtime_services.py +100 -0
  188. hwpx_automation/server.py +259 -0
  189. hwpx_automation/storage.py +747 -0
  190. hwpx_automation/tool_bindings.py +170 -0
  191. hwpx_automation/tool_contract.py +982 -0
  192. hwpx_automation/upstream.py +755 -0
  193. hwpx_automation/utils/__init__.py +2 -0
  194. hwpx_automation/utils/helpers.py +29 -0
  195. hwpx_automation/visual_qa.py +667 -0
  196. hwpx_automation/workflow/__init__.py +55 -0
  197. hwpx_automation/workflow/adapters.py +482 -0
  198. hwpx_automation/workflow/dispatcher.py +213 -0
  199. hwpx_automation/workflow/models.py +243 -0
  200. hwpx_automation/workflow/policy.py +198 -0
  201. hwpx_automation/workflow/render_contracts.py +173 -0
  202. hwpx_automation/workflow/render_metrics.py +196 -0
  203. hwpx_automation/workflow/render_queue.py +482 -0
  204. hwpx_automation/workflow/render_security.py +172 -0
  205. hwpx_automation/workflow/render_transport.py +369 -0
  206. hwpx_automation/workflow/rendering.py +206 -0
  207. hwpx_automation/workflow/service.py +758 -0
  208. hwpx_automation/workflow/state_machine.py +65 -0
  209. hwpx_automation/workflow/store.py +747 -0
  210. hwpx_automation/workspace.py +1694 -0
  211. python_hwpx_automation-6.0.3.dist-info/METADATA +279 -0
  212. python_hwpx_automation-6.0.3.dist-info/RECORD +217 -0
  213. python_hwpx_automation-6.0.3.dist-info/WHEEL +5 -0
  214. python_hwpx_automation-6.0.3.dist-info/entry_points.txt +3 -0
  215. python_hwpx_automation-6.0.3.dist-info/licenses/LICENSE +178 -0
  216. python_hwpx_automation-6.0.3.dist-info/licenses/NOTICE +14 -0
  217. python_hwpx_automation-6.0.3.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1383 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Atomic, allow-listed mutation commands for the semantic agent interface.
3
+
4
+ The public contract in :mod:`hwpx.agent.model` deliberately has no raw XML or
5
+ package-part escape hatch. This module is the only compiler from that compact
6
+ command vocabulary to the existing python-hwpx editing primitives. A batch is
7
+ applied to one disposable in-memory document, serialized once, and handed to
8
+ the normal :class:`~hwpx.quality.SavePipeline` exactly once.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import copy
14
+ import re
15
+ from collections.abc import Mapping
16
+ from pathlib import Path
17
+ from typing import Any
18
+ from xml.etree import ElementTree as ET
19
+
20
+ from lxml import etree as LET # type: ignore[reportAttributeAccessIssue] # lxml has no complete bundled typing
21
+
22
+ from hwpx.document import HwpxDocument
23
+ from hwpx.oxml import HwpxOxmlTable
24
+ from hwpx.quality import SavePipeline
25
+
26
+ from .catalog import catalog_hash
27
+ from .document import HwpxAgentDocument, NodeRecord
28
+ from .model import (
29
+ NODE_PROPERTY_CATALOG_V1,
30
+ AgentBatchResult,
31
+ AgentContractError,
32
+ validate_agent_batch,
33
+ )
34
+ from .path import parse_path
35
+ from .story import (
36
+ HEADER_STORY_EDITABLE_PROPERTIES,
37
+ HEADER_STORY_KIND,
38
+ HeaderStoryBinding,
39
+ try_parse_header_story_path,
40
+ )
41
+ # Verification/orchestration primitives for apply_document_commands live in
42
+ # _batch_verification (split out to keep this file under its line-count
43
+ # ratchet). _error_from_exception/_member_diff/_quality_policy/_revision are
44
+ # re-exported here (explicit `as` aliases) because blueprint/replay.py still
45
+ # imports them from this module.
46
+ from ._batch_verification import (
47
+ DomainVerifier,
48
+ FaultInjector,
49
+ IdempotencyStore,
50
+ _EMPTY_REVISION,
51
+ _apply_commands_build_candidate_report,
52
+ _apply_commands_domain_verification,
53
+ _apply_commands_idempotency_lookup,
54
+ _apply_commands_run_save_pipeline,
55
+ _call_fault,
56
+ _error_from_exception as _error_from_exception,
57
+ _failure_result,
58
+ _member_diff as _member_diff,
59
+ _quality_policy as _quality_policy,
60
+ _request_hash,
61
+ _require_candidate_structural_safety,
62
+ _revision as _revision,
63
+ _validate_apply_commands_input,
64
+ )
65
+
66
+ _HWP_UNITS_PER_MM = 7200 / 25.4
67
+ _COLOR_PATTERN = re.compile(r"^#[0-9A-Fa-f]{6}$")
68
+ _PARAGRAPH_ALIGNMENTS = frozenset(
69
+ {"LEFT", "CENTER", "RIGHT", "JUSTIFY", "DISTRIBUTE", "DISTRIBUTE_SPACE"}
70
+ )
71
+ _TABLE_ALIGNMENTS = frozenset({"LEFT", "CENTER", "RIGHT", "INSIDE", "OUTSIDE"})
72
+ _VERTICAL_ALIGNMENTS = frozenset({"TOP", "CENTER", "BOTTOM"})
73
+ _INLINE_KINDS = frozenset({"table", "picture", "shape", "footnote", "endnote"})
74
+ _SHAPE_KINDS = frozenset({"line", "rect", "ellipse", "arc", "polygon", "curve", "connectLine"})
75
+
76
+ # Creation needs a few geometry fields that are readable, rather than editable,
77
+ # after the node exists. Everything else comes directly from the frozen v1
78
+ # property catalog.
79
+ _CREATE_PROPERTIES: dict[str, frozenset[str]] = {
80
+ "section": frozenset({"pageWidthMm", "pageHeightMm"}),
81
+ "paragraph": frozenset(NODE_PROPERTY_CATALOG_V1["paragraph"]["editable"]),
82
+ "run": frozenset(NODE_PROPERTY_CATALOG_V1["run"]["editable"]),
83
+ "table": frozenset({"rowCount", "columnCount", "caption", "widthMm", "alignment"}),
84
+ "row": frozenset({"cellCount", "heightMm"}),
85
+ }
86
+
87
+ _ADD_PARENTS = {
88
+ "section": "document",
89
+ "paragraph": "section",
90
+ "run": "paragraph",
91
+ "table": "paragraph",
92
+ "row": "table",
93
+ }
94
+
95
+ _MOVE_COPY_PARENTS = {
96
+ "paragraph": "section",
97
+ "run": "paragraph",
98
+ "table": "paragraph",
99
+ "row": "table",
100
+ "picture": "paragraph",
101
+ "shape": "paragraph",
102
+ "memo": "section",
103
+ "footnote": "paragraph",
104
+ "endnote": "paragraph",
105
+ }
106
+
107
+ def _local_name(element: Any) -> str:
108
+ return str(getattr(element, "tag", "")).rsplit("}", 1)[-1]
109
+
110
+
111
+ def _tag_like(element: Any, local_name: str) -> str:
112
+ tag = str(element.tag)
113
+ return f"{tag.rsplit('}', 1)[0]}}}{local_name}" if "}" in tag else local_name
114
+
115
+
116
+ def _element(native: Any) -> Any:
117
+ return getattr(native, "element", native)
118
+
119
+
120
+ def _require_string(value: Any, name: str, *, allow_empty: bool = True) -> str:
121
+ if not isinstance(value, str) or (not allow_empty and not value):
122
+ raise AgentContractError("invalid_syntax", f"{name} must be a string", target=name)
123
+ return value
124
+
125
+
126
+ def _require_bool(value: Any, name: str) -> bool:
127
+ if not isinstance(value, bool):
128
+ raise AgentContractError("invalid_syntax", f"{name} must be boolean", target=name)
129
+ return value
130
+
131
+
132
+ def _require_number(value: Any, name: str, *, positive: bool = False) -> float:
133
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
134
+ raise AgentContractError("invalid_syntax", f"{name} must be numeric", target=name)
135
+ result = float(value)
136
+ if positive and result <= 0:
137
+ raise AgentContractError("invalid_syntax", f"{name} must be positive", target=name)
138
+ return result
139
+
140
+
141
+ def _require_integer(value: Any, name: str, *, positive: bool = False) -> int:
142
+ if isinstance(value, bool) or not isinstance(value, int):
143
+ raise AgentContractError("invalid_syntax", f"{name} must be an integer", target=name)
144
+ if positive and value <= 0:
145
+ raise AgentContractError("invalid_syntax", f"{name} must be positive", target=name)
146
+ return value
147
+
148
+
149
+ def _require_color(value: Any, name: str) -> str:
150
+ color = _require_string(value, name, allow_empty=False)
151
+ if not _COLOR_PATTERN.fullmatch(color):
152
+ raise AgentContractError("invalid_syntax", f"{name} must be #RRGGBB", target=name)
153
+ return color.upper()
154
+
155
+
156
+ def _require_enum(value: Any, name: str, choices: frozenset[str]) -> str:
157
+ text = _require_string(value, name, allow_empty=False).upper()
158
+ if text not in choices:
159
+ raise AgentContractError(
160
+ "invalid_syntax", f"{name} must be one of {sorted(choices)}", target=name
161
+ )
162
+ return text
163
+
164
+
165
+ def _validate_property_values(kind: str, properties: Mapping[str, Any], *, creating: bool) -> None:
166
+ allowed = (
167
+ _CREATE_PROPERTIES.get(kind, frozenset())
168
+ if creating
169
+ else frozenset(NODE_PROPERTY_CATALOG_V1[kind]["editable"])
170
+ )
171
+ unknown = sorted(set(properties) - allowed)
172
+ if unknown:
173
+ raise AgentContractError(
174
+ "unknown_property",
175
+ f"{kind} does not support properties: {unknown}",
176
+ target=f"{kind}.{unknown[0]}",
177
+ )
178
+ for name, value in properties.items():
179
+ target = f"{kind}.{name}"
180
+ if name in {"text", "value", "altText", "caption", "fontName", "style"}:
181
+ _require_string(value, target)
182
+ elif name in {"bold", "italic", "underline", "breakBefore", "keepWithNext", "readOnly"}:
183
+ _require_bool(value, target)
184
+ elif name in {"fontSizePt", "lineSpacingPercent", "heightMm", "widthMm", "pageWidthMm", "pageHeightMm"}:
185
+ _require_number(value, target, positive=True)
186
+ elif name in {"rowCount", "columnCount", "cellCount"}:
187
+ _require_integer(value, target, positive=True)
188
+ elif name == "color" or name == "backgroundColor":
189
+ _require_color(value, target)
190
+ elif name == "alignment":
191
+ _require_enum(
192
+ value,
193
+ target,
194
+ _TABLE_ALIGNMENTS if kind == "table" else _PARAGRAPH_ALIGNMENTS,
195
+ )
196
+ elif name == "verticalAlignment":
197
+ _require_enum(value, target, _VERTICAL_ALIGNMENTS)
198
+ else: # pragma: no cover - catalog additions must choose a type above
199
+ raise AgentContractError("unknown_property", f"untyped property: {target}", target=target)
200
+
201
+
202
+ def _validate_header_story_properties(properties: Mapping[str, Any]) -> None:
203
+ unknown = sorted(set(properties) - HEADER_STORY_EDITABLE_PROPERTIES)
204
+ if unknown:
205
+ raise AgentContractError(
206
+ "unknown_property",
207
+ f"header does not support properties: {unknown}",
208
+ target=f"header.{unknown[0]}",
209
+ )
210
+ if "text" not in properties:
211
+ raise AgentContractError(
212
+ "invalid_syntax", "existing header set requires text", target="header.text"
213
+ )
214
+ _require_string(properties["text"], "header.text")
215
+
216
+
217
+ def _preflight_reference_kind(value: str, alias_kinds: Mapping[str, Mapping[str, str]]) -> str:
218
+ if value.startswith("$"):
219
+ command_id, field = value[1:].split(".", 1)
220
+ try:
221
+ return alias_kinds[command_id][field]
222
+ except KeyError as exc: # validate_agent_batch already checks ordering
223
+ raise AgentContractError("not_found", f"unknown command alias: {value}") from exc
224
+ story = try_parse_header_story_path(value)
225
+ if story is not None:
226
+ return story.kind
227
+ parsed = parse_path(value)
228
+ return parsed.segments[-1].kind if parsed.segments else "document"
229
+
230
+
231
+ def _preflight_parent_kind(value: str, alias_kinds: Mapping[str, Mapping[str, str]]) -> str:
232
+ if value.startswith("$"):
233
+ command_id, field = value[1:].split(".", 1)
234
+ try:
235
+ return alias_kinds[command_id]["parentPath" if field == "path" else field]
236
+ except KeyError:
237
+ return "document"
238
+ story = try_parse_header_story_path(value)
239
+ if story is not None:
240
+ return "section"
241
+ parsed = parse_path(value)
242
+ return parsed.segments[-2].kind if len(parsed.segments) > 1 else "document"
243
+
244
+
245
+ def _preflight_add_command(command: Mapping[str, Any], alias_kinds: dict[str, dict[str, str]]) -> None:
246
+ kind = command["kind"]
247
+ if kind not in _ADD_PARENTS:
248
+ raise AgentContractError(
249
+ "unsupported_operation", f"add is unsupported for {kind}", target=command["commandId"]
250
+ )
251
+ _validate_property_values(kind, command["properties"], creating=True)
252
+ if kind == "table" and not {"rowCount", "columnCount"} <= set(command["properties"]):
253
+ raise AgentContractError(
254
+ "invalid_syntax", "table add requires rowCount and columnCount", target=command["commandId"]
255
+ )
256
+ if kind == "row" and "cellCount" not in command["properties"]:
257
+ raise AgentContractError(
258
+ "invalid_syntax", "row add requires cellCount", target=command["commandId"]
259
+ )
260
+ destination_kind = _preflight_reference_kind(command["parent"], alias_kinds)
261
+ expected_parent = _ADD_PARENTS[kind]
262
+ if destination_kind != expected_parent:
263
+ raise AgentContractError(
264
+ "incompatible_parent",
265
+ f"{kind} requires a {expected_parent} parent, not {destination_kind}",
266
+ target=command["commandId"],
267
+ )
268
+ alias_kinds[command["commandId"]] = {
269
+ "path": kind,
270
+ "parentPath": destination_kind,
271
+ }
272
+
273
+
274
+ def _preflight_mutate_command(
275
+ op: str, command: Mapping[str, Any], alias_kinds: dict[str, dict[str, str]]
276
+ ) -> None:
277
+ source_kind = _preflight_reference_kind(command["path"], alias_kinds)
278
+ if source_kind == HEADER_STORY_KIND:
279
+ if op != "set":
280
+ raise AgentContractError(
281
+ "unsupported_operation",
282
+ f"{op} is unsupported for {source_kind}",
283
+ target=command["commandId"],
284
+ )
285
+ _validate_header_story_properties(command["properties"])
286
+ alias_kinds[command["commandId"]] = {
287
+ "path": source_kind,
288
+ "parentPath": "section",
289
+ }
290
+ return
291
+ if op not in NODE_PROPERTY_CATALOG_V1[source_kind]["operations"]:
292
+ raise AgentContractError(
293
+ "unsupported_operation",
294
+ f"{op} is unsupported for {source_kind}",
295
+ target=command["commandId"],
296
+ )
297
+ if op == "set":
298
+ _validate_property_values(source_kind, command["properties"], creating=False)
299
+ destination_kind = _preflight_parent_kind(command["path"], alias_kinds)
300
+ if op in {"move", "copy"}:
301
+ destination_kind = _preflight_reference_kind(command["parent"], alias_kinds)
302
+ move_parent = _MOVE_COPY_PARENTS.get(source_kind)
303
+ if destination_kind != move_parent:
304
+ raise AgentContractError(
305
+ "incompatible_parent",
306
+ f"{source_kind} requires a {move_parent} parent, not {destination_kind}",
307
+ target=command["commandId"],
308
+ )
309
+ alias_kinds[command["commandId"]] = {
310
+ "path": source_kind,
311
+ "parentPath": destination_kind,
312
+ }
313
+
314
+
315
+ def _preflight(batch: Mapping[str, Any]) -> None:
316
+ """Reject the complete static command matrix before the first mutation."""
317
+
318
+ alias_kinds: dict[str, dict[str, str]] = {}
319
+
320
+ for command in batch["commands"]:
321
+ op = command["op"]
322
+ if op == "add":
323
+ _preflight_add_command(command, alias_kinds)
324
+ continue
325
+ _preflight_mutate_command(op, command, alias_kinds)
326
+
327
+
328
+ def _resolve_alias(value: str, aliases: Mapping[str, Mapping[str, str]]) -> str:
329
+ if not value.startswith("$"):
330
+ return value
331
+ command_id, field = value[1:].split(".", 1)
332
+ try:
333
+ return aliases[command_id][field]
334
+ except KeyError as exc:
335
+ raise AgentContractError(
336
+ "not_found", f"command alias is unavailable: {value}", target=value
337
+ ) from exc
338
+
339
+
340
+ def _resolved_position(
341
+ position: Mapping[str, Any], aliases: Mapping[str, Mapping[str, str]]
342
+ ) -> dict[str, Any]:
343
+ result = dict(position)
344
+ if "path" in result:
345
+ result["path"] = _resolve_alias(str(result["path"]), aliases)
346
+ return result
347
+
348
+
349
+ def _record_for_native(view: HwpxAgentDocument, native_or_element: Any) -> NodeRecord:
350
+ target = _element(native_or_element)
351
+ for record in view.records:
352
+ if _element(record.native) is target:
353
+ return record
354
+ raise AgentContractError("not_found", "mutated node was not present after projection")
355
+
356
+
357
+ def _ensure_operation(record: NodeRecord, operation: str) -> None:
358
+ if operation not in NODE_PROPERTY_CATALOG_V1[record.kind]["operations"]:
359
+ raise AgentContractError(
360
+ "unsupported_operation",
361
+ f"{operation} is unsupported for {record.kind}",
362
+ target=record.path,
363
+ )
364
+
365
+
366
+ def _ensure_parent(record: NodeRecord, *, source_kind: str, operation: str) -> None:
367
+ expected = _ADD_PARENTS.get(source_kind) if operation == "add" else _MOVE_COPY_PARENTS.get(source_kind)
368
+ if expected != record.kind:
369
+ raise AgentContractError(
370
+ "incompatible_parent",
371
+ f"{source_kind} requires a {expected} parent, not {record.kind}",
372
+ target=record.path,
373
+ )
374
+
375
+
376
+ def _sibling_elements(view: HwpxAgentDocument, parent: NodeRecord, kind: str) -> list[Any]:
377
+ return [
378
+ _element(record.native)
379
+ for record in view.records
380
+ if record.parent_path == parent.path and record.kind == kind
381
+ ]
382
+
383
+
384
+ def _insertion_index(
385
+ view: HwpxAgentDocument,
386
+ parent: NodeRecord,
387
+ kind: str,
388
+ position: Mapping[str, Any],
389
+ *,
390
+ siblings: list[Any] | None = None,
391
+ ) -> int:
392
+ siblings = list(siblings if siblings is not None else _sibling_elements(view, parent, kind))
393
+ mode = position["mode"]
394
+ if mode == "append":
395
+ return len(siblings)
396
+ if mode == "prepend":
397
+ return 0
398
+ if mode == "index":
399
+ index = int(position["index"])
400
+ if index > len(siblings) + 1:
401
+ raise AgentContractError(
402
+ "not_found", f"one-based insertion index {index} is out of range", target=parent.path
403
+ )
404
+ return index - 1
405
+ anchor = view.resolve_record(str(position["path"]))
406
+ if anchor.parent_path != parent.path or anchor.kind != kind:
407
+ raise AgentContractError(
408
+ "incompatible_parent", "position anchor is not a compatible sibling", target=anchor.path
409
+ )
410
+ try:
411
+ index = siblings.index(_element(anchor.native))
412
+ except ValueError as exc:
413
+ raise AgentContractError("not_found", "position anchor is unavailable", target=anchor.path) from exc
414
+ return index + (1 if mode == "after" else 0)
415
+
416
+
417
+ def _insert_direct_child(
418
+ view: HwpxAgentDocument,
419
+ parent: NodeRecord,
420
+ kind: str,
421
+ element: Any,
422
+ position: Mapping[str, Any],
423
+ ) -> None:
424
+ siblings = _sibling_elements(view, parent, kind)
425
+ index = _insertion_index(view, parent, kind, position, siblings=siblings)
426
+ container = _element(parent.native)
427
+ if index >= len(siblings):
428
+ if siblings:
429
+ absolute = list(container).index(siblings[-1]) + 1
430
+ container.insert(absolute, element)
431
+ else:
432
+ container.append(element)
433
+ else:
434
+ container.insert(list(container).index(siblings[index]), element)
435
+
436
+
437
+ def _insert_inline(
438
+ view: HwpxAgentDocument,
439
+ parent: NodeRecord,
440
+ kind: str,
441
+ element: Any,
442
+ position: Mapping[str, Any],
443
+ ) -> Any:
444
+ paragraph = parent.native
445
+ siblings = _sibling_elements(view, parent, kind)
446
+ index = _insertion_index(view, parent, kind, position, siblings=siblings)
447
+ run = paragraph.element.makeelement(
448
+ _tag_like(paragraph.element, "run"),
449
+ {"charPrIDRef": paragraph.char_pr_id_ref or "0"},
450
+ )
451
+ run.append(element)
452
+ if index >= len(siblings):
453
+ paragraph.element.append(run)
454
+ else:
455
+ anchor = siblings[index]
456
+ anchor_run = anchor.getparent() if hasattr(anchor, "getparent") else next(
457
+ child for child in paragraph.element if anchor in list(child)
458
+ )
459
+ paragraph.element.insert(list(paragraph.element).index(anchor_run), run)
460
+ paragraph.section.mark_dirty()
461
+ return run
462
+
463
+
464
+ def _remove_inline_element(element: Any, paragraph: Any) -> None:
465
+ run = element.getparent() if hasattr(element, "getparent") else next(
466
+ child for child in paragraph.element if element in list(child)
467
+ )
468
+ run.remove(element)
469
+ if not list(run) and run in list(paragraph.element):
470
+ paragraph.element.remove(run)
471
+ paragraph.section.mark_dirty()
472
+
473
+
474
+ def _set_shape_comment(element: Any, value: str) -> None:
475
+ comment = next((child for child in element if _local_name(child) == "shapeComment"), None)
476
+ if comment is None:
477
+ comment = element.makeelement(_tag_like(element, "shapeComment"), {})
478
+ element.append(comment)
479
+ comment.text = value
480
+
481
+
482
+ def _next_numeric_identity(document: HwpxDocument) -> str:
483
+ maximum = 0
484
+ for section in document.sections:
485
+ for node in section.element.iter():
486
+ for name in ("id", "instid", "instId"):
487
+ value = node.get(name)
488
+ if value and value.isdigit():
489
+ maximum = max(maximum, int(value))
490
+ return str(maximum + 1)
491
+
492
+
493
+ def _table_caption(table: Any, value: str) -> None:
494
+ element = table.element
495
+ caption = next((child for child in element if _local_name(child) == "caption"), None)
496
+ if caption is None:
497
+ caption = element.makeelement(
498
+ _tag_like(element, "caption"),
499
+ {"side": "TOP", "fullSz": "0", "width": "0", "gap": "850", "lastWidth": "0"},
500
+ )
501
+ sublist = caption.makeelement(
502
+ _tag_like(caption, "subList"),
503
+ {
504
+ "id": "",
505
+ "textDirection": "HORIZONTAL",
506
+ "lineWrap": "BREAK",
507
+ "vertAlign": "TOP",
508
+ "linkListIDRef": "0",
509
+ "linkListNextIDRef": "0",
510
+ "textWidth": "0",
511
+ "textHeight": "0",
512
+ "hasTextRef": "0",
513
+ "hasNumRef": "0",
514
+ },
515
+ )
516
+ paragraph = sublist.makeelement(
517
+ _tag_like(sublist, "p"),
518
+ {
519
+ "id": _next_numeric_identity(table.paragraph.section.document),
520
+ "paraPrIDRef": "0",
521
+ "styleIDRef": "0",
522
+ "pageBreak": "0",
523
+ "columnBreak": "0",
524
+ "merged": "0",
525
+ },
526
+ )
527
+ run = paragraph.makeelement(_tag_like(paragraph, "run"), {"charPrIDRef": "0"})
528
+ text = run.makeelement(_tag_like(run, "t"), {})
529
+ run.append(text)
530
+ paragraph.append(run)
531
+ sublist.append(paragraph)
532
+ caption.append(sublist)
533
+ children = list(element)
534
+ position_index = next(
535
+ (index + 1 for index, child in enumerate(children) if _local_name(child) == "pos"),
536
+ min(2, len(children)),
537
+ )
538
+ element.insert(position_index, caption)
539
+ text_nodes = [node for node in caption.iter() if _local_name(node) == "t"]
540
+ if not text_nodes:
541
+ raise AgentContractError("unsupported_content", "table caption has no editable text node")
542
+ text_nodes[0].text = value
543
+ for node in text_nodes[1:]:
544
+ node.text = ""
545
+ table.mark_dirty()
546
+
547
+
548
+ def _global_paragraph_index(document: HwpxDocument, paragraph: Any) -> int:
549
+ for index, candidate in enumerate(document.paragraphs):
550
+ if candidate.element is paragraph.element:
551
+ return index
552
+ raise AgentContractError("not_found", "paragraph is detached")
553
+
554
+
555
+ def _style_id(document: HwpxDocument, value: str) -> str:
556
+ if value in document.styles:
557
+ return value
558
+ matches = [style_id for style_id, style in document.styles.items() if style.name == value]
559
+ if len(matches) != 1:
560
+ raise AgentContractError(
561
+ "not_found" if not matches else "ambiguous_target",
562
+ f"paragraph style is not uniquely resolvable: {value!r}",
563
+ target="paragraph.style",
564
+ )
565
+ return matches[0]
566
+
567
+
568
+ def _mark_containing_section(document: HwpxDocument, target: Any) -> None:
569
+ for section in document.sections:
570
+ if any(node is target for node in section.element.iter()):
571
+ section.mark_dirty()
572
+ return
573
+ raise AgentContractError("not_found", "mutated element is detached")
574
+
575
+
576
+ def _apply_header_story_set(
577
+ binding: HeaderStoryBinding, properties: Mapping[str, Any]
578
+ ) -> dict[str, Any]:
579
+ """Apply the bounded existing-header mutation through its OXML owner."""
580
+
581
+ _validate_header_story_properties(properties)
582
+ try:
583
+ binding.native.set_simple_text_preserving(properties["text"])
584
+ except AgentContractError:
585
+ raise
586
+ except ValueError as exc:
587
+ # The OXML owner uses ValueError for rich/control-bearing or otherwise
588
+ # structurally unsafe headers. Do not let it degrade to the generic
589
+ # invariant envelope or fall back to the destructive whole-story setter.
590
+ message = str(exc) or "header content cannot be edited losslessly"
591
+ lowered = message.lower()
592
+ code = (
593
+ "ambiguous_target"
594
+ if "ambiguous" in lowered
595
+ else "not_found"
596
+ if "not found" in lowered
597
+ else "unsupported_content"
598
+ )
599
+ raise AgentContractError(
600
+ code,
601
+ message,
602
+ target=binding.path,
603
+ ) from exc
604
+ return {
605
+ "text": {
606
+ "before": binding.text,
607
+ "after": properties["text"],
608
+ }
609
+ }
610
+
611
+
612
+ def _apply_set_paragraph(document: HwpxDocument, native: Any, properties: Mapping[str, Any]) -> None:
613
+ if "text" in properties:
614
+ native.text = properties["text"]
615
+ if "style" in properties:
616
+ native.style_id_ref = _style_id(document, properties["style"])
617
+ format_kwargs: dict[str, Any] = {}
618
+ if "alignment" in properties:
619
+ format_kwargs["alignment"] = properties["alignment"]
620
+ if "lineSpacingPercent" in properties:
621
+ format_kwargs["line_spacing_percent"] = properties["lineSpacingPercent"]
622
+ if "keepWithNext" in properties:
623
+ format_kwargs["keep_with_next"] = properties["keepWithNext"]
624
+ if "breakBefore" in properties:
625
+ format_kwargs["page_break_before"] = properties["breakBefore"]
626
+ if format_kwargs:
627
+ document.set_paragraph_format(
628
+ paragraph_index=_global_paragraph_index(document, native), **format_kwargs
629
+ )
630
+
631
+
632
+ def _apply_set_run(document: HwpxDocument, native: Any, properties: Mapping[str, Any]) -> None:
633
+ if "text" in properties:
634
+ native.text = properties["text"]
635
+ style = native.style
636
+ child_attrs = style.child_attributes if style is not None else {}
637
+ flags = {
638
+ "bold": "bold" in child_attrs,
639
+ "italic": "italic" in child_attrs,
640
+ "underline": child_attrs.get("underline", {}).get("type", "NONE").upper() != "NONE",
641
+ }
642
+ for name in flags:
643
+ if name in properties:
644
+ flags[name] = properties[name]
645
+ if set(properties) - {"text"}:
646
+ requested_font = properties.get("fontName")
647
+ if requested_font is not None and not any(
648
+ header.font_ref_for_face(requested_font) is not None
649
+ for header in document._root.headers # type: ignore[attr-defined]
650
+ ):
651
+ raise AgentContractError(
652
+ "not_found", f"font is not declared by the document: {requested_font!r}", target="run.fontName"
653
+ )
654
+ style_id = document.ensure_run_style(
655
+ **flags,
656
+ color=properties.get("color"),
657
+ font=requested_font,
658
+ size=properties.get("fontSizePt"),
659
+ base_char_pr_id=native.char_pr_id_ref,
660
+ )
661
+ native.char_pr_id_ref = style_id
662
+
663
+
664
+ def _apply_set_table(native: Any, properties: Mapping[str, Any], record: NodeRecord) -> None:
665
+ if "caption" in properties:
666
+ _table_caption(native, properties["caption"])
667
+ if "alignment" in properties:
668
+ pos = next((child for child in native.element if _local_name(child) == "pos"), None)
669
+ if pos is None:
670
+ raise AgentContractError("unsupported_content", "table has no position element", target=record.path)
671
+ pos.set("horzAlign", properties["alignment"].upper())
672
+ native.mark_dirty()
673
+
674
+
675
+ def _apply_set_row(native: Any, properties: Mapping[str, Any]) -> None:
676
+ height = round(_require_number(properties["heightMm"], "row.heightMm", positive=True) * _HWP_UNITS_PER_MM)
677
+ for cell in native.cells:
678
+ cell.set_size(height=height)
679
+
680
+
681
+ def _apply_set_cell(native: Any, properties: Mapping[str, Any], record: NodeRecord) -> None:
682
+ if "text" in properties:
683
+ native.text = properties["text"]
684
+ if "verticalAlignment" in properties:
685
+ sublist = next((child for child in native.element if _local_name(child) == "subList"), None)
686
+ if sublist is None:
687
+ raise AgentContractError("unsupported_content", "cell has no subList", target=record.path)
688
+ sublist.set("vertAlign", properties["verticalAlignment"].upper())
689
+ native.table.mark_dirty()
690
+ if "backgroundColor" in properties:
691
+ row, column = native.address
692
+ native.table.set_cell_shading(row, column, properties["backgroundColor"])
693
+
694
+
695
+ def _apply_set_form_field(document: HwpxDocument, native: Any, properties: Mapping[str, Any], record: NodeRecord) -> None:
696
+ if "value" in properties:
697
+ document.fill_form_field(properties["value"], field_index=int(native["index"]))
698
+ if "readOnly" in properties:
699
+ runs = native.get("_runs") or []
700
+ try:
701
+ ctrl = list(runs[int(native["run_index"])])[int(native["child_index"])]
702
+ begin = next(child for child in ctrl if _local_name(child) == "fieldBegin")
703
+ except (IndexError, KeyError, StopIteration, TypeError) as exc:
704
+ raise AgentContractError(
705
+ "unsupported_content", "form-field control is incomplete", target=record.path
706
+ ) from exc
707
+ begin.set("editable", "false" if properties["readOnly"] else "true")
708
+ paragraph = native["_paragraph"]
709
+ paragraph.section.mark_dirty()
710
+
711
+
712
+ def _apply_set_picture_shape(document: HwpxDocument, native: Any, kind: str, properties: Mapping[str, Any]) -> None:
713
+ target_element = _element(native)
714
+ _set_shape_comment(target_element, properties["altText"])
715
+ if kind == "shape":
716
+ native.paragraph.section.mark_dirty()
717
+ else:
718
+ _mark_containing_section(document, target_element)
719
+
720
+
721
+ def _set_result_summary(record: NodeRecord, properties: Mapping[str, Any]) -> dict[str, Any]:
722
+ return {name: {"before": record.summary.get(name), "after": value} for name, value in properties.items()}
723
+
724
+
725
+ def _apply_set(document: HwpxDocument, record: NodeRecord, properties: Mapping[str, Any]) -> dict[str, Any]:
726
+ _ensure_operation(record, "set")
727
+ _validate_property_values(record.kind, properties, creating=False)
728
+ native = record.native
729
+ kind = record.kind
730
+
731
+ if kind == "paragraph":
732
+ _apply_set_paragraph(document, native, properties)
733
+ elif kind == "run":
734
+ _apply_set_run(document, native, properties)
735
+ elif kind == "table":
736
+ _apply_set_table(native, properties, record)
737
+ elif kind == "row":
738
+ _apply_set_row(native, properties)
739
+ elif kind == "cell":
740
+ _apply_set_cell(native, properties, record)
741
+ elif kind == "form-field":
742
+ _apply_set_form_field(document, native, properties, record)
743
+ elif kind in {"picture", "shape"}:
744
+ _apply_set_picture_shape(document, native, kind, properties)
745
+ elif kind == "memo":
746
+ native.text = properties["text"]
747
+ elif kind in {"footnote", "endnote"}:
748
+ native.text = properties["text"]
749
+ else:
750
+ raise AgentContractError("unsupported_operation", f"set is unsupported for {kind}", target=record.path)
751
+ return _set_result_summary(record, properties)
752
+
753
+
754
+ def _apply_create_properties(
755
+ document: HwpxDocument, record: NodeRecord, properties: Mapping[str, Any]
756
+ ) -> dict[str, Any]:
757
+ # Geometry used to create tables/rows/sections is already present. Apply the
758
+ # remaining editable subset through the exact same set compiler.
759
+ editable = set(NODE_PROPERTY_CATALOG_V1[record.kind]["editable"])
760
+ setters = {name: value for name, value in properties.items() if name in editable}
761
+ return _apply_set(document, record, setters) if setters else {}
762
+
763
+
764
+ def _add(
765
+ document: HwpxDocument,
766
+ view: HwpxAgentDocument,
767
+ parent: NodeRecord,
768
+ kind: str,
769
+ properties: Mapping[str, Any],
770
+ position: Mapping[str, Any],
771
+ ) -> Any:
772
+ _ensure_parent(parent, source_kind=kind, operation="add")
773
+ if kind == "section":
774
+ if position["mode"] != "append":
775
+ raise AgentContractError(
776
+ "unsupported_operation", "section insertion is append-only in v1", target=parent.path
777
+ )
778
+ created = document.add_section()
779
+ size_kwargs: dict[str, int] = {}
780
+ if "pageWidthMm" in properties:
781
+ size_kwargs["width"] = round(properties["pageWidthMm"] * _HWP_UNITS_PER_MM)
782
+ if "pageHeightMm" in properties:
783
+ size_kwargs["height"] = round(properties["pageHeightMm"] * _HWP_UNITS_PER_MM)
784
+ if size_kwargs:
785
+ created.properties.set_page_size(
786
+ width=size_kwargs.get("width"),
787
+ height=size_kwargs.get("height"),
788
+ )
789
+ return created
790
+ if kind == "paragraph":
791
+ created = parent.native.add_paragraph(str(properties.get("text", "")))
792
+ # add_paragraph appends; relocate its native element for all other positions.
793
+ if position["mode"] != "append":
794
+ if hasattr(created.element, "getparent"):
795
+ created.element.getparent().remove(created.element)
796
+ else:
797
+ parent.native.element.remove(created.element)
798
+ _insert_direct_child(view, parent, kind, created.element, position)
799
+ parent.native.mark_dirty()
800
+ return created
801
+ if kind == "run":
802
+ run_kwargs = {
803
+ key: value
804
+ for key, value in {
805
+ "bold": properties.get("bold", False),
806
+ "italic": properties.get("italic", False),
807
+ "underline": properties.get("underline", False),
808
+ "color": properties.get("color"),
809
+ "font": properties.get("fontName"),
810
+ "size": properties.get("fontSizePt"),
811
+ }.items()
812
+ if value is not None
813
+ }
814
+ created = parent.native.add_run(str(properties.get("text", "")), **run_kwargs)
815
+ if position["mode"] != "append":
816
+ parent.native.element.remove(created.element)
817
+ _insert_direct_child(view, parent, kind, created.element, position)
818
+ parent.native.section.mark_dirty()
819
+ return created
820
+ if kind == "table":
821
+ created = parent.native.add_table(
822
+ int(properties["rowCount"]),
823
+ int(properties["columnCount"]),
824
+ width=(round(properties["widthMm"] * _HWP_UNITS_PER_MM) if "widthMm" in properties else None),
825
+ )
826
+ if position["mode"] != "append":
827
+ _remove_inline_element(created.element, parent.native)
828
+ _insert_inline(view, parent, kind, created.element, position)
829
+ return created
830
+ if kind == "row":
831
+ table = parent.native
832
+ if int(properties["cellCount"]) != table.column_count:
833
+ raise AgentContractError(
834
+ "incompatible_parent", "row cellCount must equal table columnCount", target=parent.path
835
+ )
836
+ sample = HwpxOxmlTable.create(
837
+ 1,
838
+ table.column_count,
839
+ width=next(
840
+ (int(child.get("width", "0")) for child in table.element if _local_name(child) == "sz"),
841
+ None,
842
+ ),
843
+ border_fill_id_ref=table.element.get("borderFillIDRef") or "0",
844
+ )
845
+ created = next(child for child in sample if _local_name(child) == "tr")
846
+ if type(created) is not type(table.element):
847
+ payload = ET.tostring(created, encoding="utf-8")
848
+ created = LET.fromstring(payload) if isinstance(table.element, LET._Element) else ET.fromstring(payload)
849
+ _insert_direct_child(view, parent, kind, created, position)
850
+ table.mark_dirty()
851
+ _refresh_table_rows(table)
852
+ return created
853
+ raise AgentContractError("unsupported_operation", f"add is unsupported for {kind}")
854
+
855
+
856
+ def _table_has_vertical_merge(table: Any) -> bool:
857
+ return any(cell.span[0] > 1 for row in table.rows for cell in row.cells)
858
+
859
+
860
+ def _refresh_table_rows(table: Any) -> None:
861
+ rows = table.rows
862
+ table.element.set("rowCnt", str(len(rows)))
863
+ for row_index, row in enumerate(rows):
864
+ for column_index, cell in enumerate(row.cells):
865
+ addr = next((child for child in cell.element if _local_name(child) == "cellAddr"), None)
866
+ if addr is not None:
867
+ addr.set("rowAddr", str(row_index))
868
+ # Preserve explicit merged-cell column addresses where present.
869
+ if addr.get("colAddr") is None:
870
+ addr.set("colAddr", str(column_index))
871
+ table.mark_dirty()
872
+
873
+
874
+ def _remove(document: HwpxDocument, record: NodeRecord) -> None:
875
+ _ensure_operation(record, "remove")
876
+ native = record.native
877
+ if record.kind == "paragraph":
878
+ native.remove()
879
+ elif record.kind == "run":
880
+ native.remove()
881
+ elif record.kind == "table":
882
+ _remove_inline_element(native.element, native.paragraph)
883
+ elif record.kind == "row":
884
+ table = native.table
885
+ if len(table.rows) <= 1:
886
+ raise AgentContractError("invariant_violation", "a table must retain one row", target=record.path)
887
+ if _table_has_vertical_merge(table):
888
+ raise AgentContractError(
889
+ "unsupported_content", "row removal with vertical merges is unsupported", target=record.path
890
+ )
891
+ table.element.remove(native.element)
892
+ _refresh_table_rows(table)
893
+ elif record.kind == "picture":
894
+ parent = _element(record.native).getparent()
895
+ if parent is None:
896
+ raise AgentContractError("not_found", "picture is detached", target=record.path)
897
+ parent.remove(_element(record.native))
898
+ _mark_containing_section(document, parent)
899
+ elif record.kind == "shape":
900
+ _remove_inline_element(native.element, native.paragraph)
901
+ elif record.kind == "memo":
902
+ native.remove()
903
+ elif record.kind in {"footnote", "endnote"}:
904
+ _remove_inline_element(native.element, native.paragraph)
905
+ else:
906
+ raise AgentContractError(
907
+ "unsupported_operation", f"remove is unsupported for {record.kind}", target=record.path
908
+ )
909
+
910
+
911
+ def _detach(view: HwpxAgentDocument, record: NodeRecord) -> Any:
912
+ element = _element(record.native)
913
+ if record.kind == "paragraph":
914
+ record.native.section.element.remove(element)
915
+ record.native.section.mark_dirty()
916
+ elif record.kind == "run":
917
+ record.native.paragraph.element.remove(element)
918
+ record.native.paragraph.section.mark_dirty()
919
+ elif record.kind in _INLINE_KINDS:
920
+ paragraph = record.native.paragraph if hasattr(record.native, "paragraph") else None
921
+ if paragraph is None and record.parent_path is not None:
922
+ parent_record = view.resolve_record(record.parent_path)
923
+ paragraph = parent_record.native if parent_record.kind == "paragraph" else None
924
+ if paragraph is None:
925
+ raise AgentContractError("unsupported_content", "inline object has no paragraph binding")
926
+ _remove_inline_element(element, paragraph)
927
+ elif record.kind == "row":
928
+ table = record.native.table
929
+ if _table_has_vertical_merge(table):
930
+ raise AgentContractError(
931
+ "unsupported_content", "row movement with vertical merges is unsupported", target=record.path
932
+ )
933
+ table.element.remove(element)
934
+ _refresh_table_rows(table)
935
+ elif record.kind == "memo":
936
+ record.native.group.element.remove(element)
937
+ record.native.group.section.mark_dirty()
938
+ else:
939
+ raise AgentContractError("unsupported_operation", f"cannot detach {record.kind}")
940
+ return element
941
+
942
+
943
+ def _move(
944
+ view: HwpxAgentDocument,
945
+ source: NodeRecord,
946
+ parent: NodeRecord,
947
+ position: Mapping[str, Any],
948
+ ) -> Any:
949
+ _ensure_operation(source, "move")
950
+ _ensure_parent(parent, source_kind=source.kind, operation="move")
951
+ if source.path == parent.path or parent.path.startswith(source.path.rstrip("/") + "/"):
952
+ raise AgentContractError(
953
+ "incompatible_parent", "a node cannot move into its own subtree", target=parent.path
954
+ )
955
+ if source.kind == "row" and source.native.table.column_count != parent.native.column_count:
956
+ raise AgentContractError(
957
+ "incompatible_parent", "row destination must have the same column count", target=parent.path
958
+ )
959
+
960
+ element = _element(source.native)
961
+ siblings = _sibling_elements(view, parent, source.kind)
962
+ # Resolve before detaching so before/after anchors still exist. Moving to an
963
+ # explicit index uses the final sibling list after the source disappears.
964
+ anchor_index = _insertion_index(view, parent, source.kind, position, siblings=siblings)
965
+ if element in siblings:
966
+ old_index = siblings.index(element)
967
+ siblings.pop(old_index)
968
+ if position["mode"] in {"append", "prepend", "index"}:
969
+ anchor_index = _insertion_index(view, parent, source.kind, position, siblings=siblings)
970
+ elif old_index < anchor_index:
971
+ anchor_index -= 1
972
+ detached = _detach(view, source)
973
+
974
+ if source.kind in {"paragraph", "run", "row"}:
975
+ container = _element(parent.native)
976
+ if anchor_index >= len(siblings):
977
+ if siblings:
978
+ container.insert(list(container).index(siblings[-1]) + 1, detached)
979
+ else:
980
+ container.append(detached)
981
+ else:
982
+ container.insert(list(container).index(siblings[anchor_index]), detached)
983
+ if source.kind == "paragraph":
984
+ parent.native.mark_dirty()
985
+ elif source.kind == "run":
986
+ parent.native.section.mark_dirty()
987
+ else:
988
+ _refresh_table_rows(source.native.table)
989
+ _refresh_table_rows(parent.native)
990
+ elif source.kind in _INLINE_KINDS:
991
+ synthetic_position = {"mode": "index", "index": anchor_index + 1}
992
+ fresh_view = HwpxAgentDocument.from_document(view.document, revision=view.revision)
993
+ fresh_parent = _record_for_native(fresh_view, parent.native)
994
+ _insert_inline(fresh_view, fresh_parent, source.kind, detached, synthetic_position)
995
+ elif source.kind == "memo":
996
+ group = parent.native._memo_group_element(create=True)
997
+ if group is None: # pragma: no cover
998
+ raise AgentContractError("invariant_violation", "memo group creation failed")
999
+ siblings = [memo.element for memo in parent.native.memos]
1000
+ if position["mode"] == "append" or not siblings:
1001
+ group.append(detached)
1002
+ else:
1003
+ fresh_view = HwpxAgentDocument.from_document(view.document, revision=view.revision)
1004
+ fresh_parent = _record_for_native(fresh_view, parent.native)
1005
+ index = _insertion_index(fresh_view, fresh_parent, "memo", position)
1006
+ group.insert(index, detached)
1007
+ parent.native.mark_dirty()
1008
+ return detached
1009
+
1010
+
1011
+ def _identity_kind(local: str) -> str:
1012
+ return {
1013
+ "p": "paragraph",
1014
+ "tbl": "table",
1015
+ "pic": "picture",
1016
+ "memo": "memo",
1017
+ "footNote": "footnote",
1018
+ "endNote": "endnote",
1019
+ "fieldBegin": "form-field",
1020
+ }.get(local, "shape" if local in _SHAPE_KINDS else local)
1021
+
1022
+
1023
+ def _refresh_copy_identities(document: HwpxDocument, clone: Any) -> list[dict[str, str]]:
1024
+ used: set[str] = set()
1025
+ max_numeric = 0
1026
+ for section in document.sections:
1027
+ for node in section.element.iter():
1028
+ for name in ("id", "instid", "instId"):
1029
+ value = node.get(name)
1030
+ if value:
1031
+ used.add(value)
1032
+ if value.isdigit():
1033
+ max_numeric = max(max_numeric, int(value))
1034
+
1035
+ def allocate() -> str:
1036
+ nonlocal max_numeric
1037
+ while True:
1038
+ max_numeric += 1
1039
+ value = str(max_numeric)
1040
+ if value not in used:
1041
+ used.add(value)
1042
+ return value
1043
+
1044
+ identity_map: list[dict[str, str]] = []
1045
+ field_ids: dict[str, str] = {}
1046
+ for node in clone.iter():
1047
+ local = _local_name(node)
1048
+ names: tuple[str, ...] = ()
1049
+ if local in {"p", "tbl", "pic", "memo", "fieldBegin", *_SHAPE_KINDS}:
1050
+ names = tuple(name for name in ("id", "instid", "instId") if node.get(name) is not None)
1051
+ elif local in {"footNote", "endNote"}:
1052
+ names = tuple(name for name in ("instId", "id") if node.get(name) is not None)
1053
+ paired_identity = allocate() if len(names) > 1 and local in {"pic", *_SHAPE_KINDS} else None
1054
+ for name in names:
1055
+ old = str(node.get(name))
1056
+ new = paired_identity or allocate()
1057
+ node.set(name, new)
1058
+ identity_map.append(
1059
+ {"kind": _identity_kind(local), "attribute": name, "old": old, "new": new}
1060
+ )
1061
+ if local == "fieldBegin" and name == "id":
1062
+ field_ids[old] = new
1063
+ if field_ids:
1064
+ for node in clone.iter():
1065
+ if _local_name(node) != "fieldEnd":
1066
+ continue
1067
+ old = node.get("beginIDRef")
1068
+ if old in field_ids:
1069
+ node.set("beginIDRef", field_ids[old])
1070
+ return identity_map
1071
+
1072
+
1073
+ def _copy_node(
1074
+ document: HwpxDocument,
1075
+ view: HwpxAgentDocument,
1076
+ source: NodeRecord,
1077
+ parent: NodeRecord,
1078
+ position: Mapping[str, Any],
1079
+ ) -> tuple[Any, list[dict[str, str]]]:
1080
+ _ensure_operation(source, "copy")
1081
+ _ensure_parent(parent, source_kind=source.kind, operation="copy")
1082
+ if source.kind == "row":
1083
+ if source.native.table.column_count != parent.native.column_count:
1084
+ raise AgentContractError(
1085
+ "incompatible_parent", "row destination must have the same column count", target=parent.path
1086
+ )
1087
+ if _table_has_vertical_merge(source.native.table) or _table_has_vertical_merge(parent.native):
1088
+ raise AgentContractError(
1089
+ "unsupported_content", "row copy with vertical merges is unsupported", target=source.path
1090
+ )
1091
+ clone = copy.deepcopy(_element(source.native))
1092
+ identity_map = _refresh_copy_identities(document, clone)
1093
+ if source.kind in {"paragraph", "run", "row"}:
1094
+ _insert_direct_child(view, parent, source.kind, clone, position)
1095
+ if source.kind == "paragraph":
1096
+ parent.native.mark_dirty()
1097
+ elif source.kind == "run":
1098
+ parent.native.section.mark_dirty()
1099
+ else:
1100
+ _refresh_table_rows(parent.native)
1101
+ elif source.kind in _INLINE_KINDS:
1102
+ _insert_inline(view, parent, source.kind, clone, position)
1103
+ elif source.kind == "memo":
1104
+ group = parent.native._memo_group_element(create=True)
1105
+ if group is None: # pragma: no cover
1106
+ raise AgentContractError("invariant_violation", "memo group creation failed")
1107
+ siblings = [memo.element for memo in parent.native.memos]
1108
+ index = _insertion_index(view, parent, "memo", position, siblings=siblings)
1109
+ group.insert(index, clone)
1110
+ parent.native.mark_dirty()
1111
+ else:
1112
+ raise AgentContractError("unsupported_operation", f"copy is unsupported for {source.kind}")
1113
+ return clone, identity_map
1114
+
1115
+
1116
+ def _dispatch_command_op(
1117
+ document: HwpxDocument,
1118
+ view: HwpxAgentDocument,
1119
+ command: Mapping[str, Any],
1120
+ aliases: dict[str, dict[str, str]],
1121
+ input_revision: str,
1122
+ ) -> tuple[str | None, str | None, dict[str, Any], list[dict[str, str]], Any, HeaderStoryBinding | None]:
1123
+ """Apply one command's op and return its raw effects.
1124
+
1125
+ Returns (resolved_path, parent_path, changed, generated, target_native, story_before).
1126
+ """
1127
+
1128
+ op = command["op"]
1129
+ resolved_path: str | None = None
1130
+ parent_path: str | None = None
1131
+ changed: dict[str, Any] = {}
1132
+ generated: list[dict[str, str]] = []
1133
+ target_native: Any | None = None
1134
+ story_before: HeaderStoryBinding | None = None
1135
+ if op == "set":
1136
+ resolved_path = _resolve_alias(command["path"], aliases)
1137
+ story_path = try_parse_header_story_path(resolved_path)
1138
+ if story_path is not None:
1139
+ story_before = view._resolve_header_story(story_path, expected_revision=input_revision)
1140
+ changed = _apply_header_story_set(story_before, command["properties"])
1141
+ else:
1142
+ record = view.resolve_record(resolved_path, expected_revision=input_revision)
1143
+ changed = _apply_set(document, record, command["properties"])
1144
+ target_native = record.native
1145
+ elif op == "add":
1146
+ parent_path = _resolve_alias(command["parent"], aliases)
1147
+ parent = view.resolve_record(parent_path, expected_revision=input_revision)
1148
+ position = _resolved_position(command["position"], aliases)
1149
+ target_native = _add(document, view, parent, command["kind"], command["properties"], position)
1150
+ created_view = HwpxAgentDocument.from_document(document, revision=input_revision)
1151
+ created_record = _record_for_native(created_view, target_native)
1152
+ changed = _apply_create_properties(document, created_record, command["properties"])
1153
+ elif op == "remove":
1154
+ resolved_path = _resolve_alias(command["path"], aliases)
1155
+ record = view.resolve_record(resolved_path, expected_revision=input_revision)
1156
+ parent_path = record.parent_path
1157
+ _remove(document, record)
1158
+ target_native = None
1159
+ elif op == "move":
1160
+ resolved_path = _resolve_alias(command["path"], aliases)
1161
+ parent_path = _resolve_alias(command["parent"], aliases)
1162
+ source = view.resolve_record(resolved_path, expected_revision=input_revision)
1163
+ parent = view.resolve_record(parent_path, expected_revision=input_revision)
1164
+ position = _resolved_position(command["position"], aliases)
1165
+ target_native = _move(view, source, parent, position)
1166
+ else: # copy
1167
+ resolved_path = _resolve_alias(command["path"], aliases)
1168
+ parent_path = _resolve_alias(command["parent"], aliases)
1169
+ source = view.resolve_record(resolved_path, expected_revision=input_revision)
1170
+ parent = view.resolve_record(parent_path, expected_revision=input_revision)
1171
+ position = _resolved_position(command["position"], aliases)
1172
+ target_native, generated = _copy_node(document, view, source, parent, position)
1173
+ return resolved_path, parent_path, changed, generated, target_native, story_before
1174
+
1175
+
1176
+ def _finalize_command_result(
1177
+ view: HwpxAgentDocument,
1178
+ command: Mapping[str, Any],
1179
+ command_id: str,
1180
+ op: str,
1181
+ resolved_path: str | None,
1182
+ parent_path: str | None,
1183
+ target_native: Any,
1184
+ story_before: HeaderStoryBinding | None,
1185
+ story_expectations: dict[str, Mapping[str, str]],
1186
+ ) -> tuple[str, str, str | None]:
1187
+ """Return (result_path, result_parent, result_stable_id) for one applied command."""
1188
+
1189
+ result_stable_id: str | None = None
1190
+ if story_before is not None and resolved_path is not None:
1191
+ target_story = view._resolve_header_story(resolved_path)
1192
+ if target_story.stable_id != story_before.stable_id:
1193
+ raise AgentContractError(
1194
+ "invariant_violation",
1195
+ "header story identity changed during text edit",
1196
+ target=resolved_path,
1197
+ )
1198
+ result_path = target_story.path
1199
+ result_parent = target_story.parent_path
1200
+ result_stable_id = target_story.stable_id
1201
+ story_expectations[target_story.binding_key] = {
1202
+ "commandId": command_id,
1203
+ "path": target_story.path,
1204
+ "stableId": target_story.stable_id,
1205
+ "pageType": target_story.page_type,
1206
+ "text": command["properties"]["text"],
1207
+ }
1208
+ elif op == "set" and resolved_path is not None:
1209
+ # Some native bindings (notably form fields) are request-local
1210
+ # match dictionaries. A property edit is non-structural, so
1211
+ # its canonical path is the durable post-edit lookup key.
1212
+ target_record = view.resolve_record(resolved_path)
1213
+ result_path = target_record.path
1214
+ result_parent = target_record.parent_path or "/"
1215
+ elif target_native is not None:
1216
+ target_record = _record_for_native(view, target_native)
1217
+ result_path = target_record.path
1218
+ result_parent = target_record.parent_path or "/"
1219
+ else:
1220
+ result_path = resolved_path or parent_path or "/"
1221
+ result_parent = parent_path or "/"
1222
+ return result_path, result_parent, result_stable_id
1223
+
1224
+
1225
+ def apply_document_commands(
1226
+ batch: Mapping[str, Any],
1227
+ *,
1228
+ idempotency_store: IdempotencyStore | None = None,
1229
+ fault_injector: FaultInjector | None = None,
1230
+ domain_verifier: DomainVerifier | None = None,
1231
+ save_pipeline: SavePipeline | None = None,
1232
+ ) -> AgentBatchResult:
1233
+ """Validate and atomically apply one ``hwpx.agent-batch/v1`` request.
1234
+
1235
+ ``idempotency_store`` is explicitly caller-owned. The core keeps no hidden
1236
+ resident state in Leap A; MCP/CLI adapters may supply their existing scoped
1237
+ stores. Any failure returns a structured rolled-back result and leaves the
1238
+ declared output untouched.
1239
+ """
1240
+
1241
+ normalized: Mapping[str, Any] | None = None
1242
+ input_revision = _EMPTY_REVISION
1243
+ command_results: list[Mapping[str, Any]] = []
1244
+ verification: dict[str, Any] = {}
1245
+ try:
1246
+ normalized = validate_agent_batch(batch)
1247
+ _preflight(normalized)
1248
+ verification.update(
1249
+ {
1250
+ "schemaVersion": normalized["schemaVersion"],
1251
+ "catalogHash": catalog_hash(),
1252
+ "requirements": list(normalized["verificationRequirements"]),
1253
+ }
1254
+ )
1255
+ request_hash = _request_hash(normalized)
1256
+ key = normalized["idempotencyKey"]
1257
+ cached_result = _apply_commands_idempotency_lookup(
1258
+ verification, idempotency_store, key=key, request_hash=request_hash
1259
+ )
1260
+ if cached_result is not None:
1261
+ return cached_result
1262
+
1263
+ input_path = Path(normalized["input"]["filename"])
1264
+ output_path = Path(normalized["output"]["filename"])
1265
+ input_data = input_path.read_bytes()
1266
+ input_revision = _revision(input_data)
1267
+ _validate_apply_commands_input(normalized, verification, input_data, input_revision, output_path)
1268
+
1269
+ aliases: dict[str, dict[str, str]] = {}
1270
+ identity_changes: list[Mapping[str, str]] = []
1271
+ semantic_changes: list[Mapping[str, Any]] = []
1272
+ story_expectations: dict[str, Mapping[str, str]] = {}
1273
+ _call_fault(fault_injector, "before_open")
1274
+ with HwpxDocument.open(input_data) as document:
1275
+ view = HwpxAgentDocument.from_document(document, revision=input_revision)
1276
+ for index, command in enumerate(normalized["commands"]):
1277
+ _call_fault(fault_injector, "before_command", index)
1278
+ command_id = command["commandId"]
1279
+ op = command["op"]
1280
+ resolved_path, parent_path, changed, generated, target_native, story_before = _dispatch_command_op(
1281
+ document, view, command, aliases, input_revision
1282
+ )
1283
+ identity_changes.extend(generated)
1284
+
1285
+ _call_fault(fault_injector, "after_command", index)
1286
+ view = HwpxAgentDocument.from_document(document, revision=input_revision)
1287
+ result_path, result_parent, result_stable_id = _finalize_command_result(
1288
+ view,
1289
+ command,
1290
+ command_id,
1291
+ op,
1292
+ resolved_path,
1293
+ parent_path,
1294
+ target_native,
1295
+ story_before,
1296
+ story_expectations,
1297
+ )
1298
+ aliases[command_id] = {"path": result_path, "parentPath": result_parent}
1299
+ result = {
1300
+ "commandId": command_id,
1301
+ "op": op,
1302
+ "ok": True,
1303
+ "path": result_path,
1304
+ "parentPath": result_parent,
1305
+ "changedProperties": changed,
1306
+ "generatedIdentities": generated,
1307
+ "warnings": [],
1308
+ }
1309
+ if result_stable_id is not None:
1310
+ # Command results are already an untyped JSON mapping. This
1311
+ # story-only receipt carries the actual stable identity
1312
+ # without changing AgentBatchResult or the ToolSpec schema.
1313
+ result["stableId"] = result_stable_id
1314
+ command_results.append(result)
1315
+ semantic_changes.append(
1316
+ {
1317
+ "commandId": command_id,
1318
+ "op": op,
1319
+ "beforePath": resolved_path,
1320
+ "afterPath": None if op == "remove" else result_path,
1321
+ "changedProperties": changed,
1322
+ }
1323
+ )
1324
+
1325
+ _call_fault(fault_injector, "before_serialize")
1326
+ candidate_data = document.to_bytes()
1327
+ _call_fault(fault_injector, "after_serialize")
1328
+
1329
+ candidate_revision, semantic_diff, safety, byte_report = _apply_commands_build_candidate_report(
1330
+ input_data,
1331
+ candidate_data,
1332
+ input_revision,
1333
+ semantic_changes,
1334
+ identity_changes,
1335
+ story_expectations,
1336
+ verification,
1337
+ )
1338
+ requirements = set(normalized["verificationRequirements"])
1339
+ _apply_commands_domain_verification(candidate_data, normalized, requirements, domain_verifier, verification)
1340
+ _require_candidate_structural_safety(safety, byte_report)
1341
+
1342
+ _call_fault(fault_injector, "before_save")
1343
+ _apply_commands_run_save_pipeline(
1344
+ candidate_data,
1345
+ input_path,
1346
+ output_path,
1347
+ document,
1348
+ normalized,
1349
+ requirements,
1350
+ save_pipeline,
1351
+ verification,
1352
+ )
1353
+
1354
+ batch_result = AgentBatchResult(
1355
+ ok=True,
1356
+ rolled_back=False,
1357
+ dry_run=normalized["dryRun"],
1358
+ input_revision=input_revision,
1359
+ document_revision=candidate_revision,
1360
+ output_filename=str(output_path),
1361
+ command_results=tuple(command_results),
1362
+ semantic_diff=semantic_diff,
1363
+ verification_report=verification,
1364
+ )
1365
+ if key is not None and idempotency_store is not None:
1366
+ idempotency_store[key] = {"requestHash": request_hash, "result": batch_result}
1367
+ return batch_result
1368
+ except BaseException as exc: # fail closed; caller receives a stable error contract
1369
+ return _failure_result(
1370
+ exc=exc,
1371
+ batch=normalized or batch,
1372
+ input_revision=input_revision,
1373
+ command_results=command_results,
1374
+ verification=verification,
1375
+ )
1376
+
1377
+
1378
+ __all__ = [
1379
+ "DomainVerifier",
1380
+ "FaultInjector",
1381
+ "IdempotencyStore",
1382
+ "apply_document_commands",
1383
+ ]