Skip to content

Commit 6eaf404

Browse files
fix(spec): register the newTabUrl path refusal as an ADR-0087 D3 entry; the form label says path (#27282)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwBAaQW5g4DKEMyfrDWjAqdoh)_ Refs #26924 Clause-②: no Metadata-protocol: none Skills-docs: none — no `skills/**` file changes; the one sentence that teaches `newTabUrl` (`skills/objectstack-ui/rules/actions.md`) already teaches a backend path since #27252. Not Tier H. This PR carries the contract-review FAIL items of record 6470108120 on #27252, found after the maintainer had queued #27252 (queued 13:21Z, record 13:21Z, merged 13:42Z as `c13ef04c3`). ## 维护者速读 - 改了什么:为 #27252 已上线的 `newTabUrl` 收窄补登 ADR-0087 D3 语义条目 `ui-action-new-tab-url-path-refused`(结构化 TODO,两种改法都写明,不做自动转换);动作表单该字段的标签在四种语言里都改为「路径」。 - 为什么改:合约复核 6470108120 判 #27252 的 ADR-0087 处置写成 `not-required`,但变更说明里带了改法,应登记条目;复核结果出来时 #27252 已在合并队列里,所以补在这里。 - 风险与回滚:不改任何可写取值;条目只进迁移登记表(升级指南与 `os migrate meta` 的待办),标签仅文字。回滚即 revert 本 PR。 - 先看:`packages/spec/src/migrations/entries/semantic/18.ui-action-new-tab-url-path-refused.ts`、`.changeset/26924-new-tab-url-d3-entry.md`、`packages/spec/src/ui/action.form.ts`;文档 `content/docs/protocol/objectui/actions.mdx` 一句改为「本后端路径」。 - 两分钟验证:`pnpm --filter @objectstack/spec gen:migration-registry && pnpm --filter @objectstack/spec exec vitest run src/migrations`,预期全部通过,且生成的登记表含 `ui-action-new-tab-url-path-refused`。 - 需要你做的:无,席位落地。 ## What changes - **ADR-0087 registration.** New D3 semantic entry `packages/spec/src/migrations/entries/semantic/18.ui-action-new-tab-url-path-refused.ts` (protocol 18), following the in-tree `18.*-refused.ts` shape (`18.ui-action-undoable-unfulfillable-refused.ts`): - `surface`: an `action` document (standalone, an object's `actions[]`, or type-level) whose `newTabUrl` does not start with `/`. - `replacement`: the backend path (`newTabUrl: '/sso-open/{recordId}'`), or for an external page drop `opensInNewTab` / `newTabUrl` and declare a `type: 'url'` action (`target: 'https://example.com/export/${ctx.recordId}'`, `openIn: 'new-tab'`). Stated as **not mechanically convertible, a structured TODO**: an external URL names no path on this backend, and the url-action rewrite changes `type`, drops the action's own `target` / `body` handler and replaces the auth-enforcing endpoint with a page the browser opens directly, so which remedy the author meant is not recorded anywhere. - `reason` and `acceptanceCriteria`: the console's origin-prefix behaviour, why the set is closed rather than widened, and the stored-row behaviour already pinned by #27252 (loads unchanged and registered, `invalid` with a `[metadata_spec_invalid]` boot line, `_diagnostics` at `newTabUrl`, next save 422 `INVALID_METADATA`). - `ActionSchema.newTabUrl`'s JSDoc names the entry. - **Changeset.** The merged `.changeset/26924-new-tab-url-backend-path.md` is not edited (`check-empty-changeset` refuses edits to a changeset the merge base carries). A new `.changeset/26924-new-tab-url-d3-entry.md` (`@objectstack/spec` patch, `@objectstack/platform-objects` patch) carries `adr-0087: registered ui-action-new-tab-url-path-refused` and a "Supersedes, in the same release" paragraph that replaces the earlier `not-required` disposition. Both packages are unreleased since #27252, so the two notes ship together. - **Docs.** `content/docs/protocol/objectui/actions.mdx` called `newTabUrl` a "new-tab target template", which reads as a URL after #27252; it now says a path on this backend starting with `/`, and points an external page at a `type: 'url'` action with `openIn: 'new-tab'` (flagged by the docs-drift check on this PR). - **Form label.** `action.form.ts` labels `newTabUrl` "New-tab path"; `en.metadata-forms.generated.ts` is regenerated by `node scripts/check-i18n-bundles.mjs --write`. zh-CN 「新标签页路径」, ja-JP 「新しいタブのパス」, es-ES "Ruta de pestaña nueva": translated-locale values are hand-authored (AGENTS.md documentation guardrails: the generator rewrites only `en` and keeps every translated value), so they are edited in place and the generator re-run reports them in sync. ## Tests - No new test: the entry is data the registry tests read. `gen:migration-registry` then `src/migrations` + `src/conversions` → see Verification. ## Acceptance notes - No `refused-shapes.ts` detector is added for the entry; the review asked for the D3 entry. A detector would make `os migrate meta --stored` list each stored row by name; that can follow if the seat wants it. - The missing `skills/objectstack-ui/evals` case for `newTabUrl` stays with the lane seat, as noted at ACCEPT. ## Verification All readings on a shared container, base `main` `7858c1d16`. Head `78e8f9751`: two commits on `7858c1d16`, 9 paths. `0c704ce93` carries the entry, the changeset and the labels (8 paths, +61/-7); `78e8f9751` is the one-line docs edit. - Locked run (`os-verify-lock`, one script): install, `gen:migration-registry`, build, `check:generated` exit 0; `@objectstack/spec` vitest 748 files / 22349 tests passed; `@objectstack/platform-objects` 96 files / 1361 tests passed; CLI build, then `check:i18n` OK (en regenerated, translated locales in sync). VERDICT command-exit 0, 891 s. - `src/migrations` + `src/conversions` after `gen:migration-registry`: 28 files / 945 tests passed; the generated registry carries `ui-action-new-tab-url-path-refused`. - `node scripts/check-adr-0087-registration.mjs --base origin/main` exit 0, which does not judge this marker: the gate reads only changesets that declare a breaking change, and this one is a patch. The marker was checked by hand instead: the entry file name, its exported `id` and the marker all read `ui-action-new-tab-url-path-refused`, and the generated registry carries it. `check-empty-changeset` exit 0 (the merged changeset is untouched). - Derived gates: on `0c704ce93`, 80 families were derived and all 80 ran serially, every one exit 0. With the docs commit the derivation is 101; the 21 new families plus every docs/link/affected-docs family (31 in all) were rerun on `78e8f9751`, every one exit 0. `dispatch-gates --ran` on `78e8f9751`: 101 derived, 101 run, 0 NOT-MEASURED, 0 UNRUN. - Not run here: the full dogfood suite (this diff moves no runtime path; it ran on #27252 at 348 passed / 3 skipped). --- 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01WGufzhJoJYPHSvBpq8euSq --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4c9d0d3 commit 6eaf404

9 files changed

Lines changed: 62 additions & 8 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@objectstack/spec': patch
3+
'@objectstack/platform-objects': patch
4+
---
5+
6+
fix(spec): the `newTabUrl` path refusal is registered as an ADR-0087 D3 semantic entry, and the action form labels the field "New-tab path"
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
<!-- adr-0087: registered ui-action-new-tab-url-path-refused -->
12+
13+
Supersedes, in the same release, the ADR-0087 disposition of the `newTabUrl` narrowing note (`fix(spec)!: an action's newTabUrl is a path on this backend and must start with /`), which declared `not-required (no-migration-prescription)` while carrying a remedy. That narrowing is now registered: D3 semantic entry `ui-action-new-tab-url-path-refused` (protocol 18) names the surface (an `action` whose `newTabUrl` does not start with `/`), the two remedies (write the backend path, or drop `opensInNewTab` / `newTabUrl` and declare a `type: 'url'` action with `target: 'https://…'` and `openIn: 'new-tab'`), and why neither is applied mechanically: an external URL names no path on this backend, and the url-action rewrite changes the action's type, drops its handler and replaces an auth-enforcing endpoint with a page the browser opens directly. So `os migrate meta` and the upgrade guide carry it as a structured TODO, and a stored row holding such a value behaves exactly as that note says (loads unchanged and registered, counted `invalid` with a `[metadata_spec_invalid]` boot line, served with `_diagnostics`, next save refused until rewritten). No accept set changes here.
14+
15+
The action form's `newTabUrl` field label now says path in every locale: "New-tab path" (en), "新标签页路径" (zh-CN), "新しいタブのパス" (ja-JP), "Ruta de pestaña nueva" (es-ES).

‎content/docs/protocol/objectui/actions.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ params:
107107
required: true
108108
```
109109

110-
For a **static** url, `openIn: 'new-tab'` opens `target` in a new tab and `openIn: 'self'` navigates in place; omitted, absolute URLs open in a new tab and relative ones navigate in place. For an **async handler that redirects**, set `opensInNewTab: true` instead (the renderer pre-opens the tab synchronously so popup blockers don't fire); `newTabUrl` provides a zero-roundtrip new-tab target template supporting the `{recordId}` placeholder.
110+
For a **static** url, `openIn: 'new-tab'` opens `target` in a new tab and `openIn: 'self'` navigates in place; omitted, absolute URLs open in a new tab and relative ones navigate in place. For an **async handler that redirects**, set `opensInNewTab: true` instead (the renderer pre-opens the tab synchronously so popup blockers don't fire); `newTabUrl` skips the round-trip with a path on this backend, starting with `/` (`{recordId}` placeholder); an absolute URL is refused, so an external page is a `type: 'url'` action with `openIn: 'new-tab'`.
111111

112112
<Callout type="warn">
113113
**The interpolation scope is not authorable, and `params` is not it.** `params`

‎packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1844,7 +1844,7 @@ export const enMetadataForms: NonNullable<TranslationData['metadataForms']> = {
18441844
helpText: "Open the action RESULT in a new tab: the renderer pre-opens the tab synchronously on click (popup-blocker-safe) and navigates it to the handler's redirectUrl. Distinct from `openIn`, which routes a static URL target."
18451845
},
18461846
newTabUrl: {
1847-
label: "New-tab URL",
1847+
label: "New-tab path",
18481848
helpText: "Path on this backend, starting with / (e.g. /sso-open/{recordId}), with a {recordId} placeholder. Set together with `opensInNewTab` the renderer navigates the pre-opened tab to the backend origin + this path immediately and posts nothing — so the endpoint must enforce auth itself. An absolute URL is refused; for an external page use a url action with Open In set to new-tab."
18491849
},
18501850
locations: {

‎packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1844,7 +1844,7 @@ export const esESMetadataForms: NonNullable<TranslationData['metadataForms']> =
18441844
helpText: "Abre el RESULTADO de la acción en una pestaña nueva: el renderizador la abre de forma síncrona al hacer clic (a prueba de bloqueadores de ventanas emergentes) y luego la lleva a la redirectUrl del manejador. Distinto de `openIn`, que enruta un destino de URL estática."
18451845
},
18461846
newTabUrl: {
1847-
label: "URL de pestaña nueva",
1847+
label: "Ruta de pestaña nueva",
18481848
helpText: "Ruta en este backend, que empieza por / (p. ej. /sso-open/{recordId}), con un marcador {recordId}. Junto con `opensInNewTab`, el renderizador lleva la pestaña preabierta de inmediato al origen del backend + esta ruta y no envía nada, así que el endpoint debe aplicar la autenticación por su cuenta. Una URL absoluta se rechaza; para abrir una página externa usa una acción de tipo url con «Abrir en» en new-tab."
18491849
},
18501850
locations: {

‎packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1844,7 +1844,7 @@ export const jaJPMetadataForms: NonNullable<TranslationData['metadataForms']> =
18441844
helpText: "アクションの実行結果を新しいタブで開きます。レンダラーはクリック時に同期的にタブを先に開き(ポップアップブロックを回避します)、ハンドラーが返す redirectUrl へ遷移させます。静的な URL の行き先を決める `openIn` とは別物です。"
18451845
},
18461846
newTabUrl: {
1847-
label: "新しいタブの URL テンプレート",
1847+
label: "新しいタブのパス",
18481848
helpText: "このバックエンド上のパスで、/ で始めます(例: /sso-open/{recordId})。{recordId} プレースホルダーが使えます。`opensInNewTab` と併せて設定すると、レンダラーは先に開いたタブをただちに「バックエンドのオリジン + このパス」へ遷移させ、アクションの POST は行いません。したがってこのエンドポイントは自前で認証を強制する必要があります。絶対 URL は拒否されます。外部ページを開くには、url タイプのアクションで「リンクを開く場所」を new-tab にしてください。"
18491849
},
18501850
locations: {

‎packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1844,7 +1844,7 @@ export const zhCNMetadataForms: NonNullable<TranslationData['metadataForms']> =
18441844
helpText: "在新标签页打开动作的执行结果:渲染端在点击时同步预开一个标签页(不会被弹窗拦截),随后把它导航到处理程序返回的 redirectUrl。与 `openIn` 不同,后者决定静态 URL 目标的去向。"
18451845
},
18461846
newTabUrl: {
1847-
label: "新标签页 URL 模板",
1847+
label: "新标签页路径",
18481848
helpText: "本后端上的路径,必须以 / 开头(如 /sso-open/{recordId}),可用 {recordId} 占位符。与 `opensInNewTab` 同时设置时,渲染端会立即把预开的标签页导航到“后端地址 + 该路径”,不再发起动作请求——因此该端点必须自行校验鉴权。绝对 URL 会被拒绝;要打开外部页面,请用 url 类型的动作,并把“链接打开位置”设为 new-tab。"
18491849
},
18501850
locations: {
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import type { SemanticMigration } from '../../types.js';
4+
5+
export const entry: SemanticMigration = {
6+
id: 'ui-action-new-tab-url-path-refused',
7+
surface: '`action` documents (standalone, an object\'s `actions[]`, or a type-level action) '
8+
+ 'whose `newTabUrl` does not start with `/` — an absolute `http(s)://` URL, or a relative '
9+
+ 'path such as `sso-open/{recordId}`',
10+
replacement: 'for an endpoint on this backend, its path: `newTabUrl: \'/sso-open/{recordId}\'`; '
11+
+ 'for an external page, drop `opensInNewTab` and `newTabUrl` and declare a `type: \'url\'` '
12+
+ 'action instead — `target: \'https://example.com/export/${ctx.recordId}\'`, '
13+
+ '`openIn: \'new-tab\'`. ⛔ NOT mechanically convertible, so this entry is a structured TODO '
14+
+ 'and no conversion rewrites a stored row: an external URL names no path on this backend, '
15+
+ 'and the url-action rewrite is not one spelling of the same action — it changes `type`, '
16+
+ 'drops the action\'s own `target` / `body` (the handler the pre-opened-tab flow falls back '
17+
+ 'to without `newTabUrl`) and replaces the backend endpoint that enforces auth with a page '
18+
+ 'the browser opens directly. Which of the two the author meant is an intent no artifact '
19+
+ 'records; the refusal names both and the author chooses',
20+
reason:
21+
'The console\'s zero-roundtrip fast path (`consoleServerAction.ts`) drives the pre-opened '
22+
+ 'tab to the backend origin + `newTabUrl`, so an absolute value opened a tab at '
23+
+ '`https://BACKEND/https://example.com/…`. The JSDoc called the key a path template while '
24+
+ 'its describe and the form help called it a URL template, and nothing refused the absolute '
25+
+ 'value, so AI-written metadata could carry it silently. The key exists for a same-backend '
26+
+ 'endpoint that enforces auth itself (the `/sso-open` shape), so the accepted set is closed '
27+
+ 'to values that start with `/` rather than widened to navigate URLs verbatim — a widening '
28+
+ 'would let metadata open any origin from an action.',
29+
acceptanceCriteria:
30+
'Every `action` document\'s `newTabUrl`, when present, starts with `/`. A path template '
31+
+ '(`/sso-open/{recordId}`, `/api/v1/invoice/{recordId}/pdf`) parses byte-identically to '
32+
+ 'before. Any other value is refused at `newTabUrl` with a message naming the backend-path '
33+
+ 'form and the `type: \'url\'` + `openIn: \'new-tab\'` action for an external page. A stored '
34+
+ 'row holding such a value is converted by nothing: it loads unchanged and is still '
35+
+ 'registered, counted `invalid` with a `[metadata_spec_invalid]` boot line, served with '
36+
+ '`_diagnostics` locating `newTabUrl`, and its next save is refused 422 `INVALID_METADATA` '
37+
+ 'until the author writes a path or rewrites it as a url action.',
38+
};

‎packages/spec/src/ui/action.form.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ export const actionForm = defineForm({
150150
// `FormSelectOptionSchema.value` applies: the enum derives.
151151
{ field: 'openIn', visibleWhen: "data.type == 'url'", helpText: "Where to open a static `target` URL — 'self' navigates in place, 'new-tab' opens a new browser tab. Omitted, an absolute or external URL opens in a new tab and a relative one navigates in place." },
152152
{ field: 'opensInNewTab', type: 'boolean', helpText: "Open the action RESULT in a new tab: the renderer pre-opens the tab synchronously on click (popup-blocker-safe) and navigates it to the handler's redirectUrl. Distinct from `openIn`, which routes a static URL target." },
153-
{ field: 'newTabUrl', label: 'New-tab URL', type: 'text', visibleWhen: "data.opensInNewTab == true", helpText: 'Path on this backend, starting with / (e.g. /sso-open/{recordId}), with a {recordId} placeholder. Set together with `opensInNewTab` the renderer navigates the pre-opened tab to the backend origin + this path immediately and posts nothing — so the endpoint must enforce auth itself. An absolute URL is refused; for an external page use a url action with Open In set to new-tab.' },
153+
{ field: 'newTabUrl', label: 'New-tab path', type: 'text', visibleWhen: "data.opensInNewTab == true", helpText: 'Path on this backend, starting with / (e.g. /sso-open/{recordId}), with a {recordId} placeholder. Set together with `opensInNewTab` the renderer navigates the pre-opened tab to the backend origin + this path immediately and posts nothing — so the endpoint must enforce auth itself. An absolute URL is refused; for an external page use a url action with Open In set to new-tab.' },
154154
],
155155
},
156156
{

‎packages/spec/src/ui/action.zod.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1720,8 +1720,9 @@ const actionObject = () => strictObject({
17201720
* action — `target: 'https://…'` (with `${ctx.recordId}` where the record
17211721
* id goes) and `openIn: 'new-tab'`.
17221722
*
1723-
* A stored row written before the refusal is converted by nothing (no
1724-
* backend path can be derived from an external URL): it loads unchanged,
1723+
* A stored row written before the refusal is converted by nothing (the
1724+
* rewrite is the author's choice; ADR-0087 D3 entry
1725+
* `ui-action-new-tab-url-path-refused` is its structured TODO): it loads unchanged,
17251726
* is counted `invalid` with a `[metadata_spec_invalid]` boot line and still
17261727
* registered, is served with `_diagnostics` locating `newTabUrl`, and its
17271728
* next save is refused until the value is a path or the action is rewritten

0 commit comments

Comments
 (0)