feat(projects): a band for what the scan result cannot say about itself - #542
Merged
Conversation
A scan report says what is in the code. It cannot say whether the build gate
would let it ship, whether a known-exploited finding is past its CISA
deadline, whether someone is waiting on an approval, or which way the last few
scans moved — those are decisions an organisation made, and a tool that runs
once and prints has nowhere to keep them.
The band puts those five signals between the header and the tabs. All of them
are reachable from a tab already; the point is that reading them costs no
navigation, so the project's standing state is present while you work inside
any one tab.
`GET /v1/projects/{id}/governance` composes rather than measures. The risk
score comes from `services.risk_score` over the same snapshot the Overview tab
uses, the verdict from `evaluate_gate` itself, the KEV buckets and the
approval count from the functions the action queue calls. A band that
disagreed with the tab three pixels below it would be worse than no band, and
tests pin each agreement rather than trusting it.
Worth naming: this route calls `evaluate_gate` directly where the dashboard's
action queue deliberately does not. The queue needs a verdict for every
project at once and five to seven queries each does not scale, so it
aggregates the gate's inputs and pays for the duplication with a parity test.
Here there is one project, the real evaluator costs nothing, and there is
nothing to keep in sync.
Two states stay distinct because confusing them is expensive. A project with
no succeeded scan reads "Never scanned", not a passing gate — every number is
zero either way. And a band that fails to load says so instead of rendering
zeros, because an absent answer is not a reassuring one.
The trend draws nothing below two scans. One point is a dot, and a line
through it is an invented slope.
Five tiles in a row is the exact shape §3.6-(2) flagged as the narrow-viewport
trap, so the strip is a two-column grid below `xl`: the narrow gate and the
axe scan both pass on the two project-detail screens.
Access control came back clean — the reviewer traced the order of operations and confirmed nothing is read before `assert_team_access`, and that removing the guard fails a test. What it found was that the band's central claim was false on one axis. **The risk score disagreed with the Overview tab.** The band assembled it from the release-snapshot helpers, which start at `license_findings`; the tab starts at `scan_components` and LEFT JOINs. A component the scan saw but could not attribute a licence to is not absent from the licence distribution — it lands in `unknown`, which is a scored band. So a project with no CVEs and two unlicensed components scored 8.7 on the tab and 0 in the band three pixels above it. That is exactly the drift this endpoint exists to prevent. Fixed at the root rather than in the band: the Overview's aggregation is now `project_detail_service.distributions_for_scan`, and both surfaces call it. Sharing the query makes the class of defect impossible instead of merely tested for. The regression guard is a fixture where the licence axis decides the score — a security-dominant fixture proves nothing, because `max()` picks the other axis no matter how the licence side is computed. Verified by mutation: restoring the old helpers fails it. **The band ignored the snapshot pin.** Opening a two-month-old release put every tab on that snapshot while the band kept describing today's HEAD — "Blocked · 5 critical" beside a release that was not. Two of its five tiles, approvals and KEV deadlines, have no historical form to switch to, so the band is simply not rendered while a snapshot is pinned; the snapshot banner already says why the page looks different. **A gate blocked by EPSS alone read "0 critical · 0 forbidden licences"** — every number true, the sentence useless, and `epss_gate_count` was a field the payload carried and nothing rendered. Only non-zero clauses appear now, and the EPSS condition has one, because it can block on its own. Three tests were added for states the first suite could not reach: one succeeded scan cannot prove an anchor (there is nothing else to pick), so a two-scan fixture now pins that the score, the gate and the KEV bucket all describe the newest while the trend carries both in order; the approvals count is asserted against a second project's queue; and a 200 body is asserted at all, which the three refusal tests did not do. Documented rather than changed, because both are deliberate and neither was written down: the trend counts the Overview donut's population including dismissed findings, so it holds its shape when a team triages a gate to pass; and the gate's forbidden-licence count honours the team's licence policy while the score's licence axis uses the category stored at scan time. Also from the review: the independent reads are gathered rather than serial, the project row comes from the owning module's loader instead of a second copy of the query, and the 403/404 pair is no longer called existence-hiding — a 403 confirms the project exists.
"트리아지하는 그 스캔에서 CycloneDX · SPDX SBOM 을 그대로 만듭니다" is English word order wearing Korean particles. The sentence it came from — "SBOMs in CycloneDX and SPDX, generated from the same scan you triage" — puts the qualifier first, and Korean does not. Rewritten as two clauses that say the same thing the way the language says it: the formats, then the fact that no second run is needed. The same pass went over the other strings added with the cockpit, the gateway and the governance band; the ones fixed had the same shape (a relative clause carried over intact, or a noun doing work a verb should do). None of this was checked by anything. `tools/ko-style/lint.mjs` runs on the KO markdown under `docs-site/i18n/ko/.../current/` and nothing else — the app's own Korean strings have never been in its scope, so the linter I ran on the docs never saw this sentence. Worth knowing before assuming the gate covers it: fed the sentence directly, the current 18-rule catalogue does not flag it either. Extending the scope would catch catalogued patterns appearing in UI strings, which is worth doing, but it would not have caught this one.
The linter has only ever looked at the KO documentation mirror. The strings users actually read — `apps/frontend/src/locales/ko/*.json` — were outside its scope, which is how "트리아지하는 그 스캔에서 …" reached the login screen while a clean run was reported on the guides beside it. UI copy is read far more often than the guides are. Scope now covers both. The locale files are linted as raw JSON: every key is an ASCII identifier and every rule is a Korean pattern, so a rule cannot match a key, and line numbers stay honest without a parser. 69 files → 82. Nothing enforced the linter at merge time either. A PostToolUse hook ran it while editing, which helps whoever has the hook and no one else, and no workflow ran it at all. It is a step in `lint (frontend)` now — `--all` rather than `--changed`, because checkout here is shallow and the changed-mode diff is not in the clone: it would fall back to an empty file list and report success for having examined nothing, which is the exact shape of the visual gate this repository already shipped once. One rule added, `relative-clause-geu` (S3): a demonstrative 그 after a relative clause that already restricts the noun, which is what English "the same X you Y" turns into when translated in place. It flags the original sentence and stays silent on the rewrite, and it fires zero times across the existing 82 files. Being clear about what this does not do: extending the scope would not have caught that sentence on its own — fed to the pre-existing catalogue it produced no finding. The new rule points at the shape; "reads like a translation" remains a judgement a regex can raise and only a person can settle. S3 means it is reported, not blocking, for the same reason.
Captured on the Linux runner via the ui-gates dispatch. The two project-detail screens gained the band; the rest re-render byte-identically under --update-snapshots=all. The baseline also shows the composition holding: the band reads 'Blocked · 10 critical · 13 forbidden licences' and the Build gate card below it reads the same two numbers, which is the whole point of composing rather than recomputing. The a11y baseline is unchanged — the band adds no violations.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
W17. A scan report says what is in the code. It cannot say whether the build gate would let it ship, whether a known-exploited finding is past its CISA deadline, whether someone is waiting on an approval, or which way the last few scans moved — those are decisions an organisation made, and a tool that runs once and prints has nowhere to keep them.
The band puts those five signals between the header and the tabs. All of them are reachable from a tab already; the point is that reading them costs no navigation, so the project's standing state is present while you work inside any one tab.
Composition, not measurement
GET /v1/projects/{id}/governanceproduces no new number. The risk score comes from the Overview tab's aggregation, the verdict fromevaluate_gateitself, the KEV buckets and the approval count from the functions the action queue calls. A band that disagreed with the tab three pixels below it would be worse than no band.Worth naming: this route calls
evaluate_gatedirectly where the dashboard's action queue deliberately does not. The queue needs a verdict for every project at once and five to seven queries each does not scale, so it aggregates the gate's inputs and pays for the duplication with a parity test. Here there is one project, the real evaluator costs nothing, and there is nothing to keep in sync.What the review found
Access control came back clean — the reviewer traced the order of operations and confirmed nothing is read before
assert_team_access, and that removing the guard fails a test. It found the central claim false on one axis.The score disagreed with the Overview tab. The band assembled it from the release-snapshot helpers, which start at
license_findings; the tab starts atscan_componentsand LEFT JOINs. A component the scan saw but could not attribute a licence to is not absent from the licence distribution — it lands inunknown, which is a scored band. A project with no CVEs and two unlicensed components scored 8.7 on the tab and 0 in the band.Fixed at the root: the Overview's aggregation is now
distributions_for_scan, and both surfaces call it. The regression guard is a fixture where the licence axis decides the score — a security-dominant fixture proves nothing, sincemax()picks the other axis regardless. Verified by mutation.The band ignored the snapshot pin. Opening a two-month-old release put every tab on that snapshot while the band described today's HEAD. Two of its tiles have no historical form, so the band is not rendered while a snapshot is pinned.
A gate blocked by EPSS alone read "0 critical · 0 forbidden licences" — every number true, the sentence useless. Only non-zero clauses appear now, and EPSS has one.
Three tests were added for states the first suite could not reach: one succeeded scan cannot prove an anchor, so a two-scan fixture pins that score, gate and KEV all describe the newest while the trend carries both in order; approvals are asserted against a second project's queue; and a 200 body is asserted at all.
Documented rather than changed
Both are deliberate and neither was written down. The trend counts the Overview donut's population including dismissed findings, so it holds its shape when a team triages a gate to pass. And the gate's forbidden-licence count honours the team's licence policy while the score's licence axis uses the category stored at scan time — the Overview tab has the same split; the band shows both at once.
Gates
Backend: 13 governance tests plus the full unit suite green (one known local-only failure documented in the handoff), ruff and mypy clean, OpenAPI contract updated. Frontend: 1571 unit tests green, tsc clean, token-lint holds at 130. Narrow-viewport and axe both pass on the two project-detail screens — five tiles in a row is the shape §3.6-(2) flagged, so the strip is a two-column grid below
xl. EN/KO together, ko-style 0 findings, user guide updated in both.Follow-ups this surfaced
The approvals tile links to the global
/approvals, which has no project filter — the number is this project's, the destination is not.can_access_teamtreats the membership-derivedrole == "super_admin"as a bypass whileteam_scope_filterwas narrowed tois_superuseralone by an earlier review; that is shared by every project route and wants its own look.