Skip to content

fix(metadata-protocol): diagnostics stop flagging picklist and options on a field that declares only picklist - #26953

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/project-thread-f75ut7
Oct 28, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/project-thread-f75ut7

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Leehom · project thread

Fixes #26886

Clause-②: no
Metadata-protocol: none

维护者速读

  • 改了什么:字段只写了 picklist(共享选项列表)时,元数据诊断(Setup › 所有元数据类型的问题数、条目 _diagnostics、GET /meta/diagnostics)不再误报「picklist 和 options 不能同时声明」。真正同时写了两个键的字段照报,文案和级别不变。
  • 为什么改:运行时把共享列表的选项解析到字段上再下发,诊断却校验了这份解析后的副本,于是每个用共享选项列表的应用(HotCRM 有 11 个字段)都给管理员亮红色错误,说应用坏了,实际选择框工作正常。
  • 风险与回滚:低。/meta/object/:name 下发内容不变(仍是 picklist + 解析后的 options),规则本身不动。新增的只是 picklist 解析在它生成的 options 数组上打一个不可枚举的标记,诊断见标记才略过;标记丢失时退回旧行为(误报),不会漏报。回滚即 revert。
  • 先看:packages/metadata-protocol/src/metadata-diagnostics.ts(withoutResolvedPicklistOptions);packages/objectql/src/picklist-resolution.ts(resolveField 打标记的条件);packages/metadata-core/src/resolved-picklist-options.ts。
  • 两分钟验证:pnpm --filter @objectstack/objectql exec vitest run src/protocol-picklist-served-roundtrip.test.ts,预期 4/4 通过,其中三入口用例只报 fields.both.options 一条。
  • 需要你做的:无,席位落地。

What changes

The global-picklist resolution in @objectstack/objectql (resolveField in picklist-resolution.ts) now marks the options array it derives for a field that names a picklist, and the load-time diagnostics in @objectstack/metadata-protocol (computeMetadataDiagnostics) leave a marked array out of the document before the schema parse. A field is therefore judged as its author wrote it:

Field as authored Served by GET /meta/object/:name Diagnostic before Diagnostic after
picklist: 'industry' picklist + resolved options "picklist and options cannot both be declared" clean
picklist and its own options picklist + resolved options flagged flagged, same message and severity, at fields.NAME.options
inline options only unchanged clean clean
  • When the resolution marks. Only when the body it resolves carried no options, or carried options that are themselves marked (a served copy re-folded in memory). A body that brought unmarked options beside picklist is an authored pair and its resolved options stay unmarked, so the rule still fires.
  • The mark. markResolvedPicklistOptions / isResolvedPicklistOptions, a new module in @objectstack/metadata-core (resolved-picklist-options.ts): a non-enumerable property keyed with Symbol.for, on the array itself. It is not in any response, stored row, JSON or memo key; every loaded copy of the package reads the one key. A copy of the array (.map, spread, JSON round trip) does not carry it, so a reader of a copy reports the diagnostic: the failure direction is the old false error, never a missed one.
  • Why metadata-core. Both sides already declare it. The resolution sits in the lean @objectstack/objectql/core closure, which must not import @objectstack/metadata-protocol (ADR-0076 D2; core-boundary.ratchet.test.ts and check:lean-entry-closure both refused the first version of this PR, which put the mark in metadata-protocol).
  • Unchanged. The rule itself (packages/spec/src/data/field.zod.ts, untouched); what /meta serves; the save door (a PUT of the served body is still refused 422 INVALID_METADATA, pinned by the existing test in protocol-picklist-served-roundtrip.test.ts). No second copy of the rule.

Doors that show the diagnostic, all through the one seam

computeMetadataDiagnostics is the only place the read side parses an item, so every door below is fixed by the one change:

  1. An item read's _diagnostics (getMetaItem → decorateMetadataItem).
  2. A list read's per-item _diagnostics (getMetaItems → decorateMetadataItems).
  3. GET /api/v1/meta/diagnostics (protocol-meta-diagnostics.ts reads the list's _diagnostics, or computes it), which is what Setup › All Metadata Types counts.
  4. The layered read's _diagnostics on the effective item (protocol.ts, getMetaItemLayers).

The boot-time scan of stored sys_metadata rows (protocol.ts, convertStoredItem → computeMetadataDiagnostics) parses the stored body, which is authored and never resolved, so it is unmarked and keeps flagging a stored row that carries both keys.

Why

Seat ruling on the card: judge what the author wrote, never the resolved copy; "If the resolved shape is the only thing that pass can see, the resolution may mark what it derived." Measured which applies: the authored item is not visible at the decoration seam.

  • Every object read folds through the registry's finishServedObject (foldObjectExtendersOnto / resolveObject) before decoration, and that fold resolves the picklist.
  • The MetadataService body the item read prefers can itself be a served copy: the boot bridge copies registry.getAllObjects() (resolved bodies) into the service.
  • resolveField overwrites a field's options unconditionally, so even an authored pair reaches the decoration seam with the picklist's options: the served copy alone cannot tell the two apart.

So the resolution marks what it derived. Stripping every options beside picklist in the diagnostics instead would silence an authored pair on every door: a consumer-side tolerance the ruling excludes.

Tests

  • metadata-core resolved-picklist-options.test.ts (new, 4): the mark is in place and only on the marked array, invisible to JSON and key enumeration, gone from any copy of the array, kept through a copy of the field holding it.
  • metadata-protocol metadata-diagnostics.resolved-picklist-options.test.ts (new, 6): picklist-only field served with marked options is clean; an authored pair is flagged at fields.industry.options with the rule's message; inline-only clean; a marked array on a field with no picklist is judged as written; a marked and an unmarked document with the same JSON are two verdicts in either order (the verdict memo); the decorated item keeps both keys and no trace of the mark.
  • objectql protocol-picklist-served-roundtrip.test.ts (+2): a real ObjectQL registry with a code-registered object (picklist-only industry, inline stage, authored pair both) through ObjectStackProtocolImplementation: the item read, the list read and getMetaDiagnostics each report exactly fields.both.options; the item read still serves industry with picklist and the resolved options.
Red first, ablation
  • Red on main (57820085), same harness as the new objectql test, before any edit: the item read, the list read and getMetaDiagnostics each reported fields.industry.options "picklist and options cannot both be declared" for a field declaring only picklist.
  • Ablation (committed fix): in picklist-resolution.ts, markResolvedPicklistOptions(resolved); replaced by void 0; (on disk: 0 of the first, 1 of the second) turned both new objectql tests red (2 failed, 2 passed: the authored-pair control and the existing round-trip tests stay green). Restored from HEAD; git diff HEAD empty afterwards.
Live check (os dev)

examples/app-todo with its category field switched to picklist: 'todo_category' and a matching picklists entry (a scratch edit, not committed; restored), booted with os dev --fresh, signed in as the seeded admin:

main metadata-protocol + objectql (base blobs rebuilt) this branch
GET /meta/diagnostics?type=object 1 entry: todo_task fields.category.options "picklist and options cannot both be declared" 0 entries
GET /meta/diagnostics (all types, 177 items) total 1 total 0
GET /meta/object/todo_task fields.category picklist: "todo_category" + 5 resolved options identical
todo_task._diagnostics valid: false (that one error) valid: true

The count drops by exactly the false row. Base blobs were restored and both packages rebuilt from HEAD afterwards.

Acceptance notes

  • HotCRM path not booted. The card's own reproduction is HotCRM on published 18.2.0. The live check above uses the in-repo app-todo with the same field shape (a code package's object naming a picklist); booting HotCRM against this branch's packages was not done.
  • A copy loses the mark. Any future step between the fold and the decoration that copies a field's options array (rather than the field) would bring the false error back for that door, loudly and in the safe direction. Today no step does: the item, list and sweep doors and the live boot all read clean.

Verification

All at head a6f469a3, after the final commit:

  • Package suites (under the verify lock): @objectstack/objectql test: Test Files 475 passed (475), Tests 8851 passed; @objectstack/metadata-protocol test: Test Files 274 passed, 4 skipped (278), Tests 21699 passed, 26 skipped; @objectstack/metadata-core test: Test Files 21 passed, Tests 840 passed. typecheck of all three: exit 0.
  • eslint --no-inline-config on the seven touched .ts files: exit 0.
  • Derived gate families (node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack): 62 derived, 62 run, every one exit 0; --ran reconciliation: "62 derived famil(ies) accounted for — 62 run, 0 NOT-MEASURED". check:dual-build-cjs-loads was read after a full pnpm build (it reads every package's dist/): 112 require entry points across 69 packages load. check:lean-entry-closure and core-boundary.ratchet.test.ts are green on this head (both were red on the first version, which imported metadata-protocol into the lean core closure).
  • Not run locally, owned by CI: test:repo, the repo-wide pnpm lint, and the families outside the derived list.

Human Minimum

  • 1. Schemas start as Zod; packages/spec holds no runtime logic. (No packages/spec change.)
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran; pnpm lint passes. (eslint --no-inline-config on the touched files, exit 0.)
  • 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: its generated artifacts are regenerated and committed. (Not touched.)
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English.
  • The Before Submitting list in CONTRIBUTING.md is done.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FEkQ5nFWpcGdLrSDAf6vS9


Generated by Claude Code

…out the options the runtime resolved onto it

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEkQ5nFWpcGdLrSDAf6vS9
…outside the lean objectql/core boundary

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEkQ5nFWpcGdLrSDAf6vS9
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 27, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

7 anchor(s) derived from 3 changed package(s); no hand-written page names any of them. ⚠️ 1 changed file(s) yielded no anchor (packages/metadata-core/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/metadata-core/src/index.ts) — pages documenting those are invisible to this run
  • the SDK route bridge reached 55 of 207 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 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; 97 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 — 27 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 e4ba846fa3cdaf067c177f31837a33c2206341a6 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json e4ba846fa3cdaf067c177f31837a33c2206341a6

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

ACCEPT · domain:server seat 2 · os-project-manager · session_0186vfjHHMsRXC9EUT9hTFMo · 2026-10-27T23:23Z · head a6f469a31f · #26886

The maintainer's standing authorisation for landing reviewed PRs, verbatim: 「按照 pm-dispatch 规则复核过的都授权」.

  1. Shape:
    • A draft against main, assignee os-project-manager. The body carries Fixes #26886, with Clause-②: no and Metadata-protocol: none line-initial. The changeset covers all three packages the diff publishes from (metadata-core, metadata-protocol, objectql) and repeats both lines.
    • check-governed-merges --pr 26953 reads NOT governed: 0 of 8 paths hit the register, 304 changed lines.
    • The claim (6453790504) names this PR's branch.
  2. Against the seat ruling (judge what the author wrote, never the resolved copy):
    • The dev measured which arm applies. The authored item is not visible at the decoration seam: every object read folds through finishServedObject, which resolves the picklist; the boot bridge copies resolved bodies into the service; and resolveField overwrites options unconditionally. So the order's fallback applies, and the resolution marks what it derived.
    • resolveField marks the array only when the body carried no options, or carried options that are themselves marked. An authored pair stays unmarked and is still flagged, with the same message and severity, at fields.NAME.options.
    • computeMetadataDiagnostics drops only a marked array, beside a string picklist, before the parse. That is one seam, and every door named in the PR body reads it. The rule in field.zod.ts is untouched, ⛔ with no second copy.
    • What /meta serves is unchanged: the mark is non-enumerable and keyed with Symbol.for, so no response, stored row or memo key carries it. A copy of the array loses it, which fails toward the old false error, never toward a missed one.
  3. Placement: the mark lives in @objectstack/metadata-core. The resolution sits in the lean @objectstack/objectql/core closure, which must not import metadata-protocol (ADR-0076 D2). The first version tripped core-boundary.ratchet and check:lean-entry-closure; both are green now.
  4. Red first and ablation (the dev's):
    • On main 57820085, the item read, the list read and getMetaDiagnostics all reported fields.industry.options for a picklist-only field.
    • At the head, 4 + 6 + 4 new and extended cases pass, including the authored-pair control and an inline-only control.
    • Replacing the markResolvedPicklistOptions(resolved) call with void 0 fails 2 of the 4 round-trip cases. After the restore, git diff HEAD is empty.
  5. Live (the dev's): os dev on app-todo, with category switched to a picklist as a scratch edit and then restored. GET /meta/diagnostics?type=object read 1 false entry (todo_task fields.category.options) on main blobs, and 0 on the branch. /meta/object/todo_task serves picklist and the 5 options identically on both.
  6. Tests: objectql 475 of 475 files; metadata-protocol 21,699 passed and 26 skipped; metadata-core 840 tests. Type-check exits 0 three times, and eslint is clean on the 7 touched files.
  7. Gates:
    • Locally 62 of 62 derived families exit 0, the dual-build family after a full build, with 0 NOT-MEASURED.
    • At this stamp every check-run on the head reads success or skipped: 34 success, 11 skipped, none failed or in progress. GitHub reads the PR clean.
  8. Overlap: git merge-tree exits 0 against main e269d3b652, which already carries fix(objectql)!: a predicate delete honours deleteBehavior for every matched row: restrict refuses, master_detail cascades, set_null clears (#26871) #26930. The file list shares no path with the other open server PR, fix(metadata-protocol,runtime,app-shell)!: refuse a save or draft publish naming a package that is not installed #26920.

Generated by Claude Code

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

2 participants