feat(spec,driver-sql,cli): one physical representation for the NUMERIC column family, read by all three producers - #16887
Conversation
The container restart that killed the previous os-dev left this in the worktree, uncommitted. Committed verbatim so it is not the only copy; every number in it is re-measured before anything is claimed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
… for the NUMERIC column family Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
…e producers Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
…(ADR-0113) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
…c column table Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg
📓 Docs Drift CheckThis PR changes 3 package(s): 9 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 137 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin e6d404b2f686b726c399b58d2d4b67ddda32fd79 && git checkout e6d404b2f686b726c399b58d2d4b67ddda32fd79
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c1d8f98a5cdb6948d9ee2286e7f26f7d4b4922ba c0d4ed6f9f085350e8277d99c73c53c61c661999 && git checkout -B drift-repro c1d8f98a5cdb6948d9ee2286e7f26f7d4b4922ba && git merge --no-ff c0d4ed6f9f085350e8277d99c73c53c61c661999
node scripts/docs-audit/affected-docs.mjs --json c1d8f98a5cdb6948d9ee2286e7f26f7d4b4922ba
|
Contract review (
|
Fixes #16318
Clause-②: yes
Ruled C ∩ ④ (director seat, decision batch 86, 2026-09-08). One explicit per-field-type
physical-representation table lives in
packages/spec, and all three producers read it:SqlDriver.createColumn,os generate migration --format sql, and the typescript format.New tables only — no existing column is retyped, no migration is planned, no backfill runs.
The two measurements the ruling named as pre-work
Both were taken before any number was chosen. Every reading below carries a firing control in
the same run, and every count was read from the harness's own printed count, never from a pipe.
Every reading is pinned to the PR head
c0d4ed6f9f.1. The three-producer table, re-run on PostgreSQL 16.13
One object, seven plain numeric declarations, three producers (
initObjects;--format sqlviadb.raw; the typescript format imported and itsup(db)called), read back out ofinformation_schema.columns— withnumeric_precision/numeric_scale, which is the half theoriginal report's bare
data_typeread hid.BEFORE (
c930f8597, the branch point):AFTER, same command, same database:
⭐ The divergence is wider than the card reported: six of the seven were a THREE-way split,
not a two-way one.
table.decimal(name)with no arguments is knex'sdecimal(8, 2), so the twohalves of one command never agreed with each other either. The card's
data_typeread could notsee it.
2. The
DECIMAL(5,2)truncation — measured, and the inference is CORRECTEDThe order is explicit that this was an inference off the DDL literal and that a rounding or a
validating generator path re-opens the whole weighting. Executed on PostgreSQL 16.13:
(
33.336arrives as33.34); it does not truncate. The loss is silent either way.And nothing upstream prevents it — this is the half that decides whether the weighting re-opens.
Driven through
validateRecorditself:⇒ The generator path neither rounds nor validates. For a field that declares no
scaletheCOLUMN is the only thing deciding, and a narrow one silently alters data. The weighting that made
option A insufficient stands; only the word "truncate" was wrong.
record-validator.tsstates theplatform's own position on exactly this (#7501, maintainer ruling 2026-08-11): an over-scale value
"is refused the way an out-of-range one is; silent rounding is silently altering data".
summaryhas NO seam at all — it is platform-computed andvalidateRecord's type doorexcludes it — so for that member the column is the only guard there has ever been.
Where
65,30comes from — both numbers are dialect maxima, not tasteNine-value corpus, live PostgreSQL 16.13, written and read back through the driver's own pg type
parsing. Control:
12.5, a dyadic rational every candidate holds exactly — 0 of 5 lost it.real's three are the ones that matter:1234567.89reads back1234567.9andNumber.MAX_SAFE_INTEGERreads back9007199000000000. That is the money-fidelity defect thecard named, as a reading rather than an argument.
65and30are MySQL's documentedDECIMALmaxima — the binding constraint among the dialectsthis platform speaks (PostgreSQL's ceiling is 1000 digits, SQLite has none).
numericis NOT portable: measured through knex's compilers,decimal(name, null)compiles todecimalonpg, tofloatonbetter-sqlite3, and THROWS onmysql2("Specifying noprecision on decimal columns is not supported"). A stated pair is the only spelling all three
accept, which is what the ruling asked for.
The residual bound, stated rather than assumed. An exact decimal is bounded where a float is
not: magnitudes below 1e-30 round to zero and magnitudes at or above 1e35 are REFUSED, where
realkept about seven significant digits out to ~1e38. A refusal is loud and the rounding itreplaces was not, and the sql format's
numeric(18,2)already refuses everything at or above1e16 today.
THE FOSSILS, quoted
SqlDriver.createColumn's float arm — the fossil for the SQLite affinity choice:The ADR-0113 reasoning at the end of
createColumn, for the nullability half:Neither fossil contradicts these instructions. Both are carried into the new arms verbatim rather
than summarised away.
SQLite, per type — the constraint the card raised, answered
knex 3.3.0 / better-sqlite3, compiled DDL and live storage class:
Per type, for every type moved out of the float arm:
number/currency/percent/slider/progress/summary— BYTE-IDENTICALSQLite DDL to the float arm they leave.
ColumnCompiler_SQLite3.prototype.decimalis theliteral
'float', the same stringfloatingresolves to. They keep REAL affinity, the fossil'sleak stays defeated, and SQLite applies no precision and no scale — so the exactness this table
buys is a PostgreSQL/MySQL property and SQLite behaves exactly as it does today.
rating— moves to INTEGER affinity.4is stored as the integer4rather than the real4.0, and4.5is still accepted as a REAL: SQLite refuses no fractional value, so nothingthis dialect accepts today stops being accepted. The refusal
ratinggains isPostgreSQL/MySQL-only.
The
table.stringcontrol in the same run still compiled tovarchar(255)and still storedtext:4.0, so "identical" above is a discriminating reading and not a constant.⭐ A blocking consequence nobody had measured: the READ path
Moving the driver's numeric columns to an exact decimal changes the JS type they read back as.
Measured on live PostgreSQL 16.13:
node-postgres parses
numericto a STRING, and mysql2 does the same forDECIMAL. Drivenend-to-end through
driver.create/driver.find, the first implementation returned 3 of 4numeric fields as strings — a wire-contract break, since
valueSchemaForgives the whole classz.number().finite().formatOutput'snumericFieldspass already exists and already repairs exactly this, but it satinside the SQLite-only arm, on a premise stated in
readPresentationKind's docblock: "The numericrepair stays SQLite-only: it exists for legacy TEXT-affinity columns, which no other dialect has."
This change falsified that premise, so the pass now runs on every dialect and the fossil
sentence is corrected in place rather than left standing. After that, end-to-end:
1234567.89is the discriminating value: on therealcolumn it read back1234567.9.The nullability half — landed HERE, not in a paired PR
Stated as the order requires. It lands in this PR because both generators' emitters are edited a
few lines apart from the numeric arms, and splitting them would put two halves of one file's
rewrite in two reviews. Both formats now take the physical
NOT NULLfromstorage.notNullandnever from
required. Driven on live PostgreSQL 16.13, four declaration shapes, three producers:This unblocks #16294 cause 1. That card's other two causes are not addressed here and stay
on it.
The "new tables only" bound, verified rather than asserted
A table built exactly as the pre-change driver built it (
realnumeric columns, rows in it) wasput in front of
detectManagedDriftalongside a control table carrying the stale-textual shapethe detector DOES report:
⇒ 0 drift findings for the four pre-existing
realnumeric columns, while the control firedtwice in the same run. Nothing here retypes an existing column, plans a migration, or starts
reporting drift over the difference — the additive sync only ever ADDS columns.
numeric field after this lands carries a
numeric(65,30)column beside its olderrealones.That is what "new columns only" means, and it is the shape the ruling chose — the alternative is
the migration of existing data it explicitly declined (「不考虑现有数据」).
Gaps that stay OPEN — stated, not silently assumed
currencycolumns are unknown. No deployment was surveyed.The triage seat's 「无拉动」 is absence of evidence, not evidence of absence, and nothing here
changes that.
documented
DECIMALcaps; no MySQL server was driven. PostgreSQL 16.13 and better-sqlite3 were.precision nor scale, so "the six members are unchanged there" is a statement about affinity and
DDL bytes — it is not a claim that SQLite gained exactness.
--format sqlremains a PostgreSQL-only claim (os generate migration's audit-stamp columns diverge from driver-sql — the generators emitNOT NULLwhere the driver emits nullable, and the SQL format emitsTIMESTAMPwhere both knex paths yieldtimestamptz#15521). Neither generator reproduces thedriver's dialect branching, and this PR does not change that.
Full file surface
Cross-lane by design — the type table cannot be split across two PRs without producing two
spellings of it.
⛔ No governed path is touched: nothing under
docs/adr/,.claude/,skills/,AGENTS.md,CLAUDE.mdorcontent/docs/releases/.generate-multiple-json-column.pin.test.ts— the VEHICLE changed, the subject did not. Thatpin is about
multiplenot deciding nullability;requiredwas merely how a NOT NULL was spelledwhen it was written. It now spells the constrained case with
storage.notNulland asserts therequired-only case is nullable BESIDE it, which is the half that would catch this change beingsilently reverted.
#16693 is in flight on adjacent text — left alone
Card #16693 has commits pushed against
packages/spec/src/conversions/registry.ts'sfield-required-notnull-explicitentry and theADR-0113 sentence in
sql-driver.ts. ⛔ Neither was edited here. This PR quotes the ADR-0113sentence and adds a separate
#16318note elsewhere in the file; the conversion registry is notin this diff at all. If that text moves under this branch, the two land independently.
Verification
Red-before / green-after with the same command, both outputs quoted above (7 of 7 diverge → 0 of
7), plus:
NUMERIC_COLUMN_SCALEmutated 30 → 2 in the spec source, themutation proved on disk (blob hash moved off the HEAD blob; the anchor count moved 1→0 and
0→1), the pin run RED (exit 1, 1 failed / 4 passed), restored with
git checkout HEAD --, therestore proved byte-identical to the HEAD blob with an empty
git diff HEAD, and the restoredleg run GREEN (exit 0, 5 passed). Trap-guarded on EXIT/INT/TERM with an absolute path.
pnpm --filter @objectstack/driver-sql test— 162 files passed, 10 skipped; 2449 tests passed.pnpm --filter @objectstack/cli exec vitest run --project unit— 186 files, 2561 tests, allpassing after the pin above was updated (it was RED first, on exactly the assertion this change
reverses).
pnpm --filter @objectstack/cli exec vitest run --project integrationon the generator pins —36 tests; the new pin is integration-tier (it value-imports the driver) and runs 8 of them.
pnpm --filter @objectstack/spec test— 465 files passed, 1 skipped; 12969 tests passed. (Thenew pin is 5 of them.)
typecheckfor all three packages — exit 0.pnpm --filter @objectstack/spec check:generated— was red onapi-surfaceandexport-origins, regenerated with the two commands it named, and both are committed.Gate coverage was derived, not guessed —
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, all 83 run,then reconciled. The reconciliation line, verbatim:
81 of 83 exit 0, and 2 exit 3 — NOT MEASURED, never a pass:
pnpm check:dual-build-cjs-loads—PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/.It names 12 packages this diff does not touch (studio,client-react, the connectors, the plugins). It needs a whole-monorepopnpm build, which doesnot fit this box's foreground budget; CI builds everything and runs it there. Declared, not
skipped.
pnpm check:i18n-coverage— same cause, one layer out:os lintcould not loadexamples/app-showcase/objectstack.config.tsbecause@objectstack/connector-mcphas no buildoutput in this worktree. Its own text says "Nothing was compared… this result says NOTHING".
pnpm check:type-check-debt, first exited 3 with a V8FATAL ERROR: Ineffective mark-compacts near heap limitunder--max-old-space-size=4096on thisshared box — its own text says that is "NOT a pass and NOT a finding". Re-measured at 8192 it
exits 0. The pass above is that second run, not the OOM.
The gate list was re-derived against a freshly fetched
origin/mainafter the sweep: identical, 83families, none added and none dropped, so nothing newly landed upstream is owed.
Level
minoron all three packages — a widening takes at leastminor, andmajoris refused in thelaunch window, so breaking-ness rides the BREAKING banner in the changeset plus its ADR-0087
disposition (
not-required (no-migration-prescription): nothing an author writes is removed orrenamed, so there is no FROM → TO edit to prescribe).
packages/drivers/driver-sql/src/is aNESTED package dir, which the level axis cannot see (#16713) — it is graded
minorhere anyway,and that grading is the author's, not the gate's.
🤖 Generated with Claude Code
https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg