From a900779c31f3dbb238836f643c2993b1501a6910 Mon Sep 17 00:00:00 2001 From: mikemolinet Date: Mon, 11 May 2026 16:48:15 -0700 Subject: [PATCH 1/2] cues.fire: auto_verify + sha256 constant-cost path (Phase 2 follow-on B + bonus) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CHANGELOG.md | 1 + cueapi/resources/cues.py | 94 ++++++++++++++++++++++++++++++++++++- tests/test_cues_resource.py | 86 ++++++++++++++++++++++++++++++++- 3 files changed, 179 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6be8e58..1f4fea3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to cueapi-sdk will be documented here. ### Added +- **`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. - **`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). - `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. diff --git a/cueapi/resources/cues.py b/cueapi/resources/cues.py index a14ab20..3791af7 100644 --- a/cueapi/resources/cues.py +++ b/cueapi/resources/cues.py @@ -2,14 +2,26 @@ from __future__ import annotations +import hashlib +import json from datetime import datetime from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union +from cueapi.exceptions import BodyVerifyMismatchError, first_divergence_byte from cueapi.models.cue import Cue, CueList if TYPE_CHECKING: from cueapi.client import CueAPI +# Phase 2 of body-verify defense in depth (Mike directive 2026-05-11). +# Substrate echoes the request body bytes back in body_received (str) +# + body_received_sha256 (64-hex SHA256 of the same bytes) when the +# X-CueAPI-Verify-Echo: true request header is set. Field names locked +# during joint design (CMA + cueapi-primary) on Dock workspace +# cue-message-silent-corruption-substrate-design-2026-05-11. +_VERIFY_ECHO_BODY_FIELD = "body_received" +_VERIFY_ECHO_SHA256_FIELD = "body_received_sha256" + class CuesResource: """Manage cues (scheduled tasks).""" @@ -273,6 +285,7 @@ def fire( send_at: Optional[Union[str, datetime]] = None, exit_criteria: Optional[List[str]] = None, idempotency_key: Optional[str] = None, + auto_verify: bool = True, ) -> Dict[str, Any]: """Fire an existing cue, optionally overriding payload + scheduling. @@ -344,4 +357,83 @@ def fire( if idempotency_key is not None: body["idempotency_key"] = idempotency_key - return self._client._post(f"/v1/cues/{cue_id}/fire", json=body) + # Phase 2 of body-verify defense in depth (Mike directive 2026-05-11). + # Substrate echoes request body bytes back as body_received (str) + + # body_received_sha256 (64-hex SHA256) when X-CueAPI-Verify-Echo: + # true header is set. We compute the same SHA256 client-side over + # our request body JSON + compare hex (constant cost) — falls back + # to string compare on body_received string if available. Mirrors + # MessagesResource.send auto_verify pattern. + headers: Dict[str, str] = {} + sent_body_bytes: Optional[bytes] = None + if auto_verify: + headers["X-CueAPI-Verify-Echo"] = "true" + # Pre-compute canonical JSON bytes for the verify-echo + # comparison. Server hashes the body bytes it received; + # this client hashes the body bytes we send. Match should + # be byte-identical if no transport-layer mutation occurred. + sent_body_bytes = json.dumps(body, separators=(",", ":")).encode("utf-8") + + response = self._client._post( + f"/v1/cues/{cue_id}/fire", json=body, headers=headers + ) + + # Verify echo if requested. Defensive isinstance handles both + # current substrate (flat string post-fix 2026-05-11 ~23:48Z) + # and the earlier dict-shape variant + the no-echo backward- + # compat path. + if auto_verify and isinstance(response, dict) and sent_body_bytes is not None: + received_raw = response.get(_VERIFY_ECHO_BODY_FIELD) + received_str: Optional[str] = None + if isinstance(received_raw, str): + received_str = received_raw + elif isinstance(received_raw, dict): + # Pre-fix wire shape: serialize for compare. Future- + # proof in case any deployment still ships the dict. + received_str = json.dumps(received_raw, separators=(",", ":")) + + # Prefer constant-cost SHA256 comparison when both server + + # client compute the same digest. Falls back to string + # compare if the sha field is absent. + sha_field = response.get(_VERIFY_ECHO_SHA256_FIELD) + mismatch_detected = False + if isinstance(sha_field, str) and len(sha_field) == 64: + # Server's sha256 hashes the raw request bytes it + # received. We compute over our locally-serialized + # bytes. JSON-canonicalization differences (key order, + # whitespace) could cause spurious mismatch — so on + # sha mismatch, fall back to string-compare which is + # more forgiving on serialization differences. + client_sha = hashlib.sha256(sent_body_bytes).hexdigest() + if client_sha != sha_field: + # SHA mismatch — verify with string compare; if THAT + # also fails, it's a real divergence. + if received_str is not None and received_str != json.dumps( + body, separators=(",", ":") + ): + mismatch_detected = True + else: + # No sha field; compare body_received string vs our + # canonical body JSON. + if received_str is not None and received_str != json.dumps( + body, separators=(",", ":") + ): + mismatch_detected = True + + if mismatch_detected and received_str is not None: + exec_id = response.get("id", "") + sent_str = json.dumps(body, separators=(",", ":")) + divergence = first_divergence_byte(sent_str, received_str) + if divergence == -1 and len(sent_str) != len(received_str): + divergence = min(len(sent_str), len(received_str)) + raise BodyVerifyMismatchError( + f"Cue fire body received by substrate ({len(received_str)} bytes) differs " + f"from body sent ({len(sent_str)} bytes); first divergence at byte " + f"{divergence}. Likely cause: caller-side mutation of payload_override or " + f"send_at fields before reaching the SDK. Inspect the dict you constructed.", + sent_body=sent_str, + received_body=received_str, + first_divergence_byte=divergence, + message_id=exec_id, # execution id for fire (NOT message id) + ) + return response diff --git a/tests/test_cues_resource.py b/tests/test_cues_resource.py index b5d40eb..0e27145 100644 --- a/tests/test_cues_resource.py +++ b/tests/test_cues_resource.py @@ -6,6 +6,12 @@ class TestFire: + """Default ``auto_verify=True`` adds X-CueAPI-Verify-Echo header on every + call (Phase 2 of body-verify defense in depth; Mike directive 2026-05-11). + TestFireAutoVerify class below pins the verify behavior explicitly.""" + + _VERIFY_HEADER = {"X-CueAPI-Verify-Echo": "true"} + def test_fire_no_payload_override(self): mock_client = MagicMock() mock_client._post.return_value = {"id": "exec_test", "status": "queued"} @@ -13,7 +19,9 @@ def test_fire_no_payload_override(self): result = resource.fire("cue_abc123") - mock_client._post.assert_called_once_with("/v1/cues/cue_abc123/fire", json={}) + mock_client._post.assert_called_once_with( + "/v1/cues/cue_abc123/fire", json={}, headers=self._VERIFY_HEADER, + ) assert result["id"] == "exec_test" def test_fire_with_payload_override_only(self): @@ -27,6 +35,7 @@ def test_fire_with_payload_override_only(self): mock_client._post.assert_called_once_with( "/v1/cues/cue_abc123/fire", json={"payload_override": payload}, + headers=self._VERIFY_HEADER, ) def test_fire_with_payload_override_and_merge_strategy(self): @@ -40,8 +49,83 @@ def test_fire_with_payload_override_and_merge_strategy(self): mock_client._post.assert_called_once_with( "/v1/cues/cue_abc123/fire", json={"payload_override": payload, "merge_strategy": "replace"}, + headers=self._VERIFY_HEADER, ) + +class TestFireAutoVerify: + """Phase 2 cues fire auto-verify (Mike body-verify directive 2026-05-11). + + Mirrors MessagesResource.send pattern. Substrate echoes back the request + body bytes under body_received + sha256 hex under body_received_sha256. + SDK compares + raises BodyVerifyMismatchError on drift. + """ + + def test_default_adds_verify_echo_header(self): + mock_client = MagicMock() + mock_client._post.return_value = {"id": "exec_x"} + resource = CuesResource(mock_client) + + resource.fire("cue_abc") + + headers = mock_client._post.call_args.kwargs.get("headers", {}) + assert headers.get("X-CueAPI-Verify-Echo") == "true" + + def test_opt_out_omits_header(self): + mock_client = MagicMock() + mock_client._post.return_value = {"id": "exec_x"} + resource = CuesResource(mock_client) + + resource.fire("cue_abc", auto_verify=False) + + headers = mock_client._post.call_args.kwargs.get("headers", {}) + assert "X-CueAPI-Verify-Echo" not in headers + + def test_byte_identical_sha256_passes(self): + """When server's body_received_sha256 matches client's computed + sha256, send() returns response normally (constant-cost path).""" + import hashlib + import json + # Compute expected sha256 of the canonical request body + body_payload = {"payload_override": {"task": "test"}} + expected_sha = hashlib.sha256( + json.dumps(body_payload, separators=(",", ":")).encode("utf-8") + ).hexdigest() + mock_client = MagicMock() + mock_client._post.return_value = { + "id": "exec_x", + "body_received": json.dumps(body_payload, separators=(",", ":")), + "body_received_sha256": expected_sha, + } + resource = CuesResource(mock_client) + + result = resource.fire("cue_abc", payload_override={"task": "test"}) + + assert result["id"] == "exec_x" + + def test_no_op_when_substrate_omits_echo_field(self): + """Pre-Layer-1 substrate omits body_received → no raise.""" + mock_client = MagicMock() + mock_client._post.return_value = {"id": "exec_x"} + resource = CuesResource(mock_client) + + result = resource.fire("cue_abc") + + assert result["id"] == "exec_x" + + def test_opt_out_skips_verify_even_if_substrate_echoes(self): + """auto_verify=False: even if substrate sends body_received, don't check.""" + mock_client = MagicMock() + mock_client._post.return_value = { + "id": "exec_x", + "body_received": "completely different body", + } + resource = CuesResource(mock_client) + + result = resource.fire("cue_abc", auto_verify=False) + + assert result["id"] == "exec_x" + def test_fire_omits_merge_strategy_when_not_passed(self): # When the caller omits merge_strategy, the wrapper must NOT send a # client-side default. The server's Pydantic default of "merge" From d26c074d72021f29c149283bab3ace899972929d Mon Sep 17 00:00:00 2001 From: mikemolinet Date: Mon, 11 May 2026 16:57:09 -0700 Subject: [PATCH 2/2] fix(cues.fire): default auto_verify=False until substrate echo semantics locked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 2 +- cueapi/resources/cues.py | 2 +- tests/test_cues_resource.py | 49 ++++++++++++++++++++++--------------- 3 files changed, 31 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f4fea3..203e49c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ All notable changes to cueapi-sdk will be documented here. ### Added -- **`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. +- **`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. - **`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). - `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. diff --git a/cueapi/resources/cues.py b/cueapi/resources/cues.py index 3791af7..851496b 100644 --- a/cueapi/resources/cues.py +++ b/cueapi/resources/cues.py @@ -285,7 +285,7 @@ def fire( send_at: Optional[Union[str, datetime]] = None, exit_criteria: Optional[List[str]] = None, idempotency_key: Optional[str] = None, - auto_verify: bool = True, + auto_verify: bool = False, ) -> Dict[str, Any]: """Fire an existing cue, optionally overriding payload + scheduling. diff --git a/tests/test_cues_resource.py b/tests/test_cues_resource.py index 0e27145..cb903e8 100644 --- a/tests/test_cues_resource.py +++ b/tests/test_cues_resource.py @@ -6,11 +6,12 @@ class TestFire: - """Default ``auto_verify=True`` adds X-CueAPI-Verify-Echo header on every - call (Phase 2 of body-verify defense in depth; Mike directive 2026-05-11). - TestFireAutoVerify class below pins the verify behavior explicitly.""" - - _VERIFY_HEADER = {"X-CueAPI-Verify-Echo": "true"} + """Default ``auto_verify=False`` for ``CuesResource.fire`` — substrate's + /v1/cues/{id}/fire echoes a pydantic-after-parse body that may include + server-side default-population, causing spurious diff vs caller's + canonical-JSON serialization. Until field-by-field echo semantic is + locked with cueapi-primary, fire's auto_verify is opt-in via explicit + kwarg. TestFireAutoVerify class below pins the opt-in verify behavior.""" def test_fire_no_payload_override(self): mock_client = MagicMock() @@ -20,7 +21,7 @@ def test_fire_no_payload_override(self): result = resource.fire("cue_abc123") mock_client._post.assert_called_once_with( - "/v1/cues/cue_abc123/fire", json={}, headers=self._VERIFY_HEADER, + "/v1/cues/cue_abc123/fire", json={}, headers={}, ) assert result["id"] == "exec_test" @@ -35,7 +36,7 @@ def test_fire_with_payload_override_only(self): mock_client._post.assert_called_once_with( "/v1/cues/cue_abc123/fire", json={"payload_override": payload}, - headers=self._VERIFY_HEADER, + headers={}, ) def test_fire_with_payload_override_and_merge_strategy(self): @@ -49,7 +50,7 @@ def test_fire_with_payload_override_and_merge_strategy(self): mock_client._post.assert_called_once_with( "/v1/cues/cue_abc123/fire", json={"payload_override": payload, "merge_strategy": "replace"}, - headers=self._VERIFY_HEADER, + headers={}, ) @@ -61,7 +62,10 @@ class TestFireAutoVerify: SDK compares + raises BodyVerifyMismatchError on drift. """ - def test_default_adds_verify_echo_header(self): + def test_default_off_omits_verify_echo_header(self): + """auto_verify defaults to False on fire (substrate echo semantics + not yet locked for /v1/cues/{id}/fire; opt-in until field-by-field + semantic confirmed with cueapi-primary).""" mock_client = MagicMock() mock_client._post.return_value = {"id": "exec_x"} resource = CuesResource(mock_client) @@ -69,21 +73,22 @@ def test_default_adds_verify_echo_header(self): resource.fire("cue_abc") headers = mock_client._post.call_args.kwargs.get("headers", {}) - assert headers.get("X-CueAPI-Verify-Echo") == "true" + assert "X-CueAPI-Verify-Echo" not in headers - def test_opt_out_omits_header(self): + def test_opt_in_adds_verify_echo_header(self): mock_client = MagicMock() mock_client._post.return_value = {"id": "exec_x"} resource = CuesResource(mock_client) - resource.fire("cue_abc", auto_verify=False) + resource.fire("cue_abc", auto_verify=True) headers = mock_client._post.call_args.kwargs.get("headers", {}) - assert "X-CueAPI-Verify-Echo" not in headers + assert headers.get("X-CueAPI-Verify-Echo") == "true" def test_byte_identical_sha256_passes(self): """When server's body_received_sha256 matches client's computed - sha256, send() returns response normally (constant-cost path).""" + sha256, send() returns response normally (constant-cost path). + Requires explicit auto_verify=True since fire defaults to off.""" import hashlib import json # Compute expected sha256 of the canonical request body @@ -99,22 +104,26 @@ def test_byte_identical_sha256_passes(self): } resource = CuesResource(mock_client) - result = resource.fire("cue_abc", payload_override={"task": "test"}) + result = resource.fire( + "cue_abc", payload_override={"task": "test"}, auto_verify=True + ) assert result["id"] == "exec_x" def test_no_op_when_substrate_omits_echo_field(self): - """Pre-Layer-1 substrate omits body_received → no raise.""" + """Pre-Layer-1 substrate (or default-off path) omits echo → no raise.""" mock_client = MagicMock() mock_client._post.return_value = {"id": "exec_x"} resource = CuesResource(mock_client) - result = resource.fire("cue_abc") + result = resource.fire("cue_abc", auto_verify=True) assert result["id"] == "exec_x" - def test_opt_out_skips_verify_even_if_substrate_echoes(self): - """auto_verify=False: even if substrate sends body_received, don't check.""" + def test_default_off_skips_verify_even_if_substrate_echoes(self): + """Default auto_verify=False: even if substrate sends body_received + (e.g. caller targets a different SDK that opted in), this call + doesn't check. Pins the default-off invariant.""" mock_client = MagicMock() mock_client._post.return_value = { "id": "exec_x", @@ -122,7 +131,7 @@ def test_opt_out_skips_verify_even_if_substrate_echoes(self): } resource = CuesResource(mock_client) - result = resource.fire("cue_abc", auto_verify=False) + result = resource.fire("cue_abc") # default auto_verify=False assert result["id"] == "exec_x"