Skip to content

feat(drafts): CyclesEvidence envelope v0.1 — signed, content-addressed lifecycle evidence#90

Merged
amavashev merged 10 commits into
mainfrom
feat/cycles-evidence-v0.1-draft
May 13, 2026
Merged

feat(drafts): CyclesEvidence envelope v0.1 — signed, content-addressed lifecycle evidence#90
amavashev merged 10 commits into
mainfrom
feat/cycles-evidence-v0.1-draft

Conversation

@amavashev
Copy link
Copy Markdown
Contributor

Summary

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 RFC 8785 JCS 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 — so a verifier that already knows how to verify an APS PaymentReceipt can verify a CyclesEvidence envelope with the same primitives, only the canonical-bytes input differs).
  • One envelope per lifecycle event; one-of payload discriminated by artifact_type.

Why

The Cycles runtime API responses (DecisionResponse, ReservationCreateResponse, CommitResponse, ReleaseResponse) are sufficient for the immediate caller with a live connection to the Cycles server. They are not sufficient for cross-system, ledger-independent audit consumers (notably APS, https://github.com/aeoess/agent-passport-system) who need a single artifact they can fetch by content hash and verify offline.

This draft also closes two concrete gaps the Cycles signal-type crosswalk at aeoess/agent-governance-vocabulary#92 surfaced during review:

  1. reservation_id linkage. On the wire, commit_reservation and release_reservation take reservation_id as a URL path argument; the response bodies do not echo it. An evidence consumer reading just the JSON cannot recover the authorization → settlement chain. The CyclesEvidence envelope hoists reservation_id into the signed payload for commit and release artifacts, so the linkage is visible from evidence alone.
  2. replay_class promotion path. The crosswalk currently declares replay_class: decision_replay because Cycles' wire JSON requires the server's ledger state for verification. Once this envelope is normative and emitted by reference implementations, replay_class is promotable back to full_replay — replay becomes possible from recorded evidence alone.

Status

DRAFT (v0.1). Lives under drafts/ for review and external feedback. Will move to a numbered spec file at repo root (cycles-evidence-v0.2.yaml) once:

  • At least one production implementation ships the envelope.
  • At least one cross-system consumer (most likely APS) has integrated against the envelope shape end-to-end.

What's in scope (v0.1)

  • Envelope schema: schema_version, artifact_type, server_id, signer_did, issued_at_ms, payload, evidence_id, signature.
  • Per-artifact payload shapes for decide / reserve / commit / release, each pairing the Cycles request and response so evidence is self-contained.
  • Hash derivation: sha256 over JCS-canonical bytes with evidence_id and signature set to "".
  • Signature derivation: Ed25519 over JCS-canonical bytes with evidence_id populated and signature set to "".
  • Verification contract for consumers.

What's out of scope (v0.1, deliberately deferred)

  • Evidence retrieval HTTP API — URL path layout, auth scheme, replication policy stays with the server implementation. v0.1 documents expectations on the response body, not the path.
  • Signing-key rotation, JWKS publication, did:cycles method registration. These land alongside the normative move.
  • Merkle-batched aggregated evidence (one envelope per event in v0.1). A v0.2+ concern.
  • TTL-expiry artifacts (silent on the wire today, so no envelope; a future revision may add system.expire emitted by the server's expiry sweep).

Implementation notes

  • Schema mirrors of cycles-protocol-v0.yaml types (DecisionRequest, ReservationCreateRequest, etc.) are inlined under *Mirror schemas so this file validates standalone. When promoted to normative, these should be replaced with cross-file $refs into cycles-protocol-v0.yaml for single-source-of-truth.
  • Field lists in the mirrors were verified by Pydantic introspection of the runcycles Python SDK on 2026-05-12 (model_fields[*].is_required()), the same verification trail used to fix four rounds of review feedback on feat: crosswalk/cycles.yaml v0.1 — signal-type rows for Cycles budget authority aeoess/agent-governance-vocabulary#92.
  • Unrelated draft drafts/aeoess-crosswalk.yaml (Cycles ↔ APS receipt vocabulary mapping) is in-flight on a separate working copy and intentionally not part of this commit. The two drafts are orthogonal: this PR specifies the envelope shape; that draft specifies how Cycles vocabulary maps to APS receipt fields. Both can land independently.

Validation

make lint-sources does not lint drafts/ (only the canonical source specs at repo root). Running spectral directly:

npx spectral lint drafts/cycles-evidence-v0.1.yaml --fail-severity=error

Result: 0 errors, 3 warnings. The three warnings are unavoidable for a schema-only draft:

  • oas3-api-servers — file declares no servers: because there are no endpoints (the envelope wraps responses from endpoints already defined in cycles-protocol-v0.yaml).
  • oas3-unused-component on CyclesEvidence — spectral can't trace usage without paths.
  • protocol-schemas-no-additional-properties on EvidencePayload — incompatible with the oneOf discriminator; each variant carries its own additionalProperties: false.

References

Test plan

@amavashev amavashev force-pushed the feat/cycles-evidence-v0.1-draft branch 3 times, most recently from 1d89620 to f622870 Compare May 12, 2026 12:20
…d lifecycle evidence

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)
@amavashev amavashev force-pushed the feat/cycles-evidence-v0.1-draft branch from f622870 to cf31d6b Compare May 12, 2026 12:43
…fier

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.
@amavashev
Copy link
Copy Markdown
Contributor Author

Pushed fb43077 — closes the test-plan checkbox:

"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."

What landed (under drafts/fixtures/cycles-evidence-v0.1/):

  • 10 signed CyclesEvidence envelopes covering the full v0.1 surface:
    • 4 artifact_types: decide / reserve / commit / release
    • 3 decision branches: ALLOW / ALLOW_WITH_CAPS / DENY (incl. DENY-branch with no reservation_id)
    • All 4 Unit enum values: USD_MICROCENTS / TOKENS / RISK_POINTS / CREDITS
    • Optional-field round-trips: Action.tags, Balance.allocated, ReleaseRequest.reason
    • trace_id omission semantics (field absent, not "" and not null — the omit/null/empty distinction from the spec's normative note)
  • generate.py — deterministic generator. Re-running produces byte-identical output. Test signer derived from sha256("cycles-evidence-v0.1-fixture-signer") so reviewers can re-derive the keypair locally (pubkey: ec52b49b81eb29ef6f62947cade245c715bf943b7ef2a5f2789288574466fc43). Implements the v0.1 normative empty-string-sentinel + id-then-signature algorithm.
  • verify.py — standalone verifier. Enforces every spec MUST: schema_version recognized, evidence_id sha256 byte-match, Ed25519 signature, artifact_type ↔ payload-key consistency (oneOf), 32-hex trace_id pattern when present.

Reproduce locally:

cd drafts/fixtures/cycles-evidence-v0.1
pip install -r requirements.txt
python generate.py   # writes cases/*.json (byte-stable across runs)
python verify.py     # 10/10 fixtures verify; exit 0

Tamper sanity (covered in the README): flipping one character in any signed field trips both the evidence_id mismatch and signature verification failed checks; python generate.py restores from canonical sources.

One closure on a review comment: while auditing the spec against the generator I noticed the schema_version MUST clause ("consumers MUST reject envelopes whose schema_version they do not understand") didn't have a corresponding check in my first verifier draft — fixed before commit. The verifier now rejects unknown versions before further validation, with 01-decide-allow.json flipped to cycles-evidence/v0.2 as the negative test.

replay_class promotion path on aeoess/agent-governance-vocabulary#92 is unblocked from the spec side once #90 merges — the envelope + reference fixtures together provide what an offline verifier needs.

…, namespace, mirror constraints)

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.
@amavashev
Copy link
Copy Markdown
Contributor Author

Review pass on fb43077 surfaced three real bugs. All addressed in 17271c4.

Findings & resolutions

1 — HIGH: live reserve denials had no valid evidence slot. cycles-protocol-v0.yaml:978 is explicit that non-dry reserve denials MUST be 409 BUDGET_EXCEEDED, NOT 200 with decision: DENY. The previous draft + fixture 03 emitted the forbidden shape, and the most important denial path on the issue #25 thread had no evidence shape at all.

  • Added error as a 5th ArtifactType with ErrorPayload + ErrorResponseMirror schemas. ErrorPayload wraps a 4xx/5xx body from any of the four endpoints, hoists reservation_id when path-bearing, and preserves the originating request body via a discriminated oneOf over the four request mirrors.
  • Encoded the dry-run-required-for-DENY constraint as a JSON Schema if/then on ReservePayload — validators reject non-dry DENY at the schema layer without custom logic. Confirmed: 11/11 fixtures pass; a constructed non-dry-DENY envelope fails validation.
  • Renamed case_03_reserve_denycase_03_reserve_dry_run_deny with explicit dry_run: true. Added case_11_reserve_live_budget_exceeded emitting a 409 via the new error artifact type — the canonical wire shape APS receipts can now bind evidence to.

2 — MEDIUM: stale APS literal namespace. ReleasePayload description still cited rail.budget_authority.release.v1 with an outdated "open question" framing. aeoess/agent-passport-system#25 comments 4433715146 and 4433275511 renamed the namespace to budget_reservation and locked in three literals: rail.budget_reservation.{permit,release,denial}.v1. Description rewritten.

3 — MEDIUM: Mirror schemas dropped canonical constraints. Subject lost minProperties: 1 + the six-way anyOf + maxLength constraints; idempotency_key lost minLength: 1 / maxLength: 256; reason_code lost maxLength: 128; integer fields lost minimum: 0 / format: int64; Action lost its three maxLengths and tag maxItems; Caps lost tool-name maxLengths. All copied byte-for-byte against cycles-protocol-v0.yaml as of 2026-05-13. Added a MIRROR CONTRACT block at the head of the mirrors section making drift an explicit v0.1 bug.

Validation

cd drafts/fixtures/cycles-evidence-v0.1
python generate.py   # 11 fixtures + stale 03-reserve-deny.json auto-removed
python verify.py     # 11/11 verified, exit 0
  • JSON Schema validate every fixture against the updated CyclesEvidence schema: 11/11 valid.
  • Negative test: strip dry_run: true from fixture 03 in-memory (= non-dry reserve with decision: DENY) → schema rejects via the ReservePayload if/then rule.
  • npx spectral lint drafts/cycles-evidence-v0.1.yaml --fail-severity=error: 0 errors, 3 expected warnings (oas3-api-servers, oas3-unused-component, oneOf additionalProperties — same set as before this review pass).

Fixture coverage now

# Branch
01 decide ALLOW
02 reserve ALLOW
03 reserve dry-run DENY (the only legal decision: DENY shape on reserve)
04 reserve ALLOW_WITH_CAPS
05 commit (partial actual)
06 release
07 release with reason
08 reserve ALLOW, optional trace_id omitted
09 decide with RISK_POINTS unit + Action.tags
10 reserve with CREDITS unit + Balance.allocated
11 error: live 409 BUDGET_EXCEEDED from POST /v1/reservations — the issue #25 live-denial path

Thanks for the thorough review — finding 1 in particular would have shipped a wire shape the canonical protocol forbids if it had merged as-is.

amavashev added 2 commits May 13, 2026 06:24
…iscriminated error.request, mirror tightening, CREDITS prose)

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)
@amavashev
Copy link
Copy Markdown
Contributor Author

Review round 2 — all three findings confirmed against canonical sources and addressed in 708ee24.

1 — MEDIUM: error.request oneOf rejected valid minimal requests

Empirically reproduced: stripping ttl_ms from fixture 11's request body caused JSON Schema validation to fail under the previous oneOf because the minimal (idempotency_key, subject, action, estimate) shape ambiguously matches both DecisionRequestMirror and ReservationCreateRequestMirror.

Fix: replaced oneOf on ErrorPayload.request 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. Each if clause also requires request so that an absent request (it's optional) doesn't trigger a then branch.

Verified:

  • positive: minimal reserve request via error path is now accepted
  • negative: commit-body shape under endpoint: POST /v1/reservations is rejected at the correct allOf branch

2 — MEDIUM: remaining mirror constraints still divergent

Five more canonical constraints copied byte-for-byte against cycles-protocol-v0.yaml:

Field Was Now (per canonical line cited)
ttl_ms minimum: 0, no max minimum: 1000, maximum: 86400000 (L828-830)
grace_period_ms minimum: 0, no max minimum: 0, maximum: 60000 (L832-836)
overage_policy type: string enum [REJECT, ALLOW_IF_AVAILABLE, ALLOW_WITH_OVERDRAFT] (L794)
CommitResponseMirror.status type: string enum [COMMITTED] (L1068)
ReleaseResponseMirror.status type: string enum [RELEASED] (L1101)
ReleaseRequestMirror.reason type: string, no max maxLength: 256 (L1092)

Verified at the schema layer:

  • ttl_ms=500 → rejected (below canonical min 1000)
  • ttl_ms=90000000 → rejected (above canonical max 86400000)
  • overage_policy='NOT_A_REAL_POLICY' → rejected
  • commit response status='pending' → rejected

3 — LOW: CREDITS unit-class prose

Comment on case_10_reserve_credits_allow rewritten to scope strictly to the Cycles wire surface (CREDITS as 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). No positive claim about APS unit_class mapping.

Byte stability

No fixture canonical bytes change in this commit (only the spec yaml and one generator comment edited). All 11 evidence_ids and signatures are byte-identical to the prior commit 17271c4. python generate.py shows zero diff on cases/*.json.

Validation

  • python verify.py: 11/11 verified
  • JSON Schema validation of all 11 fixtures against the tightened spec: 11/11 valid
  • Six negative tests above all reject correctly
  • 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 warnings (unchanged set)

(Branch needed a rebase before push — remote had picked up cd66835 merging main in for unrelated hygiene work. Rebase was clean, no conflicts since main's diff didn't touch CyclesEvidence files.)

…ype discriminator, commit/release reservation_id requirement, stale prose)

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)
@amavashev
Copy link
Copy Markdown
Contributor Author

Review round 3 — all three findings confirmed empirically before fix, addressed in 3d8b3c8. Negative tests written for every new constraint per the round-2 rigor protocol.

1 — MEDIUM: schema-only validators accepted artifact_type / payload mismatches

Reproduced: fixture 02 (artifact_type: reserve) with payload swapped to fixture 11's payload.error → previous schema accepted it. The EvidencePayload.oneOf only constrained which payload key was present, not which one paired with artifact_type.

Fix: top-level allOf on CyclesEvidence with five if/then branches (one per artifact_type) requiring payload to contain the matching key. The pairing now lives in the schema, not just verify.py.

Verified: cross-product matrix — all 5×5 = 25 (artifact_type X, payload Y) combinations checked; 5 matching pairs accepted, all 20 mismatch pairs rejected.

2 — MEDIUM: ErrorPayload allowed commit/release errors without reservation_id

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. The path argument names which reservation the failed operation targeted; dropping it breaks the authorization → settlement chain for evidence-only readers (same rationale as the reservation_id hoist on success-path CommitPayload/ReleasePayload from #92 round 4).

Fix: 5th branch added to ErrorPayload.allOf:

- if:
    properties:
      endpoint:
        enum:
          - "POST /v1/reservations/{reservation_id}/commit"
          - "POST /v1/reservations/{reservation_id}/release"
    required: [endpoint]
  then:
    required: [reservation_id]

Endpoints without a path-arg reservation_id (POST /v1/decisions, POST /v1/reservations) are unaffected.

Verified:

  • commit endpoint, no reservation_idrejected
  • release endpoint, no reservation_idrejected
  • commit endpoint, WITH reservation_idvalid

Prose on ErrorPayload description updated to reflect the conditional rule.

3 — LOW: stale "four properties / other three" prose

EvidencePayload.description said "four properties" / "other three" but error is now the 5th branch. Updated to "five properties" / "other four", with a new sentence noting that the artifact_type ↔ payload-key pairing is enforced at the top-level via allOf/if/then.

Byte stability

No fixture canonical bytes change in this commit (only the spec yaml edited; generator untouched). All 11 evidence_ids and signatures are byte-identical to 708ee24. Confirmed by empty git diff on cases/*.json.

Validation

  • python verify.py: 11/11 verified
  • JSON Schema validation of all 11 fixtures against the tightened spec: 11/11 valid
  • F1 cross-product matrix: 20/20 mismatch pairs rejected (5 matching pairs valid)
  • F2 negative: commit-endpoint + release-endpoint ErrorPayload without reservation_id both rejected
  • F2 positive: commit-endpoint ErrorPayload WITH reservation_id valid
  • Prior regressions still trip: non-dry DENY rejected; commit-body under reserve endpoint rejected; ttl_ms=500 rejected
  • npx spectral lint drafts/cycles-evidence-v0.1.yaml --fail-severity=error: 0 errors, 3 expected schema-only warnings (unchanged set)

… sweep of "MUST be absent / Present only when" rules

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`.
@amavashev
Copy link
Copy Markdown
Contributor Author

Review round 4 — reviewer flagged 1 finding (rule A below). Proactively swept the canonical for sibling "MUST be absent / Present only when" rules and found 6 more in the same class. All 7 fixed in dba1fe6. Same rigor protocol: empirically reproduce, paired negative test per rule.

REVIEWER-FLAGGED (1)

A — ErrorPayload.reservation_id MUST be absent for non-path endpoints. The round-3 fix only enforced the REQUIRED side for commit/release endpoints; the ABSENT side for decisions/reservations was unconstrained. A stray reservation_id on a POST /v1/decisions error envelope would mislead audit consumers into binding the error to a reservation the endpoint never named.

Fix added as a 6th branch to ErrorPayload.allOf:

- if:
    properties:
      endpoint:
        enum:
          - "POST /v1/decisions"
          - "POST /v1/reservations"
    required: [endpoint]
  then:
    not:
      required: [reservation_id]

PROACTIVE SWEEP (6)

grep "MUST be absent" cycles-protocol-v0.yaml found six more rules in the same mirror-drift class that the schema wasn't enforcing. Doing the broader pass here breaks the find-more-each-round pattern. Each rule has a paired negative test.

Rule Source Constraint
B L752 DecisionResponseMirror: caps present iff decision=ALLOW_WITH_CAPS
C L997 ReservationCreateResponseMirror: caps present iff decision=ALLOW_WITH_CAPS
D L981, L1404 ReservePayload: dry_run=true → reservation_id MUST be absent
E L1404 ReservePayload: dry_run=true → expires_at_ms MUST be absent
F L981 ReservePayload: dry_run NOT true → reservation_id MUST be present
G L981 ReservePayload: dry_run NOT true → expires_at_ms MUST be present

B and C are mirror-level if/then/else. D+E and F+G are added to ReservePayload.allOf (along with the existing non-dry-DENY rule from round 1). Combined with that round-1 rule, the non-dry branch is guaranteed decision=ALLOW or ALLOW_WITH_CAPS — both of which require the IDs per canonical L981.

Validation

  • Positive: 11/11 fixtures still valid
  • Negatives (10 paired with the 7 new rules) — 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
  • Prior-round regressions (round 1-3 negatives still trip):
    • artifact_type/payload mismatch (round 3)
    • commit endpoint + no reservation_id (round 3)
    • non-dry DENY (round 1)
    • ttl_ms=500 (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 byte-identical to 3d8b3c8. Confirmed by empty git diff on cases/*.json.

…ame typo, ErrorCode enum closure, expires_at_ms over-tightening)

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.
@amavashev
Copy link
Copy Markdown
Contributor Author

Review round 5 — three findings, all confirmed against the canonical, fixed in b5ecb44. F1 was particularly embarrassing (an endpoint name typo I propagated 12 times); F2 caught my mirror-contract violation on ErrorCode; F3 caught my round-4 over-tightening on expires_at_ms.

1 — HIGH: /v1/decisions/v1/decide

The canonical defines the decide endpoint at /v1/decide (cycles-protocol-v0.yaml:1342). The draft had /v1/decisions in 12 places: ArtifactType→endpoint mapping, ErrorPayload endpoint enum, four allOf if/then branches (request-mirror routing, reservation_id-absent rule), and two mirror descriptions. All renamed.

Why no fixture caught this: none of the 11 existing fixtures emitted an error envelope on the decide endpoint. Added 12-decide-live-forbidden.json (403 FORBIDDEN on POST /v1/decide with a real DecisionRequest body) to close the coverage gap.

Verified:

  • positive: POST /v1/decide error envelope validates
  • negative: stale POST /v1/decisions value now rejected as off-enum

2 — MEDIUM: ErrorResponseMirror.error open string

My MIRROR CONTRACT block says: "Mirror schemas copy field names, types, required-lists, enum values, AND structural constraints." But I left error as type: string with description listing the 15 canonical values and prose suggesting consumers "treat unknown codes as terminal" — directly contradicting the contract.

Tightened to the closed 15-value enum copied byte-for-byte from cycles-protocol-v0.yaml L429-L446. Future ErrorCode additions trigger a mirror re-cut per the documented mirror-drift process.

Verified:

  • error: "FUTURE_CODE" → rejected
  • error: "invalid_request" (lowercase) → rejected

3 — MEDIUM: round-4 rule G over-tightened on expires_at_ms

Canonical L981 explicitly requires reservation_id on non-dry ALLOW/ALLOW_WITH_CAPS ("Present if decision is ALLOW or ALLOW_WITH_CAPS and dry_run is false"). For expires_at_ms, the canonical L1404 only requires its ABSENCE on dry_run; the canonical schema's required list never includes it; no positive-presence prose exists. My round-4 rule G required expires_at_ms on non-dry — over-tightening that rejected envelopes the canonical accepts.

Relaxed rule 3 on ReservePayload.allOf to require only reservation_id in the non-dry branch. The dry-run absent rule (which DOES cover expires_at_ms) is unchanged. Updated prose explains the asymmetry.

Verified:

  • positive: non-dry ALLOW without expires_at_ms now validates (was rejected pre-fix)
  • regression: non-dry ALLOW without reservation_id still rejected
  • regression: dry_run=true + expires_at_ms present still rejected

Validation summary

  • 12/12 fixtures verify; 12/12 validate against the tightened schema
  • All F1-F3 positive and negative cases pass
  • Round 1-4 regression negatives still trip (non-dry DENY; ttl_ms out-of-range; artifact_type/payload mismatch; commit endpoint no reservation_id; ALLOW_WITH_CAPS missing caps; stray reservation_id on decide/reservations endpoints)
  • npx spectral lint: 0 errors, 3 expected schema-only warnings (unchanged set)

Byte stability

Fixtures 1-11 byte-identical to dba1fe6. Fixture 12 is new. Generator deterministic; same test signer seed produces the same Ed25519 keypair on every run.

Self-note

F1 is the kind of miss the rigor protocol exists to prevent — a literal-string typo I propagated 12× without ever spot-checking against the canonical paths. Adding fixture 12 also addresses the deeper cause: no coverage of the decide error path meant my schema had a typo that no test could find. Saved as a memory rule: when adding an endpoint to a discriminator enum, immediately add at least one fixture exercising that endpoint.

…quest.metrics (round 6)

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.
@amavashev
Copy link
Copy Markdown
Contributor Author

Review round 6 — finding confirmed against canonical, fixed in 66ef936. Sweep for sibling drift cleared (no more additionalProperties: true slots diverging from canonical).

Finding (MEDIUM) — CommitRequestMirror.metrics was an arbitrary object

CommitRequestMirror.metrics was type: object, additionalProperties: true but canonical CommitRequest.metrics references the constrained StandardMetrics schema (cycles-protocol-v0.yaml L1055-L1056 → L1008-L1034: additionalProperties: false with 5 specific fields).

Reproduced: metrics: { "bogus_metric": -1 } validated under the previous schema; canonical rejects.

Fix

  • Added StandardMetrics schema under "Common nested types" — copies the canonical exactly: 5 fields (tokens_input, tokens_output, latency_ms all with minimum: 0; model_version with maxLength: 128; custom as the additionalProperties: true escape hatch) plus top-level additionalProperties: false.
  • CommitRequestMirror.metrics switched from arbitrary object to $ref: StandardMetrics.

Proactive sweep

Per rigor-protocol rule, swept for sibling additionalProperties: true slots in mirrors. Five total:

Slot Canonical line Verdict
DecisionRequestMirror.metadata L729 genuinely loose in canonical ✓
ReservationCreateRequestMirror.metadata L860 genuinely loose in canonical ✓
CommitRequestMirror.metrics L1055 divergent — fixed
CommitRequestMirror.metadata L1057 genuinely loose in canonical ✓
ErrorResponseMirror.details L480 genuinely loose in canonical ✓

Only metrics was divergent; the four metadata/details slots are genuinely additionalProperties: true in the canonical. No additional drift.

Coverage gap closed — fixture 13

The reason no prior fixture caught this: fixture 05 (only commit fixture before round 6) didn't populate metrics. Same coverage-gap pattern as fixture 12 in round 5 (/v1/decide endpoint). Added 13-commit-with-metrics.json — a commit envelope with all five StandardMetrics fields populated, including the custom escape hatch carrying cache_hit_ratio and retry_count.

Validation

  • 13/13 fixtures verify and schema-validate
  • F NEG: reviewer's exact case ({ "bogus_metric": -1 }) → rejected
  • F NEG: tokens_input: -1 → rejected
  • F NEG: model_version at 129 chars → rejected
  • F NEG: latency_ms: -100 → rejected
  • F POS: custom escape hatch with arbitrary nested content → valid (canonical escape hatch works as designed)
  • Prior regressions still trip: error=FUTURE_CODE; stale /v1/decisions; non-dry DENY; ALLOW_WITH_CAPS without caps
  • npx spectral lint: 0 errors, 3 expected schema-only warnings

Byte stability

Fixtures 1-12 byte-identical to b5ecb44. Fixture 13 is new.

Self-note

The two recent rounds (5 + 6) both surfaced bugs whose root cause is fixture coverage gaps, not schema design. Pattern: when adding a new constraint or enum value, I should immediately add a fixture exercising it. Already encoded as rule 5 in my review-rigor memory; this round added rule 6 (cross-check literal strings against canonical via grep). The coverage rule continues to pay back — round 6's bug would have been invisible without fixture 13.

… fixture (round 7)

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.
@amavashev
Copy link
Copy Markdown
Contributor Author

Round 7 — single doc fix in f735de8. README line 37 incorrectly called the StandardMetrics fixture a "closed-enum" mirror; it's a constrained object schema, not an enum. Updated to "constrained StandardMetrics mirror (object schema with additionalProperties: false and per-field constraints; not an enum)".

Swept for sibling occurrences of "closed-enum": generate.py:332 correctly describes the canonical UnitEnum (USD_MICROCENTS / TOKENS / CREDITS / RISK_POINTS) — that one stays.

Documentation-only. All 13 fixtures byte-identical to 66ef936.


Also: thanks for the all-clear on behavioral/spec compliance ("No remaining behavioral/spec-compliance issues found"). After seven review rounds — six finding rounds and this doc round — the spec, mirrors, fixtures, and verifier are clean against cycles-protocol-v0.yaml. The fixture set (13 envelopes) now covers all five artifact types, every Unit enum value, every decision branch, the live error path on every endpoint, the dry-run + non-dry reserve decision matrix, StandardMetrics, optional trace_id omission, and the artifact_type/payload cross-product. Negative-test matrix at the schema layer rejects every protocol-impossible shape we've identified.

Ready for whatever's next on the path to merge.

@amavashev amavashev merged commit 61186a2 into main May 13, 2026
5 checks passed
@amavashev amavashev deleted the feat/cycles-evidence-v0.1-draft branch May 13, 2026 11:25
amavashev added a commit that referenced this pull request May 13, 2026
New draft at `drafts/cycles-aps-denial-mapping-v0.1.md` specifying the
contract for `mapCyclesDenialToFoundation()` — the function the
APS-side Cycles adapter at `src/v2/payment-rails/cycles/index.ts`
(in `aeoess/agent-passport-system`) will implement to translate
Cycles denial signals into APS `PaymentDenial` Tier-1
`denial_reason` values.

Sister artifact to `#90` (CyclesEvidence v0.1).
#90 defines how Cycles denials get signed and content-addressed; this
draft defines how those denials are projected into the APS
`PaymentDenial`'s closed 6-value Tier-1 enum, with the richer
Cycles-specific detail preserved Tier-2 in the `cycles.denial_detail`
namespace.

What's in scope (v0.1.25.x):

  - Mapping table 1 — Cycles `ErrorCode` → APS Tier-1 (15 closed
    values from `cycles-protocol-v0.yaml` L429-L446, each with
    canonical-cited HTTP status code or explicit "(inferred)" flag)
  - Mapping table 2 — Cycles `DecisionReasonCode` → APS Tier-1 (6
    known v0.1.25 base values; open enum per canonical L487 with
    documented unknown-value fallback to `rail_error`)
  - Tier-2 `cycles.denial_detail` namespace schema covering both
    ErrorCode-sourced and DecisionReasonCode-sourced denials with
    standardized field naming (`code`, `source`, `http_status`,
    `message`, `request_id`, `trace_id`)
  - Forward-compat note documenting `requires_owner_confirmation` as
    the one APS Tier-1 value with no Cycles counterpart
  - TypeScript reference implementation (15-entry `ERROR_CODE_TO_TIER1`
    + 6-entry `DECISION_REASON_TO_TIER1` const tables + the
    `mapCyclesDenialToFoundation` function body), directly droppable
    into the APS adapter when it opens
  - Round-trip verification section mapping the three denial-path
    CyclesEvidence fixtures (03 dry-run-deny, 11 live-budget-exceeded,
    12 decide-forbidden) to the expected Tier-1 outcomes — these are
    the golden test cases the adapter PR will use

Scoped out (v0.2 promotion criteria):

  - `cycles-protocol-extensions-v0.1.26.yaml` adds 3 codes
    (`ACTION_QUOTA_EXCEEDED`, `ACTION_KIND_DENIED`,
    `ACTION_KIND_NOT_ALLOWED`) and the `DenyDetail` structure. The
    extension spec is published but not yet implemented in any
    Cycles deployment, so documenting its mapping now would create
    phantom rows no fixture can verify against. v0.2 of this doc
    will add three rows to each table with cross-path-consistent
    Tier-1 mappings, plus a `deny_detail` field on the evidence
    schema's response mirrors, plus at least one fixture exercising
    the extension end-to-end.

Validation:

  - 15 `ErrorCode` rows in mapping table 1, all with specific HTTP
    status codes (canonical-cited where the spec pins them; inferred
    where it doesn't, with `(inferred)` flag)
  - 6 `DecisionReasonCode` rows in mapping table 2
  - 15 + 6 entries in matching TypeScript const tables
  - 0 stray `cycles.denial_detail.error` or `cycles.denial_detail.reason_code`
    field-name references (standardized on `cycles.denial_detail.code`)
  - JSON example parses cleanly (no TypeScript union syntax)
  - All cross-repo line citations verified against current files via
    `gh api`: `aeoess/agent-passport-system/src/v2/payment-rails/types.ts`
    L92 (`PaymentReceipt`), L137 (`PaymentDenial`), L340 (`emitReceipt`),
    L344 (`emitDenial`); `hooks.ts` L87-L94 (`VALID_DENIAL_REASONS`),
    L381 + L606 + L629 (the three `INVALID_DENIAL_REASON` rejection
    points)
  - `python drafts/fixtures/cycles-evidence-v0.1/verify.py`: 13/13
    fixtures still verify (unchanged from #90 merge)
  - `git diff --check origin/main...HEAD`: passed

Review history preserved at #93 (5 review rounds with progressively
tighter rigor: initial draft + self-audit on cross-repo citations →
4 reviewer findings on doc consistency → 4 more findings on
v0.1.26-vs-v0.1.25.x scope → 3 findings against stale snapshot
(already-fixed) → 2 findings on HTTP status codes and one stale "9
known values" reference → 1 finding on `PaymentReceipt` vs
`PaymentDenial` type confusion). Each round expanded the
`feedback_spec_review_rigor` memory rule set; the final rule count is
9, covering canonical-source verification, negative tests, deployment
reality checks, and column-level literal-value cross-checking.
amavashev added a commit to amavashev/agent-passport-system that referenced this pull request May 21, 2026
Extends the scaffold from the prior commit with the full sign/verify
surface for all three Cycles artifact types (permit / release / denial)
and 19 tests covering happy-path round-trips, tamper detection,
accountability-shape invariants, TTL expiry, expected-signer mismatch,
DID URI sync-path, and the three golden CyclesEvidence fixtures from
runcycles/cycles-protocol#90.

What lands:

  - Sign functions (mirror mpp pattern, field-omission canonical bytes,
    UUID-based receipt_id):
      signCyclesPermitReceipt   → CyclesPermitReceipt   (claim_type permit)
      signCyclesReleaseReceipt  → CyclesReleaseReceipt  (claim_type release)
      signCyclesDenial          → CyclesDenial          (claim_type denial)

  - Sync verify functions:
      verifyCyclesPermitReceipt(receipt, options)  → CyclesVerifyResult
      verifyCyclesReleaseReceipt(receipt, options) → CyclesVerifyResult
      verifyCyclesDenial(denial, options)          → CyclesVerifyResult

  - Async DID-resolving verify functions (matches mpp's
    verifyMppReceiptWithDID + verifyMppDenialWithDID):
      verifyCyclesPermitReceiptWithDID(receipt, options)
      verifyCyclesReleaseReceiptWithDID(receipt, options)
      verifyCyclesDenialWithDID(denial, options)

  - Sign-input shapes in types.ts:
      SignCyclesPermitReceiptInput
      SignCyclesReleaseReceiptInput
      SignCyclesDenialInput
      VerifyCyclesOptions
      CyclesResolveDidDocument (DID document resolver type, mirrors
        MppResolveDidDocument)

  - Default scope_of_claim assertions per artifact type:
      defaultCyclesPermitReceiptScope  — "aps:cycles.permit:emit"
      defaultCyclesReleaseReceiptScope — "aps:cycles.release:emit"
      defaultCyclesDenialScope         — "aps:cycles.deny:emit"
    Each carries the right does_not_assert disclaimers for that artifact
    (permit doesn't assert commit; release doesn't assert action was
    cancelled; denial doesn't assert rail unavailability or agent
    malice).

  - Shared _verifyReceiptCore helper checking:
      * claim_type matches expected literal
      * receipt_id, signer, agent_id, delegation_ref, action_ref present
      * rail_name === 'cycles' (structural check; INVALID_RAIL_NAME)
      * cycles_evidence.cycles_evidence_url and
        cycles_evidence.cycles_evidence_id_sha256 present
      * options.expected_signer matches signer (if provided)
      * issued_at parses; not past TTL (default 24h; EXPIRED reason)
      * timestamp === issued_at (accountability-shape invariant)
      * scope_of_claim.asserts is a non-empty string
      * DID URI signer triggers DID_RESOLVER_MISSING (caller should
        use the async WithDID path)
      * Ed25519 signature verifies against the signer's hex pubkey

  - Verify-reason enum (CyclesVerifyReason) extended with two
    new reasons surfaced by self-review:
      EXPIRED          — TTL violation (was SIGNATURE_INVALID before
        self-review caught the bug)
      INVALID_RAIL_NAME — rail_name not 'cycles' (new structural check)

Self-review caught (and fixed) before commit:
  * EXPIRED reason was undefined in the enum; TTL path was emitting
    SIGNATURE_INVALID with a misleading detail message
  * rail_name === 'cycles' wasn't validated
  * timestamp === issued_at and scope_of_claim.asserts non-empty
    weren't validated (mpp pattern; missed in the scaffold)

Test coverage (19 tests, all green):
  * 3 golden-fixture mapping tests (03-reserve-dry-run-deny,
    11-reserve-live-budget-exceeded, 12-decide-live-forbidden) — copied
    from runcycles/cycles-protocol/drafts/fixtures/cycles-evidence-v0.1/
    cases/ to tests/v2/payment-rails/cycles-fixtures/
  * 1 unknown-ErrorCode → rail_error fallback test
  * 4 permit-receipt tests (round-trip, tampered reservation_id,
    missing cycles_evidence, wrong claim_type)
  * 2 release-receipt tests (round-trip with reason, tampered amount)
  * 3 denial tests (ErrorCode-sourced round-trip, DecisionReasonCode-
    sourced round-trip, tampered cycles.denial_detail.code)
  * 1 expected_signer mismatch test
  * 1 sync DID URI → DID_RESOLVER_MISSING test
  * 1 TTL → EXPIRED test
  * 1 tampered rail_name → INVALID_RAIL_NAME test
  * 2 accountability-invariant tests (timestamp != issued_at,
    empty scope_of_claim.asserts)

Regression sanity:
  * npm run build clean (tsc + chmod)
  * npm run test:quick 15/15 pass (unchanged)
  * mpp tests 41/41 pass (unchanged — no shared code modified)

Still TODO for v0.1 (explicit in file header):
  * cycles_evidence_id_sha256 join-integrity check against a fetched
    CyclesEvidence envelope (load-bearing offline-audit guarantee).
    VerifyCyclesOptions has no envelope slot today; future option add
    will pass the envelope in and recompute the sha256.
  * preAuthorizeCyclesReserve gateway hook into PaymentRail interface
    (pending design discussion in PR review).
aeoess pushed a commit to aeoess/agent-passport-system that referenced this pull request May 26, 2026
…gn/verify TODO)

Lays the directory + denial-mapping function for the APS ↔ Cycles
budget-authority rail at `src/v2/payment-rails/cycles/`. Closes the
fifth step in the integration sequencing agreed at #25 comment
4429584316; the prior four (vocabulary crosswalks, CyclesEvidence
spec, denial-mapping spec, SDK verifier acceptance for the
rail.budget_reservation.*.v1 literals) have all merged.

What lands in this commit:

  - `cycles/types.ts` — APS claim_type literal constants
    (rail.budget_reservation.{permit,release,denial}.v1, the three
    landed in #27), `CyclesEvidenceRef` minimal join shape
    (cycles_evidence_url + cycles_evidence_id_sha256 + action_ref +
    delegation_ref per #25 comment 4422627045), minimal read-only
    `CyclesEvidenceView` mirror over the canonical
    cycles-evidence-v0.1.yaml envelope shape, and skeleton interfaces
    for `CyclesPermitReceipt` / `CyclesReleaseReceipt` / `CyclesDenial`
    / `CyclesDenialDetail` / `CyclesVerifyReason` / `CyclesVerifyResult`.

  - `cycles/index.ts` — `mapCyclesDenialToFoundation()` fully
    implemented per the merged spec at runcycles/cycles-protocol
    drafts/cycles-aps-denial-mapping-v0.1.md (commit 9b0fb5e). Two
    const tables ported byte-for-byte:
      * ERROR_CODE_TO_TIER1 — 15 v0.1.25 base ErrorCode values
        (canonical cycles-protocol-v0.yaml L429-L446)
      * DECISION_REASON_TO_TIER1 — 6 v0.1.25 base DecisionReasonCode
        values (canonical L487-L545)
    Unknown DecisionReasonCode values gracefully degrade to
    rail_error per canonical L503-L505; raw code preserved Tier-2
    byte-for-byte in `cycles.denial_detail.code`.

What is NOT in this commit (explicit TODO comments in index.ts):

  - signCyclesPermitReceipt / signCyclesReleaseReceipt /
    signCyclesDenial — Ed25519 emission paths over JCS-canon receipt
    bodies. Will mirror the mpp pattern (sign with signature='',
    canonicalize, sha256→receipt_id, re-canonicalize, sign, hex).
  - verifyCyclesPermitReceipt / verifyCyclesReleaseReceipt /
    verifyCyclesDenial — symmetric verification, plus
    cycles_evidence_id_sha256 join-integrity check against the
    fetched CyclesEvidence envelope (the load-bearing offline-audit
    guarantee).
  - Pre-authorization hook (preAuthorizeCyclesReserve) — the gateway-
    side hook into the existing PaymentRail interface. Pending
    decision on whether it belongs in v0.1 or can ship in a
    follow-up.
  - Fixture-test wiring — the three golden fixtures from
    cycles-protocol drafts/fixtures/cycles-evidence-v0.1/cases/
    (03, 11, 12) will land alongside sign/verify in the next commit
    so the denial-mapper has wire-level round-trip coverage.

v0.1.26 extension codes (ACTION_QUOTA_EXCEEDED / ACTION_KIND_DENIED /
ACTION_KIND_NOT_ALLOWED) and the DenyDetail structure are OUT OF
SCOPE for v0.1 per the merged mapping spec. The extension spec exists
in cycles-protocol but is not yet implemented in any Cycles
deployment; mapping it now would create phantom rows no fixture can
verify against. v0.2 promotion criterion documented in the merged
denial-mapping spec.

Validation (all empirical, before commit):

  - 5/5 cited commit hashes verified via `gh api` against the right
    repos: 9b0fb5e (cycles-protocol #93), 61186a2 (cycles-protocol
    #90), 3ccc17f (agent-governance-vocabulary #92), 161b1d4
    (agent-governance-vocabulary #91), 4abd9df (this repo, #27).
  - ERROR_CODE_TO_TIER1 and DECISION_REASON_TO_TIER1 byte-match the
    merged spec exactly (15/15 + 6/6 entries, set-compared on parsed
    TS).
  - All 5 Tier-1 values used (no_commerce_scope, spend_limit_exceeded,
    wallet_revoked, time_window_violation, rail_error) are members of
    the closed APS `DenialReason` enum at
    src/v2/payment-rails/types.ts.
  - Compiled scaffold runs end-to-end against the three golden
    fixtures from runcycles/cycles-protocol — 3/3 produce predicted
    Tier-1 + Tier-2 outcomes.
  - npm run build clean; npm run test:quick 15/15 pass.

Status: DRAFT until sign/verify + pre-auth hook + fixture tests land.

Refs:
  - #25 (integration thread)
  - #27 (SDK PR adding rail.budget_reservation
    literals, merged 4abd9df)
  - aeoess/agent-governance-vocabulary#91 (budget_reservation
    crosswalk, merged 161b1d4)
  - aeoess/agent-governance-vocabulary#92 (cycles.yaml crosswalk,
    merged 3ccc17f)
  - runcycles/cycles-protocol#90 (CyclesEvidence envelope, merged
    61186a2)
  - runcycles/cycles-protocol#93 (denial-mapping spec, merged 9b0fb5e)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant