Skip to content

feat(spec)!: refuse a text operator over a field whose declared type can never store a string — the contract rows for the engine door (#15661) - #15804

Merged
os-project-manager merged 4 commits into
mainfrom
claude/issue-15661-text-operator-declared-type-door-rows
Sep 5, 2026
Merged

feat(spec)!: refuse a text operator over a field whose declared type can never store a string — the contract rows for the engine door (#15661)#15804
os-project-manager merged 4 commits into
mainfrom
claude/issue-15661-text-operator-declared-type-door-rows

Conversation

@claude

@claude claude Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Fixes #15661

Lane (1) of the maintainer ruling recorded on #15661 (director seat, decision batch #43, verbatim reply 「同意」): C-deny — the spec contract half only. The door itself (lane 2, packages/objectql/src/engine.ts beside INVALID_FIELD) is #15773, which is blocked by this card and consumes the table landed here. Sequencing satisfied: #14079's row landed in PR #15686 (a646120dc).

What lands

packages/spec/src/data/filter-text-operator-declared-type.ts (+ its test, + the barrel line), exported on the published @objectstack/spec/data face:

  • TEXT_FILTER_OPERATORS — the ruling's seven, pinned equal to StringOperatorSchema's key set (a text operator declared later fails the pin loudly).
  • TEXT_OPERATOR_DOOR_REFUSED_TYPES — the union of the six EXISTING sets (NUMERIC_VALUE_TYPESBOOLEAN_VALUE_TYPESCALENDAR_DATE_TYPESINSTANT_TYPESCLOCK_TIME_TYPESSTRUCTURED_JSON_TYPES), spelled by reference; TEXT_OPERATOR_DOOR_PASSING_TYPES — the string-valued classes. ⛔ No new set: the class table references the exports by identity (pinned with toBe).
  • textOperatorDoorVerdict(field) — the pure verdict (door-refusal / passes / deferred), and FORMULA_RETURN_TYPE_AS_FIELD_TYPE.
  • TEXT_OPERATOR_DOOR_TYPE_CLASSES — the verdict matrix, pinned as a census of FieldType.
  • TEXT_OPERATOR_DOOR_FIXTURE (+ _FIELDS, _OBJECT) — a FieldSchema / ObjectSchema-legal probe object with one field per FieldType member, four typed formula fields and one untyped.
  • TEXT_OPERATOR_DOOR_CASES — the derived case table the engine suite consumes (door-refusal rows carry code: 'INVALID_FILTER', status: 400, mustMention: [key, declared type (+ return type), operator]; passes / deferred rows assert the filter reaches the driver unchanged).

Changeset: @objectstack/spec minor with the BREAKING banner (feat(spec)!:), ADR-0087 disposition not-required (no-migration-prescription); ⛔ no entry in migrations/registry.ts (held by PR #15626). Generated followers regenerated by the repo tooling, never hand-edited: api-surface/data.json (+20 entries) and export-origins/data.json (+20), zero removals.

H2 — a sibling module, not an extension of the comparand table (and not a driver case-set)

The comparand table's door-refusal rows assert parseFilterAST(filter) throws — a SYNTAX door with no field map. The ruled door is FIELD-aware, so parseFilterAST cannot host it and the rows live in a sibling keyed on the declared type. The five driver comparand suites, engine-comparand-type-door.test.ts and service-analytics/src/comparand-shape.ts are untouched (readings below).

The sibling is deliberately not named *-conformance.ts and is not registered in scripts/check-driver-conformance.mjs's CASE_SETS (so check:driver-conformance does not read it): the census enrols DRIVERS, and drivers sit BENEATH this door — the ruling keeps #14079's row for every evaluator no door fronts, so a driver "covering" this table would assert the opposite of the ruling. Its one consumer is the engine door (#15773); the file is named for the door it declares, like filter-comparand-type.ts and filter-dotted-head.ts. Every case is pinned to pass the syntax door (parseFilterAST accepts each), so a refusal can only be the field-aware door's.

H1 — the census (measured at the enum, FieldType.options.length === 49)

Every FieldType member appears in exactly one class row; the test fails on a member absent, judged twice, or a ghost (pinned in both directions):

class (existing export, by reference) members n verdict
STRING_VALUE_TYPES text · textarea · email · url · phone · password · secret · markdown · html · richtext · code · color · signature · qrcode 14 passes
autonumber (named by the ruling) autonumber 1 passes
SINGLE_OPTION_TYPES select · radio 2 passes
MULTI_OPTION_TYPES multiselect · checkboxes · tags 3 passes
REFERENCE_VALUE_TYPES lookup · master_detail · user · tree 4 passes
FILE_REFERENCE_TYPES image · file · avatar · video · audio 5 passes (derived — see below)
NUMERIC_VALUE_TYPES number · currency · percent · rating · slider · progress · summary 7 door-refusal
BOOLEAN_VALUE_TYPES boolean · toggle 2 door-refusal
CALENDAR_DATE_TYPES date 1 door-refusal
INSTANT_TYPES datetime 1 door-refusal
CLOCK_TIME_TYPES time 1 door-refusal
STRUCTURED_JSON_TYPES json · composite · repeater · record · location · address · vector 7 door-refusal
formula formula 1 by return type (H3)

Refused = 19 types, passing = 29 types, formula = by return type. Refused ∩ passing = ∅; refused ∪ passing ∪ {formula} = FieldType (pinned). NON_TEXT_STORED_VALUE_TYPES (#14079, the SQL faces' compile-time gate) ⊂ refused, and refused ∖ it = exactly the temporal + JSON classes (pinned, H4).

One derived row, not ruled by name: FILE_REFERENCE_TYPESpasses. The ruling's criterion is "a DECLARED type that can never store a string"; a file field stores an opaque id / url string (or a legacy inline object) today and a sys_file id string after ADR-0104 D3 — never "never a string", so it is outside the refused criterion. Recorded on the class row as derived; re-judge only if D3 lands a non-string stored form. MULTI_OPTION_TYPESpasses follows the ruling's own protection of tags (C-allow refused for breaking it); what a text operator answers over the ARRAY beneath the door is the evaluators' question, not this table's.

H3 — formula

FieldSchema.returnType is z.enum(['number','text','boolean','date']).optional() (re-located by symbol). Every value is itself a FieldType member, so a formula with a readable return type is judged as the field type its return type namestext passes; number / boolean / date are refused through the same sets (no second vocabulary). returnType absent, or a spelling the schema does not declare, ⇒ deferred (the ruling judges formula only when the type is readable at the seam). Pinned: the map's keys equal the enum's options; each verdict; summary is refused because NUMERIC_VALUE_TYPES.has('summary') (verified at the set, with COMPUTED_VALUE_TYPES.has('summary') also true — its computed nature is not the axis); autonumber passes.

H5 — operators × classes (the verdict matrix)

All seven operators ($contains $notContains $startsWith $endsWith $icontains $like $ilike) get the same verdict per class — the ruling refuses by declared type whatever the polarity; beneath the door $notContains keeps #14079's complement. TEXT_FILTER_OPERATORS equals Object.keys(StringOperatorSchema.shape) (pinned as an equality, both directions).

Derived cases: 53 fixture fields × 7 operators + 7 structured-JSON fields × 7 operators as DOTTED keys = 420 cases: 154 door-refusal (19 refused types + 3 refused formulas = 22 fields), 210 passes (29 + formula returning text = 30 fields), 56 deferred (f_formula_untyped + 7 dotted paths). The dotted rows exist so the engine door does not re-close filter-dotted-head's deliberate structured-JSON carve-out (#8371, live on two of three backends) by reading the head's declared type.

H4 — #14079's row stays beneath the door

filter-text-conformance.ts and field-value.zod.ts are not edited. The module's docblock states the relationship: the door refuses at the engine seam by DECLARED type (six classes); beneath it every evaluator no door fronts keeps answering the stored-value row; the SQL faces' NON_TEXT_STORED_VALUE_TYPES is narrower on purpose — temporal / JSON columns are refused at the door but answer by stored representation beneath it (#15683 is the temporal finding).

ADR-0087 census (examples/** and content/docs/**)

git grep for a text operator ($contains|$notContains|$startsWith|$endsWith|$icontains|$like|$ilike) applied to a field key: 26 occurrences, over name (13), title (5), email (4), domain (1, the docs' $endsWith: '.com' example) — all textual by declaration; zero over a non-textual declared field. Positive control: the same grep finds the 5 name / email $contains occurrences. Nothing is renamed or retired; an author's remedy is to drop or retarget the mistaken condition, which is why the disposition is no-migration-prescription (gate: check-adr-0087-registration green, [BREAKING+bang] not-required (no-migration-prescription)).

Verification (sha-cited)

Head 8742baf9f (branch = base 7dafaaedd + my commits + a merge of origin/main up to 6a3cc134c, which moved packages/spec/src/api/error-code-ledger.zod.ts, so spec was rebuilt AFTER the merge). Every reading below is on that head; exit codes captured before any pipe (redirect first); verdict lines quoted from the tools. Heavy runs went through scripts/pm/os-verify-lock.sh (shared-box seconds, not idle-box figures).

Build: pnpm --filter @objectstack/spec buildcheck-dts-emitted: @objectstack/spec - 34/34 declared declaration file(s) present. Consumer closure turbo run build --filter='PKG^...' for each of the 7 consumers --concurrency=2Tasks: 15 successful, 15 total (VERDICT command-exit 0, held 278s).

Spec (pnpm --filter @objectstack/spec …):

  • exec vitest run --maxWorkers=2 src/data/filter-text-operator-declared-type.test.tsTest Files 1 passed (1) / Tests 23 passed (23).
  • exec vitest run --maxWorkers=2 (whole package) — Test Files 474 passed (474) / Tests 12734 passed (12734) (VERDICT command-exit 0).
  • typecheck (tsc --noEmit && check:scripts-typecheck && check:test-typecheck) — exit 0; check:test-typecheck: OK — @objectstack/spec's test layer compiles under packages/spec/tsconfig.test.json; the new test file IS in that program (tsc -p tsconfig.test.json --listFiles: 1 hit), so "typecheck green" covers it — the build config's **/*.test.ts exclusion does not hide it.
  • check:generated (after the regeneration commit) — green in the gate union below; the regeneration itself: --fix: regenerating 2 of the 2 stale artifact(s)api-surface/ and export-origins/ only, +20 / +20, zero removals; check:authorable-surface green, authorable-surface.base.json untouched.

Consumer readings (filter direction: ...@objectstack/spec = DOWNSTREAM consumers; closure built first), all exit=0 at 8742baf9f, one lock hold (66s):

package suite result
@objectstack/driver-memory memory-comparand-type-conformance.test.ts 20 passed (20)
@objectstack/driver-sql sql-driver-comparand-type-conformance.test.ts 43 passed, 2 skipped (45) — the skips are the unprovisioned live-dialect cells
@objectstack/driver-sqlite-wasm sqlite-wasm-comparand-type-conformance.test.ts 19 passed (19)
@objectstack/driver-turso turso-comparand-type-conformance.test.ts 19 passed (19)
@objectstack/driver-mongodb mongodb-comparand-type-conformance.test.ts 19 passed (19)
@objectstack/objectql engine-comparand-type-door.test.ts 12 passed (12)
@objectstack/service-analytics __tests__/comparand-door-single-source.test.ts 39 passed (39)

The new table has no consumer yet (by design — #15773 is its consumer); the built entries do export it: from packages/objectql, CJS require('@objectstack/spec/data') and ESM import both answer textOperatorDoorVerdict: function, TEXT_OPERATOR_DOOR_CASES.length === 420, refused 19 / passing 29, verdict({type:'number'}) === 'door-refusal', verdict({type:'formula', returnType:'text'}) === 'passes'.

Gatesnode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack with NO paths (derived from git off the merge base, at 8742baf9f; the first derivation before the merge warned STALE TREE, so it was re-derived after): 74 commands, 72 green, 2 NOT MEASURED. Beyond the dispatch lead the derivation added (all green): check:generated, check:nul-bytes, check:type-check-coverage, check:driver-memory-census, check:engine-double-contract, check:objectui-changeset, check:objectui-pin-citations, check:pm-half-states, check:query-options-erasure, check:refd-timer-probe, check:watch-hint-literal, check:dispatcher-error-vocabulary, check-changeset-no-major, check-empty-changeset, check-closing-keyword-parity, check-comment-mask-corpus, release-rehearsal-clone --self-test; nothing in the lead was dropped. ADR-0087: ✓ check-adr-0087-registration: 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition … [BREAKING+bang] not-required (no-migration-prescription).

The two NOT MEASURED, both exit 3 and both printing PREREQUISITE NOT MET … ⛔ This is NOT a pass: nothing was measured (54 packages have no dist/ in this worktree; neither script takes a scope flag): check:dual-build-cjs-loads and check:type-check-debt (--re-measure). Declared narrowing: the spec-scoped half of the first is the CJS/ESM load above; the second re-measures ledgered packages my diff does not touch. Both are repo-level runs Lint & Repo Gates owns in CI.

check:driver-conformance is not in the derivation and does not read this table (see H2).

Ablation

Committed first (HEAD 8742baf9f), then a trap-guarded script (trap restore EXIT INT TERM, absolute paths from git rev-parse --show-toplevel) mutated TEXT_OPERATOR_DOOR_REFUSED_TYPES to an EMPTY set (the six spreads replaced by an ABLATION-15661 marker) and ran the WHOLE spec package under the lock:

  • Mutation proven on disk by anchored counts, not by the editor's exit code: ...NUMERIC_VALUE_TYPES, spread lines 1 → 0, marker 0 → 1; blob d9af49037e48…db284cca7751….
  • Expected direction: red, confined to the new test file — no consumer exists yet, and the class table (the census) is untouched by this mutation, so the census pin was expected to stay green while the set/verdict pins go red. Observed: Test Files 1 failed | 472 passed | 1 skipped (474), Tests 6 failed | 12726 passed | 1 skipped; the one failing file is src/data/filter-text-operator-declared-type.test.ts; the six reds are exactly: the refused-set equality pin, the disjoint/union-is-FieldType pin, the wider-than-NON_TEXT_STORED_VALUE_TYPES pin (H4), both H3 verdict pins (summary and the typed formulas fell to passes), and the three-verdict count pin. (The 1 skipped is an environment-conditional test elsewhere in the package; the clean run reports 474 passed / 12734 with no skip.)
  • No build leg: the spec tests import the module by relative path (source), so no dist/ could carry the mutation; dist/ was not rebuilt during the ablation and still holds the clean build (the CJS/ESM load check above ran against it).
  • Restore: git checkout HEAD -- FILE (HEAD named, never bare); proven by git hash-object = HEAD blob (d9af49037e487e4302783700b2f4317fd93379d7 both sides), git diff HEAD = 0 lines, porcelain = 0 lines, marker count 0.

Out of scope


Generated by Claude Code

… fixture, derived cases (#15661)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M59rPZZFzqhfMUPFqqZTkf
…banner and ADR-0087 disposition (#15661)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M59rPZZFzqhfMUPFqqZTkf
…ed-type door exports (#15661)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01M59rPZZFzqhfMUPFqqZTkf
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 43 documentable anchor(s). ⚠️ 3 changed file(s) yielded no anchor (packages/spec/api-surface/data.json, packages/spec/export-origins/data.json, packages/spec/src/data/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

11 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/data-api.mdx (via INVALID_FILTER (literal, a string literal in TextOperatorDoorRefusalCase; a string literal in caseFor))
  • content/docs/api/error-catalog.mdx (via INVALID_FILTER (literal, a string literal in TextOperatorDoorRefusalCase; a string literal in caseFor))
  • content/docs/data-modeling/field-types.mdx (via returnType (symbol, a field of const object TEXT_OPERATOR_DOOR_FIXTURE_FIELDS; a field of interface TextOperatorDoorCaseBase; a field of interface TextOperatorDoorFieldMeta; a field of interface TextOperatorDoorFixtureField), summaryOperations (symbol, a field of interface TextOperatorDoorFixtureField))
  • content/docs/data-modeling/fields.mdx (via summaryOperations (symbol, a field of interface TextOperatorDoorFixtureField))
  • content/docs/data-modeling/formulas.mdx (via returnType (symbol, a field of const object TEXT_OPERATOR_DOOR_FIXTURE_FIELDS; a field of interface TextOperatorDoorCaseBase; a field of interface TextOperatorDoorFieldMeta; a field of interface TextOperatorDoorFixtureField))
  • content/docs/data-modeling/queries.mdx (via REFERENCE_VALUE_TYPES (literal, a string literal in TEXT_OPERATOR_DOOR_TYPE_CLASSES))
  • content/docs/data-modeling/validation-rules.mdx (via returnType (symbol, a field of const object TEXT_OPERATOR_DOOR_FIXTURE_FIELDS; a field of interface TextOperatorDoorCaseBase; a field of interface TextOperatorDoorFieldMeta; a field of interface TextOperatorDoorFixtureField), summaryOperations (symbol, a field of interface TextOperatorDoorFixtureField))
  • content/docs/getting-started/common-patterns.mdx (via returnType (symbol, a field of const object TEXT_OPERATOR_DOOR_FIXTURE_FIELDS; a field of interface TextOperatorDoorCaseBase; a field of interface TextOperatorDoorFieldMeta; a field of interface TextOperatorDoorFixtureField), summaryOperations (symbol, a field of interface TextOperatorDoorFixtureField))
  • content/docs/protocol/objectql/query-syntax.mdx (via INVALID_FILTER (literal, a string literal in TextOperatorDoorRefusalCase; a string literal in caseFor), REFERENCE_VALUE_TYPES (literal, a string literal in TEXT_OPERATOR_DOOR_TYPE_CLASSES))
  • content/docs/protocol/objectql/schema.mdx (via summaryOperations (symbol, a field of interface TextOperatorDoorFixtureField))
  • content/docs/protocol/objectql/types.mdx (via summaryOperations (symbol, a field of interface TextOperatorDoorFixtureField))

2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v16.mdx (via summaryOperations (symbol, a field of interface TextOperatorDoorFixtureField))
  • content/docs/releases/v17.mdx (via summaryOperations (symbol, a field of interface TextOperatorDoorFixtureField), INVALID_FILTER (literal, a string literal in TextOperatorDoorRefusalCase; a string literal in caseFor))

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.

What this run could not see
  • 3 changed file(s) yielded no anchor (packages/spec/api-surface/data.json, packages/spec/export-origins/data.json, packages/spec/src/data/index.ts) — pages documenting those are invisible to this run
  • 7 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 129 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json f501453b964e2ccede97219c3cb8d1399b47b6d1packageMentionDocs.

Which tree this was computed on

This run read content/docs from e93e154260fa7d339c7b12612e3027ba93785eb9 — the merge of head 8742baf9fef7f0d8f54ec9152bebb2bad58b1835 into base f501453b964e2ccede97219c3cb8d1399b47b6d1, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin e93e154260fa7d339c7b12612e3027ba93785eb9 && git checkout e93e154260fa7d339c7b12612e3027ba93785eb9
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f501453b964e2ccede97219c3cb8d1399b47b6d1 8742baf9fef7f0d8f54ec9152bebb2bad58b1835 && git checkout -B drift-repro f501453b964e2ccede97219c3cb8d1399b47b6d1 && git merge --no-ff 8742baf9fef7f0d8f54ec9152bebb2bad58b1835

node scripts/docs-audit/affected-docs.mjs --json f501453b964e2ccede97219c3cb8d1399b47b6d1

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs f501453b964e2ccede97219c3cb8d1399b47b6d1 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

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 protocol:data size/l tests tooling

Projects

None yet

2 participants