From 803076dc780ecab5c0fd36e31e018f7449db4135 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Insaurralde?= Date: Fri, 11 Sep 2026 22:29:56 -0300 Subject: [PATCH 1/3] feat(attestation): allow a retryable flag on AI security context unresolved holes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an optional `retryable` boolean to the ai-security-context-0.1 unresolved item and to the typed Unresolved model. Signed-off-by: Matías Insaurralde --- .../ai-security-context-0.1.schema.json | 4 ++++ .../schemavalidators/schemavalidators_test.go | 20 +++++++++++++++++++ .../aisecuritycontext/aisecuritycontext.go | 5 +++++ 3 files changed, 29 insertions(+) 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..6cc5cefa7 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 @@ -397,6 +397,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." } } }, diff --git a/internal/schemavalidators/schemavalidators_test.go b/internal/schemavalidators/schemavalidators_test.go index 8b915b636..8ac6b191b 100644 --- a/internal/schemavalidators/schemavalidators_test.go +++ b/internal/schemavalidators/schemavalidators_test.go @@ -422,6 +422,26 @@ 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") + }) } 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..b12348ea2 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 From 57421f23fcc6aeaab2e0d66e9b29c987685217ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Insaurralde?= Date: Fri, 11 Sep 2026 23:29:31 -0300 Subject: [PATCH 2/3] feat(attestation): record the per-survivor adjudication verdict and reason MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `verdict` (no_finding | abstain | rejected) and `verdict_reason` (abstain-only) to the ai-security-context-0.1 survivor definition and the typed Survivor model. A survivor that produced no fingerprint now carries its terminal outcome, and an abstention carries the model's short explanation, so "which commits were undecided, and why" is answerable from the artifact. Both are optional; the survivor object stays additionalProperties: false. Signed-off-by: Matías Insaurralde Chainloop-Trace-Sessions: 3f365023-61a3-4af3-bbe9-03688f152834, 7cf89fb4-1736-425d-bafb-bfb8b59155a4, df945bde-e982-472c-bb81-50055df403bb, fd98553a-50f9-41e6-abf6-f03f0b9f1f4e --- .../ai-security-context-0.1.schema.json | 9 ++++++ .../schemavalidators/schemavalidators_test.go | 28 +++++++++++++++++++ .../aisecuritycontext/aisecuritycontext.go | 7 +++++ 3 files changed, 44 insertions(+) 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 6cc5cefa7..1f10e9b38 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 @@ -442,6 +442,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 8ac6b191b..ef9dac4ca 100644 --- a/internal/schemavalidators/schemavalidators_test.go +++ b/internal/schemavalidators/schemavalidators_test.go @@ -442,6 +442,34 @@ func TestValidateSecurityContextTriageFields(t *testing.T) { }} 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 b12348ea2..29651ccb1 100644 --- a/pkg/attestation/crafter/materials/aisecuritycontext/aisecuritycontext.go +++ b/pkg/attestation/crafter/materials/aisecuritycontext/aisecuritycontext.go @@ -157,6 +157,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 From 38b57486dff6a82840a81b10168dc59c89b7cc20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C3=ADas=20Insaurralde?= Date: Sat, 12 Sep 2026 02:51:51 -0300 Subject: [PATCH 3/3] feat(attestation): report abandoned survivors in the AI security context stats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an optional `abandoned` count to the AI security context scan stats Signed-off-by: Matías Insaurralde --- .../aisecuritycontext/ai-security-context-0.1.schema.json | 5 +++++ .../crafter/materials/aisecuritycontext/aisecuritycontext.go | 5 +++++ 2 files changed, 10 insertions(+) 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 1f10e9b38..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." } } }, diff --git a/pkg/attestation/crafter/materials/aisecuritycontext/aisecuritycontext.go b/pkg/attestation/crafter/materials/aisecuritycontext/aisecuritycontext.go index 29651ccb1..101fbac31 100644 --- a/pkg/attestation/crafter/materials/aisecuritycontext/aisecuritycontext.go +++ b/pkg/attestation/crafter/materials/aisecuritycontext/aisecuritycontext.go @@ -139,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