|
| 1 | +# Cycles Protocol v0.1.23 — Client (Python) Audit |
| 2 | + |
| 3 | +**Date:** 2026-03-14 |
| 4 | +**Spec:** `cycles-protocol-v0.yaml` (OpenAPI 3.1.0, v0.1.23) |
| 5 | +**Client:** `runcycles` (Python 3.10+ / httpx / Pydantic v2) |
| 6 | +**Server audit:** See `cycles-server/AUDIT.md` (all passing) |
| 7 | + |
| 8 | +--- |
| 9 | + |
| 10 | +## Summary |
| 11 | + |
| 12 | +| Category | Pass | Issues | |
| 13 | +|----------|------|--------| |
| 14 | +| Endpoints & HTTP Methods | 9/9 | 0 | |
| 15 | +| Request Schemas (field names & JSON keys) | 6/6 | 0 | |
| 16 | +| Response Schemas (field names & JSON keys) | 10/10 | 0 | |
| 17 | +| Enum Values | 5/5 | 0 | |
| 18 | +| Nested Object Schemas | 8/8 | 0 | |
| 19 | +| Auth Header (X-Cycles-API-Key) | — | 0 | |
| 20 | +| Idempotency (header ↔ body sync) | — | 0 | |
| 21 | +| Subject Validation | — | 0 | |
| 22 | +| Response Header Capture | — | 0 | |
| 23 | +| Client-Side Spec Constraint Validation | — | 0 | |
| 24 | +| Lifecycle Orchestration | — | 0 | |
| 25 | + |
| 26 | +**Overall: Client is protocol-conformant.** All endpoints, schemas, field names, JSON keys, and enum values match the OpenAPI spec. No open issues. |
| 27 | + |
| 28 | +--- |
| 29 | + |
| 30 | +## Audit Scope |
| 31 | + |
| 32 | +Compared the following across spec YAML and client Python source: |
| 33 | +- All 9 endpoint paths, HTTP methods, and path/query parameters |
| 34 | +- All 6 request body serializations vs spec schemas |
| 35 | +- All 10 response model deserializations vs spec schemas |
| 36 | +- All 5 enum types and their values |
| 37 | +- Nested object schemas (Subject, Action, Amount, SignedAmount, Caps, CyclesMetrics, Balance, ErrorResponse) |
| 38 | +- Auth and idempotency header handling |
| 39 | +- Subject constraint validation (`anyOf` / at least one standard field) |
| 40 | +- Pydantic Field constraints vs spec min/max bounds |
| 41 | +- Lifecycle orchestration (reserve → execute → commit/release) |
| 42 | + |
| 43 | +--- |
| 44 | + |
| 45 | +## PASS — Correctly Implemented |
| 46 | + |
| 47 | +### Endpoints (all 9 match spec) |
| 48 | + |
| 49 | +| Spec Endpoint | Client Method | HTTP Method | Match | |
| 50 | +|---|---|---|---| |
| 51 | +| `/v1/decide` | `client.decide()` | POST | PASS | |
| 52 | +| `/v1/reservations` (create) | `client.create_reservation()` | POST | PASS | |
| 53 | +| `/v1/reservations` (list) | `client.list_reservations()` | GET | PASS | |
| 54 | +| `/v1/reservations/{reservation_id}` | `client.get_reservation()` | GET | PASS | |
| 55 | +| `/v1/reservations/{reservation_id}/commit` | `client.commit_reservation()` | POST | PASS | |
| 56 | +| `/v1/reservations/{reservation_id}/release` | `client.release_reservation()` | POST | PASS | |
| 57 | +| `/v1/reservations/{reservation_id}/extend` | `client.extend_reservation()` | POST | PASS | |
| 58 | +| `/v1/balances` | `client.get_balances()` | GET | PASS | |
| 59 | +| `/v1/events` | `client.create_event()` | POST | PASS | |
| 60 | + |
| 61 | +### Request Schemas (all match spec JSON keys) |
| 62 | + |
| 63 | +**ReservationCreateRequest** — spec required: `[idempotency_key, subject, action, estimate]` |
| 64 | +- Pydantic fields: `idempotency_key`, `subject`, `action`, `estimate`, `ttl_ms`, `grace_period_ms`, `overage_policy`, `dry_run`, `metadata` — all snake_case, all match spec |
| 65 | + |
| 66 | +**CommitRequest** — spec required: `[idempotency_key, actual]` |
| 67 | +- Pydantic fields: `idempotency_key`, `actual`, `metrics`, `metadata` — all match spec |
| 68 | + |
| 69 | +**ReleaseRequest** — spec required: `[idempotency_key]` |
| 70 | +- Pydantic fields: `idempotency_key`, `reason` — all match spec |
| 71 | + |
| 72 | +**DecisionRequest** — spec required: `[idempotency_key, subject, action, estimate]` |
| 73 | +- Pydantic fields: `idempotency_key`, `subject`, `action`, `estimate`, `metadata` — all match spec |
| 74 | + |
| 75 | +**EventCreateRequest** — spec required: `[idempotency_key, subject, action, actual]` |
| 76 | +- Pydantic fields: `idempotency_key`, `subject`, `action`, `actual`, `overage_policy`, `metrics`, `client_time_ms`, `metadata` — all match spec |
| 77 | + |
| 78 | +**ReservationExtendRequest** — spec required: `[idempotency_key, extend_by_ms]` |
| 79 | +- Pydantic fields: `idempotency_key`, `extend_by_ms`, `metadata` — all match spec |
| 80 | + |
| 81 | +### Response Schemas (all match spec JSON keys) |
| 82 | + |
| 83 | +| Spec Schema | Client Class | JSON Keys | Match | |
| 84 | +|---|---|---|---| |
| 85 | +| `ReservationCreateResponse` | `ReservationCreateResponse` | `decision`, `reservation_id`, `affected_scopes`, `expires_at_ms`, `scope_path`, `reserved`, `caps`, `reason_code`, `retry_after_ms`, `balances` | PASS | |
| 86 | +| `CommitResponse` | `CommitResponse` | `status`, `charged`, `released`, `balances` | PASS | |
| 87 | +| `ReleaseResponse` | `ReleaseResponse` | `status`, `released`, `balances` | PASS | |
| 88 | +| `DecisionResponse` | `DecisionResponse` | `decision`, `caps`, `reason_code`, `retry_after_ms`, `affected_scopes` | PASS | |
| 89 | +| `EventCreateResponse` | `EventCreateResponse` | `status`, `event_id`, `balances` | PASS | |
| 90 | +| `ReservationExtendResponse` | `ReservationExtendResponse` | `status`, `expires_at_ms`, `balances` | PASS | |
| 91 | +| `BalanceResponse` | `BalanceResponse` | `balances`, `has_more`, `next_cursor` | PASS | |
| 92 | +| `ReservationDetail` | `ReservationDetail` | `reservation_id`, `status`, `idempotency_key`, `subject`, `action`, `reserved`, `committed`, `created_at_ms`, `expires_at_ms`, `finalized_at_ms`, `scope_path`, `affected_scopes`, `metadata` | PASS | |
| 93 | +| `ReservationSummary` | `ReservationSummary` | `reservation_id`, `status`, `idempotency_key`, `subject`, `action`, `reserved`, `created_at_ms`, `expires_at_ms`, `scope_path`, `affected_scopes` | PASS | |
| 94 | +| `ReservationListResponse` | `ReservationListResponse` | `reservations`, `has_more`, `next_cursor` | PASS | |
| 95 | + |
| 96 | +### Nested Object Schemas (all match) |
| 97 | + |
| 98 | +| Spec Schema | Client Class | JSON Keys | Match | |
| 99 | +|---|---|---|---| |
| 100 | +| `Subject` | `Subject` | `tenant`, `workspace`, `app`, `workflow`, `agent`, `toolset`, `dimensions` | PASS | |
| 101 | +| `Action` | `Action` | `kind`, `name`, `tags` | PASS | |
| 102 | +| `Amount` | `Amount` | `unit`, `amount` | PASS | |
| 103 | +| `SignedAmount` | `SignedAmount` | `unit`, `amount` | PASS | |
| 104 | +| `Caps` | `Caps` | `max_tokens`, `max_steps_remaining`, `tool_allowlist`, `tool_denylist`, `cooldown_ms` | PASS | |
| 105 | +| `StandardMetrics` | `CyclesMetrics` | `tokens_input`, `tokens_output`, `latency_ms`, `model_version`, `custom` | PASS | |
| 106 | +| `Balance` | `Balance` | `scope`, `scope_path`, `remaining`, `reserved`, `spent`, `allocated`, `debt`, `overdraft_limit`, `is_over_limit` | PASS | |
| 107 | +| `ErrorResponse` | `ErrorResponse` | `error`, `message`, `request_id`, `details` | PASS | |
| 108 | + |
| 109 | +### Enum Values (all match spec) |
| 110 | + |
| 111 | +| Spec Enum | Client Enum | Values | Match | |
| 112 | +|---|---|---|---| |
| 113 | +| `DecisionEnum` | `Decision` | `ALLOW`, `ALLOW_WITH_CAPS`, `DENY` | PASS | |
| 114 | +| `UnitEnum` | `Unit` | `USD_MICROCENTS`, `TOKENS`, `CREDITS`, `RISK_POINTS` | PASS | |
| 115 | +| `CommitOveragePolicy` | `CommitOveragePolicy` | `REJECT`, `ALLOW_IF_AVAILABLE`, `ALLOW_WITH_OVERDRAFT` | PASS | |
| 116 | +| `ReservationStatus` | `ReservationStatus` | `ACTIVE`, `COMMITTED`, `RELEASED`, `EXPIRED` | PASS | |
| 117 | +| `ErrorCode` | `ErrorCode` | All 12 spec values + `UNKNOWN` (client fallback) | PASS | |
| 118 | + |
| 119 | +Note: Client `ErrorCode` adds `UNKNOWN` as a fallback for unrecognized server error codes. This is a client-side convenience and does not violate the spec. |
| 120 | + |
| 121 | +### Auth & Idempotency (correct) |
| 122 | + |
| 123 | +- **X-Cycles-API-Key**: Set on all requests via `httpx.Client` base headers in `CyclesClient.__init__()` (`client.py`) |
| 124 | +- **X-Idempotency-Key**: Extracted from request body `idempotency_key` field via `_extract_idempotency_key()` and set as header in `_post()`. Header and body values always match (copied from body to header), satisfying the spec rule: "If X-Idempotency-Key header is present and body.idempotency_key is present, they MUST match." |
| 125 | + |
| 126 | +### Subject Validation (correct) |
| 127 | + |
| 128 | +- `validate_subject()` in `_validation.py` calls `Subject.has_at_least_one_standard_field()` which checks all 6 standard fields — matches spec `anyOf` constraint |
| 129 | +- Pydantic Field constraints enforce `maxLength: 128` on all Subject fields and `maxLength: 256` on dimension values |
| 130 | + |
| 131 | +### Response Header Capture (correct) |
| 132 | + |
| 133 | +- `_extract_response_headers()` in `client.py` captures `x-request-id`, `x-ratelimit-remaining`, `x-ratelimit-reset`, `x-cycles-tenant` |
| 134 | +- Exposed via `CyclesResponse` properties: `request_id`, `rate_limit_remaining`, `rate_limit_reset`, `cycles_tenant` |
| 135 | + |
| 136 | +### Client-Side Spec Constraint Validation (correct) |
| 137 | + |
| 138 | +All spec constraints are validated both via Pydantic Field validators (on typed request models) and via explicit validation functions (on dict-based lifecycle path): |
| 139 | + |
| 140 | +- `validate_non_negative()`: `Amount.amount >= 0` (spec `minimum: 0`) |
| 141 | +- `validate_ttl_ms()`: 1000–86400000 (spec `minimum: 1000, maximum: 86400000`) |
| 142 | +- `validate_grace_period_ms()`: 0–60000 (spec `minimum: 0, maximum: 60000`) |
| 143 | +- `validate_extend_by_ms()`: 1–86400000 (spec `minimum: 1, maximum: 86400000`) |
| 144 | +- Pydantic `Field(ge=1, le=86_400_000)` on `ReservationExtendRequest.extend_by_ms` |
| 145 | +- Pydantic `Field(max_length=64)` on `Action.kind`, `Field(max_length=256)` on `Action.name` |
| 146 | +- Pydantic `Field(min_length=1, max_length=256)` on all `idempotency_key` fields |
| 147 | + |
| 148 | +### Lifecycle Orchestration (correct) |
| 149 | + |
| 150 | +- Reserve → Execute → Commit flow with proper cleanup (release on failure) |
| 151 | +- Heartbeat-based TTL extension at `max(ttl_ms / 2, 1000)` ms interval using `extend` endpoint |
| 152 | +- Commit retry engine for transient failures (transport errors, 5xx) with exponential backoff |
| 153 | +- Dry-run handling returns `DryRunResult` without executing guarded function |
| 154 | +- `DENY` decision correctly raises typed `CyclesProtocolError` |
| 155 | +- `ALLOW_WITH_CAPS` correctly propagates `Caps` via `CyclesContext` |
| 156 | +- Lifecycle instance cached at decoration time (deferred client resolution on first call) |
| 157 | +- `ContextVar`-based context propagation (safe for both sync threads and async tasks) |
| 158 | + |
| 159 | +### HTTP Status Code Handling (correct) |
| 160 | + |
| 161 | +- `is_success` correctly handles 2xx range (200 for most endpoints, 201 for events) |
| 162 | +- Error responses parsed via `ErrorResponse.model_validate()` with `ErrorCode` mapping |
| 163 | +- Typed exceptions: `BudgetExceededError`, `OverdraftLimitExceededError`, `DebtOutstandingError`, `ReservationExpiredError`, `ReservationFinalizedError` |
| 164 | + |
| 165 | +--- |
| 166 | + |
| 167 | +## Verdict |
| 168 | + |
| 169 | +The client is **fully protocol-conformant** with the Cycles Protocol v0.1.23 OpenAPI spec. All 9 endpoints, 6 request schemas, 10 response schemas, 5 enum types, and all nested object serializations match the spec exactly. JSON field names use correct snake_case throughout. Auth headers, idempotency handling, subject validation, response header capture, and spec constraint validation all follow spec normative rules. No open issues. |
0 commit comments