diff --git a/.env.live.example b/.env.live.example
index db1bda3..27dde2f 100644
--- a/.env.live.example
+++ b/.env.live.example
@@ -56,3 +56,9 @@ QODER_REALTIME_GLOBAL_SAT=
# Uses QODER_FORWARD_CN_PAT, QODER_FORWARD_GLOBAL_PAT, QODER_SA_CN_KEY,
# and QODER_SA_GLOBAL_KEY. Never point clear at a shared Identity.
QODER_DRIVE_LIVE_ALLOW_WRITE=false
+
+# Forward Decisions consume model usage and retain records until server expiry.
+# Also requires QODER_FORWARD_LIVE_ALLOW_WRITE/EXECUTION=true.
+QODER_FORWARD_LIVE_ALLOW_DECISIONS=false
+# Optional existing Session for the Python/TypeScript live suite and all examples.
+QODER_DECISION_SESSION_ID=
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a0c705f..bb86d34 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,10 @@ existing `0.1.0` release; earlier development prereleases are not listed.
## [Unreleased]
+### Added
+
+- Add Forward Decisions for synchronous evaluation of explicit state or accessible Session history, with choice and score questions, idempotent creation, retrieval, cursor pagination, and typed context truncation/token-estimate metadata.
+
## [0.9.0] - 2026-10-26
### Added
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 1a7a373..040e4bc 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -179,3 +179,26 @@ those tests do not replace SDK integration coverage.
## Realtime validation
Realtime REST and local WebSocket protocol/lifecycle tests run offline with the default suite. `uv run make test-live-realtime` is a separate opt-in CN/Global entry, controlled by the dedicated `QODER_REALTIME_*` configuration and both write/execution flags. It creates persistent Conversations using already-enabled Templates; transport close is not resource cleanup. Record the printed Conversation/request IDs and agree on retention with the service owner. Basic ping/history success does not replace text, PCM, playback, and background Work acceptance. Realtime auth headers, SSL context, and proxy settings use the WebSocket adapter; arbitrary httpx middleware is not applied to it.
+
+## Decision live tests
+
+Decision evaluation is opt-in because it consumes model usage and creates
+records without a delete endpoint. Configure a dedicated account using
+`QODER_FORWARD_PAT` and `QODER_FORWARD_BASE_URL` in `.env.live`, then set
+`QODER_FORWARD_LIVE_ALLOW_WRITE=true`,
+`QODER_FORWARD_LIVE_ALLOW_EXECUTION=true`, and
+`QODER_FORWARD_LIVE_ALLOW_DECISIONS=true`. These live tests use PAT; offline
+contracts cover both PAT and SAT behavior. Normal unit/contract runs are offline.
+
+```bash
+QODER_RUN_LIVE=1 uv run pytest tests/integration/test_decisions.py -m integration -v
+```
+
+This checks state evaluation, idempotent replay, retrieval, and cursor pages.
+Set `QODER_DECISION_SESSION_ID` to an accessible Session with existing history
+to also test Session input; otherwise that case is explicitly skipped. The
+suite does not modify the supplied Session.
+
+Decision records expire according to server retention. Retain only decision
+IDs, HTTP status, and request IDs in shared diagnostics; do not log credentials
+or unrelated Session contents.
diff --git a/README.md b/README.md
index 0275478..1f410bc 100644
--- a/README.md
+++ b/README.md
@@ -609,6 +609,56 @@ credential = client.vaults.credentials.retrieve("credential-id", vault_id="vault
memory = client.memory_stores.memories.retrieve("memory-id", memory_store_id="store-id")
```
+## Forward Decisions (next release)
+
+Evaluate a nonempty `state` object or an accessible `session_id` (exactly one)
+with 1–32 choice or score questions. Choice values are strings or booleans;
+score questions select from your numeric levels. Creation waits for the result.
+The service selects the model; no `model` parameter is accepted. Normal PAT or
+SAT authentication determines the caller's access.
+
+```python
+from uuid import uuid4
+from qca import Forward
+
+with Forward() as client: # Or use the PAT/SA configuration above.
+ decision = client.decisions.create(
+ state={"message": "My issue is resolved."},
+ questions=[{
+ "type": "choice", "name": "resolved",
+ "instructions": "Has the user confirmed resolution?",
+ "choices": [{"value": True}, {"value": False}],
+ }],
+ idempotency_key=str(uuid4()),
+ )
+ stored = client.decisions.retrieve(decision.id)
+ print(stored.answers)
+ page = client.decisions.list(limit=20)
+ print(page.data) # Iterate over page directly to follow all pages.
+```
+
+For Session input, replace `state=...` with `session_id=session_id`.
+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`.
+
+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
+that conflict automatically. Creation errors can contain `decision_id` in the
+standard API error body. Retrieve and list can return running or failed records.
+Lists preserve server order and support `after_id` / `before_id` cursors (one at
+a time), optional `session_id`, and a limit of 1–100. There is no delete endpoint;
+Decision records expire according to server retention.
+
+See the [runnable example](examples/forward/decisions.py) and the
+[opt-in live test instructions](CONTRIBUTING.md#decision-live-tests).
+
## Forward Drive (Beta)
Drive is a Beta API. It uses `entries` / `next_page_token`, not the ordinary
diff --git a/docs/api/reference.md b/docs/api/reference.md
index 48e2d3a..9dbc2e1 100644
--- a/docs/api/reference.md
+++ b/docs/api/reference.md
@@ -2066,6 +2066,17 @@ class Forward(SyncAPIClient)
[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/_client.py)
+
+
+#### decisions
+
+```python
+@cached_property
+def decisions() -> Decisions
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/_client.py)
+
#### webhooks
@@ -2285,6 +2296,17 @@ class AsyncForward(AsyncAPIClient)
[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/_client.py)
+
+
+#### decisions
+
+```python
+@cached_property
+def decisions() -> AsyncDecisions
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/_client.py)
+
#### webhooks
@@ -3353,6 +3375,182 @@ async def retrieve(
GET /qr_sessions/{session_key}.
+
+
+# qca.forward.resources.decisions
+
+
+
+## Decisions
+
+```python
+class Decisions(SyncAPIResource)
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/decisions.py)
+
+
+
+#### create
+
+```python
+def create(
+ *,
+ questions: List[DecisionQuestionParam],
+ session_id: Union[str, NotGiven] = NOT_GIVEN,
+ state: Union[Dict[str, Any], NotGiven] = NOT_GIVEN,
+ idempotency_key: Union[str, NotGiven] = NOT_GIVEN,
+ extra_headers: Dict[str, str] | None = None,
+ extra_query: Dict[str, Any] | None = None,
+ extra_body: Dict[str, Any] | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN
+) -> Decision
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/decisions.py)
+
+POST /decisions.
+
+Synchronous evaluation using normal PAT/SAT authentication. Provide exactly
+one of session_id and nonempty state; state.session_context is reserved.
+Questions are choice (string/boolean options) or score (numeric levels).
+Reusing an idempotency key with identical bytes replays the stored result;
+different bytes or an in-progress request return HTTP 409.
+Explicit state/questions are never shortened; over-budget input is HTTP 422.
+
+
+
+#### retrieve
+
+```python
+def retrieve(
+ decision_id: str,
+ *,
+ extra_headers: Dict[str, str] | None = None,
+ extra_query: Dict[str, Any] | None = None,
+ extra_body: Dict[str, Any] | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN
+) -> Decision
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/decisions.py)
+
+GET /decisions/{decision_id}.
+
+Retrieve a stored Decision, including running or failed records.
+
+
+
+#### list
+
+```python
+def list(
+ *,
+ session_id: Union[str, NotGiven] = NOT_GIVEN,
+ limit: Union[int, NotGiven] = NOT_GIVEN,
+ after_id: Union[str, NotGiven] = NOT_GIVEN,
+ before_id: Union[str, NotGiven] = NOT_GIVEN,
+ extra_headers: Dict[str, str] | None = None,
+ extra_query: Dict[str, Any] | None = None,
+ extra_body: Dict[str, Any] | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN
+) -> SyncPage[Decision]
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/decisions.py)
+
+GET /decisions.
+
+List in server order, optionally filtered by an accessible Session.
+Limit is 1-100 (default 20). after_id and before_id are mutually exclusive.
+Identity scope comes from authentication, not a query parameter.
+
+
+
+## AsyncDecisions
+
+```python
+class AsyncDecisions(AsyncAPIResource)
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/decisions.py)
+
+
+
+#### create
+
+```python
+async def create(
+ *,
+ questions: List[DecisionQuestionParam],
+ session_id: Union[str, NotGiven] = NOT_GIVEN,
+ state: Union[Dict[str, Any], NotGiven] = NOT_GIVEN,
+ idempotency_key: Union[str, NotGiven] = NOT_GIVEN,
+ extra_headers: Dict[str, str] | None = None,
+ extra_query: Dict[str, Any] | None = None,
+ extra_body: Dict[str, Any] | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN
+) -> Decision
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/decisions.py)
+
+POST /decisions.
+
+Synchronous evaluation using normal PAT/SAT authentication. Provide exactly
+one of session_id and nonempty state; state.session_context is reserved.
+Questions are choice (string/boolean options) or score (numeric levels).
+Reusing an idempotency key with identical bytes replays the stored result;
+different bytes or an in-progress request return HTTP 409.
+Explicit state/questions are never shortened; over-budget input is HTTP 422.
+
+
+
+#### retrieve
+
+```python
+async def retrieve(
+ decision_id: str,
+ *,
+ extra_headers: Dict[str, str] | None = None,
+ extra_query: Dict[str, Any] | None = None,
+ extra_body: Dict[str, Any] | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN
+) -> Decision
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/decisions.py)
+
+GET /decisions/{decision_id}.
+
+Retrieve a stored Decision, including running or failed records.
+
+
+
+#### list
+
+```python
+def list(
+ *,
+ session_id: Union[str, NotGiven] = NOT_GIVEN,
+ limit: Union[int, NotGiven] = NOT_GIVEN,
+ after_id: Union[str, NotGiven] = NOT_GIVEN,
+ before_id: Union[str, NotGiven] = NOT_GIVEN,
+ extra_headers: Dict[str, str] | None = None,
+ extra_query: Dict[str, Any] | None = None,
+ extra_body: Dict[str, Any] | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN
+) -> AsyncPaginator[Decision]
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/resources/decisions.py)
+
+GET /decisions.
+
+List in server order, optionally filtered by an accessible Session.
+Limit is 1-100 (default 20). after_id and before_id are mutually exclusive.
+Identity scope comes from authentication, not a query parameter.
+
# qca.forward.resources.drives
@@ -11085,6 +11283,417 @@ class ContentBlockParam(TypedDict)
#### source
+
+
+# qca.forward.types.decision
+
+
+
+## DecisionChoice
+
+```python
+class DecisionChoice(BaseModel)
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/decision.py)
+
+
+
+#### value
+
+
+
+#### description
+
+
+
+## DecisionLevel
+
+```python
+class DecisionLevel(BaseModel)
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/decision.py)
+
+
+
+#### label
+
+
+
+#### score
+
+
+
+#### description
+
+
+
+## DecisionQuestion
+
+```python
+class DecisionQuestion(BaseModel)
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/decision.py)
+
+
+
+#### type
+
+
+
+#### instructions
+
+
+
+#### name
+
+
+
+#### choices
+
+
+
+#### levels
+
+
+
+## DecisionAnswer
+
+```python
+class DecisionAnswer(BaseModel)
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/decision.py)
+
+
+
+#### type
+
+
+
+#### name
+
+
+
+#### value
+
+
+
+#### score
+
+
+
+#### refusal
+
+
+
+## DecisionContext
+
+```python
+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.
+
+Session-only fields and preparation metadata 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
+
+
+
+#### first\_event\_id
+
+
+
+#### last\_event\_id
+
+
+
+#### included\_segment\_count
+
+
+
+#### included\_event\_count
+
+
+
+#### scan\_limited
+
+
+
+#### truncated
+
+
+
+#### truncation\_reasons
+
+
+
+## DecisionError
+
+```python
+class DecisionError(BaseModel)
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/decision.py)
+
+
+
+#### code
+
+
+
+#### message
+
+
+
+## Decision
+
+```python
+class Decision(BaseModel)
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/decision.py)
+
+A stored evaluation. Retrieval and listing also expose running/failed records.
+
+
+
+#### id
+
+
+
+#### session\_id
+
+
+
+#### source
+
+
+
+#### status
+
+
+
+#### questions
+
+
+
+#### answers
+
+
+
+#### context
+
+
+
+#### usage
+
+
+
+#### error
+
+
+
+#### created\_at
+
+
+
+#### completed\_at
+
+
+
+# qca.forward.types.decision\_create\_params
+
+
+
+## DecisionChoiceParam
+
+```python
+class DecisionChoiceParam(TypedDict)
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/decision_create_params.py)
+
+
+
+#### value
+
+
+
+#### description
+
+
+
+## DecisionLevelParam
+
+```python
+class DecisionLevelParam(TypedDict)
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/decision_create_params.py)
+
+
+
+#### label
+
+
+
+#### score
+
+
+
+#### description
+
+
+
+## DecisionChoiceQuestionParam
+
+```python
+class DecisionChoiceQuestionParam(TypedDict)
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/decision_create_params.py)
+
+
+
+#### type
+
+
+
+#### instructions
+
+
+
+#### name
+
+
+
+#### choices
+
+
+
+## DecisionScoreQuestionParam
+
+```python
+class DecisionScoreQuestionParam(TypedDict)
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/decision_create_params.py)
+
+
+
+#### type
+
+
+
+#### instructions
+
+
+
+#### name
+
+
+
+#### levels
+
+
+
+#### DecisionQuestionParam
+
+
+
+## DecisionCreateParams
+
+```python
+class DecisionCreateParams(TypedDict)
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/decision_create_params.py)
+
+Provide exactly one of session_id and state, plus 1-32 questions.
+
+State must be a nonempty JSON object; state.session_context is reserved.
+Choice questions require 2-32 distinct string/boolean options; score
+questions require 2-32 finite numeric levels. No model parameter is accepted.
+
+
+
+#### questions
+
+
+
+#### session\_id
+
+
+
+#### state
+
+
+
+#### idempotency\_key
+
+
+
+# qca.forward.types.decision\_list\_params
+
+
+
+## DecisionListParams
+
+```python
+class DecisionListParams(TypedDict)
+```
+
+[[view_source]](https://github.com/QoderAI/qoder-cloud-agents-sdk-python/blob/main/src/qca/forward/types/decision_list_params.py)
+
+
+
+#### session\_id
+
+
+
+#### limit
+
+
+
+#### after\_id
+
+
+
+#### before\_id
+
# qca.forward.types.deleted\_channel
diff --git a/examples/forward/decisions.py b/examples/forward/decisions.py
new file mode 100644
index 0000000..027b43b
--- /dev/null
+++ b/examples/forward/decisions.py
@@ -0,0 +1,43 @@
+"""Create a Decision using shared PAT/SA configuration.
+
+Run: python -m examples.forward.decisions --env .env.live
+Optionally set QODER_DECISION_SESSION_ID to evaluate an accessible Session.
+"""
+
+from __future__ import annotations
+
+import os
+from uuid import uuid4
+
+from examples.common.live import Run, run_cli
+from qca import Forward
+from qca.forward.types import DecisionCreateParams
+
+
+def run(client: Forward, context: Run) -> None:
+ params: DecisionCreateParams = {
+ "state": {"message": "The issue is resolved; everything works now."},
+ "questions": [
+ {
+ "type": "choice",
+ "name": "resolved",
+ "instructions": "Has the user explicitly confirmed that their issue is resolved?",
+ "choices": [{"value": True}, {"value": False}],
+ }
+ ],
+ "idempotency_key": str(uuid4()),
+ }
+ session_id = os.environ.get("QODER_DECISION_SESSION_ID")
+ if session_id:
+ del params["state"]
+ params["session_id"] = session_id
+ decision = client.decisions.create(**params)
+ stored = client.decisions.retrieve(decision.id)
+ context.output("decision", stored.to_dict(mode="json"))
+ page = client.decisions.list(limit=5, **({"session_id": session_id} if session_id else {}))
+ context.output("page", [{"id": item.id, "status": item.status} for item in page.data])
+ # Decision records expire according to server retention; there is no delete API.
+
+
+if __name__ == "__main__":
+ run_cli("forward", Forward, {"decisions": run})
diff --git a/src/qca/forward/_client.py b/src/qca/forward/_client.py
index f2259b7..32654e7 100644
--- a/src/qca/forward/_client.py
+++ b/src/qca/forward/_client.py
@@ -7,6 +7,7 @@
from .resources.batches.batches import AsyncBatches, Batches
from .resources.channel_pairings import AsyncChannelPairings, ChannelPairings
from .resources.channels.channels import AsyncChannels, Channels
+from .resources.decisions import AsyncDecisions, Decisions
from .resources.drives import AsyncDrives, Drives
from .resources.environments import AsyncEnvironments, Environments
from .resources.files import AsyncFiles, Files
@@ -26,6 +27,10 @@
class Forward(SyncAPIClient):
+ @cached_property
+ def decisions(self) -> Decisions:
+ return Decisions(self)
+
@cached_property
def webhooks(self) -> Webhooks:
return Webhooks(self)
@@ -107,6 +112,10 @@ def models(self) -> Models:
class AsyncForward(AsyncAPIClient):
+ @cached_property
+ def decisions(self) -> AsyncDecisions:
+ return AsyncDecisions(self)
+
@cached_property
def webhooks(self) -> AsyncWebhooks:
return AsyncWebhooks(self)
diff --git a/src/qca/forward/resources/__init__.py b/src/qca/forward/resources/__init__.py
index cc2234f..c32de00 100644
--- a/src/qca/forward/resources/__init__.py
+++ b/src/qca/forward/resources/__init__.py
@@ -1,5 +1,7 @@
from .channel_pairings import AsyncChannelPairings as AsyncChannelPairings
from .channel_pairings import ChannelPairings as ChannelPairings
+from .decisions import AsyncDecisions as AsyncDecisions
+from .decisions import Decisions as Decisions
from .drives import AsyncDrives as AsyncDrives
from .drives import Drives as Drives
from .environments import AsyncEnvironments as AsyncEnvironments
diff --git a/src/qca/forward/resources/decisions.py b/src/qca/forward/resources/decisions.py
new file mode 100644
index 0000000..ce076d9
--- /dev/null
+++ b/src/qca/forward/resources/decisions.py
@@ -0,0 +1,196 @@
+from __future__ import annotations
+
+from typing import Any, Dict, List, Union
+
+import httpx
+
+from qca.common._resource import AsyncAPIResource, SyncAPIResource
+from qca.common._types import NOT_GIVEN, NotGiven
+from qca.common._utils import make_request_options, path_template
+from qca.common.pagination import AsyncPaginator, SyncPage
+from qca.forward.types.decision import Decision
+from qca.forward.types.decision_create_params import DecisionQuestionParam
+
+__all__ = ["Decisions", "AsyncDecisions"]
+
+
+class Decisions(SyncAPIResource):
+ def create(
+ self,
+ *,
+ questions: List[DecisionQuestionParam],
+ session_id: Union[str, NotGiven] = NOT_GIVEN,
+ state: Union[Dict[str, Any], NotGiven] = NOT_GIVEN,
+ idempotency_key: Union[str, NotGiven] = NOT_GIVEN,
+ extra_headers: Dict[str, str] | None = None,
+ extra_query: Dict[str, Any] | None = None,
+ extra_body: Dict[str, Any] | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN,
+ ) -> Decision:
+ """POST /decisions.
+
+ Synchronous evaluation using normal PAT/SAT authentication. Provide exactly
+ one of session_id and nonempty state; state.session_context is reserved.
+ Questions are choice (string/boolean options) or score (numeric levels).
+ Reusing an idempotency key with identical bytes replays the stored result;
+ different bytes or an in-progress request return HTTP 409.
+ Explicit state/questions are never shortened; over-budget input is HTTP 422.
+ """
+ _path = path_template("/decisions")
+ options = make_request_options(
+ body={"questions": questions, "session_id": session_id, "state": state},
+ query={},
+ headers={"Idempotency-Key": idempotency_key},
+ extra_headers=extra_headers,
+ extra_query=extra_query,
+ extra_body=extra_body,
+ timeout=timeout,
+ )
+ return self._client.request("POST", _path, cast_to=Decision, options=options)
+
+ def retrieve(
+ self,
+ decision_id: str,
+ *,
+ extra_headers: Dict[str, str] | None = None,
+ extra_query: Dict[str, Any] | None = None,
+ extra_body: Dict[str, Any] | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN,
+ ) -> Decision:
+ """GET /decisions/{decision_id}.
+
+ Retrieve a stored Decision, including running or failed records.
+ """
+ _path = path_template("/decisions/{decision_id}", decision_id=decision_id)
+ options = make_request_options(
+ body={},
+ query={},
+ headers={},
+ extra_headers=extra_headers,
+ extra_query=extra_query,
+ extra_body=extra_body,
+ timeout=timeout,
+ )
+ return self._client.request("GET", _path, cast_to=Decision, options=options)
+
+ def list(
+ self,
+ *,
+ session_id: Union[str, NotGiven] = NOT_GIVEN,
+ limit: Union[int, NotGiven] = NOT_GIVEN,
+ after_id: Union[str, NotGiven] = NOT_GIVEN,
+ before_id: Union[str, NotGiven] = NOT_GIVEN,
+ extra_headers: Dict[str, str] | None = None,
+ extra_query: Dict[str, Any] | None = None,
+ extra_body: Dict[str, Any] | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN,
+ ) -> SyncPage[Decision]:
+ """GET /decisions.
+
+ List in server order, optionally filtered by an accessible Session.
+ Limit is 1-100 (default 20). after_id and before_id are mutually exclusive.
+ Identity scope comes from authentication, not a query parameter.
+ """
+ _path = path_template("/decisions")
+ options = make_request_options(
+ body={},
+ query={"session_id": session_id, "limit": limit, "after_id": after_id, "before_id": before_id},
+ headers={},
+ extra_headers=extra_headers,
+ extra_query=extra_query,
+ extra_body=extra_body,
+ timeout=timeout,
+ )
+ return self._client.request("GET", _path, cast_to=Decision, options=options, page_style="Page")
+
+
+class AsyncDecisions(AsyncAPIResource):
+ async def create(
+ self,
+ *,
+ questions: List[DecisionQuestionParam],
+ session_id: Union[str, NotGiven] = NOT_GIVEN,
+ state: Union[Dict[str, Any], NotGiven] = NOT_GIVEN,
+ idempotency_key: Union[str, NotGiven] = NOT_GIVEN,
+ extra_headers: Dict[str, str] | None = None,
+ extra_query: Dict[str, Any] | None = None,
+ extra_body: Dict[str, Any] | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN,
+ ) -> Decision:
+ """POST /decisions.
+
+ Synchronous evaluation using normal PAT/SAT authentication. Provide exactly
+ one of session_id and nonempty state; state.session_context is reserved.
+ Questions are choice (string/boolean options) or score (numeric levels).
+ Reusing an idempotency key with identical bytes replays the stored result;
+ different bytes or an in-progress request return HTTP 409.
+ Explicit state/questions are never shortened; over-budget input is HTTP 422.
+ """
+ _path = path_template("/decisions")
+ options = make_request_options(
+ body={"questions": questions, "session_id": session_id, "state": state},
+ query={},
+ headers={"Idempotency-Key": idempotency_key},
+ extra_headers=extra_headers,
+ extra_query=extra_query,
+ extra_body=extra_body,
+ timeout=timeout,
+ )
+ return await self._client.request("POST", _path, cast_to=Decision, options=options)
+
+ async def retrieve(
+ self,
+ decision_id: str,
+ *,
+ extra_headers: Dict[str, str] | None = None,
+ extra_query: Dict[str, Any] | None = None,
+ extra_body: Dict[str, Any] | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN,
+ ) -> Decision:
+ """GET /decisions/{decision_id}.
+
+ Retrieve a stored Decision, including running or failed records.
+ """
+ _path = path_template("/decisions/{decision_id}", decision_id=decision_id)
+ options = make_request_options(
+ body={},
+ query={},
+ headers={},
+ extra_headers=extra_headers,
+ extra_query=extra_query,
+ extra_body=extra_body,
+ timeout=timeout,
+ )
+ return await self._client.request("GET", _path, cast_to=Decision, options=options)
+
+ def list(
+ self,
+ *,
+ session_id: Union[str, NotGiven] = NOT_GIVEN,
+ limit: Union[int, NotGiven] = NOT_GIVEN,
+ after_id: Union[str, NotGiven] = NOT_GIVEN,
+ before_id: Union[str, NotGiven] = NOT_GIVEN,
+ extra_headers: Dict[str, str] | None = None,
+ extra_query: Dict[str, Any] | None = None,
+ extra_body: Dict[str, Any] | None = None,
+ timeout: float | httpx.Timeout | None | NotGiven = NOT_GIVEN,
+ ) -> AsyncPaginator[Decision]:
+ """GET /decisions.
+
+ List in server order, optionally filtered by an accessible Session.
+ Limit is 1-100 (default 20). after_id and before_id are mutually exclusive.
+ Identity scope comes from authentication, not a query parameter.
+ """
+ _path = path_template("/decisions")
+ options = make_request_options(
+ body={},
+ query={"session_id": session_id, "limit": limit, "after_id": after_id, "before_id": before_id},
+ headers={},
+ extra_headers=extra_headers,
+ extra_query=extra_query,
+ extra_body=extra_body,
+ timeout=timeout,
+ )
+ return AsyncPaginator(
+ lambda: self._client.request("GET", _path, cast_to=Decision, options=options, page_style="Page")
+ )
diff --git a/src/qca/forward/types/__init__.py b/src/qca/forward/types/__init__.py
index 934732e..d4ef21e 100644
--- a/src/qca/forward/types/__init__.py
+++ b/src/qca/forward/types/__init__.py
@@ -30,6 +30,20 @@
from .channel_qr_session_create_params import ChannelQRSessionCreateParams as ChannelQRSessionCreateParams
from .channel_update_params import ChannelUpdateParams as ChannelUpdateParams
from .content_block_param import ContentBlockParam as ContentBlockParam
+from .decision import Decision as Decision
+from .decision import DecisionAnswer as DecisionAnswer
+from .decision import DecisionChoice as DecisionChoice
+from .decision import DecisionContext as DecisionContext
+from .decision import DecisionError as DecisionError
+from .decision import DecisionLevel as DecisionLevel
+from .decision import DecisionQuestion as DecisionQuestion
+from .decision_create_params import DecisionChoiceParam as DecisionChoiceParam
+from .decision_create_params import DecisionChoiceQuestionParam as DecisionChoiceQuestionParam
+from .decision_create_params import DecisionCreateParams as DecisionCreateParams
+from .decision_create_params import DecisionLevelParam as DecisionLevelParam
+from .decision_create_params import DecisionQuestionParam as DecisionQuestionParam
+from .decision_create_params import DecisionScoreQuestionParam as DecisionScoreQuestionParam
+from .decision_list_params import DecisionListParams as DecisionListParams
from .deleted_channel import DeletedChannel as DeletedChannel
from .deleted_channel_pairing import DeletedChannelPairing as DeletedChannelPairing
from .deleted_identity import DeletedIdentity as DeletedIdentity
diff --git a/src/qca/forward/types/decision.py b/src/qca/forward/types/decision.py
new file mode 100644
index 0000000..9ac115e
--- /dev/null
+++ b/src/qca/forward/types/decision.py
@@ -0,0 +1,77 @@
+from __future__ import annotations
+
+from datetime import datetime
+from typing import Any, Literal
+
+from qca.common._models import BaseModel
+
+
+class DecisionChoice(BaseModel):
+ value: str | bool
+ description: str | None = None
+
+
+class DecisionLevel(BaseModel):
+ label: str
+ score: float
+ description: str | None = None
+
+
+class DecisionQuestion(BaseModel):
+ type: Literal["choice", "score"]
+ instructions: str
+ name: str | None = None
+ choices: list[DecisionChoice] | None = None
+ levels: list[DecisionLevel] | None = None
+
+
+class DecisionAnswer(BaseModel):
+ type: Literal["choice", "score", "refusal"]
+ 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.
+
+ Session-only fields and preparation metadata 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):
+ code: str
+ message: str
+
+
+class Decision(BaseModel):
+ """A stored evaluation. Retrieval and listing also expose running/failed records."""
+
+ id: str
+ session_id: str | None
+ source: str
+ status: Literal["running", "completed", "failed"]
+ 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/src/qca/forward/types/decision_create_params.py b/src/qca/forward/types/decision_create_params.py
new file mode 100644
index 0000000..b059555
--- /dev/null
+++ b/src/qca/forward/types/decision_create_params.py
@@ -0,0 +1,47 @@
+from __future__ import annotations
+
+from typing import Any, Literal, Union
+
+from typing_extensions import Required, TypedDict
+
+
+class DecisionChoiceParam(TypedDict, total=False):
+ value: Required[Union[str, bool]]
+ description: str
+
+
+class DecisionLevelParam(TypedDict, total=False):
+ label: Required[str]
+ score: Required[float]
+ description: str
+
+
+class DecisionChoiceQuestionParam(TypedDict, total=False):
+ type: Required[Literal["choice"]]
+ instructions: Required[str]
+ name: str
+ choices: Required[list[DecisionChoiceParam]]
+
+
+class DecisionScoreQuestionParam(TypedDict, total=False):
+ type: Required[Literal["score"]]
+ instructions: Required[str]
+ name: str
+ levels: Required[list[DecisionLevelParam]]
+
+
+DecisionQuestionParam = Union[DecisionChoiceQuestionParam, DecisionScoreQuestionParam]
+
+
+class DecisionCreateParams(TypedDict, total=False):
+ """Provide exactly one of session_id and state, plus 1-32 questions.
+
+ State must be a nonempty JSON object; state.session_context is reserved.
+ Choice questions require 2-32 distinct string/boolean options; score
+ questions require 2-32 finite numeric levels. No model parameter is accepted.
+ """
+
+ questions: Required[list[DecisionQuestionParam]]
+ session_id: str
+ state: dict[str, Any]
+ idempotency_key: str
diff --git a/src/qca/forward/types/decision_list_params.py b/src/qca/forward/types/decision_list_params.py
new file mode 100644
index 0000000..1ee5f69
--- /dev/null
+++ b/src/qca/forward/types/decision_list_params.py
@@ -0,0 +1,10 @@
+from __future__ import annotations
+
+from typing_extensions import TypedDict
+
+
+class DecisionListParams(TypedDict, total=False):
+ session_id: str
+ limit: int
+ after_id: str
+ before_id: str
diff --git a/tests/fixtures/api-contracts.json b/tests/fixtures/api-contracts.json
index cd17ff7..5e75a8e 100644
--- a/tests/fixtures/api-contracts.json
+++ b/tests/fixtures/api-contracts.json
@@ -2107,6 +2107,31 @@
"endpoint_id"
],
"kind": "model"
+ },
+ {
+ "id": "forward.decisions.create",
+ "method": "POST",
+ "path": "/decisions",
+ "required": [
+ "questions"
+ ],
+ "kind": "model"
+ },
+ {
+ "id": "forward.decisions.retrieve",
+ "method": "GET",
+ "path": "/decisions/{decision_id}",
+ "required": [
+ "decision_id"
+ ],
+ "kind": "model"
+ },
+ {
+ "id": "forward.decisions.list",
+ "method": "GET",
+ "path": "/decisions",
+ "required": [],
+ "kind": "page"
}
],
"extensions": [
@@ -2117,6 +2142,10 @@
{
"source": "https://docs.qoder.com/zh/cloud-agents/api/webhooks/overview (2026-10-22); Forward webhook handler and DTO cross-check",
"operations": 8
+ },
+ {
+ "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",
+ "operations": 3
}
]
}
diff --git a/tests/fixtures/decisions.json b/tests/fixtures/decisions.json
new file mode 100644
index 0000000..30036ba
--- /dev/null
+++ b/tests/fixtures/decisions.json
@@ -0,0 +1,106 @@
+{
+ "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",
+ "request": {
+ "state": {
+ "resolved": false,
+ "risk": "low"
+ },
+ "questions": [
+ {
+ "type": "choice",
+ "name": "resolved",
+ "instructions": "Has the user confirmed resolution?",
+ "choices": [
+ {
+ "value": true,
+ "description": "Confirmed"
+ },
+ {
+ "value": false,
+ "description": "Not confirmed"
+ }
+ ]
+ },
+ {
+ "type": "score",
+ "name": "risk",
+ "instructions": "Select the risk level.",
+ "levels": [
+ {
+ "label": "low",
+ "score": 0,
+ "description": "Routine"
+ },
+ {
+ "label": "high",
+ "score": 10,
+ "description": "Destructive"
+ }
+ ]
+ }
+ ]
+ },
+ "response": {
+ "id": "dec_contract",
+ "session_id": null,
+ "source": "api",
+ "status": "completed",
+ "questions": [
+ {
+ "type": "choice",
+ "name": "resolved",
+ "instructions": "Has the user confirmed resolution?",
+ "choices": [
+ {
+ "value": true,
+ "description": "Confirmed"
+ },
+ {
+ "value": false,
+ "description": "Not confirmed"
+ }
+ ]
+ },
+ {
+ "type": "score",
+ "name": "risk",
+ "instructions": "Select the risk level.",
+ "levels": [
+ {
+ "label": "low",
+ "score": 0,
+ "description": "Routine"
+ },
+ {
+ "label": "high",
+ "score": 10,
+ "description": "Destructive"
+ }
+ ]
+ }
+ ],
+ "answers": [
+ {
+ "type": "choice",
+ "name": "resolved",
+ "value": false
+ },
+ {
+ "type": "score",
+ "name": "risk",
+ "score": 0
+ }
+ ],
+ "context": {
+ "mode": "state",
+ "policy_version": "decision-context-v3",
+ "token_estimator": "caw-heuristic-v1",
+ "estimated_input_tokens": 234,
+ "max_input_tokens": 30000
+ },
+ "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
new file mode 100644
index 0000000..4237f8c
--- /dev/null
+++ b/tests/integration/test_decisions.py
@@ -0,0 +1,60 @@
+"""Opt-in Decision evaluation; records are retained by the server (no delete API)."""
+
+import os
+from uuid import uuid4
+
+import pytest
+
+from tests.support.harness import read_env
+
+pytestmark = pytest.mark.integration
+
+
+@pytest.mark.parametrize("live_example", ["forward"], indirect=True)
+@pytest.mark.parametrize("mode", ["state", "session"])
+def test_decisions(live_example, mode):
+ values = {**read_env(os.environ.get("QODER_LIVE_ENV_FILE", ".env.live")), **os.environ}
+ for gate in ("WRITE", "EXECUTION", "DECISIONS"):
+ if values.get(f"QODER_FORWARD_LIVE_ALLOW_{gate}") != "true":
+ pytest.skip(f"QODER_FORWARD_LIVE_ALLOW_{gate}=true is required")
+ session_id = values.get("QODER_DECISION_SESSION_ID")
+ if mode == "session" and not session_id:
+ pytest.skip("Set QODER_DECISION_SESSION_ID to an accessible Session with history")
+ client, context = live_example
+ params = {
+ "questions": [
+ {
+ "type": "choice",
+ "instructions": "Return true only if RESOLVED_SDK_TEST appears in the input.",
+ "choices": [{"value": True}, {"value": False}],
+ },
+ {
+ "type": "score",
+ "instructions": "Select high if RISK_LEVEL=high appears, otherwise select low.",
+ "levels": [{"label": "low", "score": 0}, {"label": "high", "score": 10}],
+ },
+ ],
+ "idempotency_key": str(uuid4()),
+ **({"session_id": session_id} if mode == "session" else {"state": {"message": "RISK_LEVEL=low"}}),
+ }
+ 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
+ if mode == "state":
+ assert decision.session_id is None
+ assert decision.answers[0].value is False and decision.answers[1].score == 0
+ else:
+ assert decision.session_id == session_id and decision.context.included_event_count > 0
+ replay = client.decisions.create(**params, timeout=context.remaining())
+ assert replay.id == decision.id and replay.completed_at == decision.completed_at
+ stored = client.decisions.retrieve(decision.id)
+ assert stored.to_dict() == decision.to_dict()
+ page = client.decisions.list(limit=1, **({"session_id": session_id} if mode == "session" else {}))
+ assert len(page.data) == 1
+ if mode == "session":
+ assert page.data[0].session_id == session_id
+ if page.has_next_page():
+ following = page.get_next_page()
+ assert following.data and following.data[0].id != page.data[0].id
+ context.output("decision_id", decision.id)
diff --git a/tests/test_decisions.py b/tests/test_decisions.py
new file mode 100644
index 0000000..3d550d8
--- /dev/null
+++ b/tests/test_decisions.py
@@ -0,0 +1,194 @@
+import copy
+import inspect
+import json
+from pathlib import Path
+
+import httpx
+import pytest
+
+from qca import APIStatusError, AsyncForward, Forward
+from qca.forward.types import Decision
+
+FIXTURE = json.loads((Path(__file__).parent / "fixtures/decisions.json").read_text())
+
+
+async def resolve(value):
+ return await value if inspect.isawaitable(value) else value
+
+
+def client_for(async_, handle, **options):
+ http = (httpx.AsyncClient if async_ else httpx.Client)(transport=httpx.MockTransport(handle))
+ return (AsyncForward if async_ else Forward)(http_client=http, **options)
+
+
+@pytest.mark.parametrize("async_", [False, True])
+@pytest.mark.parametrize("auth", [{"pat": "token"}, {"auth_mode": "sa", "access_token": "token"}])
+async def test_decision_state_session_and_response_metadata(async_, auth):
+ calls = []
+
+ def handle(request):
+ calls.append(request)
+ assert request.headers["authorization"] == "Bearer token"
+ assert "x-qoder-beta" not in request.headers
+ assert not request.url.query
+ result = copy.deepcopy(FIXTURE["response"])
+ result["future_field"] = 1
+ if request.method == "POST":
+ assert request.url.path == "/api/v1/forward/decisions"
+ body = json.loads(request.content)
+ assert body["questions"] == FIXTURE["request"]["questions"]
+ if len(calls) == 1:
+ assert body == FIXTURE["request"]
+ assert request.headers["Idempotency-Key"] == "header-wins"
+ else:
+ assert body == {"session_id": "sess_own", "questions": FIXTURE["request"]["questions"]}
+ result["session_id"] = "sess_own"
+ result["context"].update(
+ mode="session",
+ snapshot_at="2026-10-26T00:00:00.123456789Z",
+ truncated=True,
+ truncation_reasons=["segment_budget"],
+ 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
+ return httpx.Response(200, json=result, headers={"x-request-id": "req_decision"})
+
+ client = client_for(async_, handle, _strict_response_validation=True, **auth)
+ try:
+ result = await resolve(
+ client.decisions.create(
+ **FIXTURE["request"], idempotency_key="parameter-key", extra_headers={"Idempotency-Key": "header-wins"}
+ )
+ )
+ 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.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 (await resolve(client.decisions.retrieve("opaque /?%#"))).id == "dec_contract"
+ with pytest.raises(ValueError):
+ await resolve(client.decisions.retrieve(""))
+ assert len(calls) == 3
+ finally:
+ await resolve(client.close())
+
+
+@pytest.mark.parametrize("async_", [False, True])
+@pytest.mark.parametrize("cursor", ["after_id", "before_id"])
+async def test_decision_pagination_keeps_filter_options_and_order(async_, cursor):
+ calls = []
+
+ def handle(request):
+ calls.append(request)
+ assert request.url.params["session_id"] == "sess_own"
+ assert request.url.params["limit"] == "1" and "page" not in request.url.params
+ assert request.headers["X-Page"] == "kept"
+ assert not request.content
+ other = "before_id" if cursor == "after_id" else "after_id"
+ assert other not in request.url.params
+ assert request.url.params[cursor] == ("dec_start" if len(calls) == 1 else "dec_z")
+ if len(calls) == 1:
+ return httpx.Response(
+ 200,
+ json={
+ "data": [
+ {"id": "dec_z", "status": "running", "context": {"mode": "session"}, "completed_at": None}
+ ],
+ "first_id": "dec_z",
+ "last_id": "dec_z",
+ "has_more": True,
+ },
+ )
+ return httpx.Response(
+ 200,
+ json={
+ "data": [
+ {
+ "id": "dec_a",
+ "status": "failed",
+ "error": {"code": "decision_upstream_timeout", "message": "Timed out"},
+ }
+ ],
+ "first_id": "dec_a",
+ "last_id": "dec_a",
+ "has_more": False,
+ },
+ )
+
+ client = client_for(async_, handle, pat="token")
+ try:
+ page = client.decisions.list(
+ session_id="sess_own", limit=1, extra_headers={"X-Page": "kept"}, **{cursor: "dec_start"}
+ )
+ items = [d async for d in page] if async_ else list(page)
+ assert [d.id for d in items] == ["dec_z", "dec_a"] and len(calls) == 2
+ assert items[0].completed_at is None and items[1].error.code == "decision_upstream_timeout"
+ finally:
+ await resolve(client.close())
+
+
+@pytest.mark.parametrize("async_", [False, True])
+@pytest.mark.parametrize("keyed", [False, True])
+async def test_decision_retry_requires_key_and_preserves_exact_body(async_, keyed):
+ calls = []
+
+ def handle(request):
+ calls.append(request)
+ assert request.headers.get("Idempotency-Key") == ("decision-once" if keyed else None)
+ if len(calls) == 1:
+ return httpx.Response(503, json={"error": {"message": "unavailable"}}, headers={"retry-after-ms": "1"})
+ return httpx.Response(200, json=FIXTURE["response"])
+
+ client = client_for(async_, handle, pat="token", max_retries=1)
+ try:
+ params = {**FIXTURE["request"], **({"idempotency_key": "decision-once"} if keyed else {})}
+ if keyed:
+ assert (await resolve(client.decisions.create(**params))).id == "dec_contract"
+ assert len(calls) == 2 and calls[0].content == calls[1].content
+ else:
+ with pytest.raises(APIStatusError):
+ await resolve(client.decisions.create(**params))
+ assert len(calls) == 1
+ finally:
+ await resolve(client.close())
+
+
+@pytest.mark.parametrize("async_", [False, True])
+@pytest.mark.parametrize("status", [400, 401, 403, 404, 409, 422, 429, 504])
+async def test_decision_error_keeps_correlation_and_server_validation(async_, status):
+ calls = []
+
+ def handle(request):
+ calls.append(request)
+ assert json.loads(request.content)["session_id"] == "sess_own"
+ assert json.loads(request.content)["state"] == FIXTURE["request"]["state"]
+ return httpx.Response(
+ status,
+ json={
+ "request_id": "req_decision",
+ "decision_id": "dec_failed",
+ "error": {"type": "api_error", "code": "decision_test_error", "message": "Decision failed"},
+ },
+ )
+
+ client = client_for(async_, handle, pat="token", max_retries=1 if status == 409 else 0)
+ try:
+ with pytest.raises(APIStatusError) as error:
+ await resolve(client.decisions.create(**FIXTURE["request"], session_id="sess_own", idempotency_key="key"))
+ assert error.value.status_code == status and error.value.body["decision_id"] == "dec_failed"
+ assert error.value.request_id == "req_decision" and error.value.code == "decision_test_error"
+ assert len(calls) == 1
+ finally:
+ await resolve(client.close())
diff --git a/tests/test_resource_surface.py b/tests/test_resource_surface.py
index 78b03b2..92d246e 100644
--- a/tests/test_resource_surface.py
+++ b/tests/test_resource_surface.py
@@ -39,7 +39,7 @@ def test_contract_fixture_has_pinned_provenance():
"commit": "bc5bc655d70b67fcd88623320ac01896a7360d8a",
"captured_at": "2026-09-22",
}
- assert len(CONTRACT_FIXTURE["contracts"]) == 229
+ assert len(CONTRACT_FIXTURE["contracts"]) == 232
@pytest.mark.parametrize("endpoint", ENDPOINTS, ids=endpoint_id)