Skip to content

test: identifier limit boundary + empty-identifier coverage (#1981/#1982 follow-up)#2003

Open
markmhendrickson wants to merge 1 commit into
mainfrom
test/identifier-limit-and-empty-identifier
Open

test: identifier limit boundary + empty-identifier coverage (#1981/#1982 follow-up)#2003
markmhendrickson wants to merge 1 commit into
mainfrom
test/identifier-limit-and-empty-identifier

Conversation

@markmhendrickson

Copy link
Copy Markdown
Owner

Follow-up to the review of #1982 (merged), adding the two tests flagged NON-BLOCKING. Both target retrieveEntityByIdentifierWithFallback in src/shared/action_handlers/entity_identifier_handler.ts.

No production source is changed by this PR.

Test A — limit boundary across the two snapshot passes

The identifier handler bounds its two snapshot passes independently: the exact pre-pass applies .limit(limit) to each per-field query, while the JS-scan fallback is separately capped at a fixed .limit(500) and appends every further match it finds. Nothing in between re-applies limit to the merged snapshotMatches array, so the caller-facing bound rests entirely on the final queryEntities({ limit }) call.

The test seeds 3 contacts sharing one email value (whose canonical_name deliberately omits the email, so resolution must go through the snapshot passes) and asserts:

  • the result is bounded — entities.length <= limit at limit: 1, and exactly 2 at limit: 2;
  • the result is deterministic — repeated identical calls return the same ids, and the capped set is a stable ascending-entity_id prefix (queryEntities' default sort is .order("id", { ascending: true })), not an arbitrary subset;
  • an uncapped call resolves all 3, so a capped call is genuinely exercising the bound rather than a seeding failure.

total semantics (caller-facing contract)

Verified empirically rather than assumed. For the snapshot-field pass, total is the size of the capped, returned set — not the true number of matching entities. The handler returns total: withObservations.length, so against 3 genuine matches:

limit entities.length total
1 1 1
2 2 2
100 3 3

Consequence: callers cannot use total to detect truncation or to paginatetotal === limit is indistinguishable from "exactly limit matches exist". This differs from the semantic pass, which returns the search's own total and can exceed entities.length. Documented in a comment on the test.

Test B — empty / whitespace-only identifier (FAILING — live bug)

This test currently fails, deliberately. Per the review instruction, it asserts the correct behavior rather than encoding the buggy behavior.

The bug is real but sits earlier in the pipeline than the review predicted. The review anticipated a false positive in the exact pre-pass, where .eq(lower(snapshot->>field), "") would match snapshot rows storing that field as an empty string. That failure mode is masked by a broader one at the canonical/alias stage:

.or(`canonical_name.ilike.%${normalized}%,aliases.cs.["${normalized}"]`)

With an identifier that trims to empty, normalized is "" and the pattern degrades to %% — which matches every entity row for the user. Resolution never reaches the snapshot passes at all.

Observed behavior for both identifier: "" and identifier: " ", with two seeded contacts:

  • actual: total: 2, match_mode: "direct", returning both contacts — i.e. an empty identifier acts as a wildcard that dumps the caller's entities.
  • expected: total: 0, match_mode: "none".

Failing assertion (tests/integration/entity_identifier_handler.test.ts:983):

AssertionError: expected true to be false // Object.is equality
  expect(result.entities.some((entity) => entity.id === emptyFieldId)).toBe(false);

The test seeds a contact with email: "" plus a normal contact, so it guards both failure modes: the wildcard %% dump and the .eq(lower(snapshot->>email), "") empty-string-field false positive (the latter additionally pinned via an explicit by: "email" call, which routes straight to that query).

Isolating the stages confirms the mechanism — with an entityType that has no matching entity rows, the empty identifier correctly returns total: 0 / "none", so the wildcard is unambiguously the canonical/alias ilike stage.

Suggested fix (not applied here)

Guard early in retrieveEntityByIdentifierWithFallback: if identifier.trim() is empty, return { entities: [], total: 0, match_mode: "none" } before the canonical/alias query. That fixes the wildcard dump and, being ahead of the pre-pass, the empty-string-field false positive too. Left to a follow-up so this PR stays test-only and the bug is reviewed on its own merits.

Verification

  • npx tsc --noEmit — clean (exit 0, repo-wide)
  • npx prettier --write on the touched file
  • npx vitest run tests/integration/entity_identifier_handler.test.ts --testTimeout=6000020 passed, 1 failed (21 total); the single failure is Test B, documenting the bug above. All 19 pre-existing tests still pass, plus Test A.

🤖 Generated with Claude Code

 follow-up)

Adds the two tests flagged NON-BLOCKING in the review of #1982, covering
`retrieveEntityByIdentifierWithFallback`.

Test A — limit boundary across the two snapshot passes. The exact pre-pass
applies `.limit(limit)` per field query while the JS-scan fallback is
separately capped at 500 and appends every further match; nothing re-applies
`limit` to the merged `snapshotMatches` array, so the caller-facing bound
rests on the final `queryEntities({limit})` call. Seeds 3 contacts sharing one
email and pins that the result is bounded and deterministic (a stable
ascending-entity_id prefix), not an arbitrary subset.

Documents the observed `total` semantics: for the snapshot-field pass `total`
is the size of the CAPPED set (`withObservations.length`), not the true match
count — so `total === limit` is indistinguishable from "exactly `limit`
matches exist" and callers cannot use it to detect truncation.

Test B — empty / whitespace-only identifier. Currently FAILING, documenting a
live bug rather than papering over it: an identifier that trims to empty makes
the canonical/alias stage build `canonical_name.ilike.%%`, which matches every
entity row for the user. The handler returns match_mode "direct" with all of
the caller's entities instead of an empty result. The test asserts the correct
contract (total 0 / match_mode "none") and also guards the related exact-pass
false positive, `.eq(lower(snapshot->>field), "")` matching rows that store
that field as an empty string.

No production source changed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@neotoma-agent

Copy link
Copy Markdown
Collaborator

🤖 Lanius — Ateles swarm, PR gate inheritance
BLOCKED

PR Gate Inheritance Check

Parent issue resolved from PR #1982's Closes #1981 (PR #1982 merged 2026-07-23T08:51:07Z, implementing the fix this PR adds test coverage for). Authoritative gate_status retrieved from Neotoma issue entity ent_48dee46fb2b3a50d2a134501 (github_number 1981):

Gate Status Owner
pm signed_off Pavo
ux not_required
arch pending Waxwing
impl pending Gryllus
pr_review changes_requested Vanellus
qa pending Phoenicurus
legal not_required

Blocking gate(s)

Per PR gate inheritance, any pre-impl gate (pm/ux/arch) left unsigned on the parent issue blocks descendant PRs — including this test-only follow-up.

Resolution paths

  1. Gate owner sign-off: Waxwing sets gate_status.arch → signed_off on issue ent_48dee46fb2b3a50d2a134501 via Neotoma, or
  2. Operator waiver: only @markmhendrickson may run /confirm-gates-clear on this PR — waives all unsigned pre-impl gates and re-triggers the PR pipeline.

No other commenter can clear this gate.

📎 Neotoma: issue ent_48dee46fb2b3a50d2a134501 (neotoma#1981)

GATE_INHERITANCE: blocked

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.

3 participants