Skip to content

fix(guards): the router sweep was wrong four ways, and three guards swept at IMPORT (#16187, #16188) - #16189

Merged
mrveiss merged 12 commits into
Dev_new_guifrom
issue-16188-lazy-imports
Sep 10, 2026
Merged

mrveiss merged 12 commits into
Dev_new_guifrom
issue-16188-lazy-imports

Conversation

@mrveiss

@mrveiss mrveiss commented Sep 10, 2026 •

Copy link
Copy Markdown
Owner

Closes #16187, #16188, #16202

Update — the deferred finding is now fixed, and the reason for deferring it was wrong

The commit message on 1e727d5ba deferred the ACCESSOR_NOT_DECISION finding, saying applying it
at every call site made api.knowledge report UNGATED. That reason does not hold. Reverting the
accessor change did not fix that failure — it persisted — which proved the accessor was never its
cause. The actual cause was the aggregator branch, fixed separately in this PR. The accessor change
was judged by a test failure belonging to a different defect.

Re-tested on top of the aggregator fix in 8cac39b7a: 17/17 pass, and the verdicts are right
for the right reasons:

api.knowledge   gated   per-route: check_admin_permission, get_current_user
                        (previously gated ONLY via an incidental inline accessor call --
                         right verdict, wrong evidence, one tidy-up from a false UNGATED)
api.jwks        UNGATED reaching get_auth_middleware() still does not gate
totals          96 routers, 84 gated, 12 ungated; KNOWN_UNGATED did not grow

The exclusion was at the wrong granularity, which is why the fix is not simply "subtract
everywhere": get_auth_middleware() fetches, get_auth_middleware().get_user_from_request(req)
authenticates. ACCESSOR_DECISION_METHODS names the four methods that decide, derived from what
the tree actually calls on the accessor — get_user_from_request (31 sites),
check_file_permissions (11), verify_jwt_token, _extract_user_from_device_jwt. The rest fetch
config or mint and hash credentials, and authenticate no caller.

It also closes the coverage gap the re-review flagged: that pass noted no live module drove the
mixed-case ungated branch. One does now — api.knowledge mounts api.knowledge_search_aggregator,
which is on the frozen ungated list, and the evidence line names it.


Thinking Path

Two defects in the same scope — repo_tests/ guard modules that answer cleanly about
the wrong thing, or charge every importer for work no importer asked for. They touch
disjoint files, so they ride one CI run.

#16187 — the router sweep was wrong four ways. The enumerator merged in #16170 reported
routers as UNGATED that are gated. Verifying #15738's premise surfaced the third case and
fixing it surfaced the fourth:

# Escape Why the sweep missed it
1 _check_admin defined locally 0 hops — never left the module
2 gate reached through a non-auth import 1 hop — the walk stopped at the first non-auth name
3 get_user_service → get_tenant_context → get_current_user 2 hops — deeper than the walk went
4 a 24-line aggregator with no routes of its own counted as a router; it mounts others via include_router

The depth was never a declared quantity — it was whatever the recursion happened to reach.
It is now MAX_DEPENDENCY_HOPS = 3, named and asserted, with seen as a mandatory cycle
guard: the dependency graph here is recursive in practice, and an unbounded walk hangs the
guard rather than failing it.

#16188 — three guard modules did their sweep at import. reach_declarations_test
imports every guard to enumerate declare() calls, so a change to _reach.py paid the
full import cost before a single assertion ran. Import time is charged to every consumer
whether or not it uses the result — the pre-push hook, every importer, and collection
itself, including runs that deselect all of the module's tests.

What Changed

File Change
router_auth_enumerator.py MAX_DEPENDENCY_HOPS = 3; _resolve_gate_chain(..., seen, depth); _included_modules() excludes aggregators; ACCESSOR_NOT_DECISION excludes get_auth_middleware; _parse_cached / _ENUMERATED memoisation
router_auth_coverage_test.py asserts against the corrected verdicts
no_duplicate_dict_keys_test.py _swept() / _trees() — _sweep() parses 37,561 dict literals, now on first use
man_page_indexer_path_resolves_15853_test.py _found_and_swept() replaces module-scope _FOUND, _SWEPT = ...
model_pricing_tables_agree_15912_test.py _aliases() / _tables() / _shared() replace four module-scope bindings; one of them started an Ollama client at import

Verification

Sweep result after the fix: 98 routers · 86 gated · 9 ungated · 2 documented-open · 1 unreadable
(was 98 · 83 · 12 · 2 · 1 — three of the twelve were false).

Measurement Before After
enumerate_routers across 6 tests 98s (21.7s × 6, no memoisation) 16s
no_duplicate_dict_keys_test import 19.47s 0.04s
man_page_indexer_path_resolves import 24.5s lazy
model_pricing_tables_agree import 11.4s lazy
full guard-set import 67s 5.9s

Pre-push ran router_auth_coverage_test.py and passed within budget — the same changeset
timed out three times before the import cost came down.

Two of the four false UNGATED verdicts were published as findings on #15745 before review
caught them; that is what prompted declaring the depth rather than inheriting it.

Model Used

Claude Opus 5

…1s to everyone (#16188)

Measured: importing the full guard set took 67s, of which 63.6s was three modules
doing work at module scope before a single assertion ran.

  no_duplicate_dict_keys_test          19.5s   _sweep() parses 37,561 dict literals
                                               + _trees_holding_python() walks the tree
  man_page_indexer_path_resolves       24.5s   files_naming_the_indexer() sweep
  model_pricing_tables_agree           11.4s   discover_pricing_tables() + cross-index

Now lazy behind cached accessors. Import: 67s -> 5.9s. The sweeps still run, in the
tests that read them, and are cached so a module's several tests pay once.

WHY IT MATTERED BEYOND TIDINESS. reach_declarations_test imports every guard module
to enumerate declare() calls, so ANY change to repo_tests/_reach.py paid all 67s
before running one assertion — against a 128s pre-push budget. Three pushes of the
#15928 mechanism timed out with DID NOT RUN, and I attributed it to the budget
scaling with changed-file count (#16182).

That was half right. #16182 is real and unchanged: a 2-file changeset still buys
128s where a 22-file one buys 208s. But the reason THIS changeset could not fit was
61 seconds of work no importer asked for, and I reported one external blocker where
there were two independent local causes underneath it.

The shape is worth keeping: import time is a cost every consumer pays whether or
not it uses the result. A module-scope fixture charges the pre-push hook, every
importer, and collection itself — including runs that deselect all its tests.

Behaviour is unchanged. Each accessor returns exactly what the module-scope name
held, and the tests read through them.
… is now declared (#16187)

Found while verifying #15738's premise, which turned out to be wrong in my favour
and against the sweep: create/update/delete_user ARE authenticated, two hops down.

Four distinct escapes, each a gate the resolver could not reach:

  0 hops   api/service_messages.py            _check_admin defined locally
  1 hop    api/user_provider_credentials.py   imported from a non-auth path
  2 hops   api/user_management/users.py       route -> get_user_service
                                                    -> get_tenant_context
                                                    -> get_current_user
  wrong file  api/user_management/router.py   24 lines, mounts four sub-routers,
                                              declares no routes at all

The first two were caught in review and fixed with one-hop resolution. That fix did
not reach the third — a narrower repair to a defect about narrow reach. The fourth
is the worst of them: the sweep reported UNGATED for a file containing zero routes,
so the verdict was about the wrong FILE rather than the wrong depth.

  _resolve_gate_chain   follows Depends() to MAX_DEPENDENCY_HOPS, with a CYCLE
                        GUARD. Not optional: these graphs are recursive in
                        practice, and an unbounded walk hangs the guard rather
                        than failing it — a guard that never returns is worse than
                        one that returns wrong, because nothing reports it.
  _included_modules     an aggregator is judged by what it MOUNTS, and its evidence
                        names them: "aggregator: 4/4 mounted routers gated
                        (organizations, password_change, teams, users)"

  98 routers · 86 gated · 9 ungated · 2 documented-open · 1 unreadable
  KNOWN_UNGATED 10 -> 9

THE DEPTH IS DECLARED, NOT MERELY RAISED. UNGATED now means "not gated within
MAX_DEPENDENCY_HOPS", and the module docstring says so. Raising the number four
times would treat the symptom; a reach nobody can read is the defect this module
exists to detect, and it had it.

A test asserts the docstring states the limit — and caught me omitting it on the
first pass of this very commit.
@coderabbitai

coderabbitai Bot commented Sep 10, 2026 •

Copy link
Copy Markdown
Contributor

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: de82a26b-209f-42c0-a087-7f368fd27046


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown
Contributor

✅ SSOT Configuration Compliance: Passing

🎉 No new hardcoded values of either class — ssot and other both block.

Known backlog in pipeline-scripts/hardcoded_values_baseline.txt is suppressed and tracked in #14371.

…tion about routes (#16187)

Two of the three defects found by the code-review pass on this PR. The first is
the same family the PR exists to fix, reintroduced one level up.

The aggregator branch triggered on `not _per_route(...) and not _router_level(...)`
-- a question about GATES, used to decide whether a module has ROUTES. Those
coincide for `api/user_management/router.py`, the 24-line mount-only module that
motivated the branch, and come apart in both directions for anything mixed:

  * mounts + its own UNGATED route -> no gates found, so it looked like a pure
    aggregator, was judged by what it mounts, and reported GATED while its own
    route stood open. False GATED is the direction that hides a hole.
  * mounts + its own GATED route -> the branch was skipped entirely and the
    mounted set was computed and discarded, so a sub-router reachable ONLY via
    the mount left the sweep. That is live: core_routers.py registers the
    user_management aggregator and none of the four routers it mounts.

`_defines_routes` now answers the route question directly, and only in decorator
position -- `.get` registers a route as a decorator and reads a dict everywhere
else, so a call-position scan would report routes in every file that reads a
dictionary and silently disable the branch it gates. The mixed case now classifies
both halves: an ungated mount is an open door however well the module's own routes
are gated.

The aggregator recursion had no cycle guard, in a file that documents why an
unbounded walk HANGS a guard rather than failing it. `classify` now threads `seen`
through the mount graph.

Tests assert the mixed case and the dict-read false positive on parsed source
rather than on a live module: the tree contains no mixed module today, and a guard
that can only fail once one appears is a guard reporting clean about a case it
never examined.

The review's third finding -- ACCESSOR_NOT_DECISION applied at 2 of 5
`_calls_named` call sites -- is NOT fixed here. Applying it at all five is correct
in principle and makes `api.knowledge` report UNGATED, and that verdict is wrong:
the file carries six `Depends(check_admin_permission)` routes and
`check_admin_permission` raises 401/403. So the accessor exclusion is entangled
with gate-chain resolution in a way this PR's scope cannot settle, and shipping it
would trade a false GATED for a false UNGATED. Filed separately with the evidence.
@mrveiss

mrveiss commented Sep 10, 2026

Copy link
Copy Markdown
Owner Author

Three corrections to this PR's commit message, before merge

A re-review of the 1e727d5ba delta confirmed all three original findings are fixed, and caught
three inaccuracies in what I wrote about them. Recording them here because the commit message is
what lands in base history.

1. The deferral claimed a filing that did not exist — now #16202

The commit message says of the ACCESSOR_NOT_DECISION deferral: "Filed separately with the
evidence."
It was not filed. I wrote the sentence describing what should happen and did not do
it, and no search for the gap returned anything.

Now filed as #16202, with the measurement: six Depends(check_admin_permission) routes in
api/knowledge.py, check_admin_permission raising 401/403 at auth_middleware.py:972, and the
chain breaking because _resolve_gate_chain already subtracts the accessor at line 323.

A deferral is only legitimate when the thing deferred is tracked. Until #16202 existed, this was an
undisclosed gap wearing the language of a disclosed one — the exact substitution this guard exists
to catch, in the commit message of the guard.

2. "the tree contains no mixed module today" is false

api.knowledge is a mixed module and exists today: registered directly at
core_routers.py:196, ~40 routes of its own in decorator position, and six include_router mounts
at api/knowledge.py:239, 244, 3223, 3235, 3244, 3254. It hits the new mixed branch on every
enumerate_routers() run.

What is actually true is narrower, and the in-code comment at router_auth_enumerator.py:552-559
says it correctly: no mixed module's mount is currently ungated. The commit message generalised
that into a claim about mixed modules existing at all.

3. "2 of 5 call sites" is 2 of 6

_calls_named() has six call sites — 255, 323, 386, 397, 402, 420 — with two subtracting the
exclusion. The miscount originated in the first review and I carried it forward without checking.
Corrected in #16202.

What the re-review confirmed

All three original findings fixed, traced rather than assumed:

  • _defines_routes cannot false-positive on config.get("key") — the ast.Call branch checks only
    ROUTE_ADDERS; http verbs are matched in decorator position only.
  • The mixed case appends evidence and falls through to the mechanism loop, so a mixed module's
    own gates are still evaluated and the mounted set is never discarded.
  • test_an_aggregator_is_judged_by_what_it_mounts still holds end-to-end against the live
    api.user_management.router.
  • The cycle guard is passed at the only recursive call site (:534, guarded by if name not in seen); the top-level call at :587 correctly starts fresh. Self-mount and mutual A↔B cycles
    traced by hand — both terminate.
  • _calls_named is byte-identical to base (diff exit 0), so the deferral is genuine rather than a
    half-applied edit.
  • 588 lines, under the 600 guard. The synthetic fixtures match
    api/user_management/router.py verbatim.

Known-untested line

router_auth_enumerator.py:565 — the mixed-case evidence append — has no coverage. Neither new test
calls classify(), and no live module currently drives ungated non-empty, because every one of
api.knowledge's six mounts carries its own per-route gates. Recorded rather than papered over;
covering it needs a module shape the tree does not currently have, and #16194 is where that
question lives.

…n site (#16202, #16187)

Resolves the finding this PR previously deferred, and the deferral's stated
reason turns out to have been a misattribution.

ACCESSOR_NOT_DECISION was subtracted at 2 of the 6 `_calls_named` call sites, and
the four that skipped it (`_router_level`, `_per_route` x2, `_inline`) are the four
that decide a verdict directly. So a module whose only apparent signal was an
accessor that authenticates nobody read as GATED.

Applying it at all six was tried earlier on this branch and `api.knowledge` went
UNGATED, so it was reverted and filed as #16202 with "why not was not established".
That reason was wrong. The revert did NOT fix the failure -- it persisted -- which
proved the accessor was never its cause. The actual cause was the aggregator branch,
fixed separately in this PR. The accessor change was judged by a test failure that
belonged to a different defect.

Re-tested on top of that fix: 17/17 pass.

The exclusion is at the wrong granularity on its own, which is why the fix is not
simply "subtract everywhere". `get_auth_middleware()` fetches;
`get_auth_middleware().get_user_from_request(req)` authenticates. Excluding the
accessor wholesale made `check_admin_permission` -- which raises 401/403 and gates
six routes in `api/knowledge.py` -- unresolvable, because its body's only vocabulary
hit was the accessor. ACCESSOR_DECISION_METHODS names the four methods that DO
decide, derived from what the tree actually calls on the accessor rather than
guessed: get_user_from_request (31 sites), check_file_permissions (11),
verify_jwt_token, _extract_user_from_device_jwt. The rest fetch config or mint and
hash credentials, and authenticate no caller.

Verified verdicts rather than only the suite:

  api.knowledge   gated  per-route: check_admin_permission, get_current_user
                         (was gated only via an incidental inline accessor call --
                          right verdict, wrong evidence, one tidy-up from a false
                          UNGATED)
  api.jwks        UNGATED -- reaching get_auth_middleware() still does not gate
  totals          96 routers, 84 gated, 12 ungated; no new entry on KNOWN_UNGATED

It also gives the mixed-case evidence line real coverage. The re-review noted that
no live module drove `ungated` non-empty; one does now -- `api.knowledge` mounts
`api.knowledge_search_aggregator`, which is on the frozen ungated list, and the
evidence line names it.
@mrveiss

mrveiss commented Sep 10, 2026

Copy link
Copy Markdown
Owner Author

Correction: my claim about what the accessor fix achieved was wrong, twice

The delta re-review of 8cac39b7a came back sound — no router's verdict flipped in the FALSE
GATED direction, none found anywhere in autobot-backend/.
It also caught two errors in what I
wrote about it, and both are mine.

1. api.knowledge was NOT "gated only via an incidental inline accessor call"

I wrote, in the commit message and the PR body:

api.knowledge gated — per-route: check_admin_permission, get_current_user
(was gated only via an incidental inline accessor call — right verdict, wrong evidence,
one tidy-up from a false UNGATED)

I tested that rather than assume it, by reverting the accessor change and re-running the enumerator:

PRE-FIX  api.knowledge mechanisms: ['per-route', 'inline']
         evidence: per-route: check_admin_permission, get_current_user

Identical to post-fix. check_admin_permission is imported at api/knowledge.py:62 from
auth_middleware, whose path matches the vocabulary's auth pattern, so it was already in the
derived vocabulary and _per_route already matched the bare Depends(check_admin_permission)
ast.Name — independent of this diff. The re-review traced the same thing separately.

So the "right verdict, wrong evidence" story is false. The per-route gate was always visible.

2. The count was wrong

I wrote "six routes in api/knowledge.py". grep -c 'Depends(check_admin_permission)' returns
35. I counted what was visible in a truncated grep and published the visible number as the total
— the same error as the 203-vs-136 import count earlier today, in the same PR's review cycle.

What the change actually does, stated accurately

ACCESSOR_NOT_DECISION was applied at 2 of 6 _calls_named call sites; it is now applied at all
six. Net effect on verdicts: none. Applying it at all six alone would break
check_admin_permission's chain resolution and report api.knowledge UNGATED — that is what I
observed earlier and misattributed. ACCESSOR_DECISION_METHODS exists precisely to prevent that,
by distinguishing get_auth_middleware() (fetches) from
get_auth_middleware().get_user_from_request(req) (decides).

So this is a consistency fix with no verdict change, not a repair of a wrong verdict. That is a
smaller claim than I made and it is the one the evidence supports.

Two minors from the re-review, both valid

  • _local_gates (:260) and _resolve_gate_chain (:328) still subtract ACCESSOR_NOT_DECISION
    explicitly, now redundant since _calls_named does it internally. Dead code the commit should
    have cleaned up while touching every other site.
  • _calls_named matches .attr by name only, regardless of the base object. Verified no
    collision exists today — each of the four methods has exactly one definition, all in
    auth_middleware.py, and every call site in the scanned tree resolves to the accessor. But it
    would start producing false GATED the day any unrelated class defines a same-named method.

Fixing both next.

…the match is name-only (#16187)

Two minors from the delta re-review of 8cac39b.

`_local_gates` and `_resolve_gate_chain` still subtracted ACCESSOR_NOT_DECISION
explicitly after `_calls_named` began doing it internally. Harmless -- subtracting
from a set that no longer contains the name is a no-op -- but it reads as though
those two sites need something the other four do not, which is exactly the
asymmetry the previous commit removed. One subtraction now, inside `_calls_named`.

ACCESSOR_DECISION_METHODS matches `Attribute.attr` by NAME, without resolving what
object the attribute hangs off. The review verified that is safe today: each of the
four has exactly one definition in the tree, all in `auth_middleware.py`, and every
call site under BACKEND resolves to the accessor. But it is one same-named method
on an unrelated class away from reporting FALSE GATED -- the direction that hides a
hole rather than raising a false alarm. Recorded at the declaration, with the fix
that would be correct then (resolve the base object) and the one that would not
(drop the name, which is what makes `check_admin_permission` resolvable at all).

The re-review also corrected two things I had published about the previous commit,
both now corrected on the PR: `api.knowledge` gated via per-route BEFORE that fix
as well -- verified by reverting it and re-running, mechanisms identical -- so the
"right verdict, wrong evidence" account was wrong, and the change is a consistency
fix that alters no verdict. And `Depends(check_admin_permission)` appears 35 times
in api/knowledge.py, not the six I wrote from a truncated grep.
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