Skip to content

Commit 042b9ee

Browse files
qq9340100claude
andauthored
docs(spec): session.positions 不再教一个 hook 里跑不通的调用 (#6001) (#6314)
`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 / 校验 / 运行时行为变化。 Claude-Session: https://claude.ai/code/session_011M7UwH25Unfi73UHim7ajY Co-authored-by: Claude <noreply@anthropic.com>
1 parent 881a3cc commit 042b9ee

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
@@ -422,13 +422,25 @@ export const HookContextSchema = lazySchema(() => z.object({
422422
* `buildSession()` (`packages/objectql/src/engine.ts`).
423423
*
424424
* ⚠️ **Descriptive, NOT an authorization input.** A hook may READ this to
425-
* describe the caller — forwarding it as the sharing service's evaluation
426-
* context (`services.sharing.canEdit(..., { positions })`, the shape both
427-
* `content/docs/kernel/runtime-services/` pages teach), tailoring a
428-
* message, logging — and nothing more. It grants nothing on its own, no
429-
* security middleware keys on it here, and a hook must never make the
430-
* access decision itself by testing it
425+
* describe the caller — tailoring a message, branching a *business* rule
426+
* through its own channel (`ctx.api`), logging — and nothing more. It
427+
* grants nothing on its own, no security middleware keys on it here, and a
428+
* hook must never make the access decision itself by testing it
431429
* (`session.positions.includes('sales_manager')` is the anti-pattern).
430+
*
431+
* The example that used to stand here — forwarding this array as the
432+
* sharing service's evaluation context,
433+
* `services.sharing.canEdit(..., { positions })` — was itself unreachable
434+
* and is gone (#6001). A hook context is assembled key by key by the engine
435+
* (`buildSession()` / `buildSandboxContext()` in
436+
* `packages/runtime/src/sandbox/body-runner.ts`) and carries **no
437+
* `services` key**, so that call is `undefined()` at run time and the
438+
* customary `if (!ok) throw` around it rejects every write (#5720). It was
439+
* a defect shape wearing a good-practice label. The sharing gates run
440+
* inside the engine and have already thrown `FORBIDDEN` before any hook is
441+
* reached, so there is nothing for a hook to forward: see
442+
* `content/docs/kernel/runtime-services/sharing-service.mdx`,
443+
* "Enforcement is automatic — do not re-check it in a hook".
432444
* PRIVILEGE is judged by the security service on the ExecutionContext:
433445
* capability grants (`permissions`), placements (`positions`) and the
434446
* derived posture (ADR-0095 D3). A hook that re-decides access from this
@@ -447,10 +459,13 @@ export const HookContextSchema = lazySchema(() => z.object({
447459
*/
448460
positions: z.array(z.string()).optional().describe(
449461
'Position names held by the caller (ADR-0090 D3; formerly `roles`), copied from '
450-
+ 'ExecutionContext.positions. For hook READS only — e.g. forwarding to the sharing '
451-
+ 'service as evaluation context. Authorization is decided by the security service on '
452-
+ 'the ExecutionContext (permissions / positions / derived posture); this is NOT an '
453-
+ 'authorization input and a hook must not gate a write by testing it.',
462+
+ 'ExecutionContext.positions. For hook READS only — e.g. tailoring a message, or '
463+
+ 'branching a business rule the hook runs through its own `ctx.api` channel. '
464+
+ 'Authorization is decided by the security service on the ExecutionContext '
465+
+ '(permissions / positions / derived posture); this is NOT an authorization input '
466+
+ 'and a hook must not gate a write by testing it. A hook context carries no '
467+
+ '`services` key, so the sharing service cannot be called from one either — the '
468+
+ 'sharing gates already ran inside the engine before the hook chain.',
454469
),
455470
/**
456471
* Historical-import audit-preservation flag (#3493). Set by

0 commit comments

Comments
 (0)