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