Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ All notable changes to cueapi-core will be documented here.
- **`POST /v1/executions/{id}/verify`** now accepts `{valid: bool, reason: str?}`. `valid=true` (default, preserving legacy behavior) transitions to `verified_success`; `valid=false` transitions to `verification_failed` and records the reason onto `evidence_summary` (truncated to 500 chars). Accepted starting states expanded to include `reported_failure`.
- `OutcomeResponse` now surfaces `outcome_state` in the response body.

### Restricted
- Worker-transport cues cannot currently combine with evidence-requiring verification modes (`require_external_id`, `require_result_url`, `require_artifacts`). Attempting to create or PATCH such a combination returns `400 unsupported_verification_for_transport`. `none` and `manual` are allowed for worker cues. This restriction will be lifted once cueapi-worker 0.3.0 (evidence reporting via `CUEAPI_OUTCOME_FILE`) is on PyPI.
### Removed
- **Rejection of `(worker transport, require_*)` verification combos** has been lifted. cueapi-worker 0.3.0 (released 2026-04-17 to PyPI) closes the evidence gap via `$CUEAPI_OUTCOME_FILE`: handlers write evidence JSON to a per-run temp file; the daemon reads the file after exit and merges into the outcome POST. All five verification modes now work on both transports. Operators running older cueapi-worker versions should upgrade via `pip install --upgrade cueapi-worker` — until they do, evidence-requiring modes on worker cues will land every execution in `verification_failed`.

## [0.1.2] - 2026-03-28

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,7 @@ curl -X POST http://localhost:8000/v1/executions/EXEC_ID/verify \

Backward-compat paths still work: `POST /outcome` with just `{success: true}` behaves identically to before, and `PATCH /v1/executions/{id}/evidence` remains available as a two-step alternative.

> Worker-transport cues can currently use `none` or `manual` only. Evidence-requiring modes (`require_external_id`, `require_result_url`, `require_artifacts`) are rejected at create/update time with `400 unsupported_verification_for_transport`. This restriction will be lifted once cueapi-worker 0.3.0 ships to PyPI with evidence reporting via `CUEAPI_OUTCOME_FILE`.
> Worker-transport cues accept every verification mode. Handlers report evidence via `$CUEAPI_OUTCOME_FILE` (cueapi-worker >= 0.3.0 on PyPI as of 2026-04-17). The daemon reads the file after the handler exits and merges the evidence into its outcome POST. If you're still on an older cueapi-worker, the evidence modes will land in `verification_failed` for every execution — `pip install --upgrade cueapi-worker` to unblock.

## Alerts

Expand Down
98 changes: 40 additions & 58 deletions app/services/cue_service.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import json
import logging
from datetime import datetime, timedelta, timezone
from typing import Optional

Expand Down Expand Up @@ -29,47 +30,16 @@ def validate_cron(expression: str) -> bool:
return False


# Verification modes that require evidence on the outcome report.
# Worker transport (today) has no path to attach evidence on the single
# outcome POST, so these modes are rejected for worker cues at create /
# update time. Ref: cueapi-worker < 0.3.0. This rejection is lifted in
# a later PR once cueapi-worker 0.3.0 (CUEAPI_OUTCOME_FILE) is on PyPI.
_EVIDENCE_REQUIRING_MODES = frozenset(
{"require_external_id", "require_result_url", "require_artifacts"}
)
_WORKER_COMPATIBLE_MODES = ("none", "manual")


def _check_transport_verification_combo(
transport: str, mode: Optional[str]
) -> Optional[dict]:
"""Reject worker transport paired with evidence-based verification.

Returns an error dict (matching the service-layer error shape) when
the combination is invalid, or None when it's fine. Lives here
rather than as a Pydantic validator because the existing API shape
uses structured 400 errors (``{"error": {"code": ...}}``) and
Pydantic ValueErrors surface as 422 with a different schema.
"""
if transport != "worker" or not mode or mode in _WORKER_COMPATIBLE_MODES:
return None
if mode not in _EVIDENCE_REQUIRING_MODES:
return None
return {
"error": {
"code": "unsupported_verification_for_transport",
"message": (
"Worker transport does not yet support evidence-based "
"verification modes. Use 'none' or 'manual' for worker "
"cues, or switch to webhook transport for evidence "
"verification."
),
"status": 400,
"transport": "worker",
"verification_mode": mode,
"supported_worker_modes": list(_WORKER_COMPATIBLE_MODES),
}
}
logger = logging.getLogger(__name__)


# Worker transport now supports every verification mode. Handlers
# report evidence via ``$CUEAPI_OUTCOME_FILE`` (cueapi-worker >= 0.3.0
# on PyPI as of 2026-04-17); the daemon reads the file after the
# handler exits and merges the evidence into its outcome POST. The
# rejection function that used to gate ``(worker, require_*)`` combos
# was deleted along with this comment's predecessor. See the
# cueapi-worker README for the outcome-file schema and conflict rules.


def _contains_null_byte(obj) -> bool:
Expand Down Expand Up @@ -204,16 +174,22 @@ async def create_cue(db: AsyncSession, user: AuthenticatedUser, data: CueCreate)
transport = data.transport or "webhook"
warning = None

# Reject worker transport paired with evidence-based verification.
# See ``_check_transport_verification_combo`` for the rationale —
# this will be lifted once cueapi-worker 0.3.0 (evidence reporting
# via CUEAPI_OUTCOME_FILE) is on PyPI.
if data.verification is not None:
combo_err = _check_transport_verification_combo(
transport, data.verification.mode.value
# Note: previously rejected (worker, require_*) combos because
# cueapi-worker had no evidence channel. cueapi-worker 0.3.0
# (CUEAPI_OUTCOME_FILE) closed that gap; all modes are now
# accepted on both transports. Log at INFO so operators with older
# worker versions have a breadcrumb.
if (
transport == "worker"
and data.verification is not None
and data.verification.mode.value in ("require_external_id", "require_result_url", "require_artifacts")
):
logger.info(
"Worker cue created with evidence-based verification mode=%s "
"(requires cueapi-worker >= 0.3.0 on the handler machine to "
"write $CUEAPI_OUTCOME_FILE).",
data.verification.mode.value,
)
if combo_err is not None:
return combo_err

if transport == "webhook":
is_valid, error_msg = validate_callback_url(str(data.callback.url), settings.ENV)
Expand Down Expand Up @@ -456,16 +432,22 @@ async def update_cue(db: AsyncSession, user: AuthenticatedUser, cue_id: str, dat
cue.retry_max_attempts = data.retry.max_attempts
cue.retry_backoff_minutes = data.retry.backoff_minutes

# Verification policy update. Validate the *resulting* (transport,
# mode) combo — transport is effectively immutable via PATCH today,
# so the resulting transport is whatever the cue currently has.
# Verification policy update. All (transport, mode) combinations
# are now accepted — cueapi-worker 0.3.0 closed the worker-side
# evidence gap via $CUEAPI_OUTCOME_FILE. Log at INFO when a worker
# cue is switched to an evidence-based mode so operators have a
# breadcrumb in case older worker versions are still deployed.
if data.verification is not None:
resulting_transport = cue.callback_transport or "webhook"
combo_err = _check_transport_verification_combo(
resulting_transport, data.verification.mode.value
)
if combo_err is not None:
return combo_err
if (
resulting_transport == "worker"
and data.verification.mode.value in ("require_external_id", "require_result_url", "require_artifacts")
):
logger.info(
"Cue %s updated to evidence-based verification mode=%s on "
"worker transport (requires cueapi-worker >= 0.3.0).",
cue.id, data.verification.mode.value,
)
cue.verification_mode = data.verification.mode.value

if data.on_failure is not None:
Expand Down
45 changes: 23 additions & 22 deletions tests/test_transport_verification_combo.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
"""Worker + evidence-based verification rejection.
"""Worker + evidence-based verification: now accepted.

This combo is rejected at cue create/update time because cueapi-worker
< 0.3.0 has no mechanism to attach evidence on the outcome report. The
rejection is lifted in a later PR once cueapi-worker 0.3.0 is on PyPI.
History: this file previously pinned the rejection of
``(worker, require_*)`` combos because cueapi-worker < 0.3.0 had no
mechanism to attach evidence on the outcome report. Those tests
asserted 400 on create and PATCH.

Eight tests: 3 evidence-requiring modes × (create, update, webhook
allowed) + 2 worker-compatible modes confirming the combo is allowed.
cueapi-worker 0.3.0 (CUEAPI_OUTCOME_FILE) shipped to PyPI 2026-04-17
and closes that gap: the handler writes evidence to a per-run temp
file, the daemon merges it into the outcome POST. The rejection was
lifted in the PR that replaces this file's content.

The assertions below now pin the accept behavior. Retained for
regression: if anyone reintroduces the combo-rejection they'll see
these tests fail and have to rationalize the rollback.
"""
from __future__ import annotations

Expand Down Expand Up @@ -34,27 +41,24 @@ def _cue_body(*, transport="worker", mode=None, name=None):
return body


class TestWorkerEvidenceRejectedAtCreate:
class TestWorkerEvidenceAcceptedAtCreate:
@pytest.mark.asyncio
@pytest.mark.parametrize(
"mode",
["require_external_id", "require_result_url", "require_artifacts"],
)
async def test_worker_plus_evidence_mode_rejected(
async def test_worker_plus_evidence_mode_accepted(
self, client: AsyncClient, auth_headers, mode
):
resp = await client.post(
"/v1/cues",
headers=auth_headers,
json=_cue_body(transport="worker", mode=mode),
)
assert resp.status_code == 400
assert resp.status_code == 201, resp.text
body = resp.json()
err = body["detail"]["error"] if "detail" in body else body["error"]
assert err["code"] == "unsupported_verification_for_transport"
assert err["transport"] == "worker"
assert err["verification_mode"] == mode
assert err["supported_worker_modes"] == ["none", "manual"]
assert body["verification"] == {"mode": mode}
assert body["transport"] == "worker" or body["callback"]["transport"] == "worker"


class TestWorkerCompatibleModesAcceptedAtCreate:
Expand Down Expand Up @@ -102,10 +106,11 @@ async def test_webhook_any_mode_accepted(

class TestPatchTransitions:
@pytest.mark.asyncio
async def test_patch_worker_to_evidence_mode_rejected(
async def test_patch_worker_to_evidence_mode_accepted(
self, client: AsyncClient, auth_headers
):
# Create worker cue with no verification
# Create worker cue with no verification, then PATCH to an
# evidence-requiring mode. Previously this returned 400; now 200.
create = await client.post(
"/v1/cues",
headers=auth_headers,
Expand All @@ -114,16 +119,13 @@ async def test_patch_worker_to_evidence_mode_rejected(
assert create.status_code == 201
cue_id = create.json()["id"]

# Try to PATCH verification to an evidence-requiring mode
resp = await client.patch(
f"/v1/cues/{cue_id}",
headers=auth_headers,
json={"verification": {"mode": "require_external_id"}},
)
assert resp.status_code == 400
body = resp.json()
err = body["detail"]["error"] if "detail" in body else body["error"]
assert err["code"] == "unsupported_verification_for_transport"
assert resp.status_code == 200, resp.text
assert resp.json()["verification"] == {"mode": "require_external_id"}

@pytest.mark.asyncio
async def test_patch_webhook_to_evidence_mode_accepted(
Expand Down Expand Up @@ -163,4 +165,3 @@ async def test_patch_worker_to_manual_accepted(
json={"verification": {"mode": "manual"}},
)
assert resp.status_code == 200
assert resp.json()["verification"] == {"mode": "manual"}
Loading