Skip to content

Add a filter for findings whose owner cannot act - #375

Merged
haksungjang merged 1 commit into
mainfrom
er67-inactive-filter
Sep 4, 2026
Merged

Add a filter for findings whose owner cannot act#375
haksungjang merged 1 commit into
mainfrom
er67-inactive-filter

Conversation

@haksungjang

Copy link
Copy Markdown
Contributor

Closing an account does not remove its assignments, and that is deliberate: dropping them would hide that somebody had picked the work up. What it leaves is a row with a name on it that nobody is acting on, and nothing in the product could find those. The sweep after somebody leaves a team could not be run, which is the other half of the assignment work.

?assignee=inactive returns them. The token names a state rather than a person, because the parameter already says whose, so it reads the way unassigned does. Narrow on purpose: a wider word like "unactionable" would mean one thing today and promise several, and every later reason a finding cannot move would become a decision about whether it belongs inside that word. Another reason gets another value and these keep their meaning.

The performance question the previous code left open is now answered rather than assumed. assignee_is_active was a correlated subquery, which the planner evaluates above the LIMIT, so it ran once per returned row while the value was output-only and would have run once per table row under a filter. The comment there said to convert it to an outer join and re-measure rather than assume, so that is what happened, on 5,000 findings in one scan because the original 117-row measurement puts the two hypotheses a factor of two apart where noise lives:

  • output-only, as it was: loops=50 for a 50-row page
  • with the filter added to the subquery: the same subplan twice, loops=50 for the projection and loops=5000 for the filter
  • as an outer join, with and without the filter: loops=1

Measured through list_project_vulnerabilities with EXPLAIN ANALYZE on the statement the service actually emits, not a hand-written copy, because a copy measures the transcription. The output-only path moved to the join too: it is no worse there, and two spellings of one value is how one of them gets fixed alone later.

loops=1 is the property. The hash join and sequential scan of users that produced it are what this database size chose, and a deployment with far more users may plan differently without the per-row repetition coming back. That distinction is in the comment so the next person does not read a different plan as this note being wrong.

Outer and not inner, which is the sharp edge. An unassigned finding has no user row, so an inner join removes every one of them from every list in the product. Making the join inner fails fourteen tests, and none of the fourteen is about that: they all assign before asserting, so the symptom an operator would actually meet was unwatched. A test now asks the unfiltered list for a finding nobody owns.

The filter is is_(False) rather than a negation, because an unassigned row is NULL here and a negation reads as though it should match.

The if/else that dispatched two tokens became explicit branches with an unreachable final one. A third value added to the old shape would have fallen silently into the unassigned arm.

The token vocabulary lives in four places: the service, two route patterns and the frontend set. Nothing compared them. A contract test does now, reading all four rather than restating them, which would be a fifth copy. It found the frontend missing on its first run, which without it would have shipped as an option nobody could reach with nothing reporting it.

Verification: mypy over 916 files, ruff, 297 backend tests, the frontend type-check, and the two locale files compared key by key (1,081 each) because this worktree cannot run i18n:check. Reversing each choice fails a different test: the inner join, the negation instead of is_(False), a token added to one place only, and a token renamed in the guide.

Closing an account does not remove its assignments, because dropping them
would hide that somebody had picked the work up. What is left is a row with a
name on it that nobody is acting on, and nothing could find those, so the
sweep after somebody leaves a team could not be run.

The value used to be a correlated subquery, evaluated above the LIMIT, so
filtering on it would have moved the evaluation below and run once per table
row. Measured on 5,000 findings before changing anything: output-only
loops=50, and with a filter the same subplan twice, loops=50 and loops=5000.
An outer join is loops=1 either way, so the output-only path did not regress.

Outer and not inner: an unassigned finding has no user row, and an inner join
removes every one of them from every list. The existing assignee tests all
assign before asserting, so none of them would have caught that.

The token vocabulary lives in four places and nothing compared them. A
contract test does now, and found the frontend missing on its first run.
@haksungjang
haksungjang merged commit 738c4d4 into main Sep 4, 2026
30 checks passed
@haksungjang
haksungjang deleted the er67-inactive-filter branch September 4, 2026 22:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant