Skip to content

fix(spec): serve a field's translated help on its authored inlineHelpText - #29217

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/project-thread-p9v8u9
Nov 2, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/project-thread-p9v8u9

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Steve · project thread

Fixes #29153

Clause-②: no
Metadata-protocol: none

维护者速读

  • 改了什么:服务端翻译对象时,字段帮助的译文除了照旧写到 description,还写到字段自己填写的 inlineHelpText 上。
  • 为什么改:控制台先显示 inlineHelpText,以前译文只写在 description,所以填了 inlineHelpText 的字段在任何语言下都显示英文原文(如 showcase 的「线索评分」)。
  • 风险与回滚:只改翻译后的读出结果,不改作者能写什么;没填 inlineHelpText 的字段不变,租户改过的文本照旧保留。回滚即还原本 PR。
  • 先看:packages/spec/src/system/i18n-resolver.ts 的 translateField 帮助分支,再看同文件 translateObject 文档块,最后看新测试块。
  • 两分钟验证:pnpm --filter @objectstack/spec exec vitest run src/system/i18n-resolver.test.ts,预期全部通过,含 lead_score 在 zh-CN 下的 inlineHelpText 为「由评分规则计算,不可手动编辑。」。
  • 需要你做的:无,席位落地。

What changes

translateObject (packages/spec/src/system/i18n-resolver.ts) serves the bundle's objects.OBJECT.fields.FIELD.help entry on the field's own inlineHelpText when the field authors a non-empty one, as well as on description (unchanged). The new slot follows the same per-slot packaged-base divergence rule (valueOverridesPackagedBase, ADR-0029 D9.2a) that already guards description; no second comparison was added. The docblock that said help was "not served on inlineHelpText either, which the extractor never reads" is corrected: the extractor has read inlineHelpText since #25683.

No new key, no second fold, and nothing is ever served on help. ui/packages/fields and packages/cli are untouched.

The matrix (one derivation, three places)

Field authors Extractor scaffolds help from Resolver serves translation on Console draws (inlineHelpText || description)
inlineHelpText only inlineHelpText inlineHelpText + description translated inlineHelpText
inlineHelpText + description inlineHelpText inlineHelpText + description translated inlineHelpText
description only description description (no inlineHelpText key added) translated description
inlineHelpText: '' + description nothing: the ?? chain stops at '' and pushOptional scaffolds no seed for it (see Acceptance notes) an entry, when present, on description; the empty inlineHelpText stays empty description
help help nothing on help n/a: FieldSchema refuses help (unrecognized_keys, "Did you mean help → inlineHelpText?"), so no stored field carries it

Forced test edits

  • i18n-resolver.test.ts, describe "a field's translated help is served on description": the phone fixture's bundle entries now carry what the extractor actually writes for a field authoring inlineHelpText (its inlineHelpText text, not its description), and the test that pinned "inlineHelpText is untouched" now pins the translation on both slots. Two sibling assertions on phone.description follow the new fixture string.

Tests

New describe block "a field's translated help reaches the slot the console draws first": the showcase lead_score case (zh-CN serves 由评分规则计算,不可手动编辑。 on inlineHelpText, parses with no unrecognized key), CONTROL for the source locale, CONTROL for a locale with no help entry, never another field's entry, no inlineHelpText gained by a field without one, the empty-string case, divergence kept in every locale through translateMetadataDocument, an extension field counting as diverged, no packaged base, and no input mutation.

Readings (head e1451dc, tree objectstack-ai/objectstack)
  • @objectstack/spec test: Test Files 790 passed (790), Tests 23377 passed, 1 todo. typecheck exit 0.
  • @objectstack/spec build + check:generated: "All 14 generated artifacts are up to date". No regenerated file changed.
  • Consumers (downstream of the served read): @objectstack/rest full suite, Test Files 367 passed (367), Tests 6963 passed, 332 skipped. @objectstack/metadata-protocol full suite, Test Files 298 passed, 4 skipped. @objectstack/cli unit test/i18n-extract-inline-help-text.test.ts 6/6. @objectstack/objectql src/engine-formula-option-label.test.ts 7/7. ui/ packages/fields/src/__tests__/fieldHelpText-12213.test.tsx 5/5 (run from ui/).
  • Ablation: node scripts/ablation-replace.mjs replaced the next.inlineHelpText = translatedHelp; line (anchor 1 → 0, blob 0241a7c5ab2e → 5fc0e733d89c); i18n-resolver.test.ts went red, 3 failed / 377 passed (the phone both-slots test, the lead_score test, the no-packaged-base test). Restored: blob == HEAD, git diff HEAD empty.

Acceptance notes

  • When a field authors both inlineHelpText and a different description, the entry is the translation of inlineHelpText (the extractor's source), and description is also served that text. That was already the served behaviour before this PR; the seat's ruling keeps it ("keeps serving description as today"). It is recorded in the report's open questions.
  • The extractor's help ?? inlineHelpText ?? description stops at an authored inlineHelpText: '', so os i18n extract scaffolds no help entry for such a field, while the console (||) draws its description. That half is packages/cli, outside this card's file surface; it is reported to the seat as a finding, not fixed here. The resolver side of that row is pinned (an entry, when one exists, lands on description and the empty inlineHelpText stays empty).

Verification

Derived gate list (node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack) run locally at the final head; per-command exits are in the os-dev-report on the card. Repo-wide scans (pnpm lint, the full check:* farm) are CI's.

Human Minimum

  • 1. Schemas start as Zod; packages/spec holds no runtime logic. (The resolver is existing spec-owned translation code; no new runtime surface.)
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran; pnpm lint is CI's run.
  • 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 (none were stale).
  • 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_01ADHkmStM2XRLNZKJCmbBaA


Generated by Claude Code

…Text

The i18n extractor scaffolds objects.O.fields.F.help from
help ?? inlineHelpText ?? description and the console draws
inlineHelpText || description, but translateObject served the entry on
description only, so a field authoring inlineHelpText kept showing its
source text in every locale. The entry is now served on the authored,
non-empty inlineHelpText too, under the same per-slot packaged-base
divergence rule (ADR-0029 D9.2a); description keeps today's behaviour.

Claude-Session: https://claude.ai/code/session_01ADHkmStM2XRLNZKJCmbBaA
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added the size/m label Nov 2, 2026
@github-actions

github-actions Bot commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec, touching 3 documentable anchor(s).

6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/flows.mdx (via inlineHelpText (literal, a string literal in translateObject))
  • content/docs/data-modeling/field-types.mdx (via inlineHelpText (literal, a string literal in translateObject))
  • content/docs/data-modeling/fields.mdx (via inlineHelpText (literal, a string literal in translateObject), inlineTitle (literal, a string literal in translateObject))
  • content/docs/data-modeling/object-extensions.mdx (via inlineHelpText (literal, a string literal in translateObject))
  • content/docs/getting-started/quick-reference.mdx (via inlineHelpText (literal, a string literal in translateObject))
  • content/docs/ui/translations.mdx (via inlineHelpText (literal, a string literal in translateObject))

⛔ 3 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-5.mdx (via inlineHelpText (literal, a string literal in translateObject))
  • content/docs/releases/v17/17-7.mdx (via inlineHelpText (literal, a string literal in translateObject))
  • content/docs/releases/v18/18-2.mdx (via inlineHelpText (literal, a string literal in translateObject))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 60 of 216 client-bound route-ledger rows — the other 156 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 156: 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; 101 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 — 168 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 14208f2c504ff6f1529be8a28323a91df53d0562 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 14208f2c504ff6f1529be8a28323a91df53d0562

⚠️ 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 14208f2c504ff6f1529be8a28323a91df53d0562 → 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

domain:spec#1 · os-steve · session_01ADHkmPi25XXKx92Xmg4viS

Head-sha: e1451dc02f544a7024084fc622ac218a6ba8407a · read 2026-11-02T10:06Z

维护者速读

  • 改了什么:字段写了 inlineHelpText(字段下方的帮助提示)时,翻译包里该字段的 help 译文现在也会放到 inlineHelpText 上,不再只放在 description 上。
  • 为什么改:控制台先画 inlineHelpText,所以写了它的字段,中文界面一直显示英文帮助(i18n: a field's translated help never shows when the field authors inlineHelpText — the server serves fields.FIELD.help on description and the console draws inlineHelpText first #29153,18.3.0 发布清单 i18n 项的红灯)。
  • 风险与回滚:只改服务端读出的翻译,不改任何可写的键;源语言和没有 help 译文的语言原样不动;作者改过的值仍按原规则保留。回滚即撤销本 PR。
  • 先看:packages/spec/src/system/i18n-resolver.ts 里 translateObject 的字段帮助分支和它的文档注释。
  • 两分钟验证:pnpm --filter @objectstack/spec exec vitest run src/system/i18n-resolver.test.ts。
  • 需要你做的:无,席位落地。

席位意见:可落地,等契约复审。

席位核对 — ACCEPT · domain:spec#1 · head e1451dc0

  1. Shape: draft, base main, Fixes #29153, Clause-②: no / Metadata-protocol: none at line start in the body and the changeset (spec patch). The lines follow claim amendment 6572667349: a served-read change is none, not semantic. Three files, all within the claimed surface: i18n-resolver.ts, its test and the changeset. packages/cli and ui/packages/fields are untouched, as the delta ordered. Not governed.
  2. Diff, read on the head: translateObject looks up the field's help entry once. It serves the entry on description under valueOverridesPackagedBase(…, 'description', …) as before, and on inlineHelpText only when the field authors a non-empty one, under its own per-slot valueOverridesPackagedBase (one comparison helper, never a second). A field with inlineHelpText absent or '' gains no key. Nothing is served on an undeclared help. The docblock now states the one derivation in three places: the extractor's help ?? inlineHelpText ?? description, the console's inlineHelpText || description, and this resolver. The false "the extractor never reads inlineHelpText" sentence is gone.
  3. Matrix (PR body, 5 rows): a field help is refused by FieldSchema, so it is never stored. With inlineHelpText only, the entry is served on it. With description only, it is served on description (unchanged). With both, it is served on both. inlineHelpText: '' is treated as absent. The showcase lead_score zh-CN case is pinned. CONTROLs pin the source locale and a locale with no help entry as untouched.
  4. Readings (dev): spec 790/790 files, typecheck 0. The served read's consumer is packages/rest (367/367), not core or metadata as the delta guessed. metadata-protocol 298 passed. The cli extractor unit is 6/6, the objectql translateObject caller 7/7, and ui fields fieldHelpText 5/5 (run from ui/). Ablating the inlineHelpText write turns 3 red, then it was restored. Gates: 79 derived, 78 run exit 0, 1 NOT MEASURED (check:dual-build-cjs-loads exit 3, unbuilt packages outside the closure; CI's build covers it). Offline level gate exit 0. Check-runs on e1451dc0 at this reading: 55 success, 9 skipped, 0 failed.
  5. Forced test edit (1, named): the phone fixture now carries the inlineHelpText-derived entry the extractor really writes.
  6. Open question, seat's call: Q[0] = A. When both texts are authored and differ, description still takes the help entry (which the extractor seeds from inlineHelpText), kept per the delta's ruling ("keeps serving description as today"). That edge, and the extractor's ?? stopping at inlineHelpText: '', are finding i18n(field help): the extractor's help ?? inlineHelpText ?? description stops at inlineHelpText: '', and a field authoring both texts gets the inlineHelpText translation on description #29221.
  7. Landing: one CONTRACT_REVIEW_TIER review (a published served-read change), handed to triage (line below). Then the seat readies and queues.

Contract-review-handoff: triage — CONTRACT_REVIEW_TIER unavailable (maintainer standing word 2026-10-28T15:04Z: the Fable quota is short, so contract reviews wait for triage) · head e1451dc

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U

Contract-review-claim: session_01AavokzJ5DndAwitDXvKy4U · head e1451dc02f544a7024084fc622ac218a6ba8407a · 2026-11-02T10:08Z

Taken on the lane's Request: contract-review doorbell (#28646, 6574431702). A served-read change in packages/spec (translateObject, the p1 RC card), at CONTRACT_REVIEW_TIER.


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: e1451dc02f544a7024084fc622ac218a6ba8407a
Local-runs: none
Scope: packages/spec/src/system/i18n-resolver.ts + ui/packages/fields/src/fieldHelpText.ts, packages/cli/src/utils/i18n-extract.ts, packages/spec/src/data/field.zod.ts, packages/spec/src/system/translation.zod.ts, packages/rest/src/meta-item-read-gate.ts, docs/adr/0029-kernel-object-ownership-and-platform-objects-decomposition.md, docs/qa/platform-checklist/areas/i18n.json, examples/app-showcase/src/data/objects/contact.object.ts, examples/app-showcase/src/system/translations/index.ts

Inputs read: card #29153 body and all three comments (triage, claim 6572667349, os-dev-report 6573912237); PR #29217 body, file list (3 files), the net diff against main at 14208f2c, its three comments; the 69 check-runs on the head; sibling card #29221 and its three comments; the cited code on origin/main and prr-29217 through git show. The remainder of the diff (i18n-resolver.test.ts, the changeset) is the lane seat's ACCEPT at TIER_DEFAULT (6574410876) and is read here only as evidence.

Check-runs on the head: 55 success, 13 skipped (opt-in and filter-gated jobs), 0 failure, 1 pending — a second Check Changeset run (id 125213043974, started at the PR-body edit that followed the seat's ACCEPT) is in_progress; the first Check Changeset run on this same head (id 125201052414) concluded success. The tree it judges is unchanged, so the gate's verdict is known; the pending run is stated as pending, not assumed.

① Derived judgments

  1. Served read of GET /api/v1/meta/object/:name changes; nothing authorable does — right. packages/rest/src/meta-item-read-gate.ts translateMetaItem hands every object read through translateMetadataDocument('object', …) with packagedBase = getPackagedObjectBase(name) and the request's Accept-Language locale; METADATA_DOCUMENT_TRANSLATORS.object is translateObject. The new branch adds one write, next.inlineHelpText = translatedHelp, on a declared FieldSchema key (field.zod.ts L2447), behind the string-and-non-empty test and the existing valueOverridesPackagedBase. No key is added to any schema, no export changes (ObjectFieldLike's member list is unchanged; inlineHelpText already types through its index signature), no generated artifact moves (Lint & Repo Gates success covers check:generated and check:api-surface).
  2. The console's help line now reads the translation — right. ui/packages/fields/src/fieldHelpText.ts L53 is inlineHelpText || description || undefined, and both form readers call it (plugin-form/src/ObjectForm.tsx L981, sectionFields.ts L314; resolveActionParams.ts L843 for field-backed action params). For showcase lead_score (contact.object.ts L67 authors inlineHelpText, no description; translations/index.ts L644 carries the zh-CN help), the served inlineHelpText equals the packaged one, so the catalog applies and the console draws 由评分规则计算,不可手动编辑。. The en session is unchanged: the en.help entry equals the authored text. The accept-set the card names (every reader of a non-source locale on a field authoring inlineHelpText) is reached.
  3. The two other translateObject callers are untouched — right. packages/objectql/src/formula-option-labels.ts L157 and packages/services/service-analytics/src/plugin.ts L1168 hand it synthetic fields carrying options only; no inlineHelpText, so the branch is inert there.
  4. Studio round-trip is not widened — right. The designers read GET /meta/:type/:name/layers (StudioDesignSurface.tsx L7898, ResourceEditPage.tsx, EmbeddedItemEditor.tsx L155 layered.effective ?? layered.code), the diagnostic read ADR-0029 D9.2a keeps untranslated, so the translated inlineHelpText is not written back into a tenant overlay by the console. The honest edge D9.2a records (a served body persisted verbatim freezes a catalog value) already covers label and description; the new slot joins that class and adds no new path.
  5. The one help entry serves two slots — pre-existing, now explicit. The extractor seeds objects.O.fields.F.help from help ?? inlineHelpText ?? description (i18n-extract.ts L1486-L1491, since cli/i18n: os i18n check does not read a field's inlineHelpText as the default-locale source of its help key, so translating help into en turns the default locale red #25683). For a field authoring both texts, the entry is the translation of inlineHelpText, and the PR keeps serving it on description (the spec(i18n): translateObject writes translated field help onto an undeclared help key — the console warns ~520× per page load and drops the value #21948 behaviour, kept by the seat's ruling 6572667349 "keeps serving description as today"), so description carries another sentence's translation; a field authoring inlineHelpText only GAINS a description key carrying that translation (pinned by the lead_score test). The console never draws description when inlineHelpText is non-empty, so no user-visible surface is wrong; the HTTP body and any non-console reader of description see it. i18n(field help): the extractor's help ?? inlineHelpText ?? description stops at inlineHelpText: '', and a field authoring both texts gets the inlineHelpText translation on description #29221 Edge 2 is the reading of it. Not introduced here: the same body was served on description before this PR, and dropping it would regress a reader that still reads the translated description (the spec CHANGELOG for spec(i18n): translateObject writes translated field help onto an undeclared help key — the console warns ~520× per page load and drops the value #21948 names that class).
  6. Source-locale: the bundle wins over authored text, as for every other field attribute. localeChain for the default locale is [locale] with no fallback; lookupObjectFieldAttr serves en.help whenever it is a non-empty string, so a bundle scaffolded BEFORE cli/i18n: os i18n check does not read a field's inlineHelpText as the default-locale source of its help key, so translating help into en turns the default locale red #25683 (whose en.help is the description text) now overwrites an authored inlineHelpText in the en session with the description's text, where before this PR the same stale entry rewrote description with its own text (a no-op). This is the flat catalog ?? document design the field label has always had, not a second rule, and a re-run of os i18n extract cures it; recorded, no fix asked.

② Semver level

  • Changeset .changeset/29153-field-help-on-inline-help-text.md: @objectstack/spec: patch, body carries Clause-②: no and Metadata-protocol: none at line start, matching the PR body. Clause-②: no is right: no public type, export or schema changes; the served value of an already-declared key changes.
  • Metadata-protocol: none is right under AGENTS.md Post-Task Checklist 3: the kind is "the change to what a metadata author can write"; semantic is a changed meaning or default of an authorable key. inlineHelpText's declared meaning ("Help text displayed below the field in forms") and default are unchanged; what moves is the served translation of it, exactly the class spec(i18n): translateObject writes translated field help onto an undeclared help key — the console warns ~520× per page load and drops the value #21948 shipped as patch with Clause-②: no (spec CHANGELOG, bab7685). none takes patch. The Check Changeset gate concluded success on this head once (its re-run is pending, same tree).
  • Served-tier is CONTRACT_REVIEW_TIER because the hits file is a published packages/spec module (the schema face), as the brief's reviewFace reading says.

③ Boundary flags

④ Design judgment

packages/spec/src/system/i18n-resolver.ts, judged against the card's measured scenario (zh-CN session, showcase contact form, lead_score), the ruling it implements (seat 6572667349: serve the translated help on the slot the console reads first, one derivation across extractor, resolver, console; ADR-0029 D9.2a per-slot comparison) and its first consumer (packages/rest translateMetaItem).

Holds:

  • The slot the console draws first is served. fieldHelpText reads inlineHelpText first; the branch writes it when authored non-empty. The empty-string edge matches the console's ||: '' gains nothing and description carries the entry (pinned).
  • The per-slot divergence rule is right and is the one comparison. Each slot is judged by valueOverridesPackagedBase(packagedField, slot, value) against the packaged field: a tenant edit to inlineHelpText keeps that text in every locale while description still translates, and the reverse; a field the base does not declare (packagedObjectField answers {}) counts as diverged on both slots; no base means the catalog applies. Neither slot's catalog overwrites the OTHER slot's diverged value (pinned: "keeps an inlineHelpText that diverged", "extension field counts as diverged"). No second comparison helper was added. The array-shaped fields path takes the same translateField, and a nameless array element folds to '', which packagedObjectField reads as undeclared, so the catalog is withheld, the conservative direction.
  • Nothing lands on an undeclared help; the served field still parses with no unrecognized_keys (pinned via FieldSchema.safeParse). Input is not mutated (next = { ...def }, pinned).
  • The docblock now tells the truth: the "extractor never reads inlineHelpText" sentence is gone and the three-place derivation is stated with its file paths. ObjectFieldLike's docblock follows.
  • Break attempts that did not break it: whitespace-only inlineHelpText ( ) is served the translation, which is what the console's || draws anyway; a bundle help: '' is skipped by lookupObjectFieldAttr's non-empty test; translatedHelp is looked up before the divergence tests (one lookup, no behaviour change); packagedField hoisted and reused for inlineTitle is behaviour-identical.

Findings:

Implemented-by: claude/project-thread-p9v8u9
Reviewed-by: session_01AavokzJ5DndAwitDXvKy4U

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#1 · os-steve · session_01ADHkmPi25XXKx92Xmg4viS

Contract-review-claim: session_01ADHkmPi25XXKx92Xmg4viS · head e1451dc · 2026-11-02T10:45Z

Triage ruling 6575141496 returned contract review to the lane, so the spec seat reviews this head itself at CONTRACT_REVIEW_TIER. The earlier handoff line in the seat record is withdrawn for this head.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:spec#1 · os-steve · session_01ADHkmPi25XXKx92Xmg4viS

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: e1451dc02f544a7024084fc622ac218a6ba8407a
Local-runs: none
Scope: packages/spec/src/system/i18n-resolver.ts + packages/spec/src/system/i18n-resolver.test.ts (the pin of the rule, read as one change), packages/cli/src/utils/i18n-extract.ts (collectExpectedEntries, the help ?? inlineHelpText ?? description seed), ui/packages/fields/src/fieldHelpText.ts (the console's inlineHelpText || description read) and its callers in ui/packages/plugin-form and ui/packages/fields/src/resolveActionParams.ts, packages/spec/src/data/field.zod.ts (inlineHelpText, the help alias refusal), packages/spec/src/system/translation.zod.ts (FieldTranslationSchema.help), packages/rest/src/meta-item-read-gate.ts and packages/rest/src/form-routes.ts (the served reads that call translateMetadataDocument('object', …) with packagedBase), packages/metadata-protocol/src/protocol.ts (getPackagedObjectBase: owner alone), docs/adr/0029 D9.2a, examples/app-showcase (contact.object.ts lead_score and translations/index.ts), ui/packages/app-shell ObjectFieldInspector + ResourceEditPage (Studio's write of inlineHelpText and its /layers load). The changeset is judged under ② only; the rest of the diff is the lane seat's ACCEPT at TIER_DEFAULT.

① Derived judgments

Read off the net diff at the head, each named right or wrong against the cited code:

  • Accept set unchanged — right. No schema, key or export moves: FieldSchema (field.zod.ts L2447 inlineHelpText, L1283 help → inlineHelpText alias refusal) and FieldTranslationSchema.help (translation.zod.ts L133) are untouched; Metadata-protocol: none on the PR body and the changeset is right. Nothing changes in what an author may write.
  • Served read of translateObject / translateMetadataDocument('object', …) changes — right, and additive. A field whose served inlineHelpText is a non-empty string and still equals the packaged field's is served the bundle's objects.OBJECT.fields.FIELD.help text there, as well as on description (unchanged). The branch writes only description and inlineHelpText; no help key is ever written (L4467-4479 on the head), so spec(i18n): translateObject writes translated field help onto an undeclared help key — the console warns ~520× per page load and drops the value #21948's refusal still holds and FieldSchema.safeParse of the served field carries no unrecognized key (the new test pins it).
  • Per-slot divergence — right. Both slots go through the one predicate valueOverridesPackagedBase (L4123), no second comparison, honouring the ⛔ in the predicate's docblock and ADR-0029 D9.2a's "same predicate at both layers". The inlineTitle branch now reuses the hoisted packagedField; equivalent refactor.
  • Evaluation-order change — right, no effect. lookupObjectFieldAttr(…, 'help') is now evaluated unconditionally rather than only when description is undiverged; it is a pure bundle read over localeChain, so no observable change.
  • Empty inlineHelpText reads as absent — right. the typeof === 'string' and non-zero-length guard is the resolver-side twin of the console's || (fieldHelpText.ts L53) and of Studio's inlineHelpText: v || undefined (ObjectFieldInspector.tsx L1447): a cleared box gains no translated help and description keeps carrying it.
  • Other translateObject callers unaffected — right. packages/objectql/src/formula-option-labels.ts L157 and packages/services/service-analytics/src/plugin.ts L1168 hand it synthetic fields carrying only name/options/picklist; neither carries inlineHelpText, so neither reads differently.
  • ObjectFieldLike — docblock-only change, right as far as it goes. The interface still does not declare inlineHelpText; the translator now writes a slot it reaches only through the [key: string]: any index signature (should-fix below).
  • The three-place derivation claim — right for the non-empty case, and the one disagreement is correctly placed outside this PR. Extractor L1489 seeds from help ?? inlineHelpText ?? description; console draws inlineHelpText || description; the resolver serves on the slot the console draws first when it is non-empty, and on description always. On inlineHelpText: '' the extractor's ?? stops and seeds nothing while the console falls through to description; that is packages/cli, reported in the dev's out-of-scope findings to the seat, and the resolver's half of that row is pinned.

② Semver level

.changeset/29153-field-help-on-inline-help-text.md: '@objectstack/spec': patch, Clause-②: no, Metadata-protocol: none; the PR body carries the same two lines. Patch is the right level: no signature, type, schema or export changes (ObjectFieldLike prose only; translateObject's parameters and return type unchanged); the served document gains a translated value on a key FieldSchema already declares and the console already reads. Clause-②: no is right — the contract of what may be written is unchanged, and the published functions' shapes are unchanged; the narrowing arm does not apply (nothing an author could do is taken away). Check Changeset ×3 and Governed Surface Queue Guard are success on the head. One prose nit, not a level issue: the changeset's "the source locale serve[s] the authored text" is true only because the extractor writes the source-locale help entry from the authored text; the resolver itself consults the requested locale's own bundle even for defaultLocale (localeChain L453-455 skips only the fallbacks).

③ Boundary flags

The dev's os-dev-report on card #29153 (comment 6573912237):

  • open_questions[0] — when a field authors both inlineHelpText and a different description, description is served the inlineHelpText-derived translation. Answered: A (keep) for this card, as the dev recommends. The resolver cannot tell from the bundle which authored text an entry translates, and B would leave description untranslated for every field that authors inlineHelpText (no bundle key reaches it). FieldSchema documents description as tooltip/developer documentation and the console draws it only as the fallback, so the visible help line is right under A. Pre-existing since cli/i18n: os i18n check does not read a field's inlineHelpText as the default-locale source of its help key, so translating help into en turns the default locale red #25683; no change owed in this PR. A follow-up card is owed only if a description-drawing consumer is shown to mislead.
  • out_of_scope_findings[0] — extractor seeds no help entry for { inlineHelpText: '', description: 'D' }. Escalated to the carrier named (domain:spec#1 to file against the extractor); confirmed by reading L1489 (?? stops at ''). Not this PR's file surface; the resolver's behaviour on that row (translation lands on description, '' stays '') matches the console and is pinned.
  • deviations[0] (forced edit of the phone fixture and the "inlineHelpText is untouched" test) — accepted. That test pinned the pre-ruling contract ("⛔ not served on inlineHelpText either, which the extractor never reads"), whose premise has been false since cli/i18n: os i18n check does not read a field's inlineHelpText as the default-locale source of its help key, so translating help into en turns the default locale red #25683; the seat's claim rules the slot the console reads first, so the inversion is the ruling's. The fixture now holds what the extractor writes for such a field.
  • deviations[1]-[3] (MCP writes skipped, worktree kept, consumer-suite choice: rest + metadata-protocol rather than core/metadata) — process notes, no contract bearing; the consumer choice is right (meta-item-read-gate.ts L2961/L3025 and form-routes.ts L383 are the served doors).
  • Dev's "CI in_progress at report time" — resolved: every check-run on e1451dc0 has completed with success or skipped (opt-in/conditional jobs); none failed. Those conclusions are the gate verdicts.

④ Design judgment

packages/spec/src/system/i18n-resolver.ts — judged against the seat's ruling on #29153 (translated help is served on the slot the console reads first, one derivation across extractor, resolver and console, no new key), ADR-0029 D9.2a (comparison-based provenance, one predicate), and its first consumers: the GET /api/v1/meta/object/:name read (meta-item-read-gate.ts) and the public form (form-routes.ts), rendered by the console's fieldHelpText.

Attempts to break it, each read in the live tree at the head:

  1. Tenant edits inlineHelpText only. Served value ≠ packaged → diverged → kept; description (undiverged) still receives the translation; the console draws the tenant's text. Holds (pinned through translateMetadataDocument in both locales).
  2. Field the packaged base does not declare (extension field). packagedObjectField returns {} (L1966), {}.inlineHelpText !== value → diverged → authored text kept. Holds, pinned.
  3. No packaged base (runtime object, or getPackagedObjectBase undefined). Predicate answers false → catalog applies. Holds, pinned.
  4. Idempotence / double translation. After one pass the served inlineHelpText differs from the packaged → the second pass keeps it. Holds (the same property D9.2a records for scalars).
  5. Studio round-trip pollution. Studio's designer loads /meta/:type/:name/layers (ResourceEditPage.tsx L7), a STORED-version door the rest gate does not translate; only the plain read goes through translateMetaItem. So a zh-CN Studio save does not freeze the translation into the overlay. The ADR's recorded "honest edge" (a client that PUTs the plain read back) applies to inlineHelpText exactly as it already does to description and label; nothing new.
  6. Fallback chain. zh-CN lacking help while en carries it serves the en entry onto inlineHelpText; with extractor-written bundles that is the authored text, and a hand-edited en entry wins over the authored text exactly as it already does for label and description (catalog ?? document). Consistent, not new.
  7. fieldPatches (ADR-0146 D10). An extension that patches a vendor field's inlineHelpText makes the served value diverge from the owner-alone base (getPackagedObjectBase, protocol.ts L9437: scalars are the owner's), so the catalog yields and that patched text is served untranslated in every locale. This is the same consequence description already has under the same rule and is what the ruling asked for ("same per-slot divergence rule"); recorded, no fix owed in this PR.
  8. The docblock's citations. Extractor source and path (L1489), the console reader's name and package, and the help alias refusal in FieldSchema all match the live tree. The stale "which the extractor never reads" is gone.
  9. The accepted scenario (card reproduction). showcase_contact.lead_score authors inlineHelpText only (contact.object.ts L67); the zh-CN bundle carries help: 由评分规则计算,不可手动编辑。 (translations/index.ts L644); the packaged base is the showcase's code-owned object, so the served inlineHelpText equals it → translated → fieldHelpText draws the zh text. The new test restates exactly this fixture and the console read. i18n.surface-matrix negative[1] ("a surface whose group the spec declares showing NO translation while the bundle carries one is a FAIL against the resolver") is answered at the resolver.

Findings:

  • should-fix (S1) — ObjectFieldLike (L3604-3612) declares inlineTitle?: string because the translator writes it, but not inlineHelpText, which the translator now writes too; the slot is reached only through the [key: string]: any index signature and re-typed by a typeof guard. Declare inlineHelpText?: string beside inlineTitle (optional member, additive, still patch).
  • should-fix (S2) — test "CONTROL: the source locale serves the authored inlineHelpText untouched" cannot fail on what it names: the fixture's en bundle entry equals the authored text, and the resolver writes the requested locale's own entry on the source locale too (localeChain skips only the fallbacks). The slot is rewritten with an identical string, so the assertion pins nothing the first test does not. Either state what it pins (the extractor-mirrored en entry round-trips) or make the control real: drop the en help entry and assert the authored text survives.
  • No must-fix. The change holds against the extractor, the console reader, the served doors and ADR-0029 D9.2a, and it answers the card's reproduction.

Implemented-by: claude/project-thread-p9v8u9
Reviewed-by: session_01ADHkmPi25XXKx92Xmg4viS

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 2, 2026 10:55
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 2, 2026 10:55
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 2, 2026
Merged via the queue into main with commit 20f052d Nov 2, 2026
73 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-p9v8u9 branch November 2, 2026 11:48
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 protocol:system size/m tests tooling

Projects

None yet

1 participant