diff --git a/drafts/cycles-evidence-v0.1.yaml b/drafts/cycles-evidence-v0.1.yaml new file mode 100644 index 0000000..371dee0 --- /dev/null +++ b/drafts/cycles-evidence-v0.1.yaml @@ -0,0 +1,1135 @@ +openapi: 3.1.0 +info: + title: Cycles Evidence Envelope + version: 0.1.0 + license: + name: Apache 2.0 + url: https://www.apache.org/licenses/LICENSE-2.0 + summary: > + Cross-system content-addressed evidence envelope for Cycles + authorization lifecycle events (decide / reserve / commit / release). + description: | + STATUS: DRAFT (v0.1) — published in `cycles-protocol/drafts/` for + review. Not yet normative; will move to a numbered spec file at + repo root (e.g. `cycles-evidence-v0.2.yaml`) once at least one + production implementation ships and at least one cross-system + consumer (notably APS, https://github.com/aeoess/agent-passport-system) + has integrated against the envelope shape end-to-end. + + # Purpose + + The Cycles runtime API (`cycles-protocol-v0.yaml`) emits + server-issued JSON responses for the four authorization lifecycle + events — `decide`, `reserve`, `commit`, `release`. Those responses + are sufficient for the immediate caller (an agent runtime or a + gateway with a live connection to the Cycles server), but they are + not sufficient for **cross-system, ledger-independent audit + consumers** who need to verify that an event happened without + requiring access to the Cycles server's live ledger state. + + The CyclesEvidence envelope closes that gap. It wraps the same + lifecycle information in a JCS-canonicalized, Ed25519-signed + envelope keyed by a sha256 content hash, suitable for: + + - Cross-system audit consumers fetching a single artifact by + content hash (e.g. APS `CyclesEvidenceRef.cycles_evidence_id_sha256`). + - Long-horizon archival (EU AI Act Article 12 retention, etc.) + where the original Cycles server may no longer be reachable. + - Replay verification from evidence alone, without re-consulting + the live ledger — the open question that motivated downgrading + `replay_class` from `full_replay` to `decision_replay` on the + Cycles signal-type crosswalk at + aeoess/agent-governance-vocabulary#92. Once this envelope is + normative and emitted by reference implementations, + `replay_class` is promotable back to `full_replay`. + + # Relationship to APS + + The APS-side `CyclesEvidenceRef` minimal join shape (per + aeoess/agent-passport-system#25, comment 4422627045) carries four + fields: + + - `cycles_evidence_url` — where to fetch the envelope + - `cycles_evidence_id_sha256` — content-addressed id of the envelope + - `action_ref` — APS-side action anchor + - `delegation_ref` — APS-side delegation anchor + + This document specifies what `cycles_evidence_id_sha256` is computed + over (canonical bytes of this envelope with BOTH `evidence_id` AND + `signature` set to the empty string — see the normative algorithm + under `CyclesEvidence` schema description) and what + `cycles_evidence_url` is expected to return (a single + `CyclesEvidence` envelope serialized as JCS-canonical JSON). + + # Out of scope (v0.1) + + - Evidence retrieval HTTP API. The fetch contract for + `cycles_evidence_url` is documented here as expectations on the + response body and headers, but the URL path layout, auth + scheme, and replication policy are out of scope and live with + the Cycles server implementation. + - Signing-key rotation, JWKS publication, did:cycles method + registration. These will land alongside or after the + normative move to `cycles-evidence-v0.2.yaml`. + - Aggregated evidence (Merkle-batched roots covering many + lifecycle events). A v0.2+ concern; v0.1 is one envelope per + lifecycle event. + + x-changelog: + url: ./changelogs/cycles-evidence-v0.1.md + format: keep-a-changelog + note: changelog file will be added when the draft is promoted to a normative spec. + + contact: + name: Cycles maintainers + url: https://github.com/runcycles/cycles-protocol + +# This file is schema-only. It does not declare server endpoints +# because the canonical event surface (`/v1/decide`, `/v1/reservations`, +# etc.) is already defined in `cycles-protocol-v0.yaml`; CyclesEvidence +# is the *evidence shape* that wraps the bodies those endpoints return, +# not a separate endpoint family. + +components: + + schemas: + + # ===================================================================== + # CyclesEvidence — the signed, content-addressed envelope. + # ===================================================================== + CyclesEvidence: + type: object + additionalProperties: false + description: | + Signed evidence envelope for one Cycles authorization lifecycle + event. Identified by the sha256 hex of its JCS-canonical bytes + with **both `evidence_id` and `signature`** set to the empty + string; signed by the Cycles server's Ed25519 signing key over + the same bytes with `evidence_id` populated and `signature` + still empty. See the normative algorithm below. + + # Hash derivation (v0.1, normative) + + 1. Construct the envelope object with all fields populated + **except** `evidence_id` and `signature`, both of which are + set to the empty string `""`. + 2. Canonicalize per RFC 8785 (JCS). Encode the canonical form + as UTF-8 bytes. + 3. Compute sha256 of those bytes. Hex-encode the digest + (lowercase, 64 chars). + 4. Set `evidence_id` to that hex string. + + # Signature derivation (v0.1, normative) + + 1. Take the envelope from the hash-derivation step with + `evidence_id` now populated and `signature` still `""`. + 2. Canonicalize again per RFC 8785 (JCS). Encode as UTF-8 bytes. + 3. Compute Ed25519 signature over those bytes using the Cycles + server's signing key (referenced via `signer_did`). + 4. Hex-encode the 64-byte signature (lowercase, 128 chars). + 5. Set `signature` to that hex string. + + This is the same id-then-signature ordering used by APS + receipts at `src/v2/payment-rails/canonicalize.ts` and by + Wave 1 accountability artifacts — so a consumer that already + knows how to verify an APS PaymentReceipt can verify a + CyclesEvidence envelope with the same primitives, only the + canonical-bytes input differs. + + # Verification + + A verifier MUST: + + - Re-derive `evidence_id` per the algorithm above and compare + byte-for-byte with the envelope's `evidence_id` field. + Mismatch ⇒ envelope tampered or canonicalization failure. + - Re-canonicalize with the now-populated `evidence_id` and + empty signature, and verify the Ed25519 signature against + the public key resolved from `signer_did`. + - Reject any envelope whose `artifact_type` field does not + match the keys present on `payload` (e.g. `artifact_type: + commit` REQUIRES `payload.commit` and forbids the others). + + required: + - schema_version + - artifact_type + - server_id + - signer_did + - issued_at_ms + - payload + - evidence_id + - signature + properties: + + schema_version: + type: string + const: cycles-evidence/v0.1 + description: | + Schema discriminator. Pinned to the v0.1 draft; consumers + MUST reject envelopes whose `schema_version` they do not + understand. Versioned per the file at repo root that this + draft is promoted to. + + artifact_type: + $ref: "#/components/schemas/ArtifactType" + + server_id: + type: string + format: uri + description: | + Stable URI identifying the issuing Cycles server. SHOULD be + the canonical base URL of the Cycles deployment (e.g. + `https://cycles.example.com/v1`). Distinct from `signer_did` + because a single server may rotate signing keys without + losing identity continuity. + examples: + - "https://cycles.example.com/v1" + + signer_did: + type: string + description: | + DID or hex public key identifying the Ed25519 signer. v0.1 + does not pin a method; an Ed25519 hex pubkey is the + simplest valid form. A future spec revision will likely + standardize on `did:cycles:` or similar + once signing-key rotation and JWKS publication are in + scope. + examples: + - "207a067892821e25d770f1fba0c47c11ff4b813e54162ece9eb839e076231ab6" + + issued_at_ms: + type: integer + format: int64 + minimum: 0 + description: | + Server-side issuance time (UTC milliseconds since epoch) at + which the Cycles server emitted this envelope. Distinct from + any application-level timestamp; the v0 runtime spec does + not currently expose a wire-level response `timestamp` + field, so this is the server's own issuance clock at the + moment the envelope is signed. + + trace_id: + type: string + pattern: "^[0-9a-f]{32}$" + description: | + W3C Trace Context trace-id (32-hex lowercase) the Cycles + server associated with the request that produced this + envelope. Preserved byte-for-byte from the wire response / + `X-Cycles-Trace-Id` header per the Cycles correlation + contract (see `cycles-protocol-v0.yaml` §CORRELATION AND + TRACING). + + Optional only for backward compatibility with pre-correlation + servers; a conformant Cycles server MUST populate this. + The single cross-system join key against Cycles audit logs, + event streams, and webhook deliveries — and the natural + join key for APS receipts that reference this envelope via + `CyclesEvidenceRef`. + + payload: + $ref: "#/components/schemas/EvidencePayload" + + evidence_id: + type: string + pattern: "^[0-9a-f]{64}$" + description: | + sha256 hex of the JCS-canonical envelope with this field + set to "" and `signature` set to "". See "Hash derivation" + in the schema description. + + The empty-string sentinel is normative. Both `evidence_id` + and `signature` are PRESENT in the canonical bytes with + value `""`; they are NEITHER omitted from the object NOR + set to JSON `null`. Three distinct JCS canonical forms + exist for a "field whose value is not yet known": + + - omit the key entirely → `{...}` (key absent) + - set value to JSON null → `{..., "field": null, ...}` + - set value to empty "" → `{..., "field": "", ...}` + + These produce three different canonical byte sequences + and therefore three different sha256 digests. An adapter + that uses omission or `null` will compute an + `evidence_id` that does not match the spec-compliant + value. Empty-string sentinel is the single correct rule; + both pre-hash and pre-signature canonicalization passes + apply it. + + signature: + type: string + pattern: "^[0-9a-f]{128}$" + description: | + Ed25519 signature (hex) over the JCS-canonical envelope + with `evidence_id` populated and this field set to "". + See "Signature derivation" in the schema description. + + # artifact_type ↔ payload-key pairing (NORMATIVE). + # The EvidencePayload `oneOf` enforces that payload has exactly + # one of {decide, reserve, commit, release, error}. The allOf + # below additionally ties that key to artifact_type: an envelope + # with `artifact_type: reserve` and `payload.error` is impossible + # on the wire and MUST be rejected. Schema-only validators (i.e. + # any consumer using JSON Schema without the custom verify.py) + # would otherwise accept these mismatches because oneOf alone + # only constrains which payload key is present, not which one. + allOf: + - if: + properties: + artifact_type: { const: decide } + required: [artifact_type] + then: + properties: + payload: + required: [decide] + - if: + properties: + artifact_type: { const: reserve } + required: [artifact_type] + then: + properties: + payload: + required: [reserve] + - if: + properties: + artifact_type: { const: commit } + required: [artifact_type] + then: + properties: + payload: + required: [commit] + - if: + properties: + artifact_type: { const: release } + required: [artifact_type] + then: + properties: + payload: + required: [release] + - if: + properties: + artifact_type: { const: error } + required: [artifact_type] + then: + properties: + payload: + required: [error] + + example: + schema_version: cycles-evidence/v0.1 + artifact_type: reserve + server_id: https://cycles.example.com/v1 + signer_did: "207a067892821e25d770f1fba0c47c11ff4b813e54162ece9eb839e076231ab6" + issued_at_ms: 1810000000000 + trace_id: "0af7651916cd43dd8448eb211c80319c" + payload: + reserve: + request: + idempotency_key: "01HZZ8N4F8FBQX5K6TGYR0M0A1" + subject: + tenant: acme + agent: researcher + action: + kind: model.call + name: gpt-4o + estimate: + unit: USD_MICROCENTS + amount: 2000000 + ttl_ms: 30000 + response: + decision: ALLOW + reservation_id: "01HZZ8N4F8FBQX5K6TGYR0M0A2" + reserved: + unit: USD_MICROCENTS + amount: 2000000 + affected_scopes: + - tenant + expires_at_ms: 1810000030000 + evidence_id: "0000000000000000000000000000000000000000000000000000000000000000" + signature: "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + + # ===================================================================== + # ArtifactType — discriminator for the payload one-of. + # ===================================================================== + ArtifactType: + type: string + enum: + - decide + - reserve + - commit + - release + - error + description: | + Cycles lifecycle event this envelope attests to. The four + success-path artifact types map 1:1 to the four core runtime + endpoints, plus an `error` artifact type that wraps any + 4xx/5xx ErrorResponse emitted by any of those endpoints: + + - `decide` → POST /v1/decide (stateless pre-check; 2xx) + - `reserve` → POST /v1/reservations (atomic authorization; 2xx) + - `commit` → POST /v1/reservations/{id}/commit (settle at actual; 2xx) + - `release` → POST /v1/reservations/{id}/release (clear without debit; 2xx) + - `error` → 4xx/5xx ErrorResponse from any of the above + + The `error` artifact type is normative for v0.1 because non-dry + reserve denials are the highest-signal evidence APS receipts + carry (per aeoess/agent-passport-system#25) and the canonical + wire shape for those denials is a 409 with + `error: BUDGET_EXCEEDED`, NOT a 200 with `decision: DENY` (see + `cycles-protocol-v0.yaml` §ReservationCreateResponse.decision + and §DecisionReasonCode). Without an `error` artifact slot, + v0.1 would have no evidence for the most important denial + path. + + `expire` is NOT an artifact_type in v0.1: server-side TTL + expiry is silent on the wire and produces no Cycles + response — and therefore no evidence envelope. A future + revision MAY add a `system.expire` artifact type emitted by + the Cycles server's internal expiry sweep, but v0.1 is + scoped to caller-observable lifecycle events. + + # ===================================================================== + # EvidencePayload — one-of per artifact_type. + # ===================================================================== + EvidencePayload: + type: object + description: | + Per-artifact payload. Exactly one of the five properties below + MUST be present, matching the envelope's `artifact_type`. The + other four MUST be absent (not present-but-null). The + artifact_type ↔ payload-key pairing is enforced at the + top-level CyclesEvidence schema via `allOf`/`if`/`then`, so + schema-only validators reject mismatches without needing + custom logic. + + This is the field that hoists `reservation_id` into the + signed canonical bytes for `commit` and `release` artifacts + — the gap called out at aeoess/agent-governance-vocabulary#92 + review round 4. In the wire response of `commit_reservation` + and `release_reservation`, `reservation_id` is a URL path + argument, not a body field; in the evidence envelope, it is + promoted to a payload field so audit consumers can verify + the directional authorization → settlement chain from + evidence alone. + oneOf: + - { type: object, required: [decide], properties: { decide: { $ref: "#/components/schemas/DecidePayload" } }, additionalProperties: false } + - { type: object, required: [reserve], properties: { reserve: { $ref: "#/components/schemas/ReservePayload" } }, additionalProperties: false } + - { type: object, required: [commit], properties: { commit: { $ref: "#/components/schemas/CommitPayload" } }, additionalProperties: false } + - { type: object, required: [release], properties: { release: { $ref: "#/components/schemas/ReleasePayload" } }, additionalProperties: false } + - { type: object, required: [error], properties: { error: { $ref: "#/components/schemas/ErrorPayload" } }, additionalProperties: false } + + # ===================================================================== + # Per-artifact payload shapes. + # Each pairs the Cycles request and response of the corresponding + # endpoint, so evidence is self-contained. + # ===================================================================== + + DecidePayload: + type: object + required: [request, response] + additionalProperties: false + description: | + Pre-execution decision envelope. Pairs the `DecisionRequest` + the caller submitted with the `DecisionResponse` the server + returned. No reservation is created. + properties: + request: + description: | + DecisionRequest as submitted to `POST /v1/decide`. + Schema mirror of `cycles-protocol-v0.yaml#/components/schemas/DecisionRequest`. + $ref: "#/components/schemas/DecisionRequestMirror" + response: + description: | + DecisionResponse as returned by `POST /v1/decide`. + Schema mirror of `cycles-protocol-v0.yaml#/components/schemas/DecisionResponse`. + $ref: "#/components/schemas/DecisionResponseMirror" + + ReservePayload: + type: object + required: [request, response] + additionalProperties: false + description: | + Reservation-creation envelope. Pairs the `ReservationCreateRequest` + with the `ReservationCreateResponse`. + + Decision-shape constraint (NORMATIVE, mirrors + `cycles-protocol-v0.yaml` §ReservationCreateResponse.decision): + + - `dry_run: true` reserves: `decision` MAY be ALLOW, + ALLOW_WITH_CAPS, or DENY. A DENY response with + `reason_code` populated and `reservation_id` absent is a + valid 2xx wire shape — both ALLOW and DENY states are + captured as `reserve` evidence. + - `dry_run: false` (or omitted, the default): `decision` + MUST be ALLOW or ALLOW_WITH_CAPS. Insufficient budget + MUST surface as an HTTP 409 with + `error: BUDGET_EXCEEDED`, NOT as a 200 with + `decision: DENY`. Those non-dry denials are captured via + the `error` artifact type with + `endpoint: POST /v1/reservations`, NOT via a `reserve` + artifact with decision: DENY. + + Conformant evidence emitters MUST NOT produce a `reserve` + evidence envelope with `decision: DENY` and + `dry_run: false` (or `dry_run` absent). Conformant evidence + verifiers MAY reject such envelopes as protocol-impossible + wire shapes. + + The `allOf` below encodes three normative rules schema-side + so JSON Schema validators reject the bad shapes without + needing custom logic: + + 1. Non-dry reserve with decision: DENY is impossible + (must surface as a 409 error via ErrorPayload). + 2. dry_run=true: `reservation_id` and `expires_at_ms` + MUST be ABSENT on the response (canonical L981, L1404). + 3. dry_run NOT true (false or absent): `reservation_id` + MUST be PRESENT on the response. Combined with rule 1, + decision is guaranteed ALLOW or ALLOW_WITH_CAPS in + this branch, both of which require `reservation_id` + per canonical L981 ("Present if decision is ALLOW or + ALLOW_WITH_CAPS and dry_run is false"). `expires_at_ms` + is NOT in the rule-3 required list because canonical + only requires its ABSENCE on dry_run; the canonical + schema's required list never includes it. + allOf: + # Existing: non-dry reserve with decision: DENY is impossible (must be 409 error). + - if: + properties: + response: + properties: + decision: { const: DENY } + required: [decision] + required: [response] + then: + properties: + request: + properties: + dry_run: { const: true } + required: [dry_run] + required: [request] + # dry_run=true: reservation_id and expires_at_ms MUST be ABSENT + # on the response (canonical L981, L1404). + - if: + properties: + request: + properties: + dry_run: { const: true } + required: [dry_run] + required: [request] + then: + properties: + response: + allOf: + - not: { required: [reservation_id] } + - not: { required: [expires_at_ms] } + required: [response] + # dry_run NOT true (false or absent): reservation_id MUST be + # PRESENT on the response. Because the prior rule already + # forbids decision=DENY in this branch, decision is guaranteed + # ALLOW or ALLOW_WITH_CAPS — both of which require the + # reservation_id per canonical L981 ("Present if decision is + # ALLOW or ALLOW_WITH_CAPS and dry_run is false"). + # NOTE: expires_at_ms is NOT in this required list. The + # canonical only normatively requires its ABSENCE on dry_run + # (L1404); its presence on non-dry is described but not + # required by the canonical schema's required list. Requiring + # it here would over-tighten relative to the protocol and + # reject envelopes the canonical accepts. + - if: + properties: + request: + not: + allOf: + - properties: + dry_run: { const: true } + - required: [dry_run] + required: [request] + then: + properties: + response: + required: [reservation_id] + required: [response] + properties: + request: + $ref: "#/components/schemas/ReservationCreateRequestMirror" + response: + $ref: "#/components/schemas/ReservationCreateResponseMirror" + + CommitPayload: + type: object + required: [reservation_id, request, response] + additionalProperties: false + description: | + Reservation-commit envelope. Hoists `reservation_id` (the URL + path argument of `POST /v1/reservations/{reservation_id}/commit`) + into the signed payload so the link from this commit to its + prior `reserve` envelope is visible without consulting the + Cycles server. + properties: + reservation_id: + type: string + description: Path-argument reservation_id for the commit call. + request: + $ref: "#/components/schemas/CommitRequestMirror" + response: + $ref: "#/components/schemas/CommitResponseMirror" + + ReleasePayload: + type: object + required: [reservation_id, request, response] + additionalProperties: false + description: | + Reservation-release envelope. Symmetric to `CommitPayload`: + hoists `reservation_id` (the URL path argument of + `POST /v1/reservations/{reservation_id}/release`) into the + signed payload. + + APS-side handling: release evidence binds to + `rail.budget_reservation.release.v1` per + aeoess/agent-passport-system#25 comment 4433275511. (The APS + rail-literal namespace was renamed from `budget_authority` + to `budget_reservation` in the same thread, comment + 4433715146; the three lifecycle literals are + `rail.budget_reservation.{permit,release,denial}.v1`.) The + Cycles-side envelope is unaffected by the APS namespace + decision — release evidence is emitted uniformly regardless + of which APS literal the consumer chooses to bind to. + properties: + reservation_id: + type: string + request: + $ref: "#/components/schemas/ReleaseRequestMirror" + response: + $ref: "#/components/schemas/ReleaseResponseMirror" + + ErrorPayload: + type: object + required: [endpoint, http_status, response] + additionalProperties: false + description: | + Error-response evidence envelope. Wraps a 4xx/5xx + `ErrorResponse` body from any of the four core runtime + endpoints, with the originating request body and HTTP status + preserved for offline audit. + + This is the canonical wire shape for non-dry reserve + denials. Per `cycles-protocol-v0.yaml` + §ReservationCreateResponse.decision, insufficient budget on + a non-dry `POST /v1/reservations` MUST be expressed via + HTTP 409 with `error: BUDGET_EXCEEDED`, NOT via a 200 with + `decision: DENY`. Other live denial codes (BUDGET_FROZEN, + BUDGET_CLOSED, OVERDRAFT_LIMIT_EXCEEDED, DEBT_OUTSTANDING, + UNIT_MISMATCH, etc.) surface the same way; the full + ErrorCode enum is in the canonical spec. + + For pre-execution decisions (`POST /v1/decide`) and + dry-run reserves, denials surface as a 2xx + DecisionResponse / ReservationCreateResponse with + `decision: DENY` and `reason_code` populated — those are + captured via `DecidePayload` and `ReservePayload` + respectively, NOT via this envelope. + + `request` is OPTIONAL: a Cycles server MAY redact the + request body from error evidence (for tenants subject to + request-body retention policies) but SHOULD include it for + full audit trail. + + `reservation_id` is REQUIRED when `endpoint` is the commit + or release path (`POST /v1/reservations/{reservation_id}/ + commit` or `.../release`) — the value is the URL path + argument that names which reservation the failed operation + targeted, and dropping it from the signed payload would + leave the authorization → settlement chain + unreconstructable for evidence-only readers (the same + rationale that drives `reservation_id` hoisting on + `CommitPayload` and `ReleasePayload`). For + `endpoint: POST /v1/decide` or `POST /v1/reservations`, + `reservation_id` is absent — those endpoints take no + reservation_id path argument. The endpoint-discriminated + `allOf` below enforces both rules. + + Request validation: when `request` is present, the + endpoint-discriminated `allOf` of if/then below routes it + to the matching request-mirror schema. A plain `oneOf` + would not work because the four request mirrors share + identical required-field sets (`idempotency_key`, `subject`, + `action`, `estimate` for decide/reserve; + `idempotency_key` for commit/release) — a minimal reserve + request body would ambiguously match multiple mirrors. The + `endpoint` field is the only reliable discriminator. + properties: + endpoint: + type: string + enum: + - "POST /v1/decide" + - "POST /v1/reservations" + - "POST /v1/reservations/{reservation_id}/commit" + - "POST /v1/reservations/{reservation_id}/release" + description: | + Which canonical endpoint returned the error. Acts as + the discriminator for offline readers and constrains + the shape of `request` (a conformant emitter populates + `request` with the request-body schema matching this + endpoint, when present). + http_status: + type: integer + minimum: 400 + maximum: 599 + description: HTTP status code on the wire (4xx or 5xx). + reservation_id: + type: string + description: | + Path-argument `reservation_id` when `endpoint` is + commit or release. Hoisted into the signed payload so + evidence-only verifiers can recover the authorization + chain without reconstructing the URL. Omitted for + endpoints that have no `reservation_id` path argument. + request: + type: object + description: | + Request body that triggered the error. OPTIONAL. + When present, MUST validate against the request-mirror + schema of `endpoint` (enforced by the + endpoint-discriminated allOf at this schema level — + see schema description for why a plain oneOf doesn't + work here). + response: + $ref: "#/components/schemas/ErrorResponseMirror" + allOf: + # Endpoint-discriminated request validation. Each branch fires + # only when `endpoint` matches its const AND `request` is + # present (request is optional); the matching mirror schema is + # then applied to `request`. A plain oneOf doesn't work because + # the four request mirrors share identical required-field sets + # — see schema description above. + - if: + properties: + endpoint: { const: "POST /v1/decide" } + required: [endpoint, request] + then: + properties: + request: { $ref: "#/components/schemas/DecisionRequestMirror" } + - if: + properties: + endpoint: { const: "POST /v1/reservations" } + required: [endpoint, request] + then: + properties: + request: { $ref: "#/components/schemas/ReservationCreateRequestMirror" } + - if: + properties: + endpoint: { const: "POST /v1/reservations/{reservation_id}/commit" } + required: [endpoint, request] + then: + properties: + request: { $ref: "#/components/schemas/CommitRequestMirror" } + - if: + properties: + endpoint: { const: "POST /v1/reservations/{reservation_id}/release" } + required: [endpoint, request] + then: + properties: + request: { $ref: "#/components/schemas/ReleaseRequestMirror" } + # reservation_id linkage for commit/release endpoints (NORMATIVE). + # The path argument names which reservation the failed operation + # targeted; an error envelope missing it cannot reconstruct the + # authorization → settlement chain for evidence-only readers. + - if: + properties: + endpoint: + enum: + - "POST /v1/reservations/{reservation_id}/commit" + - "POST /v1/reservations/{reservation_id}/release" + required: [endpoint] + then: + required: [reservation_id] + # reservation_id MUST be absent for endpoints that take no + # reservation_id path argument (decide, reservations). A + # stray reservation_id on those would mislead audit consumers + # into trying to bind the error to a reservation that the + # endpoint never named. + - if: + properties: + endpoint: + enum: + - "POST /v1/decide" + - "POST /v1/reservations" + required: [endpoint] + then: + not: + required: [reservation_id] + + # ===================================================================== + # Schema mirrors of the Cycles runtime types. + # v0.1 inlines these by name rather than $ref-ing across files so + # `cycles-evidence-v0.1.yaml` validates standalone. When promoted + # to normative, these should be replaced with cross-file refs into + # `cycles-protocol-v0.yaml` for single-source-of-truth. + # + # MIRROR CONTRACT (v0.1, normative): + # - Mirror schemas copy field names, types, required-lists, + # enum values, AND structural constraints (minLength, + # maxLength, minProperties, anyOf, etc.) from the canonical + # spec as of the verification date below. + # - `additionalProperties: false` on every mirror is normative + # for evidence — extra fields would change the canonical + # bytes and invalidate the signature. + # - Drift from the canonical spec is a v0.1 bug. New canonical + # constraints MUST be reflected here (or this file must be + # re-cut against a newer canonical version) before evidence + # consumers may rely on them. + # + # Field lists and constraints derived from runcycles Python SDK + # introspection + cycles-protocol-v0.yaml inspection on + # 2026-05-13 (see PR description for verification). + # ===================================================================== + + DecisionRequestMirror: + type: object + description: Mirror of `cycles-protocol-v0.yaml#/components/schemas/DecisionRequest` — the body of `POST /v1/decide`. + required: [idempotency_key, subject, action, estimate] + additionalProperties: false + properties: + idempotency_key: { type: string, minLength: 1, maxLength: 256 } + subject: { $ref: "#/components/schemas/Subject" } + action: { $ref: "#/components/schemas/Action" } + estimate: { $ref: "#/components/schemas/Amount" } + metadata: { type: object, additionalProperties: true } + + DecisionResponseMirror: + type: object + description: | + Mirror of `cycles-protocol-v0.yaml#/components/schemas/DecisionResponse` + — the body of `POST /v1/decide`. The `allOf` below + encodes the canonical L752 rule: caps is present only when + decision=ALLOW_WITH_CAPS and MUST be absent otherwise. + required: [decision] + additionalProperties: false + properties: + decision: { $ref: "#/components/schemas/Decision" } + caps: { $ref: "#/components/schemas/Caps" } + reason_code: { type: string, maxLength: 128 } + retry_after_ms: { type: integer, format: int64, minimum: 0 } + affected_scopes: { type: array, items: { type: string } } + allOf: + - if: + properties: + decision: { const: ALLOW_WITH_CAPS } + required: [decision] + then: + required: [caps] + else: + not: + required: [caps] + + ReservationCreateRequestMirror: + type: object + description: Mirror of `cycles-protocol-v0.yaml#/components/schemas/ReservationCreateRequest` — the body of `POST /v1/reservations`. + required: [idempotency_key, subject, action, estimate] + additionalProperties: false + properties: + idempotency_key: { type: string, minLength: 1, maxLength: 256 } + subject: { $ref: "#/components/schemas/Subject" } + action: { $ref: "#/components/schemas/Action" } + estimate: { $ref: "#/components/schemas/Amount" } + ttl_ms: { type: integer, format: int64, minimum: 1000, maximum: 86400000 } + grace_period_ms: { type: integer, format: int64, minimum: 0, maximum: 60000 } + overage_policy: { type: string, enum: [REJECT, ALLOW_IF_AVAILABLE, ALLOW_WITH_OVERDRAFT] } + dry_run: { type: boolean } + metadata: { type: object, additionalProperties: true } + + ReservationCreateResponseMirror: + type: object + description: | + Mirror of `cycles-protocol-v0.yaml#/components/schemas/ReservationCreateResponse` + — the body of `POST /v1/reservations`. The `allOf` below + encodes the canonical L997 caps rule: caps is present only + when decision=ALLOW_WITH_CAPS and MUST be absent otherwise. + + The dry_run-vs-reservation_id-and-expires_at_ms rules + (canonical L981 and L1404) span request and response, so they + live at the `ReservePayload` level rather than here. + required: [decision, affected_scopes] + additionalProperties: false + properties: + decision: { $ref: "#/components/schemas/Decision" } + affected_scopes: { type: array, items: { type: string } } + reservation_id: { type: string } + reserved: { $ref: "#/components/schemas/Amount" } + caps: { $ref: "#/components/schemas/Caps" } + reason_code: { type: string, maxLength: 128 } + retry_after_ms: { type: integer, format: int64, minimum: 0 } + expires_at_ms: { type: integer, format: int64, minimum: 0 } + scope_path: { type: string } + balances: + type: array + items: { $ref: "#/components/schemas/Balance" } + allOf: + - if: + properties: + decision: { const: ALLOW_WITH_CAPS } + required: [decision] + then: + required: [caps] + else: + not: + required: [caps] + + CommitRequestMirror: + type: object + description: Mirror of `cycles-protocol-v0.yaml#/components/schemas/CommitRequest` — the body of `POST /v1/reservations/{reservation_id}/commit`. + required: [idempotency_key, actual] + additionalProperties: false + properties: + idempotency_key: { type: string, minLength: 1, maxLength: 256 } + actual: { $ref: "#/components/schemas/Amount" } + metrics: { $ref: "#/components/schemas/StandardMetrics" } + metadata: { type: object, additionalProperties: true } + + CommitResponseMirror: + type: object + description: Mirror of `cycles-protocol-v0.yaml#/components/schemas/CommitResponse` — the body of `POST /v1/reservations/{reservation_id}/commit`. + required: [status, charged] + additionalProperties: false + properties: + status: { type: string, enum: [COMMITTED] } + charged: { $ref: "#/components/schemas/Amount" } + released: { $ref: "#/components/schemas/Amount" } + balances: + type: array + items: { $ref: "#/components/schemas/Balance" } + + ReleaseRequestMirror: + type: object + description: Mirror of `cycles-protocol-v0.yaml#/components/schemas/ReleaseRequest` — the body of `POST /v1/reservations/{reservation_id}/release`. + required: [idempotency_key] + additionalProperties: false + properties: + idempotency_key: { type: string, minLength: 1, maxLength: 256 } + reason: { type: string, maxLength: 256 } + + ReleaseResponseMirror: + type: object + description: Mirror of `cycles-protocol-v0.yaml#/components/schemas/ReleaseResponse` — the body of `POST /v1/reservations/{reservation_id}/release`. + required: [status, released] + additionalProperties: false + properties: + status: { type: string, enum: [RELEASED] } + released: { $ref: "#/components/schemas/Amount" } + balances: + type: array + items: { $ref: "#/components/schemas/Balance" } + + ErrorResponseMirror: + type: object + description: | + Mirror of `cycles-protocol-v0.yaml#/components/schemas/ErrorResponse` + — the body of any 4xx/5xx response from the four core + runtime endpoints. + required: [error, message, request_id] + additionalProperties: false + properties: + error: + 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 + description: | + ErrorCode value — mirror of the canonical closed + `ErrorCode` enum at `cycles-protocol-v0.yaml` L429-L446 + per the MIRROR CONTRACT above. Future v0 minor versions + that add ErrorCode values will trigger a re-cut of this + mirror against the newer canonical version (which is the + normal mirror-drift process); evidence consumers MUST + NOT accept envelopes whose `error` value is outside this + enum until the mirror is re-cut. + message: + type: string + request_id: + type: string + trace_id: + type: string + pattern: "^[0-9a-f]{32}$" + description: W3C Trace Context trace-id. OPTIONAL on the wire for forward compatibility, but conformant servers MUST populate. + details: + type: object + additionalProperties: true + + # ===================================================================== + # Common nested types. + # ===================================================================== + + Subject: + type: object + description: | + Cycles `Subject` — dimension bag for hierarchical budgets. + Mirror of `cycles-protocol-v0.yaml#/components/schemas/Subject` + per the MIRROR CONTRACT above; constraints copied from the + canonical spec as of 2026-05-13. + + At least one standard field (tenant, workspace, app, + workflow, agent, or toolset) MUST be provided; a Subject + containing only `dimensions` is invalid on the wire (the + canonical server returns 400 INVALID_REQUEST). + 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: Free-form per-deployment subject dimensions (keys lowercase, opaque string values). + maxProperties: 16 + additionalProperties: + type: string + maxLength: 256 + + Action: + type: object + description: Cycles `Action` — the caller-declared `(kind, name)` of the gated action (e.g. `model.call` / `gpt-4o`). Constraints copied from `cycles-protocol-v0.yaml#/components/schemas/Action`. + required: [kind, name] + additionalProperties: false + properties: + kind: { type: string, maxLength: 64 } + name: { type: string, maxLength: 256 } + tags: + type: array + maxItems: 10 + items: { type: string, maxLength: 64 } + description: Optional policy tags (e.g. `["prod", "customer-facing"]`). + + Amount: + type: object + description: | + Cycles `Amount` — a `(unit, amount)` pair denominated in the + closed Unit enum. Non-negative; use `SignedAmount` for values + that can go negative (overdraft / debt remaining balances). + required: [unit, amount] + additionalProperties: false + properties: + unit: { $ref: "#/components/schemas/Unit" } + amount: { type: integer, format: int64, minimum: 0 } + + SignedAmount: + type: object + description: | + Cycles `SignedAmount` — same `(unit, amount)` shape as `Amount` + but with the non-negative constraint relaxed. Used for + `Balance.remaining` in overdraft/debt states where the + remaining balance is negative. + required: [unit, amount] + additionalProperties: false + properties: + unit: { $ref: "#/components/schemas/Unit" } + amount: { type: integer, format: int64 } + + Balance: + type: object + description: | + Cycles `Balance` entry. Fields verified against runcycles + Python SDK introspection on 2026-05-12. Responses carry + `balances: list[Balance] | null` — one entry per affected + scope. `remaining` uses `SignedAmount` because overdraft + and debt states produce negative remaining balances. + required: [scope, scope_path, remaining] + additionalProperties: false + properties: + scope: { type: string } + scope_path: { type: string } + remaining: { $ref: "#/components/schemas/SignedAmount" } + reserved: { $ref: "#/components/schemas/Amount" } + spent: { $ref: "#/components/schemas/Amount" } + allocated: { $ref: "#/components/schemas/Amount" } + debt: { $ref: "#/components/schemas/Amount" } + overdraft_limit: { $ref: "#/components/schemas/Amount" } + is_over_limit: { type: boolean } + + Unit: + type: string + enum: [USD_MICROCENTS, TOKENS, CREDITS, RISK_POINTS] + description: | + Closed Cycles Unit enum. Sourced from + `cycles-protocol-v0.yaml` and verified against the runcycles + Python SDK on 2026-05-12. + + Decision: + type: string + description: | + Cycles `Decision` enum — the three-way decision returned by + `decide` and embedded in the `reserve` response. Values are + UPPERCASE byte-for-byte from the wire; this matches the + case-fidelity rule documented at the Cycles ↔ APS receipt + crosswalk (decision section in `drafts/aeoess-crosswalk.yaml`). + enum: [ALLOW, ALLOW_WITH_CAPS, DENY] + + Caps: + type: object + additionalProperties: false + description: | + Narrowed-capacity envelope for ALLOW_WITH_CAPS responses. All + fields optional; the canonical Cycles `Caps` schema treats + every field as a hint and only populates the ones that + actually narrow the original request. + 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 } + + StandardMetrics: + type: object + additionalProperties: false + description: | + Cycles `StandardMetrics` — optional execution metrics included + on commit. Mirror of + `cycles-protocol-v0.yaml#/components/schemas/StandardMetrics` + per the MIRROR CONTRACT above; constraints copied from the + canonical spec as of 2026-05-13. + + All fields are optional; `additionalProperties: false` at the + top level means deployments adding custom metrics MUST put + them under the `custom` key (which is the only field with + `additionalProperties: true` — the canonical escape hatch). + properties: + tokens_input: + type: integer + minimum: 0 + tokens_output: + type: integer + minimum: 0 + latency_ms: + type: integer + minimum: 0 + model_version: + type: string + maxLength: 128 + custom: + type: object + additionalProperties: true + description: Arbitrary additional metrics (canonical escape hatch). diff --git a/drafts/fixtures/cycles-evidence-v0.1/README.md b/drafts/fixtures/cycles-evidence-v0.1/README.md new file mode 100644 index 0000000..7d0ef92 --- /dev/null +++ b/drafts/fixtures/cycles-evidence-v0.1/README.md @@ -0,0 +1,142 @@ +# CyclesEvidence v0.1 — reference fixtures + +Thirteen signed, content-addressed `CyclesEvidence` envelopes covering +the five artifact types, the decision branches an audit consumer needs +to handle (including the live-path 4xx denial introduced for the issue +#25 integration, a 403 error on `POST /v1/decide`, and a commit with +`StandardMetrics` populated), and every value of the closed `Unit` enum. +Each fixture is the JCS-canonical bytes of a fully populated envelope, +exactly as a Cycles server would emit it. + +These fixtures back the test-plan checkbox on PR +`runcycles/cycles-protocol#90`: + +> Implement a worked example: emit one envelope per artifact type from +> a reference Cycles server, compute `evidence_id`, sign, then +> re-verify; commit fixtures alongside the spec. + +## Layout + +``` +generate.py — generator (deterministic) +verify.py — verifier (round-trip check) +requirements.txt — jcs, pynacl +cases/ + 01-decide-allow.json — DecidePayload, decision=ALLOW + 02-reserve-allow.json — ReservePayload, decision=ALLOW, balances populated + 03-reserve-dry-run-deny.json — ReservePayload with dry_run=true, decision=DENY (the ONLY valid wire shape for decision=DENY on reserve, per cycles-protocol-v0:978) + 04-reserve-allow-with-caps.json — ReservePayload, decision=ALLOW_WITH_CAPS, Caps populated + 05-commit-success.json — CommitPayload, reservation_id hoisted, partial commit + 06-release-success.json — ReleasePayload, ALLOW_WITH_CAPS reservation released + 07-release-with-reason.json — ReleasePayload with optional ReleaseRequest.reason + 08-reserve-allow-no-trace-id.json — ReservePayload, optional trace_id omitted (field absent, NOT empty string) + 09-decide-risk-points-allow.json — DecidePayload, unit=RISK_POINTS (authority class), Action.tags populated + 10-reserve-credits-allow.json — ReservePayload, unit=CREDITS (implementation-defined class), Balance.allocated populated + 11-reserve-live-budget-exceeded.json — ErrorPayload, 409 BUDGET_EXCEEDED, endpoint="POST /v1/reservations" — the canonical live-denial wire shape that issue #25's APS gateway needs to bind evidence to + 12-decide-live-forbidden.json — ErrorPayload, 403 FORBIDDEN, endpoint="POST /v1/decide" — exercises the corrected endpoint name (canonical /v1/decide, not /v1/decisions) and a non-budget ErrorCode + 13-commit-with-metrics.json — CommitPayload with StandardMetrics populated (5 fields incl. `custom` escape hatch) — exercises the constrained StandardMetrics mirror added in round 6 (object schema with `additionalProperties: false` and per-field constraints; not an enum) +``` + +## What each fixture proves + +| Fixture | Spec rule exercised | +|---|---| +| 01 | `decide` payload one-of branch; `DecisionResponse` minimal-required shape (only `decision`) | +| 02 | `reserve` happy path; `Balance` with `SignedAmount` `remaining` | +| 03 | Dry-run reserve DENY — the *only* legal wire shape with `decision: DENY` on a reserve. Per `cycles-protocol-v0.yaml` §ReservationCreateResponse.decision: "For dry_run=true, decision MAY be DENY. For dry_run=false, insufficient budget MUST be expressed via 409 BUDGET_EXCEEDED (not decision=DENY)." Live (non-dry) denials are case 11. | +| 04 | ALLOW_WITH_CAPS preserves the `Caps` payload in signed bytes (load-bearing for audit per `#25` thread) | +| 05 | `reservation_id` hoisted into the signed payload — closes the `commit_reservation` linkage gap from `#92` review round 4 | +| 06 | `release` payload one-of branch; symmetric `reservation_id` hoist | +| 07 | Optional `ReleaseRequest.reason` round-trips through canonical bytes | +| 08 | Optional `trace_id` **omitted** (field absent in canonical bytes — distinct from `""` or `null` per spec normative note on omit/null/empty) | +| 09 | `RISK_POINTS` unit (authority class per `#25` `unit_class` discussion); optional `Action.tags` populated | +| 10 | `CREDITS` unit (implementation-defined class per `cycles-protocol-v0` UnitEnum); optional `Balance.allocated` populated | +| 11 | `error` artifact type — live (non-dry) 409 `BUDGET_EXCEEDED` from `POST /v1/reservations`. The canonical wire shape for the pre-execution denial path that issue #25 needs APS receipts to bind evidence to. The request body is preserved in the signed payload; `endpoint` discriminates which Mirror schema `request` follows. | +| 12 | `error` artifact type — 403 `FORBIDDEN` from `POST /v1/decide` (canonical endpoint name, not `/v1/decisions`). Exercises the decide error path and a non-budget ErrorCode value (the round-5 fix renamed the endpoint everywhere; this fixture proves the endpoint-discriminated request validation accepts a real `DecisionRequest` body under the correct endpoint name). | +| 13 | `commit` artifact type with `metrics` populated. Exercises the `StandardMetrics` mirror added in round 6: all five canonical fields (`tokens_input`, `tokens_output`, `latency_ms`, `model_version`, `custom`) with the `custom` escape hatch carrying deployment-specific extras. Closes the coverage gap that allowed `CommitRequestMirror.metrics` to be `additionalProperties: true` undetected. | + +## Reproducing the fixtures + +```sh +pip install -r requirements.txt +python generate.py +``` + +The generator is deterministic. Re-running it overwrites `cases/*.json` +with byte-identical output; a clean working tree after `python +generate.py` proves the fixtures match the spec algorithm. + +## Verifying the fixtures + +```sh +python verify.py +``` + +For each fixture, `verify.py` runs the v0.1 normative verification +contract from `cycles-evidence-v0.1.yaml`: + +1. Recompute `evidence_id` (sha256 over JCS-canonical bytes with + `evidence_id=""` and `signature=""`); compare byte-for-byte. +2. Recanonicalize with `evidence_id` populated and `signature=""`; + Ed25519-verify against the pubkey resolved from `signer_did`. +3. Check `artifact_type` matches exactly one `payload` key. +4. Validate optional `trace_id` against the 32-hex pattern when + present. + +Exit code 0 on all-green; non-zero on any failure. + +## Test signer + +**These fixtures are signed with a test key. They are NOT authoritative +evidence and the keypair MUST NOT be used by any production +deployment.** + +The signer seed is derived deterministically as: + +``` +sha256("cycles-evidence-v0.1-fixture-signer") +``` + +so anyone can re-derive the same Ed25519 keypair locally. The resulting +public key (which appears as `signer_did` in every fixture) is: + +``` +ec52b49b81eb29ef6f62947cade245c715bf943b7ef2a5f2789288574466fc43 +``` + +Production Cycles deployments will sign with a server-owned key +published via the `did:cycles:*` method (out of scope for v0.1, see +spec). + +## Spot-checking tamper detection + +Flip one character anywhere in a fixture's signed bytes (the payload, +the timestamp, the trace_id) and re-run `verify.py`. The verifier +reports both `evidence_id mismatch` and `signature verification +failed`. This demonstrates that the canonical-bytes input to sha256 +covers the whole envelope and that the Ed25519 signature is tied to +the recomputed `evidence_id`. + +Example: + +```sh +# Tamper: change reservation_id one byte +python -c " +import json, pathlib +p = pathlib.Path('cases/05-commit-success.json') +e = json.loads(p.read_text()) +e['payload']['commit']['reservation_id'] = e['payload']['commit']['reservation_id'][:-1] + 'X' +p.write_text(json.dumps(e)) +" +python verify.py # → FAIL on 05-commit-success.json +python generate.py # → restores from canonical sources +``` + +## Promoting these fixtures + +When `cycles-evidence-v0.1.yaml` graduates from `drafts/` to a numbered +spec at repo root, this fixture set should move with it (e.g. to +`fixtures/cycles-evidence-v0.2/`). The generator inputs in +`generate.py` should be reviewed at promotion time and any sample IDs +/ public-key references regenerated against the production signing-key +shape once `did:cycles:*` is normative. diff --git a/drafts/fixtures/cycles-evidence-v0.1/cases/01-decide-allow.json b/drafts/fixtures/cycles-evidence-v0.1/cases/01-decide-allow.json new file mode 100644 index 0000000..3bfdc55 --- /dev/null +++ b/drafts/fixtures/cycles-evidence-v0.1/cases/01-decide-allow.json @@ -0,0 +1 @@ +{"artifact_type":"decide","evidence_id":"8b49e5594013cdf069ddfbaea97e1ba8136425aa3d42b778c96fd33412625d03","issued_at_ms":1810000000000,"payload":{"decide":{"request":{"action":{"kind":"model.call","name":"gpt-4o"},"estimate":{"amount":2000000,"unit":"USD_MICROCENTS"},"idempotency_key":"01HZZ8N4F8FBQX5K6TGYR0M0A1","subject":{"agent":"researcher","tenant":"acme"}},"response":{"affected_scopes":["tenant"],"decision":"ALLOW"}}},"schema_version":"cycles-evidence/v0.1","server_id":"https://cycles.example.com/v1","signature":"ab62e0eaa0fd0097d47ceb54043111f731166e1448c5fe52bf35f3e0a697a803719b030d241abdfd1ed426f473de9f79c308f729b9b1d9ec39d202fc4dedb500","signer_did":"ec52b49b81eb29ef6f62947cade245c715bf943b7ef2a5f2789288574466fc43","trace_id":"0af7651916cd43dd8448eb211c80319c"} diff --git a/drafts/fixtures/cycles-evidence-v0.1/cases/02-reserve-allow.json b/drafts/fixtures/cycles-evidence-v0.1/cases/02-reserve-allow.json new file mode 100644 index 0000000..d107106 --- /dev/null +++ b/drafts/fixtures/cycles-evidence-v0.1/cases/02-reserve-allow.json @@ -0,0 +1 @@ +{"artifact_type":"reserve","evidence_id":"ca70a9c6816d463614ec46b19266ff286bde220c2caf0951ae7888cc36c485aa","issued_at_ms":1810000000100,"payload":{"reserve":{"request":{"action":{"kind":"model.call","name":"gpt-4o"},"estimate":{"amount":2000000,"unit":"USD_MICROCENTS"},"idempotency_key":"01HZZ8N4F8FBQX5K6TGYR0M0A2","subject":{"agent":"researcher","tenant":"acme"},"ttl_ms":30000},"response":{"affected_scopes":["tenant"],"balances":[{"remaining":{"amount":8000000,"unit":"USD_MICROCENTS"},"reserved":{"amount":2000000,"unit":"USD_MICROCENTS"},"scope":"tenant","scope_path":"tenant=acme","spent":{"amount":0,"unit":"USD_MICROCENTS"}}],"decision":"ALLOW","expires_at_ms":1810000030100,"reservation_id":"rsv_01HZZ8N4F8FBQX5K6TGYR0M0A3","reserved":{"amount":2000000,"unit":"USD_MICROCENTS"},"scope_path":"tenant=acme"}}},"schema_version":"cycles-evidence/v0.1","server_id":"https://cycles.example.com/v1","signature":"e62228d5389b5b325f287d3f6267c64e8fcba4b374fde30a7b1e569426fc28afadacc69b7be158fc315b533e25f79ed5b21aa87a3ae30e2f37566802fd6c880e","signer_did":"ec52b49b81eb29ef6f62947cade245c715bf943b7ef2a5f2789288574466fc43","trace_id":"0af7651916cd43dd8448eb211c80319c"} diff --git a/drafts/fixtures/cycles-evidence-v0.1/cases/03-reserve-dry-run-deny.json b/drafts/fixtures/cycles-evidence-v0.1/cases/03-reserve-dry-run-deny.json new file mode 100644 index 0000000..e916c4d --- /dev/null +++ b/drafts/fixtures/cycles-evidence-v0.1/cases/03-reserve-dry-run-deny.json @@ -0,0 +1 @@ +{"artifact_type":"reserve","evidence_id":"a9daac55e6acac74f4d8ef4f7801fec9d3503e74251769aa5a7d44b6de577436","issued_at_ms":1810000000200,"payload":{"reserve":{"request":{"action":{"kind":"model.call","name":"gpt-4o"},"dry_run":true,"estimate":{"amount":50000000,"unit":"USD_MICROCENTS"},"idempotency_key":"01HZZ8N4F8FBQX5K6TGYR0M0B1","subject":{"agent":"researcher","tenant":"acme"},"ttl_ms":30000},"response":{"affected_scopes":["tenant"],"balances":[{"remaining":{"amount":8000000,"unit":"USD_MICROCENTS"},"reserved":{"amount":0,"unit":"USD_MICROCENTS"},"scope":"tenant","scope_path":"tenant=acme","spent":{"amount":92000000,"unit":"USD_MICROCENTS"}}],"decision":"DENY","reason_code":"BUDGET_EXCEEDED","scope_path":"tenant=acme"}}},"schema_version":"cycles-evidence/v0.1","server_id":"https://cycles.example.com/v1","signature":"8399ded9ec49c7cfa79b5969883708ff321fbac30c97066a2c7fa7dd4f33a1d0ec3a232581fd03a7db04bafd56bfc39185619f419619b29bcc88db1942c59100","signer_did":"ec52b49b81eb29ef6f62947cade245c715bf943b7ef2a5f2789288574466fc43","trace_id":"b9c8a0d3f2e147a9a7f4d2e1b0c9876f"} diff --git a/drafts/fixtures/cycles-evidence-v0.1/cases/04-reserve-allow-with-caps.json b/drafts/fixtures/cycles-evidence-v0.1/cases/04-reserve-allow-with-caps.json new file mode 100644 index 0000000..eaa6703 --- /dev/null +++ b/drafts/fixtures/cycles-evidence-v0.1/cases/04-reserve-allow-with-caps.json @@ -0,0 +1 @@ +{"artifact_type":"reserve","evidence_id":"625a4c09714ba41f11d070f589d192a7cbff4816f470e0df6d26e134755252cc","issued_at_ms":1810000000300,"payload":{"reserve":{"request":{"action":{"kind":"model.call","name":"gpt-4o"},"estimate":{"amount":32000,"unit":"TOKENS"},"idempotency_key":"01HZZ8N4F8FBQX5K6TGYR0M0C1","subject":{"agent":"researcher","tenant":"acme"},"ttl_ms":30000},"response":{"affected_scopes":["tenant"],"caps":{"cooldown_ms":1000,"max_tokens":8000,"tool_allowlist":["read.*"]},"decision":"ALLOW_WITH_CAPS","expires_at_ms":1810000030300,"reservation_id":"rsv_01HZZ8N4F8FBQX5K6TGYR0M0C2","reserved":{"amount":8000,"unit":"TOKENS"},"scope_path":"tenant=acme"}}},"schema_version":"cycles-evidence/v0.1","server_id":"https://cycles.example.com/v1","signature":"96e368bc9ac0a759f3ed4bf30e90165d2cb20b0d023a9e1812e13de833b5e1c85fe1f003098be4df0b7a7a7b1f9ffb952ed4dfa05a5e9bb2917bb07043a32b09","signer_did":"ec52b49b81eb29ef6f62947cade245c715bf943b7ef2a5f2789288574466fc43","trace_id":"c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6"} diff --git a/drafts/fixtures/cycles-evidence-v0.1/cases/05-commit-success.json b/drafts/fixtures/cycles-evidence-v0.1/cases/05-commit-success.json new file mode 100644 index 0000000..42cad7b --- /dev/null +++ b/drafts/fixtures/cycles-evidence-v0.1/cases/05-commit-success.json @@ -0,0 +1 @@ +{"artifact_type":"commit","evidence_id":"f8ebfb3918bcc3d610d80d56f649c3a8721a0e5d5ab650471f65bdcba8131cb1","issued_at_ms":1810000010000,"payload":{"commit":{"request":{"actual":{"amount":1750000,"unit":"USD_MICROCENTS"},"idempotency_key":"01HZZ8N4F8FBQX5K6TGYR0M0A4"},"reservation_id":"rsv_01HZZ8N4F8FBQX5K6TGYR0M0A3","response":{"balances":[{"remaining":{"amount":8250000,"unit":"USD_MICROCENTS"},"reserved":{"amount":0,"unit":"USD_MICROCENTS"},"scope":"tenant","scope_path":"tenant=acme","spent":{"amount":1750000,"unit":"USD_MICROCENTS"}}],"charged":{"amount":1750000,"unit":"USD_MICROCENTS"},"released":{"amount":250000,"unit":"USD_MICROCENTS"},"status":"COMMITTED"}}},"schema_version":"cycles-evidence/v0.1","server_id":"https://cycles.example.com/v1","signature":"a770949651ed1870c47c254338c42060bd69e4f23e98ccdc2e35829a7c30b2dcf66f5784936e33ab679740806d4963af59fb487bcc21ea2d59d9a3e9e41f7804","signer_did":"ec52b49b81eb29ef6f62947cade245c715bf943b7ef2a5f2789288574466fc43","trace_id":"0af7651916cd43dd8448eb211c80319c"} diff --git a/drafts/fixtures/cycles-evidence-v0.1/cases/06-release-success.json b/drafts/fixtures/cycles-evidence-v0.1/cases/06-release-success.json new file mode 100644 index 0000000..f9a1a9c --- /dev/null +++ b/drafts/fixtures/cycles-evidence-v0.1/cases/06-release-success.json @@ -0,0 +1 @@ +{"artifact_type":"release","evidence_id":"d2ef1a25d7c79d9ef0df421dd12b6874424ba3c8af2488da2bd8f705926d09fd","issued_at_ms":1810000005000,"payload":{"release":{"request":{"idempotency_key":"01HZZ8N4F8FBQX5K6TGYR0M0C3"},"reservation_id":"rsv_01HZZ8N4F8FBQX5K6TGYR0M0C2","response":{"balances":[{"remaining":{"amount":100000,"unit":"TOKENS"},"reserved":{"amount":0,"unit":"TOKENS"},"scope":"tenant","scope_path":"tenant=acme","spent":{"amount":0,"unit":"TOKENS"}}],"released":{"amount":8000,"unit":"TOKENS"},"status":"RELEASED"}}},"schema_version":"cycles-evidence/v0.1","server_id":"https://cycles.example.com/v1","signature":"11d67308e68441f7ca8ff3c3d5087604969b24dcd8cfb67abbed9c05027d71716c01a3f5f2162f4bac7e4d9038816516a423c511fb39e557266ed8ab9e031d02","signer_did":"ec52b49b81eb29ef6f62947cade245c715bf943b7ef2a5f2789288574466fc43","trace_id":"d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6"} diff --git a/drafts/fixtures/cycles-evidence-v0.1/cases/07-release-with-reason.json b/drafts/fixtures/cycles-evidence-v0.1/cases/07-release-with-reason.json new file mode 100644 index 0000000..e286e97 --- /dev/null +++ b/drafts/fixtures/cycles-evidence-v0.1/cases/07-release-with-reason.json @@ -0,0 +1 @@ +{"artifact_type":"release","evidence_id":"d0dd461732551bb6416e585db19cdc6797dcf12e19dd197856db4e9945b86821","issued_at_ms":1810000007000,"payload":{"release":{"request":{"idempotency_key":"01HZZ8N4F8FBQX5K6TGYR0M0A5","reason":"handler_timeout"},"reservation_id":"rsv_01HZZ8N4F8FBQX5K6TGYR0M0A3","response":{"balances":[{"remaining":{"amount":10000000,"unit":"USD_MICROCENTS"},"reserved":{"amount":0,"unit":"USD_MICROCENTS"},"scope":"tenant","scope_path":"tenant=acme","spent":{"amount":0,"unit":"USD_MICROCENTS"}}],"released":{"amount":2000000,"unit":"USD_MICROCENTS"},"status":"RELEASED"}}},"schema_version":"cycles-evidence/v0.1","server_id":"https://cycles.example.com/v1","signature":"d8757bae97976c386023c4c595cac4d1ff4e90da3dfb965d67b25d961155185a5192f8efbf3a6a3d42100f86bdd0d43610f26c6a57740a379d726b51e66f1e02","signer_did":"ec52b49b81eb29ef6f62947cade245c715bf943b7ef2a5f2789288574466fc43","trace_id":"e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6"} diff --git a/drafts/fixtures/cycles-evidence-v0.1/cases/08-reserve-allow-no-trace-id.json b/drafts/fixtures/cycles-evidence-v0.1/cases/08-reserve-allow-no-trace-id.json new file mode 100644 index 0000000..1d8a8bd --- /dev/null +++ b/drafts/fixtures/cycles-evidence-v0.1/cases/08-reserve-allow-no-trace-id.json @@ -0,0 +1 @@ +{"artifact_type":"reserve","evidence_id":"f97b0000ea1dae2f5652d1e9d89739cda55e5c78cc351baae14f92aabe2f384b","issued_at_ms":1810000000400,"payload":{"reserve":{"request":{"action":{"kind":"model.call","name":"gpt-4o"},"estimate":{"amount":1000000,"unit":"USD_MICROCENTS"},"idempotency_key":"01HZZ8N4F8FBQX5K6TGYR0M0D1","subject":{"agent":"researcher","tenant":"acme"},"ttl_ms":30000},"response":{"affected_scopes":["tenant"],"decision":"ALLOW","expires_at_ms":1810000030400,"reservation_id":"rsv_01HZZ8N4F8FBQX5K6TGYR0M0D2","reserved":{"amount":1000000,"unit":"USD_MICROCENTS"}}}},"schema_version":"cycles-evidence/v0.1","server_id":"https://cycles.example.com/v1","signature":"5c7df6631a1a8bcac68a4c66b755b92fd5547256ebd7ad0b151280010563b41338e19326b22c7def23df6bde39375f6c1e887dabbb20a23d0cf20a802560cc06","signer_did":"ec52b49b81eb29ef6f62947cade245c715bf943b7ef2a5f2789288574466fc43"} diff --git a/drafts/fixtures/cycles-evidence-v0.1/cases/09-decide-risk-points-allow.json b/drafts/fixtures/cycles-evidence-v0.1/cases/09-decide-risk-points-allow.json new file mode 100644 index 0000000..b72fbee --- /dev/null +++ b/drafts/fixtures/cycles-evidence-v0.1/cases/09-decide-risk-points-allow.json @@ -0,0 +1 @@ +{"artifact_type":"decide","evidence_id":"0c2d96c29903fd4165a760a72bbb5062e47057621d5c0d5d810d90e92573bb4f","issued_at_ms":1810000020000,"payload":{"decide":{"request":{"action":{"kind":"tool.call","name":"send_external_email","tags":["prod","customer-facing"]},"estimate":{"amount":5,"unit":"RISK_POINTS"},"idempotency_key":"01HZZ8N4F8FBQX5K6TGYR0M0E1","subject":{"agent":"researcher","tenant":"acme"}},"response":{"affected_scopes":["tenant","agent"],"decision":"ALLOW"}}},"schema_version":"cycles-evidence/v0.1","server_id":"https://cycles.example.com/v1","signature":"32092e6a5a3d69c8bf55d727e867d168fad60d3e81e3f605c91ca440fe6c4f41148adbdc9ae2a334c2091fba5692f2462c4ca3bd0b58c2c3e33c3dbcae9ad80a","signer_did":"ec52b49b81eb29ef6f62947cade245c715bf943b7ef2a5f2789288574466fc43","trace_id":"f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6"} diff --git a/drafts/fixtures/cycles-evidence-v0.1/cases/10-reserve-credits-allow.json b/drafts/fixtures/cycles-evidence-v0.1/cases/10-reserve-credits-allow.json new file mode 100644 index 0000000..67e84ba --- /dev/null +++ b/drafts/fixtures/cycles-evidence-v0.1/cases/10-reserve-credits-allow.json @@ -0,0 +1 @@ +{"artifact_type":"reserve","evidence_id":"0f779daaffc517057e23a25e68b6c45e1c1c30d2fb724a3ee3e6a6e4f9d07e6f","issued_at_ms":1810000030000,"payload":{"reserve":{"request":{"action":{"kind":"compute.job","name":"image-render"},"estimate":{"amount":50,"unit":"CREDITS"},"idempotency_key":"01HZZ8N4F8FBQX5K6TGYR0M0F1","subject":{"agent":"researcher","tenant":"acme"},"ttl_ms":60000},"response":{"affected_scopes":["tenant"],"balances":[{"allocated":{"amount":1000,"unit":"CREDITS"},"remaining":{"amount":950,"unit":"CREDITS"},"reserved":{"amount":50,"unit":"CREDITS"},"scope":"tenant","scope_path":"tenant=acme","spent":{"amount":0,"unit":"CREDITS"}}],"decision":"ALLOW","expires_at_ms":1810000090000,"reservation_id":"rsv_01HZZ8N4F8FBQX5K6TGYR0M0F2","reserved":{"amount":50,"unit":"CREDITS"},"scope_path":"tenant=acme"}}},"schema_version":"cycles-evidence/v0.1","server_id":"https://cycles.example.com/v1","signature":"edb0234632aa36ecc73f4435b50f50f5d9ad3fc4b0da03a25646e1d1612d0e4489fe0a5d443f84004f12892e2156a9c60d12e273741e2319dcb80e12fe6a7904","signer_did":"ec52b49b81eb29ef6f62947cade245c715bf943b7ef2a5f2789288574466fc43","trace_id":"a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6"} diff --git a/drafts/fixtures/cycles-evidence-v0.1/cases/11-reserve-live-budget-exceeded.json b/drafts/fixtures/cycles-evidence-v0.1/cases/11-reserve-live-budget-exceeded.json new file mode 100644 index 0000000..9f77829 --- /dev/null +++ b/drafts/fixtures/cycles-evidence-v0.1/cases/11-reserve-live-budget-exceeded.json @@ -0,0 +1 @@ +{"artifact_type":"error","evidence_id":"9a0a4915c31a7c0ffebe7de1cc3b9d318c09861dfa12653a84c004e797fc7742","issued_at_ms":1810000040000,"payload":{"error":{"endpoint":"POST /v1/reservations","http_status":409,"request":{"action":{"kind":"model.call","name":"gpt-4o"},"estimate":{"amount":100000000,"unit":"USD_MICROCENTS"},"idempotency_key":"01HZZ8N4F8FBQX5K6TGYR0M0G1","subject":{"agent":"researcher","tenant":"acme"},"ttl_ms":30000},"response":{"error":"BUDGET_EXCEEDED","message":"Insufficient remaining budget for scope tenant=acme","request_id":"req_01HZZ8N4F8FBQX5K6TGYR0M0G2","trace_id":"0123456789abcdef0123456789abcdef"}}},"schema_version":"cycles-evidence/v0.1","server_id":"https://cycles.example.com/v1","signature":"78eefba76d414e75bf2f5cef696005fc2bc3f14e228938c9fc0795b65e400f7ab2a7b05fde955078c45b16fc7bdbddd8fba1bcfa1bc613f2c44c07b5d1ce5909","signer_did":"ec52b49b81eb29ef6f62947cade245c715bf943b7ef2a5f2789288574466fc43","trace_id":"0123456789abcdef0123456789abcdef"} diff --git a/drafts/fixtures/cycles-evidence-v0.1/cases/12-decide-live-forbidden.json b/drafts/fixtures/cycles-evidence-v0.1/cases/12-decide-live-forbidden.json new file mode 100644 index 0000000..d7041ba --- /dev/null +++ b/drafts/fixtures/cycles-evidence-v0.1/cases/12-decide-live-forbidden.json @@ -0,0 +1 @@ +{"artifact_type":"error","evidence_id":"dbe4f743b69a42d41a30102210c8de08b97d84467b0d057a0e18dc461b912e4d","issued_at_ms":1810000050000,"payload":{"error":{"endpoint":"POST /v1/decide","http_status":403,"request":{"action":{"kind":"model.call","name":"gpt-4o"},"estimate":{"amount":1000000,"unit":"USD_MICROCENTS"},"idempotency_key":"01HZZ8N4F8FBQX5K6TGYR0M0H1","subject":{"agent":"researcher","tenant":"acme"}},"response":{"error":"FORBIDDEN","message":"Tenant scope mismatch with effective auth context","request_id":"req_01HZZ8N4F8FBQX5K6TGYR0M0H2","trace_id":"fedcba9876543210fedcba9876543210"}}},"schema_version":"cycles-evidence/v0.1","server_id":"https://cycles.example.com/v1","signature":"9de34d70a49aa0d44f6e53db33cde9bb1635e252c2979993f76d012444bb928baaeb61e7bcd85452e8765bd854febf8f5e773fad9fd96bd2a60dcf40bcf8610f","signer_did":"ec52b49b81eb29ef6f62947cade245c715bf943b7ef2a5f2789288574466fc43","trace_id":"fedcba9876543210fedcba9876543210"} diff --git a/drafts/fixtures/cycles-evidence-v0.1/cases/13-commit-with-metrics.json b/drafts/fixtures/cycles-evidence-v0.1/cases/13-commit-with-metrics.json new file mode 100644 index 0000000..d888b3a --- /dev/null +++ b/drafts/fixtures/cycles-evidence-v0.1/cases/13-commit-with-metrics.json @@ -0,0 +1 @@ +{"artifact_type":"commit","evidence_id":"9659ee69a303a0a8994f05a51ac6e7ee04c98dbc8e469848c1ba05274dbbcff8","issued_at_ms":1810000060000,"payload":{"commit":{"request":{"actual":{"amount":1500000,"unit":"USD_MICROCENTS"},"idempotency_key":"01HZZ8N4F8FBQX5K6TGYR0M0J2","metadata":{"workflow_run_id":"wf_abc123"},"metrics":{"custom":{"cache_hit_ratio":0.42,"retry_count":0},"latency_ms":2340,"model_version":"claude-sonnet-4-20250514","tokens_input":1500,"tokens_output":800}},"reservation_id":"rsv_01HZZ8N4F8FBQX5K6TGYR0M0J1","response":{"balances":[{"remaining":{"amount":8500000,"unit":"USD_MICROCENTS"},"reserved":{"amount":0,"unit":"USD_MICROCENTS"},"scope":"tenant","scope_path":"tenant=acme","spent":{"amount":1500000,"unit":"USD_MICROCENTS"}}],"charged":{"amount":1500000,"unit":"USD_MICROCENTS"},"status":"COMMITTED"}}},"schema_version":"cycles-evidence/v0.1","server_id":"https://cycles.example.com/v1","signature":"5c83764926e52c08097345195b1c79b9fcabb9b22def4de400feedafcd9618e5b953a9660d7e514250fa8338213bbd70e1a85002770386c2f51e138db07d8302","signer_did":"ec52b49b81eb29ef6f62947cade245c715bf943b7ef2a5f2789288574466fc43","trace_id":"abcdef0123456789abcdef0123456789"} diff --git a/drafts/fixtures/cycles-evidence-v0.1/generate.py b/drafts/fixtures/cycles-evidence-v0.1/generate.py new file mode 100644 index 0000000..f794cda --- /dev/null +++ b/drafts/fixtures/cycles-evidence-v0.1/generate.py @@ -0,0 +1,556 @@ +"""Generate signed CyclesEvidence v0.1 fixtures. + +Run from this directory: + + pip install -r requirements.txt + python generate.py + +Writes one JSON file per case under ./cases/. + +The signer is a TEST KEY ONLY, derived deterministically so the generator +is reproducible and reviewers can re-verify fixtures byte-for-byte. The +seed is sha256("cycles-evidence-v0.1-fixture-signer"); the same input +produces the same Ed25519 keypair on every run. + +Implements the v0.1 normative algorithm from +drafts/cycles-evidence-v0.1.yaml: + + 1. Build envelope with evidence_id="" and signature="" (empty-string + sentinel — NOT field omission, NOT JSON null). + 2. JCS-canonicalize, sha256 → evidence_id (hex). + 3. Populate evidence_id, keep signature="", JCS-canonicalize again, + Ed25519-sign → signature (hex). +""" + +from __future__ import annotations + +import json +from hashlib import sha256 +from pathlib import Path + +import jcs +import nacl.signing + + +FIXTURE_SIGNER_LABEL = "cycles-evidence-v0.1-fixture-signer" + + +def derive_signer() -> nacl.signing.SigningKey: + seed = sha256(FIXTURE_SIGNER_LABEL.encode("utf-8")).digest() + return nacl.signing.SigningKey(seed) + + +def sign_envelope(envelope: dict, signer: nacl.signing.SigningKey) -> dict: + if "evidence_id" in envelope or "signature" in envelope: + raise ValueError("caller must omit evidence_id/signature; generator sets them") + + pre_hash = {**envelope, "evidence_id": "", "signature": ""} + evidence_id = sha256(jcs.canonicalize(pre_hash)).hexdigest() + + pre_sign = {**envelope, "evidence_id": evidence_id, "signature": ""} + signature = signer.sign(jcs.canonicalize(pre_sign)).signature.hex() + + return {**envelope, "evidence_id": evidence_id, "signature": signature} + + +SIGNER_DID = derive_signer().verify_key.encode().hex() +SERVER_ID = "https://cycles.example.com/v1" +SCHEMA_VERSION = "cycles-evidence/v0.1" + + +def base(artifact_type: str, issued_at_ms: int, trace_id: str | None, payload: dict) -> dict: + env: dict = { + "schema_version": SCHEMA_VERSION, + "artifact_type": artifact_type, + "server_id": SERVER_ID, + "signer_did": SIGNER_DID, + "issued_at_ms": issued_at_ms, + } + if trace_id is not None: + env["trace_id"] = trace_id + env["payload"] = payload + return env + + +def case_01_decide_allow() -> dict: + return base( + "decide", + 1810000000000, + "0af7651916cd43dd8448eb211c80319c", + { + "decide": { + "request": { + "idempotency_key": "01HZZ8N4F8FBQX5K6TGYR0M0A1", + "subject": {"tenant": "acme", "agent": "researcher"}, + "action": {"kind": "model.call", "name": "gpt-4o"}, + "estimate": {"unit": "USD_MICROCENTS", "amount": 2000000}, + }, + "response": { + "decision": "ALLOW", + "affected_scopes": ["tenant"], + }, + }, + }, + ) + + +def case_02_reserve_allow() -> dict: + return base( + "reserve", + 1810000000100, + "0af7651916cd43dd8448eb211c80319c", + { + "reserve": { + "request": { + "idempotency_key": "01HZZ8N4F8FBQX5K6TGYR0M0A2", + "subject": {"tenant": "acme", "agent": "researcher"}, + "action": {"kind": "model.call", "name": "gpt-4o"}, + "estimate": {"unit": "USD_MICROCENTS", "amount": 2000000}, + "ttl_ms": 30000, + }, + "response": { + "decision": "ALLOW", + "reservation_id": "rsv_01HZZ8N4F8FBQX5K6TGYR0M0A3", + "reserved": {"unit": "USD_MICROCENTS", "amount": 2000000}, + "affected_scopes": ["tenant"], + "expires_at_ms": 1810000030100, + "scope_path": "tenant=acme", + "balances": [ + { + "scope": "tenant", + "scope_path": "tenant=acme", + "remaining": {"unit": "USD_MICROCENTS", "amount": 8000000}, + "reserved": {"unit": "USD_MICROCENTS", "amount": 2000000}, + "spent": {"unit": "USD_MICROCENTS", "amount": 0}, + }, + ], + }, + }, + }, + ) + + +def case_03_reserve_dry_run_deny() -> dict: + # decision: DENY on a reserve is only valid on dry_run=true per + # cycles-protocol-v0.yaml §ReservationCreateResponse.decision: + # "For dry_run=true, decision MAY be DENY. For dry_run=false, + # insufficient budget MUST be expressed via 409 BUDGET_EXCEEDED + # (not decision=DENY)." + # Live (non-dry) budget denials are captured by case_11 via the + # `error` artifact type. + return base( + "reserve", + 1810000000200, + "b9c8a0d3f2e147a9a7f4d2e1b0c9876f", + { + "reserve": { + "request": { + "idempotency_key": "01HZZ8N4F8FBQX5K6TGYR0M0B1", + "subject": {"tenant": "acme", "agent": "researcher"}, + "action": {"kind": "model.call", "name": "gpt-4o"}, + "estimate": {"unit": "USD_MICROCENTS", "amount": 50000000}, + "ttl_ms": 30000, + "dry_run": True, + }, + "response": { + "decision": "DENY", + "affected_scopes": ["tenant"], + "reason_code": "BUDGET_EXCEEDED", + "scope_path": "tenant=acme", + "balances": [ + { + "scope": "tenant", + "scope_path": "tenant=acme", + "remaining": {"unit": "USD_MICROCENTS", "amount": 8000000}, + "reserved": {"unit": "USD_MICROCENTS", "amount": 0}, + "spent": {"unit": "USD_MICROCENTS", "amount": 92000000}, + }, + ], + }, + }, + }, + ) + + +def case_04_reserve_allow_with_caps() -> dict: + return base( + "reserve", + 1810000000300, + "c1d2e3f4a5b6c7d8e9f0a1b2c3d4e5f6", + { + "reserve": { + "request": { + "idempotency_key": "01HZZ8N4F8FBQX5K6TGYR0M0C1", + "subject": {"tenant": "acme", "agent": "researcher"}, + "action": {"kind": "model.call", "name": "gpt-4o"}, + "estimate": {"unit": "TOKENS", "amount": 32000}, + "ttl_ms": 30000, + }, + "response": { + "decision": "ALLOW_WITH_CAPS", + "reservation_id": "rsv_01HZZ8N4F8FBQX5K6TGYR0M0C2", + "reserved": {"unit": "TOKENS", "amount": 8000}, + "affected_scopes": ["tenant"], + "expires_at_ms": 1810000030300, + "caps": { + "max_tokens": 8000, + "tool_allowlist": ["read.*"], + "cooldown_ms": 1000, + }, + "scope_path": "tenant=acme", + }, + }, + }, + ) + + +def case_05_commit_success() -> dict: + return base( + "commit", + 1810000010000, + "0af7651916cd43dd8448eb211c80319c", + { + "commit": { + "reservation_id": "rsv_01HZZ8N4F8FBQX5K6TGYR0M0A3", + "request": { + "idempotency_key": "01HZZ8N4F8FBQX5K6TGYR0M0A4", + "actual": {"unit": "USD_MICROCENTS", "amount": 1750000}, + }, + "response": { + "status": "COMMITTED", + "charged": {"unit": "USD_MICROCENTS", "amount": 1750000}, + "released": {"unit": "USD_MICROCENTS", "amount": 250000}, + "balances": [ + { + "scope": "tenant", + "scope_path": "tenant=acme", + "remaining": {"unit": "USD_MICROCENTS", "amount": 8250000}, + "reserved": {"unit": "USD_MICROCENTS", "amount": 0}, + "spent": {"unit": "USD_MICROCENTS", "amount": 1750000}, + }, + ], + }, + }, + }, + ) + + +def case_06_release_success() -> dict: + return base( + "release", + 1810000005000, + "d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6", + { + "release": { + "reservation_id": "rsv_01HZZ8N4F8FBQX5K6TGYR0M0C2", + "request": { + "idempotency_key": "01HZZ8N4F8FBQX5K6TGYR0M0C3", + }, + "response": { + "status": "RELEASED", + "released": {"unit": "TOKENS", "amount": 8000}, + "balances": [ + { + "scope": "tenant", + "scope_path": "tenant=acme", + "remaining": {"unit": "TOKENS", "amount": 100000}, + "reserved": {"unit": "TOKENS", "amount": 0}, + "spent": {"unit": "TOKENS", "amount": 0}, + }, + ], + }, + }, + }, + ) + + +def case_07_release_with_reason() -> dict: + return base( + "release", + 1810000007000, + "e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6", + { + "release": { + "reservation_id": "rsv_01HZZ8N4F8FBQX5K6TGYR0M0A3", + "request": { + "idempotency_key": "01HZZ8N4F8FBQX5K6TGYR0M0A5", + "reason": "handler_timeout", + }, + "response": { + "status": "RELEASED", + "released": {"unit": "USD_MICROCENTS", "amount": 2000000}, + "balances": [ + { + "scope": "tenant", + "scope_path": "tenant=acme", + "remaining": {"unit": "USD_MICROCENTS", "amount": 10000000}, + "reserved": {"unit": "USD_MICROCENTS", "amount": 0}, + "spent": {"unit": "USD_MICROCENTS", "amount": 0}, + }, + ], + }, + }, + }, + ) + + +def case_09_decide_risk_points_allow() -> dict: + # RISK_POINTS is the authority-class unit per the unit_class discussion + # on aeoess/agent-passport-system#25 — non-monetary action-authority + # budget keyed against an ActionRiskClass taxonomy. This fixture + # exercises a pre-execution decide for a side-effecting action with a + # RISK_POINTS estimate, returning ALLOW. + return base( + "decide", + 1810000020000, + "f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6", + { + "decide": { + "request": { + "idempotency_key": "01HZZ8N4F8FBQX5K6TGYR0M0E1", + "subject": {"tenant": "acme", "agent": "researcher"}, + "action": { + "kind": "tool.call", + "name": "send_external_email", + "tags": ["prod", "customer-facing"], + }, + "estimate": {"unit": "RISK_POINTS", "amount": 5}, + }, + "response": { + "decision": "ALLOW", + "affected_scopes": ["tenant", "agent"], + }, + }, + }, + ) + + +def case_10_reserve_credits_allow() -> dict: + # CREDITS is described in cycles-protocol-v0.yaml UnitEnum as a + # "generic integer unit (optional in v0 implementations)". This + # fixture exercises only the Cycles wire surface: CREDITS as a + # closed-enum unit name preserved byte-for-byte through the + # signed envelope. + # + # Whether CREDITS maps to APS unit_class=consumption, + # =authority, or =implementation-defined is an APS receipt + # concern tracked on aeoess/agent-passport-system#25, not a + # CyclesEvidence concern. This envelope makes no claim about + # APS unit_class; adapter authors writing APS receipts MUST + # consult the issue #25 thread (and the eventual + # `crosswalk/cycles.yaml` row at + # aeoess/agent-governance-vocabulary#92) for the authoritative + # APS-side mapping rule. + return base( + "reserve", + 1810000030000, + "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6", + { + "reserve": { + "request": { + "idempotency_key": "01HZZ8N4F8FBQX5K6TGYR0M0F1", + "subject": {"tenant": "acme", "agent": "researcher"}, + "action": {"kind": "compute.job", "name": "image-render"}, + "estimate": {"unit": "CREDITS", "amount": 50}, + "ttl_ms": 60000, + }, + "response": { + "decision": "ALLOW", + "reservation_id": "rsv_01HZZ8N4F8FBQX5K6TGYR0M0F2", + "reserved": {"unit": "CREDITS", "amount": 50}, + "affected_scopes": ["tenant"], + "expires_at_ms": 1810000090000, + "scope_path": "tenant=acme", + "balances": [ + { + "scope": "tenant", + "scope_path": "tenant=acme", + "remaining": {"unit": "CREDITS", "amount": 950}, + "reserved": {"unit": "CREDITS", "amount": 50}, + "spent": {"unit": "CREDITS", "amount": 0}, + "allocated": {"unit": "CREDITS", "amount": 1000}, + }, + ], + }, + }, + }, + ) + + +def case_08_reserve_allow_no_trace_id() -> dict: + return base( + "reserve", + 1810000000400, + None, + { + "reserve": { + "request": { + "idempotency_key": "01HZZ8N4F8FBQX5K6TGYR0M0D1", + "subject": {"tenant": "acme", "agent": "researcher"}, + "action": {"kind": "model.call", "name": "gpt-4o"}, + "estimate": {"unit": "USD_MICROCENTS", "amount": 1000000}, + "ttl_ms": 30000, + }, + "response": { + "decision": "ALLOW", + "reservation_id": "rsv_01HZZ8N4F8FBQX5K6TGYR0M0D2", + "reserved": {"unit": "USD_MICROCENTS", "amount": 1000000}, + "affected_scopes": ["tenant"], + "expires_at_ms": 1810000030400, + }, + }, + }, + ) + + +def case_13_commit_with_metrics() -> dict: + # Exercises StandardMetrics on CommitRequest. Closes the coverage + # gap that hid the round-6 finding (CommitRequestMirror.metrics + # was an arbitrary object instead of referencing StandardMetrics). + # Carries all five canonical StandardMetrics fields including the + # `custom` escape hatch. + return base( + "commit", + 1810000060000, + "abcdef0123456789abcdef0123456789", + { + "commit": { + "reservation_id": "rsv_01HZZ8N4F8FBQX5K6TGYR0M0J1", + "request": { + "idempotency_key": "01HZZ8N4F8FBQX5K6TGYR0M0J2", + "actual": {"unit": "USD_MICROCENTS", "amount": 1500000}, + "metrics": { + "tokens_input": 1500, + "tokens_output": 800, + "latency_ms": 2340, + "model_version": "claude-sonnet-4-20250514", + "custom": { + "cache_hit_ratio": 0.42, + "retry_count": 0, + }, + }, + "metadata": {"workflow_run_id": "wf_abc123"}, + }, + "response": { + "status": "COMMITTED", + "charged": {"unit": "USD_MICROCENTS", "amount": 1500000}, + "balances": [ + { + "scope": "tenant", + "scope_path": "tenant=acme", + "remaining": {"unit": "USD_MICROCENTS", "amount": 8500000}, + "reserved": {"unit": "USD_MICROCENTS", "amount": 0}, + "spent": {"unit": "USD_MICROCENTS", "amount": 1500000}, + }, + ], + }, + }, + }, + ) + + +def case_12_decide_live_forbidden() -> dict: + # Live 4xx error on POST /v1/decide. Exercises the corrected + # endpoint name (canonical is /v1/decide, not /v1/decisions; the + # earlier draft had this wrong and no fixture caught it because + # no error fixture used the decide endpoint). + return base( + "error", + 1810000050000, + "fedcba9876543210fedcba9876543210", + { + "error": { + "endpoint": "POST /v1/decide", + "http_status": 403, + "request": { + "idempotency_key": "01HZZ8N4F8FBQX5K6TGYR0M0H1", + "subject": {"tenant": "acme", "agent": "researcher"}, + "action": {"kind": "model.call", "name": "gpt-4o"}, + "estimate": {"unit": "USD_MICROCENTS", "amount": 1000000}, + }, + "response": { + "error": "FORBIDDEN", + "message": "Tenant scope mismatch with effective auth context", + "request_id": "req_01HZZ8N4F8FBQX5K6TGYR0M0H2", + "trace_id": "fedcba9876543210fedcba9876543210", + }, + }, + }, + ) + + +def case_11_reserve_live_budget_exceeded() -> dict: + # Non-dry reserve over budget: the canonical wire shape is a 409 + # ErrorResponse with error: BUDGET_EXCEEDED, NOT a 200 with + # decision: DENY (see cycles-protocol-v0.yaml:978). Captured here + # via the `error` artifact type with endpoint + # "POST /v1/reservations". This is the live denial path + # referenced in aeoess/agent-passport-system#25 ("Cycles denies + # → APS blocks/audits") — without an error-artifact slot, v0.1 + # would have no evidence for the most important denial branch. + return base( + "error", + 1810000040000, + "0123456789abcdef0123456789abcdef", + { + "error": { + "endpoint": "POST /v1/reservations", + "http_status": 409, + "request": { + "idempotency_key": "01HZZ8N4F8FBQX5K6TGYR0M0G1", + "subject": {"tenant": "acme", "agent": "researcher"}, + "action": {"kind": "model.call", "name": "gpt-4o"}, + "estimate": {"unit": "USD_MICROCENTS", "amount": 100000000}, + "ttl_ms": 30000, + }, + "response": { + "error": "BUDGET_EXCEEDED", + "message": "Insufficient remaining budget for scope tenant=acme", + "request_id": "req_01HZZ8N4F8FBQX5K6TGYR0M0G2", + "trace_id": "0123456789abcdef0123456789abcdef", + }, + }, + }, + ) + + +CASES: list[tuple[str, dict]] = [ + ("01-decide-allow.json", case_01_decide_allow()), + ("02-reserve-allow.json", case_02_reserve_allow()), + ("03-reserve-dry-run-deny.json", case_03_reserve_dry_run_deny()), + ("04-reserve-allow-with-caps.json", case_04_reserve_allow_with_caps()), + ("05-commit-success.json", case_05_commit_success()), + ("06-release-success.json", case_06_release_success()), + ("07-release-with-reason.json", case_07_release_with_reason()), + ("08-reserve-allow-no-trace-id.json", case_08_reserve_allow_no_trace_id()), + ("09-decide-risk-points-allow.json", case_09_decide_risk_points_allow()), + ("10-reserve-credits-allow.json", case_10_reserve_credits_allow()), + ("11-reserve-live-budget-exceeded.json", case_11_reserve_live_budget_exceeded()), + ("12-decide-live-forbidden.json", case_12_decide_live_forbidden()), + ("13-commit-with-metrics.json", case_13_commit_with_metrics()), +] + + +def main() -> None: + signer = derive_signer() + out_dir = Path(__file__).parent / "cases" + out_dir.mkdir(exist_ok=True) + + expected = {filename for filename, _ in CASES} + for stale in sorted(out_dir.glob("*.json")): + if stale.name not in expected: + stale.unlink() + print(f"removed stale {stale.name}") + + for filename, raw in CASES: + signed = sign_envelope(raw, signer) + canonical = jcs.canonicalize(signed) + (out_dir / filename).write_bytes(canonical + b"\n") + print(f"wrote {filename} ({len(canonical)} canonical bytes)") + + print(f"\nsigner pubkey (hex): {SIGNER_DID}") + print(f"signer label: {FIXTURE_SIGNER_LABEL}") + + +if __name__ == "__main__": + main() diff --git a/drafts/fixtures/cycles-evidence-v0.1/requirements.txt b/drafts/fixtures/cycles-evidence-v0.1/requirements.txt new file mode 100644 index 0000000..ba1dc38 --- /dev/null +++ b/drafts/fixtures/cycles-evidence-v0.1/requirements.txt @@ -0,0 +1,2 @@ +jcs==0.2.1 +pynacl==1.6.2 diff --git a/drafts/fixtures/cycles-evidence-v0.1/verify.py b/drafts/fixtures/cycles-evidence-v0.1/verify.py new file mode 100644 index 0000000..ec78178 --- /dev/null +++ b/drafts/fixtures/cycles-evidence-v0.1/verify.py @@ -0,0 +1,132 @@ +"""Verify CyclesEvidence v0.1 fixtures. + +Run from this directory: + + pip install -r requirements.txt + python verify.py + +Runs the normative verification path from +drafts/cycles-evidence-v0.1.yaml against every fixture under ./cases/: + + 1. Reject any envelope whose schema_version is not understood + (spec MUST on `schema_version`). + 2. Re-derive evidence_id (sha256 over JCS-canonical bytes with + evidence_id="" and signature="") and compare byte-for-byte. + 3. Re-canonicalize with evidence_id populated and signature="", + Ed25519-verify the signature against the pubkey resolved from + signer_did. + 4. Check artifact_type ↔ payload key consistency (the v0.1 spec + MUST: "artifact_type: commit REQUIRES payload.commit and forbids + the others", etc.). + 5. Check optional trace_id matches the 32-hex W3C Trace Context + pattern when present. + +Exit code 0 = all green. Non-zero = at least one fixture failed; the +failure mode is printed per fixture. +""" + +from __future__ import annotations + +import json +import re +import sys +from hashlib import sha256 +from pathlib import Path + +import jcs +import nacl.exceptions +import nacl.signing + + +SCHEMA_VERSION = "cycles-evidence/v0.1" +ARTIFACT_TYPES = ("decide", "reserve", "commit", "release", "error") +TRACE_ID_RE = re.compile(r"^[0-9a-f]{32}$") +EVIDENCE_ID_RE = re.compile(r"^[0-9a-f]{64}$") +SIGNATURE_RE = re.compile(r"^[0-9a-f]{128}$") + + +def verify_envelope(envelope: dict) -> list[str]: + errors: list[str] = [] + + schema_version = envelope.get("schema_version") + evidence_id = envelope.get("evidence_id") + signature = envelope.get("signature") + signer_did = envelope.get("signer_did") + artifact_type = envelope.get("artifact_type") + payload = envelope.get("payload") + trace_id = envelope.get("trace_id") + + if schema_version != SCHEMA_VERSION: + errors.append( + f"schema_version not understood: expected {SCHEMA_VERSION!r}, got {schema_version!r}" + ) + return errors + + if not isinstance(evidence_id, str) or not EVIDENCE_ID_RE.match(evidence_id): + errors.append(f"evidence_id missing or not 64-hex: {evidence_id!r}") + return errors + if not isinstance(signature, str) or not SIGNATURE_RE.match(signature): + errors.append(f"signature missing or not 128-hex: {signature!r}") + return errors + if not isinstance(signer_did, str): + errors.append("signer_did missing") + return errors + + pre_hash = {**envelope, "evidence_id": "", "signature": ""} + recomputed_id = sha256(jcs.canonicalize(pre_hash)).hexdigest() + if recomputed_id != evidence_id: + errors.append( + f"evidence_id mismatch: envelope={evidence_id}, recomputed={recomputed_id}" + ) + + pre_sign = {**envelope, "signature": ""} + canonical_signed = jcs.canonicalize(pre_sign) + try: + verify_key = nacl.signing.VerifyKey(bytes.fromhex(signer_did)) + verify_key.verify(canonical_signed, bytes.fromhex(signature)) + except (ValueError, nacl.exceptions.BadSignatureError) as exc: + errors.append(f"signature verification failed: {exc}") + + if artifact_type not in ARTIFACT_TYPES: + errors.append(f"artifact_type not in {ARTIFACT_TYPES}: {artifact_type!r}") + elif not isinstance(payload, dict): + errors.append("payload missing or not an object") + else: + present = [k for k in ARTIFACT_TYPES if k in payload] + if present != [artifact_type]: + errors.append( + f"payload keys {present!r} do not match artifact_type {artifact_type!r}" + ) + + if trace_id is not None and not (isinstance(trace_id, str) and TRACE_ID_RE.match(trace_id)): + errors.append(f"trace_id present but not 32-hex: {trace_id!r}") + + return errors + + +def main() -> int: + cases_dir = Path(__file__).parent / "cases" + fixtures = sorted(cases_dir.glob("*.json")) + if not fixtures: + print(f"no fixtures found under {cases_dir}", file=sys.stderr) + return 2 + + failures = 0 + for path in fixtures: + envelope = json.loads(path.read_text(encoding="utf-8")) + errors = verify_envelope(envelope) + if errors: + failures += 1 + print(f"FAIL {path.name}") + for err in errors: + print(f" - {err}") + else: + print(f"OK {path.name} evidence_id={envelope['evidence_id'][:16]}…") + + print() + print(f"{len(fixtures) - failures}/{len(fixtures)} fixtures verified") + return 0 if failures == 0 else 1 + + +if __name__ == "__main__": + sys.exit(main())