Skip to content

feat(projects): a band for what the scan result cannot say about itself - #542

Merged
haksungjang merged 5 commits into
mainfrom
feat/w17-governance-band
Jul 29, 2026
Merged

feat(projects): a band for what the scan result cannot say about itself#542
haksungjang merged 5 commits into
mainfrom
feat/w17-governance-band

Conversation

@haksungjang

Copy link
Copy Markdown
Contributor

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}/governance produces no new number. The risk score comes from the Overview tab's aggregation, 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.

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.

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 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. 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, since max() 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_team treats the membership-derived role == "super_admin" as a bypass while team_scope_filter was narrowed to is_superuser alone by an earlier review; that is shared by every project route and wants its own look.

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.
@haksungjang
haksungjang merged commit 1e20083 into main Jul 29, 2026
26 checks passed
@haksungjang
haksungjang deleted the feat/w17-governance-band branch July 29, 2026 07:36
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