Skip to content

Commit 9f5454b

Browse files
committed
feat(reservation): persist + project evidence refs (include=evidence, v0.1.25.37)
Implements cycles-protocol v0.1.25.9 (#117). Lets a consumer link a reservation to its signed CyclesEvidence envelope(s) without having captured the evidence_id off the original reserve/commit/release response. - persistEvidenceRef: fail-open follow-up HSET of <artifact>_evidence_id + _evidence_url onto the reservation hash right after the ref is stamped on the reserve/commit/release response (HSET preserves the Lua 30-day terminal TTL). - buildReservationSummary hydrates a new ReservationEvidence map (reserve / commit / release -> CyclesEvidenceRef); toSummary gates it on a new ReservationInclude.EVIDENCE token (?include=evidence), projection-only (not folded into FilterHasher). getReservation always carries it. - Degrades gracefully: null when evidence emission is disabled or for pre-feature reservations; half-written ref (id without url) ignored. Tests: ReservationEvidenceTest, ReservationIncludeTest +2, RedisReservationEvidenceLinkTest +6. Full mvn verify green across model/data/api, JaCoCo 95% gate met; contract + spec-coverage pass against #117 on main. Additive/non-breaking. AUDIT.md updated.
1 parent d64a66e commit 9f5454b

8 files changed

Lines changed: 315 additions & 1 deletion

File tree

AUDIT.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@
55

66
---
77

8+
### 2026-06-22 — v0.1.25.37: link reservations to their evidence via `include=evidence`
9+
10+
Implements cycles-protocol v0.1.25.9 (runcycles/cycles-protocol#117). The `cycles_evidence` ref previously rode only on the live reserve/commit/release response, so a reservation fetched later (e.g. by the admin dashboard) had no path back to its signed envelope — you had to have captured the `evidence_id` at the moment of the call. Now the server persists each computed ref onto the reservation and surfaces it via a new `evidence` projection.
11+
12+
`EvidenceEmitter.emit` already computes the `evidence_id` synchronously (when the server identity is configured), but only after the reserve/commit/release Lua runs — so the id can't be passed into the script. Instead `persistEvidenceRef` does a fail-open follow-up `HSET` of `<artifact>_evidence_id` + `<artifact>_evidence_url` onto the `reservation:res_*` hash right after the ref is stamped on the response (HSET preserves the terminal 30-day TTL set by Lua; a write failure logs and never fails the op). Both id and url are stored so hydration needs no server-id reconstruction. `buildReservationSummary` hydrates them into a new `ReservationEvidence` map (keyed `reserve`/`commit`/`release`, each a `CyclesEvidenceRef`); `toSummary` gates it on a new `ReservationInclude.EVIDENCE` token (`?include=evidence`) for symmetry with the metadata projections — projection-only, NOT folded into `FilterHasher`, so it never invalidates a cursor. The single-row `getReservation` always carries it. A reservation has at most a `reserve` entry plus one terminal (`commit` XOR `release`); a half-written ref (id without url) is ignored. Degrades gracefully: `null` when evidence emission is disabled or for pre-feature reservations (`NON_NULL` strips it).
13+
14+
New `ReservationEvidence` model + `ReservationInclude.EVIDENCE`. `ReservationEvidenceTest` (isEmpty + per-artifact refs), `ReservationIncludeTest` +2 (evidence token, all-three parse), `RedisReservationEvidenceLinkTest` +6 (hydrate reserve+commit, absent → null, half-written ignored, persist HSETs id+url, null-ref/null-id no-op, projection gated on include). Full `mvn verify` green across model/data/api, JaCoCo 95% gate met; contract + spec-coverage tests pass against cycles-protocol#117 (merged to main). Additive/non-breaking — clients that don't request `include=evidence` see byte-identical responses.
15+
816
### 2026-06-19 — v0.1.25.36: surface `committed` + opt-in metadata on `listReservations`
917

1018
Follow-up to v0.1.25.34/#197: the same fields surfaced on the single-row `getReservation` were dropped from the `GET /v1/reservations` list rows (runcycles/cycles-server#201). The list path already hydrated a full `ReservationDetail` per row (`buildReservationSummary`), but `toSummary` down-converted to `ReservationSummary` and discarded `committed`, `metadata`, and `committed_metadata` — so the data was read then thrown away.

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

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,9 @@ private void stampAndEmitEvidence(ReservationCreateResponse response,
355355
.evidenceId(ref.evidenceId())
356356
.cyclesEvidenceUrl(ref.cyclesEvidenceUrl())
357357
.build());
358+
// Record the ref on the reservation so it is linkable later via
359+
// include=evidence. Null reservation_id (dry_run) is a no-op.
360+
persistEvidenceRef(response.getReservationId(), "reserve", ref);
358361
}
359362
}
360363

@@ -413,6 +416,60 @@ private EvidenceEmitter.EvidenceRef emitLifecycleEvidence(String artifactType, S
413416
return evidenceEmitter.emit(artifactType, System.currentTimeMillis(), traceId, payloadBody);
414417
}
415418

419+
/**
420+
* Persist the just-computed evidence ref for an artifact onto the reservation
421+
* hash ({@code <artifact>_evidence_id} / {@code <artifact>_evidence_url}), so
422+
* {@code listReservations} / {@code getReservation} can surface it via
423+
* {@code include=evidence} — letting a consumer link a reservation to its
424+
* signed envelope(s) without having captured the id off the original
425+
* response. Both id and url are stored so hydration needs no server-id
426+
* reconstruction. No-op when the ref is null (evidence emission disabled) or
427+
* for dry-run (no reservation_id). Fail-open: a write failure is logged,
428+
* never thrown — it only degrades the evidence projection, never the op.
429+
*/
430+
private void persistEvidenceRef(String reservationId, String artifactType,
431+
EvidenceEmitter.EvidenceRef ref) {
432+
if (ref == null || reservationId == null || reservationId.isEmpty()) {
433+
return;
434+
}
435+
try (Jedis jedis = jedisPool.getResource()) {
436+
Map<String, String> f = new LinkedHashMap<>();
437+
f.put(artifactType + "_evidence_id", ref.evidenceId());
438+
f.put(artifactType + "_evidence_url", ref.cyclesEvidenceUrl());
439+
jedis.hset("reservation:res_" + reservationId, f);
440+
} catch (Exception e) {
441+
LOG.warn("Failed to persist {} evidence for reservation {}: {}",
442+
artifactType, reservationId, e.getMessage());
443+
}
444+
}
445+
446+
/**
447+
* Hydrate the {@code evidence} projection from the persisted
448+
* {@code <artifact>_evidence_id} / {@code _url} hash fields. Returns null
449+
* when no artifact has recorded evidence (NON_NULL then strips the field).
450+
*/
451+
private ReservationEvidence buildEvidence(Map<String, String> fields) {
452+
ReservationEvidence.ReservationEvidenceBuilder b = ReservationEvidence.builder();
453+
boolean any = false;
454+
for (String artifact : new String[] {"reserve", "commit", "release"}) {
455+
String id = fields.get(artifact + "_evidence_id");
456+
String url = fields.get(artifact + "_evidence_url");
457+
if (id == null || id.isEmpty() || url == null || url.isEmpty()) {
458+
continue;
459+
}
460+
CyclesEvidenceRef ref = CyclesEvidenceRef.builder()
461+
.evidenceId(id).cyclesEvidenceUrl(url).build();
462+
switch (artifact) {
463+
case "reserve" -> b.reserve(ref);
464+
case "commit" -> b.commit(ref);
465+
case "release" -> b.release(ref);
466+
default -> { /* unreachable */ }
467+
}
468+
any = true;
469+
}
470+
return any ? b.build() : null;
471+
}
472+
416473
/** Cache a finalized lifecycle response body (with evidence stamped) for verbatim replay,
417474
* keyed by {@code <artifact>:body:<reservation_id>}, 30-day TTL matching the terminal
418475
* reservation hash. Fail-open. */
@@ -872,6 +929,7 @@ public CommitResponse commitReservation(String reservationId, CommitRequest requ
872929
.evidenceId(ref.evidenceId())
873930
.cyclesEvidenceUrl(ref.cyclesEvidenceUrl())
874931
.build());
932+
persistEvidenceRef(reservationId, "commit", ref);
875933
}
876934
cacheLifecycleBody("commit", reservationId, committed);
877935
return committed;
@@ -970,6 +1028,7 @@ public ReleaseResponse releaseReservation(String reservationId, ReleaseRequest r
9701028
.evidenceId(ref.evidenceId())
9711029
.cyclesEvidenceUrl(ref.cyclesEvidenceUrl())
9721030
.build());
1031+
persistEvidenceRef(reservationId, "release", ref);
9731032
}
9741033
cacheLifecycleBody("release", reservationId, releasedResponse);
9751034
return releasedResponse;
@@ -1919,6 +1978,13 @@ private ReservationSummary toSummary(ReservationDetail detail, Set<ReservationIn
19191978
if (include.contains(ReservationInclude.COMMITTED_METADATA)) {
19201979
builder.committedMetadata(detail.getCommittedMetadata());
19211980
}
1981+
// evidence is the linkage from a reservation to its signed envelope(s);
1982+
// a small map of refs, but opt-in on list rows for symmetry with the
1983+
// other heavy projections. Always present on the single-row detail.
1984+
// Spec: cycles-protocol-v0.yaml revision 2026-06-22 (v0.1.25.9).
1985+
if (include.contains(ReservationInclude.EVIDENCE)) {
1986+
builder.evidence(detail.getEvidence());
1987+
}
19221988
return builder.build();
19231989
}
19241990

@@ -1994,6 +2060,11 @@ private ReservationDetail buildReservationSummary(Map<String, String> fields) th
19942060
detail.setExpiresAtMs(Long.parseLong(expiresAtStr));
19952061
detail.setScopePath(fields.get("scope_path"));
19962062
detail.setAffectedScopes(affectedScopes);
2063+
// Evidence refs recorded at reserve/commit/release time (spec v0.1.25.9).
2064+
// Always hydrated onto the detail; listReservations strips it in toSummary
2065+
// unless include=evidence. Null when the reservation has no recorded
2066+
// evidence (emission disabled, or pre-evidence reservation).
2067+
detail.setEvidence(buildEvidence(fields));
19972068
return detail;
19982069
}
19992070

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
package io.runcycles.protocol.data.repository;
2+
3+
import io.runcycles.protocol.data.service.EvidenceEmitter;
4+
import io.runcycles.protocol.model.*;
5+
import org.junit.jupiter.api.DisplayName;
6+
import org.junit.jupiter.api.Test;
7+
8+
import java.lang.reflect.Method;
9+
import java.util.EnumSet;
10+
import java.util.Map;
11+
import java.util.Set;
12+
13+
import static org.assertj.core.api.Assertions.assertThat;
14+
import static org.mockito.ArgumentMatchers.*;
15+
import static org.mockito.Mockito.*;
16+
17+
/**
18+
* Covers the reservation -> evidence linkage (spec v0.1.25.9): persisting the
19+
* computed evidence ref onto the reservation hash, hydrating it back into the
20+
* {@code evidence} field, and the {@code include=evidence} list projection.
21+
*/
22+
@DisplayName("RedisReservationRepository — evidence linkage")
23+
class RedisReservationEvidenceLinkTest extends BaseRedisReservationRepositoryTest {
24+
25+
private static final String HEX_A = "a".repeat(64);
26+
private static final String HEX_B = "b".repeat(64);
27+
private static final String HEX_C = "c".repeat(64);
28+
29+
// ---- hydration (getReservationById builds detail.evidence) ----
30+
31+
@Test
32+
@DisplayName("getReservationById hydrates reserve + commit evidence refs from the hash")
33+
void hydratesEvidence() {
34+
when(jedisPool.getResource()).thenReturn(jedis);
35+
doNothing().when(jedis).close();
36+
Map<String, String> fields = reservationFields("res-ev", "COMMITTED");
37+
fields.put("charged_amount", "3000");
38+
fields.put("reserve_evidence_id", HEX_A);
39+
fields.put("reserve_evidence_url", "http://h/v1/evidence/" + HEX_A);
40+
fields.put("commit_evidence_id", HEX_B);
41+
fields.put("commit_evidence_url", "http://h/v1/evidence/" + HEX_B);
42+
when(jedis.hgetAll("reservation:res_res-ev")).thenReturn(fields);
43+
44+
ReservationDetail detail = repository.getReservationById("res-ev");
45+
46+
assertThat(detail.getEvidence()).isNotNull();
47+
assertThat(detail.getEvidence().getReserve().getEvidenceId()).isEqualTo(HEX_A);
48+
assertThat(detail.getEvidence().getReserve().getCyclesEvidenceUrl())
49+
.isEqualTo("http://h/v1/evidence/" + HEX_A);
50+
assertThat(detail.getEvidence().getCommit().getEvidenceId()).isEqualTo(HEX_B);
51+
assertThat(detail.getEvidence().getRelease()).isNull();
52+
}
53+
54+
@Test
55+
@DisplayName("getReservationById leaves evidence null when no refs were recorded")
56+
void noEvidenceWhenAbsent() {
57+
when(jedisPool.getResource()).thenReturn(jedis);
58+
doNothing().when(jedis).close();
59+
when(jedis.hgetAll("reservation:res_bare")).thenReturn(reservationFields("bare", "ACTIVE"));
60+
61+
assertThat(repository.getReservationById("bare").getEvidence()).isNull();
62+
}
63+
64+
@Test
65+
@DisplayName("a half-written ref (id without url) is ignored")
66+
void partialRefIgnored() {
67+
when(jedisPool.getResource()).thenReturn(jedis);
68+
doNothing().when(jedis).close();
69+
Map<String, String> fields = reservationFields("res-half", "ACTIVE");
70+
fields.put("reserve_evidence_id", HEX_A); // url missing
71+
when(jedis.hgetAll("reservation:res_res-half")).thenReturn(fields);
72+
73+
assertThat(repository.getReservationById("res-half").getEvidence()).isNull();
74+
}
75+
76+
// ---- persistence (persistEvidenceRef writes id + url) ----
77+
78+
@Test
79+
@DisplayName("persistEvidenceRef HSETs both id and url onto the reservation hash")
80+
void persistsRef() throws Exception {
81+
when(jedisPool.getResource()).thenReturn(jedis);
82+
doNothing().when(jedis).close();
83+
EvidenceEmitter.EvidenceRef ref =
84+
new EvidenceEmitter.EvidenceRef(HEX_C, "http://h/v1/evidence/" + HEX_C);
85+
86+
invokePersist("res-p", "commit", ref);
87+
88+
verify(jedis).hset(eq("reservation:res_res-p"), argThat((Map<String, String> m) ->
89+
HEX_C.equals(m.get("commit_evidence_id"))
90+
&& ("http://h/v1/evidence/" + HEX_C).equals(m.get("commit_evidence_url"))));
91+
}
92+
93+
@Test
94+
@DisplayName("persistEvidenceRef is a no-op for a null ref or null reservation id")
95+
void persistNoOp() throws Exception {
96+
invokePersist("res-p", "reserve", null);
97+
invokePersist(null, "reserve",
98+
new EvidenceEmitter.EvidenceRef(HEX_C, "http://h/v1/evidence/" + HEX_C));
99+
// Never touched the pool / wrote anything.
100+
verify(jedisPool, never()).getResource();
101+
verify(jedis, never()).hset(anyString(), anyMap());
102+
}
103+
104+
// ---- projection (toSummary gates evidence on include=evidence) ----
105+
106+
@Test
107+
@DisplayName("toSummary projects evidence only when include=evidence")
108+
void projectionGated() throws Exception {
109+
ReservationDetail detail = new ReservationDetail();
110+
detail.setReservationId("res-x");
111+
detail.setEvidence(ReservationEvidence.builder()
112+
.reserve(CyclesEvidenceRef.builder().evidenceId(HEX_A)
113+
.cyclesEvidenceUrl("http://h/v1/evidence/" + HEX_A).build())
114+
.build());
115+
116+
assertThat(invokeToSummary(detail, EnumSet.of(ReservationInclude.EVIDENCE)).getEvidence())
117+
.isNotNull();
118+
assertThat(invokeToSummary(detail, EnumSet.noneOf(ReservationInclude.class)).getEvidence())
119+
.isNull();
120+
}
121+
122+
// ---- reflection helpers ----
123+
124+
private void invokePersist(String id, String artifact, EvidenceEmitter.EvidenceRef ref) throws Exception {
125+
Method m = RedisReservationRepository.class.getDeclaredMethod(
126+
"persistEvidenceRef", String.class, String.class, EvidenceEmitter.EvidenceRef.class);
127+
m.setAccessible(true);
128+
m.invoke(repository, id, artifact, ref);
129+
}
130+
131+
private ReservationSummary invokeToSummary(ReservationDetail detail, Set<ReservationInclude> include)
132+
throws Exception {
133+
Method m = RedisReservationRepository.class.getDeclaredMethod(
134+
"toSummary", ReservationDetail.class, Set.class);
135+
m.setAccessible(true);
136+
return (ReservationSummary) m.invoke(repository, detail, include);
137+
}
138+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package io.runcycles.protocol.model;
2+
3+
import com.fasterxml.jackson.annotation.*;
4+
import jakarta.validation.Valid;
5+
import lombok.*;
6+
7+
/**
8+
* Map of artifact type to the CyclesEvidence reference emitted for that
9+
* operation on a reservation (cycles-protocol-v0.yaml revision 2026-06-22,
10+
* v0.1.25.9). Lets a consumer jump from a reservation straight to its signed
11+
* envelope(s) via {@code getEvidence} without having captured the
12+
* {@code evidence_id} off the original reserve / commit / release response.
13+
*
14+
* <p>A reservation has at most a {@code reserve} entry plus one terminal entry
15+
* ({@code commit} XOR {@code release}). TRANSPORT METADATA, NOT ATTESTED (see
16+
* {@link CyclesEvidenceRef}) — each entry is recorded after its artifact's
17+
* {@code evidence_id} was computed. {@code NON_NULL} strips absent artifacts;
18+
* the whole object is absent when the reservation has no recorded evidence
19+
* (emission disabled, or the reservation predates evidence support).
20+
*/
21+
@Data @Builder @NoArgsConstructor @AllArgsConstructor
22+
@JsonInclude(JsonInclude.Include.NON_NULL)
23+
public class ReservationEvidence {
24+
@Valid @JsonProperty("reserve") private CyclesEvidenceRef reserve;
25+
@Valid @JsonProperty("commit") private CyclesEvidenceRef commit;
26+
@Valid @JsonProperty("release") private CyclesEvidenceRef release;
27+
28+
/** True when no artifact ref is set — used to avoid attaching an empty map. */
29+
@JsonIgnore
30+
public boolean isEmpty() {
31+
return reserve == null && commit == null && release == null;
32+
}
33+
}

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,11 @@
1717
*/
1818
public enum ReservationInclude {
1919
METADATA("metadata"),
20-
COMMITTED_METADATA("committed_metadata");
20+
COMMITTED_METADATA("committed_metadata"),
21+
/** Project ReservationSummary.evidence (CyclesEvidence refs for the
22+
* reservation's reserve/commit/release operations). Spec revision
23+
* 2026-06-22 (v0.1.25.9). */
24+
EVIDENCE("evidence");
2125

2226
private final String wire;
2327

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,4 +43,12 @@ public class ReservationSummary {
4343
* (include=committed_metadata). Spec: cycles-protocol-v0.yaml revision
4444
* 2026-06-19 (cycles-server#197 read-path, #201 list projection). */
4545
@JsonProperty("committed_metadata") private Map<String, Object> committedMetadata;
46+
/** CyclesEvidence references for this reservation's reserve / commit /
47+
* release operations, keyed by artifact type — lets a consumer resolve the
48+
* signed envelope(s) via getEvidence without having captured the
49+
* evidence_id off the original response. Always present (when recorded) on
50+
* the single-row ReservationDetail; on listReservations it is OMITTED BY
51+
* DEFAULT and projected only when the caller opts in via include=evidence.
52+
* Spec: cycles-protocol-v0.yaml revision 2026-06-22 (v0.1.25.9). */
53+
@Valid @JsonProperty("evidence") private ReservationEvidence evidence;
4654
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package io.runcycles.protocol.model;
2+
3+
import org.junit.jupiter.api.DisplayName;
4+
import org.junit.jupiter.api.Test;
5+
6+
import static org.assertj.core.api.Assertions.assertThat;
7+
8+
@DisplayName("ReservationEvidence")
9+
class ReservationEvidenceTest {
10+
11+
private static CyclesEvidenceRef ref(String hex) {
12+
return CyclesEvidenceRef.builder()
13+
.evidenceId(hex).cyclesEvidenceUrl("http://h/v1/evidence/" + hex).build();
14+
}
15+
16+
@Test
17+
@DisplayName("isEmpty is true only when no artifact ref is set")
18+
void isEmpty() {
19+
assertThat(ReservationEvidence.builder().build().isEmpty()).isTrue();
20+
assertThat(ReservationEvidence.builder().reserve(ref("a".repeat(64))).build().isEmpty())
21+
.isFalse();
22+
assertThat(ReservationEvidence.builder().commit(ref("b".repeat(64))).build().isEmpty())
23+
.isFalse();
24+
assertThat(ReservationEvidence.builder().release(ref("c".repeat(64))).build().isEmpty())
25+
.isFalse();
26+
}
27+
28+
@Test
29+
@DisplayName("holds a ref per artifact type")
30+
void holdsRefs() {
31+
ReservationEvidence e = ReservationEvidence.builder()
32+
.reserve(ref("a".repeat(64)))
33+
.commit(ref("b".repeat(64)))
34+
.build();
35+
assertThat(e.getReserve().getEvidenceId()).isEqualTo("a".repeat(64));
36+
assertThat(e.getCommit().getEvidenceId()).isEqualTo("b".repeat(64));
37+
assertThat(e.getRelease()).isNull();
38+
}
39+
}

0 commit comments

Comments
 (0)