Skip to content

fix(honesty): unwrap not-wrapped predicates in absenceBlindSpotNote - #776

Merged
divshekhar merged 4 commits into
reticlehq:mainfrom
DevChiniwala:fix/not-absence-blind-spot
Sep 6, 2026
Merged

fix(honesty): unwrap not-wrapped predicates in absenceBlindSpotNote#776
divshekhar merged 4 commits into
reticlehq:mainfrom
DevChiniwala:fix/not-absence-blind-spot

Conversation

@DevChiniwala

@DevChiniwala DevChiniwala commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

The defect

absenceBlindSpotNote is what stops "the element is absent" being read as proof when the page has regions Reticle cannot see into. It fired for one of the two ways that claim is written:

// blind-spots.ts:238
if ('element' !== predicate.kind || true !== predicate.absent) return undefined;

A not-wrapped element predicate is the same assertion — not is one of the twelve predicate kinds — and its kind is 'not', so the note never fired.

What makes this a disagreement rather than a uniform gap is the function directly below it. restsOnCompleteWindow opens with if (PredicateKind.NOT === predicate.kind) return true, so buffer loss already impeaches a not-wrapped green exactly as it should. The two honesty checks sitting beside each other read the same predicate differently, and the one that stayed quiet was the one guarding the DOM the walk could not enter.

So reticle_assert { not: { element: … } } over a page with a closed shadow root, an unmounted virtualized row, or a scoped cross-origin frame returned a green implying absence had been proven page-wide. The same assertion written absent: true said otherwise.

The third spelling of absence

not(element) is the third way to write absence:

  1. { kind: 'element', absent: true } — the explicit spelling
  2. { kind: 'net', count: 0 } — the zero-count spelling (fix(honesty): treat an exact count of zero as the absence claim it is #765)
  3. { kind: 'not', predicate: { kind: 'element' } } — the negation wrapper

The function missed exactly one of three.

Polarity decides it, not spelling

The naive unwrap gets it wrong. not flips the claim, so the fix must consider polarity:

assertion claim note
{ element, absent: true } absence fires (unchanged)
{ not: { element } } absence now fires
{ not: { element, absent: true } } double negative — presence silent
{ not: { net } } not about the DOM silent

not(element { absent: true }) asserts the element IS there. A positive assertion that passed found its evidence — an unobservable region cannot unmake it. That is the same reasoning the plain-presence case already rests on.

Returning the negated predicate also lands the CROSS_ORIGIN_IFRAME branch: that branch selects on query.scope, which lives on the inner predicate, so a wrapper-only read would have found no scope on exactly the assertions that name a frame. There is a test on both halves of that.

What this does NOT do, and why I stopped

No recursion past one level. not(not(element)) is itself a presence claim, so silence there is the correct answer, not a gap being tolerated. The one-level limit only starts to bite at three levels of nesting, which is not a shape a caller writes. A test pins this so the next reader does not re-open the question.

No allOf/anyOf unwrap. not(allOf([element, …])) is a weaker claim than "this element is absent" — asserting the note over it would over-warn about a specific element nobody named.

Does not touch restsOnCompleteWindow. Already correct for NOT, and the two functions address different questions (buffer loss vs. DOM observability).

String literals replaced with PredicateKind constants. The existing 'element' at line 238 and the new 'not' / 'element' comparisons all use PredicateKind.NOT / PredicateKind.ELEMENT — rule #3.

How it was verified

Written RED first. Without the source change, exactly three of the nine new cases fail — the note firing, the two spellings agreeing, and the scope read — while the six guard cases pass, which is the split that shows the change does not over-reach:

Gates

  • pnpm format:check — my files clean (472 pre-existing format warnings unrelated to this PR)
  • pnpm typecheck — 22/22 (apps-electron-vue-pinia pre-existing failure, missing node_modules)
  • blind-spots.test.ts20/20 passing (was 11 before)
  • assert-coverage-honesty.test.ts16/16 passing
  • No any, no free strings (PredicateKind.NOT, PredicateKind.ELEMENT), no non-null !
  • Files under the cap (blind-spots.ts: 307 lines, test: 173 lines)
  • CHANGELOG.md updated under [Unreleased]

Closes #774

🤖 Generated with Claude Code

A predicate shaped `{ kind: 'not', predicate: { kind: 'element', ... } }`
is semantically an absence assertion, but `absenceBlindSpotNote` checked
only the outer `kind`, which is 'not', not 'element'. The function returned
undefined and the agent got a green verdict with no blind-spot caveat —
the false green the function exists to prevent.

Add `predicate?: AbsencePredicate` to the interface and synthesize
`absent: true` on the inner element predicate when the outer kind is 'not'.
Pin with unit tests (blind-spots.test.ts) and an integration test
(assert-coverage-honesty.test.ts).

Closes reticlehq#774

Signed-off-by: Dev Chiniwala <dev.chiniwala@gmail.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings September 5, 2026 22:10

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

A small but important convention issue remains (use PredicateKind constants instead of hard-coded kind strings) to prevent predicate-kind drift/typos in this honesty-critical logic.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR fixes an honesty/coverage gap in @reticlehq/server where absenceBlindSpotNote failed to recognize not-wrapped element predicates as absence assertions, causing blind-spot caveats to be silently omitted.

Changes:

  • Teach absenceBlindSpotNote to unwrap { kind: 'not', predicate: { kind: 'element', ... } } and treat it as an absence assertion by synthesizing absent: true on the inner element predicate.
  • Add unit tests covering direct element absence, not-wrapped element absence, non-element not, and scoped cross-origin behavior.
  • Add an integration test ensuring reticle_assert downgrades not-wrapped element absence to UNKNOWN with ABSENCE_BLIND_SPOT when virtualized rows are unobserved.
File summaries
File Description
packages/server/src/honesty/blind-spots.ts Unwrap not(element) in absenceBlindSpotNote and synthesize absent: true so blind-spot caveats apply consistently.
packages/server/src/honesty/blind-spots.test.ts Add focused unit tests for absenceBlindSpotNote, including not unwrap and scoped cross-origin behavior.
packages/server/src/tools/assert-coverage-honesty.test.ts Add integration coverage to ensure reticle_assert downgrades not-wrapped element absence under virtualized blind spots.
Review details
  • Files reviewed: 3/3 changed files
  • Comments generated: 1
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +241 to +246
if ('not' === predicate.kind && predicate.predicate !== undefined) {
if ('element' === predicate.predicate.kind) {
return absenceBlindSpotNote({ ...predicate.predicate, absent: true }, spots);
}
return undefined;
}
divshekhar and others added 2 commits September 6, 2026 17:24
…d constants

The initial unwrap logic treated not(element { absent: true }) the same
as not(element) — both synthesized absent: true and fired the blind-spot
note. But not(element { absent: true }) is a double negative that
asserts presence, and a positive match is not threatened by an
unobservable region.

Also replaces string literals 'not' and 'element' with PredicateKind.NOT
and PredicateKind.ELEMENT (rule reticlehq#3: no free strings).

Adds CHANGELOG.md entry under [Unreleased].

Signed-off-by: Dev Chiniwala <dev.chiniwala@gmail.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…rtion

Two cases suggested by Chirag6722 in reticlehq#815:

1. not(not(element)) is itself a presence claim, so silence is the
   correct answer — pins the one-level recursion decision so the next
   reader does not re-open the question reticlehq#774 raised.

2. Both spellings (absent: true and not-wrapped) produce the identical
   note string, not merely a non-empty one — stops one of them
   quietly drifting into a weaker sentence later.

Signed-off-by: Dev Chiniwala <dev.chiniwala@gmail.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@divshekhar
divshekhar merged commit f45e8c4 into reticlehq:main Sep 6, 2026
19 checks passed
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.

A not-wrapped absence assertion loses the blind-spot caveat that absent: true gets

3 participants