Skip to content

Commit 8938f7e

Browse files
committed
docs(spec): session.positions 不再教一个 hook 里跑不通的调用 (#6001)
`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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011M7UwH25Unfi73UHim7ajY
1 parent 6965160 commit 8938f7e

2 files changed

Lines changed: 61 additions & 10 deletions

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
docs(spec): `session.positions` stops teaching a hook call that cannot run (#6001)
6+
7+
The `positions` key on the hook session carries a deliberate boundary note: it is
8+
**descriptive, never an authorization input**. That conclusion is unchanged. What
9+
changed is the example standing next to it, which was itself defective:
10+
11+
- FROM: "A hook may READ this … forwarding it as the sharing service's evaluation
12+
context (`services.sharing.canEdit(..., { positions })`, the shape both
13+
`content/docs/kernel/runtime-services/` pages teach)"
14+
- TO: an example a hook can actually reach — tailoring a message, branching a
15+
*business* rule through the hook's own `ctx.api` channel, logging — plus an
16+
explicit note that a hook context carries **no `services` key**.
17+
18+
#5720 pinned this by measurement: hook contexts are assembled key by key, and
19+
neither ObjectQL's `buildSession()` nor `buildSandboxContext()`
20+
(`packages/runtime/src/sandbox/body-runner.ts``input` / `previous` / `user` /
21+
`session` / `event` / `object` / `result` / `api` / `log` / `crypto`) ever sets
22+
`services`. So `services.sharing.canEdit(…)` is `undefined()` inside a hook, and
23+
the customary `if (!ok) throw` wrapped around it rejects **every** write. The
24+
one shape the doc held up as correct practice was the exact shape that fails
25+
closed on all traffic.
26+
27+
The two cross-referenced doc pages had already reversed under PR #5938
28+
`sharing-service.mdx` now carries "Enforcement is automatic — do not re-check it
29+
in a hook", and `examples.mdx` teaches `ctx.api` — leaving this JSDoc as the last
30+
site still teaching the withdrawn shape. The reference now points at that
31+
section instead of at the pages generically.
32+
33+
Both the JSDoc **and** the `.describe()` are updated; the `describe` is the copy
34+
served through `/api/v1/meta/types/hook` and rendered in Studio's form, so
35+
leaving it would have kept the defective example on the surface authors actually
36+
read. Text only — no schema, validation, or runtime behaviour change.

packages/spec/src/data/hook.zod.ts

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -418,13 +418,25 @@ export const HookContextSchema = lazySchema(() => z.object({
418418
* `buildSession()` (`packages/objectql/src/engine.ts`).
419419
*
420420
* ⚠️ **Descriptive, NOT an authorization input.** A hook may READ this to
421-
* describe the caller — forwarding it as the sharing service's evaluation
422-
* context (`services.sharing.canEdit(..., { positions })`, the shape both
423-
* `content/docs/kernel/runtime-services/` pages teach), tailoring a
424-
* message, logging — and nothing more. It grants nothing on its own, no
425-
* security middleware keys on it here, and a hook must never make the
426-
* access decision itself by testing it
421+
* describe the caller — tailoring a message, branching a *business* rule
422+
* through its own channel (`ctx.api`), logging — and nothing more. It
423+
* grants nothing on its own, no security middleware keys on it here, and a
424+
* hook must never make the access decision itself by testing it
427425
* (`session.positions.includes('sales_manager')` is the anti-pattern).
426+
*
427+
* The example that used to stand here — forwarding this array as the
428+
* sharing service's evaluation context,
429+
* `services.sharing.canEdit(..., { positions })` — was itself unreachable
430+
* and is gone (#6001). A hook context is assembled key by key by the engine
431+
* (`buildSession()` / `buildSandboxContext()` in
432+
* `packages/runtime/src/sandbox/body-runner.ts`) and carries **no
433+
* `services` key**, so that call is `undefined()` at run time and the
434+
* customary `if (!ok) throw` around it rejects every write (#5720). It was
435+
* a defect shape wearing a good-practice label. The sharing gates run
436+
* inside the engine and have already thrown `FORBIDDEN` before any hook is
437+
* reached, so there is nothing for a hook to forward: see
438+
* `content/docs/kernel/runtime-services/sharing-service.mdx`,
439+
* "Enforcement is automatic — do not re-check it in a hook".
428440
* PRIVILEGE is judged by the security service on the ExecutionContext:
429441
* capability grants (`permissions`), placements (`positions`) and the
430442
* derived posture (ADR-0095 D3). A hook that re-decides access from this
@@ -443,10 +455,13 @@ export const HookContextSchema = lazySchema(() => z.object({
443455
*/
444456
positions: z.array(z.string()).optional().describe(
445457
'Position names held by the caller (ADR-0090 D3; formerly `roles`), copied from '
446-
+ 'ExecutionContext.positions. For hook READS only — e.g. forwarding to the sharing '
447-
+ 'service as evaluation context. Authorization is decided by the security service on '
448-
+ 'the ExecutionContext (permissions / positions / derived posture); this is NOT an '
449-
+ 'authorization input and a hook must not gate a write by testing it.',
458+
+ 'ExecutionContext.positions. For hook READS only — e.g. tailoring a message, or '
459+
+ 'branching a business rule the hook runs through its own `ctx.api` channel. '
460+
+ 'Authorization is decided by the security service on the ExecutionContext '
461+
+ '(permissions / positions / derived posture); this is NOT an authorization input '
462+
+ 'and a hook must not gate a write by testing it. A hook context carries no '
463+
+ '`services` key, so the sharing service cannot be called from one either — the '
464+
+ 'sharing gates already ran inside the engine before the hook chain.',
450465
),
451466
/**
452467
* Historical-import audit-preservation flag (#3493). Set by

0 commit comments

Comments
 (0)