Repository navigation
fix(spec)!: refuse an array in the equality slot at the schema door, in the comparand face's own words (#19889) - #20047
Conversation
… the face's words FilterConditionSchema (implicit form and $eq) and FieldOperatorsSchema.$eq refuse an ARRAY comparand at parse. The sentence is assembled once, in an internal module both doors import (filter-comparand-refusal-text.ts, kept out of the data barrel), so the save door and the comparand-shape face print the same words; the face alone appends its `at <path>` location. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019c3Hi6ZMU1p6m6aA6Bz45d
…lot; register it The pins: both spellings refused at FilterConditionSchema and at the $eq operator slot; the schema door's message equals the face's with only the face's location clause removed; scalars, null, Date, $field, the list operators, $ne and nested-relation specs accepted by BOTH doors; and the dataset, dashboard-widget and report carriers refusing at their own paths. The face test's array-valued operator set loses $eq. A new ADR-0087 semantic entry registers the save-time refusal. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019c3Hi6ZMU1p6m6aA6Bz45d
… registry filter-equality-array-comparand-refused said the analytics normalizer's OBJECT form "still reads as membership" because it "does not route through the shared face". Since the analytics where door routes that form through the face, the sentence is false; it now says which doors refuse the shape at this release and with what, the save-time door included. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019c3Hi6ZMU1p6m6aA6Bz45d
…adds The $eq refusal is a custom check z.toJSONSchema() cannot project, so the published FieldOperators, EqualityOperator and NormalizedFilter schemas stay wider than the runtime at $eq. Declared, as the ledger requires. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019c3Hi6ZMU1p6m6aA6Bz45d
…s it made false The 19757 changeset said the schema doors still parse the shape and the document still publishes; the 19888 changeset said the authoring schema still admits it. Both are false once the schema door refuses it on save, and both ship in the same release, so each now points at the save-time change and keeps only what stays true. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019c3Hi6ZMU1p6m6aA6Bz45d
📓 Docs Drift CheckThis PR changes 1 package(s): 3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin ca1594ded0855f91029cc0b91b02fdee5ede841f && git checkout ca1594ded0855f91029cc0b91b02fdee5ede841f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin adbbc5d01e76ac7ed561dec1c95592320acb8565 46e1c81727aee80f722f4b4554b01250211bbbfb && git checkout -B drift-repro adbbc5d01e76ac7ed561dec1c95592320acb8565 && git merge --no-ff 46e1c81727aee80f722f4b4554b01250211bbbfb
node scripts/docs-audit/affected-docs.mjs --json adbbc5d01e76ac7ed561dec1c95592320acb8565
|
Contract reviewServed-tier: 164/164 Isolated at-tier reviewer subagent, run by the ① Derived judgmentsInputs read: card #19889 body and all 5 comments (ruling (a) Accept set, ruling A items 1–3. (b) Item 4, message equality. The face's text is byte-identical to (c) Carriers. (d) ADR-0087. New entry (e) Changesets. New note: (f) (g) Pins. 46 tests (9+3+11+19+4) matches 46/46. Leg arithmetic against the file: A (implicit arm off) = §1 5 implicit rows + the two-slot test + §3 5 implicit rows + the remedy test + §5 ② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
Fixes #19889
Clause-②: no
This carries out ruling 5805248669 (letter A): the schema door refuses an array in the equality slot, with the compile face's own words.
Clause-②: nois the claim's line, copied as it stands. The changeset carriesClause-②: no (narrowing)because AGENTS.md makes a narrowing BREAKING andcheck:adr-0087-registrationreads the arm there. Both lines give the same value.Session
session_019c3Hi6ZMU1p6m6aA6Bz45d, branchclaude/issue-19889-filter-schema-door-array-equality, basea0920b42dc. Every reading below was taken at head46e1c81727unless it says otherwise.1. Reproduction (before the change, on
origin/maina0920b42dc)DatasetSchema.safeParse, controlfilter: { stage: 'won' }success: trueDatasetSchema.safeParse,filter: { stage: ['won','lost'] }success: trueDatasetSchema.safeParse, measurefilter: { stage: { $eq: ['won','lost'] } }success: trueFilterConditionSchema.safeParseon both shapes;FieldOperatorsSchema.safeParse({ $eq: [...] })success: true(all three)assertListComparandShapes({ stage: ['won','lost'] })(the compile face)INVALID_FILTER/ 400assertListComparandShapes({ stage: { $eq: [...] } })INVALID_FILTER/ 400The face's refusal text read, verbatim:
The implicit-equality comparand on field "stage" requires a single comparable value, but received an array (["won","lost"]) at where.stage. For "one of these values" use {"$in": […]} (authoring: in); for "the stored list holds a value" on a multi-value field, {"$contains": "…"} (authoring: contains), an $or of those for any-of. The filter was NOT applied, and an unapplied filter would have returned the UNFILTERED result set.mainthe face built the text inline inarrayEqualityComparandError. This PR extracts it rather than copying it (§2).2. The change
packages/spec/src/data/filter-comparand-refusal-text.ts(new, internal). It holds the ONE remedy constantARRAY_EQUALITY_COMPARAND_REMEDYand the one message builderarrayEqualityComparandMessage, plusshapePreview, the$containsoperator and theinspelling row. Those three moved out of the face so both doors render the value and the prescription from one source.filter.zod.ts, because of the import cycle the face already documents.databarrel, following thecurrency-fraction-digits.tsprecedent. Exporting the text would widen@objectstack/spec/datafor no reader, which aClause-②: nocard must not do.check:api-surfacestays green.filter-comparand-shape.ts) now calls the builder. Its message is byte-for-byte what it was: every existing face pin, and the analytics / mongodb / memory suites, pass unchanged.FilterConditionSchemarefuses both arms inside its existing refinement walk:{ field: [...] }at the issue pathfield;{ field: { $eq: [...] } }atfield.$eq;$and/$or/$notmembers through their own re-parse.FieldOperatorsSchema.$eqrefuses an array comparand. So does its documentation copyEqualityOperatorSchema.$eq, through one sharedequalityComparandSchemafactory, following the file's own pairing rule (orderingComparandSchema).NormalizedFilterSchemavalidates against it and refuses too.$key (a nested-relation or deep-equality condition) is not judged at this door, because the face never descends one. A pin holds both doors to the same answer on that shape (§4, control rows).CONTROL — the same documents with $in publish, and keep their filterproves that accepted means kept."The parse-door message equals the compile-face message": what equality holds
at where.FIELDclause. Its issue carries the location as itspathinstead (filter.stage,measures.0.filter.stage.$eq).Pin §3 asserts character equality after removing exactly that one clause, and asserts the clause is present exactly once, so the removal is not vacuous. That holds on 8 shapes: implicit and
$eq, empty, and under$and/$or/$not.Printing
at where.stageat save time would name a location that does not exist in the saved document, and the wrong one for a combinator member. The$eqoperator slot cannot see its field either (the key belongs to the enclosing record). Its pin asserts equality after removing both theon field "stage"clause and the location clause. If the seat reads ruling item 4 as strict byte-equality, the alternative is to print a fixedwhere.location; I recommend against it for the reason above.3. Carrier census (ruling item 3)
Schemas that embed
FilterConditionSchemadirectly. Each one refuses now; the right-hand column is measured at head:DatasetSchema.filter,DatasetMeasureSchema.filterDashboardWidgetSchema.filter,GlobalFilterOptionsFromSchema.filterReportSchema.runtimeFilter,JoinedReportBlockSchema.runtimeFilterFieldSchema.relatedListFilter,FieldSchema.summaryOperations.filterFilterConditionSchemacarrierBlueprintSummaryOperationsSchema.filterAnalyticsQuerySchema.where, and through itAnalyticsQueryRequestSchema.whereDatasetSelectionSchema.runtimeFilterQuerySchema.where/.having,AggregationNodeSchema.filter,QueryFilterSchema.wherewhere/havingmeasured)EngineAggregateOptionsSchema.havingEngine{Query,Update,Delete,Aggregate,Count}OptionsSchema.where,DataEngineVectorFindRequestSchema.whereSchemas that embed
FieldOperatorsSchema:NormalizedFilterSchema(through its field-condition record).EqualityOperatorSchemais the documentation copy.Where the refusal is met. Every one of these was measured at head:
defineStack(strict)STACK_SCHEMA_INVALID,datasets.0.filter.stage: …os validateObjectStackDefinitionSchema, read at sourcesaveMetaItem, datasetINVALID_METADATA/ 422, issue pathfilter.status; the$incontrol saved. This was a throwaway probe on theprotocol.dashboard-dataset-publish-gateharness and was deleted, tree clean.datasetSelectionRefusalVALIDATION_FAILED/ 400,selection.runtimeFilter.stage:followed by the sentence; previously the analytics compiler'sINVALID_FILTER/ 400handleAnalyticsRequest)VALIDATION_FAILED,where.stage: …; the bridge maps it to 400, pinned indispatcher-validation-error.real.test.tsThe read path does not re-validate stored rows, so a stored document keeps loading and its next save is refused. ⛔ Nothing is rewritten or dropped.
Stored and shipped instances carrying the shape: 0.
filter/where/runtimeFilter/having/relatedListFilterliteral inpackages/**,examples/**,apps/**,content/docs/**andskills/**read 7785 files and 4709 carrier literals. It found 20 field entries whose value opens an array. 16 are test fixtures; they are its positive control, including the mongodb refusal pin rows. The other 4 are notFilterConditioncarriers:client/realtime-api.tseventTypesandcore/PHASE2_IMPLEMENTATION.mdplugin-permissionfilter. There is no YAML-authored carrier block.$eqgrep. A grep for$eqfollowed by an array found 24 lines: prose, MongoDB aggregation$eqexpressions, and onedoor-refusalconformance row.app-crm,app-todoandapp-multi-packageload through strictdefineStack/composeStacks: 0 hits.app-showcase's config imports connector plugins with nodist/here. Its barrels were parsed with the registered type schemas instead: 22 objects, 4 datasets, 3 dashboards, 4 reports. That gave 0 hits and 0 other failures.{ stage: ['won','lost'] }dataset fired once atfilter.stage.4. Pins, each proven able to fail
filter-equality-array-schema-door.test.tshas 46 tests: §1 FilterConditionSchema refusals, §2 operator slot, §3 same words, §4 controls at both doors, §5 carriers. Each ablation usedscripts/ablation-replace.mjs: the anchor hit exactly once, and each leg printed its blob change. After every leg, restore was proven by blob == HEADbe90963c90and an emptygit diff HEAD. The spec tests import fromsrc, so nodist/leg was needed.DatasetSchema filter.stage, widget)$eqarm off$eqrefusals, same-words rows,measures.0.filter.stage.$eq, report)FieldOperatorsSchema,EqualityOperatorSchema,NormalizedFilter, slot same-words)nullgit statusclean at46e1c81727Re-judged, not dropped:
filter-comparand-shape.test.tspinnedFieldOperatorsSchema's array-accepting set as['$between','$eq','$in','$ne','$nin'], "$eq… only because both arez.any()".$eqhas left that set, so the expectation now reads['$between','$in','$ne','$nin']and the comment says why.5. ADR-0087
filter-equality-array-comparand-refused-at-save. The changeset marker isregistered filter-equality-array-comparand-refused-at-save. The gate read it back as[BREAKING+bang+clause-②-narrowing] registered … (new here …). Every factual claim in it was measured at head (§3).filter-equality-array-comparand-refused. At:46-49it said the analytics normalizer's OBJECT form "still reads as membership" because it "does not route through the shared face". That has been false since fix(service-analytics)!: the analyticswheredoor refuses a list in the equality slot instead of reading it asIN(#19888) #20008. It now states which doors refuse the shape at this release, and with what:parseFilterAST, the engine seam):INVALID_FILTER/ 400;wheredoor, in both spellings: the same, and that door alone also refuses a nested-relation list;The object form's four old readings are named, and its acceptance criteria point to the new sibling entry.
gen:migration-registryregeneratedregistry.ts, andcheck:migration-registryis green.gen:upgrade-guideandgen:spec-changeswere run and wrote zero diff. Both project steps up toPROTOCOL_MAJOR(17,PROTOCOL_VERSION17.0.0), so no step-18 entry appears in them yet, this one included.check:upgrade-guideandcheck:spec-changesare green.6. Changesets, and two DELIBERATE CORRECTIONS for the seat to confirm
.changeset/19889-filter-schema-door-array-equality.md. It declares@objectstack/spec: minor, the BREAKING banner,fix(spec)!:, a FROM → TO table and the measured census. The grade follows AGENTS.md: a(narrowing)is BREAKING, and the launch-window convention ships itminor.check-changeset-no-majoris green..changeset/19757-equality-slot-array-refused.md(pending). Its bullet said: "The schema doors are not touched.FilterConditionSchemastill parses{ field: [...] }… A document carrying the shape therefore still publishes". That is false in the release both ship in. It now says this change did not touch the schema doors, and that a separate change in the same release does..changeset/19888-analytics-implicit-array.md(pending, services lane). Its sentence said: "The authoring schema still admits the shape, so such a document still publishes, and it is refused when it is charted". That is false for the same reason. It now says the schema refuses the shape on save, except for a list inside a nested relation, which only the analytics door judges.check-empty-changesetis therefore red by design ("DELIBERATE CORRECTION … say so on the PR … get it confirmed"). Neither file is restored from base, because that would republish a false sentence.7. Tests (at
46e1c81727)@objectstack/spec(full)@objectstack/spectypecheck (tsc, scripts, test layer)check:test-typecheckOK@objectstack/service-analytics@objectstack/objectql@objectstack/rest@objectstack/metadata-protocol@objectstack/runtime@objectstack/lint@objectstack/cli, unit layerNotes on the table:
cli. The first run's twopublished-subpath-*pins refused on their prerequisite, "packages/cli is not built". Aftercliwas built they ran: 2 files, 29 passed. The integration layer is declared to CI, since the diff touches no spawn entry or integration file..tsfiles were linted witheslint --no-inline-config --format json: 8 files counted from the JSON, 0 errors / 0 warnings, andisPathIgnoredis false for each. The config enables no type-aware linting (noparserOptions.project, per its own comment ateslint.config.mjs:328), so this diff cannot move a verdict on an untouched file. The repo-widepnpm lintbelongs to CI.8. Gates (at
46e1c81727)dispatch-gates --commands --repo objectstack-ai/objectstackderived 87 commands. The--ranreconciliation, with an exit code per line, reads: 87 derived, 85 run, 2 NOT-MEASURED, 0 UNRUN.check:api-surface,check:authorable-surface,check:docs,check:migration-registry,check:spec-changes,check:upgrade-guide,check:doc-authoring("16302 customer-facing string(s) … clean"; sibling prose ids "no growth"),check:nul-bytes("scanned 9435 … no raw ASCII control bytes"),check:engine-double-contractandcheck:cross-package-test-inputs.check-empty-changeset. This is the deliberate-correction refusal in §6.check:dual-build-cjs-loadsandcheck:type-check-debt. Reason: both exit 3,PREREQUISITE NOT MET, because they read the whole workspace'sdist/. PR fix(spec)!: refuse an array in the equality slot at the shared comparand-shape face #19882 recorded the same two.@objectstack/spec check:generatedreports all 15 artifacts current after the build.Acceptance notes
{ account: { region: ['a'] } }, still passesDatasetSchema. Since fix(service-analytics)!: the analyticswheredoor refuses a list in the equality slot instead of reading it asIN(#19888) #20008 the analyticswheredoor refuses it (INVALID_FILTER/ 400, measured). Refusing it at the shared schema door would refuse a deep-equality comparand the shared face and the engine accept. That is a different decision, and it is left to the seat.VALIDATION_FAILED/ 400 at the request door (REST dataset selection, runtime analytics body) instead ofINVALID_FILTER/ 400 at the compiler. Both are class-1 400s carrying the same sentence. No test pinned the old code for this input: therest,runtimeandservice-analyticssuites are green.z.toJSONSchema()has no custom-check projection). Three sites are declared indropped-refinements.baseline.json:data/FieldOperators$eq,data/EqualityOperator$eqanddata/NormalizedFilter…valueType.$eq, and itsmeasuredcounts moved 206→207 schemas and 571→574 sites.$necarrying a list is untouched. [finding]$newith an array comparand splits across backends: driver-sql and driver-memory refuse (400), driver-mongodb answers, formula matches every row — and both shared faces pass it #19886 and the face'sit.todocarry it. [finding]$newith an array comparand splits across backends: driver-sql and driver-memory refuse (400), driver-mongodb answers, formula matches every row — and both shared faces pass it #19886 remains open.Generated by Claude Code