From af75addba9482c58332769588e31cd0b5d163c75 Mon Sep 17 00:00:00 2001
From: os-dev
Date: Thu, 6 Aug 2026 12:36:58 +0000
Subject: [PATCH] =?UTF-8?q?docs(kernel):=20runtime-services=20=E7=9A=84=20?=
=?UTF-8?q?hook=20=E7=A4=BA=E4=BE=8B=E6=94=B9=E6=95=99=E7=9C=9F=E5=AE=9E?=
=?UTF-8?q?=E9=80=9A=E9=81=93=20=E2=80=94=E2=80=94=20=E5=8E=BB=E6=8E=89?=
=?UTF-8?q?=E4=B8=8D=E5=AD=98=E5=9C=A8=E7=9A=84=20`ctx.services`=20(#5720)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
hook 上下文由引擎逐键构造(object/event/input/session/provenance/user/api/
transaction/ql),从来没有 `services` 键;`services.*` 是本页记录的**契约签名面**
(见 runtime-services 索引页的 binding note),托管运行时才注入。照抄旧示例的
`beforeUpdate` 因可选链短路成 `undefined`,`if (!ok) throw` 会无条件拒掉每一次
写入,失败方向还伪装成"正常拒绝"。
- examples.mdx 第 1 节:实测 flow `script` 函数按契约是纯函数
(`handlerContract: 'pure'`),运行时只交 input/variables/automation/logger,
**没有任何数据句柄**——既无 `services` 也无 `ctx.api`。故改写为真实通道:
声明式 `get_record` 读行、`script` 节点把变量映射进函数 `inputs`、函数返回值
由后续声明式节点落库。整块过 `defineFlow` + `defineStack` 真实解析。
- examples.mdx 第 2 节:改为 hook 的真实数据通道 `ctx.api`,示例语义(写前校验 +
拒绝路径)保留,但换成引擎无法代劳的**业务**规则;共享强制由 plugin-sharing 的
引擎中间件按动词自动执行(update → canEdit,delete → canDelete,拒绝抛
FORBIDDEN),hook 手查是冗余教学,故删除。
- 两块示例入参从 `ctx: any` 改 `(ctx: HookContext)`:`any` 让 `{/* os:check */}`
变成空门(块内每次属性访问都不被检查)。反向验证:把旧函数体按 HookContext
如实标注后,两处 `ctx.services` 均报 TS2339 —— 键确实不存在,而门此前是绿的。
- sharing-service.mdx:裸 `services.sharing` 的 Example 实测教的是 hook 语境
(注释自称 "The hook exposes …"、读 ctx.input/ctx.session),不是 action 面。
改为"由持有该服务的代码调用"(契约类型 ISharingService),并新增一节写明强制
自动执行、hook 不得复查;该块补 os:check 标记,首次真正把本页签名钉在契约上。
门禁:check:skill-examples 206 → 207 块全绿(runtime-services 三块从
"标记了但零覆盖"变为真检查);check:doc-authoring 362 文件干净;
check:nul-bytes、check:docs-audit-scope 均绿。
Closes #5720
Co-Authored-By: Claude Fable 5
Claude-Session: https://claude.ai/code/session_01GX3sL71LFq8m2usg6VqTSE
---
.../docs/kernel/runtime-services/examples.mdx | 159 +++++++++++++++---
.../runtime-services/sharing-service.mdx | 45 ++++-
2 files changed, 172 insertions(+), 32 deletions(-)
diff --git a/content/docs/kernel/runtime-services/examples.mdx b/content/docs/kernel/runtime-services/examples.mdx
index bd4728a20d..8ba05ef416 100644
--- a/content/docs/kernel/runtime-services/examples.mdx
+++ b/content/docs/kernel/runtime-services/examples.mdx
@@ -5,42 +5,153 @@ description: Practical examples for flow nodes, hooks, and plugin event subscrip
# Runtime Service Examples
-## 1) Flow custom node: read related records
+
+
+These pages document the `services.*` **contract surface** — the signatures, not a
+binding every surface receives (see the [binding note](/docs/kernel/runtime-services)).
+The examples below therefore use the channel each runtime surface is really handed:
+
+| Surface | Data channel |
+|:--|:--|
+| Data hook (`beforeInsert`, `beforeUpdate`, …) | `ctx.api` — the scoped cross-object API the engine binds per operation (`buildHookApi`, `packages/objectql/src/engine.ts`) |
+| Flow `script` function | none — a function is **pure** by contract (`handlerContract: 'pure'`), so its record I/O lives on the flow graph |
+| Plugin | the plugin context (`ctx.hook`, the kernel service registry) |
+
+A hook context is built key by key by the engine and carries **no `services` key**, so
+`ctx.services?.sharing?.canEdit(…)` there evaluates to `undefined` — and a guard written on
+it (`if (!ok) throw new Error('PERMISSION_DENIED')`) rejects **every** write instead of
+checking anything ([#5720](https://github.com/objectstack-ai/objectstack/issues/5720)).
+
+
+
+## 1) Flow: read related records, then compute in a pure function
+
+The read is a declarative `get_record` node that binds its rows to a flow variable; the
+`script` node maps that variable into a registered function's `inputs`, and the function
+**returns** its result for a later declarative node to persist. A flow function is handed
+`input` / `variables` / `automation` / `logger` and **no data engine** — see
+[Flows](/docs/automation/flows) for why that purity rule keeps a run's record counts honest.
{/* os:check */}
```ts
-export async function run(ctx: any) {
- const { record: order } = await ctx.services.data.get('sales_order', ctx.input.orderId);
- const lines = await ctx.services.data.find('sales_order_line', {
- where: { sales_order_id: order.id },
- orderBy: [{ field: 'line_no', order: 'asc' }],
- limit: 200,
- });
+import { defineFlow, defineStack } from '@objectstack/spec';
+
+interface OrderTotalsInput {
+ lines: Array<{ amount?: number }>;
+}
+/** Pure: it computes from its mapped `inputs` and returns — no data handle needed. */
+function orderTotals(ctx: { input: OrderTotalsInput }) {
+ const lines = ctx.input.lines ?? [];
return {
- order,
- lines: lines.records ?? [],
+ line_count: lines.length,
+ total: lines.reduce((sum, line) => sum + (line.amount ?? 0), 0),
};
}
+
+export const stack = defineStack({
+ functions: { 'sales.orderTotals': orderTotals },
+});
+
+export const RollUpOrderTotals = defineFlow({
+ name: 'sales_order_roll_up_totals',
+ label: 'Roll up order line totals',
+ type: 'autolaunched',
+ status: 'active',
+ nodes: [
+ {
+ id: 'start',
+ type: 'start',
+ label: 'On Order Update',
+ config: { objectName: 'sales_order', triggerType: 'record-after-update' },
+ },
+ {
+ id: 'read_lines',
+ type: 'get_record',
+ label: 'Read the order lines',
+ config: {
+ objectName: 'sales_order_line',
+ filter: { sales_order_id: '{record.id}' },
+ fields: ['amount'],
+ limit: 200,
+ outputVariable: 'lines',
+ },
+ },
+ {
+ id: 'totals',
+ type: 'script',
+ label: 'Sum the lines',
+ config: {
+ function: 'sales.orderTotals',
+ inputs: { lines: '{lines}' },
+ outputVariable: 'totals',
+ },
+ },
+ {
+ id: 'apply',
+ type: 'update_record',
+ label: 'Write the totals back',
+ config: {
+ objectName: 'sales_order',
+ filter: { id: '{record.id}' },
+ fields: { line_count: '{totals.line_count}', amount_total: '{totals.total}' },
+ },
+ },
+ { id: 'end', type: 'end', label: 'End' },
+ ],
+ edges: [
+ { id: 'e1', source: 'start', target: 'read_lines' },
+ { id: 'e2', source: 'read_lines', target: 'totals' },
+ { id: 'e3', source: 'totals', target: 'apply' },
+ { id: 'e4', source: 'apply', target: 'end' },
+ ],
+});
```
-## 2) Hook: check sharing permission before mutation
+## 2) Hook: validate a write against another object
+
+A `before*` hook reaches other objects through `ctx.api`, bound to the caller's execution
+context and transaction, and rejects the write by throwing.
+
+Record-level **sharing is not a hook's job**: when `@objectstack/plugin-sharing` is
+installed its engine middleware gates every by-id write itself — `canEdit` before an
+update, `canDelete` before a delete — and throws `FORBIDDEN` on denial, before any hook
+could re-ask ([`services.sharing`](/docs/kernel/runtime-services/sharing-service)). What a
+hook adds is the **business** rule the engine cannot know.
{/* os:check */}
```ts
-export async function beforeUpdate(ctx: any) {
- const ok = await ctx.services?.sharing?.canEdit('contract', ctx.input.id, {
- userId: ctx.session?.userId,
- // Read the caller's org under `organizationId` (the `session.tenantId` alias
- // was removed in v11, #3290); it feeds the sharing context's `tenantId`.
- tenantId: ctx.session?.organizationId,
- positions: ctx.session?.positions,
- });
-
- if (!ok) {
- throw new Error('PERMISSION_DENIED');
- }
-}
+import { defineHook, type HookContext } from '@objectstack/spec/data';
+
+/**
+ * The one call this hook makes on `ctx.api`. The contract declares
+ * `HookContext.api` opaque (`api: unknown`) because the object the engine binds is
+ * ObjectQL's `ScopedContext`, so a typed handler names the slice it uses.
+ */
+type CrossObjectApi = {
+ object(name: string): {
+ findOne(query: { where: Record }): Promise<{ credit_limit?: number } | null>;
+ };
+};
+
+export const ContractWithinCreditLimit = defineHook({
+ name: 'contract_within_credit_limit',
+ object: 'contract',
+ events: ['beforeInsert', 'beforeUpdate'],
+ handler: async (ctx: HookContext) => {
+ const accountId = ctx.input.account_id;
+ if (typeof accountId !== 'string') return;
+
+ const api = ctx.api as CrossObjectApi;
+ const account = await api.object('crm_account').findOne({ where: { id: accountId } });
+
+ const limit = account?.credit_limit ?? 0;
+ const amount = Number(ctx.input.amount ?? 0);
+ if (limit > 0 && amount > limit) {
+ throw new Error('VALIDATION_FAILED: contract amount exceeds the account credit limit');
+ }
+ },
+});
```
## 3) Plugin: subscribe to kernel lifecycle events
diff --git a/content/docs/kernel/runtime-services/sharing-service.mdx b/content/docs/kernel/runtime-services/sharing-service.mdx
index b2d07157ed..374696207d 100644
--- a/content/docs/kernel/runtime-services/sharing-service.mdx
+++ b/content/docs/kernel/runtime-services/sharing-service.mdx
@@ -56,15 +56,44 @@ mask AND-ed with object CRUD, not a fourth `access_level`.
- `CONFLICT` (409) — `revoke` on a rule-materialised share (`source != 'manual'`); the next rule reconciliation would silently re-grant it. Deactivate or edit the sharing rule instead.
- `SHARING_NOT_ENABLED` (422) — `grant` on an object the sharing gates never consult (public sharing model, no `owner_id` field, a bypass object, or `controlled_by_parent`).
+## Enforcement is automatic — do not re-check it in a hook
+
+With `@objectstack/plugin-sharing` installed, the gates run **inside the engine**: its
+middleware picks the gate by verb — `canEdit` before a by-id update, `canDelete` before a
+delete — and throws `FORBIDDEN` before the hook chain could ask anything. A hook that
+re-checks adds nothing, and it cannot ask this service at all: a hook context is built key
+by key by the engine (`object` / `event` / `input` / `session` / `provenance` / `user` /
+`api` / `transaction` / `ql`) and carries **no `services` key**, so
+`ctx.services?.sharing?.canEdit(…)` is `undefined` there and `if (!ok) throw …` rejects
+every write ([#5720](https://github.com/objectstack-ai/objectstack/issues/5720)). A hook's
+own channel is `ctx.api` — use it for *business* rules
+([examples](/docs/kernel/runtime-services/examples)).
+
## Example
+Call `canEdit` only from code that **holds** the service — a plugin that resolved it from
+the kernel service registry, or a managed runtime's `services.sharing` binding — for
+example to pre-flight an affordance before offering it:
+
+{/* os:check */}
```ts
-const allowed = await services.sharing.canEdit('contract', ctx.input.id, {
- userId: ctx.session?.userId,
- // The hook exposes the caller's org as `organizationId` (the `session.tenantId`
- // alias was removed in v11, #3290); it feeds the sharing context's `tenantId`.
- tenantId: ctx.session?.organizationId,
- positions: ctx.session?.positions,
-});
-if (!allowed) throw new Error('PERMISSION_DENIED');
+import type { ISharingService } from '@objectstack/spec/contracts';
+
+export async function mayEditContract(
+ sharing: ISharingService,
+ recordId: string,
+ session: { userId?: string; organizationId?: string; positions?: string[] },
+): Promise {
+ return sharing.canEdit('contract', recordId, {
+ userId: session.userId,
+ // `SharingExecutionContext` names the org `tenantId`; a session exposes the
+ // same value as `organizationId` (the `session.tenantId` alias was removed in
+ // v11, #3290).
+ tenantId: session.organizationId,
+ positions: session.positions,
+ });
+}
```
+
+`canEdit` returns `false` rather than throwing, so a caller decides what a denial means —
+hiding a button, or raising its own error.