fix(acl): out-of-scope rows consumed slots in the row cap and starved scoped clients#39
Conversation
… 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>
Gate verdictVERDICT: REFUTED — and correctly. The reviewer attacked my fix on its own stated justification and demolished it. Reworked in My justification was empirically falseBoth 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 Measured on a 200k-row store, against The last two are the damning ones. Dropping The reworkThe scope now goes in the
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: Also fixed, from my own review between roundsReplacing A serious sibling, NOT fixed hereThe reviewer found the same cap-then-filter shape in the current-truth filter, in three live places, and proved each with real code:
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: Mutation testingSix of seven mutants killed in the reviewer's run; the survivor was answer 59 (+3) · clean 252 · corpus 48 · graph 40 · slack 13 · fetch 33 · benchmark 3, bare |
…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>
Sixth iteration of the autonomous
bughuntsweep. Both hunters and two gate reviewers flagged this one; it is the last high-confidence ACL item on theanswerside.Bug
Both read paths capped in SQL and applied the ACL filter in Python afterwards, so invisible rows occupied slots in the cap:
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: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
entitysorts first, so a single well-documented restricted entity can bury everything alphabetically after it.Fix
The scope goes into the
WHEREclause, so SQL's ownLIMITcounts 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).visible_sqlreturns the literal"1"with no parameters whenaudiences 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 theLIKEis 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
LIMITand 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: withORDER BYand noLIMIT, 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_sqlsits immediately besidevisible()in the module that owns the rule, andtest_visible_sql_agrees_with_visibleproves the two answer identically over 12aclencodings × 13 scopes — includingNULL,'', multi-label, empty scope, and labels containing theLIKEmetacharacters%,_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— 60financerows for an entity sorting first, 3 open rows after: theengclient 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 ignoredlimitwould pass the first assertion alone.test_out_of_scope_pages_do_not_starve_a_scoped_search— 45financepages 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.searchis byte-identical to main — same paths, scores to 6 dp, same factors — across three queries over a 60-page corpus with more than_POOLmatching 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_rowscaps at 100 then drops superseded, andcurrent or rowsfalls 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_textrendersrows[:30]of that same stale list, so the MCPquery_metricstool 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_acliterating 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 itlastResult["acl"], whichworker.pyalways writes as a list, andclean.acl.visiblehas 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.