Skip to content

fix(acl): scope known_metrics — the metric vocabulary leaked existence#36

Merged
sturlese merged 3 commits into
mainfrom
fix/bughunt-known-metrics-acl
Jul 25, 2026
Merged

fix(acl): scope known_metrics — the metric vocabulary leaked existence#36
sturlese merged 3 commits into
mainfrom
fix/bughunt-known-metrics-acl

Conversation

@sturlese

@sturlese sturlese commented Jul 25, 2026

Copy link
Copy Markdown
Owner

Third iteration of the autonomous bughunt sweep — lead #2 from the backlog (lead #1, the corpus taxonomy ordering, is parked pending a policy decision).

Bug

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(),)).fetchall()
return [r[0] for r in rows]

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."
  • ADR 010 — "query_metrics filters 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_text reaches known_metrics on the empty-result path — that is, precisely when the row filter just hid everything from this client — and then 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 while parsing the question.

It leaks ids rather than values — but known_entities proves the intended posture, and all three docs promise scoped existence.

Fix

known_metrics takes audiences and applies visible() itself, selecting acl alongside 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. Both service call sites pass self.audiences. Unrestricted clients (audiences=None) are unchanged.

Also matches query_metrics' verified = 1. factstore inserts verified rows only (hardcoded 1 at factstore.py:72, schema default 1), 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_metrics has no pipeline counterpart, so eval_contract_parity cannot 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 acl column is no longer served open. acl reaches facts.db through an additive migration clean runs on its write path; this package only reads it. Naming the column crashed ask() 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 unreadable acl (fix(contract): a model-chosen type/date could drop a page's ACL or kill the build #34) and the one brain-page-contract.md states. Unrestricted instances are unaffected, so open corpora and the demo are unchanged. Applied to query_metrics too.

  • Unrestricted clients no longer read acl at all, keeping 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.

  • 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 from eng through all of them, and still reachable by finance through 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 how known_metrics got in unscoped. The gate proved it by adding an unscoped known_periods() — all 48 tests passed while it handed an eng client 'acme/total-compensation@2026'. It now asserts over inspect.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 a get_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:

  1. ANSWER_AUDIENCES="," silently means UNRESTRICTED (settings.py:31) — tuple(...) or None turns 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.
  2. A non-list acl: value is encoded as "no ACL" and served open (index.py:128-137) — acl: finance (the intuitive single-label form), acl: '', acl: null all fall through to None. 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.
  3. A comma inside a label widens access (index.py:129) — clean/acl.py rejects commas because labels are CSV-serialised; answer re-parses the page text with no such check, so acl: ["finance,eng"] grants both.
  4. superseded_by echoes 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 ?) and retrieve.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.

sturlese and others added 3 commits July 25, 2026 03:11
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>
@sturlese

Copy link
Copy Markdown
Owner Author

Gate verdict

Two 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 answer, the class was worth auditing exhaustively).

Verdicts: REFUTED at f6ab55a, WEAKENED at 7f2c9fe. Both commits under-delivered on their own claims, and both are now fixed in 6d83a16.

What the refuter broke

1. The pre-ACL crash was worse than I described. I'd caught it myself, but the reviewer measured the real blast radius: at f6ab55a, ask() died with OperationalError: no such column: acl on every question, for every client including unrestricted ones — while main and the sibling query_metrics worked fine. A security PR shipped a total-outage regression, its own new checklist test did not notice, and CI was green on it.

2. The degradation direction was wrong — and this is the finding I'd have defended and been wrong about. 7f2c9fe made both facts read paths degrade to "no acl → open" on a pre-ACL store, justified as symmetry with query_metrics. That is symmetry with the wrong half: the page index takes the opposite direction for an acl it cannot read (acl = '', restricted to nobody — #34), and brain-page-contract.md states it outright. On a legacy store a scoped client got values, source_refs, the restricted page path and metric/entity existence, while the page itself stayed hidden. Now: no acl column means no audience information, so a scoped client gets nothing; unrestricted instances are unaffected.

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 known_metrics got in unscoped. The reviewer proved it by adding an unscoped known_periods(): all 48 tests passed while it handed an eng client 'acme/total-compensation@2026'.

It is now a probe table plus a completeness assertion over inspect.getmembers(AnswerService) — adding a public method fails until it is probed or explicitly classified. Verified against that same mutant. The positive direction was also one-sided (3 of 8 surfaces), so an over-denying "fix" could pass; both directions are now asserted over the full table, verified against a get_page-denies-everyone mutant that the old version passed.

4. Perf: selecting acl forced a temp B-tree instead of the covering index — 32x slower on a 300k-row store, and match_metric runs it once per question. Unrestricted clients no longer read acl at all.

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); ''/None/CSV encodings all behave; sorted() and SQL ORDER BY agree for every possible TEXT value (UTF-8 byte order = code-point order). And a behavioural delta I had not reported: because match_metric now returns None, out-of-scope refusals travel the search path, so the reason changes from "no entity named 'acme' in the brain" to "no pages matched: …" — arguably better, since it no longer asserts a falsehood about an entity that does exist.

The sweep: four more leaks, none of them this PR's

All 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:

  1. ANSWER_AUDIENCES="," silently means UNRESTRICTED (settings.py:31). tuple(...) or None turns a scope that parses to zero labels into "no scope", so ",", ",,", " , " all serve the entire corpus while the operator believes the instance is scoped. One config typo — e.g. a template rendering an empty list — and everything is exposed. 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"); it is not applied to the read side. One-line fail-loud fix. This is the most severe thing found in three iterations and is next up.
  2. A non-list acl: value is encoded as "no ACL" and served open (index.py:128-137). refresh honours acl only when it is a YAML list; acl: finance (the intuitive single-label form), acl: '', acl: null all fall through to None → open. This is my own fix(contract): a model-chosen type/date could drop a page's ACL or kill the build #34 fix being incomplete: I handled an unreadable block but not a readable block with an unreadable value, even though the doc line I wrote covers both.
  3. A comma inside a label widens access (index.py:129). clean/acl.py rejects commas precisely because labels are CSV-serialised, but answer re-parses the page text and applies no such check, so acl: ["finance,eng"] grants both.
  4. superseded_by echoes the source id of an out-of-scope successor (service.py:100). Existence disclosure, no content.

Clean bill of health for the rest, with evidence: no existence oracle (out-of-scope and nonexistent produce byte-identical messages on every surface), check_citations short-circuits before get_page, superseded_paths never emits a path, FTS bm25 statistics never leave the server, nothing reads brain_md_dir outside the index, and the facts↔page ACL asymmetry is not producible by the pipeline.

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

@sturlese
sturlese merged commit 35c8d09 into main Jul 25, 2026
10 checks passed
@sturlese
sturlese deleted the fix/bughunt-known-metrics-acl branch July 25, 2026 01:28
sturlese added a commit that referenced this pull request Jul 25, 2026
#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>
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