140 lines
6.8 KiB
Diff
140 lines
6.8 KiB
Diff
diff --git a/approval_engine/execution_confirmation.py b/approval_engine/execution_confirmation.py
|
|
index 2fef8b9..fa7daaa 100644
|
|
--- a/approval_engine/execution_confirmation.py
|
|
+++ b/approval_engine/execution_confirmation.py
|
|
@@ -8,7 +8,7 @@ from datetime import datetime
|
|
from typing import Any
|
|
from urllib.parse import quote
|
|
|
|
-from .binding import DIGEST_RE, canonical_binding
|
|
+from .binding import DIGEST_RE, binding_digest
|
|
from .pep import ApprovalHTTPClient, ApprovalProtocolError, _ID_RE
|
|
|
|
|
|
@@ -71,7 +71,7 @@ class ExecutionConfirmationClient(ApprovalHTTPClient):
|
|
if not isinstance(expected_pdp_digest, str) or not DIGEST_RE.fullmatch(expected_pdp_digest):
|
|
raise ApprovalProtocolError("exact approval-free PDP digest is required")
|
|
try:
|
|
- expected = canonical_binding(expected_binding)
|
|
+ expected = binding_digest(expected_binding)
|
|
except (TypeError, ValueError, KeyError):
|
|
raise ApprovalProtocolError("expected act binding is invalid") from None
|
|
obj = self._request("GET", f"/v1/approvals/{quote(approval_id, safe='')}")
|
|
@@ -81,7 +81,11 @@ class ExecutionConfirmationClient(ApprovalHTTPClient):
|
|
raise ApprovalProtocolError("consumption object is not a decision")
|
|
served = obj.get("binding")
|
|
try:
|
|
- same_act = isinstance(served, dict) and canonical_binding(served) == expected
|
|
+ same_act = (
|
|
+ isinstance(served, dict)
|
|
+ and binding_digest(served) == expected
|
|
+ and served.get("digest") == expected
|
|
+ )
|
|
except (TypeError, ValueError, KeyError):
|
|
same_act = False
|
|
if not same_act or served.get("pdp_digest") != expected_pdp_digest:
|
|
diff --git a/tests/test_execution_confirmation.py b/tests/test_execution_confirmation.py
|
|
index fed3f83..9892ff3 100644
|
|
--- a/tests/test_execution_confirmation.py
|
|
+++ b/tests/test_execution_confirmation.py
|
|
@@ -127,3 +127,50 @@ def test_denied_read_never_represents_confirmation(engine, app, tmp_path):
|
|
raise HTTPError(req.full_url, status, "refused", {}, None)
|
|
with pytest.raises(ApprovalProtocolError, match="unauthorized"):
|
|
confirmer(client(tmp_path, opener), obj)
|
|
+
|
|
+
|
|
+@pytest.mark.parametrize("original,substitute", [(True, 1), (False, 0), (1, 1.0), (1, True)])
|
|
+@pytest.mark.parametrize("recompute_served_digest", [False, True])
|
|
+def test_confirmation_refuses_python_equal_json_distinct_target(
|
|
+ engine, tmp_path, original, substitute, recompute_served_digest
|
|
+):
|
|
+ expected = binding(target={"nested": [{"value": original}]})
|
|
+ obj = engine.create(expected, validity(), pdp_digest=FREE, pdp_path=True,
|
|
+ human_control=True)
|
|
+ engine.add_entry(obj.id, "human:founder", principal_type="human")
|
|
+ engine.consume(obj.id, FULL, decision_id="decision:original")
|
|
+ result = engine.get(obj.id).as_dict()
|
|
+ result["binding"]["target"] = {"nested": [{"value": substitute}]}
|
|
+ if recompute_served_digest:
|
|
+ from approval_engine.binding import binding_digest
|
|
+ result["binding"]["digest"] = binding_digest(result["binding"])
|
|
+ assert result["binding"]["target"] == expected["target"]
|
|
+ with pytest.raises(ApprovalProtocolError, match="binding does not match"):
|
|
+ client(tmp_path, lambda *_a, **_k: Response(result)).confirm_consumption(
|
|
+ obj.id, FULL, "decision:original", expected_binding=expected,
|
|
+ expected_pdp_digest=FREE)
|
|
+
|
|
+
|
|
+@pytest.mark.parametrize("digest", [None, "sha256:" + "00" * 32, True])
|
|
+def test_confirmation_requires_served_native_digest_correspondence(engine, tmp_path, digest):
|
|
+ obj = consumed(engine)
|
|
+ result = engine.get(obj.id).as_dict()
|
|
+ if digest is None:
|
|
+ result["binding"].pop("digest")
|
|
+ else:
|
|
+ result["binding"]["digest"] = digest
|
|
+ with pytest.raises(ApprovalProtocolError, match="binding does not match"):
|
|
+ confirmer(client(tmp_path, lambda *_a, **_k: Response(result)), obj)
|
|
+
|
|
+
|
|
+def test_confirmation_accepts_nested_json_types_and_key_order(engine, tmp_path):
|
|
+ expected = binding(target={"b": [True, False, 1, 1.0], "a": {"unicode": "ä"}})
|
|
+ obj = engine.create(expected, validity(), pdp_digest=FREE, pdp_path=True,
|
|
+ human_control=True)
|
|
+ engine.add_entry(obj.id, "human:founder", principal_type="human")
|
|
+ engine.consume(obj.id, FULL, decision_id="decision:original")
|
|
+ result = engine.get(obj.id).as_dict()
|
|
+ result["binding"]["target"] = dict(reversed(list(expected["target"].items())))
|
|
+ assert client(tmp_path, lambda *_a, **_k: Response(result)).confirm_consumption(
|
|
+ obj.id, FULL, "decision:original", expected_binding=expected,
|
|
+ expected_pdp_digest=FREE)["approval_id"] == obj.id
|
|
diff --git a/workplans/ADHOC-2026-10-16.md b/workplans/ADHOC-2026-10-16.md
|
|
new file mode 100644
|
|
index 0000000..cd19170
|
|
--- /dev/null
|
|
+++ b/workplans/ADHOC-2026-10-16.md
|
|
@@ -0,0 +1,44 @@
|
|
+---
|
|
+id: APPROVAL-WP-ADHOC-2026-10-16
|
|
+type: workplan
|
|
+title: "Repair exact consume-confirmation native binding comparison"
|
|
+domain: infotech
|
|
+repo: approval-engine
|
|
+status: finished
|
|
+flavor: implementation
|
|
+owner: codex
|
|
+topic_slug: netkingdom
|
|
+created: "2026-10-16"
|
|
+updated: "2026-10-16"
|
|
+---
|
|
+
|
|
+## Refuse Python-equal JSON-distinct consumed act bindings
|
|
+
|
|
+```task
|
|
+id: APPROVAL-WP-ADHOC-2026-10-16-T01
|
|
+status: done
|
|
+priority: high
|
|
+```
|
|
+
|
|
+Bounded source repair demanded by the CUST-WP-0080 strict execution-seam proof.
|
|
+INTENT basis: exact binding and authenticated consumption evidence; no authority
|
|
+or native grant is added. Existing canonical native-five-field digest is reused.
|
|
+ExecutionConfirmationClient now recomputes the served and expected digests and
|
|
+requires the served digest field to correspond, replacing Python dict equality
|
|
+which accepted nested True/1, False/0 and integer/float substitutions.
|
|
+
|
|
+Before repair seven regression cases wrongly confirmed (four target type
|
|
+substitutions and three absent/wrong/type-invalid served digest fields); valid
|
|
+nested types with reordered keys passed. The repair retains this failure history.
|
|
+Twelve added regression cases cover exact native JSON correspondence, including
|
|
+substitutions with both retained and recomputed served digests, without
|
|
+changing the native digest algorithm, request/PDP digest contract or API schema.
|
|
+Canonical integration/publication and the Platform mediator's final provider
|
|
+client source pin remain with the coordinator; preparation does not claim them.
|
|
+
|
|
+Existing APPROVAL-WP-0004 closure and generated identifiers are untouched.
|
|
+APPROVAL-WP-0002-T01/T04/T05 remain existing live native acceptance obligations;
|
|
+none is promoted or completed here. No production auth, approval mutation,
|
|
+credential, deployment or backend operation was used. This completed low-risk
|
|
+local source repair introduces no residual native obligation beyond those
|
|
+existing records and the coordinator's integration of this exact artifact.
|