@ultimat3/policy 30.8.0 → 31.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -49,12 +49,18 @@ two differ, and it is why a surface that decides on input alone needs no edit.
49
49
  hoisted here. `admitsAnonymous` in particular is EXACT for `actor === null` rather than a
50
50
  heuristic, because `can()` short-circuits on the actor check before its predicate and
51
51
  `allow()`/`deny()` ignore their arguments — no predicate is consulted, so the tree alone decides.
52
+ - **`action` and `query` reach authz through `guardPrimitive()`, one function** (`As of 2026-11`, v31).
53
+ The two tier-3 `policy-gate.ts` files were the same pipeline twice and drifted: a query's MCP
54
+ call was judged as `'job'`, an action's as `'mcp'`. The surface rule is
55
+ `PRIMITIVE_POLICY_SURFACES`, data, read with `Object.hasOwn` at both levels; it RETURNS the
56
+ denial (code, reason, shape) because the error class is the primitive's and this tier cannot
57
+ name it. A new primitive surface is a row there, never a branch in a caller.
52
58
  - **One predicate shape.** A row-level rule reads `args.row`. Never pass a row through
53
59
  `input`, and never add a per-surface args type.
54
60
  - A policy is pure and synchronous. No I/O, no `await`. Load the row first, then decide.
55
61
  - `reason` must be safe to log: name permissions and clauses, never row data or PII —
56
62
  `row` being in scope changes nothing about what a denial is allowed to say. The same
57
- guarantee is what a `DecisionSink` event inherits: `decisions.ts` carries the label, the
63
+ guarantee is what a `policy.decision` event inherits: `decisions.ts` carries the label, the
58
64
  deciding clause and the actor's identifiers, and **never `row` or `input`**.
59
65
  - A missing policy is refused by the **type system**, not by a throw: `ActionDef.policy`
60
66
  is a required field in `@ultimat3/action`, so an action without one does not compile.
@@ -176,8 +182,10 @@ reappearing there is a failing test.
176
182
  | `policy.ts` | `can`/`allow`/`deny`/`and`/`or`/`not` + decision recording, and `policyPermissions` |
177
183
  | `policy-anonymous.ts` | `admitsAnonymous` — the one question a SURFACE asks of a built tree, apart from the file that builds them |
178
184
  | `evaluate.ts` | the single entry point; builds the trace, emits the one decision event |
179
- | `decisions.ts` | the `DecisionSink` seam — no-op default, one call site, never PII |
185
+ | `decisions.ts` | the `policy.decision` bus event — built only while subscribed, one call site, never PII. No slot (`X_INSTRUMENT_SLOT_FORBIDDEN`) |
180
186
  | `surfaces.ts` | http/live/job/mcp adapters — the "one system" proof |
187
+ | `guard-primitive.ts` | `guardPrimitive` — the one gate `action` and `query` run, and the per-primitive surface table |
188
+ | `denial-parts.ts` | `denialCode` / `denialReason` — any `SurfaceDenial` read back as one code and one reason |
181
189
  | `roles.ts` | the role map: merge, conflict, inheritance, wildcards |
182
190
  | `declaration-site.ts` | the caller's stack frame a role or permission declaration records |
183
191
  | `grant-index.ts` | the per-actor flattened grant set, memoised against the role generation |
@@ -194,13 +202,13 @@ with 10k watchers is 10k evaluations in one tick. Two consequences, both load-be
194
202
  re-reads the user row every request and mints a fresh frozen actor, so a revoked role still
195
203
  takes effect on the next request and the entry dies with it. **Never cache an actor, or a
196
204
  grant set, across requests** — that is the stale-authz window the framework does not have.
197
- - The trace is opt-in: on outside production, and in production only once a `DecisionSink` is
198
- installed. `evaluate(policy, args, { trace: true })` forces it — `policyMatrix()` does,
205
+ - The trace is opt-in: on outside production, and in production only while `policy.decision` has a
206
+ subscriber. `evaluate(policy, args, { trace: true })` forces it — `policyMatrix()` does,
199
207
  because `deciding` *is* the matrix.
200
208
 
201
209
  ## Commands
202
210
 
203
211
  ```
204
- bun test packages/policy
212
+ bun test ./packages/policy/
205
213
  bun run --filter @ultimat3/policy typecheck
206
214
  ```
package/README.md CHANGED
@@ -119,7 +119,9 @@ arguments, so no predicate is ever consulted and the tree alone decides. `true`
119
119
  ## Four surfaces, four adapters, one rule
120
120
 
121
121
  `surfaces.ts` is the proof. Each adapter evaluates and maps a denial to that surface's
122
- error shape; allowed returns `undefined`.
122
+ error shape; allowed returns `undefined`. The barrel exports `enforceHttp` and
123
+ `enforce(surface, policy, args)`; the live, job and MCP adapters are reached through `enforce` only
124
+ (off `.` since 31.0.0).
123
125
 
124
126
  | Adapter | Denial shape |
125
127
  |---|---|
@@ -147,6 +149,39 @@ it inherits `Object.prototype` — `enforce('valueOf' as Surface, …)` used to
147
149
  `Object.prototype.valueOf` with the table as its receiver and return a truthy value, so an authz
148
150
  dispatch failed **closed with a `SurfaceDenial` no caller could read**.
149
151
 
152
+ ## One guard for action and query
153
+
154
+ `guardPrimitive(kind, policy, subject, surface)` is the gate `@ultimat3/action` and
155
+ `@ultimat3/query` both run — siblings at tier 3 that cannot share a file, so it lives here.
156
+
157
+ ```ts
158
+ import type { Actor } from '@ultimat3/core';
159
+ import { can, guardPrimitive } from '@ultimat3/policy';
160
+
161
+ declare const actor: Actor | null;
162
+ const refused = guardPrimitive(
163
+ 'query',
164
+ can('post:read'),
165
+ { stage: 'actor', name: 'postFeed', actor },
166
+ 'mcp',
167
+ );
168
+ // `@ultimat3/query` throws its own class from the answer: `new QueryDeniedError(refused)`.
169
+ if (refused !== undefined) console.log(refused.code, refused.reason);
170
+ ```
171
+
172
+ | Field | Meaning |
173
+ |---|---|
174
+ | `kind` | `'action'` or `'query'` — picks the surface table |
175
+ | `subject.stage` | `'actor'`: before the input parse, only `can()`/`allow()`/`deny()` decide, undecided passes · `'input'`: the full evaluation, with `input` and the loaded `row` |
176
+ | `surface` | where the primitive ran: action `server · http · mcp · job`, query `server · http · mcp · live` |
177
+ | returns | `undefined` to proceed, or `{ kind, name, surface, code, reason, denial }` |
178
+
179
+ `PRIMITIVE_POLICY_SURFACES` is the surface rule as data: `server` is judged as `job`, `mcp` as
180
+ `mcp` for **both** primitives (a read was judged as `job` until v31). A surface the primitive does
181
+ not run on is `X_POLICY_SURFACE_UNKNOWN`, never a decision. `denialCode(denial)` /
182
+ `denialReason(denial)` read any `SurfaceDenial` back as one code and one reason — the MCP text
183
+ without its `<code>: ` prefix.
184
+
150
185
  ## Permissions and roles
151
186
 
152
187
  `definePermissions(['post:publish', ...])` gives a typed set; augmenting
@@ -183,26 +218,30 @@ editor deny post:read predicate returned false
183
218
  viewer deny actor lacks post:publish
184
219
  ```
185
220
 
186
- Building it is opt-in: on outside production, and in production only once a decision sink is
187
- installed. A live query evaluates policy per subscriber on every change event, so an unread
221
+ Building it is opt-in: on outside production, and in production only while a `policy.decision`
222
+ subscriber is listening. A live query evaluates policy per subscriber on every change event, so an unread
188
223
  `TraceEntry[]` per evaluation is real allocation on the busiest path there is. Force it with
189
224
  `evaluate(policy, args, { trace: true })`.
190
225
 
191
226
  ## The decision log
192
227
 
193
228
  ```ts
194
- setDecisionSink({
195
- record(event) {
196
- // { label, allowed, code, reason, actorId, actorKind, orgId, surface, deciding }
197
- },
229
+ import { subscribe } from '@ultimat3/core';
230
+
231
+ // { label, allowed, code, reason, actorId, actorKind, orgId, surface, deciding }
232
+ export const stopAudit = subscribe('policy.decision', ({ payload }) => {
233
+ void payload.label;
198
234
  });
199
235
  ```
200
236
 
201
- No-op until installed, emitted from **one place** — inside `evaluate()`, so a fifth surface
202
- inherits it — and it records the **allow** as well as the denial, which is the half an access
203
- review actually asks for. It never carries `row` or `input`: `reason` is safe to log by
204
- construction, and the sink inherits that guarantee. A sink that throws is logged and swallowed;
205
- it never turns an allowed request into a 500.
237
+ `As of 2026-11` (v31): every evaluation is one `policy.decision` event on core's instrumentation
238
+ bus (`PolicyDecisionEvent`, re-exported here). Nothing is built until something subscribes, it is
239
+ emitted from **one place** — inside `evaluate()`, so a fifth surface inherits it — and it records
240
+ the **allow** as well as the denial, which is the half an access review actually asks for. It never
241
+ carries `row` or `input`: `reason` is safe to log by construction, and the event inherits that
242
+ guarantee. Every subscriber hears it — there is no slot to replace. A subscriber's throw is
243
+ contained by the bus, as on every event: an observer is not an authorization input, so it can
244
+ neither deny nor break the evaluation.
206
245
 
207
246
  ## Errors
208
247
 
@@ -220,7 +259,7 @@ a job boundary the class is gone and the `code` is what survives — match on th
220
259
 
221
260
  | Class | Code | Declared in |
222
261
  |---|---|---|
223
- | `PolicyError` | any `PolicyErrorCode` — `POLICY_ERROR_CODES` | `src/errors.ts` |
262
+ | `PolicyError` | any `PolicyErrorCode` | `src/errors.ts` |
224
263
 
225
264
  ## Boundaries
226
265
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/policy",
3
- "version": "30.8.0",
3
+ "version": "31.0.0",
4
4
  "description": "The one authz rule, evaluated identically in every surface",
5
5
  "keywords": [
6
6
  "ultimate",
@@ -42,6 +42,6 @@
42
42
  "test": "bun test"
43
43
  },
44
44
  "dependencies": {
45
- "@ultimat3/core": "30.8.0"
45
+ "@ultimat3/core": "31.0.0"
46
46
  }
47
47
  }
package/src/decisions.ts CHANGED
@@ -1,90 +1,26 @@
1
- // The authz decision log's one seam. Shaped exactly like core's `ErrorReporter`: always on, a
2
- // no-op by default, the wire format supplied by a sink and never here. It exists because the
3
- // question after an incident is "who did this, and which rule let them?" — and until now an
4
- // allowed decision left no trace at all, on any of the four surfaces.
1
+ // The authz decision log: every evaluation, allowed ones included, is a `policy.decision` event on
2
+ // the instrumentation bus — because the question after an incident is "who did this, and which
3
+ // rule let them?", and an allowed decision used to leave no trace on any of the four surfaces.
4
+ // A SIEM exporter, an append-only table or a log line all arrive as one `subscribe()`.
5
5
  //
6
6
  // PII rule, inherited and not negotiable: an event carries the label, the clause and the actor's
7
7
  // identifiers. Never `row`, never `input`. `reason` is already safe to log by construction
8
8
  // (policy/CLAUDE.md) and this is the guarantee that keeps it that way.
9
- import { logger, renderThrowable } from '@ultimat3/core';
10
- import type { Surface } from './surfaces';
9
+ import { instrument, type PolicyDecisionEvent, subscribed } from '@ultimat3/core';
11
10
 
12
- export interface PolicyDecisionEvent {
13
- /** The policy's own label — `and(post:publish, org:administer)`. Safe to log. */
14
- readonly label: string;
15
- readonly allowed: boolean;
16
- /** `null` on an allow: there is no code for "yes". */
17
- readonly code: string | null;
18
- readonly reason: string | null;
19
- readonly actorId: string | null;
20
- readonly actorKind: string | null;
21
- readonly orgId: string | null;
22
- /** `null` when the evaluation did not come through a surface adapter. */
23
- readonly surface: Surface | null;
24
- /** The clause that decided, by label. `null` when the trace was off. */
25
- readonly deciding: string | null;
26
- }
27
-
28
- /** The driver seam. A SIEM exporter, an append-only table or a log line all arrive as one. */
29
- export interface DecisionSink {
30
- record(event: PolicyDecisionEvent): void;
31
- }
32
-
33
- export const noopDecisionSink: DecisionSink = Object.freeze({
34
- record(): void {
35
- // Intentionally empty: an app that logs no decisions pays one property read per evaluation.
36
- },
37
- });
38
-
39
- let sink: DecisionSink | undefined;
40
-
41
- export const setDecisionSink = (next: DecisionSink): void => {
42
- sink = next;
43
- };
44
-
45
- /** Test seam, and the only way back to "unconfigured" — which a literal cannot express. */
46
- export const resetDecisionSink = (): void => {
47
- sink = undefined;
48
- };
11
+ const DECISION = 'policy.decision';
49
12
 
50
13
  /**
51
- * Read by `evaluate()` before it builds an event, and by the trace default: a sink is the one
52
- * reason to keep building a trace in production.
14
+ * Read by `evaluate()` before it builds an event, and by the trace default: a subscriber is the
15
+ * one reason to keep building a trace in production. One map read when nobody listens.
53
16
  */
54
- export const decisionSinkInstalled = (): boolean => sink !== undefined;
17
+ export const decisionObserved = (): boolean => subscribed(DECISION);
55
18
 
56
19
  /**
57
- * Called from exactly ONE place — inside `evaluate()`, so a fifth surface inherits the log the
58
- * day it is added rather than the day someone remembers to wire it. Never throws: a sink that is
59
- * down must not turn an allowed request into a 500.
20
+ * Called from exactly ONE place — inside `evaluate()`, so a fifth surface inherits the log the day
21
+ * it is added rather than the day someone remembers to wire it. A throwing subscriber is contained
22
+ * by the bus, as on every event: an observer is not an authorization input and cannot deny.
60
23
  */
61
24
  export const emitDecision = (event: PolicyDecisionEvent): void => {
62
- if (sink === undefined) return;
63
- try {
64
- sink.record(event);
65
- } catch (failure) {
66
- logger.warn('policy decision sink failed', {
67
- label: event.label,
68
- error: renderThrowable(failure),
69
- });
70
- }
71
- };
72
-
73
- export interface MemoryDecisionSink extends DecisionSink {
74
- readonly events: readonly PolicyDecisionEvent[];
75
- reset(): void;
76
- }
77
-
78
- /** For tests, and for a `x dev` process that shows its own authz decisions without leaving the box. */
79
- export const memoryDecisionSink = (): MemoryDecisionSink => {
80
- const events: PolicyDecisionEvent[] = [];
81
- return {
82
- events,
83
- record(event: PolicyDecisionEvent): void {
84
- events.push(event);
85
- },
86
- reset(): void {
87
- events.length = 0;
88
- },
89
- };
25
+ instrument(DECISION, event);
90
26
  };
@@ -0,0 +1,48 @@
1
+ // The code and the reason a surface-shaped denial carries, read back out of whichever shape the
2
+ // adapter rendered. Here once: `@ultimat3/action` and `@ultimat3/query` each kept a copy, and both
3
+ // copies read an MCP denial's whole text as its reason, so the cause said the code twice.
4
+
5
+ import { assertNever } from '@ultimat3/core';
6
+ import type { SurfaceDenial } from './surfaces';
7
+
8
+ /** The `X_*` code the policy decided with — the same one on every surface. */
9
+ export function denialCode(denial: SurfaceDenial): string {
10
+ switch (denial.surface) {
11
+ case 'http':
12
+ return denial.problem.code;
13
+ case 'live':
14
+ case 'job':
15
+ return denial.code;
16
+ case 'mcp':
17
+ return mcpParts(denial.content[0]?.text).code;
18
+ default:
19
+ return assertNever(denial);
20
+ }
21
+ }
22
+
23
+ /** The policy's reason, safe to log by construction — never prefixed with the code. */
24
+ export function denialReason(denial: SurfaceDenial): string {
25
+ switch (denial.surface) {
26
+ case 'http':
27
+ return denial.problem.detail;
28
+ case 'live':
29
+ case 'job':
30
+ return denial.reason;
31
+ case 'mcp':
32
+ return mcpParts(denial.content[0]?.text).reason;
33
+ default:
34
+ return assertNever(denial);
35
+ }
36
+ }
37
+
38
+ /**
39
+ * `enforceMcp` writes `<code>: <reason>`, and a code never holds `': '`, so the first separator
40
+ * splits it exactly. A text without one is a reason with no code: `X_FORBIDDEN`, the default
41
+ * every adapter falls back to.
42
+ */
43
+ function mcpParts(text: string | undefined): { readonly code: string; readonly reason: string } {
44
+ if (text === undefined) return { code: 'X_FORBIDDEN', reason: 'denied' };
45
+ const at = text.indexOf(': ');
46
+ if (at <= 0) return { code: 'X_FORBIDDEN', reason: text };
47
+ return { code: text.slice(0, at), reason: text.slice(at + 2) };
48
+ }
package/src/errors.ts CHANGED
@@ -243,3 +243,23 @@ export const surfaceUnknown = (surface: unknown, known: readonly string[]): Poli
243
243
  cause: `enforce() was handed surface ${renderCauseValue(surface)}, which no policy adapter answers to`,
244
244
  fix: `pass one of ${known.join(', ')} — e.g. enforce('${known[0] ?? 'http'}', policy, args)`,
245
245
  });
246
+
247
+ /**
248
+ * `guardPrimitive()` was handed a surface its primitive does not run on — an action on `live`, a
249
+ * query on `job` — or a kind that is neither. The same code as `surfaceUnknown`, because it is the
250
+ * same fault one layer up: no adapter answers. Both values are rendered, never interpolated: the
251
+ * call site this exists for is a JS host or a config table no type reached.
252
+ */
253
+ export const primitiveSurfaceUnknown = (
254
+ kind: unknown,
255
+ surface: unknown,
256
+ known: readonly string[],
257
+ ): PolicyError =>
258
+ new PolicyError({
259
+ code: 'X_POLICY_SURFACE_UNKNOWN',
260
+ cause: `guardPrimitive() was handed surface ${renderCauseValue(surface)} for kind ${renderCauseValue(kind)}, which that primitive does not run on`,
261
+ fix:
262
+ known.length === 0
263
+ ? "guardPrimitive('action', policy, subject, 'server') # the kind is 'action' or 'query'"
264
+ : `guardPrimitive(kind, policy, subject, '${known[0]}') # the surface is one of ${known.map((name) => `'${name}'`).join(', ')}`,
265
+ });
package/src/evaluate.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  // One entry point for evaluating a policy, and the only place a decision trace is
2
2
  // built. The trace is what makes an authz denial debuggable: `/_x` renders it, policy
3
3
  // tests assert on it, and an agent reading a 403 can see which clause decided.
4
- // It is also the one place a decision reaches the `DecisionSink` — allowed decisions
4
+ // It is also the one place a `policy.decision` event is emitted — allowed decisions
5
5
  // included, since "who was let in" is the half an audit actually needs.
6
6
  import { type Ctx, DEFAULT_ENVIRONMENT, tryResolveEnvironment } from '@ultimat3/core';
7
- import { decisionSinkInstalled, emitDecision } from './decisions';
7
+ import { decisionObserved, emitDecision } from './decisions';
8
8
  import type { Policy, PolicyDecision, TraceEntry } from './policy';
9
9
  import type { Actor } from './roles';
10
10
  import type { Surface } from './surfaces';
@@ -24,12 +24,12 @@ export interface EvaluateArgs<I, R = unknown> {
24
24
 
25
25
  export interface EvaluateOptions {
26
26
  /**
27
- * Build the trace. Defaults to on outside production, and to on in production only once a
28
- * `DecisionSink` is installed — a `TraceEntry[]` per evaluation is real allocation on the
27
+ * Build the trace. Defaults to on outside production, and to on in production only while
28
+ * `policy.decision` has a subscriber — a `TraceEntry[]` per evaluation is real allocation on the
29
29
  * live-query path, where one write fans out to one evaluation per subscriber.
30
30
  */
31
31
  readonly trace?: boolean | undefined;
32
- /** Which adapter asked. Carried to the sink; the decision itself never depends on it. */
32
+ /** Which adapter asked. Carried on the event; the decision itself never depends on it. */
33
33
  readonly surface?: Surface | undefined;
34
34
  }
35
35
 
@@ -53,7 +53,7 @@ let outsideProduction: boolean | undefined;
53
53
  */
54
54
  const traceByDefault = (): boolean => {
55
55
  outsideProduction ??= (tryResolveEnvironment() ?? DEFAULT_ENVIRONMENT) !== 'production';
56
- return outsideProduction || decisionSinkInstalled();
56
+ return outsideProduction || decisionObserved();
57
57
  };
58
58
 
59
59
  /** Test seam: re-reads the environment on the next evaluation. */
@@ -91,7 +91,7 @@ export const evaluate = <I, R = unknown>(
91
91
  ) ??
92
92
  trace.find(agrees) ??
93
93
  null;
94
- if (decisionSinkInstalled()) {
94
+ if (decisionObserved()) {
95
95
  emitDecision({
96
96
  label: policy.label,
97
97
  allowed: decision.allowed,
@@ -0,0 +1,125 @@
1
+ // The one policy guard `action` and `query` both run: which policy surface each projection is
2
+ // judged on, the actor-only stage before the input parse, the full stage after it, and the denial
3
+ // read back as one code and one reason. Tier 3 siblings cannot share a file, so it lives here.
4
+
5
+ import type { Ctx } from '@ultimat3/core';
6
+ import { denialCode, denialReason } from './denial-parts';
7
+ import { primitiveSurfaceUnknown } from './errors';
8
+ import type { Policy } from './policy';
9
+ import { enforceBeforeInput } from './pre-input';
10
+ import type { Actor } from './roles';
11
+ import { enforce, type Surface, type SurfaceDenial } from './surfaces';
12
+
13
+ /** The two primitives that carry a policy. A mutator is an action; a route asks `evaluate()`. */
14
+ export type GuardedPrimitive = 'action' | 'query';
15
+
16
+ /** Where each primitive runs. `'server'` is in-process app code; `live` is a read's alone. */
17
+ export interface PrimitiveSurfaces {
18
+ readonly action: 'server' | 'http' | 'mcp' | 'job';
19
+ readonly query: 'server' | 'http' | 'mcp' | 'live';
20
+ }
21
+
22
+ export type PrimitiveSurface<K extends GuardedPrimitive = GuardedPrimitive> = PrimitiveSurfaces[K];
23
+
24
+ /**
25
+ * The surface rules, as data. A direct server call has no request and no response to shape, so it
26
+ * is judged as a job. An MCP call is an agent's on BOTH primitives: a read was judged as `'job'`
27
+ * until v31, so its decision events and its denial shape disagreed with an MCP action's.
28
+ */
29
+ export const PRIMITIVE_POLICY_SURFACES = Object.freeze<{
30
+ readonly [K in GuardedPrimitive]: Readonly<Record<PrimitiveSurfaces[K], Surface>>;
31
+ }>({
32
+ action: Object.freeze<Record<PrimitiveSurfaces['action'], Surface>>({
33
+ server: 'job',
34
+ http: 'http',
35
+ mcp: 'mcp',
36
+ job: 'job',
37
+ }),
38
+ query: Object.freeze<Record<PrimitiveSurfaces['query'], Surface>>({
39
+ server: 'job',
40
+ http: 'http',
41
+ mcp: 'mcp',
42
+ live: 'live',
43
+ }),
44
+ });
45
+
46
+ interface SubjectBase {
47
+ /** The registered name of the primitive — what the denial and `x policy explain` name. */
48
+ readonly name: string;
49
+ readonly actor: Actor | null;
50
+ readonly ctx?: Ctx | undefined;
51
+ }
52
+
53
+ /** Before the input parse: only who is asking. Undecided passes to the `'input'` stage. */
54
+ export interface ActorStageSubject extends SubjectBase {
55
+ readonly stage: 'actor';
56
+ }
57
+
58
+ /** After the parse: the input, and the loaded row a row-level rule decides about. */
59
+ export interface InputStageSubject<I = unknown, R = unknown> extends SubjectBase {
60
+ readonly stage: 'input';
61
+ readonly input: I;
62
+ /** Omitted when nothing was loaded; `evaluate()` hands the predicate `null`. */
63
+ readonly row?: R | undefined;
64
+ }
65
+
66
+ export type GuardSubject<I = unknown, R = unknown> = ActorStageSubject | InputStageSubject<I, R>;
67
+
68
+ /** A refusal, already read: the adopting package throws its own error class from it. */
69
+ export interface PrimitiveDenial<K extends GuardedPrimitive = GuardedPrimitive> {
70
+ readonly kind: K;
71
+ readonly name: string;
72
+ readonly surface: PrimitiveSurface<K>;
73
+ readonly code: string;
74
+ readonly reason: string;
75
+ /** The surface-shaped denial — a live socket closes 4403 off it, HTTP renders its problem. */
76
+ readonly denial: SurfaceDenial;
77
+ }
78
+
79
+ /**
80
+ * Decide once and return the refusal, or `undefined` to proceed. Returned rather than thrown: the
81
+ * error class is the primitive's (`ActionDeniedError`, `QueryDeniedError`) and this tier cannot
82
+ * name it. The decision event is `evaluate()`'s, emitted once, on the mapped policy surface.
83
+ */
84
+ export function guardPrimitive<K extends GuardedPrimitive, I, R>(
85
+ kind: K,
86
+ policy: Policy<I, R>,
87
+ subject: GuardSubject<I, R>,
88
+ surface: PrimitiveSurface<K>,
89
+ ): PrimitiveDenial<K> | undefined {
90
+ const target = policySurfaceOf(kind, surface);
91
+ const denial =
92
+ subject.stage === 'actor'
93
+ ? enforceBeforeInput(target, policy, actorArgs(subject))
94
+ : enforce(target, policy, {
95
+ input: subject.input,
96
+ actor: subject.actor,
97
+ ...(subject.row === undefined ? {} : { row: subject.row }),
98
+ ...(subject.ctx === undefined ? {} : { ctx: subject.ctx }),
99
+ });
100
+ if (denial === undefined) return undefined;
101
+ return {
102
+ kind,
103
+ name: subject.name,
104
+ surface,
105
+ code: denialCode(denial),
106
+ reason: denialReason(denial),
107
+ denial,
108
+ };
109
+ }
110
+
111
+ function actorArgs(subject: SubjectBase): { actor: Actor | null; ctx?: Ctx } {
112
+ return subject.ctx === undefined
113
+ ? { actor: subject.actor }
114
+ : { actor: subject.actor, ctx: subject.ctx };
115
+ }
116
+
117
+ /** `Object.hasOwn` at both levels, for the reason `enforce()` gives: an index answers `valueOf`. */
118
+ function policySurfaceOf(kind: GuardedPrimitive, surface: string): Surface {
119
+ if (!Object.hasOwn(PRIMITIVE_POLICY_SURFACES, kind))
120
+ throw primitiveSurfaceUnknown(kind, surface, []);
121
+ const table: Readonly<Record<string, Surface>> = PRIMITIVE_POLICY_SURFACES[kind];
122
+ if (!Object.hasOwn(table, surface))
123
+ throw primitiveSurfaceUnknown(kind, surface, Object.keys(table));
124
+ return table[surface] as Surface;
125
+ }
package/src/index.ts CHANGED
@@ -1,44 +1,33 @@
1
1
  // The public surface of @ultimat3/policy. Explicit, never `export *`.
2
- export type { DecisionSink, MemoryDecisionSink, PolicyDecisionEvent } from './decisions';
3
- export {
4
- decisionSinkInstalled,
5
- memoryDecisionSink,
6
- noopDecisionSink,
7
- resetDecisionSink,
8
- setDecisionSink,
9
- } from './decisions';
2
+ // The `policy.decision` payload is core's (the bus is tier 0); re-exported, never a second shape.
3
+ export type { PolicyDecisionEvent } from '@ultimat3/core';
10
4
  export type { DefinePolicyInput } from './define';
11
5
  export { definePolicy } from './define';
6
+ export { denialCode, denialReason } from './denial-parts';
12
7
  export type { PolicyErrorCode } from './errors';
13
8
  export {
14
9
  denialError,
15
- emptyClauseList,
16
10
  POLICY_BORROWED_ERROR_CODES,
17
- POLICY_ERROR_CODES,
18
- POLICY_ERROR_TITLES,
19
11
  PolicyDenialError,
20
12
  PolicyError,
21
13
  permissionUnknown,
22
14
  policyForbidden,
23
15
  policyMissing,
24
- roleRedefined,
25
16
  } from './errors';
26
17
  export type { EvaluateArgs, EvaluateOptions, PolicyEvaluation } from './evaluate';
27
- export {
28
- codeOf,
29
- evaluate,
30
- explain,
31
- reasonOf,
32
- renderTrace,
33
- resetPolicyTracing,
34
- } from './evaluate';
18
+ export { codeOf, evaluate, explain, reasonOf } from './evaluate';
35
19
  export { actorHas, actorPermissions } from './grant-index';
36
20
  export type {
37
- KnownPermission,
38
- Permission,
39
- PermissionRegistry,
40
- PermissionSet,
41
- } from './permissions';
21
+ ActorStageSubject,
22
+ GuardedPrimitive,
23
+ GuardSubject,
24
+ InputStageSubject,
25
+ PrimitiveDenial,
26
+ PrimitiveSurface,
27
+ PrimitiveSurfaces,
28
+ } from './guard-primitive';
29
+ export { guardPrimitive, PRIMITIVE_POLICY_SURFACES } from './guard-primitive';
30
+ export type { KnownPermission, Permission, PermissionRegistry, PermissionSet } from './permissions';
42
31
  export {
43
32
  assertPermission,
44
33
  clearPermissions,
@@ -48,7 +37,6 @@ export {
48
37
  permissionDeclarationSites,
49
38
  resourceOf,
50
39
  restorePermissions,
51
- verbOf,
52
40
  } from './permissions';
53
41
  export type {
54
42
  Policy,
@@ -83,7 +71,6 @@ export {
83
71
  restoreRoles,
84
72
  roleDeclarationSites,
85
73
  roleDefinitions,
86
- roleMapGeneration,
87
74
  rolesGranting,
88
75
  } from './roles';
89
76
  export type {
@@ -95,13 +82,6 @@ export type {
95
82
  Surface,
96
83
  SurfaceDenial,
97
84
  } from './surfaces';
98
- export {
99
- assertAllowed,
100
- enforce,
101
- enforceHttp,
102
- enforceJob,
103
- enforceLive,
104
- enforceMcp,
105
- } from './surfaces';
85
+ export { assertAllowed, enforce, enforceHttp } from './surfaces';
106
86
  export type { MatrixArgs, MatrixRow, NamedActor, PolicyMatrix } from './test-kit';
107
87
  export { policyMatrix, testActor } from './test-kit';