Skip to content

feat(app-shell): a key-only record control, and the Studio hook form offers refusals with it (#26835) - #27160

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

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/project-thread-dyli5z

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Requested by Warren · project thread
Fixes #26835

Clause-②: no
Metadata-protocol: none

维护者速读

  • 改了什么:Studio 钩子表单新增「Refusals」一行,可以逐条填写拒绝码、提示语和状态码;表单的 record 编辑器新增一个通用的「只用键」模式,任何 record 字段声明后都能用。
  • 为什么改:原来的 record 编辑器会把键名写进每个条目,而钩子的拒绝条目是封闭对象,写进去就被 spec 拒收,所以 Studio 里根本没法配置可翻译的拒绝提示。
  • 风险与回滚:只有声明了 widget: 'key-only-record' 的字段走新逻辑,其余 record 字段行为不变(有测试钉住);回滚即撤销本 PR。
  • 先看:ui/packages/app-shell/src/views/metadata-admin/SchemaForm.tsx 的 RecordField 改动(键名不写入、改名逻辑);packages/spec/src/data/hook.form.ts 新增的一行。
  • 两分钟验证:pnpm --dir ui exec vitest run packages/app-shell/src/views/metadata-admin/SchemaForm.keyOnlyRecord-26835.test.tsx,预期 8 个测试全绿。
  • 需要你做的:无,席位落地。

What changes

  • Key-only record face (SchemaForm.tsx, RecordField). A type: 'record' form row that declares widget: 'key-only-record' (KEY_ONLY_RECORD_WIDGET) edits a map of closed entries: add, rename, remove and reorder keys, and edit each entry's fields, without writing the key into the entry. A key is checked against the record's served key rule (propertyNames.pattern, which the server derives from the spec's key schema), so the rule is never restated in the UI. A rename moves the entry in place once the typed key is valid, so an invalid intermediate spelling stays typeable. A record row with no such widget is untouched: the key is still mirrored into the entry.
  • Hook form (packages/spec/src/data/hook.form.ts). The Body section offers refusals with that control, with message (textarea, required) and status (number) per entry.
  • Gate ledger (metadata-form-zod-reconciliation.test.ts). The hook root omission row for refusals that the hook refusal catalogue PR recorded is removed: the form now offers the key, and the reconciliation reads refusals.message / refusals.status against HookRefusalSchema.
  • i18n. engine.form.key (en Key, zh 键名) names the key column when the form declares no key label.
  • Translations. The generated metadata-form bundles carry the three refusals rows, translated in zh-CN, ja-JP and es-ES; three platform-objects census pins and one cli coverage filter move with them.
  • Changeset. Patch for @objectstack/spec, @objectstack/platform-objects, @object-ui/app-shell, @object-ui/i18n.

Why

After the hook refusal catalogue landed, a hook can declare refusals: { CODE: { message, status? } } so its refusal messages are translatable, but the Studio hook form could not author it: the generic record editor stamps each key into its entry (name by default), and a refusal entry is closed at message / status, so the stamped key is refused. The card asked for a generic key-only record control, opted into by declaration, and for the hook form to use it.

Tests

New file SchemaForm.keyOnlyRecord-26835.test.tsx (8 tests). Everything it reads comes from the spec: the form row is hookForm's own, the field schema is the served derivation of HookSchema (z.toJSONSchema with io: 'input'), and the key rule is checked equal to HOOK_REFUSAL_CODE_PATTERN.source.

  • the hook form declares refusals as a record on the key-only face;
  • adding an entry writes no key into it;
  • a key that breaks the served rule (clean_version) is refused and the value is unchanged;
  • a rename moves the entry in place, unstamped, through an invalid intermediate spelling; a rename onto an existing key is refused;
  • round trip: two refusals authored in the form save a body HookSchema accepts, the parsed refusals serialise byte-identical to the saved ones, and the saved body reloads into the same rows in the same order;
  • control: a record row with no widget still mirrors the key, and HookSchema refuses that body with unrecognized_keys ['name'] at the entry.

Ablation, one-off, restored by scripts/ablation-replace.mjs (blob back to HEAD 892c144b, git diff HEAD empty): const keyOnly = widget === KEY_ONLY_RECORD_WIDGET; replaced with const keyOnly = false; (the hook row falls back to the stamping editor) turns 4 of 8 red, the round trip among them; the 4 that stay green are the declaration, the served-rule and the default-face pins, which do not read the face.

Acceptance notes

  • The control refuses a bad key with the generic engine.form.keyPattern text naming the served pattern, not with the sentence HookSchema attaches to the record key ("A refusal code is SCREAMING_SNAKE …"). That sentence is inline in hook.zod.ts and the served JSON Schema does not carry it, so a generic control has no way to read it without restating it. The row's own help text states the spelling.
  • keyField.field, regex and immutable are not read on the key-only face (nothing is stamped, and a key is always renamable). Declaring keyField on such a row would also make the reconciliation gate count keyField.field as an offered member, so the hook row declares none.

Verification

Local, on the branch head named in the report: the new file 8/8; ui vitest over packages/app-shell/src/views/metadata-admin/ and packages/i18n/ 727 files green; spec vitest over src/system/, src/data/hook*.test.ts, src/kernel/repeater-item-titles.test.ts 65 files / 2208 tests green; type-check for @object-ui/app-shell, @object-ui/i18n, @objectstack/spec and type-check:scripts exit 0. The derived gate list (84 commands) is reconciled with dispatch-gates --ran in the report; check:i18n and check:dual-build-cjs-loads need a whole-repo build and are left to CI's Lint and Repo Gates.

Human Minimum

  • 1. Schemas start as Zod; packages/spec holds no runtime logic.
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran; pnpm lint passes (CI).
  • 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: no generated artifact moves (a form row, no schema change).
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JQwxbaFd8s4eYs6HGFghK3


Generated by Claude Code

…offers refusals with it

Claude-Session: https://claude.ai/code/session_01JQwxbaFd8s4eYs6HGFghK3
Co-authored-by: Claude <noreply@anthropic.com>
…refusals round trip

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

github-actions Bot commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

2 anchor(s) derived from 2 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see

Coarse fallback — 148 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 9b242df34f343e86d5fbee4513e3822f0d2e5b89 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 9b242df34f343e86d5fbee4513e3822f0d2e5b89

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

…hook form's refusals rows

Regenerated with scripts/check-i18n-bundles.mjs --write and translated in
zh-CN, ja-JP and es-ES; the three census pins that count hook-form
children and translated row labels move with the new rows, and the cli
flow-refusal coverage pin is scoped to the flows bucket.

Claude-Session: https://claude.ai/code/session_01JQwxbaFd8s4eYs6HGFghK3
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

ACCEPT · seat domain:ui#1 · session_01JQwxbZeBv2y91towxasSUT · head ecfa182f · 2026-10-28T09:50Z

席位意见:Studio 的钩子表单现在可以编写 refusals(拒绝码目录):表单的 record 编辑器新增「只用键」模式,按声明 widget: 'key-only-record' 选用,不再把键写进条目,保存出的正是 HookSchema 接受的形状;原有 record 字段行为不变。

  1. Shape: Fixes #26835; 15 files after the CI fix (+409/−42): SchemaForm.tsx +108/−27 (the key-only face of RecordField), packages/spec/src/data/hook.form.ts +14/−1 (the refusals row), the feat(spec): a hook declares its refusal catalogue, and its body refuses by code, answered in the caller's language #26831 omission row removed from metadata-form-zod-reconciliation.test.ts, one new key engine.form.key (en + zh), one new test file (8 tests), changeset @objectstack/spec + @objectstack/platform-objects + @object-ui/app-shell + @object-ui/i18n patch. The CI fix (ecfa182f) regenerated the platform-objects metadata-form bundles with check-i18n-bundles.mjs --write (zh-CN / ja-JP / es-ES rows translated), moved three census pins (7→9 enumerated hook children, 689→692 translated rows, each with a dated comment), and scoped one cli coverage filter to the flows bucket; the seat applied the PR body amendment.
  2. Premise measured: widget is an existing free-string key on the spec's form field (view.zod.ts ≈L3625), so the opt-in adds no spec shape; HookSchema is unchanged (Clause-②: no, Metadata-protocol: none). The if (widget) delegation in RecordField only fires for a WIDGETS entry, and key-only-record is not one, so it falls through to the inline face, which then reads keyOnly. On that face no key member is written (add, rename, emit), the key rule is the served propertyNames.pattern (derived from HOOK_REFUSAL_CODE_PATTERN, hook.zod.ts ≈L619, not copied), and an invalid half-typed key is held in local state until valid. The default face still stamps keyField.field ?? 'name'; a test pins that.
  3. Gates / check-runs: PR check-runs on ecfa182f: 57 success, 10 skipped, 0 failure; mergeable_state clean (the five reds on 6fd5eb4c were this PR's and are fixed there). Dev's local run: 84 derived gates, 82 exit 0, 2 NOT MEASURED (check:i18n, check:dual-build-cjs-loads need a whole-repo build; CI's Lint and Repo Gates run them); type-checks 0; ablation (face forced off) turns 4 of 8 red, round trip included.
  4. Accepted deviations: a bad key gets the generic engine.form.keyPattern text naming the served pattern, not the inline hook.zod.ts sentence (the served schema does not carry it, and the row's helpText states the spelling); the control is a face of RecordField rather than a WIDGETS entry (avoids an import cycle). Seat trial merges of this head with origin/main and with PR fix(app-shell): Studio Access pillar polish — plain-words draft race refusal, OWD banner clears on revert, live rail label, field table count and pinned name column #27148's head (same i18n files) are clean.
  5. Out of scope: the default face's editable-key path renames per keystroke (no form uses immutable: false today); noted, not filed. Skills-docs: none — skills/objectstack-data/references/data-hooks.md (Declared refusals) describes the shape, not where it is authored.
  6. Landing: not Tier H; the seat readies and arms auto-merge.

Generated by Claude Code

Resolve the platform-objects census pin in
object-lifecycle-panel-echo-decisions.test.ts: main's #26602 polarity row
(690) plus this branch's three hook-form refusals rows gives 693; both
dated comments kept.

Claude-Session: https://claude.ai/code/session_01JQwxbaFd8s4eYs6HGFghK3
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · addendum

ACCEPT stands · seat domain:ui#1 · session_01JQwxbZeBv2y91towxasSUT · head b88c9b60 · 2026-10-28T10:39Z

The merge queue removed this PR for a merge conflict. The dev merged origin/main (c07f0347) without force. The seat compared every file's +/- lines of ecfa182f and b88c9b60 against their own merge bases. The file set is the same, and every file is identical except object-lifecycle-panel-echo-decisions.test.ts. That file's census pin now reads 693, which is main's 690 (#26739's polarity row) plus this PR's three rows, with both dated comments kept. PR check-runs on b88c9b60: 55 success, 8 skipped, 0 failure; mergeable_state clean. The seat re-arms auto-merge.


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 28, 2026
Merged via the queue into main with commit 39fd5fb Oct 28, 2026
63 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-dyli5z branch October 28, 2026 11:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants