@ultimat3/policy 13.0.0 → 14.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
@@ -73,9 +73,29 @@ two differ, and it is why a surface that decides on input alone needs no edit.
73
73
  process — so a clear in one test file is permanent for every file after it, whose own `import` is a
74
74
  cache hit that declares nothing. `restoreRoles` takes the declaration sites too: `defineRoles()` derives
75
75
  them from the CALLER's stack, so restoring through it would make `X_ROLE_REDEFINED` name the harness.
76
- - **`not()` never inverts `X_UNAUTHENTICATED`.** A null actor is not a fact about this
77
- actor's grants; inverting it makes `not(can('order:internal'))` a public door into the
78
- internal one. Any denial carrying that code propagates unchanged.
76
+ - **`and()` and `or()` REFUSE an empty clause list** (`X_POLICY_CLAUSE_EMPTY`, `As of 2026-08-25`),
77
+ at the call that builds them. An empty `and()` found nothing to deny and answered ALLOWED, so
78
+ `and(...requiredCaps.map(can))` over a list that filtered to nothing — a config-driven or
79
+ per-tenant rule table — admitted an **anonymous** caller on all four surfaces, with `meta.auth`
80
+ deriving from `admitsAnonymous` so `@ultimat3/http` did not 401 first either, and no diagnostic:
81
+ the label renders as `and()`. `allow('public')` is the explicit spelling, so refusing costs a
82
+ caller nothing they cannot say another way. `or()` is refused for symmetry and for axiom 1 rather
83
+ than for safety — it fails closed, but with "no clause allowed this actor", a reason naming no
84
+ clause; `deny('<reason>')` carries one. Refused where it is WRITTEN, the same call
85
+ `@ultimat3/scraping`'s `allowHosts: []` and `discriminated-union.ts`'s unroutable member make.
86
+ - **`not()` never inverts `X_UNAUTHENTICATED`, and `or()` is what makes that true of a TREE**
87
+ (`As of 2026-08-25`). A null actor is not a fact about this actor's grants; inverting it makes
88
+ `not(can('order:internal'))` a public door into the internal one. Any denial carrying that code
89
+ propagates unchanged — but the rule held only while `not`'s DIRECT child was a `can()`:
90
+ `not(or(can('order:internal'), deny('read-only mode')))` **allowed `actor: null`**, because `or`
91
+ reported the LAST denial and `deny`'s `X_FORBIDDEN` overwrote the code `not()` had to recognise.
92
+ So `or` now reports a denial carrying `X_UNAUTHENTICATED` over a later one that does not, and
93
+ `policy-anonymous.ts`'s `or` walk mirrors it. **`and` needs no such rule** — it short-circuits, so
94
+ the denial it reports IS the deciding one. The other candidate repair, `not()` re-reading
95
+ `args.actor === null` itself, was refused: it would deny an anonymous caller under `not(deny(…))`,
96
+ which this file and `policy-anonymous.ts` both document as ALLOWED, and it states the wrong rule —
97
+ `not` inverts a decision about grants, and whether one was made without an actor is `or`'s to
98
+ report.
79
99
  - **`defineRoles()` merges** and refuses a role two modules define differently
80
100
  (`X_ROLE_REDEFINED`, naming both declaration sites). A re-declaration of an *identical*
81
101
  role is a no-op, which is what keeps `defineRoles({ ...roleDefinitions(), … })` legal.
@@ -112,6 +132,12 @@ two differ, and it is why a surface that decides on input alone needs no edit.
112
132
  - No `any`. Never throw a bare `Error` — use `errors.ts`.
113
133
  - **This package owns `X_FORBIDDEN`** and registers its title with core. `http`, `auth`
114
134
  and every surface adapter reuse the code and must not re-register it.
135
+ - **Every owned code is classified `terminal`, and every one is LISTED** (`As of 2026-08-25`).
136
+ `classifyThrown` reads an unregistered code carrying `terminal` as UNCLASSIFIED, so the attempt
137
+ count governs and a job spends its whole retry policy re-proving a denial — the cost
138
+ `@ultimat3/jobs`' webhook block is written up against. Core's own `ErrorRetry` doc names "a
139
+ permission denial" as the canonical `terminal` case and `X_FORBIDDEN` was not classified at all.
140
+ `errors.test.ts` pins the whole list, so a new code with no classification is a failing test.
115
141
 
116
142
  ## The one authz rule — and the one honest exception
117
143
 
package/README.md CHANGED
@@ -70,14 +70,24 @@ missing row to `null`. A surface that has no row passes `{ input, actor, ctx }`
70
70
  |---|---|
71
71
  | `can(p, predicate?)` | permission first, then the row-level predicate |
72
72
  | `allow()` / `deny(reason)` | terminal; `allow()` is how "public" is said out loud |
73
- | `and(...)` | first denial wins, its reason is the reason |
74
- | `or(...)` | first allowance wins; otherwise the last denial is reported |
73
+ | `and(...)` | first denial wins, its reason is the reason; **no clauses is `X_POLICY_CLAUSE_EMPTY`** |
74
+ | `or(...)` | first allowance wins; otherwise the last denial — except one carrying `X_UNAUTHENTICATED`, which outranks a later one. **No clauses is `X_POLICY_CLAUSE_EMPTY`** |
75
75
  | `not(p)` | inverts — except `X_UNAUTHENTICATED`, which propagates unchanged |
76
76
 
77
77
  `not()` never turns "there is no actor" into an allow. `can()` denies a null actor with
78
78
  `X_UNAUTHENTICATED`, and inverting that would make `not(can('order:internal'))` — the natural
79
79
  simplification of `and(can('order:read'), not(can('order:internal')))` — a public door into the
80
- internal one.
80
+ internal one. `or()` is what makes that true of a whole TREE rather than only of `not`'s direct
81
+ child: it used to report the LAST denial, so `not(or(can('order:internal'), deny('read-only mode')))`
82
+ **allowed an anonymous caller** — `deny`'s `X_FORBIDDEN` had overwritten the code `not()` had to
83
+ recognise.
84
+
85
+ `and()` and `or()` refuse an empty clause list at the call that builds them. Nobody writes `and()`;
86
+ they write `and(...requiredCaps.map(can))` over a config-driven list that filters to nothing — and
87
+ an empty `and()` found nothing to deny and **allowed everyone, anonymous callers included**, on all
88
+ four surfaces, with no diagnostic beyond a label reading `and()`. `allow('public')` and
89
+ `deny('<reason>')` are the explicit spellings, so the refusal costs you nothing you cannot say
90
+ another way.
81
91
 
82
92
  `policy.permissions` (or `policyPermissions(policy)`) is the flattened, deduped, sorted list of
83
93
  every permission a tree references, `not()` clauses included. It is what a compliance report has
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/policy",
3
- "version": "13.0.0",
3
+ "version": "14.0.0",
4
4
  "description": "The one authz rule, evaluated identically in every surface",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -31,6 +31,6 @@
31
31
  "test": "bun test"
32
32
  },
33
33
  "dependencies": {
34
- "@ultimat3/core": "13.0.0"
34
+ "@ultimat3/core": "14.0.0"
35
35
  }
36
36
  }
package/src/errors.ts CHANGED
@@ -3,12 +3,20 @@
3
3
  // an action with no policy never compiles. The code and its factory stay published for a
4
4
  // declaration site that cannot express the requirement in a type — a config-driven route table,
5
5
  // a policy resolved by name — and `policyMissing()` is how such a site says it.
6
- import { nearestName, registerErrorCodes, renderCauseValue, UltimateError } from '@ultimat3/core';
6
+ import {
7
+ type ErrorRetry,
8
+ nearestName,
9
+ registerErrorCodes,
10
+ registerErrorRetry,
11
+ renderCauseValue,
12
+ UltimateError,
13
+ } from '@ultimat3/core';
7
14
 
8
15
  export const POLICY_ERROR_CODES = [
9
16
  'X_FORBIDDEN',
10
17
  'X_POLICY_MISSING',
11
18
  'X_PERMISSION_UNKNOWN',
19
+ 'X_POLICY_CLAUSE_EMPTY',
12
20
  'X_POLICY_SURFACE_UNKNOWN',
13
21
  'X_ROLE_REDEFINED',
14
22
  ] as const;
@@ -19,6 +27,7 @@ export const POLICY_ERROR_TITLES: Readonly<Record<PolicyErrorCode, string>> = {
19
27
  X_FORBIDDEN: 'policy denied this actor',
20
28
  X_POLICY_MISSING: 'an action was declared without a policy',
21
29
  X_PERMISSION_UNKNOWN: 'permission string is not in the permission set',
30
+ X_POLICY_CLAUSE_EMPTY: 'a policy combinator was built with no clauses',
22
31
  X_POLICY_SURFACE_UNKNOWN: 'enforce() was handed a surface no adapter answers to',
23
32
  X_ROLE_REDEFINED: 'two modules define the same role differently',
24
33
  };
@@ -31,6 +40,38 @@ registerErrorCodes(
31
40
  Object.fromEntries(Object.entries(POLICY_ERROR_TITLES).map(([code, title]) => [code, { title }])),
32
41
  );
33
42
 
43
+ /**
44
+ * Every one of them, LISTED rather than left to the `terminal` default — the call
45
+ * `@ultimat3/jobs`' webhook block already makes, and for its reason: `classifyThrown` reads an
46
+ * unregistered code carrying `terminal` as UNCLASSIFIED, so the attempt count governs and a job
47
+ * spends its whole retry policy re-proving an answer no attempt can change.
48
+ *
49
+ * `X_FORBIDDEN` is the one that matters, and core's own `ErrorRetry` doc comment names its case
50
+ * verbatim — *"the same call will fail the same way forever (a config fault, a validation error, a
51
+ * permission denial)"*. The same actor, the same input and the same row decide the same way on
52
+ * attempt five; the four declaration faults beside it cannot change between attempts at all,
53
+ * because each is raised while a module is still evaluating.
54
+ *
55
+ * Registered here rather than by the surfaces that THROW `X_FORBIDDEN` (http, auth, ai, realtime)
56
+ * for the reason the title is: this package owns the code, and a second package registering a
57
+ * different classification for it is a conflict `registerErrorRetry` raises rather than absorbs.
58
+ *
59
+ * Written out key by key over a CLOSED `Record<PolicyErrorCode, …>` rather than derived from
60
+ * `POLICY_ERROR_CODES` with a `.map`: a derivation classifies a new code by accident, and the one
61
+ * question worth asking of a code is whether trying it again could ever answer differently. A code
62
+ * added above with no row here is a missing-key TYPE error, which is the enforcement.
63
+ */
64
+ const POLICY_ERROR_RETRY = Object.freeze<Record<PolicyErrorCode, ErrorRetry>>({
65
+ X_FORBIDDEN: 'terminal',
66
+ X_POLICY_MISSING: 'terminal',
67
+ X_PERMISSION_UNKNOWN: 'terminal',
68
+ X_POLICY_CLAUSE_EMPTY: 'terminal',
69
+ X_POLICY_SURFACE_UNKNOWN: 'terminal',
70
+ X_ROLE_REDEFINED: 'terminal',
71
+ });
72
+
73
+ registerErrorRetry(POLICY_ERROR_RETRY);
74
+
34
75
  export class PolicyError extends UltimateError {
35
76
  override readonly name = 'PolicyError';
36
77
 
@@ -107,6 +148,38 @@ export const permissionUnknown = (permission: string, known: readonly string[]):
107
148
  });
108
149
  };
109
150
 
151
+ /**
152
+ * A combinator built with no clauses, refused where it is WRITTEN — the call
153
+ * `@ultimat3/scraping`'s `allowHosts: []` and `discriminated-union.ts`'s unroutable member both
154
+ * already make: a declaration that is wrong for every input is wrong at its first import, not at
155
+ * the request that discovers it.
156
+ *
157
+ * `and()` is the dangerous half and the reason this code exists. With no clauses its loop finds
158
+ * nothing to deny and answers ALLOWED, so `and(...requiredCaps.map(can))` over a list that
159
+ * filtered to empty is a policy admitting an ANONYMOUS caller on all four surfaces — and
160
+ * `meta.auth` derives from `admitsAnonymous`, so `@ultimat3/http` does not 401 first either. There
161
+ * was no diagnostic to follow: the label renders as `and()`.
162
+ *
163
+ * `or()` is refused for symmetry and for axiom 1, not for safety: it denies, which fails closed,
164
+ * but it denies with "no clause allowed this actor" — a reason naming no clause, which is a denial
165
+ * nobody can debug. `deny('<reason>')` is the spelling that says the same thing WITH one.
166
+ *
167
+ * Both fixes name the explicit spelling, so refusing costs a caller nothing they cannot say
168
+ * another way.
169
+ */
170
+ export const emptyClauseList = (combinator: 'and' | 'or'): PolicyError =>
171
+ new PolicyError({
172
+ code: 'X_POLICY_CLAUSE_EMPTY',
173
+ cause:
174
+ combinator === 'and'
175
+ ? 'and() was built with no clauses, so it allows every actor — anonymous callers included — on every surface'
176
+ : 'or() was built with no clauses, so it denies every actor and names no clause that could have allowed one',
177
+ fix:
178
+ combinator === 'and'
179
+ ? "write allow('public') if every caller may act — otherwise pass the clauses the list was meant to hold: and(can('<resource>:<verb>'), …)"
180
+ : "write deny('<why nobody may act>') if nobody may act — otherwise pass the clauses the list was meant to hold: or(can('<resource>:<verb>'), …)",
181
+ });
182
+
110
183
  /**
111
184
  * `enforce()` was handed a surface with no adapter. Thrown rather than denied, because the value
112
185
  * this reports on is one an index would have resolved to something: every object literal inherits
package/src/index.ts CHANGED
@@ -11,6 +11,7 @@ export type { DefinePolicyInput } from './define';
11
11
  export { definePolicy } from './define';
12
12
  export type { PolicyErrorCode } from './errors';
13
13
  export {
14
+ emptyClauseList,
14
15
  forbidden,
15
16
  POLICY_ERROR_CODES,
16
17
  POLICY_ERROR_TITLES,
@@ -58,14 +58,20 @@ const OUTCOME_BY_KIND = Object.freeze<Record<PolicyKind, (policy: PolicyTree) =>
58
58
  }
59
59
  return 'allowed';
60
60
  },
61
- // First allowance wins; if none allow, `or` reports the LAST denial.
61
+ // First allowance wins; if none allow, `or` reports the LAST denial — except that a clause
62
+ // denying for want of an actor outranks one that does not, which is `or`'s own rule in
63
+ // `policy.ts` and the reason `not()` can recognise an `X_UNAUTHENTICATED` raised deeper than
64
+ // its direct child. Both halves were wrong here identically, so the agreement test between
65
+ // this walk and `policy.run` stayed green over the shape neither got right.
62
66
  or: (policy) => {
63
67
  let last: AnonymousOutcome = 'denied';
68
+ let unauthenticated = false;
64
69
  for (const child of policy.children) {
65
70
  last = anonymousOutcome(child);
66
71
  if (last === 'allowed') return 'allowed';
72
+ if (last === 'unauthenticated') unauthenticated = true;
67
73
  }
68
- return last;
74
+ return unauthenticated ? 'unauthenticated' : last;
69
75
  },
70
76
  not: (policy) => {
71
77
  const inner = policy.children[0];
package/src/policy.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  // object be evaluated in an HTTP request, a job, a live query and an MCP tool without
3
3
  // any of them re-implementing the rule — one authz system, never two.
4
4
  import type { Ctx } from '@ultimat3/core';
5
+ import { emptyClauseList } from './errors';
5
6
  import { actorHas } from './grant-index';
6
7
  import { assertPermission, type KnownPermission, type Permission } from './permissions';
7
8
  import type { Actor } from './roles';
@@ -175,9 +176,18 @@ const combined = <I, R>(
175
176
  },
176
177
  });
177
178
 
178
- /** First denial wins, and its reason is the reason — short-circuit, left to right. */
179
- export const and = <I, R = unknown>(...policies: readonly Policy<I, R>[]): Policy<I, R> =>
180
- combined(
179
+ /**
180
+ * First denial wins, and its reason is the reason — short-circuit, left to right.
181
+ *
182
+ * An EMPTY clause list is refused here rather than answered: the loop below finds nothing to deny
183
+ * and returns ALLOWED, so `and(...requiredCaps.map(can))` over a list that filtered to nothing is
184
+ * a policy that admits an anonymous caller on every surface. `allow('public')` is how that is said
185
+ * on purpose. The rest parameter stays a rest parameter — a required first argument would make the
186
+ * spread that produces this bug a type error AND make every legitimate spread one too.
187
+ */
188
+ export const and = <I, R = unknown>(...policies: readonly Policy<I, R>[]): Policy<I, R> => {
189
+ if (policies.length === 0) throw emptyClauseList('and');
190
+ return combined(
181
191
  'and',
182
192
  `and(${policies.map((policy) => policy.label).join(', ')})`,
183
193
  policies,
@@ -189,23 +199,44 @@ export const and = <I, R = unknown>(...policies: readonly Policy<I, R>[]): Polic
189
199
  return ALLOWED;
190
200
  },
191
201
  );
202
+ };
192
203
 
193
- /** First allowance wins; if none allow, the LAST denial is reported. */
194
- export const or = <I, R = unknown>(...policies: readonly Policy<I, R>[]): Policy<I, R> =>
195
- combined(
204
+ /**
205
+ * First allowance wins; if none allow, the LAST denial is reported **except** that a denial
206
+ * carrying `X_UNAUTHENTICATED` outranks one that does not.
207
+ *
208
+ * That exception is what makes `not()`'s rule below true of a TREE rather than only of a direct
209
+ * `can()` child. `not(or(can('order:internal'), deny('read-only mode')))` allowed `actor: null`:
210
+ * the `can` clause raised `X_UNAUTHENTICATED`, `deny`'s `X_FORBIDDEN` overwrote it as the last
211
+ * denial, and `not()` had nothing left to recognise, so it inverted a decision that was made
212
+ * because there was no actor. It is also the more accurate code on its own terms — a clause that
213
+ * could not be decided without an actor makes "authenticate and retry" a real instruction, which
214
+ * a 403 is not.
215
+ *
216
+ * `and` needs no such rule: it short-circuits, so the denial it reports IS the deciding one and
217
+ * every clause after it went unevaluated.
218
+ *
219
+ * An empty clause list is refused for the reason `and`'s is — see `emptyClauseList`.
220
+ */
221
+ export const or = <I, R = unknown>(...policies: readonly Policy<I, R>[]): Policy<I, R> => {
222
+ if (policies.length === 0) throw emptyClauseList('or');
223
+ return combined(
196
224
  'or',
197
225
  `or(${policies.map((policy) => policy.label).join(', ')})`,
198
226
  policies,
199
227
  (args, recorder, depth) => {
200
228
  let last: PolicyDecision = denied('no clause allowed this actor');
229
+ let unauthenticated: PolicyDecision | undefined;
201
230
  for (const policy of policies) {
202
231
  const decision = policy.run(args, recorder, depth);
203
232
  if (decision.allowed) return ALLOWED;
233
+ if (decision.code === 'X_UNAUTHENTICATED') unauthenticated = decision;
204
234
  last = decision;
205
235
  }
206
- return last;
236
+ return unauthenticated ?? last;
207
237
  },
208
238
  );
239
+ };
209
240
 
210
241
  /**
211
242
  * Inverts a decision about an actor's grants — and only that.