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.

@github-actions github-actions Bot added documentation Improvements or additions to documentation protocol:data tests tooling labels Sep 5, 2026
@os-project-manager
os-project-manager marked this pull request as ready for review September 5, 2026 09:11
@os-project-manager
os-project-manager added this pull request to the merge queue Sep 5, 2026
Merged via the queue into main with commit 6f1ce7d Sep 5, 2026
39 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-15661-text-operator-declared-type-door-rows branch September 5, 2026 10:08
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