Skip to content

Commit a900779

Browse files
mikemolinetclaude
andcommitted
cues.fire: auto_verify + sha256 constant-cost path (Phase 2 follow-on B + bonus)
Mike body-verify directive 2026-05-11. cue-pm unblocked these follow-ons at ~23:48Z after primary's substrate-fix (body_received flat-string + body_received_sha256 hashing body field bytes) verified on prod. Changes: cueapi/resources/cues.py: - New ``auto_verify=True`` kwarg on ``CuesResource.fire``. Default verify-on (symmetric with MessagesResource.send). When set: - sends X-CueAPI-Verify-Echo: true request header - pre-computes sha256(canonical-JSON-of-body) client-side - on response: extracts body_received (defensive isinstance for both string post-fix shape AND dict pre-fix shape — sibling of PR #40 hotfix pattern) - if body_received_sha256 present: constant-cost hex compare first, fall back to string compare on sha drift (JSON-canonicalization differences could cause spurious sha mismatch) - on confirmed drift: raises BodyVerifyMismatchError with sent_body, received_body, first_divergence_byte, message_id (= execution_id for fire) attributes. tests/test_cues_resource.py: - Existing 3 TestFire tests updated to expect the new headers kwarg in the post call (X-CueAPI-Verify-Echo: true is now default-on). - New TestFireAutoVerify class with 5 tests pinning: - Default adds verify-echo header - --auto_verify=False omits header - Byte-identical sha256 match passes - No-op when substrate omits echo fields (pre-Layer-1 backward-compat) - Opt-out skips verify even if substrate echoes All 39 tests (messages + cues resource units) pass. CHANGELOG entry under [Unreleased]. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent aae27eb commit a900779

3 files changed

Lines changed: 179 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ All notable changes to cueapi-sdk will be documented here.
66

77
### Added
88

9+
- **`client.cues.fire(auto_verify=True)` body-verify mirror (Mike body-verify directive 2026-05-11).** Parallel to `MessagesResource.send` auto_verify. Default verify-on. Sends `X-CueAPI-Verify-Echo: true` request header; substrate echoes received body bytes under `body_received` (str) + SHA256 hex under `body_received_sha256`. SDK computes sha256 of canonical request body + compares hex equality (constant-cost verify path), falling back to full string compare on hash mismatch. On drift raises `BodyVerifyMismatchError` with diagnostic attributes including `message_id` (= execution_id for fire). `auto_verify=False` opts out. Backward-compat: pre-Layer-1 substrate omits the echo fields → no-op + success. Defensive isinstance handles both dict (pre-substrate-fix) and string (post-fix 2026-05-11 ~23:48Z) wire shapes.
910
- **`client.messages.send(auto_verify=True)` body-verify defense (Mike directive 2026-05-11).** New `auto_verify` kwarg, default `True`. When set, the SDK sends `X-CueAPI-Verify-Echo: true` request header. Substrate-side (Phase 1; cueapi-core's lane) echoes the body it received back in the response under `body_received`. SDK diffs sent vs received and raises `BodyVerifyMismatchError` on drift (with `sent_body`, `received_body`, `first_divergence_byte`, `message_id` attributes for programmatic recovery / diagnostic output). Catches the caller-side shell-expansion bug class where `body=f"... {dynamic_var} ..."` or worse `body=os.popen(...)` silently mutated body content upstream. Opt-out via `auto_verify=False` for perf-sensitive flows. Backward-compat: SDK no-ops when substrate omits the echo field (pre-Layer-1 behavior unchanged). New helper: `cueapi.exceptions.first_divergence_byte(a, b)` returns the byte index of the first differing position (pure function; re-usable cross-SDK).
1011
- `client.cues.bulk_delete(ids)` — delete up to 100 cues in a single call. Returns `{"deleted": [...], "skipped": [...]}`. Per-ID atomic, not batch atomic. Sends `X-Confirm-Destructive: true` header automatically. Wraps `POST /v1/cues/bulk-delete` (cueapi #650). Parity port of cueapi-cli #46. Raises `ValueError` client-side on empty list or > 100 IDs.
1112

cueapi/resources/cues.py

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,26 @@
22

33
from __future__ import annotations
44

5+
import hashlib
6+
import json
57
from datetime import datetime
68
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union
79

10+
from cueapi.exceptions import BodyVerifyMismatchError, first_divergence_byte
811
from cueapi.models.cue import Cue, CueList
912

1013
if TYPE_CHECKING:
1114
from cueapi.client import CueAPI
1215

16+
# Phase 2 of body-verify defense in depth (Mike directive 2026-05-11).
17+
# Substrate echoes the request body bytes back in body_received (str)
18+
# + body_received_sha256 (64-hex SHA256 of the same bytes) when the
19+
# X-CueAPI-Verify-Echo: true request header is set. Field names locked
20+
# during joint design (CMA + cueapi-primary) on Dock workspace
21+
# cue-message-silent-corruption-substrate-design-2026-05-11.
22+
_VERIFY_ECHO_BODY_FIELD = "body_received"
23+
_VERIFY_ECHO_SHA256_FIELD = "body_received_sha256"
24+
1325

1426
class CuesResource:
1527
"""Manage cues (scheduled tasks)."""
@@ -273,6 +285,7 @@ def fire(
273285
send_at: Optional[Union[str, datetime]] = None,
274286
exit_criteria: Optional[List[str]] = None,
275287
idempotency_key: Optional[str] = None,
288+
auto_verify: bool = True,
276289
) -> Dict[str, Any]:
277290
"""Fire an existing cue, optionally overriding payload + scheduling.
278291
@@ -344,4 +357,83 @@ def fire(
344357
if idempotency_key is not None:
345358
body["idempotency_key"] = idempotency_key
346359

347-
return self._client._post(f"/v1/cues/{cue_id}/fire", json=body)
360+
# Phase 2 of body-verify defense in depth (Mike directive 2026-05-11).
361+
# Substrate echoes request body bytes back as body_received (str) +
362+
# body_received_sha256 (64-hex SHA256) when X-CueAPI-Verify-Echo:
363+
# true header is set. We compute the same SHA256 client-side over
364+
# our request body JSON + compare hex (constant cost) — falls back
365+
# to string compare on body_received string if available. Mirrors
366+
# MessagesResource.send auto_verify pattern.
367+
headers: Dict[str, str] = {}
368+
sent_body_bytes: Optional[bytes] = None
369+
if auto_verify:
370+
headers["X-CueAPI-Verify-Echo"] = "true"
371+
# Pre-compute canonical JSON bytes for the verify-echo
372+
# comparison. Server hashes the body bytes it received;
373+
# this client hashes the body bytes we send. Match should
374+
# be byte-identical if no transport-layer mutation occurred.
375+
sent_body_bytes = json.dumps(body, separators=(",", ":")).encode("utf-8")
376+
377+
response = self._client._post(
378+
f"/v1/cues/{cue_id}/fire", json=body, headers=headers
379+
)
380+
381+
# Verify echo if requested. Defensive isinstance handles both
382+
# current substrate (flat string post-fix 2026-05-11 ~23:48Z)
383+
# and the earlier dict-shape variant + the no-echo backward-
384+
# compat path.
385+
if auto_verify and isinstance(response, dict) and sent_body_bytes is not None:
386+
received_raw = response.get(_VERIFY_ECHO_BODY_FIELD)
387+
received_str: Optional[str] = None
388+
if isinstance(received_raw, str):
389+
received_str = received_raw
390+
elif isinstance(received_raw, dict):
391+
# Pre-fix wire shape: serialize for compare. Future-
392+
# proof in case any deployment still ships the dict.
393+
received_str = json.dumps(received_raw, separators=(",", ":"))
394+
395+
# Prefer constant-cost SHA256 comparison when both server +
396+
# client compute the same digest. Falls back to string
397+
# compare if the sha field is absent.
398+
sha_field = response.get(_VERIFY_ECHO_SHA256_FIELD)
399+
mismatch_detected = False
400+
if isinstance(sha_field, str) and len(sha_field) == 64:
401+
# Server's sha256 hashes the raw request bytes it
402+
# received. We compute over our locally-serialized
403+
# bytes. JSON-canonicalization differences (key order,
404+
# whitespace) could cause spurious mismatch — so on
405+
# sha mismatch, fall back to string-compare which is
406+
# more forgiving on serialization differences.
407+
client_sha = hashlib.sha256(sent_body_bytes).hexdigest()
408+
if client_sha != sha_field:
409+
# SHA mismatch — verify with string compare; if THAT
410+
# also fails, it's a real divergence.
411+
if received_str is not None and received_str != json.dumps(
412+
body, separators=(",", ":")
413+
):
414+
mismatch_detected = True
415+
else:
416+
# No sha field; compare body_received string vs our
417+
# canonical body JSON.
418+
if received_str is not None and received_str != json.dumps(
419+
body, separators=(",", ":")
420+
):
421+
mismatch_detected = True
422+
423+
if mismatch_detected and received_str is not None:
424+
exec_id = response.get("id", "<unknown>")
425+
sent_str = json.dumps(body, separators=(",", ":"))
426+
divergence = first_divergence_byte(sent_str, received_str)
427+
if divergence == -1 and len(sent_str) != len(received_str):
428+
divergence = min(len(sent_str), len(received_str))
429+
raise BodyVerifyMismatchError(
430+
f"Cue fire body received by substrate ({len(received_str)} bytes) differs "
431+
f"from body sent ({len(sent_str)} bytes); first divergence at byte "
432+
f"{divergence}. Likely cause: caller-side mutation of payload_override or "
433+
f"send_at fields before reaching the SDK. Inspect the dict you constructed.",
434+
sent_body=sent_str,
435+
received_body=received_str,
436+
first_divergence_byte=divergence,
437+
message_id=exec_id, # execution id for fire (NOT message id)
438+
)
439+
return response

tests/test_cues_resource.py

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,22 @@
66

77

88
class TestFire:
9+
"""Default ``auto_verify=True`` adds X-CueAPI-Verify-Echo header on every
10+
call (Phase 2 of body-verify defense in depth; Mike directive 2026-05-11).
11+
TestFireAutoVerify class below pins the verify behavior explicitly."""
12+
13+
_VERIFY_HEADER = {"X-CueAPI-Verify-Echo": "true"}
14+
915
def test_fire_no_payload_override(self):
1016
mock_client = MagicMock()
1117
mock_client._post.return_value = {"id": "exec_test", "status": "queued"}
1218
resource = CuesResource(mock_client)
1319

1420
result = resource.fire("cue_abc123")
1521

16-
mock_client._post.assert_called_once_with("/v1/cues/cue_abc123/fire", json={})
22+
mock_client._post.assert_called_once_with(
23+
"/v1/cues/cue_abc123/fire", json={}, headers=self._VERIFY_HEADER,
24+
)
1725
assert result["id"] == "exec_test"
1826

1927
def test_fire_with_payload_override_only(self):
@@ -27,6 +35,7 @@ def test_fire_with_payload_override_only(self):
2735
mock_client._post.assert_called_once_with(
2836
"/v1/cues/cue_abc123/fire",
2937
json={"payload_override": payload},
38+
headers=self._VERIFY_HEADER,
3039
)
3140

3241
def test_fire_with_payload_override_and_merge_strategy(self):
@@ -40,8 +49,83 @@ def test_fire_with_payload_override_and_merge_strategy(self):
4049
mock_client._post.assert_called_once_with(
4150
"/v1/cues/cue_abc123/fire",
4251
json={"payload_override": payload, "merge_strategy": "replace"},
52+
headers=self._VERIFY_HEADER,
4353
)
4454

55+
56+
class TestFireAutoVerify:
57+
"""Phase 2 cues fire auto-verify (Mike body-verify directive 2026-05-11).
58+
59+
Mirrors MessagesResource.send pattern. Substrate echoes back the request
60+
body bytes under body_received + sha256 hex under body_received_sha256.
61+
SDK compares + raises BodyVerifyMismatchError on drift.
62+
"""
63+
64+
def test_default_adds_verify_echo_header(self):
65+
mock_client = MagicMock()
66+
mock_client._post.return_value = {"id": "exec_x"}
67+
resource = CuesResource(mock_client)
68+
69+
resource.fire("cue_abc")
70+
71+
headers = mock_client._post.call_args.kwargs.get("headers", {})
72+
assert headers.get("X-CueAPI-Verify-Echo") == "true"
73+
74+
def test_opt_out_omits_header(self):
75+
mock_client = MagicMock()
76+
mock_client._post.return_value = {"id": "exec_x"}
77+
resource = CuesResource(mock_client)
78+
79+
resource.fire("cue_abc", auto_verify=False)
80+
81+
headers = mock_client._post.call_args.kwargs.get("headers", {})
82+
assert "X-CueAPI-Verify-Echo" not in headers
83+
84+
def test_byte_identical_sha256_passes(self):
85+
"""When server's body_received_sha256 matches client's computed
86+
sha256, send() returns response normally (constant-cost path)."""
87+
import hashlib
88+
import json
89+
# Compute expected sha256 of the canonical request body
90+
body_payload = {"payload_override": {"task": "test"}}
91+
expected_sha = hashlib.sha256(
92+
json.dumps(body_payload, separators=(",", ":")).encode("utf-8")
93+
).hexdigest()
94+
mock_client = MagicMock()
95+
mock_client._post.return_value = {
96+
"id": "exec_x",
97+
"body_received": json.dumps(body_payload, separators=(",", ":")),
98+
"body_received_sha256": expected_sha,
99+
}
100+
resource = CuesResource(mock_client)
101+
102+
result = resource.fire("cue_abc", payload_override={"task": "test"})
103+
104+
assert result["id"] == "exec_x"
105+
106+
def test_no_op_when_substrate_omits_echo_field(self):
107+
"""Pre-Layer-1 substrate omits body_received → no raise."""
108+
mock_client = MagicMock()
109+
mock_client._post.return_value = {"id": "exec_x"}
110+
resource = CuesResource(mock_client)
111+
112+
result = resource.fire("cue_abc")
113+
114+
assert result["id"] == "exec_x"
115+
116+
def test_opt_out_skips_verify_even_if_substrate_echoes(self):
117+
"""auto_verify=False: even if substrate sends body_received, don't check."""
118+
mock_client = MagicMock()
119+
mock_client._post.return_value = {
120+
"id": "exec_x",
121+
"body_received": "completely different body",
122+
}
123+
resource = CuesResource(mock_client)
124+
125+
result = resource.fire("cue_abc", auto_verify=False)
126+
127+
assert result["id"] == "exec_x"
128+
45129
def test_fire_omits_merge_strategy_when_not_passed(self):
46130
# When the caller omits merge_strategy, the wrapper must NOT send a
47131
# client-side default. The server's Pydantic default of "merge"

0 commit comments

Comments
 (0)