diff --git a/internal/schemavalidators/internal_schemas/aisecuritycontext/ai-security-context-0.1.schema.json b/internal/schemavalidators/internal_schemas/aisecuritycontext/ai-security-context-0.1.schema.json index 5b59c521b..ac79ee432 100644 --- a/internal/schemavalidators/internal_schemas/aisecuritycontext/ai-security-context-0.1.schema.json +++ b/internal/schemavalidators/internal_schemas/aisecuritycontext/ai-security-context-0.1.schema.json @@ -356,6 +356,11 @@ "adjudication_complete": { "type": "boolean", "description": "True when every survivor (outside holes/abandoned) reached a terminal state — the trust signal that an empty fingerprints list is 'clean' rather than 'not adjudicated yet'." + }, + "abandoned": { + "type": "integer", + "minimum": 0, + "description": "How many survivors adjudication gave up on after the retry cap — a terminal coverage gap, distinct from a still-pending survivor (which a later run retries). A count rather than a list because the list is already determined by survivors and adjudicated_commits. Nonzero alongside adjudication_complete means the queue drained partly by giving up: complete, but not a clean bill of health." } } }, @@ -397,6 +402,10 @@ "type": "string", "minLength": 1, "description": "Why it never resolved, e.g. \"diff too large\", \"triage: \", or an adjudication timeout" + }, + "retryable": { + "type": "boolean", + "description": "Whether a later run should re-examine this commit: a transient diff-load, triage, or backend/adjudication failure. Absent (false) for a deterministic hole (oversize diff) or a context written before this field existed." } } }, @@ -438,6 +447,15 @@ "type": "integer", "minimum": 0, "description": "Failed adjudication tries; at the cap the survivor is abandoned." + }, + "verdict": { + "type": "string", + "enum": ["no_finding", "abstain", "rejected"], + "description": "Terminal adjudication outcome for a survivor that produced no fingerprint. A survivor with a fingerprint is a finding and carries no verdict. Absent means: a finding, not adjudicated yet, or errored and still pending. Descriptive only — never affects scheduling." + }, + "verdict_reason": { + "type": "string", + "description": "The adjudicator's own short explanation, carried only for abstentions (verdict == \"abstain\"). Truncated by the producer; never the full rationale. Absent for every other verdict." } } }, diff --git a/internal/schemavalidators/schemavalidators_test.go b/internal/schemavalidators/schemavalidators_test.go index 8b915b636..ef9dac4ca 100644 --- a/internal/schemavalidators/schemavalidators_test.go +++ b/internal/schemavalidators/schemavalidators_test.go @@ -422,6 +422,54 @@ func TestValidateSecurityContextTriageFields(t *testing.T) { scan["unexpected_field"] = "x" require.ErrorContains(t, schemavalidators.ValidateSecurityContext(payload, ""), "additionalProperties") }) + + t.Run("an unresolved entry carrying a retryable flag validates", func(t *testing.T) { + payload, scan := load(t) + scan["unresolved"] = []any{map[string]any{ + "sha": "8c948c742bdfc09c4aae6b3c386faeb98f925ff2", + "reason": "adjudication: agent error", + "retryable": true, + }} + require.NoError(t, schemavalidators.ValidateSecurityContext(payload, "")) + }) + + t.Run("an unknown unresolved field is still rejected", func(t *testing.T) { + payload, scan := load(t) + scan["unresolved"] = []any{map[string]any{ + "sha": "8c948c742bdfc09c4aae6b3c386faeb98f925ff2", + "reason": "x", + "unexpected_key": "y", + }} + require.ErrorContains(t, schemavalidators.ValidateSecurityContext(payload, ""), "additionalProperties") + }) + + t.Run("a survivor carrying an abstain verdict and reason validates", func(t *testing.T) { + payload, _ := load(t) + payload["survivors"] = []any{map[string]any{ + "commit_sha": "8c948c742bdfc09c4aae6b3c386faeb98f925ff2", + "verdict": "abstain", + "verdict_reason": "insufficient context to decide reachability", + }} + require.NoError(t, schemavalidators.ValidateSecurityContext(payload, "")) + }) + + t.Run("a no_finding verdict without a reason validates", func(t *testing.T) { + payload, _ := load(t) + payload["survivors"] = []any{map[string]any{ + "commit_sha": "8c948c742bdfc09c4aae6b3c386faeb98f925ff2", + "verdict": "no_finding", + }} + require.NoError(t, schemavalidators.ValidateSecurityContext(payload, "")) + }) + + t.Run("a verdict outside the enum is rejected", func(t *testing.T) { + payload, _ := load(t) + payload["survivors"] = []any{map[string]any{ + "commit_sha": "8c948c742bdfc09c4aae6b3c386faeb98f925ff2", + "verdict": "finding", + }} + require.ErrorContains(t, schemavalidators.ValidateSecurityContext(payload, ""), "enum") + }) } func TestValidateOpenAPI(t *testing.T) { diff --git a/pkg/attestation/crafter/materials/aisecuritycontext/aisecuritycontext.go b/pkg/attestation/crafter/materials/aisecuritycontext/aisecuritycontext.go index 74eec2b5b..101fbac31 100644 --- a/pkg/attestation/crafter/materials/aisecuritycontext/aisecuritycontext.go +++ b/pkg/attestation/crafter/materials/aisecuritycontext/aisecuritycontext.go @@ -74,6 +74,11 @@ type ScanWindow struct { type Unresolved struct { SHA string `json:"sha"` Reason string `json:"reason"` + // Retryable is true when a later run should re-examine this commit: a transient + // diff-load, triage, or backend/adjudication failure. Absent (false) for a + // deterministic hole (an oversize diff) or a context written before this field + // existed. + Retryable bool `json:"retryable,omitempty"` } // ScanStats is the funnel every stage reports into, so that a silent failure @@ -134,6 +139,11 @@ type ScanStats struct { // reached a terminal state — the signal that an empty fingerprints list is // "clean" rather than "not adjudicated yet". AdjudicationComplete bool `json:"adjudication_complete,omitempty"` + // Abandoned is how many survivors adjudication gave up on after the retry cap — a + // terminal coverage gap, distinct from a still-pending survivor. Nonzero alongside + // AdjudicationComplete means the queue drained partly by giving up: complete, but + // not a clean bill of health. + Abandoned int `json:"abandoned,omitempty"` } // Survivor is one commit that survived Phase-1 triage: an entry in the @@ -152,6 +162,13 @@ type Survivor struct { // Attempts counts failed adjudication tries; at the cap the survivor is // abandoned. Attempts int `json:"attempts,omitempty"` + // Verdict is the terminal adjudication outcome for a survivor that produced no + // fingerprint: no_finding | abstain | rejected. A survivor with a fingerprint is + // a finding and carries no verdict. Descriptive only — never affects scheduling. + Verdict string `json:"verdict,omitempty"` + // VerdictReason is the adjudicator's short explanation, carried only for + // abstentions (Verdict == "abstain"). Truncated by the producer. + VerdictReason string `json:"verdict_reason,omitempty"` } // TopRisk is a component with a security-fix history, ranked by severity mass