Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .changeset/hook-positions-reachable-example.md
Original file line numberDiff line numberDiff line change
@@ -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.
35 changes: 25 additions & 10 deletions packages/spec/src/data/hook.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand All@@ -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
Expand Down
Loading