fix(objectql)!: refuse a field whose type is absent or not a FieldType member at the registration door (#16319) - #17444
Conversation
…door Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU
…ctql fixtures Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU
…e two downstream defences Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU
…-type class Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU
…ratchet holds Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU
…e triage graduated Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU
📓 Docs Drift CheckThis PR changes 5 package(s): 20 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 3 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 45 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 ddf89d6de8ac6c4157083773f10213d5189bb12b && git checkout ddf89d6de8ac6c4157083773f10213d5189bb12b
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e758131b3900eb13260f03643e295ca6d625c42b 4be5d181258262cff3033caf829a42256c83c760 && git checkout -B drift-repro e758131b3900eb13260f03643e295ca6d625c42b && git merge --no-ff 4be5d181258262cff3033caf829a42256c83c760
node scripts/docs-audit/affected-docs.mjs --json e758131b3900eb13260f03643e295ca6d625c42b
|
… member the door admits
`CROSS_FIELD_OBJECT_FIELDS` declared `stage` and `owner` as `type: 'string'`.
`string` is absent from `FieldType`, so this PR's own registration door refused
the whole `cross_field_deal` object and the runtime suite's `beforeAll` threw
before its subject ran — CI shard `Test Core (3/6)`, 1 failed | 250 passed.
`text` is not a re-typing. `canonicalizeSqlType('varchar(255)')` is `'text'` and
`suggestFieldTypeForSqlType('varchar(255)')` is `'text'`, both pinned in
`spec/data/type-compat.test.ts`, so `text` is the spelling of the column
`string` was already producing. Matches `id` / `organization_id` in the same
map and the text columns of `CROSS_FIELD_OFFSET_OBJECT_FIELDS` below it.
The emitted column does move `varchar(255)` -> `TEXT` (measured on sqlite-wasm),
which is inert here: no index keys either column, `initObjects` is passed no
indexes, and the longest value in the corpus is four characters. All four
`initObjects` consumers stay green.
A static census over the tree — `fields:` regions resolved through cross-package
imports, classified as ObjectStack field maps, `type` checked against the
`FieldType` union read out of `field.zod.ts` — reports these two as the only
non-member declarations reaching a `registerObject` door. After this change that
count is zero; the 108 that remain all reach `driver.initObjects` only, which the
changeset's scope section deliberately leaves alone.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 34500588865 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
…eld-type-refused-at-registry
…lean`
`packages/qa/dogfood/test/fixtures/schedule-organization-fixture.ts` declared
`touched: { type: 'checkbox' }`. `checkbox` is not a `FieldType` member — the
enum spells the multi-value checkbox GROUP `checkboxes` and the scalar flag
`boolean` — so this PR's registration door refuses the whole `sched_org_target`
declaration and both #16659 pins fail at `bootStack` on `[sqlite-wasm]` and
`[memory]`.
Neither side is wrong alone. The declaration landed in ecdfc94 (#17334),
after this branch's merge base `d57611dfd3`, where nothing refused it; the door
is this card's deliverable. The merge queue tests the PR merged into current
`main`, which is the first place the two meet — so this is the merge, not a
defect in either diff.
`boolean` and not `checkboxes`: the fixture's `update_record` node writes
`touched: true`, and both pins read it back as a scalar
(`r.touched === true || r.touched === 1`, `Boolean(r.touched)`). `checkboxes`
stores a JSON array of option values, whose EMPTY set is truthy — the sweep
pin's differential would then report rows nothing touched as touched. The
member is chosen from the enum's own grouping and from what the tests assert,
not from the refusal message's Levenshtein suggestion.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU
Fixes #16319
Clause-②: yesRuled by the dispatching seat and posted as a corrected claim: #16319 (comment) — same session, same branch, same assignee, so the original claim stays governing and this PR posts no second one.
needs:contract-reviewis hung on both carriers (node scripts/pm/check-clause2-carriers.mjs --pair 17444exit 0); the seat clears it in the same stroke as the review PASS.A field declaration whose
typeis absent, or is not aFieldTypemember, is now refused at the registration door, and the whole object declaration goes with it. Every downstream family default the card measured becomes a loud refusal instead of a guess.Maintainer ruling, 2026-09-10 (director seat batch #111 item 2), verbatim: 「16319 一个没写 type(或拼错)的字段 应该禁止加载。这个才是合理的吧?其他同意」 — implemented per the director seat's derived shape in that comment, not per this PR's summary of it.
What was wrong
One declaration produced two different columns. Re-derived by symbol on this branch's own head; the live PostgreSQL 16.13 table is the card's:
os generate migration --format sql--format ts{ maxLength: 100 }, notypecharacter varying(100)TEXTTEXT{ type: 'this_is_not_a_field_type', maxLength: 100 }character varying(255)TEXTTEXTSqlDriver.createColumnreadfield.type \|\| 'string', which heads its STRING-family arm and sizes the column from the declaredmaxLength. All fouros generateloops readString(fieldDef.type \|\| 'text'), which heads the TEXT family. Both directions of harm are in row 1: the platform refuses a 101-character value both generated tables accept.What this does
SchemaRegistry.registerObject. It refuses the WHOLE object declaration with the ADR-0112 envelope (INVALID_METADATA+422), naming the object, the field and the reason, with the spec's own "did you mean?" for a mis-spelling. All three contributor kinds (own/overlay/extend) are judged —ObjectSchema.fieldsandObjectExtensionSchema.fieldsare bothz.record(z.string(), FieldSchema). ⛔ The field is never dropped on its own.loadMetaFromDb's startup policy is revised for this class. 「Registered anyway so it stays serveable and fixable」 no longer applies: the row does not register, and the log states the consequence and the fix once, aterror. It is suppressed only for this class, through the door's OWN predicate — the boot log never grows a second opinion about what the registry admits.createColumnrefuses a field with notype; all fouros generateloops refuse an absent or non-membertypeand generate nothing for that object.fieldTypeToSql's docblock is rewritten in the same stroke.The shared vocabulary lives in
@objectstack/metadata-core(object-field-type.ts) for the ordinary reason: the enforcing door is objectql's and the reporting seam is metadata-protocol's, and objectql depends on metadata-protocol, so the reverse import is a cycle turbo refuses.The ruling's implementer PRECONDITION — measured, not argued
It does not.
getMetaItemsALWAYS consultssys_metadataand merges its rows in; it never serves the registry alone. Pinned inregistry-field-type-refused-at-door.test.ts§4: a refused row is still listed, still served with the offending field visible, still accepts a corrected write (which then loads on the next boot), and is still deletable.deleteMetaItemgets more permissive, not less: with nothing registered,isArtifactBackedis false and the DB-only branch applies (objectisallowRuntimeCreate: true). No stop condition fired.Clause-② — RESOLVED:
no→yesINVALID_METADATAis already registered under@objectstack/objectqlinERROR_CODE_LEDGER(4 hits) and this PR touches no ledger file; the driver reuses the standard-catalogVALIDATION_ERROR. ⛔ No new error code is minted, so the ruling's own stated flip condition did not fire.⭐ The declaration flips for a reason the ruling never anticipated. This PR adds new exported symbols to
@objectstack/metadata-core—findUndeclarableFieldType,describeUndeclarableFieldType,isDeclarableFieldType,isObjectFieldTypeRefused,OBJECT_FIELD_TYPE_REFUSED_ERROR_NAME, plus three types — and contract-review's mechanical floor is 「新导出符号…恒yes」. The dispatching seat re-measured rather than accepting the reading: all five read 0 onorigin/maind57611dfd3withindex.tsitself as the firing control. Both conditions had to be checked; only one fired, and it was not the written-down one.This PR raised it rather than rewriting the declaration, because the declaration is the claiming seat's act. The seat has now corrected it.
@objectstack/objectql's own public surface is deliberately unchanged: the new names are not re-exported from its index, and the error CLASS stays unexported — the #14936 dual-realm convention, where a consumer holding the other realm's copy getsinstanceof === falsesilently, so consumers discriminate on the error'sname. ⛔ No changeset regrade is owed: all five movedpackages/**/src/**packages are gradedminor.Acceptance
registry-field-type-refused-at-door.test.ts§1–2).sys_file.id type='text',sys_file.key type='text',sys_file.name type='text'), so the zero is a reading, not an empty walk. The one module that fails to load iscreate-objectstack's blank template, which declares throughField.text()/Field.textarea().sys_metadatarow leaves the object out of the registry, is named aterror, and stays deletable.Ablation
Fix committed first; mutation proved on disk (
git hash-objectbefore/after) and proved to reachdist/(scripts/ablation-dist-preflight.mjs, marker present in 4 built files); probe imports resolve entirely through packageexports.type)email)loaded=0 errors=1)loaded=0 errors=1)loaded=1 errors=0)loaded=1 errors=0)With the door removed the object reappears and, driven into the driver, row 2 falls back to
varchar(255)— the ruling's own predicted ablation, observed. Row 1 is refused by the driver's own defence instead, which is stronger than the ablation predicted; reported as observed rather than as the template's expectation. A second leg ablated the same statement and ran the new suite: 6 of 12 tests go red, the six that do not being the positive controls and the non-throwing seam test.Restore verified whole-tree, not per path:
git status --porcelainempty,git hash-objectequals the HEAD blob,--absentpreflight exit 0.Scope — the driver's defence is narrower than the door, deliberately (⛔ not a dropped half)
⭐ First, so the commit title
narrow the driver refusal to an absent typecannot be read as half the ruling being dropped: BOTH shapes — absent AND non-member — are refused. They are refused at the REGISTRATION DOOR, for the whole object, which is the single point of closure the ruling asked for and what its acceptance list means by 「驱动永远到不了」. What is narrower is only the driver's own defence-in-depth layer, which the ruling itself describes as 「按构造它们应当不可达,拒绝是防御」.SqlDriver.createColumnrefusestypeABSENCE (the\|\| 'string'default the ruling names), notFieldTypeMEMBERSHIP. Membership is refused for the whole object at the registration door, which fronts every route intosyncSchema— which is what the acceptance list's 「驱动永远到不了」 means. Measured reason for not going further here:driver-sql's own corpus declares 388 non-member spellings across ~100 files ('string'361,'integer'17,'auto_number'5,'varchar'4,'object'1) that driveinitObjectsdirectly and never touch the registry, and'string'is a declaredcasearm of that switch whose column shape differs from every member's — so closing that half is a corpus migration with column consequences, not a spelling fix. A pin holds the boundary in both directions so it cannot move silently. Control for that census:type: 'text'reads 164 andtype: 'datetime'reads 60 in the same corpus.Verification
pnpm build(whole repo) ·pnpm lintin its exact spelling (node --stack-size=4000 … . --no-inline-config) exit 0 ·--format jsonover the same population: 6556 files, 0 errors, 0 warnings, and all 15 changed files are inside it — a whole-tree reading, not a narrowing. All ata05a28520a.@objectstack/objectql4959 passed / 296 files ·@objectstack/metadata-protocol2487 passed ·@objectstack/driver-sql2495 passed ·@objectstack/metadata-core272 passed ·@objectstack/cli--project unit2678 passed (theintegrationtier is declared to CI: the diff touches no spawn entry point or driver/kernel boot path).typecheckgreen on all five packages, and thetest-typecheck-debtledger shrank — see below.node scripts/pm/dispatch-gates.mjs --commands, reconciled with--ran: 69 derived, 69 run, 0 NOT-MEASURED, 0 UNRUN). Two needed a whole-repopnpm buildbefore they could measure at all (check:dual-build-cjs-loads,check:i18n-coverage— both exit 3 = PREREQUISITE NOT MET, never a pass) and are green after it.check:query-options-erasurewent red on the new test files and is fixed, not waived.check:type-check-debtneeded the CI-shaped heap ceiling; at 4096 MB its own tsc re-measure OOMs and it exits 3.⭐ The type-check debt ledger shrank, and WHY is the best finding in this round
packages/objectql/test-typecheck-debt.jsongoes 44 → 40 files / 242 → 234 errors, and the four deletions are not tidying: they are the defect this card closes, seen from the compiler's side.Those four fixtures declared
type: 'longtext' as const(three of them, on asys_metadatametadatafield) andtype: 'id'/type: 'string'(the fourth) — five spellings theFieldTypeenum has never contained. Theas const/ascasts they needed to get pasttscWERE the recorded errors. The debt was a symptom. Once the registration door refuses such a declaration outright, the fixtures had to be corrected to the members the platform's ownsys_metadataobject actually uses (Field.textarea/Field.text), the casts stopped straining, and the entries graduated — which the ledger's own header requires be deleted in the same change.⛔ Nothing was raised to absorb an error: this is the shrink direction throughout. The ledger's
_noteis amended so its seed counts ("242 errors across 44 files", measured at2a181174a6) are not read as this file's current totals.Acceptance notes
Noted, not filed:
createColumn'scase 'string':arm andvarcharColumnChars' matching arm are now unreachable from any registry-fronted path —'string'is not aFieldTypemember, and [finding]sql-driver.tstwice cites aField.stringbuilder that has never existed — and the prose demonstrably steered a ruling onto an unauthorable target #12593 already measured that. Dead-code removal, out of scope here.sql-driver.tscarries 11 declaration-side'string'defaults, not the 7 the dispatch measured or the 1 the card implies:\|\| 'string'at:2411 :9976 :10054 :11951 :16189 :16555and?? 'string'at:10686 :10891 :14753 :14760 :14818; two further\|\| 'string'at:17726 :17728default an INTROSPECTED column's type and are a different question. The dispatch's list of 7 missed:17726and did not separate the??spelling. Only:16555(createColumn) is the ruled default; the rest are comparison classes, coercion-registry fills, candidate sets and error text, all downstream of the same closed door.schema-drift.tscarries 4 more (:549 :1066 :1282 :1297).os generate types' asymmetry between the two shapes (a typeless field emittedstring, a mis-spelled oneunknown) is retired by construction — both now refuse — so thedomain:cliseat's earlier note on it needs no card.🤖 Generated with Claude Code
https://claude.ai/code/session_01XTBcV7zZHmokdyQgXjbyEU
Generated by Claude Code