Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,17 @@
"items": {
"$ref": "#/definitions/fingerprint"
}
},
"survivors": {
"type": "array",
"description": "Commits that survived Phase-1 triage — the adjudication work queue, retained after draining as the coverage record. Present on an un-adjudicated (triage-only) or incrementally-built context; absent for a combined triage+adjudicate scan.",
"items": {
"$ref": "#/definitions/survivor"
}
},
"discarded": {
"$ref": "#/definitions/sha_list",
"description": "Commits Phase-1 triage classified and REJECTED — the other half of the triage record, SHAs only because a discard carries nothing else worth recording. With survivors and scan.unresolved it states the full set of commits ever handed to the classifier, so a later walk that re-covers this ground can skip it instead of paying for it again. Absent on a context whose producer did not record it."
}
},
"definitions": {
Expand Down Expand Up @@ -310,6 +321,41 @@
"type": "integer",
"minimum": 0,
"description": "Holes the list cap dropped, so a truncated list still reports an honest total"
},
"adjudicated_commits": {
Comment thread
matiasinsaurralde marked this conversation as resolved.
"type": "array",
"items": {
"type": "string"
},
"description": "Commit SHAs driven to a terminal adjudication state across all runs (the adjudication frontier)."
},
"survivors_total": {
"type": "integer",
"minimum": 0,
"description": "How many survivors the context holds at the covered window."
},
"pending_survivors": {
"type": "integer",
"minimum": 0,
"description": "How many survivors still await adjudication — survivors that are neither in the frontier, nor a triage hole, nor abandoned after the retry cap. A count rather than a list because the list is already determined by survivors and adjudicated_commits; zero alongside adjudication_complete is the drained queue."
},
"triage_budget_hit": {
"type": "boolean",
"description": "True when the most recent triage run stopped because it reached its survivor budget (the --max-new-survivors cap) rather than because it exhausted its window, which means there is known-untriaged history immediately behind scan.window.from_sha. Cleared by a later run that exhausts its window without hitting the budget. Not a statement about the adjudication queue — that is pending_survivors/adjudication_complete — and not the same as \"the window does not reach the repository root\": a --last-bounded run does not either, and that question needs git rather than this artifact."
},
"triage_input_tokens": {
"type": "integer",
"minimum": 0,
"description": "Cumulative Phase-1 input tokens across every triage run."
},
"triage_output_tokens": {
"type": "integer",
"minimum": 0,
"description": "Cumulative Phase-1 output tokens across every triage run."
},
"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'."
}
}
},
Expand Down Expand Up @@ -354,6 +400,47 @@
}
}
},
"survivor": {
"type": "object",
"title": "Survivor",
"description": "One commit that survived Phase-1 triage: an entry in the adjudication work queue the context carries. All but commit_sha are optional.",
"required": [
"commit_sha"
],
"additionalProperties": false,
"properties": {
"commit_sha": {
"type": "string",
"description": "The commit that triage marked as a candidate for adjudication"
},
"parent_sha": {
"type": "string",
"description": "Parent of the survivor commit. Empty for a root commit."
},
"commit_date": {
"type": "string",
"description": "Committer date of the survivor commit"
},
"subject": {
"type": "string",
"description": "Subject line of the survivor commit"
},
"patch_id": {
"type": "string",
"description": "git patch-id --stable; rebase-durable key for history-rewrite reconciliation. Empty for merge commits."
},
"diff_bytes": {
"type": "integer",
"minimum": 0,
"description": "Size of the survivor's normalised diff in bytes"
},
"attempts": {
"type": "integer",
"minimum": 0,
"description": "Failed adjudication tries; at the cap the survivor is abandoned."
}
}
},
"top_risk": {
"type": "object",
"title": "Top risk",
Expand Down
107 changes: 107 additions & 0 deletions internal/schemavalidators/schemavalidators_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,10 @@ func TestValidateSecurityContext(t *testing.T) {
name: "valid security context",
filePath: "./testdata/ai_security_context_valid.json",
},
{
name: "valid un-adjudicated (triage-only) context",
filePath: "./testdata/ai_security_context_triage_only.json",
},
{
name: "missing required fields",
filePath: "./testdata/ai_security_context_missing_required.json",
Expand Down Expand Up @@ -317,6 +321,109 @@ func TestValidateSecurityContext(t *testing.T) {
}
}

// TestValidateSecurityContextTriageFields covers the optional fields the
// triage/adjudicate split consolidated into the security context in place at
// security-context-0.1: the top-level survivors queue and discard record, and
// the scan_stats counters (adjudicated_commits, survivors_total,
// pending_survivors, triage_budget_hit, triage_input_tokens,
// triage_output_tokens, adjudication_complete). An un-adjudicated (triage-only)
// context carries survivors, discards and the triage counters, a
// fully-adjudicated context carries the adjudication frontier, a combined-scan
// context omits them all, and a genuinely unknown field is still rejected — so
// both the context object and scan_stats keep their additionalProperties: false
// contract.
func TestValidateSecurityContextTriageFields(t *testing.T) {
load := func(t *testing.T) (map[string]any, map[string]any) {
t.Helper()
f, err := os.ReadFile("./testdata/ai_security_context_valid.json")
require.NoError(t, err)

var payload map[string]any
require.NoError(t, json.Unmarshal(f, &payload))

scan, ok := payload["scan"].(map[string]any)
require.True(t, ok, "the fixture must carry a scan object")
return payload, scan
}

t.Run("an un-adjudicated context with survivors and triage counters validates", func(t *testing.T) {
payload, scan := load(t)
payload["survivors"] = []any{
map[string]any{
"commit_sha": "8c948c742bdfc09c4aae6b3c386faeb98f925ff2",
"parent_sha": "c8533df53b0af4b731cb1036ec61aee10e35c67b",
"commit_date": "2026-08-19T19:58:28-03:00",
"subject": "Avoid shell invocation in command handler",
"patch_id": "32d18a48dac298fa43bd3dcffdb3bbfe06a008aa",
"diff_bytes": 451,
"attempts": 0,
},
}
payload["discarded"] = []any{
"1b8f5aa595c0953995c40e92b1669282ba76dd08",
"c8533df53b0af4b731cb1036ec61aee10e35c67b",
}
scan["survivors_total"] = 1
scan["pending_survivors"] = 1
// Budget-stopped rather than window-exhausted: known-untriaged history sits
// immediately behind scan.window.from_sha.
scan["triage_budget_hit"] = true
scan["triage_input_tokens"] = 8883
scan["triage_output_tokens"] = 83
scan["adjudication_complete"] = false
require.NoError(t, schemavalidators.ValidateSecurityContext(payload, ""))
})

t.Run("an adjudicate-produced context validates", func(t *testing.T) {
payload, scan := load(t)
scan["adjudicated_commits"] = []any{"8c948c742bdfc09c4aae6b3c386faeb98f925ff2"}
scan["survivors_total"] = 1
// The drained queue: the producer emits the zero rather than omitting it, so
// "nothing pending" is stated rather than inferred from an absent field.
scan["pending_survivors"] = 0
scan["adjudication_complete"] = true
require.NoError(t, schemavalidators.ValidateSecurityContext(payload, ""))
})

t.Run("a discard list of short SHAs is rejected", func(t *testing.T) {
payload, _ := load(t)
payload["discarded"] = []any{"1b8f5aa"}
require.ErrorContains(t, schemavalidators.ValidateSecurityContext(payload, ""), "pattern")
})

t.Run("a negative pending_survivors is rejected", func(t *testing.T) {
payload, scan := load(t)
scan["pending_survivors"] = -1
require.ErrorContains(t, schemavalidators.ValidateSecurityContext(payload, ""), "minimum")
})

t.Run("a combined-scan context validates without them", func(t *testing.T) {
payload, _ := load(t)
require.NoError(t, schemavalidators.ValidateSecurityContext(payload, ""))
})

t.Run("a survivor missing its required commit_sha is rejected", func(t *testing.T) {
payload, _ := load(t)
payload["survivors"] = []any{map[string]any{"subject": "no sha"}}
require.ErrorContains(t, schemavalidators.ValidateSecurityContext(payload, ""), "missing properties")
})

t.Run("an unknown survivor field is rejected", func(t *testing.T) {
payload, _ := load(t)
payload["survivors"] = []any{map[string]any{
"commit_sha": "8c948c742bdfc09c4aae6b3c386faeb98f925ff2",
"unexpected_key": "x",
}}
require.ErrorContains(t, schemavalidators.ValidateSecurityContext(payload, ""), "additionalProperties")
})

t.Run("an unknown scan_stats field is still rejected", func(t *testing.T) {
payload, scan := load(t)
scan["unexpected_field"] = "x"
require.ErrorContains(t, schemavalidators.ValidateSecurityContext(payload, ""), "additionalProperties")
})
}

func TestValidateOpenAPI(t *testing.T) {
testCases := []struct {
name string
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
{
"schema_version": "security-context-0.1",
"generated_at": "2026-09-10T19:34:26Z",
"repo": {
"owner": "chainloop-dev",
"name": "sample-repo-go",
"url": "https://github.com/chainloop-dev/sample-repo-go",
"ref": "fe5eb735",
"head_sha": "fe5eb735f979f5a4bda31bdff281acd680cf1e5b"
},
"provenance": {
"tool": "strata-go",
"tool_version": "dev",
"protocol": "",
"triage_model": "openai/gpt-5.6-luna:nitro",
"triage_prompt_id": "current-diff-only-v1",
"input_profile": "D0"
},
"scan": {
"window": {
"from_sha": "a6214f62be37e2234f1c816dfd7a84c25376c98f",
"to_sha": "fe5eb735f979f5a4bda31bdff281acd680cf1e5b"
},
"commits_scanned": 11,
"commits_triaged": 11,
"commits_skipped": 0,
"triage_candidates": 3,
"adjudicated": 0,
"findings": 0,
"abstained": 0,
"rejected": 0,
"no_finding": 0,
"triage_errors": 0,
"adjudication_errors": 0,
"anchors_verified": 0,
"anchors_relocated": 0,
"anchors_rejected": 0,
"input_tokens": 0,
"output_tokens": 0,
"wall_clock_s": 0,
"reconciles": true,
"triage_input_tokens": 10930,
"triage_output_tokens": 417,
"adjudication_complete": false,
"survivors_total": 3,
"pending_survivors": 3,
"triage_budget_hit": true
},
"survivors": [
{
"commit_sha": "fe5eb735f979f5a4bda31bdff281acd680cf1e5b",
"parent_sha": "af5f2f747ea110af304d1feb9c0d88073005441e",
"commit_date": "2026-09-10T16:26:54-03:00",
"subject": "fix: reject absolute paths in the ls endpoint",
"patch_id": "cec8571ff951f25c91ba4581690b1c22018ed2f8",
"diff_bytes": 522
},
{
"commit_sha": "ff57f5cd0d76d4b02483d0c89a6c931557156ccb",
"parent_sha": "72a1590ff2f099f67b78a2cd47ea6dbb78a88a43",
"commit_date": "2026-09-10T16:24:44-03:00",
"subject": "fix: reject directory traversal in the ls endpoint",
"patch_id": "81e8ce4bae5aea1d2001440d9440d7dfc1e5f95d",
"diff_bytes": 510
},
{
"commit_sha": "8c948c742bdfc09c4aae6b3c386faeb98f925ff2",
"parent_sha": "c8533df53b0af4b731cb1036ec61aee10e35c67b",
"commit_date": "2026-08-19T19:58:28-03:00",
"subject": "Avoid shell invoction in command handler",
"patch_id": "32d18a48dac298fa43bd3dcffdb3bbfe06a008aa",
"diff_bytes": 451
}
],
"discarded": [
"0d7a7bd3a2f0f8b1c6e4a9f27b35d8e1c4a60f92",
"3b9c1f04e7a25d8c6b0f31e9a47d52c8f6013abd",
"a1f4c70e2b8d95a36c1e47f0b92d58ac6304e7f1"
],
"class_counts": {},
"min_support": 2,
"top_risks": [],
"shared_surfaces": [],
"fingerprints": []
}
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,53 @@ type ScanStats struct {
DuplicatesMerged int `json:"duplicates_merged,omitempty"`
Unresolved []Unresolved `json:"unresolved,omitempty"`
TruncatedUnresolved int `json:"truncated_unresolved,omitempty"`

// AdjudicatedCommits are the commit SHAs driven to a terminal Phase-2 state
// across all runs — the adjudication frontier.
AdjudicatedCommits []string `json:"adjudicated_commits,omitempty"`
// SurvivorsTotal is how many survivors the context holds at the covered
// window.
SurvivorsTotal int `json:"survivors_total,omitempty"`
// PendingSurvivors is how many survivors still await adjudication: neither in
// the frontier, nor a triage hole, nor abandoned after the retry cap. Zero
// alongside AdjudicationComplete is the drained queue.
PendingSurvivors int `json:"pending_survivors,omitempty"`
// TriageBudgetHit is true when the most recent triage run stopped because it
// reached its survivor budget (the --max-new-survivors cap) rather than
// because it exhausted its window, so known-untriaged history sits immediately
// behind Scan.Window.FromSHA. Cleared by a later run that exhausts its window
// without hitting the budget. It says nothing about the adjudication queue,
// and is not the same as a window that does not reach the repository root — a
// --last-bounded run does not either, and only git can answer that.
TriageBudgetHit bool `json:"triage_budget_hit,omitempty"`
// TriageInputTokens is the cumulative Phase-1 input tokens across every
// triage run.
TriageInputTokens int64 `json:"triage_input_tokens,omitempty"`
// TriageOutputTokens is the cumulative Phase-1 output tokens across every
// triage run.
TriageOutputTokens int64 `json:"triage_output_tokens,omitempty"`
// AdjudicationComplete is true when every survivor (outside holes/abandoned)
// reached a terminal state — the signal that an empty fingerprints list is
// "clean" rather than "not adjudicated yet".
AdjudicationComplete bool `json:"adjudication_complete,omitempty"`
}

// Survivor is one commit that survived Phase-1 triage: an entry in the
// adjudication work queue the context carries. Retained after draining as the
// coverage record. All but CommitSHA are optional.
type Survivor struct {
CommitSHA string `json:"commit_sha"`
ParentSHA string `json:"parent_sha,omitempty"`
CommitDate string `json:"commit_date,omitempty"`
Subject string `json:"subject,omitempty"`
// PatchID is git patch-id --stable: a rebase-durable key for history-rewrite
// reconciliation. Empty for merge commits.
PatchID string `json:"patch_id,omitempty"`
// DiffBytes is the size of the survivor's normalised diff in bytes.
DiffBytes int `json:"diff_bytes,omitempty"`
// Attempts counts failed adjudication tries; at the cap the survivor is
// abandoned.
Attempts int `json:"attempts,omitempty"`
}

// TopRisk is a component with a security-fix history, ranked by severity mass
Expand Down Expand Up @@ -267,6 +314,18 @@ type Data struct {
TopRisks []TopRisk `json:"top_risks"`
SharedSurfaces []SharedSurface `json:"shared_surfaces"`
Fingerprints []Fingerprint `json:"fingerprints"`

// Survivors is the adjudication work queue — the commits that survived
// Phase-1 triage, retained after draining as the coverage record. Present on
// an un-adjudicated (triage-only) or incrementally-built context; absent for
// a combined triage+adjudicate scan.
Survivors []Survivor `json:"survivors,omitempty"`

// Discarded are the commits Phase-1 triage classified and REJECTED — SHAs
// only, because a discard carries nothing else worth recording. Together with
// Survivors and Scan.Unresolved it states the full set of commits ever handed
// to the classifier.
Discarded []string `json:"discarded,omitempty"`
}

// Evidence is the Chainloop material envelope around a security context.
Expand Down
Loading
Loading