Skip to content

Commit ba1c45f

Browse files
mikemolinetclaude
andauthored
cues.fire: auto_verify + sha256 constant-cost path (Phase 2 follow-on B + bonus) (#41)
* 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> * fix(cues.fire): default auto_verify=False until substrate echo semantics locked CI revealed: substrate's /v1/cues/{id}/fire echoes a pydantic-after-parse body that includes server-side default-population (e.g. empty {} request gets echoed as 16-byte populated-defaults JSON). Client's canonical-JSON serialization diverges from substrate's defaulted echo → spurious BodyVerifyMismatchError on integration tests. Mitigation: default auto_verify=False on CuesResource.fire. Callers opt- in via auto_verify=True when their serialization aligns with substrate's echo semantic. Once cueapi-primary locks per-field echo semantics for fire (sibling to the body_received semantic locked for /v1/messages), flip default to True. Existing fire tests reverted to expect headers={} (no auto-verify). TestFireAutoVerify class tests updated to explicitly pass auto_verify=True. 39 of 39 messages + cues resource tests pass. Integration tests in test_cues.py no longer raise spurious BodyVerifyMismatchError. --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent bda862b commit ba1c45f

3 files changed

Lines changed: 188 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=False)` body-verify mirror — OPT-IN (Mike body-verify directive 2026-05-11).** Parallel to `MessagesResource.send(auto_verify=True)`. Default OFF for cues fire because substrate's `/v1/cues/{id}/fire` echoes a pydantic-after-parse body that may include server-side default-population (verified empirically against staging CI ~23:48Z); diffing client's canonical-JSON vs substrate's parsed-defaulted echo would cause spurious mismatch. Callers can opt-in via `auto_verify=True` when they know substrate echo semantics match client serialization (typical for sha256-based constant-cost path). Implementation includes sha256 hex compare (constant-cost) with string-compare fallback on hash drift. On confirmed drift raises `BodyVerifyMismatchError` with diagnostic attributes including `message_id` (= execution_id for fire). Defensive isinstance handles both dict (pre-substrate-fix) and string (post-fix 2026-05-11 ~23:48Z) wire shapes. When cueapi-primary locks per-field echo semantics for fire, default will flip to True.
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 = False,
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: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,23 @@
66

77

88
class TestFire:
9+
"""Default ``auto_verify=False`` for ``CuesResource.fire`` — substrate's
10+
/v1/cues/{id}/fire echoes a pydantic-after-parse body that may include
11+
server-side default-population, causing spurious diff vs caller's
12+
canonical-JSON serialization. Until field-by-field echo semantic is
13+
locked with cueapi-primary, fire's auto_verify is opt-in via explicit
14+
kwarg. TestFireAutoVerify class below pins the opt-in verify behavior."""
15+
916
def test_fire_no_payload_override(self):
1017
mock_client = MagicMock()
1118
mock_client._post.return_value = {"id": "exec_test", "status": "queued"}
1219
resource = CuesResource(mock_client)
1320

1421
result = resource.fire("cue_abc123")
1522

16-
mock_client._post.assert_called_once_with("/v1/cues/cue_abc123/fire", json={})
23+
mock_client._post.assert_called_once_with(
24+
"/v1/cues/cue_abc123/fire", json={}, headers={},
25+
)
1726
assert result["id"] == "exec_test"
1827

1928
def test_fire_with_payload_override_only(self):
@@ -27,6 +36,7 @@ def test_fire_with_payload_override_only(self):
2736
mock_client._post.assert_called_once_with(
2837
"/v1/cues/cue_abc123/fire",
2938
json={"payload_override": payload},
39+
headers={},
3040
)
3141

3242
def test_fire_with_payload_override_and_merge_strategy(self):
@@ -40,8 +50,91 @@ def test_fire_with_payload_override_and_merge_strategy(self):
4050
mock_client._post.assert_called_once_with(
4151
"/v1/cues/cue_abc123/fire",
4252
json={"payload_override": payload, "merge_strategy": "replace"},
53+
headers={},
54+
)
55+
56+
57+
class TestFireAutoVerify:
58+
"""Phase 2 cues fire auto-verify (Mike body-verify directive 2026-05-11).
59+
60+
Mirrors MessagesResource.send pattern. Substrate echoes back the request
61+
body bytes under body_received + sha256 hex under body_received_sha256.
62+
SDK compares + raises BodyVerifyMismatchError on drift.
63+
"""
64+
65+
def test_default_off_omits_verify_echo_header(self):
66+
"""auto_verify defaults to False on fire (substrate echo semantics
67+
not yet locked for /v1/cues/{id}/fire; opt-in until field-by-field
68+
semantic confirmed with cueapi-primary)."""
69+
mock_client = MagicMock()
70+
mock_client._post.return_value = {"id": "exec_x"}
71+
resource = CuesResource(mock_client)
72+
73+
resource.fire("cue_abc")
74+
75+
headers = mock_client._post.call_args.kwargs.get("headers", {})
76+
assert "X-CueAPI-Verify-Echo" not in headers
77+
78+
def test_opt_in_adds_verify_echo_header(self):
79+
mock_client = MagicMock()
80+
mock_client._post.return_value = {"id": "exec_x"}
81+
resource = CuesResource(mock_client)
82+
83+
resource.fire("cue_abc", auto_verify=True)
84+
85+
headers = mock_client._post.call_args.kwargs.get("headers", {})
86+
assert headers.get("X-CueAPI-Verify-Echo") == "true"
87+
88+
def test_byte_identical_sha256_passes(self):
89+
"""When server's body_received_sha256 matches client's computed
90+
sha256, send() returns response normally (constant-cost path).
91+
Requires explicit auto_verify=True since fire defaults to off."""
92+
import hashlib
93+
import json
94+
# Compute expected sha256 of the canonical request body
95+
body_payload = {"payload_override": {"task": "test"}}
96+
expected_sha = hashlib.sha256(
97+
json.dumps(body_payload, separators=(",", ":")).encode("utf-8")
98+
).hexdigest()
99+
mock_client = MagicMock()
100+
mock_client._post.return_value = {
101+
"id": "exec_x",
102+
"body_received": json.dumps(body_payload, separators=(",", ":")),
103+
"body_received_sha256": expected_sha,
104+
}
105+
resource = CuesResource(mock_client)
106+
107+
result = resource.fire(
108+
"cue_abc", payload_override={"task": "test"}, auto_verify=True
43109
)
44110

111+
assert result["id"] == "exec_x"
112+
113+
def test_no_op_when_substrate_omits_echo_field(self):
114+
"""Pre-Layer-1 substrate (or default-off path) omits echo → no raise."""
115+
mock_client = MagicMock()
116+
mock_client._post.return_value = {"id": "exec_x"}
117+
resource = CuesResource(mock_client)
118+
119+
result = resource.fire("cue_abc", auto_verify=True)
120+
121+
assert result["id"] == "exec_x"
122+
123+
def test_default_off_skips_verify_even_if_substrate_echoes(self):
124+
"""Default auto_verify=False: even if substrate sends body_received
125+
(e.g. caller targets a different SDK that opted in), this call
126+
doesn't check. Pins the default-off invariant."""
127+
mock_client = MagicMock()
128+
mock_client._post.return_value = {
129+
"id": "exec_x",
130+
"body_received": "completely different body",
131+
}
132+
resource = CuesResource(mock_client)
133+
134+
result = resource.fire("cue_abc") # default auto_verify=False
135+
136+
assert result["id"] == "exec_x"
137+
45138
def test_fire_omits_merge_strategy_when_not_passed(self):
46139
# When the caller omits merge_strategy, the wrapper must NOT send a
47140
# client-side default. The server's Pydantic default of "merge"

0 commit comments

Comments
 (0)