Skip to content

Commit 5cad4fe

Browse files
authored
Merge pull request #61 from runcycles/claude/add-ledger-allocation-aWj3W
Fix debt handling: allow reservations when debt within overdraft limit
2 parents fde0342 + bf7aa36 commit 5cad4fe

11 files changed

Lines changed: 316 additions & 91 deletions

File tree

.claude/settings.json

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,5 +12,26 @@
1212
]
1313
}
1414
]
15+
},
16+
"attribution": {
17+
"commit": "",
18+
"pr": ""
19+
},
20+
"permissions": {
21+
"allow": [
22+
"Bash(git add:*)",
23+
"Bash(git commit:*)",
24+
"Bash(git push:*)",
25+
"Bash(git pull:*)",
26+
"Bash(git status:*)",
27+
"Bash(git diff:*)",
28+
"Bash(git log:*)"
29+
],
30+
"deny": [
31+
"mcp__github__create_or_update_file",
32+
"mcp__github__push_files",
33+
"mcp__github__create_or_update_files",
34+
"mcp__github__delete_file"
35+
]
1536
}
16-
}
37+
}

AUDIT.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ Two-pass audit covering:
4545
- Response headers (X-Request-Id, X-Cycles-Tenant, X-RateLimit-*)
4646
- Lua script atomicity for reserve, commit, release, extend, event, expire
4747
- Dry-run response rules (reservation_id/expires_at_ms absent, affected_scopes populated)
48-
- Overdraft/debt model (ALLOW_WITH_OVERDRAFT, is_over_limit, DEBT_OUTSTANDING)
48+
- Overdraft/debt model (ALLOW_WITH_OVERDRAFT, is_over_limit, DEBT_OUTSTANDING — only blocks when overdraft_limit=0; ALLOW_WITH_OVERDRAFT falls back to ALLOW_IF_AVAILABLE when overdraft_limit=0)
4949
- Grace period semantics (commits accepted through expires_at_ms + grace_period_ms)
5050
- Subject.dimensions round-tripping
5151
- Unit mismatch detection on commit and event

CLAUDE.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,17 @@
11
# Cycles Protocol Server
22

3+
## Git Rules — STRICT
4+
- ALWAYS use native git for ALL commits and pushes
5+
- NEVER use mcp__github__ tools for committing or pushing
6+
- Use mcp__github__ ONLY for: PRs, Issues, GitHub Actions
7+
- Write commit messages to a temp file, then: git commit -F <file>
8+
- NEVER use --no-gpg-sign flag
9+
10+
# Cycles strict rules
11+
- yaml API specs always the authority
12+
- always udated AUDIT.md files when making changes to server, admin, client repos
13+
- maintain at least 95% or higher test coverage for all code repos
14+
315
## Maven Builds
416

517
In Claude Code remote environments, use `mvn-proxy` instead of `mvn` for all Maven commands.

cycles-protocol-service/README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -160,8 +160,8 @@ When `overage_policy` is omitted from the request, the server resolves it from t
160160

161161
- If `overdraft_limit` is absent or `0`, no overdraft is permitted (`ALLOW_WITH_OVERDRAFT` behaves as `ALLOW_IF_AVAILABLE`).
162162
- **`debt`** is created when `overage_policy=ALLOW_WITH_OVERDRAFT` and the commit delta exceeds remaining budget.
163-
- When `debt > 0`, **new reservations** against that scope are blocked with `409 DEBT_OUTSTANDING`.
164-
- When `debt > overdraft_limit`, the scope enters **over-limit** state (`is_over_limit=true`). New reservations are then blocked with `409 OVERDRAFT_LIMIT_EXCEEDED` (takes precedence over `DEBT_OUTSTANDING`).
163+
- When `debt > 0` and no `overdraft_limit` is configured (absent or `0`), **new reservations** against that scope are blocked with `409 DEBT_OUTSTANDING`. When an `overdraft_limit > 0` is set, debt within the limit does **not** block new reservations.
164+
- When `debt > overdraft_limit`, the scope enters **over-limit** state (`is_over_limit=true`). New reservations are then blocked with `409 OVERDRAFT_LIMIT_EXCEEDED`.
165165
- Debt does **not** block direct events (`/v1/events`); events always apply their own `overage_policy`.
166166
- Existing active reservations MAY be committed or released normally while a scope is over-limit.
167167
- Overdraft limit checks are per-commit and are **not** atomic across concurrent commits — concurrent commits may independently pass the check, causing total debt to temporarily exceed `overdraft_limit`. This is by design; the `is_over_limit` flag prevents further damage.
@@ -593,7 +593,7 @@ All errors use this envelope:
593593
| `RESERVATION_FINALIZED` | 409 | Reservation already committed or released |
594594
| `IDEMPOTENCY_MISMATCH` | 409 | Idempotency key reused with different parameters |
595595
| `OVERDRAFT_LIMIT_EXCEEDED` | 409 | `debt + delta > overdraft_limit`; or scope is over-limit |
596-
| `DEBT_OUTSTANDING` | 409 | Scope has unresolved debt; new reservations blocked |
596+
| `DEBT_OUTSTANDING` | 409 | Scope has unresolved debt with no overdraft limit; new reservations blocked |
597597
| `RESERVATION_EXPIRED` | 410 | Operation attempted after expiry window |
598598
| `INTERNAL_ERROR` | 500 | Unexpected server error |
599599

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

Lines changed: 143 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -662,21 +662,16 @@ void shouldReturnSameDecisionOnReplay() {
662662
}
663663

664664
@Test
665-
void shouldReturnDenyWithReasonCodeWhenDebtOutstanding() {
666-
// Spec: /decide SHOULD return decision=DENY with reason_code=DEBT_OUTSTANDING when debt > 0
667-
// Create debt: drain budget then overdraft commit
668-
String drain = createReservationAndGetId(TENANT_A, API_KEY_SECRET_A, 950_000);
669-
post("/v1/reservations/" + drain + "/commit", API_KEY_SECRET_A, commitBody(950_000));
670-
671-
Map<String, Object> overdraftBody = reservationBody(TENANT_A, 1000);
672-
overdraftBody.put("overage_policy", "ALLOW_WITH_OVERDRAFT");
673-
ResponseEntity<Map> reserveResp = post("/v1/reservations", API_KEY_SECRET_A, overdraftBody);
674-
String overdraftResId = (String) reserveResp.getBody().get("reservation_id");
675-
post("/v1/reservations/" + overdraftResId + "/commit",
676-
API_KEY_SECRET_A, commitBody(100_000));
677-
// debt > 0 now
665+
void shouldReturnDenyWithReasonCodeWhenDebtOutstandingAndNoOverdraftLimit() {
666+
// Spec (decide): "If the subject scope has debt > 0 ... server SHOULD return
667+
// decision=DENY with reason_code=DEBT_OUTSTANDING"
668+
// This applies when overdraft_limit=0 (no policy allowing debt).
669+
try (Jedis jedis = jedisPool.getResource()) {
670+
jedis.hset("budget:tenant:" + TENANT_A + ":TOKENS", "overdraft_limit", "0");
671+
jedis.hset("budget:tenant:" + TENANT_A + ":TOKENS", "debt", "5000");
672+
}
678673

679-
// /decide MUST NOT return 409, SHOULD return 200 with decision=DENY
674+
// /decide MUST NOT return 409 — returns 200 with decision=DENY
680675
ResponseEntity<Map> resp = post("/v1/decide", API_KEY_SECRET_A,
681676
decisionBody(TENANT_A, 100));
682677

@@ -685,6 +680,22 @@ void shouldReturnDenyWithReasonCodeWhenDebtOutstanding() {
685680
assertThat(resp.getBody().get("reason_code")).isEqualTo("DEBT_OUTSTANDING");
686681
}
687682

683+
@Test
684+
void shouldReturnAllowWhenDebtWithinOverdraftLimit() {
685+
// Spec: "When debt > 0, new reservations MUST be rejected ... (unless explicitly
686+
// allowed by policy)." — overdraft_limit > 0 is the explicit policy.
687+
// /decide mirrors this: debt within limit → ALLOW.
688+
try (Jedis jedis = jedisPool.getResource()) {
689+
jedis.hset("budget:tenant:" + TENANT_A + ":TOKENS", "debt", "5000");
690+
}
691+
692+
ResponseEntity<Map> resp = post("/v1/decide", API_KEY_SECRET_A,
693+
decisionBody(TENANT_A, 100));
694+
695+
assertThat(resp.getStatusCode().value()).isEqualTo(200);
696+
assertThat(resp.getBody().get("decision")).isEqualTo("ALLOW");
697+
}
698+
688699
@Test
689700
void shouldReturnAffectedScopesInDecision() {
690701
ResponseEntity<Map> resp = post("/v1/decide", API_KEY_SECRET_A,
@@ -1876,6 +1887,90 @@ void shouldRejectEventWhenOverdraftLimitExceeded() {
18761887
assertThat(resp.getBody().get("error")).isEqualTo("OVERDRAFT_LIMIT_EXCEEDED");
18771888
}
18781889

1890+
@Test
1891+
void shouldFallbackToAllowIfAvailableWhenOverdraftLimitZeroOnCommit() {
1892+
// Spec: "If overdraft_limit is absent or 0, no overdraft is permitted
1893+
// (behaves as ALLOW_IF_AVAILABLE)."
1894+
// ALLOW_IF_AVAILABLE: "cap delta to available remaining (minimum across
1895+
// all affected scopes, floor 0), charge estimate + capped_delta, and set
1896+
// is_over_limit=true on scopes where the full delta could not be covered."
1897+
try (Jedis jedis = jedisPool.getResource()) {
1898+
jedis.hset("budget:tenant:" + TENANT_A + ":TOKENS", "overdraft_limit", "0");
1899+
jedis.hset("budget:tenant:" + TENANT_A + ":TOKENS", "remaining", "50000");
1900+
jedis.hset("budget:tenant:" + TENANT_A + ":TOKENS", "spent", "950000");
1901+
}
1902+
1903+
// Reserve 1000, then commit 100_000 (overage delta=99_000 > remaining=49_000)
1904+
// With overdraft_limit=0, should cap like ALLOW_IF_AVAILABLE, not reject.
1905+
Map<String, Object> body = reservationBody(TENANT_A, 1000);
1906+
body.put("overage_policy", "ALLOW_WITH_OVERDRAFT");
1907+
ResponseEntity<Map> reserveResp = post("/v1/reservations", API_KEY_SECRET_A, body);
1908+
String resId = (String) reserveResp.getBody().get("reservation_id");
1909+
1910+
ResponseEntity<Map> commitResp = post(
1911+
"/v1/reservations/" + resId + "/commit",
1912+
API_KEY_SECRET_A, commitBody(100_000));
1913+
1914+
// Spec: commit always succeeds with ALLOW_IF_AVAILABLE fallback (never rejects)
1915+
assertThat(commitResp.getStatusCode().value()).isEqualTo(200);
1916+
// Spec: "charged = estimate + capped_delta" — must be less than actual
1917+
Map<String, Object> charged = (Map<String, Object>) commitResp.getBody().get("charged");
1918+
long chargedAmount = ((Number) charged.get("amount")).longValue();
1919+
assertThat(chargedAmount).isLessThan(100_000);
1920+
assertThat(chargedAmount).isGreaterThan(0);
1921+
1922+
// Spec: "set is_over_limit=true on scopes where full delta could not be covered"
1923+
ResponseEntity<Map> balanceResp = get(
1924+
"/v1/balances?tenant=" + TENANT_A, API_KEY_SECRET_A);
1925+
var balances = (java.util.List<Map<String, Object>>) balanceResp.getBody().get("balances");
1926+
Map<String, Object> bal = balances.get(0);
1927+
assertThat(bal.get("is_over_limit")).isEqualTo(true);
1928+
1929+
// Spec: ALLOW_IF_AVAILABLE "never creates debt"
1930+
Map<String, Object> debt = (Map<String, Object>) bal.get("debt");
1931+
long debtAmount = debt != null ? ((Number) debt.get("amount")).longValue() : 0;
1932+
assertThat(debtAmount).isEqualTo(0);
1933+
}
1934+
1935+
@Test
1936+
void shouldFallbackToAllowIfAvailableWhenOverdraftLimitZeroOnEvent() {
1937+
// Spec: "If overdraft_limit is absent or 0, no overdraft is permitted
1938+
// (behaves as ALLOW_IF_AVAILABLE)."
1939+
// ALLOW_IF_AVAILABLE for events: "cap the charge to available remaining ...
1940+
// and set is_over_limit=true on scopes where the full amount could not be covered."
1941+
try (Jedis jedis = jedisPool.getResource()) {
1942+
jedis.hset("budget:tenant:" + TENANT_A + ":TOKENS", "overdraft_limit", "0");
1943+
jedis.hset("budget:tenant:" + TENANT_A + ":TOKENS", "remaining", "50000");
1944+
jedis.hset("budget:tenant:" + TENANT_A + ":TOKENS", "spent", "950000");
1945+
}
1946+
1947+
// Event for 500_000 (exceeds remaining 50_000)
1948+
Map<String, Object> body = eventBody(TENANT_A, 500_000);
1949+
body.put("overage_policy", "ALLOW_WITH_OVERDRAFT");
1950+
1951+
ResponseEntity<Map> resp = post("/v1/events", API_KEY_SECRET_A, body);
1952+
1953+
// Spec: event always succeeds (never rejects with ALLOW_IF_AVAILABLE)
1954+
assertThat(resp.getStatusCode().value()).isEqualTo(201);
1955+
// Spec: "charged field present when ... caps the event charge to remaining budget"
1956+
assertThat(resp.getBody().get("charged")).isNotNull();
1957+
Map<String, Object> charged = (Map<String, Object>) resp.getBody().get("charged");
1958+
long chargedAmount = ((Number) charged.get("amount")).longValue();
1959+
assertThat(chargedAmount).isEqualTo(50_000);
1960+
1961+
// Spec: "set is_over_limit=true on scopes where full amount could not be covered"
1962+
ResponseEntity<Map> balanceResp = get(
1963+
"/v1/balances?tenant=" + TENANT_A, API_KEY_SECRET_A);
1964+
var balances = (java.util.List<Map<String, Object>>) balanceResp.getBody().get("balances");
1965+
Map<String, Object> bal = balances.get(0);
1966+
assertThat(bal.get("is_over_limit")).isEqualTo(true);
1967+
1968+
// Spec: ALLOW_IF_AVAILABLE "never creates debt"
1969+
Map<String, Object> debt = (Map<String, Object>) bal.get("debt");
1970+
long debtAmount = debt != null ? ((Number) debt.get("amount")).longValue() : 0;
1971+
assertThat(debtAmount).isEqualTo(0);
1972+
}
1973+
18791974
@Test
18801975
void shouldMaintainLedgerInvariantAfterCommit() {
18811976
// allocated = remaining + spent + reserved + debt (spec invariant)
@@ -2378,34 +2473,39 @@ void shouldReturnSignedAmountForRemaining() {
23782473
class DebtOutstanding {
23792474

23802475
@Test
2381-
void shouldRejectNewReservationWhenDebtOutstanding() {
2382-
// Drain budget so overdraft commit creates debt.
2383-
// Budget: allocated=1_000_000, overdraft_limit=100_000
2384-
String drain = createReservationAndGetId(TENANT_A, API_KEY_SECRET_A, 950_000);
2385-
post("/v1/reservations/" + drain + "/commit", API_KEY_SECRET_A, commitBody(950_000));
2386-
// remaining=50_000
2387-
2388-
// Create overdraft reservation and commit to create debt
2389-
Map<String, Object> overdraftBody = reservationBody(TENANT_A, 1000);
2390-
overdraftBody.put("overage_policy", "ALLOW_WITH_OVERDRAFT");
2391-
ResponseEntity<Map> reserveResp = post("/v1/reservations", API_KEY_SECRET_A, overdraftBody);
2392-
String overdraftResId = (String) reserveResp.getBody().get("reservation_id");
2393-
// remaining=49_000
2394-
2395-
// Commit with overage that creates debt: delta=99_000, remaining=49_000
2396-
// funded=49_000, deficit=50_000 ≤ 100_000 → allowed
2397-
post("/v1/reservations/" + overdraftResId + "/commit",
2398-
API_KEY_SECRET_A, commitBody(100_000));
2399-
// Now debt=50_000 > 0
2476+
void shouldRejectNewReservationWhenDebtOutstandingAndNoOverdraftLimit() {
2477+
// Spec: "When debt > 0, new reservations MUST be rejected with 409
2478+
// DEBT_OUTSTANDING (unless explicitly allowed by policy)."
2479+
// overdraft_limit=0 means no policy allowing debt → MUST reject.
2480+
try (Jedis jedis = jedisPool.getResource()) {
2481+
jedis.hset("budget:tenant:" + TENANT_A + ":TOKENS", "overdraft_limit", "0");
2482+
jedis.hset("budget:tenant:" + TENANT_A + ":TOKENS", "debt", "1000");
2483+
}
24002484

2401-
// New reservation should be blocked with DEBT_OUTSTANDING
24022485
ResponseEntity<Map> newResp = post("/v1/reservations", API_KEY_SECRET_A,
24032486
reservationBody(TENANT_A, 100));
24042487

24052488
assertThat(newResp.getStatusCode().value()).isEqualTo(409);
24062489
assertThat(newResp.getBody().get("error")).isEqualTo("DEBT_OUTSTANDING");
24072490
}
24082491

2492+
@Test
2493+
void shouldAllowNewReservationWhenDebtWithinOverdraftLimit() {
2494+
// Spec: "When debt > 0, new reservations MUST be rejected ... (unless
2495+
// explicitly allowed by policy)." — overdraft_limit > 0 is the explicit
2496+
// policy that tolerates debt up to the limit.
2497+
// Budget: allocated=1_000_000, overdraft_limit=100_000, debt=5_000.
2498+
try (Jedis jedis = jedisPool.getResource()) {
2499+
jedis.hset("budget:tenant:" + TENANT_A + ":TOKENS", "debt", "5000");
2500+
}
2501+
2502+
ResponseEntity<Map> newResp = post("/v1/reservations", API_KEY_SECRET_A,
2503+
reservationBody(TENANT_A, 100));
2504+
2505+
assertThat(newResp.getStatusCode().value()).isEqualTo(200);
2506+
assertThat(newResp.getBody().get("decision")).isEqualTo("ALLOW");
2507+
}
2508+
24092509
@Test
24102510
void shouldAllowCommitAndReleaseOnExistingReservationsDespiteDebt() {
24112511
// Spec: existing reservations may commit/release normally even when debt > 0
@@ -2483,18 +2583,15 @@ void shouldReturnOverdraftLimitExceededWhenOverLimit() {
24832583
}
24842584

24852585
@Test
2486-
void shouldReturnDebtOutstandingWhenNotOverLimit() {
2487-
// When debt > 0 but is_over_limit=false, should return DEBT_OUTSTANDING
2488-
String drain = createReservationAndGetId(TENANT_A, API_KEY_SECRET_A, 950_000);
2489-
post("/v1/reservations/" + drain + "/commit", API_KEY_SECRET_A, commitBody(950_000));
2490-
2491-
Map<String, Object> overdraftBody = reservationBody(TENANT_A, 1000);
2492-
overdraftBody.put("overage_policy", "ALLOW_WITH_OVERDRAFT");
2493-
ResponseEntity<Map> reserveResp = post("/v1/reservations", API_KEY_SECRET_A, overdraftBody);
2494-
String overdraftResId = (String) reserveResp.getBody().get("reservation_id");
2495-
post("/v1/reservations/" + overdraftResId + "/commit",
2496-
API_KEY_SECRET_A, commitBody(100_000));
2497-
// debt=50_000 > 0, is_over_limit=false
2586+
void shouldReturnDebtOutstandingWhenNotOverLimitAndNoOverdraftLimit() {
2587+
// Spec precedence: is_over_limit check → DEBT_OUTSTANDING check → BUDGET_EXCEEDED.
2588+
// "When is_over_limit=true, server MUST return OVERDRAFT_LIMIT_EXCEEDED ...
2589+
// This takes precedence over DEBT_OUTSTANDING."
2590+
// Here is_over_limit=false, overdraft_limit=0, debt>0 → DEBT_OUTSTANDING.
2591+
try (Jedis jedis = jedisPool.getResource()) {
2592+
jedis.hset("budget:tenant:" + TENANT_A + ":TOKENS", "overdraft_limit", "0");
2593+
jedis.hset("budget:tenant:" + TENANT_A + ":TOKENS", "debt", "5000");
2594+
}
24982595

24992596
ResponseEntity<Map> newResp = post("/v1/reservations", API_KEY_SECRET_A,
25002597
reservationBody(TENANT_A, 100));

cycles-protocol-service/cycles-protocol-service-data/src/main/java/io/runcycles/protocol/data/repository/RedisReservationRepository.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,8 @@ private ReservationCreateResponse evaluateDryRun(Jedis jedis, ReservationCreateR
202202
.build();
203203
}
204204
long debt = Long.parseLong(budget.getOrDefault("debt", "0"));
205-
if (debt > 0) {
205+
long overdraftLimit = Long.parseLong(budget.getOrDefault("overdraft_limit", "0"));
206+
if (debt > 0 && overdraftLimit == 0) {
206207
return ReservationCreateResponse.builder()
207208
.decision(Enums.DecisionEnum.DENY)
208209
.reasonCode("DEBT_OUTSTANDING")
@@ -689,7 +690,8 @@ public DecisionResponse decide(DecisionRequest request, String tenant) {
689690
break;
690691
}
691692
long debt = Long.parseLong(budget.getOrDefault("debt", "0"));
692-
if (debt > 0) {
693+
long overdraftLimit = Long.parseLong(budget.getOrDefault("overdraft_limit", "0"));
694+
if (debt > 0 && overdraftLimit == 0) {
693695
response = DecisionResponse.builder()
694696
.decision(Enums.DecisionEnum.DENY)
695697
.reasonCode("DEBT_OUTSTANDING")

0 commit comments

Comments
 (0)