Repository navigation
Commit 589049b
fix(metadata-protocol)!: an object saved back from a translated /meta read stores its source labels, and one the registry would refuse is refused at save and at publish (#29790)
<!-- claude-projects-attribution: {"github_login":"os-warren"} -->
_Requested by **Warren** · [project
thread](https://claude.ai/code/project/chan_01U5RdnAAmkSQmZbqh6FtqZ8?thread=cmsg_01U5RdnAAmkSQmZbqh6FtqZ8KvRttCwxSQ3t7fNg1Go9ya)_
Fixes #29673
Clause-②: no (narrowing)
Metadata-protocol: none
Skills-docs: none — `skills/objectstack-data/SKILL.md` (Ownership
queues) already teaches that an object declaring its own `queue` field
is refused, which stays true and is now said at save; no file under
`skills/**` teaches saving a translated read back.
## 维护者速读
- 改了什么:对象保存入口(`PUT
/api/v1/meta/object/NAME`,草稿与直接保存)把"中文读取被翻译过的标签"还原成源文本再存,并在保存时就拒绝注册表本会拒绝的对象(422),发布前再查一次;`os
migrate meta --stored` 可修复已存的坏行。
- 为什么改:中文 Studio 读回对象时 `queue`
的标签是"队列",原样保存后被当成作者自定义字段,注册表拒绝注册,但发布仍报"成功",数据接口 404;`owner_id`
等列的中文标签还会被所有其他语言的用户看到。
- 风险与回滚:收窄——作者自带 `queue` 字段等注册表本就拒绝的对象,现在保存即
422(以前保存成功但永远注册不上);作者恰好输入某语言对源标签的译文时会被还原为源文本。回滚即还原本 PR,无数据迁移。
-
先看:`packages/metadata-protocol/src/protocol-translated-echo.ts`(还原规则)、`packages/metadata-protocol/src/protocol.ts`
中 `saveMetaItemUnaudited`
的两处还原调用(`restoreInjectedColumnEchoes`、`restoreTranslatedObjectEchoes`,后者以
`getPackagedObjectBase` 为打包基线)与规格校验之后的
`objectRegistrationRefusal`、`packages/objectql/src/registry.ts` 的
`judgeRegistrationDoors`(原有拒绝逻辑整体搬移)。
- 两分钟验证:`pnpm --filter @objectstack/objectql exec vitest run
src/protocol-meta-effective-schema.test.ts`,预期 20 个测试全绿,其中 `[#29673]` 一节
8 个。
- 需要你做的:无,席位落地
## What changes
**The door fixed: `saveMetaItem` (`packages/metadata-protocol`), the
server door every object `PUT /api/v1/meta/object/NAME` reaches, draft
and live.** Whatever the client sends, an injected column or a field no
longer stores a label the read translated.
Where the translation came from (measured, below): the REST `/meta` read
runs `translateMetadataDocument`, which serves each injected system
column's label from the built-in table with no bundle needed (`queue` →
`队列`, `owner_id` → `负责人`) and an author field's label from the catalog.
Studio saves the whole body it read. The save door stored it verbatim;
`stripInjectedSystemColumns` removes only a byte-identical platform
definition, so the translated `queue` and `owner_id` stayed as the
author's own fields. The registry refuses an author `queue` on a
user-owned object, `applyObjectRegistryMutation` caught that refusal
into a `console.warn`, and `publish-drafts` answered `outcome:
'published'`.
1. **Restore the source text** (`protocol-translated-echo.ts`, new).
`saveMetaItemUnaudited` calls it twice: a pure half
(`restoreInjectedColumnEchoes`, the platform's injected-column
definitions only, no read) just before `stripServedObjectColumns`, and
the stored-row half (`restoreTranslatedObjectEchoes`) after every
refusal, beside `carryForwardRedactedCredentials`, so a refused save
costs no read. A slot is restored when the incoming value differs from
its source and equals what `translateObject` makes of that source in
some locale. Sources, asked in order: the stored row this save
overwrites (the draft, else the active row a first draft overlays, read
as the carry-forward reads it), else the packaged base
(`getPackagedObjectBase`), then the platform's definition of an injected
column (`injectedSystemColumnDefs`). Every read of the source is
translated against that packaged base, passed as `translateObject`'s
`packagedBase` exactly as the REST read passes it, so a packaged
object's overlay slot that diverged from the packaged text (served
untranslated) is no translation's source. The packaged base is a
registry lookup, not a MetadataService read, so a degraded metadata
service fails no save here. Locales: the registered bundle's, plus the
built-in table's (`BUILTIN_SYSTEM_FIELD_LABEL_LOCALES`, a new export of
`@objectstack/spec/system`), since the read applies that table with no
bundle. Slots: object `label` / `pluralLabel` / `description`; field
`label` / `description` / `inlineHelpText` / `inlineTitle`; option
`label`. Once restored, the injected columns are byte-identical again
and the existing strip removes them. `isInjectedColumnDefinition` is
unchanged.
2. **Refuse at save what the registry would refuse.**
`SchemaRegistry.objectRegistrationRefusal` (new,
`packages/objectql/src/registry.ts`) answers the error `registerObject`
would throw, registering nothing. `registerObject`'s doors moved whole
into a private `judgeRegistrationDoors`, which `registerObject` now
calls first, so the question and the throw are one code path. It judges
the body alone: the record-currency `summary` door, which reads the
registry's other objects as they stand, is left to the registration (a
`bodyOnly` stop in `judgeRegistrationDoors`), since a sibling drafted in
the same batch can change its answer either way. The save door asks it
in both modes, after the spec gate (so a body both refuse keeps the
gate's structured `issues[]`): `422 INVALID_METADATA`, nothing stored.
The one refusal this makes new at the door is an author's own `queue` on
a user-owned object; a field `type` the registry refuses was already
refused by the object schema.
3. **Publish truth.** `promoteDraftForPublish` asks the same question
inside its D9.9 gate block, before `repo.promoteDraft`. A draft stored
before this rule is refused there: pinned on `publishMetaItem` (refused
by name, no active row, nothing registered); in `publishPackageDrafts`
the promote gates' throws land in `failed[]`, so the receipt does not
report it published.
4. **Repair.** `migrateStoredMetadata` (`os migrate meta --stored
[--apply]`) runs the same restoration on each stored object row
(comparing the row with the platform's definition) and reports each slot
as `translated-label-echo`, e.g. `fields.queue.label 队列 → Queue`.
`--apply` re-saves the row through the save door, which restores and
strips. A restart does not heal such a row; the migration does, and so
does the row's next save.
5. **Said on the save answer.** Each author slot the stored-row half
puts back is an `info` advisory on the save answer (`rule:
'translated-label-echo'`, `path: 'objects.NAME.fields.F.label'`, with
the text stored and the text sent), beside the credential
carry-forward's `withheld-key-dropped` notices. The platform's injected
columns are not reported: the stored document never carries them.
**Out of #29414's region.** The `promoteDraftForPublish` hunk sits
inside the D9.9 gate block only; no `publishPackageDrafts` commit-plan
rows, `prevVersion` / `existedBefore` recording, `revertCommit` or
`resolveOverlayPackageBinding` line is touched.
**A ui/** change is also owed (the seat files the card).** Studio's
object designer edits the locale-translated read:
`ui/packages/auth/src/createAuthenticatedFetch.ts` attaches
`Accept-Language` to every API call, so the designer's `GET
/api/v1/meta/object/NAME` returns translated labels and it `PUT`s that
body back. With this PR the stored document is right, but the designer
still shows a translated label as editable source text, so an author who
edits it in zh-CN writes a Chinese source label (kept, correctly, as the
author's edit). The owed card: the designer loads and edits the
untranslated document, and does not send injected system columns back.
Files (12):
- `packages/metadata-protocol/src/protocol-translated-echo.ts` (new),
`protocol.ts`: the restoration, the save and promote refusals, the
migration notices.
- `packages/objectql/src/registry.ts`: `objectRegistrationRefusal`,
`judgeRegistrationDoors` (moved doors, no rule changed; `bodyOnly` stops
before the record-currency door).
- `packages/spec/src/system/i18n-resolver.ts`:
`BUILTIN_SYSTEM_FIELD_LABEL_LOCALES`; `api-surface/system.json` and
`export-origins/system.json` regenerated.
- Tests: `protocol-translated-echo.test.ts` (new),
`protocol-meta-effective-schema.test.ts`, `ownership-queue.test.ts`,
`record-currency-guards.test.ts`,
`packages/qa/dogfood/test/studio-zh-cn-echo-publish.dogfood.test.ts`
(new).
- `.changeset/29673-translated-echo-save-door.md`: patch for spec,
objectql, metadata-protocol; `!` with the ADR-0087 `not-required`
disposition.
## Tests
- `packages/objectql/src/protocol-meta-effective-schema.test.ts`,
`[#29673]` (real `SchemaRegistry` + real protocol):
- zh-CN echo save: the stored fields are `amount`, `memo`; `memo` keeps
its author label; the registry has the object; an `en` read is served
`Owner` / `Queue`.
- An author `queue` is refused at the save door in both modes
(`ObjectQueueColumnRefusedError`, `INVALID_METADATA`, 422), and no row
is written.
- A stored draft carrying an author `queue`: `publishMetaItem` is
refused by that name, no active row, nothing registered.
- Migration: preview `pending` with the two `translated-label-echo`
notices; apply `rewritten`; the stored fields are `amount` only and the
object registers.
- An author slot the catalog translated (`amount` served `金额`) is stored
as `Amount`, and the save answer carries one `info`
`translated-label-echo` advisory at
`objects.crm_invoice.fields.amount.label`.
- The packaged base: a stored overlay slot `Subject` diverged from the
packaged `Title`, with the author typing the catalog string `标题`, is
stored `标题` with no advisory; the control with no packaged base restores
`Subject`.
- A body the spec gate and the registry both refuse (an unknown field
`type`) answers the spec gate's `INVALID_METADATA` with `issues[]`.
- A brand-new object saves while the metadata service reports itself
degraded.
- `packages/metadata-protocol/src/protocol-translated-echo.test.ts` (6):
every slot in zh-CN; `ja-JP` with no bundle; author text kept; a stored
echo repaired against the platform definition; the array `fields` shape,
and the same reference back when nothing changed; a diverged overlay
slot set to the catalog string is kept when the packaged base is passed,
and the undiverged control is restored.
- `packages/objectql/src/ownership-queue.test.ts`:
`objectRegistrationRefusal` answers the throw `registerObject` makes,
and registers nothing.
- `packages/objectql/src/record-currency-guards.test.ts`:
`objectRegistrationRefusal` answers `null` for a parent whose `summary`
reads a record-currency child, while `registerObject` still refuses it.
- Dogfood `studio-zh-cn-echo-publish.dogfood.test.ts` (4, over the
booted showcase through the REST doors): the zh-CN read is translated
and the `en` read is not; the zh-CN echo publishes `published` /
`failedCount: 0`, stores neither column, serves data, and an `en` read
gets `Owner` / `Queue`; the `en` control; an author `queue` is refused
422 and no draft is stored.
## Acceptance notes
- **Clause-②: no (narrowing).** The object save door refuses `422
INVALID_METADATA` an object declaring its own `queue` field on a
user-owned object (ADR-0142 D3), which it stored before and the registry
then refused to register; the promote refuses such a draft. A label
equal to what the `/meta` read serves for its source in some locale is
stored as the source. Nothing is widened.
- **Metadata-protocol: none.** No key is added, removed, renamed or
re-shaped; every refused body was already one the registry refused.
- **The edge.** An author who types exactly the translation of a label's
source in some locale gets the source back. A reader in that locale is
served the translation anyway, since the catalog translates it.
- **Residual: the post-commit catch stays for the record-currency
door.** The pre-flight judges the body alone, so the record-currency
`summary` door, which reads the registry's other objects as they stand,
stays the registration's own question in both directions: a draft whose
sibling in the same batch takes the child's amount out of
record-currency mode is not refused at save, and one whose sibling
completes the pair passes save and promote and fails at registration,
where `applyObjectRegistryMutation` still only logs it. Turning that
catch into a `failed[]` row means changing the Phase 2 receipt, which is
#29414's region; left for a follow-up after it lands.
- **Residual: other translated slots.** The restoration covers the slots
`translateObject` writes for the object and its fields. Translated
list-view labels and validation messages are not compared; none of them
is a registration refusal.
- **The protocol.ts freeze.** `protocol.ts` was frozen from 2026-11-03
03:39 UTC until the #25467 section-3 PR merged (339f63d). Main was
merged into this branch after the lift with no conflicts, and the review
fixes that touch `protocol.ts` landed after it (1fc44a4).
- **studio-authoring.first-run-loop, zh-CN leg (for the seat to
route).** Run the loop with the console in 中文 (`Accept-Language:
zh-CN`): create the object in the designer, publish, and expect (a) the
publish receipt `published`, (b) the new object's list serves records,
not 404, (c) `GET /api/v1/meta/object/NAME?layers=true` has no `queue` /
`owner_id` in the overlay, (d) the same read in `en` shows `Owner` /
`Queue`.
## Verification
<details><summary>Red first: measured on main fc55892, booted showcase,
zh-CN vs en</summary>
```
step1 zh-CN queue 200 {"type":"text","label":"队列",...}
step1 en queue 200 Queue
package create 201
qazh_q_en PUT draft 200
qazh_q_zh PUT draft 200
publish 200 {"outcome":"published","publishedCount":2,"failedCount":0,...}
qazh_q_en meta 200 data 200
qazh_q_zh meta 200 data 404 {"error":"Object 'qazh_q_zh' is not registered","code":"OBJECT_NOT_FOUND"}
server log: [Protocol] registerObject failed for qazh_q_zh: ... declares a field named `queue`
stored overlay: fields.queue.label 队列, fields.owner_id.label 负责人
```
</details>
<details><summary>Green: same script on this branch</summary>
```
publish 200 {"outcome":"published","publishedCount":2,"failedCount":0}
qazh_q_en meta 200 data 200
qazh_q_zh meta 200 data 200
qazh_q_zh stored overlay fields {"title":{"type":"text","label":"Title"}}
author queue PUT draft 422 ... declares a field named `queue` ...
qazh_q_zh en read owner_id/queue labels Owner Queue
```
</details>
<details><summary>Ablations (scripts/ablation-replace.mjs +
ablation-dist-preflight, metadata-protocol dist rebuilt per
leg)</summary>
- Restoration ablated (the early return answers a marker):
`protocol-meta-effective-schema.test.ts` 2 failed / 14 passed (the zh-CN
echo and the migration repair).
- Registration refusal ablated: 2 failed / 14 passed (the save-door
refusal and the promote refusal). Its first run showed the promote pin
was passing on the OWD gate's 422; the draft now carries a
`sharingModel` and the pin asserts `ObjectQueueColumnRefusedError` by
name, and the rerun was red.
- Restore leg: rebuilt, marker absent, tree clean, 16/16.
</details>
<details><summary>Final head 1fc44a4 (after contract review 6595971055
and delta review): gates, lint, package tests, ablations, dogfood
battery (exit codes captured to file)</summary>
- `dispatch-gates --commands`: 85 derived; all 85 exit 0. `--ran`
reconciliation: 85 run, 0 NOT-MEASURED. (`check-plugin-teardown-shape
--self-test` needed `git fetch origin 621a487` in the shallow clone;
`check:dual-build-cjs-loads` needed a whole-workspace `turbo run build`
first.)
- `pnpm lint` (eslint over the repo, then `turbo run typecheck`): exit
0.
- Whole-workspace `turbo run build`: the first run failed only in
`@object-ui/app-shell` on `pinyin-pro`, a dependency main added, missing
from `node_modules` after the merge; after `pnpm install
--frozen-lockfile` the app-shell build ran 65/65 tasks successful.
- `pnpm --filter @objectstack/metadata-protocol test`: 311 files passed,
4 skipped; 22311 tests passed, 26 skipped.
- `pnpm --filter @objectstack/objectql test`: 544 files, 10770 tests
passed.
- `pnpm --filter @objectstack/spec exec vitest run src/system`: 69
files, 2213 tests passed.
- `pnpm --filter @objectstack/dogfood test` (the full battery, narrowing
arm): 406 files passed, 3 skipped; 3356 tests passed, 28 skipped.
- Ablations (`scripts/ablation-replace.mjs` + `ablation-dist-preflight`,
metadata-protocol dist rebuilt per leg, marker present in the built
files), each red as predicted and restored to blob == HEAD:
- the packaged base not passed: the packaged-base door test, 1 of 20;
- the advisories not added: the advisory test, 1 of 20;
- the post-gate registry ask removed: the save-door `queue` refusal, 1
of 20;
- the code-layer MetadataService read restored: the degraded-service
test, 1 of 20;
- in-module, the packaged base not passed to `translateObject`: 1 of 6;
- the `bodyOnly` stop removed: the record-currency pre-flight test, 1 of
14.
- Restore leg: marker absent from all 24 built files, 20/20, tree clean.
The first attempts of three legs were no-ops (a comment marker the build
strips, and a leg that did not type-check) and were rerun with markers
that survive the build.
- The first full pass at f1fa81f had 86 metadata-protocol and 5
objectql test failures: the stored-row read ran before the refusals,
against test doubles without `getItem` / `engine.find`. 866fe6c moved
that read after the refusals; 1fc44a4 replaced its code-layer fallback
with the packaged base, and the interim `getItem` guard in
`readCodeLayerForCarryForward` was reverted.
</details>
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01JQwxbZUVFvbHQ7Uq4qpCdd
<!-- signed: domain:server#2 · os-warren ·
session_01JQwxbZUVFvbHQ7Uq4qpCdd -->
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 99c0696 commit 589049b
12 files changed
Lines changed: 1186 additions & 45 deletions
File tree
- .changeset
- packages
- metadata-protocol/src
- objectql/src
- qa/dogfood/test
- spec
- api-surface
- export-origins
- src/system
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
0 commit comments