Skip to content

docs(architecture): the refusal has a page, and the page has a guard - #295

Merged
evkir merged 5 commits into
mainfrom
docs/the-refusal-has-a-page-and-the-page-has-a-guard
Sep 23, 2026
Merged

evkir merged 5 commits into
mainfrom
docs/the-refusal-has-a-page-and-the-page-has-a-guard

Conversation

@evkir

@evkir evkir commented Sep 23, 2026

Copy link
Copy Markdown
Owner

The tree defines ten exceptions and six environment readers. Before this
branch, not one of them was named anywhere in docs/ or the README -- a grep
across both returned zero lines. docs/architecture/refusal.md now states the
four shapes a refusal takes, keyed on who is standing at the screen, and
tests/architecture/test_every_refusal_is_on_the_page.py holds the page
against the tree in both directions.

The guard collects structurally rather than by suffix, and its control runs
the same collector over three generations of subclass -- at depth two a flat
check still answers correctly, which a shorter sample proved by letting a
mutant survive.

Two findings came from the table itself, not from the plan:

  • RateLimitError was declared in utils/backoff.py, raised by nothing, caught
    by nothing, imported by nothing. Removed with nvd_backoff, which had the
    same single occurrence. The 429 both described is already answered by
    httpx.HTTPStatusError in the two NVD paths.
  • exponential_backoff(fn, max_retries=0) reached raise None and answered
    with "exceptions must derive from BaseException" -- a TypeError from the
    retry helper naming neither the call nor the reason. Now a ValueError
    before the first request. mypy had reported the line as [misc] all along;
    the module is outside [tool.mypy] files, so the gate never saw it.

The helper had no tests at all before this branch. Eight now hold it.

Badge 2988 -> 3001. Wide mypy errors 272 -> 269, boundary unchanged, drift
none. Version 1.7.0 untouched.

What this changes

How it was measured

Checklist

  • ruff format --check cyberai/ tests/ and ruff check cyberai/ tests/ pass
  • pytest -W ignore::DeprecationWarning -m "not slow and not smoke" passes
  • New behaviour is covered by a test that fails without the change
  • I have read CLA.md and I hereby sign the CLA

The tree defines ten exceptions and six environment readers, and not one of
them was named in docs/ or in the README. Ten ways the product says no,
documented nowhere -- including RateLimitError, which is declared in
utils/backoff.py and raised by nothing at all. A grep for any of the ten
across docs/ and README.md returned zero lines before this commit.

docs/architecture/refusal.md states the four shapes a refusal takes, keyed
on who is standing at the screen: raised and left to travel, raised and
turned into one CLI line, raised and turned into a value, or not raised at
all. The last of those is the environment readers, which narrow to a default
rather than aborting a scan: from_env has no raise in it by design, nine call
sites reach it, and one is inside an MCP tool where an exception is not a
message anybody reads.

The guard is the inversion the manifest page already uses, with the set
collected by AST instead of by dataclasses.fields. Both directions: a
refusal the page omits is a mechanism no reader can find, and a name the
page keeps after a rename is the same rot wearing the other face.

Collection is structural, never by suffix. Every exception in the tree today
ends in one of five words, so a name rule passes every assertion here --
mutation confirmed it. The control therefore runs the same collector over a
sample the package does not contain: three generations of subclass and a
helper called ReportsError that inherits nothing. Depth three rather than
two, because at depth two a flat check still answers correctly and the
mutant that removed the recursion survived.

One collector, called twice. An earlier revision had two copies and the
package copy could be swapped for a name test with the suite staying green.
…eads

A page nobody links is a page nobody opens. The Documentation table is where
a reviewer goes, and the new page sits beside the two that share its folder.

Reachability is not guarded, deliberately for now. test_readme_links holds
that a path in the table resolves; nothing holds that a file in docs/ is in
the table, and mutation confirmed it -- deleting this row leaves every test
green. Measured on 2026-09-23: six of twenty-five pages under docs/ are
linked from nowhere in the README, so the guard that would close this is a
day of work and six decisions, not a line in this commit.
The page written in the first commit generates its table from the tree, and
the first thing that table found was a refusal that did not exist.
RateLimitError declared "raised when API returns 429" with no code raising
it, no code catching it and no code importing it: one occurrence in the
whole tree, its own class statement. The 429 it described is real and is
already answered -- both NVD request paths raise httpx.HTTPStatusError on
429 and 503, and exponential_backoff retries on exactly that type.

nvd_backoff goes with it, for the same measured reason: one occurrence,
its own def. It also caught (Exception,) indiscriminately, so had anything
called it, a typo in a URL would have cost five identical attempts.

The tests come first in the same commit because exponential_backoff had
none at all, and deleting two objects from an untested module cannot be
told apart from breaking it. Five now hold the properties the caller
depends on: a late success is not a failure, the ceiling counts attempts
rather than sleeps, the exception the caller sees is the last real one, a
failure outside the declared set travels at once, and no delay is paid
after the final attempt.

Wide mypy errors 272 -> 270, both on the deleted def. The module is outside
[tool.mypy] files, so the boundary does not move and drift stays none.
exponential_backoff(fn, max_retries=0) walked past an empty loop to its own
final raise with nothing caught, and Python answered `raise None` with
"exceptions must derive from BaseException". A TypeError from the retry
helper, naming neither the call that failed nor the reason -- logged one
line after an error claiming the call had failed after zero attempts.

Zero attempts is a caller's mistake, so it is answered at once and before
any request goes out: ValueError naming the value received. Reading zero as
one attempt would invent a choice nobody made, which is the distinction the
refusal page spends half its length on.

The None branch after the loop is unreachable while the guard stands, and
it says so rather than carrying a type: ignore. Mutation shows why the
branch is there and not silenced: replacing its condition with False leaves
every test green and brings [misc] straight back on the final raise -- the
checker is the only channel that sees it, exactly as it was the only
channel that saw the original defect. The module is outside [tool.mypy]
files, so mypy had been reporting this line all along while the gate could
not.

Three tests: zero refused before the first call, a negative refused the
same way, and one attempt allowed and made. The third is the control -- a
guard written as `< 2` passes the first two.

Wide mypy errors 270 -> 269. Boundary unchanged, drift none.
@codecov

codecov Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

…rage too

codecov failed the patch at 75%: one line, the RuntimeError inside the
None branch after the loop. It is not a line anybody forgot -- it cannot
be reached while the argument guard stands, which is what its own comment
says and what the guard makes true.

Marked with the form already used in cyberai/mcp/client_probe.py: pragma
with the reason spelled out, not a bare directive. Coverage reads the
pragma and the checker does not, so the branch stays where it is and goes
on holding the type. Re-ran the mutation that matters: replacing the
condition with False still brings [misc] back on the final raise, so the
pragma silenced a report and not a rule.
@evkir
evkir merged commit a85d882 into main Sep 23, 2026
10 checks passed
@evkir
evkir deleted the docs/the-refusal-has-a-page-and-the-page-has-a-guard branch September 23, 2026 15:02
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