From ead727dfab3c0370914317b7ef3a8ff9c063af17 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 07:05:15 +0000 Subject: [PATCH 1/3] fix(core/app-shell): derive `resultDialog`'s label members from the contract and resolve them (objectui#9542) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `@object-ui/core`'s `ResultDialogSpec` claimed to mirror `Action.resultDialog` in `@objectstack/spec` and did not: `title`, `description`, `acknowledge` and each `fields[].label` were hand-written `string` where the contract declares every one of them `I18nLabel` — a plain string or an inline per-locale map, both authorized and neither deprecated. All four now derive from the contract type, so the alignment is re-checked by `tsc` instead of asserted in prose. Measured consequence: the map arm reached `ActionResultDialog`'s JSX as a React child, React refused the object, and the dialog threw rather than mis-rendering — the one-shot reveal failed to render at all on an action that had already succeeded. The dialog now resolves all four through `resolveI18nLabel` from `@objectstack/spec/ui` (the producer's own resolver for the inline form, aliased per this package's convention) against `useObjectTranslation().language`. Both action renderers drop the write-side narrowing assertion objectui#8648 left behind, and its ledger leg is converted to pin the unasserted write — keeping only the `as any` negative, which was never the workaround. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Xm4WFhEe5mwcgyqHjxR2hn --- .changeset/9542-result-dialog-i18nlabel.md | 42 ++++++++++ .../src/views/ActionResultDialog.tsx | 42 ++++++++-- .../__tests__/ActionResultDialog.test.tsx | 76 ++++++++++++++++++- .../action-undeclared-keys-8648.test.ts | 59 +++++++++----- .../src/renderers/action/action-button.tsx | 23 +++--- .../src/renderers/action/action-icon.tsx | 9 ++- packages/core/src/actions/ActionRunner.ts | 36 ++++++++- 7 files changed, 238 insertions(+), 49 deletions(-) create mode 100644 .changeset/9542-result-dialog-i18nlabel.md diff --git a/.changeset/9542-result-dialog-i18nlabel.md b/.changeset/9542-result-dialog-i18nlabel.md new file mode 100644 index 0000000000..3bcc60b6ec --- /dev/null +++ b/.changeset/9542-result-dialog-i18nlabel.md @@ -0,0 +1,42 @@ +--- +'@object-ui/core': patch +'@object-ui/app-shell': patch +'@object-ui/components': patch +--- + +Accept an inline per-locale label on an action's `resultDialog`, and resolve it +against the display language (objectui#9542). + +`@object-ui/core`'s `ResultDialogSpec` claimed in its own docblock to mirror +`Action.resultDialog` in `@objectstack/spec` and did not: `title`, +`description`, `acknowledge` and each `fields[].label` were hand-written +`string` where the contract declares every one of them `I18nLabel` — a plain +string **or** an inline per-locale map, both authorized by that type's docblock +and neither deprecated. This repo therefore refused what the platform accepts, +which is the `check:spec-symbols` rule-2 failure class (an alignment CLAIM with +a hand copy behind it), one package over from where that gate matches by name. + +**The consequence was measured, not inferred.** The map arm reached +`ActionResultDialog`'s JSX as a React child and React refuses an object there, +so the dialog did not mis-render — it threw and failed to render at all, on an +action that had **already succeeded**. That dialog is the only place a one-shot +reveal is ever shown (a TOTP secret, a freshly minted OAuth `client_secret`, +regenerated backup codes), so the value was gone. The rendering test in +`packages/app-shell` reproduces the throw: it is red on the unfixed tree with +`Objects are not valid as a React child (found: object with keys {en, zh-CN})`. + +The four members now DERIVE from the contract type instead of restating it, so +the mirror cannot drift again without `tsc` saying so, and the dialog resolves +each through `resolveI18nLabel` from `@objectstack/spec/ui` — the producer's own +resolver for the inline form — against `useObjectTranslation().language`, the +same display locale every other inline-`I18nLabel` caller in `app-shell` +resolves against. A plain-string label is unaffected: the resolver returns it +unchanged, and the existing fallback chain still answers for an absent label or +a map with no usable entry. + +Both action renderers drop the write-side narrowing assertion objectui#8648 had +to leave behind, so the whole `resultDialog` forward is compiler-checked again +with nothing asserted between it and `ActionDef`. The ledger leg that pinned +that workaround is converted rather than deleted outright: the `as any` negative +it carried is not a workaround and still guards a write-side cast that the +read-side matcher walks straight past. diff --git a/packages/app-shell/src/views/ActionResultDialog.tsx b/packages/app-shell/src/views/ActionResultDialog.tsx index 64327b501e..80a62b304c 100644 --- a/packages/app-shell/src/views/ActionResultDialog.tsx +++ b/packages/app-shell/src/views/ActionResultDialog.tsx @@ -14,6 +14,10 @@ * are skipped — the response shape varies per request (e.g. no * `temporaryPassword` when the admin typed one), and a labelled * `undefined` row would be noise. + * - `title`, `description`, `acknowledge` and each `fields[].label` are + * `I18nLabel` on the contract — a plain string OR an inline per-locale map + * — and every one is resolved against the display language before it + * reaches the DOM (objectui#9542). * - The dialog has NO close button — the user must click acknowledge. * This is the whole point: a toast would let them dismiss the value * before reading it. @@ -33,6 +37,12 @@ import { import { useObjectTranslation } from '@object-ui/i18n'; import { Copy, Eye, EyeOff, Check } from 'lucide-react'; import { toCanvas } from 'qrcode'; +// Aliased per PR #4169's convention — app-shell has its OWN `resolveI18nLabel` +// for the KEYED vocabulary, which does not accept the inline per-locale map +// this resolves. ⛔ Not `resolveKeyedI18nLabel`: the two are structurally +// confusable and answer wrongly for each other's input (objectui#4167), and +// `resultDialog`'s label members are the INLINE form. +import { resolveI18nLabel as resolveInlineI18nLabel } from '@objectstack/spec/ui'; import type { ResultDialogSpec, ResultDialogFieldSpec } from '@object-ui/core'; export interface ResultDialogState { @@ -58,9 +68,26 @@ function readPath(root: unknown, path: string): unknown { } export function ActionResultDialog({ state, onAcknowledge }: ActionResultDialogProps) { - const { t } = useObjectTranslation(); + // `language` is the display locale every other inline-`I18nLabel` caller in + // this package resolves against — `AppSidebar` and `UnifiedSidebar` read it + // off this same hook, `resolveActionParams` takes it threaded in by both its + // callers, and metadata-admin's `useMetadataLocale` narrows this same value + // to that designer's two bundled locales. One source, so a title and the + // surrounding chrome cannot disagree about the user's language. + const { t, language } = useObjectTranslation(); const { spec, data } = state; + // Each of these three is an `I18nLabel` on the contract: a plain string, or + // an inline per-locale map. Resolved here, once, on the way INTO JSX — an + // unresolved map is an object, and React refuses an object as a child, so the + // dialog threw rather than mis-rendering and the one-shot value it exists to + // reveal was lost on an action that had already succeeded (objectui#9542). + // `|| fallback` is kept over `??`: the resolver answers `undefined` for a map + // with no usable entry, and an empty string must reach the default too. + const title = resolveInlineI18nLabel(spec?.title, language); + const description = resolveInlineI18nLabel(spec?.description, language); + const acknowledge = resolveInlineI18nLabel(spec?.acknowledge, language); + // Synthesise a single-field render plan when the action did not declare // explicit fields — keeps the dialog body uniform. Declared fields whose // path does not resolve in the payload are dropped: the value is @@ -93,10 +120,10 @@ export function ActionResultDialog({ state, onAcknowledge }: ActionResultDialogP > - {spec?.title || t('actions.resultDialog.defaultTitle') || 'Save this value now'} + {title || t('actions.resultDialog.defaultTitle') || 'Save this value now'} - {spec?.description ? ( - {spec.description} + {description ? ( + {description} ) : null} @@ -113,7 +140,7 @@ export function ActionResultDialog({ state, onAcknowledge }: ActionResultDialogP @@ -130,8 +157,11 @@ function ResultField({ value: unknown; defaultFormat?: FieldFormat; }) { + const { language } = useObjectTranslation(); const format: FieldFormat = field.format ?? defaultFormat ?? 'json'; - const label = field.label; + // Same `I18nLabel` contract as the dialog's own three label members, same + // resolution — a field label is the fourth member the block declares as one. + const label = resolveInlineI18nLabel(field.label, language); // Type-guard each renderer rather than crash if the server returned an // unexpected shape (e.g. format=qrcode but value is undefined). We diff --git a/packages/app-shell/src/views/__tests__/ActionResultDialog.test.tsx b/packages/app-shell/src/views/__tests__/ActionResultDialog.test.tsx index aff2c9320f..555e88b782 100644 --- a/packages/app-shell/src/views/__tests__/ActionResultDialog.test.tsx +++ b/packages/app-shell/src/views/__tests__/ActionResultDialog.test.tsx @@ -5,9 +5,19 @@ import { render, screen } from '@testing-library/react'; import type { ResultDialogSpec } from '@object-ui/core'; import { ActionResultDialog } from '../ActionResultDialog'; +/** + * The display language the dialog resolves an `I18nLabel` against, mutable per + * test. Hoisted so the `vi.mock` factory below can close over it — a plain + * `let` would still be in TDZ when the hoisted factory first runs. + */ +const i18nState = vi.hoisted(() => ({ language: 'en' })); + vi.mock('@object-ui/i18n', async (importOriginal) => ({ ...(await importOriginal()), - useObjectTranslation: () => ({ t: (key: string) => key }), + // `language` is the source every other `resolveI18nLabel` caller in this + // package threads (`AppSidebar`, `UnifiedSidebar`, `resolveActionParams`, and + // metadata-admin's `useMetadataLocale`), so the stub carries it too. + useObjectTranslation: () => ({ t: (key: string) => key, language: i18nState.language }), })); // jsdom has no canvas; the qrcode path is not under test. vi.mock('qrcode', () => ({ toCanvas: vi.fn() })); @@ -57,3 +67,67 @@ describe('ActionResultDialog — unresolved field paths are skipped', () => { expect(screen.getByText(/"anything": 1/)).toBeInTheDocument(); }); }); + +/** + * objectui#9542 — every label member the contract declares as `I18nLabel` + * reaches this dialog in EITHER authorized form: a plain string, or an inline + * per-locale map. `@objectstack/spec`'s `I18nLabel` authorizes both and + * deprecates neither, and `ActionSchema.resultDialog` declares `title`, + * `description`, `acknowledge` and `fields[].label` as `I18nLabel`. + * + * ⭐ Before this card these went red by THROWING, not by mis-rendering: the map + * was passed straight into JSX as a React child, and React refuses an object + * there ("Objects are not valid as a React child"). The dialog therefore failed + * to render AT ALL on an action that had already succeeded — and this dialog is + * the only place its one-shot value is ever shown. The plain-string cases above + * are the control: they passed before and after, so these legs are about the + * map arm and nothing else. + */ +describe('ActionResultDialog — an inline per-locale map resolves against the display language', () => { + it('resolves title, description and acknowledge for the active language', () => { + i18nState.language = 'zh-CN'; + open( + { + title: { en: 'Save this value now', 'zh-CN': '立即保存此值' }, + description: { en: 'Shown once.', 'zh-CN': '仅显示一次。' }, + acknowledge: { en: 'I have saved this', 'zh-CN': '我已保存' }, + fields: [{ path: 'secret', label: { en: 'Secret', 'zh-CN': '密钥' }, format: 'text' }], + }, + { secret: 's3cr3t' }, + ); + + expect(screen.getByText('立即保存此值')).toBeInTheDocument(); + expect(screen.getByText('仅显示一次。')).toBeInTheDocument(); + expect(screen.getByText('我已保存')).toBeInTheDocument(); + expect(screen.getByText('密钥')).toBeInTheDocument(); + // The value itself still reaches the user — the whole reason the dialog exists. + expect(screen.getByText('s3cr3t')).toBeInTheDocument(); + // The other locale's text is resolved AWAY, not rendered alongside. + expect(screen.queryByText('Save this value now')).not.toBeInTheDocument(); + expect(screen.queryByText('Secret')).not.toBeInTheDocument(); + }); + + it('picks the other locale when the display language changes — so the language is really read', () => { + i18nState.language = 'en'; + open( + { + title: { en: 'Save this value now', 'zh-CN': '立即保存此值' }, + fields: [{ path: 'secret', label: { en: 'Secret', 'zh-CN': '密钥' }, format: 'text' }], + }, + { secret: 's3cr3t' }, + ); + + expect(screen.getByText('Save this value now')).toBeInTheDocument(); + expect(screen.getByText('Secret')).toBeInTheDocument(); + expect(screen.queryByText('立即保存此值')).not.toBeInTheDocument(); + }); + + it('falls back to the translated default when the map has no usable entry', () => { + i18nState.language = 'zh-CN'; + // An empty map resolves to `undefined`, which must behave exactly as an + // absent `title` does — the fallback chain, not an empty heading. + open({ title: {}, fields: [{ path: 'secret', format: 'text' }] }, { secret: 's3cr3t' }); + + expect(screen.getByText('actions.resultDialog.defaultTitle')).toBeInTheDocument(); + }); +}); diff --git a/packages/components/src/renderers/action/__tests__/action-undeclared-keys-8648.test.ts b/packages/components/src/renderers/action/__tests__/action-undeclared-keys-8648.test.ts index 41132aa975..6f41cafa08 100644 --- a/packages/components/src/renderers/action/__tests__/action-undeclared-keys-8648.test.ts +++ b/packages/components/src/renderers/action/__tests__/action-undeclared-keys-8648.test.ts @@ -50,16 +50,17 @@ * unwraps the cast) still reports as present. Declaring without un-casting * is an inert declaration that pins green. * - Every negative leg carries a control that varies ONLY the claim. - * - The last block is a LEDGER, not a pin on a good state. Un-casting the + * - The last block WAS a ledger and is not one any more. Un-casting the * `resultDialog` READ made the compiler name a second, separate defect the - * `as any` had been hiding: `@object-ui/core`'s `ResultDialogSpec` is a + * `as any` had been hiding: `@object-ui/core`'s `ResultDialogSpec` was a * hand copy of the contract's block whose `title` / `description` / - * `acknowledge` are `string` where the contract says `I18nLabel`. That fix - * is in another package and needs an i18n-resolution decision, so it is - * filed as objectui#9542 and the WRITE carries a narrowing assertion here - * meanwhile. The ledger leg asserts that workaround is still exactly what - * it says it is — so it cannot quietly become `as any` again, and it goes - * red (as an unused entry) when objectui#9542 lands and the assertions go. + * `acknowledge` read `string` where the contract says `I18nLabel`. That + * repair was filed as objectui#9542 and has LANDED — the mirror derives + * those members from the contract now — so the narrowing assertion the + * WRITE carried meanwhile is deleted, and the ledger entry pinning it with + * it. What replaces the block pins the two things that outlive a + * workaround: the write arriving with nothing asserted between it and + * `ActionDef`, and the `as any` it must never become. */ import { describe, it, expect } from 'vitest'; import { readFileSync } from 'node:fs'; @@ -227,8 +228,11 @@ const maskedSource = (file: string): string => mask(readFileSync(join(RENDERERS, const castBefore = (key: string): RegExp => new RegExp(String.raw`\(\s*schema\s+as\s+\w+\s*\)\s*\.\s*${key}\b`); -/** The write-side narrowing this card had to leave behind — see objectui#9542. */ -const LEDGERED_WRITE_NARROWING = /resultDialog:\s*schema\.resultDialog as ActionDef\['resultDialog'\]/; +/** + * The write, forwarded with NOTHING between it and `ActionDef` — the state + * objectui#9542 left behind when it deleted this card's write-side narrowing. + */ +const UNNARROWED_WRITE = /resultDialog:\s*schema\.resultDialog\s*,/; /** The spelling it must never regress to. */ const WRITE_SIDE_ANY = /resultDialog:\s*schema\.resultDialog as any/; @@ -303,24 +307,39 @@ describe('objectui#8648 — the mirror declaration reaches the read sites (NOT i } }); -describe('objectui#8648 — the `resultDialog` write-side narrowing is a LEDGER entry, not a fix', () => { +/* + * The LEDGER entry this card left behind is GONE, and this block is what it + * became. objectui#9542 made `@object-ui/core`'s `ResultDialogSpec` DERIVE its + * label members from the contract instead of hand-writing them as `string`, so + * the narrowing assertion that stood at both write sites had nothing left to + * narrow and was deleted — the workaround did not outlive its cause. + * + * ⭐ What survives is the half that was never a workaround: the `as any` + * negative. `castBefore` matches a cast between `schema` and the key, which a + * cast on the WRITE side — `schema.resultDialog as any` — walks straight past, + * so this is the only leg that refuses it. It is also the spelling that hid two + * defects at once, which is why the guard outlives the entry. + */ +describe('objectui#9542 — the `resultDialog` write reaches `ActionDef` with nothing between', () => { it('both matchers can fire, so the legs below are readings', () => { + // The controls vary ONLY the claim: the same write, asserted and plain. const narrowed = "resultDialog: schema.resultDialog as ActionDef['resultDialog'],"; - expect(LEDGERED_WRITE_NARROWING.test(narrowed)).toBe(true); - expect(WRITE_SIDE_ANY.test(narrowed)).toBe(false); + expect(UNNARROWED_WRITE.test('resultDialog: schema.resultDialog,')).toBe(true); + expect(UNNARROWED_WRITE.test(narrowed)).toBe(false); expect(WRITE_SIDE_ANY.test('resultDialog: schema.resultDialog as any,')).toBe(true); + expect(WRITE_SIDE_ANY.test('resultDialog: schema.resultDialog,')).toBe(false); }); for (const file of CARD_KEYS.resultDialog) { - it(`${file} narrows the \`resultDialog\` WRITE to \`ActionDef\` and never to \`any\``, () => { + it(`${file} forwards \`resultDialog\` unasserted, and never as \`any\``, () => { const source = maskedSource(file); - // The entry is live. When objectui#9542 lands, `ResultDialogSpec` derives - // from the contract, the assertion is deleted, and THIS goes red — which - // is the point: a workaround must not outlive its cause in silence. - expect(source).toMatch(LEDGERED_WRITE_NARROWING); + // Proof the file was read and masked, so the reading below is about the + // spelling and not about an empty string. + expect(source).toMatch(/UIActionSchema/); + expect(source).toMatch(UNNARROWED_WRITE); // ⛔ The spelling that hid two defects at once must not come back. `as - // any` here would re-swallow the `ResultDialogSpec` drift AND make the - // mirror declaration inert at this site in one stroke. + // any` here would re-swallow a future `ResultDialogSpec` drift AND make + // the mirror declaration inert at this site in one stroke. expect(source).not.toMatch(WRITE_SIDE_ANY); }); } diff --git a/packages/components/src/renderers/action/action-button.tsx b/packages/components/src/renderers/action/action-button.tsx index 83edd648eb..f4aa74c405 100644 --- a/packages/components/src/renderers/action/action-button.tsx +++ b/packages/components/src/renderers/action/action-button.tsx @@ -232,20 +232,15 @@ const ActionButtonRenderer = forwardRef< // backup codes). Without this forward the ActionRunner falls // back to the success toast and the user loses the value. // - // The READ is uncast since objectui#8648: `resultDialog` is declared - // on the mirror, so the compiler types it as the contract's own - // block. What the assertion narrows is the WRITE, and it is a - // ledgered workaround, not a shrug — removing the read-side `as any` - // is what made the compiler name it. `ActionDef['resultDialog']` is - // `@object-ui/core`'s hand-written `ResultDialogSpec`, whose own - // docblock claims it mirrors the contract's block and does not: - // `title` / `description` / `acknowledge` are `string` there and - // `I18nLabel` in the contract, so a contract-valid inline locale map - // reaches the dialog as an object. Filed as objectui#9542; the fix is - // in `@object-ui/core` plus the dialog's own resolver and is outside - // this card's declared surface. ⛔ Never widen this back to `as any` — - // that spelling hid this AND the missing declaration at once. - resultDialog: schema.resultDialog as ActionDef['resultDialog'], + // Both ends uncast: the READ since objectui#8648 (`resultDialog` is + // declared on the mirror, so the compiler types it as the contract's + // own block), and the WRITE since objectui#9542 retired the narrowing + // assertion that stood here — `ActionDef['resultDialog']` now DERIVES + // its label members from the contract instead of hand-writing them as + // `string`, so the whole forward type-checks against one declared + // meaning. ⛔ Never widen either end back to `as any`: that one + // spelling hid the missing declaration AND the drifted mirror at once. + resultDialog: schema.resultDialog, // Declared post-success navigation — spec's closed strict // `{ navigate, openIn }` block, authorable on `ActionSchema` since // @objectstack/spec 17.1.0 (objectui#5328). The runner reads it off diff --git a/packages/components/src/renderers/action/action-icon.tsx b/packages/components/src/renderers/action/action-icon.tsx index 978cac79bf..70e5ad4841 100644 --- a/packages/components/src/renderers/action/action-icon.tsx +++ b/packages/components/src/renderers/action/action-icon.tsx @@ -154,10 +154,11 @@ const ActionIconRenderer = forwardRef< // See action-button.tsx — the one-shot reveal spec (2FA setup, fresh // OAuth secret). Without it the runner falls back to the success // toast and the value the user was meant to copy is gone. The READ is - // uncast since objectui#8648; the write-side assertion is the same - // ledgered `ResultDialogSpec` drift `action-button.tsx` documents - // (filed as objectui#9542). ⛔ Never widen it back to `as any`. - resultDialog: schema.resultDialog as ActionDef['resultDialog'], + // uncast since objectui#8648; the write-side narrowing that stood + // here went with objectui#9542, which made `ResultDialogSpec` derive + // its label members from the contract. ⛔ Never widen either end back + // to `as any`. + resultDialog: schema.resultDialog, // See action-button.tsx — the declared post-success hop // (objectui#5493). The runner reads it off the forwarded def; dropped // here the action succeeds and the authored navigation never runs. diff --git a/packages/core/src/actions/ActionRunner.ts b/packages/core/src/actions/ActionRunner.ts index 65f7481677..b091e292ac 100644 --- a/packages/core/src/actions/ActionRunner.ts +++ b/packages/core/src/actions/ActionRunner.ts @@ -552,15 +552,43 @@ export type ParamCollectionHandler = ( * values the user must copy NOW (2FA secret, OAuth client_secret, backup * codes). */ +/** + * The contract's own `resultDialog` block, and one entry of its field list — + * the source the two mirrors below DERIVE their label members from, so the + * "Mirrors `Action.resultDialog`" claim above is re-checked by `tsc` on every + * build instead of being asserted once in prose. + * + * ⭐ Deriving rather than restating is the whole repair of objectui#9542. The + * three label members were hand-written `string` here while the contract + * declares each as `I18nLabel` — a plain string **or** an inline per-locale + * map, both authorized and neither deprecated — so this mirror REFUSED what + * the platform accepts while its docblock claimed the alignment. A hand copy + * can drift again the moment the contract moves; a derived member cannot. + * + * ⛔ Local, deliberately not exported: the two interfaces below stay the only + * names this module publishes for the block. + */ +type SpecResultDialog = NonNullable; +type SpecResultDialogField = NonNullable[number]; + export interface ResultDialogFieldSpec { path: string; - label?: string; + /** + * Derived `I18nLabel` — a plain string, or an inline per-locale map. A + * renderer must RESOLVE it (`resolveI18nLabel` in `@objectstack/spec/ui`) + * before it reaches the DOM; passed through as-is, the map arm is an object + * and React refuses an object as a child. + */ + label?: SpecResultDialogField['label']; format?: 'qrcode' | 'code-list' | 'secret' | 'text' | 'json'; } export interface ResultDialogSpec { - title?: string; - description?: string; - acknowledge?: string; + /** Derived `I18nLabel` — see `ResultDialogFieldSpec.label` on resolving it. */ + title?: SpecResultDialog['title']; + /** Derived `I18nLabel` — see `ResultDialogFieldSpec.label` on resolving it. */ + description?: SpecResultDialog['description']; + /** Derived `I18nLabel` — see `ResultDialogFieldSpec.label` on resolving it. */ + acknowledge?: SpecResultDialog['acknowledge']; format?: 'qrcode' | 'code-list' | 'secret' | 'text' | 'json'; fields?: ResultDialogFieldSpec[]; } From 7321c6e328722cfa2312cabc2d1d920a8a4dd989 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 07:31:31 +0000 Subject: [PATCH 2/3] refactor(core): keep the spec-alignment claim attached to the mirror it describes The derived local aliases had been inserted BETWEEN the "Mirrors `Action.resultDialog` in @objectstack/spec" docblock and the interface it describes, which detached the claim: `attachedDoc` in `scripts/check-spec-symbol-derivation.mjs` reads only the LAST leading block, so `pnpm check:spec-symbols` reported the claim as gone and asked for its ledger entry to be deleted. Moving the aliases above the docblock puts the claim back on the exported mirror, where the ledger already tracks it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Xm4WFhEe5mwcgyqHjxR2hn --- packages/core/src/actions/ActionRunner.ts | 41 ++++++++++++----------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/packages/core/src/actions/ActionRunner.ts b/packages/core/src/actions/ActionRunner.ts index b091e292ac..319b4c7efd 100644 --- a/packages/core/src/actions/ActionRunner.ts +++ b/packages/core/src/actions/ActionRunner.ts @@ -543,27 +543,16 @@ export type ParamCollectionHandler = ( ) => Promise | null>; /** - * Result dialog spec — declarative description of how to render a - * one-shot reveal of an action's API response. Mirrors - * `Action.resultDialog` in @objectstack/spec. - * - * When set on an action and the action succeeds, the runner suppresses - * the success toast and awaits a ResultDialogHandler instead. Used for - * values the user must copy NOW (2FA secret, OAuth client_secret, backup - * codes). - */ -/** - * The contract's own `resultDialog` block, and one entry of its field list — - * the source the two mirrors below DERIVE their label members from, so the - * "Mirrors `Action.resultDialog`" claim above is re-checked by `tsc` on every - * build instead of being asserted once in prose. + * The contract's own result-dialog block, and one entry of its field list. * - * ⭐ Deriving rather than restating is the whole repair of objectui#9542. The - * three label members were hand-written `string` here while the contract - * declares each as `I18nLabel` — a plain string **or** an inline per-locale - * map, both authorized and neither deprecated — so this mirror REFUSED what - * the platform accepts while its docblock claimed the alignment. A hand copy - * can drift again the moment the contract moves; a derived member cannot. + * ⭐ The two interfaces below DERIVE their label members from these instead of + * restating them, and that is the whole repair of objectui#9542: the three + * label members were hand-written `string` while the producer declares each as + * `I18nLabel` — a plain string **or** an inline per-locale map, both authorized + * and neither deprecated — so this mirror refused what the platform accepts + * while its own docblock claimed the alignment. A hand copy can drift again the + * moment the contract moves; a derived member makes `tsc` re-check the claim on + * every build instead of trusting a sentence written once. * * ⛔ Local, deliberately not exported: the two interfaces below stay the only * names this module publishes for the block. @@ -571,6 +560,18 @@ export type ParamCollectionHandler = ( type SpecResultDialog = NonNullable; type SpecResultDialogField = NonNullable[number]; +/** + * Result dialog spec — declarative description of how to render a + * one-shot reveal of an action's API response. Mirrors + * `Action.resultDialog` in @objectstack/spec — derived from it member by + * member for the labels, so the claim in this sentence is enforced rather + * than asserted. + * + * When set on an action and the action succeeds, the runner suppresses + * the success toast and awaits a ResultDialogHandler instead. Used for + * values the user must copy NOW (2FA secret, OAuth client_secret, backup + * codes). + */ export interface ResultDialogFieldSpec { path: string; /** From 23592bab10afa989f3494f83f2d2964a04240afd Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 18 Sep 2026 07:44:39 +0000 Subject: [PATCH 3/3] docs(changeset): mark objectui#8648's write-side narrowing as superseded by this change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `.changeset/8648-ui-action-four-undeclared-keys.md` is pending, so its body publishes verbatim into the CHANGELOG at the next release. Its last paragraph states in the present tense that the `resultDialog` WRITE carries a narrowing assertion at both forward sites and that a ledger entry pins it — which this change removes, in the same release. A supersession note is appended rather than the original claims rewritten: every word the objectui#8648 author wrote stands, and the final state is stated after it. The other three pending changesets naming files this branch edits were checked and are unmoved: 6306's `type: schema` population is byte-identical on the base and this branch and no line of it is in this diff; 8318's "`ActionRunner.ts` does not read `level`" still holds (every `level` in that file is prose); 5642's argument rests on `ActionDef` carrying no index signature and on the interface living in `ActionRunner.ts`, neither of which this diff touches. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Xm4WFhEe5mwcgyqHjxR2hn --- .changeset/8648-ui-action-four-undeclared-keys.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.changeset/8648-ui-action-four-undeclared-keys.md b/.changeset/8648-ui-action-four-undeclared-keys.md index 9afe96d1e3..ea7f838733 100644 --- a/.changeset/8648-ui-action-four-undeclared-keys.md +++ b/.changeset/8648-ui-action-four-undeclared-keys.md @@ -55,3 +55,12 @@ narrowing assertion to `ActionDef['resultDialog']` at both forward sites — strictly narrower than the `as any` it replaces, since every other member of the forward literal is compiler-checked again — documented as a ledger entry and pinned so it cannot regress to `as any` and cannot outlive its cause in silence. + +⇒ **It did not outlive it, and the paragraph above is superseded in this same +release.** objectui#9542 landed the derivation: `ResultDialogSpec` derives its +label members from the contract, so BOTH narrowing assertions and the ledger +entry that pinned them are gone and the write is compiler-checked with nothing +between it and `ActionDef`. What the pin still refuses is the `as any`. (Noted +here by the objectui#9542 seat, because this body publishes verbatim into the +CHANGELOG and its present tense would otherwise describe a workaround that the +same release removes.)