From 1333fcfa3581737642626fca93d30b050e09ad35 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 13:25:20 +0000 Subject: [PATCH 01/18] =?UTF-8?q?wip(i18n):=20hook=20refusal=20catalogue?= =?UTF-8?q?=20=E2=80=94=20spec,=20runtime,=20cli=20and=20lint=20halves?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01WGufzfyVQuRsL6SRGuKCem Co-authored-by: Claude --- packages/cli/src/utils/i18n-coverage.ts | 9 + packages/cli/src/utils/i18n-extract.ts | 31 +++ .../src/validate-translation-references.ts | 65 ++++++ .../objectql/src/engine-execution-plumbing.ts | 12 +- packages/runtime/src/app-artifact-handlers.ts | 9 +- packages/runtime/src/app-plugin.ts | 11 +- packages/runtime/src/sandbox/body-runner.ts | 16 +- .../src/sandbox/hook-refusal-catalogue.ts | 194 ++++++++++++++++++ .../runtime/src/sandbox/quickjs-runner.ts | 65 +++++- packages/spec/liveness/hook.json | 22 ++ packages/spec/liveness/translation.json | 24 +++ packages/spec/src/data/hook-refusal-codes.ts | 176 ++++++++++++++++ packages/spec/src/data/hook.zod.ts | 146 ++++++++++++- packages/spec/src/data/index.ts | 4 + packages/spec/src/system/i18n-resolver.ts | 20 ++ packages/spec/src/system/translation.zod.ts | 79 ++++++- 16 files changed, 858 insertions(+), 25 deletions(-) create mode 100644 packages/runtime/src/sandbox/hook-refusal-catalogue.ts create mode 100644 packages/spec/src/data/hook-refusal-codes.ts diff --git a/packages/cli/src/utils/i18n-coverage.ts b/packages/cli/src/utils/i18n-coverage.ts index 321ccb3f9a1..e065d0fcd28 100644 --- a/packages/cli/src/utils/i18n-coverage.ts +++ b/packages/cli/src/utils/i18n-coverage.ts @@ -71,6 +71,7 @@ export interface CoverageIssue { | 'picklist' | 'page' | 'flow' + | 'hook' | 'position' | 'permission' | 'metadataForm'; @@ -377,6 +378,12 @@ const COVERAGE_SOURCE: Record // locales (#11485). Its six approval flows stayed that invisible until // #24916. flow: 'flow', + // A hook's declared refusal messages (`hooks..refusals..message`, + // #26170) — the sentence a user reads when the app says "not yet", so it + // keeps its own bucket and reports as `i18n/missing-hook`. Until the + // catalogue existed a hook refusal had no key at all, and the family reported + // `0 missing` while a zh-CN user read every refusal in English. + hook: 'hook', // Security-catalog copy (`positions.

.label` / `.description`, // `permissions..label` / `.description`) — the author's own positions and // permission sets, read in Setup and on every approval step that waits on a @@ -406,6 +413,7 @@ const SOURCE_NOUN: Record = { picklist: 'Picklist', page: 'Page', flow: 'Flow', + hook: 'Hook', position: 'Position', permission: 'Permission set', metadataForm: 'Metadata form', @@ -451,6 +459,7 @@ const SOURCE_SURFACE: Record = { picklist: 'picklists', page: 'pages', flow: 'flow screens, approval steps, toasts and refusals', + hook: 'hook refusals', position: 'positions', permission: 'permission sets', metadataForm: 'metadata forms', diff --git a/packages/cli/src/utils/i18n-extract.ts b/packages/cli/src/utils/i18n-extract.ts index d17f41e4da5..8670b88149b 100644 --- a/packages/cli/src/utils/i18n-extract.ts +++ b/packages/cli/src/utils/i18n-extract.ts @@ -122,6 +122,7 @@ * ^ walked because the ledger's `flows` row is `live` and warns no * author; a group the ledger does warn on is left out of the walk * (see `authorWarnedTranslationGroups`) + * hooks..refusals..message (#26170) * positions..label / .description * permissions..label / .description * metadataForms..label / .description @@ -228,6 +229,7 @@ export interface ExpectedEntry { | 'picklist' | 'page' | 'flow' + | 'hook' | 'position' | 'permission' | 'metadataType' @@ -1673,6 +1675,9 @@ export function collectExpectedEntries( // ── Screen flows (`flows..screens..…`, #7646 / #11287) ─ walkScreenFlows(config, out); + // ── Hook refusal catalogues (`hooks..refusals..message`) ─ + walkHookRefusals(config, out); + // ── Object sections (fieldGroups + authored form/page sections) ─── // Deliberately a pass of its own: the two authoring surfaces live in // `objects`, `views` and `pages`, and one section may be declared by more @@ -2239,6 +2244,32 @@ function walkScreenFlows(config: any, out: ExpectedEntry[]): void { } } +/** + * Emit `hooks..refusals..message` for every entry a hook's refusal + * catalogue declares (#26170, maintainer ruling 6431311681 letter A), seeded + * with the declared message. + * + * The sandboxed body runner reads exactly that key (`hookRefusalMessageKey`) + * in the caller's locale when the body throws the entry's code, so each + * declared entry is one demand, counted by the coverage gate under its own + * `hook` bucket. Keyed by hook name, not by the objects the hook binds, so a + * hook bound to several objects or to `'*'` is asked for one translation per + * code. A hook that declares no `refusals` has no key here: its throws carry + * no address a bundle could reach. + */ +function walkHookRefusals(config: any, out: ExpectedEntry[]): void { + const hooks: any[] = Array.isArray(config?.hooks) ? config.hooks : []; + for (const hook of hooks) { + const hookName = typeof hook?.name === 'string' && hook.name.length > 0 ? hook.name : undefined; + if (!hookName) continue; + const refusals = hook.refusals; + if (!refusals || typeof refusals !== 'object' || Array.isArray(refusals)) continue; + for (const [code, entry] of Object.entries(refusals)) { + pushOptional(out, ['hooks', hookName, 'refusals', code, 'message'], inlineText(entry?.message), 'hook'); + } + } +} + /** * Iterate the canonical metadata form registry and emit translation entries * for every metadata type's display label/description, plus the section and diff --git a/packages/lint/src/validate-translation-references.ts b/packages/lint/src/validate-translation-references.ts index 7d96c4db3e8..454096a5bb8 100644 --- a/packages/lint/src/validate-translation-references.ts +++ b/packages/lint/src/validate-translation-references.ts @@ -124,6 +124,12 @@ * node, is pointed at `approvals..label` rather than told only that * the node is not a screen. * + * `hooks..refusals..message` (#26170) is judged against the hook's + * refusal catalogue: the hook must be one the stack defines (or a sibling + * package's, through the artifact carrier), and the code one its `refusals` + * declares — the body runner reads the key for a declared code and nothing + * else. An unknown hook and an undeclared code are each an orphan. + * * `flows..successMessage` / `.errorMessage` (#22507), the terminal * toasts, are judged against the flow itself: `translateFlow` overlays a toast * only where the flow AUTHORS one, so a translated toast over a flow that @@ -493,6 +499,12 @@ interface Universe { dashboards: Map; actions: Set }>; /** Flow name (`Flow.name`) → its screen nodes and their declared fields. */ flows: Map; + /** + * [#26170] Hook name (`Hook.name`) → the codes its refusal catalogue + * (`HookSchema.refusals`) declares — what a `hooks..refusals.` + * key may name. Empty for a hook that declares no catalogue. + */ + hooks: Map>; /** Object-less actions — the ones `globalActions.*` may name. */ globalActions: Map; /** Action name → owning object, so a misfiled `globalActions` key can say where it belongs. */ @@ -1622,6 +1634,25 @@ function buildUniverse(stack: AnyRec): Universe { collectFlowRecord(flow, { ownDeclaration: false }); } + // ── Hooks: the refusal codes `hooks..refusals.` may name (#26170) ── + // + // The body runner reads `hooks..refusals..message` for a code the + // hook's catalogue declares, so the universe is each hook's declared codes. + // `hooks` carries disposition `concat` in `COMPOSE_KEY_DISPOSITIONS`, so a + // sibling package's hooks are read through the artifact carrier; the + // declaration this leg is JUDGING keeps the slot, the flows' decision. + const hooks = new Map>(); + const collectHookRecord = (hook: AnyRec, { ownDeclaration }: { ownDeclaration: boolean }) => { + const hookName = strName(hook.name); + if (!hookName) return; + if (!ownDeclaration && hooks.has(hookName)) return; + hooks.set(hookName, new Set(Object.keys(asRecord(hook.refusals)))); + }; + for (const hook of recordsOf(stack.hooks)) collectHookRecord(hook, { ownDeclaration: true }); + for (const hook of artifactProvidedRecords(stack, 'hooks')) { + collectHookRecord(hook, { ownDeclaration: false }); + } + // ── Picklists: the option values `picklists..options.` may name (#23566) ── // // The runtime reads this face through `lookupPicklistOption` @@ -1699,6 +1730,7 @@ function buildUniverse(stack: AnyRec): Universe { contributedOnlyApps, dashboards, flows, + hooks, globalActions, actionOwners, picklists, @@ -2315,6 +2347,39 @@ export function validateTranslationReferences(stack: AnyRec): TranslationRefFind ); } } + + // ── hooks..refusals. (#26170) ─────────────────────────── + for (const [hookName, rawHook] of Object.entries(asRecord(rawData.hooks))) { + const hookPath = `${base}.hooks.${hookName}`; + const declared = universe.hooks.get(hookName); + if (!declared) { + orphan( + `${inLocale} · hook "${hookName}"`, + hookPath, + `Translations are keyed to hook "${hookName}", which this stack does not define, so its ` + + `refusals keep their declared messages.` + + suggest(hookName, universe.hooks.keys()), + `Match the key to a hook's \`name\`, or drop it.`, + ); + continue; + } + if (!isRec(rawHook)) continue; + for (const code of Object.keys(asRecord(rawHook.refusals))) { + if (declared.has(code)) continue; + orphan( + `${inLocale} · hook "${hookName}" · refusal "${code}"`, + `${hookPath}.refusals.${code}`, + declared.size === 0 + ? `Translations are keyed to refusal "${code}", but hook "${hookName}" declares no ` + + `\`refusals\`, so nothing resolves this key.` + : `Translations are keyed to refusal "${code}", a code hook "${hookName}" does not declare ` + + `under \`refusals\`, so nothing resolves this key.` + + suggest(code, declared), + `Refusal translations are keyed by a code the hook's \`refusals\` declares; declare it there, ` + + `or drop the key.`, + ); + } + } } } diff --git a/packages/objectql/src/engine-execution-plumbing.ts b/packages/objectql/src/engine-execution-plumbing.ts index e484368a226..4487bae3b7d 100644 --- a/packages/objectql/src/engine-execution-plumbing.ts +++ b/packages/objectql/src/engine-execution-plumbing.ts @@ -79,9 +79,15 @@ export function buildSession(execCtx?: ExecutionContext): HookContext['session'] // Nothing to say about the caller → say nothing. An object whose every // property is `undefined` conveys no more than no session at all, and // returning it would turn "no caller" into "an anonymous caller". - return Object.values(session as Record).some((v) => v !== undefined) - ? session - : undefined; + if (!Object.values(session as Record).some((v) => v !== undefined)) return undefined; + // [#26170] The caller's locale rides on a session that exists, so the + // sandboxed body runner can answer a declared hook refusal in the caller's + // language. It is asked AFTER the presence test on purpose: a locale alone + // says how to speak, not who is speaking, and must not turn "no caller" + // into a session either. + const locale = (execCtx as { locale?: unknown }).locale; + if (typeof locale === 'string' && locale.length > 0) (session as { locale?: string }).locale = locale; + return session; } /** The body of `ObjectQL#buildProvenance`; the method's docblock there is its contract. */ diff --git a/packages/runtime/src/app-artifact-handlers.ts b/packages/runtime/src/app-artifact-handlers.ts index 1b658c88ace..305413e4594 100644 --- a/packages/runtime/src/app-artifact-handlers.ts +++ b/packages/runtime/src/app-artifact-handlers.ts @@ -145,6 +145,7 @@ import { } from '@objectstack/types'; import { SEMCONV } from '@objectstack/observability'; import { QuickJSScriptRunner } from './sandbox/quickjs-runner.js'; +import type { HookRefusalI18nSource } from './sandbox/hook-refusal-catalogue.js'; import { hookBodyRunnerFactory, actionBodyRunnerFactory, @@ -189,6 +190,12 @@ export interface AppArtifactHandlerBindingOptions { * module, leaves this unset and binds such hooks as it always did. */ withholdHooksWithoutBody?: boolean; + /** + * [#26170] The `i18n` service a hook's declared refusal is translated + * through, read at refusal time. Absent, a declared refusal is answered + * with its declared message. + */ + i18n?: HookRefusalI18nSource; } /** What one {@link bindAppArtifactHandlers} call bound. */ @@ -273,7 +280,7 @@ export function bindAppArtifactHandlers( ql.bindHooks(hooks, { packageId: owner, functions, - bodyRunner: hookBodyRunnerFactory(new QuickJSScriptRunner(), { ql, logger, appId }), + bodyRunner: hookBodyRunnerFactory(new QuickJSScriptRunner(), { ql, logger, appId, i18n: options.i18n }), // [#24289] A handler that runs in-process, an inline function or // the function a string `handler` names, is handed the // `ctx.title(field?)` a `body` gets in the sandbox. The binder diff --git a/packages/runtime/src/app-plugin.ts b/packages/runtime/src/app-plugin.ts index 33907a5d373..252a35b1833 100644 --- a/packages/runtime/src/app-plugin.ts +++ b/packages/runtime/src/app-plugin.ts @@ -36,6 +36,7 @@ import { SEED_WRITE_EXECUTION_CONTEXT } from '@objectstack/spec/kernel'; import { applyProtection } from '@objectstack/spec/shared'; import { QuickJSScriptRunner } from './sandbox/quickjs-runner.js'; import { hookBodyRunnerFactory, actionBodyRunnerFactory } from './sandbox/body-runner.js'; +import { hookRefusalI18nReader } from './sandbox/hook-refusal-catalogue.js'; import { bindAppArtifactHandlers, scheduleAppArtifactJobs } from './app-artifact-handlers.js'; import { countServerTiming } from '@objectstack/observability'; @@ -746,6 +747,7 @@ export class AppPlugin implements Plugin { ql, logger: ctx.logger, appId: 'runtime-authored', + i18n: hookRefusalI18nReader(ctx), })); if (installed !== false) { ctx.logger.info('[AppPlugin] Installed default hook body runner (runtime-authored hooks can execute)'); @@ -1716,7 +1718,14 @@ export class AppPlugin implements Plugin { handlerDefinition.manifest[key] = handlerSource[key]; } } - bindAppArtifactHandlers(ql, handlerDefinition, { appId, logger: ctx.logger, source: 'AppPlugin' }); + bindAppArtifactHandlers(ql, handlerDefinition, { + appId, + logger: ctx.logger, + source: 'AppPlugin', + // [#26170] A hook's declared refusal is answered in the caller's + // language, through the i18n service read at refusal time. + i18n: hookRefusalI18nReader(ctx), + }); // [ADR-0110 D5] The action-governance inventory used to hang off a // `kernel:ready` hook HERE. Moved to ObjectQLPlugin: AppPlugin is diff --git a/packages/runtime/src/sandbox/body-runner.ts b/packages/runtime/src/sandbox/body-runner.ts index bfd61dd8ec7..73eef3a571f 100644 --- a/packages/runtime/src/sandbox/body-runner.ts +++ b/packages/runtime/src/sandbox/body-runner.ts @@ -73,12 +73,19 @@ import { storedMetadataFamilyTableList, } from '../stored-metadata-body-boundary.js'; import { mapDataError } from '@objectstack/types'; +import { resolveHookRefusal, type HookRefusalI18nSource } from './hook-refusal-catalogue.js'; import { isRelayedSandboxRefusal } from './quickjs-runner.js'; interface FactoryOptions { ql: any; appId: string; logger?: any; + /** + * [#26170] Where a hook's declared refusal is translated: the `i18n` + * service, read at refusal time (see `resolveHookRefusal`). Absent, a + * declared refusal is answered with its declared message. + */ + i18n?: HookRefusalI18nSource; } /** @@ -486,8 +493,13 @@ export function hookBodyRunnerFactory( }); applyMutationsToInput(engineCtx, result, sandboxCtx.input); } catch (err: any) { - logBodyThrow(opts, { kind: 'hook', name: hook.name }, err); - throw err; + // [#26170] A hook that declares `refusals` refuses by code only: the + // body's throw is answered with the declared entry in the caller's + // locale, or refused loudly when the catalogue cannot answer it. + // Everything else leaves exactly as thrown. + const answered = resolveHookRefusal(hook, err, engineCtx, { i18n: opts.i18n, logger: opts.logger }); + logBodyThrow(opts, { kind: 'hook', name: hook.name }, answered); + throw answered; } }; }; diff --git a/packages/runtime/src/sandbox/hook-refusal-catalogue.ts b/packages/runtime/src/sandbox/hook-refusal-catalogue.ts new file mode 100644 index 00000000000..7f3a62f7da5 --- /dev/null +++ b/packages/runtime/src/sandbox/hook-refusal-catalogue.ts @@ -0,0 +1,194 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A hook's declared refusal catalogue, answered at run time (#26170, + * maintainer ruling 6431311681 letter A). + * + * A hook that declares `refusals: { : { message, status? } }` + * (`HookSchema.refusals`) refuses by code only. Its sandboxed body throws an + * error carrying a declared `code`, and {@link resolveHookRefusal} answers that + * throw with the catalogue's entry: + * + * - the sentence is `hooks..refusals..message`, picked through the + * `i18n` service in the caller's `HookContext.session.locale` (negotiated by + * `resolveBundleLocale`, the one negotiation rule), else the declared + * `message` — served as the error's caller-facing sentence (`innerMessage`) + * and marked as the end user's (`userMessage`, the objectui#5210 opt-in); + * - the status is the declared `status`, else 400; + * - the code is the declared code, so the caller can branch on it. + * + * **Picked here, before the refusal leaves.** A hook refusal reaches the + * caller already rendered — the error envelope every door builds from the + * thrown error — so nothing downstream could translate it: the validation-rule + * posture (`objectValidationMessageKey`, objectql's rule evaluator) and the + * flow `end` one (`flowRefusalMessageKey`, the automation engine). + * + * **Loud for anything else the body made.** A throw the catalogue cannot answer + * — a code it does not declare, or no code at all — is refused as a sandbox + * fault naming the hook and the code (`undeclaredHookRefusalAtRun`), which + * every door answers `500` and the body runner logs at `error`. The save + * refuses a code the body throws literally first (`HookSchema`); this is the + * half for the codes a save cannot read. + * + * **Untouched: what the body did not make.** An error a host call raised and + * the body let leave unchanged ({@link isHostRelayedSandboxError}: an engine + * refusal of a `ctx.api` write, a nested hook's refusal), the engine's own + * error the runner relays as itself, a crash (`TypeError: …`) and the + * sandbox's own faults (budget, capability) all pass through as before: they + * are the platform speaking, or a bug, never a refusal the body chose. + * + * A hook that declares no `refusals` is answered exactly as before. + */ + +import type { Hook } from '@objectstack/spec/data'; +import { undeclaredHookRefusalAtRun } from '@objectstack/spec/data'; +import { hookRefusalMessageKey, resolveBundleLocale } from '@objectstack/spec/system'; +import { sandboxBusinessMessage } from '@objectstack/types'; +import { SandboxError, isHostRelayedSandboxError, isRelayedSandboxRefusal } from './quickjs-runner.js'; + +/** The status a declared refusal is answered with when its entry names none. */ +export const DEFAULT_HOOK_REFUSAL_STATUS = 400; + +/** + * The slice of `II18nService` a declared refusal is translated through: a key + * lookup in a locale (which echoes the key on a miss) and, when the service + * can say, the locales it holds. + */ +export interface HookRefusalI18nService { + t(key: string, locale: string, params?: Record): string; + getLocales?(): string[]; +} + +/** + * Where the body runner reads the `i18n` service from — asked at refusal + * time, never at bind, so a service registered after the hook bound is still + * found (the `i18nServiceReader` posture of the automation engine). + */ +export type HookRefusalI18nSource = () => HookRefusalI18nService | undefined; + +/** + * The `i18n` service as a {@link HookRefusalI18nSource}: read from the kernel + * at refusal time, `undefined` while none is registered or when the one + * registered cannot look a key up. + */ +export function hookRefusalI18nReader(ctx: { getService(name: string): T }): HookRefusalI18nSource { + return () => { + let service: HookRefusalI18nService | undefined; + try { + service = ctx.getService('i18n'); + } catch { + return undefined; + } + return typeof service?.t === 'function' ? service : undefined; + }; +} + +/** One declared entry, as the catalogue holds it. */ +interface DeclaredRefusal { + message: string; + status?: number; +} + +/** The hook's catalogue, or `undefined` when it declares none. */ +function catalogueOf(hook: Pick): Record | undefined { + const refusals = (hook as { refusals?: unknown }).refusals; + return refusals && typeof refusals === 'object' ? (refusals as Record) : undefined; +} + +/** The locale the service can answer in for `requested` — `resolveBundleLocale` against what it holds. */ +function negotiatedLocale(service: HookRefusalI18nService, requested: string): string { + if (typeof service.getLocales !== 'function') return requested; + let available: unknown; + try { + available = service.getLocales(); + } catch { + return requested; + } + if (!Array.isArray(available) || available.length === 0) return requested; + const offered: Record = {}; + for (const code of available) { + if (typeof code === 'string' && code.length > 0) offered[code] = true; + } + return resolveBundleLocale(offered, requested) ?? requested; +} + +/** + * The sentence a declared refusal is answered with: its translation in + * `locale` when the `i18n` service holds one, else the declared `message`. + * + * Plain text both ways — an authored refusal has no parameter contract, so the + * translation replaces the message whole. A service that throws, or a key it + * echoes back, picks nothing; a refusal stays a refusal whatever the service + * does. + */ +export function declaredRefusalMessage( + hookName: string, + code: string, + entry: DeclaredRefusal, + locale: string | undefined, + i18n: HookRefusalI18nSource | undefined, + logger?: { warn?: (...args: any[]) => void }, +): string { + if (typeof locale !== 'string' || locale.length === 0 || !i18n) return entry.message; + let service: HookRefusalI18nService | undefined; + try { + service = i18n(); + } catch { + return entry.message; + } + if (!service || typeof service.t !== 'function') return entry.message; + const key = hookRefusalMessageKey(hookName, code); + try { + const translated = service.t(key, negotiatedLocale(service, locale)); + // II18nService echoes the key back on a miss. + if (typeof translated === 'string' && translated.trim().length > 0 && translated !== key) return translated; + } catch (err: unknown) { + logger?.warn?.( + `[BodyRunner] hook '${hookName}': the i18n service threw reading '${key}' ` + + `(${err instanceof Error ? err.message : String(err)}), so the refusal shows its declared message.`, + ); + } + return entry.message; +} + +/** + * Answer a sandboxed hook body's throw against the hook's refusal catalogue. + * Returns the error to throw in its place — `err` itself whenever the + * catalogue has nothing to say about it (see the module header). + */ +export function resolveHookRefusal( + hook: Pick, + err: unknown, + engineCtx: { session?: { locale?: unknown } } | undefined, + opts: { i18n?: HookRefusalI18nSource; logger?: { warn?: (...args: any[]) => void } } = {}, +): unknown { + const catalogue = catalogueOf(hook); + if (!catalogue) return err; + // Not the body's own throw: the engine's error relayed as itself, the + // sandbox's own fault, a host error re-thrown unchanged, a nested refusal. + if (!(err instanceof SandboxError) || err.innerMessage === undefined) return err; + if (isHostRelayedSandboxError(err) || isRelayedSandboxRefusal(err)) return err; + // A crash (`TypeError: …`) is a bug, not a refusal: it stays the fault it is. + if (sandboxBusinessMessage(err) === undefined) return err; + + const code = typeof err.code === 'string' && err.code.length > 0 ? err.code : undefined; + const entry = code !== undefined && Object.prototype.hasOwnProperty.call(catalogue, code) ? catalogue[code] : undefined; + if (code === undefined || !entry) { + return new SandboxError(undeclaredHookRefusalAtRun(String(hook.name), code, Object.keys(catalogue))); + } + + const locale = engineCtx?.session?.locale; + const message = declaredRefusalMessage( + String(hook.name), + code, + entry, + typeof locale === 'string' ? locale : undefined, + opts.i18n, + opts.logger, + ); + return new SandboxError(err.message, message, { + code, + status: typeof entry.status === 'number' ? entry.status : DEFAULT_HOOK_REFUSAL_STATUS, + userMessage: message, + }); +} diff --git a/packages/runtime/src/sandbox/quickjs-runner.ts b/packages/runtime/src/sandbox/quickjs-runner.ts index 1758a636cff..425f9fd9556 100644 --- a/packages/runtime/src/sandbox/quickjs-runner.ts +++ b/packages/runtime/src/sandbox/quickjs-runner.ts @@ -369,6 +369,12 @@ export class QuickJSScriptRunner implements ScriptRunner { ? e['${SANDBOX_REFUSAL_PROP}'] : undefined; } catch (_) { globalThis.__errorRefusal = undefined; } + try { + globalThis.__errorFromHost = (e && typeof e['${SANDBOX_HOST_ERROR_OF_PROP}'] === 'string' + && e['${SANDBOX_HOST_ERROR_OF_PROP}'] === JSON.stringify([e.message, typeof e.code === 'string' ? e.code : null])) + ? true + : undefined; + } catch (_) { globalThis.__errorFromHost = undefined; } }`; // A job body takes the hook's `(ctx)` wrapper (#21489): it has no input to // hand in as a first parameter, and `JobSchema.body` documents `ctx` as @@ -382,12 +388,12 @@ export class QuickJSScriptRunner implements ScriptRunner { // the wrapper's second line. const SETTLE = '((p, onOk, onErr) => (p.then(onOk, onErr), p))'; const wrapped = args.origin.kind !== 'action' - ? `globalThis.__result = undefined; globalThis.__error = undefined; globalThis.__errorInfo = undefined; globalThis.__errorRefusal = undefined; + ? `globalThis.__result = undefined; globalThis.__error = undefined; globalThis.__errorInfo = undefined; globalThis.__errorRefusal = undefined; globalThis.__errorFromHost = undefined; ${SETTLE}((async (ctx) => { ${args.source} })(globalThis.__ctx), function(v){ globalThis.__result = JSON.stringify(v === undefined ? null : v); }, ${REJECT_HANDLER} );` - : `globalThis.__result = undefined; globalThis.__error = undefined; globalThis.__errorInfo = undefined; globalThis.__errorRefusal = undefined; + : `globalThis.__result = undefined; globalThis.__error = undefined; globalThis.__errorInfo = undefined; globalThis.__errorRefusal = undefined; globalThis.__errorFromHost = undefined; ${SETTLE}((async (input, ctx) => { ${args.source} })(globalThis.__input, globalThis.__ctx), function(v){ globalThis.__result = JSON.stringify(v === undefined ? null : v); }, ${REJECT_HANDLER} @@ -519,6 +525,10 @@ export class QuickJSScriptRunner implements ScriptRunner { info, ); if (relayed !== undefined) relayedRefusals.add(thrown); + // [#26170] …and an error a HOST call raised that the body let leave + // unchanged is remembered as such, so a hook's refusal catalogue + // answers only the body's own throws (`isHostRelayedSandboxError`). + if (readErrorFromHost(vm)) hostRelayedErrors.add(thrown); throw thrown; } @@ -1488,6 +1498,12 @@ function hostErrorToVm(vm: QuickJSContext, err: unknown): QuickJSHandle { vm.setProp(errH, SANDBOX_REFUSAL_OF_PROP, of); of.dispose(); } + // [#26170] Every marshalled host error carries the `message` and `code` it + // crossed with, so the reject handler can tell it leaving unchanged from + // an error the body made (see SANDBOX_HOST_ERROR_OF_PROP). + const hostOf = vm.newString(JSON.stringify([vmMessage, typeof e?.code === 'string' && e.code ? e.code : null])); + vm.setProp(errH, SANDBOX_HOST_ERROR_OF_PROP, hostOf); + hostOf.dispose(); } catch { /* keep the bare name/message error */ } @@ -1729,6 +1745,24 @@ const SANDBOX_REFUSAL_PROP = '__objectstackRefusalSentence'; */ const SANDBOX_REFUSAL_OF_PROP = '__objectstackRefusalOf'; +/** + * [#26170] The `[message, code]` a marshalled HOST error crossed into the VM + * with, as one JSON string — the "re-thrown unchanged" test the + * `__errorFromHost` channel is gated on. + * + * A hook that declares a refusal catalogue (`HookSchema.refusals`) refuses by + * code only, and its catalogue answers the throws the BODY made. An error a + * host call raised — a `ctx.api` write the engine refused (`VALIDATION_FAILED`, + * `RECORD_NOT_FOUND`, `PERMISSION_DENIED`), a nested hook's refusal — that the + * body lets leave is the platform speaking, not the body, and passes through + * the catalogue untouched. Both arrive at the body runner as a `SandboxError` + * with an `innerMessage` and a `code`, so the difference is recorded here, + * where it is still visible: a body that rewrote the error's `message` or + * `code` before throwing it is speaking for itself, the line + * {@link SANDBOX_REFUSAL_OF_PROP} draws for the nested-refusal relay. + */ +const SANDBOX_HOST_ERROR_OF_PROP = '__objectstackHostErrorOf'; + /** * [#17265] The caller-addressed BUSINESS sentence a sandboxed body threw, or * `undefined` when this error is not a body's deliberate refusal. @@ -2056,6 +2090,33 @@ function readRelayedRefusal(vm: QuickJSContext): string | undefined { */ const relayedRefusals = new WeakSet(); +/** + * [#26170] The errors this runner built from a HOST error the body let leave + * unchanged ({@link SANDBOX_HOST_ERROR_OF_PROP}). Identity, not a field, for + * the reason {@link relayedRefusals} gives. + */ +const hostRelayedErrors = new WeakSet(); + +/** + * [#26170] Was this error raised by a host call (the engine, a nested hook) + * and re-thrown by the body unchanged, rather than made by the body? + */ +export function isHostRelayedSandboxError(err: unknown): boolean { + return typeof err === 'object' && err !== null && hostRelayedErrors.has(err); +} + +/** [#26170] Read the `__errorFromHost` channel the wrapper's reject handler writes. */ +function readErrorFromHost(vm: QuickJSContext): boolean { + try { + const h = vm.getProp(vm.global, '__errorFromHost'); + const raw = vm.dump(h); + h.dispose(); + return raw === true; + } catch { + return false; + } +} + /** [#26153] Was this error a nested body's refusal relayed across the VM hop? */ export function isRelayedSandboxRefusal(err: unknown): boolean { return typeof err === 'object' && err !== null && relayedRefusals.has(err); diff --git a/packages/spec/liveness/hook.json b/packages/spec/liveness/hook.json index a1b998cb54b..5344fd6cec3 100644 --- a/packages/spec/liveness/hook.json +++ b/packages/spec/liveness/hook.json @@ -84,6 +84,28 @@ "producer": "packages/objectql/src/hook-binder.ts#bindHooksToEngine (the same `wrapDeclarativeHook(hook, resolved, …)` call as retryPolicy — the binder hands the AUTHORED hook to the wrapper)", "note": "#14010 (ruling 2026-09-01): 'system' | 'user' | 'inherit', default 'inherit' = the pre-runAs behaviour. Scope is ctx.api data operations only (condition, readonly strip, session, async untouched). Honoured on both the in-process handler and the sandboxed body because the sandbox reads ctx.api from the engine context at call time (runtime/src/sandbox/body-runner.ts buildSandboxApi). Pinned in packages/objectql/src/hook-run-as.test.ts and packages/runtime/src/sandbox/hook-run-as.integration.test.ts." }, + "refusals": { + "status": "live", + "verifiedAt": "2026-10-27", + "evidenceScope": "in-repo", + "evidence": "packages/runtime/src/sandbox/hook-refusal-catalogue.ts#resolveHookRefusal (the one reader: a sandboxed body's throw of a declared code is answered with that entry, and any other deliberate throw of a hook that declares `refusals` is refused as a fault naming the hook and the code); packages/runtime/src/sandbox/body-runner.ts#hookBodyRunnerFactory (hands every body throw of the hook to `resolveHookRefusal` before it leaves the handler)", + "producer": "packages/objectql/src/hook-binder.ts#bindHooksToEngine (hands the AUTHORED hook to the body runner factory, `refusals` included); packages/spec/src/data/hook.zod.ts#HookSchema (refuses at save a code the body throws literally and the catalogue does not declare)", + "children": { + "message": { + "status": "live", + "verifiedAt": "2026-10-27", + "evidenceScope": "in-repo", + "evidence": "packages/runtime/src/sandbox/hook-refusal-catalogue.ts#declaredRefusalMessage (the sentence the refusal is answered with: `hooks..refusals..message` picked through the `i18n` service in the caller's `HookContext.session.locale`, negotiated by `resolveBundleLocale`, else the declared `message`); packages/runtime/src/sandbox/hook-refusal-catalogue.ts#resolveHookRefusal (serves it as the refusal's caller-facing sentence and its `userMessage`)" + }, + "status": { + "status": "live", + "verifiedAt": "2026-10-27", + "evidenceScope": "in-repo", + "evidence": "packages/runtime/src/sandbox/hook-refusal-catalogue.ts#resolveHookRefusal (the refusal carries the declared `status`, else 400; the status the body set on its own throw is not read)" + } + }, + "note": "[#26170, maintainer ruling 6431311681 letter A] Seeded `live` together with its reader: the key, the body runner that answers it, the translation group and the coverage count land in one change (Prime Directive 10). Read on the sandboxed `body` path only; an in-process `handler` is answered as before (the build lowers an inline handler to a `body`, which is the path a served runtime runs). An error a host call raised and the body let through unchanged, a nested hook's refusal, a crash and a sandbox fault pass through untouched. DRILLED to the entry's two keys: the entry face is closed (`.strict()`)." + }, "label": { "status": "live", "verifiedAt": "2026-09-29", diff --git a/packages/spec/liveness/translation.json b/packages/spec/liveness/translation.json index 0c2ea7b8c3f..561d227114f 100644 --- a/packages/spec/liveness/translation.json +++ b/packages/spec/liveness/translation.json @@ -206,6 +206,30 @@ }, "note": "[#7646] Contract-first spec half of the screen-flow localization split, and `planned` is the honest status rather than `live` or `dead`: `dead` means declared with no consumer and no plan, while this group was ruled into the vocabulary by the maintainer specifically so the runner half could be built against it (the same ruling fixes the boundary — runner chrome, Cancel/Submit, stays in the console's own message catalog, NOT here). Addressing is measured against what the runner already holds: `flows..screens.` — the node id reaches the client verbatim as `ScreenSpec.nodeId` (packages/spec/src/contracts/automation-service.ts:138), which is also what correlates a resume back to its pause point — and `.fields.` (packages/spec/src/automation/builtin-node-config.zod.ts:382, forwarded as `ScreenFieldSpec.name`). Key face measured against `ScreenFieldConfigSchema`, not mirrored from the report: the per-field keys are `label`, `placeholder` and `inlineHelpText`, each spelled as the screen field spells the string it overlays. The report's `help` was never a key here: when #7646 ruled the face the screen field declared nothing help-shaped, so it would have parsed clean and translated nothing (the ADR-0078 shape #6080 kept out of the page-component face). #17306 gave the screen field `inlineHelpText`, and the face gained it on 2026-10-02 once the console drew the help text; `help` / `helpText` / `hint` / `tooltip` / `description` are aliases onto it on the field surface. `options` stayed out, on `guidance`, because a value-keyed map could not address `ScreenFieldConfig.options[].value`, which is unconstrained (superseded 2026-10-09, see the end of this note). Flip to `live` with an objectui screen-flow-runner evidence pointer when the downstream consumer card lands; the resolver-side helper (a `FLOW_SCREEN_COPY_KEYS` sibling of `PAGE_COMPONENT_COPY_KEYS` in packages/spec/src/system/i18n-resolver.ts) is deliberately NOT in this change — #7634 was in flight on that file. 2026-09-27 (#20296): the `screens` child FLIPPED to `live` (objectui's FlowRunner reads it at the `.objectui-sha` pin f8a9d0fb; see that row), and `authorHint` was rewritten to say so. The container keeps `planned` + `authorWarn` for `label` alone, which nothing reads yet (#20318). The CLI i18n coverage gate keys its whole-group `flows.*` demand off this `authorWarn`, so dropping the bit waits for that reader, and then the whole group flips. 2026-10-05: the `label` child FLIPPED to `live` (objectui's FlowRunner names the flow by `flows..label`, then the served `flowLabel`, then the API name, read at the `.objectui-sha` pin 0abd4f9f8; see that row). Both children are `live`, so the container flips with them, and `authorWarn` and `authorHint` are dropped: a `live` row refuses them, and no half of the group is unread any more. Dropping the bit switches the CLI i18n coverage gate's whole-group `flows.*` demand back on, which is what that gate was built to do on this day. 2026-10-09 (#22507): the closing pass for user-read flow text. The face gains the terminal toasts (`successMessage` / `errorMessage`, child rows `planned`) and, inside `screens`, a screen field's option labels (`fields..options.`, each option keyed by `String(value)`; `ScreenFieldConfigSchema` refuses two options of one field that collide as text). Both are read by `translateFlow` and await the objectui runner change, so neither is drawn translated yet. The container stays `live`: its `label` and `screens` readers are unchanged. The screen `description` stays unkeyed and is recorded as owed in `packages/spec/src/system/flows-translation-face.test.ts`: it is a `{{ }}` template the server renders per run, so its reader is a decision of its own. 2026-10-10 (#22507, maintainer ruling A): that decision is taken. The screen face gains `description`, and the engine picks the translated `title` and `description` templates in the run's locale before it renders them (see the `screens` row). The container stays `live`." }, + "hooks": { + "status": "live", + "verifiedAt": "2026-10-27", + "evidenceScope": "in-repo", + "evidence": "packages/runtime/src/sandbox/hook-refusal-catalogue.ts#declaredRefusalMessage (the one reader of the group: each `hooks..refusals.` entry is read for its `message`, the drilled child below)", + "children": { + "refusals": { + "status": "live", + "verifiedAt": "2026-10-27", + "evidenceScope": "in-repo", + "evidence": "packages/runtime/src/sandbox/hook-refusal-catalogue.ts#declaredRefusalMessage (each `refusals.` entry is read for its `message`, the drilled child below)", + "children": { + "message": { + "status": "live", + "verifiedAt": "2026-10-27", + "evidenceScope": "in-repo", + "evidence": "packages/runtime/src/sandbox/hook-refusal-catalogue.ts#declaredRefusalMessage (reads `hooks..refusals..message` through the `i18n` service, spelled by `hookRefusalMessageKey`, in the caller's `HookContext.session.locale` negotiated by `resolveBundleLocale`; a miss echoes the key and the declared message is served); packages/runtime/src/sandbox/hook-refusal-catalogue.ts#resolveHookRefusal (serves the picked sentence as the refusal's caller-facing message and `userMessage`)", + "producer": "packages/runtime/src/app-plugin.ts#loadTranslations (hands each bundle's locale data to the i18n service whole, `hooks` included); packages/objectql/src/engine-execution-plumbing.ts#buildSession (copies the write's `ExecutionContext.locale` onto the hook session the body runner reads)" + } + } + } + }, + "note": "[#26170, maintainer ruling 6431311681 letter A] Seeded `live` together with its reader, the flow `refusals` precedent: the key and the body-runner read that makes it true land in one change (Prime Directive 10). A hook's refusal leaves the server already rendered (the error envelope's message and `userMessage`), so the body runner picks the translation before it serves the refusal, the validation-message posture (`objectValidationMessageKey`). Keyed by hook name, never by object, so a hook bound to several objects or to `'*'` owns one translation per code. `objectstack validate` (`validate-translation-references.ts`) refuses a key over an unknown hook or an undeclared code, and the i18n extract/check walk counts each declared entry. No objectui reader: the console draws the served refusal verbatim. DRILLED two levels, to `refusals.message`; each entry face is closed (`.strict()`)." + }, "positions": { "status": "live", "verifiedAt": "2026-10-24", diff --git a/packages/spec/src/data/hook-refusal-codes.ts b/packages/spec/src/data/hook-refusal-codes.ts new file mode 100644 index 00000000000..b1597d59cee --- /dev/null +++ b/packages/spec/src/data/hook-refusal-codes.ts @@ -0,0 +1,176 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A hook's declared refusal catalogue (#26170, maintainer ruling 6431311681, + * letter A): the vocabulary, the save-time reading of a body's literal throws, + * and the sentences both halves speak. + * + * A hook that declares `refusals: { : { message, status? } }` refuses by + * code only. Its body throws an error carrying a declared `code`; the runtime + * answers with the declared message, translated through + * `hooks..refusals..message` in the caller's locale, and the + * declared status. A code the catalogue does not declare is refused loudly: + * + * - at save time, where the body names the code literally in a `throw` + * ({@link hookRefusalCodesThrownIn}) — `HookSchema` refuses the hook at + * every door that parses one; + * - otherwise at run time, where the sandboxed body runner answers the throw + * as a fault naming the hook and the code + * (`packages/runtime/src/sandbox/hook-refusal-catalogue.ts`). + * + * It imports nothing, on purpose: `data/hook.zod.ts` reads it and so does the + * runtime, and a leaf keeps the schema's import graph unchanged (the + * `identity/identity-assignment-body-writes.ts` precedent). + */ + +/** + * The spelling of a declared refusal code: SCREAMING_SNAKE, the one wire + * vocabulary of an error code (ADR-0112). The code a body throws is the + * `code` the caller's envelope carries, so it is spelled as every other code a + * client branches on. + */ +export const HOOK_REFUSAL_CODE_PATTERN = /^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)*$/; + +/** A string literal in any of the three quote forms, holding a refusal code. */ +const CODE_LITERAL = String.raw`(?['"\x60])(?[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)*)\k`; + +/** `code: 'X'` — the property in an object literal a `throw` builds. */ +const CODE_PROPERTY = new RegExp(String.raw`(?[A-Za-z_$][\w$]*)\s*\.\s*code\s*=(?!=)\s*` + CODE_LITERAL, + 'g', +); + +/** + * The text of each `throw` statement in `source`: from the keyword to the end + * of its expression — a `;` or a line break outside every bracket and string, + * or the close of the block that holds it. + * + * Read, never parsed or run, so a body that does not parse still answers; the + * scan skips over strings, template literals and comments so a `;` or a + * bracket inside one does not end or extend the expression. + */ +function throwStatements(source: string): string[] { + const found: string[] = []; + const keyword = /(? 0) { + end = i; + break; + } + } + found.push(source.slice(start, end)); + } + return found; +} + +/** The index of the closing quote of the string opened at `open`. */ +function skipString(source: string, open: number): number { + const quote = source[open]; + for (let i = open + 1; i < source.length; i++) { + if (source[i] === '\\') { + i++; + continue; + } + if (source[i] === quote) return i; + } + return source.length; +} + +/** The identifier a `throw` statement throws, when it throws a bare name. */ +function thrownBinding(statement: string): string | undefined { + const m = /^\s*([A-Za-z_$][\w$]*)\s*$/.exec(statement); + return m ? m[1] : undefined; +} + +/** + * The refusal codes a sandboxed body's source throws LITERALLY, in first-seen + * order — the cases a save can decide: + * + * - a `code: 'X'` property inside a `throw` statement: + * `throw Object.assign(new Error(), { code: 'X' })`, `throw { code: 'X' }`; + * - `e.code = 'X'` on a name some `throw` statement throws bare: `throw e`. + * + * A code that arrives any other way — a helper's parameter + * (`refuse('…', code)`), a variable, a computed value — is left to the run-time + * refusal, which judges the code the body actually threw. Only SCREAMING_SNAKE + * literals are read ({@link HOOK_REFUSAL_CODE_PATTERN}), so a record field + * named `code` written with a lower-case value is never mistaken for one. + */ +export function hookRefusalCodesThrownIn(source: string): string[] { + const codes = new Set(); + const thrownNames = new Set(); + for (const statement of throwStatements(source)) { + for (const m of statement.matchAll(CODE_PROPERTY)) codes.add(m.groups!.code); + const binding = thrownBinding(statement); + if (binding) thrownNames.add(binding); + } + for (const m of source.matchAll(CODE_ASSIGNMENT)) { + if (thrownNames.has(m.groups!.binding)) codes.add(m.groups!.code); + } + return [...codes]; +} + +/** The declared codes, as the sentence lists them. */ +function declaredList(declared: readonly string[]): string { + return declared.length > 0 ? declared.map((c) => `'${c}'`).join(', ') : 'none'; +} + +/** + * The save-time refusal for a code a body throws literally and the hook's + * catalogue does not declare. + */ +export function undeclaredHookRefusalCodeAtSave(code: string, declared: readonly string[]): string { + return ( + `\`body.source\` throws code '${code}', and this hook's \`refusals\` declares no such code ` + + `(declared: ${declaredList(declared)}). A hook that declares \`refusals\` refuses by code only, so the ` + + `throw is refused when the body runs. Declare it under \`refusals.${code}\` with the message the user ` + + 'reads, or throw one of the declared codes.' + ); +} + +/** + * The run-time refusal for a body throw the catalogue cannot answer: a code it + * does not declare, or no code at all. Fault text, for the server log and the + * author: it names the hook and the code and says how to fix it. + */ +export function undeclaredHookRefusalAtRun( + hookName: string, + code: string | undefined, + declared: readonly string[], +): string { + const thrown = code === undefined ? 'threw no `code`' : `threw code '${code}'`; + return ( + `hook '${hookName}' ${thrown}, and its \`refusals\` declares no such code (declared: ${declaredList(declared)}). ` + + 'A hook that declares `refusals` refuses by code only: throw an error whose `code` is one of the declared ' + + 'codes, or declare this one with the message the user reads.' + ); +} diff --git a/packages/spec/src/data/hook.zod.ts b/packages/spec/src/data/hook.zod.ts index cca44378356..df93017b89c 100644 --- a/packages/spec/src/data/hook.zod.ts +++ b/packages/spec/src/data/hook.zod.ts @@ -25,6 +25,14 @@ import { // (`contracts/data-engine.ts`), so a VALUE import here would close a runtime // cycle. `import type` is erased, leaving the edge in the type graph only. import type { IScopedContext } from '../contracts/scoped-context'; +// [#26170] The refusal catalogue's code spelling, the save-time reading of a +// body's literal throws and the sentence — an import-free leaf the runtime +// reads too, so this schema's import graph is unchanged. +import { + HOOK_REFUSAL_CODE_PATTERN, + hookRefusalCodesThrownIn, + undeclaredHookRefusalCodeAtSave, +} from './hook-refusal-codes'; /* * ── Unknown-key strictness (#4001 data step) ──────────────────────────────── @@ -187,6 +195,79 @@ function refuseElevatedIdentityAssignmentBodyWrite( } } +/* + * ── A hook that declares `refusals` refuses by code only ──────────────────── + * + * [#26170, maintainer ruling 6431311681 letter A] A hook's refusal catalogue + * names every refusal its body may throw. The runtime answers a declared code + * with the declared message in the caller's locale; it refuses a code the + * catalogue does not declare, loudly. Where the body names the code literally + * in a `throw` (`hookRefusalCodesThrownIn`), the save decides it first: the + * parse refuses the hook at every door that parses one — the metadata save + * door, `defineStack` / `os validate`, compile, an artifact's parse — so the + * author meets the refusal before any write does. One issue per code, at + * `body.source`. A code that arrives any other way is the run-time refusal's. + * + * A hook that declares no `refusals` is untouched: its body's throws are + * answered as they always were. + */ + +/** + * The object-level check that refuses a code a hook's sandboxed body throws + * literally and its `refusals` does not declare. See the block above. + */ +function refuseUndeclaredLiteralRefusalCode( + hook: { refusals?: Record; body?: { language?: string; source?: unknown } }, + ctx: z.RefinementCtx, +): void { + if (!hook.refusals || hook.body?.language !== 'js' || typeof hook.body.source !== 'string') return; + const declared = Object.keys(hook.refusals); + for (const code of hookRefusalCodesThrownIn(hook.body.source)) { + if (Object.prototype.hasOwnProperty.call(hook.refusals, code)) continue; + ctx.addIssue({ + code: 'custom', + path: ['body', 'source'], + message: undeclaredHookRefusalCodeAtSave(code, declared), + }); + } +} + +/** + * One entry of a hook's refusal catalogue (#26170): the sentence the user + * reads when the hook's body throws this entry's code, and the status the + * refusal is answered with. + * + * The message is plain text, handed over verbatim in the source locale and + * replaced whole by its translation, `hooks..refusals..message` — + * the validation-rule message's posture (`objects.._validations..message`): + * an authored refusal has no parameter contract, so no `{{ }}` hole is filled. + */ +export const HookRefusalSchema = lazySchema(() => strictObject( + { + surface: 'this hook refusal', + aliases: { text: 'message', reason: 'message', description: 'message', httpStatus: 'status', statusCode: 'status' }, + guidance: { + code: + '`code` is not a refusal key — the code is the entry\'s KEY in `refusals` ' + + '(`refusals: { CLEAN_VERSION_REQUIRED: { message } }`), and the body throws it.', + title: + 'A hook refusal has no heading — the caller reads the `message` alone. Write the sentence in `message`.', + }, + }, + { + message: z.string().trim().min(1).describe( + 'The sentence the user reads when the body throws this code — plain text in the source locale, translated ' + + 'whole through hooks..refusals..message in the caller\'s locale; no {{ }} holes are filled', + ), + status: z.number().int().min(400).max(499).optional().describe( + 'HTTP status the refusal is answered with (4xx; a refusal is never a fault). Absent: 400. A status the ' + + 'body itself sets on the throw is not read — the catalogue decides', + ), + }, +)); + +export type HookRefusal = z.input; + /** * Hook Lifecycle Events * Defines the interception points in the ObjectQL execution pipeline. @@ -495,6 +576,42 @@ export const HookSchema = lazySchema(() => strictObject( + '(elsewhere the caller is refused and the row stays stored). log: log the error and continue', ), + /** + * The hook's refusal catalogue (#26170, maintainer ruling 6431311681 + * letter A): every refusal its body may throw, keyed by the code it throws. + * + * refusals: { + * CLEAN_VERSION_REQUIRED: { message: 'A current clean version is required before signing.', status: 422 }, + * } + * + * The body refuses by code only — it throws an error carrying a declared + * `code`, and the runtime answers with that entry: its `message`, translated + * through `hooks..refusals..message` in the caller's locale with + * the declared message as the fallback, marked as the end user's sentence, + * and its `status`. A code the catalogue does not declare, or a throw with no + * code, is refused loudly: at save where the body names it literally, + * otherwise when it runs, as a fault naming the hook and the code. An error + * a host call raised and the body let through unchanged (a `ctx.api` write + * refused by the engine, a nested hook's refusal) passes through as before, + * and so does a crash. + * + * Keyed by hook name, not by object: one hook bound to several objects, or + * to `'*'`, owns one catalogue and one translation per code. Read by the + * sandboxed `body` runner; an in-process `handler` is answered as before. + * Absent: the hook's throws are answered as they always were. + */ + refusals: z.record( + z.string().regex(HOOK_REFUSAL_CODE_PATTERN, { + message: 'A refusal code is SCREAMING_SNAKE (e.g. CLEAN_VERSION_REQUIRED) — the error code the body throws', + }), + HookRefusalSchema, + ).optional().describe( + 'Refusal catalogue keyed by the SCREAMING_SNAKE code the body throws: { message, status? }. The body refuses ' + + 'by code only; the runtime answers with the declared message (translated through ' + + 'hooks..refusals..message in the caller\'s locale) and status. A code not declared here is refused ' + + 'loudly — at save when the body throws it literally, otherwise at run time', + ), + /** * Execution identity for the hook's `ctx.api` data operations (#14010; * ruling 2026-09-01). The value semantics of `'system'` / `'user'` are @@ -565,11 +682,13 @@ export const HookSchema = lazySchema(() => strictObject( // overlay path before Tier-A declared them (#4001 findings log, entries 2/8). ...MetadataProtectionFields, // [#21565] The object-level checks: the first pairs `object` with `body`, - // [#25653] the second `runAs` with `body`, pairs no field-level refine can - // see together. A schema DERIVED from this one by overriding a key must use - // `.safeExtend()` (zod refuses `.extend()` over a refined object), which is - // what keeps both checks on the derived schema too. -}).superRefine(refuseBodyOnStoredMetadataTarget).superRefine(refuseElevatedIdentityAssignmentBodyWrite)); + // [#25653] the second `runAs` with `body`, [#26170] the third `refusals` + // with `body`, pairs no field-level refine can see together. A schema + // DERIVED from this one by overriding a key must use `.safeExtend()` (zod + // refuses `.extend()` over a refined object), which is what keeps every + // check on the derived schema too. +}).superRefine(refuseBodyOnStoredMetadataTarget).superRefine(refuseElevatedIdentityAssignmentBodyWrite) + .superRefine(refuseUndeclaredLiteralRefusalCode)); /** * Hook Runtime Context @@ -1150,6 +1269,23 @@ export const HookContextSchema = lazySchema(() => z.object({ + 'the import instant. Server-set, opt-in, absent on normal writes; read by the ' + 'built-in audit hook. A stamping policy, not an authorization input.', ), + /** + * The caller's locale (#26170) — the write's already-resolved + * `ExecutionContext.locale`, copied by `buildSession()`: the request's + * `Accept-Language` first tag, else the `localization` setting. Absent on + * a write no person asked for (a system or programmatic write). + * + * The sandboxed body runner reads it to answer a declared refusal + * (`HookSchema.refusals`) in the caller's language: it negotiates it + * against the locales the `i18n` service holds (`resolveBundleLocale`) + * and picks `hooks..refusals..message`. A hook may read it too, + * to describe the caller; like {@link positions} it authorizes nothing. + */ + locale: z.string().optional().describe( + 'The caller\'s locale (BCP-47), copied from ExecutionContext.locale: the request\'s Accept-Language, else ' + + 'the localization setting; absent on a write no person asked for. The body runner answers a declared refusal ' + + '(HookSchema.refusals) in it. Descriptive only — not an authorization input.', + ), // `roles` REMOVED (#5050, ADR-0049 D2). It was DECLARED here, READ by two // dead exemption branches in plugin-approvals (the approval record lock and // the delegation write guard, both deleted in #4839 / PR #5049), and NEVER diff --git a/packages/spec/src/data/index.ts b/packages/spec/src/data/index.ts index bd06fe5295a..f8912e8243b 100644 --- a/packages/spec/src/data/index.ts +++ b/packages/spec/src/data/index.ts @@ -206,6 +206,10 @@ export * from './autonumber-format'; export * from './validation.zod'; export * from './hook.zod'; export * from './hook-body.zod'; +// [#26170] A hook's refusal catalogue: the code spelling, the save-time reading +// of a body's literal throws, and the sentences the schema and the body runner +// both speak. +export * from './hook-refusal-codes'; // [#18163] The TYPED AUTHORING FACE of `HookContext.api` — `HookApi`, // `HookObjectApi` and the query / count / update / delete option shapes the // engine actually accepts. `contracts/scoped-context.ts` declares the CHECKED diff --git a/packages/spec/src/system/i18n-resolver.ts b/packages/spec/src/system/i18n-resolver.ts index 77823c09515..347b6bcf970 100644 --- a/packages/spec/src/system/i18n-resolver.ts +++ b/packages/spec/src/system/i18n-resolver.ts @@ -3491,6 +3491,26 @@ export function flowRefusalMessageKey(flowName: string, nodeId: string): string return `flows.${flowName}.refusals.${nodeId}.message`; } +/** + * Dot-notation i18n key for a hook's declared refusal message — + * `hooks..refusals..message` (#26170, maintainer ruling 6431311681 + * letter A). + * + * A sibling of {@link objectValidationMessageKey} and + * {@link flowRefusalMessageKey}, spelled here for the same reason: its consumer + * holds an `II18nService` (which takes a key), not a `TranslationBundle`. Used + * by the sandboxed body runner, which answers a body's throw of a declared code + * (`HookSchema.refusals`) with this translation in the caller's locale + * (`HookContext.session.locale`), the declared message on a miss. + * + * `hookName` is `Hook.name` and `code` the catalogue's key — by hook, never by + * object, so a hook bound to several objects or to `'*'` owns one translation + * per code. + */ +export function hookRefusalMessageKey(hookName: string, code: string): string { + return `hooks.${hookName}.refusals.${code}.message`; +} + /** * Dot-notation i18n key for a screen's heading or body text — * `flows..screens..title` / `.description` (#22507). diff --git a/packages/spec/src/system/translation.zod.ts b/packages/spec/src/system/translation.zod.ts index 525953111fe..fb8d5f94cdb 100644 --- a/packages/spec/src/system/translation.zod.ts +++ b/packages/spec/src/system/translation.zod.ts @@ -718,9 +718,9 @@ const PER_APP_SETTINGS_PLATFORM_ONLY = + '(`@objectstack/service-settings`\'s `settingsBuiltinTranslations`, typed ' + '`PlatformTranslationData`); a key it does not translate falls back to the manifest\'s own ' + 'literal, so correct it there rather than filling the gap from an application. For an ' - + 'application\'s own copy use the fourteen groups this bundle does declare, in the order it ' + + 'application\'s own copy use the fifteen groups this bundle does declare, in the order it ' + "declares them — 'objects', 'picklists', 'apps', 'messages', 'globalActions', 'dashboards', " - + "'datasets', 'reports', 'pages', 'flows', 'positions', 'permissions', 'metadataForms', " + + "'datasets', 'reports', 'pages', 'flows', 'hooks', 'positions', 'permissions', 'metadataForms', " + "'settingsCommon'. " + "Note the last one: 'settingsCommon' IS " + 'on this face, so the Settings UI shell strings an application may translate (the source ' @@ -802,12 +802,12 @@ const ITEM_TRANSLATION_KEY_GUIDANCE: Record = { */ /** * The translation groups an APPLICATION may author, as a shape rather than a - * schema. Fourteen groups — the fifteenth, `settings`, is platform-only and lives in + * schema. Fifteen groups — the sixteenth, `settings`, is platform-only and lives in * {@link platformSettingsShape}. * * Three schemas need exactly these keys: {@link TranslationDataSchema} (one - * entry of a per-app file-authored bundle — these fourteen and no more), - * {@link PlatformTranslationDataSchema} (these fourteen plus `settings`) and + * entry of a per-app file-authored bundle — these fifteen and no more), + * {@link PlatformTranslationDataSchema} (these fifteen plus `settings`) and * {@link TranslationItemSchema} (the registered `translation` metadata type — * the per-app face plus `locale`, its identity keys and the ADR-0010 * envelope; it carried the platform face's `settings` too until #19620). The item used to @@ -1680,6 +1680,63 @@ const appTranslationDataShape = () => ({ })).optional().describe("Approval-step translations keyed by the node id (`FlowNode.id`) of an `approval` node"), })).optional().describe('Screen-flow translations keyed by flow name'), + /** + * Hook translations keyed by hook name (`Hook.name`) — #26170, maintainer + * ruling 6431311681 letter A. + * + * hooks..refusals..message (a declared refusal, `HookSchema.refusals`) + * + * A hook's business refusal ("A current clean version is required before + * signing.") is the sentence a user reads most often when an app says "not + * yet", and it was the one refusal door with nowhere to put a translation: + * validation rules read `objects.._validations..message`, a + * refused flow `end` reads `flows..refusals..message`, and a + * hook threw its sentence from body code no bundle could reach. Under the + * ruling a hook DECLARES its refusals (`refusals: { : { message, + * status? } }`) and its body refuses by code only, so each sentence is on a + * named declaration this group can address and the i18n gates can count. + * + * **Addressed by hook, not by object.** A hook binds one object, a list, or + * `'*'` (`HookSchema.object`); one catalogue per hook means one translation + * per code, whatever the hook is bound to. + * + * **Picked before it is served, by the body runner.** The sandboxed body + * runner answers a throw of a declared code with `hookRefusalMessageKey(hook, + * code)` (`system/i18n-resolver.ts`), read through the `i18n` service in the + * caller's `HookContext.session.locale` negotiated by `resolveBundleLocale`; + * with no locale, or no entry, the declared message is served. The message + * is plain text like the validation-rule message it sits beside: no `{{ }}` + * hole is filled, the translation replaces it whole. + * + * `objectstack validate` refuses a key over an unknown hook, or a code the + * hook's `refusals` does not declare. + */ + hooks: z.record(z.string(), strictObject({ + surface: 'this hook translation', + history: TRANSLATION_HISTORY, + aliases: { refusal: 'refusals', errors: 'refusals', messages: 'refusals' }, + guidance: { + label: + 'A hook has no translated label — the one string of a hook a user reads is a refusal. Translate ' + + '`refusals..message`.', + }, + }, { + refusals: z.record(z.string(), strictObject({ + surface: 'this hook refusal translation', + history: TRANSLATION_HISTORY, + // The spellings `HookRefusalSchema` renames onto its own `message`. + aliases: { text: 'message', reason: 'message', description: 'message' }, + }, { + // Plain text, like the declared message it translates; an empty string + // is the untranslated skeleton slot `os i18n extract` writes (the + // declared message is served). + message: z.string().optional().describe( + "Translated refusal message of a hook's declared refusal code — served whole in the caller's locale in " + + 'place of the declared message', + ), + })).optional().describe("Refusal translations keyed by the code the hook's `refusals` declares"), + })).optional().describe('Hook translations keyed by hook name'), + /** * Position translations keyed by position name (`Position.name`, * `identity/position.zod.ts`, the stack's `positions[]`). @@ -1940,10 +1997,10 @@ const platformSettingsShape = () => ({ * One locale of a PER-APP translation bundle — `stack.translations`, and * everything {@link defineTranslationBundle} builds. * - * Fourteen groups: every group the platform bundle declares EXCEPT `settings`, + * Fifteen groups: every group the platform bundle declares EXCEPT `settings`, * which is platform-only and is refused here by name with * {@link PER_APP_SETTINGS_PLATFORM_ONLY} as the remedy. See - * {@link PlatformTranslationDataSchema} for the fifteen-group face and for why + * {@link PlatformTranslationDataSchema} for the sixteen-group face and for why * the two are separate namespaces. */ export const TranslationDataSchema = lazySchema(() => strictObject({ @@ -1954,7 +2011,7 @@ export const TranslationDataSchema = lazySchema(() => strictObject({ // `settings`, and an alias prescribing a key the shape rejects is a // suggestion the author cannot take (the `alias-integrity` audit judges // exactly that). Both spellings are answered by `guidance` above instead. - aliases: { object: 'objects', fields: 'objects', app: 'apps', page: 'pages', dashboard: 'dashboards', dataset: 'datasets', report: 'reports', picklist: 'picklists', flow: 'flows', position: 'positions', permission: 'permissions', permissionSets: 'permissions', message: 'messages', strings: 'messages', labels: 'messages', actions: 'globalActions' }, + aliases: { object: 'objects', fields: 'objects', app: 'apps', page: 'pages', dashboard: 'dashboards', dataset: 'datasets', report: 'reports', picklist: 'picklists', flow: 'flows', hook: 'hooks', position: 'positions', permission: 'permissions', permissionSets: 'permissions', message: 'messages', strings: 'messages', labels: 'messages', actions: 'globalActions' }, // `locale` lives on the ITEM, not on a bundle entry (the bundle keys ARE the // locales). Naming it keeps the suggestion useful for an author who moved a // `translation` item into a bundle and left the field behind. @@ -1964,7 +2021,7 @@ export const TranslationDataSchema = lazySchema(() => strictObject({ export type TranslationData = z.input; /** - * One locale of a PLATFORM translation bundle — the fifteen groups, `settings` + * One locale of a PLATFORM translation bundle — the sixteen groups, `settings` * included. * * The platform's own bundles are code, not authored metadata @@ -1999,7 +2056,7 @@ export const PlatformTranslationDataSchema = lazySchema(() => strictObject({ surface: 'this locale of the platform translation bundle', history: TRANSLATION_HISTORY, guidance: TRANSLATION_KEY_GUIDANCE, - aliases: { object: 'objects', fields: 'objects', app: 'apps', page: 'pages', dashboard: 'dashboards', dataset: 'datasets', report: 'reports', picklist: 'picklists', flow: 'flows', position: 'positions', permission: 'permissions', permissionSets: 'permissions', setting: 'settings', message: 'messages', strings: 'messages', labels: 'messages', actions: 'globalActions' }, + aliases: { object: 'objects', fields: 'objects', app: 'apps', page: 'pages', dashboard: 'dashboards', dataset: 'datasets', report: 'reports', picklist: 'picklists', flow: 'flows', hook: 'hooks', position: 'positions', permission: 'permissions', permissionSets: 'permissions', setting: 'settings', message: 'messages', strings: 'messages', labels: 'messages', actions: 'globalActions' }, extraKeys: ['locale'], }, { ...appTranslationDataShape(), @@ -2206,7 +2263,7 @@ export const TranslationItemSchema = lazySchema(() => strictObject({ // this door no longer declares `settings`, and an alias prescribing a key // the shape rejects is a suggestion the author cannot take. Both spellings // are answered by `guidance` above instead. - aliases: { object: 'objects', app: 'apps', page: 'pages', dataset: 'datasets', report: 'reports', picklist: 'picklists', flow: 'flows', position: 'positions', permission: 'permissions', permissionSets: 'permissions', message: 'messages', strings: 'messages', labels: 'messages', actions: 'globalActions', lang: 'locale', language: 'locale' }, + aliases: { object: 'objects', app: 'apps', page: 'pages', dataset: 'datasets', report: 'reports', picklist: 'picklists', flow: 'flows', hook: 'hooks', position: 'positions', permission: 'permissions', permissionSets: 'permissions', message: 'messages', strings: 'messages', labels: 'messages', actions: 'globalActions', lang: 'locale', language: 'locale' }, }, { ...appTranslationDataShape(), locale: LocaleSchema.describe('BCP-47 locale this item translates (e.g. "zh-CN")'), From 108e7dbbe8743123bfadc38f1474817171bd93e3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 13:41:01 +0000 Subject: [PATCH 02/18] chore(spec): history for HookRefusalSchema; regenerate spec artifacts Claude-Session: https://claude.ai/code/session_01WGufzfyVQuRsL6SRGuKCem Co-authored-by: Claude --- content/docs/references/api/protocol.mdx | 1 + content/docs/references/data/hook.mdx | 27 ++++++++++++++++-- content/docs/references/index.mdx | 10 +++---- .../docs/references/system/translation.mdx | 28 +++++++++++++++++++ .../data.md | 12 ++++---- .../system.md | 2 +- packages/spec/api-surface/data.json | 6 ++++ packages/spec/api-surface/system.json | 1 + packages/spec/authorable-surface/data.json | 3 ++ packages/spec/authorable-surface/system.json | 4 +++ packages/spec/declaration-map/data.json | 2 ++ packages/spec/export-origins/data.json | 6 ++++ packages/spec/export-origins/system.json | 1 + packages/spec/json-schema.manifest/data.json | 1 + packages/spec/liveness/state-counts/hook.md | 2 +- .../spec/liveness/state-counts/translation.md | 2 +- packages/spec/src/data/hook.zod.ts | 1 + skills/objectstack-data/references/_index.md | 2 +- .../objectstack-platform/references/_index.md | 2 +- 19 files changed, 94 insertions(+), 19 deletions(-) diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 551a6319339..5ecc6326976 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1781,6 +1781,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **reports** | `Record` | optional | Report translations keyed by report name | | **pages** | `Record` | optional | Page translations keyed by page name | | **flows** | `Record; … }>` | optional | Screen-flow translations keyed by flow name | +| **hooks** | `Record }>` | optional | Hook translations keyed by hook name | | **positions** | `Record` | optional | Position translations keyed by position name | | **permissions** | `Record` | optional | Permission-set translations keyed by permission-set name | | **settingsCommon** | `{ sourceLabels?: object }` | optional | Cross-namespace Settings UI strings | diff --git a/content/docs/references/data/hook.mdx b/content/docs/references/data/hook.mdx index 2932817ebe3..7b6c1fc2b79 100644 --- a/content/docs/references/data/hook.mdx +++ b/content/docs/references/data/hook.mdx @@ -1,7 +1,7 @@ --- title: Hook schema — Data Protocol property reference navTitle: Hook -description: "Hook schemas of the ObjectStack Data Protocol: Hook, HookContext, HookEvent — each property with its type, default and a TypeScript example." +description: "Hook schemas of the ObjectStack Data Protocol: Hook, HookContext, HookEvent, HookRefusal — each property with its type, default and a TypeScript example." --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -13,8 +13,8 @@ description: "Hook schemas of the ObjectStack Data Protocol: Hook, HookContext, ## TypeScript Usage ```typescript -import { HookSchema, HookContextSchema, HookEvent } from '@objectstack/spec/data'; -import type { Hook, HookContext } from '@objectstack/spec/data'; +import { HookSchema, HookContextSchema, HookEvent, HookRefusalSchema } from '@objectstack/spec/data'; +import type { Hook, HookContext, HookRefusal } from '@objectstack/spec/data'; // Validate data const result = HookSchema.parse(data); @@ -42,6 +42,7 @@ const result = HookSchema.parse(data); | **timeoutMs** | `number` | optional | Maximum execution time in milliseconds before the hook is aborted | | **timeout** | `never` | optional | [REMOVED] `hook.timeout` was removed in @objectstack/spec 17 — its unit (milliseconds) lived only in the description, beside a body-level `timeoutMs` and a `retryPolicy.backoffMs` that spell theirs, so the same number read as two conventions on one surface. Rename the key to `timeoutMs`; the value (milliseconds) is unchanged. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; `--write` applies the ones it can prove, and you apply the rest by hand. | | **onError** | `Enum<'abort' \| 'log'>` | optional (default: `"abort"`) | Error handling strategy. abort: refuse the operation; a blocking hook's abort rolls the write back inside a unit of work, which the write door opens for an after* hook on an object on the default datasource whose driver declares transactions (elsewhere the caller is refused and the row stays stored). log: log the error and continue | +| **refusals** | `Record` | optional | Refusal catalogue keyed by the SCREAMING_SNAKE code the body throws: `{ message, status? }`. The body refuses by code only; the runtime answers with the declared message (translated through hooks.``.refusals.``.message in the caller's locale) and status. A code not declared here is refused loudly — at save when the body throws it literally, otherwise at run time | | **runAs** | `Enum<'system' \| 'user' \| 'inherit'>` | optional (default: `"inherit"`) | Execution identity for the hook's ctx.api data operations: system = elevated (bypasses RLS), user = the triggering user (RLS-respecting), inherit = the context of the write that fired the hook (the pre-runAs behaviour; the default). A hook with no trigger user has no identity to scope to, so under user its ctx.api data operations are REFUSED — declare system to make the elevation explicit. This covers any hook fired by a write that carried no user (an isSystem plugin/service write; a system-elevated flow node). Elevation does not reach identity assignment: a system hook's governed writes (insert, update, delete, transfer, restore, purge) to sys_user_position, sys_user_permission_set, sys_invitation and sys_member are refused with PERMISSION_DENIED, and a system hook whose body names such a write is refused at save; declare user for position holdings and permission-set grants (the ADR-0090 D12 delegated-administration gate then judges the user the hook acts for), and change invitations and memberships through the organization endpoints. Scope: ctx.api only — condition evaluation, the readonly strip on ctx.input, ctx.session and async are unchanged. | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | | **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | @@ -79,6 +80,13 @@ L2 sandboxed JS body — runs inside an isolated VM with declared capabilities | **maxRetries** | `number` | optional (default: `3`) | Maximum retry attempts on failure | | **backoffMs** | `number` | optional (default: `1000`) | Backoff delay between retries in milliseconds | +### Nested Shape: `Hook.refusals[string]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **message** | `string` | ✅ | The sentence the user reads when the body throws this code — plain text in the source locale, translated whole through hooks.``.refusals.``.message in the caller's locale; no `{{ }}` holes are filled | +| **status** | `integer` | optional | HTTP status the refusal is answered with (4xx; a refusal is never a fault). Absent: 400. A status the body itself sets on the throw is not read — the catalogue decides | + --- @@ -126,6 +134,7 @@ L2 sandboxed JS body — runs inside an isolated VM with declared capabilities | **skipAutomations** | `boolean` | optional | True when metadata-bound automation hooks must be suppressed for this write — e.g. a data import the system principal runs with "run automations" unchecked, or the system principal's import undo. Implies skipTriggers; code-registered system hooks (audit, security) still run. Only the system principal may take an app's declared controls off a write, and the import undo door is not an exception: an undo by any other caller runs under the object's hooks, record-change triggers and state machine. | | **positions** | `string[]` | optional | Position names held by the caller (ADR-0090 D3; formerly `roles`), copied from ExecutionContext.positions. For hook READS only — e.g. tailoring a message, or branching a business rule the hook runs through its own `ctx.api` channel. Authorization is decided by the security service on the ExecutionContext (permissions / positions / derived posture); this is NOT an authorization input and a hook must not gate a write by testing it. A hook context carries no `services` key, so the sharing service cannot be called from one either — the sharing gates already ran inside the engine before the hook chain. | | **preserveAudit** | `boolean` | optional | True when this write is a historical import that must KEEP its caller-supplied updated_at/updated_by (and the readonly audit family) instead of being stamped with the import instant. Server-set, opt-in, absent on normal writes; read by the built-in audit hook. A stamping policy, not an authorization input. | +| **locale** | `string` | optional | The caller's locale (BCP-47), copied from ExecutionContext.locale: the request's Accept-Language, else the localization setting; absent on a write no person asked for. The body runner answers a declared refusal (HookSchema.refusals) in it. Descriptive only — not an authorization input. | | **roles** | `never` | optional | [REMOVED] `HookContext.session.roles` was removed in @objectstack/spec 17.0.0 (ADR-0049 D2) — it was declared, read by two dead exemption branches, and never produced: ObjectQL's `buildSession()` builds the session field by field and has never written `roles`, so every read resolved `undefined` and a guard keyed on it was dead code that merely LOOKED like an authorization decision. Delete the key. To gate a hook on the caller, read `ctx.session.userId` / `ctx.session.isSystem`; to judge PRIVILEGE, ask the security service, which evaluates the ADR-0095 vocabulary on the execution context — capability grants (`permissions`), placements (`positions`) and the derived posture — never a role-name string comparison (ADR-0090 D3 bans the `role` spelling outright). Nothing to migrate: a HookContext is built per operation by the engine and never stored, so no metadata source carries this key. NOTE an ACTION body's `ctx.session` is a different object and still carries its own `roles` array today; that surface is tracked separately and is not what this key was. | ### Nested Shape: `HookContext.provenance` @@ -164,3 +173,15 @@ L2 sandboxed JS body — runs inside an isolated VM with declared capabilities --- +## HookRefusal + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **message** | `string` | ✅ | The sentence the user reads when the body throws this code — plain text in the source locale, translated whole through hooks.``.refusals.``.message in the caller's locale; no `{{ }}` holes are filled | +| **status** | `integer` | optional | HTTP status the refusal is answered with (4xx; a refusal is never a fault). Absent: 400. A status the body itself sets on the throw is not read — the catalogue decides | + + +--- + diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index c4be7fb7e5e..a2ac028ecf6 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,7 +1,7 @@ --- title: Protocol reference — every schema by module navTitle: Protocol Reference -description: Every schema published by @objectstack/spec — 1549 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1550 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -23,7 +23,7 @@ counts are sums of the rows they head. Regenerate with | [AI Protocol](/docs/references/ai) | 12 | 68 | Agents, tools, skills, RAG and knowledge sources, model registry, conversations. | | [API Protocol](/docs/references/api) | 32 | 438 | REST contracts, endpoints, routing, realtime, batch, discovery. | | [Automation Protocol](/docs/references/automation) | 14 | 73 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. | -| [Data Protocol](/docs/references/data) | 31 | 184 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. | +| [Data Protocol](/docs/references/data) | 31 | 185 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. | | [Identity Protocol](/docs/references/identity) | 5 | 27 | Users and accounts, organizations, positions, SCIM provisioning. | | [Integration Protocol](/docs/references/integration) | 1 | 12 | The single connector protocol (ADR-0097) — catalog descriptors and provider-bound instances. | | [Kernel Protocol](/docs/references/kernel) | 29 | 151 | Plugin lifecycle and manifests, capabilities and security, metadata loading, service registry. | @@ -34,7 +34,7 @@ counts are sums of the rows they head. Regenerate with | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | | [System Protocol](/docs/references/system) | 37 | 287 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 16 | 171 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **200** | **1549** | 14 protocol modules | +| **Total** | **200** | **1550** | 14 protocol modules | --- @@ -131,7 +131,7 @@ Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execu ## Data Protocol -**Source:** `packages/spec/src/data/` · **Import:** `@objectstack/spec/data` · **31 pages, 184 schemas** +**Source:** `packages/spec/src/data/` · **Import:** `@objectstack/spec/data` · **31 pages, 185 schemas** Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. @@ -158,7 +158,7 @@ Objects, fields, queries, filters, datasources and drivers — the ObjectQL laye | [`field.zod.ts`](/docs/references/data/field) | `CurrencyConfig`, `CurrencyValue`, `Field`, `FieldMaskingKeep`, `FieldMaskingRule`, `FieldType`, `InlineGridColumn`, `LocationCoordinates`, `SelectOption`, `UniqueScope` | | [`field-value.zod.ts`](/docs/references/data/field-value) | `Address`, `AddressValue`, `CalendarDateValue`, `ClockTimeValue`, `FileLikeValue`, `FileReferenceIdValue`, `FileRefusedValue`, `FileValue`, `InstantValue`, `LocationValue`, `ReferenceIdValue` | | [`filter.zod.ts`](/docs/references/data/filter) | `ComparisonOperator`, `EqualityOperator`, `FieldOperators`, `FieldReference`, `FilterArray`, `FilterCondition`, `NormalizedFilter`, `NowAnchor`, `QueryFilter`, `RangeOperator`, `SetOperator`, `SpecialOperator`, `StringOperator` | -| [`hook.zod.ts`](/docs/references/data/hook) | `Hook`, `HookContext`, `HookEvent` | +| [`hook.zod.ts`](/docs/references/data/hook) | `Hook`, `HookContext`, `HookEvent`, `HookRefusal` | | [`hook-body.zod.ts`](/docs/references/data/hook-body) | `ExpressionBody`, `HookBody`, `HookBodyCapability`, `ScriptBody` | | [`mapping.zod.ts`](/docs/references/data/mapping) | `ImportFieldMapping`, `Mapping`, `TransformType` | | [`object.zod.ts`](/docs/references/data/object) | `ApiMethod`, `ApiOperation`, `Index`, `Lifecycle`, `LifecycleClass`, `Object`, `ObjectAccessConfig`, `ObjectCapabilities`, `ObjectExtension`, `ObjectExternalBinding`, `ObjectFieldGroup`, `ObjectOwnershipEnum`, `ObjectRequiredPermissions`, `PerOperationRequiredPermissions`, `RowCrudActionOverride`, `TenancyConfig` | diff --git a/content/docs/references/system/translation.mdx b/content/docs/references/system/translation.mdx index d16708d3bf6..f45c6dba9a6 100644 --- a/content/docs/references/system/translation.mdx +++ b/content/docs/references/system/translation.mdx @@ -180,6 +180,7 @@ Platform translation data — the per-app groups plus the platform-only `setting | **reports** | `Record` | optional | Report translations keyed by report name | | **pages** | `Record` | optional | Page translations keyed by page name | | **flows** | `Record; … }>` | optional | Screen-flow translations keyed by flow name | +| **hooks** | `Record }>` | optional | Hook translations keyed by hook name | | **positions** | `Record` | optional | Position translations keyed by position name | | **permissions** | `Record` | optional | Permission-set translations keyed by permission-set name | | **metadataForms** | `Record; fields?: Record }>` | optional | Translations for metadata-type configuration forms keyed by metadata type | @@ -276,6 +277,12 @@ Translation data for a single object | **refusals** | `Record` | optional | Refusal translations keyed by the node id (`FlowNode.id`) of an `end` node declaring `outcome: 'refused'` | | **approvals** | `Record` | optional | Approval-step translations keyed by the node id (`FlowNode.id`) of an `approval` node | +### Nested Shape: `PlatformTranslationData.hooks[string]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **refusals** | `Record` | optional | Refusal translations keyed by the code the hook's `refusals` declares | + ### Nested Shape: `PlatformTranslationData.positions[string]` | Property | Type | Required | Description | @@ -337,6 +344,7 @@ Served translation data — the platform groups the browser reads, without the s | **reports** | `Record` | optional | Report translations keyed by report name | | **pages** | `Record` | optional | Page translations keyed by page name | | **flows** | `Record; … }>` | optional | Screen-flow translations keyed by flow name | +| **hooks** | `Record }>` | optional | Hook translations keyed by hook name | | **positions** | `Record` | optional | Position translations keyed by position name | | **permissions** | `Record` | optional | Permission-set translations keyed by permission-set name | | **settingsCommon** | `{ sourceLabels?: object }` | optional | Cross-namespace Settings UI strings | @@ -432,6 +440,12 @@ Translation data for a single object | **refusals** | `Record` | optional | Refusal translations keyed by the node id (`FlowNode.id`) of an `end` node declaring `outcome: 'refused'` | | **approvals** | `Record` | optional | Approval-step translations keyed by the node id (`FlowNode.id`) of an `approval` node | +### Nested Shape: `ServedTranslationData.hooks[string]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **refusals** | `Record` | optional | Refusal translations keyed by the code the hook's `refusals` declares | + ### Nested Shape: `ServedTranslationData.positions[string]` | Property | Type | Required | Description | @@ -555,6 +569,7 @@ Per-app translation data for objects, apps, and UI messages | **reports** | `Record` | optional | Report translations keyed by report name | | **pages** | `Record` | optional | Page translations keyed by page name | | **flows** | `Record; … }>` | optional | Screen-flow translations keyed by flow name | +| **hooks** | `Record }>` | optional | Hook translations keyed by hook name | | **positions** | `Record` | optional | Position translations keyed by position name | | **permissions** | `Record` | optional | Permission-set translations keyed by permission-set name | | **metadataForms** | `Record; fields?: Record }>` | optional | Translations for metadata-type configuration forms keyed by metadata type | @@ -650,6 +665,12 @@ Translation data for a single object | **refusals** | `Record` | optional | Refusal translations keyed by the node id (`FlowNode.id`) of an `end` node declaring `outcome: 'refused'` | | **approvals** | `Record` | optional | Approval-step translations keyed by the node id (`FlowNode.id`) of an `approval` node | +### Nested Shape: `TranslationData.hooks[string]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **refusals** | `Record` | optional | Refusal translations keyed by the code the hook's `refusals` declares | + ### Nested Shape: `TranslationData.positions[string]` | Property | Type | Required | Description | @@ -732,6 +753,7 @@ One locale of translations — the `translation` metadata type | **reports** | `Record` | optional | Report translations keyed by report name | | **pages** | `Record` | optional | Page translations keyed by page name | | **flows** | `Record; … }>` | optional | Screen-flow translations keyed by flow name | +| **hooks** | `Record }>` | optional | Hook translations keyed by hook name | | **positions** | `Record` | optional | Position translations keyed by position name | | **permissions** | `Record` | optional | Permission-set translations keyed by permission-set name | | **metadataForms** | `Record; fields?: Record }>` | optional | Translations for metadata-type configuration forms keyed by metadata type | @@ -837,6 +859,12 @@ Translation data for a single object | **refusals** | `Record` | optional | Refusal translations keyed by the node id (`FlowNode.id`) of an `end` node declaring `outcome: 'refused'` | | **approvals** | `Record` | optional | Approval-step translations keyed by the node id (`FlowNode.id`) of an `approval` node | +### Nested Shape: `TranslationItem.hooks[string]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **refusals** | `Record` | optional | Refusal translations keyed by the code the hook's `refusals` declares | + ### Nested Shape: `TranslationItem.positions[string]` | Property | Type | Required | Description | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/data.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/data.md index 540c8af8c70..6c4f9fcb8c2 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/data.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/data.md @@ -21,7 +21,7 @@ The `strict` column is the one the campaign schedules against; it counts both th | Dir | Sites | strict | passthrough | catchall | strip | |---|---|---|---|---|---| -| `data/` | 164 | 87 | 2 | 0 | 75 | +| `data/` | 165 | 88 | 2 | 0 | 75 | ## `data/` — sites @@ -48,7 +48,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `field.zod.ts` | 14 | | `filter.zod.ts` | 13 | | `hook-body.zod.ts` | 2 | -| `hook.zod.ts` | 7 | +| `hook.zod.ts` | 8 | | `mapping.zod.ts` | 5 | | `object.zod.ts` | 23 | | `picklist.zod.ts` | 3 | @@ -57,7 +57,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit | `seed-loader.zod.ts` | 12 | | `seed.zod.ts` | 2 | | `validation.zod.ts` | 6 | -| **total** | **164** | +| **total** | **165** | ## `data/` — open @@ -65,7 +65,7 @@ Per file, how many of its sites still silently discard unknown keys. The `Class` column that decides the bucket split is hand-written in the ledger; the arithmetic over it is here. -**75 strip of 164**, in 10 file(s). +**75 strip of 165**, in 10 file(s). | File | Strip | Sites | |---|---|---| @@ -76,10 +76,10 @@ over it is here. | `external-catalog.zod.ts` | 4 | 4 | | `field.zod.ts` | 2 | 14 | | `filter.zod.ts` | 11 | 13 | -| `hook.zod.ts` | 5 | 7 | +| `hook.zod.ts` | 5 | 8 | | `query.zod.ts` | 4 | 5 | | `seed-loader.zod.ts` | 12 | 12 | -| **total** | **75** | **164** | +| **total** | **75** | **165** | | Bucket | Sites | |---|---| diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md index 9c535ae34bf..4663ecd292e 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md @@ -19,4 +19,4 @@ hand-patch a number here** — fix the code or the verdict and regenerate. | Dir | Sites | |---|---| -| `system/` | 383 | +| `system/` | 385 | diff --git a/packages/spec/api-surface/data.json b/packages/spec/api-surface/data.json index 39875fe9578..27c15adc5c5 100644 --- a/packages/spec/api-surface/data.json +++ b/packages/spec/api-surface/data.json @@ -374,6 +374,7 @@ "FullTextSearchSchema (const)", "GroupByNode (type)", "GroupByNodeSchema (const)", + "HOOK_REFUSAL_CODE_PATTERN (const)", "Hook (type)", "HookApi (interface)", "HookBody (type)", @@ -393,6 +394,8 @@ "HookObjectApi (interface)", "HookParsed (type)", "HookQuery (type)", + "HookRefusal (type)", + "HookRefusalSchema (const)", "HookSchema (const)", "HookUpdateDoc (type)", "HookUpdateOptions (type)", @@ -902,6 +905,7 @@ "hasDynamicTokens (function)", "hasNulInLikePattern (function)", "hookForm (const)", + "hookRefusalCodesThrownIn (function)", "importMappingEntryTargets (function)", "indexImportMappingTargets (function)", "injectedSystemColumnDefs (function)", @@ -1014,6 +1018,8 @@ "suggestFieldTypeForSqlType (function)", "textComparandRefusalReason (function)", "textOperatorDoorVerdict (function)", + "undeclaredHookRefusalAtRun (function)", + "undeclaredHookRefusalCodeAtSave (function)", "unknownImportMappingTargets (function)", "unprovisionedInjectedColumns (function)", "urlCredentialQueryParams (function)", diff --git a/packages/spec/api-surface/system.json b/packages/spec/api-surface/system.json index 9297c68fbf7..df3afcc1450 100644 --- a/packages/spec/api-surface/system.json +++ b/packages/spec/api-surface/system.json @@ -833,6 +833,7 @@ "hasMovedFileColumns (function)", "hasObservedDeviation (function)", "hasPlatformObjectPrefix (function)", + "hookRefusalMessageKey (function)", "inProcessServiceMessage (function)", "interpolateValidationMessage (function)", "isAudiencePosture (function)", diff --git a/packages/spec/authorable-surface/data.json b/packages/spec/authorable-surface/data.json index dc6c87af478..c6278e47f81 100644 --- a/packages/spec/authorable-surface/data.json +++ b/packages/spec/authorable-surface/data.json @@ -476,6 +476,7 @@ "data/Hook:object", "data/Hook:onError", "data/Hook:priority", + "data/Hook:refusals", "data/Hook:retryPolicy", "data/Hook:runAs", "data/Hook:timeout [RETIRED]", @@ -496,6 +497,8 @@ "data/HookContext:submitted", "data/HookContext:transaction", "data/HookContext:user", + "data/HookRefusal:message", + "data/HookRefusal:status", "data/ImportFieldMapping:params", "data/ImportFieldMapping:source", "data/ImportFieldMapping:target", diff --git a/packages/spec/authorable-surface/system.json b/packages/spec/authorable-surface/system.json index 5230dc0fbb4..d11bc4ea1c0 100644 --- a/packages/spec/authorable-surface/system.json +++ b/packages/spec/authorable-surface/system.json @@ -905,6 +905,7 @@ "system/PlatformTranslationData:datasets", "system/PlatformTranslationData:flows", "system/PlatformTranslationData:globalActions", + "system/PlatformTranslationData:hooks", "system/PlatformTranslationData:messages", "system/PlatformTranslationData:metadataForms", "system/PlatformTranslationData:objects", @@ -1033,6 +1034,7 @@ "system/ServedTranslationData:datasets", "system/ServedTranslationData:flows", "system/ServedTranslationData:globalActions", + "system/ServedTranslationData:hooks", "system/ServedTranslationData:messages", "system/ServedTranslationData:objects", "system/ServedTranslationData:pages", @@ -1335,6 +1337,7 @@ "system/TranslationData:datasets", "system/TranslationData:flows", "system/TranslationData:globalActions", + "system/TranslationData:hooks", "system/TranslationData:messages", "system/TranslationData:metadataForms", "system/TranslationData:objects", @@ -1363,6 +1366,7 @@ "system/TranslationItem:datasets", "system/TranslationItem:flows", "system/TranslationItem:globalActions", + "system/TranslationItem:hooks", "system/TranslationItem:label", "system/TranslationItem:locale", "system/TranslationItem:messages", diff --git a/packages/spec/declaration-map/data.json b/packages/spec/declaration-map/data.json index 5627eecf373..305b8efa5c2 100644 --- a/packages/spec/declaration-map/data.json +++ b/packages/spec/declaration-map/data.json @@ -173,6 +173,8 @@ "HookContext": "data/HookContext", "HookContextSchema": "data/HookContext", "HookEvent": "data/HookEvent", + "HookRefusal": "data/HookRefusal", + "HookRefusalSchema": "data/HookRefusal", "HookSchema": "data/Hook", "ImportFieldMapping": "data/ImportFieldMapping", "ImportFieldMappingSchema": "data/ImportFieldMapping", diff --git a/packages/spec/export-origins/data.json b/packages/spec/export-origins/data.json index cae90bf011d..5f3e212ee3a 100644 --- a/packages/spec/export-origins/data.json +++ b/packages/spec/export-origins/data.json @@ -365,6 +365,7 @@ "FullTextSearchSchema": "src/data/query.zod.ts#FullTextSearchSchema (const)", "GroupByNode": "src/data/query.zod.ts#GroupByNode (type)", "GroupByNodeSchema": "src/data/query.zod.ts#GroupByNodeSchema (const)", + "HOOK_REFUSAL_CODE_PATTERN": "src/data/hook-refusal-codes.ts#HOOK_REFUSAL_CODE_PATTERN (const)", "Hook": "src/data/hook.zod.ts#Hook (type)", "HookApi": "src/data/hook-api.ts#HookApi (interface)", "HookBody": "src/data/hook-body.zod.ts#HookBody (type)", @@ -383,6 +384,8 @@ "HookObjectApi": "src/data/hook-api.ts#HookObjectApi (interface)", "HookParsed": "src/data/hook.zod.ts#HookParsed (type)", "HookQuery": "src/data/hook-api.ts#HookQuery (type)", + "HookRefusal": "src/data/hook.zod.ts#HookRefusal (type)", + "HookRefusalSchema": "src/data/hook.zod.ts#HookRefusalSchema (const)", "HookSchema": "src/data/hook.zod.ts#HookSchema (const)", "HookUpdateDoc": "src/data/hook-api.ts#HookUpdateDoc (type)", "HookUpdateOptions": "src/data/hook-api.ts#HookUpdateOptions (type)", @@ -888,6 +891,7 @@ "hasDynamicTokens": "src/data/autonumber-format.ts#hasDynamicTokens (function)", "hasNulInLikePattern": "src/data/filter.zod.ts#hasNulInLikePattern (function)", "hookForm": "src/data/hook.form.ts#hookForm (const)", + "hookRefusalCodesThrownIn": "src/data/hook-refusal-codes.ts#hookRefusalCodesThrownIn (function)", "importMappingEntryTargets": "src/data/import-mapping-target.ts#importMappingEntryTargets (function)", "indexImportMappingTargets": "src/data/import-mapping-target.ts#indexImportMappingTargets (function)", "injectedSystemColumnDefs": "src/data/injected-system-column-provenance.ts#injectedSystemColumnDefs (function)", @@ -1000,6 +1004,8 @@ "suggestFieldTypeForSqlType": "src/data/type-compat.ts#suggestFieldTypeForSqlType (function)", "textComparandRefusalReason": "src/data/filter-text-comparand.ts#textComparandRefusalReason (function)", "textOperatorDoorVerdict": "src/data/filter-text-operator-declared-type.ts#textOperatorDoorVerdict (function)", + "undeclaredHookRefusalAtRun": "src/data/hook-refusal-codes.ts#undeclaredHookRefusalAtRun (function)", + "undeclaredHookRefusalCodeAtSave": "src/data/hook-refusal-codes.ts#undeclaredHookRefusalCodeAtSave (function)", "unknownImportMappingTargets": "src/data/import-mapping-target.ts#unknownImportMappingTargets (function)", "unprovisionedInjectedColumns": "src/data/injected-system-column-provenance.ts#unprovisionedInjectedColumns (function)", "urlCredentialQueryParams": "src/data/driver/common.zod.ts#urlCredentialQueryParams (function)", diff --git a/packages/spec/export-origins/system.json b/packages/spec/export-origins/system.json index 935a29155b9..205b3a05823 100644 --- a/packages/spec/export-origins/system.json +++ b/packages/spec/export-origins/system.json @@ -794,6 +794,7 @@ "hasMovedFileColumns": "src/system/migration.zod.ts#hasMovedFileColumns (function)", "hasObservedDeviation": "src/system/migration.zod.ts#hasObservedDeviation (function)", "hasPlatformObjectPrefix": "src/system/constants/platform-object-names.ts#hasPlatformObjectPrefix (function)", + "hookRefusalMessageKey": "src/system/i18n-resolver.ts#hookRefusalMessageKey (function)", "inProcessServiceMessage": "src/system/core-services.zod.ts#inProcessServiceMessage (function)", "interpolateValidationMessage": "src/system/validation-message.ts#interpolateValidationMessage (function)", "isAudiencePosture": "src/system/auth-config.zod.ts#isAudiencePosture (function)", diff --git a/packages/spec/json-schema.manifest/data.json b/packages/spec/json-schema.manifest/data.json index 6702300453c..0153cab3ee6 100644 --- a/packages/spec/json-schema.manifest/data.json +++ b/packages/spec/json-schema.manifest/data.json @@ -97,6 +97,7 @@ "data/HookBodyCapability", "data/HookContext", "data/HookEvent", + "data/HookRefusal", "data/ImportFieldMapping", "data/Index", "data/InlineGridColumn", diff --git a/packages/spec/liveness/state-counts/hook.md b/packages/spec/liveness/state-counts/hook.md index 87162fb9537..63586507ed5 100644 --- a/packages/spec/liveness/state-counts/hook.md +++ b/packages/spec/liveness/state-counts/hook.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `hook` | 21 | 0 | 0 | 1 | 0 | 22 | +| `hook` | 23 | 0 | 0 | 1 | 0 | 24 | diff --git a/packages/spec/liveness/state-counts/translation.md b/packages/spec/liveness/state-counts/translation.md index bba0cf8b57d..576beb01e2d 100644 --- a/packages/spec/liveness/state-counts/translation.md +++ b/packages/spec/liveness/state-counts/translation.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `translation` | 36 | 0 | 0 | 0 | 0 | 36 | +| `translation` | 37 | 0 | 0 | 0 | 0 | 37 | diff --git a/packages/spec/src/data/hook.zod.ts b/packages/spec/src/data/hook.zod.ts index df93017b89c..25e2eaa0bee 100644 --- a/packages/spec/src/data/hook.zod.ts +++ b/packages/spec/src/data/hook.zod.ts @@ -245,6 +245,7 @@ function refuseUndeclaredLiteralRefusalCode( export const HookRefusalSchema = lazySchema(() => strictObject( { surface: 'this hook refusal', + history: 'Closed from the day it was declared — a key this entry does not declare would be served nowhere.', aliases: { text: 'message', reason: 'message', description: 'message', httpStatus: 'status', statusCode: 'status' }, guidance: { code: diff --git a/skills/objectstack-data/references/_index.md b/skills/objectstack-data/references/_index.md index 045ab01efe2..f411956caa4 100644 --- a/skills/objectstack-data/references/_index.md +++ b/skills/objectstack-data/references/_index.md @@ -11,7 +11,7 @@ from `node_modules` — there is no local copy in the skill bundle. - `node_modules/@objectstack/spec/src/data/datasource.zod.ts` — Exports: DriverType, DriverDefinitionSchema, SchemaModeSchema, ExternalDatasourceSettingsSchema, DatasourceSchema - `node_modules/@objectstack/spec/src/data/field.zod.ts` — Exports: FieldType, SelectOptionSchema, LocationCoordinatesSchema, CurrencyConfigSchema, CurrencyValueSchema -- `node_modules/@objectstack/spec/src/data/hook.zod.ts` — Exports: HookEvent, HookSchema, HookContextSchema +- `node_modules/@objectstack/spec/src/data/hook.zod.ts` — Exports: HookRefusalSchema, HookEvent, HookSchema, HookContextSchema - `node_modules/@objectstack/spec/src/data/object.zod.ts` — Exports: ApiMethod, ApiOperationSchema, ObjectCapabilities, IndexSchema, TenancyConfigSchema - `node_modules/@objectstack/spec/src/data/seed.zod.ts` — Exports: SeedMode, SeedColumnSchema, SeedSchema, leadSeed - `node_modules/@objectstack/spec/src/data/validation.zod.ts` — ObjectStack Validation Protocol diff --git a/skills/objectstack-platform/references/_index.md b/skills/objectstack-platform/references/_index.md index fd8975e38c7..6d2ef8a89f6 100644 --- a/skills/objectstack-platform/references/_index.md +++ b/skills/objectstack-platform/references/_index.md @@ -23,7 +23,7 @@ from `node_modules` — there is no local copy in the skill bundle. ## Transitive dependencies - `node_modules/@objectstack/spec/src/data/field.zod.ts` — Exports: FieldType, SelectOptionSchema, LocationCoordinatesSchema, CurrencyConfigSchema, CurrencyValueSchema -- `node_modules/@objectstack/spec/src/data/hook.zod.ts` — Exports: HookEvent, HookSchema, HookContextSchema +- `node_modules/@objectstack/spec/src/data/hook.zod.ts` — Exports: HookRefusalSchema, HookEvent, HookSchema, HookContextSchema - `node_modules/@objectstack/spec/src/data/object.zod.ts` — Exports: ApiMethod, ApiOperationSchema, ObjectCapabilities, IndexSchema, TenancyConfigSchema - `node_modules/@objectstack/spec/src/security/rls.zod.ts` — Row-Level Security (RLS) Protocol - `node_modules/@objectstack/spec/src/ui/app.zod.ts` — Exports: ObjectNavItemSchema, DashboardNavItemSchema, PageNavItemSchema, UrlNavItemSchema, ReportNavItemSchema From 08af21e22b820f9701eefa0a5fa41cfff18222c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 13:45:34 +0000 Subject: [PATCH 03/18] test(i18n): hook refusal catalogue pins; skill docs and changeset The save-time reading now masks comments and string contents first, so a throw or code written inside either is never read as one. Claude-Session: https://claude.ai/code/session_01WGufzfyVQuRsL6SRGuKCem Co-authored-by: Claude --- .changeset/26170-hook-refusal-catalogue.md | 22 ++ .../test/i18n-hook-refusal-coverage.test.ts | 119 +++++++++ .../validate-translation-references.test.ts | 57 +++++ ...-execution-plumbing.session-locale.test.ts | 27 +++ .../sandbox/hook-refusal-catalogue.test.ts | 229 ++++++++++++++++++ packages/spec/src/data/hook-refusal-codes.ts | 75 ++++-- packages/spec/src/data/hook-refusals.test.ts | 197 +++++++++++++++ .../objectstack-data/references/data-hooks.md | 47 ++++ 8 files changed, 747 insertions(+), 26 deletions(-) create mode 100644 .changeset/26170-hook-refusal-catalogue.md create mode 100644 packages/cli/test/i18n-hook-refusal-coverage.test.ts create mode 100644 packages/objectql/src/engine-execution-plumbing.session-locale.test.ts create mode 100644 packages/runtime/src/sandbox/hook-refusal-catalogue.test.ts create mode 100644 packages/spec/src/data/hook-refusals.test.ts diff --git a/.changeset/26170-hook-refusal-catalogue.md b/.changeset/26170-hook-refusal-catalogue.md new file mode 100644 index 00000000000..6ded35f6773 --- /dev/null +++ b/.changeset/26170-hook-refusal-catalogue.md @@ -0,0 +1,22 @@ +--- +'@objectstack/spec': minor +'@objectstack/runtime': minor +'@objectstack/objectql': minor +'@objectstack/cli': minor +'@objectstack/lint': minor +--- + +feat(spec): a hook declares its refusal catalogue, and its body refuses by code, answered in the caller's language + +Clause-②: yes (widening) +Metadata-protocol: added + +A hook can now declare `refusals`: one entry per SCREAMING_SNAKE code, each with the `message` the user reads and an optional 4xx `status` (default `400`). A sandboxed `body` then refuses by code only: it throws an error carrying a declared `code`, and the body runner answers with the entry. The message is translated through `hooks..refusals..message` in the caller's locale, the code reaches the caller's error envelope, and the status is the entry's. The body's own error text and `status` are not what the caller reads. + +- **Spec.** `HookSchema.refusals` (`HookRefusalSchema`: `message`, `status?`). A new `hooks` group on the translation bundle, `hooks..refusals..message`, and `hookRefusalMessageKey`. `HookContext.session.locale` declares the caller's locale. +- **Undeclared codes are refused loudly.** A code the body throws literally (`{ code: 'X' }`, or `e.code = 'X'; throw e`) that the catalogue does not declare fails the save at `body.source`. Any other undeclared code, and a throw with no code, is a `500` fault at run time that names the hook and the code. This applies only to a hook that declares `refusals`; a hook without one is answered exactly as before. +- **Runtime.** The body runner resolves the throw against the catalogue and picks the translation through the `i18n` service, negotiated against the locales it holds. An error a `ctx.api` call raised that the body lets leave unchanged, a crash, and the sandbox's own faults pass through untouched. ObjectQL copies the request's locale onto the hook session. +- **CLI.** `os i18n extract` scaffolds one key per declared entry and `os i18n check` counts them, in a new `hook` coverage bucket. +- **Lint.** A translation keyed to a hook the stack does not define, or to a code its `refusals` does not declare, is reported as `translation-target-unknown`. + +Nothing to do for existing hooks. To use it, declare `refusals` on a sandboxed hook and throw its codes. diff --git a/packages/cli/test/i18n-hook-refusal-coverage.test.ts b/packages/cli/test/i18n-hook-refusal-coverage.test.ts new file mode 100644 index 00000000000..1441cd43c5a --- /dev/null +++ b/packages/cli/test/i18n-hook-refusal-coverage.test.ts @@ -0,0 +1,119 @@ +// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license. +// +// objectstack#26170 — a hook's declared refusal catalogue joins the i18n +// buckets as `hooks..refusals..message` (maintainer ruling +// 6431311681, letter A). +// +// A hook declaring `refusals: { : { message, status? } }` refuses by code +// only, and the sandboxed body runner reads the key in the caller's locale +// before it answers. These pins are the CLI half: the walker scaffolds one key +// per declared entry, the coverage gate demands it, and the key the walker +// writes is the very address the runtime asks for (`hookRefusalMessageKey`), +// so the two cannot drift. +// +// The ledger mock is `i18n-flow-refusal-coverage.test.ts`'s, for its reason: +// these pins measure the bucket itself, whatever the shipped ledger says next. + +import { describe, it, expect, vi } from 'vitest'; + +vi.mock('@objectstack/lint', async (importOriginal) => ({ + ...(await importOriginal()), + authorWarnedProperties: () => new Set(), +})); + +import { collectExpectedEntries, extractTranslations } from '../src/utils/i18n-extract.js'; +import { computeI18nCoverage } from '../src/utils/i18n-coverage.js'; +import { TranslationDataSchema, hookRefusalMessageKey } from '@objectstack/spec/system'; + +const LIMIT = 'This order exceeds the customer credit limit.'; +const HOLD = 'The account is on hold.'; + +const creditGuard = { + name: 'credit_guard', + object: 'order', + events: ['beforeInsert'], + body: { language: 'js', source: "throw Object.assign(new Error('x'), { code: 'CREDIT_LIMIT' });" }, + refusals: { + CREDIT_LIMIT: { message: LIMIT }, + ACCOUNT_ON_HOLD: { message: HOLD, status: 409 }, + }, +}; + +/** A hook with no catalogue contributes nothing. */ +const plainHook = { name: 'stamp', object: 'order', events: ['beforeInsert'], handler: 'stamp' }; + +const hookEntries = (config: any) => collectExpectedEntries(config).filter((e) => e.path[0] === 'hooks'); + +describe('what the walker harvests from a hook catalogue (#26170)', () => { + it('emits one key per declared entry, keyed as the runtime reads it, seeded with the message', () => { + const entries = hookEntries({ hooks: [creditGuard, plainHook] }); + + expect(entries.map((e) => e.path.join('.'))).toEqual([ + hookRefusalMessageKey('credit_guard', 'CREDIT_LIMIT'), + hookRefusalMessageKey('credit_guard', 'ACCOUNT_ON_HOLD'), + ]); + expect(entries[0]).toMatchObject({ sourceValue: LIMIT, inline: LIMIT, source: 'hook' }); + }); + + it('harvests nothing from a stack whose hooks declare no catalogue', () => { + expect(hookEntries({ hooks: [plainHook] })).toEqual([]); + expect(hookEntries({})).toEqual([]); + }); +}); + +describe('the coverage gate demands each declared refusal (#26170)', () => { + const tree = (hooks?: Record) => ({ + i18n: { defaultLocale: 'en', supportedLocales: ['en', 'zh-CN'] }, + hooks: [creditGuard], + translations: [{ 'zh-CN': { ...(hooks ? { hooks } : {}) } }], + }); + const zhHooks = (config: any) => + computeI18nCoverage(config).issues.filter((i) => i.locale === 'zh-CN' && i.key.startsWith('hooks.')); + + it('reports each untranslated refusal in the hook bucket', () => { + const issues = zhHooks(tree()); + expect(issues.map((i) => i.key).sort()).toEqual([ + 'hooks.credit_guard.refusals.ACCOUNT_ON_HOLD.message', + 'hooks.credit_guard.refusals.CREDIT_LIMIT.message', + ]); + expect(issues.every((i) => i.source === 'hook')).toBe(true); + }); + + it('counts what is still missing once one is translated', () => { + const issues = zhHooks(tree({ credit_guard: { refusals: { CREDIT_LIMIT: { message: '订单超出客户信用额度。' } } } })); + expect(issues.map((i) => i.key)).toEqual(['hooks.credit_guard.refusals.ACCOUNT_ON_HOLD.message']); + }); + + it('goes quiet once every entry is translated', () => { + const issues = zhHooks(tree({ + credit_guard: { + refusals: { + CREDIT_LIMIT: { message: '订单超出客户信用额度。' }, + ACCOUNT_ON_HOLD: { message: '该客户账户已冻结。' }, + }, + }, + })); + expect(issues).toEqual([]); + }); +}); + +describe('the skeleton `os i18n extract` writes (#26170)', () => { + const result = extractTranslations({ hooks: [creditGuard] }, { locales: ['en', 'zh-CN'] }); + + it('writes the message into the default locale and an empty slot into the others', () => { + expect((result.bundles.en as any).hooks.credit_guard.refusals).toEqual({ + CREDIT_LIMIT: { message: LIMIT }, + ACCOUNT_ON_HOLD: { message: HOLD }, + }); + expect((result.bundles['zh-CN'] as any).hooks.credit_guard.refusals).toEqual({ + CREDIT_LIMIT: { message: '' }, + ACCOUNT_ON_HOLD: { message: '' }, + }); + }); + + it('parses through the schema it is written for, in every locale', () => { + for (const [locale, bundle] of Object.entries(result.bundles)) { + expect(TranslationDataSchema.safeParse(bundle).success, `skeleton for ${locale}`).toBe(true); + } + }); +}); diff --git a/packages/lint/src/validate-translation-references.test.ts b/packages/lint/src/validate-translation-references.test.ts index 686ce4300f2..2ebb504664c 100644 --- a/packages/lint/src/validate-translation-references.test.ts +++ b/packages/lint/src/validate-translation-references.test.ts @@ -4512,3 +4512,60 @@ describe('validateTranslationReferences — reports', () => { expect(findings).toEqual([]); }); }); + +describe('validateTranslationReferences — hook refusals (#26170)', () => { + const creditStack = { + hooks: [ + { + name: 'credit_guard', + object: 'order', + events: ['beforeInsert'], + body: { language: 'js', source: "throw Object.assign(new Error('x'), { code: 'CREDIT_LIMIT' });" }, + refusals: { CREDIT_LIMIT: { message: 'Over the credit limit.' } }, + }, + { name: 'stamp', object: 'order', events: ['beforeInsert'], handler: 'stamp' }, + ], + }; + const bundle = (hooks: unknown) => ({ ...creditStack, translations: [{ 'zh-CN': { hooks } }] }); + + it('reports nothing for a translation of a declared code', () => { + expect( + validateTranslationReferences(bundle({ credit_guard: { refusals: { CREDIT_LIMIT: { message: '超出信用额度。' } } } })), + ).toEqual([]); + }); + + it('refuses a code the hook does not declare, with the nearest declared code offered', () => { + const findings = validateTranslationReferences(bundle({ credit_guard: { refusals: { CREDIT_LIMT: { message: 'x' } } } })); + expect(findings).toHaveLength(1); + expect(findings[0].rule).toBe(TRANSLATION_TARGET_UNKNOWN); + expect(findings[0].severity).toBe('error'); + expect(findings[0].path).toBe('translations[0]["zh-CN"].hooks.credit_guard.refusals.CREDIT_LIMT'); + expect(findings[0].message).toContain('a code hook "credit_guard" does not declare under `refusals`'); + expect(findings[0].message).toContain('Did you mean "CREDIT_LIMIT"?'); + }); + + it('refuses a refusal key on a hook that declares no catalogue', () => { + const findings = validateTranslationReferences(bundle({ stamp: { refusals: { ANY: { message: 'x' } } } })); + expect(findings).toHaveLength(1); + expect(findings[0].path).toBe('translations[0]["zh-CN"].hooks.stamp.refusals.ANY'); + expect(findings[0].message).toContain('hook "stamp" declares no `refusals`'); + }); + + it('refuses a hook the stack does not define, at the hook level', () => { + const findings = validateTranslationReferences(bundle({ credit_gaurd: { refusals: { CREDIT_LIMIT: { message: 'x' } } } })); + expect(findings).toHaveLength(1); + expect(findings[0].path).toBe('translations[0]["zh-CN"].hooks.credit_gaurd'); + expect(findings[0].message).toContain('hook "credit_gaurd", which this stack does not define'); + expect(findings[0].message).toContain('Did you mean "credit_guard"?'); + }); + + it('reads a sibling package\'s hooks through the artifact carrier', () => { + const { hooks: _own, ...rest } = creditStack; + const findings = validateTranslationReferences({ + ...rest, + packages: [{ manifest: { hooks: creditStack.hooks } }], + translations: [{ 'zh-CN': { hooks: { credit_guard: { refusals: { CREDIT_LIMIT: { message: '超出信用额度。' } } } } } }], + }); + expect(findings).toEqual([]); + }); +}); diff --git a/packages/objectql/src/engine-execution-plumbing.session-locale.test.ts b/packages/objectql/src/engine-execution-plumbing.session-locale.test.ts new file mode 100644 index 00000000000..c12fe4a2c9d --- /dev/null +++ b/packages/objectql/src/engine-execution-plumbing.session-locale.test.ts @@ -0,0 +1,27 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#26170] `HookContext.session.locale` — the caller's locale rides on the + * session a hook receives, so the sandboxed body runner can answer a declared + * hook refusal (`hooks..refusals..message`) in the caller's + * language. + */ + +import { describe, it, expect } from 'vitest'; +import { buildSession } from './engine-execution-plumbing.js'; + +describe('[#26170] buildSession carries the caller\'s locale', () => { + it('copies `execCtx.locale` onto a session that exists', () => { + expect(buildSession({ userId: 'u1', locale: 'zh-CN' } as any)).toMatchObject({ userId: 'u1', locale: 'zh-CN' }); + }); + + it('a locale alone does not make a session — it says how to speak, not who is speaking', () => { + expect(buildSession({ locale: 'zh-CN' } as any)).toBeUndefined(); + }); + + it('writes no `locale` key when none was asked', () => { + for (const locale of [undefined, '']) { + expect(buildSession({ userId: 'u1', locale } as any)).not.toHaveProperty('locale'); + } + }); +}); diff --git a/packages/runtime/src/sandbox/hook-refusal-catalogue.test.ts b/packages/runtime/src/sandbox/hook-refusal-catalogue.test.ts new file mode 100644 index 00000000000..a5dc1d264ea --- /dev/null +++ b/packages/runtime/src/sandbox/hook-refusal-catalogue.test.ts @@ -0,0 +1,229 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#26170] A hook's declared refusal catalogue, answered by the sandboxed body + * runner (maintainer ruling 6431311681, letter A). + * + * Driven through the real factory and the real QuickJS runner, and read off + * `mapDataError` — the ADR-0112 table every HTTP door answers with — so what is + * asserted is the status, code and sentence the caller is answered with. + */ + +import { describe, it, expect } from 'vitest'; +import { mapDataError } from '@objectstack/types'; + +import { hookBodyRunnerFactory } from './body-runner.js'; +import { QuickJSScriptRunner, SandboxError } from './quickjs-runner.js'; +import { + DEFAULT_HOOK_REFUSAL_STATUS, + declaredRefusalMessage, + hookRefusalI18nReader, + resolveHookRefusal, + type HookRefusalI18nService, +} from './hook-refusal-catalogue.js'; + +const runner = new QuickJSScriptRunner({ hookTimeoutMs: 10_000 }); + +const REFUSALS = { + CREDIT_LIMIT: { message: 'This order exceeds the customer credit limit.' }, + ACCOUNT_ON_HOLD: { message: 'The account is on hold.', status: 409 }, +}; + +const ZH = '订单超出客户信用额度。'; + +/** An i18n service holding one zh-CN translation; echoes the key on a miss, as `II18nService` does. */ +function i18nService(overrides: Partial = {}): HookRefusalI18nService { + const bundle: Record> = { + 'zh-CN': { 'hooks.credit_guard.refusals.CREDIT_LIMIT.message': ZH }, + }; + return { + t: (key, locale) => bundle[locale]?.[key] ?? key, + getLocales: () => ['en', 'zh-CN'], + ...overrides, + }; +} + +/** A `beforeInsert` hook with the catalogue, bound through the real factory. */ +function boundHook(source: string, opts: { refusals?: object; i18n?: () => HookRefusalI18nService | undefined } = {}) { + const fn = hookBodyRunnerFactory(runner, { ql: {}, appId: 'crm', i18n: opts.i18n })({ + name: 'credit_guard', + object: 'order', + events: ['beforeInsert'], + body: { language: 'js', source, capabilities: ['api.write'] }, + refusals: 'refusals' in opts ? opts.refusals : REFUSALS, + } as any); + expect(fn, 'the hook body must bind').toBeTypeOf('function'); + return fn!; +} + +async function rejectionOf(p: Promise): Promise { + const err = await p.then(() => null, (e) => e); + expect(err, 'expected the body to reject').not.toBeNull(); + return err; +} + +const throwing = (code: string) => `throw Object.assign(new Error('internal detail'), { code: '${code}' });`; +const ctx = (locale?: string) => ({ object: 'order', event: 'beforeInsert', input: {}, session: locale ? { userId: 'u1', locale } : { userId: 'u1' } }); + +describe('[#26170] a declared code is answered with the catalogue entry', () => { + it('the declared message, the declared code, and 400 when the entry names no status', async () => { + const err = await rejectionOf(boundHook(throwing('CREDIT_LIMIT'))(ctx())); + expect(err).toBeInstanceOf(SandboxError); + const wire = mapDataError(err, 'order'); + expect(wire.status).toBe(DEFAULT_HOOK_REFUSAL_STATUS); + expect(wire.body.code).toBe('CREDIT_LIMIT'); + expect(wire.body.userMessage).toBe(REFUSALS.CREDIT_LIMIT.message); + // The body's own sentence is not what the caller reads. + expect(JSON.stringify(wire.body)).not.toContain('internal detail'); + }); + + it('the entry\'s status, and the body\'s own status is not read', async () => { + const err = await rejectionOf(boundHook( + "throw Object.assign(new Error('x'), { code: 'ACCOUNT_ON_HOLD', status: 422 });", + )(ctx())); + const wire = mapDataError(err, 'order'); + expect(wire.status).toBe(409); + expect(wire.body.code).toBe('ACCOUNT_ON_HOLD'); + expect(wire.body.userMessage).toBe('The account is on hold.'); + }); + + it('translated in the caller\'s locale through `hooks..refusals..message`', async () => { + const err = await rejectionOf(boundHook(throwing('CREDIT_LIMIT'), { i18n: () => i18nService() })(ctx('zh-CN'))); + expect(mapDataError(err, 'order').body.userMessage).toBe(ZH); + }); + + it('negotiated against the locales the service holds — `zh` picks `zh-CN`', async () => { + const err = await rejectionOf(boundHook(throwing('CREDIT_LIMIT'), { i18n: () => i18nService() })(ctx('zh'))); + expect(mapDataError(err, 'order').body.userMessage).toBe(ZH); + }); + + it('the declared message when the locale has no translation, or no locale was asked', async () => { + const i18n = () => i18nService(); + for (const c of [ctx('fr-FR'), ctx()]) { + const err = await rejectionOf(boundHook(throwing('CREDIT_LIMIT'), { i18n })(c)); + expect(mapDataError(err, 'order').body.userMessage).toBe(REFUSALS.CREDIT_LIMIT.message); + } + }); + + it('the declared message when no i18n service is registered', async () => { + const err = await rejectionOf(boundHook(throwing('CREDIT_LIMIT'), { i18n: () => undefined })(ctx('zh-CN'))); + expect(mapDataError(err, 'order').body.userMessage).toBe(REFUSALS.CREDIT_LIMIT.message); + }); + + it('`e.code = …; throw e` is the same refusal', async () => { + const err = await rejectionOf(boundHook("const e = new Error('x'); e.code = 'CREDIT_LIMIT'; throw e;")(ctx())); + expect(mapDataError(err, 'order').body.code).toBe('CREDIT_LIMIT'); + }); +}); + +describe('[#26170] an undeclared code is refused loudly at run time', () => { + it('a code the catalogue does not declare is a 500 fault naming the hook and the code', async () => { + const err = await rejectionOf(boundHook( + "const c = 'CREDIT' + '_LIMT'; throw Object.assign(new Error('x'), { code: c });", + )(ctx())); + expect(err).toBeInstanceOf(SandboxError); + expect(err.innerMessage).toBeUndefined(); + expect(err.message).toContain("hook 'credit_guard' threw code 'CREDIT_LIMT'"); + expect(mapDataError(err, 'order').status).toBe(500); + }); + + it('a throw with no code is the same fault', async () => { + const err = await rejectionOf(boundHook("throw new Error('no code');")(ctx())); + expect(err.message).toContain('threw no `code`'); + expect(mapDataError(err, 'order').status).toBe(500); + }); +}); + +describe('[#26170] what the body did not make passes through untouched', () => { + it('a hook without `refusals` is answered as before', async () => { + const err = await rejectionOf(boundHook( + "throw Object.assign(new Error('Refused.'), { code: 'ANY_CODE', status: 422 });", + { refusals: undefined }, + )(ctx())); + const wire = mapDataError(err, 'order'); + expect(wire.status).toBe(422); + expect(wire.body.code).toBe('ANY_CODE'); + }); + + it('a crash stays a crash', async () => { + const err = await rejectionOf(boundHook('const v = null; return v.x;')(ctx())); + expect(err.message).not.toContain('declares no such code'); + expect(mapDataError(err, 'order').status).toBe(500); + }); + + it('a host error the body lets leave unchanged is not judged against the catalogue', async () => { + const hostRefusal = Object.assign(new Error('Field `amount` is required.'), { code: 'VALIDATION_FAILED', status: 400 }); + const fn = hookBodyRunnerFactory(runner, { + ql: { object: () => ({ insert: async () => { throw hostRefusal; } }) }, + appId: 'crm', + })({ + name: 'credit_guard', + object: 'order', + events: ['beforeInsert'], + body: { language: 'js', source: "await ctx.api.object('audit').insert({});", capabilities: ['api.write'] }, + refusals: REFUSALS, + } as any)!; + const err = await rejectionOf(fn(ctx())); + expect(String(err.message)).not.toContain('declares no such code'); + expect(err.code).toBe('VALIDATION_FAILED'); + }); + + it('a host error the body catches and re-throws with its own declared code is the body\'s refusal', async () => { + const fn = hookBodyRunnerFactory(runner, { + ql: { object: () => ({ insert: async () => { throw Object.assign(new Error('dup'), { code: 'X' }); } }) }, + appId: 'crm', + })({ + name: 'credit_guard', + object: 'order', + events: ['beforeInsert'], + body: { + language: 'js', + source: "try { await ctx.api.object('audit').insert({}); } catch (e) { e.code = 'ACCOUNT_ON_HOLD'; throw e; }", + capabilities: ['api.write'], + }, + refusals: REFUSALS, + } as any)!; + const err = await rejectionOf(fn(ctx())); + expect(mapDataError(err, 'order').status).toBe(409); + expect(mapDataError(err, 'order').body.code).toBe('ACCOUNT_ON_HOLD'); + }); +}); + +describe('declaredRefusalMessage / hookRefusalI18nReader', () => { + const entry = REFUSALS.CREDIT_LIMIT; + + it('a service that throws picks nothing and is warned about', () => { + const warned: string[] = []; + const message = declaredRefusalMessage( + 'credit_guard', 'CREDIT_LIMIT', entry, 'zh-CN', + () => i18nService({ t: () => { throw new Error('boom'); } }), + { warn: (m: string) => warned.push(m) }, + ); + expect(message).toBe(entry.message); + expect(warned[0]).toContain("'hooks.credit_guard.refusals.CREDIT_LIMIT.message'"); + }); + + it('a blank translation picks nothing', () => { + expect(declaredRefusalMessage('credit_guard', 'CREDIT_LIMIT', entry, 'zh-CN', () => i18nService({ t: () => ' ' }))).toBe(entry.message); + }); + + it('the reader answers undefined while no service is registered, and reads at call time', () => { + let registered: unknown; + const reader = hookRefusalI18nReader({ + getService: (name: string): T => { + if (name !== 'i18n' || !registered) throw new Error('not registered'); + return registered as T; + }, + }); + expect(reader()).toBeUndefined(); + registered = i18nService(); + expect(reader()).toBe(registered); + registered = { notT: true }; + expect(reader()).toBeUndefined(); + }); + + it('resolveHookRefusal returns a non-sandbox error unchanged', () => { + const err = new Error('engine'); + expect(resolveHookRefusal({ name: 'h', refusals: REFUSALS } as any, err, ctx())).toBe(err); + }); +}); diff --git a/packages/spec/src/data/hook-refusal-codes.ts b/packages/spec/src/data/hook-refusal-codes.ts index b1597d59cee..efb636457c8 100644 --- a/packages/spec/src/data/hook-refusal-codes.ts +++ b/packages/spec/src/data/hook-refusal-codes.ts @@ -44,35 +44,57 @@ const CODE_ASSIGNMENT = new RegExp( ); /** - * The text of each `throw` statement in `source`: from the keyword to the end - * of its expression — a `;` or a line break outside every bracket and string, - * or the close of the block that holds it. + * `source` with every comment blanked and every string literal's contents + * blanked, keeping each offset where it was — except a string that holds + * nothing but a refusal code, which is the literal the reading looks for. * - * Read, never parsed or run, so a body that does not parse still answers; the - * scan skips over strings, template literals and comments so a `;` or a - * bracket inside one does not end or extend the expression. + * So a `throw`, a `code:` or a bracket written inside a comment or inside a + * longer string is never read as code. */ -function throwStatements(source: string): string[] { +function maskedSource(source: string): string { + const out = source.split(''); + const blank = (from: number, to: number) => { + for (let k = from; k < to; k++) if (out[k] !== '\n') out[k] = ' '; + }; + for (let i = 0; i < source.length; i++) { + const ch = source[i]; + if (ch === '"' || ch === "'" || ch === '`') { + const close = skipString(source, i); + if (!HOOK_REFUSAL_CODE_PATTERN.test(source.slice(i + 1, close))) blank(i + 1, close); + i = close; + } else if (ch === '/' && source[i + 1] === '/') { + const nl = source.indexOf('\n', i); + const end = nl === -1 ? source.length : nl; + blank(i, end); + i = end - 1; + } else if (ch === '/' && source[i + 1] === '*') { + const close = source.indexOf('*/', i + 2); + const end = close === -1 ? source.length : close + 2; + blank(i, end); + i = end - 1; + } + } + return out.join(''); +} + +/** + * The text of each `throw` statement in a masked source ({@link maskedSource}): + * from the keyword to the end of its expression — a `;` or a line break + * outside every bracket, or the close of the block that holds it. + * + * Read, never parsed or run, so a body that does not parse still answers. + */ +function throwStatements(masked: string): string[] { const found: string[] = []; const keyword = /(? 0) { + } else if (depth === 0 && (ch === ';' || ch === '\n') && masked.slice(start, i).trim().length > 0) { end = i; break; } } - found.push(source.slice(start, end)); + found.push(masked.slice(start, end)); } return found; } @@ -128,12 +150,13 @@ function thrownBinding(statement: string): string | undefined { export function hookRefusalCodesThrownIn(source: string): string[] { const codes = new Set(); const thrownNames = new Set(); - for (const statement of throwStatements(source)) { + const masked = maskedSource(source); + for (const statement of throwStatements(masked)) { for (const m of statement.matchAll(CODE_PROPERTY)) codes.add(m.groups!.code); const binding = thrownBinding(statement); if (binding) thrownNames.add(binding); } - for (const m of source.matchAll(CODE_ASSIGNMENT)) { + for (const m of masked.matchAll(CODE_ASSIGNMENT)) { if (thrownNames.has(m.groups!.binding)) codes.add(m.groups!.code); } return [...codes]; diff --git a/packages/spec/src/data/hook-refusals.test.ts b/packages/spec/src/data/hook-refusals.test.ts new file mode 100644 index 00000000000..6495b2dbf38 --- /dev/null +++ b/packages/spec/src/data/hook-refusals.test.ts @@ -0,0 +1,197 @@ +import { describe, it, expect } from 'vitest'; +import { HookSchema, HookRefusalSchema, HookContextSchema } from './hook.zod'; +import { + HOOK_REFUSAL_CODE_PATTERN, + hookRefusalCodesThrownIn, + undeclaredHookRefusalAtRun, + undeclaredHookRefusalCodeAtSave, +} from './hook-refusal-codes'; +import { hookRefusalMessageKey } from '../system/i18n-resolver'; +import { TranslationDataSchema } from '../system/translation.zod'; + +// #26170 (maintainer ruling 6431311681, letter A): a hook declares its refusal +// catalogue, `refusals: { : { message, status? } }`, and its body refuses +// by code only. + +const base = { name: 'credit_guard', object: 'order', events: ['beforeInsert' as const] }; +const body = (source: string) => ({ language: 'js' as const, source }); + +describe('Hook.refusals — the declared catalogue', () => { + it('accepts a catalogue of SCREAMING_SNAKE codes with a message and an optional 4xx status', () => { + const parsed = HookSchema.parse({ + ...base, + body: body("throw Object.assign(new Error('over limit'), { code: 'CREDIT_LIMIT' });"), + refusals: { + CREDIT_LIMIT: { message: 'This order exceeds the customer credit limit.' }, + ACCOUNT_ON_HOLD: { message: 'The account is on hold.', status: 409 }, + }, + }); + expect(parsed.refusals?.CREDIT_LIMIT).toEqual({ message: 'This order exceeds the customer credit limit.' }); + expect(parsed.refusals?.ACCOUNT_ON_HOLD.status).toBe(409); + }); + + it('is optional — a hook with no catalogue is unchanged', () => { + const parsed = HookSchema.parse({ ...base, body: body("throw new Error('no');") }); + expect(parsed.refusals).toBeUndefined(); + }); + + it('refuses a code that is not SCREAMING_SNAKE', () => { + const r = HookSchema.safeParse({ ...base, handler: 'h', refusals: { creditLimit: { message: 'x' } } }); + expect(r.success).toBe(false); + expect(r.error!.issues.some((i) => i.path[0] === 'refusals')).toBe(true); + }); + + it('refuses an empty message', () => { + expect(HookSchema.safeParse({ ...base, handler: 'h', refusals: { NO: { message: ' ' } } }).success).toBe(false); + }); + + it.each([399, 500, 503, 404.5])('refuses status %s — a refusal is a 4xx', (status) => { + expect(HookRefusalSchema.safeParse({ message: 'x', status }).success).toBe(false); + }); + + it('names `reason` as the rename onto `message` it is', () => { + const r = HookRefusalSchema.safeParse({ reason: 'x' }); + expect(r.success).toBe(false); + expect(r.error!.issues.find((i) => i.code === 'unrecognized_keys')?.message).toContain('`reason` → `message`'); + }); + + it('names `httpStatus` as the rename onto `status` it is', () => { + const r = HookRefusalSchema.safeParse({ message: 'x', httpStatus: 409 }); + expect(r.success).toBe(false); + expect(r.error!.issues.find((i) => i.code === 'unrecognized_keys')?.message).toContain('`httpStatus` → `status`'); + }); +}); + +describe('Hook.refusals — an undeclared code is refused at save where the body names it literally', () => { + const refusals = { CREDIT_LIMIT: { message: 'Over the credit limit.' } }; + + it('refuses a literal `code:` the catalogue does not declare, at `body.source`', () => { + const r = HookSchema.safeParse({ + ...base, + refusals, + body: body("throw Object.assign(new Error('x'), { code: 'CREDIT_LIMT' });"), + }); + expect(r.success).toBe(false); + const issue = r.error!.issues.find((i) => i.path.join('.') === 'body.source')!; + expect(issue.message).toBe(undeclaredHookRefusalCodeAtSave('CREDIT_LIMT', ['CREDIT_LIMIT'])); + expect(issue.message).toContain("throws code 'CREDIT_LIMT'"); + expect(issue.message).toContain("declared: 'CREDIT_LIMIT'"); + }); + + it('refuses `e.code = …` on an error the body throws bare', () => { + const r = HookSchema.safeParse({ + ...base, + refusals, + body: body("const e = new Error('x');\ne.code = 'ON_HOLD';\nthrow e;"), + }); + expect(r.success).toBe(false); + expect(r.error!.issues[0]!.message).toContain("throws code 'ON_HOLD'"); + }); + + it('accepts a body whose literal codes are all declared', () => { + const r = HookSchema.safeParse({ + ...base, + refusals, + body: body("if (ctx.input.amount > 10) throw Object.assign(new Error('x'), { code: 'CREDIT_LIMIT' });"), + }); + expect(r.success).toBe(true); + }); + + it('leaves a computed code to the run-time refusal', () => { + const r = HookSchema.safeParse({ + ...base, + refusals, + body: body("const c = ctx.input.kind; throw Object.assign(new Error('x'), { code: c });"), + }); + expect(r.success).toBe(true); + }); + + it('reads nothing when the hook declares no catalogue — a hook without `refusals` saves as before', () => { + const r = HookSchema.safeParse({ + ...base, + body: body("throw Object.assign(new Error('x'), { code: 'ANYTHING' });"), + }); + expect(r.success).toBe(true); + }); +}); + +describe('hookRefusalCodesThrownIn — the save-time reading', () => { + it.each([ + ["throw Object.assign(new Error('x'), { code: 'A_B' });", ['A_B']], + ['throw { message: "x", code: "QUOTED" }', ['QUOTED']], + ['throw Object.assign(new Error(`x`), { code: `TPL` })', ['TPL']], + ["const e = new Error('x'); e.code = 'BOUND'; throw e;", ['BOUND']], + ['throw Object.assign(new Error("a;b"), {\n code: "MULTI_LINE",\n});', ['MULTI_LINE']], + ])('reads %j', (source, codes) => { + expect(hookRefusalCodesThrownIn(source)).toEqual(codes); + }); + + it.each([ + // a record field named `code`, outside any throw + "ctx.input.code = 'NOT_THROWN'; return;", + "await ctx.api.object('x').insert({ code: 'ROW_CODE' });", + // an assignment on a name that is never thrown bare + "const row = {}; row.code = 'ROW'; throw new Error('x');", + // a lower-case value is never a refusal code + "throw Object.assign(new Error('x'), { code: 'lower' });", + // inside a comment or a string + "// throw Object.assign(new Error(), { code: 'COMMENTED' });\nreturn;", + "const s = \"throw { code: 'IN_STRING' }\";", + "throw new Error(\"bad code: 'IN_MESSAGE'\");", + ])('reads nothing in %j', (source) => { + expect(hookRefusalCodesThrownIn(source)).toEqual([]); + }); + + it('stops a throw statement at its line end, so a later `code:` is not charged to it', () => { + expect(hookRefusalCodesThrownIn("if (x) throw new Error('a')\nconst r = { code: 'LATER' };")).toEqual([]); + }); +}); + +describe('the code pattern and the sentences', () => { + it.each(['A', 'CREDIT_LIMIT', 'E404', 'A_1_B'])('accepts %s', (c) => expect(HOOK_REFUSAL_CODE_PATTERN.test(c)).toBe(true)); + it.each(['a', 'Credit', '_A', 'A_', 'A__B', '1A', 'A-B'])('refuses %s', (c) => expect(HOOK_REFUSAL_CODE_PATTERN.test(c)).toBe(false)); + + it('the run-time fault names the hook, the code and the declared codes', () => { + expect(undeclaredHookRefusalAtRun('credit_guard', 'X', ['A', 'B'])).toContain("hook 'credit_guard' threw code 'X'"); + expect(undeclaredHookRefusalAtRun('credit_guard', 'X', ['A', 'B'])).toContain("declared: 'A', 'B'"); + expect(undeclaredHookRefusalAtRun('credit_guard', undefined, [])).toContain('threw no `code`'); + expect(undeclaredHookRefusalAtRun('credit_guard', undefined, [])).toContain('declared: none'); + }); +}); + +describe('the translation address — `hooks..refusals..message`', () => { + it('is the key the runtime asks the i18n service for', () => { + expect(hookRefusalMessageKey('credit_guard', 'CREDIT_LIMIT')).toBe('hooks.credit_guard.refusals.CREDIT_LIMIT.message'); + }); + + it('is a path a translation bundle can write', () => { + const r = TranslationDataSchema.safeParse({ + hooks: { credit_guard: { refusals: { CREDIT_LIMIT: { message: '订单超出客户信用额度。' } } } }, + }) as { success: true; data: any }; + expect(r.success).toBe(true); + expect(r.data.hooks.credit_guard.refusals.CREDIT_LIMIT.message).toBe('订单超出客户信用额度。'); + }); + + it('accepts the empty slot `os i18n extract` writes into a skeleton', () => { + expect(TranslationDataSchema.safeParse({ hooks: { h: { refusals: { C: { message: '' } } } } }).success).toBe(true); + }); + + it('refuses a key a hook translation does not hold', () => { + const r = TranslationDataSchema.safeParse({ hooks: { h: { label: 'x' } } }); + expect(r.success).toBe(false); + }); + + it('names `refusal` as the rename onto `refusals` it is', () => { + const r = TranslationDataSchema.safeParse({ hooks: { h: { refusal: {} } } }); + expect(r.success).toBe(false); + expect(r.error!.issues.find((i) => i.code === 'unrecognized_keys')?.message).toContain('`refusal` → `refusals`'); + }); +}); + +describe('HookContext.session.locale — the locale a refusal is picked in', () => { + it('is declared, optional', () => { + const ctx = { object: 'order', event: 'beforeInsert', input: {}, ql: {} }; + expect(HookContextSchema.safeParse({ ...ctx, session: { userId: 'u1', locale: 'zh-CN' } }).success).toBe(true); + expect(HookContextSchema.safeParse({ ...ctx, session: { userId: 'u1' } }).success).toBe(true); + }); +}); diff --git a/skills/objectstack-data/references/data-hooks.md b/skills/objectstack-data/references/data-hooks.md index 54a04ce8d58..f22f1469b37 100644 --- a/skills/objectstack-data/references/data-hooks.md +++ b/skills/objectstack-data/references/data-hooks.md @@ -113,6 +113,9 @@ const myHook = defineHook({ // Optional: Error handling strategy onError: 'abort', // 'abort' | 'log' + // Optional: Declared refusal catalogue (sandboxed `body` refuses by code) + // refusals: { CREDIT_LIMIT: { message: 'Over the credit limit.', status: 422 } }, + // Optional: Execution timeout (ms) timeoutMs: 5000, @@ -976,6 +979,50 @@ handler: async (ctx) => { } ``` +### Declared refusals (`refusals`) — coded, translatable refusals + +A hook can declare its refusal catalogue: one entry per code, each with the +sentence the user reads and an optional 4xx `status` (default `400`). The body +then refuses **by code only** — it throws an error carrying a declared `code`, +and the runtime answers with the entry's message and status: + +```typescript +defineHook({ + name: 'credit_guard', + object: 'order', + events: ['beforeInsert'], + refusals: { + CREDIT_LIMIT: { message: 'This order exceeds the customer credit limit.' }, + ACCOUNT_ON_HOLD: { message: 'The account is on hold.', status: 409 }, + }, + body: { + language: 'js', + source: ` + if (ctx.input.amount > 100000) { + throw Object.assign(new Error('over limit'), { code: 'CREDIT_LIMIT' }); + } + `, + }, +}); +``` + +- **Codes** are SCREAMING_SNAKE (`CREDIT_LIMIT`); the caller's error envelope + carries the code, so a client can branch on it. +- **The message is translated** through + `hooks..refusals..message` in the caller's locale; `os i18n + extract` scaffolds one key per declared entry and `os i18n check` counts them. + The message is plain text — no `{{ }}` holes are filled. The body's own + `Error` text and `status` are not what the caller reads. +- **An undeclared code is refused loudly.** A code the body throws literally + (`{ code: 'X' }`, or `e.code = 'X'; throw e`) that the catalogue does not + declare fails the save at `body.source`. Any other undeclared code — or a + throw with no code — is a `500` fault at run time, naming the hook and the + code. So once a hook declares `refusals`, every refusal it makes is one of them. +- An error a `ctx.api` call raised that the body lets leave unchanged, and a + crash (`TypeError`), pass through as before; they are not the body's refusal. +- The catalogue is read for a sandboxed `body`. An inline `handler` keeps + throwing its own errors. + --- ## Testing Hooks From 7a55ba4015e3360a1c2ca5b1b5970b93f7e9ce44 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 14:13:53 +0000 Subject: [PATCH 04/18] fix(i18n): carry the hooks translation group through its exhaustive copies The console's translation preview and loader group list, the i18n docs, the walk-parity fixture and the served hook property count move with the new group. Hook refusal pins read the declared code off declaredCode. Claude-Session: https://claude.ai/code/session_01WGufzfyVQuRsL6SRGuKCem Co-authored-by: Claude --- .changeset/26170-hook-refusal-catalogue.md | 2 +- content/docs/protocol/kernel/i18n-standard.mdx | 8 +++++--- content/docs/ui/translations.mdx | 1 + ...otocol.meta-types-degenerate-derivation.test.ts | 3 ++- .../src/sandbox/hook-refusal-catalogue.test.ts | 14 +++++++++----- .../runtime/src/sandbox/hook-refusal-catalogue.ts | 3 ++- packages/spec/src/data/hook-refusal-codes.ts | 6 +++--- .../i18n-walk-parity/every-group.stack.json | 9 +++++++++ skills/objectstack-data/references/data-hooks.md | 5 +++-- .../app-shell/src/views/metadata-admin/i18n.ts | 2 ++ .../TranslationPreview.categories-11765.test.tsx | 5 +++++ .../metadata-admin/previews/TranslationPreview.tsx | 2 ++ .../previews/adminPreviews.i18n-10862-s2.test.tsx | 3 ++- ui/packages/i18n/src/utils/spec-translations.ts | 5 +++++ 14 files changed, 51 insertions(+), 17 deletions(-) diff --git a/.changeset/26170-hook-refusal-catalogue.md b/.changeset/26170-hook-refusal-catalogue.md index 6ded35f6773..55e982366cd 100644 --- a/.changeset/26170-hook-refusal-catalogue.md +++ b/.changeset/26170-hook-refusal-catalogue.md @@ -11,7 +11,7 @@ feat(spec): a hook declares its refusal catalogue, and its body refuses by code, Clause-②: yes (widening) Metadata-protocol: added -A hook can now declare `refusals`: one entry per SCREAMING_SNAKE code, each with the `message` the user reads and an optional 4xx `status` (default `400`). A sandboxed `body` then refuses by code only: it throws an error carrying a declared `code`, and the body runner answers with the entry. The message is translated through `hooks..refusals..message` in the caller's locale, the code reaches the caller's error envelope, and the status is the entry's. The body's own error text and `status` are not what the caller reads. +A hook can now declare `refusals`: one entry per SCREAMING_SNAKE code, each with the `message` the user reads and an optional 4xx `status` (default `400`). A sandboxed `body` then refuses by code only: it throws an error carrying a declared `code`, and the body runner answers with the entry. The message is translated through `hooks..refusals..message` in the caller's locale, the code reaches the caller's error envelope as `declaredCode` (beside the status's registered `code`), and the status is the entry's. The body's own error text and `status` are not what the caller reads. - **Spec.** `HookSchema.refusals` (`HookRefusalSchema`: `message`, `status?`). A new `hooks` group on the translation bundle, `hooks..refusals..message`, and `hookRefusalMessageKey`. `HookContext.session.locale` declares the caller's locale. - **Undeclared codes are refused loudly.** A code the body throws literally (`{ code: 'X' }`, or `e.code = 'X'; throw e`) that the catalogue does not declare fails the save at `body.source`. Any other undeclared code, and a throw with no code, is a `500` fault at run time that names the hook and the code. This applies only to a hook that declares `refusals`; a hook without one is answered exactly as before. diff --git a/content/docs/protocol/kernel/i18n-standard.mdx b/content/docs/protocol/kernel/i18n-standard.mdx index 7979b59f9d2..1bb165ddd52 100644 --- a/content/docs/protocol/kernel/i18n-standard.mdx +++ b/content/docs/protocol/kernel/i18n-standard.mdx @@ -406,8 +406,8 @@ Three rules the shape enforces, all of them closed since #4001: - **The top level is the declared group set and nothing else** — `objects`, `picklists`, `apps`, `messages`, `globalActions`, `dashboards`, `datasets`, - `reports`, `pages`, `flows`, `positions`, `permissions`, `metadataForms`, - `settingsCommon`. A group invented beside them + `reports`, `pages`, `flows`, `hooks`, `positions`, `permissions`, + `metadataForms`, `settingsCommon`. A group invented beside them (`"account"`, `"fields"`, `"list"`, `"industries"`) is rejected **by name** at both authoring doors, with the group to use instead named in the rejection. @@ -496,7 +496,7 @@ what the overlay writes back as the node's `title`. The contract is `t(key, locale, params?)` — the target locale is always passed explicitly, and the key itself is returned when no translation is found (there is no `defaultValue` option). The key is a **dot path into `TranslationData`**, -so it starts at one of the fourteen declared groups. +so it starts at one of the fifteen declared groups. ```typescript const i18n = kernel.getService('i18n'); @@ -1077,6 +1077,8 @@ beneath an action entry are judged the same way under either. | `positions.{name}` | a position's `name`, declared, or an audience anchor (`everyone`, `guest`) | | `permissions.{name}` | a permission set's `name`, declared | | `reports.{name}` | a report's `name`, declared | +| `hooks.{hook}` | a hook's `name`, declared | +| `hooks.{hook}.refusals.{code}` | a code that hook's `refusals` declares | | `flows.{flow}.screens.{node}` | the `id` of a `type: 'screen'` node in that flow, nodes nested in loop, parallel and try/catch regions included | | `flows.{flow}.screens.{node}.fields.{field}` | a `config.fields[].name` on that screen — an object-form screen (`config.objectName`) declares none, and its input labels live under `objects.{objectName}.fields` | diff --git a/content/docs/ui/translations.mdx b/content/docs/ui/translations.mdx index 40c9233391a..486a825b48d 100644 --- a/content/docs/ui/translations.mdx +++ b/content/docs/ui/translations.mdx @@ -87,6 +87,7 @@ export default defineStack({ | Page component copy, by component id | `pages..components..title` / `description` / `label` / `placeholder` / `emptyText` — reached under `regions[].components[]` and `slots.`, through `properties.children` and a `page:tabs` / `page:accordion` panel's `items[].children` | | Page tab labels (`page:tabs` items) | `pages..components..items..label` — keyed by the tabs component's `id` and the item's `value`; an item without a `value` has no key (localize it with an inline locale map), and `page:accordion` panels have none | | Screen-flow wizards (flow label, screen headings and body text, screen field copy) | `flows..label` / `flows..screens..title` / `.description` / `.fields..label` / `.placeholder` / `.inlineHelpText` — a screen field's help text is `inlineHelpText`, the key the field itself declares, not `help`; see the boundary note below | +| Hook refusal messages | `hooks..refusals..message` — one per entry of the hook's `refusals` catalogue. A sandboxed hook body refuses by code, and the server answers the refusal with this message in the caller's locale, else the declared `message` | | Position label / description | `positions..label` / `description` — keyed by the position's `name`; overlaid on every served position, which is the list the console's position picker draws | | Permission-set label / description | `permissions..label` / `description` — the group is spelled as the stack spells the collection (`permissions[]`), keyed by the set's `name` | | Global actions, messages | `globalActions`, `messages` | diff --git a/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts b/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts index a20b0d59916..329e1ab8e33 100644 --- a/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts +++ b/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts @@ -341,9 +341,10 @@ describe('#17501 — /meta/types serves a real schema for `action`, and moves no // `dashboard` 22 → 20 (`aria`, `performance`), `field` 80 → 79 // (`conditionalRequired`), `flow` 24 → 22 (`template`, `active`), `skill` // 17 → 16 (`triggerPhrases`). The live sets are unchanged. + // [#26170] `hook` 22 → 23: the declared refusal catalogue, `refusals`. const CARD_PROPERTY_COUNTS: Record = { agent: 24, app: 22, dashboard: 20, dataset: 16, field: 79, flow: 22, - hook: 22, object: 48, page: 26, position: 13, report: 21, skill: 16, tool: 14, + hook: 23, object: 48, page: 26, position: 13, report: 21, skill: 16, tool: 14, }; it.each(Object.entries(CARD_PROPERTY_COUNTS))( diff --git a/packages/runtime/src/sandbox/hook-refusal-catalogue.test.ts b/packages/runtime/src/sandbox/hook-refusal-catalogue.test.ts index a5dc1d264ea..d136707e5f1 100644 --- a/packages/runtime/src/sandbox/hook-refusal-catalogue.test.ts +++ b/packages/runtime/src/sandbox/hook-refusal-catalogue.test.ts @@ -71,7 +71,10 @@ describe('[#26170] a declared code is answered with the catalogue entry', () => expect(err).toBeInstanceOf(SandboxError); const wire = mapDataError(err, 'order'); expect(wire.status).toBe(DEFAULT_HOOK_REFUSAL_STATUS); - expect(wire.body.code).toBe('CREDIT_LIMIT'); + // An author's code is not an ADR-0112 registered one: the wire `code` is + // the status's registered code, and the declared one rides beside it. + expect(wire.body.code).toBe('VALIDATION_ERROR'); + expect(wire.body.declaredCode).toBe('CREDIT_LIMIT'); expect(wire.body.userMessage).toBe(REFUSALS.CREDIT_LIMIT.message); // The body's own sentence is not what the caller reads. expect(JSON.stringify(wire.body)).not.toContain('internal detail'); @@ -83,7 +86,7 @@ describe('[#26170] a declared code is answered with the catalogue entry', () => )(ctx())); const wire = mapDataError(err, 'order'); expect(wire.status).toBe(409); - expect(wire.body.code).toBe('ACCOUNT_ON_HOLD'); + expect(wire.body.declaredCode).toBe('ACCOUNT_ON_HOLD'); expect(wire.body.userMessage).toBe('The account is on hold.'); }); @@ -112,7 +115,7 @@ describe('[#26170] a declared code is answered with the catalogue entry', () => it('`e.code = …; throw e` is the same refusal', async () => { const err = await rejectionOf(boundHook("const e = new Error('x'); e.code = 'CREDIT_LIMIT'; throw e;")(ctx())); - expect(mapDataError(err, 'order').body.code).toBe('CREDIT_LIMIT'); + expect(mapDataError(err, 'order').body.declaredCode).toBe('CREDIT_LIMIT'); }); }); @@ -142,7 +145,8 @@ describe('[#26170] what the body did not make passes through untouched', () => { )(ctx())); const wire = mapDataError(err, 'order'); expect(wire.status).toBe(422); - expect(wire.body.code).toBe('ANY_CODE'); + expect(wire.body.declaredCode).toBe('ANY_CODE'); + expect(wire.body.userMessage).toBeUndefined(); }); it('a crash stays a crash', async () => { @@ -185,7 +189,7 @@ describe('[#26170] what the body did not make passes through untouched', () => { } as any)!; const err = await rejectionOf(fn(ctx())); expect(mapDataError(err, 'order').status).toBe(409); - expect(mapDataError(err, 'order').body.code).toBe('ACCOUNT_ON_HOLD'); + expect(mapDataError(err, 'order').body.declaredCode).toBe('ACCOUNT_ON_HOLD'); }); }); diff --git a/packages/runtime/src/sandbox/hook-refusal-catalogue.ts b/packages/runtime/src/sandbox/hook-refusal-catalogue.ts index 7f3a62f7da5..636db3ffdd7 100644 --- a/packages/runtime/src/sandbox/hook-refusal-catalogue.ts +++ b/packages/runtime/src/sandbox/hook-refusal-catalogue.ts @@ -15,7 +15,8 @@ * `message` — served as the error's caller-facing sentence (`innerMessage`) * and marked as the end user's (`userMessage`, the objectui#5210 opt-in); * - the status is the declared `status`, else 400; - * - the code is the declared code, so the caller can branch on it. + * - the code is the declared code — on the wire, `declaredCode` beside the + * status's registered `code` — so the caller can branch on it. * * **Picked here, before the refusal leaves.** A hook refusal reaches the * caller already rendered — the error envelope every door builds from the diff --git a/packages/spec/src/data/hook-refusal-codes.ts b/packages/spec/src/data/hook-refusal-codes.ts index efb636457c8..af0202ed9d6 100644 --- a/packages/spec/src/data/hook-refusal-codes.ts +++ b/packages/spec/src/data/hook-refusal-codes.ts @@ -25,9 +25,9 @@ /** * The spelling of a declared refusal code: SCREAMING_SNAKE, the one wire - * vocabulary of an error code (ADR-0112). The code a body throws is the - * `code` the caller's envelope carries, so it is spelled as every other code a - * client branches on. + * vocabulary of an error code (ADR-0112). The code a body throws reaches the + * caller's envelope (as `declaredCode`, beside the status's registered `code`), + * so it is spelled as every other code a client branches on. */ export const HOOK_REFUSAL_CODE_PATTERN = /^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)*$/; diff --git a/scripts/fixtures/i18n-walk-parity/every-group.stack.json b/scripts/fixtures/i18n-walk-parity/every-group.stack.json index b68f688d516..a4dd2b586f1 100644 --- a/scripts/fixtures/i18n-walk-parity/every-group.stack.json +++ b/scripts/fixtures/i18n-walk-parity/every-group.stack.json @@ -173,6 +173,15 @@ ] } ], + "hooks": [ + { + "name": "parity_guard", + "object": "parity_object", + "events": ["beforeInsert"], + "body": { "language": "js", "source": "throw Object.assign(new Error('refused'), { code: 'PARITY_REFUSED' });" }, + "refusals": { "PARITY_REFUSED": { "message": "Parity records are refused." } } + } + ], "positions": [ { "name": "parity_reviewer", "label": "Parity Reviewer", "description": "Reviews parity findings" } ], diff --git a/skills/objectstack-data/references/data-hooks.md b/skills/objectstack-data/references/data-hooks.md index f22f1469b37..c9b4b3584d6 100644 --- a/skills/objectstack-data/references/data-hooks.md +++ b/skills/objectstack-data/references/data-hooks.md @@ -1006,8 +1006,9 @@ defineHook({ }); ``` -- **Codes** are SCREAMING_SNAKE (`CREDIT_LIMIT`); the caller's error envelope - carries the code, so a client can branch on it. +- **Codes** are SCREAMING_SNAKE (`CREDIT_LIMIT`). The error envelope carries + it as `declaredCode`, beside the status's registered `code` + (`VALIDATION_ERROR` for 400), so a client can branch on it. - **The message is translated** through `hooks..refusals..message` in the caller's locale; `os i18n extract` scaffolds one key per declared entry and `os i18n check` counts them. diff --git a/ui/packages/app-shell/src/views/metadata-admin/i18n.ts b/ui/packages/app-shell/src/views/metadata-admin/i18n.ts index ca2c38c0292..0658c36803f 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/ui/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -1867,6 +1867,7 @@ const ENGINE_STRINGS_EN: Record = { 'engine.translationPreview.category.reports': 'Reports', 'engine.translationPreview.category.pages': 'Pages', 'engine.translationPreview.category.flows': 'Flows', + 'engine.translationPreview.category.hooks': 'Hooks', 'engine.translationPreview.category.positions': 'Positions', 'engine.translationPreview.category.permissions': 'Permission Sets', 'engine.translationPreview.category.metadataForms': 'Metadata Forms', @@ -5143,6 +5144,7 @@ const ENGINE_STRINGS_ZH: Record = { 'engine.translationPreview.category.reports': '报表', 'engine.translationPreview.category.pages': '页面', 'engine.translationPreview.category.flows': '流程', + 'engine.translationPreview.category.hooks': '钩子', 'engine.translationPreview.category.positions': '岗位', 'engine.translationPreview.category.permissions': '权限集', 'engine.translationPreview.category.metadataForms': '元数据表单', diff --git a/ui/packages/app-shell/src/views/metadata-admin/previews/TranslationPreview.categories-11765.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/previews/TranslationPreview.categories-11765.test.tsx index 26ca72b5c35..1a3d88a3aa4 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/previews/TranslationPreview.categories-11765.test.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/previews/TranslationPreview.categories-11765.test.tsx @@ -63,6 +63,11 @@ const ONE_ENTRY: Record; key: string; ro }, pages: { data: { home: { label: 'Accueil' } }, key: 'home', row: () => keys(1) }, flows: { data: { onboarding: { label: 'Intégration' } }, key: 'onboarding', row: () => keys(1) }, + hooks: { + data: { credit_guard: { refusals: { CREDIT_LIMIT: { message: 'Limite de crédit dépassée.' } } } }, + key: 'credit_guard', + row: () => keys(1), + }, positions: { data: { legal_counsel: { label: 'Conseiller juridique' } }, key: 'legal_counsel', row: () => keys(1) }, permissions: { data: { contract_reviewer: { label: 'Revue des contrats', description: 'Lire et approuver les contrats' } }, diff --git a/ui/packages/app-shell/src/views/metadata-admin/previews/TranslationPreview.tsx b/ui/packages/app-shell/src/views/metadata-admin/previews/TranslationPreview.tsx index d9fe855240a..24f77e06272 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/previews/TranslationPreview.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/previews/TranslationPreview.tsx @@ -47,6 +47,7 @@ import { ShieldCheck, SquareChevronDown, Users, + Webhook, Workflow, } from 'lucide-react'; import { EmptyDescription } from '@object-ui/components'; @@ -98,6 +99,7 @@ const CATEGORY_DEFS = { reports: { label: 'engine.translationPreview.category.reports', icon: BarChart3, flat: false }, pages: { label: 'engine.translationPreview.category.pages', icon: PanelsTopLeft, flat: false }, flows: { label: 'engine.translationPreview.category.flows', icon: Workflow, flat: false }, + hooks: { label: 'engine.translationPreview.category.hooks', icon: Webhook, flat: false }, positions: { label: 'engine.translationPreview.category.positions', icon: Users, flat: false }, permissions: { label: 'engine.translationPreview.category.permissions', icon: ShieldCheck, flat: false }, metadataForms: { label: 'engine.translationPreview.category.metadataForms', icon: FileText, flat: false }, diff --git a/ui/packages/app-shell/src/views/metadata-admin/previews/adminPreviews.i18n-10862-s2.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/previews/adminPreviews.i18n-10862-s2.test.tsx index 26a8c8eccd4..e302f93a06e 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/previews/adminPreviews.i18n-10862-s2.test.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/previews/adminPreviews.i18n-10862-s2.test.tsx @@ -462,6 +462,7 @@ describe('TranslationPreview reads the designer locale (objectui#10862)', () => { key: 'engine.translationPreview.category.reports' }, { key: 'engine.translationPreview.category.pages' }, { key: 'engine.translationPreview.category.flows' }, + { key: 'engine.translationPreview.category.hooks' }, { key: 'engine.translationPreview.category.positions' }, { key: 'engine.translationPreview.category.permissions' }, { key: 'engine.translationPreview.category.metadataForms' }, @@ -471,7 +472,7 @@ describe('TranslationPreview reads the designer locale (objectui#10862)', () => { key: 'engine.translationPreview.keyCountOne', vars: { count: 1 } }, ]); // The bundle's own locale is author data, not the designer's language. - expectAsWritten(['French', 'fr-FR', 'account', '"Un"', '2/14 (14%)']); + expectAsWritten(['French', 'fr-FR', 'account', '"Un"', '2/15 (13%)']); }); } }); diff --git a/ui/packages/i18n/src/utils/spec-translations.ts b/ui/packages/i18n/src/utils/spec-translations.ts index 0f3638a4788..6e92c3e6755 100644 --- a/ui/packages/i18n/src/utils/spec-translations.ts +++ b/ui/packages/i18n/src/utils/spec-translations.ts @@ -107,6 +107,11 @@ const SPEC_TRANSLATION_GROUPS = [ // objectstack #25788 serves `reports` (a report's label and description are // translatable), copied through onto `app` the same way. 'reports', + // objectstack #26170 serves `hooks` (a hook's declared refusal messages, + // `hooks..refusals..message`); the server answers a refusal + // already translated, so nothing here reads it, but a payload carrying it + // is namespaced like any other group. + 'hooks', ] as const; function isPlainObject(value: unknown): value is Record { From 38126886a6b94d916f6ec93feb83ac381bf5d595 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 14:16:10 +0000 Subject: [PATCH 05/18] docs(skills): fit the hook refusal section under the data-hooks token ceiling Pays for the new section by deleting the error-handling examples the handler and onError sections already carry, and folds the testing pointer into the references list. Claude-Session: https://claude.ai/code/session_01WGufzfyVQuRsL6SRGuKCem Co-authored-by: Claude --- .../objectstack-data/references/data-hooks.md | 112 +++--------------- 1 file changed, 19 insertions(+), 93 deletions(-) diff --git a/skills/objectstack-data/references/data-hooks.md b/skills/objectstack-data/references/data-hooks.md index c9b4b3584d6..ff5e0d9218c 100644 --- a/skills/objectstack-data/references/data-hooks.md +++ b/skills/objectstack-data/references/data-hooks.md @@ -4,7 +4,7 @@ - [Hook Definition Schema](#hook-definition-schema) · [Key Properties Explained](#key-properties-explained) - [Sandboxed Hook Bodies (`body`)](#sandboxed-hook-bodies-body--what-the-sandbox-ctx-can-call) · [Capabilities — the complete list](#capabilities--the-complete-list) · [Sandbox restrictions](#sandbox-restrictions) - [Hook Context API](#hook-context-api) · [Common Patterns](#common-patterns) · [Registration Methods](#registration-methods) -- [Best Practices](#best-practices) · [Error Handling](#error-handling) · [Testing Hooks](#testing-hooks) · [References](#references) +- [Best Practices](#best-practices) · [Error Handling](#error-handling) · [References](#references) Reference companion to `objectstack-data/SKILL.md`, and the catalog's anchor for data hooks. Covers the 8 lifecycle events, the inline `handler` and sandboxed @@ -113,9 +113,6 @@ const myHook = defineHook({ // Optional: Error handling strategy onError: 'abort', // 'abort' | 'log' - // Optional: Declared refusal catalogue (sandboxed `body` refuses by code) - // refusals: { CREDIT_LIMIT: { message: 'Over the credit limit.', status: 422 } }, - // Optional: Execution timeout (ms) timeoutMs: 5000, @@ -938,101 +935,29 @@ hook re-trigger itself; use `object: '*'` unless you mean every object; throw in ## Error Handling -### Throwing Errors (Abort Operation) - -```typescript -handler: async (ctx) => { - if (!ctx.input.email) { - // Aborts operation, rolls back transaction - throw new Error('Email is required'); - } -} -``` - -### Logging Errors (Continue) - -```typescript -{ - onError: 'log', // Log error, don't abort - handler: async (ctx) => { - try { - await sendEmail(ctx.input.email); - } catch (error) { - // Error is logged, operation continues - console.error('Failed to send email', error); - } - } -} -``` - -### Custom Error Messages - -```typescript -handler: async (ctx) => { - if (ctx.input.annual_revenue < 0) { - throw new Error('Annual revenue cannot be negative'); - } - - if (ctx.input.annual_revenue > 1000000000) { - throw new Error('Annual revenue exceeds maximum allowed value (1B)'); - } -} -``` +A throw aborts the operation and rolls back its transaction; `onError: 'log'` +(above) makes an `after*` hook log and continue instead. -### Declared refusals (`refusals`) — coded, translatable refusals +### Declared refusals (`refusals`) -A hook can declare its refusal catalogue: one entry per code, each with the -sentence the user reads and an optional 4xx `status` (default `400`). The body -then refuses **by code only** — it throws an error carrying a declared `code`, -and the runtime answers with the entry's message and status: +With a `refusals` catalogue, a sandboxed `body` refuses **by code only** and the +runtime answers with that entry: ```typescript -defineHook({ - name: 'credit_guard', - object: 'order', - events: ['beforeInsert'], - refusals: { - CREDIT_LIMIT: { message: 'This order exceeds the customer credit limit.' }, - ACCOUNT_ON_HOLD: { message: 'The account is on hold.', status: 409 }, - }, - body: { - language: 'js', - source: ` - if (ctx.input.amount > 100000) { - throw Object.assign(new Error('over limit'), { code: 'CREDIT_LIMIT' }); - } - `, - }, -}); +refusals: { + CREDIT_LIMIT: { message: 'Over the credit limit.' }, + ON_HOLD: { message: 'Account on hold.', status: 409 }, // default 400 +}, +body: { language: 'js', source: ` + if (ctx.input.amount > 100000) throw Object.assign(new Error(), { code: 'CREDIT_LIMIT' }); +` }, ``` -- **Codes** are SCREAMING_SNAKE (`CREDIT_LIMIT`). The error envelope carries - it as `declaredCode`, beside the status's registered `code` - (`VALIDATION_ERROR` for 400), so a client can branch on it. -- **The message is translated** through - `hooks..refusals..message` in the caller's locale; `os i18n - extract` scaffolds one key per declared entry and `os i18n check` counts them. - The message is plain text — no `{{ }}` holes are filled. The body's own - `Error` text and `status` are not what the caller reads. -- **An undeclared code is refused loudly.** A code the body throws literally - (`{ code: 'X' }`, or `e.code = 'X'; throw e`) that the catalogue does not - declare fails the save at `body.source`. Any other undeclared code — or a - throw with no code — is a `500` fault at run time, naming the hook and the - code. So once a hook declares `refusals`, every refusal it makes is one of them. -- An error a `ctx.api` call raised that the body lets leave unchanged, and a - crash (`TypeError`), pass through as before; they are not the body's refusal. -- The catalogue is read for a sandboxed `body`. An inline `handler` keeps - throwing its own errors. - ---- - -## Testing Hooks - -Hook test harnesses — vitest units and `LiteKernel` integration setups — are the -**objectstack-platform** skill's surface (its frontmatter claims "test harnesses via -LiteKernel"): see [objectstack-platform/SKILL.md](../../objectstack-platform/SKILL.md). - ---- +- Codes are SCREAMING_SNAKE; the error envelope carries one as `declaredCode`. +- The plain-text message is translated via `hooks..refusals..message` + in the caller's locale; the body's own error text and `status` are not used. +- An undeclared code, or none, is refused: at save if thrown literally, else as + a `500` fault. Unchanged `ctx.api` errors and crashes pass through. --- @@ -1042,5 +967,6 @@ LiteKernel"): see [objectstack-platform/SKILL.md](../../objectstack-platform/SKI - [Project hooks pattern](../SKILL.md#lifecycle-hooks) — Hook integration in the data skill - [objectstack-platform/references/plugin-hooks.md](../../objectstack-platform/references/plugin-hooks.md) — plugin/kernel hooks (a different extension point) - [objectstack-automation](../../objectstack-automation/SKILL.md) — Flows and Workflows +- [objectstack-platform/SKILL.md](../../objectstack-platform/SKILL.md) — hook test harnesses (vitest units, `LiteKernel`) --- From 7ceba6a5184ee8a26de80e09507631e438ea2a81 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 14:37:58 +0000 Subject: [PATCH 06/18] fix(runtime): keep the host-error marker off a host error's enumerable keys; offer refusals in the hook form Claude-Session: https://claude.ai/code/session_01WGufzfyVQuRsL6SRGuKCem Co-authored-by: Claude --- packages/runtime/src/sandbox/quickjs-runner.ts | 5 +++-- packages/spec/src/data/hook.form.ts | 16 ++++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/runtime/src/sandbox/quickjs-runner.ts b/packages/runtime/src/sandbox/quickjs-runner.ts index 425f9fd9556..7799600285f 100644 --- a/packages/runtime/src/sandbox/quickjs-runner.ts +++ b/packages/runtime/src/sandbox/quickjs-runner.ts @@ -1500,9 +1500,10 @@ function hostErrorToVm(vm: QuickJSContext, err: unknown): QuickJSHandle { } // [#26170] Every marshalled host error carries the `message` and `code` it // crossed with, so the reject handler can tell it leaving unchanged from - // an error the body made (see SANDBOX_HOST_ERROR_OF_PROP). + // an error the body made (see SANDBOX_HOST_ERROR_OF_PROP). Not enumerable: + // the body reads the same keys off a host error as before (#3918's allowlist). const hostOf = vm.newString(JSON.stringify([vmMessage, typeof e?.code === 'string' && e.code ? e.code : null])); - vm.setProp(errH, SANDBOX_HOST_ERROR_OF_PROP, hostOf); + vm.defineProp(errH, SANDBOX_HOST_ERROR_OF_PROP, { value: hostOf, configurable: true, enumerable: false }); hostOf.dispose(); } catch { /* keep the bare name/message error */ diff --git a/packages/spec/src/data/hook.form.ts b/packages/spec/src/data/hook.form.ts index 2d21c410f54..cfdc80ac3bf 100644 --- a/packages/spec/src/data/hook.form.ts +++ b/packages/spec/src/data/hook.form.ts @@ -46,6 +46,22 @@ export const hookForm = defineForm({ { field: 'memoryMb', type: 'number', helpText: 'Per-invocation memory cap (MB, max 256)' }, ], }, + { + field: 'refusals', + type: 'record', + helpText: 'Refusals the body may throw, by code — the body throws an error carrying the code, and the caller reads this message', + keyField: { + field: 'code', + label: 'Code', + placeholder: 'CREDIT_LIMIT', + helpText: 'SCREAMING_SNAKE code the body throws', + regex: '^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)*$', + }, + fields: [ + { field: 'message', type: 'text', required: true, helpText: 'What the user reads (translated per locale)' }, + { field: 'status', type: 'number', helpText: 'HTTP status, 400–499 (default 400)' }, + ], + }, ], }, { From bf529b57c971ecc8bd7a0ace476349e9a22498c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 15:47:33 +0000 Subject: [PATCH 07/18] i18n(platform-objects): translate the hook form's refusals field Claude-Session: https://claude.ai/code/session_01WGufzfyVQuRsL6SRGuKCem Co-authored-by: Claude --- .../apps/translations/en.metadata-forms.generated.ts | 12 ++++++++++++ .../translations/es-ES.metadata-forms.generated.ts | 12 ++++++++++++ .../translations/ja-JP.metadata-forms.generated.ts | 12 ++++++++++++ .../translations/zh-CN.metadata-forms.generated.ts | 12 ++++++++++++ 4 files changed, 48 insertions(+) diff --git a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts index ae59c273659..a4a0f4ddbc5 100644 --- a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts @@ -937,6 +937,18 @@ export const enMetadataForms: NonNullable = { label: "Memory Mb", helpText: "Per-invocation memory cap (MB, max 256)" }, + refusals: { + label: "Refusals", + helpText: "Refusals the body may throw, by code — the body throws an error carrying the code, and the caller reads this message" + }, + "refusals.message": { + label: "Message", + helpText: "What the user reads (translated per locale)" + }, + "refusals.status": { + label: "Status", + helpText: "HTTP status, 400–499 (default 400)" + }, handler: { label: "Handler", helpText: "Handler function name (deprecated — prefer `body`)" diff --git a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts index 9efad055ce2..9e66721e10e 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts @@ -937,6 +937,18 @@ export const esESMetadataForms: NonNullable = label: "Memoria (MB)", helpText: "Límite de memoria por invocación (MB, máx. 256)" }, + refusals: { + label: "Rechazos", + helpText: "Rechazos que el cuerpo puede lanzar, por código: el cuerpo lanza un error con el código y quien llama lee este mensaje" + }, + "refusals.message": { + label: "Mensaje", + helpText: "Lo que lee el usuario (traducido por idioma)" + }, + "refusals.status": { + label: "Estado", + helpText: "Estado HTTP, 400–499 (400 por defecto)" + }, handler: { label: "Manejador", helpText: "Nombre de función manejadora (obsoleto — preferir `body`)" diff --git a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts index 6f89cf2a49e..160d3c9a45f 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts @@ -937,6 +937,18 @@ export const jaJPMetadataForms: NonNullable = label: "メモリ(MB)", helpText: "呼び出しごとのメモリ上限(MB、最大 256)" }, + refusals: { + label: "拒否カタログ", + helpText: "body が投げうる拒否をコード別に宣言 —— body はそのコードを持つエラーを投げ、呼び出し側はこのメッセージを受け取ります" + }, + "refusals.message": { + label: "メッセージ", + helpText: "ユーザーが読む文言(ロケールごとに翻訳)" + }, + "refusals.status": { + label: "ステータス", + helpText: "HTTP ステータス、400–499(既定 400)" + }, handler: { label: "ハンドラー", helpText: "ハンドラー関数名(非推奨 — `body` を推奨)" diff --git a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts index e799e40b0dc..c0de91ef83a 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts @@ -937,6 +937,18 @@ export const zhCNMetadataForms: NonNullable = label: "内存(MB)", helpText: "单次调用内存上限(MB,最大 256)" }, + refusals: { + label: "拒绝目录", + helpText: "按代码列出 body 可能抛出的拒绝 —— body 抛出带该代码的错误,调用方读到此处的消息" + }, + "refusals.message": { + label: "消息", + helpText: "用户读到的文字(按语言翻译)" + }, + "refusals.status": { + label: "状态码", + helpText: "HTTP 状态码,400–499(默认 400)" + }, handler: { label: "处理器", helpText: "处理器函数名(已废弃——建议使用 body)" From 547ffcae0262b1d0ef7e5582f6ad8902515ed0a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 15:58:18 +0000 Subject: [PATCH 08/18] fix(spec): record why the hook form does not offer refusals, instead of a record control that would stamp a refused key Claude-Session: https://claude.ai/code/session_01WGufzfyVQuRsL6SRGuKCem Co-authored-by: Claude --- .../translations/en.metadata-forms.generated.ts | 12 ------------ .../es-ES.metadata-forms.generated.ts | 12 ------------ .../ja-JP.metadata-forms.generated.ts | 12 ------------ .../zh-CN.metadata-forms.generated.ts | 12 ------------ packages/spec/src/data/hook.form.ts | 16 ---------------- .../metadata-form-zod-reconciliation.test.ts | 9 +++++++++ 6 files changed, 9 insertions(+), 64 deletions(-) diff --git a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts index a4a0f4ddbc5..ae59c273659 100644 --- a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts @@ -937,18 +937,6 @@ export const enMetadataForms: NonNullable = { label: "Memory Mb", helpText: "Per-invocation memory cap (MB, max 256)" }, - refusals: { - label: "Refusals", - helpText: "Refusals the body may throw, by code — the body throws an error carrying the code, and the caller reads this message" - }, - "refusals.message": { - label: "Message", - helpText: "What the user reads (translated per locale)" - }, - "refusals.status": { - label: "Status", - helpText: "HTTP status, 400–499 (default 400)" - }, handler: { label: "Handler", helpText: "Handler function name (deprecated — prefer `body`)" diff --git a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts index 9e66721e10e..9efad055ce2 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts @@ -937,18 +937,6 @@ export const esESMetadataForms: NonNullable = label: "Memoria (MB)", helpText: "Límite de memoria por invocación (MB, máx. 256)" }, - refusals: { - label: "Rechazos", - helpText: "Rechazos que el cuerpo puede lanzar, por código: el cuerpo lanza un error con el código y quien llama lee este mensaje" - }, - "refusals.message": { - label: "Mensaje", - helpText: "Lo que lee el usuario (traducido por idioma)" - }, - "refusals.status": { - label: "Estado", - helpText: "Estado HTTP, 400–499 (400 por defecto)" - }, handler: { label: "Manejador", helpText: "Nombre de función manejadora (obsoleto — preferir `body`)" diff --git a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts index 160d3c9a45f..6f89cf2a49e 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts @@ -937,18 +937,6 @@ export const jaJPMetadataForms: NonNullable = label: "メモリ(MB)", helpText: "呼び出しごとのメモリ上限(MB、最大 256)" }, - refusals: { - label: "拒否カタログ", - helpText: "body が投げうる拒否をコード別に宣言 —— body はそのコードを持つエラーを投げ、呼び出し側はこのメッセージを受け取ります" - }, - "refusals.message": { - label: "メッセージ", - helpText: "ユーザーが読む文言(ロケールごとに翻訳)" - }, - "refusals.status": { - label: "ステータス", - helpText: "HTTP ステータス、400–499(既定 400)" - }, handler: { label: "ハンドラー", helpText: "ハンドラー関数名(非推奨 — `body` を推奨)" diff --git a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts index c0de91ef83a..e799e40b0dc 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts @@ -937,18 +937,6 @@ export const zhCNMetadataForms: NonNullable = label: "内存(MB)", helpText: "单次调用内存上限(MB,最大 256)" }, - refusals: { - label: "拒绝目录", - helpText: "按代码列出 body 可能抛出的拒绝 —— body 抛出带该代码的错误,调用方读到此处的消息" - }, - "refusals.message": { - label: "消息", - helpText: "用户读到的文字(按语言翻译)" - }, - "refusals.status": { - label: "状态码", - helpText: "HTTP 状态码,400–499(默认 400)" - }, handler: { label: "处理器", helpText: "处理器函数名(已废弃——建议使用 body)" diff --git a/packages/spec/src/data/hook.form.ts b/packages/spec/src/data/hook.form.ts index cfdc80ac3bf..2d21c410f54 100644 --- a/packages/spec/src/data/hook.form.ts +++ b/packages/spec/src/data/hook.form.ts @@ -46,22 +46,6 @@ export const hookForm = defineForm({ { field: 'memoryMb', type: 'number', helpText: 'Per-invocation memory cap (MB, max 256)' }, ], }, - { - field: 'refusals', - type: 'record', - helpText: 'Refusals the body may throw, by code — the body throws an error carrying the code, and the caller reads this message', - keyField: { - field: 'code', - label: 'Code', - placeholder: 'CREDIT_LIMIT', - helpText: 'SCREAMING_SNAKE code the body throws', - regex: '^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)*$', - }, - fields: [ - { field: 'message', type: 'text', required: true, helpText: 'What the user reads (translated per locale)' }, - { field: 'status', type: 'number', helpText: 'HTTP status, 400–499 (default 400)' }, - ], - }, ], }, { diff --git a/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts b/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts index 8d23c62903d..1ed3c53acdd 100644 --- a/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts +++ b/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts @@ -331,6 +331,15 @@ const LEDGER: ReadonlyArray = [ key: 'requires', why: "platform-written, never authored — the schema's own words: `derived from the source at save — omit it`. The key exists only on an `html` / `jsx` page, where, on a server with the deployment's SDUI manifest, the save door stamps the compiled list and refuses a written list that disagrees (`page-requires-disagrees-with-source`); on every other kind the parse refuses it; and the Studio page editor drops the key on every save. A control would invite the list the describe tells every author to omit", }, + // #26170 — a hook's refusal catalogue is a record keyed by the code, and an + // entry has no member that echoes its key. + { + kind: 'omit', + type: 'hook', + path: ROOT_PATH, + key: 'refusals', + why: "no record control fits it — the form's `record` editor stamps each entry's key into the entry under `keyField` (`{ ...entry, [keyField]: key }`), and a refusal entry is closed at `message` / `status`: the key IS the code, so a stamped `code` (or the default `name`) is refused by `HookRefusalSchema`. The catalogue is authored beside the body it serves, in the hook's source; offering it needs a key-only record control, a form-face addition rather than a reconciliation", + }, // #22158 — the print declaration (ruling B′ on #8346) lands spec-first: // `planned` in the liveness ledger, read by no renderer until card ② (the // console's print rendering), which is also where the ruling puts its From 9f560709c55dad5ae553efb7dfd06f2663fd387d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 16:19:13 +0000 Subject: [PATCH 09/18] chore(spec): regenerate spec artifacts on the merged tree Claude-Session: https://claude.ai/code/session_01WGufzfyVQuRsL6SRGuKCem Co-authored-by: Claude --- content/docs/references/api/protocol.mdx | 2 +- content/docs/references/index.mdx | 14 ++++++----- .../docs/references/system/translation.mdx | 24 ++++++++++++------- .../system.md | 2 +- packages/spec/api-surface/data.json | 1 + packages/spec/api-surface/system.json | 1 + packages/spec/authorable-surface/system.json | 1 + packages/spec/export-origins/data.json | 1 + packages/spec/export-origins/system.json | 1 + .../spec/liveness/state-counts/translation.md | 2 +- 10 files changed, 31 insertions(+), 18 deletions(-) diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 5ecc6326976..208b77a463f 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1778,7 +1778,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **globalActions** | `Record` | optional | Global action translations keyed by action name | | **dashboards** | `Record; widgets?: Record; … }>` | optional | Dashboard translations keyed by dashboard name | | **datasets** | `Record; measures?: Record }>` | optional | Analytics dataset translations keyed by dataset name | -| **reports** | `Record` | optional | Report translations keyed by report name | +| **reports** | `Record }>` | optional | Report translations keyed by report name | | **pages** | `Record` | optional | Page translations keyed by page name | | **flows** | `Record; … }>` | optional | Screen-flow translations keyed by flow name | | **hooks** | `Record }>` | optional | Hook translations keyed by hook name | diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index a2ac028ecf6..401e935510b 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,7 +1,7 @@ --- title: Protocol reference — every schema by module navTitle: Protocol Reference -description: Every schema published by @objectstack/spec — 1550 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1557 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -22,19 +22,19 @@ counts are sums of the rows they head. Regenerate with | :--- | ---: | ---: | :--- | | [AI Protocol](/docs/references/ai) | 12 | 68 | Agents, tools, skills, RAG and knowledge sources, model registry, conversations. | | [API Protocol](/docs/references/api) | 32 | 438 | REST contracts, endpoints, routing, realtime, batch, discovery. | -| [Automation Protocol](/docs/references/automation) | 14 | 73 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. | +| [Automation Protocol](/docs/references/automation) | 15 | 77 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. | | [Data Protocol](/docs/references/data) | 31 | 185 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. | | [Identity Protocol](/docs/references/identity) | 5 | 27 | Users and accounts, organizations, positions, SCIM provisioning. | | [Integration Protocol](/docs/references/integration) | 1 | 12 | The single connector protocol (ADR-0097) — catalog descriptors and provider-bound instances. | | [Kernel Protocol](/docs/references/kernel) | 29 | 151 | Plugin lifecycle and manifests, capabilities and security, metadata loading, service registry. | | [Marketplace Protocol](/docs/references/marketplace) | 4 | 30 | The package & marketplace format — package identity and versions, listing, publish, review, search, install, template manifests. | | [QA Protocol](/docs/references/qa) | 1 | 8 | Declarative test suites — scenarios, steps, actions and assertions. | -| [Security Protocol](/docs/references/security) | 5 | 34 | Permission sets, row-level security, sharing rules, tenancy posture. | +| [Security Protocol](/docs/references/security) | 6 | 37 | Permission sets, row-level security, sharing rules, tenancy posture. | | [Shared Protocol](/docs/references/shared) | 10 | 31 | Primitives used across every protocol — identifiers, HTTP, expressions, error maps, enums. | | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | | [System Protocol](/docs/references/system) | 37 | 287 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 16 | 171 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **200** | **1550** | 14 protocol modules | +| **Total** | **202** | **1557** | 14 protocol modules | --- @@ -106,7 +106,7 @@ REST contracts, endpoints, routing, realtime, batch, discovery. ## Automation Protocol -**Source:** `packages/spec/src/automation/` · **Import:** `@objectstack/spec/automation` · **14 pages, 73 schemas** +**Source:** `packages/spec/src/automation/` · **Import:** `@objectstack/spec/automation` · **15 pages, 77 schemas** Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. @@ -114,6 +114,7 @@ Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execu | :--- | :--- | | [`api-trigger-secret-ref.zod.ts`](/docs/references/automation/api-trigger-secret-ref) | `ApiTriggerSecretRef` | | [`approval.zod.ts`](/docs/references/automation/approval) | `ApprovalDecision`, `ApprovalEscalation`, `ApprovalNodeApprover`, `ApprovalNodeConfig`, `ApproverType`, `DecisionOutputDef` | +| [`assignment-rule.zod.ts`](/docs/references/automation/assignment-rule) | `AssignmentRule`, `AssignmentRuleEntry`, `AssignmentStrategy`, `AssignmentTarget` | | [`bpmn-interop.zod.ts`](/docs/references/automation/bpmn-interop) | `BpmnDiagnostic`, `BpmnElementMapping`, `BpmnExportOptions`, `BpmnImportOptions`, `BpmnInteropResult`, `BpmnUnmappedStrategy`, `BpmnVersion` | | [`builtin-node-config.zod.ts`](/docs/references/automation/builtin-node-config) | `AssignmentConfig`, `AssignmentExpressionValue`, `AssignmentValue`, `CreateRecordConfig`, `DeleteRecordConfig`, `EndConfig`, `FlowCollectionSlot`, `FlowValueSlot`, `GetRecordConfig`, `MapConfig`, `ScreenConfig`, `ScreenFieldConfig`, `UpdateRecordConfig` | | [`control-flow.zod.ts`](/docs/references/automation/control-flow) | `FlowRegion`, `LoopConfig`, `ParallelBranch`, `ParallelConfig`, `RetryPolicy`, `TryCatchConfig`, `TryCatchErrorValue` | @@ -268,7 +269,7 @@ Declarative test suites — scenarios, steps, actions and assertions. ## Security Protocol -**Source:** `packages/spec/src/security/` · **Import:** `@objectstack/spec/security` · **5 pages, 34 schemas** +**Source:** `packages/spec/src/security/` · **Import:** `@objectstack/spec/security` · **6 pages, 37 schemas** Permission sets, row-level security, sharing rules, tenancy posture. @@ -277,6 +278,7 @@ Permission sets, row-level security, sharing rules, tenancy posture. | [`explain.zod.ts`](/docs/references/security/explain) | `AccessMatrix`, `AccessMatrixEntry`, `AuthzPosture`, `ExplainDecision`, `ExplainLayer`, `ExplainMatchedRule`, `ExplainOperation`, `ExplainRecordAttribution`, `ExplainRequest` | | [`misc`](/docs/references/security/misc) *(no single source file)* | `CapabilityDeclaration`, `OrgScopingEntitlement`, `PlatformGlobalObjects`, `TenancyPosture`, `TenantLayer0Verdict` | | [`permission.zod.ts`](/docs/references/security/permission) | `AdminScope`, `EffectiveObjectPermission`, `FieldPermission`, `ObjectAccessScope`, `ObjectPermission`, `PermissionSet` | +| [`queue.zod.ts`](/docs/references/security/queue) | `DeclaredQueueMember`, `Queue`, `QueueMemberType` | | [`rls.zod.ts`](/docs/references/security/rls) | `DefaultRowScope`, `MembershipKey`, `MembershipKeyName`, `MembershipKeys`, `RLSEvaluationResult`, `RLSOperation`, `RLSUserContext`, `RowLevelSecurityPolicy` | | [`sharing.zod.ts`](/docs/references/security/sharing) | `CriteriaSharingRule`, `OWDModel`, `ShareRecipientType`, `SharingLevel`, `SharingRule`, `SharingRuleType` | diff --git a/content/docs/references/system/translation.mdx b/content/docs/references/system/translation.mdx index f45c6dba9a6..99e038a5b21 100644 --- a/content/docs/references/system/translation.mdx +++ b/content/docs/references/system/translation.mdx @@ -66,6 +66,7 @@ Translation data for a single field | **help** | `string` | optional | Translated help text | | **placeholder** | `string` | optional | Translated placeholder text for form inputs | | **options** | `Record` | optional | Option value to translated label map | +| **inlineTitle** | `string` | optional | Translated inline master-detail grid title — overlays the field's own `inlineTitle` | --- @@ -90,7 +91,7 @@ Translation data for a single object | **label** | `string` | optional | Translated singular label | | **pluralLabel** | `string` | optional | Translated plural label | | **description** | `string` | optional | Translated object description | -| **fields** | `Record }>` | optional | Field-level translations | +| **fields** | `Record; … }>` | optional | Field-level translations | | **_views** | `Record` | optional | View translations keyed by view name | | **_actions** | `Record` | optional | Action translations keyed by action name | | **_sections** | `Record }>` | optional | Section translations keyed by section name | @@ -107,6 +108,7 @@ Translation data for a single field | **help** | `string` | optional | Translated help text | | **placeholder** | `string` | optional | Translated placeholder text for form inputs | | **options** | `Record` | optional | Option value to translated label map | +| **inlineTitle** | `string` | optional | Translated inline master-detail grid title — overlays the field's own `inlineTitle` | ### Nested Shape: `ObjectTranslationData._views[string]` @@ -177,7 +179,7 @@ Platform translation data — the per-app groups plus the platform-only `setting | **globalActions** | `Record` | optional | Global action translations keyed by action name | | **dashboards** | `Record; widgets?: Record; … }>` | optional | Dashboard translations keyed by dashboard name | | **datasets** | `Record; measures?: Record }>` | optional | Analytics dataset translations keyed by dataset name | -| **reports** | `Record` | optional | Report translations keyed by report name | +| **reports** | `Record }>` | optional | Report translations keyed by report name | | **pages** | `Record` | optional | Page translations keyed by page name | | **flows** | `Record; … }>` | optional | Screen-flow translations keyed by flow name | | **hooks** | `Record }>` | optional | Hook translations keyed by hook name | @@ -196,7 +198,7 @@ Translation data for a single object | **label** | `string` | optional | Translated singular label | | **pluralLabel** | `string` | optional | Translated plural label | | **description** | `string` | optional | Translated object description | -| **fields** | `Record }>` | optional | Field-level translations | +| **fields** | `Record; … }>` | optional | Field-level translations | | **_views** | `Record` | optional | View translations keyed by view name | | **_actions** | `Record` | optional | Action translations keyed by action name | | **_sections** | `Record }>` | optional | Section translations keyed by section name | @@ -255,6 +257,7 @@ Translation data for a single object | :--- | :--- | :--- | :--- | | **label** | `string` | optional | Translated report label — overlays the report's own `label` | | **description** | `string` | optional | Translated report description — overlays the report's own `description` | +| **blocks** | `Record` | optional | Joined report block translations keyed by block name | ### Nested Shape: `PlatformTranslationData.pages[string]` @@ -341,7 +344,7 @@ Served translation data — the platform groups the browser reads, without the s | **globalActions** | `Record` | optional | Global action translations keyed by action name | | **dashboards** | `Record; widgets?: Record; … }>` | optional | Dashboard translations keyed by dashboard name | | **datasets** | `Record; measures?: Record }>` | optional | Analytics dataset translations keyed by dataset name | -| **reports** | `Record` | optional | Report translations keyed by report name | +| **reports** | `Record }>` | optional | Report translations keyed by report name | | **pages** | `Record` | optional | Page translations keyed by page name | | **flows** | `Record; … }>` | optional | Screen-flow translations keyed by flow name | | **hooks** | `Record }>` | optional | Hook translations keyed by hook name | @@ -359,7 +362,7 @@ Translation data for a single object | **label** | `string` | optional | Translated singular label | | **pluralLabel** | `string` | optional | Translated plural label | | **description** | `string` | optional | Translated object description | -| **fields** | `Record }>` | optional | Field-level translations | +| **fields** | `Record; … }>` | optional | Field-level translations | | **_views** | `Record` | optional | View translations keyed by view name | | **_actions** | `Record` | optional | Action translations keyed by action name | | **_sections** | `Record }>` | optional | Section translations keyed by section name | @@ -418,6 +421,7 @@ Translation data for a single object | :--- | :--- | :--- | :--- | | **label** | `string` | optional | Translated report label — overlays the report's own `label` | | **description** | `string` | optional | Translated report description — overlays the report's own `description` | +| **blocks** | `Record` | optional | Joined report block translations keyed by block name | ### Nested Shape: `ServedTranslationData.pages[string]` @@ -566,7 +570,7 @@ Per-app translation data for objects, apps, and UI messages | **globalActions** | `Record` | optional | Global action translations keyed by action name | | **dashboards** | `Record; widgets?: Record; … }>` | optional | Dashboard translations keyed by dashboard name | | **datasets** | `Record; measures?: Record }>` | optional | Analytics dataset translations keyed by dataset name | -| **reports** | `Record` | optional | Report translations keyed by report name | +| **reports** | `Record }>` | optional | Report translations keyed by report name | | **pages** | `Record` | optional | Page translations keyed by page name | | **flows** | `Record; … }>` | optional | Screen-flow translations keyed by flow name | | **hooks** | `Record }>` | optional | Hook translations keyed by hook name | @@ -584,7 +588,7 @@ Translation data for a single object | **label** | `string` | optional | Translated singular label | | **pluralLabel** | `string` | optional | Translated plural label | | **description** | `string` | optional | Translated object description | -| **fields** | `Record }>` | optional | Field-level translations | +| **fields** | `Record; … }>` | optional | Field-level translations | | **_views** | `Record` | optional | View translations keyed by view name | | **_actions** | `Record` | optional | Action translations keyed by action name | | **_sections** | `Record }>` | optional | Section translations keyed by section name | @@ -643,6 +647,7 @@ Translation data for a single object | :--- | :--- | :--- | :--- | | **label** | `string` | optional | Translated report label — overlays the report's own `label` | | **description** | `string` | optional | Translated report description — overlays the report's own `description` | +| **blocks** | `Record` | optional | Joined report block translations keyed by block name | ### Nested Shape: `TranslationData.pages[string]` @@ -750,7 +755,7 @@ One locale of translations — the `translation` metadata type | **globalActions** | `Record` | optional | Global action translations keyed by action name | | **dashboards** | `Record; widgets?: Record; … }>` | optional | Dashboard translations keyed by dashboard name | | **datasets** | `Record; measures?: Record }>` | optional | Analytics dataset translations keyed by dataset name | -| **reports** | `Record` | optional | Report translations keyed by report name | +| **reports** | `Record }>` | optional | Report translations keyed by report name | | **pages** | `Record` | optional | Page translations keyed by page name | | **flows** | `Record; … }>` | optional | Screen-flow translations keyed by flow name | | **hooks** | `Record }>` | optional | Hook translations keyed by hook name | @@ -778,7 +783,7 @@ Translation data for a single object | **label** | `string` | optional | Translated singular label | | **pluralLabel** | `string` | optional | Translated plural label | | **description** | `string` | optional | Translated object description | -| **fields** | `Record }>` | optional | Field-level translations | +| **fields** | `Record; … }>` | optional | Field-level translations | | **_views** | `Record` | optional | View translations keyed by view name | | **_actions** | `Record` | optional | Action translations keyed by action name | | **_sections** | `Record }>` | optional | Section translations keyed by section name | @@ -837,6 +842,7 @@ Translation data for a single object | :--- | :--- | :--- | :--- | | **label** | `string` | optional | Translated report label — overlays the report's own `label` | | **description** | `string` | optional | Translated report description — overlays the report's own `description` | +| **blocks** | `Record` | optional | Joined report block translations keyed by block name | ### Nested Shape: `TranslationItem.pages[string]` diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md index 4663ecd292e..c9ba2459292 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md @@ -19,4 +19,4 @@ hand-patch a number here** — fix the code or the verdict and regenerate. | Dir | Sites | |---|---| -| `system/` | 385 | +| `system/` | 386 | diff --git a/packages/spec/api-surface/data.json b/packages/spec/api-surface/data.json index 27c15adc5c5..cb29567e8e1 100644 --- a/packages/spec/api-surface/data.json +++ b/packages/spec/api-surface/data.json @@ -599,6 +599,7 @@ "QUERY_TRANSPORT_ALIAS_SLOTS (const)", "QUERY_TRANSPORT_DOLLAR_ALIASES (const)", "QUERY_TRANSPORT_DOLLAR_PARAMS (const)", + "QUEUE_FIELD_DEF (const)", "QueryAST (type)", "QueryAliasConflict (interface)", "QueryAliasSlot (interface)", diff --git a/packages/spec/api-surface/system.json b/packages/spec/api-surface/system.json index df3afcc1450..5d32e1ec34b 100644 --- a/packages/spec/api-surface/system.json +++ b/packages/spec/api-surface/system.json @@ -559,6 +559,7 @@ "RenderOperationMessageOptions (interface)", "RenderValidationMessageInput (interface)", "RenderValidationMessageOptions (interface)", + "ReportBlockLike (interface)", "ReportLike (interface)", "ResolveOptions (interface)", "ResolvedBook (interface)", diff --git a/packages/spec/authorable-surface/system.json b/packages/spec/authorable-surface/system.json index d11bc4ea1c0..da46361781c 100644 --- a/packages/spec/authorable-surface/system.json +++ b/packages/spec/authorable-surface/system.json @@ -441,6 +441,7 @@ "system/FieldEncryption:fieldName", "system/FieldEncryption:indexable", "system/FieldTranslation:help", + "system/FieldTranslation:inlineTitle", "system/FieldTranslation:label", "system/FieldTranslation:options", "system/FieldTranslation:placeholder", diff --git a/packages/spec/export-origins/data.json b/packages/spec/export-origins/data.json index 5f3e212ee3a..e82759d867c 100644 --- a/packages/spec/export-origins/data.json +++ b/packages/spec/export-origins/data.json @@ -588,6 +588,7 @@ "QUERY_TRANSPORT_ALIAS_SLOTS": "src/data/data-engine.zod.ts#QUERY_TRANSPORT_ALIAS_SLOTS (const)", "QUERY_TRANSPORT_DOLLAR_ALIASES": "src/data/data-engine.zod.ts#QUERY_TRANSPORT_DOLLAR_ALIASES (const)", "QUERY_TRANSPORT_DOLLAR_PARAMS": "src/data/data-engine.zod.ts#QUERY_TRANSPORT_DOLLAR_PARAMS (const)", + "QUEUE_FIELD_DEF": "src/data/injected-system-column-provenance.ts#QUEUE_FIELD_DEF (const)", "QueryAST": "src/data/query.zod.ts#QueryAST (type)", "QueryAliasConflict": "src/data/data-engine.zod.ts#QueryAliasConflict (interface)", "QueryAliasSlot": "src/data/data-engine.zod.ts#QueryAliasSlot (interface)", diff --git a/packages/spec/export-origins/system.json b/packages/spec/export-origins/system.json index 205b3a05823..b995ffe938c 100644 --- a/packages/spec/export-origins/system.json +++ b/packages/spec/export-origins/system.json @@ -535,6 +535,7 @@ "RenderOperationMessageOptions": "src/system/operation-message.ts#RenderOperationMessageOptions (interface)", "RenderValidationMessageInput": "src/system/validation-message.ts#RenderValidationMessageInput (interface)", "RenderValidationMessageOptions": "src/system/validation-message.ts#RenderValidationMessageOptions (interface)", + "ReportBlockLike": "src/system/i18n-resolver.ts#ReportBlockLike (interface)", "ReportLike": "src/system/i18n-resolver.ts#ReportLike (interface)", "ResolveOptions": "src/system/i18n-resolver.ts#ResolveOptions (interface)", "ResolvedBook": "src/system/book.zod.ts#ResolvedBook (interface)", diff --git a/packages/spec/liveness/state-counts/translation.md b/packages/spec/liveness/state-counts/translation.md index 576beb01e2d..f8cbfa91f69 100644 --- a/packages/spec/liveness/state-counts/translation.md +++ b/packages/spec/liveness/state-counts/translation.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `translation` | 37 | 0 | 0 | 0 | 0 | 37 | +| `translation` | 39 | 0 | 0 | 0 | 0 | 39 | From 8b3638b15065170b0a67ee458583d12400989c84 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 17:13:24 +0000 Subject: [PATCH 10/18] test(spec): pin HookRefusalSchema's two shapes as one (ADR-0122) Claude-Session: https://claude.ai/code/session_01WGufzfyVQuRsL6SRGuKCem Co-authored-by: Claude --- packages/spec/src/type-alias-convention.pin.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts index 381b1f9ebac..1d0467d496b 100644 --- a/packages/spec/src/type-alias-convention.pin.test.ts +++ b/packages/spec/src/type-alias-convention.pin.test.ts @@ -898,6 +898,7 @@ export type Iso_data_hookBody__HookBodyCapability = Assert, z.infer< typeof M69.HookContextSchema > >>; export type Iso_data_hook__HookEvent = Assert, z.infer< typeof M69.HookEvent > >>; +export type Iso_data_hook__HookRefusalSchema = Assert, z.infer< typeof M69.HookRefusalSchema > >>; // data/mapping.zod.ts export type Iso_data_mapping__TransformType = Assert, z.infer< typeof M179.TransformType > >>; From 72219f52f5d8e1fcfb9d2418e9b2a975b7454b2c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 17:16:41 +0000 Subject: [PATCH 11/18] chore(spec): regenerate spec artifacts on the merged tree Claude-Session: https://claude.ai/code/session_01WGufzfyVQuRsL6SRGuKCem Co-authored-by: Claude --- packages/spec/authorable-surface/system.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/spec/authorable-surface/system.json b/packages/spec/authorable-surface/system.json index da46361781c..0dbb43b43bf 100644 --- a/packages/spec/authorable-surface/system.json +++ b/packages/spec/authorable-surface/system.json @@ -294,6 +294,7 @@ "system/DeleteObjectOperation:objectName", "system/DeleteObjectOperation:type", "system/DemoStaff:email", + "system/DemoStaff:manager", "system/DemoStaff:name", "system/DemoStaff:permissionSets", "system/DemoStaff:position", From 78dc14528e8a3d403f551ff7500b33e72c984b26 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 17:37:24 +0000 Subject: [PATCH 12/18] test(spec): count the HookRefusalSchema pin in the ADR-0122 census Claude-Session: https://claude.ai/code/session_01WGufzfyVQuRsL6SRGuKCem Co-authored-by: Claude --- packages/spec/src/type-alias-convention.pin.test.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts index 1d0467d496b..093c204e49e 100644 --- a/packages/spec/src/type-alias-convention.pin.test.ts +++ b/packages/spec/src/type-alias-convention.pin.test.ts @@ -285,7 +285,7 @@ import type * as M194 from './automation/assignment-rule.zod.js'; import type * as M195 from './security/queue.zod.js'; // --------------------------------------------------------------------------- -// 796 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. +// 797 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared. // // That number is machine-checked, not hand-kept. The runtime companion at the // bottom of this file recomputes the pin count from the source and asserts that @@ -1731,7 +1731,7 @@ describe('ADR-0122 type-alias convention', () => { // this title and the section header above the pin list — are now asserted // against the recomputed count below, so neither can go stale without a red // test naming it. - it('still declares all 796 isomorphic pins', () => { + it('still declares all 797 isomorphic pins', () => { // The truth of each pin is proved by tsc, not here — an `Assert>` // that stops holding is a compile error with the alias named. What tsc // cannot notice is a pin that was DELETED: removing the assertion removes @@ -2648,7 +2648,11 @@ describe('ADR-0122 type-alias convention', () => { // Iso_automation_assignmentRule__AssignmentStrategy, // Iso_automation_assignmentRule__AssignmentTargetSchema). The rule's entry // schema has two shapes and carries `AssignmentRuleEntryParsed` instead. +4 added. - expect(pins).toHaveLength(796); + // + // 796 -> 797 is #26170: a hook's declared refusal entry, `HookRefusalSchema` + // (`data/hook.zod.ts`), closed at `message` / `status` with no default or + // transform, pinned here (Iso_data_hook__HookRefusalSchema). +1 added. + expect(pins).toHaveLength(797); // The count is stated in PROSE twice as well — this case's title and the // section header above the pin list — and until commit c6b05c76a nothing read either From e5ea8881c777cf7b9ed8f5c31985524720593d5f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 18:03:20 +0000 Subject: [PATCH 13/18] test(spec): mark the lower-case code fixture as source text, not an emitted error code Claude-Session: https://claude.ai/code/session_01WGufzfyVQuRsL6SRGuKCem Co-authored-by: Claude --- packages/spec/src/data/hook-refusals.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/spec/src/data/hook-refusals.test.ts b/packages/spec/src/data/hook-refusals.test.ts index 6495b2dbf38..8506b084af4 100644 --- a/packages/spec/src/data/hook-refusals.test.ts +++ b/packages/spec/src/data/hook-refusals.test.ts @@ -133,6 +133,7 @@ describe('hookRefusalCodesThrownIn — the save-time reading', () => { // an assignment on a name that is never thrown bare "const row = {}; row.code = 'ROW'; throw new Error('x');", // a lower-case value is never a refusal code + // adr0112-ok: a fixture body's source text the save-time reader must NOT read as a code; nothing emits it "throw Object.assign(new Error('x'), { code: 'lower' });", // inside a comment or a string "// throw Object.assign(new Error(), { code: 'COMMENTED' });\nreturn;", From c19dbcd0d4bed8e5ba3c101983007fe7c5d6ffda Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 18:19:17 +0000 Subject: [PATCH 14/18] fix(spec): refuse `refusals` on a hook with no js body, and a `{{ }}` hole in a refusal message (#26170) Contract review FAIL-2: a catalogue beside an in-process `handler` (or an expression body) was saved, its translations demanded by `os i18n`, and never answered. HookSchema now refuses it at `refusals`, naming the remedy. A refusal message is plain text; a `{{ }}` hole is refused by a pattern the published JSON Schema carries too, so a later params slot stays additive. The liveness note no longer claims the build lowers every handler to a body. The changeset names @object-ui/i18n and @object-ui/app-shell, which move. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01WGufzfyVQuRsL6SRGuKCem --- .changeset/26170-hook-refusal-catalogue.md | 7 ++-- content/docs/references/data/hook.mdx | 4 +-- packages/spec/api-surface/data.json | 3 ++ packages/spec/export-origins/data.json | 3 ++ packages/spec/liveness/hook.json | 2 +- packages/spec/src/data/hook-refusal-codes.ts | 27 +++++++++++++++ packages/spec/src/data/hook-refusals.test.ts | 33 +++++++++++++++++-- packages/spec/src/data/hook.zod.ts | 28 ++++++++++++---- .../objectstack-data/references/data-hooks.md | 9 ++--- 9 files changed, 98 insertions(+), 18 deletions(-) diff --git a/.changeset/26170-hook-refusal-catalogue.md b/.changeset/26170-hook-refusal-catalogue.md index 55e982366cd..5b165a5da57 100644 --- a/.changeset/26170-hook-refusal-catalogue.md +++ b/.changeset/26170-hook-refusal-catalogue.md @@ -4,6 +4,8 @@ '@objectstack/objectql': minor '@objectstack/cli': minor '@objectstack/lint': minor +'@object-ui/i18n': patch +'@object-ui/app-shell': patch --- feat(spec): a hook declares its refusal catalogue, and its body refuses by code, answered in the caller's language @@ -13,10 +15,11 @@ Metadata-protocol: added A hook can now declare `refusals`: one entry per SCREAMING_SNAKE code, each with the `message` the user reads and an optional 4xx `status` (default `400`). A sandboxed `body` then refuses by code only: it throws an error carrying a declared `code`, and the body runner answers with the entry. The message is translated through `hooks..refusals..message` in the caller's locale, the code reaches the caller's error envelope as `declaredCode` (beside the status's registered `code`), and the status is the entry's. The body's own error text and `status` are not what the caller reads. -- **Spec.** `HookSchema.refusals` (`HookRefusalSchema`: `message`, `status?`). A new `hooks` group on the translation bundle, `hooks..refusals..message`, and `hookRefusalMessageKey`. `HookContext.session.locale` declares the caller's locale. -- **Undeclared codes are refused loudly.** A code the body throws literally (`{ code: 'X' }`, or `e.code = 'X'; throw e`) that the catalogue does not declare fails the save at `body.source`. Any other undeclared code, and a throw with no code, is a `500` fault at run time that names the hook and the code. This applies only to a hook that declares `refusals`; a hook without one is answered exactly as before. +- **Spec.** `HookSchema.refusals` (`HookRefusalSchema`: `message`, `status?`). A new `hooks` group on the translation bundle, `hooks..refusals..message`, and `hookRefusalMessageKey`. A message is plain text: a `{{ }}` hole is refused at save, so a later parameter slot stays additive. `HookContext.session.locale` declares the caller's locale. +- **Undeclared codes are refused loudly.** A code the body throws literally (`{ code: 'X' }`, or `e.code = 'X'; throw e`) that the catalogue does not declare fails the save at `body.source`. Any other undeclared code, and a throw with no code, is a `500` fault at run time that names the hook and the code. A catalogue on a hook with no sandboxed `js` body (an in-process `handler`, an expression body) is refused at save, since nothing would answer it. All of this applies only to a hook that declares `refusals`; a hook without one is answered exactly as before. - **Runtime.** The body runner resolves the throw against the catalogue and picks the translation through the `i18n` service, negotiated against the locales it holds. An error a `ctx.api` call raised that the body lets leave unchanged, a crash, and the sandbox's own faults pass through untouched. ObjectQL copies the request's locale onto the hook session. - **CLI.** `os i18n extract` scaffolds one key per declared entry and `os i18n check` counts them, in a new `hook` coverage bucket. - **Lint.** A translation keyed to a hook the stack does not define, or to a code its `refusals` does not declare, is reported as `translation-target-unknown`. +- **Console.** The translation preview shows a `hooks` category (en/zh labels), and the translation loader lists `hooks` among the spec groups. Nothing to do for existing hooks. To use it, declare `refusals` on a sandboxed hook and throw its codes. diff --git a/content/docs/references/data/hook.mdx b/content/docs/references/data/hook.mdx index 7b6c1fc2b79..82e94eda253 100644 --- a/content/docs/references/data/hook.mdx +++ b/content/docs/references/data/hook.mdx @@ -84,7 +84,7 @@ L2 sandboxed JS body — runs inside an isolated VM with declared capabilities | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **message** | `string` | ✅ | The sentence the user reads when the body throws this code — plain text in the source locale, translated whole through hooks.``.refusals.``.message in the caller's locale; no `{{ }}` holes are filled | +| **message** | `string` | ✅ | The sentence the user reads when the body throws this code — plain text in the source locale, translated whole through hooks.``.refusals.``.message in the caller's locale; a `{{ }}` hole is refused | | **status** | `integer` | optional | HTTP status the refusal is answered with (4xx; a refusal is never a fault). Absent: 400. A status the body itself sets on the throw is not read — the catalogue decides | @@ -179,7 +179,7 @@ L2 sandboxed JS body — runs inside an isolated VM with declared capabilities | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **message** | `string` | ✅ | The sentence the user reads when the body throws this code — plain text in the source locale, translated whole through hooks.``.refusals.``.message in the caller's locale; no `{{ }}` holes are filled | +| **message** | `string` | ✅ | The sentence the user reads when the body throws this code — plain text in the source locale, translated whole through hooks.``.refusals.``.message in the caller's locale; a `{{ }}` hole is refused | | **status** | `integer` | optional | HTTP status the refusal is answered with (4xx; a refusal is never a fault). Absent: 400. A status the body itself sets on the throw is not read — the catalogue decides | diff --git a/packages/spec/api-surface/data.json b/packages/spec/api-surface/data.json index cb29567e8e1..0d8cd5c1cb9 100644 --- a/packages/spec/api-surface/data.json +++ b/packages/spec/api-surface/data.json @@ -375,6 +375,8 @@ "GroupByNode (type)", "GroupByNodeSchema (const)", "HOOK_REFUSAL_CODE_PATTERN (const)", + "HOOK_REFUSAL_MESSAGE_HOLE_REFUSED (const)", + "HOOK_REFUSAL_MESSAGE_WITHOUT_HOLE (const)", "Hook (type)", "HookApi (interface)", "HookBody (type)", @@ -907,6 +909,7 @@ "hasNulInLikePattern (function)", "hookForm (const)", "hookRefusalCodesThrownIn (function)", + "hookRefusalsWithoutSandboxedBody (function)", "importMappingEntryTargets (function)", "indexImportMappingTargets (function)", "injectedSystemColumnDefs (function)", diff --git a/packages/spec/export-origins/data.json b/packages/spec/export-origins/data.json index e82759d867c..31ca2e009c5 100644 --- a/packages/spec/export-origins/data.json +++ b/packages/spec/export-origins/data.json @@ -366,6 +366,8 @@ "GroupByNode": "src/data/query.zod.ts#GroupByNode (type)", "GroupByNodeSchema": "src/data/query.zod.ts#GroupByNodeSchema (const)", "HOOK_REFUSAL_CODE_PATTERN": "src/data/hook-refusal-codes.ts#HOOK_REFUSAL_CODE_PATTERN (const)", + "HOOK_REFUSAL_MESSAGE_HOLE_REFUSED": "src/data/hook-refusal-codes.ts#HOOK_REFUSAL_MESSAGE_HOLE_REFUSED (const)", + "HOOK_REFUSAL_MESSAGE_WITHOUT_HOLE": "src/data/hook-refusal-codes.ts#HOOK_REFUSAL_MESSAGE_WITHOUT_HOLE (const)", "Hook": "src/data/hook.zod.ts#Hook (type)", "HookApi": "src/data/hook-api.ts#HookApi (interface)", "HookBody": "src/data/hook-body.zod.ts#HookBody (type)", @@ -893,6 +895,7 @@ "hasNulInLikePattern": "src/data/filter.zod.ts#hasNulInLikePattern (function)", "hookForm": "src/data/hook.form.ts#hookForm (const)", "hookRefusalCodesThrownIn": "src/data/hook-refusal-codes.ts#hookRefusalCodesThrownIn (function)", + "hookRefusalsWithoutSandboxedBody": "src/data/hook-refusal-codes.ts#hookRefusalsWithoutSandboxedBody (function)", "importMappingEntryTargets": "src/data/import-mapping-target.ts#importMappingEntryTargets (function)", "indexImportMappingTargets": "src/data/import-mapping-target.ts#indexImportMappingTargets (function)", "injectedSystemColumnDefs": "src/data/injected-system-column-provenance.ts#injectedSystemColumnDefs (function)", diff --git a/packages/spec/liveness/hook.json b/packages/spec/liveness/hook.json index 5344fd6cec3..20d747d6af1 100644 --- a/packages/spec/liveness/hook.json +++ b/packages/spec/liveness/hook.json @@ -104,7 +104,7 @@ "evidence": "packages/runtime/src/sandbox/hook-refusal-catalogue.ts#resolveHookRefusal (the refusal carries the declared `status`, else 400; the status the body set on its own throw is not read)" } }, - "note": "[#26170, maintainer ruling 6431311681 letter A] Seeded `live` together with its reader: the key, the body runner that answers it, the translation group and the coverage count land in one change (Prime Directive 10). Read on the sandboxed `body` path only; an in-process `handler` is answered as before (the build lowers an inline handler to a `body`, which is the path a served runtime runs). An error a host call raised and the body let through unchanged, a nested hook's refusal, a crash and a sandbox fault pass through untouched. DRILLED to the entry's two keys: the entry face is closed (`.strict()`)." + "note": "[#26170, maintainer ruling 6431311681 letter A] Seeded `live` together with its reader: the key, the body runner that answers it, the translation group and the coverage count land in one change (Prime Directive 10). Read on the sandboxed `body` path only, so the schema refuses `refusals` on a hook with no `js` body (an in-process `handler`, an expression body): a catalogue nothing would answer is never saved. An error a host call raised and the body let through unchanged, a nested hook's refusal, a crash and a sandbox fault pass through untouched. DRILLED to the entry's two keys: the entry face is closed (`.strict()`)." }, "label": { "status": "live", diff --git a/packages/spec/src/data/hook-refusal-codes.ts b/packages/spec/src/data/hook-refusal-codes.ts index af0202ed9d6..c2bbbe725be 100644 --- a/packages/spec/src/data/hook-refusal-codes.ts +++ b/packages/spec/src/data/hook-refusal-codes.ts @@ -180,6 +180,33 @@ export function undeclaredHookRefusalCodeAtSave(code: string, declared: readonly ); } +/** + * A refusal message with no `{{ … }}` hole. Nothing fills one today (a declared + * message is plain text, translated whole), so a hole is refused at save: a + * later params slot then stays a widening instead of re-reading text authors + * already saved with braces in it. A pattern, not a refinement, so the + * published JSON Schema refuses the same text. + */ +export const HOOK_REFUSAL_MESSAGE_WITHOUT_HOLE = /^(?![\s\S]*\{\{[^{}]*\}\})[\s\S]*$/; + +/** The save-time refusal for a `{{ … }}` hole in a refusal message. */ +export const HOOK_REFUSAL_MESSAGE_HOLE_REFUSED = + 'A hook refusal `message` is plain text and nothing fills a `{{ }}` hole: the caller would read the braces as ' + + 'written. Write the sentence without the hole (a refusal carries no parameters yet).'; + +/** + * The save-time refusal for a catalogue on a hook with no sandboxed `js` body: + * the catalogue is answered only on that path. + */ +export function hookRefusalsWithoutSandboxedBody(language: string | undefined): string { + const has = language === undefined ? 'no `body`' : `a \`${language}\` body`; + return ( + `\`refusals\` is answered only for a sandboxed \`body\` (\`language: 'js'\`), and this hook has ${has}: ` + + 'an in-process `handler` or an expression body would never serve the catalogue, while `os i18n` still ' + + 'demanded its translations. Give the hook a `js` body that throws the declared codes, or drop `refusals`.' + ); +} + /** * The run-time refusal for a body throw the catalogue cannot answer: a code it * does not declare, or no code at all. Fault text, for the server log and the diff --git a/packages/spec/src/data/hook-refusals.test.ts b/packages/spec/src/data/hook-refusals.test.ts index 8506b084af4..406ac3ea338 100644 --- a/packages/spec/src/data/hook-refusals.test.ts +++ b/packages/spec/src/data/hook-refusals.test.ts @@ -2,7 +2,9 @@ import { describe, it, expect } from 'vitest'; import { HookSchema, HookRefusalSchema, HookContextSchema } from './hook.zod'; import { HOOK_REFUSAL_CODE_PATTERN, + HOOK_REFUSAL_MESSAGE_HOLE_REFUSED, hookRefusalCodesThrownIn, + hookRefusalsWithoutSandboxedBody, undeclaredHookRefusalAtRun, undeclaredHookRefusalCodeAtSave, } from './hook-refusal-codes'; @@ -36,13 +38,40 @@ describe('Hook.refusals — the declared catalogue', () => { }); it('refuses a code that is not SCREAMING_SNAKE', () => { - const r = HookSchema.safeParse({ ...base, handler: 'h', refusals: { creditLimit: { message: 'x' } } }); + const r = HookSchema.safeParse({ ...base, body: body('return;'), refusals: { creditLimit: { message: 'x' } } }); expect(r.success).toBe(false); expect(r.error!.issues.some((i) => i.path[0] === 'refusals')).toBe(true); }); it('refuses an empty message', () => { - expect(HookSchema.safeParse({ ...base, handler: 'h', refusals: { NO: { message: ' ' } } }).success).toBe(false); + expect(HookSchema.safeParse({ ...base, body: body('return;'), refusals: { NO: { message: ' ' } } }).success).toBe(false); + }); + + it.each([ + ['an in-process `handler`', { handler: 'creditGuard' }, 'no `body`'], + ['an expression body', { body: { language: 'expression', source: 'record.amount > 0' } }, 'a `expression` body'], + ])('refuses a catalogue on a hook with %s — nothing would answer it', (_label, rest, has) => { + const r = HookSchema.safeParse({ ...base, ...rest, refusals: { CREDIT_LIMIT: { message: 'x' } } }); + expect(r.success).toBe(false); + const issue = r.error!.issues.find((i) => i.path.join('.') === 'refusals')!; + expect(issue.message).toBe(hookRefusalsWithoutSandboxedBody(has === 'no `body`' ? undefined : 'expression')); + expect(issue.message).toContain(has); + }); + + it.each(['Over the limit of {{limit}}.', 'Hi {{ user.name }}, no.', 'Line one.\nOver {{limit}}.'])( + 'refuses a `{{ }}` hole in %j — nothing fills it, and a params slot stays additive', + (message) => { + const r = HookSchema.safeParse({ ...base, body: body('return;'), refusals: { CREDIT_LIMIT: { message } } }); + expect(r.success).toBe(false); + const issue = r.error!.issues.find((i) => i.path.join('.') === 'refusals.CREDIT_LIMIT.message')!; + expect(issue.message).toBe(HOOK_REFUSAL_MESSAGE_HOLE_REFUSED); + }, + ); + + it('accepts single braces and an unclosed pair as plain text', () => { + for (const message of ['Use {x} here.', 'An open {{ brace.']) { + expect(HookRefusalSchema.safeParse({ message }).success, message).toBe(true); + } }); it.each([399, 500, 503, 404.5])('refuses status %s — a refusal is a 4xx', (status) => { diff --git a/packages/spec/src/data/hook.zod.ts b/packages/spec/src/data/hook.zod.ts index 25e2eaa0bee..e1e6f47afc4 100644 --- a/packages/spec/src/data/hook.zod.ts +++ b/packages/spec/src/data/hook.zod.ts @@ -30,7 +30,10 @@ import type { IScopedContext } from '../contracts/scoped-context'; // reads too, so this schema's import graph is unchanged. import { HOOK_REFUSAL_CODE_PATTERN, + HOOK_REFUSAL_MESSAGE_HOLE_REFUSED, + HOOK_REFUSAL_MESSAGE_WITHOUT_HOLE, hookRefusalCodesThrownIn, + hookRefusalsWithoutSandboxedBody, undeclaredHookRefusalCodeAtSave, } from './hook-refusal-codes'; @@ -208,19 +211,30 @@ function refuseElevatedIdentityAssignmentBodyWrite( * author meets the refusal before any write does. One issue per code, at * `body.source`. A code that arrives any other way is the run-time refusal's. * + * A catalogue is answered only on the sandboxed `body` path, so `refusals` on + * a hook with no `js` body — an in-process `handler`, or an `expression` body + * that cannot throw a code — is refused at `refusals`: the catalogue would be + * demanded by `os i18n` and served by nobody. + * * A hook that declares no `refusals` is untouched: its body's throws are * answered as they always were. */ /** - * The object-level check that refuses a code a hook's sandboxed body throws - * literally and its `refusals` does not declare. See the block above. + * The object-level check that refuses a catalogue nothing would answer, and a + * code a hook's sandboxed body throws literally that its `refusals` does not + * declare. See the block above. */ function refuseUndeclaredLiteralRefusalCode( hook: { refusals?: Record; body?: { language?: string; source?: unknown } }, ctx: z.RefinementCtx, ): void { - if (!hook.refusals || hook.body?.language !== 'js' || typeof hook.body.source !== 'string') return; + if (!hook.refusals) return; + if (hook.body?.language !== 'js') { + ctx.addIssue({ code: 'custom', path: ['refusals'], message: hookRefusalsWithoutSandboxedBody(hook.body?.language) }); + return; + } + if (typeof hook.body.source !== 'string') return; const declared = Object.keys(hook.refusals); for (const code of hookRefusalCodesThrownIn(hook.body.source)) { if (Object.prototype.hasOwnProperty.call(hook.refusals, code)) continue; @@ -240,7 +254,7 @@ function refuseUndeclaredLiteralRefusalCode( * The message is plain text, handed over verbatim in the source locale and * replaced whole by its translation, `hooks..refusals..message` — * the validation-rule message's posture (`objects.._validations..message`): - * an authored refusal has no parameter contract, so no `{{ }}` hole is filled. + * an authored refusal has no parameter contract, so a `{{ }}` hole is refused at save. */ export const HookRefusalSchema = lazySchema(() => strictObject( { @@ -256,9 +270,9 @@ export const HookRefusalSchema = lazySchema(() => strictObject( }, }, { - message: z.string().trim().min(1).describe( + message: z.string().trim().min(1).regex(HOOK_REFUSAL_MESSAGE_WITHOUT_HOLE, HOOK_REFUSAL_MESSAGE_HOLE_REFUSED).describe( 'The sentence the user reads when the body throws this code — plain text in the source locale, translated ' - + 'whole through hooks..refusals..message in the caller\'s locale; no {{ }} holes are filled', + + 'whole through hooks..refusals..message in the caller\'s locale; a {{ }} hole is refused', ), status: z.number().int().min(400).max(499).optional().describe( 'HTTP status the refusal is answered with (4xx; a refusal is never a fault). Absent: 400. A status the ' @@ -598,7 +612,7 @@ export const HookSchema = lazySchema(() => strictObject( * * Keyed by hook name, not by object: one hook bound to several objects, or * to `'*'`, owns one catalogue and one translation per code. Read by the - * sandboxed `body` runner; an in-process `handler` is answered as before. + * sandboxed `body` runner only, so it is refused on a hook with no `js` body. * Absent: the hook's throws are answered as they always were. */ refusals: z.record( diff --git a/skills/objectstack-data/references/data-hooks.md b/skills/objectstack-data/references/data-hooks.md index ff5e0d9218c..397cf30f330 100644 --- a/skills/objectstack-data/references/data-hooks.md +++ b/skills/objectstack-data/references/data-hooks.md @@ -953,11 +953,12 @@ body: { language: 'js', source: ` ` }, ``` -- Codes are SCREAMING_SNAKE; the error envelope carries one as `declaredCode`. -- The plain-text message is translated via `hooks..refusals..message` - in the caller's locale; the body's own error text and `status` are not used. +- Codes are SCREAMING_SNAKE; the envelope carries one as `declaredCode`. +- Refused at save without a `js` body (e.g. a bare `handler`). +- The message is plain text (a `{{ }}` hole is refused), translated via + `hooks..refusals..message`; the body's own text and `status` are unused. - An undeclared code, or none, is refused: at save if thrown literally, else as - a `500` fault. Unchanged `ctx.api` errors and crashes pass through. + a `500`. Unchanged `ctx.api` errors and crashes pass through. --- From 8f7675f85b95d944d747fee6de09498cee3f520b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 18:22:55 +0000 Subject: [PATCH 15/18] chore(spec): regenerate spec artifacts on the merged tree Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01WGufzfyVQuRsL6SRGuKCem --- content/docs/references/system/translation.mdx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/content/docs/references/system/translation.mdx b/content/docs/references/system/translation.mdx index 99e038a5b21..cb36aec08f9 100644 --- a/content/docs/references/system/translation.mdx +++ b/content/docs/references/system/translation.mdx @@ -128,6 +128,7 @@ Translation data for a single field | **description** | `string` | optional | Translated action description — the explanatory line under the title in the action's param dialog | | **confirmText** | `string` | optional | Translated confirmation prompt | | **successMessage** | `string` | optional | Translated success toast/message | +| **disabledReason** | `string` | optional | Translated reason for a greyed action, for its hover title and accessible description while its `disabled` predicate is TRUE (no renderer shows it yet) | | **outcomeMessages** | `Record` | optional | Translated success copy per handler outcome, keyed by the snake_case outcome name the action's `outcomeMessages` declares | | **params** | `Record }>` | optional | Action parameter translations keyed by parameter name | | **resultDialog** | `{ title?: string; description?: string; acknowledge?: string; fields?: Record }` | optional | Translations for the action result dialog | @@ -228,6 +229,7 @@ Translation data for a single object | **description** | `string` | optional | Translated action description — the explanatory line under the title in the action's param dialog | | **confirmText** | `string` | optional | Translated confirmation prompt | | **successMessage** | `string` | optional | Translated success toast/message | +| **disabledReason** | `string` | optional | Translated reason for a greyed action, for its hover title and accessible description while its `disabled` predicate is TRUE (no renderer shows it yet) | | **outcomeMessages** | `Record` | optional | Translated success copy per handler outcome, keyed by the snake_case outcome name the action's `outcomeMessages` declares | | **params** | `Record }>` | optional | Action parameter translations keyed by parameter name | | **resultDialog** | `{ title?: string; description?: string; acknowledge?: string; fields?: Record }` | optional | Translations for the action result dialog | @@ -392,6 +394,7 @@ Translation data for a single object | **description** | `string` | optional | Translated action description — the explanatory line under the title in the action's param dialog | | **confirmText** | `string` | optional | Translated confirmation prompt | | **successMessage** | `string` | optional | Translated success toast/message | +| **disabledReason** | `string` | optional | Translated reason for a greyed action, for its hover title and accessible description while its `disabled` predicate is TRUE (no renderer shows it yet) | | **outcomeMessages** | `Record` | optional | Translated success copy per handler outcome, keyed by the snake_case outcome name the action's `outcomeMessages` declares | | **params** | `Record }>` | optional | Action parameter translations keyed by parameter name | | **resultDialog** | `{ title?: string; description?: string; acknowledge?: string; fields?: Record }` | optional | Translations for the action result dialog | @@ -618,6 +621,7 @@ Translation data for a single object | **description** | `string` | optional | Translated action description — the explanatory line under the title in the action's param dialog | | **confirmText** | `string` | optional | Translated confirmation prompt | | **successMessage** | `string` | optional | Translated success toast/message | +| **disabledReason** | `string` | optional | Translated reason for a greyed action, for its hover title and accessible description while its `disabled` predicate is TRUE (no renderer shows it yet) | | **outcomeMessages** | `Record` | optional | Translated success copy per handler outcome, keyed by the snake_case outcome name the action's `outcomeMessages` declares | | **params** | `Record }>` | optional | Action parameter translations keyed by parameter name | | **resultDialog** | `{ title?: string; description?: string; acknowledge?: string; fields?: Record }` | optional | Translations for the action result dialog | @@ -813,6 +817,7 @@ Translation data for a single object | **description** | `string` | optional | Translated action description — the explanatory line under the title in the action's param dialog | | **confirmText** | `string` | optional | Translated confirmation prompt | | **successMessage** | `string` | optional | Translated success toast/message | +| **disabledReason** | `string` | optional | Translated reason for a greyed action, for its hover title and accessible description while its `disabled` predicate is TRUE (no renderer shows it yet) | | **outcomeMessages** | `Record` | optional | Translated success copy per handler outcome, keyed by the snake_case outcome name the action's `outcomeMessages` declares | | **params** | `Record }>` | optional | Action parameter translations keyed by parameter name | | **resultDialog** | `{ title?: string; description?: string; acknowledge?: string; fields?: Record }` | optional | Translations for the action result dialog | From e55978368d717b982ea1573917b9f9aea2f9c885 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 19:23:06 +0000 Subject: [PATCH 16/18] chore(spec): regenerate spec artifacts on the merged tree Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01WGufzfyVQuRsL6SRGuKCem --- content/docs/references/system/translation.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/docs/references/system/translation.mdx b/content/docs/references/system/translation.mdx index cb36aec08f9..dd82b05ada6 100644 --- a/content/docs/references/system/translation.mdx +++ b/content/docs/references/system/translation.mdx @@ -768,7 +768,7 @@ One locale of translations — the `translation` metadata type | **metadataForms** | `Record; fields?: Record }>` | optional | Translations for metadata-type configuration forms keyed by metadata type | | **settingsCommon** | `{ sourceLabels?: object }` | optional | Cross-namespace Settings UI strings | | **locale** | `string` | ✅ | BCP-47 locale this item translates (e.g. "zh-CN") | -| **name** | `string` | optional | Item name — conventionally the locale code (`zh-CN`); the runtime sync falls back to it when `locale` is absent | +| **name** | `string` | optional | Item name — snake_case like every metadata item (e.g. `zh_cn`); the BCP-47 tag goes in `locale` | | **label** | `string` | optional | Human-readable label shown in metadata lists | | **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). | | **_lockReason** | `string` | optional | Human-readable reason shown when a write is refused by _lock. | From 189bc37962f31242d0a9fab797a9945db11f05b8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 00:53:56 +0000 Subject: [PATCH 17/18] chore(spec): regenerate spec artifacts on the merged tree Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01WGufzfyVQuRsL6SRGuKCem --- content/docs/references/api/protocol.mdx | 16 ++++--- .../docs/references/system/translation.mdx | 46 +++++++++++++++++-- .../system.md | 2 +- packages/spec/api-surface/system.json | 3 ++ packages/spec/authorable-surface/system.json | 4 ++ packages/spec/export-origins/system.json | 3 ++ .../spec/liveness/state-counts/translation.md | 2 +- 7 files changed, 62 insertions(+), 14 deletions(-) diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 208b77a463f..263d71f578f 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -920,14 +920,14 @@ Enable package response | **$top** | `integer \| string \| null` | optional | Transport spelling of `limit` (OData `$top`) — a non-negative integer, or the digits a querystring carries it as | | **$skip** | `integer \| string \| null` | optional | Transport spelling of `offset` (OData `$skip`) — a non-negative integer, or the digits a querystring carries it as | | **$orderby** | `Record> \| Record \| { field: string; order?: Enum<'asc' \| 'desc'> }[] \| null` | optional | Transport spelling of `orderBy` (OData `$orderby`) | -| **$select** | `string \| string[] \| null` | optional | Transport spelling of `fields` (OData `$select`) | +| **$select** | `string \| string[] \| null` | optional | Transport spelling of `fields` (OData `$select`) — at least one field name; a value naming no field (blank, only commas and spaces, an empty list, or a list with a blank name) is refused | | **$expand** | `string \| string[] \| Record \| null` | optional | Transport spelling of `expand` (OData `$expand`) | | **$search** | `string \| { query: string; fields?: string[]; fuzzy?: boolean; operator?: Enum<'and' \| 'or'>; … } \| null` | optional | Transport spelling of `search` (OData `$search`) | | **$searchFields** | `string \| string[] \| null` | optional | Transport spelling of `searchFields` (OData `$searchFields`) | | **$count** | `boolean \| Enum<'true' \| 'false'> \| null` | optional | Transport spelling of the response total-count flag (OData `$count`) | | **count** | `boolean \| Enum<'true' \| 'false'> \| null` | optional | Response total-count flag — only an explicit `false` skips the COUNT query | | **filter** | `[string, string, any] \| [string, string] \| [string, object, ...object[]] \| object[] \| Record \| any \| null` | optional | Transport spelling of `where` | -| **select** | `string \| string[] \| null` | optional | Transport spelling of `fields` | +| **select** | `string \| string[] \| null` | optional | Transport spelling of `fields` — at least one field name; a value naming no field (blank, only commas and spaces, an empty list, or a list with a blank name) is refused | | **sort** | `Record> \| Record \| { field: string; order?: Enum<'asc' \| 'desc'> }[] \| null` | optional | Transport spelling of `orderBy` | | **skip** | `integer \| string \| null` | optional | Transport spelling of `offset` — a non-negative integer, or the digits a querystring carries it as | | **populate** | `string \| string[] \| null` | optional | Transport spelling of `expand` | @@ -1038,7 +1038,7 @@ This schema accepts one of the following structures: | :--- | :--- | :--- | :--- | | **object** | `string` | ✅ | The object name. | | **id** | `string` | ✅ | The unique record identifier (primary key). | -| **select** | `string[]` | optional | Fields to include in the response (allowlisted query param). | +| **select** | `string[]` | optional | Fields to include in the response (allowlisted query param) — at least one field name; an empty list, or a list with a blank name, is refused. | | **expand** | `string[]` | optional | Lookup/master_detail field names to expand. The engine resolves these via batch $in queries, replacing foreign key IDs with full objects. | @@ -1380,6 +1380,7 @@ This schema accepts one of the following structures: | **type** | `string` | ✅ | Metadata type name | | **name** | `string` | ✅ | Item name | | **packageId** | `string` | optional | Optional package ID — scopes the `code` layer so a same-name collision resolves to the requested package's artifact (ADR-0048). | +| **member** | `string` | optional | Bundle member selector, for a metadata type whose identity is a pair (today only `email_template`, identified by `(name, locale)`): the member this request addresses, spelled as its discriminator value (`zh-CN`). Absent, the canonical member (`en-US` for `email_template`), which is what a bare-name read answers; naming the canonical value is the same request. It is not the response translation locale, which `?locale=` and `Accept-Language` choose. Refused with 400 `VALIDATION_ERROR` on a type that has no members, and for a blank value or one longer than a stored member can be (100 characters). A well-formed member with nothing stored or shipped answers as that door answers an absent item. | --- @@ -1784,6 +1785,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **hooks** | `Record }>` | optional | Hook translations keyed by hook name | | **positions** | `Record` | optional | Position translations keyed by position name | | **permissions** | `Record` | optional | Permission-set translations keyed by permission-set name | +| **books** | `Record }>` | optional | Doc-book translations keyed by book name | | **settingsCommon** | `{ sourceLabels?: object }` | optional | Cross-namespace Settings UI strings | | **settings** | `Record; keys?: Record; … }>` | optional | Settings manifest translations keyed by namespace | @@ -1840,7 +1842,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **resizable** | `boolean` | optional | Enable column resizing | | **compactToolbar** | `boolean` | optional | Collapse Group/Color/Density/Hide-fields into a single View settings popover | | **selection** | `{ type?: Enum<'none' \| 'single' \| 'multiple'> }` | optional | Row selection configuration | -| **navigation** | `{ mode?: Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>; preventNavigation?: boolean; openNewTab?: boolean; size?: Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>; … }` | optional | Configuration for item click navigation (page, drawer, modal, etc.) | +| **navigation** | `{ mode?: Enum<'drawer' \| 'page' \| 'new_window' \| 'none'>; preventNavigation?: boolean; openNewTab?: boolean; size?: Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>; … }` | optional | Configuration for item click navigation (drawer by default, page, new_window or none) | | **pagination** | `{ pageSize?: integer; pageSizeOptions?: integer[] }` | optional | Pagination configuration | | **kanban** | `{ groupByField: string; summarizeField?: string; titleField?: string; columns: string[] }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | | **calendar** | `{ startDateField: string; endDateField?: string; titleField?: string; colorField?: string; … }` | optional | Calendar configuration — applies when the view renders as a calendar layout | @@ -1919,7 +1921,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **resizable** | `boolean` | optional | Enable column resizing | | **compactToolbar** | `boolean` | optional | Collapse Group/Color/Density/Hide-fields into a single View settings popover | | **selection** | `{ type?: Enum<'none' \| 'single' \| 'multiple'> }` | optional | Row selection configuration | -| **navigation** | `{ mode?: Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>; preventNavigation?: boolean; openNewTab?: boolean; size?: Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>; … }` | optional | Configuration for item click navigation (page, drawer, modal, etc.) | +| **navigation** | `{ mode?: Enum<'drawer' \| 'page' \| 'new_window' \| 'none'>; preventNavigation?: boolean; openNewTab?: boolean; size?: Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>; … }` | optional | Configuration for item click navigation (drawer by default, page, new_window or none) | | **pagination** | `{ pageSize?: integer; pageSizeOptions?: integer[] }` | optional | Pagination configuration | | **kanban** | `{ groupByField: string; summarizeField?: string; titleField?: string; columns: string[] }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | | **calendar** | `{ startDateField: string; endDateField?: string; titleField?: string; colorField?: string; … }` | optional | Calendar configuration — applies when the view renders as a calendar layout | @@ -2043,7 +2045,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | :--- | :--- | :--- | :--- | | **filter** | `string` | optional | JSON-encoded filter expression (canonical, singular). | | **filters** | `string` | optional | JSON-encoded filter expression (deprecated plural alias). | -| **select** | `string` | optional | Comma-separated list of fields to retrieve. | +| **select** | `string` | optional | Comma-separated list of fields to retrieve — at least one field name; a value naming no field (blank, or only commas and spaces) is refused. | | **sort** | `string` | optional | Sort expression (e.g. "name asc,created_at desc" or "-created_at"). | | **orderBy** | `string` | optional | Alias for sort (OData compatibility). | | **top** | `integer \| string` | optional | Max records to return (limit) — a non-negative integer. | @@ -2051,7 +2053,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **expand** | `string` | optional | Comma-separated list of lookup/master_detail field names to expand. Resolved to populate array and passed to the engine for batch $in expansion. | | **search** | `string` | optional | Full-text search query. | | **distinct** | `never` | optional | [REMOVED] `query.distinct` was removed in @objectstack/spec 17 (ADR-0049 / ADR-0078) — no driver ever rendered SELECT DISTINCT; the flag's only observable effect was MIS-WIRED: the REST list path treated a distinct query as not countable and silently degraded `total`/`hasMore` to a page-local estimate while still returning duplicate rows. Delete the key; `QueryBuilder.distinct()` was removed with it, and the count suppression is gone (`total` is truthful again). For unique values of one column use the SQL/memory drivers' `distinct(object, field)` door; for unique combinations, `groupBy`; for a deduplicated count, the `count_distinct` aggregation. | -| **count** | `boolean` | optional | Include total count in response. | +| **count** | `boolean \| Enum<'true' \| 'false'>` | optional | Include the total count in the response — `true` or `false`; any other value is refused. | --- diff --git a/content/docs/references/system/translation.mdx b/content/docs/references/system/translation.mdx index dd82b05ada6..c7df78a708f 100644 --- a/content/docs/references/system/translation.mdx +++ b/content/docs/references/system/translation.mdx @@ -128,7 +128,7 @@ Translation data for a single field | **description** | `string` | optional | Translated action description — the explanatory line under the title in the action's param dialog | | **confirmText** | `string` | optional | Translated confirmation prompt | | **successMessage** | `string` | optional | Translated success toast/message | -| **disabledReason** | `string` | optional | Translated reason for a greyed action, for its hover title and accessible description while its `disabled` predicate is TRUE (no renderer shows it yet) | +| **disabledReason** | `string` | optional | Translated reason a greyed action shows (hover title and accessible description) while its `disabled` predicate is TRUE | | **outcomeMessages** | `Record` | optional | Translated success copy per handler outcome, keyed by the snake_case outcome name the action's `outcomeMessages` declares | | **params** | `Record }>` | optional | Action parameter translations keyed by parameter name | | **resultDialog** | `{ title?: string; description?: string; acknowledge?: string; fields?: Record }` | optional | Translations for the action result dialog | @@ -186,6 +186,7 @@ Platform translation data — the per-app groups plus the platform-only `setting | **hooks** | `Record }>` | optional | Hook translations keyed by hook name | | **positions** | `Record` | optional | Position translations keyed by position name | | **permissions** | `Record` | optional | Permission-set translations keyed by permission-set name | +| **books** | `Record }>` | optional | Doc-book translations keyed by book name | | **metadataForms** | `Record; fields?: Record }>` | optional | Translations for metadata-type configuration forms keyed by metadata type | | **settingsCommon** | `{ sourceLabels?: object }` | optional | Cross-namespace Settings UI strings | | **settings** | `Record; keys?: Record; … }>` | optional | Settings manifest translations keyed by namespace | @@ -229,7 +230,7 @@ Translation data for a single object | **description** | `string` | optional | Translated action description — the explanatory line under the title in the action's param dialog | | **confirmText** | `string` | optional | Translated confirmation prompt | | **successMessage** | `string` | optional | Translated success toast/message | -| **disabledReason** | `string` | optional | Translated reason for a greyed action, for its hover title and accessible description while its `disabled` predicate is TRUE (no renderer shows it yet) | +| **disabledReason** | `string` | optional | Translated reason a greyed action shows (hover title and accessible description) while its `disabled` predicate is TRUE | | **outcomeMessages** | `Record` | optional | Translated success copy per handler outcome, keyed by the snake_case outcome name the action's `outcomeMessages` declares | | **params** | `Record }>` | optional | Action parameter translations keyed by parameter name | | **resultDialog** | `{ title?: string; description?: string; acknowledge?: string; fields?: Record }` | optional | Translations for the action result dialog | @@ -302,6 +303,14 @@ Translation data for a single object | **label** | `string` | optional | Translated permission-set label — overlays the set's own `label` | | **description** | `string` | optional | Translated permission-set description — overlays the set's own `description` | +### Nested Shape: `PlatformTranslationData.books[string]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **label** | `string` | optional | Translated book title — overlays the book's own `label` | +| **description** | `string` | optional | Translated book description — overlays the book's own `description` | +| **groups** | `Record` | optional | Group (section) translations keyed by the group `key` | + ### Nested Shape: `PlatformTranslationData.metadataForms[string]` | Property | Type | Required | Description | @@ -352,6 +361,7 @@ Served translation data — the platform groups the browser reads, without the s | **hooks** | `Record }>` | optional | Hook translations keyed by hook name | | **positions** | `Record` | optional | Position translations keyed by position name | | **permissions** | `Record` | optional | Permission-set translations keyed by permission-set name | +| **books** | `Record }>` | optional | Doc-book translations keyed by book name | | **settingsCommon** | `{ sourceLabels?: object }` | optional | Cross-namespace Settings UI strings | | **settings** | `Record; keys?: Record; … }>` | optional | Settings manifest translations keyed by namespace | @@ -394,7 +404,7 @@ Translation data for a single object | **description** | `string` | optional | Translated action description — the explanatory line under the title in the action's param dialog | | **confirmText** | `string` | optional | Translated confirmation prompt | | **successMessage** | `string` | optional | Translated success toast/message | -| **disabledReason** | `string` | optional | Translated reason for a greyed action, for its hover title and accessible description while its `disabled` predicate is TRUE (no renderer shows it yet) | +| **disabledReason** | `string` | optional | Translated reason a greyed action shows (hover title and accessible description) while its `disabled` predicate is TRUE | | **outcomeMessages** | `Record` | optional | Translated success copy per handler outcome, keyed by the snake_case outcome name the action's `outcomeMessages` declares | | **params** | `Record }>` | optional | Action parameter translations keyed by parameter name | | **resultDialog** | `{ title?: string; description?: string; acknowledge?: string; fields?: Record }` | optional | Translations for the action result dialog | @@ -467,6 +477,14 @@ Translation data for a single object | **label** | `string` | optional | Translated permission-set label — overlays the set's own `label` | | **description** | `string` | optional | Translated permission-set description — overlays the set's own `description` | +### Nested Shape: `ServedTranslationData.books[string]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **label** | `string` | optional | Translated book title — overlays the book's own `label` | +| **description** | `string` | optional | Translated book description — overlays the book's own `description` | +| **groups** | `Record` | optional | Group (section) translations keyed by the group `key` | + ### Nested Shape: `ServedTranslationData.settingsCommon` | Property | Type | Required | Description | @@ -579,6 +597,7 @@ Per-app translation data for objects, apps, and UI messages | **hooks** | `Record }>` | optional | Hook translations keyed by hook name | | **positions** | `Record` | optional | Position translations keyed by position name | | **permissions** | `Record` | optional | Permission-set translations keyed by permission-set name | +| **books** | `Record }>` | optional | Doc-book translations keyed by book name | | **metadataForms** | `Record; fields?: Record }>` | optional | Translations for metadata-type configuration forms keyed by metadata type | | **settingsCommon** | `{ sourceLabels?: object }` | optional | Cross-namespace Settings UI strings | @@ -621,7 +640,7 @@ Translation data for a single object | **description** | `string` | optional | Translated action description — the explanatory line under the title in the action's param dialog | | **confirmText** | `string` | optional | Translated confirmation prompt | | **successMessage** | `string` | optional | Translated success toast/message | -| **disabledReason** | `string` | optional | Translated reason for a greyed action, for its hover title and accessible description while its `disabled` predicate is TRUE (no renderer shows it yet) | +| **disabledReason** | `string` | optional | Translated reason a greyed action shows (hover title and accessible description) while its `disabled` predicate is TRUE | | **outcomeMessages** | `Record` | optional | Translated success copy per handler outcome, keyed by the snake_case outcome name the action's `outcomeMessages` declares | | **params** | `Record }>` | optional | Action parameter translations keyed by parameter name | | **resultDialog** | `{ title?: string; description?: string; acknowledge?: string; fields?: Record }` | optional | Translations for the action result dialog | @@ -694,6 +713,14 @@ Translation data for a single object | **label** | `string` | optional | Translated permission-set label — overlays the set's own `label` | | **description** | `string` | optional | Translated permission-set description — overlays the set's own `description` | +### Nested Shape: `TranslationData.books[string]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **label** | `string` | optional | Translated book title — overlays the book's own `label` | +| **description** | `string` | optional | Translated book description — overlays the book's own `description` | +| **groups** | `Record` | optional | Group (section) translations keyed by the group `key` | + ### Nested Shape: `TranslationData.metadataForms[string]` | Property | Type | Required | Description | @@ -765,6 +792,7 @@ One locale of translations — the `translation` metadata type | **hooks** | `Record }>` | optional | Hook translations keyed by hook name | | **positions** | `Record` | optional | Position translations keyed by position name | | **permissions** | `Record` | optional | Permission-set translations keyed by permission-set name | +| **books** | `Record }>` | optional | Doc-book translations keyed by book name | | **metadataForms** | `Record; fields?: Record }>` | optional | Translations for metadata-type configuration forms keyed by metadata type | | **settingsCommon** | `{ sourceLabels?: object }` | optional | Cross-namespace Settings UI strings | | **locale** | `string` | ✅ | BCP-47 locale this item translates (e.g. "zh-CN") | @@ -817,7 +845,7 @@ Translation data for a single object | **description** | `string` | optional | Translated action description — the explanatory line under the title in the action's param dialog | | **confirmText** | `string` | optional | Translated confirmation prompt | | **successMessage** | `string` | optional | Translated success toast/message | -| **disabledReason** | `string` | optional | Translated reason for a greyed action, for its hover title and accessible description while its `disabled` predicate is TRUE (no renderer shows it yet) | +| **disabledReason** | `string` | optional | Translated reason a greyed action shows (hover title and accessible description) while its `disabled` predicate is TRUE | | **outcomeMessages** | `Record` | optional | Translated success copy per handler outcome, keyed by the snake_case outcome name the action's `outcomeMessages` declares | | **params** | `Record }>` | optional | Action parameter translations keyed by parameter name | | **resultDialog** | `{ title?: string; description?: string; acknowledge?: string; fields?: Record }` | optional | Translations for the action result dialog | @@ -890,6 +918,14 @@ Translation data for a single object | **label** | `string` | optional | Translated permission-set label — overlays the set's own `label` | | **description** | `string` | optional | Translated permission-set description — overlays the set's own `description` | +### Nested Shape: `TranslationItem.books[string]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **label** | `string` | optional | Translated book title — overlays the book's own `label` | +| **description** | `string` | optional | Translated book description — overlays the book's own `description` | +| **groups** | `Record` | optional | Group (section) translations keyed by the group `key` | + ### Nested Shape: `TranslationItem.metadataForms[string]` | Property | Type | Required | Description | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md index c9ba2459292..700fa76e260 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md @@ -19,4 +19,4 @@ hand-patch a number here** — fix the code or the verdict and regenerate. | Dir | Sites | |---|---| -| `system/` | 386 | +| `system/` | 388 | diff --git a/packages/spec/api-surface/system.json b/packages/spec/api-surface/system.json index 5d32e1ec34b..989dd6b1fb1 100644 --- a/packages/spec/api-surface/system.json +++ b/packages/spec/api-surface/system.json @@ -73,9 +73,11 @@ "BookAudience (type)", "BookAudienceSchema (const)", "BookGroup (type)", + "BookGroupLike (interface)", "BookGroupSchema (const)", "BookInclude (type)", "BookIncludeSchema (const)", + "BookLike (interface)", "BookNode (type)", "BookNodeSchema (const)", "BookSchema (const)", @@ -892,6 +894,7 @@ "toLocaleDescriptors (function)", "translateAction (function)", "translateApp (function)", + "translateBook (function)", "translateDashboard (function)", "translateDataset (function)", "translateFlow (function)", diff --git a/packages/spec/authorable-surface/system.json b/packages/spec/authorable-surface/system.json index 0dbb43b43bf..fdc9250b952 100644 --- a/packages/spec/authorable-surface/system.json +++ b/packages/spec/authorable-surface/system.json @@ -903,6 +903,7 @@ "system/Plan:priceMonthly", "system/Plan:priceYearly", "system/PlatformTranslationData:apps", + "system/PlatformTranslationData:books", "system/PlatformTranslationData:dashboards", "system/PlatformTranslationData:datasets", "system/PlatformTranslationData:flows", @@ -1032,6 +1033,7 @@ "system/SecurityEventCorrelation:linkEncryptionToAudit", "system/SecurityEventCorrelation:linkMaskingToAudit", "system/ServedTranslationData:apps", + "system/ServedTranslationData:books", "system/ServedTranslationData:dashboards", "system/ServedTranslationData:datasets", "system/ServedTranslationData:flows", @@ -1335,6 +1337,7 @@ "system/TranslationCoverageResult:totalKeys", "system/TranslationCoverageResult:translatedKeys", "system/TranslationData:apps", + "system/TranslationData:books", "system/TranslationData:dashboards", "system/TranslationData:datasets", "system/TranslationData:flows", @@ -1364,6 +1367,7 @@ "system/TranslationItem:_packageVersion", "system/TranslationItem:_provenance", "system/TranslationItem:apps", + "system/TranslationItem:books", "system/TranslationItem:dashboards", "system/TranslationItem:datasets", "system/TranslationItem:flows", diff --git a/packages/spec/export-origins/system.json b/packages/spec/export-origins/system.json index b995ffe938c..1b4787e7ad3 100644 --- a/packages/spec/export-origins/system.json +++ b/packages/spec/export-origins/system.json @@ -72,9 +72,11 @@ "BookAudience": "src/system/book.zod.ts#BookAudience (type)", "BookAudienceSchema": "src/system/book.zod.ts#BookAudienceSchema (const)", "BookGroup": "src/system/book.zod.ts#BookGroup (type)", + "BookGroupLike": "src/system/i18n-resolver.ts#BookGroupLike (interface)", "BookGroupSchema": "src/system/book.zod.ts#BookGroupSchema (const)", "BookInclude": "src/system/book.zod.ts#BookInclude (type)", "BookIncludeSchema": "src/system/book.zod.ts#BookIncludeSchema (const)", + "BookLike": "src/system/i18n-resolver.ts#BookLike (interface)", "BookNode": "src/system/book.zod.ts#BookNode (type)", "BookNodeSchema": "src/system/book.zod.ts#BookNodeSchema (const)", "BookSchema": "src/system/book.zod.ts#BookSchema (const)", @@ -853,6 +855,7 @@ "toLocaleDescriptors": "src/system/i18n-resolver.ts#toLocaleDescriptors (function)", "translateAction": "src/system/i18n-resolver.ts#translateAction (function)", "translateApp": "src/system/i18n-resolver.ts#translateApp (function)", + "translateBook": "src/system/i18n-resolver.ts#translateBook (function)", "translateDashboard": "src/system/i18n-resolver.ts#translateDashboard (function)", "translateDataset": "src/system/i18n-resolver.ts#translateDataset (function)", "translateFlow": "src/system/i18n-resolver.ts#translateFlow (function)", diff --git a/packages/spec/liveness/state-counts/translation.md b/packages/spec/liveness/state-counts/translation.md index f8cbfa91f69..368254b51fd 100644 --- a/packages/spec/liveness/state-counts/translation.md +++ b/packages/spec/liveness/state-counts/translation.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `translation` | 39 | 0 | 0 | 0 | 0 | 39 | +| `translation` | 42 | 0 | 0 | 0 | 0 | 42 | From a1dbd6eaa144abbeecd35b2dd82a46a51c45e2ae Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 01:11:30 +0000 Subject: [PATCH 18/18] test(app-shell): the translation preview divides coverage by sixteen groups now that `hooks` and `books` both stand Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01WGufzfyVQuRsL6SRGuKCem --- .../previews/adminPreviews.i18n-10862-s2.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/packages/app-shell/src/views/metadata-admin/previews/adminPreviews.i18n-10862-s2.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/previews/adminPreviews.i18n-10862-s2.test.tsx index 81204ffa4b3..c8a12830c1a 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/previews/adminPreviews.i18n-10862-s2.test.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/previews/adminPreviews.i18n-10862-s2.test.tsx @@ -479,7 +479,7 @@ describe('TranslationPreview reads the designer locale (objectui#10862)', () => { key: 'engine.translationPreview.keyCountOne', vars: { count: 1 } }, ]); // The bundle's own locale is author data, not the designer's language. - expectAsWritten(['French', 'fr-FR', 'account', '"Un"', '2/15 (13%)']); + expectAsWritten(['French', 'fr-FR', 'account', '"Un"', '2/16 (13%)']); }); } });