Skip to content

fix(objectql)!: a number or boolean written to a text-family field is stored as the string the validator judged (#29241) - #29384

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/project-thread-6u3vdx
Nov 2, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/project-thread-6u3vdx

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

domain:server#1 · os-steve · session_01ADHkmQjPiXgNoSaEpqeGvY

Requested by Steve · project thread
Fixes #29241

Clause-②: yes (narrowing)
Metadata-protocol: none
Skills-docs: none — no file under skills/** or content/docs/** teaches writing a number or boolean into a text-family field, or how a unique text field compares 42 with "42" (a git grep of skills/** for unique and text/number shows none); the field-types rule already says a text field stores a string.

Clause-② as amended by seat ruling 6579176393 on #29241. Measured: a single write is answered as before (the same payloads are admitted and refused). The unique key now compares the stored string, so on a unique text-family field the accept set of a write sequence moves in both directions (table under Why). Metadata-protocol stays none: no key, value or metadata type an author writes moves.

BREAKING for one case only, a unique text-family field. FROM: { code: 42 } then { code: "42" } (or true then "true") both answered 201 and stored two rows, on driver-memory and on SQLite. TO: both store "42", so the second write is refused 409 UNIQUE_VIOLATION. The fix for a caller: the two writes are the same value; send it once, or update the existing row. The changeset carries one ADR-0087 marker, not-required (no-migration-prescription): no metadata moves, and rows already stored are not rewritten, so nothing is prescribed. check-adr-0087-registration reports 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition … [BREAKING+bang+clause-②-narrowing] not-required (no-migration-prescription).

维护者速读

  • 改了什么:数字或布尔值写进文本类字段(text、textarea、email、phone 等 12 种)时,服务端写入口先把它改写成校验器判定用的那个字符串(42 存成 "42",true 存成 "true"),再交给驱动。
  • 为什么改:校验器一直按字符串 "42" 判定,存的却是原值,结果因驱动而异:内存驱动存数字 42,SQLite 存成 "42.0"、true 存成 "1.0"(maxLength: 2 的字段放行了 42,最后存了 4 个字符)。同一次写入,三种存法,等值过滤和排序因库而异。
  • 风险与回滚:单次写入的放行与拒绝不变。变化在唯一(unique)文本字段上:先写 42 再写 "42",以前两条都存,现在第二条 409 UNIQUE_VIOLATION(两个驱动都如此,这是 BREAKING);SQLite 上先写 true 再写 1 以前被拒,现在两条都存。已存的行不迁移,SQLite 旧行 "42.0" 和新写的 "42" 不再相等。回滚 = revert 本 PR。
  • 先看:packages/objectql/src/validation/record-validator.ts 的 normalizeNumericStringRow 新的 else if 分支;packages/rest/src/rest-data-text-scalar-value.test.ts;.changeset/29241-text-field-scalar-stored-string.md。
  • 两分钟验证:pnpm --filter @objectstack/rest exec vitest run src/rest-data-text-scalar-value.test.ts,预期 8 个全绿(含唯一字段的 4 个:两个 409、两个双 201)。
  • 需要你做的:无,席位落地。

What changes

Why

Measured on origin/main @ a19759ac (base) and on this branch (head), through the real REST routes (POST /data/:object, then the physical row, engine.findOne and GET /data/:object/:id):

Sent on a text / textarea field driver-memory, base driver-sql (SQLite), base both drivers, head
42 / true 42 / true (number, boolean) "42.0" / "1.0" "42" / "true"
false / 0 false / 0 "0.0" / "0.0" "false" / "0"
1.5 / -3 1.5 / -3 "1.5" / "-3.0" "1.5" / "-3"
"x42" / "true" (control) as sent as sent as sent

All three reads (physical row, engine, REST) agree within each cell. Every write answered 201 on base and head.

On SQLite the stored value is neither the raw scalar nor the judged string: a number is bound as a REAL and the TEXT column keeps its REAL spelling, and a boolean is bound as 1/0. So a maxLength: 2 text field admitted 42 and stored four characters, and { title: true } stored "1.0". PostgreSQL and MongoDB: NOT MEASURED (no daemon in the container). The engine now hands every driver the string, so no driver decides.

Who writes a number or boolean into a text-family field today (option A vs B). A log-only probe at the new arm recorded every rewrite while these suites ran: @objectstack/objectql, @objectstack/rest, @objectstack/runtime, @objectstack/service-automation (the flow nodes, including the create_record / update_record and screen paths) and the dogfood battery, which boots the example apps and their seeds. Positive control: the probe was proven in dist/ (4 built files) and recorded the 6 writes of the new REST test, which reach objectql through dist/.

Writer Hits
Seeds and data in examples/, packages/apps, platform objects (booted by the dogfood battery and the runtime suite) 0
Flow / screen paths (service-automation suite, dogfood flow fixtures) 0
Dogfood fixtures 0
Tests: protocol-save-notice-answer.test.ts (a text field used to hand a raw 42 to a hook) 1

No product writer under these suites relies on the raw scalar, so A was built as the card's default. The census did not cover the plugin suites (for example plugin-audit and service-messaging) or the other service packages, so it is not exhaustive; the review spot-read the obvious text-typed value columns there (sys_audit_log.old_value / new_value are written through safeStringify) and found no number writer.

A unique text-family field (measured: the REST create route, the base leg with origin/main @ 7f277a86's record-validator.ts built into objectql's dist/, the head leg with this branch's; SQLite in packages/rest, driver-memory in a one-off probe beside it, deleted):

Sequence (two POSTs) base, SQLite base, driver-memory head, both drivers
42, then "42" 201, 201 201, 201 201, 409 UNIQUE_VIOLATION
"42", then 42 201, 201 201, 201 201, 409
true, then "true" 201, 201 201, 201 201, 409
true, then 1 201, 409 201, 201 201, 201
false, then 0 201, 409 201, 201 201, 201
1, then true 201, 409 201, 201 201, 201
42, then "42.0" 201, 409 201, 201 201, 201

The first three rows are the narrowing, on both drivers: the validator reads both values as one string, and now the unique key does too. The last four are SQLite's widening: base stored true and 1 both as "1.0", and 42 as "42.0". Each new answer is the one the validator's reading implies.

Tests

  • packages/objectql/src/validation/record-validator.text-scalar-value.test.ts (70 tests): the spec set (12 types) × 42, 0, -1.5, true, false; the written string is the one a maxLength reads; a readonly field; a batch per row, caller objects unmutated; the controls listed above; and the engine doors (insert, insertMany, update by id, update by predicate) with a recording driver, which receives the string; and a unique text field, where 42 and "42" reach the driver as one value (what driver-memory's unique key compares). The validate() dry run calls the same function; its answer carries no values, so it is not pinned here.
  • packages/rest/src/rest-data-text-scalar-value.test.ts (8 tests): real ObjectQL + SQLite SqlDriver + RestServer, POST, PATCH and batch create, checked on the physical column, findOne and GET …/:id; a string control and a number-field control; and the unique field in both directions: 42 then "42" and true then "true" refused 409 UNIQUE_VIOLATION (status and code asserted), true then 1 and false then 0 both landing as two strings.
  • One forced test edit: packages/objectql/src/protocol-save-notice-answer.test.ts. Its refuses a number case sent say: 42 through a text field so the hook would call ctx.notice(42). The hook now receives "42", so the vehicle field say is json, which keeps the value as sent. The assertion is unchanged, and the file's other 21 tests are green on it.
Red on base, green on head
Run Base a19759ac Head
rest file, first 4 tests Tests 3 failed, 1 passed (4): expected '42.0' to be '42', expected '0.0' to be '0', expected '1.0' to be 'true' Tests 4 passed (4)
rest file, the 4 unique tests measured by the probe table under Why (same routes, same SQLite boot): 42/"42" and true/"true" both 201, true/1 and false/0 second 409 Tests 8 passed (8) at 926fa801
Ablations (the fix, the three negative pins, and the unique pins)

Each mutation was made with scripts/ablation-replace.mjs (anchor 1 → 0, blob changed) and restored to the HEAD blob bdeb597b with git diff HEAD empty. The objectql file resolves src/ directly; the rest file reads objectql from dist/, so leg 1 rebuilt objectql and ablation-dist-preflight.mjs found the marker in 4 built files, then absent from all 14 after the restore rebuild.

Leg Mutation Red
1 the arm writes the raw value again objectql Tests 64 failed, 5 passed (69); rest Tests 3 failed, 1 passed (4) ('42.0', '0.0', '1.0')
2 the rewrite reaches every field type a json, number or boolean field keeps its number or boolean; the engine-door test (its amount: 7 number-field control)
3 the validator-walk scope dropped a declared field the validator never judges is not rewritten
4 null / missing / object / array reach the rewrite a string, null, a missing key, an object and an array on a text field: the same row back
1, round 2 (head 926fa801) the arm writes the raw value again (marker in 4 built files, then absent from 14; restored to blob 92fe1708, git diff HEAD empty) rest Tests 7 failed, 1 passed (8): both 409 pins, both 201,201 pins and the three stored-form tests; objectql a unique text field: 42 and "42" reach the driver as one value

Acceptance notes

  • Stored rows are not rewritten. A SQLite row written before this change keeps "42.0" and no longer equals a new "42", nor collides with it on a unique field. driver-memory keeps rows for the process by default, but its persistence option (file, local or a custom adapter) is opt-in and keeps raw 42 / true across restarts the same way. A migration for such rows would be a separate card.
  • The filter door (measured, the same two runs, one row written as 42 and one as "42", engine.find and POST /data/:object/query agreeing): base: { title: 42 } matched only the row written as 42 on both drivers (driver-memory's raw 42, SQLite's "42.0"), and { title: "42" } only the other. Head: { title: "42" } matches both rows, and { title: 42 } matches none, on both drivers. So at head a number comparand on a text field does not answer differently by driver; it silently matches nothing, while the write door stores that number as its string. The seat ruled it (b), a gap that predates this PR, to be filed as its own card after landing; the changeset tells a caller to filter with the string.
  • Other non-string values on a text field. The string arm also judges a Date (or another class instance) by String(value) while the raw object reaches the driver. Out of this card's scope (numbers and booleans). Reported to the seat, not filed.
  • A before* hook that writes a number into a text field after the door is the hook's own value and is not rewritten, as on the numeric half.

Verification

Head 288190cd (round 3: prose only — the changeset's filter sentence, and the record-validator docblock and objectql test header restated to the unique answer; no code or test line changed. Round 2 head 926fa801: the unique pins and the declaration; round 1 head 34c30195 had merged origin/main with #29271, merge-tree clean). See the PR's check-runs for CI.

Suites, typecheck, gates, lint, dogfood
Run Head Result
pnpm --filter @objectstack/dogfood test (closure built at this head) 926fa801 lock VERDICT command-exit 0 · Test Files 393 passed, 3 skipped (396) · Tests 3243 passed, 28 skipped (3271)
objectql record-validator.text-scalar-value.test.ts 926fa801 Tests 70 passed (70)
rest rest-data-text-scalar-value.test.ts 926fa801 Tests 8 passed (8)
dispatch-gates.mjs --commands (61 families), each run 926fa801 61 × exit 0; --ran: 61 derived, 61 run, 0 NOT-MEASURED
check-adr-0087-registration --base origin/main 288190cd exit 0: 1 declared-breaking changeset with its disposition, not-required (no-migration-prescription)
eslint (--no-inline-config) over packages/objectql and packages/rest 926fa801 exit 0 (neither package has its own lint script)
round 3 prose checks: check:doc-authoring, check:issue-citations, check:nul-bytes, check-empty-changeset, check-closing-keyword-parity, eslint on packages/objectql/src/validation 288190cd each exit 0
pnpm --filter @objectstack/objectql test 34c30195 Test Files 531 passed (531) · Tests 10345 passed (10345)
pnpm --filter @objectstack/objectql typecheck 34c30195 exit 0
pnpm --filter @objectstack/rest test 34c30195 Test Files 371 passed (371) · Tests 7034 passed, 332 skipped (7366)
pnpm --filter @objectstack/rest typecheck 34c30195 exit 0
pnpm --filter @objectstack/runtime test 27920d0a + log-only probe Test Files 516 passed (516) · Tests 8367 passed, 19 skipped
pnpm --filter @objectstack/service-automation test 27920d0a + log-only probe Test Files 259 passed (259) · Tests 3335 passed

Round 2 (34c30195 → 926fa801) changed only the changeset and the two new test files, so the full objectql and rest suites were not re-run; their round 2 readings are the two edited files above, and Test Core in CI. The runtime and automation rows ran with the census probe in objectql's dist/: one uncommitted line in the new arm that appended a JSON line to a file when OS_TEST_PROBE_29241 was set, and changed no value; it was removed and ablation-dist-preflight proved it absent from all 14 built files. The round 1 dogfood reading (27920d0a + probe: 391 files, 3223 tests) is superseded by the head reading above.

Human Minimum

  • 1. Schemas start as Zod; packages/spec holds no runtime logic. (not touched)
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran; pnpm lint passes on the touched packages.
  • 4. One issue, one branch off an up-to-date main, one pull request.
  • 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset.
  • 6. packages/spec touched: n/a.
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English (the maintainer brief above is Chinese by repo rule).

Generated by Claude Code

…e string the validator judged

The string arm judges a number or a boolean by String(value) while the
write carried the raw scalar, so driver-memory stored 42 and SQLite stored
the TEXT '42.0' ('1.0' for true). normalizeNumericStringValues, the #20309
door seam, now writes the judged string into the payload for the
BOUNDED_STRING_FIELD_TYPES fields the validator walks.

Claude-Session: https://claude.ai/code/session_01ADHkmPGQiCkuYTyC61yVpF
Co-authored-by: Claude <noreply@anthropic.com>
The refusal case hands the hook a raw number through the `say` field. A
text-family field now stores, and hands its hooks, the string the
validator judges a number by, so the vehicle moves to `json`, which keeps
the value as sent.

Claude-Session: https://claude.ai/code/session_01ADHkmPGQiCkuYTyC61yVpF
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Nov 2, 2026
@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

3 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • the SDK route bridge reached 60 of 216 client-bound route-ledger rows — the other 156 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 156: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 101 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.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 20 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 6fe99f8634e7da14d1af67f97a3b6b3030bf70ba → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 75d1344fa04e5e3c682de5dfb38103831d021080 — the merge of head 288190cde77357eee227b6f17e121511a732fc3e into base 6fe99f8634e7da14d1af67f97a3b6b3030bf70ba, 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 75d1344fa04e5e3c682de5dfb38103831d021080 && git checkout 75d1344fa04e5e3c682de5dfb38103831d021080
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6fe99f8634e7da14d1af67f97a3b6b3030bf70ba 288190cde77357eee227b6f17e121511a732fc3e && git checkout -B drift-repro 6fe99f8634e7da14d1af67f97a3b6b3030bf70ba && git merge --no-ff 288190cde77357eee227b6f17e121511a732fc3e

node scripts/docs-audit/affected-docs.mjs --json 6fe99f8634e7da14d1af67f97a3b6b3030bf70ba

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-steve · session_01ADHkmQjPiXgNoSaEpqeGvY

Contract-review-claim: session_01ADHkmQjPiXgNoSaEpqeGvY · head 34c3019 · 2026-11-02T13:46Z

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-steve · session_01ADHkmQjPiXgNoSaEpqeGvY

Contract review

Served-tier: TIER_DEFAULT
Head-sha: 34c30195fcde0fbcc5bff2f50bbd80a295e5fe03
Local-runs: none

Read at 2026-11-02T14:04Z from the net diff against main (merge-base 57cde8cb, 5 files, +362/-7), card #29241 (body, triage 6575529488, claims 6576283748 and 6576374454, dev report 6578737095), the PR body, and the head's check-runs. Nothing was built, run or re-run. Every claim below is either read from code at the head or reasoned from the PR's own base and head measurements, and it says which.

① Derived judgments

  1. The seam: right. normalizeNumericStringRow now writes String(value) for a number or boolean on a BOUNDED_STRING_FIELD_TYPES field. The field scope is the numeric half's (isInReadonlyScope(…, 'include'): readonly included, SKIP_FIELDS and non-readonly system excluded). The written string is exactly the s that the string arm's maxLength, minLength, valueDomain and format checks read (record-validator.ts ~1286). Strings, null, a missing key, objects, arrays, bigint and every type outside the set fall through unchanged.
  2. Doors: no door is left half-applied. I read each one at the head:
    • insert() calls the seam at engine.ts:15391. insertMany() is insert(rows) (16737).
    • update() calls it at 16828, ahead of the by-id and predicate dispatch, so driver.update, driver.updateMany and updateByIdExpecting all receive the rewritten payload.
    • The validate() dry run calls it at engine-validate.ts:158, and validateInsertValues at record-validator.ts:2164.
    • REST create, PATCH and batch, import, and the flow create_record/update_record nodes all reach the engine through these methods.
    • The only direct driver.create calls outside the engine are testkits (default-organization*.testkit.ts) and the packages/verify conformance harness. None of them is a product writer.
    • Residuals, all accepted under the record validator's number arm accepts any value whose Number() is finite, so POST /api/v1/data with a number field [500] answers 201 and driver-sql stores the text '[500]' #20309 posture: a value written after the door is not rewritten. That covers a before* hook (the PR says so), write middleware, and a CEL-expression defaultValue on a text field whose result is a number. The CEL case is read from the defaultValue docblock and not measured. The spec refuses a non-string literal default at parse.
    • Gap: no test pins validate(). The objectql file's header says only that it "calls the same function". The changeset names validate() among the doors, so this is a nit for the follow-up.
  3. Ordering: right. The rewrite runs first, before the middleware, the callerKeys snapshot, defaults, hooks, the readonly strip, the validator and the driver's unique check. All of them therefore see one image.
    • Formula fields are computed on read from the stored row, so they read the string.
    • Hooks now receive "42" where they received 42. The forced edit to protocol-save-notice-answer.test.ts shows this is visible to hooks. It is the same accident class as the numeric half, and it is acceptable.
    • Because the rewrite runs before the driver's unique check, that check now compares the judged string. That is correct, but it is also what moves the accept set in item 5.
  4. The read-back type change on driver-memory (42 becomes "42") corrects an accident; it does not break a contract.
    • The published stored shape of a text-family field is a string. The arm itself says: "A string type's stored shape is a string". FieldSchema types it so. SQLite already handed back "42.0", so no reader could have relied on a raw number across drivers.
    • What answers differently now on driver-memory, read from code:
      • An equality or $in comparand 42 on a text field. mingo compares strictly, so it no longer matches newly written rows.
      • The comparand "42" now matches those rows.
      • sort and min/max order lexically instead of putting numbers first by BSON type order.
      • A formula or validation-rule expression now reads a string.
    • All four make the field behave as its declared type. None is a published surface.
    • Should-fix follow-up, not owed by this card: the filter door has no text-family comparand arm. normalizeFilterComparandTypes in packages/spec/src/data/filter-comparand-type.ts is not field-aware. So a number comparand on a text field still answers by driver. On driver-memory it misses the row. On SQLite, TEXT affinity matches '42'. The seat should file it.
  5. must-fix: the accept set does move, on unique-constrained text fields. The PR, the changeset and the Clause-② measurement say "nothing is refused and nothing is newly admitted". That was measured on single writes only. The driver's unique key now compares the rewritten value. Reasoned from code (uniqueKeyOf is a JSON.stringify of the stored parts in memory-unique-constraint.ts) and from the PR's own base/head table. Not run.
    • Narrowing, on both drivers. Take a unique text field, insert { code: 42 }, then { code: "42" }.
      • On main: driver-memory keys [42] and ["42"]; SQLite stores '42.0' and '42'. Both writes answer 201.
      • On head: both store "42", so the second write is 409 DUPLICATE_RECORD.
      • The same holds for true then "true".
    • Widening, on SQLite, on an empty table. Insert { code: true }, then { code: 1 }.
      • On main: both store '1.0', so the second write is 409.
      • On head: 'true' and '1' are distinct, so both writes answer 201.
      • The same holds for false/0, and for 42 then "42.0".
    • Legacy rows. A row stored on main ('42.0', or a raw 42 on a driver-memory store with persistence: 'file', 'local' or an adapter) no longer collides with a new write of the same value.
    • The card's own business case names exactly the fields that carry unique: "a phone extension, a code, an external id".
    • Each new answer is the one the validator's reading implies. I judge the direction right. The declaration and the prose are what is wrong (see ②).
  6. Prose, should-fix.
    • The PR's acceptance note says "driver-memory rows are process-lifetime". InMemoryDriver takes opt-in persistence (file, local, or a custom adapter; memory-driver.ts ~236). Persisted raw scalars outlive the change, as SQLite's '42.0' rows do.
    • The census covered objectql, rest, runtime, service-automation and dogfood. Plugin suites (for example plugin-audit and service-messaging) were not under the probe. I spot-read the obvious text-typed value columns: sys_audit_log.old_value/new_value are written through safeStringify. I found no product number writer, but the census is not exhaustive.

② Semver level

  • Changeset '@objectstack/objectql': patch: right. It is a fix in a released package. A non-protocol change ships patch even when it narrows, keeping the banner. Metadata-protocol: none is also right: no key, accepted value or metadata type that an author writes moves.
  • Clause-②: no: wrong. must-fix. Item ① 5 shows a write sequence accepted on main that is refused at the head (narrowing, both drivers). On SQLite it also shows a sequence refused on main that is admitted at the head (widening). Under the closed pair, the line has to carry the narrowing arm:
    • Clause-②: yes (narrowing) if the seat counts the SQLite de-collision as a widening;
    • otherwise Clause-②: no (narrowing).
    • Either way the arm is BREAKING. It owes the changeset a BREAKING banner, the ADR-0087 disposition marker beside both declaration lines (pnpm check:adr-0087-registration), and a Skills-docs: line on the PR body. A narrowing arm also moves the review face to CONTRACT_REVIEW_TIER (--face narrowing), so a TIER_DEFAULT PASS could not govern this head in any case.
  • The alternative is a ruling. If the seat rules on the card that a state-dependent unique outcome between values the validator already reads as one string is outside Clause-②, plain no can stand. The prose "nothing is refused / nothing is newly admitted" in the PR body and the changeset (which ships as CHANGELOG.md) still has to be corrected to name the unique-collision change, and that ruling has to be on the card before a re-review.
  • Either path owes a pin: one test of the unique-collision answer on the SQLite real-driver file the PR already adds.

③ Boundary flags

  • open_questions: the function name (normalizeNumericStringValues now also writes the string arm's reading). Answer: A, keep the name in this PR. The docblock's [#29241] section names the second half, and a rename touches engine.ts call sites that other server cards serialise on. A rename (for example normalizeJudgedScalarValues) is optional future hygiene, not owed. If the seat wants it, it belongs on records: a free-form select field (no options) admits a plain object or an array on create and stores it as-is, although a select's stored value is a scalar option value #29244, which edits the same file next.
  • Deviation: no driver-memory test pin. Accepted. scripts/check-driver-memory-census.mjs and its ledger confine driver-memory test consumers to ruled files. The objectql recording-driver test pins what every driver receives (the string), and driver-memory stores what it receives (the base measurement). The real-driver pin is SQLite in packages/rest.
  • Deviation: PostgreSQL and MongoDB NOT MEASURED. Accepted, with residual risk named. The engine now hands every driver a JS string, so no driver decides for new writes. What stays unmeasured is each driver's legacy rows (MongoDB keeps raw numbers and booleans as written) and their unique answer (①5). Neither blocks this card. The migration of legacy rows stays a separate card, as the PR says.
  • Deviation: runtime, service-automation and dogfood ran at 27920d0a with a log-only probe, before the origin/main merge and the forced test edit. Accepted as evidence for the census, not as head verdicts.
  • Out-of-scope findings.
    • The string arm judges a Date or another class instance by String(value) while the raw object is stored. That is the same judged≠stored class this card fixes. The seat should file a card for it; it is not this PR's.
    • Legacy SQLite '42.0'/'1.0' rows, and persisted driver-memory raw scalars: a migration card only if wanted.
    • The filter-door text comparand gap in ①4: should be filed.
  • Dev deviations "no assignee" and "no PR subscription": relay rules, no contract bearing.
  • PR state. The PR is a draft. Its author is objectstack-fleet[bot] (relay), so no out-of-relay login to note.
  • CI on the head: all 48 check-runs completed: 35 success and 13 skipped, with 0 failures and 0 in progress. Test Core 1–8/8, the Test Core aggregate, Type Check (workspace, source, consumer, debt ledger), Build Core, every Lint leg, Check Changeset, Governed Surface Queue Guard and PR-body guards all passed. Skipped by the path filter: the UI legs, the Dogfood shards and Verify CLI, Temporal Conformance (live PG + MySQL), and the opt-in legs. The CI gates are green, so this FAIL rests on ② alone.

Implemented-by: claude/project-thread-6u3vdx
Reviewed-by: session_01ADHkmQjPiXgNoSaEpqeGvY

VERDICT: FAIL

Must-fix before a re-review:

  • (1) Fix the Clause-② declaration in the PR body and the changeset, either with the narrowing arm and its BREAKING and ADR-0087 obligations, or with a seat ruling on the card that keeps plain no.
  • (2) Correct the "nothing is refused / nothing is newly admitted" prose in the PR body and the changeset to name the unique-collision change.
  • (3) Pin that change in packages/rest/src/rest-data-text-scalar-value.test.ts.

The code change itself (①1–4) is judged right and needs no edit.

A unique text-family field compares the stored string now: 42 then "42"
is refused 409 UNIQUE_VIOLATION where base stored both, and on SQLite
true then 1 lands where base stored both as '1.0'. Pinned on the SQLite
REST file (both directions) and at the engine door (one value reaches the
driver). The changeset declares Clause-② yes (narrowing) with its BREAKING
banner and the ADR-0087 not-required marker.

Claude-Session: https://claude.ai/code/session_01ADHkmPGQiCkuYTyC61yVpF
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet objectstack-fleet Bot changed the title fix(objectql): a number or boolean written to a text-family field is stored as the string the validator judged (#29241) fix(objectql)!: a number or boolean written to a text-family field is stored as the string the validator judged (#29241) Nov 2, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-steve · session_01ADHkmQjPiXgNoSaEpqeGvY

Contract-review-claim: session_01ADHkmQjPiXgNoSaEpqeGvY · head 926fa80 · 2026-11-02T15:11Z

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-steve · session_01ADHkmQjPiXgNoSaEpqeGvY

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 926fa801bb273e098893f224461500f73ad29987
Local-runs: none
Scope: packages/objectql/src/validation/record-validator.ts, packages/objectql/src/validation/record-validator.text-scalar-value.test.ts, packages/rest/src/rest-data-text-scalar-value.test.ts, packages/objectql/src/protocol-save-notice-answer.test.ts, .changeset/29241-text-field-scalar-stored-string.md + packages/objectql/src/engine.ts, packages/objectql/src/engine-validate.ts, packages/objectql/src/number-comparand-declared-type-door.ts, packages/spec/src/data/filter-comparand-type.ts, packages/spec/src/data/filter-number-comparand-declared-type.ts, packages/spec/src/data/filter-text-comparand.ts, packages/drivers/driver-memory/src/memory-driver.ts, packages/drivers/driver-memory/src/memory-unique-constraint.ts, content/docs/protocol/objectql/types.mdx, content/docs/protocol/objectql/query-syntax.mdx

Round 2, on the narrowing face. Supersedes record 6579147685 (TIER_DEFAULT FAIL on head 34c30195, which failed on the declaration alone and passed the code). Read at 2026-11-02T15:22Z from the net diff against main (merge-base 57cde8cb, 5 files, +419/-7), card #29241 (body, triage 6575529488, claims 6576283748 and 6576374454, dev report 6578737095, seat ruling 6579176393), the PR body as rewritten for round 2, the PR's own comments, and the head's check-runs. Nothing was built, run or re-run; every statement about behaviour below is read from the code at the head or from the PR's own base and head measurements, and says which. The dispatch brief's conclusions were not an input.

① Derived judgments

  1. The seam: right. normalizeNumericStringRow (record-validator.ts) now takes a number or boolean (isStringReadScalar) beside a string, and on a field whose type is in the spec's BOUNDED_STRING_FIELD_TYPES writes String(value), the exact s the string arm reads for maxLength, minLength, valueDomain and the email/url/phone formats. The string half is unchanged (isJudgedNumberType then parseNumericString). Field scope is the numeric half's, isInReadonlyScope(name, def, 'include'): readonly reached, SKIP_FIELDS and non-readonly system not. Strings, null, a missing key, objects, arrays, bigint, Date, and every type outside the set fall through by reference (out ?? row). Copy-on-write per row and per array holds.
  2. Doors: every engine write passes the seam, read at the head. insert() calls it at engine.ts 15391 (after normalizeBlankTypedValues), update() at 16828 ahead of the by-id, updateByIdExpecting and updateMany dispatch, the validate() dry run at engine-validate.ts 158, validateInsertValues at record-validator.ts 2164. The remaining direct driver.create / driver.update sites in engine.ts (createWithAutonumberResync 8863 and 8919, the hook-context update at 18204, the bulkCreate fan-out at 16477 and 16480) all receive a row that already passed one of those two doors. REST create, PATCH, batch, import and the flow record nodes reach the engine through them. Residual, accepted under the record validator's number arm accepts any value whose Number() is finite, so POST /api/v1/data with a number field [500] answers 201 and driver-sql stores the text '[500]' #20309 posture the docblock states: a value a before* hook or write middleware writes after the door is its own.
  3. Ordering: right. The rewrite runs before the middleware, the caller snapshot, defaults, hooks, the readonly strip, the validator and the driver's unique check, so every reader sees one image. Hooks now receive "42" where they received 42 (the forced edit to protocol-save-notice-answer.test.ts is that effect made visible; moving the vehicle field to json keeps the hook's raw-number case intact). Formulas, aggregation min/max/groupBy, search and the memory driver's filter all read the string.
  4. Accept-set changes the diff implies, each judged:
    • Single write on a text-family field: unchanged. The arm's verdict reads s, which is byte-equal to what is now stored. Right.
    • unique text-family field, both drivers: 42 then "42" (and true then "true") was two rows, is now 409 UNIQUE_VIOLATION on the second. Read from uniqueKeyOf (JSON.stringify of the stored parts, memory-unique-constraint.ts 499-518) and from the PR's base/head table; pinned by two REST tests. A narrowing, and the right one: the validator read both writes as one value, and now the constraint does too.
    • unique text-family field, SQLite only: true then 1, false then 0, 42 then "42.0" were 409 (both bound as REAL/integer and spelled '1.0'/'0.0'/'42.0' by TEXT affinity), now two rows. A widening, and the right one: they are different strings under the spec's reading. Pinned by two REST tests. driver-memory admitted both before and still does.
    • Read-back type on driver-memory: 42 becomes "42". The published stored shape of the family is a string (types.mdx: SQL TEXT, MongoDB String; the arm: "a string type's stored shape is a string"), so this corrects an accident and publishes nothing new.
    • Filter answers (ruled on in ④): { title: 42 } at head matches no row on either driver; { title: "42" } matches every row written as 42 or "42". Right as a consequence of the write door; the comparand gap is the filter door's and pre-existing.
    • Legacy rows (SQLite '42.0'/'1.0', driver-memory with persistence, MongoDB raw scalars) are not rewritten and no longer collide with or equal new writes. Named in the changeset and the acceptance notes; a migration is a separate card. Right.
  5. No public surface moves. No export, type, Zod schema or metadata key changes; packages/spec is untouched; the one new function (isStringReadScalar) is module-private.
  6. Prose in the tree, should-fix (not blocking). Two sentences at the head still carry round 1's claim that the seat ruled false: the [#29241] docblock section in record-validator.ts ("Nothing is refused or newly admitted: only the stored form of a value the arm already admits changes") and the header of record-validator.text-scalar-value.test.ts ("Nothing is refused and nothing is newly admitted"). Round 1's must-fix named only the PR body and the changeset, and both are corrected; these two comments should say what the changeset says (single-write answer unchanged; the unique answer moves). Fix them in the follow-up, or when records: a free-form select field (no options) admits a plain object or an array on create and stores it as-is, although a select's stored value is a scalar option value #29244 next edits the file.

② Semver level

  • Changeset '@objectstack/objectql': patch: right. A fix in a released package; AGENTS Post-Task step 3: a non-protocol change, widening or breaking, ships patch and keeps its BREAKING banner and ADR-0087 disposition.
  • Clause-②: yes (narrowing) in the PR body and the changeset: right, and it is the arm the seat ruling 6579176393 amended the claim to. ①4 shows the narrowing (both drivers) and the widening (SQLite), so yes with the (narrowing) arm is the one well-formed spelling under the closed pair.
  • Metadata-protocol: none: right. No key, accepted value or metadata type an author writes moves.
  • BREAKING banner: present, and it names the FROM → TO case ({ code: 42 } then { code: "42" }: two rows → 409 UNIQUE_VIOLATION), the caller's one-line fix, and the SQLite widening in the other direction. The code name matches what the REST test asserts and what engine.ts 8773 documents (UNIQUE_VIOLATION / 409).
  • ADR-0087 marker: exactly one, not-required (no-migration-prescription), honest. No metadata key, export or type moves, so objectstack migrate meta has nothing to rewrite and no tombstone is owed; the body's "send the string once, or update the existing row" is a behavioural note, not a code-rewrite prescription. The gate (Lint · CI, release & engine gates) is green on the head, and the dev reports check-adr-0087-registration --base origin/main exit 0 with the [BREAKING+bang+clause-②-narrowing] signal set.
  • PR title fix(objectql)!: carries the bang and matches the changeset summary's !, so the gate's three signals (bang, banner, narrowing arm) agree.
  • Skills-docs: none — … line: present and true as far as the tree shows: no file under skills/**, .claude/skills/** or content/docs/** teaches writing a number or boolean into a text-family field or how unique compares 42 with "42"; the types page already states the family's stored shape.

③ Boundary flags

  • open_questions — the function name (normalizeNumericStringValues now carries the string arm's half). Answer: A, keep the name. The docblock's [#29241] section names the second half, and a rename touches engine.ts call sites other server cards serialise on. Optional hygiene for records: a free-form select field (no options) admits a plain object or an array on create and stores it as-is, although a select's stored value is a scalar option value #29244, which edits the same file next; not owed.
  • Round 1 must-fixes, all three met at the head: (1) the declaration in body and changeset now reads yes (narrowing) with banner, marker and Skills-docs: line; (2) the "nothing is refused / nothing is newly admitted" prose in the body and the changeset is replaced by the single-write / unique-answer distinction (the two in-tree comments in ①6 remain, should-fix); (3) the unique answer is pinned: four REST tests on SQLite (two 409, two 201/201) and one objectql recording-driver test.
  • Deviation: no driver-memory test pin. Accepted. The driver-memory census gate admits only ruled consumers; the recording-driver test pins what every driver receives, and the real-driver pin is SQLite in packages/rest.
  • Deviation: PostgreSQL and MongoDB NOT MEASURED. Accepted with the residual named: the engine now hands every driver a JS string, so no driver decides for new writes; what stays unmeasured is each backend's legacy rows and their unique answer. Not this card's.
  • Deviation: runtime, service-automation and the round-1 dogfood reading ran at 27920d0a with a log-only probe in objectql's dist. Accepted as census evidence, not as head verdicts. The head's verdicts are the check-runs below plus the dev's round-2 dogfood reading at 926fa801 (393 files, no failures; CI's dogfood shards were skipped by the path filter, so that local reading is the only dogfood reading of this head).
  • Round 2 ran only the two edited test files and the changeset gate locally; the full objectql and rest suites were last run at 34c30195. Accepted: round 2 changed only the changeset and the two new test files, and Test Core 1–8 on the head is green.
  • No round-2 os-dev-report comment exists on the card or the PR at the time of this read; the rewritten PR body is the round-2 account. The dev should post the round-2 report on the card so the card thread carries it; no contract bearing.
  • Out-of-scope findings, routing: (i) the filter-door text-family comparand gap → a follow-up card, filed by the seat (④); (ii) a Date or other class instance on a text field is judged by String(value) while the raw object reaches the driver (isPlainObjectValue excludes it) → a card of its own, same judged ≠ stored class; (iii) legacy '42.0'/'1.0' SQLite rows and persisted driver-memory scalars → a migration card only if wanted.
  • "No assignee", "no PR subscription": relay rules, no contract bearing. The PR is a draft; its author is the relay bot.
  • Check-runs on 926fa801: 52 check-runs, all completed: 37 success, 15 skipped, 0 failure, 0 in progress. Test Core 1–8/8 and the aggregate, Build Core, Type Check (workspace, source gates, consumer gates, debt ledger), every Lint leg, Check Changeset, PR-body guards, Governed Surface Queue Guard, Dogfood Regression Gate (satisfied, shards skipped by path filter), Flag docs affected, Test UI and Live E2E UI all green. Skipped by filter or opt-in: Type Check UI, Lint UI, the UI shards and artifacts, Build Docs, Dogfood shards and Verify CLI, Temporal Conformance (live PG + MySQL), the packed-tarball and 18.x harnesses. Those conclusions are the gate verdicts.

④ Design judgment

The narrowing against the published spec on main. The spec declares a text-family field's stored value to be a string (types.mdx: TEXT on every SQL dialect, String on MongoDB; the record validator: "a string type's stored shape is a string; everything below is a bound, a domain or a format"), and unique as a constraint over stored values. On main the validator read 42 as "42" and admitted it, while the driver stored the raw scalar, so a unique column held two rows for one value on both drivers, and SQLite refused two different values (true, 1) because its affinity spelled them alike. Neither answer was declared anywhere; both were the judged ≠ stored accident reaching the constraint. The narrowing takes no declared capability away at the consumer: it makes the spec's own refusal (one value, one row) real, and the SQLite widening is the same reading applied to values the spec says are different. Pinned on the real driver in both directions. I tried to break the design on -0 ("0"), 1e21 ("1e+21"), NaN, a password or valueDomain field, a readonly text field, update by predicate, updateByIdExpecting under a precondition, the autonumber resync path and the bulkCreate fan-out: in each case the stored string is byte-equal to the s the arm judged, and the rewritten row is the one every later reader gets. No must-fix.

The filter measurement the dev reported (two rows, 42 and "42"; base { title: 42 } matched the raw-42 row on both drivers; head it matches nothing on both, { title: "42" } matches both rows). Ruling: (b), a pre-existing filter-door gap this PR exposes, not a regression it introduces. Argued from the published filter contract and the code:

Findings. must-fix: none. should-fix: (1) the two in-tree sentences in ①6; (2) the changeset sentence above; (3) the follow-up cards in ③ (filter-door text-family comparand arm; Date/class instance on a text field); (4) carried from round 1 as a nit: no test pins the validate() dry run's call of the seam.

Implemented-by: claude/project-thread-6u3vdx
Reviewed-by: session_01ADHkmQjPiXgNoSaEpqeGvY

VERDICT: PASS


Generated by Claude Code

…ilter answers

The docblock and the test header said nothing is refused or newly
admitted; the unique-key answer moves (Clause-② yes, narrowing). The
changeset now names the filter consequence: a number comparand on a
text-family field no longer matches a row written as that number.

Claude-Session: https://claude.ai/code/session_01ADHkmPGQiCkuYTyC61yVpF
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-steve · session_01ADHkmQjPiXgNoSaEpqeGvY

Regen-provenance: contract review 6580906146 (PASS, head 926fa801bb273e098893f224461500f73ad29987) carries over to head 288190cde77357eee227b6f17e121511a732fc3e. The one commit between them is prose only. It touches .changeset/29241-text-field-scalar-stored-string.md (+1 / −1, the filter sentence) and comment lines in record-validator.ts (docblock) and record-validator.text-scalar-value.test.ts (header), and so applies that record's three should-fixes. No code line, test assertion or level changes; checked against the compare diff.

席位核对 · ACCEPT · domain:server seat 1 · os-steve · session_01ADHkmQjPiXgNoSaEpqeGvY · 2026-11-02T15:49Z

Head 288190cde7, claim 6576283748 as amended by seat ruling 6579176393. The diff narrows one accept set and widens another, so it is declared Clause-②: yes (narrowing). A contract review is owed at CONTRACT_REVIEW_TIER. Record 6580906146 is a PASS with no must-fix; it supersedes the TIER_DEFAULT FAIL 6579147685 and is carried to this head by the provenance line above.

item reading
Shape Draft, base main, Fixes #29241, title fix(objectql)!. 5 files: the #20309 seam in record-validator.ts, the objectql and rest tests, and a changeset. The changeset is @objectstack/objectql patch, Clause-②: yes (narrowing), with a BREAKING banner (FROM → TO), one ADR-0087 marker not-required (no-migration-prescription) and Skills-docs: none. Metadata-protocol: none. No packages/spec/** change and no new code. Not governed (check-governed-merges reports governed: false).
Rule A number or boolean written to a text-family field is stored as the string the validator judged (42 → "42", true → "true") on every engine write door. A single write is admitted or refused as before. On a unique text field, 42 after "42" is now 409 on both drivers (the narrowing), and on SQLite true after 1 is now admitted (the widening).
Evidence Base 201,201 → head 201,409 on SQLite and memory; base 201,409 → head 201,201 on SQLite for true/1. Ablations went red and were then restored. Dogfood VERDICT 0 at 926fa801 (393 files, 3243 tests). objectql 531/531, rest 371/371, 61 gates. CI on this head: 52 runs, 37 green, 15 skipped, none red. check-expected-skips OK (15, all in main's roster).
Follow-up The filter door has no field-aware arm for a number or boolean comparand on a text-family field. Ruling (b) of the record found this gap already existed; this change exposes it. The seat files it as its own card after landing.
Landing 「按照 pm-dispatch 规则复核过的都授权」 (Leehom, 2026-10-25T11:35Z): ready and auto-merge through the relay.

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/m tests tooling

Projects

None yet

1 participant