From cf31d6be87584775bc0f1a84bcbcee3d02167c5f Mon Sep 17 00:00:00 2001 From: Albert Mavashev Date: Tue, 12 May 2026 07:22:39 -0400 Subject: [PATCH 1/9] =?UTF-8?q?feat(drafts):=20CyclesEvidence=20envelope?= =?UTF-8?q?=20v0.1=20=E2=80=94=20signed,=20content-addressed=20lifecycle?= =?UTF-8?q?=20evidence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New schema-only OpenAPI 3.1 draft at drafts/cycles-evidence-v0.1.yaml defining a JCS-canonicalized, Ed25519-signed envelope for the four Cycles authorization lifecycle events (decide / reserve / commit / release). Content-addressed via sha256 over canonical bytes with signature emptied; signature derived over canonical bytes with evidence_id populated and signature emptied (id-then-signature ordering matches APS payment-rails/canonicalize.ts and Wave 1 accountability artifacts). Motivation: - Cycles runtime responses are sufficient for the immediate caller but not for ledger-independent audit consumers — cross-system verifiers (notably APS) need a single artifact they can fetch by content hash and verify offline. - Hoists reservation_id into the signed payload for commit/release artifacts, closing the linkage gap called out at aeoess/agent-governance-vocabulary#92 review round 4 (the wire response bodies do not echo reservation_id; it lives on the URL path). - Provides the canonicalization commitment promised at aeoess/agent-passport-system#25 (CyclesEvidenceRef join interface): RFC 8785 JCS + sha256 to match APS conventions. Status: DRAFT (v0.1) in drafts/. Will move to a normative repo-root spec (cycles-evidence-v0.2.yaml) once at least one production implementation ships and APS has integrated against the envelope shape end-to-end. Out of scope for v0.1: - Evidence retrieval HTTP API (URL path layout, auth scheme, replication policy stays with the server impl). - Signing-key rotation, JWKS publication, did:cycles registration. - Merkle-batched aggregated evidence (one envelope per event in v0.1). Schema mirrors of cycles-protocol-v0.yaml types (DecisionRequest, ReservationCreateRequest, etc.) are inlined for standalone validation; the normative spec move will replace them with cross-file refs. Spectral lint passes with 0 errors (3 warnings, all unavoidable for a schema-only draft: oas3-api-servers, oas3-unused-component on CyclesEvidence root, oneOf-incompatible additionalProperties on EvidencePayload). References: - aeoess/agent-passport-system#25 (CyclesEvidenceRef join interface) - aeoess/agent-governance-vocabulary#92 (Cycles signal-type crosswalk) - RFC 8785 (JCS) --- drafts/cycles-evidence-v0.1.yaml | 668 +++++++++++++++++++++++++++++++ 1 file changed, 668 insertions(+) create mode 100644 drafts/cycles-evidence-v0.1.yaml diff --git a/drafts/cycles-evidence-v0.1.yaml b/drafts/cycles-evidence-v0.1.yaml new file mode 100644 index 0000000..63ba4ec --- /dev/null +++ b/drafts/cycles-evidence-v0.1.yaml @@ -0,0 +1,668 @@ +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/decisions`, `/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. + + 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 + description: | + Cycles lifecycle event this envelope attests to. Maps 1:1 + to the four core runtime endpoints: + + - `decide` → POST /v1/decisions (stateless pre-check) + - `reserve` → POST /v1/reservations (atomic authorization) + - `commit` → POST /v1/reservations/{id}/commit (settle at actual) + - `release` → POST /v1/reservations/{id}/release (clear without debit) + + `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 four properties below + MUST be present, matching the envelope's `artifact_type`. The + other three MUST be absent (not present-but-null). + + 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 } + + # ===================================================================== + # 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/decisions`. + Schema mirror of `cycles-protocol-v0.yaml#/components/schemas/DecisionRequest`. + $ref: "#/components/schemas/DecisionRequestMirror" + response: + description: | + DecisionResponse as returned by `POST /v1/decisions`. + 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`. On `decision: DENY` the + response's `reservation_id` is absent and `reason_code` + carries the denial reason — both states are valid evidence. + 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 of release evidence is the subject of an + open question on aeoess/agent-passport-system#25 (whether to + bridge it to a third `rail.budget_authority.release.v1` + literal, reuse the denial literal with a release-flavored + `scope_of_claim`, or omit the APS receipt entirely). The + Cycles-side envelope is unaffected by that choice — release + evidence is emitted uniformly regardless of which APS + literal the consumer chooses. + properties: + reservation_id: + type: string + request: + $ref: "#/components/schemas/ReleaseRequestMirror" + response: + $ref: "#/components/schemas/ReleaseResponseMirror" + + # ===================================================================== + # 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. + # The field lists below are derived from runcycles Python SDK + # introspection on 2026-05-12 (see PR description for verification). + # ===================================================================== + + DecisionRequestMirror: + type: object + description: Mirror of `cycles-protocol-v0.yaml#/components/schemas/DecisionRequest` — the body of `POST /v1/decisions`. + required: [idempotency_key, subject, action, estimate] + additionalProperties: false + properties: + idempotency_key: { type: string } + 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/decisions`. + required: [decision] + additionalProperties: false + properties: + decision: { $ref: "#/components/schemas/Decision" } + caps: { $ref: "#/components/schemas/Caps" } + reason_code: { type: string } + retry_after_ms: { type: integer, format: int64 } + affected_scopes: { type: array, items: { type: string } } + + 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 } + subject: { $ref: "#/components/schemas/Subject" } + action: { $ref: "#/components/schemas/Action" } + estimate: { $ref: "#/components/schemas/Amount" } + ttl_ms: { type: integer, format: int64 } + grace_period_ms: { type: integer, format: int64 } + overage_policy: { type: string } + 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`. + 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 } + retry_after_ms: { type: integer, format: int64 } + expires_at_ms: { type: integer, format: int64 } + scope_path: { type: string } + balances: + type: array + items: { $ref: "#/components/schemas/Balance" } + + 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 } + actual: { $ref: "#/components/schemas/Amount" } + metrics: { type: object, additionalProperties: true } + 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 } + 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 } + reason: { type: string } + + 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 } + released: { $ref: "#/components/schemas/Amount" } + balances: + type: array + items: { $ref: "#/components/schemas/Balance" } + + # ===================================================================== + # Common nested types. + # ===================================================================== + + Subject: + type: object + description: | + Cycles `Subject` — the budgeting dimension a reservation is + debited against. All seven fields are optional in the SDK + model (the Cycles server applies its own minimum-validity + constraints based on the effective tenant resolved from auth + context); the envelope preserves the request shape verbatim. + Field list verified against runcycles Python SDK introspection + on 2026-05-12. + additionalProperties: false + properties: + tenant: { type: string } + workspace: { type: string } + app: { type: string } + workflow: { type: string } + agent: { type: string } + toolset: { type: string } + dimensions: + type: object + additionalProperties: true + description: Free-form per-deployment subject dimensions. + + Action: + type: object + description: Cycles `Action` — the caller-declared `(kind, name)` of the gated action (e.g. `model.call` / `gpt-4o`). + required: [kind, name] + additionalProperties: false + properties: + kind: { type: string } + name: { type: string } + tags: + type: array + items: { type: string } + 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, 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 } + + 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 } } + tool_denylist: { type: array, items: { type: string } } + cooldown_ms: { type: integer, minimum: 0 } From fb4307709dc12d7a3ffe9daaf454dc3bb4e0270c Mon Sep 17 00:00:00 2001 From: Albert Mavashev Date: Wed, 13 May 2026 06:04:13 -0400 Subject: [PATCH 2/9] feat(drafts): CyclesEvidence v0.1 reference fixtures + generator/verifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ten signed, content-addressed CyclesEvidence envelopes covering the full v0.1 surface, plus the deterministic generator and standalone verifier that produced and verify them. Surface coverage: - 4 artifact_types: decide, reserve, commit, release - 3 decision branches: ALLOW, ALLOW_WITH_CAPS, DENY - All 4 Unit enum values: USD_MICROCENTS, TOKENS, RISK_POINTS, CREDITS - Optional fields exercised: Action.tags, Balance.allocated, ReleaseRequest.reason - trace_id omission semantics (field absent in canonical bytes, NOT empty string and NOT null — the distinction called out in the spec normative note on omit/null/empty) Generator + verifier: - generate.py is deterministic: re-running produces byte-identical output. Test signer derived from sha256("cycles-evidence-v0.1-fixture-signer") so reviewers can re-derive the Ed25519 keypair locally (pubkey: ec52b49b81eb29ef6f62947cade245c715bf943b7ef2a5f2789288574466fc43). Implements the v0.1 normative empty-string-sentinel + id-then-signature algorithm. - verify.py enforces every spec MUST clause: schema_version must be understood (rejects unknown versions before further checks), evidence_id sha256 byte-match against recomputed canonical bytes, Ed25519 signature verification, artifact_type ↔ payload key consistency (oneOf), and 32-hex trace_id pattern when present. - Tamper-detection sanity: flipping one character in any signed field trips both the evidence_id mismatch and the signature failure; re-running generate.py restores from canonical sources. Closes the test-plan checkbox on PR #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." Test plan: - cd drafts/fixtures/cycles-evidence-v0.1 && pip install -r requirements.txt && python generate.py — writes cases/*.json with byte-identical output across runs. - python verify.py — 10/10 fixtures verify green; exit code 0. --- .../fixtures/cycles-evidence-v0.1/README.md | 134 ++++++ .../cases/01-decide-allow.json | 1 + .../cases/02-reserve-allow.json | 1 + .../cases/03-reserve-deny.json | 1 + .../cases/04-reserve-allow-with-caps.json | 1 + .../cases/05-commit-success.json | 1 + .../cases/06-release-success.json | 1 + .../cases/07-release-with-reason.json | 1 + .../cases/08-reserve-allow-no-trace-id.json | 1 + .../cases/09-decide-risk-points-allow.json | 1 + .../cases/10-reserve-credits-allow.json | 1 + .../fixtures/cycles-evidence-v0.1/generate.py | 421 ++++++++++++++++++ .../cycles-evidence-v0.1/requirements.txt | 2 + .../fixtures/cycles-evidence-v0.1/verify.py | 132 ++++++ 14 files changed, 699 insertions(+) create mode 100644 drafts/fixtures/cycles-evidence-v0.1/README.md create mode 100644 drafts/fixtures/cycles-evidence-v0.1/cases/01-decide-allow.json create mode 100644 drafts/fixtures/cycles-evidence-v0.1/cases/02-reserve-allow.json create mode 100644 drafts/fixtures/cycles-evidence-v0.1/cases/03-reserve-deny.json create mode 100644 drafts/fixtures/cycles-evidence-v0.1/cases/04-reserve-allow-with-caps.json create mode 100644 drafts/fixtures/cycles-evidence-v0.1/cases/05-commit-success.json create mode 100644 drafts/fixtures/cycles-evidence-v0.1/cases/06-release-success.json create mode 100644 drafts/fixtures/cycles-evidence-v0.1/cases/07-release-with-reason.json create mode 100644 drafts/fixtures/cycles-evidence-v0.1/cases/08-reserve-allow-no-trace-id.json create mode 100644 drafts/fixtures/cycles-evidence-v0.1/cases/09-decide-risk-points-allow.json create mode 100644 drafts/fixtures/cycles-evidence-v0.1/cases/10-reserve-credits-allow.json create mode 100644 drafts/fixtures/cycles-evidence-v0.1/generate.py create mode 100644 drafts/fixtures/cycles-evidence-v0.1/requirements.txt create mode 100644 drafts/fixtures/cycles-evidence-v0.1/verify.py 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..8d77767 --- /dev/null +++ b/drafts/fixtures/cycles-evidence-v0.1/README.md @@ -0,0 +1,134 @@ +# CyclesEvidence v0.1 — reference fixtures + +Ten signed, content-addressed `CyclesEvidence` envelopes covering the +four artifact types, the decision branches an audit consumer needs to +handle, 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-deny.json — ReservePayload, decision=DENY, no reservation_id + 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 +``` + +## 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 | DENY-branch evidence; `reservation_id` absent on DENY (both states are valid evidence per spec) | +| 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 | + +## 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-deny.json b/drafts/fixtures/cycles-evidence-v0.1/cases/03-reserve-deny.json new file mode 100644 index 0000000..1ec4e47 --- /dev/null +++ b/drafts/fixtures/cycles-evidence-v0.1/cases/03-reserve-deny.json @@ -0,0 +1 @@ +{"artifact_type":"reserve","evidence_id":"e051b4cd0e6dc5c98ac14b27bbabb2630098ef1a61650c92658b374a3cfb20a2","issued_at_ms":1810000000200,"payload":{"reserve":{"request":{"action":{"kind":"model.call","name":"gpt-4o"},"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":"5a96f035651053bcdd6736778581ac5549f75dca78bedac1c6570fbc24b13000f2a23141b017f354ad635d1a085561ddfa3ad04b4250efc9b425b28016023806","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/generate.py b/drafts/fixtures/cycles-evidence-v0.1/generate.py new file mode 100644 index 0000000..969ffca --- /dev/null +++ b/drafts/fixtures/cycles-evidence-v0.1/generate.py @@ -0,0 +1,421 @@ +"""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_deny() -> dict: + 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, + }, + "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 the implementation-defined unit per cycles-protocol-v0 + # ("generic integer units (optional in v0 implementations)") — some + # deployments treat them as consumption, others as authority. This + # fixture exercises a reserve with CREDITS as the closed-enum unit; + # the receipt-level rule that consumers MUST populate unit_class + # explicitly when unit=CREDITS is an APS receipt concern, not a + # CyclesEvidence concern (the envelope carries the unit name as it + # appears on the wire). + 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, + }, + }, + }, + ) + + +CASES: list[tuple[str, dict]] = [ + ("01-decide-allow.json", case_01_decide_allow()), + ("02-reserve-allow.json", case_02_reserve_allow()), + ("03-reserve-deny.json", case_03_reserve_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()), +] + + +def main() -> None: + signer = derive_signer() + out_dir = Path(__file__).parent / "cases" + out_dir.mkdir(exist_ok=True) + + 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..c7f0139 --- /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") +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()) From 17271c4bed79f09fe0b1d94321544a225a5f32d6 Mon Sep 17 00:00:00 2001 From: Albert Mavashev Date: Wed, 13 May 2026 06:19:38 -0400 Subject: [PATCH 3/9] =?UTF-8?q?fix(drafts):=20CyclesEvidence=20v0.1=20?= =?UTF-8?q?=E2=80=94=20address=20review=20(live-denial=20branch,=20namespa?= =?UTF-8?q?ce,=20mirror=20constraints)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review findings on PR #90 surfaced real bugs. All addressed. 1. HIGH — live reserve denials had no valid evidence slot. cycles-protocol-v0.yaml §ReservationCreateResponse.decision is explicit: "For dry_run=true, decision MAY be DENY. For dry_run=false, insufficient budget MUST be expressed via 409 BUDGET_EXCEEDED (not decision=DENY)." The previous draft treated reserve `decision: DENY` as universally valid evidence, and fixture 03 emitted a non-dry reserve with decision: DENY — a wire shape the canonical protocol forbids. The most important denial path on the issue #25 integration thread ("Cycles denies → APS blocks/audits") had no evidence shape at all. Changes: - Extend ArtifactType with `error` (5th type). - Add ErrorPayload + ErrorResponseMirror schemas. ErrorPayload wraps a 4xx/5xx ErrorResponse from any of the four endpoints, hoisting `reservation_id` (when path-arg-bearing) into the signed payload and preserving the originating request body via a discriminated oneOf over the four request mirrors. - Tighten ReservePayload description with the normative dry_run + decision: DENY constraint, AND encode it schema-side as an if/then: validators reject non-dry DENY without custom logic. Confirmed: 11/11 fixtures pass, and a constructed non-dry-DENY envelope fails validation at the EvidencePayload oneOf. - Generator: rename case_03 → case_03_reserve_dry_run_deny with explicit dry_run: true; add case_11_reserve_live_budget_exceeded emitting a 409 BUDGET_EXCEEDED via the `error` artifact type. Stale 03-reserve-deny.json removed automatically by the generator (cleanup step added). 2. MEDIUM — stale APS literal namespace. ReleasePayload description still referenced `rail.budget_authority. release.v1` from the abandoned namespace, with the "open question" framing left over from a thread state that no longer holds. Issue #25 comments 4433715146 (aeoess) and 4433275511 (me) renamed the APS rail-literal namespace from `budget_authority` to `budget_reservation` and locked in three lifecycle literals: `rail.budget_reservation.{permit,release,denial}.v1`. Description rewritten to cite the rename and the confirmed literal without the open-question framing. 3. MEDIUM — Mirror schemas looser than the canonical source. The previous mirrors stripped constraints from the canonical: - Subject lost minProperties: 1, the anyOf for the six standard fields, maxLength: 128 on string fields, maxProperties: 16 on dimensions, and maxLength: 256 on dimensions values. - idempotency_key lost minLength: 1, maxLength: 256 on all four *RequestMirror schemas. - reason_code on DecisionResponseMirror and ReservationCreateResponseMirror lost maxLength: 128 (the canonical DecisionReasonCode bound). - retry_after_ms, ttl_ms, grace_period_ms, expires_at_ms lost minimum: 0. - Action.kind lost maxLength: 64; Action.name lost maxLength: 256; Action.tags lost maxItems: 10 and items maxLength: 64. - Caps.tool_allowlist/tool_denylist lost items maxLength: 256. - Amount.amount and SignedAmount.amount lost format: int64. This was a documentation gap — fixtures happened to satisfy the canonical constraints — but it misled adapter authors reading the draft about what's permitted on the wire. Changes: - All canonical constraints copied into the mirrors byte-for-byte against cycles-protocol-v0.yaml as of 2026-05-13. - New MIRROR CONTRACT block at the head of the mirrors section: drift from canonical is now an explicit v0.1 bug. Coverage after this commit: - 11 signed fixtures (was 10), exit 0 on python verify.py. - All 11 schema-valid under the tightened spec (Draft 2020-12). - The if/then ReservePayload rule rejects non-dry DENY at the JSON Schema layer. - npx spectral lint drafts/cycles-evidence-v0.1.yaml --fail-severity=error: 0 errors, 3 expected schema-only warnings (oas3-api-servers / oas3-unused-component / oneOf additionalProperties — same set as before this review pass). Test plan: - cd drafts/fixtures/cycles-evidence-v0.1 && python generate.py && python verify.py — 11/11 verified. - JSON Schema validate every fixture against the updated CyclesEvidence schema — 11/11 valid. - Construct a non-dry reserve with decision: DENY (stripped dry_run: true from fixture 03 in-memory) — schema rejects it. - npx spectral lint — 0 errors. --- drafts/cycles-evidence-v0.1.yaml | 302 ++++++++++++++---- .../fixtures/cycles-evidence-v0.1/README.md | 35 +- .../cases/03-reserve-deny.json | 1 - .../cases/03-reserve-dry-run-deny.json | 1 + .../11-reserve-live-budget-exceeded.json | 1 + .../fixtures/cycles-evidence-v0.1/generate.py | 54 +++- .../fixtures/cycles-evidence-v0.1/verify.py | 2 +- 7 files changed, 322 insertions(+), 74 deletions(-) delete mode 100644 drafts/fixtures/cycles-evidence-v0.1/cases/03-reserve-deny.json create mode 100644 drafts/fixtures/cycles-evidence-v0.1/cases/03-reserve-dry-run-deny.json create mode 100644 drafts/fixtures/cycles-evidence-v0.1/cases/11-reserve-live-budget-exceeded.json diff --git a/drafts/cycles-evidence-v0.1.yaml b/drafts/cycles-evidence-v0.1.yaml index 63ba4ec..52595de 100644 --- a/drafts/cycles-evidence-v0.1.yaml +++ b/drafts/cycles-evidence-v0.1.yaml @@ -307,14 +307,28 @@ components: - reserve - commit - release + - error description: | - Cycles lifecycle event this envelope attests to. Maps 1:1 - to the four core runtime endpoints: - - - `decide` → POST /v1/decisions (stateless pre-check) - - `reserve` → POST /v1/reservations (atomic authorization) - - `commit` → POST /v1/reservations/{id}/commit (settle at actual) - - `release` → POST /v1/reservations/{id}/release (clear without debit) + 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/decisions (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 @@ -347,6 +361,7 @@ components: - { 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. @@ -380,9 +395,48 @@ components: additionalProperties: false description: | Reservation-creation envelope. Pairs the `ReservationCreateRequest` - with the `ReservationCreateResponse`. On `decision: DENY` the - response's `reservation_id` is absent and `reason_code` - carries the denial reason — both states are valid evidence. + 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 if/then constraint below encodes this rule + schema-side so JSON Schema validators reject the bad shape + without needing custom logic. + if: + properties: + response: + properties: + decision: + const: DENY + required: [decision] + required: [response] + then: + properties: + request: + properties: + dry_run: + const: true + required: [dry_run] + required: [request] properties: request: $ref: "#/components/schemas/ReservationCreateRequestMirror" @@ -418,14 +472,16 @@ components: `POST /v1/reservations/{reservation_id}/release`) into the signed payload. - APS-side handling of release evidence is the subject of an - open question on aeoess/agent-passport-system#25 (whether to - bridge it to a third `rail.budget_authority.release.v1` - literal, reuse the denial literal with a release-flavored - `scope_of_claim`, or omit the APS receipt entirely). The - Cycles-side envelope is unaffected by that choice — release - evidence is emitted uniformly regardless of which APS - literal the consumer chooses. + 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 @@ -434,14 +490,104 @@ components: 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/decisions`) 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 OPTIONAL and only + meaningful when `endpoint` includes the path argument + (commit / release). + properties: + endpoint: + type: string + enum: + - "POST /v1/decisions" + - "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: + description: | + Request body that triggered the error, matching the + request-mirror schema of `endpoint`. OPTIONAL; one of + DecisionRequestMirror, ReservationCreateRequestMirror, + CommitRequestMirror, or ReleaseRequestMirror, as + determined by `endpoint`. + oneOf: + - $ref: "#/components/schemas/DecisionRequestMirror" + - $ref: "#/components/schemas/ReservationCreateRequestMirror" + - $ref: "#/components/schemas/CommitRequestMirror" + - $ref: "#/components/schemas/ReleaseRequestMirror" + response: + $ref: "#/components/schemas/ErrorResponseMirror" + # ===================================================================== # 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. - # The field lists below are derived from runcycles Python SDK - # introspection on 2026-05-12 (see PR description for verification). + # + # 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: @@ -450,7 +596,7 @@ components: required: [idempotency_key, subject, action, estimate] additionalProperties: false properties: - idempotency_key: { type: string } + idempotency_key: { type: string, minLength: 1, maxLength: 256 } subject: { $ref: "#/components/schemas/Subject" } action: { $ref: "#/components/schemas/Action" } estimate: { $ref: "#/components/schemas/Amount" } @@ -464,8 +610,8 @@ components: properties: decision: { $ref: "#/components/schemas/Decision" } caps: { $ref: "#/components/schemas/Caps" } - reason_code: { type: string } - retry_after_ms: { type: integer, format: int64 } + reason_code: { type: string, maxLength: 128 } + retry_after_ms: { type: integer, format: int64, minimum: 0 } affected_scopes: { type: array, items: { type: string } } ReservationCreateRequestMirror: @@ -474,12 +620,12 @@ components: required: [idempotency_key, subject, action, estimate] additionalProperties: false properties: - idempotency_key: { type: string } + 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 } - grace_period_ms: { type: integer, format: int64 } + ttl_ms: { type: integer, format: int64, minimum: 0 } + grace_period_ms: { type: integer, format: int64, minimum: 0 } overage_policy: { type: string } dry_run: { type: boolean } metadata: { type: object, additionalProperties: true } @@ -495,9 +641,9 @@ components: reservation_id: { type: string } reserved: { $ref: "#/components/schemas/Amount" } caps: { $ref: "#/components/schemas/Caps" } - reason_code: { type: string } - retry_after_ms: { type: integer, format: int64 } - expires_at_ms: { type: integer, format: int64 } + 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 @@ -509,7 +655,7 @@ components: required: [idempotency_key, actual] additionalProperties: false properties: - idempotency_key: { type: string } + idempotency_key: { type: string, minLength: 1, maxLength: 256 } actual: { $ref: "#/components/schemas/Amount" } metrics: { type: object, additionalProperties: true } metadata: { type: object, additionalProperties: true } @@ -533,7 +679,7 @@ components: required: [idempotency_key] additionalProperties: false properties: - idempotency_key: { type: string } + idempotency_key: { type: string, minLength: 1, maxLength: 256 } reason: { type: string } ReleaseResponseMirror: @@ -548,6 +694,40 @@ components: 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 + description: | + ErrorCode value. The canonical `cycles-protocol-v0.yaml` + ErrorCode enum is closed (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). Future v0 minor versions MAY add codes; + evidence consumers SHOULD treat unknown codes as terminal + error states rather than rejecting the envelope. + 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. # ===================================================================== @@ -555,37 +735,51 @@ components: Subject: type: object description: | - Cycles `Subject` — the budgeting dimension a reservation is - debited against. All seven fields are optional in the SDK - model (the Cycles server applies its own minimum-validity - constraints based on the effective tenant resolved from auth - context); the envelope preserves the request shape verbatim. - Field list verified against runcycles Python SDK introspection - on 2026-05-12. + 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 } - workspace: { type: string } - app: { type: string } - workflow: { type: string } - agent: { type: string } - toolset: { type: string } + 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 - additionalProperties: true - description: Free-form per-deployment subject dimensions. + 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`). + 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 } - name: { type: string } + kind: { type: string, maxLength: 64 } + name: { type: string, maxLength: 256 } tags: type: array - items: { type: string } + maxItems: 10 + items: { type: string, maxLength: 64 } description: Optional policy tags (e.g. `["prod", "customer-facing"]`). Amount: @@ -598,7 +792,7 @@ components: additionalProperties: false properties: unit: { $ref: "#/components/schemas/Unit" } - amount: { type: integer, minimum: 0 } + amount: { type: integer, format: int64, minimum: 0 } SignedAmount: type: object @@ -611,7 +805,7 @@ components: additionalProperties: false properties: unit: { $ref: "#/components/schemas/Unit" } - amount: { type: integer } + amount: { type: integer, format: int64 } Balance: type: object @@ -663,6 +857,6 @@ components: properties: max_tokens: { type: integer, minimum: 0 } max_steps_remaining: { type: integer, minimum: 0 } - tool_allowlist: { type: array, items: { type: string } } - tool_denylist: { type: array, items: { type: string } } + tool_allowlist: { type: array, items: { type: string, maxLength: 256 } } + tool_denylist: { type: array, items: { type: string, maxLength: 256 } } cooldown_ms: { type: integer, minimum: 0 } diff --git a/drafts/fixtures/cycles-evidence-v0.1/README.md b/drafts/fixtures/cycles-evidence-v0.1/README.md index 8d77767..b3fddcd 100644 --- a/drafts/fixtures/cycles-evidence-v0.1/README.md +++ b/drafts/fixtures/cycles-evidence-v0.1/README.md @@ -1,10 +1,11 @@ # CyclesEvidence v0.1 — reference fixtures -Ten signed, content-addressed `CyclesEvidence` envelopes covering the -four artifact types, the decision branches an audit consumer needs to -handle, 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. +Eleven 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), 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`: @@ -20,16 +21,17 @@ 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-deny.json — ReservePayload, decision=DENY, no reservation_id - 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 + 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 ``` ## What each fixture proves @@ -38,7 +40,7 @@ cases/ |---|---| | 01 | `decide` payload one-of branch; `DecisionResponse` minimal-required shape (only `decision`) | | 02 | `reserve` happy path; `Balance` with `SignedAmount` `remaining` | -| 03 | DENY-branch evidence; `reservation_id` absent on DENY (both states are valid evidence per spec) | +| 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 | @@ -46,6 +48,7 @@ cases/ | 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. | ## Reproducing the fixtures diff --git a/drafts/fixtures/cycles-evidence-v0.1/cases/03-reserve-deny.json b/drafts/fixtures/cycles-evidence-v0.1/cases/03-reserve-deny.json deleted file mode 100644 index 1ec4e47..0000000 --- a/drafts/fixtures/cycles-evidence-v0.1/cases/03-reserve-deny.json +++ /dev/null @@ -1 +0,0 @@ -{"artifact_type":"reserve","evidence_id":"e051b4cd0e6dc5c98ac14b27bbabb2630098ef1a61650c92658b374a3cfb20a2","issued_at_ms":1810000000200,"payload":{"reserve":{"request":{"action":{"kind":"model.call","name":"gpt-4o"},"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":"5a96f035651053bcdd6736778581ac5549f75dca78bedac1c6570fbc24b13000f2a23141b017f354ad635d1a085561ddfa3ad04b4250efc9b425b28016023806","signer_did":"ec52b49b81eb29ef6f62947cade245c715bf943b7ef2a5f2789288574466fc43","trace_id":"b9c8a0d3f2e147a9a7f4d2e1b0c9876f"} 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/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/generate.py b/drafts/fixtures/cycles-evidence-v0.1/generate.py index 969ffca..d732430 100644 --- a/drafts/fixtures/cycles-evidence-v0.1/generate.py +++ b/drafts/fixtures/cycles-evidence-v0.1/generate.py @@ -130,7 +130,14 @@ def case_02_reserve_allow() -> dict: ) -def case_03_reserve_deny() -> dict: +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, @@ -143,6 +150,7 @@ def case_03_reserve_deny() -> dict: "action": {"kind": "model.call", "name": "gpt-4o"}, "estimate": {"unit": "USD_MICROCENTS", "amount": 50000000}, "ttl_ms": 30000, + "dry_run": True, }, "response": { "decision": "DENY", @@ -388,10 +396,45 @@ def case_08_reserve_allow_no_trace_id() -> dict: ) +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-deny.json", case_03_reserve_deny()), + ("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()), @@ -399,6 +442,7 @@ def case_08_reserve_allow_no_trace_id() -> dict: ("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()), ] @@ -407,6 +451,12 @@ def main() -> None: 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) diff --git a/drafts/fixtures/cycles-evidence-v0.1/verify.py b/drafts/fixtures/cycles-evidence-v0.1/verify.py index c7f0139..ec78178 100644 --- a/drafts/fixtures/cycles-evidence-v0.1/verify.py +++ b/drafts/fixtures/cycles-evidence-v0.1/verify.py @@ -39,7 +39,7 @@ SCHEMA_VERSION = "cycles-evidence/v0.1" -ARTIFACT_TYPES = ("decide", "reserve", "commit", "release") +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}$") From 708ee242e7689931cc8e54d5a4903d409db08b58 Mon Sep 17 00:00:00 2001 From: Albert Mavashev Date: Wed, 13 May 2026 06:27:10 -0400 Subject: [PATCH 4/9] =?UTF-8?q?fix(drafts):=20CyclesEvidence=20v0.1=20?= =?UTF-8?q?=E2=80=94=20address=20review=20round=202=20(endpoint-discrimina?= =?UTF-8?q?ted=20error.request,=20mirror=20tightening,=20CREDITS=20prose)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review findings on PR #90 round 2. All addressed, all empirically verified before commit. 1. MEDIUM — `error.request` oneOf rejected valid minimal requests. The four request mirrors share identical required-field sets (idempotency_key+subject+action+estimate for decide/reserve; idempotency_key for commit/release). A minimal `POST /v1/reservations` body therefore ambiguously matched both `DecisionRequestMirror` and `ReservationCreateRequestMirror` under JSON Schema `oneOf`, failing the "exactly one" rule. Empirically reproduced: stripping `ttl_ms` from fixture 11's request caused schema validation to fail under the previous `oneOf`. Fix: replace `oneOf` with an endpoint-discriminated `allOf` of `if`/ `then` at the `ErrorPayload` level. Each branch fires only when `endpoint` matches its `const`, routing `request` to the matching mirror. Validates as expected: - positive: minimal reserve request via error path → ACCEPTED - negative: commit-body shape under endpoint=POST /v1/reservations → REJECTED (the `endpoint`-mirror mismatch is caught by the correct branch) 2. MEDIUM — remaining mirror constraints still not fully copied. Reviewer caught five more divergences from cycles-protocol-v0.yaml that the round-1 mirror tightening missed: - `ttl_ms`: canonical is `minimum: 1000, maximum: 86400000` (cycles-protocol-v0.yaml:828-830). Mirror had `minimum: 0`, no max. - `grace_period_ms`: canonical is `minimum: 0, maximum: 60000` (cycles-protocol-v0.yaml:832-836). Mirror had no max. - `overage_policy`: canonical is `CommitOveragePolicy` enum `[REJECT, ALLOW_IF_AVAILABLE, ALLOW_WITH_OVERDRAFT]` (cycles-protocol-v0.yaml:765-794). Mirror had plain `type: string`. - `CommitResponseMirror.status`: canonical is enum `[COMMITTED]` (cycles-protocol-v0.yaml:1066-1068). Mirror had plain string. - `ReleaseResponseMirror.status`: canonical is enum `[RELEASED]` (cycles-protocol-v0.yaml:1099-1101). Mirror had plain string. - `ReleaseRequestMirror.reason`: canonical is `maxLength: 256` (cycles-protocol-v0.yaml:1091-1092). Mirror had no maxLength. All copied into the mirrors. Verified by negative tests at the schema layer: - ttl_ms=500 (below min) → REJECTED - ttl_ms=90000000 (above max) → REJECTED - overage_policy='NOT_A_REAL_POLICY' → REJECTED - commit response status='pending' → REJECTED 3. LOW — CREDITS unit-class prose in generator could mislead adapter authors. The previous comment on `case_10_reserve_credits_allow` said "some deployments treat them as consumption, others as authority". Even though that reflects an open position in aeoess/agent-passport-system #25 (comment 4421702699), it can read as a positive claim about APS unit_class mapping when the fixture is only exercising Cycles wire semantics. Rewritten to scope the comment strictly to the Cycles wire surface (CREDITS is a closed-enum Unit name preserved byte-for-byte) and defer the APS unit_class question explicitly to issue #25 and crosswalk/cycles.yaml (aeoess/agent-governance-vocabulary#92). Byte stability: no fixture canonical bytes change in this commit (only the spec and one generator comment edited). `evidence_id` and `signature` on all 11 fixtures are byte-identical to the prior commit. Validation summary: - python verify.py: 11/11 verified - JSON Schema validate all 11 fixtures against the tightened spec: 11/11 valid - F1 positive: minimal reserve request via error path now validates - F1 negative: commit-body under reserve endpoint correctly rejected - F2 negatives: out-of-range ttl_ms (both directions), bad overage_policy enum, and bad commit status all correctly rejected - Prior regression: non-dry reserve with decision: DENY still rejected by the if/then on ReservePayload - npx spectral lint drafts/cycles-evidence-v0.1.yaml --fail-severity=error: 0 errors, 3 expected schema-only warnings (unchanged set from prior commits) --- drafts/cycles-evidence-v0.1.yaml | 68 ++++++++++++++----- .../fixtures/cycles-evidence-v0.1/generate.py | 23 ++++--- 2 files changed, 67 insertions(+), 24 deletions(-) diff --git a/drafts/cycles-evidence-v0.1.yaml b/drafts/cycles-evidence-v0.1.yaml index 52595de..39e2d17 100644 --- a/drafts/cycles-evidence-v0.1.yaml +++ b/drafts/cycles-evidence-v0.1.yaml @@ -523,6 +523,16 @@ components: full audit trail. `reservation_id` is OPTIONAL and only meaningful when `endpoint` includes the path argument (commit / release). + + 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 @@ -551,19 +561,45 @@ components: 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, matching the - request-mirror schema of `endpoint`. OPTIONAL; one of - DecisionRequestMirror, ReservationCreateRequestMirror, - CommitRequestMirror, or ReleaseRequestMirror, as - determined by `endpoint`. - oneOf: - - $ref: "#/components/schemas/DecisionRequestMirror" - - $ref: "#/components/schemas/ReservationCreateRequestMirror" - - $ref: "#/components/schemas/CommitRequestMirror" - - $ref: "#/components/schemas/ReleaseRequestMirror" + 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: + - if: + properties: + endpoint: { const: "POST /v1/decisions" } + 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" } # ===================================================================== # Schema mirrors of the Cycles runtime types. @@ -624,9 +660,9 @@ components: subject: { $ref: "#/components/schemas/Subject" } action: { $ref: "#/components/schemas/Action" } estimate: { $ref: "#/components/schemas/Amount" } - ttl_ms: { type: integer, format: int64, minimum: 0 } - grace_period_ms: { type: integer, format: int64, minimum: 0 } - overage_policy: { type: string } + 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 } @@ -666,7 +702,7 @@ components: required: [status, charged] additionalProperties: false properties: - status: { type: string } + status: { type: string, enum: [COMMITTED] } charged: { $ref: "#/components/schemas/Amount" } released: { $ref: "#/components/schemas/Amount" } balances: @@ -680,7 +716,7 @@ components: additionalProperties: false properties: idempotency_key: { type: string, minLength: 1, maxLength: 256 } - reason: { type: string } + reason: { type: string, maxLength: 256 } ReleaseResponseMirror: type: object @@ -688,7 +724,7 @@ components: required: [status, released] additionalProperties: false properties: - status: { type: string } + status: { type: string, enum: [RELEASED] } released: { $ref: "#/components/schemas/Amount" } balances: type: array diff --git a/drafts/fixtures/cycles-evidence-v0.1/generate.py b/drafts/fixtures/cycles-evidence-v0.1/generate.py index d732430..df727fe 100644 --- a/drafts/fixtures/cycles-evidence-v0.1/generate.py +++ b/drafts/fixtures/cycles-evidence-v0.1/generate.py @@ -326,14 +326,21 @@ def case_09_decide_risk_points_allow() -> dict: def case_10_reserve_credits_allow() -> dict: - # CREDITS is the implementation-defined unit per cycles-protocol-v0 - # ("generic integer units (optional in v0 implementations)") — some - # deployments treat them as consumption, others as authority. This - # fixture exercises a reserve with CREDITS as the closed-enum unit; - # the receipt-level rule that consumers MUST populate unit_class - # explicitly when unit=CREDITS is an APS receipt concern, not a - # CyclesEvidence concern (the envelope carries the unit name as it - # appears on the wire). + # 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, From 3d8b3c84394242767f0c2568fe4a8a70c73d8044 Mon Sep 17 00:00:00 2001 From: Albert Mavashev Date: Wed, 13 May 2026 06:34:00 -0400 Subject: [PATCH 5/9] =?UTF-8?q?fix(drafts):=20CyclesEvidence=20v0.1=20?= =?UTF-8?q?=E2=80=94=20address=20review=20round=203=20(artifact=5Ftype=20d?= =?UTF-8?q?iscriminator,=20commit/release=20reservation=5Fid=20requirement?= =?UTF-8?q?,=20stale=20prose)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review findings on PR #90 round 3. All addressed, all empirically verified before commit. Same rigor protocol as round 2 (negative tests for every new constraint). 1. MEDIUM — schema-only validators accepted artifact_type / payload key mismatches. The previous `EvidencePayload.oneOf` enforced that `payload` had exactly one of {decide, reserve, commit, release, error}, but did NOT tie that key to `artifact_type`. So an envelope with `artifact_type: reserve` and `payload.error` validated at the schema layer. The custom `verify.py` catches this, but any consumer using JSON Schema alone (an APS verifier, an offline auditor, a generic gateway) would silently accept the impossible envelope. Empirically reproduced: fixture 02 with payload swapped to fixture 11's `payload.error` → previous schema accepted it. Fix: top-level `allOf` of five `if`/`then` rules on `CyclesEvidence`, one per artifact_type, requiring `payload` to contain the matching key. The pairing now lives in the schema, not just in the custom verifier. Verified via cross-product: all 5 × 5 = 25 (artifact_type X, payload Y) combinations checked; 5 matching pairs accepted, 20 mismatch pairs rejected. 2. MEDIUM — `ErrorPayload.reservation_id` was optional even for commit/release endpoints, breaking authorization chain linkage. The `reservation_id` is the URL path argument of `POST /v1/reservations/{reservation_id}/{commit,release}`. For successful evidence (`CommitPayload`, `ReleasePayload`), `reservation_id` is REQUIRED — that's the whole point of the hoist discussed in the #92 round-4 review. But `ErrorPayload` left it optional unconditionally, so a 4xx/5xx error on those same paths could ship without the linkage. Empirically reproduced: error envelope with `endpoint: POST /v1/reservations/{reservation_id}/commit` and a valid `CommitRequestMirror`-shaped body but no `reservation_id` → previous schema accepted it. Fix: add a 5th branch to `ErrorPayload.allOf` requiring `reservation_id` when `endpoint` is the commit or release path. Endpoints with no path-arg reservation_id (`POST /v1/decisions`, `POST /v1/reservations`) are unaffected. Verified: commit/release endpoint without reservation_id → REJECTED; commit endpoint WITH reservation_id → VALID; non-commit/release endpoints without reservation_id remain VALID. Prose: ErrorPayload description updated to call out the conditional "required when endpoint is commit/release" rule, citing the same chain-completeness rationale as CommitPayload/ReleasePayload. 3. LOW — stale prose said "four properties" / "other three" but `error` is now a 5th branch. `EvidencePayload.description` updated to "five properties" / "other four", with a new sentence noting that 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 the custom verifier). Byte stability: no fixture canonical bytes change in this commit (only spec yaml edited; generator untouched). All 11 evidence_ids and signatures are byte-identical to the prior commit. Validation summary: - python verify.py: 11/11 verified - JSON Schema validate all 11 fixtures against the tightened spec: 11/11 valid - F1 cross-product matrix: 20/20 artifact_type/payload mismatches rejected (5 matching pairs remain valid) - F2 negatives: commit-endpoint and release-endpoint ErrorPayload without reservation_id both REJECTED - F2 positive: commit-endpoint ErrorPayload WITH reservation_id VALID - All prior regression negatives still trip (non-dry DENY, commit-body under reserve endpoint, ttl_ms below canonical min) - npx spectral lint drafts/cycles-evidence-v0.1.yaml --fail-severity=error: 0 errors, 3 expected warnings (unchanged set) --- drafts/cycles-evidence-v0.1.yaml | 96 ++++++++++++++++++++++++++++++-- 1 file changed, 91 insertions(+), 5 deletions(-) diff --git a/drafts/cycles-evidence-v0.1.yaml b/drafts/cycles-evidence-v0.1.yaml index 39e2d17..0a63f2d 100644 --- a/drafts/cycles-evidence-v0.1.yaml +++ b/drafts/cycles-evidence-v0.1.yaml @@ -264,6 +264,57 @@ components: 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 @@ -343,9 +394,13 @@ components: EvidencePayload: type: object description: | - Per-artifact payload. Exactly one of the four properties below + Per-artifact payload. Exactly one of the five properties below MUST be present, matching the envelope's `artifact_type`. The - other three MUST be absent (not present-but-null). + 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 @@ -520,9 +575,21 @@ components: `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 OPTIONAL and only - meaningful when `endpoint` includes the path argument - (commit / release). + 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/decisions` 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 @@ -572,6 +639,12 @@ components: 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/decisions" } @@ -600,6 +673,19 @@ components: 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] # ===================================================================== # Schema mirrors of the Cycles runtime types. From dba1fe6dd585510c48c5dad6e837486cd2bdd81b Mon Sep 17 00:00:00 2001 From: Albert Mavashev Date: Wed, 13 May 2026 06:41:33 -0400 Subject: [PATCH 6/9] =?UTF-8?q?fix(drafts):=20CyclesEvidence=20v0.1=20?= =?UTF-8?q?=E2=80=94=20address=20review=20round=204=20+=20proactive=20swee?= =?UTF-8?q?p=20of=20"MUST=20be=20absent=20/=20Present=20only=20when"=20rul?= =?UTF-8?q?es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One reviewer finding + six proactive sweep findings on the same mirror-drift class. Same rigor protocol as rounds 2-3: empirically reproduce, fix, paired negative tests for every constraint. The reviewer flagged that the round-3 fix only enforced the "MUST be present" side of the commit/release reservation_id rule, not the "MUST be absent" side for non-path endpoints. While verifying that finding I grepped the canonical for "MUST be absent" and found six more constraints in the same class (caps-vs-decision and dry_run-vs-reservation_id rules from canonical L752 / L981 / L997 / L1404), all of which the mirrors were silently dropping. All seven rejected invalid envelopes empirically before this commit; all seven now reject them. REVIEWER-FLAGGED (1) A. ErrorPayload.reservation_id MUST be absent when endpoint is `POST /v1/decisions` or `POST /v1/reservations` (those endpoints take no reservation_id path argument). Previous schema enforced the commit/release REQUIRED side but not this ABSENT side, so a stray reservation_id on a decisions/reservations error validated and would mislead audit consumers. PROACTIVE SWEEP (6) Six more "MUST be absent" / "Present only when" rules from cycles-protocol-v0.yaml that the mirrors weren't enforcing. Same class of mirror drift the reviewer caught in rounds 1, 2, and 4 — doing the broader pass here breaks the find-more-each-round pattern. B. DecisionResponseMirror: `caps` MUST be present when decision=ALLOW_WITH_CAPS, MUST be absent otherwise (canonical L752). Encoded as mirror-level if/then/else. C. ReservationCreateResponseMirror: same caps rule (canonical L997). Same mirror-level if/then/else. D. ReservePayload: dry_run=true → reservation_id MUST be absent on response (canonical L981, L1404). Added to ReservePayload allOf (rule lives there because it spans request and response). E. ReservePayload: dry_run=true → expires_at_ms MUST be absent on response (canonical L1404). Combined with D into a single conditional branch. F. ReservePayload: dry_run NOT true (false or absent) → reservation_id MUST be present on response (canonical L981). The inverse of D. Combined with the round-1 rule forbidding non-dry DENY, this means decision is guaranteed ALLOW or ALLOW_WITH_CAPS in the non-dry branch — both of which require reservation_id per canonical. G. ReservePayload: same expires_at_ms required side. Combined with F into a single conditional branch. Validation summary: - 11/11 fixtures still valid (positive) - 10 paired negative tests covering A through G, all REJECT: * reservations endpoint + stray reservation_id * decisions endpoint + stray reservation_id * decide ALLOW + spurious caps * decide ALLOW_WITH_CAPS + missing caps * reserve ALLOW + spurious caps * reserve ALLOW_WITH_CAPS + missing caps * dry_run=true + reservation_id present * dry_run=true + expires_at_ms present * non-dry ALLOW + missing reservation_id * non-dry ALLOW + missing expires_at_ms - All prior-round regressions still trip: * artifact_type / payload mismatch (round 3) * commit endpoint + no reservation_id (round 3) * non-dry DENY (round 1) * ttl_ms out-of-range (round 2) - npx spectral lint drafts/cycles-evidence-v0.1.yaml --fail-severity=error: 0 errors, 3 expected schema-only warnings (unchanged set) Byte stability: no fixture canonical bytes change in this commit (only spec yaml edited; generator untouched). All 11 evidence_ids and signatures are byte-identical to `3d8b3c8`. --- drafts/cycles-evidence-v0.1.yaml | 132 ++++++++++++++++++++++++++----- 1 file changed, 113 insertions(+), 19 deletions(-) diff --git a/drafts/cycles-evidence-v0.1.yaml b/drafts/cycles-evidence-v0.1.yaml index 0a63f2d..7ec6431 100644 --- a/drafts/cycles-evidence-v0.1.yaml +++ b/drafts/cycles-evidence-v0.1.yaml @@ -473,25 +473,72 @@ components: 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 if/then constraint below encodes this rule - schema-side so JSON Schema validators reject the bad shape - without needing custom logic. - if: - properties: - response: + 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` + and `expires_at_ms` MUST be PRESENT on the response. + Combined with rule 1, this means decision is + guaranteed ALLOW or ALLOW_WITH_CAPS in this branch, + both of which require the IDs per canonical L981. + allOf: + # Existing: non-dry reserve with decision: DENY is impossible (must be 409 error). + - if: properties: - decision: - const: DENY - required: [decision] - required: [response] - then: - properties: - request: + 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: - dry_run: - const: true - required: [dry_run] - required: [request] + response: + allOf: + - not: { required: [reservation_id] } + - not: { required: [expires_at_ms] } + required: [response] + # dry_run NOT true (false or absent): reservation_id and + # expires_at_ms 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 IDs per canonical L981. + - if: + properties: + request: + not: + allOf: + - properties: + dry_run: { const: true } + - required: [dry_run] + required: [request] + then: + properties: + response: + required: [reservation_id, expires_at_ms] + required: [response] properties: request: $ref: "#/components/schemas/ReservationCreateRequestMirror" @@ -686,6 +733,21 @@ components: required: [endpoint] then: required: [reservation_id] + # reservation_id MUST be absent for endpoints that take no + # reservation_id path argument (decisions, 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/decisions" + - "POST /v1/reservations" + required: [endpoint] + then: + not: + required: [reservation_id] # ===================================================================== # Schema mirrors of the Cycles runtime types. @@ -726,7 +788,11 @@ components: DecisionResponseMirror: type: object - description: Mirror of `cycles-protocol-v0.yaml#/components/schemas/DecisionResponse` — the body of `POST /v1/decisions`. + description: | + Mirror of `cycles-protocol-v0.yaml#/components/schemas/DecisionResponse` + — the body of `POST /v1/decisions`. 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: @@ -735,6 +801,16 @@ components: 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 @@ -754,7 +830,15 @@ components: ReservationCreateResponseMirror: type: object - description: Mirror of `cycles-protocol-v0.yaml#/components/schemas/ReservationCreateResponse` — the body of `POST /v1/reservations`. + 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: @@ -770,6 +854,16 @@ components: 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 From b5ecb44e941d9018b2cf1031d4848b1f95a1cfe7 Mon Sep 17 00:00:00 2001 From: Albert Mavashev Date: Wed, 13 May 2026 06:54:46 -0400 Subject: [PATCH 7/9] =?UTF-8?q?fix(drafts):=20CyclesEvidence=20v0.1=20?= =?UTF-8?q?=E2=80=94=20address=20review=20round=205=20(endpoint=20name=20t?= =?UTF-8?q?ypo,=20ErrorCode=20enum=20closure,=20expires=5Fat=5Fms=20over-t?= =?UTF-8?q?ightening)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three findings, all empirically verified before fix. 1. HIGH — endpoint name typo: /v1/decisions → /v1/decide. The canonical Cycles spec defines the decide endpoint at `/v1/decide` (cycles-protocol-v0.yaml:1342). The draft used `/v1/decisions` everywhere — in the ArtifactType→endpoint mapping prose, the ErrorPayload endpoint enum, the four endpoint-discriminated allOf branches (request-mirror routing + reservation_id absent rule), and the DecisionRequestMirror / DecisionResponseMirror descriptions. 12 occurrences renamed. No fixture caught this earlier because no existing fixture exercised an error envelope on the decide endpoint; fixture 12 (added in this commit) does, closing the coverage gap. Verified: a synthetic POST /v1/decide error envelope now validates; the old POST /v1/decisions value is now rejected as off-enum. 2. MEDIUM — ErrorResponseMirror.error open string, contradicting the mirror contract. The MIRROR CONTRACT block at the top of the mirrors section is explicit: "Mirror schemas copy field names, types, required-lists, enum values, AND structural constraints." The previous ErrorResponseMirror.error was `type: string` with a description listing the 15 canonical ErrorCode values, plus loose prose suggesting consumers "treat unknown codes as terminal error states" — directly contradicting the contract. Tightened to the 15-value closed enum copied byte-for-byte from canonical L429-L446. Future ErrorCode additions in v0 minor versions trigger a re-cut of this mirror, which is the normal mirror-drift process documented in the MIRROR CONTRACT. Verified: error="FUTURE_CODE" rejected; error="invalid_request" (lowercase) rejected. 3. MEDIUM/clarify — Round-4 rule G over-tightened by requiring expires_at_ms on non-dry responses. Canonical L981 explicitly says reservation_id is "Present if decision is ALLOW or ALLOW_WITH_CAPS and dry_run is false" (a positive presence rule). For expires_at_ms, the canonical L1404 only requires its ABSENCE on dry_run; the canonical schema's required list never includes it, and there is no positive-presence prose for non-dry. The round-4 rule G required expires_at_ms on non-dry, which rejected envelopes the canonical schema accepts — over-tightening. Relaxed rule 3 on ReservePayload.allOf to require only reservation_id (not expires_at_ms) in the non-dry branch. The dry-run absent rule (which DOES cover expires_at_ms per L1404) is unchanged. Updated prose explains the asymmetry. Verified: - F3 POS: non-dry ALLOW without expires_at_ms now validates - F3 REG: non-dry ALLOW without reservation_id still rejected - F3 REG: dry_run=true + expires_at_ms present still rejected ADDED FIXTURE 12 — error on /v1/decide endpoint. The first 11 fixtures had no decide-error coverage, which is exactly why F1 went undetected for five review rounds. Fixture 12 emits a 403 FORBIDDEN on POST /v1/decide with a DecisionRequest body. This proves the corrected endpoint name is consistent with the endpoint-discriminated request validation end-to-end. Validation summary: - 12/12 fixtures verify - 12/12 fixtures validate against the tightened schema - F1: POST /v1/decide validates, POST /v1/decisions rejected - F2: unknown ErrorCode rejected (FUTURE_CODE and lowercase invalid_request both fail) - F3 positive: non-dry ALLOW without expires_at_ms validates - F3 regression: dry_run=true + expires_at_ms still rejected; non-dry ALLOW without reservation_id still rejected - Prior-round regressions still trip: stray reservation_id on decide/reservations endpoints; ALLOW_WITH_CAPS without caps; artifact_type/payload mismatch; non-dry DENY - npx spectral lint drafts/cycles-evidence-v0.1.yaml --fail-severity=error: 0 errors, 3 expected schema-only warnings (unchanged set) Byte stability: fixtures 1-11 are byte-identical to dba1fe6. Fixture 12 is new. Generator is deterministic and reproducible from the same test signer seed. --- drafts/cycles-evidence-v0.1.yaml | 89 ++++++++++++------- .../fixtures/cycles-evidence-v0.1/README.md | 10 ++- .../cases/12-decide-live-forbidden.json | 1 + .../fixtures/cycles-evidence-v0.1/generate.py | 31 +++++++ 4 files changed, 95 insertions(+), 36 deletions(-) create mode 100644 drafts/fixtures/cycles-evidence-v0.1/cases/12-decide-live-forbidden.json diff --git a/drafts/cycles-evidence-v0.1.yaml b/drafts/cycles-evidence-v0.1.yaml index 7ec6431..4d3eded 100644 --- a/drafts/cycles-evidence-v0.1.yaml +++ b/drafts/cycles-evidence-v0.1.yaml @@ -85,7 +85,7 @@ info: url: https://github.com/runcycles/cycles-protocol # This file is schema-only. It does not declare server endpoints -# because the canonical event surface (`/v1/decisions`, `/v1/reservations`, +# 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. @@ -365,7 +365,7 @@ components: endpoints, plus an `error` artifact type that wraps any 4xx/5xx ErrorResponse emitted by any of those endpoints: - - `decide` → POST /v1/decisions (stateless pre-check; 2xx) + - `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) @@ -435,12 +435,12 @@ components: properties: request: description: | - DecisionRequest as submitted to `POST /v1/decisions`. + 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/decisions`. + DecisionResponse as returned by `POST /v1/decide`. Schema mirror of `cycles-protocol-v0.yaml#/components/schemas/DecisionResponse`. $ref: "#/components/schemas/DecisionResponseMirror" @@ -484,10 +484,14 @@ components: 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` - and `expires_at_ms` MUST be PRESENT on the response. - Combined with rule 1, this means decision is - guaranteed ALLOW or ALLOW_WITH_CAPS in this branch, - both of which require the IDs per canonical L981. + 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: @@ -520,11 +524,18 @@ components: - not: { required: [reservation_id] } - not: { required: [expires_at_ms] } required: [response] - # dry_run NOT true (false or absent): reservation_id and - # expires_at_ms 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 IDs per canonical L981. + # 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: @@ -537,7 +548,7 @@ components: then: properties: response: - required: [reservation_id, expires_at_ms] + required: [reservation_id] required: [response] properties: request: @@ -612,7 +623,7 @@ components: UNIT_MISMATCH, etc.) surface the same way; the full ErrorCode enum is in the canonical spec. - For pre-execution decisions (`POST /v1/decisions`) and + 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 @@ -633,7 +644,7 @@ components: unreconstructable for evidence-only readers (the same rationale that drives `reservation_id` hoisting on `CommitPayload` and `ReleasePayload`). For - `endpoint: POST /v1/decisions` or `POST /v1/reservations`, + `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. @@ -651,7 +662,7 @@ components: endpoint: type: string enum: - - "POST /v1/decisions" + - "POST /v1/decide" - "POST /v1/reservations" - "POST /v1/reservations/{reservation_id}/commit" - "POST /v1/reservations/{reservation_id}/release" @@ -694,7 +705,7 @@ components: # — see schema description above. - if: properties: - endpoint: { const: "POST /v1/decisions" } + endpoint: { const: "POST /v1/decide" } required: [endpoint, request] then: properties: @@ -734,7 +745,7 @@ components: then: required: [reservation_id] # reservation_id MUST be absent for endpoints that take no - # reservation_id path argument (decisions, reservations). A + # 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. @@ -742,7 +753,7 @@ components: properties: endpoint: enum: - - "POST /v1/decisions" + - "POST /v1/decide" - "POST /v1/reservations" required: [endpoint] then: @@ -776,7 +787,7 @@ components: DecisionRequestMirror: type: object - description: Mirror of `cycles-protocol-v0.yaml#/components/schemas/DecisionRequest` — the body of `POST /v1/decisions`. + 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: @@ -790,7 +801,7 @@ components: type: object description: | Mirror of `cycles-protocol-v0.yaml#/components/schemas/DecisionResponse` - — the body of `POST /v1/decisions`. The `allOf` below + — 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] @@ -921,17 +932,31 @@ components: 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. The canonical `cycles-protocol-v0.yaml` - ErrorCode enum is closed (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). Future v0 minor versions MAY add codes; - evidence consumers SHOULD treat unknown codes as terminal - error states rather than rejecting the envelope. + 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: diff --git a/drafts/fixtures/cycles-evidence-v0.1/README.md b/drafts/fixtures/cycles-evidence-v0.1/README.md index b3fddcd..4b94d00 100644 --- a/drafts/fixtures/cycles-evidence-v0.1/README.md +++ b/drafts/fixtures/cycles-evidence-v0.1/README.md @@ -1,11 +1,11 @@ # CyclesEvidence v0.1 — reference fixtures -Eleven signed, content-addressed `CyclesEvidence` envelopes covering +Twelve 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), 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. +#25 integration and a 403 error on `POST /v1/decide`), 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`: @@ -32,6 +32,7 @@ cases/ 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 ``` ## What each fixture proves @@ -49,6 +50,7 @@ cases/ | 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). | ## Reproducing the fixtures 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/generate.py b/drafts/fixtures/cycles-evidence-v0.1/generate.py index df727fe..d58fd5c 100644 --- a/drafts/fixtures/cycles-evidence-v0.1/generate.py +++ b/drafts/fixtures/cycles-evidence-v0.1/generate.py @@ -403,6 +403,36 @@ def case_08_reserve_allow_no_trace_id() -> dict: ) +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 @@ -450,6 +480,7 @@ def case_11_reserve_live_budget_exceeded() -> dict: ("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()), ] From 66ef93672806266df3adaa88473d2c29103a9e92 Mon Sep 17 00:00:00 2001 From: Albert Mavashev Date: Wed, 13 May 2026 07:02:45 -0400 Subject: [PATCH 8/9] =?UTF-8?q?fix(drafts):=20CyclesEvidence=20v0.1=20?= =?UTF-8?q?=E2=80=94=20mirror=20StandardMetrics=20on=20CommitRequest.metri?= =?UTF-8?q?cs=20(round=206)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reviewer caught one finding: `CommitRequestMirror.metrics` was an arbitrary object (`type: object, additionalProperties: true`) but the canonical `CommitRequest.metrics` references the constrained `StandardMetrics` schema (cycles-protocol-v0.yaml L1055-L1056 → L1008-L1034 with additionalProperties: false and 5 specific fields). Empirically reproduced: `metrics: { "bogus_metric": -1 }` validated under the previous schema while the canonical rejects it. Per the rigor protocol, also ran a sweep for sibling `additionalProperties: true` slots on the off-chance there were more. Five total in mirrors — `DecisionRequestMirror.metadata`, `ReservationCreateRequestMirror.metadata`, `CommitRequestMirror.metrics`, `CommitRequestMirror.metadata`, and `ErrorResponseMirror.details` — but only `metrics` is divergent from canonical. The four `metadata`/`details` slots are genuinely `additionalProperties: true` in the canonical (lines 480, 729, 860, 1057). No additional drift found. Fix: - Added `StandardMetrics` schema under "Common nested types" section. Copies the canonical exactly: 5 fields (tokens_input/tokens_output/latency_ms with `minimum: 0`, model_version with `maxLength: 128`, custom as the `additionalProperties: true` escape hatch) plus the top-level `additionalProperties: false`. - Updated `CommitRequestMirror.metrics` from `{type: object, additionalProperties: true}` to `$ref: "#/components/schemas/StandardMetrics"`. Coverage gap closed with fixture 13: - The reason no prior fixture exercised this constraint is that fixture 05 (the only commit fixture before this round) didn't populate `metrics`. Same coverage gap pattern as fixture 12 (round 5, `/v1/decide` typo). - Fixture 13 (`13-commit-with-metrics.json`) is a commit envelope with all five StandardMetrics fields populated, including the `custom` escape hatch carrying deployment-specific keys (`cache_hit_ratio`, `retry_count`). Validation: - 13/13 fixtures verify (python verify.py) - 13/13 fixtures validate against the tightened schema - F NEG: bogus top-level metric key `{ "bogus_metric": -1 }` → rejected (reviewer's exact case) - F NEG: `tokens_input: -1` → rejected - F NEG: `model_version` 129 chars → rejected - F NEG: `latency_ms: -100` → rejected - F POS: `custom` escape hatch carrying arbitrary nested content → valid (confirms the canonical escape hatch works) - Prior-round regressions still trip: error=FUTURE_CODE, /v1/decisions stale endpoint, non-dry DENY, ALLOW_WITH_CAPS without caps - npx spectral lint: 0 errors, 3 expected schema-only warnings (unchanged set) Byte stability: fixtures 1-12 byte-identical to b5ecb44. Fixture 13 is new. --- drafts/cycles-evidence-v0.1.yaml | 34 +++++++++++++- .../fixtures/cycles-evidence-v0.1/README.md | 11 +++-- .../cases/13-commit-with-metrics.json | 1 + .../fixtures/cycles-evidence-v0.1/generate.py | 47 +++++++++++++++++++ 4 files changed, 88 insertions(+), 5 deletions(-) create mode 100644 drafts/fixtures/cycles-evidence-v0.1/cases/13-commit-with-metrics.json diff --git a/drafts/cycles-evidence-v0.1.yaml b/drafts/cycles-evidence-v0.1.yaml index 4d3eded..371dee0 100644 --- a/drafts/cycles-evidence-v0.1.yaml +++ b/drafts/cycles-evidence-v0.1.yaml @@ -884,7 +884,7 @@ components: properties: idempotency_key: { type: string, minLength: 1, maxLength: 256 } actual: { $ref: "#/components/schemas/Amount" } - metrics: { type: object, additionalProperties: true } + metrics: { $ref: "#/components/schemas/StandardMetrics" } metadata: { type: object, additionalProperties: true } CommitResponseMirror: @@ -1101,3 +1101,35 @@ components: 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 index 4b94d00..9145976 100644 --- a/drafts/fixtures/cycles-evidence-v0.1/README.md +++ b/drafts/fixtures/cycles-evidence-v0.1/README.md @@ -1,11 +1,12 @@ # CyclesEvidence v0.1 — reference fixtures -Twelve signed, content-addressed `CyclesEvidence` envelopes covering +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 and a 403 error on `POST /v1/decide`), 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. +#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`: @@ -33,6 +34,7 @@ cases/ 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 closed-enum StandardMetrics mirror added in round 6 ``` ## What each fixture proves @@ -51,6 +53,7 @@ cases/ | 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 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 index d58fd5c..f794cda 100644 --- a/drafts/fixtures/cycles-evidence-v0.1/generate.py +++ b/drafts/fixtures/cycles-evidence-v0.1/generate.py @@ -403,6 +403,52 @@ def case_08_reserve_allow_no_trace_id() -> dict: ) +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 @@ -481,6 +527,7 @@ def case_11_reserve_live_budget_exceeded() -> dict: ("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()), ] From f735de89afd916a913838e238e6304b26f05dd94 Mon Sep 17 00:00:00 2001 From: Albert Mavashev Date: Wed, 13 May 2026 07:13:37 -0400 Subject: [PATCH 9/9] docs(drafts): fix incorrect "closed-enum" wording for StandardMetrics fixture (round 7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README line called StandardMetrics a "closed-enum mirror". It is a constrained object schema with additionalProperties: false and 5 typed fields plus a `custom` escape hatch — not an enum. Reviewer flagged the documentation inconsistency. Updated to "constrained StandardMetrics mirror (object schema with `additionalProperties: false` and per-field constraints; not an enum)" — eliminates the imprecise wording and makes the actual shape explicit for adapter authors reading the fixture coverage table. Swept for sibling occurrences of "closed-enum": only one other use in `generate.py:332` describes the canonical UnitEnum (USD_MICROCENTS, TOKENS, CREDITS, RISK_POINTS) — that IS a closed enum, so the wording is correct there. Documentation-only. No schema, generator, fixture-byte, or verification logic changed. All 13 fixtures byte-identical to 66ef936. --- drafts/fixtures/cycles-evidence-v0.1/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/drafts/fixtures/cycles-evidence-v0.1/README.md b/drafts/fixtures/cycles-evidence-v0.1/README.md index 9145976..7d0ef92 100644 --- a/drafts/fixtures/cycles-evidence-v0.1/README.md +++ b/drafts/fixtures/cycles-evidence-v0.1/README.md @@ -34,7 +34,7 @@ cases/ 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 closed-enum StandardMetrics mirror added in round 6 + 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