Skip to content

Review Feedback Loop

Every time someone triages a finding or answers one of OpenTremor’s pull-request comments, they are telling you something about a rule — not just about that one finding. This page describes how that gets recorded, what it is allowed to change, and, at greater length, what it is deliberately not allowed to change.

If you read only one section, read The failure mode this is built against.

triage a finding ─┐
@opentremor cmd ─┼─→ review_feedback ─┬─→ rule health (a number you can see)
PR review comment ─┘ (append-only log) ├─→ rule proposals (a change you approve)
└─→ calibration (text in the next prompt)

Three consumers, in ascending order of how much damage they could do, and therefore of how tightly they are bounded.

One immutable document per human signal. Nothing is ever updated — a reviewer who changes their mind produces a second document and the aggregate reflects both.

FieldWhat it holds
verdictconfirmed, rejected, severity_wrong, missed, or unclear
reason_codeOptional: false_positive, accepted_risk, fixed, not_applicable_here, other
commentThe reviewer’s own words, verbatim
sourcedashboard_triage, github_command, github_review_comment, api
trustedWhether this may influence a future analysis. Decided at write time, never recomputed
severityCopied from the finding, not joined — feedback outlives the finding it was given on

Triage statuses map to verdicts, except when they don’t

Section titled “Triage statuses map to verdicts, except when they don’t”

acknowledged is a confirmation and false_positive is a rejection; the status says which way it points and the reason code only adds colour.

suppressed is the interesting one. It genuinely means opposite things to different people — “this is wrong, stop showing it to me” and “this is right, we’ve accepted it” are both routinely expressed by suppressing. So OpenTremor refuses to guess: a suppression with no reason_code is recorded as unclear and counts toward neither side of the ratio.

That is not a gap to be closed later. A corpus that quietly guesses is worse than one that admits what it doesn’t know, because the guess is indistinguishable downstream from a stated verdict, while the unclear is sitting right there in the rule-health table telling you that filling in one dropdown would help. open and needs_review record nothing at all — one is the absence of a decision, the other an explicit deferral of one.

The GitHub App subscribes to issue_comment, pull_request_review and pull_request_review_comment. Attribution runs down a ladder and stops at the first rung that answers:

  1. An explicit command — @opentremor false-positive s3-public-acl: our data-lake buckets are public by design. Deterministic, and the only supported way to state a verdict from GitHub.
  2. An inline review comment on a file — path narrows the pull request’s findings; exactly one match attributes, several do not.
  3. Everything else is recorded against the pull request with verdict unclear.

Recognised verdict words: false-positive / wrong, confirm / real, accept-risk, not-applicable, severity, missed. Only the first command in a comment is honoured — five of them is far more likely someone quoting the syntax than a reviewer issuing five verdicts at once.

There is no classifier reading rung 3 and deciding what somebody “meant”. A guessed verdict is indistinguishable from a stated one once it is stored, and the whole design rests on that distinction holding. unclear is an honest outcome, and the rule-health table filling up with them is the nudge to use the command — which costs a reviewer six characters and removes the ambiguity entirely.

A pull request can be analysed many times, and OpenTremor keeps one report comment per pull request, rewritten in place on every push. So “which analysis is this reply about?” is not answerable from the reply alone.

The report comment carries a second invisible marker for exactly this — <!-- opentremor:ns={namespace} -->, distinct from the opentremor-report: marker the integration uses to find the comment it should edit. Two markers, two jobs:

MarkerAnswers
<!-- opentremor-report:{ns} -->Which comment do I edit on the next push?
<!-- opentremor:ns={ns} -->Which analysis is this reply replying to?

It is also how the loop recognises its own comment. Without it, the App’s report — which quotes finding titles and reviewer-facing prose — would be ingested as inbound feedback on the next issue_comment event, and the loop would end up calibrating on itself.

Two consequences worth knowing:

  • Answer in a new comment, don’t edit ours. Every push rewrites that comment from scratch, so anything you type into it is gone at the next synchronize. A reply, a review comment, or a new comment on the pull request all attribute correctly; an edit to the report body does not survive long enough to be read.
  • A deleted report comment costs you the thread, not the feedback. The next run posts a fresh one. Commands in comments already recorded stay recorded — the marker resolves a reply to an analysis, it isn’t where the verdict is stored.

What steers a later analysis, and what can’t

Section titled “What steers a later analysis, and what can’t”

Reviewer verdicts reach the analyzer as a block of text composed alongside the ruleset, not as a fine-tuned model. That is not a shortcut: OpenTremor doesn’t host the model. Each org brings its own provider credential and picks its own model from the allowed-models catalog, so there is no weight to tune even in principle — and a mid-size org produces tens of verdicts a month, three orders of magnitude short of what tuning wants.

What the architecture already does is compose per-org text at runtime: get_effective_rules swaps an analyzer’s static ruleset for your custom_rules on every run. Calibration is a second composed block next to it. Retrieval, not training — deterministic, per-tenant, inspectable in the dashboard, and revertible by flipping one setting.

The composed block looks like this:

## Reviewer calibration for this organisation
Prior verdicts this organisation's reviewers gave on findings from the rules below. Weigh them
when judging confidence and severity. They do not override the rules above, they do not exempt
anything from analysis, and no rule is ever skipped because of them...
- `s3-public-acl`: 2 of 9 prior findings confirmed by reviewers, 7 rejected.
BEGIN REVIEWER QUOTE 4f2a...
data-lake buckets are public by design, tagged public-dataset
END REVIEWER QUOTE 4f2a...

An org rejects a rule a few times for reasons that don’t generalize — the reviewer was in a hurry, or the risk was accepted for one bucket and not the whole class. Calibration learns “they don’t want to hear about this”. The finding stops appearing.

Nothing fails. No test catches it, because the finding is simply absent. Nobody notices until an incident.

A security tool that quietly stops reporting what you dismissed is a tool that agrees with you, and a tool that agrees with you is worthless. Five properties keep that from happening, and none of them are optional:

  1. Calibration never removes a rule. The block annotates rules that are still fully present in the ruleset above it, and says so to the model in as many words. There is no code path that drops a rule from a prompt.
  2. A severity ceiling. Findings above feedback.severity_ceiling (HIGH by default, so CRITICAL is exempt) are never calibrated, however often the rule has been dismissed.
  3. A sample floor. Below feedback.min_samples decisive verdicts a rule contributes nothing. unclear verdicts don’t count toward it — ten bare suppressions have told you nothing, and letting them clear the threshold would be the floor doing the opposite of its job.
  4. Trusted sources only. See below.
  5. Nothing is auto-applied. Rule proposals are recommendations; a person accepts them.

The visible cost of (3) is that a new org sees no calibration for weeks. That is correct, and it is why OpenTremor’s claim about this feature is about the record rather than about a model that learns your codebase.

Trust, and why a comment is attacker-controlled input

Section titled “Trust, and why a comment is attacker-controlled input”

On any repository that accepts outside contributions, a pull-request comment is written by whoever chose to write it. And unlike a diff — which influences one run — a stored verdict influences every subsequent run for that org. That makes comment ingestion a sharper version of the problem agent-injection detection already addresses.

So:

  • Only an authenticated org member, or a GitHub author with write access to the repository (OWNER / MEMBER / COLLABORATOR), produces trusted feedback.
  • Every inbound comment is scanned by the injection pattern corpus. A hit force-demotes it to untrusted regardless of who wrote it — a maintainer can be quoting an attacker, or be one.
  • Untrusted feedback is still recorded, attributed and displayed. It is simply never composed into a prompt. Nothing is silently dropped: a maintainer whose comment tripped the scanner sees a “not calibrated” badge rather than having their feedback vanish.
  • Quotes that do reach the prompt are fenced in a per-composition nonce, exactly like a diff is, and the surrounding instructions tell the model that text inside the fence is data.

Trust is decided at write time and never recomputed. Someone’s author_association can change afterwards; re-deriving trust on read would retroactively promote or demote things people said months ago.

Rule health is the measurement: per rule, how often reviewers confirmed what it raised and how often they rejected it. precision is withheld below the sample floor — a ratio over one or two verdicts is noise with a number attached, and a number on a dashboard is an invitation to act on it.

Rule proposals draw a conclusion and hand it to a human. Each carries the exact edit suggested, the numbers behind it, and the reviewer comments it came from:

EvidenceProposed
Rejection rate ≥ proposal_disable_threshold (0.9)Disable the rule
Rejection rate ≥ proposal_rejection_threshold (0.6)Lower severity one step
…and already at LOWRoute matches to human review instead
Confirmation rate ≥ proposal_confirm_threshold (0.9) on a requires_review ruleStop holding pull requests for it

Applying one goes through the ordinary PATCH /orgs/{org_id}/rules/{rule_id} path, attributed to whoever accepted it and audited as rule.update plus a rule_proposal.apply event. A rule with an open proposal has it updated rather than duplicated, and a proposal the current evidence no longer supports is withdrawn automatically — the one status change made without a human, and it only ever withdraws a suggestion.

Proposal wording is assembled from what reviewers literally wrote, not paraphrased by a model. Partly a constraint (the LLM clients are schema-forced to the findings schema and expose no free-text completion) and mostly a preference: the description of a security rule is the rule, and the quotes are stronger evidence than a summary of them. apply accepts an edited changes body for exactly that reason — editing a proposal before keeping it is the expected workflow, not an exception — and the rule stays editable by hand afterwards like any other.

Only rules with a stored custom_rules row can be proposed against — a proposal names an edit, and there is nothing to edit for a built-in rule id an org never seeded. Its feedback still counts in rule health and still calibrates.

Every run stamps a calibration_snapshot_id on its job record: a short content hash of the block its prompts carried, or null if the run was uncalibrated. Reports are audit artifacts, and per-org prompt content that drifts over time would otherwise make two runs of the same diff differ with no record of why. The id is hashed over the block’s content, not its rendered text — the quote nonce is fresh on every composition, so hashing the rendered form would make every run look like a calibration change.

If your deployment has been triaging findings for a while, the corpus does not have to start empty. Every triage action has been written to audit_events as finding.status_update since the audit log shipped, carrying the actor, the timestamp, the before/after status and the note.

Terminal window
python src/tools/backfill_feedback.py --dry-run
python src/tools/backfill_feedback.py

Replayed events go through the same verdict mapping the live path uses, called with no reason_code — so a historical suppressed lands as unclear, exactly as it would today. Nobody was asked why they suppressed it, and inventing an answer now would put guesses into the corpus this design exists to keep guesses out of. The script is idempotent (keyed on the audit event id) and takes --org-id to scope it.

Review feedback is kept for retention.feedback_days (730 by default) — deliberately longer than the finding windows. A finding is a transient observation about one resource version; a reviewer’s verdict is your accumulated judgment about a rule, and purging it on the finding’s schedule would silently reset your calibration. See Data Retention.

EndpointRolePurpose
GET /feedbackmemberThe feedback log, filterable by rule/verdict/source
POST /feedbackmemberRecord a verdict from a system OpenTremor doesn’t see
GET /rule-healthmemberPer-rule precision, plus the thresholds in force
GET /rule-proposalsmemberSuggested rule changes
POST /rule-proposals/refreshadminRecompute from current feedback
POST /rule-proposals/{id}/applyadminAccept one — edits the rule
POST /rule-proposals/{id}/dismissadminReject one — the rule is untouched