diff --git a/CHANGELOG.md b/CHANGELOG.md
index 20b871e..a0e3750 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,18 @@ existing `0.1.0` release; earlier development prereleases are not listed.
- Clarify that the Forward Decision API is in Beta and its behavior may change in future updates.
+### Breaking changes
+
+- Align Beta Forward Decision responses with the updated API: remove `usage`, the `refusal` answer type/field, and internal context fields (`policy_version`, `token_estimator`, `estimated_input_tokens`, `max_input_tokens`, `snapshot_at`, `scan_limited`, `truncated`, `truncation_reasons`).
+
+### Migration
+
+- Remove reads of the deleted fields and handle `choice` / `score` answers. Use `context.mode` and optional Session event IDs/counts for context information; check `status` and `error` on stored records for failures.
+
+### Fixed
+
+- Accept Decision responses without `usage` when strict response validation is enabled.
+
## [0.10.0]
### Added
diff --git a/README.md b/README.md
index d3cd372..6c937de 100644
--- a/README.md
+++ b/README.md
@@ -644,11 +644,9 @@ For scores, use `{"type": "score", "instructions": "Rate risk", "levels":
[{"label": "low", "score": 0}, {"label": "high", "score": 10}]}`.
`AsyncForward` offers the same methods with `await` and asynchronous pagination.
-Session context is selected by the server. Inspect `context.truncated`,
-`context.truncation_reasons`, and the input-token estimate to understand the
-selected history. Explicit state and questions are never shortened; oversized
-input returns HTTP 422. Context estimates are separate from `usage`, which can
-be null. Answers can also have type `refusal`.
+Session context is selected by the server. `context.mode` identifies the input
+mode; Session results may include event IDs and counts describing the selected
+history. Answers have type `choice` or `score`. Oversized input returns HTTP 422.
Reuse an idempotency key only for identical request bytes. A changed request or
a still-running request with that key returns HTTP 409; the SDK does not retry
diff --git a/docs/api/reference.md b/docs/api/reference.md
index 77b6ec1..6b21f93 100644
--- a/docs/api/reference.md
+++ b/docs/api/reference.md
@@ -11387,10 +11387,6 @@ class DecisionAnswer(BaseModel)
#### score
-
-
-#### refusal
-
## DecisionContext
@@ -11401,35 +11397,15 @@ class DecisionContext(BaseModel)
[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/decision.py)
-Server-selected input metadata. Estimates are not billed model usage.
+Input mode and the Session events used for evaluation.
-Session-only fields and preparation metadata may be absent, especially on
+Session-only fields may be absent, especially on
running or failed records. Unknown fields remain accessible as model extras.
#### mode
-
-
-#### policy\_version
-
-
-
-#### token\_estimator
-
-
-
-#### estimated\_input\_tokens
-
-
-
-#### max\_input\_tokens
-
-
-
-#### snapshot\_at
-
#### anchor\_event\_id
@@ -11450,18 +11426,6 @@ running or failed records. Unknown fields remain accessible as model extras.
#### included\_event\_count
-
-
-#### scan\_limited
-
-
-
-#### truncated
-
-
-
-#### truncation\_reasons
-
## DecisionError
@@ -11520,10 +11484,6 @@ A stored evaluation. Retrieval and listing also expose running/failed records.
#### context
-
-
-#### usage
-
#### error
diff --git a/src/qca/forward/types/decision.py b/src/qca/forward/types/decision.py
index 9ac115e..0638a75 100644
--- a/src/qca/forward/types/decision.py
+++ b/src/qca/forward/types/decision.py
@@ -1,7 +1,7 @@
from __future__ import annotations
from datetime import datetime
-from typing import Any, Literal
+from typing import Literal
from qca.common._models import BaseModel
@@ -26,34 +26,25 @@ class DecisionQuestion(BaseModel):
class DecisionAnswer(BaseModel):
- type: Literal["choice", "score", "refusal"]
+ type: Literal["choice", "score"]
name: str | None = None
value: str | bool | None = None
score: float | None = None
- refusal: str | None = None
class DecisionContext(BaseModel):
- """Server-selected input metadata. Estimates are not billed model usage.
+ """Input mode and the Session events used for evaluation.
- Session-only fields and preparation metadata may be absent, especially on
+ Session-only fields may be absent, especially on
running or failed records. Unknown fields remain accessible as model extras.
"""
mode: Literal["state", "session"]
- policy_version: str | None = None
- token_estimator: str | None = None
- estimated_input_tokens: int | None = None
- max_input_tokens: int | None = None
- snapshot_at: datetime | None = None
anchor_event_id: str | None = None
first_event_id: str | None = None
last_event_id: str | None = None
included_segment_count: int | None = None
included_event_count: int | None = None
- scan_limited: bool | None = None
- truncated: bool | None = None
- truncation_reasons: list[str] | None = None
class DecisionError(BaseModel):
@@ -71,7 +62,6 @@ class Decision(BaseModel):
questions: list[DecisionQuestion]
answers: list[DecisionAnswer]
context: DecisionContext
- usage: dict[str, Any] | None
error: DecisionError | None
created_at: datetime
completed_at: datetime | None
diff --git a/tests/fixtures/decisions.json b/tests/fixtures/decisions.json
index 30036ba..04fdd86 100644
--- a/tests/fixtures/decisions.json
+++ b/tests/fixtures/decisions.json
@@ -1,5 +1,5 @@
{
- "source": "cloud-agents-forward-service@8b9474c6: internal/router/router.go, internal/handler/decision/handler.go, internal/model/dto/decision.go, internal/service/decision/{validation,service,context}.go",
+ "source": "cloud-agents-forward-service@102f6f64: internal/router/router.go, internal/handler/decision/handler.go, internal/model/dto/decision.go, internal/service/decision/{validation,service,context}.go",
"request": {
"state": {
"resolved": false,
@@ -92,13 +92,8 @@
}
],
"context": {
- "mode": "state",
- "policy_version": "decision-context-v3",
- "token_estimator": "caw-heuristic-v1",
- "estimated_input_tokens": 234,
- "max_input_tokens": 30000
+ "mode": "state"
},
- "usage": null,
"error": null,
"created_at": "2026-10-23T05:00:00.123456Z",
"completed_at": "2026-10-23T05:00:01.123456Z"
diff --git a/tests/integration/test_decisions.py b/tests/integration/test_decisions.py
index 4237f8c..5c9f686 100644
--- a/tests/integration/test_decisions.py
+++ b/tests/integration/test_decisions.py
@@ -40,7 +40,21 @@ def test_decisions(live_example, mode):
decision = client.decisions.create(**params, timeout=context.remaining())
assert decision.id and decision.status == "completed"
assert decision.context.mode == mode and len(decision.answers) == 2
- assert 0 < decision.context.estimated_input_tokens <= decision.context.max_input_tokens
+ assert "usage" not in decision.to_dict()
+ assert (
+ not {
+ "truncated",
+ "max_input_tokens",
+ "policy_version",
+ "estimated_input_tokens",
+ "token_estimator",
+ "truncation_reasons",
+ "scan_limited",
+ "snapshot_at",
+ }
+ & decision.context.to_dict().keys()
+ )
+ assert all(answer.type in ("choice", "score") for answer in decision.answers)
if mode == "state":
assert decision.session_id is None
assert decision.answers[0].value is False and decision.answers[1].score == 0
diff --git a/tests/test_decisions.py b/tests/test_decisions.py
index 3d550d8..a64e934 100644
--- a/tests/test_decisions.py
+++ b/tests/test_decisions.py
@@ -45,14 +45,13 @@ def handle(request):
result["session_id"] = "sess_own"
result["context"].update(
mode="session",
- snapshot_at="2026-10-26T00:00:00.123456789Z",
- truncated=True,
- truncation_reasons=["segment_budget"],
+ anchor_event_id="evt_last",
+ first_event_id="evt_first",
+ last_event_id="evt_last",
+ included_event_count=14,
included_segment_count=12,
- scan_limited=False,
future_context=True,
)
- result["answers"] = [{"type": "refusal", "name": "private", "refusal": "Cannot assess"}]
else:
assert request.url.raw_path.endswith(b"/decisions/opaque%20%2F%3F%25%23")
assert not request.content
@@ -68,15 +67,18 @@ def handle(request):
assert isinstance(result, Decision)
assert result.answers[0].value is False and result.answers[1].score == 0
assert "value" not in result.answers[1].model_fields_set
- assert result.session_id is None and result.usage is None and result.error is None
+ assert result.session_id is None and result.error is None
+ assert "usage" not in result.to_dict()
+ assert result.context.to_dict() == {"mode": "state"}
assert result.created_at.microsecond == 123456
assert result._request_id == "req_decision" and result.model_extra["future_field"] == 1
result = await resolve(
client.decisions.create(session_id="sess_own", questions=FIXTURE["request"]["questions"])
)
- assert result.context.truncated and result.context.included_segment_count == 12
- assert result.context.scan_limited is False and result.context.model_extra["future_context"] is True
- assert result.answers[0].refusal == "Cannot assess" and "value" not in result.answers[0].model_fields_set
+ assert result.context.included_segment_count == 12 and result.context.included_event_count == 14
+ assert result.context.anchor_event_id == result.context.last_event_id == "evt_last"
+ assert result.context.first_event_id == "evt_first" and result.context.model_extra["future_context"] is True
+ assert result.answers[0].value is False and result.answers[1].score == 0
assert (await resolve(client.decisions.retrieve("opaque /?%#"))).id == "dec_contract"
with pytest.raises(ValueError):
await resolve(client.decisions.retrieve(""))
@@ -166,7 +168,7 @@ def handle(request):
@pytest.mark.parametrize("async_", [False, True])
-@pytest.mark.parametrize("status", [400, 401, 403, 404, 409, 422, 429, 504])
+@pytest.mark.parametrize("status", [400, 401, 403, 404, 409, 422, 429, 502, 504])
async def test_decision_error_keeps_correlation_and_server_validation(async_, status):
calls = []
@@ -192,3 +194,36 @@ def handle(request):
assert len(calls) == 1
finally:
await resolve(client.close())
+
+
+@pytest.mark.parametrize("async_", [False, True])
+@pytest.mark.parametrize("status", ["running", "failed"])
+async def test_decision_incomplete_records_without_internal_fields(async_, status):
+ record = copy.deepcopy(FIXTURE["response"])
+ record.update(status=status, session_id="sess_own", answers=[], context={"mode": "session"})
+ if status == "running":
+ record["completed_at"] = None
+ else:
+ record["error"] = {"code": "decision_invalid_upstream_response", "message": "Invalid JEV response."}
+
+ def handle(request):
+ if request.url.path.endswith("/dec_contract"):
+ return httpx.Response(200, json=record)
+ return httpx.Response(
+ 200, json={"data": [record], "has_more": False, "first_id": record["id"], "last_id": record["id"]}
+ )
+
+ client = client_for(async_, handle, pat="token", _strict_response_validation=True)
+ try:
+ result = await resolve(client.decisions.retrieve("dec_contract"))
+ page = await resolve(client.decisions.list(session_id="sess_own"))
+ assert result.to_dict() == page.data[0].to_dict()
+ assert result.status == status and result.answers == []
+ assert result.context.to_dict() == {"mode": "session"}
+ assert "usage" not in result.to_dict()
+ if status == "running":
+ assert result.completed_at is None and result.error is None
+ else:
+ assert result.error.code == "decision_invalid_upstream_response"
+ finally:
+ await resolve(client.close())