diff --git a/.changeset/26170-hook-refusal-catalogue.md b/.changeset/26170-hook-refusal-catalogue.md new file mode 100644 index 00000000000..5b165a5da57 --- /dev/null +++ b/.changeset/26170-hook-refusal-catalogue.md @@ -0,0 +1,25 @@ +--- +'@objectstack/spec': minor +'@objectstack/runtime': minor +'@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 + +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 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`. 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/protocol/kernel/i18n-standard.mdx b/content/docs/protocol/kernel/i18n-standard.mdx index 56672622c34..19928595fca 100644 --- a/content/docs/protocol/kernel/i18n-standard.mdx +++ b/content/docs/protocol/kernel/i18n-standard.mdx @@ -406,7 +406,7 @@ 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`, `books`, + `reports`, `pages`, `flows`, `hooks`, `positions`, `permissions`, `books`, `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 @@ -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 fifteen declared groups. +so it starts at one of the sixteen 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/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 266a340a994..263d71f578f 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1782,6 +1782,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 | | **books** | `Record }>` | optional | Doc-book translations keyed by book name | diff --git a/content/docs/references/data/hook.mdx b/content/docs/references/data/hook.mdx index 2932817ebe3..82e94eda253 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; 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 | + --- @@ -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; 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/content/docs/references/index.mdx b/content/docs/references/index.mdx index d862cc1d3da..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 — 1556 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/. */} @@ -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) | 15 | 77 | 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** | **202** | **1556** | 14 protocol modules | +| **Total** | **202** | **1557** | 14 protocol modules | --- @@ -132,7 +132,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. @@ -159,7 +159,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 c5a1f2d504b..c7df78a708f 100644 --- a/content/docs/references/system/translation.mdx +++ b/content/docs/references/system/translation.mdx @@ -183,6 +183,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 | | **books** | `Record }>` | optional | Doc-book translations keyed by book name | @@ -282,6 +283,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 | @@ -351,6 +358,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 | | **books** | `Record }>` | optional | Doc-book translations keyed by book name | @@ -449,6 +457,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 | @@ -580,6 +594,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 | | **books** | `Record }>` | optional | Doc-book translations keyed by book name | @@ -678,6 +693,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 | @@ -768,6 +789,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 | | **books** | `Record }>` | optional | Doc-book translations keyed by book name | @@ -876,6 +898,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/content/docs/ui/translations.mdx b/content/docs/ui/translations.mdx index ea97d69127b..b05d645407e 100644 --- a/content/docs/ui/translations.mdx +++ b/content/docs/ui/translations.mdx @@ -88,6 +88,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` | | Doc book title, description and section titles | `books..label` / `description` / `groups..label` — keyed by the book's `name` and each group's `key`; overlaid on every served book and on the book tree, which is what the docs sidebar draws. A doc's own title and body localize through `doc.translations` instead | 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 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/cli/src/utils/i18n-coverage.ts b/packages/cli/src/utils/i18n-coverage.ts index d6f085d71bb..b2bf5fd9a0e 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' | 'book' @@ -379,6 +380,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 @@ -412,6 +419,7 @@ const SOURCE_NOUN: Record = { picklist: 'Picklist', page: 'Page', flow: 'Flow', + hook: 'Hook', position: 'Position', permission: 'Permission set', book: 'Book', @@ -458,6 +466,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', book: 'doc books', diff --git a/packages/cli/src/utils/i18n-extract.ts b/packages/cli/src/utils/i18n-extract.ts index 323e1975f90..3c1cc2c74af 100644 --- a/packages/cli/src/utils/i18n-extract.ts +++ b/packages/cli/src/utils/i18n-extract.ts @@ -124,6 +124,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 * books..label / .description @@ -232,6 +233,7 @@ export interface ExpectedEntry { | 'picklist' | 'page' | 'flow' + | 'hook' | 'position' | 'permission' | 'book' @@ -1689,6 +1691,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 @@ -2293,6 +2298,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/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 59b1ce6e9b1..0152216bc7f 100644 --- a/packages/lint/src/validate-translation-references.test.ts +++ b/packages/lint/src/validate-translation-references.test.ts @@ -4514,6 +4514,63 @@ describe('validateTranslationReferences — reports', () => { }); }); +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([]); + }); +}); + describe('validateTranslationReferences — doc books (#26690)', () => { const stack = (data: Record, extra: Record = {}) => ({ books: [{ diff --git a/packages/lint/src/validate-translation-references.ts b/packages/lint/src/validate-translation-references.ts index 08b9298ad29..070c8f6ae56 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 @@ -511,6 +517,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. */ @@ -1645,6 +1657,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` @@ -1746,6 +1777,7 @@ function buildUniverse(stack: AnyRec): Universe { contributedOnlyApps, dashboards, flows, + hooks, globalActions, actionOwners, picklists, @@ -2392,6 +2424,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/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts b/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts index fefd90cba4c..4df11e4fa35 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 @@ -345,9 +345,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: 23, skill: 16, tool: 14, + hook: 23, object: 48, page: 26, position: 13, report: 23, skill: 16, tool: 14, }; it.each(Object.entries(CARD_PROPERTY_COUNTS))( 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/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.test.ts b/packages/runtime/src/sandbox/hook-refusal-catalogue.test.ts new file mode 100644 index 00000000000..d136707e5f1 --- /dev/null +++ b/packages/runtime/src/sandbox/hook-refusal-catalogue.test.ts @@ -0,0 +1,233 @@ +// 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); + // 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'); + }); + + 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.declaredCode).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.declaredCode).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.declaredCode).toBe('ANY_CODE'); + expect(wire.body.userMessage).toBeUndefined(); + }); + + 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.declaredCode).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/runtime/src/sandbox/hook-refusal-catalogue.ts b/packages/runtime/src/sandbox/hook-refusal-catalogue.ts new file mode 100644 index 00000000000..636db3ffdd7 --- /dev/null +++ b/packages/runtime/src/sandbox/hook-refusal-catalogue.ts @@ -0,0 +1,195 @@ +// 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 — 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 + * 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..7799600285f 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,13 @@ 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). 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.defineProp(errH, SANDBOX_HOST_ERROR_OF_PROP, { value: hostOf, configurable: true, enumerable: false }); + hostOf.dispose(); } catch { /* keep the bare name/message error */ } @@ -1729,6 +1746,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 +2091,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/api-surface/data.json b/packages/spec/api-surface/data.json index b5617055c40..0d8cd5c1cb9 100644 --- a/packages/spec/api-surface/data.json +++ b/packages/spec/api-surface/data.json @@ -374,6 +374,9 @@ "FullTextSearchSchema (const)", "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)", @@ -393,6 +396,8 @@ "HookObjectApi (interface)", "HookParsed (type)", "HookQuery (type)", + "HookRefusal (type)", + "HookRefusalSchema (const)", "HookSchema (const)", "HookUpdateDoc (type)", "HookUpdateOptions (type)", @@ -903,6 +908,8 @@ "hasDynamicTokens (function)", "hasNulInLikePattern (function)", "hookForm (const)", + "hookRefusalCodesThrownIn (function)", + "hookRefusalsWithoutSandboxedBody (function)", "importMappingEntryTargets (function)", "indexImportMappingTargets (function)", "injectedSystemColumnDefs (function)", @@ -1015,6 +1022,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 c102c7586d5..989dd6b1fb1 100644 --- a/packages/spec/api-surface/system.json +++ b/packages/spec/api-surface/system.json @@ -836,6 +836,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 17dfed4a5bc..fdc9250b952 100644 --- a/packages/spec/authorable-surface/system.json +++ b/packages/spec/authorable-surface/system.json @@ -908,6 +908,7 @@ "system/PlatformTranslationData:datasets", "system/PlatformTranslationData:flows", "system/PlatformTranslationData:globalActions", + "system/PlatformTranslationData:hooks", "system/PlatformTranslationData:messages", "system/PlatformTranslationData:metadataForms", "system/PlatformTranslationData:objects", @@ -1037,6 +1038,7 @@ "system/ServedTranslationData:datasets", "system/ServedTranslationData:flows", "system/ServedTranslationData:globalActions", + "system/ServedTranslationData:hooks", "system/ServedTranslationData:messages", "system/ServedTranslationData:objects", "system/ServedTranslationData:pages", @@ -1340,6 +1342,7 @@ "system/TranslationData:datasets", "system/TranslationData:flows", "system/TranslationData:globalActions", + "system/TranslationData:hooks", "system/TranslationData:messages", "system/TranslationData:metadataForms", "system/TranslationData:objects", @@ -1369,6 +1372,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 09cb83c405a..31ca2e009c5 100644 --- a/packages/spec/export-origins/data.json +++ b/packages/spec/export-origins/data.json @@ -365,6 +365,9 @@ "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_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)", @@ -383,6 +386,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)", @@ -889,6 +894,8 @@ "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)", + "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)", @@ -1001,6 +1008,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 9b4f706b011..1b4787e7ad3 100644 --- a/packages/spec/export-origins/system.json +++ b/packages/spec/export-origins/system.json @@ -797,6 +797,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/hook.json b/packages/spec/liveness/hook.json index a1b998cb54b..20d747d6af1 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, 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", "verifiedAt": "2026-09-29", 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 87b8234dd48..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` | 41 | 0 | 0 | 0 | 0 | 41 | +| `translation` | 42 | 0 | 0 | 0 | 0 | 42 | diff --git a/packages/spec/liveness/translation.json b/packages/spec/liveness/translation.json index d4dd272ab1a..7f1fa28f5ae 100644 --- a/packages/spec/liveness/translation.json +++ b/packages/spec/liveness/translation.json @@ -226,6 +226,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..c2bbbe725be --- /dev/null +++ b/packages/spec/src/data/hook-refusal-codes.ts @@ -0,0 +1,226 @@ +// 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 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]+)*$/; + +/** 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', +); + +/** + * `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. + * + * So a `throw`, a `code:` or a bracket written inside a comment or inside a + * longer string is never read as code. + */ +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) { + end = i; + break; + } + } + found.push(masked.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(); + 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 masked.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.' + ); +} + +/** + * 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 + * 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-refusals.test.ts b/packages/spec/src/data/hook-refusals.test.ts new file mode 100644 index 00000000000..406ac3ea338 --- /dev/null +++ b/packages/spec/src/data/hook-refusals.test.ts @@ -0,0 +1,227 @@ +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'; +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, 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, 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) => { + 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 + // 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;", + "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/packages/spec/src/data/hook.zod.ts b/packages/spec/src/data/hook.zod.ts index cca44378356..e1e6f47afc4 100644 --- a/packages/spec/src/data/hook.zod.ts +++ b/packages/spec/src/data/hook.zod.ts @@ -25,6 +25,17 @@ 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, + HOOK_REFUSAL_MESSAGE_HOLE_REFUSED, + HOOK_REFUSAL_MESSAGE_WITHOUT_HOLE, + hookRefusalCodesThrownIn, + hookRefusalsWithoutSandboxedBody, + undeclaredHookRefusalCodeAtSave, +} from './hook-refusal-codes'; /* * ── Unknown-key strictness (#4001 data step) ──────────────────────────────── @@ -187,6 +198,91 @@ 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 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 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) 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; + 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 a `{{ }}` hole is refused at save. + */ +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: + '`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).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; 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 ' + + '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 +591,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 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( + 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 +697,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 +1284,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 12c4841edec..0a863016c04 100644 --- a/packages/spec/src/system/i18n-resolver.ts +++ b/packages/spec/src/system/i18n-resolver.ts @@ -3667,6 +3667,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/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 diff --git a/packages/spec/src/system/translation.zod.ts b/packages/spec/src/system/translation.zod.ts index 6c05a9a9e20..fcf67510b71 100644 --- a/packages/spec/src/system/translation.zod.ts +++ b/packages/spec/src/system/translation.zod.ts @@ -744,9 +744,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 fifteen groups this bundle does declare, in the order it ' + + 'application\'s own copy use the sixteen groups this bundle does declare, in the order it ' + "declares them — 'objects', 'picklists', 'apps', 'messages', 'globalActions', 'dashboards', " - + "'datasets', 'reports', 'pages', 'flows', 'positions', 'permissions', 'books', " + + "'datasets', 'reports', 'pages', 'flows', 'hooks', 'positions', 'permissions', 'books', " + "'metadataForms', 'settingsCommon'. " + "Note the last one: 'settingsCommon' IS " + 'on this face, so the Settings UI shell strings an application may translate (the source ' @@ -789,7 +789,7 @@ const ITEM_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 overriding it from an application. For an ' - + 'application\'s own copy use the groups this item does declare — the same fifteen a per-app ' + + 'application\'s own copy use the groups this item does declare — the same sixteen a per-app ' + "bundle declares, 'settingsCommon' among them: the Settings UI shell strings an application " + 'may translate (the source badges, under `settingsCommon.sourceLabels`) are NOT what is being ' + "refused here — only the per-namespace manifest copy under 'settings' is. " @@ -828,12 +828,12 @@ const ITEM_TRANSLATION_KEY_GUIDANCE: Record = { */ /** * The translation groups an APPLICATION may author, as a shape rather than a - * schema. Fifteen groups — the sixteenth, `settings`, is platform-only and lives in + * schema. Sixteen groups — the seventeenth, `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 fifteen and no more), - * {@link PlatformTranslationDataSchema} (these fifteen plus `settings`) and + * entry of a per-app file-authored bundle — these sixteen and no more), + * {@link PlatformTranslationDataSchema} (these sixteen 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 @@ -1727,6 +1727,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[]`). @@ -2025,7 +2082,7 @@ const platformSettingsShape = () => ({ * 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 sixteen-group face and for why + * {@link PlatformTranslationDataSchema} for the seventeen-group face and for why * the two are separate namespaces. */ export const TranslationDataSchema = lazySchema(() => strictObject({ @@ -2036,7 +2093,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', book: 'books', 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', book: 'books', 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. @@ -2046,7 +2103,7 @@ export const TranslationDataSchema = lazySchema(() => strictObject({ export type TranslationData = z.input; /** - * One locale of a PLATFORM translation bundle — the sixteen groups, `settings` + * One locale of a PLATFORM translation bundle — the seventeen groups, `settings` * included. * * The platform's own bundles are code, not authored metadata @@ -2081,7 +2138,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', book: 'books', 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', book: 'books', setting: 'settings', message: 'messages', strings: 'messages', labels: 'messages', actions: 'globalActions' }, extraKeys: ['locale'], }, { ...appTranslationDataShape(), @@ -2249,7 +2306,7 @@ export type TranslationConfig = z.input; * to whoever — or whatever — authored it. * * `settings` is NOT on this door (#19620, ruling batch #210 item 2 letter B): - * the item takes the PER-APP face, the same fifteen groups as + * the item takes the PER-APP face, the same sixteen groups as * {@link TranslationDataSchema}, and refuses `settings` (and the singular * `setting`) by name with {@link ITEM_SETTINGS_PLATFORM_ONLY} as the remedy. * The file door and the item door are two authoring surfaces for one app @@ -2288,7 +2345,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', book: 'books', 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', book: 'books', 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")'), diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts index 381b1f9ebac..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 @@ -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 > >>; @@ -1730,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 @@ -2647,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 diff --git a/scripts/fixtures/i18n-walk-parity/every-group.stack.json b/scripts/fixtures/i18n-walk-parity/every-group.stack.json index 061ad21d74c..5d42786413e 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/_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-data/references/data-hooks.md b/skills/objectstack-data/references/data-hooks.md index 54a04ce8d58..397cf30f330 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 @@ -935,56 +935,30 @@ hook re-trigger itself; use `object: '*'` unless you mean every object; throw in ## Error Handling -### Throwing Errors (Abort Operation) +A throw aborts the operation and rolls back its transaction; `onError: 'log'` +(above) makes an `after*` hook log and continue instead. -```typescript -handler: async (ctx) => { - if (!ctx.input.email) { - // Aborts operation, rolls back transaction - throw new Error('Email is required'); - } -} -``` +### Declared refusals (`refusals`) -### Logging Errors (Continue) +With a `refusals` catalogue, a sandboxed `body` refuses **by code only** and the +runtime answers with that entry: ```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)'); - } -} -``` - ---- - -## 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). - ---- +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; 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`. Unchanged `ctx.api` errors and crashes pass through. --- @@ -994,5 +968,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`) --- 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 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 b417d7169f2..cf6434163fd 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/ui/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -616,6 +616,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.books': 'Doc Books', @@ -1333,6 +1334,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.books': '文档书', 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 bd0bf469550..55c37c066bb 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 7d6f61332ba..350d5c14639 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 @@ -48,6 +48,7 @@ import { ShieldCheck, SquareChevronDown, Users, + Webhook, Workflow, } from 'lucide-react'; import { EmptyDescription } from '@object-ui/components'; @@ -99,6 +100,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 }, books: { label: 'engine.translationPreview.category.books', icon: BookOpen, 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 097aa125daf..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 @@ -468,6 +468,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.books' }, @@ -478,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%)']); }); } }); diff --git a/ui/packages/i18n/src/utils/spec-translations.ts b/ui/packages/i18n/src/utils/spec-translations.ts index b4a6ab5e78e..51f02c8e43d 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', // objectstack #26690 serves `books` (a doc book's title, description and // section titles are translatable), copied through onto `app` the same way. 'books',