fix(acl): the index trusted the acl value it re-derived from page text#38
Conversation
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>
… 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>
…losed 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>
Gate verdictVERDICT: WEAKENED. (A first reviewer stalled; a relaunched one completed. Its two lead findings are fixed in The same disclosure, one line above the code I changed
Byte-identical on A predicate that renamed labels instead of rejecting them
The attack I expected to land, dismissed on evidenceI expected "you fixed the page half and left the facts half open". The reviewer traced Also correctedThe v2 migration comment claimed "nothing goes dark". False: it closes every page to scoped clients until the next refresh, including pages whose ACL was fine. Verified soundNot over-restrictive against the real pipeline: indexing the evals' produced Recorded for its own PR
answer 56 (+4) · clean 252 · corpus 48 · graph 40 · slack 13 · fetch 33 · benchmark 3, bare |
Fifth iteration of the autonomous
bughuntsweep — the last two "silently open" items from the full ACL audit that #36's gate ran. Both live in the same encoding step inindex.refresh, so they go together.Bug 1 — a non-list
acl:value meant "no ACL"refreshhonouredaclonly when it was a YAML list. Every other shape fell through toacl = None, which means "this page carries no ACL" — open to everyone:acl: financeis what anyone writing a single label by hand produces.cleanalways 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 explicitly foresees other writers feeding this same field.This is #34's fix being incomplete, not 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
aclkey is open" — but the code only ever checked the block, never the value."acl" in fmcloses it, and that distinction is load-bearing:fm.get("acl")cannot tellacl: nullfrom an absent key.Bug 2 — a comma inside a label widened access
Labels are joined into one CSV column, and nothing checked them, so a label containing a comma split back into two audiences at enforcement time:
clean.acl._check_labelsrejects a comma or a blank label at config load, calling it "the exact silent-corruption failure mode an access-control config must not have". The index re-derives the same encoding from page text and cannot assume the page came through that validation._label_okmirrors that rule.Bug 3 — recognising the block required it to be CLOSED
Found by the gate, and the larger half of the same hole.
carries_frontmatter_blockran the full block regex despite a docstring (mine, from #34) claiming "whether or not it parses". So a page carryingacl: [finance]was indexedNULL— open — whenever its block was unclosed, truncated mid-write, ended with YAML's..., preceded by a BOM, or preceded by a blank line. A truncated page leaked its entire ACL, where bugs 1 and 2 leaked only a malformed one.Bug 4 — the label check renamed labels instead of rejecting them
Also from the gate.
_label_okvalidatedstr(label)rather than requiring astr, and a renamed label is grantable: YAML 1.1 readsacl: [010]as the int8and[12:30]as750, so the index stored audiences nobody granted. It also collided[~]with["None"]and[1.50]with[1.5].Fix
One condition and one predicate:
elif "acl" in fm or (not fm and carries_frontmatter_block(text))— anaclthis encoding cannot represent is still anacl, so the audience is unknown, and unknown is not open._label_ok— a label must have no comma and be non-blank, matchingclean.acl._check_labels. A label the encoding cannot represent makes the whole ACL unreadable rather than quietly dropping the bad label.carries_frontmatter_blockmatches the opener alone, tolerating a BOM and leading blank lines. The BOM case is handled better than merely closing it:split_frontmatterstrips the BOM, so that page's real ACL is read — a BOM is encoding noise editors add invisibly, not content, and it must never be able to hide anacl._label_okrequiresisinstance(label, str). Verified this cannot reject anything the pipeline writes:clean.page._yamlemits a scalar plain only when it round-trips as the identical string, so every label it writes reads back as astr.Everything unreadable now stores
''— restricted to nobody: no scoped client sees the page, an unrestricted operator still does, so the breakage is discoverable rather than silent.And a migration, or none of this reaches a deployed index.
refreshre-reads a page only when its (mtime, size) changed, so a row stored asNULLunder the old rule would stay open for the life of the index — measuredadded: 0, updated: 0on an existing index.PRAGMA user_version = 2setsmtime = -1to force the re-encode, and closes rows meanwhile rather than after. That closes every page to scoped clients until refresh completes (AnswerServicerefreshes immediately afterconnect()); unrestricted clients are unaffected.Test
Both confirmed to fail with the source change stashed, and verified across ten
acl:shapes:acl:value[finance]'finance'finance·"finance"·''·null·{finance: true}''["finance,eng"]·[finance, " "]·[true]''[]''aclkey)NULLThe last two rows are the ones that matter for regressions:
acl: []still round-trips through the list branch to''(PR #30's semantics, unchanged), and a page with genuinely noaclkey is still the one open case.answer 56 (+6) · clean 252 · corpus 48 · graph 40 · slack 13 · fetch 33 · benchmark 3 — bare
pytest(CI mode). ruff clean; golden scorecard 25/25; ACL visibility parity 11/11.Parity is unaffected:
eval_contract_paritybuilds the CSV itself rather than going throughrefresh, and the two hand-mirrored halves agree exactly on the valid-label domain that this fix now enforces at index time.Docs
brain-page-contract.md's tolerance rule now covers theaclvalue as well as the block, and states the separator constraint for any consumer that serialises labels into one field — the assumption that cost this bug, twice.Still open
clean.acl.dossier_acliterates a string member ACL into characters (found by this PR's gate, verified):dossier_acl(["finance"]) == ['a','c','e','f','i','n'], so a dossier is emitted with six single-character audiences its members never granted. The same value-shape assumption the index just stopped making, on the pipeline side.clean.acl._check_labelsaccepts whitespace-padded labels — it rejects commas and blanks but not surrounding whitespace, so"audiences": [" finance "]mints a label noANSWER_AUDIENCESvalue can ever match (the scope side strips, the page side does not). Silently over-restrictive, so fail-closed, but it means an ACL config can be quietly inert. Deliberately not fixed here: normalising on the read side alone would make the two mirrored halves disagree, so it wants fixing on the write side, in its own PR.query_metricsandretrieve.search, which can starve a scoped client to zero results.