Skip to content

Three readers of one contract: the transitive finding count over-reports, and two named readers disagree #130

Closed

Description

@rodrigorjsf

Finding

Three readers now answer the same question — "which findings does a finished scan's report
actually carry?" — with different code, one behavioural difference between two of them, and an
outright over-count in the third. This was filed describing only the first two; triage measured the
rest and widened it.

Two are named functions:

Both select filtered_findings by presence rather than truthiness, and both subtract the
suppressed_findings partition. They diverge on exactly one input: when filtered_findings is
absent or malformed and the raw findings list is used instead.

  • reported_findings falls back to findings and still subtracts suppressed_findings.
  • effective_findings returns the raw list without subtracting, and its docstring says why:
    "raw findings are not the population that produced suppressed_findings".

Reproduce with a result mapping that has no filtered_findings key, a findings list of one
Finding, and a suppressed_findings entry wrapping that same Finding:

reported_findings  -> []
effective_findings -> ['TM1']

The third reader is unnamed and inline: the transitive merge path in the CLI computes
transitive_finding_count over _coerce_findings_list(report_result.get("filtered_findings")) and
subtracts nothing. filtered_findings is the pre-suppression partition, so every
baseline-suppressed transitive finding is counted into a number the report publishes:

transitive_finding_count as the merge path computes it : 2
what the report actually carries                       : 1

Reachable from an ordinary invocation — --transitive with a --baseline that accepts a transitive
finding — not only from a hand-assembled state.

Call-site inventory, measured rather than recalled: reported_findings has six call sites (five
in the CLI, one in the MCP server), effective_findings has one — _multi_skill_public_record_count,
in the same CLI module. So a single recursive scan counts its findings one way for the summary
table's finding_count and another way for its public-record budget.

Reason

The two were written independently for the same defect, on the two sides of a fork that had not
synced. The 2.9.6 upstream sync (#TBD) merged both into the tree and kept both, because collapsing
them is a behaviour decision and the merge's job was to be attributable, not to redesign. The four
reported_findings call sites (CLI summary tables, skillspector baseline, the MCP verdict, the
repository-scan digest) were left pointing at the fork's function so the merge's snapshot ledger
stayed readable.

Impact

The two named functions differ only on a branch a real scan cannot reach: the report node always
writes filtered_findings, so the fallback is exercised by unit tests alone.

The third reader is a live defect. transitive_finding_count reaches the JSON report and the
recursive combined payload, so a consumer gating on it sees more transitive findings than the report
lists, by exactly the number a baseline suppressed. Silent — the count and the findings array
disagree with nothing to flag it.

The real cost is drift. effective_findings is upstream's and will keep evolving; reported_findings
is the fork's and is what the CLI and the MCP server call. The next upstream change to the
selection semantics lands in one of them and not the other, and the two readers disagree exactly the
way reported_findings' own docstring warns about: "three consumers reading graph state three ways
is how they came to disagree with the report in the first place."

Gotchas

Options

  1. Fix the over-count first, on its own: route the transitive count through whichever reader
    survives, selecting before filtering on source_url. It is the only part with user-visible
    impact and it does not depend on the naming question below.
  2. Make reported_findings delegate to effective_findings and keep the fork's name and
    docstring. Smallest diff; adopts upstream's fallback semantics, so the five stream tests have to
    be re-read first. Cost: ~30 minutes.
  3. Delete reported_findings, repoint the six call sites. Cleaner, but loses the fork's
    docstring and touches the MCP server, whose comment names the fork's function by name.
  4. Keep both and add a test asserting they agree on every input the graph can produce. Cheapest
    to write and the weakest: it documents the duplication rather than removing it.

Option 0 first — it is the defect a user can observe. Then 1, which settles the semantics question
with the smallest reversible change; 2 is the cleanup that follows once nothing depends on the
fork's name.

Activity

  1. changed the title [-]Two readers of one contract: reported_findings and effective_findings diverge on the raw-findings fallback[/-] [+]Three readers of one contract: the transitive finding count over-reports, and two named readers disagree[/+] on Aug 22, 2026
  2. rodrigorjsf commented on Aug 22, 2026

    @rodrigorjsf
    OwnerAuthor

    This was generated by AI during triage.

    Agent Brief

    Category: bug
    Summary: One reader decides which findings a finished scan reports; today three do, and the one the transitive count uses over-reports by the baseline-suppressed set

    Current behavior:

    Three expressions answer "which findings does a finished scan's report carry", and they do not agree.

    • reported_findings (fork-authored, in the report node) — selects filtered_findings by presence, subtracts the suppressed_findings partition, and also subtracts when it falls back to the raw findings list.
    • effective_findings (upstream, in the suppression module) — the same selection, but its raw-findings fallback returns the list unsubtracted, deliberately: raw findings are not the population that produced suppressed_findings.
    • An inline third reading in the CLI's transitive merge path computes transitive_finding_count over the coerced filtered_findings list and subtracts nothing before counting entries carrying a source_url.

    The first two differ only on a branch a real scan cannot reach, because the report node always writes filtered_findings. The third is a live defect: filtered_findings is the pre-suppression partition, so with --transitive and a --baseline that accepts a transitive finding, transitive_finding_count exceeds the number of transitive findings the report lists, by exactly the suppressed count. Measured: 2 published against 1 reported.

    The two named readers are also both live in the CLI at once — the recursive summary table's finding_count uses one, and the recursive public-record budget uses the other, on the same result.

    Desired behavior:

    One reader owns the selection semantics. Every consumer that counts or lists a finished scan's findings — the CLI summary tables, the recursive public-record budget, the baseline generator, the MCP verdict payload, the repository-scan digest, and the transitive count — goes through it, and agrees with what the report renders.

    transitive_finding_count equals the number of findings the report carries that have a source_url. A baseline that suppresses a transitive finding lowers both the count and the list, together.

    Key interfaces:

    Acceptance criteria:

    • Exactly one function in the package selects "the findings a finished scan reports"; a repository-wide search for the other name returns no call sites (or only a delegating definition).
    • A test asserts that, for a result whose filtered_findings holds one kept and one baseline-suppressed transitive finding, transitive_finding_count is 1, not 2.
    • That test fails against the current implementation — demonstrate the failing run before the fix.
    • A test asserts that the recursive summary table's per-skill finding_count and the recursive public-record budget count the same result identically.
    • The chosen raw-findings fallback semantics (subtract or do not subtract) is stated in the surviving function's docstring, with the reason.
    • The five CLI stream tests that exercise the fallback branch are re-read against the chosen semantics and pass, adjusted with a stated reason if their expectation changes.
    • make test-unit, make lint, make format-check pass, and make update-snapshots leaves the tree clean.

    Out of scope:

    • Changing what the report node writes into filtered_findings or suppressed_findings. This issue is about how consumers read those two keys, not about the partition itself.
    • The baseline fingerprinting semantics, or which findings a baseline matches.
    • Renaming suppressed_findings or filtered_findings in graph state.
    • The MCP verdict's payload shape beyond which findings populate it.
  3. added
    bugSomething isn't working
    ready-for-agentAgent: ready for Claude to pick up and work on
    and removed
    needs-triageTriage: issue was just created; untriaged
    on Aug 22, 2026
  4. rodrigorjsf commented on Aug 22, 2026

    @rodrigorjsf
    OwnerAuthor

    This was generated by AI during triage.

    Closed by #141, squash-merged as e47ec3f. This issue's central measurement no longer reproduces,
    and that is the first thing a later reader needs.

    The premise is falsified

    The body states the over-count is "Reachable from an ordinary invocation" and measures "2 published
    against 1 reported". That was true when this was filed. It is not true on the current tree: upstream
    73dd1f1 ("Security fixes", NVIDIA/SkillSpector#393) narrowed what the report node writes into
    filtered_findings to the kept side alone, so the state the transitive count reads no longer
    carries the suppressed findings it was over-counting.

    The fix still landed, and the contract test still earns its place — but it has to induce the
    pre-73dd1f1 shape through a spy, because that shape is what the transitive merge path hands over
    today even though the report node no longer produces it. So the correct framing is a latent contract
    defect held shut by an upstream change, not a bug a user hits this afternoon. Anyone re-deriving the
    original repro will fail to reproduce it and should not conclude the fix was unnecessary.

    What shipped, in the order this issue asked for

    The brief sequenced the over-count fix first and the redundancy second, and that ordering held. The
    transitive count now counts only the findings the report carries, proven by a failing test written
    first. Separately, exactly one function in the package decides which findings a finished Scan
    reports: this issue measured the fork's reported_findings and upstream's effective_findings
    diverging on the raw-findings fallback — the fork's subtracted suppressed_findings, upstream's
    deliberately did not — and upstream's semantics won, per this fork's standing rule that upstream
    owns the truth about the core.

    Three things review caught that this issue did not anticipate

    The acceptance criterion's own test was true of itself. The assertion for the summary/budget
    agreement compared _multi_skill_public_record_count(result) - 1 against
    len(reported_findings(result)) — and that right-hand side was character-for-character the
    production expression at the call site the criterion names. An adversarial critic proved it by
    inlining a raw filtered_findings read at _combined_skill_entry's finding_count, the exact
    regression this issue exists to prevent
    , and watching every case stay green. It now routes through
    the entry builder and derives the suppressed record count from the state rather than hard-coding it.

    The premise survived in four docstrings, while the commit correcting it claimed there were three
    and that all three were done.

    A contract document gained a rule the tree does not keep. docs/DEVELOPMENT.md's new
    filtered_findings row read "Read it through suppression.effective_findings, never directly" —
    contradicted by three legitimate mid-graph reads — and described only what the report node writes,
    while the transitive merge writes the pre-partition merged population into the same key beforehand.
    Both ends are now qualified.

    Deferred, and filed

    reported_findings is now a one-line delegation, so one selection carries two names across six call
    sites. Its removal was deliberately sequenced after the defect fix and is #140, which also
    records the argument for keeping the name.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingready-for-agentAgent: ready for Claude to pick up and work on

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions