Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
1333fcf
wip(i18n): hook refusal catalogue — spec, runtime, cli and lint halves
claude Oct 27, 2026
108e7db
chore(spec): history for HookRefusalSchema; regenerate spec artifacts
claude Oct 27, 2026
08af21e
test(i18n): hook refusal catalogue pins; skill docs and changeset
claude Oct 27, 2026
7a55ba4
fix(i18n): carry the hooks translation group through its exhaustive c…
claude Oct 27, 2026
3812688
docs(skills): fit the hook refusal section under the data-hooks token…
claude Oct 27, 2026
7ceba6a
fix(runtime): keep the host-error marker off a host error's enumerabl…
claude Oct 27, 2026
bf529b5
i18n(platform-objects): translate the hook form's refusals field
claude Oct 27, 2026
547ffca
fix(spec): record why the hook form does not offer refusals, instead …
claude Oct 27, 2026
5d10aee
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Oct 27, 2026
9f56070
chore(spec): regenerate spec artifacts on the merged tree
claude Oct 27, 2026
8b3638b
test(spec): pin HookRefusalSchema's two shapes as one (ADR-0122)
claude Oct 27, 2026
dc267bb
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Oct 27, 2026
72219f5
chore(spec): regenerate spec artifacts on the merged tree
claude Oct 27, 2026
78dc145
test(spec): count the HookRefusalSchema pin in the ADR-0122 census
claude Oct 27, 2026
e5ea888
test(spec): mark the lower-case code fixture as source text, not an e…
claude Oct 27, 2026
c19dbcd
fix(spec): refuse `refusals` on a hook with no js body, and a `{{ }}`…
claude Oct 27, 2026
b9f32c5
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Oct 27, 2026
8f7675f
chore(spec): regenerate spec artifacts on the merged tree
claude Oct 27, 2026
df6cfaf
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Oct 27, 2026
e559783
chore(spec): regenerate spec artifacts on the merged tree
claude Oct 27, 2026
f27ff28
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Oct 28, 2026
189bc37
chore(spec): regenerate spec artifacts on the merged tree
claude Oct 28, 2026
a1dbd6e
test(app-shell): the translation preview divides coverage by sixteen …
claude Oct 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .changeset/26170-hook-refusal-catalogue.md
Original file line number Diff line number Diff line change
@@ -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.<hook>.refusals.<CODE>.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.<hook>.refusals.<CODE>.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.
6 changes: 4 additions & 2 deletions content/docs/protocol/kernel/i18n-standard.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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` |

Expand Down
1 change: 1 addition & 0 deletions content/docs/references/api/protocol.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1782,6 +1782,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own
| **reports** | `Record<string, { label?: string; description?: string; blocks?: Record<string, object> }>` | optional | Report translations keyed by report name |
| **pages** | `Record<string, { label?: string; description?: string; title?: string; subtitle?: string; … }>` | optional | Page translations keyed by page name |
| **flows** | `Record<string, { label?: string; successMessage?: string; errorMessage?: string; screens?: Record<string, object>; … }>` | optional | Screen-flow translations keyed by flow name |
| **hooks** | `Record<string, { refusals?: Record<string, object> }>` | optional | Hook translations keyed by hook name |
| **positions** | `Record<string, { label?: string; description?: string }>` | optional | Position translations keyed by position name |
| **permissions** | `Record<string, { label?: string; description?: string }>` | optional | Permission-set translations keyed by permission-set name |
| **books** | `Record<string, { label?: string; description?: string; groups?: Record<string, object> }>` | optional | Doc-book translations keyed by book name |
Expand Down
27 changes: 24 additions & 3 deletions content/docs/references/data/hook.mdx
Original file line number Diff line number Diff line change
@@ -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/. */}
Expand All @@ -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);
Expand Down Expand Up @@ -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<string, { message: string; status?: integer }>` | 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.`<hook>`.refusals.`<CODE>`.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. |
Expand Down Expand Up @@ -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.`<hook>`.refusals.`<CODE>`.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 |


---

Expand Down Expand Up @@ -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`
Expand Down Expand Up @@ -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.`<hook>`.refusals.`<CODE>`.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 |


---

10 changes: 5 additions & 5 deletions content/docs/references/index.mdx
Original file line number Diff line number Diff line change
@@ -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/. */}
Expand All @@ -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. |
Expand All @@ -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 |

---

Expand Down Expand Up @@ -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.

Expand All @@ -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` |
Expand Down
Loading
Loading