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.
- hwpx_automation/__init__.py +61 -0
- hwpx_automation/__init__.pyi +27 -0
- hwpx_automation/__main__.py +8 -0
- hwpx_automation/agent_document.py +392 -0
- hwpx_automation/api.py +136 -0
- hwpx_automation/blind_eval.py +407 -0
- hwpx_automation/capabilities.py +110 -0
- hwpx_automation/compat.py +48 -0
- hwpx_automation/configuration.py +60 -0
- hwpx_automation/core/__init__.py +2 -0
- hwpx_automation/core/content.py +762 -0
- hwpx_automation/core/context.py +111 -0
- hwpx_automation/core/diff.py +53 -0
- hwpx_automation/core/document.py +37 -0
- hwpx_automation/core/formatting.py +513 -0
- hwpx_automation/core/handles.py +24 -0
- hwpx_automation/core/locations.py +205 -0
- hwpx_automation/core/locator.py +162 -0
- hwpx_automation/core/plan.py +680 -0
- hwpx_automation/core/resources.py +42 -0
- hwpx_automation/core/search.py +296 -0
- hwpx_automation/core/transactions.py +434 -0
- hwpx_automation/core/txn.py +48 -0
- hwpx_automation/document_state.py +95 -0
- hwpx_automation/errors.py +174 -0
- hwpx_automation/execution_lock.py +15 -0
- hwpx_automation/fastmcp_adapter.py +672 -0
- hwpx_automation/form_fill.py +1177 -0
- hwpx_automation/form_output_models.py +223 -0
- hwpx_automation/handlers/__init__.py +2 -0
- hwpx_automation/handlers/_shared.py +377 -0
- hwpx_automation/handlers/agent_document.py +257 -0
- hwpx_automation/handlers/authoring.py +750 -0
- hwpx_automation/handlers/content_edit.py +1078 -0
- hwpx_automation/handlers/form_fill.py +607 -0
- hwpx_automation/handlers/layout_style.py +660 -0
- hwpx_automation/handlers/quality_render.py +566 -0
- hwpx_automation/handlers/read_export.py +1295 -0
- hwpx_automation/handlers/specialized.py +624 -0
- hwpx_automation/handlers/tracked_changes.py +589 -0
- hwpx_automation/handlers/workflow.py +105 -0
- hwpx_automation/hwp_converter.py +227 -0
- hwpx_automation/hwp_support.py +94 -0
- hwpx_automation/hwpx_ops.py +1439 -0
- hwpx_automation/identity.json +263 -0
- hwpx_automation/identity.py +18 -0
- hwpx_automation/ingest_adapters.py +85 -0
- hwpx_automation/markdown_plan.py +216 -0
- hwpx_automation/mcp_cli.py +29 -0
- hwpx_automation/metadata/tools_meta.py +40 -0
- hwpx_automation/mixed_form.py +3007 -0
- hwpx_automation/mutation_models.py +401 -0
- hwpx_automation/network_policy.py +232 -0
- hwpx_automation/office/__init__.py +14 -0
- hwpx_automation/office/agent/__init__.py +125 -0
- hwpx_automation/office/agent/_batch_verification.py +383 -0
- hwpx_automation/office/agent/blueprint/__init__.py +58 -0
- hwpx_automation/office/agent/blueprint/bundle.py +282 -0
- hwpx_automation/office/agent/blueprint/catalog.py +136 -0
- hwpx_automation/office/agent/blueprint/dump.py +520 -0
- hwpx_automation/office/agent/blueprint/mapping.py +312 -0
- hwpx_automation/office/agent/blueprint/model.py +722 -0
- hwpx_automation/office/agent/blueprint/native.py +621 -0
- hwpx_automation/office/agent/blueprint/replay.py +622 -0
- hwpx_automation/office/agent/catalog.py +252 -0
- hwpx_automation/office/agent/cli.py +647 -0
- hwpx_automation/office/agent/commands.py +1383 -0
- hwpx_automation/office/agent/document.py +801 -0
- hwpx_automation/office/agent/form_plan.py +1760 -0
- hwpx_automation/office/agent/model.py +808 -0
- hwpx_automation/office/agent/path.py +155 -0
- hwpx_automation/office/agent/query.py +230 -0
- hwpx_automation/office/agent/story.py +207 -0
- hwpx_automation/office/authoring/__init__.py +3542 -0
- hwpx_automation/office/authoring/advanced_generators.py +154 -0
- hwpx_automation/office/authoring/builder/__init__.py +52 -0
- hwpx_automation/office/authoring/builder/core.py +996 -0
- hwpx_automation/office/authoring/builder/report.py +195 -0
- hwpx_automation/office/authoring/design/__init__.py +30 -0
- hwpx_automation/office/authoring/design/_support.py +144 -0
- hwpx_automation/office/authoring/design/composer.py +282 -0
- hwpx_automation/office/authoring/design/harvest.py +305 -0
- hwpx_automation/office/authoring/design/plan.py +69 -0
- hwpx_automation/office/authoring/design/profile.py +88 -0
- hwpx_automation/office/authoring/design/profiles/application_form/fragments/body.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/application_form/fragments/heading.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/application_form/fragments/info_table.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/application_form/fragments/title.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/application_form/profile.json +25 -0
- hwpx_automation/office/authoring/design/profiles/application_form/template.hwpx +0 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/fragments/body.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/fragments/heading.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/fragments/title.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/profile.json +24 -0
- hwpx_automation/office/authoring/design/profiles/home_notice/template.hwpx +0 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/fragments/body.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/fragments/heading.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/fragments/info_table.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/fragments/title.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/profile.json +25 -0
- hwpx_automation/office/authoring/design/profiles/official_notice/template.hwpx +0 -0
- hwpx_automation/office/authoring/design/profiles/report/fragments/body.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/report/fragments/heading.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/report/fragments/info_table.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/report/fragments/title.xml +1 -0
- hwpx_automation/office/authoring/design/profiles/report/profile.json +25 -0
- hwpx_automation/office/authoring/design/profiles/report/template.hwpx +0 -0
- hwpx_automation/office/authoring/design/validator.py +107 -0
- hwpx_automation/office/authoring/presets/__init__.py +22 -0
- hwpx_automation/office/authoring/presets/proposal.py +538 -0
- hwpx_automation/office/authoring/report_parser.py +141 -0
- hwpx_automation/office/authoring/style_profile.py +437 -0
- hwpx_automation/office/authoring/template_analyzer.py +657 -0
- hwpx_automation/office/compliance/__init__.py +38 -0
- hwpx_automation/office/compliance/official_lint.py +478 -0
- hwpx_automation/office/compliance/pii.py +388 -0
- hwpx_automation/office/document_ops/__init__.py +13 -0
- hwpx_automation/office/document_ops/comparison.py +62 -0
- hwpx_automation/office/document_ops/mail_merge.py +73 -0
- hwpx_automation/office/document_ops/redline.py +35 -0
- hwpx_automation/office/evalplan/__init__.py +36 -0
- hwpx_automation/office/evalplan/runtime.py +2762 -0
- hwpx_automation/office/exam/__init__.py +44 -0
- hwpx_automation/office/exam/compose.py +282 -0
- hwpx_automation/office/exam/ir.py +44 -0
- hwpx_automation/office/exam/measure.py +163 -0
- hwpx_automation/office/exam/parser.py +150 -0
- hwpx_automation/office/exam/profile.py +123 -0
- hwpx_automation/office/form_fill/__init__.py +66 -0
- hwpx_automation/office/form_fill/classification.py +108 -0
- hwpx_automation/office/form_fill/fill_residue.py +242 -0
- hwpx_automation/office/form_fill/fit/__init__.py +36 -0
- hwpx_automation/office/form_fill/fit/apply.py +24 -0
- hwpx_automation/office/form_fill/fit/engine.py +24 -0
- hwpx_automation/office/form_fill/fit/measure.py +50 -0
- hwpx_automation/office/form_fill/fit/policy.py +28 -0
- hwpx_automation/office/form_fill/fit/report.py +28 -0
- hwpx_automation/office/form_fill/fit/seal.py +457 -0
- hwpx_automation/office/form_fill/fit/wordbox.py +1343 -0
- hwpx_automation/office/form_fill/guidance.py +704 -0
- hwpx_automation/office/form_fill/quality.py +961 -0
- hwpx_automation/office/form_fill/split_run.py +333 -0
- hwpx_automation/office/form_fill/template_formfit.py +656 -0
- hwpx_automation/office/house_style/__init__.py +196 -0
- hwpx_automation/office/house_style/composition.py +68 -0
- hwpx_automation/office/house_style/data/bank.json +625 -0
- hwpx_automation/office/house_style/data/genres.json +43 -0
- hwpx_automation/office/quality/__init__.py +14 -0
- hwpx_automation/office/quality/page_guard.py +277 -0
- hwpx_automation/office/rendering/__init__.py +145 -0
- hwpx_automation/office/rendering/_hancom_open_rate.ps1 +374 -0
- hwpx_automation/office/rendering/_refresh_hwpx_mac.applescript +162 -0
- hwpx_automation/office/rendering/_render_hwpx.ps1 +72 -0
- hwpx_automation/office/rendering/_render_hwpx_mac.applescript +249 -0
- hwpx_automation/office/rendering/block_splits.py +76 -0
- hwpx_automation/office/rendering/detectors.py +151 -0
- hwpx_automation/office/rendering/diff.py +153 -0
- hwpx_automation/office/rendering/fixture_corpus.py +215 -0
- hwpx_automation/office/rendering/oracle.py +909 -0
- hwpx_automation/office/rendering/page_qa.py +245 -0
- hwpx_automation/office/rendering/qa_contracts.py +293 -0
- hwpx_automation/office/rendering/qa_metrics.py +241 -0
- hwpx_automation/office/rendering/worker.py +290 -0
- hwpx_automation/office/utilities/__init__.py +12 -0
- hwpx_automation/office/utilities/table_compute.py +477 -0
- hwpx_automation/ops_services/__init__.py +1 -0
- hwpx_automation/ops_services/_border_fill.py +283 -0
- hwpx_automation/ops_services/composition.py +55 -0
- hwpx_automation/ops_services/content_layout.py +322 -0
- hwpx_automation/ops_services/context.py +213 -0
- hwpx_automation/ops_services/form_fields.py +557 -0
- hwpx_automation/ops_services/media.py +178 -0
- hwpx_automation/ops_services/memo_style.py +477 -0
- hwpx_automation/ops_services/package_validation.py +166 -0
- hwpx_automation/ops_services/planning.py +201 -0
- hwpx_automation/ops_services/preview_export.py +585 -0
- hwpx_automation/ops_services/read_query.py +601 -0
- hwpx_automation/ops_services/save_policy.py +604 -0
- hwpx_automation/ops_services/tables.py +539 -0
- hwpx_automation/ops_services/transactions.py +616 -0
- hwpx_automation/preview_output_models.py +69 -0
- hwpx_automation/public-modules.json +206 -0
- hwpx_automation/py.typed +1 -0
- hwpx_automation/quality.py +351 -0
- hwpx_automation/quality_generation.py +725 -0
- hwpx_automation/runtime.py +321 -0
- hwpx_automation/runtime_services.py +100 -0
- hwpx_automation/server.py +259 -0
- hwpx_automation/storage.py +747 -0
- hwpx_automation/tool_bindings.py +170 -0
- hwpx_automation/tool_contract.py +982 -0
- hwpx_automation/upstream.py +755 -0
- hwpx_automation/utils/__init__.py +2 -0
- hwpx_automation/utils/helpers.py +29 -0
- hwpx_automation/visual_qa.py +667 -0
- hwpx_automation/workflow/__init__.py +55 -0
- hwpx_automation/workflow/adapters.py +482 -0
- hwpx_automation/workflow/dispatcher.py +213 -0
- hwpx_automation/workflow/models.py +243 -0
- hwpx_automation/workflow/policy.py +198 -0
- hwpx_automation/workflow/render_contracts.py +173 -0
- hwpx_automation/workflow/render_metrics.py +196 -0
- hwpx_automation/workflow/render_queue.py +482 -0
- hwpx_automation/workflow/render_security.py +172 -0
- hwpx_automation/workflow/render_transport.py +369 -0
- hwpx_automation/workflow/rendering.py +206 -0
- hwpx_automation/workflow/service.py +758 -0
- hwpx_automation/workflow/state_machine.py +65 -0
- hwpx_automation/workflow/store.py +747 -0
- hwpx_automation/workspace.py +1694 -0
- python_hwpx_automation-6.0.3.dist-info/METADATA +279 -0
- python_hwpx_automation-6.0.3.dist-info/RECORD +217 -0
- python_hwpx_automation-6.0.3.dist-info/WHEEL +5 -0
- python_hwpx_automation-6.0.3.dist-info/entry_points.txt +3 -0
- python_hwpx_automation-6.0.3.dist-info/licenses/LICENSE +178 -0
- python_hwpx_automation-6.0.3.dist-info/licenses/NOTICE +14 -0
- 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
|
+
]
|