Skip to content

Commit c2cb1c1

Browse files
NotYuShengclaude
andauthored
Evidence axes become facts; Identity owns the verdict (#499, #512) (#537)
* Evidence axes become facts; Identity owns the verdict (#499, #512) Adjudication explainability (slices 3-5, #512): shared human_override and manual_evidence tables keyed by (question, file_id, entity_key) with actor attribution (CurrentActor, V36-V40), a generic override/evidence controller, and the reusable AdjudicationPanel — verdict, per-candidate Why (scores + reasons), "I disagree" override, weighted evidence-append, audit trail. Frontend model is now facts → votes → verdict: - The three evidence axes (Hardware / Ports-Service / Behaviour) render as read-only measured-fact rows — OUI manufacturer + TTL, confirmed service roles or nDPI-identified apps, and measured opened/answered connection counts (#496). No badges, no per-axis scores, no per-axis conclusions. - ScoreBoard reasons survive Scan → Adjudicate → DTO; the Identity panel's Why block shows every candidate type with its score and reasons. - The keyword-regex reason router, per-axis confidence display, per-axis adjudication panels, and NodeClassificationPopup are removed; the vestigial backend axis-override plumbing is tracked in #536. - Conflict banner compares observed service vs the Identity verdict and is suppressed once a human verdict resolves it. Node labels: analyst-assigned role labels (confirmed only) render under graph nodes, on by default, served by GET /files/{fileId}/node-roles. Role management gains a Remove button; label pickers open on "Select a label…" instead of Other…; persisted label configs migrate automatically. Review fixes: identity-less hosts keep adjudication tools; stale liveIdentity reset on node switch; 401 re-auth latch releases after a failed redirect; graph-load fetches parallelised; O(roles×nodes) MAC lookup replaced with a map; nDPI apps credited to both endpoints; shell script exec bits restored. Sphinx docs updated to the new model. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Address Gemini review on #537 - Evidence update/delete now validate the addressed (question, fileId, entityKey) against the loaded row; a mismatched id is a 404, so an id cannot be edited through another entity's URL (IDOR). - AdjudicationPanel: action failures surface in a danger Alert via a shared runAction wrapper instead of rejecting silently; open editors, drafts and errors reset when the panel switches entities; LabelField's dropdown mode is derived from the value each render so it can no longer desync from an externally changed value. - Role-label MAC lookup normalises both sides to lowercase. - fromMachine gets the same runner-up != winner guard as machineCandidates so a degenerate runner-up cannot double a candidate's score. Two remaining comments were verified inaccurate (NODE_TYPE_CONFIG is a total Record over NodeType; carryForwardAndValidate already carries @transactional) — refuted in-thread, no change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent d7395fb commit c2cb1c1

63 files changed

Lines changed: 3671 additions & 582 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CLAUDE.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ Current SGDS usage: `Container`, `Row`, `Col`, `Card`, `Modal`, `Pagination`.
88

99
Only build a custom component if SGDS has no equivalent.
1010

11+
### Popups & info affordances
12+
13+
- **Explainer / help content behind a `bi bi-info-circle`** must open an **SGDS `Modal`** (or, for a small inline hint, a click-toggled help block) — never rely on a native `title=` tooltip, which is unreliable and inconsistent across the app. The info-circle should be `role="button"` with `cursor: pointer` so it reads as clickable.
14+
- **Forms that create/edit a thing** (add evidence, override a label, add a snapshot) belong in an **SGDS `Modal`**, not an inline expanding panel — keep the surface uncluttered and the action focused.
15+
- Keep the copy inside these modals task-oriented: say what the thing is and how to act on it (e.g. "click a badge to inspect it and add evidence").
16+
1117
## Stack
1218

1319
- **Frontend**: React + TypeScript + Vite, served via nginx

backend/src/main/java/com/tracepcap/analysis/entity/HostClassificationEntity.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,4 +100,16 @@ public class HostClassificationEntity {
100100

101101
@Column(name = "runner_up_score")
102102
private Integer runnerUpScore;
103+
104+
/**
105+
* Newline-joined human-readable reasons the winning type scored (one per contributing signal,
106+
* e.g. "MAC OUI is Cisco (+40)"). Null pre-migration/override. Split back into a list on read —
107+
* this is the explainability trail the adjudicator carries out to the analyst.
108+
*/
109+
@Column(name = "winner_reasons", columnDefinition = "text")
110+
private String winnerReasons;
111+
112+
/** Newline-joined reasons the runner-up scored; null on walkover/override. */
113+
@Column(name = "runner_up_reasons", columnDefinition = "text")
114+
private String runnerUpReasons;
103115
}

backend/src/main/java/com/tracepcap/analysis/service/HostClassificationLookupAdapter.java

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,9 @@ public List<ClassifiedHost> classifiedHosts(UUID fileId) {
2828
e.getConfidence(),
2929
e.getWinnerScore(),
3030
e.getRunnerUpType(),
31-
e.getRunnerUpScore()))
31+
e.getRunnerUpScore(),
32+
splitReasons(e.getWinnerReasons()),
33+
splitReasons(e.getRunnerUpReasons())))
3234
.toList();
3335
}
3436

@@ -74,4 +76,13 @@ private static List<String> splitRoles(String joined) {
7476
if (joined == null || joined.isBlank()) return List.of();
7577
return Arrays.stream(joined.split(",")).map(String::trim).filter(s -> !s.isEmpty()).toList();
7678
}
79+
80+
/**
81+
* Splits the newline-joined reasons column into a list (empty when null/blank). Newline, not
82+
* comma: a reason may itself contain a comma, so it must not be the delimiter.
83+
*/
84+
private static List<String> splitReasons(String joined) {
85+
if (joined == null || joined.isBlank()) return List.of();
86+
return Arrays.stream(joined.split("\n")).map(String::trim).filter(s -> !s.isEmpty()).toList();
87+
}
7788
}

backend/src/main/java/com/tracepcap/analysis/spi/HostClassificationLookup.java

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,20 @@ record ClassifiedHost(
2929
int confidence,
3030
Integer winnerScore,
3131
String runnerUpType,
32-
Integer runnerUpScore) {}
32+
Integer runnerUpScore,
33+
List<String> winnerReasons,
34+
List<String> runnerUpReasons) {
35+
36+
/**
37+
* Compact constructor guaranteeing the reason lists are never null — the adjudicator builds
38+
* candidate maps from them without guarding. Each list is the human-readable "why" behind its
39+
* type's score, e.g. {@code ["MAC OUI is Cisco (+40)", "listens on 53/udp (+30)"]}.
40+
*/
41+
public ClassifiedHost {
42+
winnerReasons = winnerReasons == null ? List.of() : List.copyOf(winnerReasons);
43+
runnerUpReasons = runnerUpReasons == null ? List.of() : List.copyOf(runnerUpReasons);
44+
}
45+
}
3346

3447
/**
3548
* Descriptive facts observed about one host in one file.
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
package com.tracepcap.common.adjudication;
2+
3+
import com.tracepcap.common.adjudication.dto.EvidenceDto;
4+
import com.tracepcap.common.adjudication.dto.EvidenceRequest;
5+
import com.tracepcap.common.adjudication.dto.OverrideDto;
6+
import com.tracepcap.common.adjudication.dto.OverrideRequest;
7+
import io.swagger.v3.oas.annotations.Operation;
8+
import io.swagger.v3.oas.annotations.tags.Tag;
9+
import jakarta.validation.Valid;
10+
import java.util.List;
11+
import java.util.UUID;
12+
import lombok.RequiredArgsConstructor;
13+
import org.springframework.http.HttpStatus;
14+
import org.springframework.http.ResponseEntity;
15+
import org.springframework.web.bind.annotation.DeleteMapping;
16+
import org.springframework.web.bind.annotation.GetMapping;
17+
import org.springframework.web.bind.annotation.PathVariable;
18+
import org.springframework.web.bind.annotation.PostMapping;
19+
import org.springframework.web.bind.annotation.PutMapping;
20+
import org.springframework.web.bind.annotation.RequestBody;
21+
import org.springframework.web.bind.annotation.RequestMapping;
22+
import org.springframework.web.bind.annotation.ResponseStatus;
23+
import org.springframework.web.bind.annotation.RestController;
24+
25+
/**
26+
* The generic human-override surface for any adjudicated question (adjudication explainability).
27+
*
28+
* <p><b>One controller, every question.</b> The {@code question} is a path variable — the
29+
* {@code Adjudicator.question()} key — so overriding "host-identity" and overriding a future
30+
* "os-family" go through the same endpoint with no new code. Setting or clearing an override re-runs
31+
* adjudication, so the conclusion reflects the human's answer at once.
32+
*/
33+
@RestController
34+
@RequestMapping("/files")
35+
@RequiredArgsConstructor
36+
@Tag(
37+
name = "Adjudication Overrides",
38+
description = "A human's final answer to an adjudicated question — outranks the machine vote")
39+
public class AdjudicationOverrideController {
40+
41+
private final HumanOverrideService overrideService;
42+
private final ManualEvidenceService evidenceService;
43+
44+
@GetMapping("/{fileId}/adjudications/{question}/{entityKey}/override")
45+
@Operation(summary = "The human override for a question about an entity, if one is set")
46+
public ResponseEntity<OverrideDto> get(
47+
@PathVariable UUID fileId,
48+
@PathVariable String question,
49+
@PathVariable String entityKey) {
50+
return overrideService
51+
.find(question, fileId, entityKey)
52+
.map(o -> ResponseEntity.ok(toDto(o)))
53+
.orElse(ResponseEntity.noContent().build());
54+
}
55+
56+
@PutMapping("/{fileId}/adjudications/{question}/{entityKey}/override")
57+
@Operation(summary = "Set or replace the human override; re-runs adjudication (actor from token)")
58+
public ResponseEntity<OverrideDto> put(
59+
@PathVariable UUID fileId,
60+
@PathVariable String question,
61+
@PathVariable String entityKey,
62+
@Valid @RequestBody OverrideRequest request) {
63+
HumanOverrideEntity saved =
64+
overrideService.override(question, fileId, entityKey, request.getLabel(), request.getRationale());
65+
return ResponseEntity.ok(toDto(saved));
66+
}
67+
68+
@DeleteMapping("/{fileId}/adjudications/{question}/{entityKey}/override")
69+
@Operation(summary = "Clear the human override, letting the machine vote decide again")
70+
public ResponseEntity<Void> delete(
71+
@PathVariable UUID fileId,
72+
@PathVariable String question,
73+
@PathVariable String entityKey) {
74+
overrideService.clear(question, fileId, entityKey);
75+
return ResponseEntity.noContent().build();
76+
}
77+
78+
// ── Analyst-appended evidence (informs the vote; does not decide it) ──────────────────
79+
80+
@GetMapping("/{fileId}/adjudications/{question}/{entityKey}/evidence")
81+
@Operation(summary = "Analyst evidence for a question about an entity, newest first")
82+
public ResponseEntity<List<EvidenceDto>> listEvidence(
83+
@PathVariable UUID fileId,
84+
@PathVariable String question,
85+
@PathVariable String entityKey) {
86+
List<EvidenceDto> out =
87+
evidenceService.forEntity(question, fileId, entityKey).stream().map(this::toDto).toList();
88+
return ResponseEntity.ok(out);
89+
}
90+
91+
@PostMapping("/{fileId}/adjudications/{question}/{entityKey}/evidence")
92+
@ResponseStatus(HttpStatus.CREATED)
93+
@Operation(summary = "Append weighted evidence toward a candidate; re-runs adjudication")
94+
public EvidenceDto appendEvidence(
95+
@PathVariable UUID fileId,
96+
@PathVariable String question,
97+
@PathVariable String entityKey,
98+
@Valid @RequestBody EvidenceRequest request) {
99+
ManualEvidenceEntity saved =
100+
evidenceService.append(
101+
question, fileId, entityKey, request.getLabel(), request.getWeight(), request.getReason());
102+
return toDto(saved);
103+
}
104+
105+
@PutMapping("/{fileId}/adjudications/{question}/{entityKey}/evidence/{evidenceId}")
106+
@Operation(summary = "Edit your own evidence; re-runs adjudication (author-only, else 403)")
107+
public EvidenceDto updateEvidence(
108+
@PathVariable UUID fileId,
109+
@PathVariable String question,
110+
@PathVariable String entityKey,
111+
@PathVariable Long evidenceId,
112+
@Valid @RequestBody EvidenceRequest request) {
113+
ManualEvidenceEntity saved =
114+
evidenceService.update(
115+
question, fileId, entityKey, evidenceId,
116+
request.getLabel(), request.getWeight(), request.getReason());
117+
return toDto(saved);
118+
}
119+
120+
@DeleteMapping("/{fileId}/adjudications/{question}/{entityKey}/evidence/{evidenceId}")
121+
@ResponseStatus(HttpStatus.NO_CONTENT)
122+
@Operation(summary = "Delete your own evidence; re-runs adjudication (author-only, else 403)")
123+
public void deleteEvidence(
124+
@PathVariable UUID fileId,
125+
@PathVariable String question,
126+
@PathVariable String entityKey,
127+
@PathVariable Long evidenceId) {
128+
evidenceService.delete(question, fileId, entityKey, evidenceId);
129+
}
130+
131+
private OverrideDto toDto(HumanOverrideEntity e) {
132+
return OverrideDto.builder()
133+
.question(e.getQuestion())
134+
.entityKey(e.getEntityKey())
135+
.label(e.getLabel())
136+
.rationale(e.getRationale())
137+
.actor(e.getActor())
138+
.createdAt(e.getCreatedAt())
139+
.updatedAt(e.getUpdatedAt())
140+
.staleSince(e.getStaleSince())
141+
.staleFields(e.getStaleFields())
142+
.build();
143+
}
144+
145+
private EvidenceDto toDto(ManualEvidenceEntity e) {
146+
return EvidenceDto.builder()
147+
.id(e.getId())
148+
.question(e.getQuestion())
149+
.entityKey(e.getEntityKey())
150+
.label(e.getLabel())
151+
.weight(e.getWeight())
152+
.reason(e.getReason())
153+
.actor(e.getActor())
154+
.createdAt(e.getCreatedAt())
155+
.build();
156+
}
157+
}
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
package com.tracepcap.common.adjudication;
2+
3+
import jakarta.persistence.Column;
4+
import jakarta.persistence.Entity;
5+
import jakarta.persistence.GeneratedValue;
6+
import jakarta.persistence.GenerationType;
7+
import jakarta.persistence.Id;
8+
import jakarta.persistence.Table;
9+
import jakarta.persistence.UniqueConstraint;
10+
import java.time.LocalDateTime;
11+
import java.util.List;
12+
import java.util.Map;
13+
import java.util.UUID;
14+
import lombok.AllArgsConstructor;
15+
import lombok.Builder;
16+
import lombok.Getter;
17+
import lombok.NoArgsConstructor;
18+
import lombok.Setter;
19+
import org.hibernate.annotations.CreationTimestamp;
20+
import org.hibernate.annotations.JdbcTypeCode;
21+
import org.hibernate.annotations.UpdateTimestamp;
22+
import org.hibernate.type.SqlTypes;
23+
24+
/**
25+
* A human's final answer to one adjudicated question about one entity — the generic override that
26+
* every {@link com.tracepcap.common.stage.Adjudicator} consults before it votes.
27+
*
28+
* <p><b>Question-agnostic on purpose.</b> Keyed by {@code (question, fileId, entityKey)} — the same
29+
* coordinates every adjudicator already works in — so a new adjudicated question inherits override
30+
* by reading this table under its own {@code question()} key, with no new table and no new endpoint.
31+
*
32+
* <p><b>It outranks the machine.</b> When a row exists for an adjudicator's question and entity, that
33+
* label IS the answer: basis HUMAN, confidence 100, never contested. "The analyst has spoken."
34+
*
35+
* <p><b>It carries its own audit trail.</b> {@link #actor} + {@link #createdAt} record who overrode
36+
* and when. The actor is resolved server-side from the token (see
37+
* {@link com.tracepcap.config.security.CurrentActor}); it is never taken from the request body.
38+
*/
39+
@Entity
40+
@Table(
41+
name = "human_overrides",
42+
uniqueConstraints =
43+
@UniqueConstraint(
44+
name = "uq_human_override",
45+
columnNames = {"question", "file_id", "entity_key"}))
46+
@Getter
47+
@Setter
48+
@Builder
49+
@NoArgsConstructor
50+
@AllArgsConstructor
51+
public class HumanOverrideEntity {
52+
53+
@Id
54+
@GeneratedValue(strategy = GenerationType.IDENTITY)
55+
private Long id;
56+
57+
/** The {@code Adjudicator.question()} this override answers, e.g. {@code "host-identity"}. */
58+
@Column(nullable = false, length = 64)
59+
private String question;
60+
61+
@Column(name = "file_id", nullable = false)
62+
private UUID fileId;
63+
64+
/** The host/IP/MAC this answer is about. */
65+
@Column(name = "entity_key", nullable = false, length = 255)
66+
private String entityKey;
67+
68+
/** The human's answer — replaces whatever the machine concluded. */
69+
@Column(nullable = false, length = 100)
70+
private String label;
71+
72+
/** Why they overrode (optional, free text). */
73+
@Column(columnDefinition = "TEXT")
74+
private String rationale;
75+
76+
/** Who overrode (audit): the authenticated username, or {@code "system"} when auth is off. */
77+
@Column(nullable = false, length = 255)
78+
private String actor;
79+
80+
/**
81+
* MANUAL when the human set this override directly on this file; CARRIED_FORWARD when it was copied
82+
* from the previous monitor snapshot and re-validated (#499). Carried rows are regenerated per
83+
* snapshot by {@code OverrideStalenessService}.
84+
*/
85+
@Builder.Default
86+
@Column(name = "origin", nullable = false, length = 20)
87+
private String origin = "MANUAL";
88+
89+
/** Baseline of the classifying properties when carried, diffed against the new pcap to detect drift. */
90+
@JdbcTypeCode(SqlTypes.JSON)
91+
@Column(name = "observed_properties", columnDefinition = "jsonb")
92+
private Map<String, Object> observedProperties;
93+
94+
/** When this carried override first drifted from its baseline; sticky until re-affirmed or cleared. */
95+
@Column(name = "stale_since")
96+
private LocalDateTime staleSince;
97+
98+
/** The human-readable changes that made it stale (e.g. "MAC changed (… → …)"). */
99+
@JdbcTypeCode(SqlTypes.JSON)
100+
@Column(name = "stale_fields", columnDefinition = "jsonb")
101+
private List<String> staleFields;
102+
103+
@CreationTimestamp
104+
@Column(name = "created_at", nullable = false, updatable = false)
105+
private LocalDateTime createdAt;
106+
107+
@UpdateTimestamp
108+
@Column(name = "updated_at", nullable = false)
109+
private LocalDateTime updatedAt;
110+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package com.tracepcap.common.adjudication;
2+
3+
import java.util.List;
4+
import java.util.Optional;
5+
import java.util.UUID;
6+
import org.springframework.data.jpa.repository.JpaRepository;
7+
8+
/** Persistence for {@link HumanOverrideEntity}, keyed by the adjudicated question. */
9+
public interface HumanOverrideRepository extends JpaRepository<HumanOverrideEntity, Long> {
10+
11+
/** Every override for one question in one file — an adjudicator loads this to apply precedence. */
12+
List<HumanOverrideEntity> findByQuestionAndFileId(String question, UUID fileId);
13+
14+
/** The override for one entity, if a human has answered this question about it. */
15+
Optional<HumanOverrideEntity> findByQuestionAndFileIdAndEntityKey(
16+
String question, UUID fileId, String entityKey);
17+
18+
void deleteByQuestionAndFileIdAndEntityKey(String question, UUID fileId, String entityKey);
19+
20+
/** Remove regenerable carried-forward overrides for a question in a file (#499 monitor carry-forward). */
21+
void deleteByQuestionAndFileIdAndOrigin(String question, UUID fileId, String origin);
22+
}

0 commit comments

Comments
 (0)