Skip to content

Commit 4f68289

Browse files
committed
feat: emit remaining_ttl_ms on reserve and extend responses (v0.1.25.59)
Implements the spec v0.1.25.16 wire addition (cycles-protocol#148). Five external review rounds on the client heartbeat design proved that regime detection from (grant, elapsed) observables is undecidable when tenant policy may cap the initial TTL or clamp the maximum lead, so the server now emits the authoritative remaining lease on both reservation responses; clients schedule heartbeat extensions directly from it. - reserve.lua: remaining_ttl_ms = granted (possibly tenant-capped) ttl, same Redis TIME snapshot that sets expires_at. - extend.lua: remaining_ttl_ms = new_expires_at - now; the idempotent replay branch decodes the cached result and recomputes the field against fresh TIME - a heartbeat retrying a lost extend with the same key schedules from the replayed body, and the cached value is stale by the retry delay (it would overshoot the real lease). - Reserve replays stay byte-verbatim (evidence-envelope integrity) and carry the ORIGINAL value, per the spec field description. - Absent on dry-run and DENY; purely additive, no request or key-shape changes. Pre-upgrade cached extend entries lack the field in stored JSON; the replay path derives it fresh from cached expires_at_ms, so mixed-version replay windows still emit it. Integration tests (RemainingTtlIntegrationTest): granted-ttl equality, tenant-cap exposure (24h request -> 10s cap), dry-run absence, verbatim reserve replay, fresh extend remaining, replay-freshness invariant (replay <= original - sleep, extension_count still 1). Full verify green locally against the PR-branch spec via -Dcontract.spec.url (the contract validator fetches the spec from cycles-protocol main, so CI stays red on contract checks until cycles-protocol#148 merges). [benchmark-skip] response-field addition; no hot-path behavior change.
1 parent 42e5de0 commit 4f68289

11 files changed

Lines changed: 220 additions & 4 deletions

File tree

AUDIT.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,23 @@
55

66
---
77

8-
### 2026-07-27 — build and release dependency maintenance
8+
### 2026-07-28 — remaining_ttl_ms on reserve/extend responses (v0.1.25.59)
9+
10+
Implements the spec v0.1.25.16 wire addition (cycles-protocol#148): five
11+
external review rounds on the client heartbeat design proved regime detection
12+
from `(grant, elapsed)` observables undecidable under policy clamping, so the
13+
server now emits the authoritative remaining lease. `reserve.lua` returns
14+
`remaining_ttl_ms = ttl_ms` (the granted, possibly tenant-capped TTL — same
15+
TIME snapshot as `expires_at`); `extend.lua` returns `new_expires_at − now`,
16+
and its idempotent-replay branch decodes the cached result and recomputes the
17+
field against fresh TIME (a same-key heartbeat retry schedules from the
18+
replayed body; the cached value is stale by the retry delay). Reserve replays
19+
stay byte-verbatim (evidence integrity) and carry the original value, per the
20+
spec field description. Absent on dry-run/DENY. Integration tests cover
21+
granted-TTL equality, tenant-cap exposure (24h request → 10s cap), dry-run
22+
absence, verbatim reserve replay, fresh extend remaining, and the
23+
replay-freshness invariant (replay ≤ original − sleep, extension_count still 1)
24+
under the contract validator pinned to the PR-branch spec.
925

1026
Dependabot PRs #253, #254, and #258 update the Maven flatten plugin from 1.7.3
1127
to 1.8.0, the full-SHA `actions/setup-python` pin from 6.3.0 to 7.0.0, and the

CHANGELOG.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,32 @@ changes to request/response bodies or Lua-script semantics would require a
1414
minor bump. "Internal signature changes" (e.g. Java method parameters) are
1515
called out but are not breaking to API clients.
1616

17+
## [0.1.25.59] — 2026-07-28
18+
19+
### Added
20+
21+
- **`remaining_ttl_ms` on reserve and extend responses** (spec v0.1.25.16,
22+
[runcycles/cycles-protocol#148](https://github.com/runcycles/cycles-protocol/pull/148)):
23+
server-authoritative remaining reservation lifetime in milliseconds,
24+
measured on the same Redis `TIME` snapshot that computes `expires_at`.
25+
Clients schedule heartbeat extensions from this value — spec review proved
26+
no portable, safe, extension-efficient heartbeat can be built from
27+
`expires_at_ms` alone when tenant policy may cap the initial TTL or clamp
28+
the maximum lead. On reserve it equals the granted (possibly
29+
tenant-capped) TTL; on extend it is the new lease remaining. **Extend
30+
idempotent replays recompute it fresh** (a heartbeat retrying a lost
31+
extend with the same key schedules its next beat from the replayed body; a
32+
stale value would overshoot the real lease). Reserve replays return the
33+
original body verbatim (evidence-envelope integrity), so there it reflects
34+
the original evaluation. Absent on dry-run and DENY.
35+
36+
### Compatibility
37+
38+
- Purely additive response field; no request changes, no Lua key-shape
39+
changes. Pre-upgrade cached extend-idempotency entries lack the field in
40+
their stored JSON; the replay path derives it fresh from the cached
41+
`expires_at_ms`, so mixed-version replay windows still emit it.
42+
1743
## [0.1.25.58] — 2026-07-14
1844

1945
### Changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
package io.runcycles.protocol.api;
2+
3+
import org.junit.jupiter.api.DisplayName;
4+
import org.junit.jupiter.api.Nested;
5+
import org.junit.jupiter.api.Test;
6+
import org.springframework.http.ResponseEntity;
7+
import redis.clients.jedis.Jedis;
8+
9+
import java.util.HashMap;
10+
import java.util.Map;
11+
import java.util.UUID;
12+
13+
import static org.assertj.core.api.Assertions.assertThat;
14+
15+
/**
16+
* remaining_ttl_ms (spec v0.1.25.16, cycles-protocol#148): server-authoritative
17+
* remaining reservation lifetime on reserve and extend responses, measured on
18+
* the same Redis TIME snapshot that computes expires_at. Clients schedule
19+
* heartbeat extensions from this value, so extend replays MUST recompute it
20+
* fresh (a same-key retry schedules from the replayed body), while reserve
21+
* replays return the original body verbatim (evidence-envelope integrity) and
22+
* therefore carry the ORIGINAL value.
23+
*/
24+
@DisplayName("remaining_ttl_ms Integration Tests")
25+
class RemainingTtlIntegrationTest extends BaseIntegrationTest {
26+
27+
@Nested
28+
@DisplayName("Reserve responses")
29+
class ReserveResponses {
30+
31+
@Test
32+
void freshReserveCarriesRemainingEqualToGrantedTtl() {
33+
ResponseEntity<Map> resp = post("/v1/reservations", API_KEY_SECRET_A,
34+
reservationBody(TENANT_A, 1000));
35+
36+
assertThat(resp.getStatusCode().value()).isEqualTo(200);
37+
// reserve.lua sets expires_at = now + ttl on the same TIME snapshot,
38+
// so the fresh-path remaining is exactly the granted ttl.
39+
assertThat(((Number) resp.getBody().get("remaining_ttl_ms")).longValue())
40+
.isEqualTo(60_000L);
41+
}
42+
43+
@Test
44+
void remainingReflectsTenantCapNotRequestedTtl() throws Exception {
45+
try (Jedis jedis = jedisPool.getResource()) {
46+
seedTenant(jedis, TENANT_A, null, null, 10_000L, null);
47+
}
48+
Map<String, Object> body = reservationBody(TENANT_A, 1000);
49+
body.put("ttl_ms", 86_400_000L); // 24h request, tenant caps at 10s
50+
51+
ResponseEntity<Map> resp = post("/v1/reservations", API_KEY_SECRET_A, body);
52+
53+
assertThat(resp.getStatusCode().value()).isEqualTo(200);
54+
// The silently-capped lease is exactly what remaining_ttl_ms must
55+
// expose — the case that motivated the field (a delayed first
56+
// heartbeat outlives the real lease).
57+
assertThat(((Number) resp.getBody().get("remaining_ttl_ms")).longValue())
58+
.isEqualTo(10_000L);
59+
}
60+
61+
@Test
62+
void dryRunResponseOmitsRemaining() {
63+
Map<String, Object> body = reservationBody(TENANT_A, 1000);
64+
body.put("dry_run", true);
65+
66+
ResponseEntity<Map> resp = post("/v1/reservations", API_KEY_SECRET_A, body);
67+
68+
assertThat(resp.getStatusCode().value()).isEqualTo(200);
69+
assertThat(resp.getBody()).doesNotContainKey("remaining_ttl_ms");
70+
assertThat(resp.getBody()).doesNotContainKey("reservation_id");
71+
}
72+
73+
@Test
74+
void idempotentReplayReturnsOriginalRemainingVerbatim() throws Exception {
75+
Map<String, Object> body = reservationBody(TENANT_A, 1000);
76+
ResponseEntity<Map> first = post("/v1/reservations", API_KEY_SECRET_A, body);
77+
assertThat(first.getStatusCode().value()).isEqualTo(200);
78+
long original = ((Number) first.getBody().get("remaining_ttl_ms")).longValue();
79+
80+
Thread.sleep(1_200);
81+
ResponseEntity<Map> replay = post("/v1/reservations", API_KEY_SECRET_A, body);
82+
83+
assertThat(replay.getStatusCode().value()).isEqualTo(200);
84+
assertThat(replay.getBody().get("reservation_id"))
85+
.isEqualTo(first.getBody().get("reservation_id"));
86+
// Reserve replays are the ORIGINAL body verbatim (the evidence
87+
// envelope references it), so remaining reflects the original
88+
// evaluation — documented in the spec field description.
89+
assertThat(((Number) replay.getBody().get("remaining_ttl_ms")).longValue())
90+
.isEqualTo(original);
91+
}
92+
}
93+
94+
@Nested
95+
@DisplayName("Extend responses")
96+
class ExtendResponses {
97+
98+
@Test
99+
void freshExtendCarriesRemainingOfNewLease() {
100+
String reservationId = createReservationAndGetId(TENANT_A, API_KEY_SECRET_A, 1000);
101+
102+
ResponseEntity<Map> resp = post(
103+
"/v1/reservations/" + reservationId + "/extend",
104+
API_KEY_SECRET_A, extendBody(30_000));
105+
106+
assertThat(resp.getStatusCode().value()).isEqualTo(200);
107+
long remaining = ((Number) resp.getBody().get("remaining_ttl_ms")).longValue();
108+
// New lease = leftover of the initial 60s + the 30s extension; the
109+
// request round-trip consumes a little of the initial lease.
110+
assertThat(remaining).isGreaterThan(80_000L).isLessThanOrEqualTo(90_000L);
111+
}
112+
113+
@Test
114+
void idempotentReplayRecomputesRemainingFresh() throws Exception {
115+
String reservationId = createReservationAndGetId(TENANT_A, API_KEY_SECRET_A, 1000);
116+
Map<String, Object> body = new HashMap<>();
117+
body.put("idempotency_key", UUID.randomUUID().toString());
118+
body.put("extend_by_ms", 30_000L);
119+
120+
ResponseEntity<Map> first = post(
121+
"/v1/reservations/" + reservationId + "/extend", API_KEY_SECRET_A, body);
122+
assertThat(first.getStatusCode().value()).isEqualTo(200);
123+
long expiresAt = ((Number) first.getBody().get("expires_at_ms")).longValue();
124+
long remaining1 = ((Number) first.getBody().get("remaining_ttl_ms")).longValue();
125+
126+
Thread.sleep(1_500);
127+
ResponseEntity<Map> replay = post(
128+
"/v1/reservations/" + reservationId + "/extend", API_KEY_SECRET_A, body);
129+
130+
assertThat(replay.getStatusCode().value()).isEqualTo(200);
131+
// Replay, not a double-extend: same expiry, extension_count still 1.
132+
assertThat(((Number) replay.getBody().get("expires_at_ms")).longValue())
133+
.isEqualTo(expiresAt);
134+
try (Jedis jedis = jedisPool.getResource()) {
135+
assertThat(jedis.hget("reservation:res_" + reservationId, "extension_count"))
136+
.isEqualTo("1");
137+
}
138+
// But remaining is FRESH: a heartbeat retrying a lost extend with
139+
// the same key schedules from this value; the cached one is stale
140+
// by the retry delay and would overshoot the real lease.
141+
long remaining2 = ((Number) replay.getBody().get("remaining_ttl_ms")).longValue();
142+
assertThat(remaining2).isLessThanOrEqualTo(remaining1 - 1_000L);
143+
assertThat(remaining2).isGreaterThan(0L);
144+
}
145+
}
146+
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -547,6 +547,7 @@ private ReservationCreateResponse buildReserveResponse(Map<String, Object> respo
547547
.scopePath(response.get("scope_path").toString())
548548
.reserved(new Amount(unit, parseLong(response.get("estimate_amount"))))
549549
.expiresAtMs(parseLong(response.get("expires_at")))
550+
.remainingTtlMs(parseNullableLong(response.get("remaining_ttl_ms")))
550551
.caps(caps)
551552
.balances(parseLuaBalances(response, unit))
552553
.preRemaining(parsePreRemaining(response))
@@ -1496,6 +1497,7 @@ public ReservationExtendResponse extendReservation(String reservationId, Reserva
14961497
return ReservationExtendResponse.builder()
14971498
.status(Enums.ExtendStatus.ACTIVE)
14981499
.expiresAtMs(((Number) response.get("expires_at_ms")).longValue())
1500+
.remainingTtlMs(parseNullableLong(response.get("remaining_ttl_ms")))
14991501
.balances(balances)
15001502
.build();
15011503
} catch (CyclesProtocolException e){

cycles-protocol-service/cycles-protocol-service-data/src/main/resources/lua/extend.lua

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,19 @@ if idempotency_key ~= "" and idempotency_key ~= nil and tenant ~= "" and tenant
2020
return cjson.encode({error = "IDEMPOTENCY_MISMATCH"})
2121
end
2222
end
23+
-- remaining_ttl_ms MUST be fresh even on replay: a heartbeat retrying
24+
-- a lost extend with the same key schedules its next beat from this
25+
-- value, and the cached one is stale by the retry delay. Recompute
26+
-- against the same Redis clock that stamped expires_at.
27+
local ok_cached, cached_obj = pcall(cjson.decode, cached)
28+
if ok_cached and type(cached_obj) == 'table' and tonumber(cached_obj['expires_at_ms']) then
29+
local rt = redis.call('TIME')
30+
local rnow = tonumber(rt[1]) * 1000 + math.floor(tonumber(rt[2]) / 1000)
31+
local rem = tonumber(cached_obj['expires_at_ms']) - rnow
32+
if rem < 0 then rem = 0 end
33+
cached_obj['remaining_ttl_ms'] = rem
34+
return cjson.encode(cached_obj)
35+
end
2336
return cached
2437
end
2538
end
@@ -134,6 +147,8 @@ end
134147
local result = cjson.encode({
135148
reservation_id = reservation_id,
136149
expires_at_ms = new_expires_at,
150+
-- Same clock snapshot as expires_at: the authoritative remaining lease.
151+
remaining_ttl_ms = new_expires_at - now,
137152
extended_at = now,
138153
estimate_unit = estimate_unit,
139154
balances = balances

cycles-protocol-service/cycles-protocol-service-data/src/main/resources/lua/reserve.lua

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,9 @@ local response = cjson.encode({
280280
reservation_id = reservation_id,
281281
state = "ACTIVE",
282282
expires_at = tostring(expires_at),
283+
-- Remaining lease at evaluation = the granted (possibly tenant-capped)
284+
-- ttl, measured on the same Redis TIME snapshot that set expires_at.
285+
remaining_ttl_ms = ttl_ms,
283286
affected_scopes = affected_scopes,
284287
balances = balances,
285288
-- Redis cjson emits numbers with only 14 significant digits. Preserve the

cycles-protocol-service/cycles-protocol-service-model/src/main/java/io/runcycles/protocol/model/ReservationCreateResponse.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ public class ReservationCreateResponse {
1616
@JsonProperty("reservation_id") private String reservationId;
1717
@NotNull @JsonProperty("affected_scopes") private List<String> affectedScopes;
1818
@JsonProperty("expires_at_ms") private Long expiresAtMs;
19+
/** Remaining reservation lifetime (ms) at response evaluation, same clock
20+
* snapshot as expires_at_ms. Absent on dry-run and DENY. On an idempotent
21+
* replay the body is the original, so this reflects the ORIGINAL evaluation. */
22+
@Min(0) @JsonProperty("remaining_ttl_ms") private Long remainingTtlMs;
1923
@JsonProperty("scope_path") private String scopePath;
2024
@Valid @JsonProperty("reserved") private Amount reserved;
2125
@Valid @JsonProperty("caps") private Caps caps;

cycles-protocol-service/cycles-protocol-service-model/src/main/java/io/runcycles/protocol/model/ReservationExtendResponse.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,5 +13,9 @@
1313
public class ReservationExtendResponse {
1414
@NotNull @JsonProperty("status") private Enums.ExtendStatus status;
1515
@NotNull @Min(0) @JsonProperty("expires_at_ms") private Long expiresAtMs;
16+
/** Remaining reservation lifetime (ms) at response evaluation, same clock
17+
* snapshot as expires_at_ms. Recomputed FRESH on idempotent replays (a
18+
* heartbeat retrying a lost extend schedules from this value). */
19+
@Min(0) @JsonProperty("remaining_ttl_ms") private Long remainingTtlMs;
1620
@Valid @JsonProperty("balances") private List<Balance> balances;
1721
}

cycles-protocol-service/pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
<module>cycles-protocol-service-api</module>
1919
</modules>
2020
<properties>
21-
<revision>0.1.25.58</revision>
21+
<revision>0.1.25.59</revision>
2222
<java.version>21</java.version>
2323
<maven.compiler.source>21</maven.compiler.source>
2424
<maven.compiler.target>21</maven.compiler.target>

docker-compose.full-stack.prod.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ services:
2424

2525
cycles-server:
2626
logging: *default-logging
27-
image: ghcr.io/runcycles/cycles-server:0.1.25.58
27+
image: ghcr.io/runcycles/cycles-server:0.1.25.59
2828
restart: unless-stopped
2929
ports:
3030
- "7878:7878"

0 commit comments

Comments
 (0)