Skip to content

Commit e41ca0f

Browse files
fix(server): withhold the claim that fails its evidence check, not the review (#1512)
A quote that does not verify now discredits only its own claim, via a dedicated EvidenceQuoteUnverifiedException caught per observation; every other refusal in the evidence gate — unstaged source, malformed citation, wrong attribution — still fails the whole delivery. Occurrence keys index the submitted position so a retry with a different admitted set cannot renumber them. Logs when every negative claim is withheld, since the remainder reads as an all-clear.
1 parent c96eae4 commit e41ca0f

7 files changed

Lines changed: 165 additions & 37 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"hephaestus": patch
3+
---
4+
5+
A review whose evidence check fails on one observation now delivers the others. The check that refuses to show a developer a claim it cannot trace back to the code applied to the whole review at once: one practice that mis-quoted its source — by a stray character, in a file the reader never sees — withheld every other finding in that review, including correct, fully evidenced ones. The developer saw nothing at all. Only a quote that does not match its source is treated this way; a citation to evidence the review never gathered still stops the whole delivery, as before. What was withheld is logged with the reason, and a review in which no claim can be verified still fails rather than arriving empty.

server/src/main/java/de/tum/cit/aet/hephaestus/agent/handler/PracticeDetectionDeliveryService.java

Lines changed: 61 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import de.tum.cit.aet.hephaestus.agent.conversation.ConversationSourceLiveness;
55
import de.tum.cit.aet.hephaestus.agent.documentation.DocumentProjection;
66
import de.tum.cit.aet.hephaestus.agent.handler.PracticeDetectionResultParser.ValidatedObservation;
7+
import de.tum.cit.aet.hephaestus.agent.handler.spi.EvidenceQuoteUnverifiedException;
78
import de.tum.cit.aet.hephaestus.agent.handler.spi.JobDeliveryException;
89
import de.tum.cit.aet.hephaestus.agent.job.AgentJob;
910
import de.tum.cit.aet.hephaestus.agent.runtime.ProvenanceDigest;
@@ -33,9 +34,9 @@
3334
import de.tum.cit.aet.hephaestus.practices.observation.PracticeDetectionCompletedEvent;
3435
import java.nio.charset.StandardCharsets;
3536
import java.time.Instant;
37+
import java.util.ArrayList;
3638
import java.util.HashMap;
3739
import java.util.HashSet;
38-
import java.util.IdentityHashMap;
3940
import java.util.List;
4041
import java.util.Map;
4142
import java.util.Set;
@@ -144,7 +145,15 @@ public DeliveryResult deliver(AgentJob job, List<ValidatedObservation> validObse
144145
}
145146
Target target = resolveTarget(job, metadata);
146147
Map<String, PracticeRevision> revisionsBySlug = admittedRevisions(job, workspaceId);
147-
for (ValidatedObservation observation : validObservations) {
148+
// A quote that does not verify discredits its own claim, and only EvidenceQuoteUnverifiedException
149+
// means that. Every other refusal here — an unstaged source, a malformed citation, work attributed
150+
// to the wrong person — impugns the run, so it stays fatal.
151+
List<Integer> admittedIndexes = new ArrayList<>(validObservations.size());
152+
List<ValidatedObservation> admittedObservations = new ArrayList<>(validObservations.size());
153+
List<String> withheldObservations = new ArrayList<>();
154+
boolean withheldNegative = false;
155+
for (int submittedIndex = 0; submittedIndex < validObservations.size(); submittedIndex++) {
156+
ValidatedObservation observation = validObservations.get(submittedIndex);
148157
PracticeRevision revision = revisionsBySlug.get(observation.practiceSlug());
149158
if (revision == null) {
150159
throw new JobDeliveryException(
@@ -155,7 +164,42 @@ public DeliveryResult deliver(AgentJob job, List<ValidatedObservation> validObse
155164
);
156165
}
157166
enforceAttribution(observation, revision, job);
158-
enforceEvidenceBoundary(observation, revision, evidenceBoundary, job);
167+
try {
168+
enforceEvidenceBoundary(observation, revision, evidenceBoundary, job);
169+
admittedIndexes.add(submittedIndex);
170+
admittedObservations.add(observation);
171+
} catch (EvidenceQuoteUnverifiedException ex) {
172+
withheldNegative |= observation.assessment() == Assessment.BAD;
173+
withheldObservations.add(observation.practiceSlug() + ": " + ex.getMessage());
174+
}
175+
}
176+
if (!withheldObservations.isEmpty()) {
177+
// Per claim, because a model that cannot quote its own evidence is a defect an otherwise
178+
// successful delivery would hide.
179+
log.warn(
180+
"Withheld {} of {} observation(s) whose quoted evidence did not verify, delivering the rest: jobId={} withheld={}",
181+
withheldObservations.size(),
182+
validObservations.size(),
183+
job.getId(),
184+
withheldObservations
185+
);
186+
// Withholding the only fault leaves an all-clear standing over a defect the model did find,
187+
// which is a different statement to the reader than an incomplete review.
188+
if (withheldNegative && admittedObservations.stream().noneMatch(o -> o.assessment() == Assessment.BAD)) {
189+
log.error(
190+
"Withheld every negative observation; the remaining claims read as an all-clear: jobId={}",
191+
job.getId()
192+
);
193+
}
194+
}
195+
// Only when there was something to admit: a review that found nothing still publishes its zero.
196+
if (admittedObservations.isEmpty() && !validObservations.isEmpty()) {
197+
throw new JobDeliveryException(
198+
"No observation survived the evidence check, so there is nothing to deliver: jobId=" +
199+
job.getId() +
200+
", withheld=" +
201+
withheldObservations
202+
);
159203
}
160204

161205
ObservationOrigin origin = originOf(metadata);
@@ -171,20 +215,22 @@ public DeliveryResult deliver(AgentJob job, List<ValidatedObservation> validObse
171215
boolean hasNegative = false;
172216
Instant observedAt = Instant.now();
173217

174-
// Keyed by observation identity because equal observations still represent distinct occurrences.
175-
Map<ValidatedObservation, ObservationKeys> observationKeys = new IdentityHashMap<>();
218+
// Carries the keys each observation was persisted under.
219+
List<ValidatedObservation> deliveredObservations = new ArrayList<>(admittedObservations.size());
176220

177-
for (int i = 0; i < validObservations.size(); i++) {
178-
ValidatedObservation observation = validObservations.get(i);
221+
for (int i = 0; i < admittedObservations.size(); i++) {
222+
ValidatedObservation observation = admittedObservations.get(i);
179223

180224
PracticeRevision revision = revisionsBySlug.get(observation.practiceSlug());
181225
Practice practice = revision.getPractice();
182226

183-
// Includes the index so distinct observations for the same practice on one artifact don't collide.
227+
// The position the observation was SUBMITTED at, not its position among those admitted: this key
228+
// is a retry's dedup grain, so a claim withheld on one attempt and not the next must not renumber
229+
// the claims after it into keys that miss what is already stored.
184230
String occurrenceKey =
185231
observation.practiceSlug() +
186232
":" +
187-
i +
233+
admittedIndexes.get(i) +
188234
":" +
189235
artifactKind.value() +
190236
":" +
@@ -211,7 +257,7 @@ public DeliveryResult deliver(AgentJob job, List<ValidatedObservation> validObse
211257
aboutUserId,
212258
firstLocationPath(observation.evidence())
213259
);
214-
observationKeys.put(observation, new ObservationKeys(occurrenceKey, recurrenceKey));
260+
deliveredObservations.add(observation.withKeys(new ObservationKeys(occurrenceKey, recurrenceKey)));
215261

216262
Long practiceRevisionId = revision.getId();
217263

@@ -274,7 +320,7 @@ public DeliveryResult deliver(AgentJob job, List<ValidatedObservation> validObse
274320
)
275321
);
276322

277-
return new DeliveryResult(inserted, discardedDuplicate, hasNegative, observationKeys);
323+
return new DeliveryResult(inserted, discardedDuplicate, hasNegative, deliveredObservations);
278324
}
279325

280326
/**
@@ -419,7 +465,7 @@ private void enforceEvidenceBoundary(
419465
);
420466
String artifactContent = new String(content, StandardCharsets.UTF_8);
421467
if (!"scm.pull-request.diff".equals(kind.value()) && !artifactContent.contains(exactQuote)) {
422-
throw new JobDeliveryException(
468+
throw new EvidenceQuoteUnverifiedException(
423469
"Evidence quote does not occur in the cited artifact: path=" +
424470
artifactPath.asText() +
425471
", jobId=" +
@@ -445,7 +491,7 @@ private void enforceEvidenceBoundary(
445491
exactQuote
446492
))
447493
) {
448-
throw new JobDeliveryException(
494+
throw new EvidenceQuoteUnverifiedException(
449495
"Evidence quote does not match the cited diff location: path=" +
450496
path.asText() +
451497
", line=" +
@@ -996,14 +1042,11 @@ static String firstLocationPath(JsonNode evidence) {
9961042
return path != null && path.isString() ? path.asString() : null;
9971043
}
9981044

999-
/**
1000-
* @param observationKeys the keys persisted for each observation, by identity, so the caller stamps the same
1001-
* keys onto its deliverable observations instead of recomputing them
1002-
*/
1045+
/** @param delivered what this call persisted, each carrying the keys it was stored under. */
10031046
public record DeliveryResult(
10041047
int inserted,
10051048
int discardedDuplicate,
10061049
boolean hasNegative,
1007-
Map<ValidatedObservation, ObservationKeys> observationKeys
1050+
List<ValidatedObservation> delivered
10081051
) {}
10091052
}

server/src/main/java/de/tum/cit/aet/hephaestus/agent/handler/PullRequestReviewHandler.java

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -494,16 +494,9 @@ private void processObservations(AgentJob job, JsonNode output, boolean admissio
494494
throw new JobDeliveryException("Delivery failed unexpectedly: jobId=" + job.getId(), e);
495495
}
496496

497-
// Stamp each observation with the exact keys deliver() persisted, by identity, so downstream stages
498-
// address the stored observation without recomputing a key that could drift.
499-
Map<PracticeDetectionResultParser.ValidatedObservation, ObservationKeys> keysByObservation =
500-
result.observationKeys();
501-
for (int i = 0; i < scopedObservations.size(); i++) {
502-
scopedObservations.set(
503-
i,
504-
scopedObservations.get(i).withKeys(keysByObservation.get(scopedObservations.get(i)))
505-
);
506-
}
497+
// What deliver() persisted, carrying the keys it stored them under, so a later stage addresses the
498+
// stored observation rather than recomputing a key that could drift.
499+
scopedObservations = new ArrayList<>(result.delivered());
507500

508501
if (admissionOnly) return;
509502

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package de.tum.cit.aet.hephaestus.agent.handler.spi;
2+
3+
/**
4+
* An observation quoted evidence that does not appear where it said the evidence was.
5+
*
6+
* <p>Distinct from every other refusal in the evidence gate because it is the one that says nothing
7+
* about the run: the sources were staged, the citation was well formed and authorized, and the model
8+
* simply did not reproduce what it read. That discredits the claim and no other, so it is caught per
9+
* observation. Every other refusal — an unstaged source, a malformed citation, a missing search —
10+
* means the run itself cannot be trusted, and stays fatal.
11+
*/
12+
public class EvidenceQuoteUnverifiedException extends JobDeliveryException {
13+
14+
public EvidenceQuoteUnverifiedException(String message) {
15+
super(message);
16+
}
17+
}

server/src/test/java/de/tum/cit/aet/hephaestus/agent/handler/PracticeDetectionDeliveryServiceIntegrationTest.java

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -299,7 +299,7 @@ void validObservationsPersistedToDb() {
299299
}
300300

301301
@Test
302-
@DisplayName("returned observationKeys align exactly with the persisted recurrence_key set")
302+
@DisplayName("returned delivered observations align exactly with the persisted recurrence_key set")
303303
void returnedFingerprintsMatchPersistedRecurrenceKeys() {
304304
var observations = List.of(
305305
observation("pr-description-quality", Presence.PRESENT),
@@ -308,7 +308,13 @@ void returnedFingerprintsMatchPersistedRecurrenceKeys() {
308308

309309
var result = deliveryService.deliver(agentJob, observations);
310310

311-
assertThat(result.observationKeys().values().stream().map(ObservationKeys::recurrenceKey).toList())
311+
assertThat(
312+
result
313+
.delivered()
314+
.stream()
315+
.map(o -> o.recurrenceKey())
316+
.toList()
317+
)
312318
.as("one stable key returned per delivered observation")
313319
.hasSize(2)
314320
.allMatch(k -> k != null && k.matches("[0-9a-f]{64}"));
@@ -321,7 +327,11 @@ void returnedFingerprintsMatchPersistedRecurrenceKeys() {
321327
assertThat(persistedKeys)
322328
.as("every returned fingerprint is persisted as a recurrence_key, and vice versa")
323329
.containsExactlyInAnyOrderElementsOf(
324-
result.observationKeys().values().stream().map(ObservationKeys::recurrenceKey).toList()
330+
result
331+
.delivered()
332+
.stream()
333+
.map(o -> o.recurrenceKey())
334+
.toList()
325335
);
326336
}
327337

server/src/test/java/de/tum/cit/aet/hephaestus/agent/handler/PracticeDetectionDeliveryServiceTest.java

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,64 @@ void rejectsAQuoteThatIsNotInTheCitedArtifact() {
390390
verifyNoInteractions(observationRepository);
391391
}
392392

393+
@Test
394+
@DisplayName("only the claim whose quote does not verify is withheld; the other is delivered")
395+
void withholdsOnlyTheObservationWhoseQuoteDoesNotVerify() {
396+
Practice second = new Practice();
397+
ReflectionTestUtils.setField(second, "id", 20L);
398+
second.setSlug("pr-scope");
399+
second.setBindings(PracticeTestEvidence.bindings(ArtifactKinds.PULL_REQUEST));
400+
second.setAutomatedReviewPolicy(PracticeTestEvidence.forArtifact(ArtifactKinds.PULL_REQUEST));
401+
admit(second, 21L);
402+
403+
ValidatedObservation sound = validObservation("pr-description-quality", Presence.PRESENT);
404+
ValidatedObservation misquoted = validObservation("pr-scope", Presence.PRESENT);
405+
((ObjectNode) misquoted.evidence().withArray("citations").get(0)).put("quote", "+ insecure();,");
406+
407+
var result = service.deliver(testJob, List.of(sound, misquoted));
408+
409+
assertThat(result.delivered())
410+
.as("the claim that verified is the one persisted, and it is the only one")
411+
.extracting(ValidatedObservation::practiceSlug)
412+
.containsExactly("pr-description-quality");
413+
assertThat(result.inserted()).isEqualTo(1);
414+
}
415+
416+
@Test
417+
@DisplayName("a citation to an unstaged source still fails the whole delivery, even beside a sound claim")
418+
void anEvidenceFailureThatIsNotAQuoteMismatchStillFailsEverything() {
419+
Practice second = new Practice();
420+
ReflectionTestUtils.setField(second, "id", 20L);
421+
second.setSlug("pr-scope");
422+
second.setBindings(PracticeTestEvidence.bindings(ArtifactKinds.PULL_REQUEST));
423+
second.setAutomatedReviewPolicy(PracticeTestEvidence.forArtifact(ArtifactKinds.PULL_REQUEST));
424+
admit(second, 21L);
425+
426+
ValidatedObservation sound = validObservation("pr-description-quality", Presence.PRESENT);
427+
ValidatedObservation unstaged = validObservation("pr-scope", Presence.PRESENT);
428+
ObjectNode citation = (ObjectNode) unstaged.evidence().withArray("citations").get(0);
429+
citation.put("sourceKind", "scm.repository.tree");
430+
citation.remove("side");
431+
432+
assertThatThrownBy(() -> service.deliver(testJob, List.of(sound, unstaged)))
433+
.as("an unstaged source impugns the run, not just the claim that cited it")
434+
.isInstanceOf(JobDeliveryException.class)
435+
.hasMessageContaining("misattributed evidence source");
436+
verifyNoInteractions(observationRepository);
437+
}
438+
439+
@Test
440+
@DisplayName("a batch in which no quote verifies is still a failed delivery")
441+
void refusesTheDeliveryWhenNoObservationSurvivesAdmission() {
442+
ValidatedObservation misquoted = validObservation("pr-description-quality", Presence.PRESENT);
443+
((ObjectNode) misquoted.evidence().withArray("citations").get(0)).put("quote", "fabricated quote");
444+
445+
assertThatThrownBy(() -> service.deliver(testJob, List.of(misquoted)))
446+
.isInstanceOf(JobDeliveryException.class)
447+
.hasMessageContaining("No observation survived the evidence check");
448+
verifyNoInteractions(observationRepository);
449+
}
450+
393451
@Test
394452
void acceptsASecretScannerCitationWithoutPersistingTheSecret() {
395453
ValidatedObservation observation = validObservation("pr-description-quality", Presence.PRESENT);
@@ -833,7 +891,7 @@ void persistsValidObservation() {
833891
assertThat(fingerprintCaptor.getValue())
834892
.as("persisted recurrence_key matches the returned findingFingerprint")
835893
.matches("[0-9a-f]{64}")
836-
.isEqualTo(result.observationKeys().values().iterator().next().recurrenceKey());
894+
.isEqualTo(result.delivered().get(0).keys().recurrenceKey());
837895

838896
verify(eventPublisher).publishEvent(eventCaptor.capture());
839897
PracticeDetectionCompletedEvent event = eventCaptor.getValue();

server/src/test/java/de/tum/cit/aet/hephaestus/agent/handler/PullRequestReviewHandlerTest.java

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -464,7 +464,9 @@ void delegatesToDeliveryService() {
464464
}
465465
""";
466466
AgentJob job = jobWithOutput(rawOutput);
467-
when(deliveryService.deliver(eq(job), any())).thenReturn(new DeliveryResult(1, 0, false, Map.of()));
467+
when(deliveryService.deliver(eq(job), any())).thenAnswer(inv ->
468+
new DeliveryResult(1, 0, false, inv.getArgument(1))
469+
);
468470

469471
admit(job, rawOutput);
470472

@@ -499,8 +501,8 @@ void hardcodedSecretUsesPracticeSeverityCap() {
499501
ArgumentCaptor<List<PracticeDetectionResultParser.ValidatedObservation>> captor = ArgumentCaptor.forClass(
500502
List.class
501503
);
502-
when(deliveryService.deliver(eq(job), captor.capture())).thenReturn(
503-
new DeliveryResult(1, 0, false, Map.of())
504+
when(deliveryService.deliver(eq(job), captor.capture())).thenAnswer(inv ->
505+
new DeliveryResult(1, 0, false, inv.getArgument(1))
504506
);
505507

506508
admit(job, rawOutput);
@@ -606,8 +608,8 @@ void injectsSecretFindingWhenModelAbstainsButDiffCommitsCredential() {
606608
ArgumentCaptor<List<PracticeDetectionResultParser.ValidatedObservation>> captor = ArgumentCaptor.forClass(
607609
List.class
608610
);
609-
when(deliveryService.deliver(eq(job), captor.capture())).thenReturn(
610-
new DeliveryResult(1, 0, false, Map.of())
611+
when(deliveryService.deliver(eq(job), captor.capture())).thenAnswer(inv ->
612+
new DeliveryResult(1, 0, false, inv.getArgument(1))
611613
);
612614

613615
admit(job, rawOutput);

0 commit comments

Comments
 (0)