Skip to content

Commit 70fb43b

Browse files
authored
Merge pull request #49 from runcycles/claude/admin-tenant-update-endpoint-iTR5Q
Add tenant-level configuration for reservation policies and TTL
2 parents 197f363 + b01c6e7 commit 70fb43b

12 files changed

Lines changed: 681 additions & 18 deletions

File tree

AUDIT.md

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
| Overdraft/Debt Model || 0 |
2626
| Grace Period Handling || 0 |
2727
| Test Coverage || 0 |
28+
| Tenant Default Config || 0 |
2829

2930
**All previously identified issues have been fixed. No remaining spec violations found.**
3031

@@ -149,15 +150,33 @@ Two-pass audit covering:
149150
### Test Coverage (above 98%)
150151
- JaCoCo line coverage threshold raised from 90% to **95%** (enforced in parent pom.xml)
151152
- **API module**: 209/209 lines covered — **100%** line coverage (93 unit tests)
152-
- **Data module**: 770/782 lines covered — **98.5%** line coverage, 76% branch coverage (205 unit tests)
153+
- **Data module**: 770+ lines covered — **98.5%+** line coverage, 76%+ branch coverage (215 unit tests)
153154
- Branch gaps: defensive null-check ternaries and unreachable `&&` short-circuit branches in `RedisReservationRepository`
155+
- Tenant default resolution: 10 unit tests covering all fallback paths, TTL capping, max extensions, malformed JSON recovery
154156
- **Model module**: Coverage skipped (POJOs only, no business logic)
155-
- Total unit test count: 93 (API) + 205 (Data) + 5 (Model) = **303 unit tests**
156-
- **Integration tests**: 26 nested test classes covering all 9 endpoints, including:
157+
- Total unit test count: 93 (API) + 215 (Data) + 5 (Model) = **313 unit tests**
158+
- **Integration tests**: 27+ nested test classes covering all 9 endpoints, including:
159+
- Tenant Defaults (9 tests): overage policy resolution (ALLOW_IF_AVAILABLE, ALLOW_WITH_OVERDRAFT, REJECT), explicit override vs tenant default, TTL capping, max extensions enforcement, default TTL usage, no-tenant-record fallback
157160
- Expiry Sweep (7 tests): end-to-end expire.lua execution, grace period skip, orphan TTL cleanup, multi-scope budget release, already-finalized skip
158161
- Budget Status (2 tests): BUDGET_FROZEN and BUDGET_CLOSED enforcement on reserve
159162
- All unit tests pass without Docker/Testcontainers (integration tests excluded by default)
160163

164+
### Tenant Default Configuration (correct)
165+
- `default_commit_overage_policy`: resolved at reservation/event creation time
166+
- Resolution order: request-level `overage_policy` > tenant `default_commit_overage_policy` > hardcoded `REJECT`
167+
- Tenant config read from Redis `tenant:{tenant_id}` JSON record (shared with admin service)
168+
- Graceful fallback to `REJECT` on tenant read failure or missing record
169+
- `default_reservation_ttl_ms`: used when request omits `ttl_ms` (was hardcoded to 60000ms)
170+
- Resolution order: request `ttl_ms` > tenant `default_reservation_ttl_ms` > hardcoded 60000ms
171+
- `max_reservation_ttl_ms`: caps requested TTL to tenant maximum (default 3600000ms)
172+
- Applied after default resolution: `Math.min(effectiveTtl, maxTtl)`
173+
- `max_reservation_extensions`: stored on reservation at creation, enforced in extend.lua
174+
- `extension_count` tracked and incremented atomically in extend.lua
175+
- Returns `MAX_EXTENSIONS_EXCEEDED` (HTTP 409) when count reaches max
176+
- Default: 10 (spec default)
177+
- Request model fields (`overagePolicy`, `ttlMs`) changed from hardcoded defaults to `null`
178+
to allow tenant config resolution when client omits them
179+
161180
### Overdraft/Debt Model (correct)
162181
- `ALLOW_WITH_OVERDRAFT` policy supported on both commit and event
163182
- Debt tracked per-scope, `is_over_limit` flag set when `debt > overdraft_limit`
@@ -199,6 +218,19 @@ Two-pass audit covering:
199218
- **Fix:** Added LIMIT of 1000 per sweep cycle via `zrangeByScore(key, 0, now, 0, SWEEP_BATCH_SIZE)` — backlog drains naturally across subsequent sweeps
200219
- **Location:** `ReservationExpiryService.java:31,45`
201220

221+
### Issue 8 [FIXED]: Tenant default configuration not honored by protocol server
222+
- **Was:** `default_commit_overage_policy`, `default_reservation_ttl_ms`, `max_reservation_ttl_ms`, and `max_reservation_extensions` were set via admin API but ignored by the protocol server. The request models hardcoded `overagePolicy = REJECT` and `ttlMs = 60000L` as field defaults, so when clients omitted these fields they always got hardcoded values — never the tenant's configured defaults.
223+
- **Fix:**
224+
1. Removed hardcoded defaults from `ReservationCreateRequest.overagePolicy` (was `REJECT`), `ReservationCreateRequest.ttlMs` (was `60000L`), and `EventCreateRequest.overagePolicy` (was `REJECT`) — now `null` when omitted
225+
2. Added `getTenantConfig()` to read tenant JSON from `tenant:{id}` Redis key
226+
3. Added `resolveOveragePolicy()`: request > tenant `default_commit_overage_policy` > `REJECT`
227+
4. Added `resolveReservationTtl()`: request > tenant `default_reservation_ttl_ms` > 60000ms, then `Math.min(ttl, max_reservation_ttl_ms)`
228+
5. Added `resolveMaxExtensions()`: reads tenant `max_reservation_extensions` (default 10), stored on reservation, enforced in extend.lua
229+
6. extend.lua: tracks `extension_count`, returns `MAX_EXTENSIONS_EXCEEDED` when limit reached
230+
7. reserve.lua: accepts `max_extensions` as ARGV[14], stores on reservation hash
231+
8. Added `MAX_EXTENSIONS_EXCEEDED` error code (HTTP 409)
232+
- **Location:** `RedisReservationRepository.java`, `ReservationCreateRequest.java`, `EventCreateRequest.java`, `reserve.lua`, `extend.lua`, `Enums.java`
233+
202234
### Issue 7 [FIXED]: Clock skew between Java and Redis in expiry sweep candidate query
203235
- **Was:** Java `System.currentTimeMillis()` used for the `zrangeByScore` query; all Lua scripts use `redis.call('TIME')` — clock drift could cause missed or premature candidate selection
204236
- **Fix:** Replaced with `jedis.time()` to use Redis server clock, consistent with reserve/commit/release/extend/expire Lua scripts

cycles-protocol-service/README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,8 @@ Beyond expires_at_ms: extend blocked (410)
154154

155155
The same three policies apply to `/v1/events` for direct debits.
156156

157+
When `overage_policy` is omitted from the request, the server resolves it from the tenant's `default_commit_overage_policy` (set via the Admin API). If the tenant has no default configured, `REJECT` is used.
158+
157159
### Debt and Overdraft
158160

159161
- If `overdraft_limit` is absent or `0`, no overdraft is permitted (`ALLOW_WITH_OVERDRAFT` behaves as `ALLOW_IF_AVAILABLE`).
@@ -304,9 +306,9 @@ Reserve budget before executing an action. Returns `200 OK`.
304306
| `action.tags` | no || max 10 items, each max 64 chars |
305307
| `estimate.unit` | yes || see Units |
306308
| `estimate.amount` | yes || ≥ 0 |
307-
| `ttl_ms` | no | `60000` | 1000–86400000 ms |
309+
| `ttl_ms` | no | tenant `default_reservation_ttl_ms` or `60000` | 1000–86400000 ms; capped to tenant `max_reservation_ttl_ms` |
308310
| `grace_period_ms` | no | `5000` | 0–60000 ms |
309-
| `overage_policy` | no | `REJECT` | see Overage Policies |
311+
| `overage_policy` | no | tenant `default_commit_overage_policy` or `REJECT` | see Overage Policies |
310312
| `dry_run` | no | `false` | evaluates without persisting if true |
311313

312314
**Response** `200 OK`
@@ -430,7 +432,7 @@ Extend the reservation TTL. Only allowed while `server_time ≤ expires_at_ms` (
430432

431433
`extend_by_ms` is added to the current `expires_at_ms` (not to request time). Does not change reserved amount, unit, subject, action, or scope.
432434

433-
**Error conditions:** `409 RESERVATION_FINALIZED` if the reservation is already COMMITTED or RELEASED; `410 RESERVATION_EXPIRED` if `server_time > expires_at_ms`; `404 NOT_FOUND` if the reservation does not exist.
435+
**Error conditions:** `409 RESERVATION_FINALIZED` if the reservation is already COMMITTED or RELEASED; `410 RESERVATION_EXPIRED` if `server_time > expires_at_ms`; `409 MAX_EXTENSIONS_EXCEEDED` if the tenant's `max_reservation_extensions` limit has been reached; `404 NOT_FOUND` if the reservation does not exist.
434436

435437
---
436438

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,23 @@ protected String createReservationAndGetId(String tenant, String apiKey, long am
211211
return (String) response.getBody().get("reservation_id");
212212
}
213213

214+
/**
215+
* Seed a tenant record in Redis with configurable defaults.
216+
*/
217+
protected void seedTenant(Jedis jedis, String tenantId, String overagePolicy,
218+
Long defaultTtlMs, Long maxTtlMs, Integer maxExtensions) throws Exception {
219+
Map<String, Object> tenant = new HashMap<>();
220+
tenant.put("tenant_id", tenantId);
221+
tenant.put("name", tenantId);
222+
tenant.put("status", "ACTIVE");
223+
tenant.put("created_at", Instant.now().toString());
224+
if (overagePolicy != null) tenant.put("default_commit_overage_policy", overagePolicy);
225+
if (defaultTtlMs != null) tenant.put("default_reservation_ttl_ms", defaultTtlMs);
226+
if (maxTtlMs != null) tenant.put("max_reservation_ttl_ms", maxTtlMs);
227+
if (maxExtensions != null) tenant.put("max_reservation_extensions", maxExtensions);
228+
jedis.set("tenant:" + tenantId, objectMapper.writeValueAsString(tenant));
229+
}
230+
214231
/**
215232
* Seed a budget at an arbitrary scope path (e.g. "tenant:tenant-a/workspace:default/agent:my-agent").
216233
*/

0 commit comments

Comments
 (0)