fix(acl): scope known_metrics — the metric vocabulary leaked existence#36
Conversation
known_metrics was the one read path in the serving layer that ignored the ACL
scope, though observations.acl is right there in the table it queries:
rows = conn.execute("SELECT DISTINCT metric FROM observations"
" WHERE entity = ?", (entity.strip().lower(),))
return [r[0] for r in rows]
Three documents state the contract it broke. answer/index.md: "the ACL scope
(ANSWER_AUDIENCES) must filter EVERY read path; out-of-scope pages must look
non-existent, not forbidden." ADR 010: "query_metrics filters rows, and even
entity *existence* is scoped." docs/answer.md: "their numbers don't resolve, and
even entity existence is scoped." Its own sibling twelve lines up,
service.known_entities, carries the comment "existence is also scoped".
Where it hurts is the caller. service.metrics_text reaches known_metrics on the
EMPTY-result path — that is, precisely when the row filter just hid everything
from this client — and answers with the vocabulary anyway:
eng.metrics_text(None, "acme")
-> "no observations for that query. known metrics for acme: total-compensation"
So a client scoped to `eng` learns the two things the rest of the service goes out
of its way to hide: that entity `acme` exists, and that it has a
total-compensation figure. The same value reaches match_metric, so an out-of-scope
metric id also resolves during question parsing.
known_metrics now takes `audiences` and applies visible() itself, selecting acl
alongside the metric: one metric may be carried by several documents with
different audiences, and it must stay visible when ANY of them is in scope — which
is why the caller cannot do this filtering. Both service call sites pass
self.audiences. Unrestricted clients (audiences=None) are unchanged.
Also mirrors query_metrics' `verified = 1`. factstore inserts verified rows only
(hardcoded 1 at factstore.py:72, schema default 1), so it changes no result today;
it keeps the hand-mirrored read paths faithful, per #32.
Tests: the scoping itself, plus one consolidated checklist test asserting the same
restricted document is unreachable through EVERY public read surface of
AnswerService — search, get_page, query_metrics, current_metric_rows,
known_entities, match_metric, metrics_text, ask — and still served to the client
holding the label. known_metrics reached the service without being added to any
such list, which is how it stayed unscoped; on the unfixed code the checklist
reports `out-of-scope document reachable through: ['match_metric',
'metrics_text']`.
answer 47 (+2). clean 252, corpus 48, graph 40, slack 13, fetch 33, benchmark 3,
bare pytest (CI mode). ruff clean; scorecard 25/25.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Self-review of the previous commit: naming `acl` explicitly in the SELECT made known_metrics raise OperationalError on a pre-ACL store, where query_metrics (SELECT *) degrades to "no acl -> open" instead. `acl` reaches facts.db through an additive migration clean runs on its WRITE path (factstore.py:52-55). This package only ever reads facts.db and never migrates it, so it must not assume the column is there — the same read-only-consumer boundary ADR 001/011 draws. Guard with PRAGMA table_info and select `NULL AS acl` when it is absent, which reproduces query_metrics' degradation exactly and keeps the two hand-mirrored read paths symmetric. Verified: the pre-ACL store raised `no such column: acl` before, returns ["arr-usd"] now, and query_metrics returns its 1 row either way. answer 48 (+1). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… reflective Gate review found the previous two commits both under-delivered on their own claims. **A store with no audience information was served open.** 7f2c9fe made both facts read paths degrade to "no acl -> open" on a pre-ACL store and justified it as symmetry with query_metrics — but that is symmetry with the wrong half. The page index takes the opposite direction for an acl it cannot read (#34: acl = '', restricted to nobody), and brain-page-contract.md states the rule outright: "a page whose block you cannot read has an acl you cannot read, so treat it as restricted-to-nobody rather than open." A legacy store leaked values, source_refs, the restricted page path and metric/entity existence to every scoped client, while the page itself stayed hidden — an asymmetric failure that is hard to even notice. Now: no acl column means the store records no audience information, so a SCOPED client gets nothing from it. Unrestricted instances — the only legitimate producers of such a store — are unaffected, so `make demo`/evals and open corpora do not change. Applied to query_metrics as well as known_metrics; the crash 7f2c9fe fixed stays fixed, via a shared _carries_acl() rather than a NULL-substituting SELECT. **The checklist test could not catch what it claimed to.** Its docstring said "a surface missing from it is the bug this test exists to catch" — false: the list was hardcoded, so it regression-tested the 8 surfaces already in it and was structurally blind to a NEW one, which is precisely how known_metrics got in unscoped. The reviewer proved it by adding an unscoped known_periods() and watching all 48 tests pass while it handed an eng client 'acme/total-compensation@2026'. Rewritten as a probe table plus a completeness assertion over inspect.getmembers(AnswerService): adding a public method now FAILS until it is either probed for leaks or explicitly classified as not-a-content-surface. Verified against that same mutant — it is caught now. The probe table also covers search_text and page_text (renderings the typed-primitive list missed) and probes for the document rather than for words the client supplied, since these messages echo the query back and matching the echo is a false positive. The positive direction was one-sided too (3 of 8 surfaces), so an over-denying "fix" could pass. Both directions are now asserted over the full table: hidden from eng through every surface, reachable by finance through every surface. Verified against a get_page-denies-everyone mutant, which the old version passed. refresh() is the one deliberate exclusion — it reports what the INDEX did, and the index holds every page since one instance serves one scope. Its own test pins that reasoning and asserts no transport hands the counters to a client, so if that ever changes the exclusion breaks loudly instead of silently becoming a count oracle. Also: known_metrics no longer reads acl at all for unrestricted clients, which keeps that query inside the covering index idx_obs_metric. Selecting the extra column forced a temp B-tree and measured 32x slower on a 300k-row store — and match_metric runs it once per question. answer 50 (+4). scorecard 25/25, ruff clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Gate verdictTwo agents: one told to refute this fix, one told to sweep the whole serving layer for any other unscoped read path (this being the third ACL leak found in Verdicts: REFUTED at What the refuter broke1. The pre-ACL crash was worse than I described. I'd caught it myself, but the reviewer measured the real blast radius: at 2. The degradation direction was wrong — and this is the finding I'd have defended and been wrong about. 3. My checklist test could not catch what its docstring claimed. It said "a surface missing from it is the bug this test exists to catch." False — the list was hardcoded, so it regression-tested the 8 surfaces already in it and was structurally blind to a new one, which is exactly how It is now a probe table plus a completeness assertion over 4. Perf: selecting Also confirmed by the refuter and worth recording: the ANY-in-scope union logic is correct (a metric shared by an in-scope and an out-of-scope document stays visible; one carried only by out-of-scope documents vanishes); The sweep: four more leaks, none of them this PR'sAll 11 public service surfaces are correctly scoped after this PR, and the sweep found no fourth unscoped method. But it found four leaks below the method level, reproduced with literal output. These are separate bugs and are NOT fixed here — they are recorded as the top of the bughunt backlog:
Clean bill of health for the rest, with evidence: no existence oracle (out-of-scope and nonexistent produce byte-identical messages on every surface), answer 50 (+4) · clean 252 · corpus 48 · graph 40 · slack 13 · fetch 33 · benchmark 3, bare |
#38) * fix(acl): the index trusted the acl value it re-derived from page text Two holes in one encoding step, both silently OPEN, both found by the full ACL sweep #36's gate ran over this package. `refresh` honoured `acl` only when it was a YAML list. Every other shape fell to `acl = None`, which means "this page carries no ACL" — open to everyone: acl: finance stored None eng client sees it: True <== the intuitive form acl: "finance" stored None True acl: '' stored None True acl: null stored None True acl: {finance: true} stored None True `acl: finance` is what anyone writing a single label by hand produces. clean always emits the list form, so the trigger is a hand-authored or hand-edited page — but the whole point of an ACL is that it holds for pages the pipeline did not write, and ADR 010 foresees other writers feeding this same field. This is #34's fix being incomplete rather than a new bug: #34 taught this block that an unreadable *block* means restricted-to-nobody, and the contract line it added says "only a page with genuinely no `acl` key is open" — but the code only ever checked the block, never the value. `"acl" in fm` closes it, and the distinction is real: `fm.get("acl")` cannot tell `acl: null` from an absent key. Second hole, same block: labels are joined into one CSV column, and nothing checked them. A label containing a comma split back into TWO audiences at enforcement time: acl: ["finance,eng"] stored 'finance,eng' eng client sees it: True clean's acl._check_labels rejects a comma or a blank label at config load, calling it "the exact silent-corruption failure mode an access-control config must not have" — but the index re-derives the same encoding from page text and cannot assume the page came through that validation. `_label_ok` now mirrors that rule, and a label the encoding cannot represent makes the whole ACL unreadable rather than dropping the bad label quietly. Every unreadable form now stores '' — restricted to nobody: no scoped client sees the page, an unrestricted operator still does, so the breakage is discoverable instead of silent. Verified across ten shapes, including that `acl: []` still round-trips through the list branch to '' (PR #30's semantics, unchanged) and that a page with genuinely no `acl` key is still the one open case. brain-page-contract.md: the tolerance rule now covers the acl VALUE as well as the block, and states the separator constraint for any consumer that serialises labels into one field — the assumption that cost this, twice. answer 54 (+2). parity 11/11 (unaffected: it builds the CSV itself, and the two halves agree exactly on the valid-label domain this fix now enforces). scorecard 25/25, ruff clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(acl): re-derive already-indexed pages, or the fix does nothing on upgrade Self-review caught that the previous commit changed nothing for any existing deployment. `refresh` re-reads a page only when its (mtime, size) differs from the indexed row, so a page stored as acl = NULL under the old rule stays NULL for the life of the index. Measured, old index + new code over untouched files: refresh={'added': 0, 'updated': 0, 'removed': 0, 'total': 1} stored acl=None eng client sees it: True <== still open So the fix only ever applied to pages that happened to change afterwards. PR #30 had exactly this problem and solved it with a PRAGMA user_version migration; this needed the same, at version 2: set mtime = -1 to invalidate the cached stat so refresh re-encodes every page. The migration also sets acl = '' rather than waiting for the re-read. `connect()` and `refresh()` run back to back in AnswerService.__init__, so the window is nil in the server — but connect() is public, and "unknown is not open" should not depend on a caller's ordering. Unrestricted clients are unaffected either way (visible('', None) is True), so nothing goes dark. Verified: the restricted page re-encodes to '' and is hidden from eng, a page with genuinely no acl returns to NULL and stays open, and a second run is a no-op (added 0, updated 0). That interaction rewrote the #30 migration test, which asserted the value a migration leaves behind — now provisional, since v2 blanket-closes and lets refresh derive the truth. It now asserts the property that actually matters, through a refresh over real pages: v1's legacy '' for a no-acl page ends up open again, v2's NULL for an unreadable acl ends up restricted-to-nobody, neither migration re-fires, and the invalidated mtime is restored. That is a stronger test than the original. Mutation-tested the whole fix, all six caught: dropping the _label_ok clause (1 failure), reverting to the block-only condition (3), _label_ok always True (1), unreadable -> None (4), no-acl -> '' i.e. over-restriction (5), dropping the v2 migration (1). Also verified NOT over-restrictive against the real pipeline: indexing the evals' produced brain-md with main's index.py and with this one gives **zero** differences in the acl column across all 8 pages — clean and the dossier writer always emit the list form. And padded labels are deliberately still accepted: neither half of the mirrored `visible` strips, so rejecting or stripping them here alone would make the two halves disagree. That belongs on the write side (clean.acl._check_labels), whose gap is recorded separately. answer 54. parity 11/11, scorecard 25/25, ruff clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(acl): recognising a frontmatter block must not require it to be closed Gate review found the same disclosure one line above the code the first commit changed, plus a predicate that renamed labels instead of rejecting them. **carries_frontmatter_block required the CLOSING `---`.** Its docstring — which I wrote in #34 — claims "True when the page OPENS a frontmatter block, whether or not it parses". It did not: it ran the full block regex. So a page carrying `acl: [finance]` was indexed as NULL, open to everyone, whenever its block was unclosed, truncated mid-write, ended with YAML's `...`, preceded by a BOM, or preceded by a blank line — because `fm` is {} AND the helper says "no block", which is exactly the "genuinely no acl key" case. Verified byte-identical on main, so this is a miss of the same class rather than a regression, and the LARGER half of it: a truncated page leaked its whole ACL, where the values the first commit closed leaked only a non-list one. It now matches on the opener alone, tolerating a BOM and leading blank lines. The BOM case is better than closed: split_frontmatter strips the BOM, so that page's real ACL is read rather than merely being hidden — a BOM is encoding noise editors add invisibly, not content, and it should never be able to hide an acl. **_label_ok validated str(label) rather than requiring a str, so it renamed labels instead of rejecting them — and a renamed label is grantable.** YAML 1.1 reads `acl: [010]` as the int 8 and `acl: [12:30]` as 750, so the index stored audiences "8" and "750" that nobody granted; a client holding either saw the page. It also collided `[~]` with `["None"]` (both "None", and matchable) and `[1.50]` with `[1.5]`, and accepted or rejected nested containers by whether repr() happened to contain a comma. Excluding bool while coercing int/float/None/list/dict was three answers to one question. `isinstance(label, str)` is the whole fix, and it cannot reject anything the pipeline writes: clean.page._yaml emits a scalar plain only when it round-trips as the identical string, so every label it writes reads back as a str. Also corrected the v2 migration comment, which claimed "nothing goes dark". It closes EVERY page to scoped clients until the next refresh, including pages whose ACL was fine; AnswerService refreshes immediately after connect, but a caller that connects without refreshing serves a scoped client nothing. And if the UPDATE cannot run (another process holding a write lock) connect() raises rather than proceeding — no service beats a leaky one. Mutation-tested: reverting the block predicate, reverting _label_ok to coercion, and dropping the BOM strip each fail exactly one of the new tests. Recorded for its own PR (pipeline side, pre-existing, verified): clean.acl.dossier_acl iterates a member acl that is a STRING instead of a list, so dossier_acl(["finance"]) == ['a','c','e','f','i','n'] — the dossier is emitted with six single-character audiences its members never granted. Same value-shape assumption the index just stopped making. answer 56 (+2). parity 11/11, scorecard 25/25, ruff clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Third iteration of the autonomous
bughuntsweep — lead #2 from the backlog (lead #1, the corpus taxonomy ordering, is parked pending a policy decision).Bug
known_metricswas the one read path in the serving layer that ignored the ACL scope, thoughobservations.aclis right there in the table it queries:Three documents state the contract it breaks:
answer/index.md:43— "the ACL scope (ANSWER_AUDIENCES) must filter every read path; out-of-scope pages must look non-existent, not forbidden."query_metricsfilters rows, and even entity existence is scoped."docs/answer.md:51— "their numbers don't resolve, and even entity existence is scoped."And its own sibling twelve lines up,
service.known_entities, carries the comment "existence is also scoped".Why it matters — the caller makes it worse
service.metrics_textreachesknown_metricson the empty-result path — that is, precisely when the row filter just hid everything from this client — and then answers with the vocabulary anyway:So a client scoped to
englearns the two things the rest of the service goes out of its way to hide: that entityacmeexists, and that it has atotal-compensationfigure. The same value reachesmatch_metric, so an out-of-scope metric id also resolves while parsing the question.It leaks ids rather than values — but
known_entitiesproves the intended posture, and all three docs promise scoped existence.Fix
known_metricstakesaudiencesand appliesvisible()itself, selectingaclalongside the metric. The filtering cannot be pushed to the caller: one metric may be carried by several documents with different audiences, and it must stay visible when any of them is in scope. Bothservicecall sites passself.audiences. Unrestricted clients (audiences=None) are unchanged.Also matches
query_metrics'verified = 1.factstoreinserts verified rows only (hardcoded1atfactstore.py:72, schema default1), so this changes no result today; it keeps the hand-mirrored read paths saying the same thing. (Noted by the gate: nothing enforces that agreement —known_metricshas no pipeline counterpart, soeval_contract_paritycannot probe it. It is symmetry by convention, not by proof.)Test
Three commits: the fix, plus two rounds from self-review and the adversarial gate (detail in the gate-verdict comment). Two further hardenings came out of those and are part of this PR:
A store with no
aclcolumn is no longer served open.aclreachesfacts.dbthrough an additive migration clean runs on its write path; this package only reads it. Naming the column crashedask()outright on a legacy store, and degrading to "no acl → open" leaked values,source_refs and existence to scoped clients while the page itself stayed hidden. Now "the store records no audience information" means a scoped client gets nothing — the direction the page index already takes for an unreadableacl(fix(contract): a model-chosen type/date could drop a page's ACL or kill the build #34) and the onebrain-page-contract.mdstates. Unrestricted instances are unaffected, so open corpora and the demo are unchanged. Applied toquery_metricstoo.Unrestricted clients no longer read
aclat all, keeping that query inside the covering indexidx_obs_metric; selecting the extra column forced a temp B-tree and measured 32x slower on a 300k-row store, andmatch_metricruns it once per question.test_known_metrics_are_scoped— the scoping itself, plus both service paths that reach it (metrics_text,match_metric), and that open facts and unrestricted clients are unaffected.test_no_public_read_surface_leaks_an_out_of_scope_document— a probe table over every public read surface (search,search_text,get_page,page_text,query_metrics,current_metric_rows,known_entities,match_metric,metrics_text,ask), asserted in both directions: hidden fromengthrough all of them, and still reachable byfinancethrough all of them, so an over-denying "fix" cannot pass by hiding everything from everybody. On the unfixed code:reachable through: ['match_metric', 'metrics_text'].test_leak_probes_cover_every_public_service_surface— the completeness assertion, and the actual point. My first version of the checklist was a hardcoded list whose docstring claimed a missing surface was "the bug this test exists to catch". That was false: it was structurally blind to a new surface, which is exactly howknown_metricsgot in unscoped. The gate proved it by adding an unscopedknown_periods()— all 48 tests passed while it handed anengclient'acme/total-compensation@2026'. It now asserts overinspect.getmembers(AnswerService), so adding a public method fails until it is either probed or explicitly classified as not-a-content-surface. Verified against that mutant, and against aget_page-denies-everyone mutant for the positive direction.test_refresh_counts_are_index_maintenance_not_a_content_surface— the one deliberate exclusion, with its reasoning pinned rather than waived:refresh()reports what the index did, and the index holds every page because one instance serves one scope. The test asserts no transport hands those counters to a client, so if that ever changes the exclusion breaks loudly instead of silently becoming a count oracle.test_pre_acl_facts_db_serves_unrestricted_but_never_a_scoped_client— the legacy-store direction, failing without the guard.answer 50 (+5) · clean 252 · corpus 48 · graph 40 · slack 13 · fetch 33 · benchmark 3 — bare
pytest(CI mode). ruff clean; golden scorecard 25/25. Full CI-equivalent suite green locally before opening.Found by the gate's sweep — NOT fixed here
Auditing this class exhaustively (it is the third ACL leak in this package) turned up four more, each reproduced with literal output. They are separate bugs with their own fixes, recorded rather than ridden in:
ANSWER_AUDIENCES=","silently means UNRESTRICTED (settings.py:31) —tuple(...) or Noneturns a scope that parses to zero labels into "no scope", so",",",,"," , "serve the entire corpus while the operator believes the instance is scoped. ADR 010 states this doctrine for the write side ("a malformed config errors loudly: silently-open is the one failure mode an access-control file must not have") but it is not applied to the read side. Most severe item found so far; next up.acl:value is encoded as "no ACL" and served open (index.py:128-137) —acl: finance(the intuitive single-label form),acl: '',acl: nullall fall through toNone. My own fix(contract): a model-chosen type/date could drop a page's ACL or kill the build #34 fix being incomplete: it handled an unreadable block but not a readable block with an unreadable value.index.py:129) —clean/acl.pyrejects commas because labels are CSV-serialised;answerre-parses the page text with no such check, soacl: ["finance,eng"]grants both.superseded_byechoes the source id of an out-of-scope successor (service.py:100) — existence disclosure, no content.Plus the sibling bug in the same file: the ACL filter runs after the SQL row cap in both
query_metrics(LIMIT ?) andretrieve.search(LIMIT 40), so out-of-scope rows consume candidate slots and can starve a scoped client to zero results — a wrong answer rather than a truncated one. Independent blast radius and a different fix (stream the ordered cursor, stop after N visible rows), so it gets its own PR.