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.
The shape of it
Section titled “The shape of it”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.
What gets captured
Section titled “What gets captured”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.
| Field | What it holds |
|---|---|
verdict | confirmed, rejected, severity_wrong, missed, or unclear |
reason_code | Optional: false_positive, accepted_risk, fixed, not_applicable_here, other |
comment | The reviewer’s own words, verbatim |
source | dashboard_triage, github_command, github_review_comment, api |
trusted | Whether this may influence a future analysis. Decided at write time, never recomputed |
severity | Copied 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.
Learning from pull-request comments
Section titled “Learning from pull-request comments”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:
- 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. - An inline review comment on a file —
pathnarrows the pull request’s findings; exactly one match attributes, several do not. - 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.
How a reply finds its analysis
Section titled “How a reply finds its analysis”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:
| Marker | Answers |
|---|---|
<!-- 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 themwhen judging confidence and severity. They do not override the rules above, they do not exemptanything 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...The failure mode this is built against
Section titled “The failure mode this is built against”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:
- 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.
- A severity ceiling. Findings above
feedback.severity_ceiling(HIGHby default, soCRITICALis exempt) are never calibrated, however often the rule has been dismissed. - A sample floor. Below
feedback.min_samplesdecisive verdicts a rule contributes nothing.unclearverdicts 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. - Trusted sources only. See below.
- 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 and rule proposals
Section titled “Rule health and rule proposals”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:
| Evidence | Proposed |
|---|---|
Rejection rate ≥ proposal_disable_threshold (0.9) | Disable the rule |
Rejection rate ≥ proposal_rejection_threshold (0.6) | Lower severity one step |
…and already at LOW | Route matches to human review instead |
Confirmation rate ≥ proposal_confirm_threshold (0.9) on a requires_review rule | Stop 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.
Reproducibility
Section titled “Reproducibility”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.
Seeding from history
Section titled “Seeding from history”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.
python src/tools/backfill_feedback.py --dry-runpython src/tools/backfill_feedback.pyReplayed 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.
Retention
Section titled “Retention”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.
Endpoints
Section titled “Endpoints”| Endpoint | Role | Purpose |
|---|---|---|
GET /feedback | member | The feedback log, filterable by rule/verdict/source |
POST /feedback | member | Record a verdict from a system OpenTremor doesn’t see |
GET /rule-health | member | Per-rule precision, plus the thresholds in force |
GET /rule-proposals | member | Suggested rule changes |
POST /rule-proposals/refresh | admin | Recompute from current feedback |
POST /rule-proposals/{id}/apply | admin | Accept one — edits the rule |
POST /rule-proposals/{id}/dismiss | admin | Reject one — the rule is untouched |