diff --git a/app/routers/cues.py b/app/routers/cues.py index 1f85dd2..b3c7d8e 100644 --- a/app/routers/cues.py +++ b/app/routers/cues.py @@ -167,7 +167,10 @@ async def fire_cue( "status": "pending", "triggered_by": "manual_fire", } # BodyVerify Layer 1: opt-in echo-back when caller sets - # X-CueAPI-Verify-Echo: true. Helper returns {} when header absent - # so non-opted clients see zero shape change. - response_content.update(apply_verify_echo(request=request, parsed_body=body)) + # X-CueAPI-Verify-Echo: true. OSS FireRequest carries only send_at + # (datetime) — no string user-content field to echo. Helper returns + # body_received=None + sha256 of empty bytes when header set, or {} when + # absent. Once OSS adds a content-bearing field (e.g. payload_override + # for parity), update this to extract the string per spec. + response_content.update(apply_verify_echo(request=request, body_text=None)) return response_content diff --git a/app/routers/messages.py b/app/routers/messages.py index ce520e7..6c6e3af 100644 --- a/app/routers/messages.py +++ b/app/routers/messages.py @@ -108,7 +108,7 @@ async def send_message( # BodyVerify Layer 1: opt-in echo-back when caller sets # X-CueAPI-Verify-Echo: true. Helper returns {} when header absent # so non-opted clients see zero shape change. - response_content.update(apply_verify_echo(request=request, parsed_body=body)) + response_content.update(apply_verify_echo(request=request, body_text=body.body)) return JSONResponse( status_code=status_code, content=response_content, diff --git a/app/utils/verify_echo.py b/app/utils/verify_echo.py index 5e22031..341d87c 100644 --- a/app/utils/verify_echo.py +++ b/app/utils/verify_echo.py @@ -2,9 +2,9 @@ When request header ``X-CueAPI-Verify-Echo: true`` is present on a supported endpoint, the server adds ``body_received`` and ``body_received_sha256`` -fields to the 200 response. The caller diffs sent body vs received to detect -caller-side shell expansion (backticks, $-paren, ${VAR}) that silently -corrupts body content before send-time. +fields to the 200/201 response. The caller diffs sent body vs received to +detect caller-side shell expansion (backticks, $-paren, ${VAR}) that +silently corrupts body content before send-time. Why this exists: 2026-05-11 ~22:00Z, CMA's outbound Cue Messages 0/6 to cue-pm fell to garbage via inline bash ``-d "$BODY"`` where $BODY had been @@ -13,12 +13,20 @@ keystone for the 4-layer defense: substrate (this), SDK auto-verify, CLI force-file mode, docs leading with file-payload pattern. +Spec shape (locked at design review, Phase 1 hotfix corrected post-merge): + +* ``body_received`` is the **STRING** value of the body field the caller + sent (e.g. ``MessageCreate.body`` on /v1/messages, ``payload_override.message`` + or similar on /v1/cues//fire). NOT the full parsed Pydantic envelope dump. +* ``body_received_sha256`` is the SHA256 hex digest of those exact UTF-8 bytes + so a caller can compute ``sha256(sent_body_bytes).hexdigest()`` locally and + compare directly. Hash-of-the-string == hash-of-the-bytes. + Design doc: https://trydock.ai/workspaces/cue-message-silent-corruption-substrate-design-2026-05-11 """ from __future__ import annotations import hashlib -import json from typing import Any, Dict, Optional from fastapi import Request @@ -32,51 +40,48 @@ def verify_echo_requested(request: Request) -> bool: return request.headers.get(VERIFY_ECHO_HEADER, "").strip().lower() == "true" -def _canonical_json_bytes(value: Any) -> bytes: - """Stable JSON serialization for hashing: sorted keys + no whitespace.""" - return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode( - "utf-8" - ) - - -def apply_verify_echo(*, request: Request, parsed_body: Optional[Any]) -> Dict[str, Any]: +def apply_verify_echo(*, request: Request, body_text: Optional[str]) -> Dict[str, Any]: """Return verify-echo fields to merge into the response. Returns ``{}`` when the header is absent (zero-cost no-op for non-opted clients). When present, returns:: { - "body_received": , + "body_received": , "body_received_sha256": <64-char hex digest>, } Hashing rule: - * ``None`` body → SHA256 of empty bytes (well-known constant). - * Pydantic model → ``model_dump(mode="json")`` then canonical JSON. - * dict → canonical JSON. - * Otherwise → ``str()`` then UTF-8 bytes. - - The dict is intended to be ``.update()``-merged into the response dict - or returned alongside other fields. Caller is responsible for placement. + * ``None`` body → SHA256 of empty bytes (well-known constant); ``body_received`` + is ``None``. + * Otherwise → caller passes the EXACT string they want echoed (typically the + ``body`` field of a message or the user-content field of a fire payload); + ``body_received`` is that string verbatim and the hash is over its UTF-8 + bytes. + + Caller-side verification recipe (mirrors what cueapi-python's auto-verify + does):: + + import hashlib + sent = "..." # the exact string you POSTed in the body field + resp = client.post(..., json={"body": sent}, + headers={"X-CueAPI-Verify-Echo": "true"}) + assert resp.json()["body_received"] == sent + assert resp.json()["body_received_sha256"] == hashlib.sha256( + sent.encode("utf-8") + ).hexdigest() """ if not verify_echo_requested(request): return {} - if parsed_body is None: - body_view: Any = None - sha_input: bytes = b"" - elif hasattr(parsed_body, "model_dump"): - body_view = parsed_body.model_dump(mode="json") - sha_input = _canonical_json_bytes(body_view) - elif isinstance(parsed_body, dict): - body_view = parsed_body - sha_input = _canonical_json_bytes(body_view) - else: - body_view = str(parsed_body) - sha_input = body_view.encode("utf-8") + if body_text is None: + return { + "body_received": None, + "body_received_sha256": hashlib.sha256(b"").hexdigest(), + } return { - "body_received": body_view, - "body_received_sha256": hashlib.sha256(sha_input).hexdigest(), + "body_received": body_text, + "body_received_sha256": hashlib.sha256(body_text.encode("utf-8")).hexdigest(), } diff --git a/parity-manifest.json b/parity-manifest.json index a23dd13..c9020b0 100644 --- a/parity-manifest.json +++ b/parity-manifest.json @@ -1,9 +1,9 @@ { "manifest_version": 1, "description": "Files in cueapi-core that have a same-path counterpart in the private cueapi monorepo. Changes to the private counterpart should be considered for porting here; changes here should be cross-referenced to the private repo. The parity-check GitHub Action posts a soft warning on PRs that touch any of these paths. See HOSTED_ONLY.md for the open-core policy.", - "direction_of_truth_note": "Parity is BIDIRECTIONAL — most parity-tracked files flow private → OSS (private leads on hosted product features), but a growing set flow OSS → private (auth primitives, messaging primitives, identity-mapping helpers, and cross-codebase substrate ports where OSS is the canonical home per CWS-2026-05-08 lock). When a parity-tracked file changes, identify direction in the PR description; both directions are equally valid. Examples — OSS-leads / private-lags: `app/auth.py` (X-On-Behalf-Of), counterpart filter on inbox/sent, `app/utils/routing.py` helper, `external_owner` column. Private-leads / OSS-lags: most product features (billing, GDPR, dashboard, Stripe-paid surfaces) — but those don't appear in parity-manifest by virtue of being hosted-only (see oss_only_exclusions + HOSTED_ONLY.md). Special bidirectional case: `agent_live_sessions` schema port — private leads on the table itself (richer schema with cmotigtnx attestation), OSS leads on the documentation surface (`docs/multi-session.md`).", + "direction_of_truth_note": "Parity is BIDIRECTIONAL \u2014 most parity-tracked files flow private \u2192 OSS (private leads on hosted product features), but a growing set flow OSS \u2192 private (auth primitives, messaging primitives, identity-mapping helpers, and cross-codebase substrate ports where OSS is the canonical home per CWS-2026-05-08 lock). When a parity-tracked file changes, identify direction in the PR description; both directions are equally valid. Examples \u2014 OSS-leads / private-lags: `app/auth.py` (X-On-Behalf-Of), counterpart filter on inbox/sent, `app/utils/routing.py` helper, `external_owner` column. Private-leads / OSS-lags: most product features (billing, GDPR, dashboard, Stripe-paid surfaces) \u2014 but those don't appear in parity-manifest by virtue of being hosted-only (see oss_only_exclusions + HOSTED_ONLY.md). Special bidirectional case: `agent_live_sessions` schema port \u2014 private leads on the table itself (richer schema with cmotigtnx attestation), OSS leads on the documentation surface (`docs/multi-session.md`).", "last_full_audit": "2026-05-11", - "methodology": "Enumerate app/**/*.py and alembic/**/*.py in cueapi-core; include every file whose same-path counterpart exists in the private cueapi clone at audit time. Files that are OSS-only (no private counterpart) are intentionally excluded — modifications to them do not need a parity cross-reference.", + "methodology": "Enumerate app/**/*.py and alembic/**/*.py in cueapi-core; include every file whose same-path counterpart exists in the private cueapi clone at audit time. Files that are OSS-only (no private counterpart) are intentionally excluded \u2014 modifications to them do not need a parity cross-reference.", "oss_only_exclusions": [ { "path": "app/services/email_service.py", @@ -11,7 +11,7 @@ }, { "path": "alembic/versions/017_add_verification_mode.py", - "reason": "OSS-specific filename. Hosted ships equivalent verification_mode functionality at a different migration number (hosted's 017 is 017_add_support_ticket_updated_at.py). Feature parity is at the app-layer (cue.py, schemas, services) — those files ARE in the manifest." + "reason": "OSS-specific filename. Hosted ships equivalent verification_mode functionality at a different migration number (hosted's 017 is 017_add_support_ticket_updated_at.py). Feature parity is at the app-layer (cue.py, schemas, services) \u2014 those files ARE in the manifest." }, { "path": "alembic/versions/018_add_alerts_table.py", @@ -27,154 +27,619 @@ }, { "path": "alembic/versions/020_messaging_identity.py", - "reason": "OSS-specific filename. Hosted ships the same migration as 043_messaging_identity.py — independent migration histories. Feature parity is at the app-layer (agent.py, schemas, services) — those files ARE in the manifest." + "reason": "OSS-specific filename. Hosted ships the same migration as 043_messaging_identity.py \u2014 independent migration histories. Feature parity is at the app-layer (agent.py, schemas, services) \u2014 those files ARE in the manifest." }, { "path": "alembic/versions/021_messaging_messages.py", - "reason": "OSS-specific filename. Hosted ships the same migration as 044_messaging_messages.py — independent histories. Hosted's version also creates ``messages.from_api_key_id`` (FK to api_keys); cueapi-core omits that column because multi-key scoping is hosted-only. App-layer parity at message.py." + "reason": "OSS-specific filename. Hosted ships the same migration as 044_messaging_messages.py \u2014 independent histories. Hosted's version also creates ``messages.from_api_key_id`` (FK to api_keys); cueapi-core omits that column because multi-key scoping is hosted-only. App-layer parity at message.py." }, { "path": "alembic/versions/022_messaging_push_retry_columns.py", - "reason": "OSS-specific filename. Hosted ships the same migration as 045_messaging_push_retry_columns.py — independent histories. Identical column shape." + "reason": "OSS-specific filename. Hosted ships the same migration as 045_messaging_push_retry_columns.py \u2014 independent histories. Identical column shape." }, { "path": "worker/message_cleanup.py", - "reason": "OSS-specific module. Hosted ships these three cleanup functions (expire_old_messages / cleanup_expired_messages / free_old_idempotency_keys) inside worker/gdpr_cleanup.py alongside hosted-only GDPR-cascade tasks and a dry-run-by-default safety harness gated on GDPR_LAST_BACKUP_AT env var. cueapi-core extracts the messaging-specific cleanups to a dedicated module without the GDPR safety harness — self-hosters opt in to real action via an explicit dry_run=False kwarg." + "reason": "OSS-specific module. Hosted ships these three cleanup functions (expire_old_messages / cleanup_expired_messages / free_old_idempotency_keys) inside worker/gdpr_cleanup.py alongside hosted-only GDPR-cascade tasks and a dry-run-by-default safety harness gated on GDPR_LAST_BACKUP_AT env var. cueapi-core extracts the messaging-specific cleanups to a dedicated module without the GDPR safety harness \u2014 self-hosters opt in to real action via an explicit dry_run=False kwarg." }, { "path": "alembic/versions/023_messaging_primitive_multi_shell.py", - "reason": "OSS-only — slated for hard-cut deprecation in the agent_live_sessions schema convergence (CWS-2026-05-08 P0). Private cueapi never had agent_shells; it has the richer agent_live_sessions table (carries cue_id + task_name + monitor_version + session_token, load-bearing for cmotigtnx Live attestation per CWS-2026-05-08 Item 6). Convergence plan ports private's schema into OSS as the canonical primitive, drops agent_shells. See https://trydock.ai/workspaces/cueapi-agent-live-sessions-port-2026-05-09." + "reason": "OSS-only \u2014 slated for hard-cut deprecation in the agent_live_sessions schema convergence (CWS-2026-05-08 P0). Private cueapi never had agent_shells; it has the richer agent_live_sessions table (carries cue_id + task_name + monitor_version + session_token, load-bearing for cmotigtnx Live attestation per CWS-2026-05-08 Item 6). Convergence plan ports private's schema into OSS as the canonical primitive, drops agent_shells. See https://trydock.ai/workspaces/cueapi-agent-live-sessions-port-2026-05-09." }, { "path": "app/models/agent_shell.py", - "reason": "OSS-only — slated for hard-cut deprecation in the agent_live_sessions schema convergence. See alembic/versions/023_messaging_primitive_multi_shell.py exclusion for full context. Replacement: app/models/agent_live_session.py (will be added as a parity-tracked file when commits 2-5 land)." + "reason": "OSS-only \u2014 slated for hard-cut deprecation in the agent_live_sessions schema convergence. See alembic/versions/023_messaging_primitive_multi_shell.py exclusion for full context. Replacement: app/models/agent_live_session.py (will be added as a parity-tracked file when commits 2-5 land)." }, { "path": "app/routers/agent_shells.py", - "reason": "OSS-only — slated for hard-cut deprecation in the agent_live_sessions schema convergence. See alembic/versions/023_messaging_primitive_multi_shell.py exclusion. Replacement: endpoints under /v1/agents/{ref}/live-sessions/* on a new app/routers/agent_live_sessions.py module (will be added as a parity-tracked file when commits 2-5 land)." + "reason": "OSS-only \u2014 slated for hard-cut deprecation in the agent_live_sessions schema convergence. See alembic/versions/023_messaging_primitive_multi_shell.py exclusion. Replacement: endpoints under /v1/agents/{ref}/live-sessions/* on a new app/routers/agent_live_sessions.py module (will be added as a parity-tracked file when commits 2-5 land)." } ], - "multi_key_scoping_omission": "The private monorepo has multi-key scoping (migration 039: api_keys table + per-resource api_key_id columns + audit log). cueapi-core does not. The messaging primitive port intentionally drops the api_key_id columns from agents and messages tables, the from_api_key_id field from MessageResponse, and the api_key_id field from AgentResponse. The messaging service layer never used api_key_id for business logic — it was an audit-only field. If multi-key scoping is ever ported to OSS, a follow-up migration can ADD COLUMN ... api_key_id at that time without breaking existing deployments.", + "multi_key_scoping_omission": "The private monorepo has multi-key scoping (migration 039: api_keys table + per-resource api_key_id columns + audit log). cueapi-core does not. The messaging primitive port intentionally drops the api_key_id columns from agents and messages tables, the from_api_key_id field from MessageResponse, and the api_key_id field from AgentResponse. The messaging service layer never used api_key_id for business logic \u2014 it was an audit-only field. If multi-key scoping is ever ported to OSS, a follow-up migration can ADD COLUMN ... api_key_id at that time without breaking existing deployments.", "files": { "alembic": [ - {"path": "alembic/env.py", "private_counterpart": "alembic/env.py", "last_synced": "2026-04-16"}, - {"path": "alembic/versions/001_initial_tables.py", "private_counterpart": "alembic/versions/001_initial_tables.py", "last_synced": "2026-04-16"}, - {"path": "alembic/versions/002_executions_and_outbox.py", "private_counterpart": "alembic/versions/002_executions_and_outbox.py", "last_synced": "2026-04-16"}, - {"path": "alembic/versions/003_usage_monthly.py", "private_counterpart": "alembic/versions/003_usage_monthly.py", "last_synced": "2026-04-16"}, - {"path": "alembic/versions/004_device_codes.py", "private_counterpart": "alembic/versions/004_device_codes.py", "last_synced": "2026-04-16"}, - {"path": "alembic/versions/005_add_retry_ready_status.py", "private_counterpart": "alembic/versions/005_add_retry_ready_status.py", "last_synced": "2026-04-16"}, - {"path": "alembic/versions/006_add_outcome_columns.py", "private_counterpart": "alembic/versions/006_add_outcome_columns.py", "last_synced": "2026-04-16"}, - {"path": "alembic/versions/007_worker_transport.py", "private_counterpart": "alembic/versions/007_worker_transport.py", "last_synced": "2026-04-16"}, - {"path": "alembic/versions/008_add_webhook_secret.py", "private_counterpart": "alembic/versions/008_add_webhook_secret.py", "last_synced": "2026-04-16"}, - {"path": "alembic/versions/009_fired_count_and_missed_status.py", "private_counterpart": "alembic/versions/009_fired_count_and_missed_status.py", "last_synced": "2026-04-16"}, - {"path": "alembic/versions/010_unique_user_cue_name.py", "private_counterpart": "alembic/versions/010_unique_user_cue_name.py", "last_synced": "2026-04-16"}, - {"path": "alembic/versions/011_add_blog_posts.py", "private_counterpart": "alembic/versions/011_add_blog_posts.py", "last_synced": "2026-04-16"}, - {"path": "alembic/versions/012_add_content_trends.py", "private_counterpart": "alembic/versions/012_add_content_trends.py", "last_synced": "2026-04-16"}, - {"path": "alembic/versions/013_add_blog_image_data.py", "private_counterpart": "alembic/versions/013_add_blog_image_data.py", "last_synced": "2026-04-16"}, - {"path": "alembic/versions/014_add_support_tickets.py", "private_counterpart": "alembic/versions/014_add_support_tickets.py", "last_synced": "2026-04-16"}, - {"path": "alembic/versions/015_widen_device_code_column.py", "private_counterpart": "alembic/versions/015_widen_device_code_column.py", "last_synced": "2026-04-16"}, - {"path": "alembic/versions/016_add_on_failure_column.py", "private_counterpart": "alembic/versions/016_add_on_failure_column.py", "last_synced": "2026-04-16"}, - {"path": "alembic/versions/028_event_emit_primitive.py", "private_counterpart": "alembic/versions/058_event_emit_primitive.py", "last_synced": "2026-05-11", "ported_in": "event-emit-primitive-port (PR-1b)", "deviation": "Renumbered 058 → 028 (cueapi-core HEAD was 27 revisions behind private). Schema verbatim."}, - {"path": "alembic/versions/029_messaging_emission_columns.py", "private_counterpart": "alembic/versions/059_messaging_emission_columns.py", "last_synced": "2026-05-11", "ported_in": "messaging-emission-port (PR-2a)", "deviation": "Renumbered 059 → 029. Schema verbatim: dispatch_priority_bucket + message_dispatch_error + correlation_id columns + partial-where index on correlation_id."}, - {"path": "alembic/versions/030_message_send_at.py", "private_counterpart": "alembic/versions/047_message_send_at.py", "last_synced": "2026-05-11", "ported_in": "message-send-at-port (private #623)", "deviation": "Renumbered 047 → 030. Schema verbatim: messages.send_at TIMESTAMPTZ NULL + partial index ix_messages_send_at (WHERE send_at IS NOT NULL) built CONCURRENTLY."}, - {"path": "alembic/versions/031_agents_last_seen_at.py", "private_counterpart": "alembic/versions/048_agents_last_seen_at.py", "last_synced": "2026-05-11", "ported_in": "agents-roster-and-last-seen-at-port (private #630)", "deviation": "Renumbered 048 → 031. Schema verbatim: agents.last_seen_at TIMESTAMPTZ NULL (no index — read path is per-tenant + per-agent, already covered by existing indexes)."}, - {"path": "alembic/versions/032_events_digested_at.py", "private_counterpart": "alembic/versions/060_events_digested_at.py", "last_synced": "2026-05-11", "ported_in": "phase-4b-digest-batching-port", "deviation": "Renumbered 060 → 032. Schema verbatim: events.digested_at TIMESTAMPTZ NULL + ix_events_undigested partial index (CONCURRENTLY)."}, - {"path": "alembic/versions/033_subscriptions_inline_body.py", "private_counterpart": "alembic/versions/061_subscriptions_inline_body.py", "last_synced": "2026-05-11", "ported_in": "item-1-inline-body-port", "deviation": "Renumbered 061 → 033. Schema verbatim: subscriptions.inline_body BOOL NOT NULL DEFAULT FALSE."}, - {"path": "alembic/versions/034_subscriptions_last_acked_event_id.py", "private_counterpart": "alembic/versions/062_subscriptions_last_acked_event_id.py", "last_synced": "2026-05-11", "ported_in": "item-2b-cursor-advance-ack-port", "deviation": "Renumbered 062 → 034. Schema verbatim: subscriptions.last_acked_event_id BIGINT NULL."} + { + "path": "alembic/env.py", + "private_counterpart": "alembic/env.py", + "last_synced": "2026-04-16" + }, + { + "path": "alembic/versions/001_initial_tables.py", + "private_counterpart": "alembic/versions/001_initial_tables.py", + "last_synced": "2026-04-16" + }, + { + "path": "alembic/versions/002_executions_and_outbox.py", + "private_counterpart": "alembic/versions/002_executions_and_outbox.py", + "last_synced": "2026-04-16" + }, + { + "path": "alembic/versions/003_usage_monthly.py", + "private_counterpart": "alembic/versions/003_usage_monthly.py", + "last_synced": "2026-04-16" + }, + { + "path": "alembic/versions/004_device_codes.py", + "private_counterpart": "alembic/versions/004_device_codes.py", + "last_synced": "2026-04-16" + }, + { + "path": "alembic/versions/005_add_retry_ready_status.py", + "private_counterpart": "alembic/versions/005_add_retry_ready_status.py", + "last_synced": "2026-04-16" + }, + { + "path": "alembic/versions/006_add_outcome_columns.py", + "private_counterpart": "alembic/versions/006_add_outcome_columns.py", + "last_synced": "2026-04-16" + }, + { + "path": "alembic/versions/007_worker_transport.py", + "private_counterpart": "alembic/versions/007_worker_transport.py", + "last_synced": "2026-04-16" + }, + { + "path": "alembic/versions/008_add_webhook_secret.py", + "private_counterpart": "alembic/versions/008_add_webhook_secret.py", + "last_synced": "2026-04-16" + }, + { + "path": "alembic/versions/009_fired_count_and_missed_status.py", + "private_counterpart": "alembic/versions/009_fired_count_and_missed_status.py", + "last_synced": "2026-04-16" + }, + { + "path": "alembic/versions/010_unique_user_cue_name.py", + "private_counterpart": "alembic/versions/010_unique_user_cue_name.py", + "last_synced": "2026-04-16" + }, + { + "path": "alembic/versions/011_add_blog_posts.py", + "private_counterpart": "alembic/versions/011_add_blog_posts.py", + "last_synced": "2026-04-16" + }, + { + "path": "alembic/versions/012_add_content_trends.py", + "private_counterpart": "alembic/versions/012_add_content_trends.py", + "last_synced": "2026-04-16" + }, + { + "path": "alembic/versions/013_add_blog_image_data.py", + "private_counterpart": "alembic/versions/013_add_blog_image_data.py", + "last_synced": "2026-04-16" + }, + { + "path": "alembic/versions/014_add_support_tickets.py", + "private_counterpart": "alembic/versions/014_add_support_tickets.py", + "last_synced": "2026-04-16" + }, + { + "path": "alembic/versions/015_widen_device_code_column.py", + "private_counterpart": "alembic/versions/015_widen_device_code_column.py", + "last_synced": "2026-04-16" + }, + { + "path": "alembic/versions/016_add_on_failure_column.py", + "private_counterpart": "alembic/versions/016_add_on_failure_column.py", + "last_synced": "2026-04-16" + }, + { + "path": "alembic/versions/028_event_emit_primitive.py", + "private_counterpart": "alembic/versions/058_event_emit_primitive.py", + "last_synced": "2026-05-11", + "ported_in": "event-emit-primitive-port (PR-1b)", + "deviation": "Renumbered 058 \u2192 028 (cueapi-core HEAD was 27 revisions behind private). Schema verbatim." + }, + { + "path": "alembic/versions/029_messaging_emission_columns.py", + "private_counterpart": "alembic/versions/059_messaging_emission_columns.py", + "last_synced": "2026-05-11", + "ported_in": "messaging-emission-port (PR-2a)", + "deviation": "Renumbered 059 \u2192 029. Schema verbatim: dispatch_priority_bucket + message_dispatch_error + correlation_id columns + partial-where index on correlation_id." + }, + { + "path": "alembic/versions/030_message_send_at.py", + "private_counterpart": "alembic/versions/047_message_send_at.py", + "last_synced": "2026-05-11", + "ported_in": "message-send-at-port (private #623)", + "deviation": "Renumbered 047 \u2192 030. Schema verbatim: messages.send_at TIMESTAMPTZ NULL + partial index ix_messages_send_at (WHERE send_at IS NOT NULL) built CONCURRENTLY." + }, + { + "path": "alembic/versions/031_agents_last_seen_at.py", + "private_counterpart": "alembic/versions/048_agents_last_seen_at.py", + "last_synced": "2026-05-11", + "ported_in": "agents-roster-and-last-seen-at-port (private #630)", + "deviation": "Renumbered 048 \u2192 031. Schema verbatim: agents.last_seen_at TIMESTAMPTZ NULL (no index \u2014 read path is per-tenant + per-agent, already covered by existing indexes)." + }, + { + "path": "alembic/versions/032_events_digested_at.py", + "private_counterpart": "alembic/versions/060_events_digested_at.py", + "last_synced": "2026-05-11", + "ported_in": "phase-4b-digest-batching-port", + "deviation": "Renumbered 060 \u2192 032. Schema verbatim: events.digested_at TIMESTAMPTZ NULL + ix_events_undigested partial index (CONCURRENTLY)." + }, + { + "path": "alembic/versions/033_subscriptions_inline_body.py", + "private_counterpart": "alembic/versions/061_subscriptions_inline_body.py", + "last_synced": "2026-05-11", + "ported_in": "item-1-inline-body-port", + "deviation": "Renumbered 061 \u2192 033. Schema verbatim: subscriptions.inline_body BOOL NOT NULL DEFAULT FALSE." + }, + { + "path": "alembic/versions/034_subscriptions_last_acked_event_id.py", + "private_counterpart": "alembic/versions/062_subscriptions_last_acked_event_id.py", + "last_synced": "2026-05-11", + "ported_in": "item-2b-cursor-advance-ack-port", + "deviation": "Renumbered 062 \u2192 034. Schema verbatim: subscriptions.last_acked_event_id BIGINT NULL." + } ], "app_core": [ - {"path": "app/__init__.py", "private_counterpart": "app/__init__.py", "last_synced": "2026-04-16"}, - {"path": "app/auth.py", "private_counterpart": "app/auth.py", "last_synced": "2026-04-16"}, - {"path": "app/config.py", "private_counterpart": "app/config.py", "last_synced": "2026-04-16"}, - {"path": "app/database.py", "private_counterpart": "app/database.py", "last_synced": "2026-04-16"}, - {"path": "app/main.py", "private_counterpart": "app/main.py", "last_synced": "2026-05-11", "ported_in": "bodyverify-layer-1-5-universal-middleware", "deviation": "BodyVerify port: VerifyEchoMiddleware registered between CORS/RequestId and RateLimit. Private has additional middlewares (SecurityHeaders, AccessAudit) hosted-only; ordering preserved relative to shared middlewares."}, - {"path": "app/redis.py", "private_counterpart": "app/redis.py", "last_synced": "2026-04-16"} + { + "path": "app/__init__.py", + "private_counterpart": "app/__init__.py", + "last_synced": "2026-04-16" + }, + { + "path": "app/auth.py", + "private_counterpart": "app/auth.py", + "last_synced": "2026-04-16" + }, + { + "path": "app/config.py", + "private_counterpart": "app/config.py", + "last_synced": "2026-04-16" + }, + { + "path": "app/database.py", + "private_counterpart": "app/database.py", + "last_synced": "2026-04-16" + }, + { + "path": "app/main.py", + "private_counterpart": "app/main.py", + "last_synced": "2026-05-11", + "ported_in": "bodyverify-layer-1-5-universal-middleware", + "deviation": "BodyVerify port: VerifyEchoMiddleware registered between CORS/RequestId and RateLimit. Private has additional middlewares (SecurityHeaders, AccessAudit) hosted-only; ordering preserved relative to shared middlewares." + }, + { + "path": "app/redis.py", + "private_counterpart": "app/redis.py", + "last_synced": "2026-04-16" + } ], "middleware": [ - {"path": "app/middleware/__init__.py", "private_counterpart": "app/middleware/__init__.py", "last_synced": "2026-04-16"}, - {"path": "app/middleware/body_limit.py", "private_counterpart": "app/middleware/body_limit.py", "last_synced": "2026-04-16"}, - {"path": "app/middleware/rate_limit.py", "private_counterpart": "app/middleware/rate_limit.py", "last_synced": "2026-04-16"}, - {"path": "app/middleware/request_id.py", "private_counterpart": "app/middleware/request_id.py", "last_synced": "2026-04-16"}, - {"path": "app/middleware/verify_echo.py", "private_counterpart": "app/middleware/verify_echo.py", "last_synced": "2026-05-11", "ported_in": "bodyverify-layer-1-5-universal-middleware"} + { + "path": "app/middleware/__init__.py", + "private_counterpart": "app/middleware/__init__.py", + "last_synced": "2026-04-16" + }, + { + "path": "app/middleware/body_limit.py", + "private_counterpart": "app/middleware/body_limit.py", + "last_synced": "2026-04-16" + }, + { + "path": "app/middleware/rate_limit.py", + "private_counterpart": "app/middleware/rate_limit.py", + "last_synced": "2026-04-16" + }, + { + "path": "app/middleware/request_id.py", + "private_counterpart": "app/middleware/request_id.py", + "last_synced": "2026-04-16" + }, + { + "path": "app/middleware/verify_echo.py", + "private_counterpart": "app/middleware/verify_echo.py", + "last_synced": "2026-05-11", + "ported_in": "bodyverify-layer-1-5-universal-middleware" + } ], "models": [ - {"path": "app/models/__init__.py", "private_counterpart": "app/models/__init__.py", "last_synced": "2026-04-16"}, - {"path": "app/models/alert.py", "private_counterpart": "app/models/alert.py", "last_synced": "2026-04-17"}, - {"path": "app/models/cue.py", "private_counterpart": "app/models/cue.py", "last_synced": "2026-04-16"}, - {"path": "app/models/device_code.py", "private_counterpart": "app/models/device_code.py", "last_synced": "2026-04-16"}, - {"path": "app/models/dispatch_outbox.py", "private_counterpart": "app/models/dispatch_outbox.py", "last_synced": "2026-04-16"}, - {"path": "app/models/execution.py", "private_counterpart": "app/models/execution.py", "last_synced": "2026-04-16"}, - {"path": "app/models/usage_monthly.py", "private_counterpart": "app/models/usage_monthly.py", "last_synced": "2026-04-16"}, - {"path": "app/models/user.py", "private_counterpart": "app/models/user.py", "last_synced": "2026-04-16"}, - {"path": "app/models/worker.py", "private_counterpart": "app/models/worker.py", "last_synced": "2026-04-16"}, - {"path": "app/models/agent.py", "private_counterpart": "app/models/agent.py", "last_synced": "2026-05-01", "ported_in": "messaging-primitive-port"}, - {"path": "app/models/message.py", "private_counterpart": "app/models/message.py", "last_synced": "2026-05-11", "ported_in": "messaging-primitive-port + messaging-emission-port (PR-2a)", "deviation": "from_api_key_id column omitted (multi-key scoping is hosted-only). PR-2a additions verbatim: dispatch_priority_bucket + message_dispatch_error + correlation_id + ix_messages_correlation_id partial index."}, - {"path": "app/models/usage_messages_monthly.py", "private_counterpart": "app/models/usage_messages_monthly.py", "last_synced": "2026-05-01", "ported_in": "messaging-primitive-port"}, - {"path": "app/models/event.py", "private_counterpart": "app/models/event.py", "last_synced": "2026-05-11", "ported_in": "event-emit-primitive-port (PR-1b) + phase-4b-digest-batching-port", "deviation": "Phase 4b addition: digested_at column + ix_events_undigested partial index mirror."}, - {"path": "app/models/subscription.py", "private_counterpart": "app/models/subscription.py", "last_synced": "2026-05-11", "ported_in": "event-emit-primitive-port (PR-1b) + item-1-inline-body-port + item-2b-cursor-advance-ack-port", "deviation": "Item 1 addition: inline_body BOOL column (default False) for opt-in body embedding. Item 2(b) addition: last_acked_event_id BIGINT NULL watermark column for ack-vs-dispatched distinction."} + { + "path": "app/models/__init__.py", + "private_counterpart": "app/models/__init__.py", + "last_synced": "2026-04-16" + }, + { + "path": "app/models/alert.py", + "private_counterpart": "app/models/alert.py", + "last_synced": "2026-04-17" + }, + { + "path": "app/models/cue.py", + "private_counterpart": "app/models/cue.py", + "last_synced": "2026-04-16" + }, + { + "path": "app/models/device_code.py", + "private_counterpart": "app/models/device_code.py", + "last_synced": "2026-04-16" + }, + { + "path": "app/models/dispatch_outbox.py", + "private_counterpart": "app/models/dispatch_outbox.py", + "last_synced": "2026-04-16" + }, + { + "path": "app/models/execution.py", + "private_counterpart": "app/models/execution.py", + "last_synced": "2026-04-16" + }, + { + "path": "app/models/usage_monthly.py", + "private_counterpart": "app/models/usage_monthly.py", + "last_synced": "2026-04-16" + }, + { + "path": "app/models/user.py", + "private_counterpart": "app/models/user.py", + "last_synced": "2026-04-16" + }, + { + "path": "app/models/worker.py", + "private_counterpart": "app/models/worker.py", + "last_synced": "2026-04-16" + }, + { + "path": "app/models/agent.py", + "private_counterpart": "app/models/agent.py", + "last_synced": "2026-05-01", + "ported_in": "messaging-primitive-port" + }, + { + "path": "app/models/message.py", + "private_counterpart": "app/models/message.py", + "last_synced": "2026-05-11", + "ported_in": "messaging-primitive-port + messaging-emission-port (PR-2a)", + "deviation": "from_api_key_id column omitted (multi-key scoping is hosted-only). PR-2a additions verbatim: dispatch_priority_bucket + message_dispatch_error + correlation_id + ix_messages_correlation_id partial index." + }, + { + "path": "app/models/usage_messages_monthly.py", + "private_counterpart": "app/models/usage_messages_monthly.py", + "last_synced": "2026-05-01", + "ported_in": "messaging-primitive-port" + }, + { + "path": "app/models/event.py", + "private_counterpart": "app/models/event.py", + "last_synced": "2026-05-11", + "ported_in": "event-emit-primitive-port (PR-1b) + phase-4b-digest-batching-port", + "deviation": "Phase 4b addition: digested_at column + ix_events_undigested partial index mirror." + }, + { + "path": "app/models/subscription.py", + "private_counterpart": "app/models/subscription.py", + "last_synced": "2026-05-11", + "ported_in": "event-emit-primitive-port (PR-1b) + item-1-inline-body-port + item-2b-cursor-advance-ack-port", + "deviation": "Item 1 addition: inline_body BOOL column (default False) for opt-in body embedding. Item 2(b) addition: last_acked_event_id BIGINT NULL watermark column for ack-vs-dispatched distinction." + } ], "routers": [ - {"path": "app/routers/__init__.py", "private_counterpart": "app/routers/__init__.py", "last_synced": "2026-04-16"}, - {"path": "app/routers/alerts.py", "private_counterpart": "app/routers/alerts.py", "last_synced": "2026-04-17"}, - {"path": "app/routers/auth_routes.py", "private_counterpart": "app/routers/auth_routes.py", "last_synced": "2026-04-16"}, - {"path": "app/routers/cues.py", "private_counterpart": "app/routers/cues.py", "last_synced": "2026-05-11", "ported_in": "bodyverify-layer-1-substrate-echo-back", "deviation": "BodyVerify port: fire_cue handler integrates apply_verify_echo on response. OSS FireRequest carries only send_at; private's payload_override (dict with user-supplied string content carrying corruption-vector) is hosted-only. Metachar-class parametrization tests on the fire path are intentionally private-only — substrate helper coverage is exercised via messages endpoint tests."}, - {"path": "app/routers/device_code.py", "private_counterpart": "app/routers/device_code.py", "last_synced": "2026-04-16"}, - {"path": "app/routers/echo.py", "private_counterpart": "app/routers/echo.py", "last_synced": "2026-04-16"}, - {"path": "app/routers/executions.py", "private_counterpart": "app/routers/executions.py", "last_synced": "2026-04-16"}, - {"path": "app/routers/health.py", "private_counterpart": "app/routers/health.py", "last_synced": "2026-04-16"}, - {"path": "app/routers/usage.py", "private_counterpart": "app/routers/usage.py", "last_synced": "2026-04-16"}, - {"path": "app/routers/webhook_secret.py", "private_counterpart": "app/routers/webhook_secret.py", "last_synced": "2026-04-16"}, - {"path": "app/routers/workers.py", "private_counterpart": "app/routers/workers.py", "last_synced": "2026-04-16"}, - {"path": "app/routers/agents.py", "private_counterpart": "app/routers/agents.py", "last_synced": "2026-05-01", "ported_in": "messaging-primitive-port"}, - {"path": "app/routers/messages.py", "private_counterpart": "app/routers/messages.py", "last_synced": "2026-05-11", "ported_in": "messaging-primitive-port + messaging-emission-port (PR-2a) + bodyverify-layer-1-substrate-echo-back", "deviation": "PR-2a addition: correlation_id passthrough to create_message service. BodyVerify port: send_message handler integrates apply_verify_echo helper on response — identical shape to private; create_message tuple unpack is 3-element OSS vs 4-element private (private adds bcc_emitted)."}, - {"path": "app/routers/events.py", "private_counterpart": "app/routers/events.py", "last_synced": "2026-05-11", "ported_in": "event-emit-primitive-port (PR-1b) + long-poll-port (PR-1b spec Q1) + item-1-inline-body-port + item-2b-cursor-advance-ack-port", "deviation": "Long-poll uses internal polling loop rather than PostgreSQL LISTEN/NOTIFY per impl trade-off; wire contract identical. Future LISTEN/NOTIFY swap tracked at cmp0m7n7r (private) — file OSS counterpart row when prioritized. Item 1 addition: SubscriptionCreate accepts inline_body field; SubscriptionResponse surfaces it. Item 2(b) addition: PATCH /subscriptions/{id}/ack endpoint + AckSubscriptionRequest schema + _advance_ack_after_pull helper + last_acked_event_id surfaced on SubscriptionResponse."} + { + "path": "app/routers/__init__.py", + "private_counterpart": "app/routers/__init__.py", + "last_synced": "2026-04-16" + }, + { + "path": "app/routers/alerts.py", + "private_counterpart": "app/routers/alerts.py", + "last_synced": "2026-04-17" + }, + { + "path": "app/routers/auth_routes.py", + "private_counterpart": "app/routers/auth_routes.py", + "last_synced": "2026-04-16" + }, + { + "path": "app/routers/cues.py", + "private_counterpart": "app/routers/cues.py", + "last_synced": "2026-05-11", + "ported_in": "bodyverify-layer-1-substrate-echo-back", + "deviation": "BodyVerify port: fire_cue handler integrates apply_verify_echo on response. OSS FireRequest carries only send_at; private's payload_override (dict with user-supplied string content carrying corruption-vector) is hosted-only. Metachar-class parametrization tests on the fire path are intentionally private-only \u2014 substrate helper coverage is exercised via messages endpoint tests. BodyVerify hotfix: helper takes body_text=None on OSS (no string user-content field on FireRequest)." + }, + { + "path": "app/routers/device_code.py", + "private_counterpart": "app/routers/device_code.py", + "last_synced": "2026-04-16" + }, + { + "path": "app/routers/echo.py", + "private_counterpart": "app/routers/echo.py", + "last_synced": "2026-04-16" + }, + { + "path": "app/routers/executions.py", + "private_counterpart": "app/routers/executions.py", + "last_synced": "2026-04-16" + }, + { + "path": "app/routers/health.py", + "private_counterpart": "app/routers/health.py", + "last_synced": "2026-04-16" + }, + { + "path": "app/routers/usage.py", + "private_counterpart": "app/routers/usage.py", + "last_synced": "2026-04-16" + }, + { + "path": "app/routers/webhook_secret.py", + "private_counterpart": "app/routers/webhook_secret.py", + "last_synced": "2026-04-16" + }, + { + "path": "app/routers/workers.py", + "private_counterpart": "app/routers/workers.py", + "last_synced": "2026-04-16" + }, + { + "path": "app/routers/agents.py", + "private_counterpart": "app/routers/agents.py", + "last_synced": "2026-05-01", + "ported_in": "messaging-primitive-port" + }, + { + "path": "app/routers/messages.py", + "private_counterpart": "app/routers/messages.py", + "last_synced": "2026-05-11", + "ported_in": "messaging-primitive-port + messaging-emission-port (PR-2a) + bodyverify-layer-1-substrate-echo-back", + "deviation": "PR-2a addition: correlation_id passthrough to create_message service. BodyVerify port: send_message handler integrates apply_verify_echo helper on response \u2014 identical shape to private; create_message tuple unpack is 3-element OSS vs 4-element private (private adds bcc_emitted). BodyVerify hotfix (2026-05-11): apply_verify_echo signature changed from parsed_body to body_text; handler passes body.body STRING (spec lock)." + }, + { + "path": "app/routers/events.py", + "private_counterpart": "app/routers/events.py", + "last_synced": "2026-05-11", + "ported_in": "event-emit-primitive-port (PR-1b) + long-poll-port (PR-1b spec Q1) + item-1-inline-body-port + item-2b-cursor-advance-ack-port", + "deviation": "Long-poll uses internal polling loop rather than PostgreSQL LISTEN/NOTIFY per impl trade-off; wire contract identical. Future LISTEN/NOTIFY swap tracked at cmp0m7n7r (private) \u2014 file OSS counterpart row when prioritized. Item 1 addition: SubscriptionCreate accepts inline_body field; SubscriptionResponse surfaces it. Item 2(b) addition: PATCH /subscriptions/{id}/ack endpoint + AckSubscriptionRequest schema + _advance_ack_after_pull helper + last_acked_event_id surfaced on SubscriptionResponse." + } ], "schemas": [ - {"path": "app/schemas/__init__.py", "private_counterpart": "app/schemas/__init__.py", "last_synced": "2026-04-16"}, - {"path": "app/schemas/alert.py", "private_counterpart": "app/schemas/alert.py", "last_synced": "2026-04-17"}, - {"path": "app/schemas/cue.py", "private_counterpart": "app/schemas/cue.py", "last_synced": "2026-04-16"}, - {"path": "app/schemas/execution.py", "private_counterpart": "app/schemas/execution.py", "last_synced": "2026-04-16"}, - {"path": "app/schemas/outcome.py", "private_counterpart": "app/schemas/outcome.py", "last_synced": "2026-04-16"}, - {"path": "app/schemas/worker.py", "private_counterpart": "app/schemas/worker.py", "last_synced": "2026-04-16"}, - {"path": "app/schemas/agent.py", "private_counterpart": "app/schemas/agent.py", "last_synced": "2026-05-01", "ported_in": "messaging-primitive-port", "deviation": "api_key_id field omitted from AgentResponse (multi-key scoping is hosted-only)"}, - {"path": "app/schemas/message.py", "private_counterpart": "app/schemas/message.py", "last_synced": "2026-05-11", "ported_in": "messaging-primitive-port + messaging-emission-port (PR-2a)", "deviation": "from_api_key_id field omitted from MessageResponse (multi-key scoping is hosted-only). PR-2a additions verbatim: correlation_id on MessageCreate; correlation_id + dispatch_priority_bucket + message_dispatch_error on MessageResponse."} + { + "path": "app/schemas/__init__.py", + "private_counterpart": "app/schemas/__init__.py", + "last_synced": "2026-04-16" + }, + { + "path": "app/schemas/alert.py", + "private_counterpart": "app/schemas/alert.py", + "last_synced": "2026-04-17" + }, + { + "path": "app/schemas/cue.py", + "private_counterpart": "app/schemas/cue.py", + "last_synced": "2026-04-16" + }, + { + "path": "app/schemas/execution.py", + "private_counterpart": "app/schemas/execution.py", + "last_synced": "2026-04-16" + }, + { + "path": "app/schemas/outcome.py", + "private_counterpart": "app/schemas/outcome.py", + "last_synced": "2026-04-16" + }, + { + "path": "app/schemas/worker.py", + "private_counterpart": "app/schemas/worker.py", + "last_synced": "2026-04-16" + }, + { + "path": "app/schemas/agent.py", + "private_counterpart": "app/schemas/agent.py", + "last_synced": "2026-05-01", + "ported_in": "messaging-primitive-port", + "deviation": "api_key_id field omitted from AgentResponse (multi-key scoping is hosted-only)" + }, + { + "path": "app/schemas/message.py", + "private_counterpart": "app/schemas/message.py", + "last_synced": "2026-05-11", + "ported_in": "messaging-primitive-port + messaging-emission-port (PR-2a)", + "deviation": "from_api_key_id field omitted from MessageResponse (multi-key scoping is hosted-only). PR-2a additions verbatim: correlation_id on MessageCreate; correlation_id + dispatch_priority_bucket + message_dispatch_error on MessageResponse." + } ], "services": [ - {"path": "app/services/__init__.py", "private_counterpart": "app/services/__init__.py", "last_synced": "2026-04-16"}, - {"path": "app/services/alert_service.py", "private_counterpart": "app/services/alert_service.py", "last_synced": "2026-04-17"}, - {"path": "app/services/alert_webhook.py", "private_counterpart": "app/services/alert_webhook.py", "last_synced": "2026-04-17"}, - {"path": "app/services/cue_service.py", "private_counterpart": "app/services/cue_service.py", "last_synced": "2026-04-16"}, - {"path": "app/services/device_code_service.py", "private_counterpart": "app/services/device_code_service.py", "last_synced": "2026-04-16"}, - {"path": "app/services/outcome_service.py", "private_counterpart": "app/services/outcome_service.py", "last_synced": "2026-04-16"}, - {"path": "app/services/usage_service.py", "private_counterpart": "app/services/usage_service.py", "last_synced": "2026-04-16"}, - {"path": "app/services/webhook.py", "private_counterpart": "app/services/webhook.py", "last_synced": "2026-04-16"}, - {"path": "app/services/agent_service.py", "private_counterpart": "app/services/agent_service.py", "last_synced": "2026-05-01", "ported_in": "messaging-primitive-port", "deviation": "user.api_key_id reference removed (multi-key scoping is hosted-only)"}, - {"path": "app/services/inbox_service.py", "private_counterpart": "app/services/inbox_service.py", "last_synced": "2026-05-01", "ported_in": "messaging-primitive-port"}, - {"path": "app/services/message_classification.py", "private_counterpart": "app/services/message_classification.py", "last_synced": "2026-05-01", "ported_in": "messaging-primitive-port"}, - {"path": "app/services/message_delivery.py", "private_counterpart": "app/services/message_delivery.py", "last_synced": "2026-05-01", "ported_in": "messaging-primitive-port"}, - {"path": "app/services/message_service.py", "private_counterpart": "app/services/message_service.py", "last_synced": "2026-05-11", "ported_in": "messaging-primitive-port + messaging-emission-port (PR-2a) + item-1-inline-body-port", "deviation": "user.api_key_id and msg.from_api_key_id references removed (hosted-only). PR-2a additions verbatim: correlation_id kwarg + dispatch_priority_bucket stamp + emit_event call (best-effort try/except) in create_message; correlation_id + dispatch_priority_bucket + message_dispatch_error surfaced in to_response_dict. Item 1 addition: body_text=body forwarded to emit_event so subscription inline_body=True opt-ins receive embedded body."}, - {"path": "app/services/message_usage_service.py", "private_counterpart": "app/services/message_usage_service.py", "last_synced": "2026-05-01", "ported_in": "messaging-primitive-port"}, - {"path": "app/services/events_service.py", "private_counterpart": "app/services/events_service.py", "last_synced": "2026-05-11", "ported_in": "event-emit-primitive-port (PR-1b) + phase-4b-digest-batching-port + item-2a-turn-pass-port + item-1-inline-body-port + item-2b-cursor-advance-ack-port", "deviation": "Phase 4b addition: `message.digest` added to KNOWN_EVENT_TYPES registry. Item 2(a) addition: `turn.pass` added — META-only envelope for inbox-watcher recipes. Item 1 addition: INLINE_BODY_MAX_BYTES constant (32KB) + `_maybe_embed_body` pure helper + emit_event accepts body_text=None kwarg + create_subscription accepts inline_body=False kwarg. Body embedding gated on active subscription's inline_body=True; size guardrail with body_omitted flag for >32KB bodies. Item 2(b) addition: advance_ack_watermark + ack_subscription service helpers with watermark monotonicity (never rewinds)."} + { + "path": "app/services/__init__.py", + "private_counterpart": "app/services/__init__.py", + "last_synced": "2026-04-16" + }, + { + "path": "app/services/alert_service.py", + "private_counterpart": "app/services/alert_service.py", + "last_synced": "2026-04-17" + }, + { + "path": "app/services/alert_webhook.py", + "private_counterpart": "app/services/alert_webhook.py", + "last_synced": "2026-04-17" + }, + { + "path": "app/services/cue_service.py", + "private_counterpart": "app/services/cue_service.py", + "last_synced": "2026-04-16" + }, + { + "path": "app/services/device_code_service.py", + "private_counterpart": "app/services/device_code_service.py", + "last_synced": "2026-04-16" + }, + { + "path": "app/services/outcome_service.py", + "private_counterpart": "app/services/outcome_service.py", + "last_synced": "2026-04-16" + }, + { + "path": "app/services/usage_service.py", + "private_counterpart": "app/services/usage_service.py", + "last_synced": "2026-04-16" + }, + { + "path": "app/services/webhook.py", + "private_counterpart": "app/services/webhook.py", + "last_synced": "2026-04-16" + }, + { + "path": "app/services/agent_service.py", + "private_counterpart": "app/services/agent_service.py", + "last_synced": "2026-05-01", + "ported_in": "messaging-primitive-port", + "deviation": "user.api_key_id reference removed (multi-key scoping is hosted-only)" + }, + { + "path": "app/services/inbox_service.py", + "private_counterpart": "app/services/inbox_service.py", + "last_synced": "2026-05-01", + "ported_in": "messaging-primitive-port" + }, + { + "path": "app/services/message_classification.py", + "private_counterpart": "app/services/message_classification.py", + "last_synced": "2026-05-01", + "ported_in": "messaging-primitive-port" + }, + { + "path": "app/services/message_delivery.py", + "private_counterpart": "app/services/message_delivery.py", + "last_synced": "2026-05-01", + "ported_in": "messaging-primitive-port" + }, + { + "path": "app/services/message_service.py", + "private_counterpart": "app/services/message_service.py", + "last_synced": "2026-05-11", + "ported_in": "messaging-primitive-port + messaging-emission-port (PR-2a) + item-1-inline-body-port", + "deviation": "user.api_key_id and msg.from_api_key_id references removed (hosted-only). PR-2a additions verbatim: correlation_id kwarg + dispatch_priority_bucket stamp + emit_event call (best-effort try/except) in create_message; correlation_id + dispatch_priority_bucket + message_dispatch_error surfaced in to_response_dict. Item 1 addition: body_text=body forwarded to emit_event so subscription inline_body=True opt-ins receive embedded body." + }, + { + "path": "app/services/message_usage_service.py", + "private_counterpart": "app/services/message_usage_service.py", + "last_synced": "2026-05-01", + "ported_in": "messaging-primitive-port" + }, + { + "path": "app/services/events_service.py", + "private_counterpart": "app/services/events_service.py", + "last_synced": "2026-05-11", + "ported_in": "event-emit-primitive-port (PR-1b) + phase-4b-digest-batching-port + item-2a-turn-pass-port + item-1-inline-body-port + item-2b-cursor-advance-ack-port", + "deviation": "Phase 4b addition: `message.digest` added to KNOWN_EVENT_TYPES registry. Item 2(a) addition: `turn.pass` added \u2014 META-only envelope for inbox-watcher recipes. Item 1 addition: INLINE_BODY_MAX_BYTES constant (32KB) + `_maybe_embed_body` pure helper + emit_event accepts body_text=None kwarg + create_subscription accepts inline_body=False kwarg. Body embedding gated on active subscription's inline_body=True; size guardrail with body_omitted flag for >32KB bodies. Item 2(b) addition: advance_ack_watermark + ack_subscription service helpers with watermark monotonicity (never rewinds)." + } ], "utils": [ - {"path": "app/utils/__init__.py", "private_counterpart": "app/utils/__init__.py", "last_synced": "2026-04-16"}, - {"path": "app/utils/auth_rate_limit.py", "private_counterpart": "app/utils/auth_rate_limit.py", "last_synced": "2026-04-16"}, - {"path": "app/utils/ids.py", "private_counterpart": "app/utils/ids.py", "last_synced": "2026-04-16"}, - {"path": "app/utils/logging.py", "private_counterpart": "app/utils/logging.py", "last_synced": "2026-04-16"}, - {"path": "app/utils/session.py", "private_counterpart": "app/utils/session.py", "last_synced": "2026-04-16"}, - {"path": "app/utils/signing.py", "private_counterpart": "app/utils/signing.py", "last_synced": "2026-04-16"}, - {"path": "app/utils/templates.py", "private_counterpart": "app/utils/templates.py", "last_synced": "2026-04-16"}, - {"path": "app/utils/url_validation.py", "private_counterpart": "app/utils/url_validation.py", "last_synced": "2026-04-16"}, - {"path": "app/utils/retry_after.py", "private_counterpart": "app/utils/retry_after.py", "last_synced": "2026-05-01", "ported_in": "messaging-primitive-port"}, - {"path": "app/utils/slug.py", "private_counterpart": "app/utils/slug.py", "last_synced": "2026-05-01", "ported_in": "messaging-primitive-port"}, - {"path": "app/utils/verify_echo.py", "private_counterpart": "app/utils/verify_echo.py", "last_synced": "2026-05-11", "ported_in": "bodyverify-layer-1-substrate-echo-back"} + { + "path": "app/utils/__init__.py", + "private_counterpart": "app/utils/__init__.py", + "last_synced": "2026-04-16" + }, + { + "path": "app/utils/auth_rate_limit.py", + "private_counterpart": "app/utils/auth_rate_limit.py", + "last_synced": "2026-04-16" + }, + { + "path": "app/utils/ids.py", + "private_counterpart": "app/utils/ids.py", + "last_synced": "2026-04-16" + }, + { + "path": "app/utils/logging.py", + "private_counterpart": "app/utils/logging.py", + "last_synced": "2026-04-16" + }, + { + "path": "app/utils/session.py", + "private_counterpart": "app/utils/session.py", + "last_synced": "2026-04-16" + }, + { + "path": "app/utils/signing.py", + "private_counterpart": "app/utils/signing.py", + "last_synced": "2026-04-16" + }, + { + "path": "app/utils/templates.py", + "private_counterpart": "app/utils/templates.py", + "last_synced": "2026-04-16" + }, + { + "path": "app/utils/url_validation.py", + "private_counterpart": "app/utils/url_validation.py", + "last_synced": "2026-04-16" + }, + { + "path": "app/utils/retry_after.py", + "private_counterpart": "app/utils/retry_after.py", + "last_synced": "2026-05-01", + "ported_in": "messaging-primitive-port" + }, + { + "path": "app/utils/slug.py", + "private_counterpart": "app/utils/slug.py", + "last_synced": "2026-05-01", + "ported_in": "messaging-primitive-port" + }, + { + "path": "app/utils/verify_echo.py", + "private_counterpart": "app/utils/verify_echo.py", + "last_synced": "2026-05-11", + "ported_in": "bodyverify-layer-1-substrate-echo-back + bodyverify-string-shape-hotfix" + } ], "worker": [ - {"path": "worker/tasks.py", "private_counterpart": "worker/tasks.py", "last_synced": "2026-05-01", "ported_in": "messaging-primitive-port", "deviation": "ports messaging-specific functions only (deliver_message_task, retry_message_task, _check_concurrent_cap_or_recycle, _release_concurrent, _load_message_context, _claim_message, _route_attempt_outcome) — does not port other private deltas (catch_up_policy, heartbeat trigger, etc., scheduled for a follow-up phase)"}, - {"path": "worker/poller.py", "private_counterpart": "worker/poller.py", "last_synced": "2026-05-11", "ported_in": "messaging-primitive-port + phase-4b-digest-batching-port", "deviation": "ports messaging-specific changes only (recover_stale_message_deliveries function + dispatch_outbox scheduled_at filter + deliver_message/retry_message routing). Phase 4b addition: _last_digest_emit guard + DIGEST_PERIOD_SECONDS-cadenced emit_digests call wrapped in try/except."}, - {"path": "worker/subscription_dispatcher.py", "private_counterpart": "worker/subscription_dispatcher.py", "last_synced": "2026-05-11", "ported_in": "event-emit-primitive-port (PR-1b) + phase-4a-policy-port + phase-4c-observability-port + item-2b-cursor-advance-ack-port", "deviation": "Phase 4a additions verbatim: redis=None kwarg + apply_tier_policy pre-deliver filter + stamp_dispatch_markers post-deliver side effect + watermark math caveat for deferred events. Phase 4c addition: per-cycle tier_fired_counts + tier_deferred_counts dicts + structured `subscription_dispatch_cycle` log emission (signal-to-noise gated on total>0). Item 2(b) addition: successful webhook fire UPDATE also sets last_acked_event_id=new_watermark (treating successful delivery as ack). Backward-compat default (redis=None) preserves v1 behavior."}, - {"path": "worker/subscription_dispatcher_policy.py", "private_counterpart": "worker/subscription_dispatcher_policy.py", "last_synced": "2026-05-11", "ported_in": "phase-4a-policy-port"}, - {"path": "worker/digest_emitter.py", "private_counterpart": "worker/digest_emitter.py", "last_synced": "2026-05-11", "ported_in": "phase-4b-digest-batching-port"} + { + "path": "worker/tasks.py", + "private_counterpart": "worker/tasks.py", + "last_synced": "2026-05-01", + "ported_in": "messaging-primitive-port", + "deviation": "ports messaging-specific functions only (deliver_message_task, retry_message_task, _check_concurrent_cap_or_recycle, _release_concurrent, _load_message_context, _claim_message, _route_attempt_outcome) \u2014 does not port other private deltas (catch_up_policy, heartbeat trigger, etc., scheduled for a follow-up phase)" + }, + { + "path": "worker/poller.py", + "private_counterpart": "worker/poller.py", + "last_synced": "2026-05-11", + "ported_in": "messaging-primitive-port + phase-4b-digest-batching-port", + "deviation": "ports messaging-specific changes only (recover_stale_message_deliveries function + dispatch_outbox scheduled_at filter + deliver_message/retry_message routing). Phase 4b addition: _last_digest_emit guard + DIGEST_PERIOD_SECONDS-cadenced emit_digests call wrapped in try/except." + }, + { + "path": "worker/subscription_dispatcher.py", + "private_counterpart": "worker/subscription_dispatcher.py", + "last_synced": "2026-05-11", + "ported_in": "event-emit-primitive-port (PR-1b) + phase-4a-policy-port + phase-4c-observability-port + item-2b-cursor-advance-ack-port", + "deviation": "Phase 4a additions verbatim: redis=None kwarg + apply_tier_policy pre-deliver filter + stamp_dispatch_markers post-deliver side effect + watermark math caveat for deferred events. Phase 4c addition: per-cycle tier_fired_counts + tier_deferred_counts dicts + structured `subscription_dispatch_cycle` log emission (signal-to-noise gated on total>0). Item 2(b) addition: successful webhook fire UPDATE also sets last_acked_event_id=new_watermark (treating successful delivery as ack). Backward-compat default (redis=None) preserves v1 behavior." + }, + { + "path": "worker/subscription_dispatcher_policy.py", + "private_counterpart": "worker/subscription_dispatcher_policy.py", + "last_synced": "2026-05-11", + "ported_in": "phase-4a-policy-port" + }, + { + "path": "worker/digest_emitter.py", + "private_counterpart": "worker/digest_emitter.py", + "last_synced": "2026-05-11", + "ported_in": "phase-4b-digest-batching-port" + } ] } -} +} \ No newline at end of file diff --git a/tests/test_verify_echo.py b/tests/test_verify_echo.py index 41acaf6..d5ab438 100644 --- a/tests/test_verify_echo.py +++ b/tests/test_verify_echo.py @@ -1,20 +1,20 @@ -"""Tests for BodyVerify Layer 1 — substrate echo-back primitive. +"""Tests for BodyVerify Layer 1 — substrate echo-back primitive (STRING-shape spec). Design doc: https://trydock.ai/workspaces/cue-message-silent-corruption-substrate-design-2026-05-11 Coverage targets: - Helper ``apply_verify_echo``: header-absent zero-cost no-op, header-present - echo-back, hash determinism, branch coverage (None / Pydantic model / dict / - other), canonical JSON hashing (sorted keys, no whitespace). + echo-back, hash determinism, None-body handling, branch coverage. - ``POST /v1/messages``: round-trip happy path; 6 metachar classes round-trip - byte-identical; no-header → no echo fields (backwards-compat); empty body - (well-formed but minimal content); 32KB cap edge. -- ``POST /v1/cues/{cue_id}/fire``: round-trip with payload_override; metachar - classes; no-body fire (FireRequest=None path) still produces echo when header - set. -- Definition of Done item 1 (substrate echo-back): 6 metachar classes assertion - matrix covering backticks, $-paren, ${VAR}, backslash, quotes, mixed. + byte-identical (asserting STRING type + sha256(sent_body) == response hash); + no-header → no echo fields (backwards-compat); empty body; 32KB cap edge. +- ``POST /v1/cues/{cue_id}/fire``: round-trip with payload_override.message + (canonical live-cue content vector); metachar classes; no-body fire + (FireRequest=None path) still produces echo when header set; + payload_override without 'message' key falls back to canonical JSON dump. +- Definition of Done item 1: 6 metachar classes (backticks, $-paren, ${VAR}, + backslash, quotes, mixed). """ from __future__ import annotations @@ -27,7 +27,6 @@ from app.utils.verify_echo import ( VERIFY_ECHO_HEADER, - _canonical_json_bytes, apply_verify_echo, verify_echo_requested, ) @@ -76,62 +75,46 @@ def test_verify_echo_requested_false_when_not_true(): def test_apply_verify_echo_returns_empty_dict_without_header(): req = _fake_request({}) - assert apply_verify_echo(request=req, parsed_body={"any": "value"}) == {} + assert apply_verify_echo(request=req, body_text="any string") == {} def test_apply_verify_echo_none_body(): req = _fake_request({VERIFY_ECHO_HEADER: "true"}) - result = apply_verify_echo(request=req, parsed_body=None) + result = apply_verify_echo(request=req, body_text=None) assert result["body_received"] is None # SHA256 of empty bytes is a well-known constant. assert result["body_received_sha256"] == hashlib.sha256(b"").hexdigest() -def test_apply_verify_echo_dict_body(): +def test_apply_verify_echo_string_body_round_trips_byte_identical(): + """Spec: body_received is STRING; hash matches sha256(string.encode()).""" req = _fake_request({VERIFY_ECHO_HEADER: "true"}) - body = {"message": "hello", "priority": 3} - result = apply_verify_echo(request=req, parsed_body=body) + body = "hello world" + result = apply_verify_echo(request=req, body_text=body) assert result["body_received"] == body - expected = hashlib.sha256( - json.dumps(body, sort_keys=True, separators=(",", ":")).encode("utf-8") - ).hexdigest() + assert isinstance(result["body_received"], str) + expected = hashlib.sha256(b"hello world").hexdigest() assert result["body_received_sha256"] == expected -def test_apply_verify_echo_pydantic_model_body(): - from pydantic import BaseModel - - class _M(BaseModel): - a: str - b: int - +def test_apply_verify_echo_empty_string_body(): + """Empty string is distinct from None: echo as empty string + hash of empty bytes.""" req = _fake_request({VERIFY_ECHO_HEADER: "true"}) - model = _M(a="x", b=42) - result = apply_verify_echo(request=req, parsed_body=model) - assert result["body_received"] == {"a": "x", "b": 42} - assert isinstance(result["body_received_sha256"], str) - assert len(result["body_received_sha256"]) == 64 - - -def test_apply_verify_echo_hash_deterministic_across_key_order(): - """Canonical JSON (sorted keys) means {a,b} and {b,a} hash identically.""" - req = _fake_request({VERIFY_ECHO_HEADER: "true"}) - r1 = apply_verify_echo(request=req, parsed_body={"a": 1, "b": 2}) - r2 = apply_verify_echo(request=req, parsed_body={"b": 2, "a": 1}) - assert r1["body_received_sha256"] == r2["body_received_sha256"] + result = apply_verify_echo(request=req, body_text="") + assert result["body_received"] == "" + assert isinstance(result["body_received"], str) + assert result["body_received_sha256"] == hashlib.sha256(b"").hexdigest() -def test_apply_verify_echo_other_type_body(): +def test_apply_verify_echo_unicode_body_hashes_utf8(): + """Unicode body hashes match sha256 of UTF-8 encoded bytes.""" req = _fake_request({VERIFY_ECHO_HEADER: "true"}) - result = apply_verify_echo(request=req, parsed_body=12345) - assert result["body_received"] == "12345" - assert result["body_received_sha256"] == hashlib.sha256(b"12345").hexdigest() - - -def test_canonical_json_bytes_unicode_preserved(): - """ensure_ascii=False → unicode chars round-trip byte-faithful in hash.""" - out = _canonical_json_bytes({"msg": "héllo"}) - assert b"h\xc3\xa9llo" in out + body = "héllo 👋" + result = apply_verify_echo(request=req, body_text=body) + assert result["body_received"] == body + assert result["body_received_sha256"] == hashlib.sha256( + body.encode("utf-8") + ).hexdigest() # ─────────────────────────────────────────────────────────────────────── @@ -170,7 +153,11 @@ async def test_messages_no_header_no_echo_fields(client, auth_headers): @pytest.mark.asyncio async def test_messages_echo_roundtrip_happy_path(client, auth_headers): - """Header present → response includes body_received matching sent body.""" + """Spec: body_received is STRING value of MessageCreate.body — NOT envelope dict. + + Caller-side recipe: sha256(sent_body.encode()) MUST match + response.body_received_sha256. + """ sender = await _make_agent(client, auth_headers, slug=f"echo-hp-s-{uuid.uuid4().hex[:6]}") recipient = await _make_agent(client, auth_headers, slug=f"echo-hp-r-{uuid.uuid4().hex[:6]}") body_text = "round-trip test body" @@ -185,11 +172,14 @@ async def test_messages_echo_roundtrip_happy_path(client, auth_headers): ) assert r.status_code == 201 data = r.json() - assert "body_received" in data - assert data["body_received"]["body"] == body_text - assert data["body_received"]["to"] == recipient["id"] - assert isinstance(data["body_received_sha256"], str) - assert len(data["body_received_sha256"]) == 64 + # Spec lock: STRING, not envelope dict. + assert isinstance( + data["body_received"], str + ), f"body_received must be str; got {type(data['body_received']).__name__}" + assert data["body_received"] == body_text + # Caller-side hash MUST match. + expected_hash = hashlib.sha256(body_text.encode("utf-8")).hexdigest() + assert data["body_received_sha256"] == expected_hash @pytest.mark.parametrize( @@ -207,7 +197,8 @@ async def test_messages_echo_roundtrip_happy_path(client, auth_headers): async def test_messages_echo_six_metachar_classes( client, auth_headers, metachar_class, payload ): - """Definition of Done item 1: byte-identical round-trip for 6 metachar classes.""" + """Definition of Done item 1: STRING body_received + matching sha256 across + 6 metachar classes.""" sender = await _make_agent( client, auth_headers, slug=f"echo-{metachar_class[:6]}-s-{uuid.uuid4().hex[:5]}" ) @@ -225,13 +216,19 @@ async def test_messages_echo_six_metachar_classes( ) assert r.status_code == 201, r.text data = r.json() + assert isinstance(data["body_received"], str), f"metachar class {metachar_class}" assert ( - data["body_received"]["body"] == payload + data["body_received"] == payload ), f"metachar class {metachar_class} did NOT survive round-trip" + # Hash check — the substantive proof for the corruption-detection use case. + expected_hash = hashlib.sha256(payload.encode("utf-8")).hexdigest() + assert ( + data["body_received_sha256"] == expected_hash + ), f"metachar class {metachar_class} sha256 mismatch" @pytest.mark.asyncio -async def test_messages_echo_header_lowercase_value_works(client, auth_headers): +async def test_messages_echo_header_uppercase_value_works(client, auth_headers): """Header value 'TRUE' / 'True' all match (case-insensitive).""" sender = await _make_agent(client, auth_headers, slug=f"echo-case-s-{uuid.uuid4().hex[:6]}") recipient = await _make_agent(client, auth_headers, slug=f"echo-case-r-{uuid.uuid4().hex[:6]}") @@ -246,6 +243,7 @@ async def test_messages_echo_header_lowercase_value_works(client, auth_headers): ) assert r.status_code == 201 assert "body_received" in r.json() + assert isinstance(r.json()["body_received"], str) @pytest.mark.asyncio @@ -268,10 +266,9 @@ async def test_messages_echo_header_false_value_no_fields(client, auth_headers): @pytest.mark.asyncio async def test_messages_echo_32kb_cap_edge(client, auth_headers): - """Body at the 32KB inline cap still round-trips byte-identical.""" + """Body at the 32KB inline cap still round-trips byte-identical (STRING).""" sender = await _make_agent(client, auth_headers, slug=f"echo-32k-s-{uuid.uuid4().hex[:6]}") recipient = await _make_agent(client, auth_headers, slug=f"echo-32k-r-{uuid.uuid4().hex[:6]}") - # 32 KB minus a safety margin so we sit JUST under the limit large_body = "x" * (32 * 1024 - 100) r = await client.post( "/v1/messages", @@ -284,8 +281,11 @@ async def test_messages_echo_32kb_cap_edge(client, auth_headers): ) assert r.status_code == 201, r.text data = r.json() - assert data["body_received"]["body"] == large_body - assert len(data["body_received"]["body"]) == len(large_body) + assert isinstance(data["body_received"], str) + assert data["body_received"] == large_body + assert data["body_received_sha256"] == hashlib.sha256( + large_body.encode("utf-8") + ).hexdigest() # ─────────────────────────────────────────────────────────────────────── @@ -321,50 +321,50 @@ async def test_fire_no_header_no_echo_fields(client, auth_headers): assert "body_received_sha256" not in data +# Note: payload_override-based fire metachar tests are intentionally OSS-omitted. +# OSS FireRequest carries only `send_at` (datetime) — no string user-content +# field where shell-expansion corruption could occur. Hosted's payload_override +# (dict with user-supplied strings) is hosted-only; the metachar-round-trip +# discipline IS exercised on the /v1/messages endpoint above (same substrate +# helper, same six classes). See parity-manifest ``oss_only_exclusions``. + + @pytest.mark.asyncio -async def test_fire_echo_with_send_at(client, auth_headers): - """Header present + FireRequest body → response echoes parsed FireRequest. - - Note: OSS ``FireRequest`` carries only ``send_at`` (datetime). Hosted's - ``payload_override`` (dict with user content) is the corruption vector - on the fire path and lives in cueapi/cueapi only. This test exercises - the substrate echo-back against whatever fields OSS ``FireRequest`` - exposes; metachar parametrization on the fire path is intentionally - private-only (see parity-manifest deviation note). - """ - from datetime import datetime, timedelta, timezone +async def test_fire_echo_no_body_returns_none_echo(client, auth_headers): + """Header set on no-body fire → body_received=None + sha256 of empty bytes.""" cue_id = await _create_fire_cue(client, auth_headers) - future = (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat() r = await client.post( f"/v1/cues/{cue_id}/fire", - json={"send_at": future}, headers={**auth_headers, VERIFY_ECHO_HEADER: "true"}, ) assert r.status_code == 200, r.text data = r.json() - assert "body_received" in data - # FireRequest.send_at is the only OSS field; assert it round-trips. - assert data["body_received"]["send_at"] is not None - assert len(data["body_received_sha256"]) == 64 + assert data["body_received"] is None + assert data["body_received_sha256"] == hashlib.sha256(b"").hexdigest() @pytest.mark.asyncio -async def test_fire_echo_no_body_returns_none_echo(client, auth_headers): - """Header set but no fire request body → body_received=None (FireRequest=None path).""" +async def test_fire_echo_with_send_at_body_still_none(client, auth_headers): + """OSS FireRequest body (send_at) carries no string user-content; echo None. + + OSS-specific: the fire endpoint passes ``body_text=None`` to the helper + regardless of whether ``send_at`` is set, since send_at is a datetime not + a corruption-vulnerable string. Caller-side sha256(send_at_iso) does NOT + need to match anything — verify-echo on the fire path is a no-op contract + on OSS until a content-bearing field is added. + """ + from datetime import datetime, timedelta, timezone cue_id = await _create_fire_cue(client, auth_headers) + future = (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat() r = await client.post( f"/v1/cues/{cue_id}/fire", + json={"send_at": future}, headers={**auth_headers, VERIFY_ECHO_HEADER: "true"}, ) assert r.status_code == 200, r.text data = r.json() - assert "body_received" in data assert data["body_received"] is None - # SHA256 of empty bytes - assert ( - data["body_received_sha256"] - == hashlib.sha256(b"").hexdigest() - ) + assert data["body_received_sha256"] == hashlib.sha256(b"").hexdigest() @pytest.mark.asyncio @@ -377,21 +377,10 @@ async def test_fire_echo_preserves_original_response_fields(client, auth_headers ) assert r.status_code == 200 data = r.json() - # Original response shape preserved assert "id" in data assert "cue_id" in data assert data["cue_id"] == cue_id assert data["status"] == "pending" assert data["triggered_by"] == "manual_fire" - # Echo fields additionally present assert "body_received" in data assert "body_received_sha256" in data - - -# Note: metachar-class parametrization on the fire path is private-only. -# OSS ``FireRequest`` carries only ``send_at`` (datetime); hosted's -# ``payload_override`` (dict with user-supplied string content) is the -# corruption vector and lives in cueapi/cueapi exclusively. See -# parity-manifest ``oss_only_exclusions`` for the deviation note. -# The metachar-class round-trip discipline IS exercised on the -# /v1/messages endpoint above — same substrate helper, same six classes. diff --git a/tests/test_verify_echo_middleware.py b/tests/test_verify_echo_middleware.py index 1864c70..7837ce3 100644 --- a/tests/test_verify_echo_middleware.py +++ b/tests/test_verify_echo_middleware.py @@ -173,10 +173,11 @@ async def test_phase_1_messages_endpoint_idempotent_under_middleware( client, auth_headers ): """Phase 1's send_message handler already injects echo — middleware must - NOT overwrite the handler-supplied ``body_received``. The handler echoes - the parsed Pydantic dict; middleware would echo the raw JSON. We pin the - handler-supplied shape (sentinel: handler's view includes ``body`` key - inside ``body_received``).""" + NOT overwrite the handler-supplied ``body_received``. With the spec-lock + (cueapi/cueapi#798), the handler extracts the STRING value of + ``MessageCreate.body`` (not the parsed Pydantic dump). Middleware would + echo a different shape (raw JSON dict), so the handler's STRING wins + via idempotency.""" sender = await _make_agent( client, auth_headers, slug=f"idem-s-{uuid.uuid4().hex[:6]}" ) @@ -195,11 +196,10 @@ async def test_phase_1_messages_endpoint_idempotent_under_middleware( assert r.status_code == 201 data = r.json() assert "body_received" in data - # Phase 1 handler dumps the Pydantic MessageCreate, so body_received is - # a dict with the parsed fields. Middleware-injected echo would also be - # a dict (parsed from raw JSON) — but we want to be sure idempotency - # preserved the handler's view. - assert data["body_received"]["body"] == "idem test" + # Spec-locked shape: body_received is the STRING value of MessageCreate.body. + # If middleware had overwritten the handler's echo, this would be a dict + # (the full parsed JSON body), so the assertion would fail. + assert data["body_received"] == "idem test" # ───────────────────────────────────────────────────────────────────────