Skip to content

Commit b3f7344

Browse files
feat(spec): a hook declares its refusal catalogue, and its body refuses by code, answered in the caller's language (#26831)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwBNwSiQjPk36YGpZnrxAs9yW)_ Fixes #26170 Clause-②: yes (widening) Metadata-protocol: added Skills-docs: follow-up #26834 ## 维护者速读 - 改了什么:按裁决 A,Hook 新增 `refusals: { CODE: { message, status? } }` 拒绝目录;沙箱 body 只按 code 拒绝,运行时用目录里的消息(经 `hooks.HOOK.refusals.CODE.message` 按调用方语言翻译)和状态码作答。 - 为什么改:hook 的业务拒绝是用户最常读到的"不行",却是唯一没有翻译键的拒绝入口(校验规则、flow 拒绝都已有)。 - 风险与回滚:只对声明了 `refusals` 的 hook 生效,未声明的 hook 行为完全不变;没有 js body 的 hook(进程内 handler、表达式 body)声明 `refusals` 会在保存时被拒;回滚即 revert 本 PR。新增翻译分组 `hooks` 连带改了控制台翻译预览、i18n 文档和 walk-parity 夹具。 - 先看:`packages/runtime/src/sandbox/hook-refusal-catalogue.ts`、`packages/spec/src/data/hook-refusal-codes.ts`、`hook.zod.ts` 的 `refusals`。 - 两分钟验证:`cd packages/runtime && npx vitest run src/sandbox/hook-refusal-catalogue.test.ts`(17 项:声明码、翻译、协商、未声明码 500、宿主错误透传)。 - 需要你做的:本 PR 触及 `skills/**`(受治理面),需维护者合并;拒绝消息暂不带参数(`{{ }}` 现在保存时即拒,以后加参数是纯增量),见 Acceptance notes。 ## What changes Ruling A (6431311681), as bound by the order: - **`@objectstack/spec`** - `HookSchema.refusals`: a record keyed by a SCREAMING_SNAKE code (`HOOK_REFUSAL_CODE_PATTERN`) of `HookRefusalSchema` = `{ message, status? }`, closed, `status` 400–499 (default 400). Aliases name `reason`/`text`/`description` → `message` and `httpStatus`/`statusCode` → `status`. - A catalogue is answered only on the sandboxed `body` path, so `refusals` on a hook with no `js` body (an in-process `handler`, an expression body) is refused at save, at `refusals`, naming the remedy (move the refusing logic into a `js` body, or drop `refusals`). - A refusal `message` is plain text: a `{{ }}` hole is refused at save, by a pattern the published JSON Schema carries too, so a later params slot stays additive. - Save-time refusal (`refuseUndeclaredLiteralRefusalCode` superRefine): when a hook declares `refusals` and its js body throws a code literally (`{ code: 'X' }` in a `throw`, or `e.code = 'X'` on a name thrown bare) that the catalogue does not declare, the parse fails at `body.source`. The reader (`hook-refusal-codes.ts`, an import-free leaf) masks comments and string contents first, so a `throw` or `code:` written inside either is never read. - Translation: a new `hooks` group, `hooks.HOOK.refusals.CODE.message`, on the per-app and platform faces, and `hookRefusalMessageKey`. The group counts in prose move fourteen → fifteen. - `HookContext.session.locale` declares the caller's locale. - Liveness rows `hook/refusals` (with `message` and `status` children) and `translation/hooks`. - **`@objectstack/objectql`**: `buildSession` copies `ExecutionContext.locale` onto a session that exists (a locale alone never makes one). - **`@objectstack/runtime`** - `hook-refusal-catalogue.ts`: `resolveHookRefusal` answers a sandboxed body's throw. A declared code becomes a `SandboxError` with the translated (else declared) message as `innerMessage` and `userMessage`, the declared code, and the entry's status. Any other throw from the body (an undeclared code, or none) becomes a 500 fault naming the hook and the code. The locale is negotiated with `resolveBundleLocale` against the i18n service's locales, read lazily at refusal time. - `body-runner.ts` calls it in the hook catch; `app-plugin.ts` and `app-artifact-handlers.ts` pass the lazy `i18n` reader. - `quickjs-runner.ts`: a marshalled host error carries a non-enumerable marker of the `message` and `code` it crossed with, so an error a `ctx.api` call raised and the body let leave unchanged passes through untouched (`isHostRelayedSandboxError`); one the body rewrote is the body's own. - **`@objectstack/cli`**: `os i18n extract` walks `hooks[].refusals` (one key per declared entry) and `os i18n check` counts them in a new `hook` coverage bucket. - **`@objectstack/lint`**: `translation-target-unknown` judges `hooks.NAME` against the stack's hooks (sibling packages' included) and `hooks.NAME.refusals.CODE` against that hook's declared codes, with a nearest-name suggestion. - **Ripples of the new group**: the console translation preview gains a `hooks` category (en/zh labels) and the loader's group list gains `hooks` (`ui/`); `i18n-standard.mdx` and `ui/translations.mdx`; the walk-parity fixture authors one hook with a catalogue; `CARD_PROPERTY_COUNTS.hook` 22 → 23. - **Skills**: `skills/objectstack-data/references/data-hooks.md` teaches `refusals`. - Changeset: minor for spec, runtime, objectql, cli, lint; patch for `@object-ui/i18n` and `@object-ui/app-shell` (the loader's group list and the translation preview move). ## Tests - `packages/spec/src/data/hook-refusals.test.ts` (new, 52): the schema, the save-time refusal, `refusals` refused beside a `handler` or an expression body, a `{{ }}` hole refused, the literal reader (positive and negative shapes, comments and strings masked), the sentences, the translation path, `session.locale`. - `packages/runtime/src/sandbox/hook-refusal-catalogue.test.ts` (new, 17): through the real factory and QuickJS runner, read off `mapDataError`: declared code with default and declared status, zh-CN translation, `zh` → `zh-CN` negotiation, fallback, no service, undeclared and missing code as 500, a hook without `refusals` unchanged, a crash unchanged, a host error passed through, a host error re-coded by the body judged. - `packages/cli/test/i18n-hook-refusal-coverage.test.ts` (new, 7): walker, coverage bucket, skeleton parses through the schema. - `packages/lint/src/validate-translation-references.test.ts` (+5): the hooks arm. - `packages/objectql/src/engine-execution-plumbing.session-locale.test.ts` (new, 3). - Ablation (on-disk mutation, restored after): with the body runner's `resolveHookRefusal` call replaced by `err`, 9 of the 17 runtime tests fail; with the host-relay marker not recorded, the passthrough test fails. ## Acceptance notes - **The OPEN item (throw shape versus `api.refuse`), measured:** the existing throw shape carries `code` across the VM already (`__errorInfo`), so the body spells a refusal `throw Object.assign(new Error(), { code: 'X' })`, and no new API was added. **Params are not carried:** a declared message is plain text, translated whole, like a validation rule's message (no parameter contract). I did not treat this as a fork, because the ruling's catalogue has no param slot either. A `{{ }}` hole is refused at save now, so a later params slot (e.g. `params` on the thrown error) is purely additive. - **On the wire** an author's code is not an ADR-0112 registered one, so the envelope's `code` is the status's registered code (`VALIDATION_ERROR` for 400, `RESOURCE_CONFLICT` for 409) and the declared code rides as `declaredCode`, the existing demotion contract. The skills doc says so. - **Enforcement scope:** the undeclared-code refusal applies only to a hook that declares `refusals`; every existing hook is answered as before, so the change is non-breaking. A catalogue is served only on the sandboxed `body` path, so `refusals` on a hook with no `js` body is refused at save rather than wired into the in-process `handler` wrap (that would widen past the card). - **Outside the order's file surface, and why:** `packages/objectql/src/engine-execution-plumbing.ts` (the producer of `session.locale`; the runtime had no other way to read the caller's locale), `packages/runtime/src/app-plugin.ts` and `app-artifact-handlers.ts` (wiring the i18n reader), the `ui/` translation-preview and loader lists (an exhaustive `satisfies Record` over the translation groups fails type-check otherwise), the i18n docs, the walk-parity fixture, and the metadata-protocol property count. - **Studio hook form:** `refusals` is not offered. The form's `record` editor stamps each entry's key into the entry, and a refusal entry is closed at `message`/`status` (the key is the code), so a stamped key would be refused. The form gate's ledger records this omission. Offering it needs a key-only record control, filed as #26835. - **Skills, line and token counts:** `data-hooks.md` 998 → 972 lines; the `skills/objectstack-data` markdown package 3849 → 3823 lines. The new section was paid for under the file's token ceiling by deleting error-handling examples the handler and `onError` sections already carry (10063 of 10066 tokens). - No `!` in the title and no `(narrowing)` arm, so no full dogfood run is owed. ## Verification **At head `e55978368d`.** Main is merged through `scripts/pm/os-regen-merge.sh` three times, last at `a20824f366` with #26859. Each merge took main's side of the generated artifacts and regenerated them as their own commit. Every run is under `os-verify-lock`. - **r1 change (`c19dbcd0d4`):** - `hook-refusals.test.ts`: 52 passed. - With `hook.test.ts`, the translation tests and the ADR-0122 pin: 342 passed. - `spec typecheck`: exit 0. - `check:generated --fix`: wrote `api-surface`, `export-origins` and the `hook.mdx` reference. - I first tried a refinement for the hole refusal. The build refused it, because the published JSON Schema could not carry it (dropped-refinements ledger, 5 sites). A pattern carries it, so no ledger rows are added. - `check-skills-token-ratchet`, `check-changeset-fixed`, `check-changeset-no-major` and `check-empty-changeset`: exit 0. - **On the merged tree:** - Full `pnpm build`: exit 0. - `spec check:generated`: all 14 current after the regen commit. - `actionKeys.pin.test.ts`: 19 of 19 (#26859). - The nine whole packages of CI's `Test Core (6/8)` shard: 44 of 44 turbo tasks, on the `8f7675f85b` tree. - Suites: spec 22093 passed (736 files); runtime 7474 passed (461 files); cli 6460 passed (519 files); lint 7453 passed (169 files); objectql 8846 passed (476 files). `check:i18n` exit 0. - Dispatch gates: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 130 families, and all 130 were run with each exit code recorded before any pipe. All 130 exited 0. `--ran` reconciliation: 130 derived, 130 run, 0 NOT-MEASURED, 0 UNRUN, exit 0. - **CI at this head:** all 62 check-runs completed, 57 success and 5 opt-in skips. - **Before r1** (head `78dc14528e`, main merged at `c8583f80ac`): - Suites: spec 22063, runtime 7465, lint 7435, objectql 8846, cli i18n 13, ui 1445. - Dispatch gates: 130 derived, 130 run, 0 NOT-MEASURED, 0 UNRUN. - **Dogfood:** not owed (no `!`, no narrowing arm). --- _Generated by [Claude Code](https://claude.ai/code/session_01WGufzfyVQuRsL6SRGuKCem)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent a90163d commit b3f7344

52 files changed

Lines changed: 1784 additions & 99 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/runtime': minor
4+
'@objectstack/objectql': minor
5+
'@objectstack/cli': minor
6+
'@objectstack/lint': minor
7+
'@object-ui/i18n': patch
8+
'@object-ui/app-shell': patch
9+
---
10+
11+
feat(spec): a hook declares its refusal catalogue, and its body refuses by code, answered in the caller's language
12+
13+
Clause-②: yes (widening)
14+
Metadata-protocol: added
15+
16+
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.
17+
18+
- **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.
19+
- **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.
20+
- **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.
21+
- **CLI.** `os i18n extract` scaffolds one key per declared entry and `os i18n check` counts them, in a new `hook` coverage bucket.
22+
- **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`.
23+
- **Console.** The translation preview shows a `hooks` category (en/zh labels), and the translation loader lists `hooks` among the spec groups.
24+
25+
Nothing to do for existing hooks. To use it, declare `refusals` on a sandboxed hook and throw its codes.

‎content/docs/protocol/kernel/i18n-standard.mdx‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -406,7 +406,7 @@ Three rules the shape enforces, all of them closed since #4001:
406406

407407
- **The top level is the declared group set and nothing else** — `objects`,
408408
`picklists`, `apps`, `messages`, `globalActions`, `dashboards`, `datasets`,
409-
`reports`, `pages`, `flows`, `positions`, `permissions`, `books`,
409+
`reports`, `pages`, `flows`, `hooks`, `positions`, `permissions`, `books`,
410410
`metadataForms`, `settingsCommon`. A group invented beside them
411411
(`"account"`, `"fields"`, `"list"`, `"industries"`) is rejected **by name**
412412
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`.
496496
The contract is `t(key, locale, params?)` — the target locale is always passed
497497
explicitly, and the key itself is returned when no translation is found (there
498498
is no `defaultValue` option). The key is a **dot path into `TranslationData`**,
499-
so it starts at one of the fifteen declared groups.
499+
so it starts at one of the sixteen declared groups.
500500

501501
```typescript
502502
const i18n = kernel.getService('i18n');
@@ -1077,6 +1077,8 @@ beneath an action entry are judged the same way under either.
10771077
| `positions.{name}` | a position's `name`, declared, or an audience anchor (`everyone`, `guest`) |
10781078
| `permissions.{name}` | a permission set's `name`, declared |
10791079
| `reports.{name}` | a report's `name`, declared |
1080+
| `hooks.{hook}` | a hook's `name`, declared |
1081+
| `hooks.{hook}.refusals.{code}` | a code that hook's `refusals` declares |
10801082
| `flows.{flow}.screens.{node}` | the `id` of a `type: 'screen'` node in that flow, nodes nested in loop, parallel and try/catch regions included |
10811083
| `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` |
10821084

‎content/docs/references/api/protocol.mdx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1782,6 +1782,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own
17821782
| **reports** | `Record<string, { label?: string; description?: string; blocks?: Record<string, object> }>` | optional | Report translations keyed by report name |
17831783
| **pages** | `Record<string, { label?: string; description?: string; title?: string; subtitle?: string; … }>` | optional | Page translations keyed by page name |
17841784
| **flows** | `Record<string, { label?: string; successMessage?: string; errorMessage?: string; screens?: Record<string, object>; … }>` | optional | Screen-flow translations keyed by flow name |
1785+
| **hooks** | `Record<string, { refusals?: Record<string, object> }>` | optional | Hook translations keyed by hook name |
17851786
| **positions** | `Record<string, { label?: string; description?: string }>` | optional | Position translations keyed by position name |
17861787
| **permissions** | `Record<string, { label?: string; description?: string }>` | optional | Permission-set translations keyed by permission-set name |
17871788
| **books** | `Record<string, { label?: string; description?: string; groups?: Record<string, object> }>` | optional | Doc-book translations keyed by book name |

‎content/docs/references/data/hook.mdx‎

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
title: Hook schema — Data Protocol property reference
33
navTitle: Hook
4-
description: "Hook schemas of the ObjectStack Data Protocol: Hook, HookContext, HookEvent — each property with its type, default and a TypeScript example."
4+
description: "Hook schemas of the ObjectStack Data Protocol: Hook, HookContext, HookEvent, HookRefusal — each property with its type, default and a TypeScript example."
55
---
66

77
{/* ⚠️ 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,
1313
## TypeScript Usage
1414

1515
```typescript
16-
import { HookSchema, HookContextSchema, HookEvent } from '@objectstack/spec/data';
17-
import type { Hook, HookContext } from '@objectstack/spec/data';
16+
import { HookSchema, HookContextSchema, HookEvent, HookRefusalSchema } from '@objectstack/spec/data';
17+
import type { Hook, HookContext, HookRefusal } from '@objectstack/spec/data';
1818

1919
// Validate data
2020
const result = HookSchema.parse(data);
@@ -42,6 +42,7 @@ const result = HookSchema.parse(data);
4242
| **timeoutMs** | `number` | optional | Maximum execution time in milliseconds before the hook is aborted |
4343
| **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. |
4444
| **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 |
45+
| **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 |
4546
| **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. |
4647
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
4748
| **_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
7980
| **maxRetries** | `number` | optional (default: `3`) | Maximum retry attempts on failure |
8081
| **backoffMs** | `number` | optional (default: `1000`) | Backoff delay between retries in milliseconds |
8182

83+
### Nested Shape: `Hook.refusals[string]`
84+
85+
| Property | Type | Required | Description |
86+
| :--- | :--- | :--- | :--- |
87+
| **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 |
88+
| **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 |
89+
8290

8391
---
8492

@@ -126,6 +134,7 @@ L2 sandboxed JS body — runs inside an isolated VM with declared capabilities
126134
| **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. |
127135
| **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. |
128136
| **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. |
137+
| **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. |
129138
| **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. |
130139

131140
### Nested Shape: `HookContext.provenance`
@@ -164,3 +173,15 @@ L2 sandboxed JS body — runs inside an isolated VM with declared capabilities
164173

165174
---
166175

176+
## HookRefusal
177+
178+
### Properties
179+
180+
| Property | Type | Required | Description |
181+
| :--- | :--- | :--- | :--- |
182+
| **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 |
183+
| **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 |
184+
185+
186+
---
187+

‎content/docs/references/index.mdx‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
---
22
title: Protocol reference — every schema by module
33
navTitle: Protocol Reference
4-
description: Every schema published by @objectstack/spec — 1556 schemas across 14 protocol modules
4+
description: Every schema published by @objectstack/spec — 1557 schemas across 14 protocol modules
55
---
66

77
{/* ⚠️ 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
2323
| [AI Protocol](/docs/references/ai) | 12 | 68 | Agents, tools, skills, RAG and knowledge sources, model registry, conversations. |
2424
| [API Protocol](/docs/references/api) | 32 | 438 | REST contracts, endpoints, routing, realtime, batch, discovery. |
2525
| [Automation Protocol](/docs/references/automation) | 15 | 77 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. |
26-
| [Data Protocol](/docs/references/data) | 31 | 184 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. |
26+
| [Data Protocol](/docs/references/data) | 31 | 185 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. |
2727
| [Identity Protocol](/docs/references/identity) | 5 | 27 | Users and accounts, organizations, positions, SCIM provisioning. |
2828
| [Integration Protocol](/docs/references/integration) | 1 | 12 | The single connector protocol (ADR-0097) — catalog descriptors and provider-bound instances. |
2929
| [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
3434
| [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. |
3535
| [System Protocol](/docs/references/system) | 37 | 287 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. |
3636
| [UI Protocol](/docs/references/ui) | 16 | 171 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. |
37-
| **Total** | **202** | **1556** | 14 protocol modules |
37+
| **Total** | **202** | **1557** | 14 protocol modules |
3838

3939
---
4040

@@ -132,7 +132,7 @@ Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execu
132132

133133
## Data Protocol
134134

135-
**Source:** `packages/spec/src/data/` · **Import:** `@objectstack/spec/data` · **31 pages, 184 schemas**
135+
**Source:** `packages/spec/src/data/` · **Import:** `@objectstack/spec/data` · **31 pages, 185 schemas**
136136

137137
Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer.
138138

@@ -159,7 +159,7 @@ Objects, fields, queries, filters, datasources and drivers — the ObjectQL laye
159159
| [`field.zod.ts`](/docs/references/data/field) | `CurrencyConfig`, `CurrencyValue`, `Field`, `FieldMaskingKeep`, `FieldMaskingRule`, `FieldType`, `InlineGridColumn`, `LocationCoordinates`, `SelectOption`, `UniqueScope` |
160160
| [`field-value.zod.ts`](/docs/references/data/field-value) | `Address`, `AddressValue`, `CalendarDateValue`, `ClockTimeValue`, `FileLikeValue`, `FileReferenceIdValue`, `FileRefusedValue`, `FileValue`, `InstantValue`, `LocationValue`, `ReferenceIdValue` |
161161
| [`filter.zod.ts`](/docs/references/data/filter) | `ComparisonOperator`, `EqualityOperator`, `FieldOperators`, `FieldReference`, `FilterArray`, `FilterCondition`, `NormalizedFilter`, `NowAnchor`, `QueryFilter`, `RangeOperator`, `SetOperator`, `SpecialOperator`, `StringOperator` |
162-
| [`hook.zod.ts`](/docs/references/data/hook) | `Hook`, `HookContext`, `HookEvent` |
162+
| [`hook.zod.ts`](/docs/references/data/hook) | `Hook`, `HookContext`, `HookEvent`, `HookRefusal` |
163163
| [`hook-body.zod.ts`](/docs/references/data/hook-body) | `ExpressionBody`, `HookBody`, `HookBodyCapability`, `ScriptBody` |
164164
| [`mapping.zod.ts`](/docs/references/data/mapping) | `ImportFieldMapping`, `Mapping`, `TransformType` |
165165
| [`object.zod.ts`](/docs/references/data/object) | `ApiMethod`, `ApiOperation`, `Index`, `Lifecycle`, `LifecycleClass`, `Object`, `ObjectAccessConfig`, `ObjectCapabilities`, `ObjectExtension`, `ObjectExternalBinding`, `ObjectFieldGroup`, `ObjectOwnershipEnum`, `ObjectRequiredPermissions`, `PerOperationRequiredPermissions`, `RowCrudActionOverride`, `TenancyConfig` |

0 commit comments

Comments
 (0)