From 7fdc67c766f3f66386e4a45defd157ae07f1501b Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 23 Oct 2026 11:54:21 +1100 Subject: [PATCH] docs(skills): say v3 JSON contains can match across array elements The stash-drizzle and stash-encryption skills told readers that containment on a types.Json column is exact, with no false positives. That is not true for arrays of objects. EQL v3 SteVec containment matches each leaf of the needle on its own and does not group leaves by array element, so a needle like { items: [{ a: 1, b: 2 }] } also matches a document where a: 1 and b: 2 sit in different elements of items (#1231). An agent following the old wording would skip the re-check after decryption that this case needs. The skills now say containment follows jsonb @> semantics for objects and scalars, that array containment is position-independent, and that arrays of objects can return extra rows, so a caller needing an exact match on an object inside an array must re-check the decrypted rows. EQL v4 json_tree fixes this; v3 is unchanged. Claude-Session: https://claude.ai/code/session_01N7bCqa3ZVJMcEE99RzVqEu --- .../skill-json-contains-array-false-positive.md | 11 +++++++++++ skills/stash-drizzle/SKILL.md | 6 ++++-- skills/stash-encryption/SKILL.md | 4 ++-- 3 files changed, 17 insertions(+), 4 deletions(-) create mode 100644 .changeset/skill-json-contains-array-false-positive.md diff --git a/.changeset/skill-json-contains-array-false-positive.md b/.changeset/skill-json-contains-array-false-positive.md new file mode 100644 index 000000000..d160d0994 --- /dev/null +++ b/.changeset/skill-json-contains-array-false-positive.md @@ -0,0 +1,11 @@ +--- +"stash": patch +--- + +The `stash-drizzle` and `stash-encryption` skills no longer describe EQL v3 JSON +containment (`contains` on a `types.Json` column) as exact with no false +positives. Containment follows jsonb `@>` semantics for objects and scalars, but +for an array of objects a needle's fields can match across different elements +of the array, so the query can return extra rows (#1231). The skills now say so, +and tell the reader to re-check the decrypted rows when an exact match on an +object inside an array matters. diff --git a/skills/stash-drizzle/SKILL.md b/skills/stash-drizzle/SKILL.md index 99a418376..83e2561a4 100644 --- a/skills/stash-drizzle/SKILL.md +++ b/skills/stash-drizzle/SKILL.md @@ -318,7 +318,7 @@ const results = await db ### Encrypted-JSONB Containment (`contains`) -`contains(col, subDoc)` on a `types.Json` column is **exact** encrypted containment (jsonb `@>` semantics, no false positives). The needle is a ciphertext-free `query_json` term. Array containment is position-independent — `{ roles: ["admin"] }` matches any document whose `roles` array includes `"admin"`: +`contains(col, subDoc)` on a `types.Json` column is encrypted containment with jsonb `@>` semantics for objects and scalars. The needle is a ciphertext-free `query_json` term. Array containment is position-independent — `{ roles: ["admin"] }` matches any document whose `roles` array includes `"admin"`: ```typescript const results = await db @@ -327,6 +327,8 @@ const results = await db .where(await ops.contains(usersTable.profile, { roles: ["admin"] })) ``` +**Arrays of objects can return extra rows.** Each field of a needle element is matched on its own, not per array element, so `{ items: [{ a: 1, b: 2 }] }` also matches a document where `a: 1` and `b: 2` sit in different elements of `items` (#1231). If you need an exact match on an object inside an array, re-check the decrypted rows. + An empty-object needle (`{}`) is rejected — `doc @> '{}'` holds for every document, so it would silently match every row. Omit the predicate if you want all rows. `types.Json` carries no equality or ordering: `eq`/`gt`/`asc` on a `Json` column throw. @@ -664,7 +666,7 @@ All comparison/containment operators auto-encrypt their operands and are async; | `notBetween(col, min, max)` | Negated range | order/range | | `inArray(col, values)` / `notInArray(col, values)` | Membership (single-batch encryption; empty list rejected) | equality | | `matches(col, needle)` | Fuzzy free-text token match (short needles rejected) | free-text (`TextMatch`, `TextSearch`) | -| `contains(col, subDoc)` | Exact encrypted-JSONB containment (`{}` rejected) | `Json` | +| `contains(col, subDoc)` | Encrypted-JSONB containment (`{}` rejected; arrays of objects can return extra rows) | `Json` | | `selector(col, path).eq/ne/gt/gte/lt/lte(value)` | JSONPath selector-with-constraint (dot-notation paths, scalar leaves) | `Json` | | `selector(col, path).asc()/desc()` | ORDER BY a scalar JSONPath leaf (missing paths are SQL NULL) | `Json` | diff --git a/skills/stash-encryption/SKILL.md b/skills/stash-encryption/SKILL.md index 3daf57211..eba9ef180 100644 --- a/skills/stash-encryption/SKILL.md +++ b/skills/stash-encryption/SKILL.md @@ -671,9 +671,9 @@ A `types.Json("metadata")` column encrypts a whole JSON document to a `public.eq `types.Json` carries no equality or ordering capability — `eq` / `gt` / `asc` on it throw. It supports two query patterns: containment and JSONPath selectors. -### Containment (exact, `@>`) +### Containment (`@>`) -Pass a sub-object or sub-array to `encryptQuery` with `queryType: 'searchableJson'`; it matches documents that contain the needle with jsonb `@>` semantics — **exact** containment, no false positives. Array containment is a subset test regardless of element position: `{ roles: ['admin'] }` matches any document whose `roles` array includes `admin`. +Pass a sub-object or sub-array to `encryptQuery` with `queryType: 'searchableJson'`; it matches documents that contain the needle with jsonb `@>` semantics for objects and scalars. Array containment is a subset test regardless of element position: `{ roles: ['admin'] }` matches any document whose `roles` array includes `admin`. **Arrays of objects can return extra rows:** each field of a needle element is matched on its own, not per array element, so `{ items: [{ a: 1, b: 2 }] }` also matches a document where `a: 1` and `b: 2` sit in different elements of `items` (#1231). If you need an exact match on an object inside an array, re-check the decrypted rows. ```typescript const events = encryptedTable("events", { metadata: types.Json("metadata") })