Skip to content

fix(acl): out-of-scope rows consumed slots in the row cap and starved scoped clients#39

Merged
sturlese merged 3 commits into
mainfrom
fix/bughunt-acl-filter-after-cap
Jul 25, 2026
Merged

fix(acl): out-of-scope rows consumed slots in the row cap and starved scoped clients#39
sturlese merged 3 commits into
mainfrom
fix/bughunt-acl-filter-after-cap

Conversation

@sturlese

@sturlese sturlese commented Jul 25, 2026

Copy link
Copy Markdown
Owner

Sixth iteration of the autonomous bughunt sweep. Both hunters and two gate reviewers flagged this one; it is the last high-confidence ACL item on the answer side.

Bug

Both read paths capped in SQL and applied the ACL filter in Python afterwards, so invisible rows occupied slots in the cap:

metrics.query_metrics   ... ORDER BY entity, metric, period, source_ref LIMIT ?    then filter
retrieve.search         ... ORDER BY bm25 LIMIT 40                                then filter

retrieve.py's own comment states the rule this breaks — "not an annotation: an invisible page simply isn't there" — and a row that isn't there must not occupy a candidate slot. With enough out-of-scope rows sorting ahead, the visible tail was silently truncated, down to nothing:

FACTS: eng-scoped client asking for arr-usd
   unrestricted     -> 50 rows
   eng-scoped       -> 0 rows        <== STARVED
PAGES: eng-scoped client searching "widget detail"
   unrestricted     -> 5 hits
   eng-scoped       -> 0 hits        <== an OPEN matching page existed

Why this matters more than a truncation

It is a wrong answer, not a short one. ask() refuses a question the brain can answer from content the client is fully entitled to — the same observable outcome as "the brain doesn't know", which is what the refusal path is supposed to mean.

And it is scope-dependent: an unrestricted client never sees it, so it cannot be reproduced by an operator testing the deployment without adopting a client's exact scope. That is precisely the asymmetry the ACL layer exists to avoid.

The trigger is not an extreme corpus. 60 restricted rows sorting ahead of 3 open ones is ordinary — the facts store holds one row per figure per document, and entity sorts first, so a single well-documented restricted entity can bury everything alphabetically after it.

Fix

The scope goes into the WHERE clause, so SQL's own LIMIT counts rows the client may see. That is the only shape that is both correct and bounded — cap-then-filter starves, and filter-then-cap unbounds the sort (see below).

                                main    branch     x
scoped, metric filter          0.008s   0.059s   7.3x   (and now a CORRECT answer)
scoped, NO filter              0.016s   0.018s   1.1x
unrestricted, metric filter    0.008s   0.008s   1.0x   <== byte-identical query
unrestricted, NO filter        0.016s   0.016s   1.0x

visible_sql returns the literal "1" with no parameters when audiences is None, so an unrestricted deployment executes exactly the query it executed before. The residual 7.3x is a scoped client on a store where every row shares the filtered metric — the pathological case, since the LIKE is a residual filter applied after the index seek.

I tried the other shape first, and the gate destroyed it. My first attempt dropped the SQL LIMIT and filtered while "streaming" the cursor, specifically to avoid a third hand-mirrored copy of the visibility rule (the thing that produced the five ACL bugs already fixed here). That justification was empirically false: with ORDER BY and no LIMIT, SQLite materialises the entire sort before yielding row one, so nothing streams and breaking out of the loop saves nothing. It measured 11x slower for unrestricted clients on a path the MCP tool reaches with no arguments, and up to 72x for scoped ones — trading a correctness bug for a DoS amplification.

So the objection had to be answered rather than avoided. visible_sql sits immediately beside visible() in the module that owns the rule, and test_visible_sql_agrees_with_visible proves the two answer identically over 12 acl encodings × 13 scopes — including NULL, '', multi-label, empty scope, and labels containing the LIKE metacharacters %, _ and \, which are escaped. Unescaped, an audience label of "%" would have matched every restricted row. It is a second form of one rule, with the agreement proven rather than assumed.

Incidental: the search pool is now the best 40 candidates the client can actually see rather than whatever survived a pool chosen without regard to them, so scoped ranking is better informed; and the now-redundant visible() check in the ranking loop is gone.

Test

Both confirmed to fail with the source change stashed (assert 0 >= 3, and no hits at all).

  • test_out_of_scope_rows_do_not_starve_a_scoped_client — 60 finance rows for an entity sorting first, 3 open rows after: the eng client gets exactly the 3 open values. Also asserts the cap still caps, for both a scoped and an unrestricted caller — an over-eager "fix" that ignored limit would pass the first assertion alone.
  • test_out_of_scope_pages_do_not_starve_a_scoped_search — 45 finance pages matching the query plus one open one: the open page is returned and none of the restricted ones are.
  • test_visible_sql_agrees_with_visible — the truth-table agreement above.

Also verified and not otherwise asserted: for an unrestricted client retrieve.search is byte-identical to main — same paths, scores to 6 dp, same factors — across three queries over a 60-page corpus with more than _POOL matching pages, with and without exact bm25 ties.

answer 59 (+3) · clean 252 · corpus 48 · graph 40 · slack 13 · fetch 33 · benchmark 3 — bare pytest (CI mode). ruff clean; golden scorecard 25/25 including all four end-to-end Q&A cases and the ACL metric; ACL visibility parity 11/11.

A serious sibling, found by the gate and NOT fixed here

The same cap-then-filter shape exists for the current-truth filter, in three live places:

  • service.current_metric_rows caps at 100 then drops superseded, and current or rows falls back to the 100 stale rows. Verified with real code: 100 rows returned, all from a superseded page, while 3 current rows existed. That is worse than the bug this PR fixes — confidently wrong numbers rather than none.
  • service.metrics_text renders rows[:30] of that same stale list, so the MCP query_metrics tool shows 30 superseded lines.
  • retrieve.search(include_superseded=False) drops superseded after the pool — three lines below the check this PR removed — giving zero hits while a current matching page exists.

Same defect shape, different invariant, so it gets its own PR. Confirmed clean (no cap before filter): known_metrics, known_entities, superseded_paths, search_text, check_citations, synthesis evidence assembly.

Not shipped this iteration, and why

The queued lead was clean.acl.dossier_acl iterating a string member ACL into characters (dossier_acl(["finance"]) == ['a','c','e','f','i','n']). It reproduces, but I traced the callers before writing anything: dossier_acl's only caller feeds it lastResult["acl"], which worker.py always writes as a list, and clean.acl.visible has no production caller at all — only the parity eval and unit tests. So it is defensive-only, and this sweep's bar excludes that. It stays recorded as a robustness item rather than being shipped as a bugfix.

sturlese and others added 3 commits July 25, 2026 04:59
… scoped clients

Both read paths capped in SQL and applied the ACL filter in Python afterwards, so
invisible rows occupied slots in the cap:

    metrics.query_metrics   ... ORDER BY ... LIMIT ?    then filter
    retrieve.search         ... ORDER BY bm25 LIMIT 40  then filter

retrieve.py's own comment states the rule this breaks — "not an annotation: an
invisible page simply isn't there" — and a row that isn't there must not occupy a
candidate slot. With enough out-of-scope rows sorting ahead, the visible tail was
silently truncated, down to nothing:

    FACTS: eng-scoped client asking for arr-usd
       unrestricted     -> 50 rows
       eng-scoped       -> 0 rows        <== STARVED
    PAGES: eng-scoped client searching "widget detail"
       unrestricted     -> 5 hits
       eng-scoped       -> 0 hits        <== an OPEN matching page existed

That is a wrong answer, not a truncated one: `ask()` then refuses a question the
brain can answer from content the client is fully entitled to. And it is
scope-dependent — unrestricted clients never see it — which is exactly the asymmetry
the ACL layer exists to avoid. 60 restricted rows ahead of 3 open ones is not an
extreme corpus; the facts store is one row per figure per document.

Both now stream the ordered cursor and stop once the cap is full of rows the client
may SEE. The cap still caps (verified for both scoped and unrestricted callers) and
`visible` stays the single copy of the rule — pushing the filter into SQL would have
created a third hand-mirrored copy of visibility logic, which is the thing that has
produced five bugs in this package already.

Scan cost is bounded by what already bounded these queries: the WHERE clause for
facts, the FTS MATCH for pages. A scoped client with few visible rows examines more
of that set than before, which is the price of a correct answer.

Two incidental improvements: the search pool is now the best 40 candidates the client
can actually see rather than whatever survived a pool chosen without regard to them,
so scoped ranking is strictly better informed; and the now-redundant visible() check
inside the ranking loop is gone, with the pool documented as pre-filtered.

answer 58 (+2). scorecard 25/25 including all four golden Q&A cases and the ACL
metric; parity 11/11; ruff clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…g in SQL

Self-review: replacing `LIMIT ?` with a `len(out) >= limit` break silently changed
two edge semantics the SQL had for free.

    limit   old (SQL)   new (broken)
      0         0            1
     -1         5            1        (SQL: negative means unlimited)

Breaking after the append means the cap is tested only once a row is already in, so
any limit <= 0 yielded exactly one row. `0 < limit <= len(out)`, guarded by a
`limit != 0` skip, reproduces SQL exactly: 0 none, negative unlimited, positive N.

No caller passes either today (service.current_metric_rows passes 100, the default is
50), so nothing observable was wrong — but it is a gratuitous divergence in a function
whose whole job this commit is changing, and the kind of thing that later reads as
intentional. Pinned by assertions rather than left to inference.

Also verified the property that mattered most for this change and is not otherwise
asserted: for an UNRESTRICTED client, retrieve.search is byte-identical to main —
same paths, same scores to 6 dp, same factors — across three queries over a 60-page
corpus with more than _POOL matching pages. Any change there would have been a
regression for every open deployment.

Mutation-tested, all five caught: reverting either site to its SQL LIMIT (1 failure
each), ignoring limit entirely (1), _POOL = 1 (2), and the `<` off-by-one (1).

answer 58. scorecard 25/25, ruff clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Gate review refuted the previous approach on its own stated justification, and it
was right. The comments claimed "the WHERE clause still bounds how much can be
scanned" and "stream the cursor". Both are false: with ORDER BY and no LIMIT,
SQLite materialises the ENTIRE sort before yielding row one, so nothing streams and
breaking out of the Python loop after N rows saves nothing. Measured on a 200k-row
store, against main:

    scoped, metric filter          72.3x slower
    scoped, NO filter              48.1x
    unrestricted, NO filter        11.0x     <== ordinary clients, no ACL involved
    unrestricted, metric filter     1.8x

The last two are the damning ones: dropping LIMIT cost every open deployment, on a
path the MCP query_metrics tool reaches with no arguments at all. Trading a
correctness bug for a DoS amplification — worst for exactly the scoped deployments
the fix protects — is not a trade worth making.

The scope now goes in the WHERE clause, so SQL's own LIMIT counts rows the client may
SEE. That is the only shape that is both correct and bounded: cap-then-filter starves,
filter-then-cap unbounds the sort.

    scoped, metric filter           7.3x     (0.008s -> 0.059s, and now a CORRECT answer)
    scoped, NO filter               1.1x
    unrestricted, metric filter     1.0x     <== byte-identical query, zero cost
    unrestricted, NO filter         1.0x

`visible_sql(column, audiences)` returns the literal "1" with no parameters when
audiences is None, so an unrestricted deployment executes exactly the query it
executed before — verified 1.0x and retrieve.search byte-identical to main (paths,
scores to 6 dp, factors) across three queries over a corpus with more than _POOL
matches. The residual 7.3x is a scoped client on a store where every row shares the
filtered metric, i.e. the pathological case: the LIKE is a residual filter after the
index seek, so it costs only over rows already matching the WHERE.

My objection to this shape was that it creates a THIRD hand-mirrored copy of the
visibility rule, which is what has produced five ACL bugs here. That objection is
answered rather than ignored: visible_sql lives immediately beside visible() in the
module that owns the rule, and test_visible_sql_agrees_with_visible proves the two
answer identically over a truth table of 12 acl encodings x 13 scopes — including
NULL, '', multi-label, empty scope, and labels containing the LIKE metacharacters
%, _ and backslash, which are escaped (unescaped, an audience label of "%" would
have matched every restricted row). It is a second FORM of one rule, with the
agreement proven rather than assumed.

answer 59 (+1). scorecard 25/25, parity 11/11, ruff clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sturlese

Copy link
Copy Markdown
Owner Author

Gate verdict

VERDICT: REFUTED — and correctly. The reviewer attacked my fix on its own stated justification and demolished it. Reworked in 4da21cf.

My justification was empirically false

Both new comments claimed the scan stayed bounded: "the WHERE clause still bounds how much can be scanned", "stream the bm25-ordered cursor". Neither is true. With ORDER BY and no LIMIT, SQLite materialises the entire sort before yielding row one — so nothing streams, and breaking out of the Python loop after N rows saves nothing. LIMIT was what made the sorter bounded; removing it removed the very bound the comment claimed still existed.

Measured on a 200k-row store, against main (I reproduced this independently before acting):

scoped, metric filter          72.3x slower
scoped, NO filter              48.1x
unrestricted, NO filter        11.0x     <== ordinary clients, no ACL involved
unrestricted, metric filter     1.8x

The last two are the damning ones. Dropping LIMIT cost every open deployment, on a path the MCP query_metrics tool reaches with no arguments at all. Trading a correctness bug for a DoS amplification — worst for exactly the scoped deployments the fix protects — is not a trade worth making.

The rework

The scope now goes in the WHERE clause, so SQL's own LIMIT counts rows the client may see. That is the only shape that is both correct and bounded: cap-then-filter starves, filter-then-cap unbounds the sort.

scoped, metric filter           7.3x     (0.008s -> 0.059s, and now a CORRECT answer)
scoped, NO filter               1.1x
unrestricted, metric filter     1.0x     <== byte-identical query, zero cost
unrestricted, NO filter         1.0x

visible_sql returns the literal "1" with no parameters when audiences is None, so an unrestricted deployment executes exactly the query it executed before. The residual 7.3x is a scoped client on a store where every row shares the filtered metric — the pathological case, since the LIKE is a residual filter after the index seek.

On my own objection. I had argued against this shape because it creates a third hand-mirrored copy of the visibility rule — the thing that has produced five ACL bugs in this package. That objection is answered rather than dropped: visible_sql sits immediately beside visible() in the module that owns the rule, and test_visible_sql_agrees_with_visible proves the two answer identically over 12 acl encodings × 13 scopes — including NULL, '', multi-label, empty scope, and labels containing the LIKE metacharacters %, _ and \, which are escaped. Unescaped, an audience label of "%" would have matched every restricted row. It is a second form of one rule, with agreement proven rather than assumed.

Also fixed, from my own review between rounds

Replacing LIMIT ? with a len(out) >= limit break had silently changed two edge semantics SQL gave for free: limit=0 returned one row instead of none, and limit=-1 returned one instead of unlimited. Moot now that LIMIT is back, but it was real in the reviewed commit.

A serious sibling, NOT fixed here

The reviewer found the same cap-then-filter shape in the current-truth filter, in three live places, and proved each with real code:

  • service.current_metric_rows caps at 100 then drops superseded, and current or rows falls back to the 100 stale rows. Verified: 100 rows returned, all from a superseded page, while 3 current rows existed in the store. This is worse than the bug this PR fixes — it returns confidently wrong numbers rather than no numbers.
  • service.metrics_text then renders rows[:30] of that same stale list, so the MCP query_metrics tool shows 30 superseded lines.
  • retrieve.search(include_superseded=False) drops superseded after the pool — three lines below the check this PR removed — giving zero hits while a current matching page exists.

Same defect shape, different invariant (current truth, not ACL), so it gets its own PR rather than riding on this one. It is now the top item in the backlog.

Confirmed clean, with no cap before the filter: known_metrics, known_entities, superseded_paths, search_text, check_citations/verify_answer, synthesis evidence assembly.

Mutation testing

Six of seven mutants killed in the reviewer's run; the survivor was len(rows) > _POOL (a pool of 41 instead of 40) — the exact pool size is only loosely constrained, which I am leaving as is since _POOL is a tuning constant rather than a correctness boundary.

answer 59 (+3) · clean 252 · corpus 48 · graph 40 · slack 13 · fetch 33 · benchmark 3, bare pytest (CI mode) · ruff clean · scorecard 25/25 · parity 11/11.

@sturlese
sturlese merged commit ecb5e41 into main Jul 25, 2026
10 checks passed
@sturlese
sturlese deleted the fix/bughunt-acl-filter-after-cap branch July 25, 2026 03:14
sturlese added a commit that referenced this pull request Jul 25, 2026
…rseded ones (#40)

* fix(current-truth): the cap could hide the current figure behind superseded ones

The same cap-then-filter defect #39 fixed for the ACL scope, on the current-truth
filter. Found by #39's own gate review, in three places:

`service.current_metric_rows` capped at query_metrics(limit=100), dropped superseded
rows AFTERWARDS, then `current or rows` fell back to the stale ones. With enough
superseded rows sorting ahead the cap filled with them, `current` came back empty,
and the fallback served the stale figure. Verified against real code:

    current_metric_rows('arr-usd','acme') -> 100 rows, distinct values ['1.2M']
    all from a superseded page?  True     (3 CURRENT rows valued 9.9M were in the store)

This is worse than the starvation it mirrors: #39 returned nothing, this returns a
confidently WRONG number, cited, with no indication anything is missing. It is also
the exact scenario the golden evals' "freshness (conflict -> current version)" metric
exists to protect, which passed throughout because its corpus has two rows, not 120.

`service.metrics_text` then rendered rows[:30] of that same capped list, so the MCP
query_metrics tool showed 30 superseded lines and never reached the current figure.

`retrieve.search(include_superseded=False)` dropped superseded pages after the pool —
three lines below the ACL check #39 removed — giving zero hits while a current
matching page existed.

All three now exclude in the QUERY, so the cap counts rows the caller will keep:
query_metrics gains `exclude_pages`, and retrieve adds `p.superseded_by = ''` to the
WHERE. Only when there is genuinely nothing current does current_metric_rows fall
back, and those rows stay flagged.

metrics_text needed care rather than the same treatment: it deliberately shows
superseded rows alongside current ones, flagged, and test_metrics_text_marks_superseded_rows
pins that. My first attempt routed it through current_metric_rows and broke it — the
test was right and the change was too broad. It now fetches current rows first and
lets stale ones fill the remainder, so both still appear and current truth cannot be
crowded out of either the cap or the slice.

Verified: the default search path (include_superseded=True) produces byte-identical
results to main — paths, scores to 6 dp, factors — so the common path is untouched.
Mutation-tested, all three caught: reverting current_metric_rows to post-filtering,
reverting retrieve, and ignoring exclude_pages.

answer 61 (+2). scorecard 25/25 (freshness metric included), parity 11/11, ruff clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(current-truth): exclude via a temp table — the inline NOT IN list broke at 999 pages

Self-review caught a hard failure I introduced. `exclude_pages` carries EVERY
superseded page in the index, and rendering it as an inline `NOT IN (?,?,…)` makes the
SQL parameter count grow with the corpus. SQLITE_MAX_VARIABLE_NUMBER is 999 in plenty
of builds (this machine happens to ship 250000, which is exactly why it passed
locally), and crossing it does not degrade — it raises:

    with a 999-variable ceiling:
       500 superseded pages -> ok
       998 superseded pages -> OperationalError: too many SQL variables
      5000 superseded pages -> OperationalError: too many SQL variables

`current_metric_rows` and `metrics_text` are on the answer path, so on such a build a
corpus with ~1000 superseded pages would take every numeric answer down. Worse than
the bug the previous commit fixed, and invisible on any build with a high ceiling —
including CI, which is why no test would have caught it.

Now a TEMP table on the per-call connection (`INSERT OR IGNORE` + `NOT IN (SELECT …)`).
Bounded parameters, index-backed by the PRIMARY KEY, nothing to clean up since the
connection is closed at the end of the call. Same ceiling forced to 999:

      50000 superseded pages -> ok in 38.2ms

The test forces the limit down with monkeypatch rather than relying on the build, so it
fails on ANY machine if the exclusion goes back to an inline list — verified by
reverting: 1 failed. Testing it with a large set alone would have passed here and
caught nothing.

Also recorded while checking: keeping rows with a NULL page_path is correct, not an
oversight — annotate_superseded computes `bool(page_path and page_path in superseded)`,
so it treats such a row as not-superseded too. The two agree.

answer 62 (+1). scorecard 25/25 (freshness included), ruff clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(current-truth): restore the display order, and agree with the other half about NULL

Gate review: the mechanism survived every correctness, read-only, ACL, lock and
query-plan attack, but three claims did not hold and four properties were unpinned.

**Two halves of one commit disagreed about NULL.** index.superseded_paths tests
`superseded_by != ''`, which excludes NULL — so it treats a NULL as CURRENT, and
metrics' `page_path IS NULL` branch reaches the same conclusion for a row with no
page. My new search filter used a bare `= ''`, which calls that same page superseded:

    superseded_paths (!= ''):  ['b.md']            <- NULL 'c.md' is current
    my filter (= ''):          ['a.md']            <- NULL 'c.md' is superseded
    with COALESCE:             ['a.md', 'c.md']    <- agrees

Not reachable through index.refresh (it writes '' rather than NULL), so no live bug —
but this package pins visible_sql against visible with a truth table precisely because
mirrored halves drifting on an edge value is how its ACL bugs happened. COALESCE now,
and pinned by a test that writes the NULL directly, since no corpus can produce one.

**metrics_text lost the global row order.** Selecting current rows first protects them
from the cap, but it left the output as a current-block-then-stale-block list: on the
standard test corpus the stale globex figure moved from directly beneath the current
one to seven unrelated rows below it. The whole value of showing both is that a
conflicting pair reads as a pair. The selection still puts current first; the display
is re-sorted by (entity, metric, period, source_ref) afterwards, so both properties
hold at once.

**"Both are shown" was not true in every regime.** With 30 or more CURRENT rows
matching, no stale row is rendered at all and that page does not reach ctx.read_paths.
Nothing stale is served, which is the safe direction, but the comment claimed a
guarantee the code does not give. It now states the limit instead.

Pinned the four properties the reviewer's extra mutants walked through untouched: the
NULL-page orphan is kept by the exclusion (and agrees with annotate_superseded), the
fallback honours its `limit` rather than a hardcoded one, the display order, and the
NULL asymmetry above. All four now fail their mutants; previously all four passed.

Also removed a redundant superseded_paths() computation per metrics_text call (it was
computed once directly and once inside self.query_metrics).

answer 66 (+4). scorecard 25/25 (numeric exactness and freshness included), ruff clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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