Repository navigation
fix(mcp): validate_expression refuses the label() call the object door refuses - #28774
Conversation
…or does (#28715) Both MCP bridges answer optionFieldsOf(name) from the served object describeObject reads, through @objectstack/formula's declaresOptions, and the validate_expression handler hands it to the shared validator as ExprSchemaHint.optionFields on the formula site. describe_object's answer is unchanged. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FEkQ5pqvaBsS9WCP8ecER5
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FEkQ5pqvaBsS9WCP8ecER5
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FEkQ5pqvaBsS9WCP8ecER5
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FEkQ5pqvaBsS9WCP8ecER5
…e's tests Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FEkQ5pqvaBsS9WCP8ecER5
📓 Docs Drift CheckThis PR changes 2 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 35 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 8c5ebc764aece48d8239b405c6fb537b2b690ecd && git checkout 8c5ebc764aece48d8239b405c6fb537b2b690ecd
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin d08b859a7177cf16d6190163f2e42efa2d980d3a 46c4246f66c0528d68c74ad9a8446190e4bf0ce6 && git checkout -B drift-repro d08b859a7177cf16d6190163f2e42efa2d980d3a && git merge --no-ff 46c4246f66c0528d68c74ad9a8446190e4bf0ce6
node scripts/docs-audit/affected-docs.mjs --json d08b859a7177cf16d6190163f2e42efa2d980d3a
|
|
domain:server#2 · os-project-manager · session_0186vfjHHMsRXC9EUT9hTFMo Contract reviewServed-tier: Face: declaration — Read at 2026-11-01T18:56Z; the head is pinned above. ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
|
domain:server#2 · os-project-manager · session_0186vfjHHMsRXC9EUT9hTFMo 席位核对ACCEPT · The maintainer's standing authorisation for landing reviewed PRs, verbatim: 「按照 pm-dispatch 规则复核过的都授权」.
Generated by Claude Code |
Requested by Leehom · project thread
Fixes #28715
Clause-②: yes (widening) — the published
McpDataBridgeinterface (@objectstack/mcp,export type) gains one optional member,optionFieldsOf(name); a bridge that omits it behaves exactly as before. No tool input or output shape changes:describe_objectserves the same projection, andvalidate_expression's new verdict is anerrors[]entry with an existing code. Expectednoin the claim; measuredyes (widening)because the member lands on an exported type.Metadata-protocol: none
维护者速读
validate_expression工具现在对label(record.某字段)(字段没有声明选项,比如文本字段)给出与保存对象时相同的拒绝:同一个错误码label-field-no-options、同一句话、点名字段。以前工具答 ok,保存时才被拒。optionFieldsOf,describe_object的返回不变;没有这个方法的桥照旧不检查。判定仍是同一个校验器,没有新规则。回滚即 revert 本 PR。packages/mcp/src/mcp-http-tools.ts(validate_expression处理器与VALIDATE_SITE_MAP的optionLabels)、packages/mcp/src/stdio-data-bridge.ts与packages/runtime/src/domains/mcp.ts(两个桥的optionFieldsOf)。pnpm --filter @objectstack/mcp exec vitest run --maxWorkers=2 src/mcp-validate-expression.option-labels.test.ts,预期 13 个用例全绿。What changes
@objectstack/mcpMcpDataBridgegains an optionaloptionFieldsOf(name), answering the list of field names (ornull): the fields of the objectdescribeObjectreads that declare options, ornullwhen no such object is served.validate_expressionhandler, on theformulasite, asks the bridge for that list and hands it to the one validator asExprSchemaHint.optionFields(the member PR fix(formula,lint)!: a formula whose label() names a field that declares no options is refused at author time (#28659) #28714 added).label(record.FIELD)on a declared field outside the list is then refused aslabel-field-no-options, in the sentence the door carries. No second rule and no second spelling of "declares options".formulasite asks (VALIDATE_SITE_MAP[...].optionLabels):@objectstack/lint's formula pass hands the validatoroptionFieldson a formula field'sexpressionand nowhere else, sovalidation,flow_conditionandtemplateanswer exactly what the door answers there, as before.optionFieldsOffromobjectView.getObject(name), the read itsdescribeObjectmakes, through@objectstack/formula'sdeclaresOptions.@objectstack/runtimebuildMcpBridge) answersoptionFieldsOfthe same way. Its by-name object read is factored into onereadServedObject(name)that bothdescribeObjectandoptionFieldsOfcall, so the two cannot read different objects.vitest.config.tsaliases@objectstack/formulato source, ascheck:test-source-aliasprescribes for the new import.describe_objectis unchanged. The option reading is a bridge member of its own, not a key ondescribeObject's answer; a test pins the answer's keys (name,label,fields,enableFeatures; each fieldname,type,label,required).Which predicate, measured
The order said to pick the predicate by the document the tool reads. A one-time probe (scratch test, not committed) booted real kernels (
ObjectKernel+MetadataPlugin+ObjectQLPluginover better-sqlite3, once without and once withenvironmentId: 'env_local') with a package declaring a picklisttierand an object whosetierfield is{ type: 'select', picklist: 'tier' }, then read the documents each bridge reads:Probe readings (field tier; first text and kind inline select as controls)
tieras readprotocol.getMetaItems({ type: 'object' })(both bridges' merged read)environmentId{"type":"select","picklist":"tier","options":[{"label":"Gold","value":"gold"}]}metadata.getObject(name)(both bridges' fallback with no merged read)environmentIdpicklist+ resolvedoptionsprotocol.getMetaItems({ type: 'object' })env_localpicklist+ resolvedoptionsmetadata.getObject(name)env_localdescribeObject(before){"name":"tier","type":"select","label":"Tier","required":false}: options strippedThe stdio bridge's
objectView.getObjectreads the sameprotocol.getMetaItems({ type: 'object' })(readMergedObjects,stdio-data-bridge.ts), elsemetadataService.getObject.Both bridges read the SERVED object, where a picklist-bound field already carries its list's options, so the served-form predicate
declaresOptionsis the one that matches; the door reads the authored body and usesdeclaresOptionsOnceServed. Apicklist-bound select reads ok through MCP exactly as at the door (pinned below).Tests
packages/mcp/src/mcp-validate-expression.option-labels.test.ts(new, 13 cases): drives the real stdio bridge (createStdioDataBridgeover a protocol serving the measured served shape) through the shared tool handler.label(record.first)andlabel(record.kind) + label(record.first)answerok: falsewith one error,code: 'label-field-no-options',params: { field: 'first' }.select/radiooptions, thepicklist-bound select, mixed expressions.label(record.nope)keeps exactly['unknown-field']. Thevalidation,flow_conditionandtemplatesites answer deep-equal to the same bridge withoutoptionFieldsOf. A bridge withoutoptionFieldsOfanswers as before.describe_object's keys are unchanged.runRuntimeAuthoringRuleson the authored body, picklist-boundtierwith nooptions) and throughvalidate_expression(served body); same verdict, same error count, and each door message contains the tool's message.packages/runtime/src/domains/mcp-option-fields-read.test.ts(new, 2 cases): the HTTP bridge the real/mcpentry builds (HttpDispatcher.handleMcp) answers['kind', 'stage', 'tier']andnullfor an absent object, from the merged listing and from the metadata service on a host with no merged read; itsdescribeObjectfields carry no options reading.stdio-data-bridge.stored-metadata-body.test.ts(BRIDGE_MEMBERS) andmcp-write-response-internal-fields.tripwire.test.ts(RECIPES), both "no row body / no write result".Red first (base 7a48bf7 sources, new tests)
packages/mcp/src/mcp-http-tools.tsandstdio-data-bridge.tsrestored from7a48bf73(on-disk check:optionFieldsOfcount 0 in both), new test file run: 4 failed | 9 passed (13). Red: the stdio bridge'soptionFieldsOfcase, both ⭐ LIT cases (AssertionError: expected true to be falseonok), PARITY. Restored withgit checkout HEAD --,git diff HEADempty.packages/runtime/src/domains/mcp.tsrestored from7a48bf73: 2 failed (2),TypeError: bridge.optionFieldsOf is not a function. Restored,git diff HEADempty.Ablations (one line each, scripts/ablation-replace.mjs, src-resolved suite so no build leg)
optionFieldsfrom the hint (...(optionFields ? { optionFields } : {})→...({} /* ABL_28715 */), marker count 1 on disk): 3 failed | 10 passed: the two ⭐ LIT cases and PARITY (its refused rows). Every control stays green. Restore blob999355773ff1= HEAD blob.optionLabels && …→(/* ABL2_28715 */ true || optionLabels) && …, marker count 1): 1 failed | 12 passed: the site control (validation: label(record.first) == 'x'answersok: falsewhere the door answers ok). Restore blob = HEAD blob.Acceptance notes
no; I declareyes (widening)becauseoptionFieldsOfis a new optional member on the exportedMcpDataBridgetype. No wire shape changes. The order says the seat runs a contract review for a widening.label()targets on theformulasite only, because that is the only site where the door does (judgeFieldFormulain@objectstack/lint). If the door later judges validation predicates too,VALIDATE_SITE_MAPtakesoptionLabels: trueon that row.validate_expressionon theformulasite now makes a second object read through the bridge (the merged listing on the HTTP bridge, the same listingdescribeObjectalready reads). It is an authoring tool called once per check.Verification
Local, on head
46c4246f:pnpm --filter @objectstack/mcp typecheck: exit 0.pnpm --filter @objectstack/mcp test: Test Files 49 passed (49), Tests 626 passed (626).pnpm --filter @objectstack/runtime typecheck: exit 0.pnpm --filter @objectstack/runtime test: Test Files 506 passed (506), Tests 8206 passed, 19 skipped (8225), lock VERDICT command-exit 0 (re-run after the@objectstack/formulaalias).node scripts/pm/dispatch-gates.mjs --commands, 60): all 60 exit 0, includingcheck:type-check-debt(under the lock, VERDICT command-exit 0),check:test-source-alias(red on the first pass for the new runtime import; fixed by the alias) andcheck:dual-build-cjs-loads(after the full buildcheck:type-check-debtran).dispatch-gates --ran: "60 derived famil(ies) accounted for — 60 run, 0 NOT-MEASURED". Record:pm-orders/server/28715-gates-ran.txt.Human Minimum
packages/specholds no runtime logic. (no spec change)pnpm lintpasses. (repo-wide lint is CI's run)main, one pull request.packages/spectouched: its generated artifacts are regenerated and committed. (not touched)content/docs/references/,packages/*/CHANGELOG.mdorcontent/docs/releases/.Generated by Claude Code