feat(spec): declare id_field a retirement with no successor on FieldSchema - #17060
Merged
Conversation
…dSchema `FIELD_KEY_GUIDANCE` gains an `id_field` entry so objectui's ingestion choke point has the declared spec-side fact it needs to canonicalise the retired spelling (objectui#7650 ruling A). A lookup stores the referenced record's id and the picker resolves record identity itself, so there is no `FieldSchema` member to fold onto; the prescription names `displayField` and a seed dataset's `externalId` instead. The entry is keyed in snake_case on purpose: `to` rows become `strictObject` aliases matched through `aliasProbe` (case and separators folded), while `why` rows become strict guidance matched exactly and case-sensitively, so a camelCase row would never be reached. Three new assertions read the parse channel that actually answers an authored field key, which is the reachability the existing table tests never asserted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
…-field-canonical-target
Contributor
📓 Docs Drift Check
What this run could not see
Coarse fallback — 131 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): |
os-bill
marked this pull request as ready for review
September 9, 2026 05:52
This was referenced Sep 9, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #16632
Clause-②: no
The direction call first, because it is the first deliverable and it is a factual finding, not a preference.
Direction:
id_fieldhas NO canonical successor onFieldSchema— awhyentryThe cheaper branch is also the measured one, so here is the evidence rather than the verdict alone. Four independent readings on this tree (
022f6d23), all re-derived, none inherited from the card:FieldSchema(packages/spec/src/data/field.zod.ts:849) declares the whole lookup-picker group —reference,referenceVia,displayField,descriptionField,lookupColumns,lookupPageSize,lookupFilters,dependsOn,allowCreate— and nothing that names which field holds a lookup's stored value.packages/objectql,packages/runtimeandpackages/metadata-protocolcontain zero readers of a per-field id key. The oneidFieldidentifier inpackages/runtime/src/action-execution.ts:696is a local bound fromaction.recordIdField— anActionkey, a different surface.APPROVER_VALUE_BINDINGS(packages/spec/src/automation/approval.zod.ts:167) types each approver as{ source: 'record'; object: string; valueField: 'id' | 'name' }and pinsposition: { object: 'sys_position', valueField: 'name' }. That is precisely the example objectui's ownLookupFieldMetadata.idFielddocblock cites (an approval position approver stores sys_position.name, objectstack 审批节点「处理人 Value」应改为记录 lookup(现查 metadata 端点→只能手填);附 approver value 语义核实 + queue 未实现 #3508) — so the capability exists, but the platform decides it per approver type, not the author per field. The other channel is a seed dataset'sexternalId(packages/metadata-protocol/src/seed-loader.ts:320,:561), through which lookup/master_detail references already resolve.content/docs/releases/v17.mdx:493distinguishes the eight resolved-side picker keys exactly this way: six "areFieldSchemakeys, resolved at runtime from the referenced field's own metadata", while "idFieldandtitleFormatwere never authorable on either side — the picker resolves record identity itself".So
id_fieldis thewhybranch, and per the acceptance criterion the entry is a prescription, not a bare "retired": it names why (identity is not an authored per-field choice) and what to reach for instead (displayFieldfor the label, a datasetexternalIdfor a portable natural key).retiredKey()was considered and rejected: its own contract is "Declare a key that has been REMOVED from the spec" (shared/retired-key.ts).id_fieldwas never in the spec, which is theindexed/immutable/filterable/startingNumbershape — awhyrow.The same-named GridColumn key is a DIFFERENT schema
⛔ Do not unify them.
git grep -c idField packages/spec/src/data/field.zod.tsstill returns 2, and neither hit is aFieldSchemamember::837idFieldonInlineGridColumnSchema(declared:804) — the strictinlineColumnsmirror of objectui'sGridColumn. Live, and it stays live.:789packages/fields/src/widgets/GridField.tsxThe
:789hit is not merely incidental:GridFieldliterally contains the substringidField("GridField".includes("idField") === true). A word-boundary count is 1, not 2:displayFieldis simultaneously a member of both schemas with different meanings, which makes "find the target by name" worse still. The third new assertion pins both halves:InlineGridColumnSchemastill acceptsidField, andFieldSchema's shape still does not carry it.The entry is keyed
id_fieldin snake_case, and the spelling is load-bearingTriage warned that pasting the card's snake spelling into a camelCase table would file a row the lint never hits. Measured, the answer is the opposite of the fear, and for a reason worth writing down: the two channels this table feeds do not agree on the key face.
tobecomes astrictObjectalias, and aliases are indexed byaliasProbe—key.toLowerCase().replace(/[_\-\s]/g, '')(shared/alias-probe.ts). Its contract says so: "Map keys are matched case-insensitively with_/-/ space separators removed." One camelCase row covers every separator spelling; therollupcomment infield.zod.ts:866says exactly this.whybecomes strict guidance, and that channel isguidance[key]with no probe (strictUnknownKeyError,shared/suggestions.zod.ts:445). Its contract: "Matched case-sensitively (exact authored spelling)." This file's own comparator does the same (guidance[key]inlintAuthoredRecordKeys).So a camelCase
whyrow would never be reached byid_field, the spelling objectui'sresolveActionParamsreads off an object-schema field def and the one this card exists for. A NOTE in the table records this so nobody "normalises" the row to match its neighbours.The reading that proves the entry is REACHED
An assertion that cannot fail and an assertion that passed look identical from the outside, so this PR ships the reading, not just a green tick.
The channel is the parse, not the lint.
FieldSchemais astrictObjectthat pulls this table in viafieldKeyGuidanceAsStrictOptions()(field.zod.ts:762,:854,:888), and the authoring-key walker is silent on a strict surface by its own posture rule (kernel/metadata-authoring-lint.ts:strict→ silent, "the parse is loud on its own"). Triage suggested proving reachability by making the lint fire on metadata carrying the key — on this tree that would have proved nothing, because the lint never fires here.Before (measured on
de1a611, both spellings, bare refusal — no rename, no prescription):After, from
FieldSchema.safeParse({ name: 'account_id', type: 'lookup', reference: 'crm_account', id_field: 'name' }):Two ablations, both restored byte-exact from
HEAD(git diff HEADempty, blob hash back to the HEAD blob), each with its on-disk mutation proven by anchor counts before and after — the editor's exit code was not taken as evidence. These resolve throughsrc(a relative import inside the same package, not a dependency'sexports), so nodistleg applies.id_field→idField1 → 0, camel0 → 1; blobd9d2b4d→ba40ee0...fieldKeyGuidanceAsStrictOptions().guidance→{}), row left in the table1 → 0, marker0 → 1; bloba05e28e→c8e3ecd; table still holds the row (1)The second is the decisive one: the row is present and the parse message reverts exactly to the pre-change bare refusal, so the assertion is live rather than vacuously satisfied by the table lookup.
One honest note: the first attempt at the second ablation was a no-op —
perl -0pi -eaborted on the replacement text and exited without writing. The anchor counts caught it (after: old=1 marker=0, blob unchanged), so itsexit 0was discarded as NOT MEASURED rather than read as green; the table above is the redone run.Clause-②— the measured answer flips it, and that is the triage seat's call, not mineThe claim comment (5595423887) records
Clause-②: yes, soneeds:contract-reviewis hung with this PR as declared. But the direction landed onwhy, and triage's own rule (5578761558) is explicit: "若答案是why(无后继,只在 guidance 表里加一条)⇒ ⛔ 没有 schema 变化,Clause-② no,本枚标应当摘掉". No published schema gains or renames a member here —check:api-surface,check:export-originsandcheck:authorable-surfaceall pass unmoved, confirming it. ⛔ I have not removed the label: triage asked to be told and said it would retire it. Flagged, not decided.Scope
title_formatis deliberately untouched. Comment 5590745899 measured it as a second key with no declared target and said of it: "That is a maintainer ruling, not mine; recording the measurement so the ruling is taken on eight keys rather than four." Widening this card would re-block objectui#7650'sid_fieldslice, which is the one thing it exists to unblock. Reported upward, not filed and not fixed here.For the landing seat
Once this merges, objectui#7650 needs a reply so its
id_fieldslice can release itsBlocked-by. The declared fact its ingestion choke point reads isFIELD_KEY_GUIDANCE.id_field.why—FIELD_KEY_GUIDANCEis a published export of@objectstack/spec/data(api-surface/data.json:260), the subpath objectui already consumes — so the choke point drops the key with that sentence as its loud diagnostic instead of folding it onto a member that does not exist. ⛔ Nothing was pushed to the objectui repo from here.验收备注
Triage's five criteria (5578761558), each answered above:
why, on four measurements, and the reasoning against thetobranch is stated rather than assumed.whyrow gives an executable alternative in the style of theindexrow —displayFieldfor the label, a datasetexternalIdfor a portable natural key, not a bare "retired".field.zod.ts:837; the two same-named keys are named as different schemas — its own section, plus a pinning assertion, plus theGridField-contains-idFieldsubstring trap that makes the naive count read2.Noted, not filed (out of scope, no card opened):
title_format— routed upward per the scope fence, not filed.idFieldauthored on aFieldSchemagets a bare refusal with no rename suggestion today (measured: the edit-distance fallback offers nothing for it), so there is no confident-wrong advice to correct and no defect here. Left alone deliberately — the guidance channel's exact match means covering it would be a second row, which is beyond this card.auditTrail,dataQuality,encryptionConfigand the other camelCasewhyrows have the same exact-match property: a snake_case authoring of any of them reaches no prescription. Observation only; no card, and no evidence any of those spellings circulates.Verification
pnpm --filter @objectstack/spec build && … test && … typecheck— 467 test files, 13103 tests passed, typecheck clean, on the merged head022f6d23(VERDICT command-exit 0fromos-verify-lock.sh).node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, reconciled with--ran: "76 derived famil(ies) accounted for — 76 run, 0 NOT-MEASURED"), re-derived after the merge and identical. 73 green; 3 return exit 3PREREQUISITE NOT MET(check:dual-build-cjs-loads,check:lean-entry-closure,check:type-check-debt) because they read built output of packages this worktree has not built — that is nothing measured, neither a pass nor a finding, and it is left to CI's full build.check:doc-formula-expressionsreturned the same exit 3, was given its named targeted prerequisite build, and then passed.pnpm exec eslint . --no-inline-config— the full repo union, not a narrowing: 6398 files, 0 errors, 0 warnings, at022f6d23.pnpm check:nul-bytesgreen, plus a direct control-character sweep of the changed files.origin/mainmerged once (022f6d23) before opening.🤖 Generated with Claude Code
https://claude.ai/code/session_01MkQhmuuJAVDjmeWNixwDDH
Generated by Claude Code
Generated by Claude Code