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,3007 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ """Typed MCP adapter for the canonical mixed-anchor form-fill transaction.
3
+
4
+ The adapter deliberately does not edit HWPX packages. It validates the public
5
+ wire model, resolves workspace paths, and delegates planning and execution to
6
+ the canonical ``hwpx_automation.office.agent`` mixed-form compiler/executor.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import copy
12
+ import hashlib
13
+ import os
14
+ import re
15
+ import secrets
16
+ import stat
17
+ import zipfile
18
+ from collections import Counter
19
+ from dataclasses import dataclass
20
+ from io import BytesIO
21
+ from pathlib import Path
22
+ from typing import Annotated, Any, Callable, Literal, TypeAlias
23
+
24
+ from hwpx_automation.office.agent import (
25
+ MIXED_FORM_COMPILED_PLAN_SCHEMA,
26
+ MIXED_FORM_PLAN_SCHEMA,
27
+ HwpxAgentDocument,
28
+ apply_mixed_form_plan,
29
+ plan_mixed_form_fill,
30
+ validate_mixed_form_plan,
31
+ )
32
+ from hwpx.quality import SavePipeline
33
+ from pydantic import (
34
+ BaseModel,
35
+ ConfigDict,
36
+ Field,
37
+ TypeAdapter,
38
+ field_validator,
39
+ model_validator,
40
+ )
41
+
42
+ from .execution_lock import PUBLIC_MUTATION_LOCK
43
+ from .office.rendering import resolve_hancom_backend
44
+ from .storage import build_hwpx_open_safety_report
45
+ from .utils.helpers import resolve_path
46
+ from .workspace import (
47
+ WorkspaceMissingParentGuard,
48
+ WorkspaceOutputGuard,
49
+ WorkspacePathError,
50
+ WorkspaceResolver,
51
+ )
52
+
53
+
54
+ FORM_VERIFICATION_RECEIPT_SCHEMA = "hwpx.form-verification-receipt/v1"
55
+
56
+
57
+ class _StrictModel(BaseModel):
58
+ model_config = ConfigDict(extra="forbid", populate_by_name=True)
59
+
60
+
61
+ class NativeFieldTarget(_StrictModel):
62
+ model_config = ConfigDict(
63
+ extra="forbid",
64
+ populate_by_name=True,
65
+ json_schema_extra={
66
+ "oneOf": [
67
+ {
68
+ "required": ["fieldId"],
69
+ "properties": {
70
+ "fieldId": {
71
+ "type": "string",
72
+ "minLength": 1,
73
+ "maxLength": 4096,
74
+ },
75
+ },
76
+ "not": {"required": ["name"]},
77
+ },
78
+ {
79
+ "required": ["name"],
80
+ "properties": {
81
+ "name": {
82
+ "type": "string",
83
+ "minLength": 1,
84
+ "maxLength": 4096,
85
+ },
86
+ },
87
+ "not": {"required": ["fieldId"]},
88
+ },
89
+ ]
90
+ },
91
+ )
92
+
93
+ kind: Literal["nativeField"]
94
+ field_id: str | None = Field(
95
+ default=None,
96
+ alias="fieldId",
97
+ min_length=1,
98
+ max_length=4096,
99
+ )
100
+ name: str | None = Field(default=None, min_length=1, max_length=4096)
101
+
102
+ @model_validator(mode="after")
103
+ def _one_selector(self) -> NativeFieldTarget:
104
+ if (
105
+ "field_id" in self.model_fields_set and self.field_id is None
106
+ ) or ("name" in self.model_fields_set and self.name is None):
107
+ raise ValueError("nativeField selector keys cannot be null")
108
+ if (self.field_id is None) == (self.name is None):
109
+ raise ValueError("nativeField requires exactly one of fieldId or name")
110
+ return self
111
+
112
+
113
+ class LabelCellAnchor(_StrictModel):
114
+ label: str = Field(min_length=1, max_length=4096)
115
+ direction: Literal["right", "left", "below", "above"]
116
+
117
+
118
+ class LabelCellTarget(_StrictModel):
119
+ model_config = ConfigDict(
120
+ extra="forbid",
121
+ populate_by_name=True,
122
+ json_schema_extra={
123
+ "oneOf": [
124
+ {
125
+ "required": ["tableAnchor"],
126
+ "properties": {
127
+ "tableAnchor": {
128
+ "type": "string",
129
+ "minLength": 1,
130
+ "maxLength": 4096,
131
+ },
132
+ },
133
+ "not": {"required": ["tableIndex"]},
134
+ },
135
+ {
136
+ "required": ["tableIndex"],
137
+ "properties": {
138
+ "tableIndex": {"type": "integer", "minimum": 0},
139
+ },
140
+ "not": {"required": ["tableAnchor"]},
141
+ },
142
+ ]
143
+ },
144
+ )
145
+
146
+ kind: Literal["labelCell"]
147
+ section_path: str = Field(
148
+ alias="sectionPath",
149
+ pattern=r"^/section\[[1-9][0-9]*\]$",
150
+ )
151
+ table_anchor: str | None = Field(
152
+ default=None,
153
+ alias="tableAnchor",
154
+ min_length=1,
155
+ max_length=4096,
156
+ )
157
+ table_index: int | None = Field(default=None, alias="tableIndex", ge=0)
158
+ cell_anchor: LabelCellAnchor = Field(alias="cellAnchor")
159
+
160
+ @model_validator(mode="after")
161
+ def _one_table_selector(self) -> LabelCellTarget:
162
+ if (
163
+ "table_anchor" in self.model_fields_set and self.table_anchor is None
164
+ ) or ("table_index" in self.model_fields_set and self.table_index is None):
165
+ raise ValueError("labelCell selector keys cannot be null")
166
+ if (self.table_anchor is None) == (self.table_index is None):
167
+ raise ValueError("labelCell requires exactly one of tableAnchor or tableIndex")
168
+ return self
169
+
170
+
171
+ class CanonicalPathTarget(_StrictModel):
172
+ kind: Literal["canonicalPath"]
173
+ path: str = Field(min_length=1)
174
+
175
+
176
+ class BodyAnchorTarget(_StrictModel):
177
+ kind: Literal["bodyAnchor"]
178
+ section_path: str = Field(
179
+ alias="sectionPath",
180
+ pattern=r"^/section\[[1-9][0-9]*\]$",
181
+ )
182
+ anchor: str = Field(min_length=1, max_length=4096)
183
+ expected_count: Literal[1] = Field(alias="expectedCount")
184
+
185
+
186
+ MixedFormTarget: TypeAlias = Annotated[
187
+ NativeFieldTarget | LabelCellTarget | CanonicalPathTarget | BodyAnchorTarget,
188
+ Field(discriminator="kind"),
189
+ ]
190
+
191
+
192
+ class MixedFormOperation(_StrictModel):
193
+ operation_id: str = Field(
194
+ alias="operationId",
195
+ pattern=r"^[A-Za-z][A-Za-z0-9_-]{0,31}$",
196
+ )
197
+ target: MixedFormTarget
198
+ value: str = Field(max_length=4096)
199
+
200
+
201
+ class MixedFormQuality(_StrictModel):
202
+ # The core schema permits omission but rejects an explicitly supplied
203
+ # JSON null for each option. A non-null annotation with a None default
204
+ # preserves that presence-sensitive wire contract in Pydantic.
205
+ mode: Literal["transparent", "strict"] = None
206
+ render_check: Literal["off", "auto", "required"] = Field(
207
+ default=None,
208
+ alias="renderCheck",
209
+ )
210
+ xsd_mode: Literal["off", "lint"] = Field(default=None, alias="xsdMode")
211
+ overflow_policy: Literal["fail", "warn", "truncate"] = Field(
212
+ default=None,
213
+ alias="overflowPolicy",
214
+ )
215
+ layout_lint: Literal["off", "warn", "strict"] = Field(
216
+ default=None,
217
+ alias="layoutLint",
218
+ )
219
+ preserve_unmodified_parts: bool = Field(
220
+ default=None,
221
+ alias="preserveUnmodifiedParts",
222
+ )
223
+ require_reference_integrity: bool = Field(
224
+ default=None,
225
+ alias="requireReferenceIntegrity",
226
+ )
227
+
228
+ @classmethod
229
+ def __get_pydantic_json_schema__(
230
+ cls,
231
+ core_schema: Any,
232
+ handler: Any,
233
+ ) -> dict[str, Any]:
234
+ schema = handler(core_schema)
235
+ for option in schema.get("properties", {}).values():
236
+ if isinstance(option, dict):
237
+ # None means "omitted" to the Pydantic runtime but is not a
238
+ # valid explicit value. Do not advertise that internal default
239
+ # as a JSON value the frozen core schema would reject.
240
+ option.pop("default", None)
241
+ return schema
242
+
243
+
244
+ VerificationRequirement: TypeAlias = Literal[
245
+ "package",
246
+ "reopen",
247
+ "openSafety",
248
+ "semanticDiff",
249
+ "bytePreservation",
250
+ "domain",
251
+ "realHancom",
252
+ ]
253
+ QualityInput: TypeAlias = Literal["transparent", "strict"] | MixedFormQuality | None
254
+
255
+
256
+ class MixedFormPlanInput(_StrictModel):
257
+ """Strict public ``hwpx.mixed-form-plan/v1`` request."""
258
+
259
+ schema_version: Literal["hwpx.mixed-form-plan/v1"] = Field(alias="schemaVersion")
260
+ source: str = Field(min_length=1)
261
+ output: str = Field(min_length=1)
262
+ expected_revision: str | None = Field(
263
+ alias="expectedRevision",
264
+ pattern=r"^sha256:[0-9a-f]{64}$",
265
+ )
266
+ idempotency_key: str | None = Field(
267
+ alias="idempotencyKey",
268
+ min_length=1,
269
+ max_length=128,
270
+ )
271
+ dry_run: bool = Field(alias="dryRun")
272
+ overwrite: bool
273
+ quality: QualityInput
274
+ verification_requirements: list[VerificationRequirement] = Field(
275
+ alias="verificationRequirements",
276
+ json_schema_extra={"uniqueItems": True},
277
+ )
278
+ operations: list[MixedFormOperation] = Field(min_length=1, max_length=100)
279
+
280
+ @field_validator("verification_requirements")
281
+ @classmethod
282
+ def _unique_verification_requirements(
283
+ cls,
284
+ value: list[VerificationRequirement],
285
+ ) -> list[VerificationRequirement]:
286
+ if len(value) != len(set(value)):
287
+ raise ValueError("verificationRequirements must contain unique items")
288
+ return value
289
+
290
+
291
+ class _FilenameRef(_StrictModel):
292
+ filename: str = Field(min_length=1)
293
+
294
+
295
+ class _OutputRef(_FilenameRef):
296
+ overwrite: bool
297
+
298
+
299
+ class _TextSetProperties(_StrictModel):
300
+ text: str = Field(max_length=4096)
301
+
302
+
303
+ class _ValueSetProperties(_StrictModel):
304
+ value: str = Field(max_length=4096)
305
+
306
+
307
+ class _CompiledSetCommand(_StrictModel):
308
+ command_id: str = Field(
309
+ alias="commandId",
310
+ pattern=r"^[A-Za-z][A-Za-z0-9_-]{0,31}$",
311
+ )
312
+ op: Literal["set"]
313
+ path: str = Field(min_length=1)
314
+ properties: _TextSetProperties | _ValueSetProperties
315
+
316
+
317
+ class _CompiledBatch(_StrictModel):
318
+ schema_version: Literal["hwpx.agent-batch/v1"] = Field(alias="schemaVersion")
319
+ input: _FilenameRef
320
+ output: _OutputRef
321
+ commands: list[_CompiledSetCommand] = Field(min_length=1, max_length=100)
322
+ expected_revision: str = Field(
323
+ alias="expectedRevision",
324
+ pattern=r"^sha256:[0-9a-f]{64}$",
325
+ )
326
+ idempotency_key: str | None = Field(
327
+ alias="idempotencyKey",
328
+ min_length=1,
329
+ max_length=128,
330
+ )
331
+ dry_run: bool = Field(alias="dryRun")
332
+ quality: QualityInput
333
+ verification_requirements: list[VerificationRequirement] = Field(
334
+ alias="verificationRequirements",
335
+ json_schema_extra={"uniqueItems": True},
336
+ )
337
+
338
+ @field_validator("verification_requirements")
339
+ @classmethod
340
+ def _unique_verification_requirements(
341
+ cls,
342
+ value: list[VerificationRequirement],
343
+ ) -> list[VerificationRequirement]:
344
+ if len(value) != len(set(value)):
345
+ raise ValueError("verificationRequirements must contain unique items")
346
+ return value
347
+
348
+
349
+ class MixedFormResolution(_StrictModel):
350
+ operation_id: str = Field(
351
+ alias="operationId",
352
+ pattern=r"^[A-Za-z][A-Za-z0-9_-]{0,31}$",
353
+ )
354
+ locator_kind: Literal["nativeField", "labelCell", "canonicalPath", "bodyAnchor"] = Field(
355
+ alias="locatorKind"
356
+ )
357
+ path: str = Field(min_length=1)
358
+ node_kind: Literal["paragraph", "run", "cell", "form-field"] = Field(alias="nodeKind")
359
+ stability: Literal["native", "derived", "positional"]
360
+ section: int | None = Field(ge=1)
361
+ table_index: int | None = Field(alias="tableIndex", ge=0)
362
+ logical_row: int | None = Field(alias="logicalRow", ge=0)
363
+ logical_column: int | None = Field(alias="logicalColumn", ge=0)
364
+ physical_row: int | None = Field(alias="physicalRow", ge=0)
365
+ physical_column: int | None = Field(alias="physicalColumn", ge=0)
366
+
367
+
368
+ class MixedFormCompiledPlanInput(_StrictModel):
369
+ """Detached, revision-bound plan returned by ``analyze_form_fill``."""
370
+
371
+ schema_version: Literal["hwpx.mixed-form-compiled-plan/v1"] = Field(
372
+ alias="schemaVersion"
373
+ )
374
+ input_revision: str = Field(
375
+ alias="inputRevision",
376
+ pattern=r"^sha256:[0-9a-f]{64}$",
377
+ )
378
+ request_hash: str = Field(
379
+ alias="requestHash",
380
+ pattern=r"^sha256:[0-9a-f]{64}$",
381
+ )
382
+ resolutions: list[MixedFormResolution] = Field(min_length=1, max_length=100)
383
+ batch: _CompiledBatch
384
+ plan_hash: str = Field(
385
+ alias="planHash",
386
+ pattern=r"^sha256:[0-9a-f]{64}$",
387
+ )
388
+
389
+
390
+ class _ReceiptPlanSummary(_StrictModel):
391
+ schema_version: Literal["hwpx.mixed-form-compiled-plan/v1"] = Field(
392
+ alias="schemaVersion"
393
+ )
394
+ plan_hash: str = Field(alias="planHash", pattern=r"^sha256:[0-9a-f]{64}$")
395
+ request_hash: str = Field(alias="requestHash", pattern=r"^sha256:[0-9a-f]{64}$")
396
+ input_revision: str = Field(alias="inputRevision", pattern=r"^sha256:[0-9a-f]{64}$")
397
+
398
+
399
+ class _ReceiptFile(_StrictModel):
400
+ filename: str
401
+ revision: str | None
402
+
403
+
404
+ class _ReceiptOutput(_ReceiptFile):
405
+ exists: bool
406
+ expected_revision: str | None = Field(alias="expectedRevision")
407
+ revision_matched: bool | None = Field(alias="revisionMatched")
408
+
409
+
410
+ class _ReceiptValueVerification(_StrictModel):
411
+ ok: bool | None
412
+ status: Literal["checked", "deferred"]
413
+ matched_count: int = Field(alias="matchedCount", ge=0)
414
+ check_count: int = Field(alias="checkCount", ge=0)
415
+ checks: list[dict[str, Any]]
416
+
417
+
418
+ class FormVerificationReceipt(_StrictModel):
419
+ """Closed top-level public receipt envelope for form mutations/verifiers."""
420
+
421
+ schema_version: Literal["hwpx.form-verification-receipt/v1"] = Field(
422
+ alias="schemaVersion"
423
+ )
424
+ phase: Literal["apply", "verify", "domain-apply"]
425
+ status: Literal[
426
+ "committed",
427
+ "dry-run",
428
+ "failed",
429
+ "replayed",
430
+ "rolled-back",
431
+ "structurally-verified",
432
+ "verified",
433
+ ]
434
+ ok: bool
435
+ dry_run: bool = Field(alias="dryRun")
436
+ committed: bool
437
+ rolled_back: bool = Field(alias="rolledBack")
438
+ plan: _ReceiptPlanSummary | None
439
+ source: _ReceiptFile
440
+ output: _ReceiptOutput
441
+ source_preservation: dict[str, Any] = Field(alias="sourcePreservation")
442
+ resolutions: list[dict[str, Any]]
443
+ expected_targets: list[dict[str, Any]] = Field(alias="expectedTargets")
444
+ value_verification: _ReceiptValueVerification = Field(alias="valueVerification")
445
+ package: dict[str, Any]
446
+ reopen: dict[str, Any]
447
+ open_safety: dict[str, Any] = Field(alias="openSafety")
448
+ semantic_diff: dict[str, Any] = Field(alias="semanticDiff")
449
+ member_diff: dict[str, Any] = Field(alias="memberDiff")
450
+ byte_preservation: dict[str, Any] = Field(alias="bytePreservation")
451
+ idempotency: dict[str, Any]
452
+ save_pipeline: dict[str, Any] = Field(alias="savePipeline")
453
+ domain: dict[str, Any]
454
+ real_hancom: dict[str, Any] = Field(alias="realHancom")
455
+ error: dict[str, Any] | None
456
+ rollback_preservation: dict[str, Any] | None = Field(
457
+ default=None, alias="rollbackPreservation"
458
+ )
459
+ operation: str | None = None
460
+
461
+
462
+ MixedFormApplyInput: TypeAlias = Annotated[
463
+ MixedFormPlanInput | MixedFormCompiledPlanInput,
464
+ Field(discriminator="schema_version"),
465
+ ]
466
+
467
+
468
+ _PUBLIC_PLAN_ADAPTER = TypeAdapter(MixedFormPlanInput)
469
+ _APPLY_PLAN_ADAPTER = TypeAdapter(MixedFormApplyInput)
470
+ _IDEMPOTENCY_STORE: dict[str, Any] = {}
471
+ _IDEMPOTENCY_LOCK = PUBLIC_MUTATION_LOCK
472
+ # Test-only failure seam. It is intentionally absent from every MCP model.
473
+ _fault_injector_for_tests: Any = None
474
+ _SECTION_PATH = re.compile(r"^/section\[([1-9][0-9]*)\](?:/|$)")
475
+ _FAILURE_RECOVERY_PREFIX = ".hwpx-mixed-form-recovery"
476
+
477
+
478
+ def _publish_exact_failure_recovery(
479
+ workspace: WorkspaceResolver,
480
+ output: Path,
481
+ data: bytes,
482
+ *,
483
+ mode: int | None,
484
+ max_candidates: int = 64,
485
+ ) -> WorkspaceOutputGuard:
486
+ """Publish one random, bounded preimage sidecar without overwriting aliases."""
487
+
488
+ parent = output.parent.resolve(strict=True)
489
+ output_hash = hashlib.sha256(output.name.encode("utf-8")).hexdigest()[:12]
490
+ for _ in range(max_candidates):
491
+ candidate = parent / (
492
+ f"{_FAILURE_RECOVERY_PREFIX}-{output_hash}-{secrets.token_hex(16)}"
493
+ )
494
+ if os.path.lexists(candidate):
495
+ continue
496
+ try:
497
+ guard = workspace.capture_output(candidate, create_parents=False)
498
+ except WorkspacePathError:
499
+ if os.path.lexists(candidate):
500
+ continue
501
+ raise
502
+ if guard.path != candidate:
503
+ if os.path.lexists(candidate):
504
+ continue
505
+ raise WorkspacePathError(
506
+ "mixed-form recovery path changed before publication",
507
+ code="WORKSPACE_PATH_CHANGED",
508
+ reason="output_target_changed",
509
+ )
510
+ if guard.target_existed:
511
+ continue
512
+ try:
513
+ publication = workspace.atomic_publish_bytes(
514
+ guard,
515
+ data,
516
+ mode=mode,
517
+ )
518
+ if workspace.read_guarded_bytes(publication) != data:
519
+ raise RuntimeError(
520
+ "mixed-form recovery sidecar differs from the output preimage"
521
+ )
522
+ except (
523
+ WorkspacePathError,
524
+ FileNotFoundError,
525
+ FileExistsError,
526
+ RuntimeError,
527
+ ):
528
+ # A publish may have replaced the name and then lost its final
529
+ # ownership claim to an external replacement or deletion. Never
530
+ # reuse that name; retain the immutable preimage in a fresh random
531
+ # candidate instead.
532
+ continue
533
+ except OSError:
534
+ # Capacity and permission failures are not namespace races. Fail
535
+ # closed before the document output can be mutated.
536
+ raise
537
+ return publication
538
+ raise RuntimeError(
539
+ f"no available random recovery sidecar for {output.name}"
540
+ )
541
+
542
+
543
+ @dataclass(slots=True)
544
+ class _FailurePreimagePreserver:
545
+ """Reserve, retain, and guardedly release an output preimage sidecar."""
546
+
547
+ workspace: WorkspaceResolver
548
+ output: Path
549
+ data: bytes | None
550
+ mode: int | None
551
+ publication: WorkspaceOutputGuard | None = None
552
+
553
+ def reserve(self) -> WorkspaceOutputGuard | None:
554
+ if self.data is None:
555
+ return None
556
+ if self.publication is not None:
557
+ try:
558
+ if self.workspace.read_guarded_bytes(self.publication) == self.data:
559
+ return self.publication
560
+ except (OSError, RuntimeError):
561
+ self.publication = None
562
+ self.publication = _publish_exact_failure_recovery(
563
+ self.workspace,
564
+ self.output,
565
+ self.data,
566
+ mode=self.mode,
567
+ )
568
+ return self.publication
569
+
570
+ def preserve(self) -> WorkspaceOutputGuard | None:
571
+ return self.reserve()
572
+
573
+ def cleanup_after_success(self) -> bool:
574
+ if self.publication is None:
575
+ return True
576
+ publication = self.publication
577
+ try:
578
+ if self.workspace.read_guarded_bytes(publication) != self.data:
579
+ return False
580
+ self.workspace.remove_output(publication)
581
+ except (OSError, RuntimeError):
582
+ return False
583
+ self.publication = None
584
+ return True
585
+
586
+
587
+ class _WorkspaceSavePipeline(SavePipeline):
588
+ """Run the core quality gate but publish through an identity-bound path."""
589
+
590
+ def __init__(
591
+ self,
592
+ resolver: WorkspaceResolver,
593
+ precondition: WorkspaceOutputGuard | WorkspaceMissingParentGuard,
594
+ failure_preimage: _FailurePreimagePreserver,
595
+ ) -> None:
596
+ super().__init__(oracle_factory=resolve_hancom_backend)
597
+ self._workspace_resolver = resolver
598
+ self._workspace_precondition = precondition
599
+ self._failure_preimage = failure_preimage
600
+ self.publication: WorkspaceOutputGuard | None = None
601
+
602
+ def _publish(
603
+ self,
604
+ data: bytes,
605
+ output_path: str | os.PathLike[str] | None,
606
+ output_stream: Any | None,
607
+ ) -> str | None:
608
+ if output_stream is not None:
609
+ return super()._publish(data, output_path, output_stream)
610
+ if output_path is None:
611
+ return None
612
+ target = Path(output_path).resolve(strict=False)
613
+ if target != self._workspace_precondition.path:
614
+ raise RuntimeError("quality pipeline attempted an unauthorized output path")
615
+ self._failure_preimage.reserve()
616
+ guard = self._workspace_resolver.materialize_output_guard(
617
+ self._workspace_precondition
618
+ )
619
+ try:
620
+ self.publication = self._workspace_resolver.atomic_publish_bytes(
621
+ guard,
622
+ data,
623
+ )
624
+ except BaseException:
625
+ self._failure_preimage.preserve()
626
+ self._workspace_resolver.cleanup_owned_parent_directories(guard)
627
+ raise
628
+ return str(target)
629
+
630
+
631
+ def _payload(value: BaseModel | dict[str, Any], *, apply: bool) -> dict[str, Any]:
632
+ adapter = _APPLY_PLAN_ADAPTER if apply else _PUBLIC_PLAN_ADAPTER
633
+ parsed = adapter.validate_python(value)
634
+ if not isinstance(parsed, BaseModel): # pragma: no cover - union invariant
635
+ raise TypeError("mixed-form plan did not validate to a typed model")
636
+ payload = parsed.model_dump(by_alias=True, exclude_none=False)
637
+ # Nullable fields at the plan/batch level are required by the frozen core
638
+ # schema. Selector and quality optionals, however, are presence-sensitive.
639
+ if payload["schemaVersion"] == MIXED_FORM_PLAN_SCHEMA:
640
+ for operation in payload["operations"]:
641
+ target = operation["target"]
642
+ for name in ("fieldId", "name", "tableAnchor", "tableIndex"):
643
+ if target.get(name) is None:
644
+ target.pop(name, None)
645
+ quality = payload.get("quality")
646
+ if isinstance(quality, dict):
647
+ payload["quality"] = {name: item for name, item in quality.items() if item is not None}
648
+ batch_quality = payload.get("batch", {}).get("quality")
649
+ if isinstance(batch_quality, dict):
650
+ payload["batch"]["quality"] = {
651
+ name: item for name, item in batch_quality.items() if item is not None
652
+ }
653
+ return payload
654
+
655
+
656
+ def _authorize_public_plan(payload: dict[str, Any]) -> dict[str, Any]:
657
+ authorized = copy.deepcopy(payload)
658
+ authorized["source"] = resolve_path(str(payload["source"]))
659
+ authorized["output"] = resolve_path(str(payload["output"]))
660
+ return authorized
661
+
662
+
663
+ def _authorize_compiled_plan(payload: dict[str, Any]) -> tuple[Path, Path]:
664
+ batch = payload["batch"]
665
+ # Resolution is itself the workspace authorization check. Keep the bytes
666
+ # hash-protected compiled payload untouched after checking it.
667
+ source_text = str(batch["input"]["filename"])
668
+ output_text = str(batch["output"]["filename"])
669
+ source = Path(resolve_path(source_text))
670
+ output = Path(resolve_path(output_text))
671
+ # A detached plan cannot be rewritten after its planHash is frozen. Reject
672
+ # relative/non-canonical coordinates whose runtime interpretation could
673
+ # differ from the workspace resolver used for authorization.
674
+ for raw, resolved, name in (
675
+ (source_text, source, "batch.input.filename"),
676
+ (output_text, output, "batch.output.filename"),
677
+ ):
678
+ raw_path = Path(raw).expanduser()
679
+ if not raw_path.is_absolute() or raw_path != resolved:
680
+ raise ValueError(
681
+ f"compiled mixed-form {name} must be an authorized canonical absolute path; "
682
+ "run analyze_form_fill again"
683
+ )
684
+ return source, output
685
+
686
+
687
+ def _revision_bytes(data: bytes) -> str:
688
+ return "sha256:" + hashlib.sha256(data).hexdigest()
689
+
690
+
691
+ @dataclass(frozen=True, slots=True)
692
+ class _PathSnapshot:
693
+ path: Path
694
+ existed: bool
695
+ data: bytes | None
696
+ mode: int | None
697
+ device: int | None
698
+ inode: int | None
699
+ guard: WorkspaceOutputGuard | None
700
+ missing_guard: WorkspaceMissingParentGuard | None
701
+ missing_parent_anchor: Path | None
702
+ missing_parent_device: int | None
703
+ missing_parent_inode: int | None
704
+ missing_parent_parts: tuple[str, ...]
705
+
706
+
707
+ def _capture_path_snapshot(
708
+ path: Path,
709
+ *,
710
+ create_parents: bool = False,
711
+ ) -> _PathSnapshot:
712
+ """Capture bytes and metadata through one workspace-bound file identity."""
713
+
714
+ workspace = WorkspaceResolver.from_environment()
715
+ guard = workspace.capture_output(path, create_parents=create_parents)
716
+ data = workspace.read_guarded_bytes(guard) if guard.target_existed else None
717
+ return _PathSnapshot(
718
+ path=guard.path,
719
+ existed=guard.target_existed,
720
+ data=data,
721
+ mode=guard.target_mode,
722
+ device=guard.target_device,
723
+ inode=guard.target_inode,
724
+ guard=guard,
725
+ missing_guard=None,
726
+ missing_parent_anchor=None,
727
+ missing_parent_device=None,
728
+ missing_parent_inode=None,
729
+ missing_parent_parts=(),
730
+ )
731
+
732
+
733
+ def _capture_optional_path_snapshot(path: Path) -> _PathSnapshot:
734
+ """Capture a path while treating a missing parent as an absent output.
735
+
736
+ Read-only analysis and dry-run calls must not create output directories just
737
+ to prove that the output does not exist. A ``None`` guard records that no
738
+ parent descriptor could be captured; a later comparison still observes any
739
+ directory or file that appeared in the meantime.
740
+ """
741
+
742
+ workspace = WorkspaceResolver.from_environment()
743
+ precondition = workspace.capture_output_precondition(path)
744
+ if isinstance(precondition, WorkspaceOutputGuard):
745
+ data = (
746
+ workspace.read_guarded_bytes(precondition)
747
+ if precondition.target_existed
748
+ else None
749
+ )
750
+ return _PathSnapshot(
751
+ path=precondition.path,
752
+ existed=precondition.target_existed,
753
+ data=data,
754
+ mode=precondition.target_mode,
755
+ device=precondition.target_device,
756
+ inode=precondition.target_inode,
757
+ guard=precondition,
758
+ missing_guard=None,
759
+ missing_parent_anchor=None,
760
+ missing_parent_device=None,
761
+ missing_parent_inode=None,
762
+ missing_parent_parts=(),
763
+ )
764
+ return _PathSnapshot(
765
+ path=precondition.path,
766
+ existed=False,
767
+ data=None,
768
+ mode=None,
769
+ device=None,
770
+ inode=None,
771
+ guard=None,
772
+ missing_guard=precondition,
773
+ missing_parent_anchor=precondition.anchor,
774
+ missing_parent_device=precondition.anchor_device,
775
+ missing_parent_inode=precondition.anchor_inode,
776
+ missing_parent_parts=precondition.missing_parts,
777
+ )
778
+
779
+
780
+ def _snapshots_match(
781
+ observed: _PathSnapshot,
782
+ expected: _PathSnapshot,
783
+ *,
784
+ identity: bool = True,
785
+ ) -> bool:
786
+ if observed.existed != expected.existed:
787
+ return False
788
+ if not expected.existed:
789
+ if expected.guard is not None and observed.guard is not None:
790
+ return bool(
791
+ observed.path == expected.path
792
+ and observed.guard.root == expected.guard.root
793
+ and observed.guard.root_device == expected.guard.root_device
794
+ and observed.guard.root_inode == expected.guard.root_inode
795
+ and observed.guard.parent_device == expected.guard.parent_device
796
+ and observed.guard.parent_inode == expected.guard.parent_inode
797
+ )
798
+ if (
799
+ expected.guard is None
800
+ and observed.guard is None
801
+ and expected.missing_guard is not None
802
+ and observed.missing_guard is not None
803
+ ):
804
+ return bool(
805
+ observed.path == expected.path
806
+ and observed.missing_guard.root == expected.missing_guard.root
807
+ and observed.missing_guard.root_device
808
+ == expected.missing_guard.root_device
809
+ and observed.missing_guard.root_inode
810
+ == expected.missing_guard.root_inode
811
+ and observed.missing_parent_anchor
812
+ == expected.missing_parent_anchor
813
+ and observed.missing_parent_device
814
+ == expected.missing_parent_device
815
+ and observed.missing_parent_inode == expected.missing_parent_inode
816
+ and observed.missing_parent_parts == expected.missing_parent_parts
817
+ )
818
+ return False
819
+ return bool(
820
+ observed.path == expected.path
821
+ and observed.guard is not None
822
+ and expected.guard is not None
823
+ and observed.guard.root == expected.guard.root
824
+ and observed.guard.root_device == expected.guard.root_device
825
+ and observed.guard.root_inode == expected.guard.root_inode
826
+ and observed.guard.parent_device == expected.guard.parent_device
827
+ and observed.guard.parent_inode == expected.guard.parent_inode
828
+ and observed.data == expected.data
829
+ and observed.mode == expected.mode
830
+ and (
831
+ not identity
832
+ or (
833
+ observed.device == expected.device
834
+ and observed.inode == expected.inode
835
+ )
836
+ )
837
+ )
838
+
839
+
840
+ def _snapshot_matches_publication(
841
+ observed: _PathSnapshot,
842
+ publication: WorkspaceOutputGuard | None,
843
+ ) -> bool:
844
+ return bool(
845
+ publication is not None
846
+ and observed.existed
847
+ and observed.guard is not None
848
+ and observed.path == publication.path
849
+ and observed.guard.root == publication.root
850
+ and observed.guard.root_device == publication.root_device
851
+ and observed.guard.root_inode == publication.root_inode
852
+ and observed.guard.parent_device == publication.parent_device
853
+ and observed.guard.parent_inode == publication.parent_inode
854
+ and observed.device == publication.target_device
855
+ and observed.inode == publication.target_inode
856
+ and observed.mode == publication.target_mode
857
+ and observed.guard.target_digest == publication.target_digest
858
+ )
859
+
860
+
861
+ def _snapshot_path(path: Path) -> tuple[bool, bytes | None]:
862
+ """Read existence and bytes as one observation."""
863
+
864
+ snapshot = _capture_optional_path_snapshot(path)
865
+ return snapshot.existed, snapshot.data
866
+
867
+
868
+ def _member_diff(
869
+ before: bytes,
870
+ after: bytes,
871
+ *,
872
+ allowed_changed_members: set[str] | None = None,
873
+ ) -> dict[str, Any]:
874
+ try:
875
+ with (
876
+ zipfile.ZipFile(BytesIO(before)) as old_zip,
877
+ zipfile.ZipFile(BytesIO(after)) as new_zip,
878
+ ):
879
+ old_infos: dict[str, list[zipfile.ZipInfo]] = {}
880
+ new_infos: dict[str, list[zipfile.ZipInfo]] = {}
881
+ for info in old_zip.infolist():
882
+ old_infos.setdefault(info.filename, []).append(info)
883
+ for info in new_zip.infolist():
884
+ new_infos.setdefault(info.filename, []).append(info)
885
+ old_counts = Counter(info.filename for info in old_zip.infolist())
886
+ new_counts = Counter(info.filename for info in new_zip.infolist())
887
+ old_names = set(old_counts)
888
+ new_names = set(new_counts)
889
+ shared = sorted(old_names & new_names)
890
+ changed = [
891
+ name
892
+ for name in shared
893
+ if old_counts[name] == 1
894
+ and new_counts[name] == 1
895
+ and old_zip.read(old_infos[name][0]) != new_zip.read(new_infos[name][0])
896
+ ]
897
+ added = sorted(new_names - old_names)
898
+ removed = sorted(old_names - new_names)
899
+ duplicate_before = sorted(
900
+ name for name, count in old_counts.items() if count > 1
901
+ )
902
+ duplicate_after = sorted(
903
+ name for name, count in new_counts.items() if count > 1
904
+ )
905
+ multiplicity_changed = sorted(
906
+ name
907
+ for name in old_names | new_names
908
+ if old_counts[name] != new_counts[name]
909
+ )
910
+ allowed = (
911
+ sorted(allowed_changed_members)
912
+ if allowed_changed_members is not None
913
+ else None
914
+ )
915
+ unexpected = (
916
+ sorted(set(changed) - allowed_changed_members)
917
+ if allowed_changed_members is not None
918
+ else []
919
+ )
920
+ preservation_ok = (
921
+ not added
922
+ and not removed
923
+ and not unexpected
924
+ and not duplicate_before
925
+ and not duplicate_after
926
+ and not multiplicity_changed
927
+ if allowed_changed_members is not None
928
+ else None
929
+ )
930
+ return {
931
+ "ok": preservation_ok,
932
+ "status": (
933
+ "verified"
934
+ if preservation_ok is True
935
+ else "violated"
936
+ if preservation_ok is False
937
+ else "diff-only"
938
+ ),
939
+ "diffComputed": True,
940
+ "changedMembers": changed,
941
+ "allowedChangedMembers": allowed,
942
+ "unexpectedChangedMembers": unexpected,
943
+ "addedMembers": added,
944
+ "removedMembers": removed,
945
+ "duplicateMembersBefore": duplicate_before,
946
+ "duplicateMembersAfter": duplicate_after,
947
+ "multiplicityChangedMembers": multiplicity_changed,
948
+ "unchangedMemberCount": len(shared) - len(changed),
949
+ "beforeMemberCount": sum(old_counts.values()),
950
+ "afterMemberCount": sum(new_counts.values()),
951
+ }
952
+ except (OSError, zipfile.BadZipFile) as exc:
953
+ return {
954
+ "ok": False,
955
+ "status": "unavailable",
956
+ "diffComputed": False,
957
+ "errorCode": type(exc).__name__,
958
+ }
959
+
960
+
961
+ def _not_produced(reason: str = "not-produced") -> dict[str, Any]:
962
+ return {"ok": None, "status": reason}
963
+
964
+
965
+ def _expected_values(compiled: dict[str, Any]) -> list[dict[str, Any]]:
966
+ expected: list[dict[str, Any]] = []
967
+ commands = compiled["batch"]["commands"]
968
+ for resolution, command in zip(compiled["resolutions"], commands, strict=True):
969
+ properties = command["properties"]
970
+ property_name = "value" if "value" in properties else "text"
971
+ expected.append(
972
+ {
973
+ "operationId": resolution["operationId"],
974
+ "locatorKind": resolution["locatorKind"],
975
+ "path": resolution["path"],
976
+ "nodeKind": resolution["nodeKind"],
977
+ "property": property_name,
978
+ "value": properties[property_name],
979
+ }
980
+ )
981
+ return expected
982
+
983
+
984
+ def _public_expected_targets(
985
+ expected: list[dict[str, Any]],
986
+ ) -> list[dict[str, Any]]:
987
+ """Return receipt-safe target metadata without user-provided values."""
988
+
989
+ keys = ("operationId", "locatorKind", "path", "nodeKind", "property")
990
+ return [{key: item[key] for key in keys if key in item} for item in expected]
991
+
992
+
993
+ def _public_value_checks(checks: list[dict[str, Any]]) -> list[dict[str, Any]]:
994
+ """Strip expected/actual document content from durable receipts."""
995
+
996
+ public: list[dict[str, Any]] = []
997
+ keys = ("operationId", "locatorKind", "path", "nodeKind", "property", "ok")
998
+ for check in checks:
999
+ item = {key: check[key] for key in keys if key in check}
1000
+ if check.get("errorCode"):
1001
+ item["errorCode"] = check["errorCode"]
1002
+ public.append(item)
1003
+ return public
1004
+
1005
+
1006
+ def _public_error(error: dict[str, Any] | None) -> dict[str, Any] | None:
1007
+ """Keep stable failure metadata while excluding paths and document values."""
1008
+
1009
+ if not isinstance(error, dict):
1010
+ return None
1011
+ keys = ("code", "recoverability", "target")
1012
+ return _receipt_safe({key: error[key] for key in keys if key in error})
1013
+
1014
+
1015
+ def _public_source_preservation(report: dict[str, Any]) -> dict[str, Any]:
1016
+ public = copy.deepcopy(report)
1017
+ filename = public.get("filename")
1018
+ if isinstance(filename, str):
1019
+ public["filename"] = Path(filename).name
1020
+ return public
1021
+
1022
+
1023
+ def _receipt_safe(value: Any) -> Any:
1024
+ """Recursively remove absolute filesystem coordinates from receipts."""
1025
+
1026
+ if isinstance(value, dict):
1027
+ return {str(key): _receipt_safe(item) for key, item in value.items()}
1028
+ if isinstance(value, list):
1029
+ return [_receipt_safe(item) for item in value]
1030
+ if isinstance(value, tuple):
1031
+ return [_receipt_safe(item) for item in value]
1032
+ if isinstance(value, str) and not value.startswith("/section["):
1033
+ candidate = Path(value).expanduser()
1034
+ if candidate.is_absolute():
1035
+ return candidate.name
1036
+ return value
1037
+
1038
+
1039
+ def _public_apply_response(response: dict[str, Any]) -> dict[str, Any]:
1040
+ """Keep transactional evidence while removing document content echoes."""
1041
+
1042
+ public = copy.deepcopy(response)
1043
+ if "error" in public:
1044
+ public["error"] = _public_error(public.get("error"))
1045
+ command_results: list[dict[str, Any]] = []
1046
+ for raw in public.get("commandResults", []):
1047
+ if not isinstance(raw, dict):
1048
+ continue
1049
+ item = {
1050
+ key: copy.deepcopy(raw[key])
1051
+ for key in (
1052
+ "commandId",
1053
+ "op",
1054
+ "ok",
1055
+ "path",
1056
+ "parentPath",
1057
+ )
1058
+ if key in raw
1059
+ }
1060
+ warnings = raw.get("warnings")
1061
+ item["warningCount"] = len(warnings) if isinstance(warnings, list) else 0
1062
+ changed = raw.get("changedProperties")
1063
+ item["changedPropertyNames"] = (
1064
+ sorted(str(name) for name in changed) if isinstance(changed, dict) else []
1065
+ )
1066
+ identities = raw.get("generatedIdentities")
1067
+ item["generatedIdentityCount"] = (
1068
+ len(identities) if isinstance(identities, list) else 0
1069
+ )
1070
+ command_results.append(item)
1071
+ if "commandResults" in public:
1072
+ public["commandResults"] = command_results
1073
+
1074
+ semantic = public.get("semanticDiff")
1075
+ if isinstance(semantic, dict):
1076
+ changes: list[dict[str, Any]] = []
1077
+ for raw in semantic.get("changes", []):
1078
+ if not isinstance(raw, dict):
1079
+ continue
1080
+ item = {
1081
+ key: copy.deepcopy(raw[key])
1082
+ for key in ("commandId", "op", "beforePath", "afterPath")
1083
+ if key in raw
1084
+ }
1085
+ changed = raw.get("changedProperties")
1086
+ item["changedPropertyNames"] = (
1087
+ sorted(str(name) for name in changed)
1088
+ if isinstance(changed, dict)
1089
+ else []
1090
+ )
1091
+ changes.append(item)
1092
+ public["semanticDiff"] = {
1093
+ key: copy.deepcopy(semantic[key])
1094
+ for key in (
1095
+ "schemaVersion",
1096
+ "inputRevision",
1097
+ "candidateRevision",
1098
+ )
1099
+ if key in semantic
1100
+ }
1101
+ public["semanticDiff"].update(
1102
+ {
1103
+ "changeCount": len(changes),
1104
+ "changes": changes,
1105
+ "identityChangeCount": len(semantic.get("identityMap", [])),
1106
+ }
1107
+ )
1108
+ safe = _receipt_safe(public)
1109
+ if not isinstance(safe, dict): # pragma: no cover - object invariant
1110
+ raise TypeError("public mixed-form response must remain an object")
1111
+ return safe
1112
+
1113
+
1114
+ def _section_member_paths(source_bytes: bytes) -> tuple[str, ...]:
1115
+ """Return manifest-spine section parts in logical document order."""
1116
+
1117
+ with HwpxAgentDocument.open(source_bytes) as document:
1118
+ return tuple(str(section.part_name) for section in document.document.sections)
1119
+
1120
+
1121
+ def _allowed_plan_members(
1122
+ compiled: dict[str, Any],
1123
+ source_bytes: bytes,
1124
+ ) -> set[str]:
1125
+ """Map logical section coordinates to manifest-spine member names."""
1126
+
1127
+ section_paths = _section_member_paths(source_bytes)
1128
+ allowed: set[str] = set()
1129
+ for resolution in compiled.get("resolutions", []):
1130
+ section = resolution.get("section")
1131
+ if not isinstance(section, int) or section < 1:
1132
+ match = _SECTION_PATH.match(str(resolution.get("path", "")))
1133
+ section = int(match.group(1)) if match is not None else None
1134
+ if isinstance(section, int) and section >= 1:
1135
+ try:
1136
+ allowed.add(section_paths[section - 1])
1137
+ except IndexError as exc:
1138
+ raise ValueError(
1139
+ f"compiled mixed-form plan references missing logical section {section}"
1140
+ ) from exc
1141
+ if not allowed:
1142
+ raise ValueError("compiled mixed-form plan has no authorized section members")
1143
+ return allowed
1144
+
1145
+
1146
+ def _assess_declared_member_diff(
1147
+ report: dict[str, Any] | None,
1148
+ *,
1149
+ allowed_changed_members: set[str],
1150
+ ) -> dict[str, Any]:
1151
+ """Apply preservation semantics to a core candidate-only diff report."""
1152
+
1153
+ if not isinstance(report, dict):
1154
+ return {
1155
+ "ok": False,
1156
+ "status": "not-produced",
1157
+ "diffComputed": False,
1158
+ "allowedChangedMembers": sorted(allowed_changed_members),
1159
+ }
1160
+ assessed = copy.deepcopy(report)
1161
+ readable = bool(report.get("ok"))
1162
+ changed = {str(name) for name in report.get("changedMembers", [])}
1163
+ added = sorted(str(name) for name in report.get("addedMembers", []))
1164
+ removed = sorted(str(name) for name in report.get("removedMembers", []))
1165
+ unexpected = sorted(changed - allowed_changed_members)
1166
+ ok = readable and not added and not removed and not unexpected
1167
+ assessed.update(
1168
+ {
1169
+ "ok": ok,
1170
+ "status": "verified" if ok else "violated",
1171
+ "diffComputed": True,
1172
+ "allowedChangedMembers": sorted(allowed_changed_members),
1173
+ "unexpectedChangedMembers": unexpected,
1174
+ "addedMembers": added,
1175
+ "removedMembers": removed,
1176
+ }
1177
+ )
1178
+ return assessed
1179
+
1180
+
1181
+ def _path_mode(path: Path) -> int | None:
1182
+ try:
1183
+ return stat.S_IMODE(path.stat(follow_symlinks=False).st_mode)
1184
+ except OSError:
1185
+ return None
1186
+
1187
+
1188
+ def _open_safety_snapshot(data: bytes) -> dict[str, Any]:
1189
+ """Validate immutable bytes so one receipt never combines two path states."""
1190
+
1191
+ return build_hwpx_open_safety_report(data)
1192
+
1193
+
1194
+ def validate_form_verification_receipt(
1195
+ receipt: dict[str, Any],
1196
+ ) -> dict[str, Any]:
1197
+ """Validate and normalize the closed receipt envelope before publication."""
1198
+
1199
+ return FormVerificationReceipt.model_validate(receipt).model_dump(
1200
+ by_alias=True,
1201
+ exclude_none=False,
1202
+ )
1203
+
1204
+
1205
+ def _source_preservation_snapshot(
1206
+ source: Path,
1207
+ *,
1208
+ expected_revision: str | None,
1209
+ before: bytes,
1210
+ after: bytes | None,
1211
+ ) -> dict[str, Any]:
1212
+ """Build preservation evidence from one immutable post-operation read."""
1213
+
1214
+ before_revision = _revision_bytes(before)
1215
+ actual_revision = _revision_bytes(after) if after is not None else None
1216
+ expected_matches_before = (
1217
+ expected_revision is None or before_revision == expected_revision
1218
+ )
1219
+ preserved = bool(
1220
+ actual_revision == before_revision and expected_matches_before
1221
+ )
1222
+ return {
1223
+ "ok": preserved,
1224
+ "filename": str(source),
1225
+ "expectedRevision": expected_revision,
1226
+ "beforeRevision": before_revision,
1227
+ "actualRevision": actual_revision,
1228
+ "expectedMatchedBefore": expected_matches_before,
1229
+ "preserved": preserved,
1230
+ }
1231
+
1232
+
1233
+ def _unified_receipt(
1234
+ *,
1235
+ phase: Literal["apply", "verify", "domain-apply"],
1236
+ ok: bool,
1237
+ source: Path,
1238
+ output: Path,
1239
+ dry_run: bool,
1240
+ rolled_back: bool,
1241
+ source_preservation: dict[str, Any],
1242
+ plan: dict[str, Any] | None = None,
1243
+ core_verification: dict[str, Any] | None = None,
1244
+ member_diff: dict[str, Any] | None = None,
1245
+ value_checks: list[dict[str, Any]] | None = None,
1246
+ output_revision: str | None = None,
1247
+ output_exists: bool | None = None,
1248
+ expected_output_revision: str | None = None,
1249
+ error: dict[str, Any] | None = None,
1250
+ domain: dict[str, Any] | None = None,
1251
+ ) -> dict[str, Any]:
1252
+ verification = copy.deepcopy(core_verification or {})
1253
+ idempotency = verification.get("idempotency")
1254
+ replayed = bool(isinstance(idempotency, dict) and idempotency.get("replayed"))
1255
+ if ok and dry_run:
1256
+ status = "dry-run"
1257
+ elif ok and replayed:
1258
+ status = "replayed"
1259
+ elif ok and phase == "verify":
1260
+ real_hancom = verification.get("realHancom")
1261
+ visually_verified = bool(
1262
+ isinstance(real_hancom, dict)
1263
+ and real_hancom.get("ok")
1264
+ and real_hancom.get("renderChecked") is True
1265
+ )
1266
+ revision_bound = (
1267
+ expected_output_revision is not None
1268
+ and expected_output_revision == output_revision
1269
+ )
1270
+ status = (
1271
+ "verified"
1272
+ if visually_verified and revision_bound
1273
+ else "structurally-verified"
1274
+ )
1275
+ elif ok:
1276
+ status = "committed"
1277
+ elif rolled_back:
1278
+ status = "rolled-back"
1279
+ else:
1280
+ status = "failed"
1281
+ observed_output_exists = output.exists() if output_exists is None else output_exists
1282
+ open_safety = copy.deepcopy(verification.get("openSafety"))
1283
+ if not isinstance(open_safety, dict):
1284
+ open_safety = _not_produced("dry-run" if dry_run else "not-produced")
1285
+ byte_preservation = copy.deepcopy(
1286
+ member_diff or verification.get("bytePreservation") or _not_produced()
1287
+ )
1288
+ plan_summary = None
1289
+ resolutions: list[dict[str, Any]] = []
1290
+ expected: list[dict[str, Any]] = []
1291
+ if plan is not None:
1292
+ plan_summary = {
1293
+ "schemaVersion": plan["schemaVersion"],
1294
+ "planHash": plan["planHash"],
1295
+ "requestHash": plan["requestHash"],
1296
+ "inputRevision": plan["inputRevision"],
1297
+ }
1298
+ resolutions = copy.deepcopy(plan["resolutions"])
1299
+ expected = _public_expected_targets(_expected_values(plan))
1300
+ checks = _public_value_checks(copy.deepcopy(value_checks or []))
1301
+ values_ok: bool | None = None
1302
+ if checks:
1303
+ values_ok = all(bool(item.get("ok")) for item in checks)
1304
+ receipt = {
1305
+ "schemaVersion": FORM_VERIFICATION_RECEIPT_SCHEMA,
1306
+ "phase": phase,
1307
+ "status": status,
1308
+ "ok": ok,
1309
+ "dryRun": dry_run,
1310
+ "committed": bool(ok and not dry_run and phase != "verify" and not replayed),
1311
+ "rolledBack": rolled_back,
1312
+ "plan": plan_summary,
1313
+ "source": {
1314
+ "filename": source.name,
1315
+ "revision": source_preservation.get("actualRevision"),
1316
+ },
1317
+ "output": {
1318
+ "filename": output.name,
1319
+ "exists": observed_output_exists,
1320
+ "revision": output_revision,
1321
+ "expectedRevision": expected_output_revision,
1322
+ "revisionMatched": (
1323
+ output_revision == expected_output_revision
1324
+ if expected_output_revision is not None
1325
+ else None
1326
+ ),
1327
+ },
1328
+ "sourcePreservation": _public_source_preservation(source_preservation),
1329
+ "resolutions": resolutions,
1330
+ "expectedTargets": expected,
1331
+ "valueVerification": {
1332
+ "ok": values_ok,
1333
+ "status": "checked" if checks else "deferred",
1334
+ "matchedCount": sum(bool(item.get("ok")) for item in checks),
1335
+ "checkCount": len(checks),
1336
+ "checks": checks,
1337
+ },
1338
+ "package": _receipt_safe(
1339
+ copy.deepcopy(verification.get("package") or _not_produced())
1340
+ ),
1341
+ "reopen": _receipt_safe(
1342
+ copy.deepcopy(verification.get("reopen") or _not_produced())
1343
+ ),
1344
+ "openSafety": _receipt_safe(open_safety),
1345
+ "semanticDiff": _receipt_safe(
1346
+ copy.deepcopy(verification.get("semanticDiff") or _not_produced())
1347
+ ),
1348
+ "memberDiff": _receipt_safe(byte_preservation),
1349
+ # Compatibility key retained for callers already reading the core name.
1350
+ "bytePreservation": _receipt_safe(copy.deepcopy(byte_preservation)),
1351
+ "idempotency": _receipt_safe(
1352
+ copy.deepcopy(verification.get("idempotency") or _not_produced())
1353
+ ),
1354
+ "savePipeline": _receipt_safe(
1355
+ copy.deepcopy(verification.get("savePipeline") or _not_produced())
1356
+ ),
1357
+ "domain": _receipt_safe(
1358
+ copy.deepcopy(domain or verification.get("domain") or _not_produced())
1359
+ ),
1360
+ "realHancom": _receipt_safe(
1361
+ copy.deepcopy(
1362
+ verification.get("realHancom") or _not_produced("not-requested")
1363
+ )
1364
+ ),
1365
+ "error": _public_error(error),
1366
+ }
1367
+ return validate_form_verification_receipt(receipt)
1368
+
1369
+
1370
+ def analyze_mixed_form_plan(
1371
+ plan: MixedFormPlanInput | dict[str, Any],
1372
+ ) -> dict[str, Any]:
1373
+ """Compile a public plan without copying, saving, or mutating either path."""
1374
+
1375
+ request = _authorize_public_plan(_payload(plan, apply=False))
1376
+ source = Path(request["source"])
1377
+ source_snapshot = _capture_path_snapshot(source)
1378
+ if source_snapshot.data is None:
1379
+ raise FileNotFoundError(source)
1380
+ before = source_snapshot.data
1381
+ output = Path(request["output"])
1382
+ output_snapshot = _capture_optional_path_snapshot(output)
1383
+
1384
+ compiled = plan_mixed_form_fill(request).to_dict()
1385
+ open_safety = _open_safety_snapshot(before)
1386
+ source_unchanged = _snapshots_match(
1387
+ _capture_path_snapshot(source),
1388
+ source_snapshot,
1389
+ )
1390
+ output_unchanged = _snapshots_match(
1391
+ _capture_optional_path_snapshot(output),
1392
+ output_snapshot,
1393
+ )
1394
+ if not source_unchanged or not output_unchanged: # pragma: no cover - race/invariant
1395
+ raise RuntimeError("mixed-form analysis mutated an input or output file")
1396
+ detached = copy.deepcopy(compiled)
1397
+ return {
1398
+ "schemaVersion": "hwpx.mixed-form-analysis/v1",
1399
+ "mutated": False,
1400
+ "source": {
1401
+ "filename": str(source),
1402
+ "revision": detached["inputRevision"],
1403
+ "unchangedAfterAnalysis": True,
1404
+ },
1405
+ "output": {
1406
+ "filename": str(output),
1407
+ "unchangedAfterAnalysis": True,
1408
+ },
1409
+ "compiledPlan": detached,
1410
+ "planHash": detached["planHash"],
1411
+ "requestHash": detached["requestHash"],
1412
+ "resolutions": copy.deepcopy(detached["resolutions"]),
1413
+ "openSafety": open_safety,
1414
+ "nextTool": "apply_form_fill",
1415
+ }
1416
+
1417
+
1418
+ def apply_canonical_mixed_form_plan(
1419
+ plan: MixedFormApplyInput | dict[str, Any],
1420
+ ) -> dict[str, Any]:
1421
+ """Apply one plan while serializing adapter verification and rollback."""
1422
+
1423
+ with _IDEMPOTENCY_LOCK:
1424
+ return _apply_canonical_mixed_form_plan_locked(plan)
1425
+
1426
+
1427
+ def _forget_fresh_idempotency(
1428
+ compiled: dict[str, Any],
1429
+ *,
1430
+ fresh: bool,
1431
+ ) -> None:
1432
+ if not fresh:
1433
+ return
1434
+ key = compiled["batch"].get("idempotencyKey")
1435
+ if isinstance(key, str):
1436
+ _IDEMPOTENCY_STORE.pop(key, None)
1437
+
1438
+
1439
+ def _restore_owned_candidate(
1440
+ *,
1441
+ output: Path,
1442
+ output_existed: bool,
1443
+ output_before: bytes | None,
1444
+ output_mode_before: int | None,
1445
+ candidate_bytes: bytes | None,
1446
+ claimed_revision: str | None,
1447
+ fresh: bool,
1448
+ publication: WorkspaceOutputGuard | None = None,
1449
+ failure_preimage: _FailurePreimagePreserver | None = None,
1450
+ ) -> bool:
1451
+ """Restore only a candidate represented by an exact publication token."""
1452
+
1453
+ if failure_preimage is not None:
1454
+ failure_preimage.preserve()
1455
+ if publication is not None:
1456
+ workspace = WorkspaceResolver.from_environment()
1457
+ try:
1458
+ if output_existed:
1459
+ if output_before is None: # pragma: no cover - snapshot invariant
1460
+ return False
1461
+ workspace.atomic_publish_bytes(
1462
+ publication,
1463
+ output_before,
1464
+ mode=output_mode_before,
1465
+ )
1466
+ else:
1467
+ workspace.remove_output(publication)
1468
+ except (OSError, RuntimeError):
1469
+ return False
1470
+ if not output_existed:
1471
+ return workspace.cleanup_owned_parent_directories(publication)
1472
+ return True
1473
+
1474
+ # Every canonical commit is required to publish through
1475
+ # ``_WorkspaceSavePipeline``. Bytes alone are not an ownership claim: an
1476
+ # external writer can replace the path with identical contents. Without the
1477
+ # publication token, fail closed and preserve the live path.
1478
+ return False
1479
+
1480
+
1481
+ def _apply_canonical_mixed_form_plan_locked(
1482
+ plan: MixedFormApplyInput | dict[str, Any],
1483
+ ) -> dict[str, Any]:
1484
+ """Apply and bind every public claim to one immutable output candidate."""
1485
+
1486
+ payload = _payload(plan, apply=True)
1487
+ if payload["schemaVersion"] == MIXED_FORM_PLAN_SCHEMA:
1488
+ compiled = plan_mixed_form_fill(_authorize_public_plan(payload)).to_dict()
1489
+ elif payload["schemaVersion"] == MIXED_FORM_COMPILED_PLAN_SCHEMA:
1490
+ _authorize_compiled_plan(payload)
1491
+ compiled = payload
1492
+ else: # pragma: no cover - discriminated union guard
1493
+ raise ValueError("unsupported mixed-form plan schema")
1494
+
1495
+ compiled = validate_mixed_form_plan(compiled).to_dict()
1496
+ source, output = _authorize_compiled_plan(compiled)
1497
+ source_snapshot = _capture_path_snapshot(source)
1498
+ if source_snapshot.data is None: # pragma: no cover - compiled plan invariant
1499
+ raise FileNotFoundError(source)
1500
+ source_before = source_snapshot.data
1501
+ dry_run = bool(compiled["batch"]["dryRun"])
1502
+ save_pipeline: _WorkspaceSavePipeline | None = None
1503
+ output_snapshot = _capture_optional_path_snapshot(output)
1504
+ output_existed = output_snapshot.existed
1505
+ output_before = output_snapshot.data
1506
+ output_mode_before = output_snapshot.mode
1507
+ workspace = WorkspaceResolver.from_environment()
1508
+ failure_preimage = _FailurePreimagePreserver(
1509
+ workspace=workspace,
1510
+ output=output_snapshot.path,
1511
+ data=output_before if output_existed else None,
1512
+ mode=output_mode_before,
1513
+ )
1514
+ if not dry_run:
1515
+ output_precondition = (
1516
+ output_snapshot.guard or output_snapshot.missing_guard
1517
+ )
1518
+ if output_precondition is None: # pragma: no cover - snapshot invariant
1519
+ raise RuntimeError("canonical output precondition is unavailable")
1520
+ save_pipeline = _WorkspaceSavePipeline(
1521
+ workspace,
1522
+ output_precondition,
1523
+ failure_preimage,
1524
+ )
1525
+ fresh = False
1526
+ claimed_revision: str | None = None
1527
+ publication: WorkspaceOutputGuard | None = None
1528
+ candidate_existed = False
1529
+ candidate_bytes: bytes | None = None
1530
+ try:
1531
+ result = apply_mixed_form_plan(
1532
+ compiled,
1533
+ idempotency_store=_IDEMPOTENCY_STORE,
1534
+ fault_injector=_fault_injector_for_tests,
1535
+ save_pipeline=save_pipeline,
1536
+ )
1537
+ response = result.to_dict()
1538
+ core_verification = copy.deepcopy(response.get("verificationReport", {}))
1539
+ idempotency = core_verification.get("idempotency")
1540
+ replayed = bool(
1541
+ isinstance(idempotency, dict) and idempotency.get("replayed") is True
1542
+ )
1543
+ fresh = bool(
1544
+ response.get("ok")
1545
+ and isinstance(idempotency, dict)
1546
+ and idempotency.get("replayed") is False
1547
+ )
1548
+ claimed_revision = (
1549
+ response.get("documentRevision")
1550
+ if isinstance(response.get("documentRevision"), str)
1551
+ else None
1552
+ )
1553
+ if claimed_revision is None and isinstance(
1554
+ core_verification.get("candidateRevision"),
1555
+ str,
1556
+ ):
1557
+ claimed_revision = core_verification["candidateRevision"]
1558
+ publication = save_pipeline.publication if save_pipeline is not None else None
1559
+ candidate_snapshot = _capture_optional_path_snapshot(output)
1560
+ candidate_existed = candidate_snapshot.existed
1561
+ candidate_bytes = candidate_snapshot.data
1562
+ return _finalize_canonical_apply(
1563
+ compiled=compiled,
1564
+ source=source,
1565
+ output=output,
1566
+ source_snapshot_before=source_snapshot,
1567
+ output_snapshot_before=output_snapshot,
1568
+ candidate_snapshot=candidate_snapshot,
1569
+ source_before=source_before,
1570
+ output_existed=output_existed,
1571
+ output_before=output_before,
1572
+ output_mode_before=output_mode_before,
1573
+ candidate_existed=candidate_existed,
1574
+ candidate_bytes=candidate_bytes,
1575
+ response=response,
1576
+ core_verification=core_verification,
1577
+ claimed_revision=claimed_revision,
1578
+ replayed=replayed,
1579
+ fresh=fresh,
1580
+ publication=publication,
1581
+ failure_preimage=failure_preimage,
1582
+ )
1583
+ except BaseException:
1584
+ if publication is None and save_pipeline is not None:
1585
+ publication = save_pipeline.publication
1586
+ failure_preimage.preserve()
1587
+ _forget_fresh_idempotency(
1588
+ compiled,
1589
+ fresh=bool(fresh or publication is not None),
1590
+ )
1591
+ _restore_owned_candidate(
1592
+ output=output,
1593
+ output_existed=output_existed,
1594
+ output_before=output_before,
1595
+ output_mode_before=output_mode_before,
1596
+ candidate_bytes=candidate_bytes,
1597
+ claimed_revision=claimed_revision,
1598
+ fresh=fresh,
1599
+ publication=publication,
1600
+ failure_preimage=failure_preimage,
1601
+ )
1602
+ raise
1603
+
1604
+
1605
+ def _finalize_canonical_apply(
1606
+ *,
1607
+ compiled: dict[str, Any],
1608
+ source: Path,
1609
+ output: Path,
1610
+ source_snapshot_before: _PathSnapshot,
1611
+ output_snapshot_before: _PathSnapshot,
1612
+ candidate_snapshot: _PathSnapshot,
1613
+ source_before: bytes,
1614
+ output_existed: bool,
1615
+ output_before: bytes | None,
1616
+ output_mode_before: int | None,
1617
+ candidate_existed: bool,
1618
+ candidate_bytes: bytes | None,
1619
+ response: dict[str, Any],
1620
+ core_verification: dict[str, Any],
1621
+ claimed_revision: str | None,
1622
+ replayed: bool,
1623
+ fresh: bool,
1624
+ publication: WorkspaceOutputGuard | None,
1625
+ failure_preimage: _FailurePreimagePreserver,
1626
+ ) -> dict[str, Any]:
1627
+ """Finalize a core result without combining evidence from different bytes."""
1628
+
1629
+ dry_run = bool(compiled["batch"]["dryRun"])
1630
+ response["dryRun"] = dry_run
1631
+ observed_source_snapshot = _capture_optional_path_snapshot(source)
1632
+ source_exists = observed_source_snapshot.existed
1633
+ source_after = observed_source_snapshot.data
1634
+ source_report = _source_preservation_snapshot(
1635
+ source,
1636
+ expected_revision=compiled["inputRevision"],
1637
+ before=source_before,
1638
+ after=source_after if source_exists else None,
1639
+ )
1640
+ if not _snapshots_match(observed_source_snapshot, source_snapshot_before):
1641
+ source_report.update(
1642
+ {
1643
+ "ok": False,
1644
+ "preserved": False,
1645
+ "identityPreserved": False,
1646
+ }
1647
+ )
1648
+ candidate_revision = (
1649
+ _revision_bytes(candidate_bytes) if candidate_bytes is not None else None
1650
+ )
1651
+ allowed_members = _allowed_plan_members(compiled, source_before)
1652
+ verification = copy.deepcopy(core_verification)
1653
+ response_ok = bool(response.get("ok")) and bool(source_report["ok"])
1654
+ missing_fresh_publication = bool(
1655
+ response_ok
1656
+ and not dry_run
1657
+ and not replayed
1658
+ and publication is None
1659
+ )
1660
+ rolled_back = bool(response.get("rolledBack"))
1661
+ final_exists = candidate_existed
1662
+ final_bytes = candidate_bytes
1663
+
1664
+ if not source_report["ok"]:
1665
+ response_ok = False
1666
+ response["error"] = {
1667
+ "code": "source_revision_mismatch",
1668
+ "recoverability": "retryable",
1669
+ "target": "batch.input.filename",
1670
+ }
1671
+ _forget_fresh_idempotency(compiled, fresh=fresh)
1672
+ rolled_back = _restore_owned_candidate(
1673
+ output=output,
1674
+ output_existed=output_existed,
1675
+ output_before=output_before,
1676
+ output_mode_before=output_mode_before,
1677
+ candidate_bytes=candidate_bytes,
1678
+ claimed_revision=claimed_revision,
1679
+ fresh=fresh,
1680
+ publication=publication,
1681
+ failure_preimage=failure_preimage,
1682
+ )
1683
+ final_exists, final_bytes = _snapshot_path(output)
1684
+ elif missing_fresh_publication:
1685
+ response_ok = False
1686
+ _forget_fresh_idempotency(compiled, fresh=fresh)
1687
+ failure_preimage.preserve()
1688
+ response["error"] = {
1689
+ "code": "materialized_output_changed",
1690
+ "recoverability": "retryable",
1691
+ "target": "batch.output.filename",
1692
+ }
1693
+ elif not response_ok:
1694
+ _forget_fresh_idempotency(compiled, fresh=fresh)
1695
+ restored = _restore_owned_candidate(
1696
+ output=output,
1697
+ output_existed=output_existed,
1698
+ output_before=output_before,
1699
+ output_mode_before=output_mode_before,
1700
+ candidate_bytes=candidate_bytes,
1701
+ claimed_revision=claimed_revision,
1702
+ fresh=fresh,
1703
+ publication=publication,
1704
+ failure_preimage=failure_preimage,
1705
+ )
1706
+ final_exists, final_bytes = _snapshot_path(output)
1707
+ prestate_preserved = bool(
1708
+ final_exists == output_existed
1709
+ and (
1710
+ not output_existed
1711
+ or (
1712
+ final_bytes == output_before
1713
+ and _path_mode(output) == output_mode_before
1714
+ )
1715
+ )
1716
+ )
1717
+ rolled_back = bool(prestate_preserved and (restored or rolled_back))
1718
+ elif dry_run:
1719
+ output_preserved = bool(
1720
+ candidate_existed == output_existed
1721
+ and (
1722
+ not output_existed
1723
+ or (
1724
+ candidate_bytes == output_before
1725
+ and _path_mode(output) == output_mode_before
1726
+ )
1727
+ )
1728
+ )
1729
+ if not output_preserved:
1730
+ response_ok = False
1731
+ response["error"] = {
1732
+ "code": "dry_run_mutated",
1733
+ "recoverability": "terminal",
1734
+ "target": "batch.output.filename",
1735
+ }
1736
+ _forget_fresh_idempotency(compiled, fresh=fresh)
1737
+ rolled_back = _restore_owned_candidate(
1738
+ output=output,
1739
+ output_existed=output_existed,
1740
+ output_before=output_before,
1741
+ output_mode_before=output_mode_before,
1742
+ candidate_bytes=candidate_bytes,
1743
+ claimed_revision=claimed_revision,
1744
+ fresh=False,
1745
+ publication=publication,
1746
+ failure_preimage=failure_preimage,
1747
+ )
1748
+ final_exists, final_bytes = _snapshot_path(output)
1749
+ elif response_ok:
1750
+ revision_matches = bool(
1751
+ candidate_bytes is not None and claimed_revision == candidate_revision
1752
+ )
1753
+ if candidate_bytes is None:
1754
+ open_safety = {"ok": False, "status": "output-unavailable"}
1755
+ member_diff = {
1756
+ "ok": False,
1757
+ "status": "output-unavailable",
1758
+ "diffComputed": False,
1759
+ }
1760
+ else:
1761
+ open_safety = _open_safety_snapshot(candidate_bytes)
1762
+ member_diff = _member_diff(
1763
+ source_before,
1764
+ candidate_bytes,
1765
+ allowed_changed_members=allowed_members,
1766
+ )
1767
+ verification.update(
1768
+ {
1769
+ "package": copy.deepcopy(
1770
+ open_safety.get("validatePackage") or _not_produced()
1771
+ ),
1772
+ "reopen": copy.deepcopy(
1773
+ open_safety.get("reopen") or _not_produced()
1774
+ ),
1775
+ "openSafety": open_safety,
1776
+ "bytePreservation": member_diff,
1777
+ }
1778
+ )
1779
+ if not (
1780
+ revision_matches and open_safety.get("ok") and member_diff.get("ok")
1781
+ ):
1782
+ response_ok = False
1783
+ _forget_fresh_idempotency(compiled, fresh=fresh)
1784
+ rolled_back = _restore_owned_candidate(
1785
+ output=output,
1786
+ output_existed=output_existed,
1787
+ output_before=output_before,
1788
+ output_mode_before=output_mode_before,
1789
+ candidate_bytes=candidate_bytes,
1790
+ claimed_revision=claimed_revision,
1791
+ fresh=fresh and revision_matches,
1792
+ publication=publication,
1793
+ failure_preimage=failure_preimage,
1794
+ )
1795
+ response["error"] = {
1796
+ "code": (
1797
+ "idempotency_output_mismatch"
1798
+ if replayed
1799
+ else "materialized_output_verification_failed"
1800
+ if rolled_back
1801
+ else "materialized_output_changed"
1802
+ ),
1803
+ "recoverability": "terminal" if rolled_back else "retryable",
1804
+ "target": "batch.output.filename",
1805
+ }
1806
+ final_exists, final_bytes = _snapshot_path(output)
1807
+
1808
+ if dry_run and response_ok:
1809
+ receipt_members = _assess_declared_member_diff(
1810
+ verification.get("bytePreservation"),
1811
+ allowed_changed_members=allowed_members,
1812
+ )
1813
+ elif response_ok:
1814
+ receipt_members = copy.deepcopy(
1815
+ verification.get("bytePreservation") or _not_produced()
1816
+ )
1817
+ else:
1818
+ receipt_members = _not_produced(
1819
+ "rolled-back" if rolled_back else "not-committed"
1820
+ )
1821
+
1822
+ def assemble_public(
1823
+ *,
1824
+ ok: bool,
1825
+ source_evidence: dict[str, Any],
1826
+ observed_exists: bool,
1827
+ observed_bytes: bytes | None,
1828
+ receipt_verification: dict[str, Any],
1829
+ member_evidence: dict[str, Any],
1830
+ path_prestate_preserved: bool = True,
1831
+ ) -> dict[str, Any]:
1832
+ response["ok"] = ok
1833
+ response["rolledBack"] = rolled_back
1834
+ response["documentRevision"] = claimed_revision if ok else None
1835
+ if ok:
1836
+ response.pop("error", None)
1837
+ observed_revision = (
1838
+ None
1839
+ if dry_run and ok
1840
+ else _revision_bytes(observed_bytes)
1841
+ if observed_bytes is not None
1842
+ else None
1843
+ )
1844
+ rollback_preserved = bool(
1845
+ path_prestate_preserved
1846
+ and
1847
+ observed_exists == output_existed
1848
+ and (
1849
+ not output_existed
1850
+ or (
1851
+ observed_bytes == output_before
1852
+ and _path_mode(output) == output_mode_before
1853
+ )
1854
+ )
1855
+ )
1856
+ if not ok:
1857
+ reason = "rolled-back" if rolled_back else "not-committed"
1858
+ receipt_verification = copy.deepcopy(receipt_verification)
1859
+ receipt_verification.update(
1860
+ {
1861
+ "package": _not_produced(reason),
1862
+ "reopen": _not_produced(reason),
1863
+ "openSafety": _not_produced(reason),
1864
+ "bytePreservation": member_evidence,
1865
+ }
1866
+ )
1867
+ receipt = _unified_receipt(
1868
+ phase="apply",
1869
+ ok=ok,
1870
+ source=source,
1871
+ output=output,
1872
+ dry_run=dry_run,
1873
+ rolled_back=rolled_back,
1874
+ source_preservation=source_evidence,
1875
+ plan=compiled,
1876
+ core_verification=receipt_verification,
1877
+ member_diff=member_evidence,
1878
+ output_revision=observed_revision,
1879
+ output_exists=observed_exists,
1880
+ expected_output_revision=(claimed_revision if ok and not dry_run else None),
1881
+ error=response.get("error"),
1882
+ )
1883
+ receipt["rollbackPreservation"] = {
1884
+ "required": bool(
1885
+ rolled_back
1886
+ or dry_run
1887
+ or (not ok and publication is not None)
1888
+ ),
1889
+ "outputExistedBefore": output_existed,
1890
+ "preserved": rollback_preserved,
1891
+ }
1892
+ receipt = validate_form_verification_receipt(receipt)
1893
+ response["verificationReport"] = receipt_verification
1894
+ response.update(
1895
+ {
1896
+ "planHash": compiled["planHash"],
1897
+ "requestHash": compiled["requestHash"],
1898
+ "resolutions": copy.deepcopy(compiled["resolutions"]),
1899
+ "verificationReceipt": receipt,
1900
+ "openSafety": copy.deepcopy(receipt["openSafety"]),
1901
+ }
1902
+ )
1903
+ return _public_apply_response(response)
1904
+
1905
+ public = assemble_public(
1906
+ ok=response_ok,
1907
+ source_evidence=source_report,
1908
+ observed_exists=final_exists,
1909
+ observed_bytes=final_bytes,
1910
+ receipt_verification=verification,
1911
+ member_evidence=receipt_members,
1912
+ )
1913
+ observed_source = _capture_optional_path_snapshot(source)
1914
+ observed_output = _capture_optional_path_snapshot(output)
1915
+ source_matches = _snapshots_match(observed_source, source_snapshot_before)
1916
+ if not response_ok:
1917
+ failure_preimage.preserve()
1918
+ final_source_report = _source_preservation_snapshot(
1919
+ source,
1920
+ expected_revision=compiled["inputRevision"],
1921
+ before=source_before,
1922
+ after=observed_source.data if observed_source.existed else None,
1923
+ )
1924
+ if not source_matches:
1925
+ final_source_report.update(
1926
+ {
1927
+ "ok": False,
1928
+ "preserved": False,
1929
+ "identityPreserved": False,
1930
+ }
1931
+ )
1932
+ response["error"] = {
1933
+ "code": "source_revision_mismatch",
1934
+ "recoverability": "retryable",
1935
+ "target": "batch.input.filename",
1936
+ }
1937
+ return assemble_public(
1938
+ ok=False,
1939
+ source_evidence=final_source_report,
1940
+ observed_exists=observed_output.existed,
1941
+ observed_bytes=observed_output.data,
1942
+ receipt_verification=verification,
1943
+ member_evidence=receipt_members,
1944
+ path_prestate_preserved=_snapshots_match(
1945
+ observed_output,
1946
+ output_snapshot_before,
1947
+ identity=False,
1948
+ ),
1949
+ )
1950
+
1951
+ if dry_run:
1952
+ output_matches = _snapshots_match(
1953
+ observed_output,
1954
+ output_snapshot_before,
1955
+ )
1956
+ elif publication is not None:
1957
+ output_matches = _snapshot_matches_publication(
1958
+ observed_output,
1959
+ publication,
1960
+ )
1961
+ else:
1962
+ output_matches = _snapshots_match(observed_output, candidate_snapshot)
1963
+ success_cleanup_failed = False
1964
+ if source_matches and output_matches:
1965
+ if dry_run:
1966
+ return public
1967
+ if failure_preimage.cleanup_after_success():
1968
+ observed_source = _capture_optional_path_snapshot(source)
1969
+ observed_output = _capture_optional_path_snapshot(output)
1970
+ source_matches = _snapshots_match(
1971
+ observed_source,
1972
+ source_snapshot_before,
1973
+ )
1974
+ output_matches = (
1975
+ _snapshot_matches_publication(observed_output, publication)
1976
+ if publication is not None
1977
+ else _snapshots_match(observed_output, candidate_snapshot)
1978
+ )
1979
+ if source_matches and output_matches:
1980
+ return public
1981
+ else:
1982
+ success_cleanup_failed = True
1983
+
1984
+ _forget_fresh_idempotency(compiled, fresh=fresh)
1985
+ failure_preimage.preserve()
1986
+ if not source_matches:
1987
+ response["error"] = {
1988
+ "code": "source_revision_mismatch",
1989
+ "recoverability": "retryable",
1990
+ "target": "batch.input.filename",
1991
+ }
1992
+ rolled_back = _restore_owned_candidate(
1993
+ output=output,
1994
+ output_existed=output_existed,
1995
+ output_before=output_before,
1996
+ output_mode_before=output_mode_before,
1997
+ candidate_bytes=candidate_bytes,
1998
+ claimed_revision=claimed_revision,
1999
+ fresh=fresh and not dry_run,
2000
+ publication=publication,
2001
+ failure_preimage=failure_preimage,
2002
+ )
2003
+ else:
2004
+ response["error"] = {
2005
+ "code": "materialized_output_changed",
2006
+ "recoverability": "retryable",
2007
+ "target": "batch.output.filename",
2008
+ }
2009
+ rolled_back = (
2010
+ _restore_owned_candidate(
2011
+ output=output,
2012
+ output_existed=output_existed,
2013
+ output_before=output_before,
2014
+ output_mode_before=output_mode_before,
2015
+ candidate_bytes=candidate_bytes,
2016
+ claimed_revision=claimed_revision,
2017
+ fresh=fresh and not dry_run,
2018
+ publication=publication,
2019
+ failure_preimage=failure_preimage,
2020
+ )
2021
+ if success_cleanup_failed and output_matches
2022
+ else False
2023
+ )
2024
+ final_output_snapshot = _capture_optional_path_snapshot(output)
2025
+ final_exists = final_output_snapshot.existed
2026
+ final_bytes = final_output_snapshot.data
2027
+ source_exists, source_after = _snapshot_path(source)
2028
+ source_report = _source_preservation_snapshot(
2029
+ source,
2030
+ expected_revision=compiled["inputRevision"],
2031
+ before=source_before,
2032
+ after=source_after if source_exists else None,
2033
+ )
2034
+ if not source_matches:
2035
+ source_report.update(
2036
+ {
2037
+ "ok": False,
2038
+ "preserved": False,
2039
+ "identityPreserved": False,
2040
+ }
2041
+ )
2042
+ return assemble_public(
2043
+ ok=False,
2044
+ source_evidence=source_report,
2045
+ observed_exists=final_exists,
2046
+ observed_bytes=final_bytes,
2047
+ receipt_verification=verification,
2048
+ member_evidence=_not_produced(
2049
+ "rolled-back" if rolled_back else "output-changed"
2050
+ ),
2051
+ path_prestate_preserved=_snapshots_match(
2052
+ final_output_snapshot,
2053
+ output_snapshot_before,
2054
+ identity=False,
2055
+ ),
2056
+ )
2057
+
2058
+
2059
+ def verify_canonical_mixed_form_plan(
2060
+ plan: MixedFormCompiledPlanInput | dict[str, Any],
2061
+ *,
2062
+ require: bool = False,
2063
+ expected_output_revision: str | None = None,
2064
+ render_verifier: Callable[[str, str, bool], dict[str, Any]] | None = None,
2065
+ ) -> dict[str, Any]:
2066
+ """Verify one compiled plan while excluding in-process public writers."""
2067
+
2068
+ with _IDEMPOTENCY_LOCK:
2069
+ return _verify_canonical_mixed_form_plan_locked(
2070
+ plan,
2071
+ require=require,
2072
+ expected_output_revision=expected_output_revision,
2073
+ render_verifier=render_verifier,
2074
+ )
2075
+
2076
+
2077
+ def _verify_canonical_mixed_form_plan_locked(
2078
+ plan: MixedFormCompiledPlanInput | dict[str, Any],
2079
+ *,
2080
+ require: bool = False,
2081
+ expected_output_revision: str | None = None,
2082
+ render_verifier: Callable[[str, str, bool], dict[str, Any]] | None = None,
2083
+ ) -> dict[str, Any]:
2084
+ """Verify one compiled plan against immutable source/output snapshots."""
2085
+
2086
+ payload = _payload(plan, apply=True)
2087
+ if payload["schemaVersion"] != MIXED_FORM_COMPILED_PLAN_SCHEMA:
2088
+ raise ValueError("verify_form_fill requires hwpx.mixed-form-compiled-plan/v1")
2089
+ compiled = validate_mixed_form_plan(payload).to_dict()
2090
+ source, output = _authorize_compiled_plan(compiled)
2091
+ source_snapshot = _capture_path_snapshot(source)
2092
+ if source_snapshot.data is None: # pragma: no cover - validated plan invariant
2093
+ raise FileNotFoundError(source)
2094
+ source_bytes = source_snapshot.data
2095
+ checks: list[dict[str, Any]] = []
2096
+ output_bytes: bytes | None = None
2097
+ output_error_code: str | None = None
2098
+ output_snapshot_before = _capture_optional_path_snapshot(output)
2099
+ try:
2100
+ if output_snapshot_before.data is None:
2101
+ raise FileNotFoundError(output)
2102
+ output_bytes = output_snapshot_before.data
2103
+ with HwpxAgentDocument.open(output_bytes) as document:
2104
+ for expected in _expected_values(compiled):
2105
+ try:
2106
+ record = document.resolve_record(expected["path"])
2107
+ actual = record.summary.get(expected["property"])
2108
+ checks.append(
2109
+ {
2110
+ **copy.deepcopy(expected),
2111
+ "ok": actual == expected["value"],
2112
+ }
2113
+ )
2114
+ except Exception as exc:
2115
+ checks.append(
2116
+ {
2117
+ **copy.deepcopy(expected),
2118
+ "ok": False,
2119
+ "errorCode": type(exc).__name__,
2120
+ }
2121
+ )
2122
+ except Exception as exc:
2123
+ output_error_code = type(exc).__name__
2124
+
2125
+ if output_bytes is not None:
2126
+ open_safety = _open_safety_snapshot(output_bytes)
2127
+ member_diff = _member_diff(
2128
+ source_bytes,
2129
+ output_bytes,
2130
+ allowed_changed_members=_allowed_plan_members(compiled, source_bytes),
2131
+ )
2132
+ core_verification = {
2133
+ "package": copy.deepcopy(
2134
+ open_safety.get("validatePackage") or _not_produced()
2135
+ ),
2136
+ "reopen": copy.deepcopy(open_safety.get("reopen") or _not_produced()),
2137
+ "openSafety": open_safety,
2138
+ "semanticDiff": {
2139
+ "ok": all(bool(item.get("ok")) for item in checks),
2140
+ "verifiedTargetCount": len(checks),
2141
+ },
2142
+ "bytePreservation": member_diff,
2143
+ }
2144
+ else:
2145
+ open_safety = {
2146
+ "ok": False,
2147
+ "status": "output-unavailable",
2148
+ "errorCode": output_error_code,
2149
+ }
2150
+ member_diff = {
2151
+ "ok": False,
2152
+ "status": "output-unavailable",
2153
+ "errorCode": output_error_code,
2154
+ }
2155
+ core_verification = {
2156
+ "openSafety": open_safety,
2157
+ "bytePreservation": member_diff,
2158
+ }
2159
+
2160
+ real_hancom = _not_produced("not-requested")
2161
+ if render_verifier is not None and (require or output_bytes is not None):
2162
+ snapshot_cleanup_failed = False
2163
+ try:
2164
+ if output_bytes is None:
2165
+ raise FileNotFoundError("verification output is unavailable")
2166
+ workspace = WorkspaceResolver.from_environment()
2167
+ snapshot_token = secrets.token_hex(16)
2168
+ output_render_snapshot = (
2169
+ workspace.primary_root
2170
+ / f".hwpx-verify-{snapshot_token}-output.hwpx"
2171
+ )
2172
+ source_render_snapshot = (
2173
+ workspace.primary_root
2174
+ / f".hwpx-verify-{snapshot_token}-source.hwpx"
2175
+ )
2176
+ snapshot_publications: list[WorkspaceOutputGuard] = []
2177
+ try:
2178
+ for snapshot_path, snapshot_bytes in (
2179
+ (output_render_snapshot, output_bytes),
2180
+ (source_render_snapshot, source_bytes),
2181
+ ):
2182
+ snapshot_guard = workspace.capture_output(
2183
+ snapshot_path,
2184
+ create_parents=False,
2185
+ )
2186
+ if snapshot_guard.target_existed:
2187
+ raise RuntimeError("verification snapshot name collision")
2188
+ snapshot_publications.append(
2189
+ workspace.atomic_publish_bytes(
2190
+ snapshot_guard,
2191
+ snapshot_bytes,
2192
+ mode=0o400,
2193
+ )
2194
+ )
2195
+ real_hancom = render_verifier(
2196
+ str(output_render_snapshot),
2197
+ str(source_render_snapshot),
2198
+ require,
2199
+ )
2200
+ finally:
2201
+ for publication in reversed(snapshot_publications):
2202
+ try:
2203
+ workspace.remove_output(publication)
2204
+ except (OSError, RuntimeError):
2205
+ snapshot_cleanup_failed = True
2206
+ # A verifier may chmod or rewrite the same inode. It is
2207
+ # still our snapshot, so recapture that exact identity
2208
+ # and remove it. Never delete a replacement inode.
2209
+ try:
2210
+ current = workspace.capture_output(
2211
+ publication.path,
2212
+ create_parents=False,
2213
+ )
2214
+ if (
2215
+ current.target_existed
2216
+ and current.target_device
2217
+ == publication.target_device
2218
+ and current.target_inode
2219
+ == publication.target_inode
2220
+ ):
2221
+ workspace.remove_output(current)
2222
+ except (OSError, RuntimeError):
2223
+ pass
2224
+ if snapshot_cleanup_failed:
2225
+ real_hancom = {
2226
+ "ok": False,
2227
+ "status": "failed",
2228
+ "renderChecked": False,
2229
+ "errorCode": "verification_snapshot_cleanup_failed",
2230
+ }
2231
+ except Exception as exc:
2232
+ real_hancom = (
2233
+ {
2234
+ "ok": False,
2235
+ "status": "failed",
2236
+ "renderChecked": False,
2237
+ "errorCode": "verification_snapshot_cleanup_failed",
2238
+ }
2239
+ if snapshot_cleanup_failed
2240
+ else {
2241
+ "ok": False if require else None,
2242
+ "status": "failed" if require else "unavailable",
2243
+ "renderChecked": False,
2244
+ "errorCode": type(exc).__name__,
2245
+ }
2246
+ )
2247
+ if (
2248
+ not require
2249
+ and real_hancom.get("renderChecked") is not True
2250
+ and real_hancom.get("status") != "failed"
2251
+ ):
2252
+ # Optional visual verification is advisory. A fully installed visual
2253
+ # stack can report ``ok=false`` when the external Hancom oracle itself
2254
+ # is unavailable, while a base install raises and reaches the explicit
2255
+ # unavailable branch above. Normalize both environments to the same
2256
+ # honest unverified state; only observed render failures or internal
2257
+ # snapshot-cleanup failures may fail structural verification.
2258
+ real_hancom = {
2259
+ **real_hancom,
2260
+ "ok": None,
2261
+ "status": "unavailable",
2262
+ "renderChecked": False,
2263
+ }
2264
+ core_verification["realHancom"] = real_hancom
2265
+ values_ok = bool(checks) and all(bool(item.get("ok")) for item in checks)
2266
+ render_observed = real_hancom.get("renderChecked") is True
2267
+ render_failed = bool(
2268
+ (render_observed and real_hancom.get("ok") is False)
2269
+ or real_hancom.get("status") == "failed"
2270
+ )
2271
+ render_ok = (
2272
+ bool(real_hancom.get("ok"))
2273
+ if render_observed
2274
+ else not require and not render_failed
2275
+ )
2276
+ output_revision = (
2277
+ _revision_bytes(output_bytes) if output_bytes is not None else None
2278
+ )
2279
+ revision_ok = (
2280
+ output_revision == expected_output_revision
2281
+ if expected_output_revision is not None
2282
+ else True
2283
+ )
2284
+ source_exists, source_after = _snapshot_path(source)
2285
+ if not source_exists:
2286
+ source_after = None
2287
+ source_report = _source_preservation_snapshot(
2288
+ source,
2289
+ expected_revision=compiled["inputRevision"],
2290
+ before=source_bytes,
2291
+ after=source_after,
2292
+ )
2293
+ ok = bool(
2294
+ source_report["ok"]
2295
+ and output_bytes is not None
2296
+ and open_safety.get("ok")
2297
+ and member_diff.get("ok")
2298
+ and values_ok
2299
+ and render_ok
2300
+ and revision_ok
2301
+ )
2302
+ receipt = _unified_receipt(
2303
+ phase="verify",
2304
+ ok=ok,
2305
+ source=source,
2306
+ output=output,
2307
+ dry_run=False,
2308
+ rolled_back=False,
2309
+ source_preservation=source_report,
2310
+ plan=compiled,
2311
+ core_verification=core_verification,
2312
+ member_diff=member_diff,
2313
+ value_checks=checks,
2314
+ output_revision=output_revision,
2315
+ output_exists=output_bytes is not None,
2316
+ expected_output_revision=expected_output_revision,
2317
+ error=(
2318
+ None
2319
+ if ok
2320
+ else {
2321
+ "code": "verification_failed",
2322
+ "recoverability": "retryable",
2323
+ "target": "batch.output.filename",
2324
+ }
2325
+ ),
2326
+ )
2327
+ observed_source = _capture_optional_path_snapshot(source)
2328
+ observed_output = _capture_optional_path_snapshot(output)
2329
+ source_matches = _snapshots_match(observed_source, source_snapshot)
2330
+ output_matches = _snapshots_match(observed_output, output_snapshot_before)
2331
+ if source_matches and output_matches:
2332
+ return receipt
2333
+
2334
+ output_exists, current_output = _snapshot_path(output)
2335
+ source_exists, current_source = _snapshot_path(source)
2336
+ source_report = _source_preservation_snapshot(
2337
+ source,
2338
+ expected_revision=compiled["inputRevision"],
2339
+ before=source_bytes,
2340
+ after=current_source if source_exists else None,
2341
+ )
2342
+ if not source_matches:
2343
+ source_report.update(
2344
+ {
2345
+ "ok": False,
2346
+ "preserved": False,
2347
+ "identityPreserved": False,
2348
+ }
2349
+ )
2350
+ changed_target = "batch.input.filename" if not source_matches else "batch.output.filename"
2351
+ changed_code = "source_revision_mismatch" if not source_matches else "materialized_output_changed"
2352
+ unavailable = _not_produced("verification-input-changed")
2353
+ failure_verification = copy.deepcopy(core_verification)
2354
+ failure_verification.update(
2355
+ {
2356
+ "package": unavailable,
2357
+ "reopen": unavailable,
2358
+ "openSafety": unavailable,
2359
+ "bytePreservation": unavailable,
2360
+ "semanticDiff": unavailable,
2361
+ "realHancom": _not_produced("verification-input-changed"),
2362
+ }
2363
+ )
2364
+ return _unified_receipt(
2365
+ phase="verify",
2366
+ ok=False,
2367
+ source=source,
2368
+ output=output,
2369
+ dry_run=False,
2370
+ rolled_back=False,
2371
+ source_preservation=source_report,
2372
+ plan=compiled,
2373
+ core_verification=failure_verification,
2374
+ member_diff=unavailable,
2375
+ value_checks=[],
2376
+ output_revision=(
2377
+ _revision_bytes(current_output) if current_output is not None else None
2378
+ ),
2379
+ output_exists=output_exists,
2380
+ expected_output_revision=expected_output_revision,
2381
+ error={
2382
+ "code": changed_code,
2383
+ "recoverability": "retryable",
2384
+ "target": changed_target,
2385
+ },
2386
+ )
2387
+
2388
+
2389
+ def _specialized_allowed_members(source_bytes: bytes) -> set[str]:
2390
+ """Allow only document body/header XML parts for specialized text edits."""
2391
+
2392
+ with HwpxAgentDocument.open(source_bytes) as document:
2393
+ members = {str(section.part_name) for section in document.document.sections}
2394
+ members.update(
2395
+ str(header.part_name)
2396
+ for header in getattr(document.document, "headers", ())
2397
+ )
2398
+ if not members:
2399
+ raise ValueError("specialized form operation has no editable document members")
2400
+ return members
2401
+
2402
+
2403
+ def attach_common_form_receipt(
2404
+ result: dict[str, Any],
2405
+ *,
2406
+ operation: str,
2407
+ source: Path,
2408
+ output: Path,
2409
+ source_before: bytes,
2410
+ source_after: bytes | None = None,
2411
+ output_exists: bool | None = None,
2412
+ output_bytes: bytes | None = None,
2413
+ output_existed_before: bool | None = None,
2414
+ output_before: bytes | None = None,
2415
+ output_mode_before: int | None = None,
2416
+ output_mode_after: int | None = None,
2417
+ source_snapshot_preserved: bool | None = None,
2418
+ output_snapshot_preserved: bool | None = None,
2419
+ allowed_changed_members: set[str] | None = None,
2420
+ dry_run: bool = False,
2421
+ rolled_back: bool = False,
2422
+ rollback_required: bool = False,
2423
+ ) -> dict[str, Any]:
2424
+ """Attach snapshot-bound evidence to a specialized mutation result."""
2425
+
2426
+ payload = copy.deepcopy(result)
2427
+ payload.pop("_workspacePublication", None)
2428
+ source_is_output = source.resolve(strict=False) == output.resolve(strict=False)
2429
+ if source_after is None:
2430
+ source_exists, source_after = _snapshot_path(source)
2431
+ if not source_exists:
2432
+ source_after = None
2433
+ if output_exists is None:
2434
+ output_exists, output_bytes = _snapshot_path(output)
2435
+ if allowed_changed_members is None:
2436
+ allowed_changed_members = _specialized_allowed_members(source_before)
2437
+ target_bytes = None if dry_run else output_bytes
2438
+
2439
+ if source_is_output and not dry_run:
2440
+ source_report = {
2441
+ "ok": None,
2442
+ "filename": str(source),
2443
+ "expectedRevision": _revision_bytes(source_before),
2444
+ "actualRevision": (
2445
+ _revision_bytes(source_after) if source_after is not None else None
2446
+ ),
2447
+ "preserved": None,
2448
+ "status": "in-place-specialized-operation",
2449
+ }
2450
+ else:
2451
+ source_report = _source_preservation_snapshot(
2452
+ source,
2453
+ expected_revision=_revision_bytes(source_before),
2454
+ before=source_before,
2455
+ after=source_after,
2456
+ )
2457
+ if source_snapshot_preserved is False:
2458
+ source_report.update(
2459
+ {
2460
+ "ok": False,
2461
+ "preserved": False,
2462
+ "status": "source-identity-or-mode-changed",
2463
+ }
2464
+ )
2465
+
2466
+ if dry_run:
2467
+ member_diff = _not_produced("dry-run")
2468
+ elif target_bytes is not None:
2469
+ member_diff = _member_diff(
2470
+ source_before,
2471
+ target_bytes,
2472
+ allowed_changed_members=allowed_changed_members,
2473
+ )
2474
+ if member_diff.get("diffComputed"):
2475
+ declared = payload.get("changedParts")
2476
+ declared_members = (
2477
+ sorted(str(item) for item in declared)
2478
+ if isinstance(declared, list)
2479
+ and all(isinstance(item, str) for item in declared)
2480
+ else None
2481
+ )
2482
+ observed_members = sorted(
2483
+ str(item) for item in member_diff.get("changedMembers", [])
2484
+ )
2485
+ member_diff["declaredChangedMembers"] = declared_members
2486
+ member_diff["declaredMatchesObserved"] = (
2487
+ declared_members == observed_members
2488
+ if declared_members is not None
2489
+ else False
2490
+ )
2491
+ # Correct legacy result payloads from independent final-byte evidence.
2492
+ payload["changedParts"] = observed_members
2493
+ else:
2494
+ member_diff = _not_produced("output-unavailable")
2495
+
2496
+ # Normalize every public specialized response, including early/explicit
2497
+ # failures, to the same typed top-level envelope advertised by FastMCP.
2498
+ payload.setdefault("dryRun", dry_run)
2499
+ payload.setdefault("outputPath", str(output))
2500
+ payload.setdefault("changedParts", [])
2501
+ if not dry_run and member_diff.get("diffComputed") is not True:
2502
+ payload["changedParts"] = []
2503
+ payload.setdefault(
2504
+ "byteIdentical",
2505
+ bool(target_bytes is not None and target_bytes == source_before),
2506
+ )
2507
+ payload.setdefault("skipped", [])
2508
+ if not dry_run:
2509
+ payload["byteIdentical"] = bool(
2510
+ target_bytes is not None and target_bytes == source_before
2511
+ )
2512
+ if operation == "apply_table_ops":
2513
+ payload.setdefault("applied", [])
2514
+ payload.setdefault("zipMethod", "unavailable")
2515
+ payload.setdefault("transcript", None)
2516
+ elif operation == "apply_body_ops":
2517
+ payload.setdefault("transcript", [])
2518
+ elif operation == "apply_evalplan_fill":
2519
+ payload.setdefault("transcript", [])
2520
+ payload.setdefault("expectedSkeleton", None)
2521
+ payload.setdefault("contentReport", {})
2522
+ payload.setdefault("rubricNeedsReview", 0)
2523
+ payload.setdefault("needsReviewNotes", [])
2524
+
2525
+ open_safety = (
2526
+ _not_produced("dry-run-candidate-not-materialized")
2527
+ if dry_run
2528
+ else _open_safety_snapshot(target_bytes)
2529
+ if target_bytes is not None
2530
+ else _not_produced("output-unavailable")
2531
+ )
2532
+ verification = copy.deepcopy(payload.get("verificationReport") or {})
2533
+ verification.update(
2534
+ {
2535
+ "openSafety": open_safety,
2536
+ "package": copy.deepcopy(
2537
+ open_safety.get("validatePackage") or _not_produced()
2538
+ ),
2539
+ "reopen": copy.deepcopy(open_safety.get("reopen") or _not_produced()),
2540
+ "bytePreservation": member_diff,
2541
+ }
2542
+ )
2543
+ if dry_run:
2544
+ verification["candidateStatus"] = "not-materialized"
2545
+ payload["openSafety"] = copy.deepcopy(open_safety)
2546
+ payload["verificationReport"] = copy.deepcopy(verification)
2547
+ explicit_ok = payload.get("ok")
2548
+ source_ok = True if source_is_output else source_report.get("ok") is True
2549
+ evidence_ok = bool(
2550
+ dry_run
2551
+ or (
2552
+ target_bytes is not None
2553
+ and open_safety.get("ok") is True
2554
+ and member_diff.get("ok") is True
2555
+ )
2556
+ )
2557
+ receipt_ok = bool(
2558
+ not rolled_back and explicit_ok is not False and source_ok and evidence_ok
2559
+ )
2560
+ domain = {
2561
+ "ok": receipt_ok,
2562
+ "operation": operation,
2563
+ "status": (
2564
+ "rolled-back"
2565
+ if rolled_back
2566
+ else "specialized-semantics-preserved"
2567
+ if receipt_ok
2568
+ else "specialized-verification-failed"
2569
+ ),
2570
+ }
2571
+ receipt = _unified_receipt(
2572
+ phase="domain-apply",
2573
+ ok=receipt_ok,
2574
+ source=source,
2575
+ output=output,
2576
+ dry_run=dry_run,
2577
+ rolled_back=rolled_back,
2578
+ source_preservation=source_report,
2579
+ core_verification=verification,
2580
+ member_diff=member_diff,
2581
+ output_revision=(
2582
+ _revision_bytes(output_bytes) if output_bytes is not None else None
2583
+ ),
2584
+ output_exists=bool(output_exists),
2585
+ error=payload.get("error"),
2586
+ domain=domain,
2587
+ )
2588
+ receipt["operation"] = operation
2589
+ if output_existed_before is not None:
2590
+ rollback_required = bool(rollback_required or rolled_back or dry_run)
2591
+ rollback_preserved = bool(
2592
+ output_snapshot_preserved is not False
2593
+ and
2594
+ output_exists == output_existed_before
2595
+ and (
2596
+ not output_existed_before
2597
+ or (
2598
+ output_bytes == output_before
2599
+ and output_mode_after == output_mode_before
2600
+ )
2601
+ )
2602
+ )
2603
+ receipt["rollbackPreservation"] = {
2604
+ "required": rollback_required,
2605
+ "outputExistedBefore": output_existed_before,
2606
+ "preserved": rollback_preserved if rollback_required else None,
2607
+ }
2608
+ receipt = validate_form_verification_receipt(receipt)
2609
+ payload["ok"] = receipt_ok
2610
+ payload["rolledBack"] = rolled_back
2611
+ if not receipt["committed"]:
2612
+ payload["documentRevision"] = None
2613
+ payload["verificationReceipt"] = receipt
2614
+ payload.setdefault("compatibility", {})
2615
+ payload["compatibility"].update(
2616
+ {
2617
+ "status": domain["status"],
2618
+ "canonicalReceipt": FORM_VERIFICATION_RECEIPT_SCHEMA,
2619
+ }
2620
+ )
2621
+ return payload
2622
+
2623
+
2624
+ def run_specialized_form_operation(
2625
+ *,
2626
+ operation: str,
2627
+ source: Path,
2628
+ output: Path,
2629
+ dry_run: bool,
2630
+ execute: Callable[[], dict[str, Any]] | None = None,
2631
+ execute_with_guard: Callable[
2632
+ [
2633
+ WorkspaceOutputGuard | WorkspaceMissingParentGuard,
2634
+ Callable[[WorkspaceOutputGuard], None],
2635
+ ],
2636
+ dict[str, Any],
2637
+ ]
2638
+ | None = None,
2639
+ ) -> dict[str, Any]:
2640
+ """Serialize specialized verification and rollback with canonical writes."""
2641
+
2642
+ if (execute is None) == (execute_with_guard is None):
2643
+ raise ValueError("provide exactly one specialized execution callback")
2644
+ with _IDEMPOTENCY_LOCK:
2645
+ same_path = source.resolve(strict=False) == output.resolve(strict=False)
2646
+ source_before = _capture_path_snapshot(source)
2647
+ if not source_before.existed or source_before.data is None:
2648
+ raise FileNotFoundError(source)
2649
+ output_before = (
2650
+ source_before
2651
+ if same_path
2652
+ else _capture_optional_path_snapshot(output)
2653
+ )
2654
+ return _run_specialized_form_operation_locked(
2655
+ operation=operation,
2656
+ source=source,
2657
+ output=output,
2658
+ source_before=source_before,
2659
+ output_before=output_before,
2660
+ dry_run=dry_run,
2661
+ execute=execute,
2662
+ execute_with_guard=execute_with_guard,
2663
+ )
2664
+
2665
+
2666
+ def _run_specialized_form_operation_locked(
2667
+ *,
2668
+ operation: str,
2669
+ source: Path,
2670
+ output: Path,
2671
+ source_before: _PathSnapshot,
2672
+ output_before: _PathSnapshot,
2673
+ dry_run: bool,
2674
+ execute: Callable[[], dict[str, Any]] | None,
2675
+ execute_with_guard: Callable[
2676
+ [
2677
+ WorkspaceOutputGuard | WorkspaceMissingParentGuard,
2678
+ Callable[[WorkspaceOutputGuard], None],
2679
+ ],
2680
+ dict[str, Any],
2681
+ ]
2682
+ | None,
2683
+ ) -> dict[str, Any]:
2684
+ """Execute a specialized write and roll back only its exact publication."""
2685
+
2686
+ same_path = source.resolve(strict=False) == output.resolve(strict=False)
2687
+ source_bytes = source_before.data
2688
+ if source_bytes is None: # pragma: no cover - required source invariant
2689
+ raise FileNotFoundError(source)
2690
+ allowed_members = _specialized_allowed_members(source_bytes)
2691
+ workspace = WorkspaceResolver.from_environment()
2692
+ failure_preimage = _FailurePreimagePreserver(
2693
+ workspace=workspace,
2694
+ output=output_before.path,
2695
+ data=output_before.data if output_before.existed else None,
2696
+ mode=output_before.mode,
2697
+ )
2698
+ rollback_attempted = False
2699
+
2700
+ def snapshots() -> tuple[_PathSnapshot, _PathSnapshot]:
2701
+ source_snapshot = _capture_path_snapshot(source)
2702
+ output_snapshot = (
2703
+ source_snapshot
2704
+ if same_path
2705
+ else _capture_optional_path_snapshot(output)
2706
+ )
2707
+ return source_snapshot, output_snapshot
2708
+
2709
+ def restore_publication(publication: WorkspaceOutputGuard | None) -> bool:
2710
+ nonlocal rollback_attempted
2711
+ failure_preimage.preserve()
2712
+ if publication is None:
2713
+ return False
2714
+ rollback_attempted = True
2715
+ try:
2716
+ if output_before.existed:
2717
+ if output_before.data is None: # pragma: no cover - invariant
2718
+ return False
2719
+ workspace.atomic_publish_bytes(
2720
+ publication,
2721
+ output_before.data,
2722
+ mode=output_before.mode,
2723
+ )
2724
+ else:
2725
+ workspace.remove_output(publication)
2726
+ except (OSError, RuntimeError):
2727
+ return False
2728
+ if not output_before.existed:
2729
+ return workspace.cleanup_owned_parent_directories(publication)
2730
+ return True
2731
+
2732
+ def attach(
2733
+ raw: dict[str, Any],
2734
+ source_snapshot: _PathSnapshot,
2735
+ output_snapshot: _PathSnapshot,
2736
+ *,
2737
+ rolled_back: bool = False,
2738
+ ) -> dict[str, Any]:
2739
+ source_preserved = (
2740
+ True
2741
+ if same_path and not dry_run
2742
+ else _snapshots_match(source_snapshot, source_before)
2743
+ )
2744
+ return attach_common_form_receipt(
2745
+ raw,
2746
+ operation=operation,
2747
+ source=source,
2748
+ output=output,
2749
+ source_before=source_bytes,
2750
+ source_after=source_snapshot.data,
2751
+ output_exists=output_snapshot.existed,
2752
+ output_bytes=output_snapshot.data,
2753
+ output_existed_before=output_before.existed,
2754
+ output_before=output_before.data,
2755
+ output_mode_before=output_before.mode,
2756
+ output_mode_after=output_snapshot.mode,
2757
+ source_snapshot_preserved=source_preserved,
2758
+ output_snapshot_preserved=_snapshots_match(
2759
+ output_snapshot,
2760
+ output_before,
2761
+ identity=not rolled_back,
2762
+ ),
2763
+ allowed_changed_members=allowed_members,
2764
+ dry_run=dry_run,
2765
+ rolled_back=rolled_back,
2766
+ rollback_required=rollback_attempted,
2767
+ )
2768
+
2769
+ recorded_publication: WorkspaceOutputGuard | None = None
2770
+
2771
+ def record_publication(publication: WorkspaceOutputGuard) -> None:
2772
+ nonlocal recorded_publication
2773
+ if publication.path != output_before.path:
2774
+ raise RuntimeError("specialized writer published an unexpected output path")
2775
+ if recorded_publication is not None:
2776
+ raise RuntimeError("specialized writer published more than one candidate")
2777
+ recorded_publication = publication
2778
+
2779
+ try:
2780
+ output_precondition = output_before.guard or output_before.missing_guard
2781
+ if output_precondition is None: # pragma: no cover - snapshot invariant
2782
+ raise RuntimeError("specialized output precondition is unavailable")
2783
+ if not dry_run:
2784
+ failure_preimage.reserve()
2785
+ result = (
2786
+ execute_with_guard(output_precondition, record_publication)
2787
+ if execute_with_guard is not None
2788
+ else execute()
2789
+ if execute is not None
2790
+ else None
2791
+ )
2792
+ except BaseException:
2793
+ failure_preimage.preserve()
2794
+ restore_publication(recorded_publication)
2795
+ raise
2796
+ if not isinstance(result, dict):
2797
+ failure_preimage.preserve()
2798
+ restore_publication(recorded_publication)
2799
+ raise TypeError(f"{operation} returned a non-object result")
2800
+
2801
+ publication_value = result.pop("_workspacePublication", None)
2802
+ publication = recorded_publication
2803
+ if publication is None:
2804
+ publication = (
2805
+ publication_value
2806
+ if isinstance(publication_value, WorkspaceOutputGuard)
2807
+ and publication_value.path == output_before.path
2808
+ else None
2809
+ )
2810
+ try:
2811
+ candidate_source, candidate_output = snapshots()
2812
+ except BaseException:
2813
+ failure_preimage.preserve()
2814
+ restore_publication(publication)
2815
+ raise
2816
+ source_preserved = _snapshots_match(candidate_source, source_before)
2817
+ output_preserved = _snapshots_match(candidate_output, output_before)
2818
+ candidate_owned = _snapshot_matches_publication(candidate_output, publication)
2819
+ source_mutated = (dry_run or not same_path) and not source_preserved
2820
+ dry_run_mutated = dry_run and not (source_preserved and output_preserved)
2821
+ unowned_output = bool(
2822
+ not dry_run
2823
+ and not output_preserved
2824
+ and not candidate_owned
2825
+ )
2826
+ explicit_failure = result.get("ok") is False
2827
+
2828
+ if explicit_failure or source_mutated or dry_run_mutated or unowned_output:
2829
+ failure_preimage.preserve()
2830
+ restored = restore_publication(publication if candidate_owned else None)
2831
+ final_source, final_output = snapshots()
2832
+ failed = copy.deepcopy(result)
2833
+ failed["ok"] = False
2834
+ if dry_run_mutated:
2835
+ failed["error"] = {
2836
+ "code": "dry_run_mutated",
2837
+ "recoverability": "terminal",
2838
+ "target": "output",
2839
+ }
2840
+ elif source_mutated:
2841
+ failed["error"] = {
2842
+ "code": "source_mutated",
2843
+ "recoverability": "retryable",
2844
+ "target": "source",
2845
+ }
2846
+ elif unowned_output:
2847
+ failed["error"] = {
2848
+ "code": "specialized_output_changed",
2849
+ "recoverability": "retryable",
2850
+ "target": "output",
2851
+ }
2852
+ rolled_back = bool(
2853
+ restored
2854
+ and _snapshots_match(final_output, output_before, identity=False)
2855
+ )
2856
+ return attach(
2857
+ failed,
2858
+ final_source,
2859
+ final_output,
2860
+ rolled_back=rolled_back,
2861
+ )
2862
+
2863
+ try:
2864
+ attached = attach(result, candidate_source, candidate_output)
2865
+ except BaseException:
2866
+ failure_preimage.preserve()
2867
+ restore_publication(publication if candidate_owned else None)
2868
+ raise
2869
+
2870
+ try:
2871
+ final_source, final_output = snapshots()
2872
+ except BaseException:
2873
+ failure_preimage.preserve()
2874
+ restore_publication(publication if candidate_owned else None)
2875
+ raise
2876
+ source_matches = (
2877
+ _snapshot_matches_publication(final_source, publication)
2878
+ if same_path and publication is not None
2879
+ else _snapshots_match(final_source, source_before)
2880
+ )
2881
+ output_matches = (
2882
+ _snapshot_matches_publication(final_output, publication)
2883
+ if publication is not None
2884
+ else _snapshots_match(final_output, output_before)
2885
+ )
2886
+ if not source_matches or not output_matches:
2887
+ failure_preimage.preserve()
2888
+ restored = restore_publication(
2889
+ publication
2890
+ if output_matches and publication is not None
2891
+ else None
2892
+ )
2893
+ final_source, final_output = snapshots()
2894
+ failed = copy.deepcopy(result)
2895
+ failed["ok"] = False
2896
+ failed["error"] = {
2897
+ "code": (
2898
+ "source_mutated" if not source_matches else "specialized_output_changed"
2899
+ ),
2900
+ "recoverability": "retryable",
2901
+ "target": "source" if not source_matches else "output",
2902
+ }
2903
+ rolled_back = bool(
2904
+ restored
2905
+ and _snapshots_match(final_output, output_before, identity=False)
2906
+ )
2907
+ return attach(
2908
+ failed,
2909
+ final_source,
2910
+ final_output,
2911
+ rolled_back=rolled_back,
2912
+ )
2913
+
2914
+ if not dry_run and not attached["verificationReceipt"]["ok"]:
2915
+ failure_preimage.preserve()
2916
+ restored = restore_publication(publication if output_matches else None)
2917
+ final_source, final_output = snapshots()
2918
+ failed = copy.deepcopy(result)
2919
+ failed["ok"] = False
2920
+ failed["error"] = {
2921
+ "code": (
2922
+ "specialized_verification_failed"
2923
+ if restored
2924
+ else "specialized_output_changed"
2925
+ ),
2926
+ "recoverability": "terminal" if restored else "retryable",
2927
+ "target": "output",
2928
+ }
2929
+ rolled_back = bool(
2930
+ restored
2931
+ and _snapshots_match(final_output, output_before, identity=False)
2932
+ )
2933
+ return attach(
2934
+ failed,
2935
+ final_source,
2936
+ final_output,
2937
+ rolled_back=rolled_back,
2938
+ )
2939
+ if dry_run:
2940
+ return attached
2941
+
2942
+ cleanup_ok = failure_preimage.cleanup_after_success()
2943
+ try:
2944
+ final_source, final_output = snapshots()
2945
+ except BaseException:
2946
+ failure_preimage.preserve()
2947
+ restore_publication(publication if output_matches else None)
2948
+ raise
2949
+ source_matches = (
2950
+ _snapshot_matches_publication(final_source, publication)
2951
+ if same_path and publication is not None
2952
+ else _snapshots_match(final_source, source_before)
2953
+ )
2954
+ output_matches = (
2955
+ _snapshot_matches_publication(final_output, publication)
2956
+ if publication is not None
2957
+ else _snapshots_match(final_output, output_before)
2958
+ )
2959
+ if cleanup_ok and source_matches and output_matches:
2960
+ return attached
2961
+
2962
+ failure_preimage.preserve()
2963
+ restored = restore_publication(
2964
+ publication if output_matches and publication is not None else None
2965
+ )
2966
+ final_source, final_output = snapshots()
2967
+ failed = copy.deepcopy(result)
2968
+ failed["ok"] = False
2969
+ failed["error"] = {
2970
+ "code": (
2971
+ "source_mutated" if not source_matches else "specialized_output_changed"
2972
+ ),
2973
+ "recoverability": "retryable",
2974
+ "target": "source" if not source_matches else "output",
2975
+ }
2976
+ rolled_back = bool(
2977
+ restored
2978
+ and _snapshots_match(final_output, output_before, identity=False)
2979
+ )
2980
+ return attach(
2981
+ failed,
2982
+ final_source,
2983
+ final_output,
2984
+ rolled_back=rolled_back,
2985
+ )
2986
+
2987
+
2988
+ __all__ = [
2989
+ "BodyAnchorTarget",
2990
+ "CanonicalPathTarget",
2991
+ "LabelCellTarget",
2992
+ "MixedFormApplyInput",
2993
+ "MixedFormCompiledPlanInput",
2994
+ "MixedFormOperation",
2995
+ "MixedFormPlanInput",
2996
+ "MixedFormResolution",
2997
+ "MixedFormTarget",
2998
+ "NativeFieldTarget",
2999
+ "FORM_VERIFICATION_RECEIPT_SCHEMA",
3000
+ "FormVerificationReceipt",
3001
+ "analyze_mixed_form_plan",
3002
+ "apply_canonical_mixed_form_plan",
3003
+ "attach_common_form_receipt",
3004
+ "run_specialized_form_operation",
3005
+ "validate_form_verification_receipt",
3006
+ "verify_canonical_mixed_form_plan",
3007
+ ]