Skip to content

feat(formula): label(record.<field>) renders a select value in the reader's locale - #26755

Merged
os-zhuang merged 5 commits into
mainfrom
claude/project-thread-luyb0c
Oct 27, 2026
Merged

os-zhuang merged 5 commits into
mainfrom
claude/project-thread-luyb0c

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Requested by Yibo Wang · project thread

Fixes #26608

Clause-②: yes (widening)
Metadata-protocol: added
Skills-docs: #26760

维护者速读

  • 改了什么:新增 CEL 函数 label(record.<field>),把 select 字段的存储值渲染为读者语言的选项标签;objectql 在读和写的响应里按请求的 Accept-Language 给公式备好标签。'v' + string(record.version_no) + ' · ' + label(record.kind) 对 zh-CN 读者是 v2 · 清稿,对 en-US 读者是 v2 · Clean。
  • 为什么改:select 存的是值,公式 nameField 只能拼出 v2 · clean,保存提示和记录标题对所有语言的读者都显示英文值(A record name composed of select option labels cannot render in the reader's locale — no option-label function in CEL, and the save toast and related lists show the raw or stored string #26608)。
  • 风险与回滚:只有调用 label( 的公式才会构建标签表,其余读路径只多一次源码扫描。没有新的翻译键,也没有第二条翻译路径(复用 translateObject,与 GET /meta/object 同一函数)。i18n 服务缺失或抛错时退回声明的标签,读不会失败。回滚即 revert 本 PR。
  • 先看:packages/objectql/src/formula-option-labels.ts,然后 packages/formula/src/stdlib.ts 里的 bindOptionLabel。
  • 两分钟验证:pnpm --filter @objectstack/objectql exec vitest run src/engine-formula-option-label.test.ts,以及 packages/qa/dogfood 下的 test/formula-option-label-locale.dogfood.test.ts。
  • 需要你做的:本 PR 改了 skills/**(Tier H),落地需要你的同意。

What changes

@objectstack/formula

  • label(record.<field>) joins the CEL stdlib and CEL_STDLIB_FUNCTIONS. It is a macro: the type check accepts exactly record.<field> and refuses a literal, a path through a lookup, an optional select, previous.<field> and any computed argument, with a message that names the one shape.
  • It reads data only. EvalContext.optionLabels (new, type EvalOptionLabels: field → stored value → label) is resolved by the caller in the reader's locale before evaluation, so the stdlib holds no translator or resolver. It is not mounted as a CEL variable.
  • An empty value reads '', and a value no option declares reads as itself, as the select control shows it. A field with no options (on every row, empty or not), a list value (multi-select), and a context with no optionLabels fail loudly.
  • The measured catalog counts in validate.ts are updated (77 registered names, 42 callable bare, 30 from registerStdLib, 38 advertised). The receiver-only count also picks up hasCapability, which was already registered on main and missing from the prose.

@objectstack/objectql

  • New formula-option-labels.ts builds the map for one read: the object's fields that declare options, with the i18n service's bundle laid over them by translateObject from @objectstack/spec/system. The bundle and the locale options (fallbackChain, defaultLocale) are built as the REST metadata route builds them, so the label a formula prints is the label the select control shows the same reader: objects.<o>.fields.<f>.options.<v>, then picklists.<p>.options.<v>, then the declared label. No key is spelled in this module.
  • applyFormulaPlan passes the map to ExpressionEngine.evaluate. The map is built only when some formula in the plan calls label(.
  • Wired readers, each in ExecutionContext.locale (the request's Accept-Language): find, findOne, the insert and update responses (hydrateWriteFormulas), and serveFormulaField. expandRelatedRecords already runs a find with the read's context, so expanded lookups follow.
  • Declared labels: stored expressions (computeStoredExpressions), the synchronous evaluateFormulaField, any read with no locale, and an engine with no i18n service or one whose getTranslations throws.
  • setI18nService accepts the optional getTranslations, getFallbackLocale and getDefaultLocale it now reads. II18nService already carries all three.

Docs and skill

  • skills/objectstack-formula/SKILL.md gains an "Options" row for label(record.f) (+116 tokens, 5627 of 5997). The row says it answers only in formula fields and faults in a predicate.
  • content/docs/data-modeling/formulas.mdx gains the stdlib row and a cookbook entry, "Record name from an option label".

Skills readings

Measure Before After
skills/objectstack-formula/SKILL.md lines 465 471
skills/objectstack-formula/SKILL.md tokens (ceiling 5997) 5511 5627
Bundle, all SKILL.md, lines 4600 4606
Bundle, all SKILL.md, tokens (ceil(bytes / 4)) 56017 56133
evals/cel-predicates-formulas.json tokens (ceiling 1046) 1046 1046

Readers (measured)

Reader Where it gets the name Locale
Save toast (recordSaveMessage, app-shell) the POST/PATCH response writer's Accept-Language
Record page H1 (getRecordDisplayName) GET /data/:object/:id → findOne reader's
Related list list read → find reader's
Lookup display expand → find with the same context reader's
REST GET / POST / PATCH the same engine paths request's
Search does not index formula fields (SEARCH_VIRTUAL_TYPES) unchanged
Stored computed field (text + expression) computed once on write declared labels
evaluateFormulaField (sync, e.g. a hook's record-title accessor) evaluated with no read context declared labels (left out)

No ui/** file changes: the console renders the name the server serves. The console sends its display language as Accept-Language on every call.

Tests

  • packages/formula/src/option-label.test.ts (new): zh-CN and en-US maps, per-field maps, empty and undeclared values, the run-time faults, the compile-time refusals, and inferExpressionType returning text.
  • packages/formula/src/cel-engine.test.ts: label joins the drift probe.
  • packages/objectql/src/engine-formula-option-label.test.ts (new): the insert response in zh-CN and en-US, the update response, findOne and find per locale, a base-language tag (zh), no locale, a stored expression keeping the declared label under a zh-CN writer, and no i18n service or a throwing one.
  • packages/qa/dogfood/test/formula-option-label-locale.dogfood.test.ts (new, real boot): POST in zh-CN and en-US, GET by id in both, PATCH in zh-CN, the list read in zh-CN, and the name matching the option label GET /meta/object/:name serves in the same locale.

Acceptance notes

Verification

All runs on f780ce78, in a cloud worktree, under scripts/pm/os-verify-lock.sh, unless a section names 8ffe3ae6.

Unit tests: @objectstack/formula and @objectstack/objectql
  • @objectstack/formula: option-label.test.ts, cel-engine.test.ts and skill-catalog-sync.test.ts pass (94 tests), and cel-stdlib-drift.test.ts passes. The catalog counts in validate.ts were measured on the built environment (77 / 42 / 35 / 30 / 38).
  • @objectstack/objectql: engine-formula-option-label.test.ts passes 7/7; with the other engine-formula* files, 8 files and 135 tests pass.
Dogfood on a real boot
  • @objectstack/dogfood: formula-option-label-locale.dogfood.test.ts passes 5/5.
Typecheck
  • typecheck passes for @objectstack/formula and @objectstack/objectql (including check:test-typecheck).
  • tsc --noEmit passes for @objectstack/dogfood with NODE_OPTIONS=--max-old-space-size=8192; at the default heap it runs out of memory locally, before reporting anything.
Derived gates: 102 commands, 101 measured green, 1 not measured
  • node scripts/pm/dispatch-gates.mjs --commands derives 102 commands; 99 exit 0 on the first run.
  • check:objectql-double-limit found that the new test's find double ignored the caller's limit. Fixed in f780ce78, and it now reports OK.
  • spec check:skill-examples exited 3 because the client SDK was not built. After building @objectstack/client-react and its dependencies it passes: 266 examples type-check.
  • check:dual-build-cjs-loads exits 3 because it needs a whole-repo build; nothing was measured. That is left to CI.
  • --ran with exit codes: 102 derived families accounted for, 101 run, 1 NOT-MEASURED (the recorded exit 3).
Contract-review should-fixes on 8ffe3ae6
  • optionLabelOf checks the field's options before the empty-value answer; option-label.test.ts pins a null text field faulting.
  • @objectstack/formula: option-label, cel-engine, skill-catalog-sync and cel-stdlib-drift pass (4 files, 98 tests); typecheck passes. @objectstack/objectql: engine-formula-option-label.test.ts passes 7/7 against the rebuilt formula package.
  • Exit 0: spec check:skill-docs, check:skill-identifier-liveness, check:skill-compatibility, check:skill-frame-sync, check:skill-top-level-keys, check:nul-bytes, check-skills-token-ratchet, spec check:skill-examples.
Skills token ratchet
  • check-skills-token-ratchet exits 0. The readings are in the Skills readings table above.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WGufzfay61tE4MHpZNiV4t

…ader's option label

A CEL macro that reads the field name off its argument and answers from a
pure-data EvalContext.optionLabels map the caller resolves in the reader's
locale. Added to the advertised catalog.

Claude-Session: https://claude.ai/code/session_01WGufzfay61tE4MHpZNiV4t
Co-authored-by: Claude <noreply@anthropic.com>
The engine pins the option labels a formula's label(record.<field>) reads
for each read and write it serves: the object's declared options with the
i18n bundle laid over them by translateObject, in ExecutionContext.locale
(the request's Accept-Language). Stored expressions get the declared labels.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGufzfay61tE4MHpZNiV4t
…t-Language; changeset

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGufzfay61tE4MHpZNiV4t
@github-actions

github-actions Bot commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/formula, @objectstack/objectql, touching 45 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/formula/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.

26 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json a485b5a3e28d84550e8a33bbb5cfc2d52c82ff9e.

⛔ 10 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/formula/src/index.ts) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: ObjectQL (symbol, 72 pages)
  • 5 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 25 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 a485b5a3e28d84550e8a33bbb5cfc2d52c82ff9e → packageMentionDocs.

Which tree this was computed on

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

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

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

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs a485b5a3e28d84550e8a33bbb5cfc2d52c82ff9e → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: f780ce7816bc82896e4777be152ee6e4e4ec6455
Local-runs: none
Scope: skills/objectstack-formula/SKILL.md + packages/formula/src/stdlib.ts, packages/formula/src/types.ts, packages/formula/src/validate.ts, packages/formula/src/cel-engine.ts, packages/formula/src/index.ts, packages/objectql/src/formula-option-labels.ts, packages/objectql/src/engine.ts (the applyFormulaPlan / hydrateWriteFormulas / computeStoredExpressions / evaluateFormulaField / serveFormulaField / find / findOne / insert / update hunks), .changeset/26608-formula-option-label.md, content/docs/data-modeling/formulas.mdx (cited, read against the code; the test files and the dogfood test were read to judge them, not reviewed)

Inputs read: card #26608 (body, triage 6440185938, claim 6442789358, amendment 6442958772, os-dev-report 6444975664), PR #26755 body and file list, the net diff 43731031..f780ce78 (merge-base of origin/main), and the check-runs on the head. The PR has no reviews and one bot comment (docs-drift, advisory). The head's tree was read through a detached git checkout of the fetched sha for file reads only — nothing built, run or re-run — and the checkout was restored afterwards.

① Derived judgments

Each accept-set or public-surface change the diff implies, named right or wrong against the tree:

  1. CEL grammar widens by one bare-callable name, label, registered as a MACRO (stdlib.ts bindOptionLabel, env.registerFunction('label(ast): string', …)). The type check accepts exactly the AST shape record.FIELD (optionLabelFieldOf: op ., receiver id = record, non-empty string member) and refuses a literal, a path through a lookup, an optional read, previous.FIELD and a computed argument with one message (LABEL_ARGUMENT_REFUSAL). Registered unconditionally, like can and hasCapability, so the publish gate's answer does not depend on which context built the environment. Right. The widening reaches every CEL surface the skill lists (formula, visibleWhen, validation when, hook/sharing condition, flow, seed) because the stdlib is one environment; only the engine's formula serving pins optionLabels, so everywhere else label() compiles and then faults at run time (see ④).
  2. EvalContext.optionLabels (type EvalOptionLabels, field → stored value as text → label) is pure data, not a CEL variable. buildScope (stdlib.ts ~764-787) mounts record, previous, input, user/os, org, env, extra only; optionLabels travels on StdLibBinding and is read through read() at call time, as the permission binding is. stdlib.ts imports no translator, no i18n type, no resolver. Right, and it is the seat instruction's first invariant.
  3. @objectstack/formula public surface: EvalOptionLabels exported from index.ts; registerStdLib gains a fourth optional parameter (index.ts already exported it); CEL_STDLIB_FUNCTIONS gains 'label'. All additive. Right.
  4. Catalog counts in validate.ts (77 registered / 42 bare / 35 receiver-only / 30 from registerStdLib / 38 advertised): the advertised list moved 37 → 38 and the bare count 41 → 42 by exactly the one name the diff registers; cel-stdlib-drift.test.ts asserts list identities (toEqual([]), the withheld set), not the prose numbers, so the hasCapability absorption the dev reports (34 → 35 receiver-only) is a prose repair of a count main already under-stated. Right; Test Core will measure it.
  5. @objectstack/objectql surface: setI18nService accepts three more optional methods (getTranslations, getFallbackLocale, getDefaultLocale), which II18nService already carries and plugin.ts:1167 already passes whole. Additive. Right.
  6. evaluateFormulaField (exported, sync) changes behaviour: a label() formula no longer faults with "no option labels" but evaluates against declaredOptionLabels(schema, plan). Disclosed in its docblock and in the PR's reader table. Right as a documented exception (judged in ④).
  7. One translation path. readerOptionLabels builds the bundle as metaTranslationBundleOf does (getLocales() × getTranslations(locale), meta-item-read-gate.ts:2757-2769), builds the options as metaTranslateOptions does (fallbackChain: [getFallbackLocale()], defaultLocale: getDefaultLocale(), :2783-2790), and calls the same translateObject from @objectstack/spec/system with { name, fields }. translateObject (i18n-resolver.ts:3837) resolves a field's option label as objects.NAME.fields.FIELD.options.VALUE then picklists.PICKLIST.options.VALUE (:3878-3889) when the field carries picklist; optionFields keeps the whole field def so the picklist key survives, and every registry read passes finishServedObject → resolvePicklistFields (registry.ts:3838-3855), so a picklist-bound field reaches the module with its options resolved. No key is spelled in objectql. Right, and the dogfood test pins /meta/object label == formula label on one boot.
  8. Locale: ExecutionContext.locale, which both the REST and the MCP door set from Accept-Language (resolve-execution-context.ts:216, preferredLocaleFromHeader, the same parser metaRequestLocale uses). Right for every request that sends the header (the console always does). Observation, not a finding: with no header, the metadata route falls to ?locale= then getDefaultLocale(), while the execution context falls to the settings service's localization — the two can disagree only on a header-less request, and the dogfood "no locale" case is served the declared labels, which is the documented answer.
  9. Readers wired (the claim names them; each one found in the tree): find (engine.ts:13590-13596), findOne (:13879-13883), the insert 201 body (hydrateWriteFormulas, :15855-15861), the by-id update body (:17800-17807; a predicate update returns a count and has no rows to hydrate, pre-existing), serveFormulaField (:6594-6597, and ScopedContext.serveFormulaField at :22003 passes the hook's own execution context), expand (expandRelatedRecords runs this.find with { ...execCtx, __expandRead: true }, so a lookup's display name follows the reader). Stored expressions: both storedExpressionServing objects (insert :14960, update :16364) carry optionLabelsFor: declaredOptionLabels, and all four computeStoredExpressions sites share them. No REST export or MCP path evaluates formulas on its own: every one found goes through engine.find/findOne. Right; no reader missed.
  10. Search: a formula field has no column (the skill's own "formula is virtual" warning; where/searchableFields refuse one). Unchanged. Right.
  11. Tenant / permission: the map is built from the read object's own declared options after that object's read doors; nothing from another object is reachable (the macro refuses record.lookup.field at compile; an expand is a separate find under the related object's doors). A reader allowed the formula but denied the select field reads the label of a value the formula already exposed raw before this PR — pre-existing, not widened. No new leak.

② Semver level

Changeset .changeset/26608-formula-option-label.md: @objectstack/formula: minor, @objectstack/objectql: minor, with Clause-②: yes (widening) and Metadata-protocol: added line-initial in both the changeset body and the PR body. A new accepted CEL function in an authorable expression.source is a new accepted value (added), and added on a package the diff moves takes at least minor (AGENTS.md Post-Task Checklist step 3): matches. Nothing is narrowed, removed or re-meant, so no BREAKING banner and no ADR-0087 disposition is owed. @objectstack/dogfood is private: true; content/docs/** is not a package. skip-changeset does not apply. Right.

③ Boundary flags

  • Dev flag: Tier H (skills/** on the file list). Confirmed: landing waits for the maintainer's word; the Governed Surface Queue Guard is green on this head. This record is the contract-tier review of the governed paths; it does not substitute for the approval.
  • Dev flag: Skills-docs: follow-up owed — skills/objectstack-formula/evals/cel-predicates-formulas.json. The form is the one the claim template allows (.claude/skills/pm-dispatch/SKILL.md:313: follow-up owed — FILES, which the seat rewrites to #N before ACCEPT after filing the card). SKILL.md and formulas.mdx teach label() in this diff; the eval does not exercise it. The file is at its ceiling (1046/1046 per the dev's ratchet reading; check-skills-token-ratchet is green). Answered: deferral accepted; the seat files the owed card and rewrites the line.
  • open_questions[0] — where does the label() eval land? Answer: A, a follow-up card. Trimming evals 1–5 changes what those cases grade, which is a Tier H content decision for the maintainer (and the ceiling is a ratchet ruling, not a dev's call). The follow-up card should carry one compact label(record.kind) case plus the ceiling question, and — tied to ④ should-fix 1 — a negative case asserting must_not_contain: ['label('] in a validation-rule prompt, since that is the wrong authoring this surface now makes possible.
  • Dev flag: PR body not patched (three cosmetic fixes the seat writes: the 需要你做的 section's second action moves to Acceptance notes, the bundle readings are missing, the Verification bullets are not folded). Prose outside hits; noted for the seat, not judged here.
  • Dev flag: check:dual-build-cjs-loads not measured locally; the derived families on the head answer it (see the check-runs below).
  • Out-of-scope findings (dev's): label() outside a formula faults at run time with no publish-time refusal — carried into ④; the dogfood tsc heap reading — environment, not a code finding.
  • Deviation from the order's sketch (EvalContext.locale threaded into the function) to the accepted design (caller resolves data): recorded in amendment 6442958772 before the diff; the tree follows the amendment.

④ Design judgment

skills/objectstack-formula/SKILL.md (the hits file): a new "Options" table row for label(record.f). Each claim was read against the code: "in the reader's locale" (engine reads: ①-8/9), "Only record.FIELD" (optionLabelFieldOf), "'' for null" (optionLabelOf: value === null || value === undefined → ''), "A stored (non-formula) field stores the declared label" (storedExpressionServing.optionLabelsFor = declaredOptionLabels, pinned by the objectql test's search_name case). The table header's promise ("every entry resolves at runtime, and this table documents them all") is enforced by skill-catalog-sync.test.ts and the drift probe, both updated. Token reading 5600/5997. The row holds against the code and the card's scenario (a formula nameField composed of option labels, read on the toast, H1, related list and lookup).

Attempts to break it, with the outcome of each:

  • label() reads only pinned data; stdlib.ts takes no resolver. Holds (①-2). The map is read at call time through read(), so the shared evaluation environment answers each evaluation from its own map; the drift probe hands optionLabels: { a: { 1: 'One' } } and nothing else reads it.
  • A stored expression stores the declared label, never the writer's locale. Holds: declaredOptionLabels ignores locale by construction; the objectql test writes under zh-CN and reads search_name = v2 · Clean off the store.
  • Value with no declared option → the stored value itself (the select control shows the same). Silent by design and documented in the skill row's sibling docs; acceptable. Null → '' (documented). Multi-select → throws renders ONE option value (typeof value === 'object'); the SQL driver's formatOutput JSON.parses json columns on read (sql-driver.ts:18724, :18858), so a multi-select reaches the macro as an array and the fault is loud. Unknown field → loud at publish when a schema is in play (buildEnv registers record as the typed OsRecordScope, so checker.check(args[0]) refuses an undeclared member) and, at run time, cel-js's no_such_key surfaces as a runtime fault. Non-select field → see should-fix 1: loud on a non-null value (declares no options), silent '' on a null one, because the null check precedes the options check. Context without optionLabels → throws, which the engine never reaches (the map is built whenever label( is in the plan's sources); outside a formula field it is the loud fault the docs describe.
  • The sync evaluateFormulaField left out. It evaluates with the DECLARED labels (not a throw, not the writer's locale). Its live readers: resolveRecordTitle (record-title.ts:142) when no serving channel is at hand, i.e. the hook sandbox's ctx.recordTitle fallback (body-runner.ts:283). plugin-approvals recordTitleOf reads the served row's column and is not on this path. So the only place a declared label is "served" is a hook that composes its own text from a title it evaluates without a read channel — the dev's disclosed exception; no console reader the card names reads it. No wrong label is served to a named reader. Noted as a seam for a later card if a hook consumer (notification text) surfaces.
  • Reader missed? None found (①-9). A list view and a related list are find; an export or an MCP data tool reads through find/findOne with the door's execution context (resolve-execution-context.ts:216 is the shared door for REST and /mcp).
  • Tenant/permission leak? None new (①-11).
  • Map build cost: bundleOf pulls every locale's bundle on each read whose plan calls label( — the same work GET /meta/object does per request; planCallsOptionLabel is one regex per formula source otherwise. Acceptable; an observation.

Findings

  • Should-fix 1 (skill row, the hits file). The stdlib table is the one table the skill's predicate surfaces (visibleWhen, validation when, hook/sharing condition, flow) read from, and §"CEL surfaces" says an unevaluable validation predicate aborts the write. The row does not say that label() answers only where the engine serves a formula field; formulas.mdx does. An author who writes label(record.status) == '草稿' in a validation rule passes os build and then every write to that object faults. Add the clause to the row (≈15 tokens; 397 of headroom), e.g. "formula fields only — in a predicate it faults; compare the stored value there". Not a must-fix: the failure is loud, names the function, and nothing silently wrong is served.
  • Should-fix 2 (stdlib.ts optionLabelOf, cited code). Check the field's options entry before the null short-circuit, so label(record.title) on a text field faults on every row rather than only on rows where title is non-null; and, where ExprSchemaHint.fieldTypes is present at publish (validate.ts typeSoundnessIssue), refuse label() on a field whose spec type declares no options so the shape is caught at build like the other argument shapes are. Both are tightenings of a loud path, not corrections of a served value.
  • Must-fix: none.

Check-runs on f780ce78 at render time: every concluded derived family is green — Lint (ESLint, repo & tooling, docs & code guards, CI/release/engine, PM dispatch-gates, scoped ×3), Type Check (source gates, consumer gates, debt ledger), Check Changeset (latest run; one earlier run cancelled by the re-run), Governed Surface Queue Guard, PR-body guards, Spec property liveness, Dogfood Regression Gate, Build Docs, Check Documentation Links, Test UI, Build UI Deps, Build Core, Build & E2E UI, Test Core 1/8, 7/8 and 8/8. Still in progress at the last reading: Test Core 2/8–6/8, Type Check · workspace, Type Check UI. Their conclusions are the gate verdicts for those families; the Tier H landing already waits on green CI and the maintainer's word, so this record's verdict stands on the tree and the concluded families, and the seat re-reads the head's check-runs before the landing.

Implemented-by: claude/project-thread-luyb0c
Reviewed-by: session_01WGufzfhobWEgUUrjz4rrbh

VERDICT: PASS

…he skill row says formula fields only

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WGufzfay61tE4MHpZNiV4t
@os-zhuang
os-zhuang marked this pull request as ready for review October 27, 2026 15:07
@os-zhuang
os-zhuang enabled auto-merge October 27, 2026 15:07
@os-zhuang
os-zhuang added this pull request to the merge queue Oct 27, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · ACCEPT · domain:spec seat 1 (#6017) · session session_01WGufzfhobWEgUUrjz4rrbh · 2026-10-27T15:29Z

Head 8ffe3ae67856cc968eb7ae61788f1b2f6611cac3. Card #26608, claim 6442789358 (surface amended by 6442958772), dev report 6444975664.

  • Contract review: owed at CONTRACT_REVIEW_TIER for the formula function catalog (label()) and skills/objectstack-formula/SKILL.md. PASS 6445230404 at f780ce78, with no must-fixes. The one commit since then, 8ffe3ae6, lands both should-fixes only:

    • (a) The SKILL.md label() row now says it works in formula fields only, and that in a predicate (validation, visibility, hook) it faults, so authors compare the stored value there.
    • (b) optionLabelOf checks the field's options before the null short-circuit, so label(record.textfield) faults on every row. option-label.test.ts pins a null text field faulting.

    The diff from f780ce78 to 8ffe3ae6 is 3 files, +15/−9.

  • Lane seat (rest of the diff, TIER_DEFAULT):

    • objectql builds the label map per read through translateObject in ExecutionContext.locale. That is the one path GET /meta/object serves, with picklist inheritance intact.
    • The map is wired into find, findOne, the insert and update responses, serveFormulaField and expand.
    • Stored expressions store the declared label.
    • The dogfood test pins POST, PATCH, GET and list in zh-CN and en-US on a real boot.
    • No ui/** change.
    • Search is unchanged.
  • Not carried: the optional publish-time refusal of label() on a non-options field (validate.ts typeSoundnessIssue). The run-time fault is loud on every row.

  • Skills-docs: #26760, the eval follow-up. cel-predicates-formulas.json is at 1046 / 1046, and the card carries the negative predicate case.

  • Shape: Fixes #26608, 14 files. @objectstack/formula and @objectstack/objectql are minor. Clause-②: yes (widening) and Metadata-protocol: added. check-governed-merges --pr 26755: 1 path is governed (skills/**), 948 changed lines.

  • Gates: check-runs on this head are 43 success, 9 skipped and 0 red.

  • Landing: Tier H. The maintainer approved and armed it at 2026-10-27T15:07Z. This seat arms nothing.

Merged via the queue into main with commit 76a1275 Oct 27, 2026
52 checks passed
@os-zhuang
os-zhuang deleted the claude/project-thread-luyb0c branch October 27, 2026 15:54
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/l tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

3 participants