diff --git a/AUDIT.md b/AUDIT.md index dde85a5..739c72b 100644 --- a/AUDIT.md +++ b/AUDIT.md @@ -167,3 +167,18 @@ All spec constraints are validated both via Pydantic Field validators (on typed ## Verdict 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. + +--- + +## OpenAPI Contract Tests (added 2026-03-28) + +**Spec version:** v0.1.24 +**Test file:** `tests/test_contract.py` (34 tests, all passing) + +Automated contract tests validate sample request/response payloads against the OpenAPI spec schemas using `jsonschema.Draft202012Validator` with recursive `$ref` resolution: + +- **Request schemas validated:** DecisionRequest, ReservationCreateRequest, CommitRequest, ReleaseRequest, EventCreateRequest +- **Response schemas validated:** DecisionResponse, ReservationCreateResponse, CommitResponse, ReleaseResponse, EventCreateResponse, ErrorResponse +- **Negative tests:** missing required fields, extra fields (additionalProperties), invalid enum values +- **Enum value tests:** UnitEnum, ErrorCode, DecisionEnum, ReservationStatus, CommitOveragePolicy +- **Spec fixture:** `tests/fixtures/cycles-protocol-v0.yaml` (copy of canonical spec) diff --git a/pyproject.toml b/pyproject.toml index b2d3701..57ff8f6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,8 @@ dev = [ "mypy>=1.0", "ruff>=0.1", "respx>=0.21", + "jsonschema>=4.20", + "pyyaml>=6.0", ] [tool.ruff] diff --git a/tests/fixtures/cycles-protocol-v0.yaml b/tests/fixtures/cycles-protocol-v0.yaml new file mode 100644 index 0000000..f10cb60 --- /dev/null +++ b/tests/fixtures/cycles-protocol-v0.yaml @@ -0,0 +1,1432 @@ +openapi: 3.1.0 +info: + title: Cycles Budget Authority API + version: 0.1.24 + license: + name: Apache 2.0 + url: https://www.apache.org/licenses/LICENSE-2.0 + summary: v0 protocol for deterministic budget governance (reserve/commit) with optional decide + balance queries, plus debt/overdraft support with soft-limit reconciliation. + description: |- + PURPOSE (v0): + - Provide a minimal, language-agnostic protocol to enforce deterministic spend exposure for agent runtimes + via concurrency-safe reservations and idempotent commits. + - Include optional integration endpoints: /decide (soft landing) and /balances (operator visibility). + + NON-GOALS (v0) (NORMATIVE): + - Budget establishment and funding operations are out of scope for v0. + v0 defines the reservation/commit/release enforcement plane and balance reporting only. + - v0 provides no API for budget CRUD (create/update/delete), allocation setting, credit/deposit, or debit/withdrawal. + Implementations MAY provide these via an operator/admin plane or a separate API; future versions may standardize them. + - A reservation lifecycle is denominated in exactly one unit (single-unit reserve/commit/release). + Multi-unit atomic reservation/settlement is a v1+ concern. + + AUTH & TENANCY (NORMATIVE): + - Requests are authenticated via X-Cycles-API-Key. + - Server determines an "effective tenant" from the API key (or other auth context). + - Subject.tenant is a budgeting dimension and MUST be validated against the effective tenant. + If mismatched, server MUST return 403 FORBIDDEN. + - Reservation ownership MUST be enforced: every reservation is bound to the effective tenant at creation. + Any subsequent GET/commit/release for a reservation that exists but is owned by a different tenant + MUST return 403 FORBIDDEN. + - Balance visibility MUST be tenant-scoped: the server MUST only return balances within the effective tenant. + If a request attempts to query another tenant (e.g., tenant filter mismatches), server MUST return 403 FORBIDDEN. + + EVOLUTION CONTRACT: + - This API starts at v0.1.0 with /v1 paths to avoid future client churn. + - v1+ evolution MUST be backward-compatible by default: new fields are additive, existing field meanings MUST NOT change. + - Breaking changes (e.g., new required fields, semantic changes) require a new major API path (e.g., /v2). + + CORE INVARIANTS: + - Reserve is atomic across all derived scopes. + - Commit and release are idempotent. + - No double-charge on retries (idempotency key enforced). + + ERROR SEMANTICS (NORMATIVE): + - Budget denials MUST return HTTP 409 with error=BUDGET_EXCEEDED. + - Overdraft limit exceeded MUST return HTTP 409 with error=OVERDRAFT_LIMIT_EXCEEDED in two cases: + 1. During commit: when overage_policy=ALLOW_WITH_OVERDRAFT and (current_debt + delta) > overdraft_limit at commit time + 2. During reservation: when the scope is in over-limit state (debt > overdraft_limit due to prior concurrent commits) + - Outstanding debt blocking reservation MUST return HTTP 409 with error=DEBT_OUTSTANDING + (when debt > 0 and new reservation is attempted). + - Finalized reservations MUST return HTTP 409 with error=RESERVATION_FINALIZED. + - Expired reservations MUST return HTTP 410 with error=RESERVATION_EXPIRED. + (commit/release: beyond expires_at_ms + grace_period_ms; extend: beyond expires_at_ms). + - Reservations that never existed MUST return HTTP 404 with error=NOT_FOUND. + - HTTP 429 is reserved for server-side throttling/rate limiting (optional in v0), not deterministic budget exhaustion. + - Unit mismatch on commit (actual.unit ≠ reservation unit) or event (actual.unit not supported for the target scope) MUST return HTTP 400 with error=UNIT_MISMATCH. + - For expiry comparisons, “now” refers to server time (not client-provided time). + - When is_over_limit=true, server MUST return 409 OVERDRAFT_LIMIT_EXCEEDED for new reservations. + This takes precedence over DEBT_OUTSTANDING even when debt > 0. + + OVERDRAFT RECONCILIATION (NORMATIVE): + - When concurrent commits cause debt > overdraft_limit on a scope, the server MUST mark that scope as "over-limit" (is_over_limit=true). + - Over-limit scopes MUST reject ALL new reservation attempts with 409 OVERDRAFT_LIMIT_EXCEEDED until debt is reduced below overdraft_limit. + - Operators reconcile over-limit scopes via budget funding operations (out-of-scope for this API). + When debt is repaid below overdraft_limit, is_over_limit automatically returns to false. + - Servers SHOULD provide monitoring/alerting when scopes enter over-limit state: + * Log events with scope identifier, current debt, and overdraft_limit + * Optionally emit webhooks or notifications to operators + * Optionally expose metrics endpoint showing over-limit scope count + - Clients SHOULD handle 409 OVERDRAFT_LIMIT_EXCEEDED on reservation as a signal to wait/retry with exponential backoff, or escalate to operators. + + IDEMPOTENCY (NORMATIVE): + - If X-Idempotency-Key header is present and body.idempotency_key is present, they MUST match. + - Server MUST enforce idempotency per (effective tenant, endpoint, idempotency_key). + - On replay of an idempotent request that previously succeeded, server MUST return the original successful + response payload (including any server-generated identifiers such as reservation_id). + - If the same key is reused with a different request payload, server MUST return 409 IDEMPOTENCY_MISMATCH. + - Servers SHOULD compare idempotency payloads using a canonical JSON representation + (e.g., RFC 8785 JSON Canonicalization Scheme) or an equivalent stable serialization. + + SCOPE DERIVATION (NORMATIVE): + - Server derives canonical scope identifiers and a canonical scope_path from Subject fields. + - Canonical ordering is: tenant → workspace → app → workflow → agent → toolset. + - Only explicitly provided subject levels are included in scope paths; intermediate gaps are skipped (not filled with "default"). + - Scopes without budgets are skipped during enforcement; at least one derived scope MUST have a budget. + - affected_scopes returned by the server MUST be in that canonical order. + + RESERVATION LEASING (GUIDANCE): + - To mitigate "zombie reservations" (client crash after reserve), SDKs SHOULD: + * keep ttl_ms short (typically 10s–30s), + * include modest estimation buffers when using overage_policy=REJECT, + * reserve in small initial leases and increase gradually ("slow start") for long or bursty operations, + * prefer chunked reserve/commit cycles for long-running actions rather than a single large reservation. + + OVERDRAFT MONITORING (GUIDANCE): + - Implementations SHOULD provide visibility into over-limit states: + * Dashboard showing scopes with is_over_limit=true + * Alerts when debt exceeds overdraft_limit + * Time-series metrics: debt_utilization = debt / overdraft_limit + - Recommended alerting thresholds: + * Warning at 80% of overdraft_limit + * Critical at 100% (over-limit state) + - Recommended operator runbook: + 1. Investigate which reservations caused the over-limit state + 2. Determine if overdraft_limit should be increased (normal variance) or if this represents anomalous consumption (incident) + 3. Fund the scope to repay debt below limit + 4. Monitor that is_over_limit returns to false + 5. Resume operations automatically + + CHANGELOG: + v0.1.24 (2026-03-24): + - BREAKING: Changed default CommitOveragePolicy from REJECT to ALLOW_IF_AVAILABLE. + Clients relying on implicit REJECT must now set overage_policy explicitly. + - Changed ALLOW_IF_AVAILABLE commit behavior: when remaining budget cannot cover + the full overage delta, the commit now succeeds with a capped charge + (estimate + available remaining) instead of returning 409 BUDGET_EXCEEDED. + Scopes where the full delta could not be covered are marked is_over_limit=true, + blocking future reservations until reconciled. + - Extended is_over_limit semantics: now also set by ALLOW_IF_AVAILABLE when + overage delta is capped, in addition to ALLOW_WITH_OVERDRAFT debt scenarios. + - Updated CommitResponse.charged description: charged may be less than actual + when ALLOW_IF_AVAILABLE caps the overage delta. + - Added charged field to EventCreateResponse: optional Amount present when + ALLOW_IF_AVAILABLE caps the event charge to remaining budget. + - Added ErrorCode values: BUDGET_FROZEN, BUDGET_CLOSED, MAX_EXTENSIONS_EXCEEDED. + These were already used by the reference server but missing from the spec enum. + + v0.1.23 (2026-02-21): + - Renamed Subject.toolGroup → Subject.toolset across all endpoints, schemas, + and normative text for consistency with scope hierarchy naming conventions. + - Added ALLOW_WITH_OVERDRAFT semantics to EventCreateRequest.overage_policy + (finding: behavior was previously unspecified for events). + - Clarified DecisionResponse.decision description: DENY is a valid live-path + outcome on /decide; removed erroneous dry_run framing copied from + ReservationCreateResponse. + - Added ERROR SEMANTICS precedence rule: when is_over_limit=true, server MUST + return 409 OVERDRAFT_LIMIT_EXCEEDED for new reservations, taking precedence + over DEBT_OUTSTANDING even when debt > 0. + - Added DEBT/OVERDRAFT STATE normative block to /decide: server SHOULD return + decision=DENY under debt or over-limit conditions; MUST NOT return 409. + + v0.1.22 (2026-02-18): + - Added overdraft/debt model: ALLOW_WITH_OVERDRAFT overage policy, debt and + overdraft_limit fields on Balance, is_over_limit flag, OVERDRAFT_LIMIT_EXCEEDED + and DEBT_OUTSTANDING error codes. + - Added SignedAmount schema to support negative remaining in overdraft state. + - Added OVERDRAFT RECONCILIATION normative section and OVERDRAFT MONITORING + guidance section including recommended alerting thresholds and operator runbook. + - Added OVER-LIMIT BLOCKING normative block to createReservation. + + v0.1.0 → v0.1.21: + - Initial protocol definition: reserve/commit/release lifecycle, idempotency, + scope derivation, /decide, /balances, /events, dry_run shadow mode, + reservation extend/list/get endpoints, soft enforcement via Caps. + +servers: + - url: https://api.cycles.local + description: Replace with your implementation endpoint + +tags: + - name: Decisions + description: Optional preflight checks (no reservation created) + - name: Reservations + description: Reservation, commit, release, and (optional) get-by-id operations + - name: Balances + description: Query balances for operator visibility + - name: Events + description: Optional post-only accounting for non-estimable actions + +security: + - ApiKeyAuth: [] + +components: + securitySchemes: + ApiKeyAuth: + type: apiKey + in: header + name: X-Cycles-API-Key + + headers: + X-Request-Id: + description: Unique request identifier for debugging + schema: + type: string + X-RateLimit-Remaining: + description: Number of requests remaining in current window (optional in v0) + schema: + type: integer + X-RateLimit-Reset: + description: Unix timestamp (seconds) when rate limit resets (optional in v0) + schema: + type: integer + format: int64 + X-Cycles-Tenant: + description: Effective tenant identifier derived from auth context (optional in v0) + schema: + type: string + + parameters: + IdempotencyKeyHeader: + name: X-Idempotency-Key + in: header + required: false + description: >- + Optional idempotency key header. If both header and body idempotency_key are provided, they MUST match. + Server MUST enforce idempotency per endpoint by (effective tenant, endpoint, idempotency_key). + On replay of an idempotent request that previously succeeded, server MUST return the original successful + response payload (including any server-generated identifiers such as reservation_id). + schema: + $ref: '#/components/schemas/IdempotencyKey' + + ReservationId: + name: reservation_id + in: path + required: true + schema: + type: string + minLength: 1 + maxLength: 128 + + Limit: + name: limit + in: query + required: false + schema: + type: integer + minimum: 1 + maximum: 200 + default: 50 + description: Maximum number of results to return + + Cursor: + name: cursor + in: query + required: false + schema: + type: string + description: Opaque cursor from previous response + + responses: + ErrorResponse: + description: Error response + headers: + X-Request-Id: + $ref: '#/components/headers/X-Request-Id' + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + schemas: + IdempotencyKey: + type: string + minLength: 1 + maxLength: 256 + + ErrorCode: + type: string + enum: + - INVALID_REQUEST + - UNAUTHORIZED + - FORBIDDEN + - NOT_FOUND + - BUDGET_EXCEEDED + - BUDGET_FROZEN + - BUDGET_CLOSED + - RESERVATION_EXPIRED + - RESERVATION_FINALIZED + - IDEMPOTENCY_MISMATCH + - UNIT_MISMATCH + - OVERDRAFT_LIMIT_EXCEEDED + - DEBT_OUTSTANDING + - MAX_EXTENSIONS_EXCEEDED + - INTERNAL_ERROR + + ErrorResponse: + type: object + required: [error, message, request_id] + additionalProperties: false + properties: + error: + $ref: '#/components/schemas/ErrorCode' + message: + type: string + request_id: + type: string + details: + type: object + additionalProperties: true + + DecisionEnum: + type: string + enum: [ALLOW, ALLOW_WITH_CAPS, DENY] + + UnitEnum: + type: string + description: > + Standard units. + + USD_MICROCENTS preserves precision for per-call and batched accounting (int64). + - 1 USD_MICROCENTS = 10^-6 cents = 10^-8 dollars + - 1 USD = 100 cents = 10^8 USD_MICROCENTS + - Max int64 ≈ 9.22e18 USD_MICROCENTS ≈ $92.2B + + TOKENS are integer token counts. + CREDITS/RISK_POINTS are generic integer units (optional in v0 implementations). + enum: [USD_MICROCENTS, TOKENS, CREDITS, RISK_POINTS] + + Amount: + type: object + required: [unit, amount] + additionalProperties: false + properties: + unit: + $ref: '#/components/schemas/UnitEnum' + amount: + type: integer + format: int64 + minimum: 0 + + SignedAmount: + type: object + required: [unit, amount] + additionalProperties: false + description: >- + Like Amount, but allows negative values. Used for Balance.remaining which can be negative in overdraft scenarios. + properties: + unit: + $ref: '#/components/schemas/UnitEnum' + amount: + type: integer + format: int64 + description: >- + Signed integer amount. Can be negative when representing remaining balance in overdraft state + (debt > allocated - spent - reserved). + + Subject: + type: object + description: > + Dimension bag for hierarchical budgets. At least one standard field (tenant, workspace, app, workflow, agent, or toolset) MUST be provided. + A subject containing only `dimensions` is invalid; server MUST return 400 INVALID_REQUEST. + Hierarchy: tenant → workspace → app → workflow → agent → toolset. + + EXTENSIBILITY (v0): + - dimensions is an optional, user-defined map for alternative taxonomies + (e.g., cost_center/department/project) and policy/reporting. + - v0 servers MAY ignore dimensions for budgeting decisions; if they do, they still MUST accept and round-trip it. + - Keys SHOULD be lowercase and match ^[a-z0-9_.-]+$ for stable canonicalization; values are opaque strings. + additionalProperties: false + minProperties: 1 + anyOf: + - required: [tenant] + - required: [workspace] + - required: [app] + - required: [workflow] + - required: [agent] + - required: [toolset] + properties: + tenant: + type: string + maxLength: 128 + workspace: + type: string + maxLength: 128 + app: + type: string + maxLength: 128 + workflow: + type: string + maxLength: 128 + agent: + type: string + maxLength: 128 + toolset: + type: string + maxLength: 128 + dimensions: + type: object + description: Optional custom dimensions for enterprise taxonomies and policy/reporting. + additionalProperties: + type: string + maxLength: 256 + maxProperties: 16 + + Action: + type: object + required: [kind, name] + additionalProperties: false + properties: + kind: + type: string + maxLength: 64 + description: > + Action type identifier. Recommended format: . + Examples: llm.completion, llm.embedding, tool.search, tool.calculator, + db.query, db.write, http.get, http.post, file.upload, message.email, webhook.outbound + name: + type: string + maxLength: 256 + description: Provider/model/tool identifier (e.g., "openai:gpt-4o-mini", "web.search") + tags: + type: array + maxItems: 10 + items: + type: string + maxLength: 64 + description: Optional policy tags (e.g., ["prod","customer-facing"]) + + Caps: + type: object + description: > + Optional soft-landing constraints returned by /decide or reservation ALLOW_WITH_CAPS. + v0 intentionally keeps caps simple and concrete (no condition language). + + PRECEDENCE RULES: + - If tool_allowlist is non-empty, ONLY those tools are allowed (denylist ignored). + - Otherwise, if tool_denylist is non-empty, all tools EXCEPT those are allowed. + - If both are empty/null, no tool restrictions apply. + - Tool names are case-sensitive and match Action.name exactly. + additionalProperties: false + properties: + max_tokens: + type: integer + minimum: 0 + max_steps_remaining: + type: integer + minimum: 0 + tool_allowlist: + type: array + items: + type: string + maxLength: 256 + tool_denylist: + type: array + items: + type: string + maxLength: 256 + cooldown_ms: + type: integer + minimum: 0 + + # ---- Decide (optional) ---- + DecisionRequest: + type: object + required: [idempotency_key, subject, action, estimate] + additionalProperties: false + properties: + idempotency_key: + $ref: '#/components/schemas/IdempotencyKey' + subject: + $ref: '#/components/schemas/Subject' + action: + $ref: '#/components/schemas/Action' + estimate: + $ref: '#/components/schemas/Amount' + metadata: + type: object + additionalProperties: true + + DecisionResponse: + type: object + required: [decision] + additionalProperties: false + properties: + decision: + $ref: '#/components/schemas/DecisionEnum' + description: > + ALLOW indicates sufficient budget exists. ALLOW_WITH_CAPS indicates budget exists + but soft constraints apply. DENY indicates insufficient budget or policy block. + Note: /decide does not create a reservation; a subsequent reservation call may + still fail if concurrent activity depletes budget between the two calls. + caps: + $ref: '#/components/schemas/Caps' + description: Present only when decision=ALLOW_WITH_CAPS; MUST be absent otherwise. + reason_code: + type: string + maxLength: 128 + description: Stable machine-readable reason (extensible). + retry_after_ms: + type: integer + minimum: 0 + affected_scopes: + type: array + items: + type: string + description: Canonical scope identifiers impacted by this decision, in canonical order. + + # ---- Reservations (core) ---- + CommitOveragePolicy: + type: string + description: > + How server handles commits where actual > reserved. + + REJECT: reject commit (client must reserve a buffer or over-estimate). + Recommended: add 10-20% buffer to estimates when using REJECT. + If the action already happened externally, this creates an unaccounted gap. + + ALLOW_IF_AVAILABLE (default): commit always succeeds when actual > reserved. + If remaining budget supports the full delta, charge it atomically across all derived scopes. + Otherwise, cap the delta to available remaining (minimum across all affected scopes, floor 0), + charge estimate + capped_delta, and set is_over_limit=true on scopes where the full delta + could not be covered. This blocks future reservations until reconciled. + Never creates debt. Never rejects a commit — the action already happened. + + ALLOW_WITH_OVERDRAFT: if remaining budget supports the delta, commit normally. + Otherwise, check if (debt + delta) <= overdraft_limit across all affected scopes. + If yes: commit succeeds, add delta to debt, remaining can go negative. + If no: commit fails with 409 OVERDRAFT_LIMIT_EXCEEDED. + This policy ensures the ledger always reflects reality even when budget is exhausted at commit time. + + CONCURRENCY (NORMATIVE): + - The overdraft_limit check is performed per-commit and is NOT atomic across concurrent commits to the same scope. + - Multiple commits may each individually pass the (debt + delta) <= limit check but collectively cause debt to exceed overdraft_limit. + - This is acceptable: all commits succeed (actions already happened), and the scope enters "over-limit" state, + blocking future reservations until reconciled. + - Servers SHOULD use optimistic locking or compare-and-swap on debt updates to ensure individual commit atomicity, + but cross-commit atomicity is not required. + enum: [REJECT, ALLOW_IF_AVAILABLE, ALLOW_WITH_OVERDRAFT] + + ReservationStatus: + type: string + description: Reservation lifecycle state (v1+ may add additional terminal states). + enum: [ACTIVE, COMMITTED, RELEASED, EXPIRED] + + ReservationCreateRequest: + type: object + required: [idempotency_key, subject, action, estimate] + additionalProperties: false + properties: + idempotency_key: + $ref: '#/components/schemas/IdempotencyKey' + subject: + $ref: '#/components/schemas/Subject' + action: + $ref: '#/components/schemas/Action' + estimate: + $ref: '#/components/schemas/Amount' + ttl_ms: + type: integer + minimum: 1000 + maximum: 86400000 + default: 60000 + grace_period_ms: + type: integer + minimum: 0 + maximum: 60000 + default: 5000 + description: > + Grace window after TTL for in-flight commits. Default 5s. + + OPERATIONAL GUIDANCE: + - For high-latency actions (streaming LLM, slow APIs), consider setting higher (10-30s). + + INTEROP / SAFETY RATIONALE: + - The 60s maximum is an interoperability ceiling to limit long-lived "in-flight" commit windows and reduce + zombie-reservation and abuse surface (grace extends the time that a crashed client can keep budget locked). + + LONG-RUNNING WORKFLOWS: + - For multi-minute operations, clients SHOULD keep ttl_ms relatively short and use /v1/reservations/{reservation_id}/extend + as a heartbeat, and/or use chunked reserve/commit cycles rather than relying on large grace_period_ms. + overage_policy: + $ref: '#/components/schemas/CommitOveragePolicy' + default: ALLOW_IF_AVAILABLE + dry_run: + type: boolean + default: false + description: > + Shadow-mode evaluation. If true, the server MUST evaluate the reservation request and return + decision/caps/affected_scopes as if it would reserve, but MUST NOT modify balances, persist a + reservation, or require commit/release. Intended for safe rollout and testing of full reserve-path logic. + metadata: + type: object + additionalProperties: true + + Balance: + type: object + required: [scope, scope_path, remaining] + additionalProperties: false + description: >- + Ledger state for a single (scope, unit) balance. + + UNIT CONSISTENCY (NORMATIVE): + - All Amount fields within a Balance (remaining/reserved/spent/allocated/debt/overdraft_limit), if present, MUST share the same unit. + - Servers MUST NOT emit a Balance with mixed units; clients MAY treat such responses as invalid. + + LEDGER INVARIANT (NORMATIVE): + - If allocated, spent, reserved, and debt are all present, then remaining MUST satisfy: + remaining.amount = allocated.amount - spent.amount - reserved.amount - debt.amount (same unit). + - Note that remaining.amount can be negative when debt exceeds available budget. + - If allocated is absent, remaining is authoritative and clients MUST treat it as opaque (i.e., MUST NOT derive it from other fields). + - Servers MUST NOT emit a Balance where the invariant is violated when all fields are present. + + DEBT SEMANTICS (NORMATIVE): + - debt represents actual consumption that occurred when insufficient budget was available to cover a commit overage. + - debt is created only when overage_policy=ALLOW_WITH_OVERDRAFT and (remaining + released_reservation) < actual at commit time. + - When debt > 0, new reservations MUST be rejected with 409 DEBT_OUTSTANDING (unless explicitly allowed by policy). + - debt MUST be repaid via budget funding operations (out-of-scope for this API) before new reservations are permitted. + - When budget is added to a scope with debt > 0, debt MUST be repaid first, then remaining funds added to remaining. + + OVERDRAFT LIMIT (NORMATIVE): + - overdraft_limit defines the target maximum debt for this scope. + - If overdraft_limit is absent or amount=0, no overdraft is permitted (behaves as ALLOW_IF_AVAILABLE). + - The overdraft_limit is enforced per-commit but is NOT atomic across concurrent commits. + Multiple concurrent commits MAY cause cumulative debt to briefly exceed overdraft_limit. + - Individual commits are rejected with 409 OVERDRAFT_LIMIT_EXCEEDED only if (current_debt + delta) > overdraft_limit + at the moment of that specific commit. + - When debt > overdraft_limit after reconciliation (due to concurrent commits), the scope enters an "over-limit" state: + * The server MUST set is_over_limit=true on the Balance + * ALL new reservations against that scope MUST be rejected with 409 OVERDRAFT_LIMIT_EXCEEDED (not DEBT_OUTSTANDING) + * Existing active reservations MAY be committed or released normally + * The server SHOULD emit alerts/events for operator intervention + - Once debt is repaid below overdraft_limit, is_over_limit returns to false and normal reservation operation resumes. + properties: + scope: + type: string + description: Canonical scope identifier (server-derived), e.g. "tenant:t1", "workflow:run123" + scope_path: + type: string + description: Canonical hierarchical path (server-derived) + remaining: + $ref: '#/components/schemas/SignedAmount' + description: >- + Available budget for new reservations. Can be negative when debt exceeds allocated budget. + Formula: remaining = allocated - spent - reserved - debt + reserved: + $ref: '#/components/schemas/Amount' + description: Amount currently locked by active reservations + spent: + $ref: '#/components/schemas/Amount' + description: Amount successfully committed through reservations + debt: + $ref: '#/components/schemas/Amount' + description: >- + Overdraft amount from commits where actual > reserved and insufficient budget existed to cover the delta. + Represents consumption that happened but couldn't be paid from available budget at commit time. + Must be repaid before new reservations are allowed (unless policy permits otherwise). + allocated: + $ref: '#/components/schemas/Amount' + description: Optional total budget cap if a fixed allocation exists. + overdraft_limit: + $ref: '#/components/schemas/Amount' + description: >- + Optional maximum debt allowed for this scope. If absent or amount=0, no overdraft is permitted. + When present and amount > 0, commits can succeed by creating debt up to this limit. + is_over_limit: + type: boolean + description: >- + True when debt > overdraft_limit due to concurrent commits creating cumulative debt beyond the limit, + or when ALLOW_IF_AVAILABLE could not cover the full overage delta and capped the charge. + When true, ALL new reservations against this scope are blocked with 409 OVERDRAFT_LIMIT_EXCEEDED + until reconciled (debt repaid below overdraft_limit, or is_over_limit cleared by operator). + Defaults to false when absent. + + ReservationCreateResponse: + type: object + required: [decision, affected_scopes] + additionalProperties: false + properties: + decision: + $ref: '#/components/schemas/DecisionEnum' + description: > + For dry_run=true, decision MAY be DENY. For dry_run=false, insufficient budget MUST be expressed via 409 BUDGET_EXCEEDED (not decision=DENY). + reservation_id: + type: string + description: Present if decision is ALLOW or ALLOW_WITH_CAPS and dry_run is false. MUST be absent when dry_run is true. + reserved: + $ref: '#/components/schemas/Amount' + expires_at_ms: + type: integer + format: int64 + scope_path: + type: string + description: Canonical scope path (server-derived) + affected_scopes: + type: array + items: + type: string + description: Canonical scope identifiers impacted by this reservation, in canonical order. + caps: + $ref: '#/components/schemas/Caps' + description: Present only when decision=ALLOW_WITH_CAPS; MUST be absent otherwise. + balances: + type: array + items: + $ref: '#/components/schemas/Balance' + reason_code: + type: string + maxLength: 128 + retry_after_ms: + type: integer + minimum: 0 + + StandardMetrics: + type: object + description: > + Optional standard metrics for commit/event. All fields are optional. + Recommended for consistency across clients. For custom fields, use `custom`. + additionalProperties: false + properties: + tokens_input: + type: integer + minimum: 0 + description: Input tokens consumed + tokens_output: + type: integer + minimum: 0 + description: Output tokens generated + latency_ms: + type: integer + minimum: 0 + description: Total operation latency in milliseconds + model_version: + type: string + maxLength: 128 + description: Actual model/tool version used + custom: + type: object + additionalProperties: true + description: Arbitrary additional metrics + + CommitRequest: + type: object + required: [idempotency_key, actual] + additionalProperties: false + properties: + idempotency_key: + $ref: '#/components/schemas/IdempotencyKey' + actual: + $ref: '#/components/schemas/Amount' + metrics: + $ref: '#/components/schemas/StandardMetrics' + metadata: + type: object + additionalProperties: true + + CommitResponse: + type: object + required: [status, charged] + additionalProperties: false + properties: + status: + type: string + enum: [COMMITTED] + charged: + $ref: '#/components/schemas/Amount' + description: >- + Amount actually charged to the budget. Equals actual when the full amount could be covered. + With ALLOW_IF_AVAILABLE, charged may be less than actual when the overage delta is capped + to available remaining (charged = estimate + capped_delta). + released: + $ref: '#/components/schemas/Amount' + description: Amount returned from over-reservation (if any) + balances: + type: array + items: + $ref: '#/components/schemas/Balance' + + ReleaseRequest: + type: object + required: [idempotency_key] + additionalProperties: false + properties: + idempotency_key: + $ref: '#/components/schemas/IdempotencyKey' + reason: + type: string + maxLength: 256 + + ReleaseResponse: + type: object + required: [status, released] + additionalProperties: false + properties: + status: + type: string + enum: [RELEASED] + released: + $ref: '#/components/schemas/Amount' + balances: + type: array + items: + $ref: '#/components/schemas/Balance' + + ReservationDetail: + type: object + description: > + Reservation details returned by GET /v1/reservations/{reservation_id}. Useful for debugging. + required: + - reservation_id + - status + - subject + - action + - reserved + - created_at_ms + - expires_at_ms + - scope_path + - affected_scopes + additionalProperties: false + properties: + reservation_id: + type: string + status: + $ref: '#/components/schemas/ReservationStatus' + idempotency_key: + $ref: '#/components/schemas/IdempotencyKey' + description: Present if the server persists reservation creation idempotency keys for debugging/recovery. + subject: + $ref: '#/components/schemas/Subject' + action: + $ref: '#/components/schemas/Action' + reserved: + $ref: '#/components/schemas/Amount' + committed: + $ref: '#/components/schemas/Amount' + created_at_ms: + type: integer + format: int64 + expires_at_ms: + type: integer + format: int64 + finalized_at_ms: + type: integer + format: int64 + scope_path: + type: string + description: Canonical scope path (server-derived) + affected_scopes: + type: array + items: + type: string + description: Canonical scope identifiers impacted by this reservation, in canonical order. + metadata: + type: object + additionalProperties: true + + BalanceResponse: + type: object + required: [balances] + additionalProperties: false + properties: + balances: + type: array + items: + $ref: '#/components/schemas/Balance' + next_cursor: + type: string + description: Opaque cursor for next page (if any). + has_more: + type: boolean + description: True if next_cursor is present and more results may be available. + + # ---- Events (optional) ---- + EventCreateRequest: + type: object + required: [idempotency_key, subject, action, actual] + additionalProperties: false + properties: + idempotency_key: + $ref: '#/components/schemas/IdempotencyKey' + subject: + $ref: '#/components/schemas/Subject' + action: + $ref: '#/components/schemas/Action' + actual: + $ref: '#/components/schemas/Amount' + overage_policy: + $ref: '#/components/schemas/CommitOveragePolicy' + default: ALLOW_IF_AVAILABLE + description: > + How server handles events where actual would exceed remaining budget. + REJECT: server MUST return 409 BUDGET_EXCEEDED if actual > remaining across any derived scope. + ALLOW_IF_AVAILABLE (default): event always succeeds. If remaining budget supports the full actual, + charge it atomically. Otherwise, cap the charge to available remaining (minimum across all derived + scopes, floor 0) and set is_over_limit=true on scopes where the full amount could not be covered. + Never creates debt. Never rejects an event. + ALLOW_WITH_OVERDRAFT: if remaining budget supports actual, apply normally. Otherwise, check if + (current_debt + actual) <= overdraft_limit across all derived scopes. If yes: apply succeeds, + add actual to debt, remaining can go negative. If no: server MUST return 409 OVERDRAFT_LIMIT_EXCEEDED. + Concurrency semantics are identical to commit: the overdraft_limit check is per-event and NOT + atomic across concurrent events; scopes may enter over-limit state if concurrent events collectively + exceed overdraft_limit. + metrics: + $ref: '#/components/schemas/StandardMetrics' + client_time_ms: + type: integer + format: int64 + minimum: 0 + description: > + Optional client-observed timestamp (ms). This value is advisory only and MUST NOT be used for authorization, expiry, + idempotency or budget enforcement decisions. The server's receipt time governs all budget comparisons and invariants. + metadata: + type: object + additionalProperties: true + description: > + NOTE: Events MUST be applied atomically across derived scopes (same as reservations), or rejected. + + EventCreateResponse: + type: object + required: [status, event_id] + additionalProperties: false + properties: + status: + type: string + enum: [APPLIED] + event_id: + type: string + charged: + description: >- + The amount actually applied. Present when overage_policy is + ALLOW_IF_AVAILABLE and the actual was capped to the remaining + budget, so the client can see the effective charge. + $ref: '#/components/schemas/Amount' + balances: + type: array + items: + $ref: '#/components/schemas/Balance' + + ReservationSummary: + type: object + additionalProperties: false + required: + - reservation_id + - status + - subject + - action + - reserved + - created_at_ms + - expires_at_ms + - scope_path + - affected_scopes + properties: + reservation_id: + type: string + status: + $ref: '#/components/schemas/ReservationStatus' + idempotency_key: + $ref: '#/components/schemas/IdempotencyKey' + description: Present if the server persists reservation creation idempotency keys. + subject: + $ref: '#/components/schemas/Subject' + action: + $ref: '#/components/schemas/Action' + reserved: + $ref: '#/components/schemas/Amount' + created_at_ms: + type: integer + format: int64 + minimum: 0 + expires_at_ms: + type: integer + format: int64 + minimum: 0 + scope_path: + type: string + affected_scopes: + type: array + items: + type: string + + ReservationListResponse: + type: object + additionalProperties: false + required: [reservations] + properties: + reservations: + type: array + items: + $ref: '#/components/schemas/ReservationSummary' + next_cursor: + type: string + description: Opaque cursor for next page (if any). + has_more: + type: boolean + description: True if next_cursor is present and more results may be available. + + ReservationExtendRequest: + type: object + required: [idempotency_key, extend_by_ms] + additionalProperties: false + properties: + idempotency_key: + $ref: '#/components/schemas/IdempotencyKey' + extend_by_ms: + type: integer + minimum: 1 + maximum: 86400000 + description: > + Amount of time (ms) to extend the reservation expiry relative to its current expires_at_ms + (i.e., not relative to request time). This does NOT change reserved amount. Server MAY clamp + the effective extension to policy limits; the returned expires_at_ms is authoritative. + metadata: + type: object + additionalProperties: true + description: Optional debugging/audit metadata. + + ReservationExtendResponse: + type: object + required: [status, expires_at_ms] + additionalProperties: false + properties: + status: + type: string + enum: [ACTIVE] + description: Reservation remains ACTIVE after a successful extension. + expires_at_ms: + type: integer + format: int64 + minimum: 0 + description: New server-authoritative expiry timestamp (ms). + balances: + type: array + items: + $ref: '#/components/schemas/Balance' + description: Optional updated balances snapshot after extension (nice-to-have). + +paths: + /v1/decide: + post: + tags: [Decisions] + operationId: decide + summary: Optional preflight policy decision (no reservation created) + description: >- + Returns ALLOW / DENY, optionally with Caps for soft landing. This endpoint does not reserve budget. Clients that require + concurrency safety MUST use /v1/reservations. + + IDEMPOTENCY (NORMATIVE): + - On replay with the same idempotency_key, the server MUST return the original successful response payload. + + TENANCY (NORMATIVE): + - subject.tenant MUST match the effective tenant derived from auth; otherwise the server MUST return 403 FORBIDDEN. + + DEBT/OVERDRAFT STATE (NORMATIVE): + - If the subject scope has debt > 0 or is_over_limit=true, server SHOULD return + decision=DENY with reason_code=DEBT_OUTSTANDING or reason_code=OVERDRAFT_LIMIT_EXCEEDED + respectively. Server MUST NOT return 409 for these conditions on /decide. + + Idempotency on /decide is for request deduplication only. A replayed ALLOW response reflects budget state at the time of + the original call; clients MUST NOT treat a replayed decision as current budget authorization. + parameters: + - $ref: '#/components/parameters/IdempotencyKeyHeader' + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/DecisionRequest'} + responses: + "200": + description: Decision result + headers: + X-Request-Id: {$ref: '#/components/headers/X-Request-Id'} + X-Cycles-Tenant: {$ref: '#/components/headers/X-Cycles-Tenant'} + X-RateLimit-Remaining: {$ref: '#/components/headers/X-RateLimit-Remaining'} + X-RateLimit-Reset: {$ref: '#/components/headers/X-RateLimit-Reset'} + content: + application/json: + schema: {$ref: '#/components/schemas/DecisionResponse'} + "400": {$ref: '#/components/responses/ErrorResponse'} + "401": {$ref: '#/components/responses/ErrorResponse'} + "403": {$ref: '#/components/responses/ErrorResponse'} + "409": {$ref: '#/components/responses/ErrorResponse'} + "500": {$ref: '#/components/responses/ErrorResponse'} + + /v1/reservations: + post: + tags: [Reservations] + operationId: createReservation + summary: Reserve budget for a planned action (concurrency-safe) + description: >- + Atomically reserves the estimated amount across server-derived scopes and returns a reservation_id. Reservations expire + at expires_at_ms; commits are accepted through (expires_at_ms + grace_period_ms). + + If dry_run=true, server MUST evaluate the full reservation request and return decision/caps/affected_scopes/balances + as if the reservation were live, but MUST NOT modify balances, persist a reservation, or require commit/release. + + DRY-RUN RESPONSE RULES (NORMATIVE): + - reservation_id and expires_at_ms MUST be absent. + - affected_scopes MUST be populated regardless of decision outcome (ALLOW / ALLOW_WITH_CAPS / DENY). + - If decision=ALLOW_WITH_CAPS, caps MUST be present; otherwise caps MUST be absent. + - If decision=DENY, reason_code SHOULD be populated; it is the primary diagnostic signal for why the dry_run was denied. + - balances MAY be populated (recommended for operator visibility), but MUST reflect a non-mutating evaluation. + + OVER-LIMIT BLOCKING (NORMATIVE): + - If ANY affected scope has debt > overdraft_limit (is_over_limit=true), the reservation MUST be rejected + with 409 OVERDRAFT_LIMIT_EXCEEDED, regardless of available remaining budget. + - This blocks new work when overdraft reconciliation is needed. + + IDEMPOTENCY (NORMATIVE): + - On replay with the same idempotency_key, the server MUST return the original successful response payload, + including the original reservation_id (if any). + + TENANCY (NORMATIVE): + - subject.tenant MUST match the effective tenant derived from auth; otherwise the server MUST return 403 FORBIDDEN. + parameters: + - $ref: '#/components/parameters/IdempotencyKeyHeader' + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/ReservationCreateRequest'} + responses: + "200": + description: Reservation decision (ALLOW/DENY with optional caps) + headers: + X-Request-Id: {$ref: '#/components/headers/X-Request-Id'} + X-Cycles-Tenant: {$ref: '#/components/headers/X-Cycles-Tenant'} + content: + application/json: + schema: {$ref: '#/components/schemas/ReservationCreateResponse'} + "400": {$ref: '#/components/responses/ErrorResponse'} + "401": {$ref: '#/components/responses/ErrorResponse'} + "403": {$ref: '#/components/responses/ErrorResponse'} + "409": {$ref: '#/components/responses/ErrorResponse'} + "500": {$ref: '#/components/responses/ErrorResponse'} + get: + tags: [Reservations] + operationId: listReservations + summary: List reservations (optional recovery/debug endpoint) + description: >- + Lists reservations visible to the effective tenant. This endpoint is OPTIONAL in v0 deployments. + + + RECOVERY (NORMATIVE): + - If a client loses reservation_id, it MAY recover it by querying with idempotency_key and/or subject filters. + - If idempotency_key is provided, the server SHOULD return at most one matching reservation (uniqueness is expected per (effective tenant, endpoint, idempotency_key)). + - Servers SHOULD support filtering by status=ACTIVE to identify "stuck" reservations. + + SUBJECT FILTERS (GUIDANCE): + - Query parameters tenant/workspace/app/workflow/agent/toolset filter on the canonical Subject fields. + - Filtering on Subject.dimensions is out of scope for v0 unless explicitly implemented by the server. + + TENANCY (NORMATIVE): + - The server MUST scope results to the effective tenant derived from auth. + - If the tenant query parameter is provided, it is validation-only and MUST match the effective tenant; otherwise the server MUST return 403 FORBIDDEN. + - If tenant is omitted, the effective tenant is used. + parameters: + - name: idempotency_key + in: query + required: false + schema: + $ref: '#/components/schemas/IdempotencyKey' + description: Lookup handle to recover the reservation_id from a prior createReservation call. + - name: status + in: query + required: false + schema: + $ref: '#/components/schemas/ReservationStatus' + description: Filter by reservation status (e.g., ACTIVE). + - name: tenant + in: query + required: false + schema: {type: string} + - name: workspace + in: query + required: false + schema: {type: string} + - name: app + in: query + required: false + schema: {type: string} + - name: workflow + in: query + required: false + schema: {type: string} + - name: agent + in: query + required: false + schema: {type: string} + - name: toolset + in: query + required: false + schema: {type: string} + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Cursor' + responses: + "200": + description: Reservations list + headers: + X-Request-Id: {$ref: '#/components/headers/X-Request-Id'} + X-Cycles-Tenant: {$ref: '#/components/headers/X-Cycles-Tenant'} + content: + application/json: + schema: {$ref: '#/components/schemas/ReservationListResponse'} + "400": {$ref: '#/components/responses/ErrorResponse'} + "401": {$ref: '#/components/responses/ErrorResponse'} + "403": {$ref: '#/components/responses/ErrorResponse'} + "500": {$ref: '#/components/responses/ErrorResponse'} + + /v1/reservations/{reservation_id}: + get: + tags: [Reservations] + operationId: getReservation + summary: Get reservation details (optional, for debugging) + description: >- + Retrieve current status and details of a reservation by ID. Useful for debugging and monitoring long-running operations. + + TENANCY (NORMATIVE): + - If the reservation exists but is owned by a different effective tenant, the server MUST return 403 FORBIDDEN. + parameters: + - $ref: '#/components/parameters/ReservationId' + responses: + "200": + description: Reservation details + headers: + X-Request-Id: {$ref: '#/components/headers/X-Request-Id'} + X-Cycles-Tenant: {$ref: '#/components/headers/X-Cycles-Tenant'} + content: + application/json: + schema: {$ref: '#/components/schemas/ReservationDetail'} + "401": {$ref: '#/components/responses/ErrorResponse'} + "403": {$ref: '#/components/responses/ErrorResponse'} + "404": {$ref: '#/components/responses/ErrorResponse'} + "410": {$ref: '#/components/responses/ErrorResponse'} + "500": {$ref: '#/components/responses/ErrorResponse'} + + /v1/reservations/{reservation_id}/commit: + post: + tags: [Reservations] + operationId: commitReservation + summary: Commit actual spend for a reservation (auto-releases delta) + description: >- + Commits actual spend. If actual < reserved, delta is released automatically. If actual > reserved, behavior is controlled + by the reservation's overage_policy. + + IDEMPOTENCY (NORMATIVE): + - On replay with the same idempotency_key, the server MUST return the original successful response payload. + + TENANCY (NORMATIVE): + - If the reservation exists but is owned by a different effective tenant, the server MUST return 403 FORBIDDEN. + parameters: + - $ref: '#/components/parameters/ReservationId' + - $ref: '#/components/parameters/IdempotencyKeyHeader' + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/CommitRequest'} + responses: + "200": + description: Commit succeeded + headers: + X-Request-Id: {$ref: '#/components/headers/X-Request-Id'} + X-Cycles-Tenant: {$ref: '#/components/headers/X-Cycles-Tenant'} + content: + application/json: + schema: {$ref: '#/components/schemas/CommitResponse'} + "400": {$ref: '#/components/responses/ErrorResponse'} + "401": {$ref: '#/components/responses/ErrorResponse'} + "403": {$ref: '#/components/responses/ErrorResponse'} + "404": {$ref: '#/components/responses/ErrorResponse'} + "409": {$ref: '#/components/responses/ErrorResponse'} + "410": {$ref: '#/components/responses/ErrorResponse'} + "500": {$ref: '#/components/responses/ErrorResponse'} + + /v1/reservations/{reservation_id}/release: + post: + tags: [Reservations] + operationId: releaseReservation + summary: Release an unused reservation + description: >- + Releases reserved amount back to remaining budget. + + IDEMPOTENCY (NORMATIVE): + - On replay with the same idempotency_key, the server MUST return the original successful response payload. + + TENANCY (NORMATIVE): + - If the reservation exists but is owned by a different effective tenant, the server MUST return 403 FORBIDDEN. + parameters: + - $ref: '#/components/parameters/ReservationId' + - $ref: '#/components/parameters/IdempotencyKeyHeader' + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/ReleaseRequest'} + responses: + "200": + description: Release succeeded + headers: + X-Request-Id: {$ref: '#/components/headers/X-Request-Id'} + X-Cycles-Tenant: {$ref: '#/components/headers/X-Cycles-Tenant'} + content: + application/json: + schema: {$ref: '#/components/schemas/ReleaseResponse'} + "401": {$ref: '#/components/responses/ErrorResponse'} + "403": {$ref: '#/components/responses/ErrorResponse'} + "404": {$ref: '#/components/responses/ErrorResponse'} + "409": {$ref: '#/components/responses/ErrorResponse'} + "410": {$ref: '#/components/responses/ErrorResponse'} + "500": {$ref: '#/components/responses/ErrorResponse'} + + /v1/reservations/{reservation_id}/extend: + post: + tags: [Reservations] + operationId: extendReservation + summary: Extend reservation TTL (lease refresh / heartbeat) + description: >- + Extends the expiry of an ACTIVE reservation to support long-running agent workflows. + + SEMANTICS (NORMATIVE): + - Extension updates expires_at_ms only; it MUST NOT change reserved amount, unit, subject, action, scope_path, or affected_scopes. + - Extensions MUST be applied in a concurrency-safe way. + - Server MUST accept extend only when status is ACTIVE and the reservation has not yet expired + (i.e., server time ≤ expires_at_ms). If the reservation is expired, server MUST return 410 with error=RESERVATION_EXPIRED. + + IDEMPOTENCY (NORMATIVE): + - On replay with the same idempotency_key, the server MUST return the original successful response payload. + + TENANCY (NORMATIVE): + - If the reservation exists but is owned by a different effective tenant, the server MUST return 403 FORBIDDEN. + + ERROR SEMANTICS (NORMATIVE): + - If the reservation is COMMITTED or RELEASED, server MUST return 409 with error=RESERVATION_FINALIZED. + - If the reservation is expired (server time > expires_at_ms), server MUST return 410 with error=RESERVATION_EXPIRED. + - If the reservation never existed, server MUST return 404 with error=NOT_FOUND. + parameters: + - $ref: '#/components/parameters/ReservationId' + - $ref: '#/components/parameters/IdempotencyKeyHeader' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ReservationExtendRequest' + responses: + "200": + description: Reservation expiry extended + headers: + X-Request-Id: {$ref: '#/components/headers/X-Request-Id'} + X-Cycles-Tenant: {$ref: '#/components/headers/X-Cycles-Tenant'} + content: + application/json: + schema: + $ref: '#/components/schemas/ReservationExtendResponse' + "400": {$ref: '#/components/responses/ErrorResponse'} + "401": {$ref: '#/components/responses/ErrorResponse'} + "403": {$ref: '#/components/responses/ErrorResponse'} + "404": {$ref: '#/components/responses/ErrorResponse'} + "409": {$ref: '#/components/responses/ErrorResponse'} + "410": {$ref: '#/components/responses/ErrorResponse'} + "500": {$ref: '#/components/responses/ErrorResponse'} + + /v1/balances: + get: + tags: [Balances] + operationId: getBalances + summary: Query current budget balances across scopes (nice-to-have) + description: > + Returns balances for scopes matching the provided subject filter. + include_children MAY be ignored by v0 implementations. + + SUBJECT FILTER REQUIREMENT (NORMATIVE): + - At least one of tenant/workspace/app/workflow/agent/toolset MUST be provided. + - If all of these filters are omitted, server MUST return 400 with error=INVALID_REQUEST. + + TENANCY (NORMATIVE): + - The server MUST scope results to the effective tenant derived from auth. + - If the tenant query parameter is provided, it is validation-only and MUST match the effective tenant; otherwise the server MUST return 403 FORBIDDEN. + - If tenant is omitted, the effective tenant is used. + + parameters: + - name: tenant + in: query + schema: {type: string} + - name: workspace + in: query + schema: {type: string} + - name: app + in: query + schema: {type: string} + - name: workflow + in: query + schema: {type: string} + - name: agent + in: query + schema: {type: string} + - name: toolset + in: query + schema: {type: string} + - name: include_children + in: query + schema: {type: boolean, default: false} + - $ref: '#/components/parameters/Limit' + - $ref: '#/components/parameters/Cursor' + responses: + "200": + description: Balance response + headers: + X-Request-Id: {$ref: '#/components/headers/X-Request-Id'} + X-Cycles-Tenant: {$ref: '#/components/headers/X-Cycles-Tenant'} + content: + application/json: + schema: {$ref: '#/components/schemas/BalanceResponse'} + "400": {$ref: '#/components/responses/ErrorResponse'} + "401": {$ref: '#/components/responses/ErrorResponse'} + "403": {$ref: '#/components/responses/ErrorResponse'} + "500": {$ref: '#/components/responses/ErrorResponse'} + + /v1/events: + post: + tags: [Events] + operationId: createEvent + summary: Optional post-only accounting when pre-estimation is not available + description: >- + Records an accounting event without a reservation. This endpoint is optional in v0 deployments. + The event MUST be applied atomically across all derived scopes before the server returns 201. + + IDEMPOTENCY (NORMATIVE): + - On replay with the same idempotency_key, the server MUST return the original successful response payload. + + TENANCY (NORMATIVE): + - subject.tenant MUST match the effective tenant derived from auth; otherwise the server MUST return 403 FORBIDDEN. + parameters: + - $ref: '#/components/parameters/IdempotencyKeyHeader' + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/EventCreateRequest'} + responses: + "201": + description: Event created and atomically applied to balances + headers: + X-Request-Id: {$ref: '#/components/headers/X-Request-Id'} + X-Cycles-Tenant: {$ref: '#/components/headers/X-Cycles-Tenant'} + content: + application/json: + schema: {$ref: '#/components/schemas/EventCreateResponse'} + "400": {$ref: '#/components/responses/ErrorResponse'} + "401": {$ref: '#/components/responses/ErrorResponse'} + "403": {$ref: '#/components/responses/ErrorResponse'} + "409": {$ref: '#/components/responses/ErrorResponse'} + "500": {$ref: '#/components/responses/ErrorResponse'} diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/test_live_server.py b/tests/integration/test_live_server.py new file mode 100644 index 0000000..0273b50 --- /dev/null +++ b/tests/integration/test_live_server.py @@ -0,0 +1,40 @@ +""" +Integration tests against a live Cycles server. +Skipped unless CYCLES_BASE_URL is set. +""" +import os +import pytest + +pytestmark = pytest.mark.skipif( + not os.environ.get("CYCLES_BASE_URL"), + reason="CYCLES_BASE_URL not set -- skipping live server tests" +) + +# These will be fleshed out once the nightly workflow is configured with secrets + + +def test_health_check(): + """Server responds to health endpoint.""" + import urllib.request + base = os.environ["CYCLES_BASE_URL"] + req = urllib.request.Request(f"{base}/actuator/health") + with urllib.request.urlopen(req, timeout=5) as resp: + assert resp.status == 200 + + +def test_reservation_lifecycle(): + """Create, commit, and verify a reservation.""" + # TODO: implement once API key provisioning is set up + pass + + +def test_decide_endpoint(): + """POST /v1/decide returns a valid decision.""" + # TODO: implement + pass + + +def test_balance_query(): + """GET /v1/balances returns balance data.""" + # TODO: implement + pass diff --git a/tests/test_contract.py b/tests/test_contract.py new file mode 100644 index 0000000..284ccf2 --- /dev/null +++ b/tests/test_contract.py @@ -0,0 +1,386 @@ +"""OpenAPI contract tests: validate sample payloads against the Cycles Protocol v0 spec.""" + +from __future__ import annotations + +import pathlib + +import copy + +import pytest +import yaml +from jsonschema import Draft202012Validator, ValidationError + +SPEC_PATH = pathlib.Path(__file__).parent / "fixtures" / "cycles-protocol-v0.yaml" + +JSON_SCHEMA_2020_12 = "https://json-schema.org/draft/2020-12/schema" + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _load_spec() -> dict: + with open(SPEC_PATH) as f: + return yaml.safe_load(f) + + +def _resolve_refs(schema: dict | list, schemas: dict) -> dict | list: + """Recursively resolve $ref pointers against the components/schemas map.""" + if isinstance(schema, list): + return [_resolve_refs(item, schemas) for item in schema] + if not isinstance(schema, dict): + return schema + if "$ref" in schema: + ref_path = schema["$ref"] # e.g. "#/components/schemas/Amount" + ref_name = ref_path.rsplit("/", 1)[-1] + resolved = copy.deepcopy(schemas[ref_name]) + return _resolve_refs(resolved, schemas) + return {k: _resolve_refs(v, schemas) for k, v in schema.items()} + + +def _validate(instance: dict, schema_name: str, spec: dict) -> None: + """Validate *instance* against *schema_name* from the spec with $ref resolution.""" + schemas = spec["components"]["schemas"] + schema = copy.deepcopy(schemas[schema_name]) + resolved = _resolve_refs(schema, schemas) + validator = Draft202012Validator(resolved) + validator.validate(instance) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture(scope="module") +def spec() -> dict: + return _load_spec() + + +# --------------------------------------------------------------------------- +# Sample payloads +# --------------------------------------------------------------------------- + +SAMPLE_SUBJECT = {"tenant": "acme"} +SAMPLE_ACTION = {"kind": "llm.completion", "name": "openai:gpt-4o"} +SAMPLE_AMOUNT = {"unit": "USD_MICROCENTS", "amount": 5000} +SAMPLE_AMOUNT_TOKENS = {"unit": "TOKENS", "amount": 1024} + + +# ---- Request bodies ---- + +DECISION_REQUEST = { + "idempotency_key": "dec-001", + "subject": SAMPLE_SUBJECT, + "action": SAMPLE_ACTION, + "estimate": SAMPLE_AMOUNT, +} + +RESERVATION_REQUEST = { + "idempotency_key": "res-001", + "subject": SAMPLE_SUBJECT, + "action": SAMPLE_ACTION, + "estimate": SAMPLE_AMOUNT, + "ttl_ms": 30000, + "grace_period_ms": 5000, + "overage_policy": "ALLOW_IF_AVAILABLE", +} + +RESERVATION_REQUEST_MINIMAL = { + "idempotency_key": "res-002", + "subject": {"tenant": "acme"}, + "action": {"kind": "tool.search", "name": "web.search"}, + "estimate": {"unit": "TOKENS", "amount": 100}, +} + +COMMIT_REQUEST = { + "idempotency_key": "cmt-001", + "actual": SAMPLE_AMOUNT, +} + +COMMIT_REQUEST_WITH_METRICS = { + "idempotency_key": "cmt-002", + "actual": SAMPLE_AMOUNT, + "metrics": { + "tokens_input": 512, + "tokens_output": 128, + "latency_ms": 340, + "model_version": "gpt-4o-2024-05-13", + }, +} + +RELEASE_REQUEST = { + "idempotency_key": "rel-001", + "reason": "user cancelled", +} + +RELEASE_REQUEST_MINIMAL = { + "idempotency_key": "rel-002", +} + +EVENT_REQUEST = { + "idempotency_key": "evt-001", + "subject": SAMPLE_SUBJECT, + "action": SAMPLE_ACTION, + "actual": SAMPLE_AMOUNT, +} + +EVENT_REQUEST_FULL = { + "idempotency_key": "evt-002", + "subject": {"tenant": "acme", "workspace": "ws1", "agent": "a1"}, + "action": {"kind": "llm.completion", "name": "openai:gpt-4o", "tags": ["prod"]}, + "actual": {"unit": "USD_MICROCENTS", "amount": 3200}, + "overage_policy": "ALLOW_WITH_OVERDRAFT", + "metrics": {"tokens_input": 100, "tokens_output": 50, "latency_ms": 200}, + "client_time_ms": 1700000000000, +} + +# ---- Response bodies ---- + +DECISION_RESPONSE_ALLOW = {"decision": "ALLOW"} + +DECISION_RESPONSE_CAPS = { + "decision": "ALLOW_WITH_CAPS", + "caps": {"max_tokens": 1000, "cooldown_ms": 500}, + "affected_scopes": ["tenant:acme"], +} + +DECISION_RESPONSE_DENY = { + "decision": "DENY", + "reason_code": "budget_exhausted", + "retry_after_ms": 60000, + "affected_scopes": ["tenant:acme"], +} + +RESERVATION_RESPONSE = { + "decision": "ALLOW", + "reservation_id": "res_abc123", + "reserved": SAMPLE_AMOUNT, + "expires_at_ms": 1700000060000, + "scope_path": "tenant:acme", + "affected_scopes": ["tenant:acme"], +} + +RESERVATION_RESPONSE_CAPS = { + "decision": "ALLOW_WITH_CAPS", + "reservation_id": "res_abc456", + "reserved": SAMPLE_AMOUNT, + "expires_at_ms": 1700000060000, + "scope_path": "tenant:acme", + "affected_scopes": ["tenant:acme"], + "caps": {"max_tokens": 500}, +} + +COMMIT_RESPONSE = { + "status": "COMMITTED", + "charged": SAMPLE_AMOUNT, +} + +COMMIT_RESPONSE_WITH_RELEASED = { + "status": "COMMITTED", + "charged": {"unit": "USD_MICROCENTS", "amount": 3000}, + "released": {"unit": "USD_MICROCENTS", "amount": 2000}, +} + +RELEASE_RESPONSE = { + "status": "RELEASED", + "released": SAMPLE_AMOUNT, +} + +EVENT_RESPONSE = { + "status": "APPLIED", + "event_id": "evt_xyz789", +} + +ERROR_RESPONSE = { + "error": "BUDGET_EXCEEDED", + "message": "Insufficient budget in scope tenant:acme", + "request_id": "req-abc-123", +} + +ERROR_RESPONSE_WITH_DETAILS = { + "error": "OVERDRAFT_LIMIT_EXCEEDED", + "message": "Debt exceeds overdraft limit", + "request_id": "req-def-456", + "details": {"current_debt": 50000, "overdraft_limit": 40000}, +} + + +# =========================================================================== +# Request body contract tests +# =========================================================================== + +class TestDecisionRequestContract: + def test_valid_decision_request(self, spec: dict) -> None: + _validate(DECISION_REQUEST, "DecisionRequest", spec) + + def test_decision_request_with_metadata(self, spec: dict) -> None: + payload = {**DECISION_REQUEST, "metadata": {"trace_id": "t-123"}} + _validate(payload, "DecisionRequest", spec) + + def test_decision_request_missing_required_field(self, spec: dict) -> None: + incomplete = {k: v for k, v in DECISION_REQUEST.items() if k != "estimate"} + with pytest.raises(ValidationError, match="estimate"): + _validate(incomplete, "DecisionRequest", spec) + + def test_decision_request_extra_field_rejected(self, spec: dict) -> None: + bad = {**DECISION_REQUEST, "bogus_field": True} + with pytest.raises(ValidationError): + _validate(bad, "DecisionRequest", spec) + + +class TestReservationRequestContract: + def test_valid_full_reservation(self, spec: dict) -> None: + _validate(RESERVATION_REQUEST, "ReservationCreateRequest", spec) + + def test_valid_minimal_reservation(self, spec: dict) -> None: + _validate(RESERVATION_REQUEST_MINIMAL, "ReservationCreateRequest", spec) + + def test_reservation_with_dry_run(self, spec: dict) -> None: + payload = {**RESERVATION_REQUEST_MINIMAL, "dry_run": True} + _validate(payload, "ReservationCreateRequest", spec) + + def test_reservation_missing_subject(self, spec: dict) -> None: + bad = {k: v for k, v in RESERVATION_REQUEST.items() if k != "subject"} + with pytest.raises(ValidationError): + _validate(bad, "ReservationCreateRequest", spec) + + +class TestCommitRequestContract: + def test_valid_commit(self, spec: dict) -> None: + _validate(COMMIT_REQUEST, "CommitRequest", spec) + + def test_commit_with_metrics(self, spec: dict) -> None: + _validate(COMMIT_REQUEST_WITH_METRICS, "CommitRequest", spec) + + def test_commit_missing_actual(self, spec: dict) -> None: + with pytest.raises(ValidationError, match="actual"): + _validate({"idempotency_key": "x"}, "CommitRequest", spec) + + +class TestReleaseRequestContract: + def test_valid_release(self, spec: dict) -> None: + _validate(RELEASE_REQUEST, "ReleaseRequest", spec) + + def test_minimal_release(self, spec: dict) -> None: + _validate(RELEASE_REQUEST_MINIMAL, "ReleaseRequest", spec) + + +class TestEventRequestContract: + def test_valid_event(self, spec: dict) -> None: + _validate(EVENT_REQUEST, "EventCreateRequest", spec) + + def test_event_full(self, spec: dict) -> None: + _validate(EVENT_REQUEST_FULL, "EventCreateRequest", spec) + + def test_event_missing_action(self, spec: dict) -> None: + bad = {k: v for k, v in EVENT_REQUEST.items() if k != "action"} + with pytest.raises(ValidationError): + _validate(bad, "EventCreateRequest", spec) + + +# =========================================================================== +# Response body contract tests +# =========================================================================== + +class TestDecisionResponseContract: + def test_allow(self, spec: dict) -> None: + _validate(DECISION_RESPONSE_ALLOW, "DecisionResponse", spec) + + def test_allow_with_caps(self, spec: dict) -> None: + _validate(DECISION_RESPONSE_CAPS, "DecisionResponse", spec) + + def test_deny(self, spec: dict) -> None: + _validate(DECISION_RESPONSE_DENY, "DecisionResponse", spec) + + +class TestReservationResponseContract: + def test_allow(self, spec: dict) -> None: + _validate(RESERVATION_RESPONSE, "ReservationCreateResponse", spec) + + def test_allow_with_caps(self, spec: dict) -> None: + _validate(RESERVATION_RESPONSE_CAPS, "ReservationCreateResponse", spec) + + +class TestCommitResponseContract: + def test_committed(self, spec: dict) -> None: + _validate(COMMIT_RESPONSE, "CommitResponse", spec) + + def test_committed_with_released(self, spec: dict) -> None: + _validate(COMMIT_RESPONSE_WITH_RELEASED, "CommitResponse", spec) + + +class TestReleaseResponseContract: + def test_released(self, spec: dict) -> None: + _validate(RELEASE_RESPONSE, "ReleaseResponse", spec) + + +class TestEventResponseContract: + def test_applied(self, spec: dict) -> None: + _validate(EVENT_RESPONSE, "EventCreateResponse", spec) + + +class TestErrorResponseContract: + def test_error_basic(self, spec: dict) -> None: + _validate(ERROR_RESPONSE, "ErrorResponse", spec) + + def test_error_with_details(self, spec: dict) -> None: + _validate(ERROR_RESPONSE_WITH_DETAILS, "ErrorResponse", spec) + + def test_error_missing_message(self, spec: dict) -> None: + with pytest.raises(ValidationError, match="message"): + _validate({"error": "NOT_FOUND", "request_id": "r1"}, "ErrorResponse", spec) + + def test_error_invalid_code(self, spec: dict) -> None: + with pytest.raises(ValidationError): + _validate( + {"error": "MADE_UP_CODE", "message": "nope", "request_id": "r1"}, + "ErrorResponse", + spec, + ) + + +# =========================================================================== +# Enum value tests +# =========================================================================== + +class TestEnumValues: + def test_unit_enum_values(self, spec: dict) -> None: + expected = {"USD_MICROCENTS", "TOKENS", "CREDITS", "RISK_POINTS"} + actual = set(spec["components"]["schemas"]["UnitEnum"]["enum"]) + assert actual == expected + + def test_error_code_values(self, spec: dict) -> None: + expected = { + "INVALID_REQUEST", + "UNAUTHORIZED", + "FORBIDDEN", + "NOT_FOUND", + "BUDGET_EXCEEDED", + "BUDGET_FROZEN", + "BUDGET_CLOSED", + "RESERVATION_EXPIRED", + "RESERVATION_FINALIZED", + "IDEMPOTENCY_MISMATCH", + "UNIT_MISMATCH", + "OVERDRAFT_LIMIT_EXCEEDED", + "DEBT_OUTSTANDING", + "MAX_EXTENSIONS_EXCEEDED", + "INTERNAL_ERROR", + } + actual = set(spec["components"]["schemas"]["ErrorCode"]["enum"]) + assert actual == expected + + def test_decision_enum_values(self, spec: dict) -> None: + expected = {"ALLOW", "ALLOW_WITH_CAPS", "DENY"} + actual = set(spec["components"]["schemas"]["DecisionEnum"]["enum"]) + assert actual == expected + + def test_reservation_status_values(self, spec: dict) -> None: + expected = {"ACTIVE", "COMMITTED", "RELEASED", "EXPIRED"} + actual = set(spec["components"]["schemas"]["ReservationStatus"]["enum"]) + assert actual == expected + + def test_overage_policy_values(self, spec: dict) -> None: + expected = {"REJECT", "ALLOW_IF_AVAILABLE", "ALLOW_WITH_OVERDRAFT"} + actual = set(spec["components"]["schemas"]["CommitOveragePolicy"]["enum"]) + assert actual == expected