fix(audit): stop under-reporting suppressed_count as a maximum - #86
Merged
Conversation
The field took the larger of two tallies — the withheld bug ids and the id-less suppressed content — rather than their total. A summarize_bug call that dropped three private comments while hiding two duplicate marker ids recorded 3 beside a two-element id list, where the truth was 5. It under-reported silently: the record validated and looked complete. That matters because I3 forbids the client learning anything was withheld, so the audit stream is the only place the fact surfaces, and suppressed_count is authoritative by design — suppressed_ids sits behind a config switch, so with ids off the count is all an operator has. A maximum of two disjoint populations is not a count of anything, and cannot satisfy the "authoritative, never infer it from the id list" contract the field already documented. Sum them. The populations are disjoint by construction, not merely by category: the two tools that feed both tallies, bug_comments and summarize_bug, harvest their duplicate-marker ids from the comments that survived the private filter, so a dropped comment contributes its id to nothing even when it carries one. list_attachments, the third id-less site, names no ids at all. Comment at both harvest sites, since that is where the mistake would be made — the I14 rationale beside them is an active invitation to move the harvest pre-filter, which would silently turn this under-report into an over-report. Keep SCHEMA_VERSION at 1. No field is added, removed, renamed or retyped, and a bump would fork the reader for a structurally identical record. #68 asked for the version to move with any semantics change; it deliberately does not, and DESIGN.md records the cost that buys — a v: 1 corpus spanning this change carries both readings, with the deployed build and not the schema version as the boundary. Closes #68
Closed
2 tasks
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.
Closes #68.
AuditCell::into_guard_infocomputedsuppressed_countasmax(suppressed_ids.len(), suppressed_extra)— the larger of two tallies, not a total. A call that dropped 3 private comments while hiding 2 duplicate-marker bug ids recordedsuppressed_count: 3beside a two-element id list, where the truth was 5. It under-reported silently: the record validated and looked complete.I3 forbids the client learning anything was withheld, so the audit stream is the only place this surfaces, and
suppressed_countis authoritative by design —suppressed_idssits behind a config switch, so with ids elided the count is all an operator has.The decision, and a conscious departure from AC 2
Sum the two tallies. Stay on
SCHEMA_VERSION = 1. No field added, removed, renamed or retyped.#68's second acceptance criterion asked that
SCHEMA_VERSIONmove with any semantics change, coordinated with #34. It deliberately does not. A maximum of two disjoint populations is not a count of anything, so this restores the contract the field already documented ("authoritative … never infer the count from the id list") rather than replacing a working one; and a bump would fork every reader for a record whose fields, names and types are identical. The project's own precedent points the same way —scanwas added to the record and stayed v1.The cost that buys is recorded in
DESIGN.md, not just here. There is no discriminator in the record:AuditEventcarriesv/ts/seq/sessiononly,initializerecords the client's version and never bugwarden's build,policy_hashis unchanged absent a policy edit, andsuppressed_count >= len(suppressed_ids)holds under both readings, so there is no structural tell either. Av: 1corpus spanning this change carries both readings, with the deployed build as the boundary. The safe reading of a pre-upgrade record is "at leastmax(suppressed_count, len(suppressed_ids)), possibly more".SCHEMA_VERSION's rustdoc now states the structure-not-meaning rule this relies on, which it previously left unstated.Why the sum is safe
The populations are disjoint by construction, not merely by category. Three sites feed the id-less tally:
bug_comments(server.rs:1889),list_attachments(:2723),summarize_bug(:3117). The two that also name ids harvestGuard::duplicate_marker_idsfrom the post-filter comment list (server.rs:1881,:3111), so a dropped private comment contributes its id to nothing even when its text carries a marker.list_attachmentsnames no ids.bugs_quicksearchfeeds only ids, into aBTreeSetthat dedupes;note_scannever touches the id-less tally.Both harvest sites now carry a
LOAD-BEARING:comment — that is where the mistake would be made, and the I14 rationale sitting beside them is an active invitation to move the harvest pre-filter.Rollout impact
Operators running
suppressed_ids = truewith a consumer that alerts onsuppressed_count != len(suppressed_ids)will see new firings on mixedbug_comments/summarize_bugcalls after deploy. The record is now correct; the alert's premise was only ever true under the old maximum.Adversarial review before opening
Three reviewers attacked the uncommitted diff — disjointness, invariants, and convention lenses. Findings and resolutions:
count=6, ids=[666,667,668]where the truth is 5: one withheld comment counted twice, an over-report strictly worse than the bug being fixed.id=666,667,668classify variant mounted so the mutant dies on the number rather than a 404. Both pre-filter mutants now fail.summarize_bug's recorded number changes with this diff but had zero integration coverage — deleting its counter call left the suite green.summarize_bug_records_the_same_total_as_bug_comments; the deletion mutant now fails 2-vs-5.audit.rs, but the mistake is made inserver.rs.LOAD-BEARING:comments at both harvest sites.DESIGN.mdandSCHEMA_VERSION's rustdoc.Guard::disclosablefails closed and withholds anyway), making the policy fixture decorative..expect(1)on the mock; all three tests now fail if it goes unmatched.Verified under refutation and left alone:
ScanInfo::dropped's analogy is about elidability, not max-vs-sum; the serialization golden builds a hand-writtenGuardInfoliteral and never drains a cell; the README worked example is abugs_quicksearchrecord where no id-less path exists. A repo-wide sweep found no survivor of the oldmax()wording. Disjointness also holds underinclude_private = true,total - filtered.len()cannot underflow (filter_privateis a pure.filter()), and noastruncation shadows the saturation.Verification
cargo fmt --check, both clippy invocations,cargo test --workspace --all-targets --locked(421 passed, 0 failed) andcargo deny checkall pass — re-run independently, not taken on report.cargo doc --no-deps --workspaceis byte-identical to themainbaseline.Reverting
saturating_addto.max(fails five tests. Every test added or rewritten here was mutation-proven to fail against the change it claims to pin.Follow-ups filed
#85 (
AGENTS.mdstill calls the invariant set I1–I13 while DESIGN.md defines through I16). Also noted, not yet filed: removingnote_suppressed_count'sif n == 0 { return; }guard leaves the suite green, so nothing asserts a cleanbug_commentscall recordsservedrather thanserved_filtered— pre-existing at HEAD.