Skip to content

Commit 89d2651

Browse files
authored
Merge pull request #80 from runcycles/fix/reserve-unit-mismatch
fix: distinguish UNIT_MISMATCH from BUDGET_NOT_FOUND on reserve/event/decide (v0.1.25.6)
2 parents 3c82e4e + e42ecad commit 89d2651

12 files changed

Lines changed: 336 additions & 25 deletions

File tree

AUDIT.md

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,39 @@
11
# Cycles Protocol v0.1.25 — Server Implementation Audit
22

3-
**Date:** 2026-04-08 (v0.1.25.5 duplicate event fix), 2026-04-07 (v0.1.25.4 event data completeness), 2026-04-01 (v0.1.25 event emission + TTL), 2026-03-24 (Round 6: spec compliance audit), 2026-03-24 (v0.1.24 update), 2026-03-23 (updated), 2026-03-15 (initial)
3+
**Date:** 2026-04-10 (v0.1.25.6 reserve/event UNIT_MISMATCH detection), 2026-04-08 (v0.1.25.5 duplicate event fix), 2026-04-07 (v0.1.25.4 event data completeness), 2026-04-01 (v0.1.25 event emission + TTL), 2026-03-24 (Round 6: spec compliance audit), 2026-03-24 (v0.1.24 update), 2026-03-23 (updated), 2026-03-15 (initial)
44
**Spec:** `cycles-protocol-v0.yaml` (OpenAPI 3.1.0, v0.1.25) + `complete-budget-governance-v0.1.25.yaml` (events/webhooks)
55
**Server:** Spring Boot 3.5.11 / Java 21 / Redis (Lua scripts)
66

77
---
88

9+
### 2026-04-10 — v0.1.25.6: Distinguish UNIT_MISMATCH from BUDGET_NOT_FOUND on reserve/event
10+
11+
**Bug (runcycles/cycles-client-rust#8):** `POST /v1/reservations` with `Amount::tokens(1000)` against a scope whose budget was stored in `USD_MICROCENTS` returned `404 BUDGET_NOT_FOUND`. The client could not distinguish "no budget at this scope" from "budget exists but in a different unit" and had no hint toward the fix. `/v1/events` had the same latent bug.
12+
13+
**Root cause:** `reserve.lua` / `event.lua` key budgets by `budget:<scope>:<unit>`. When the requested unit doesn't match the stored unit, the key doesn't exist, the scope is silently skipped, and `#budgeted_scopes == 0` falls through to `BUDGET_NOT_FOUND`. The existing `UNIT_MISMATCH` branch in `event.lua` only caught an internal inconsistency between key suffix and stored `unit` field — it did not catch the cross-unit case.
14+
15+
**Fix:** On the empty-budgeted-scopes error path only, both scripts now probe the fixed `UnitEnum` set (`USD_MICROCENTS`, `TOKENS`, `CREDITS`, `RISK_POINTS`) via `EXISTS budget:<scope>:<unit_alt>` for each affected scope. If any alternate-unit budget exists, the script returns `UNIT_MISMATCH` (400) with `scope`, `requested_unit`, and `expected_units` in the error payload so the client can self-correct. Otherwise falls through to the existing `BUDGET_NOT_FOUND` (404).
16+
17+
Cascade semantics preserved: scopes without a budget at the requested unit are still silently skipped during the main validation loop — the probe only fires when every affected scope missed. No hot-path change; the cost is paid once on the error path only.
18+
19+
`evaluateDryRun` and `/v1/decide` (the non-Lua Java paths) get the symmetric probe via a shared `probeAlternateUnits` helper and throw `UNIT_MISMATCH` (400) to match the reserve/event behavior. Spec line 1131-1134 only prohibits 409 on `/decide` for debt/overdraft conditions; 400 for a request-validity error (wrong unit) is permitted and is consistent across all four entry points.
20+
21+
**Modified files:**
22+
- `reserve.lua` — new `ARGV[15] = units_csv`; scopes now start at ARGV[16]; alternate-unit probe added to the empty-budgeted-scopes branch
23+
- `event.lua` — new `ARGV[14] = units_csv`; scopes now start at ARGV[15]; symmetric probe
24+
- `RedisReservationRepository.java``UNIT_CSV` constant derived once from `Enums.UnitEnum.values()`; passed into both `createReservation` and `createEvent` args; `evaluateDryRun` and `decide` mirror the probe via a shared `probeAlternateUnits(jedis, scope, requestedUnit)` helper; `handleScriptError` extracts `scope` / `requested_unit` / `expected_units` for reserve/event and falls back to the no-detail factory for commit.lua's legacy form
25+
- `CyclesProtocolException.java``unitMismatch(scope, requestedUnit, expectedUnits)` overload populating `details`
26+
- `ReservationLifecycleIntegrationTest.java``shouldRejectWhenNoBudgetExistsForUnit` renamed to `shouldRejectWithUnitMismatchWhenBudgetExistsInDifferentUnit` and flipped to expect 400 + details; added `shouldReturnBudgetNotFoundWhenNoBudgetAtAnyUnit` regression guard and `shouldReturnUnitMismatchOnDryRunWhenBudgetExistsInDifferentUnit`
27+
- `DecisionAndEventIntegrationTest.java``shouldRejectEventWithUnitMismatch` flipped from 404 `NOT_FOUND` to 400 `UNIT_MISMATCH` + details; added `shouldReturnBudgetNotFoundWhenNoBudgetAtAnyUnitOnEvent`, `shouldRejectDecideWithUnitMismatchWhenBudgetExistsInDifferentUnit`, and `shouldReturnDenyBudgetNotFoundOnDecideWhenNoBudgetAtAnyUnit`
28+
- `RedisReservationCoreOpsTest.java` — existing `shouldThrowUnitMismatch` asserts the no-detail fallback path; added `shouldThrowUnitMismatchWithDetailsFromReserve`
29+
- `CyclesProtocolExceptionTest.java` — coverage for the new factory overload (populated details + null-tolerant form)
30+
- `cycles-protocol-service/README.md` — error table entry for `UNIT_MISMATCH` broadened to include reserve + describe the `details.*` payload
31+
- `cycles-protocol-service/pom.xml``<revision>` bumped `0.1.25.5``0.1.25.6`
32+
33+
**Closes:** runcycles/cycles-server#79. Addresses runcycles/cycles-client-rust#8.
34+
35+
**Out of scope:** Client-side rust SDK changes (not needed — structured error is enough for the user to correct their call). Protocol YAML spec update lives in `runcycles/cycles-protocol` and is handled on a coordinated branch (adds `"404"` to `/v1/reservations` POST and `/v1/events` POST response lists + broadens normative UNIT_MISMATCH wording to cover reserve).
36+
937
### 2026-04-07 — v0.1.25.4: Event data payload completeness
1038

1139
**Compliance review** against protocol spec v0.1.25 + admin spec v0.1.25 found 5 event data payload gaps. Core protocol (endpoints, schemas, error codes, Lua scripts, idempotency, scope derivation, auth/tenancy) was fully compliant.
@@ -276,7 +304,7 @@ Two-pass audit covering:
276304
- RESERVATION_FINALIZED → 409
277305
- RESERVATION_EXPIRED → 410
278306
- NOT_FOUND → 404
279-
- UNIT_MISMATCH → 400 (enforced in commit.lua and event.lua)
307+
- UNIT_MISMATCH → 400 (enforced in reserve.lua, commit.lua, event.lua; reserve/event paths return `scope`, `requested_unit`, `expected_units` in details so the client can self-correct — see v0.1.25.6)
280308
- IDEMPOTENCY_MISMATCH → 409
281309
- INVALID_REQUEST → 400
282310
- INTERNAL_ERROR → 500

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ cd cycles-protocol-service
5353

5454
# 4. Run
5555
REDIS_HOST=localhost REDIS_PORT=6379 \
56-
java -jar cycles-protocol-service-api/target/cycles-protocol-service-api-0.1.25.5.jar
56+
java -jar cycles-protocol-service-api/target/cycles-protocol-service-api-0.1.25.6.jar
5757
```
5858

5959
Server starts on **port 7878**. Interactive API docs: http://localhost:7878/swagger-ui.html
@@ -117,7 +117,7 @@ mvn clean install
117117
./build-all.sh
118118
```
119119

120-
The fat JAR is produced at `cycles-protocol-service-api/target/cycles-protocol-service-api-0.1.25.5.jar`.
120+
The fat JAR is produced at `cycles-protocol-service-api/target/cycles-protocol-service-api-0.1.25.6.jar`.
121121

122122
## Docker Deployment
123123

@@ -136,7 +136,7 @@ Pre-built images are published to GitHub Container Registry on each release:
136136

137137
```
138138
ghcr.io/runcycles/cycles-server:latest
139-
ghcr.io/runcycles/cycles-server:<version> # e.g. 0.1.25.5
139+
ghcr.io/runcycles/cycles-server:<version> # e.g. 0.1.25.6
140140
```
141141

142142
## Testing

cycles-protocol-service/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ docker run -d -p 6379:6379 redis:7-alpine
7070

7171
# 4. Run
7272
REDIS_HOST=localhost REDIS_PORT=6379 \
73-
java -jar cycles-protocol-service-api/target/cycles-protocol-service-api-0.1.25.5.jar
73+
java -jar cycles-protocol-service-api/target/cycles-protocol-service-api-0.1.25.6.jar
7474
```
7575

7676
The server starts on **port 7878**. Interactive API docs: http://localhost:7878/swagger-ui.html
@@ -585,7 +585,7 @@ All errors use this envelope:
585585
| Code | HTTP | Meaning |
586586
|---|---|---|
587587
| `INVALID_REQUEST` | 400 | Missing or invalid field |
588-
| `UNIT_MISMATCH` | 400 | Commit unit differs from reservation unit, or event unit not supported for target scope |
588+
| `UNIT_MISMATCH` | 400 | Requested unit does not match the stored budget unit for the target scope. Raised by reserve/commit/event. Reserve and event responses include `details.scope`, `details.requested_unit`, and `details.expected_units` so the client can self-correct; commit uses the legacy no-detail form. |
589589
| `UNAUTHORIZED` | 401 | Missing or invalid API key |
590590
| `FORBIDDEN` | 403 | Tenant in request does not match API key |
591591
| `NOT_FOUND` | 404 | Reservation, budget, or resource not found |

cycles-protocol-service/cycles-protocol-service-api/src/test/java/io/runcycles/protocol/api/DecisionAndEventIntegrationTest.java

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,45 @@ void shouldReturnAffectedScopesInDecision() {
136136
assertThat(resp.getStatusCode().value()).isEqualTo(200);
137137
assertThat(resp.getBody().get("affected_scopes")).isNotNull();
138138
}
139+
140+
@Test
141+
void shouldRejectDecideWithUnitMismatchWhenBudgetExistsInDifferentUnit() {
142+
// Budget seeded at tenant:tenant-a in TOKENS. Request USD_MICROCENTS on /decide →
143+
// Java probe finds TOKENS at the scope → 400 UNIT_MISMATCH with expected_units.
144+
// Spec line 1131-1134 only prohibits 409 on decide for debt/overdraft; 400 for a
145+
// request-validity error (wrong unit) is permitted and consistent with reserve/event.
146+
Map<String, Object> body = new HashMap<>();
147+
body.put("idempotency_key", UUID.randomUUID().toString());
148+
body.put("subject", Map.of("tenant", TENANT_A));
149+
body.put("action", Map.of("kind", "llm.completion", "name", "test-model"));
150+
body.put("estimate", Map.of("unit", "USD_MICROCENTS", "amount", 1000));
151+
152+
ResponseEntity<Map> resp = post("/v1/decide", API_KEY_SECRET_A, body);
153+
154+
assertThat(resp.getStatusCode().value()).isEqualTo(400);
155+
assertThat(resp.getBody().get("error")).isEqualTo("UNIT_MISMATCH");
156+
Map<String, Object> details = (Map<String, Object>) resp.getBody().get("details");
157+
assertThat(details).isNotNull();
158+
assertThat(details.get("scope")).isEqualTo("tenant:" + TENANT_A);
159+
assertThat(details.get("requested_unit")).isEqualTo("USD_MICROCENTS");
160+
assertThat((List<String>) details.get("expected_units")).contains("TOKENS");
161+
}
162+
163+
@Test
164+
void shouldReturnDenyBudgetNotFoundOnDecideWhenNoBudgetAtAnyUnit() {
165+
// Regression guard: when no budget exists at ANY unit for the scope, decide still
166+
// returns 200 DENY with reason_code=BUDGET_NOT_FOUND (the probe found nothing).
167+
try (Jedis jedis = jedisPool.getResource()) {
168+
jedis.del("budget:tenant:" + TENANT_A + ":TOKENS");
169+
}
170+
171+
ResponseEntity<Map> resp = post("/v1/decide", API_KEY_SECRET_A,
172+
decisionBody(TENANT_A, 1000));
173+
174+
assertThat(resp.getStatusCode().value()).isEqualTo(200);
175+
assertThat(resp.getBody().get("decision")).isEqualTo("DENY");
176+
assertThat(resp.getBody().get("reason_code")).isEqualTo("BUDGET_NOT_FOUND");
177+
}
139178
}
140179

141180
@Nested
@@ -185,8 +224,9 @@ void shouldRejectEventWithMissingSubject() {
185224

186225
@Test
187226
void shouldRejectEventWithUnitMismatch() {
188-
// Spec: event actual.unit not supported for the target scope MUST return error
189-
// Budget is seeded for TOKENS only; USD_MICROCENTS has no budget → BUDGET_NOT_FOUND
227+
// Spec: event actual.unit not supported for the target scope MUST return UNIT_MISMATCH.
228+
// Budget seeded for TOKENS at tenant:tenant-a; request USD_MICROCENTS → Lua probes
229+
// alternate units, finds TOKENS, returns UNIT_MISMATCH (400) with expected_units.
190230
Map<String, Object> body = new HashMap<>();
191231
body.put("idempotency_key", UUID.randomUUID().toString());
192232
body.put("subject", Map.of("tenant", TENANT_A));
@@ -195,6 +235,26 @@ void shouldRejectEventWithUnitMismatch() {
195235

196236
ResponseEntity<Map> resp = post("/v1/events", API_KEY_SECRET_A, body);
197237

238+
assertThat(resp.getStatusCode().value()).isEqualTo(400);
239+
assertThat(resp.getBody().get("error")).isEqualTo("UNIT_MISMATCH");
240+
Map<String, Object> details = (Map<String, Object>) resp.getBody().get("details");
241+
assertThat(details).isNotNull();
242+
assertThat(details.get("scope")).isEqualTo("tenant:" + TENANT_A);
243+
assertThat(details.get("requested_unit")).isEqualTo("USD_MICROCENTS");
244+
assertThat((List<String>) details.get("expected_units")).contains("TOKENS");
245+
}
246+
247+
@Test
248+
void shouldReturnBudgetNotFoundWhenNoBudgetAtAnyUnitOnEvent() {
249+
// Remove the seeded TOKENS budget so the scope has no budget at ANY unit.
250+
// Regression guard: event.lua probe falls through to BUDGET_NOT_FOUND (404).
251+
try (Jedis jedis = jedisPool.getResource()) {
252+
jedis.del("budget:tenant:" + TENANT_A + ":TOKENS");
253+
}
254+
255+
ResponseEntity<Map> resp = post("/v1/events", API_KEY_SECRET_A,
256+
eventBody(TENANT_A, 500));
257+
198258
assertThat(resp.getStatusCode().value()).isEqualTo(404);
199259
assertThat(resp.getBody().get("error")).isEqualTo("NOT_FOUND");
200260
}

cycles-protocol-service/cycles-protocol-service-api/src/test/java/io/runcycles/protocol/api/ReservationLifecycleIntegrationTest.java

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -173,16 +173,53 @@ void shouldRejectMissingIdempotencyKey() {
173173
}
174174

175175
@Test
176-
void shouldRejectWhenNoBudgetExistsForUnit() {
177-
// No budget seeded for USD_MICROCENTS unit — Lua returns BUDGET_NOT_FOUND → 404
176+
void shouldRejectWithUnitMismatchWhenBudgetExistsInDifferentUnit() {
177+
// Budget seeded at tenant:tenant-a in TOKENS. Request USD_MICROCENTS → Lua probes
178+
// alternate units, finds TOKENS, returns UNIT_MISMATCH (400) with expected_units.
178179
Map<String, Object> body = reservationBody(TENANT_A, 1000, "USD_MICROCENTS");
179180

180181
ResponseEntity<Map> resp = post("/v1/reservations", API_KEY_SECRET_A, body);
181182

183+
assertThat(resp.getStatusCode().value()).isEqualTo(400);
184+
assertThat(resp.getBody().get("error")).isEqualTo("UNIT_MISMATCH");
185+
Map<String, Object> details = (Map<String, Object>) resp.getBody().get("details");
186+
assertThat(details).isNotNull();
187+
assertThat(details.get("scope")).isEqualTo("tenant:" + TENANT_A);
188+
assertThat(details.get("requested_unit")).isEqualTo("USD_MICROCENTS");
189+
assertThat((List<String>) details.get("expected_units")).contains("TOKENS");
190+
}
191+
192+
@Test
193+
void shouldReturnBudgetNotFoundWhenNoBudgetAtAnyUnit() {
194+
// Remove the seeded TOKENS budget so the scope has no budget at ANY unit.
195+
// Regression guard: BUDGET_NOT_FOUND (404) still wins when the probe finds nothing.
196+
try (Jedis jedis = jedisPool.getResource()) {
197+
jedis.del("budget:tenant:" + TENANT_A + ":TOKENS");
198+
}
199+
200+
ResponseEntity<Map> resp = post("/v1/reservations", API_KEY_SECRET_A,
201+
reservationBody(TENANT_A, 1000, "TOKENS"));
202+
182203
assertThat(resp.getStatusCode().value()).isEqualTo(404);
183204
assertThat(resp.getBody().get("error")).isEqualTo("NOT_FOUND");
184205
}
185206

207+
@Test
208+
void shouldReturnUnitMismatchOnDryRunWhenBudgetExistsInDifferentUnit() {
209+
// Symmetric probe in evaluateDryRun: dry_run with wrong unit should 400 UNIT_MISMATCH,
210+
// not silently DENY with reason_code=BUDGET_NOT_FOUND.
211+
Map<String, Object> body = reservationBody(TENANT_A, 1000, "USD_MICROCENTS");
212+
body.put("dry_run", true);
213+
214+
ResponseEntity<Map> resp = post("/v1/reservations", API_KEY_SECRET_A, body);
215+
216+
assertThat(resp.getStatusCode().value()).isEqualTo(400);
217+
assertThat(resp.getBody().get("error")).isEqualTo("UNIT_MISMATCH");
218+
Map<String, Object> details = (Map<String, Object>) resp.getBody().get("details");
219+
assertThat(details).isNotNull();
220+
assertThat((List<String>) details.get("expected_units")).contains("TOKENS");
221+
}
222+
186223
@Test
187224
void shouldRejectSubjectWithOnlyDimensions() {
188225
Map<String, Object> body = new HashMap<>();

cycles-protocol-service/cycles-protocol-service-data/src/main/java/io/runcycles/protocol/data/exception/CyclesProtocolException.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
import io.runcycles.protocol.model.Enums;
44
import lombok.Getter;
5+
import java.util.LinkedHashMap;
6+
import java.util.List;
57
import java.util.Map;
68

79
/** Cycles Protocol v0.1.25 */
@@ -55,6 +57,16 @@ public static CyclesProtocolException idempotencyMismatch() {
5557
public static CyclesProtocolException unitMismatch() {
5658
return new CyclesProtocolException(Enums.ErrorCode.UNIT_MISMATCH, "Provided units does not match the stored ones", 400);
5759
}
60+
public static CyclesProtocolException unitMismatch(String scope, String requestedUnit, List<String> expectedUnits) {
61+
Map<String, Object> details = new LinkedHashMap<>();
62+
if (scope != null) details.put("scope", scope);
63+
if (requestedUnit != null) details.put("requested_unit", requestedUnit);
64+
if (expectedUnits != null) details.put("expected_units", expectedUnits);
65+
String message = String.format(
66+
"Budget at scope '%s' exists but in a different unit (requested: %s, expected: %s)",
67+
scope, requestedUnit, expectedUnits);
68+
return new CyclesProtocolException(Enums.ErrorCode.UNIT_MISMATCH, message, 400, details);
69+
}
5870
public static CyclesProtocolException reservationExpired() {
5971
return new CyclesProtocolException(Enums.ErrorCode.RESERVATION_EXPIRED, "Provided reservation has already expired", 410);
6072
}

0 commit comments

Comments
 (0)