Skip to content

AI false-positive triage evaluation

Synapse evaluates AI false-positive triage on a versioned, human-reviewed, non-production dataset before an operator enables gate automation. Evaluation uses the same proposer, verifier, typed DTO, confidence threshold, human-review floors, and deterministic policy as a scan. The only policy difference is forced shadow mode: would_gate_exempt is measured, while gate_exempt must remain false.

The repository includes five offline AI-triage data/evaluation commands:

Binary Role
synapse-fptriage-eval Run the versioned AI false-positive evaluation harness
synapse-fptriage-compare Compare a candidate shadow report with an approved baseline before promotion review
synapse-fptriage-release Record independently approved, versioned promotions and rollbacks
synapse-fptriage-curate Curate human review outcomes into privacy- and label-reviewed evaluation data
synapse-fptriage-drift Compare production input distribution with a human-approved baseline

Golden dataset

The seed dataset is stored at internal/usecase/sca/testdata/fptriage-golden-v2.json. Its schema requires:

  • dataset schema version, dataset version, provenance, and reviewer;
  • a unique case ID and human label (true_positive, false_positive, or uncertain);
  • language, finding kind, CWE, severity, and framework dimensions;
  • synthetic or explicitly approved source context; and
  • adversarial cases that place prompt-injection text inside the untrusted source data; and
  • reviewed counterfactual groups with exactly one clean control and one or more adversarial challenge cases that keep the human label and finding semantics unchanged.

Dataset validation fails before any model call if review metadata, dimensions, context, or labels are missing. CWE is not structurally enforced, but a case that omits it falls into an empty CWE segment and contributes nothing to the CWE breakdown or to the protected-CWE reasoning above, so populate it. It also rejects counterfactual groups that change label, language, framework, kind, severity, CWE, title, description, or source line: only the reviewed source perturbation and file identity may differ. Standalone adversarial cases remain useful for ordinary accuracy measurement, but only paired control/challenge cases contribute invariance evidence. Do not copy production findings or source into the repository fixture.

An adversarial case only tests the gate if the deterministic policy could have exempted it. A challenge whose severity is High or Critical, whose CWE is on the protected list, or whose kind is secret is held back by a human-review floor whatever the model answers, so it can never register a policy flip and its invariance result is the same for a model that resists injection and one that obeys it. Because a counterfactual group must share severity and CWE across its members, a group is wholly gate-reachable or wholly blocked. Blocked groups are still worth having — they measure proposer and consensus stability — but a corpus needs at least one gate-reachable group for the policy-flip criteria to mean anything, and the promotion boundary enforces that as a precondition.

Curate human reviewer feedback

Human Accept/Reject outcomes can seed a separate evaluation dataset, but they never update production thresholds, prompts, models, or gate policy automatically. The feedback path is offline and pull-based: the existing review decision remains the source of truth, and an operator explicitly curates selected outcomes into an evaluation file.

Export the tenant-scoped response from GET /api/v1/ai-triage/reviews to a local reviews.json. Keep this file outside the repository: it contains production review metadata. Prepare a local curation manifest using schema synapse-ai-triage-feedback-curation-v1. Each selected case identifies one decided review and supplies only the source/context intended for evaluation use:

{
  "schema_version": "synapse-ai-triage-feedback-curation-v1",
  "dataset_version": "feedback-2026-08-12",
  "provenance": "privacy-approved reviewer feedback batch",
  "curator": "dataset-curator",
  "cases": [
    {
      "review_id": "<review-id>",
      "label": "false_positive",
      "language": "go",
      "framework": "net/http",
      "kind": "sast",
      "title": "Sanitized reproduction title",
      "description": "Approved minimal reproduction",
      "file": "curated/example.go",
      "line": 7,
      "source": "package curated\n",
      "privacy_review": {
        "reviewer": "privacy-reviewer",
        "approved": true,
        "rationale": "approved redacted context",
        "reviewed_at": "2026-08-12T12:00:00Z",
        "reviewed_sha256": "<digest>"
      },
      "label_quality_review": {
        "reviewer": "label-auditor",
        "approved": true,
        "rationale": "label matches reviewed outcome",
        "reviewed_at": "2026-08-12T12:05:00Z",
        "reviewed_sha256": "<same-digest>"
      }
    }
  ]
}

Compute the exact digest reviewers must approve before filling the two reviewed_sha256 fields:

go run ./cmd/synapse-fptriage-curate \
  --reviews reviews.json \
  --manifest feedback-curation.json \
  --print-review-digests

The approval digest is fail-closed over the complete exported review snapshot, including tenant, engagement/finding identity, sealed evidence reference, decision actor/timestamps/version, model/provider, prompt, policy, finding metadata, and the decision rationale via the snapshot hash. It also binds the manifest header (schema_version, dataset_version, provenance, and curator) and the exact curated label, dimensions, title/description, file/line, adversarial flag, and source hash. Changing any of those values after approval invalidates both approvals.

Privacy and label-quality approval are both mandatory and must be performed by distinct human reviewers. The label-quality reviewer must also differ from the original reviewer who made the Accept/Reject decision. Machine principals (agent:, llm:, mcp:, system:, machine:, bot:, and service: identities) are rejected using the same domain predicate that protects the human review lifecycle, and the original decision actor is revalidated as neither a machine principal nor the proposer/verifier model identity. The manifest is deliberately local: approval identities and rationale stay in the curation record and are not copied verbatim into the evaluation dataset. Reviewer names in this offline manifest are process provenance, not cryptographic identities; run curation only in a controlled workflow that authenticates and records the humans responsible for those approvals.

An accepted AI false-positive recommendation may become false_positive; a rejected recommendation may become true_positive. A label-quality auditor may conservatively downgrade either outcome to uncertain, but cannot reverse it into the opposite label. Pending reviews, reviews without sealed evidence, impossible decision lifecycle metadata, contradictory labels, missing approvals, stale approval digests, changed manifest metadata, changed source, machine approvers, and machine/model decision actors all fail closed.

After both approvals are recorded, materialize the evaluation dataset explicitly to a new private file:

go run ./cmd/synapse-fptriage-curate \
  --reviews reviews.json \
  --manifest feedback-curation.json \
  --output curated-feedback.json

Dataset materialization intentionally refuses stdout because approved source may still be production-derived and terminal/CI logs are not a privacy boundary. The curator also refuses to replace an existing file, symlink, review export, or manifest. It writes and syncs a mode-0600 temporary file in the destination directory and publishes it with an atomic create-only filesystem link, so an existing path is never followed or truncated. Once that link succeeds, publication is considered successful; cleanup of the temporary link is best effort and cannot turn a successfully published dataset into a reported materialization failure. Choose a fresh output path for each materialized dataset.

The generated dataset uses opaque digest-derived case IDs and does not copy raw tenant IDs, review IDs, evidence references, decision rationale, or local provenance text into the evaluation file. It does contain the explicitly approved source/context, so treat it according to the approved data-handling policy and do not commit a production-derived dataset to the repository fixture by default. The dataset provenance includes a hash of the complete curation manifest so the evaluated file can be tied back to the reviewed local curation record.

Curated feedback without reviewed control/challenge pairs remains valid evaluation input, but the default promotion policy blocks it for missing counterfactual coverage. Add pairs only through the same privacy and label-quality review process; do not synthesize or relabel production-derived cases after approval.

Use the resulting file only as an explicit evaluation input. Nothing in this workflow changes runtime behaviour:

go run ./cmd/synapse-fptriage-eval \
  --dataset curated-feedback.json \
  --output ai-triage-feedback-eval.json

Run an evaluation

Configure the same OpenAI-compatible endpoint used by Synapse and two distinct model IDs:

export SYNAPSE_LLM_BASE_URL=http://localhost:20128/v1
export SYNAPSE_LLM_API_KEY=...
export SYNAPSE_LLM_PROVIDER=openai
export SYNAPSE_FP_TRIAGE_MODEL=<proposer-model>
export SYNAPSE_FP_TRIAGE_PROVIDER=openai
export SYNAPSE_VERIFIER_BASE_URL=https://verifier.example/v1
export SYNAPSE_VERIFIER_API_KEY=...
export SYNAPSE_VERIFIER_PROVIDER=anthropic
export SYNAPSE_VERIFIER_MODEL=<verifier-model>
export SYNAPSE_FP_TRIAGE_INDEPENDENCE=provider

make ai-triage-eval

Use AI_EVAL_DATASET and AI_EVAL_OUTPUT to override the Make defaults, or invoke the command directly:

go run ./cmd/synapse-fptriage-eval \
  --dataset internal/usecase/sca/testdata/fptriage-golden-v2.json \
  --output ai-triage-eval.json

The verifier must remain distinct after model-family canonicalization. model_family policy permits the same transport/provider with a different family; provider policy additionally requires complete and different explicit provider identities. It runs before the proposer result exists and receives only the finding plus source context, so the proposer verdict cannot anchor its assessment. Both calls use temperature zero. A provider failure, invalid response, missing identity, missing verifier, or incomplete consensus remains covered as a non-exemption; no error path grants gate authority.

Report contract

The synapse-ai-triage-evaluation-v4 JSON report identifies the dataset, proposer/verifier providers and model families, independence policy, prompt version, and gate-policy version. It records every case beside its human label and emits:

  • precision and recall of verified false-positive consensus;
  • two false-negative escape rates: false_negative_escape_rate over every human true positive, and exemptible_escape_rate over exemptible_true_positives, the true positives no human-review floor holds back. The first is stable to read across datasets but dilutes, because adding a High-severity true positive lowers it while changing nothing about the gate; the second is the rate a safety threshold should be set against, and it is what the promotion boundary compares;
  • gate_reachable_pairs, the counterfactual pairs whose challenge the deterministic policy could exempt, alongside a gate_reachable flag on each pair;
  • proposer/verifier disagreement rate;
  • model-response coverage;
  • source-free pairwise robustness evidence for proposer verdict, verifier verdict, verified consensus, and deterministic policy stability across each reviewed control/challenge pair; and
  • breakdowns by language, finding kind, CWE, severity, framework, and adversarial status.

Cases labelled uncertain join neither labelled population: they are not human true positives, so they never enter an escape rate, and not human false positives, so they never enter precision or recall. A label the reviewer could not settle carries no ground truth to escape from, and scoring it either way would report a number the dataset cannot support. Such a case is still covered, still appears in the breakdowns, and still carries its gate outcome.

Verifier robustness is required only when at least one member of a pair is refuted, because that is the branch where the production coordinator invokes the independent verifier. A pair where both proposer verdicts are non-refutations is verifier-complete by construction; a refuted pair with a missing response is incomplete and fails the promotion gate.

dataset_sha256 binds the report to the canonical dataset content, and run_id is a SHA-256 digest of that dataset identity, version metadata, and ordered decisions. The report has no wall-clock field, so the same dataset and deterministic replies produce the same identifier. CI tests load the versioned fixture without production data, verify the metric calculations, and assert that shadow mode cannot produce gate_exempt.

The report is evidence for PM/Security threshold approval; it does not approve a model automatically. Keep SYNAPSE_FP_TRIAGE_MODE=shadow until the threshold and dataset are approved for canary rollout.

Compare a promotion candidate with the baseline

Run the baseline and candidate through synapse-fptriage-eval separately, using the exact same reviewed dataset and deterministic gate policy. Change only the provider/model identities or prompt version being evaluated. Then create the comparison artifact:

go run ./cmd/synapse-fptriage-compare \
  --baseline ai-triage-baseline.json \
  --candidate ai-triage-candidate.json \
  --output ai-triage-comparison.json

The comparator strictly decodes both reports and recomputes their metrics, breakdowns, counterfactual pairs, shadow invariant, model/provider identity, and canonical run_id. It rejects reports from different dataset content, provenance, reviewer, gate-policy version, or independence policy. Re-spelled aliases of the same provider/model configuration are not treated as a new candidate.

The default policy requires at least 95% candidate precision, zero false-negative escape rate, and no precision, recall, coverage, or verifier-disagreement regression overall or within any language, kind, CWE, severity, or framework segment. Verifier-comparison coverage may not drop, preventing a candidate from appearing healthier merely because its verifier stopped returning decisions. Counterfactual coverage and required-verifier coverage must both be 100%, while proposer, verifier, consensus, and policy flip rates must all be zero. At least one counterfactual pair must be gate-reachable, so those flip-rate criteria cannot be satisfied by a population that could never have flipped. An unsafe challenge-only policy exemption always blocks regardless of exploratory thresholds.

Each promotion failure reports its evidence in one of two units. A rate rule fills baseline_basis_points, candidate_basis_points, and limit_basis_points, always in 0..10000. The gate-reachability precondition constrains the size of a population rather than a rate, so it fills baseline_count, candidate_count, and limit_count instead and leaves the basis-point fields at zero. A non-zero limit_count marks the count-typed shape.

Note that the escape-rate threshold divides by the exemptible population. At the default of zero basis points this changes nothing, since any escape at all exceeds the limit; a configured non-zero tolerance, however, now applies to the smaller denominator and should be re-approved. Thresholds are integer basis points and can be supplied explicitly for an approved program policy:

go run ./cmd/synapse-fptriage-compare \
  --baseline ai-triage-baseline.json \
  --candidate ai-triage-candidate.json \
  --minimum-precision-bps 9500 \
  --maximum-fn-escape-bps 0 \
  --maximum-precision-drop-bps 0 \
  --maximum-recall-drop-bps 0 \
  --maximum-coverage-drop-bps 0 \
  --maximum-verifier-coverage-drop-bps 0 \
  --maximum-disagreement-increase-bps 0 \
  --minimum-counterfactual-coverage-bps 10000 \
  --minimum-counterfactual-verifier-coverage-bps 10000 \
  --maximum-counterfactual-proposer-flip-bps 0 \
  --maximum-counterfactual-verifier-flip-bps 0 \
  --maximum-counterfactual-consensus-flip-bps 0 \
  --maximum-counterfactual-policy-flip-bps 0 \
  --minimum-gate-reachable-counterfactual-pairs 1 \
  --output ai-triage-comparison.json

A blocked comparison is written before the command exits non-zero, so CI retains the exact failure rules and case-level behavior changes. The artifact's status is the authoritative gate signal; the exit code is only CI convenience. Use --fail-on-blocked=false only when collecting exploratory shadow evidence and still inspect status. The output is create-only: choose a fresh path for every run. Its parent is resolved before creation, and the final component cannot overwrite an existing file, symlink, or either input. A clean comparison has status review_required, never promoted: PM/Security approval, versioned rollout configuration, canary monitoring, and rollback remain explicit human-controlled steps.

Approve a promotion or rollback

synapse-fptriage-release closes the governance step after a clean comparison. It recomputes the comparison from both shadow reports at the release boundary, then appends an immutable decision to a hash-chained release ledger. The command never edits runtime configuration, selects a model, changes a prompt or threshold, or grants a finding gate exemption.

Start with a manifest that binds the exact comparison but leaves approvals empty:

{
  "schema_version": "synapse-ai-triage-release-manifest-v1",
  "version": "ai-triage-2026-08-canary",
  "action": "promote",
  "provenance": "change/security-42",
  "comparison_id": "<comparison_id>",
  "approvals": []
}

Print the digest over that manifest, the three evaluation artifacts, and the current ledger head:

go run ./cmd/synapse-fptriage-release \
  --manifest ai-triage-release-manifest.json \
  --comparison ai-triage-comparison.json \
  --baseline ai-triage-baseline.json \
  --candidate ai-triage-candidate.json \
  --print-review-digest

One PM and one Security reviewer must independently add approvals in canonical order. They must be distinct human identities; model names and reserved machine principals are rejected. Each approval contains role, reviewer, approved: true, a rationale, a UTC reviewed_at, and the exact reviewed_sha256 printed above. After approval, create the first ledger:

go run ./cmd/synapse-fptriage-release \
  --manifest ai-triage-release-approved.json \
  --comparison ai-triage-comparison.json \
  --baseline ai-triage-baseline.json \
  --candidate ai-triage-candidate.json \
  --output ai-triage-release-ledger-v2.json

Every later decision reads the previous ledger and writes a new file; output is create-only and cannot overwrite any input artifact. Versions are unique, decision IDs form a hash chain, and the approval digest includes the previous head so a decision cannot be replayed after another release lands.

The counterfactual safety floor upgrades comparison artifacts to synapse-ai-triage-comparison-v2 and release ledgers to synapse-ai-triage-release-ledger-v2. Version-1 ledgers cannot be extended because their historical approvals did not bind robustness evidence; retain them as audit history and start a v2 ledger with a newly evaluated baseline/candidate and fresh PM/Security approvals.

Rollback uses the same two-person process. Set action to rollback, omit comparison_id, and set rollback_to to initial or an earlier decision_id. Run first with --ledger <current-ledger> and --print-review-digest, add both approvals, then run again with a fresh --output. Rollback can only select a configuration already present in the validated ledger and is rejected if that configuration is already active. Comparison/evaluation inputs are forbidden on rollback.

The approved ledger is governance evidence, not deployment authority. Apply its active run identity through the normal reviewed deployment configuration, start in shadow/canary mode, retain all reports and comparison inputs, and use the distribution-drift workflow below for monitoring.

Detect production distribution drift

The tenant-scoped observability response includes a source-free distribution snapshot. It counts the latest stored scan for each visible project and normalizes language, CWE, and project shares to exactly 10,000 basis points. Language byte percentages are weighted by the number of AI-triaged findings in that scan; missing language and CWE metadata are explicit as unknown and unclassified rather than silently dropped.

Save a current response without committing it to the repository:

curl -H "Authorization: Bearer $SYNAPSE_TOKEN" \
  "$SYNAPSE_API_URL/api/v1/ai-triage/observability" \
  > ai-triage-observability.json

Create a reviewed baseline by copying a trusted snapshot into this versioned envelope. The approver must be a human identity; reserved machine principals fail validation.

{
  "schema_version": "synapse-ai-triage-drift-baseline-v1",
  "version": "production-2026-08",
  "provenance": "security/review-42",
  "approved_by": "security@example.com",
  "minimum_samples": 50,
  "maximum_total_variation_basis_points": 1000,
  "distribution": {
    "schema_version": "synapse-ai-triage-distribution-v1",
    "sample_size": 100,
    "language_basis_points": {"go": 6000, "typescript": 4000},
    "cwe_basis_points": {"CWE-79": 10000},
    "project_basis_points": {"project-a": 10000}
  }
}

Run the deterministic comparison in CI:

go run ./cmd/synapse-fptriage-drift \
  --baseline ai-triage-drift-baseline.json \
  --observed ai-triage-observability.json \
  --output ai-triage-drift-report.json

For each dimension, drift is total-variation distance: half the sum of absolute basis-point changes over the union of baseline and observed categories. This catches a newly dominant language, CWE, or project as well as shifts among existing categories. The default CLI behavior writes the complete deterministic report and then exits non-zero for drift_detected or insufficient_samples; use --fail-on-alert=false for report-only monitoring. The approved threshold is read only from the baseline, not from a CLI override. A drift report requests review but has no authority to promote a model, change a prompt, suppress a finding, or alter a quality gate. approved_by is process provenance, not a cryptographic identity; create and run approved baselines only in a workflow that authenticates and audits the responsible reviewer.