Skip to content

feat(spec,drivers,objectql,analytics,formula): $icontains reaches every JS evaluation face (#6520) - #7123

Merged
os-zhuang merged 4 commits into
mainfrom
claude/issue-6520-icontains-js-faces
Aug 9, 2026
Merged

os-zhuang merged 4 commits into
mainfrom
claude/issue-6520-icontains-js-faces

Conversation

@os-zhuang

@os-zhuang os-zhuang commented Aug 9, 2026 •

Copy link
Copy Markdown
Contributor

Closes #6520

The other half of #5702. That change implemented $icontains on the SQL family and correctly left the spec's FILTER_OPERATORS alone; this one adds the operator to that array and gives every remaining evaluation face an arm — in one PR, because those two steps cannot be separated.

The ruling chain

Maintainer ruling, 2026-08-08 (Option A — approved), quoted from the card:

Maintainer ruling (2026-08-08): Option A — approved. Add $icontains to the spec FILTER_OPERATORS word list and bring every JS evaluation face up to parity, as a sanctioned one-off exception to the #5499 investment freeze, strictly for semantic parity — not new capability.

Hard constraints for the dispatch (from the card, now binding):

  • Single PR, whole batch: spec word list + driver-memory (both faces) + driver-mongodb + service-analytics + objectql having + formula, plus conformance coverage. The word list must NOT land ahead of the evaluators — that would flip the JS faces' fail-closed refusal into silent acceptance, the A filter with an operator outside VALID_AST_OPERATORS is silently dropped, not rejected — single-condition views return unfiltered results #3948 RLS-bypass shape.
  • The PR body must declare a conclusion for every compile face per the face-inventory clause in the dispatch playbook (changed / already compliant with evidence / explicitly out of scope with reason). The frozen driver-memory / driver-mongodb faces are being changed here under the sanctioned exception, so they report as changed.

The maintainer directive recorded on that comment is quoted verbatim and untranslated: 「你的建议全部接受」.

Census note, 2026-08-09 (#6993), quoted from the card:

#6993 普查实测补一格(2026-08-09,origin/main @ 08863dd18,执行测量):本卡现状表中 @objectstack/formula matchesFilter 一行标 ❌ 与其余拒收面并列,但它的「❌」与其他面不同类:

即 formula 面对一个 spec 已声明的算子给出的是「无声的否答」,不是「fail-closed 拒收」—— 恰是 #4706 对旧 $regex 的第二项指控(illegal pattern → return false)在这一面的残留形态。给 formula 加 $icontains 臂时(本卡范围第 4 条),建议同 PR 决定 unknown-$ 算子在该面是维持静默 false 还是升级为 throw,并把决定写进 matches-filter.ts 的 docblock;filter.zod.ts 的 "The other JS evaluators sit on the refusing side too" 一句对 formula 的措辞也应随之校正。

Premise verification (on origin/main @ f5a9bc2f3)

Every claim in the card re-measured before implementing; all held.

card's claim measured
FILTER_OPERATORS lacks $icontains held — filter.zod.ts:1356, 15 members, no $icontains
vocabulary pin fixes the diff set at exactly {$icontains} held — filter-operator-vocabulary.test.ts:60 toEqual(['$icontains'])
SQL family evaluates with ASCII-only folding held — sql-driver.ts:1379-1382, Postgres translate(expr,'A..Z','a..z') on both sides
driver-memory refuses via a gate derived from FILTER_OPERATORS held — filter-refusal.ts:178 new Set([...FILTER_OPERATORS])
driver-mongodb refuses at its translator default: held — mongodb-filter.ts:590
objectql having refuses (CONDITION_OPERATORS) held — having-filter.ts:60
formula answers a silent false, a different class held — matches-filter.ts evalOp default: return false

One scope correction the card's estimate did not carry: item 3 says "add an arm to compileScopedFilterToSql", but the echo-coverage test drives three compilers off FILTER_OPERATORS (compileScopedFilterToSql, NativeSQLStrategy, ObjectQLStrategy.generateSql) plus filter-normalizer.ts's MONGO_TO_CUBE_OP. All four are changed here; leaving any one out fails that test.

Face inventory — a conclusion for every compile/evaluation face

Changed (under the sanctioned #5499 exception):

face file what it does now
spec word list packages/spec/src/data/filter.zod.ts FILTER_OPERATORS admits $icontains; staging docblock rewritten
spec shared fold same file new foldAsciiCase / asciiCaseInsensitiveContains / asciiCaseInsensitiveRegexSource
driver-memory query path memory-driver.ts mingo $regex from the ASCII pattern source, no i flag
driver-memory reference matcher memory-matcher.ts asciiCaseInsensitiveContains
driver-memory analytics (cube) face memory-analytics.ts new icontains cube operator + predicate builder + SQL row
driver-memory shape gate filter-refusal.ts admits by derivation; new non-empty-string comparand rule
driver-mongodb mongodb-filter.ts $regex with the ASCII pattern and no $options; comparand rule on the walk
objectql having having-filter.ts asciiCaseInsensitiveContains
formula (RLS write check) matches-filter.ts asciiCaseInsensitiveContains + the posture decision below
service-analytics normalizer strategies/filter-normalizer.ts $icontains maps to the icontains leaf
service-analytics RLS lowering read-scope-sql.ts folded LIKE, both sides
service-analytics native SQL strategies/native-sql-strategy.ts folded LIKE, both sides
service-analytics /analytics/sql echo strategies/objectql-strategy.ts folded LIKE, both sides
conformance ledger scripts/check-driver-conformance.mjs requirement-1 half cleared on both DEBT rows

Already compliant — evidence, not assumption:

Out of scope, with reason:

The #3948 probe — direction predicted first, then measured

PREDICTION, written before running: with the word list admitted and memory-matcher.ts's arm reverted, the matcher's default: break treats the operator as "no constraint", so the reference matcher should answer EVERY row — a silent widening, not a throw — and the new pins should go red on the row-set assertion rather than on a missing error.

MEASURED on this branch — git checkout origin/main -- memory-matcher.ts (never git stash: refs/stash is shared by every worktree of this repo, objectui#3430), then run match() over the conformance fixture, then git checkout HEAD -- to restore:

rows returned : ["1","2","3","4","5","6","7","8","9"]
fixture size  : 9
VERDICT       : MATCH-ALL - predicate silently DROPPED (#3948)
a NON-match row ("zzz") answers: true

The prediction held exactly, including the direction. The face does not refuse and does not error — it answers true for a row that plainly does not contain acme, which is #5701's measurement reproduced on this branch rather than quoted from it. The gate accepts the operator because SUPPORTED_FIELD_OPERATORS is FILTER_OPERATORS, and the matcher's default: arm breaks, which means "no constraint".

So the loudness lives in the TESTS, not in the runtime: memory-icontains.test.ts asserts exact row-id lists, so this state fails it on the ROW SET (9 ids where 2 were expected) — which is why those pins assert ids rather than toThrow(). A throw-only assertion would have stayed green against a face that silently matched everything, and that is precisely the shape it would have missed.

This is also the answer to "why one PR": there is no ordering in which the word list lands first and the failure is visible. It is not a refusal, not an error, and not a smaller row set — it is a permission-shaped widening that looks like a working filter.

Folding parity — same answers as SQL

Every face is driven over FILTER_TEXT_ROWS — the conformance standard's own fixture — so the answers are comparable to the SQL family's by construction. Predictions written before the suites were run, from the contract rather than from the implementation:

filter predicted ids why that is the contract
{ $icontains: 'acme' } 1, 2 the fold runs on BOTH sides, so ACME Corp matches a lower-case comparand
{ $icontains: 'ACME' } 1, 2 and the mirror direction
{ $icontains: 'café' } 4 only ASCII-ONLY: É does not fold, so CAFÉ must NOT match (#4706 Q1 = A)
{ $icontains: 'CAFÉ' } 3 only the mirror — exactly one row survives each direction
{ $icontains: '100%' } 5 only comparand is LITERAL: % is not a wildcard
{ $icontains: 'a_b' } 7 only _ is not a wildcard
{ $icontains: 'a.b' } 9 only . is not a regex metacharacter — the $regex defect, restated

The two non-ASCII rows are the load-bearing ones: a face that reached for toLowerCase() or a RegExp with the i flag answers 3, 4 to BOTH and is wrong twice. That is the only way to tell an ASCII fold from a Unicode one by row set.

MEASURED on this branch, by running each face over the fixture — every prediction held, on every face:

comparand | mem-matcher | having | formula
"acme"    | 1,2         | 1,2    | 1,2
"ACME"    | 1,2         | 1,2    | 1,2
"café"    | 4           | 4      | 4
"CAFÉ"    | 3           | 3      | 3
"100%"    | 5           | 5      | 5
"a_b"     | 7           | 7      | 7
"a.b"     | 9           | 9      | 9

$contains "acme" (must stay case-exact): matcher=2 formula=2

driver-memory's query path and analytics face are held to the same row sets by memory-icontains.test.ts (which asserts the two faces answer identically, case by case) and by the analytics face's own declared-operator probe; driver-mongodb by mongodb-icontains.test.ts, which executes the emitted pattern over the same fixture.

On the SQL side the parity is structural rather than statistical — the emitted statement is character-identical in construct to driver-sql's Postgres arm. Measured by calling compileScopedFilterToSql directly on this branch:

$icontains "acme"
  sql   : translate("person"."name", 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz') LIKE translate(?, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz') ESCAPE ?
  params: ["%acme%","\\"]
$icontains "100%"
  params: ["%100\\%%","\\"]
$icontains "a_b"
  params: ["%a\\_b%","\\"]

Both sides folded; the comparand still escaped and the ESCAPE character still bound, so the fold rides ON TOP of the literal-comparand rule rather than replacing it. translate() with an explicit 26-character domain is used rather than LOWER() precisely because Postgres' LOWER() is locale-aware and would fold É.

Additionally pinned per face, because a fold is easy to add and easy to over-apply: $contains stays case-SENSITIVE on the faces that already had it right (the reference matcher, having, formula), and driver-mongodb's $contains family still emits its hardcoded $options: 'i' — pinned as the CURRENT state so that fixing #6682 and breaking $icontains can never be confused for one another.

The formula posture decision (census note)

Decision: keep the silent, fail-closed false for unknown operators; documented in matches-filter.ts's header. Reasons, in the order they carry weight:

  1. The error direction is opposite on this face. The other five faces compile READ predicates, where a dropped constraint WIDENS (A filter with an operator outside VALID_AST_OPERATORS is silently dropped, not rejected — single-condition views return unfiltered results #3948) and must be loud. This one evaluates a WRITE-side check: an unevaluable condition DENIES the write. Silence costs a diagnostic, not a boundary.
  2. Callers rely on it being TOTAL — plugin-security's explain-engine calls it per record with no catch, and several driver doubles use it as a list filter. Throwing turns a per-record verdict into an aborted operation on the read/explain path, which is not what a $icontains parity PR should be deciding.
  3. The census's measured defect is gone without the upgrade: every operator FILTER_OPERATORS declares now has an arm, so the silent answer is reachable only for a name the protocol never declared or has retired.

Left open on the record rather than folded in: a retired $regex / $options still gets the silent false here while the other five faces print the prescription naming $icontains. That changes an accept/reject surface and belongs to the maintainer, not to a parity PR. filter.zod.ts's "The other JS evaluators sit on the refusing side too" sentence — which mis-described this face — is corrected.

Downstream

#5814 (better-auth Where.mode: 'insensitive') had its blocker cleared: an authentication query using $icontains no longer 400s on the in-memory double.

Verification

Gates and suites run locally on this branch (each build/test wrapped in the repo's shared flock so parallel agents' memory peaks do not stack):

check result
check:driver-conformance OK — 36 covered cells, 4 DEBT, 0 exempt; both FILTER_TEXT rows retained
check:adr-0087-registration OK — this PR declares no breaking changeset, so no registry entry is due
check:nul-bytes OK — 6563 tracked files, no raw control bytes
check:error-code-casing / check:route-envelope OK
check:driver-memory-census / check:engine-double-contract OK
check:wildcard-fallthrough OK
check:spec-changes / check:upgrade-guide up to date
check:api-surface regenerated (gen:api-surface) — the gate reported 0 breaking (removed/narrowed), 3 added; this is a SEPARATE snapshot from check:export-origins and was initially missed
check:exported-any / check:dual-source-exports OK — the new exports are typed and single-origin
check:export-origins regenerated (gen:export-origins) — diff is exactly the 3 new exports, one origin each, no dual-source
check:authorable-surface OK — 1311 defaults unchanged, 1598 schemas
check:docs regenerated (gen:schema + gen:docs) — one line, the $icontains description
ESLint over every changed file 0 errors (3 pre-existing unrelated warnings in having-filter.ts, untouched by this diff)
full local lap — build + all six packages' suites 14,905 tests passing, 0 failing
@objectstack/spec 356 files, 9281 tests
@objectstack/objectql 164 files, 2805 tests
@objectstack/service-analytics 71 files, 1442 tests
@objectstack/formula 23 files, 584 tests
@objectstack/driver-memory 19 files, 560 tests
@objectstack/driver-mongodb 233 passing, 143 skipped (the opt-in real-mongod suites, #5517)
typecheck on the five packages that declare one pass (service-analytics declares no typecheck script)

CI on this PR has fully converged: all 26 check runs green on head cf3da3d (one skipped), including both gate jobs — ESLint (which is where this repo's family gates run, so a red check:* shows up there and nowhere in a local pnpm test) and TypeScript Type Check (which carries check:driver-conformance, check:api-surface, check:export-origins, check:docs and the workspace build/typecheck), plus Test Core 1-3, Build Core, Build Docs, Dogfood Regression Gate 1-3, Dogfood Verify CLI and Temporal Conformance (live PG + MySQL).

Three gates fired during development, and all three were right

Recorded because a gate that catches you is evidence the gate works, and the first two of these are the mechanism this PR is about:

  1. check:driver-conformance — RECONCILED. The first version of the driver-memory suite mentioned the text case-set's marker export in its docblock prose. The gate matches an import span loosely, read that as "this driver now runs the case-set", and failed because a ledger entry for a covered cell is an error. Correct: requirement 2 of that case-set is still open here (drivers(memory, mongodb): the $contains family still folds case — the last two backends left on the wrong side of #4706 Q2 = A #6682), so this driver must NOT claim the whole set. Fixed by driving FILTER_TEXT_ROWS and never naming the marker; both DEBT rows survive, each now naming one open requirement instead of two.
  2. driver-memory's declared-operator probe gate. memory-driver-filter-logic-conformance.test.ts keys a probe map by the analytics face's OWN declared vocabulary and fails when a declared operator has no probe. Adding $icontains to MONGO_TO_CUBE_OPERATOR turned it red immediately — which is exactly the guarantee driver-memory analytics 面的 $notContains 编译成裸 mingo {$not: 'x'},该谓词不约束任何行 —— 结果被放大到全表 #5374 bought. The probe added for it uses an UPPER-case comparand ('BET') against a lower-case fixture row (beta), so it only selects a row once the fold actually runs; a lower-case comparand would have agreed across the two faces for free even if neither folded, the free-agreement certification that block's own header warns against.
  3. check:api-surface — the SECOND export snapshot. Regenerating export-origins/ is not enough: api-surface/ is a separate artifact tracking the public NAME SET (plus factory signatures), and it went red on CI with 0 breaking (removed/narrowed), 3 added. Fixed with gen:api-surface; the delta is exactly the three additive ./data exports. Recorded because "I regenerated the export snapshot" is precisely the reasoning that misses it — there are two, and adding an export touches both. The rest of that family (check:exported-any, check:dual-source-exports, check:docs, check:authorable-surface, check:skill-refs, check:react-blocks) was then swept in one pass rather than one CI lap at a time, and is green.

Out-of-scope finding filed


Generated by Claude Code

claude added 3 commits August 9, 2026 17:42
…ry JS evaluation face (#6520)

#5702 implemented `$icontains` on the SQL family and correctly left the spec's
`FILTER_OPERATORS` alone. This adds the operator to that array and gives every
remaining evaluation face an arm, in ONE change, because those two steps cannot
be separated: the array is a runtime allowlist that `driver-memory`'s shape gate
derives from, so admitting the name while the matcher had no arm turns a loud
refusal into a silently dropped predicate — measured on #5701's branch, and a
dropped predicate WIDENS, which on an RLS read scope is a permission bypass
(#3948).

Faces changed, under the sanctioned one-off exception to the #5499 freeze
(maintainer ruling 2026-08-08, semantic parity only):

- spec: `FILTER_OPERATORS` admits `$icontains`; the vocabulary pin's diff set
  goes empty; new shared fold `foldAsciiCase` /
  `asciiCaseInsensitiveContains` / `asciiCaseInsensitiveRegexSource`
- driver-memory: query path, reference matcher and analytics (cube) face
- driver-mongodb: an ASCII-only `$regex`, never `$options: 'i'`
- objectql `having`, `@objectstack/formula` `matchesFilterCondition`
- service-analytics: the normalizer plus all three SQL compilers

The fold is ASCII-only by ruling (#4706 Q1 = A) and lives in ONE definition,
because both obvious per-package spellings are wrong the same way:
`toLowerCase()` folds all of Unicode, and so does a `RegExp` with the `i` flag.
The pattern-binding faces emit one `[Aa]` class per ASCII letter and pass no
flags. Comparand rules match driver-sql's: literal, and refused when empty or
non-string.

The formula face keeps its silent fail-closed `false` for unknown operators —
decided, not inherited, and documented on `matches-filter.ts`: it governs a
write-side check where an unevaluable condition denies rather than widens, and
callers rely on it being a total predicate. No operator the spec DECLARES is
answered that way any more, which was the #6993 census's measured defect.

Both `FILTER_TEXT_CASES` DEBT rows survive, now naming one open requirement each
(the `$contains` family's Unicode fold, #6682) instead of two.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PiRUoQkTSBBmpyXBY3cVn2
…D on memory + mongodb (#6520)

The status section still said those two REFUSE it, which this PR made false. It
also now says why each still carries a DEBT row rather than importing the table:
coverage is judged by IMPORT, and requirement 2 (the $contains family's Unicode
fold, #6682) is still open on both.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PiRUoQkTSBBmpyXBY3cVn2
…ers $icontains (#6520)

`memory-driver-filter-logic-conformance.test.ts` keys its probe map by the
face's OWN declared vocabulary and fails when a declared operator has no probe —
so adding `$icontains` to `MONGO_TO_CUBE_OPERATOR` correctly turned it red.
That gate is the reason #5374 cannot recur, and it fired exactly as designed.

The comparand is UPPER-case ('BET') against a lower-case fixture row ('beta'),
so the probe only selects a row once the ASCII fold actually runs. A lower-case
comparand would have been discriminating too and would have agreed across the
two faces for free even if neither folded — the free-agreement certification
that block's own header warns against.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PiRUoQkTSBBmpyXBY3cVn2
@vercel

vercel Bot commented Aug 9, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 9, 2026 6:26pm

Request Review

@github-actions

github-actions Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 6 package(s): @objectstack/driver-memory, @objectstack/driver-mongodb, @objectstack/formula, @objectstack/objectql, @objectstack/service-analytics, @objectstack/spec.

111 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/agents.mdx (via @objectstack/spec)
  • content/docs/ai/skills-reference.mdx (via @objectstack/spec)
  • content/docs/ai/skills.mdx (via @objectstack/spec)
  • content/docs/api/client-sdk.mdx (via @objectstack/spec)
  • content/docs/api/data-api.mdx (via @objectstack/service-analytics)
  • content/docs/api/environment-routing.mdx (via @objectstack/spec)
  • content/docs/api/error-catalog.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-client.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-server.mdx (via @objectstack/spec)
  • content/docs/api/index.mdx (via @objectstack/service-analytics, @objectstack/spec)
  • content/docs/automation/approvals.mdx (via @objectstack/spec)
  • content/docs/automation/connectors.mdx (via @objectstack/spec)
  • content/docs/automation/flows.mdx (via @objectstack/spec)
  • content/docs/automation/hook-bodies.mdx (via packages/spec)
  • content/docs/automation/hooks.mdx (via @objectstack/spec)
  • content/docs/automation/index.mdx (via @objectstack/spec)
  • content/docs/automation/webhooks.mdx (via @objectstack/spec)
  • content/docs/automation/workflows.mdx (via @objectstack/spec)
  • content/docs/concepts/architecture.mdx (via @objectstack/spec)
  • content/docs/concepts/design-principles.mdx (via packages/spec)
  • content/docs/concepts/index.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-driven.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-lifecycle.mdx (via @objectstack/objectql, packages/spec)
  • content/docs/concepts/north-star.mdx (via @objectstack/spec)
  • content/docs/data-modeling/analytics.mdx (via @objectstack/spec)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/driver-memory, @objectstack/driver-mongodb, @objectstack/spec)
  • content/docs/data-modeling/external-datasources.mdx (via @objectstack/spec)
  • content/docs/data-modeling/field-types.mdx (via @objectstack/spec)
  • content/docs/data-modeling/fields.mdx (via @objectstack/spec)
  • content/docs/data-modeling/formulas.mdx (via @objectstack/formula, packages/objectql, @objectstack/spec)
  • content/docs/data-modeling/index.mdx (via @objectstack/spec)
  • content/docs/data-modeling/objects.mdx (via @objectstack/spec)
  • content/docs/data-modeling/queries.mdx (via @objectstack/spec)
  • content/docs/data-modeling/schema-design.mdx (via @objectstack/spec)
  • content/docs/data-modeling/seed-data.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation-rules.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation.mdx (via @objectstack/formula, @objectstack/spec)
  • content/docs/deployment/cli.mdx (via @objectstack/spec)
  • content/docs/deployment/migration-from-objectql.mdx (via @objectstack/objectql)
  • content/docs/deployment/tenancy-modes.mdx (via @objectstack/spec)
  • content/docs/deployment/troubleshooting.mdx (via @objectstack/spec)
  • content/docs/deployment/validating-metadata.mdx (via @objectstack/spec)
  • content/docs/deployment/vercel.mdx (via @objectstack/driver-memory, @objectstack/objectql)
  • content/docs/getting-started/build-with-claude-code.mdx (via @objectstack/spec)
  • content/docs/getting-started/common-patterns.mdx (via @objectstack/spec)
  • content/docs/getting-started/examples.mdx (via @objectstack/spec)
  • content/docs/getting-started/glossary.mdx (via @objectstack/driver-memory, @objectstack/driver-mongodb)
  • content/docs/getting-started/quick-reference.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-start.mdx (via @objectstack/spec)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/spec)
  • content/docs/kernel/cluster.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/auth-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/cache-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/data-engine.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/index.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/metadata-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/storage-service.mdx (via @objectstack/spec)
  • content/docs/kernel/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/data-service.mdx (via @objectstack/spec)
  • content/docs/kernel/runtime-services/email-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/examples.mdx (via packages/objectql, @objectstack/spec)
  • content/docs/kernel/runtime-services/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/queue-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sharing-service.mdx (via @objectstack/spec)
  • content/docs/kernel/runtime-services/sms-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/storage-service.mdx (via @objectstack/spec)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/driver-memory, @objectstack/driver-mongodb, @objectstack/objectql, @objectstack/service-analytics, @objectstack/spec)
  • content/docs/kernel/services.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/permissions/authentication.mdx (via @objectstack/driver-memory, @objectstack/objectql)
  • content/docs/permissions/authorization.mdx (via @objectstack/spec)
  • content/docs/permissions/permission-sets.mdx (via @objectstack/spec)
  • content/docs/permissions/permissions-matrix.mdx (via @objectstack/spec)
  • content/docs/permissions/positions.mdx (via @objectstack/spec)
  • content/docs/permissions/rls.mdx (via @objectstack/spec)
  • content/docs/permissions/sharing-rules.mdx (via @objectstack/service-analytics, @objectstack/spec)
  • content/docs/permissions/system-context.mdx (via packages/objectql, packages/spec)
  • content/docs/plugins/adding-a-metadata-type.mdx (via @objectstack/spec)
  • content/docs/plugins/development.mdx (via @objectstack/spec)
  • content/docs/plugins/index.mdx (via @objectstack/driver-memory, @objectstack/objectql, @objectstack/spec)
  • content/docs/plugins/packages.mdx (via @objectstack/driver-memory, @objectstack/driver-mongodb, @objectstack/formula, @objectstack/objectql, @objectstack/service-analytics, @objectstack/spec)
  • content/docs/protocol/backward-compatibility.mdx (via @objectstack/spec)
  • content/docs/protocol/diagram.mdx (via packages/spec)
  • content/docs/protocol/kernel/config-resolution.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/http-protocol.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/i18n-standard.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/plugin-spec.mdx (via @objectstack/spec)
  • content/docs/protocol/knowledge.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/query-syntax.mdx (via @objectstack/driver-memory, @objectstack/driver-mongodb, packages/objectql, @objectstack/spec)
  • content/docs/protocol/objectql/schema.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/security.mdx (via packages/spec)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/protocol/objectui/actions.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/concept.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/layout-dsl.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/record-alert.mdx (via @objectstack/formula, @objectstack/spec)
  • content/docs/protocol/objectui/widget-contract.mdx (via @objectstack/spec)
  • content/docs/ui/actions.mdx (via @objectstack/spec)
  • content/docs/ui/apps.mdx (via @objectstack/spec)
  • content/docs/ui/create-vs-edit-form.mdx (via @objectstack/spec)
  • content/docs/ui/dashboards.mdx (via @objectstack/spec)
  • content/docs/ui/field-grouping-and-order.mdx (via @objectstack/spec)
  • content/docs/ui/forms.mdx (via @objectstack/spec)
  • content/docs/ui/index.mdx (via @objectstack/spec)
  • content/docs/ui/public-data-collection.mdx (via @objectstack/spec)
  • content/docs/ui/setup-app.mdx (via @objectstack/spec)
  • content/docs/ui/translations.mdx (via @objectstack/spec)
  • content/docs/ui/views.mdx (via @objectstack/spec)

⛔ 8 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx (via @objectstack/driver-memory, @objectstack/driver-mongodb, @objectstack/objectql, @objectstack/service-analytics, @objectstack/spec)
  • content/docs/releases/index.mdx (via @objectstack/spec)
  • content/docs/releases/v12.mdx (via @objectstack/spec)
  • content/docs/releases/v13.mdx (via @objectstack/spec)
  • content/docs/releases/v15.mdx (via @objectstack/formula)
  • content/docs/releases/v16.mdx (via @objectstack/formula, @objectstack/spec)
  • content/docs/releases/v17.mdx (via @objectstack/service-analytics, @objectstack/spec)
  • content/docs/releases/v9.mdx (via @objectstack/service-analytics, @objectstack/spec)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actions github-actions Bot added documentation Improvements or additions to documentation protocol:data tests tooling labels Aug 9, 2026
…ld exports (#6520)

`check:api-surface` is a SEPARATE snapshot from `check:export-origins`, and this
PR regenerated only the latter. Both track the public surface and both must be
regenerated when it grows: export-origins records WHICH DECLARATION each name
resolves to, api-surface records the NAME SET plus factory signatures.

The delta is exactly the three additive exports on `./data` —
`foldAsciiCase`, `asciiCaseInsensitiveContains`,
`asciiCaseInsensitiveRegexSource` — reported by the gate as
"0 breaking (removed/narrowed), 3 added".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PiRUoQkTSBBmpyXBY3cVn2
@os-zhuang
os-zhuang marked this pull request as ready for review August 9, 2026 18:51
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 9, 2026
Merged via the queue into main with commit 3f8817a Aug 9, 2026
28 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-6520-icontains-js-faces branch August 9, 2026 19:07
os-zhuang pushed a commit that referenced this pull request Aug 9, 2026
…main (#6966)

Two generated artifacts move with the merge:

- `export-origins/` is new on main (#4796) and had never seen `HookDispatch`.
- `api-surface/` needed a real regeneration, not just my own entry. The merge
  of main into this branch produced a file byte-identical to THIS branch's
  side, silently dropping the five exports #7123 added on main
  (`SEARCH_VIRTUAL_TYPES`, `foldAsciiCase`, `asciiCaseInsensitiveContains`,
  `asciiCaseInsensitiveRegexSource`, `isVirtualSearchField`). These paths carry
  `merge=os-regen` in .gitattributes precisely so a merge regenerates rather
  than picks a side; it did not here, and `check:api-surface` is what caught
  it. Regenerating yields the union — both main's five and this branch's
  `HookDispatch`.

Read the export-origins diff as its gate asks: one line, `HookDispatch` under
`src/data/hook.zod.ts`, the same origin as `HookContext`. Not a re-home, not a
new dual-source.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EcLMsZRoR3daV3QzPXgwxK
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Aug 17, 2026
…on the hook contract (objectstack-ai#6966) (objectstack-ai#7101)

* fix(spec,objectql,sharing,storage): state per-row vs record dispatch on the hook contract (objectstack-ai#6966)

A predicate (`multi: true`) write dispatches its lifecycle hooks once per
matched row — `after*` since objectstack-ai#5038, `before*` since objectstack-ai#5574 — on a context
deliberately indistinguishable from a single-id write's. That is the feature,
and it erased the only signal several handlers had: before objectstack-ai#5574 a bulk
`before*` fired once with `input.id` present-but-`undefined`, so "no id" meant
"this call stands for N rows". Every guard written on it silently inverted
rather than failing.

Adds `HookContext.dispatch` — `{ mode: 'record' | 'per-row', index, scope }` —
bound by the engine at every write dispatch site (insert, update, delete, both
phases), at the point the dispatch ladder is decided. Optional, and an absent
marker reads as "not per-row", so existing handlers keep their behaviour.
`scope` is one object shared by every dispatch of one write across both phases:
the seam handlers used to get by stashing on the context, which only ever
worked because a single-id write reuses one context across its pair.

Deliberately not the `isPredicateBulkWrite` discriminator objectstack-ai#5574 retired under
ADR-0049: that one inferred "bulk" at the consumer from `input.id` and
`options.multi` and ended with no producer and no reachable consumer. This one
is engine-produced and has readers.

Behaviour fixed:

- plugin-sharing — the `before*` stash of a write's affected row set was landing
  on a per-row context the `after*` phase never saw, so every bulk update or
  delete on a ruled object revoked all of that object's rule grants and queued a
  full asynchronous re-grant, once per matched row, with the repeats racing each
  other. Access was never widened; a bounded write now takes the bounded path
  again, the cap still applies to the union, and the `after*` work runs once per
  write instead of N times (the bounded branch was quadratic in batch size).
- service-storage — the `beforeDelete` id pre-resolution was dead on every path
  and `afterDelete` was doing one `sys_file` lookup per row where the batch fits
  one `$in`. The pre-resolution query is gone entirely: the engine has already
  matched the rows. `beforeUpdate` copy-on-claim no longer runs per row against
  a batch-scoped payload, removing a row-conditioned rewrite of a shared SET
  clause (out of contract under ADR-0058 Addendum II D3).

Stale comments in both packages asserting that predicate writes never populate
`input.id`, and that the engine reuses one `HookContext` across a write's
before/after pair, are corrected where they sit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EcLMsZRoR3daV3QzPXgwxK

* docs(service-storage): name the follow-up issue at the afterUpdate ownership note (objectstack-ai#6966)

The comment said the bulk-update file-ownership hole was "filed separately";
it is objectstack-ai#7102. A pointer a reader can follow beats a promise they cannot check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EcLMsZRoR3daV3QzPXgwxK

* test(objectql): pin the marker as an invariant, not just path by path (objectstack-ai#6966)

`HookContext.dispatch`'s JSDoc claims every dispatched context carries it —
which is what makes `ctx.dispatch?.mode` safe to read with no "what if the
engine did not bind it" branch. The per-path cases prove the four write paths;
this proves the claim itself, and covers the one context a reader might worry
about: update()/delete() keep a batch-scoped `hookContext` on the predicate
path, and no handler ever sees it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EcLMsZRoR3daV3QzPXgwxK

* docs(spec): regenerate the hook reference page for HookContext.dispatch (objectstack-ai#6966)

`content/docs/references/data/hook.mdx` is generated from the Zod schema, so
the new key has to land there too — `check:docs` is what caught it. The row
renders all three members inline (three keys, under the renderer's four-key
limit), so it carries no `…` elision and none of the `any` trap the `roles`
tombstone note warns about.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EcLMsZRoR3daV3QzPXgwxK

* fix(plugin-sharing): an empty per-row union reads as unbounded, not as "no rows" (objectstack-ai#6966)

The accumulator only exists because a `before*` dispatch created it, so an
empty one means every id it was handed was null — "we do not know", not
"nothing changed". Reading it as an empty row set would silently skip the
cleanup entirely, which is the direction objectstack-ai#4757 was filed for and the rule this
module states for its resolve path.

Adds direct unit cover for the accumulator: the union across rows without
re-querying the predicate, dedup across the two subscribers that both stash on
every row, the empty-union verdict, and the cap applied to the union.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EcLMsZRoR3daV3QzPXgwxK

* chore(spec): record HookDispatch in the api-surface snapshot (objectstack-ai#6966)

`HookDispatch` is a new public export, so the surface snapshot moves with it —
0 breaking, 1 added. Caught by CI's `check:api-surface`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EcLMsZRoR3daV3QzPXgwxK

* chore(spec): regenerate api-surface and export-origins after merging main (objectstack-ai#6966)

Two generated artifacts move with the merge:

- `export-origins/` is new on main (objectstack-ai#4796) and had never seen `HookDispatch`.
- `api-surface/` needed a real regeneration, not just my own entry. The merge
  of main into this branch produced a file byte-identical to THIS branch's
  side, silently dropping the five exports objectstack-ai#7123 added on main
  (`SEARCH_VIRTUAL_TYPES`, `foldAsciiCase`, `asciiCaseInsensitiveContains`,
  `asciiCaseInsensitiveRegexSource`, `isVirtualSearchField`). These paths carry
  `merge=os-regen` in .gitattributes precisely so a merge regenerates rather
  than picks a side; it did not here, and `check:api-surface` is what caught
  it. Regenerating yields the union — both main's five and this branch's
  `HookDispatch`.

Read the export-origins diff as its gate asks: one line, `HookDispatch` under
`src/data/hook.zod.ts`, the same origin as `HookContext`. Not a re-home, not a
new dual-source.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EcLMsZRoR3daV3QzPXgwxK

---------

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

2 participants