diff --git a/docs/http-api.md b/docs/http-api.md index c63fb94..da63124 100644 --- a/docs/http-api.md +++ b/docs/http-api.md @@ -292,7 +292,7 @@ policy passes. `reason` must be non-empty. `actor` defaults to `"http"`. -**Response** +**Response (policy passes)** ```json { "action_id": "act_def456", @@ -310,31 +310,62 @@ policy passes. } ``` -When `promoted_pointer_changed` is `false`, policy did not pass — the release was **not** -promoted. Check `policy.reasons` for the failure details. - **First promotion** (no prior baseline for this agent/environment): policy evaluation is skipped and the release is promoted unconditionally with reason `"first promotion: no promoted baseline for agent/environment"`. +**Policy-blocked response (HTTP 409)** + +When the active policy blocks promotion, the server returns HTTP **409 Conflict**. The +action is still written to the audit ledger; only the promoted pointer is not updated. + +```json +{ + "detail": { + "message": "Promotion blocked by policy.", + "outcome": { + "action_id": "act_def456", + "action": "promote", + "release_id": "rel_abc123", + "agent_id": "agent_support", + "environment": "production", + "baseline_release_id": "rel_prev789", + "promoted_pointer_changed": false, + "policy": { + "passed": false, + "reasons": ["candidate cost per run USD 0.006 exceeds max 0.005"], + "evaluated_at": "2026-05-01T13:00:00+00:00" + } + } + } +} +``` + +Check `detail.outcome.policy.reasons` for the specific constraints that failed. + **Errors** - HTTP 400 — unknown release ID, missing pricing table, invalid window, or empty reason. - HTTP 401 — Bearer token missing or invalid (when a token is configured). - HTTP 403 — caller is not a loopback client and no token is configured. +- HTTP 409 — action recorded in the audit ledger but blocked by the active policy. --- ## `POST /v1/rollback` Roll back to a prior release. Identical contract to `/v1/promote` but with `"action": -"rollback"` in the response. A promoted baseline must already exist; rolling back when -nothing is promoted returns HTTP 400. +"rollback"` in the response and `"message": "Rollback blocked by policy."` in the 409 +body. A promoted baseline must already exist; rolling back when nothing is promoted +returns HTTP 400. **Requires mutation access** (loopback client or Bearer token). **Request body** — same shape as `/v1/promote`. -**Response** — same shape as `/v1/promote` with `"action": "rollback"`. +**Response (policy passes)** — same shape as `/v1/promote` with `"action": "rollback"`. + +**Policy-blocked response** — same 409 shape as `/v1/promote` with `"action": "rollback"` +and `"message": "Rollback blocked by policy."`. --- diff --git a/docs/operations-and-policy.md b/docs/operations-and-policy.md index 3c8c30d..58431f7 100644 --- a/docs/operations-and-policy.md +++ b/docs/operations-and-policy.md @@ -226,9 +226,16 @@ require_high_diff_confidence: false ### Promotion blocked by policy When policy fails, the promotion/rollback attempt is **recorded in the audit ledger** -(the intent is captured) but the promoted pointer is **not** updated. The CLI exits with -a non-zero code; the HTTP API returns the full response body with `promoted_pointer_changed: -false` and `policy.passed: false`. +(the intent is captured) but the promoted pointer is **not** updated. + +- **CLI:** exits with a non-zero code and prints the policy failure reasons. +- **HTTP API:** returns **HTTP 409 Conflict** with a structured `detail` body containing + `message` and the full `outcome` object (including `promoted_pointer_changed: false` and + `policy.passed: false`). See [HTTP API reference](http-api.md#post-v1promote) for the + exact response shape. +- **SDK (`post_promote` / `post_rollback`):** raises `httpx.HTTPStatusError` with + `response.status_code == 409`. The full `detail` body is accessible via + `e.response.json()["detail"]`. --- diff --git a/docs/sdk.md b/docs/sdk.md index e7d3a37..f0cfd29 100644 --- a/docs/sdk.md +++ b/docs/sdk.md @@ -172,6 +172,12 @@ All methods call `response.raise_for_status()` before returning, so HTTP 4xx/5xx responses raise `httpx.HTTPStatusError`. Transient network failures raise `httpx.RequestError` and are retried up to `max_retries` times with exponential backoff. +**Policy-blocked promote/rollback (HTTP 409)** + +When the active policy blocks a `post_promote` or `post_rollback` call, the server returns +HTTP 409. The SDK raises `httpx.HTTPStatusError`; the full outcome — including which +policy constraints failed — is in `e.response.json()["detail"]`. + ```python import httpx @@ -183,9 +189,18 @@ try: reason="tested in staging", ) except httpx.HTTPStatusError as e: - print(e.response.status_code, e.response.json()) + if e.response.status_code == 409: + detail = e.response.json()["detail"] + # detail["message"] -> "Promotion blocked by policy." + # detail["outcome"]["policy"]["reasons"] -> list of failed constraints + print("Blocked:", detail["outcome"]["policy"]["reasons"]) + else: + raise ``` +The action is still recorded in the audit ledger even when blocked; `GET /v1/actions` +will show it with `policy_passed: false`. + ## Custom `httpx.Client` Inject a pre-configured client to set custom SSL certificates, proxies, or connection