Skip to content

docs: record what guard.rule names and when it is absent - #83

Merged
plusky merged 1 commit into
mainfrom
docs/audit-rule-field
Aug 11, 2026
Merged

docs: record what guard.rule names and when it is absent#83
plusky merged 1 commit into
mainfrom
docs/audit-rule-field

Conversation

@plusky

@plusky plusky commented Aug 11, 2026

Copy link
Copy Markdown
Owner

Closes #67.

GuardInfo.rule was documented as "absent when the policy default decided". Policy::classify writes the literal "default" in both default arms (policy.rs:897, :901), so the parenthetical inverted the encoding — a consumer would read "default" as a rule of that name matching, and absence as the default deciding. The README corrected in #66 was left as the only place stating the truth.

The decision

Doc fix, not behaviour fix. The literal "default" stays. It is strictly more informative than absence, which cannot also express "no single rule decided the call". Re-encoding a default decision as absence would be a record-schema change and is deferred to the audit schema v2 work in #34; schema v1 records "default".

Per AGENTS.md, that decision is recorded in DESIGN.md's audit-stream decisions list — parallel to the #28 and #29 bullets — not only in a field comment, since #67 was filed precisely because rule: "default" looked like an accident.

What the documentation now says

  • A default-decided call records the literal "default", never an absent field.
  • The four synthetic names the guard emits beside the operator's own: "default", "min_bug_age_days", "<rule>:unreadable-metadata", "unavailable". The :unreadable-metadata suffix is scoped to a granting rule — an undecidable deny rule keeps its plain name, having denied for its own reason (policy.rs:886-891).
  • Nothing reserves those spellings. Policy::validate performs no rule-name check at all, so a policy may define a rule literally called default; the field names what decided, it does not prove which kind of thing it was.
  • The six cases where the field genuinely is absent. The issue named three (search, refusal, pre-dispatch gate) and missed the create gate on either arm, an id with no matching Access, and the attachment withhold plus its bug-0 padding assessment.
  • A tool removed from the router under I13 is a separate case: it records no guard object at all, already pinned by audit_wiremock.rs:520.

Tests

Two audit-record-level tests, plus a rename of cell_upgrade_by_a_default_decision_clears_the_rulecell_upgrade_by_a_rule_less_verdict_clears_the_rule (assertions byte-identical; the old name described a rule-less verdict, not a policy-default decision).

The search test uses a clean scan deliberately. With drops, note_scan merges ServedFiltered/rule: None — a strict rank upgrade that clears the rule unconditionally — so a dropping search cannot observe the property. A clean scan under default_action = "deny" with a named rule granting every row is where absence is genuinely attributable.

Adversarial review before opening

Three independent reviewers attacked the uncommitted diff — source-truth, invariants, and convention lenses. Two returned NOT MERGE-SAFE. Findings and resolutions:

Finding Resolution
The new rustdoc listed "a disabled tool" as a rule-less refusal. Found by two lenses independently. Disabled tools are removed from the router at construction (I13); there is no runtime refusal path, and audit_wiremock.rs:520 already pins guard.is_none(). The accuracy PR introducing a false claim. Removed, and replaced with the true statement about removed routes.
The search test was vacuous — proven by mutation: changing server.rs:1996 to note a "MUTANT-RULE" left the whole 418-test workspace green. note_scan with dropped > 0 was forcing the None. DESIGN.md booked it as coverage that did not exist. Rewritten against a clean scan; the replacement was mutation-proven to fail (left: Some("MUTANT-RULE"), right: None) in a scratch copy outside the repo.
The deliberate decision landed in DESIGN.md's Testing bullet rather than the decisions list AGENTS.md names as the design authority. Moved; audit.rs shrank to a reason plus a citation.
README.md:620 contradicted the new rustdoc twice — an absence list reading as exhaustive while missing three verified categories, and :unreadable-metadata attributed to any undecidable rule rather than a granting one. Both corrected in the same PR: it is the same logical change. (The two crate-level READMEs are symlinks to the root.)

Five non-blocking findings also applied: the Access::Granted rustdoc asymmetry, changelog voice in reference docs, the quicksearch_ test-name prefix, .as_deref() in an assertion, and a doc-link from note_verdict to the full case list.

Confirmed correct under refutation and left alone: the synthetic-name list is complete and exactly spelled; the "nothing reserves the name" warning was proven by a scratch binary built against the real Policy::from_toml_str; the merge description is exact at every boundary; the default-decision test is non-vacuous (two policy.rs mutations each killed it).

Verification

cargo fmt --check, cargo clippy --workspace --all-targets -- -D warnings, cargo clippy -p bugwarden --features gen --all-targets -- -D warnings, cargo test --workspace --all-targets --locked (12 suites, 418 tests, 0 failures), and cargo deny check all pass — re-run independently, not taken on report. cargo doc --no-deps --workspace emits the same 4 pre-existing warnings as main, none new.

Documentation-only outside the tests: no runtime behaviour, public signature, serde attribute, default, or SCHEMA_VERSION changes.

Follow-up

Nothing reserves the synthetic rule names, so an operator can make audit records ambiguous by naming a rule default. Documented here, not fixed — validate() rejecting the reserved spellings would be a behaviour change. Filing separately.

GuardInfo.rule was documented as "absent when the policy default
decided". Policy::classify writes the literal string "default" in both
default arms, so the parenthetical inverted the encoding: a consumer
would have read "default" as a rule of that name matching, and absence
as the default deciding. The README corrected in #66 was left as the
only place stating the truth.

Fix the documentation, not the behaviour. The literal "default" is
strictly more informative than absence, which could not also express
"no single rule decided the call"; re-encoding it would be a record
schema change and belongs with the v2 work in #34. Record that decision
in DESIGN.md's audit-stream decisions list, where a reader auditing the
schema will find it, rather than only in a field comment.

Document what the field really carries: the four synthetic names the
guard emits beside the operator's own, that nothing reserves those
spellings so a policy may define a rule called "default", and the six
cases where the field genuinely is absent — the issue named three and
missed the create gate, an unassessable id, and the attachment paths. A
tool removed from the router (I13) is a separate case again: it records
no guard object at all.

Pin it with two audit-record tests: a default-decided call records
"default" on the deny and allow sides alike, and a clean search records
no rule even though a named rule granted every row it served. The clean
path is load-bearing — with drops, note_scan's strict upgrade clears
the rule regardless, so a dropping search cannot observe the property.

Closes #67
@plusky
plusky merged commit d95b869 into main Aug 11, 2026
11 checks passed
@plusky
plusky deleted the docs/audit-rule-field branch August 11, 2026 20:46
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.

GuardInfo.rule rustdoc contradicts what the policy actually writes

1 participant