From 8938f7ec824a522d3658133fcdbd4b6e1d62905d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 13:21:16 +0000 Subject: [PATCH] =?UTF-8?q?docs(spec):=20session.positions=20=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E6=95=99=E4=B8=80=E4=B8=AA=20hook=20=E9=87=8C?= =?UTF-8?q?=E8=B7=91=E4=B8=8D=E9=80=9A=E7=9A=84=E8=B0=83=E7=94=A8=20(#6001?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `positions` 的边界结论不变 —— 描述性、非授权输入。变的是它旁边那个例子, 因为例子本身是缺陷形状: FROM「forwarding it as the sharing service's evaluation context (`services.sharing.canEdit(..., { positions })`,两页 runtime-services 文档都这么教)」 TO 一个 hook 真拿得到的通道 —— 定制消息、经 `ctx.api` 跑业务判断、日志 —— 并写明 hook 上下文没有 `services` 键。 #5720 已实测钉死:hook 上下文逐键构造,ObjectQL 的 buildSession() 与 buildSandboxContext()(runtime/src/sandbox/body-runner.ts —— input / previous / user / session / event / object / result / api / log / crypto) 都不设 `services`。于是 `services.sharing.canEdit(…)` 在 hook 里是 `undefined()`,外面惯常包的 `if (!ok) throw` 会拒掉每一次写。文档举为正例的 那个形状,恰是对全量流量 fail-close 的那个。 被引用的两页文档在 PR #5938 后已反转(sharing-service.mdx 新增 「Enforcement is automatic — do not re-check it in a hook」;examples.mdx 改教 `ctx.api`),这段 JSDoc 是最后一处还在教已撤回形状的地方。交叉引用改指 那一节。 JSDoc 与 `.describe()` 同改:describe 是 /api/v1/meta/types/hook 对外供给、 Studio 表单渲染的那一份,只改 JSDoc 会把缺陷例子留在作者真正会看的面上。 仅文案,无 schema / 校验 / 运行时行为变化。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011M7UwH25Unfi73UHim7ajY --- .../hook-positions-reachable-example.md | 36 +++++++++++++++++++ packages/spec/src/data/hook.zod.ts | 35 ++++++++++++------ 2 files changed, 61 insertions(+), 10 deletions(-) create mode 100644 .changeset/hook-positions-reachable-example.md diff --git a/.changeset/hook-positions-reachable-example.md b/.changeset/hook-positions-reachable-example.md new file mode 100644 index 0000000000..2908e0b336 --- /dev/null +++ b/.changeset/hook-positions-reachable-example.md @@ -0,0 +1,36 @@ +--- +"@objectstack/spec": patch +--- + +docs(spec): `session.positions` stops teaching a hook call that cannot run (#6001) + +The `positions` key on the hook session carries a deliberate boundary note: it is +**descriptive, never an authorization input**. That conclusion is unchanged. What +changed is the example standing next to it, which was itself defective: + +- FROM: "A hook may READ this … forwarding it as the sharing service's evaluation + context (`services.sharing.canEdit(..., { positions })`, the shape both + `content/docs/kernel/runtime-services/` pages teach)" +- TO: an example a hook can actually reach — tailoring a message, branching a + *business* rule through the hook's own `ctx.api` channel, logging — plus an + explicit note that a hook context carries **no `services` key**. + +#5720 pinned this by measurement: hook contexts are assembled key by key, and +neither ObjectQL's `buildSession()` nor `buildSandboxContext()` +(`packages/runtime/src/sandbox/body-runner.ts` — `input` / `previous` / `user` / +`session` / `event` / `object` / `result` / `api` / `log` / `crypto`) ever sets +`services`. So `services.sharing.canEdit(…)` is `undefined()` inside a hook, and +the customary `if (!ok) throw` wrapped around it rejects **every** write. The +one shape the doc held up as correct practice was the exact shape that fails +closed on all traffic. + +The two cross-referenced doc pages had already reversed under PR #5938 — +`sharing-service.mdx` now carries "Enforcement is automatic — do not re-check it +in a hook", and `examples.mdx` teaches `ctx.api` — leaving this JSDoc as the last +site still teaching the withdrawn shape. The reference now points at that +section instead of at the pages generically. + +Both the JSDoc **and** the `.describe()` are updated; the `describe` is the copy +served through `/api/v1/meta/types/hook` and rendered in Studio's form, so +leaving it would have kept the defective example on the surface authors actually +read. Text only — no schema, validation, or runtime behaviour change. diff --git a/packages/spec/src/data/hook.zod.ts b/packages/spec/src/data/hook.zod.ts index 19b500905a..0bc65edea6 100644 --- a/packages/spec/src/data/hook.zod.ts +++ b/packages/spec/src/data/hook.zod.ts @@ -418,13 +418,25 @@ export const HookContextSchema = lazySchema(() => z.object({ * `buildSession()` (`packages/objectql/src/engine.ts`). * * ⚠️ **Descriptive, NOT an authorization input.** A hook may READ this to - * describe the caller — forwarding it as the sharing service's evaluation - * context (`services.sharing.canEdit(..., { positions })`, the shape both - * `content/docs/kernel/runtime-services/` pages teach), tailoring a - * message, logging — and nothing more. It grants nothing on its own, no - * security middleware keys on it here, and a hook must never make the - * access decision itself by testing it + * describe the caller — tailoring a message, branching a *business* rule + * through its own channel (`ctx.api`), logging — and nothing more. It + * grants nothing on its own, no security middleware keys on it here, and a + * hook must never make the access decision itself by testing it * (`session.positions.includes('sales_manager')` is the anti-pattern). + * + * The example that used to stand here — forwarding this array as the + * sharing service's evaluation context, + * `services.sharing.canEdit(..., { positions })` — was itself unreachable + * and is gone (#6001). A hook context is assembled key by key by the engine + * (`buildSession()` / `buildSandboxContext()` in + * `packages/runtime/src/sandbox/body-runner.ts`) and carries **no + * `services` key**, so that call is `undefined()` at run time and the + * customary `if (!ok) throw` around it rejects every write (#5720). It was + * a defect shape wearing a good-practice label. The sharing gates run + * inside the engine and have already thrown `FORBIDDEN` before any hook is + * reached, so there is nothing for a hook to forward: see + * `content/docs/kernel/runtime-services/sharing-service.mdx`, + * "Enforcement is automatic — do not re-check it in a hook". * PRIVILEGE is judged by the security service on the ExecutionContext: * capability grants (`permissions`), placements (`positions`) and the * derived posture (ADR-0095 D3). A hook that re-decides access from this @@ -443,10 +455,13 @@ export const HookContextSchema = lazySchema(() => z.object({ */ positions: z.array(z.string()).optional().describe( 'Position names held by the caller (ADR-0090 D3; formerly `roles`), copied from ' - + 'ExecutionContext.positions. For hook READS only — e.g. forwarding to the sharing ' - + 'service as evaluation context. 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.', + + '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.', ), /** * Historical-import audit-preservation flag (#3493). Set by