Skip to content

Commit d26c074

Browse files
committed
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.
1 parent a900779 commit d26c074

3 files changed

Lines changed: 31 additions & 22 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +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.
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.
1010
- **`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).
1111
- `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.
1212

cueapi/resources/cues.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -285,7 +285,7 @@ def fire(
285285
send_at: Optional[Union[str, datetime]] = None,
286286
exit_criteria: Optional[List[str]] = None,
287287
idempotency_key: Optional[str] = None,
288-
auto_verify: bool = True,
288+
auto_verify: bool = False,
289289
) -> Dict[str, Any]:
290290
"""Fire an existing cue, optionally overriding payload + scheduling.
291291

tests/test_cues_resource.py

Lines changed: 29 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,12 @@
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"}
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."""
1415

1516
def test_fire_no_payload_override(self):
1617
mock_client = MagicMock()
@@ -20,7 +21,7 @@ def test_fire_no_payload_override(self):
2021
result = resource.fire("cue_abc123")
2122

2223
mock_client._post.assert_called_once_with(
23-
"/v1/cues/cue_abc123/fire", json={}, headers=self._VERIFY_HEADER,
24+
"/v1/cues/cue_abc123/fire", json={}, headers={},
2425
)
2526
assert result["id"] == "exec_test"
2627

@@ -35,7 +36,7 @@ def test_fire_with_payload_override_only(self):
3536
mock_client._post.assert_called_once_with(
3637
"/v1/cues/cue_abc123/fire",
3738
json={"payload_override": payload},
38-
headers=self._VERIFY_HEADER,
39+
headers={},
3940
)
4041

4142
def test_fire_with_payload_override_and_merge_strategy(self):
@@ -49,7 +50,7 @@ def test_fire_with_payload_override_and_merge_strategy(self):
4950
mock_client._post.assert_called_once_with(
5051
"/v1/cues/cue_abc123/fire",
5152
json={"payload_override": payload, "merge_strategy": "replace"},
52-
headers=self._VERIFY_HEADER,
53+
headers={},
5354
)
5455

5556

@@ -61,29 +62,33 @@ class TestFireAutoVerify:
6162
SDK compares + raises BodyVerifyMismatchError on drift.
6263
"""
6364

64-
def test_default_adds_verify_echo_header(self):
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)."""
6569
mock_client = MagicMock()
6670
mock_client._post.return_value = {"id": "exec_x"}
6771
resource = CuesResource(mock_client)
6872

6973
resource.fire("cue_abc")
7074

7175
headers = mock_client._post.call_args.kwargs.get("headers", {})
72-
assert headers.get("X-CueAPI-Verify-Echo") == "true"
76+
assert "X-CueAPI-Verify-Echo" not in headers
7377

74-
def test_opt_out_omits_header(self):
78+
def test_opt_in_adds_verify_echo_header(self):
7579
mock_client = MagicMock()
7680
mock_client._post.return_value = {"id": "exec_x"}
7781
resource = CuesResource(mock_client)
7882

79-
resource.fire("cue_abc", auto_verify=False)
83+
resource.fire("cue_abc", auto_verify=True)
8084

8185
headers = mock_client._post.call_args.kwargs.get("headers", {})
82-
assert "X-CueAPI-Verify-Echo" not in headers
86+
assert headers.get("X-CueAPI-Verify-Echo") == "true"
8387

8488
def test_byte_identical_sha256_passes(self):
8589
"""When server's body_received_sha256 matches client's computed
86-
sha256, send() returns response normally (constant-cost path)."""
90+
sha256, send() returns response normally (constant-cost path).
91+
Requires explicit auto_verify=True since fire defaults to off."""
8792
import hashlib
8893
import json
8994
# Compute expected sha256 of the canonical request body
@@ -99,30 +104,34 @@ def test_byte_identical_sha256_passes(self):
99104
}
100105
resource = CuesResource(mock_client)
101106

102-
result = resource.fire("cue_abc", payload_override={"task": "test"})
107+
result = resource.fire(
108+
"cue_abc", payload_override={"task": "test"}, auto_verify=True
109+
)
103110

104111
assert result["id"] == "exec_x"
105112

106113
def test_no_op_when_substrate_omits_echo_field(self):
107-
"""Pre-Layer-1 substrate omits body_received → no raise."""
114+
"""Pre-Layer-1 substrate (or default-off path) omits echo → no raise."""
108115
mock_client = MagicMock()
109116
mock_client._post.return_value = {"id": "exec_x"}
110117
resource = CuesResource(mock_client)
111118

112-
result = resource.fire("cue_abc")
119+
result = resource.fire("cue_abc", auto_verify=True)
113120

114121
assert result["id"] == "exec_x"
115122

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."""
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."""
118127
mock_client = MagicMock()
119128
mock_client._post.return_value = {
120129
"id": "exec_x",
121130
"body_received": "completely different body",
122131
}
123132
resource = CuesResource(mock_client)
124133

125-
result = resource.fire("cue_abc", auto_verify=False)
134+
result = resource.fire("cue_abc") # default auto_verify=False
126135

127136
assert result["id"] == "exec_x"
128137

0 commit comments

Comments
 (0)