Repository navigation
fix(spec)!: defineSeed refuses a record key the target object does not have - #22294
Conversation
… have The record type admitted any key whenever TypeScript's excess-property check could not see the record (a spread of untyped rows, a variable, an object typed ServiceObject), and it refused the injected system columns (created_at, owner_id, ...) in a record literal. defineSeed now judges every record when it runs against the object's declared fields plus the system columns resolveInjectedSystemColumns gives that object, and the record type admits the injectable column names. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn
The seed-data page promised a compile-time check of every record key; it now states the two halves (TypeScript's excess-property check on a record literal, and the call-time check of every record) and the system columns a record may carry. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn
…stemColumnName Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn
…n table The refused keys never named a column, so the repair is the author's edit of a typo and there is no rewrite for the ADR-0087 ledger to carry. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn
📓 Docs Drift CheckThis PR changes 1 package(s): 1 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 — 139 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 5d2aae7808ed8fc95c09602b5d10f40bd6ef59b4 && git checkout 5d2aae7808ed8fc95c09602b5d10f40bd6ef59b4
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d1dbe70ebdee39a8e4cfee10da0fb7f1c8086af4 d82844b9ec2a0dd81e245ce7461e8884920d82b1 && git checkout -B drift-repro d1dbe70ebdee39a8e4cfee10da0fb7f1c8086af4 && git merge --no-ff d82844b9ec2a0dd81e245ce7461e8884920d82b1
node scripts/docs-audit/affected-docs.mjs --json d1dbe70ebdee39a8e4cfee10da0fb7f1c8086af4
|
The diff also widens two published surfaces: the record type admits the injected system column names, and InjectedSystemColumnName is a new export of @objectstack/spec/data. Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DhTqaEHqPVSVnAkjG3jywn
Contract reviewServed-tier: Rendered 2026-10-08T13:09Z by an isolated at-tier subagent of the dispatching seat's session, on PR #22294 for card #22149. Inputs: the card body and its six comments, the PR body and file list, the net diff ① Derived judgmentsEvery accept-set and public-surface change the diff implies, each judged on the diff:
② Semver levelThe changeset
③ Boundary flagsEvery dev deviation, every out-of-scope finding and the
Implemented-by: VERDICT: PASS Generated by Claude Code |
⛔ merge queue 构建失败 — 先分诊,再决定要不要重排队列构建 37785508890 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集), 分类: 失败的 job(日志抽取,best effort):
跨 PR 相同签名(24h,按失败测试文件聚合):
历史信号:
分诊清单:
Generated by Claude Code · merge-queue-triage workflow (#4859) |
|
Queue ejection, triaged: not this PR's failure; one re-queue ·
Generated by Claude Code |
Fixes #22149
Clause-②: yes (narrowing: a misspelled seed key is refused where it passed, while the record type now admits the injected system columns and
InjectedSystemColumnNameis a new public export, which widen the published surface)What changes
defineSeed(obj, config)checks every record key when it runs. A key must be a fieldobjdeclares or a system column the platform injects onobj. That set isresolveInjectedSystemColumns(obj).names, the per-object answer the registry's ownapplySystemFieldsreads. Every unknown key is refused in one error naming the object, the record index and the key, with a near-miss suggestion. The shape is the oneObjectSchema.create()uses for an unknown object key:The record type admits the injectable system columns. The new exported type
InjectedSystemColumnNamecomes frompackages/spec/src/data/injected-system-columns.ts: a record literal writingcreated_atorowner_idnow passestsc, where it used to be refused. The type cannot evaluate an object's opt-outs, so the call narrows it per object.created_aton asystemFields: falseobject, orowner_idon anownership: 'org'object, is refused when the call runs.The docblock and
content/docs/data-modeling/seed-data.mdxsay exactly what each half enforces. The compile-time half is TypeScript's excess-property check on a record literal. The call-time half checks every record.Which leg, named with the measurement (triage asked the claimant to name it): both. The type is exact wherever TypeScript's excess-property check runs, but that cannot hold for every record or object shape. So the check that holds for every record is the call-time one.
os validate,os buildand boot reach it by evaluating the config module, so nopackages/cliedit was needed.Premises, measured
stringkeys for anObjectSchema.create()object: FALSIFIED. Compiled hotcrm's owncrm_caseobject (src/service/objects/case.object.tsat hotcrm99d290a) against the published@objectstack/spec17.7.0 tarball.keyofitsfieldsis 23 literal keys, and an inline{ subject, created_atx }record is TS2353. What actually silences the check is the shape of hotcrm'scasesseed: itsrecordsarray also spreads an array typed as a string-keyedRecordof unknown values. That spread turns off excess-property checking for every inline record beside it. Probe: inline misspelling plus that spread compiles clean, and the control (inline misspelling plus a spread of narrowly typed rows) is TS2353. The other holes measured on the source: records from a variable, and an object typedServiceObjector anObjectSchema.parse()result, whose keys arestring. The same type refusedcreated_atin a record literal (TS2353 on the 17.7.0 types too), so hotcrm's legitimatecreated_atcompiled only through the spread hole.os validateevaluates the seed module: HOLDS.loadConfigevaluates the config throughbundleRequire, which runs every module-leveldefineSeedcall. Measured onexamples/app-todothrough the source CLI entry (packages/cli/bin/run-dev.js validate), with a misspelled keycategroyinjected into a seed record. Before (the call-time check disabled in spec'sdist, marker proven present in 4 built files): exit 0, "Validation passed". After: exit 1,defineSeed('todo_task'): unknown field(s) in records — categroy, with "Did you mean 'category'?". Controls: the unmodified app exits 0, andcreated_atinjected into a record exits 0, "Validation passed". Every injection was restored and hash-verified to the HEAD blob.resolveInjectedSystemColumns(def).names(packages/spec/src/data/injected-system-columns.ts) is "every column addressable on this object without being authored". It holds the driver'sid, plusorganization_id, the four audit columns,owner_idandowning_business_unit_idas the object'ssystemFields,tenancy,ownershipandmanagedByselect them. The registry'sapplySystemFieldsconsumes the same plan. No second list is written: the type-level union is read off the same constants, and the function now buildsnamesas a Set ofInjectedSystemColumnName, so a column it starts adding without widening the union is a compile error. What it answers is existence, not "the loader honours this value": see Acceptance notes.Landing outside the claimed file surface
packages/spec/src/data/injected-system-columns.ts: one exported type plus the typed Set. The type half has to admit the system columns without a hand-written list, and the declared source lives here.content/docs/data-modeling/seed-data.mdx: the page made the same promise the docblock made (lines 7-9 and 48: TypeScript validates every record key), quoted a stale error text, and showed a stalerecordssignature. Now it states both halves. Its CEL example writingcreated_at,owner_idandorganization_idinline compiles under the new type.packages/spec/api-surface/data.jsonandexport-origins/data.json: regenerated bycheck:generated --fix, one added type entry each.Tests
Final gate union at
d6ab9ae9c. The spec suites ran at2e559a83d. The only commit since,d6ab9ae9c, touches only the changeset file.New
packages/spec/src/data/define-seed-record-keys.test.ts, 9 cases. The docblock's own unknown-key example failstsc(a@ts-expect-errorin thetsconfig.test.jsonprogram; the file is in its 2283-file--listFilesOnlylist) and is refused when it runs. Also refused when they run: a misspelling beside the spread of untyped rows, records from a variable, an object typedServiceObject, and several unknown keys collected into one error. The per-object plan refusescreated_atonsystemFields: falseandowner_idonownership: 'org', whileidis still accepted. Controls: a declared-fields-only seed passes and returns the parsed seed. The system-field control passes:created_at,id,owner_idandorganization_idpasstscand the call.Ablation U1 (runtime check disabled by an early return): 7 refusal cases red, 2 controls green. The first U1 attempt was a no-op: the replacement still contained the anchor, so the tool refused and restored, and nothing ran. Re-anchored, the mutation landed (anchor 1 to 0).
Ablation T1 (
SeedRecordadmitting any key): the test file's directive becomes TS2578 "Unused '@ts-expect-error' directive". The narrow program with the sametsconfig.test.jsonsettings is clean on HEAD.Door ablation and its restore leg. Described under H2. After restoring, spec was rebuilt; the marker is absent from all 232 built files, and the whole tree is clean against HEAD.
pnpm --filter @objectstack/spec test: 626 files, 18670 passed, 1 todo.test:repo: 53 files, 903 passed.typecheck: exit 0 (srctsc, scripts, and the test layer held bytest-typecheck-debt.json).Consumers. The
defineSeedcallers were re-taken from the tree:examples/app-crm,examples/app-showcase,examples/app-todoandpackages/qa/dogfood.organizationsandplugin-securityonly mention it in comments, andspec/scripts/schema-index.test.tsholds it in a string fixture. Results:example-todo: 7 files, 238 passed;typecheckexit 0.example-crm: 5 files, 45 passed;typecheckexit 0.example-showcase: 33 files, 408 passed;typecheckexit 0.seed-ownership-claim-dispatch.dogfood.test.ts: 1 passed;typecheckexit 0.Each seed module is in its package's
tscprogram (--listFilesOnly). The rest of the dogfood suite is declared to CI.Records the narrowing refuses: none. Every seed module was evaluated against the rebuilt
dist. app-crm passes (5 seeds, 28 records), app-showcase passes (19 seeds, 132 records) and app-todo passes (1 seed, 8 records). In the same resolution context, a misspelled record throws, which is the control. hotcrm at99d290a, with@objectstack/specresolved to this build: all 8 seed modules pass (354 records, including thecreated_atits case seeds author), and itscrm_casewithcreated_atxis refused.Gates.
dispatch-gates --commandsderives 108 (the dispatch list's 86, plus 22 docs families from the page edit,check:generatedandcheck:skill-examples). Run atd6ab9ae9c: 108 run, all exit 0.--ranverdict: "108 derived famil(ies) accounted for — 108 run, 0 NOT-MEASURED (a DERIVED zero — all 108 recorded an exit code and none of them is 3)". An earlier pass at2e559a83dhad three non-zero results, all resolved:check-adr-0087-registrationexit 1: a FROM to TO table contradicted theno-migration-prescriptiondisposition. The remedy is now prose, as precedent22019does.check:skill-examplesandcheck:dual-build-cjs-loadsexit 3: PREREQUISITE NOT MET, unbuilt packages. After the prerequisites were built, both exit 0.Lint, a declared narrowing. The repo sweep is CI's. ① Population: the
eslint.config.mjsfilesglobs match only code extensions, so this diff's lintable files are the 3 changed.tsfiles. The other 4 are.md,.mdxand.json. ② Count: eslint--format jsonreports 3 files, 0 errors, 0 warnings. ③ Invariance: the config enables no type-aware linting (noparserOptions.project, noprojectService), so this diff cannot move the verdict on an untouched file.Changeset
minor, BREAKING, the launch-window grade for accept-set narrowings. This is not a type-only change. The refusal is a call-time verdict, reached atos validate,os buildand boot. ADR-0087 disposition:not-required (no-migration-prescription). No key, spelling, export or stored shape moves; the only export change is an added type. The repair is the author's edit of a misspelled key, which no ledger entry can derive.Acceptance notes (noted, not filed)
skills/objectstack-data/references/seeds.md(governed, not edited here). Lines 5-6 say TypeScript checks every record's keys, which holds only for record literals; the call checks every record. Line 54 gives therecordstype as a partial record over the object's field keys. That is stale: the type isSeedRecord, which adds the injectable system columns and narrows reference values. Its CEL example (lines 114-121) writescreated_at,owner_idandorganization_idinline; it compiles under the new type.defineSeedget no authoring-time key check. That covers a plain seed literal indefineStack({ data }),SeedSchema.parse(), and a runtimeseeddraft. For these, the engine's declared-field door on insert (undeclaredWriteFieldErrors,INVALID_FIELD) is what refuses an undeclared key. That is a code reading, not measured here.updated_atbecause the column exists, but the insert audit stamp overwrites an authoredupdated_at(onlycreated_atis kept for a seed). That is a code reading of objectql's audit binder: value semantics, not this card.unknownvalues. Forowner_id,created_by,updated_by,organization_idandowning_business_unit_idin a record literal, the value type isunknownunless the object declares the field itself. SoSeedFieldValue's natural-key narrowing does not apply to them. Their definitions are not literally typed, so the narrowing cannot be derived from the source without a second list.code. It is a plainError, the same asObjectSchema.create()'s unknown-key refusal. A code would be a new ledger entry, a naming decision not taken here.Generated by Claude Code