@cerbos/orm-prisma 4.0.0 → 4.1.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.
Files changed (59) hide show
  1. package/README.md +168 -12
  2. package/lib/index.d.ts +74 -1
  3. package/lib/index.d.ts.map +1 -1
  4. package/lib/index.js +344 -90
  5. package/lib/index.js.map +1 -1
  6. package/package.json +10 -7
  7. package/src/adversarial.test.ts +948 -253
  8. package/src/corpus.ts +414 -0
  9. package/src/generated/prisma-adversarial/browser.ts +10 -0
  10. package/src/generated/prisma-adversarial/client.ts +10 -0
  11. package/src/generated/prisma-adversarial/internal/class.ts +24 -4
  12. package/src/generated/prisma-adversarial/internal/prismaNamespace.ts +178 -2
  13. package/src/generated/prisma-adversarial/internal/prismaNamespaceBrowser.ts +27 -1
  14. package/src/generated/prisma-adversarial/models/AdversarialInner.ts +1369 -0
  15. package/src/generated/prisma-adversarial/models/AdversarialParent.ts +1474 -0
  16. package/src/generated/prisma-adversarial/models/AdversarialResource.ts +124 -0
  17. package/src/generated/prisma-adversarial/models.ts +2 -0
  18. package/src/generated/{prisma → prisma-adversarial-pg}/browser.ts +14 -19
  19. package/src/generated/{prisma → prisma-adversarial-pg}/client.ts +16 -21
  20. package/src/generated/{prisma → prisma-adversarial-pg}/commonInputTypes.ts +153 -71
  21. package/src/generated/prisma-adversarial-pg/internal/class.ts +264 -0
  22. package/src/generated/{prisma → prisma-adversarial-pg}/internal/prismaNamespace.ts +339 -371
  23. package/src/generated/{prisma → prisma-adversarial-pg}/internal/prismaNamespaceBrowser.ts +56 -48
  24. package/src/generated/prisma-adversarial-pg/models/AdversarialCategory.ts +1404 -0
  25. package/src/generated/prisma-adversarial-pg/models/AdversarialInner.ts +1371 -0
  26. package/src/generated/prisma-adversarial-pg/models/AdversarialLabel.ts +1275 -0
  27. package/src/generated/prisma-adversarial-pg/models/AdversarialParent.ts +1476 -0
  28. package/src/generated/prisma-adversarial-pg/models/AdversarialResource.ts +1791 -0
  29. package/src/generated/prisma-adversarial-pg/models/AdversarialSubCategory.ts +1404 -0
  30. package/src/generated/prisma-adversarial-pg/models/AdversarialTag.ts +1310 -0
  31. package/src/generated/prisma-adversarial-pg/models.ts +18 -0
  32. package/src/generated/prisma-adversarial-pg-v6/index.d.ts +11830 -0
  33. package/src/generated/prisma-adversarial-v6/index.d.ts +3173 -44
  34. package/src/index.ts +508 -119
  35. package/src/test-setup.adversarial-url.ts +18 -0
  36. package/src/test-setup.adversarial.pg.v6.ts +13 -0
  37. package/src/test-setup.adversarial.pg.v7.ts +9 -0
  38. package/src/translator.test.ts +1586 -0
  39. package/src/generated/prisma/internal/class.ts +0 -274
  40. package/src/generated/prisma/models/Category.ts +0 -1412
  41. package/src/generated/prisma/models/Label.ts +0 -1276
  42. package/src/generated/prisma/models/NestedResource.ts +0 -1562
  43. package/src/generated/prisma/models/NextLevelNestedResource.ts +0 -1337
  44. package/src/generated/prisma/models/Resource.ts +0 -2196
  45. package/src/generated/prisma/models/SubCategory.ts +0 -1412
  46. package/src/generated/prisma/models/Tag.ts +0 -1276
  47. package/src/generated/prisma/models/User.ts +0 -1503
  48. package/src/generated/prisma/models.ts +0 -19
  49. package/src/generated/prisma-v6/index.d.ts +0 -13726
  50. package/src/index.test.ts +0 -8885
  51. package/src/test-setup.v6.ts +0 -5
  52. package/src/test-setup.v7.ts +0 -7
  53. /package/src/generated/{prisma → prisma-adversarial-pg}/enums.ts +0 -0
  54. /package/src/generated/{prisma-v6 → prisma-adversarial-pg-v6}/client.d.ts +0 -0
  55. /package/src/generated/{prisma-v6 → prisma-adversarial-pg-v6}/default.d.ts +0 -0
  56. /package/src/generated/{prisma-v6 → prisma-adversarial-pg-v6}/edge.d.ts +0 -0
  57. /package/src/generated/{prisma-v6 → prisma-adversarial-pg-v6}/runtime/index-browser.d.ts +0 -0
  58. /package/src/generated/{prisma-v6 → prisma-adversarial-pg-v6}/runtime/library.d.ts +0 -0
  59. /package/src/generated/{prisma-v6 → prisma-adversarial-pg-v6}/wasm.d.ts +0 -0
@@ -2,9 +2,31 @@ import * as fs from "fs";
2
2
  import * as path from "path";
3
3
 
4
4
  import { GRPC as Cerbos } from "@cerbos/grpc";
5
- import type { Principal, Resource, Value } from "@cerbos/core";
5
+ import {
6
+ PlanExpression,
7
+ PlanExpressionValue,
8
+ PlanExpressionVariable,
9
+ } from "@cerbos/core";
10
+ import type {
11
+ PlanExpressionOperand,
12
+ Principal,
13
+ Resource,
14
+ Value,
15
+ } from "@cerbos/core";
6
16
 
7
17
  import { queryPlanToPrisma, PlanKind, MapperConfig } from ".";
18
+ import {
19
+ CONFORMANCE_DIR,
20
+ MAPPER,
21
+ MODEL,
22
+ classifyActionsForAdapter,
23
+ requireMessage,
24
+ } from "./corpus";
25
+ import type {
26
+ ActionClassification,
27
+ ActionsFile,
28
+ ThrowingAction,
29
+ } from "./corpus";
8
30
  import { prisma } from "./test-setup.adversarial";
9
31
 
10
32
  /**
@@ -16,13 +38,73 @@ import { prisma } from "./test-setup.adversarial";
16
38
  *
17
39
  * No hand-computed expectations: if this adapter's filter semantics diverge from Cerbos's own
18
40
  * evaluation for any row, the mismatch surfaces mechanically. See `conformance/README.md` for the
19
- * oracle recipe (NULL-as-missing-attribute, the degeneracy guard) — this file only owns the
20
- * Prisma-specific translation (seeding, field mapping, executing the query).
41
+ * oracle recipe (NULL-as-missing-attribute, the degeneracy guard) — this file only owns seeding
42
+ * and executing the query. The mapper and the per-adapter classification live in `./corpus`,
43
+ * shared with `translator.test.ts`, which pins the filter this suite then proves returns the
44
+ * right rows.
45
+ *
46
+ * The whole corpus is replayed against every store this adapter is proved on, one store per run,
47
+ * selected with `ADAPTER_TEST_DB` (`sqlite` by default, `postgres` for the container-backed leg —
48
+ * see `jest.globalSetup.adversarial.js`). The Prisma major and the store are independent
49
+ * dimensions: v6/v7 is an ENGINE matrix, and an engine matrix says nothing about how a provider
50
+ * coerces a fractional threshold against an `Int` column, escapes a LIKE metacharacter, or
51
+ * compares a real `timestamp` rather than milliseconds since the epoch
52
+ * (cerbos/query-plan-adapters#320).
21
53
  */
22
54
 
23
55
  const cerbos = new Cerbos("127.0.0.1:3593", { tls: false });
24
56
 
25
- const CONFORMANCE_DIR = path.join(__dirname, "..", "..", "conformance");
57
+ const SCHEMA_DIR = path.join(__dirname, "..", "prisma");
58
+
59
+ const STORE_NAMES = ["sqlite", "postgres"] as const;
60
+ type StoreName = (typeof STORE_NAMES)[number];
61
+
62
+ function selectedStoreName(): StoreName {
63
+ const requested = process.env["ADAPTER_TEST_DB"] ?? "sqlite";
64
+ // jest.adversarial.config.js rejects an unknown value before jest resolves this module; the
65
+ // repeat here is what keeps the assertion below reading against a value this file trusts.
66
+ if (!STORE_NAMES.includes(requested as StoreName)) {
67
+ throw new Error(
68
+ `Unknown ADAPTER_TEST_DB "${requested}": expected one of ${STORE_NAMES.join(", ")}`
69
+ );
70
+ }
71
+ return requested as StoreName;
72
+ }
73
+
74
+ const STORE_NAME = selectedStoreName();
75
+
76
+ /**
77
+ * The four adversarial schemas, which must hold one data model between them.
78
+ *
79
+ * A generated Prisma client bakes in its provider and its major, so proving the corpus on
80
+ * (Prisma 6, Prisma 7) x (SQLite, PostgreSQL) needs four schema files. They are only a matrix
81
+ * over the same models — a column that drifts in one of them would seed a different row shape on
82
+ * that leg while every assertion in this file stayed identical, which is the projection trap
83
+ * conformance/README.md describes applied to the schema instead of the seeds.
84
+ */
85
+ const ADVERSARIAL_SCHEMAS = [
86
+ "schema.adversarial.prisma",
87
+ "schema.adversarial.v6.prisma",
88
+ "schema.adversarial.pg.prisma",
89
+ "schema.adversarial.pg.v6.prisma",
90
+ ] as const;
91
+
92
+ /**
93
+ * A schema's `model` blocks, with comments, the generator/datasource blocks and all incidental
94
+ * whitespace removed — everything that legitimately differs between the four.
95
+ */
96
+ function modelBlocks(schema: string): string {
97
+ const withoutComments = schema
98
+ .split("\n")
99
+ .filter((line) => !line.trimStart().startsWith("//"))
100
+ .join("\n");
101
+ const models = withoutComments.match(/^model\s[\s\S]*$/m) ?? [];
102
+ return models
103
+ .join("\n")
104
+ .replace(/[ \t]+/g, " ")
105
+ .replace(/\n\s*\n/g, "\n")
106
+ .trim();
107
+ }
26
108
 
27
109
  interface Tag {
28
110
  id: string;
@@ -37,6 +119,8 @@ interface Seed {
37
119
  aOptionalString: string | null;
38
120
  tags: Tag[];
39
121
  subCategoryNames: string[];
122
+ /** The seed whose scalars this row's to-one `parent` carries; null for no parent. */
123
+ parentSeedId: string | null;
40
124
  }
41
125
 
42
126
  interface SeedsFile {
@@ -45,71 +129,112 @@ interface SeedsFile {
45
129
  seeds: Seed[];
46
130
  }
47
131
 
48
- interface UnsupportedShape {
49
- action: string;
50
- shape: string;
51
- springDataMessage: string;
52
- }
132
+ // -- corpus coverage guards ---------------------------------------------------------------------
133
+ //
134
+ // The same parsed seed feeds the stored row AND the check() oracle, so a corpus field this harness
135
+ // does not consume is dropped from both sides at once and the differential agrees for the wrong
136
+ // reason — the projection trap conformance/README.md describes for actions.json, applied to the
137
+ // seeds. Asserting set equality catches both directions: a corpus key nothing here reads, and a key
138
+ // this harness reads that the corpus no longer carries.
53
139
 
54
- interface AdapterUnsupportedEntry {
55
- action: string;
56
- reason: string;
57
- }
140
+ const SEED_KEYS = [
141
+ "id",
142
+ "aBool",
143
+ "aString",
144
+ "aNumber",
145
+ "aOptionalString",
146
+ "tags",
147
+ "subCategoryNames",
148
+ "parentSeedId",
149
+ ] as const;
58
150
 
59
- interface KnownDivergence {
60
- action: string;
61
- adapters: string[];
62
- }
151
+ /** Corpus prose, never read by a harness: the one documented exclusion from SEED_KEYS. */
152
+ const SEED_NOTE_KEY = "note";
153
+
154
+ /** The one nested object array a seed carries. A key added inside an element is dropped from both
155
+ * sides of the differential just as silently as a top-level one, so it is guarded the same way. */
156
+ const TAG_KEYS = ["id", "name"] as const;
63
157
 
64
- interface ActionsFile {
65
- conformance: string[];
66
- adapterUnsupported?: Record<string, AdapterUnsupportedEntry[]>;
67
- adapterSupportedExpected?: Record<string, AdapterUnsupportedEntry[]>;
68
- expectedUnsupported: UnsupportedShape[];
69
- knownDivergences?: KnownDivergence[];
158
+ const DERIVED_KEYS = [
159
+ "createdBy",
160
+ "aDouble",
161
+ "createdAt",
162
+ "scope",
163
+ "labels",
164
+ ] as const;
165
+
166
+ // The corpus principal is guarded the same way and for the same reason. It feeds the PLAN under
167
+ // test AND the check() oracle, so an attribute dropped on the way in vanishes from both sides at
168
+ // once: the plan folds to ALWAYS_DENIED and the oracle, built from the same principal, agrees. That
169
+ // is how langchain-chromadb's hardcoded attribute allowlist let `pv-exists` pass while testing
170
+ // nothing (conformance/README.md, "Adding a new hostile shape", step 7). This harness passes the
171
+ // principal through verbatim, which is correct; the guard is what proves it still does.
172
+ //
173
+ // `id` and `roles` are deliberately IN scope, guarded by PRINCIPAL_KEYS one level above the
174
+ // attributes — the same two-level shape SEED_KEYS and TAG_KEYS use for a row and its `tags[]`
175
+ // elements. A role dropped on the way in changes every policy decision at once; that it is less
176
+ // likely to be projected away than an attribute is a reason to expect the assertion to stay quiet,
177
+ // not a reason to omit it.
178
+ const PRINCIPAL_KEYS = ["id", "roles", "attr"] as const;
179
+
180
+ const PRINCIPAL_ATTR_KEYS = [
181
+ "allowedTags",
182
+ "context",
183
+ "fewTeams",
184
+ "manyTeams",
185
+ ] as const;
186
+
187
+ /** One seed's derived fields, exactly as conformance/derived-fields.json carries them. */
188
+ interface DerivedEntry {
189
+ createdBy: string;
190
+ aDouble: number | null;
191
+ createdAt: string | null;
192
+ scope: string | null;
193
+ labels: (string | null)[];
70
194
  }
71
195
 
72
- type ThrowingAction = readonly [action: string, reason: string];
196
+ interface DerivedFile {
197
+ fields: string[];
198
+ derived: Record<string, DerivedEntry>;
199
+ }
73
200
 
74
- interface ActionClassification {
75
- oracleActions: string[];
76
- throwingActions: ThrowingAction[];
77
- supportedExpected: Set<string>;
201
+ function assertKeys(
202
+ label: string,
203
+ got: string[],
204
+ want: readonly string[],
205
+ optional: readonly string[] = []
206
+ ): void {
207
+ const allowed = new Set<string>([...want, ...optional]);
208
+ for (const key of got) {
209
+ if (!allowed.has(key)) {
210
+ throw new Error(
211
+ `${label} carries "${key}", which this harness does not consume: an unconsumed corpus field is dropped from the stored row and the check() oracle at once`
212
+ );
213
+ }
214
+ }
215
+ const present = new Set(got);
216
+ for (const key of want) {
217
+ if (!present.has(key)) {
218
+ throw new Error(
219
+ `${label} is missing "${key}", which this harness consumes`
220
+ );
221
+ }
222
+ }
78
223
  }
79
224
 
80
- function classifyActionsForAdapter(
81
- manifest: ActionsFile,
82
- adapter: string
83
- ): ActionClassification {
84
- const unsupported = manifest.adapterUnsupported?.[adapter] ?? [];
85
- const unsupportedActions = new Set(unsupported.map((entry) => entry.action));
86
- const supportedExpected = new Set(
87
- (manifest.adapterSupportedExpected?.[adapter] ?? []).map(
88
- (entry) => entry.action
89
- )
225
+ /**
226
+ * One principal attribute, checked against the two JSON shapes the corpus carries. A key-set guard
227
+ * says nothing about a change inside a value and three of the four attributes are lists, so the
228
+ * element type is asserted for the same reason the seed guard descends into `tags[]`.
229
+ */
230
+ function assertPrincipalAttrShape(label: string, value: unknown): void {
231
+ if (typeof value === "string") return;
232
+ if (Array.isArray(value) && value.every((el) => typeof el === "string")) {
233
+ return;
234
+ }
235
+ throw new Error(
236
+ `${label} is neither a string nor an array of strings, the only two shapes this harness consumes: a reshaped principal attribute feeds the plan and the check() oracle at once`
90
237
  );
91
- const oracleActions = [
92
- ...manifest.conformance.filter(
93
- (action) => !unsupportedActions.has(action)
94
- ),
95
- ...supportedExpected,
96
- ];
97
- const throwingActions: ThrowingAction[] = [
98
- ...unsupported.map(
99
- (entry): ThrowingAction => [entry.action, entry.reason]
100
- ),
101
- ...manifest.expectedUnsupported
102
- .filter((entry) => !supportedExpected.has(entry.action))
103
- .map((entry): ThrowingAction => [entry.action, entry.shape]),
104
- ];
105
-
106
- return {
107
- oracleActions: [...new Set(oracleActions)].sort(),
108
- throwingActions: throwingActions.sort(([left], [right]) =>
109
- left.localeCompare(right)
110
- ),
111
- supportedExpected,
112
- };
113
238
  }
114
239
 
115
240
  const seedsFile: SeedsFile = JSON.parse(
@@ -118,8 +243,56 @@ const seedsFile: SeedsFile = JSON.parse(
118
243
  const actionsFile: ActionsFile = JSON.parse(
119
244
  fs.readFileSync(path.join(CONFORMANCE_DIR, "actions.json"), "utf8")
120
245
  );
246
+ const derivedFile: DerivedFile = JSON.parse(
247
+ fs.readFileSync(path.join(CONFORMANCE_DIR, "derived-fields.json"), "utf8")
248
+ );
121
249
  const SEEDS = seedsFile.seeds;
122
250
 
251
+ // SEEDS holds the parsed JSON rows verbatim, so Object.keys reports the corpus key set. Keep it
252
+ // that way: a parser that rebuilt each row field by field could only ever report the keys this
253
+ // harness already names, and the assertion would pass vacuously.
254
+ SEEDS.forEach((seed, index) => {
255
+ const label = `seeds.json seeds[${index}]`;
256
+ assertKeys(label, Object.keys(seed), SEED_KEYS, [SEED_NOTE_KEY]);
257
+ seed.tags.forEach((tag, tagIndex) => {
258
+ assertKeys(`${label}.tags[${tagIndex}]`, Object.keys(tag), TAG_KEYS);
259
+ });
260
+ });
261
+
262
+ // seedsFile.principal is the parsed JSON object, handed to the SDK untouched, so Object.keys
263
+ // reports the corpus key set on both levels.
264
+ assertKeys(
265
+ "seeds.json principal",
266
+ Object.keys(seedsFile.principal),
267
+ PRINCIPAL_KEYS
268
+ );
269
+ // `attr` is optional on the SDK's Principal type; the corpus always carries it, and the assertion
270
+ // above is what proves it rather than this fallback.
271
+ const PRINCIPAL_ATTR = seedsFile.principal.attr ?? {};
272
+ assertKeys(
273
+ "seeds.json principal.attr",
274
+ Object.keys(PRINCIPAL_ATTR),
275
+ PRINCIPAL_ATTR_KEYS
276
+ );
277
+ for (const [key, value] of Object.entries(PRINCIPAL_ATTR)) {
278
+ assertPrincipalAttrShape(`seeds.json principal.attr.${key}`, value);
279
+ }
280
+
281
+ assertKeys("derived-fields.json fields", derivedFile.fields, DERIVED_KEYS);
282
+ const DERIVED_IDS = Object.keys(derivedFile.derived);
283
+ if (DERIVED_IDS.length !== SEEDS.length) {
284
+ throw new Error(
285
+ `derived-fields.json has ${DERIVED_IDS.length} entries for ${SEEDS.length} seeds`
286
+ );
287
+ }
288
+ for (const seed of SEEDS) {
289
+ assertKeys(
290
+ `derived-fields.json derived["${seed.id}"]`,
291
+ Object.keys(derivedFor(seed)),
292
+ DERIVED_KEYS
293
+ );
294
+ }
295
+
123
296
  const {
124
297
  oracleActions: ORACLE_ACTIONS,
125
298
  throwingActions: THROWING_ACTIONS,
@@ -133,207 +306,269 @@ const PRISMA_KNOWN_DIVERGENCES = new Set(
133
306
  const EXPECTED_UNSUPPORTED_ACTIONS = new Set(
134
307
  actionsFile.expectedUnsupported.map((entry) => entry.action)
135
308
  );
309
+ // Actions whose `== null` probe targets an attribute the oracle OMITS for NULL columns. They
310
+ // carry no oracle comparison: under the omitted representation check() denies every row, so the
311
+ // adapter must reject the shape rather than emit a filter (#302).
312
+ const NULL_REPRESENTATION_OMITTED = actionsFile.nullRepresentationOmitted.map(
313
+ (entry): ThrowingAction => [
314
+ entry.action,
315
+ entry.reason,
316
+ requireMessage(
317
+ `nullRepresentationOmitted.${entry.action}.messages.prisma`,
318
+ entry.messages?.["prisma"]
319
+ ),
320
+ ]
321
+ );
322
+ /** The one message every null-carrying action must be rejected with under `omitted`. */
323
+ const NULL_OMITTED_MESSAGE = NULL_REPRESENTATION_OMITTED[0]?.[2] ?? "";
136
324
  const MANIFEST_ACTIONS = new Set([
137
325
  ...actionsFile.conformance,
138
326
  ...EXPECTED_UNSUPPORTED_ACTIONS,
139
- ...PRISMA_KNOWN_DIVERGENCES,
327
+ ...NULL_REPRESENTATION_OMITTED.map(([action]) => action),
328
+ // ALL divergences, not just Prisma's: a divergence registered solely for another adapter
329
+ // must still enter this manifest, so the size tripwire and the classified-exactly-once
330
+ // check flag it for triage here instead of letting the action silently vanish from this
331
+ // harness. Classification/skipping still uses the Prisma-filtered set.
332
+ ...(actionsFile.knownDivergences ?? []).map((entry) => entry.action),
140
333
  ]);
141
334
 
142
- function doubleFor(seed: Seed): number | null {
143
- switch (seed.id) {
144
- case "a1":
145
- return -0.6;
146
- case "a2":
147
- return 0.25;
148
- case "a3":
149
- return null;
150
- default:
151
- return seed.aNumber + 0.3;
335
+ // -- the degeneracy guard (conformance/README.md, "The degeneracy guard") -----------------------
336
+ //
337
+ // A representative sample of the actions this adapter ORACLE-COMPARES, one per hostile group it
338
+ // can express. The two lists are asserted to be complements of `ORACLE_ACTIONS`, so neither can
339
+ // drift into the other unnoticed.
340
+ //
341
+ // w1-size-zero-chain, w1-not-size-chain, w1-size-frac-chain, the two string-cast actions and
342
+ // filter-as-conjunct are deliberately absent from both lists: their oracles are empty by
343
+ // CONSTRUCTION (no seed holds a to-one parent with zero children, nor one with two or more; every
344
+ // seed's aString raises in int()/double(); a filter() in boolean position is not evaluable at
345
+ // all), so they cannot satisfy a non-empty assertion. filter-as-conjunct carries its own
346
+ // anti-vacuity test instead — see "dropping the untranslatable half over-grants" below.
347
+
348
+ const DEGENERACY_GUARD_ACTIONS = [
349
+ "vf-le",
350
+ // Prisma escapes no LIKE metacharacter at all, so every needle-carrying shape in that group is
351
+ // a liveness probe below. `[` is the one metacharacter it can leave alone — it is literal on
352
+ // every provider but SQL Server, which Prisma's own guard only rejects for hierarchy prefixes —
353
+ // so like-bracket is what carries the group's oracle comparison.
354
+ "like-bracket",
355
+ "all-on-empty",
356
+ "pv-exists",
357
+ "pv-all",
358
+ "null-eq",
359
+ "null-ne",
360
+ // The explicit-null convention against a non-null operand (#308). All five are compared rather
361
+ // than thrown, because the mapper declares the convention per attribute; every one of them
362
+ // under-granted by exactly the NULL-column rows before that declaration existed.
363
+ "null-value-ne-const",
364
+ "null-value-not-eq-const",
365
+ "null-value-not-in-const",
366
+ "null-value-f2f",
367
+ "null-value-pv-not-exists",
368
+ // The absent to-one parent (#309/#315/#316/#334): the four discriminating chain shapes Prisma
369
+ // translates. Its unsupported siblings are liveness probes below.
370
+ "w1-not-exists-chain",
371
+ "w1-not-in-chain",
372
+ "w1-not-hasint-chain",
373
+ "w1-ternary-chain-cond",
374
+ // The real to-one join (#375): one per hazard — the negated hop, the null comparison, two-level
375
+ // depth, the root conjunction, and the disjunction, whose failure direction is an under-grant.
376
+ "rel-not-bool-hop",
377
+ "rel-ne-null-hop",
378
+ "rel-bool-hop2",
379
+ "rel-hop-and-root",
380
+ "rel-hop2-or-exists",
381
+ // Case sensitivity in STRING MATCHING (#375 follow-up), a different mechanism from cs-eq:
382
+ // collation governs `=`, and on SQLite nothing but `PRAGMA case_sensitive_like` governs LIKE.
383
+ "cs-contains",
384
+ // The primary key as a filterable attribute (#376): the key against a constant, against a
385
+ // column under negation — the shape that emitted an invalid `not: { _ref }` before this
386
+ // change — the value-first concatenation solved back to a key equality, and the key inside a
387
+ // constructed hierarchy path.
388
+ "id-eq-const",
389
+ "id-f2f-ne",
390
+ "id-concat-vf",
391
+ "hier-list-id",
392
+ // Root position and bare operand forms (#388): one per hazard — the negation over a bare
393
+ // ordering (every other negated ordering in the corpus wraps a size() or a ternary), the bare
394
+ // boolean at the ROOT of the condition, and the collection subquery disjoined with a scalar
395
+ // predicate rather than conjoined with one.
396
+ "not-lt",
397
+ "root-bare-bool",
398
+ "or-eq-exists",
399
+ // Hazard classes the corpus missed (#387): the De Morgan branch over a conjunction, the
400
+ // value-first hasIntersection whose operands are not interchangeable in the emitted query, and
401
+ // the BELOW-cliff unroll of a principal collection — the shape a principal with three teams
402
+ // produces, which nothing planned while pv-exists carried only the value-list form.
403
+ "not-and",
404
+ "vf-hasint",
405
+ "pv-exists-unrolled",
406
+ ] as const;
407
+
408
+ /**
409
+ * Shapes Prisma refuses to translate: they have no oracle comparison to guard, and stay here as
410
+ * PDP/policy liveness probes for a group Prisma's own list cannot cover. See
411
+ * cerbos/query-plan-adapters#324.
412
+ */
413
+ const DEGENERACY_LIVENESS_PROBES = [
414
+ // Prisma emits LIKE with no ESCAPE clause, so a % needle throws — and so does a backslash one,
415
+ // which is the default escape character on PostgreSQL and MySQL and literal on SQLite
416
+ // (cerbos/query-plan-adapters#320).
417
+ "like-percent",
418
+ "like-backslash",
419
+ // every() cannot require the intermediate hop of a chain, and no relation count with an
420
+ // arbitrary threshold — >= 0 or a rounded fractional one — has a none/some spelling.
421
+ "w1-all-chain",
422
+ "w1-size-nonneg-chain",
423
+ "w1-size-frac-le-chain",
424
+ // Prisma filters have no column arithmetic, so the whole cr-div group (#311) throws.
425
+ "cr-div-neg-zero",
426
+ // int() over a numeric column: truncation-versus-rounding, unsupported for every adapter but
427
+ // convex, which promotes it in adapterSupportedExpected.
428
+ "cast-int-double",
429
+ // string() has no Prisma filter form (#376). cast-string-bool carries the group's probe rather
430
+ // than cast-string-double because its oracle is 14 of 21 rather than a single row, so a PDP or
431
+ // policy that went quiet fails the non-total half of the assertion too.
432
+ "cast-string-bool",
433
+ // Concatenation against the key where BOTH operands carry a column — the arithmetic solver
434
+ // needs a value on the other side, so the id-* group's one throwing shape probes here.
435
+ "id-concat",
436
+ // The same missing form with BOTH operands columns, so there is no constant to invert off
437
+ // either side (#391).
438
+ "concat-f2f",
439
+ // #387, one probe per group Prisma cannot compare. The negated LIKE is refused for its COLUMN
440
+ // needle before the negation matters, so the whole not-* group throws; `mod`, a positional read
441
+ // of a scalar list, and list equality over a projection have no Prisma filter form at all.
442
+ "not-contains",
443
+ "arith-mod",
444
+ "index-scalar-list",
445
+ "map-eq-list",
446
+ ] as const;
447
+
448
+ // -- deterministic derived fields (conformance/README.md, "Deterministic derived fields") --------
449
+ //
450
+ // Read from conformance/derived-fields.json rather than restated here. The same value feeds the
451
+ // stored row and the check() oracle, so a transcription error would be self-consistent and
452
+ // invisible to the differential; one machine-readable definition is what makes that impossible.
453
+
454
+ function derivedFor(seed: Seed): DerivedEntry {
455
+ const entry = derivedFile.derived[seed.id];
456
+ if (entry === undefined) {
457
+ throw new Error(`derived-fields.json has no entry for seed "${seed.id}"`);
152
458
  }
459
+ return entry;
460
+ }
461
+
462
+ function doubleFor(seed: Seed): number | null {
463
+ return derivedFor(seed).aDouble;
153
464
  }
154
465
 
466
+ /** Third-level label names. A null element is a NULL label name — a missing element attribute. */
155
467
  function labelsFor(seed: Seed): (string | null)[] {
156
- switch (seed.id) {
157
- case "a1":
158
- return ["gold", "silver"];
159
- case "a6":
160
- return [null, "silver"];
161
- case "a8":
162
- return ["silver"];
163
- case "c1":
164
- return ["Gold"];
165
- default:
166
- return [];
167
- }
468
+ return derivedFor(seed).labels;
168
469
  }
169
470
 
170
471
  /** Deterministic ISO instant per seed for the timestamp probe: split around 2025-01-01. */
171
472
  function isoFor(seed: Seed): string {
172
- return seed.aNumber >= 2 ? "2024-06-01T00:00:00Z" : "2026-06-01T00:00:00Z";
473
+ return derivedFor(seed).createdBy;
173
474
  }
174
475
 
175
476
  function timestampFor(seed: Seed): string | null {
176
- switch (seed.id) {
177
- case "a1":
178
- return "2020-03-15T10:30:00Z";
179
- case "a2":
180
- return "2037-01-01T00:00:00Z";
181
- case "a3":
182
- return null;
183
- case "a4":
184
- return "2024-06-01T00:00:00Z";
185
- case "a5":
186
- return "2020-03-15T10:30:00.123456Z";
187
- default:
188
- return seed.aNumber >= 2
189
- ? "2036-06-06T06:06:06Z"
190
- : "2021-05-05T05:05:05Z";
191
- }
477
+ return derivedFor(seed).createdAt;
192
478
  }
193
479
 
194
480
  function scopeFor(seed: Seed): string | null {
195
- switch (seed.id) {
196
- case "a1":
197
- return "dept";
198
- case "a2":
199
- return "dept.eng";
200
- case "a3":
201
- return "dept.eng.platform";
202
- case "a4":
203
- return "dept.eng.platform.obs";
204
- case "a5":
205
- return "dept.engineering";
206
- case "a6":
207
- return "dept.sales";
208
- case "a8":
209
- return "";
210
- case "a9":
211
- return "50%";
212
- case "b1":
213
- return "50%:a_b:x";
214
- case "b2":
215
- return "50x:a_b:y";
216
- case "b3":
217
- return "50%:aXb:y";
218
- case "b4":
219
- return "50%:a_b";
220
- case "b5":
221
- return "dept.eng.platform2";
222
- case "b6":
223
- return "50%.a_b";
224
- case "c1":
225
- return "Dept.Eng";
226
- case "c2":
227
- return "dept.eng.";
228
- case "d1":
229
- return "[env]:prod:eu";
230
- case "d2":
231
- return "e:prod:eu";
232
- default:
233
- return null;
481
+ return derivedFor(seed).scope;
482
+ }
483
+
484
+ // -- the real to-one relation (conformance/README.md, "The real to-one relation") ----------------
485
+ //
486
+ // `parentSeedId` names the seed whose four scalars this row's `parent` carries, and that seed's
487
+ // own `parentSeedId` names the ones `parent.inner` carries. The chain is cut at two levels. Every
488
+ // resource owns a FRESH parent (and inner) row rather than pointing at the named seed's own row,
489
+ // so no two resources share one and a filter that returned the parent instead of the child cannot
490
+ // agree with the oracle by accident.
491
+
492
+ const SEEDS_BY_ID = new Map(SEEDS.map((seed) => [seed.id, seed]));
493
+
494
+ function parentSeedOf(seed: Seed | undefined): Seed | undefined {
495
+ const id = seed?.parentSeedId;
496
+ if (id === undefined || id === null) {
497
+ return undefined;
234
498
  }
499
+ const parent = SEEDS_BY_ID.get(id);
500
+ if (parent === undefined) {
501
+ throw new Error(
502
+ `seeds.json: "${seed?.id}" names parent "${id}", which is not a seed id`
503
+ );
504
+ }
505
+ return parent;
235
506
  }
236
507
 
237
- const MAPPER: Record<string, MapperConfig> = {
238
- "request.resource.attr.aBool": { field: "aBool" },
239
- "request.resource.attr.aString": { field: "aString" },
240
- "request.resource.attr.aNumber": { field: "aNumber" },
241
- "request.resource.attr.aDouble": { field: "aDouble" },
242
- "request.resource.attr.aOptionalString": { field: "aOptionalString" },
243
- "request.resource.attr.createdBy": { field: "createdBy" },
244
- "request.resource.attr.scope": { field: "scope" },
245
- "request.resource.attr.createdAt": {
246
- field: "createdAt",
247
- valueType: "dateTime",
248
- },
249
- "request.resource.attr.owner": {
250
- field: "aOptionalString",
251
- nullable: true,
252
- },
253
- // obj.inner is not a real nested column — mirrors aString, same trick the spring-data
254
- // reference harness uses for the p-struct probe.
255
- "request.resource.attr.obj.inner": { field: "aString" },
256
- "request.resource.attr.tags": {
257
- relation: {
258
- name: "tags",
259
- type: "many",
260
- // Model name enables field-to-field comparisons between tag columns; the nullable
261
- // flag on `name` enables the adapter's three-valued-logic guards for collection
262
- // macros over elements whose name column is NULL (a missing attribute — a CEL
263
- // error, hence deny — on the check side).
264
- model: "AdversarialTag",
265
- fields: {
266
- id: { field: "tagId" },
267
- name: { field: "name", nullable: true },
268
- },
269
- },
270
- },
271
- "request.resource.attr.tagNames": {
272
- relation: {
273
- name: "tags",
274
- type: "many",
275
- field: "name",
276
- fields: { name: { field: "name", nullable: true } },
277
- },
278
- },
279
- "request.resource.attr.categories": {
280
- relation: {
281
- name: "categories",
282
- type: "many",
283
- fields: {
284
- name: { field: "name" },
285
- subCategories: {
286
- relation: {
287
- name: "subCategories",
288
- type: "many",
289
- fields: {
290
- name: { field: "name" },
291
- labels: {
292
- relation: {
293
- name: "labels",
294
- type: "many",
295
- fields: {
296
- name: { field: "name", nullable: true },
297
- },
298
- },
299
- },
300
- },
301
- },
302
- },
303
- },
304
- },
305
- },
306
- // Multi-hop chain probe (W1): mainCategory mirrors the SAME categories/subCategories relation
307
- // as a single-object dotted chain on the check side (every seed holds at most one category),
308
- // pinning that the adapter joins through every intermediate hop, never off the root.
309
- "request.resource.attr.mainCategory": {
310
- relation: {
311
- name: "categories",
312
- type: "many",
313
- fields: {
314
- name: { field: "name" },
315
- subCategories: {
316
- relation: {
317
- name: "subCategories",
318
- type: "many",
319
- fields: { name: { field: "name" } },
320
- },
321
- },
322
- // subNames: the same 2-hop chain but with a bare `field`, so plain `in` membership
323
- // compares the flattened tail's name column directly.
324
- subNames: {
325
- relation: {
326
- name: "subCategories",
327
- type: "many",
328
- field: "name",
329
- },
330
- },
331
- },
332
- },
333
- },
334
- };
508
+ /** The four scalars one level of the chain stores, as columns. */
509
+ function relationColumns(seed: Seed): {
510
+ aBool: boolean;
511
+ aString: string;
512
+ aNumber: number;
513
+ aOptionalString: string | null;
514
+ } {
515
+ return {
516
+ aBool: seed.aBool,
517
+ aString: seed.aString,
518
+ aNumber: seed.aNumber,
519
+ aOptionalString: seed.aOptionalString,
520
+ };
521
+ }
522
+
523
+ /** The same four as check() attributes: a NULL column is a MISSING attribute, one hop out. */
524
+ function relationAttr(seed: Seed): Record<string, Value> {
525
+ const attr: Record<string, Value> = {
526
+ aBool: seed.aBool,
527
+ aString: seed.aString,
528
+ aNumber: seed.aNumber,
529
+ };
530
+ if (seed.aOptionalString !== null) {
531
+ attr["aOptionalString"] = seed.aOptionalString;
532
+ }
533
+ return attr;
534
+ }
535
+
536
+ /**
537
+ * The same mapper with every per-attribute null convention stripped, so the call-level option is
538
+ * the only thing governing null operands.
539
+ *
540
+ * The #302 completeness guard is a statement about that option: every corpus action carrying a
541
+ * null literal must be rejected under `"omitted"`. Declaring `owner`/`coOwner` as explicit-null
542
+ * (#308) deliberately overrides the option for those two attributes — which would otherwise read
543
+ * as the guard going quiet, when in fact it is the per-attribute declaration doing exactly its
544
+ * job. Stripping the declarations keeps the guard testing what it was written to test.
545
+ */
546
+ function withoutNullConventions(
547
+ mapper: Record<string, MapperConfig>
548
+ ): Record<string, MapperConfig> {
549
+ return Object.fromEntries(
550
+ Object.entries(mapper).map(([reference, config]) => {
551
+ const { nullAttributeRepresentation: _stripped, ...rest } = config;
552
+ return [reference, rest];
553
+ })
554
+ );
555
+ }
556
+
557
+ const MAPPER_WITHOUT_NULL_CONVENTIONS = withoutNullConventions(MAPPER);
335
558
 
336
559
  beforeAll(async () => {
560
+ // CEL string matching is case-sensitive, and this adapter lowers contains/startsWith/endsWith
561
+ // to LIKE. On SQLite, LIKE is case-INSENSITIVE for ASCII no matter what collation the column
562
+ // was created with — only this pragma changes it — so without it every string predicate
563
+ // over-grants by exactly the case-variant rows (the `cs-contains` group; proved by c1, "One").
564
+ // The column collation the README talks about governs `=`, not LIKE, which is why cs-eq passed
565
+ // here for a long time while the string operators did not. ent and sqlalchemy set the same
566
+ // pragma; drizzle needs none because it lowers to REPLACE rather than LIKE.
567
+ if (STORE_NAME === "sqlite") {
568
+ await prisma.$executeRawUnsafe("PRAGMA case_sensitive_like = ON");
569
+ }
570
+ await prisma.adversarialInner.deleteMany();
571
+ await prisma.adversarialParent.deleteMany();
337
572
  await prisma.adversarialLabel.deleteMany();
338
573
  await prisma.adversarialSubCategory.deleteMany();
339
574
  await prisma.adversarialCategory.deleteMany();
@@ -342,6 +577,8 @@ beforeAll(async () => {
342
577
 
343
578
  // Distinct sub-category/category graphs per seed so no rows share relations by accident.
344
579
  for (const seed of SEEDS) {
580
+ const parentSeed = parentSeedOf(seed);
581
+ const innerSeed = parentSeedOf(parentSeed);
345
582
  await prisma.adversarialResource.create({
346
583
  data: {
347
584
  id: seed.id,
@@ -356,6 +593,21 @@ beforeAll(async () => {
356
593
  tags: {
357
594
  create: seed.tags.map((t) => ({ tagId: t.id, name: t.name })),
358
595
  },
596
+ // The to-one chain, one owned row per level. A seed with no parent gets no row at all,
597
+ // which is what makes the absent-parent hazard reachable through a SCALAR rather than
598
+ // only through mainCategory's collection.
599
+ ...(parentSeed === undefined
600
+ ? {}
601
+ : {
602
+ parent: {
603
+ create: {
604
+ ...relationColumns(parentSeed),
605
+ ...(innerSeed === undefined
606
+ ? {}
607
+ : { inner: { create: relationColumns(innerSeed) } }),
608
+ },
609
+ },
610
+ }),
359
611
  categories: {
360
612
  create: seed.subCategoryNames.map((subName) => ({
361
613
  name: "business",
@@ -401,6 +653,10 @@ function asCheckResource(seed: Seed): Resource {
401
653
  aNumber: seed.aNumber,
402
654
  createdBy: isoFor(seed),
403
655
  owner: seed.aOptionalString,
656
+ // The explicit-null alias of the `scope` column, the second half of `null-value-f2f`:
657
+ // `scope` itself is omitted when NULL (below), so the corpus carries the same column under
658
+ // both conventions and the field-to-field probe has two explicit nulls to compare.
659
+ coOwner: scopeFor(seed),
404
660
  tagNames: seed.tags.map((tag) => tag.name),
405
661
  obj: { inner: seed.aString },
406
662
  tags: seed.tags.map(asTagAttribute),
@@ -444,6 +700,18 @@ function asCheckResource(seed: Seed): Resource {
444
700
  subNames: seed.subCategoryNames,
445
701
  };
446
702
  }
703
+ // The real to-one chain, mirroring the seeded rows exactly. A row with no parent sends NO
704
+ // `parent` attribute — a CEL missing-path error (deny) — matching the adapter's join finding
705
+ // nothing; the same holds one level down for `parent.inner`.
706
+ const parentSeed = parentSeedOf(seed);
707
+ if (parentSeed !== undefined) {
708
+ const parentAttr = relationAttr(parentSeed);
709
+ const innerSeed = parentSeedOf(parentSeed);
710
+ if (innerSeed !== undefined) {
711
+ parentAttr["inner"] = relationAttr(innerSeed);
712
+ }
713
+ attr["parent"] = parentAttr;
714
+ }
447
715
  return { kind: seedsFile.resourceKind, id: seed.id, attr };
448
716
  }
449
717
 
@@ -464,9 +732,23 @@ async function oracleAllowedIds(action: string): Promise<string[]> {
464
732
  return ids.sort();
465
733
  }
466
734
 
735
+ /** The degeneracy guard's per-action assertion, labelled so a failure names the action. */
736
+ async function expectNonDegenerateOracle(action: string): Promise<void> {
737
+ const ids = await oracleAllowedIds(action);
738
+ expect({
739
+ action,
740
+ nonEmpty: ids.length > 0,
741
+ nonTotal: ids.length < SEEDS.length,
742
+ }).toEqual({ action, nonEmpty: true, nonTotal: true });
743
+ }
744
+
467
745
  // -- adapter execution through the public queryPlanToPrisma path --
468
746
 
469
- async function adapterFilteredIds(action: string): Promise<string[]> {
747
+ async function adapterFilteredIds(
748
+ action: string,
749
+ nullAttributeRepresentation: "explicit" | "omitted" = "explicit",
750
+ mapper: Record<string, MapperConfig> = MAPPER
751
+ ): Promise<string[]> {
470
752
  const queryPlan = await cerbos.planResources({
471
753
  principal: principal(),
472
754
  resource: { kind: seedsFile.resourceKind },
@@ -474,8 +756,9 @@ async function adapterFilteredIds(action: string): Promise<string[]> {
474
756
  });
475
757
  const result = queryPlanToPrisma({
476
758
  queryPlan,
477
- mapper: MAPPER,
478
- model: "AdversarialResource",
759
+ mapper,
760
+ model: MODEL,
761
+ nullAttributeRepresentation,
479
762
  });
480
763
  if (result.kind === PlanKind.ALWAYS_DENIED) {
481
764
  return [];
@@ -488,7 +771,56 @@ async function adapterFilteredIds(action: string): Promise<string[]> {
488
771
  return rows.map((r) => r.id).sort();
489
772
  }
490
773
 
491
- describe("adversarial conformance corpus", () => {
774
+ /** Whether any operand anywhere in the plan is a literal null, or a list containing one. */
775
+ function planCarriesNullLiteral(operand: unknown): boolean {
776
+ if (typeof operand !== "object" || operand === null) return false;
777
+ const node = operand as Record<string, unknown>;
778
+ if ("value" in node) {
779
+ const value = node["value"];
780
+ return value === null || (Array.isArray(value) && value.includes(null));
781
+ }
782
+ const operands = node["operands"];
783
+ return Array.isArray(operands) && operands.some(planCarriesNullLiteral);
784
+ }
785
+
786
+ describe(`adversarial conformance corpus (${STORE_NAME})`, () => {
787
+ // Anti-vacuity for the store split: every other assertion in this file is identical on both
788
+ // legs, so a PostgreSQL leg that silently fell back to SQLite would pass the entire suite while
789
+ // proving nothing about PostgreSQL — the exact gap #320 reports. Ask the connection the suite
790
+ // actually queries through which engine it is. Each spelling is rejected by the other engine, so
791
+ // this fails in both directions rather than only when the container is missing.
792
+ test("executes against the store ADAPTER_TEST_DB selects", async () => {
793
+ const rows = await prisma.$queryRawUnsafe<{ banner: string }[]>(
794
+ STORE_NAME === "postgres"
795
+ ? "select version() as banner"
796
+ : "select 'SQLite ' || sqlite_version() as banner"
797
+ );
798
+ expect(rows[0]?.banner?.split(" ")[0]).toBe(
799
+ STORE_NAME === "postgres" ? "PostgreSQL" : "SQLite"
800
+ );
801
+ });
802
+
803
+ test("every adversarial schema declares the same data model", () => {
804
+ const [reference, ...rest] = ADVERSARIAL_SCHEMAS.map((name) => ({
805
+ name,
806
+ models: modelBlocks(
807
+ fs.readFileSync(path.join(SCHEMA_DIR, name), "utf8")
808
+ ),
809
+ }));
810
+ if (!reference) {
811
+ throw new Error("ADVERSARIAL_SCHEMAS is empty");
812
+ }
813
+ // Guard the guard: a regex that stopped matching would make every schema compare equal on an
814
+ // empty string.
815
+ expect(reference.models).toContain(`model ${MODEL}`);
816
+ for (const schema of rest) {
817
+ expect({ name: schema.name, models: schema.models }).toEqual({
818
+ name: schema.name,
819
+ models: reference.models,
820
+ });
821
+ }
822
+ });
823
+
492
824
  test("adapter-supported expected shapes move from throwing to oracle", () => {
493
825
  const promotedAction = "promoted-shape";
494
826
  const classification = classifyActionsForAdapter(
@@ -501,9 +833,10 @@ describe("adversarial conformance corpus", () => {
501
833
  {
502
834
  action: promotedAction,
503
835
  shape: "synthetic globally unsupported shape",
504
- springDataMessage: "unsupported",
836
+ messages: { prisma: "unsupported" },
505
837
  },
506
838
  ],
839
+ nullRepresentationOmitted: [],
507
840
  },
508
841
  "prisma"
509
842
  );
@@ -514,19 +847,46 @@ describe("adversarial conformance corpus", () => {
514
847
  ).not.toContain(promotedAction);
515
848
  });
516
849
 
517
- test("manifest assigns all 118 policy actions exactly one Prisma outcome", () => {
850
+ // Adding a throwing action without pinning its message must fail this harness rather than
851
+ // silently degrade the throw suite to a bare "it threw" (cerbos/query-plan-adapters#326).
852
+ test("a throwing action with no pinned message fails classification", () => {
853
+ const unpinnedAction = "unpinned-shape";
854
+ const classify = (): ActionClassification =>
855
+ classifyActionsForAdapter(
856
+ {
857
+ conformance: [unpinnedAction],
858
+ adapterUnsupported: {
859
+ prisma: [{ action: unpinnedAction, reason: "synthetic limitation" }],
860
+ },
861
+ expectedUnsupported: [],
862
+ nullRepresentationOmitted: [],
863
+ },
864
+ "prisma"
865
+ );
866
+
867
+ expect(classify).toThrow(/pins no throw message/);
868
+ });
869
+
870
+ test("manifest assigns all 146 policy actions exactly one Prisma outcome", () => {
518
871
  const oracle = new Set(ORACLE_ACTIONS);
519
872
  const throwing = new Set(THROWING_ACTIONS.map(([action]) => action));
873
+ const nullOmitted = new Set(
874
+ NULL_REPRESENTATION_OMITTED.map(([action]) => action)
875
+ );
520
876
  const misclassified = [...MANIFEST_ACTIONS].filter((action) => {
521
877
  const classificationCount = [
522
878
  oracle.has(action),
523
879
  throwing.has(action),
880
+ nullOmitted.has(action),
524
881
  PRISMA_KNOWN_DIVERGENCES.has(action),
525
882
  ].filter(Boolean).length;
526
883
  return classificationCount !== 1;
527
884
  });
528
885
 
529
- expect(MANIFEST_ACTIONS.size).toBe(118);
886
+ expect(MANIFEST_ACTIONS.size).toBe(199);
887
+ // Deliberate tripwire: every one of these carries a pinned message, so a throwing action
888
+ // gained or lost has to be re-triaged here rather than joining the suite unnoticed.
889
+ expect(THROWING_ACTIONS).toHaveLength(61);
530
890
  expect(misclassified).toEqual([]);
531
891
  expect(
532
892
  [...PRISMA_SUPPORTED_EXPECTED].filter(
@@ -548,14 +908,142 @@ describe("adversarial conformance corpus", () => {
548
908
 
549
909
  // Shapes the adapter does not support (globally unsupported planner shapes plus Prisma's
550
910
  // declared adapterUnsupported list): translation must fail loudly, never produce a
551
- // silently-wrong filter.
911
+ // silently-wrong filter. The plan is fetched OUTSIDE the assertion so a PDP failure fails
912
+ // the test instead of passing it, and no query executes — the invariant is that the shape
913
+ // throws BEFORE a filter exists, so SQLite rejecting a wrongly emitted filter afterwards
914
+ // must not be able to masquerade as the adapter refusing to translate.
915
+ //
916
+ // The message is asserted, not just the throw: a bare `toThrow()` is satisfied by a mapper
917
+ // typo or an unrelated validation, which would leave the classification resting on a failure
918
+ // that has nothing to do with the limitation it declares (cerbos/query-plan-adapters#326).
552
919
  test.each(THROWING_ACTIONS)(
553
- "%s fails loudly instead of silently mistranslating (%s)",
554
- async (action) => {
555
- await expect(adapterFilteredIds(action)).rejects.toThrow();
920
+ "%s fails during translation with the declared message, before any filter exists (%s)",
921
+ async (action, _reason, message) => {
922
+ const queryPlan = await cerbos.planResources({
923
+ principal: principal(),
924
+ resource: { kind: seedsFile.resourceKind },
925
+ action,
926
+ });
927
+ expect(queryPlan.kind).toBe(PlanKind.CONDITIONAL);
928
+ expect(() =>
929
+ queryPlanToPrisma({
930
+ queryPlan,
931
+ mapper: MAPPER,
932
+ model: MODEL,
933
+ nullAttributeRepresentation: "explicit",
934
+ })
935
+ ).toThrow(message);
556
936
  }
557
937
  );
558
938
 
939
+ // #387. `filter-as-conjunct` puts a filter() one level below the root, where the guard that
940
+ // refuses `filter-as-condition` does not look. Its oracle is empty BY CONSTRUCTION — check()
941
+ // cannot evaluate a non-boolean conjunction — so it belongs to neither degeneracy-guard list,
942
+ // and a bare "it throws" would say nothing about whether refusing it is REQUIRED.
943
+ //
944
+ // This is that argument. The other conjunct is `R.attr.aBool`, which the adapter certainly can
945
+ // express and which `root-bare-bool` spells on its own; an adapter that dropped the conjunct it
946
+ // could not translate would emit exactly that filter and return every row it selects, all of
947
+ // which the PDP denies for this action.
948
+ test("filter-as-conjunct must be refused: dropping its untranslatable half over-grants", async () => {
949
+ expect(await oracleAllowedIds("filter-as-conjunct")).toEqual([]);
950
+
951
+ const survivingHalf = await adapterFilteredIds("root-bare-bool");
952
+ expect(survivingHalf.length).toBeGreaterThan(0);
953
+ expect(survivingHalf.length).toBeLessThan(SEEDS.length);
954
+
955
+ const message = THROWING_ACTIONS.find(
956
+ ([action]) => action === "filter-as-conjunct"
957
+ )?.[2];
958
+ expect(message).toBeDefined();
959
+ await expect(adapterFilteredIds("filter-as-conjunct")).rejects.toThrow(
960
+ message
961
+ );
962
+ });
963
+
964
+ // #302. `null-eq-missing` probes `aOptionalString == null`, and `aOptionalString` follows the
965
+ // corpus default: a NULL column sends NO attribute. Both halves are asserted because the
966
+ // rejection alone would pass vacuously if the adapter threw for an unrelated reason — the
967
+ // over-grant under the default representation is what makes the rejection necessary.
968
+ test.each(NULL_REPRESENTATION_OMITTED)(
969
+ "%s over-grants under the explicit representation and is rejected under omitted (%s)",
970
+ async (action, _reason, message) => {
971
+ const oracle = await oracleAllowedIds(action);
972
+ expect(oracle).toEqual([]);
973
+
974
+ // The default translation emits IS NULL and returns exactly the rows the PDP denies.
975
+ const overGranted = await adapterFilteredIds(action, "explicit");
976
+ expect(overGranted.length).toBeGreaterThan(0);
977
+
978
+ await expect(adapterFilteredIds(action, "omitted")).rejects.toThrow(
979
+ message
980
+ );
981
+ }
982
+ );
983
+
984
+ // #308. The per-attribute declaration overrides the call-level option, which is the property
985
+ // that makes a suite mixing both conventions expressible at all. Asserted in both directions
986
+ // against the SAME action and the SAME call-level option, varying only whether the mapper
987
+ // declares the convention — so a declaration that did nothing would show up here as the two
988
+ // runs agreeing.
989
+ test("a per-attribute declaration overrides the call-level representation", async () => {
990
+ // `owner` declares "explicit", so the call-level "omitted" does not reach it.
991
+ await expect(adapterFilteredIds("null-eq", "omitted")).resolves.toEqual(
992
+ await oracleAllowedIds("null-eq")
993
+ );
994
+
995
+ // Strip the declaration and the same action under the same option is rejected — so the
996
+ // stripped mapper the completeness guard below uses is not quietly equivalent to MAPPER.
997
+ await expect(
998
+ adapterFilteredIds("null-eq", "omitted", MAPPER_WITHOUT_NULL_CONVENTIONS)
999
+ ).rejects.toThrow(NULL_OMITTED_MESSAGE);
1000
+ });
1001
+
1002
+ // #302 completeness guard. The rejection must key off the null OPERAND, not off a list of
1003
+ // operators: `hasIntersection(tagNames, ["public", null])` carries one in its value list, and
1004
+ // an allowlist of eq/ne/in silently misses it. Enumerating the corpus rather than naming
1005
+ // shapes means a newly added action carrying a null constant is covered automatically.
1006
+ test("every corpus action carrying a null literal is rejected under omitted", async () => {
1007
+ const nullCarrying: string[] = [];
1008
+ for (const action of [...MANIFEST_ACTIONS].sort()) {
1009
+ const queryPlan = await cerbos.planResources({
1010
+ principal: principal(),
1011
+ resource: { kind: seedsFile.resourceKind },
1012
+ action,
1013
+ });
1014
+ if (
1015
+ queryPlan.kind === PlanKind.CONDITIONAL &&
1016
+ planCarriesNullLiteral(queryPlan.condition)
1017
+ ) {
1018
+ nullCarrying.push(action);
1019
+ }
1020
+ }
1021
+
1022
+ // Guard the guard: if the walk stopped finding null operands the loop below is vacuous.
1023
+ expect(nullCarrying).toContain("null-eq-missing");
1024
+ expect(nullCarrying).toContain("in-null-elem-hasint");
1025
+
1026
+ const notRejected: string[] = [];
1027
+ for (const action of nullCarrying) {
1028
+ try {
1029
+ await adapterFilteredIds(
1030
+ action,
1031
+ "omitted",
1032
+ MAPPER_WITHOUT_NULL_CONVENTIONS
1033
+ );
1034
+ notRejected.push(action);
1035
+ } catch (error) {
1036
+ // The rejection must be the null-operand check talking, not an incidental failure — a
1037
+ // transport error or mapper typo counting as the required rejection is the silent pass
1038
+ // the corpus README warns about.
1039
+ if (!String(error).includes(NULL_OMITTED_MESSAGE)) {
1040
+ notRejected.push(`${action} (rejected for the wrong reason: ${String(error)})`);
1041
+ }
1042
+ }
1043
+ }
1044
+ expect(notRejected).toEqual([]);
1045
+ });
1046
+
559
1047
  test("pins the upstream has() planner over-grant", async () => {
560
1048
  const action = "p-has";
561
1049
  expect(PRISMA_KNOWN_DIVERGENCES.has(action)).toBe(true);
@@ -574,13 +1062,220 @@ describe("adversarial conformance corpus", () => {
574
1062
  expect(await adapterFilteredIds(action)).toEqual(allIds);
575
1063
  });
576
1064
 
1065
+ // The corpus pins two count spellings over the chain — `size(...) == 0` and
1066
+ // `!(size(...) > 0)` — and CEL's type checker rules out a third that a real policy could
1067
+ // reach through the planner (`>= 1` and `<= 0` are the only other thresholds this adapter
1068
+ // can express, and no policy needs both spellings). The guard must nonetheless be a
1069
+ // property of the chain rather than of the two spellings that happen to be pinned, so
1070
+ // these synthesise the remaining threshold/polarity combinations directly onto the same
1071
+ // seeded store and assert the parentless rows stay out of every one
1072
+ // (cerbos/query-plan-adapters#316).
1073
+ test("every count threshold over the chain inherits the absent-parent guard", async () => {
1074
+ const chain = new PlanExpressionVariable(
1075
+ "request.resource.attr.mainCategory.subCategories"
1076
+ );
1077
+ const size = new PlanExpression("size", [chain]);
1078
+ const compare = (operator: string, threshold: number) =>
1079
+ new PlanExpression(operator, [size, new PlanExpressionValue(threshold)]);
1080
+ const negate = (condition: PlanExpressionOperand) =>
1081
+ new PlanExpression("not", [condition]);
1082
+
1083
+ const filteredIdsFor = async (
1084
+ condition: PlanExpressionOperand
1085
+ ): Promise<string[]> => {
1086
+ const result = queryPlanToPrisma({
1087
+ queryPlan: {
1088
+ kind: PlanKind.CONDITIONAL,
1089
+ condition,
1090
+ cerbosCallId: "synthetic",
1091
+ requestId: "synthetic",
1092
+ validationErrors: [],
1093
+ metadata: undefined,
1094
+ },
1095
+ mapper: MAPPER,
1096
+ model: MODEL,
1097
+ });
1098
+ expect(result.kind).toBe(PlanKind.CONDITIONAL);
1099
+ const where = result.kind === PlanKind.CONDITIONAL ? result.filters : {};
1100
+ const rows = await prisma.adversarialResource.findMany({
1101
+ where,
1102
+ select: { id: true },
1103
+ });
1104
+ return rows.map((row) => row.id).sort();
1105
+ };
1106
+
1107
+ // Each of these is TRUE for a row with no mainCategory only if the guard leaks: an
1108
+ // absent to-one parent is a CEL missing-path error, so the PDP denies it outright.
1109
+ const emptyByConstruction: [string, PlanExpressionOperand][] = [
1110
+ ["size(chain) == 0", compare("eq", 0)],
1111
+ ["size(chain) <= 0", compare("le", 0)],
1112
+ ["size(chain) < 1", compare("lt", 1)],
1113
+ ["!(size(chain) >= 1)", negate(compare("ge", 1))],
1114
+ ["!(size(chain) > 0)", negate(compare("gt", 0))],
1115
+ ];
1116
+
1117
+ for (const [shape, condition] of emptyByConstruction) {
1118
+ expect([shape, await filteredIdsFor(condition)]).toEqual([shape, []]);
1119
+ }
1120
+
1121
+ // The mirror image, so the loop above cannot pass by denying everything: the negation of
1122
+ // an emptiness check is TRUE for exactly the rows that HAVE the parent.
1123
+ const withParent = await oracleAllowedIds("w1-size-nonneg-chain");
1124
+ expect(withParent.length).toBeGreaterThan(0);
1125
+ expect(withParent.length).toBeLessThan(SEEDS.length);
1126
+ expect(await filteredIdsFor(negate(compare("eq", 0)))).toEqual(withParent);
1127
+ expect(await filteredIdsFor(negate(compare("lt", 1)))).toEqual(withParent);
1128
+ });
1129
+
1130
+ // The corpus pins ONE ternary whose condition reaches a chain — `w1-ternary-chain-cond`, whose
1131
+ // else-branch is a bare `!aBool`. The guard has to be a property of the ternary's false-branch
1132
+ // rather than of that one spelling, so these synthesise the other condition positions onto the
1133
+ // same seeded store (cerbos/query-plan-adapters#334). Each expectation is a real check() oracle,
1134
+ // never a hand-computed row list.
1135
+ test("every ternary condition over the chain inherits the absent-parent guard", async () => {
1136
+ const chainIn = new PlanExpression("in", [
1137
+ new PlanExpressionValue("finance"),
1138
+ new PlanExpressionVariable("request.resource.attr.mainCategory.subNames"),
1139
+ ]);
1140
+ const TRUE = new PlanExpressionValue(true);
1141
+ const FALSE = new PlanExpressionValue(false);
1142
+ const ternary = (
1143
+ condition: PlanExpressionOperand,
1144
+ thenBranch: PlanExpressionOperand,
1145
+ elseBranch: PlanExpressionOperand
1146
+ ) => new PlanExpression("if", [condition, thenBranch, elseBranch]);
1147
+
1148
+ const filteredIdsFor = async (
1149
+ condition: PlanExpressionOperand
1150
+ ): Promise<string[]> => {
1151
+ const result = queryPlanToPrisma({
1152
+ queryPlan: {
1153
+ kind: PlanKind.CONDITIONAL,
1154
+ condition,
1155
+ cerbosCallId: "synthetic",
1156
+ requestId: "synthetic",
1157
+ validationErrors: [],
1158
+ metadata: undefined,
1159
+ },
1160
+ mapper: MAPPER,
1161
+ model: MODEL,
1162
+ });
1163
+ expect(result.kind).toBe(PlanKind.CONDITIONAL);
1164
+ const where = result.kind === PlanKind.CONDITIONAL ? result.filters : {};
1165
+ const rows = await prisma.adversarialResource.findMany({
1166
+ where,
1167
+ select: { id: true },
1168
+ });
1169
+ return rows.map((row) => row.id).sort();
1170
+ };
1171
+
1172
+ // The rows the chain condition is definitively TRUE for, and the ones it is definitively
1173
+ // FALSE for. Everything else — every row with no mainCategory at all — is a CEL
1174
+ // missing-path error, which selects NEITHER branch.
1175
+ const conditionTrue = await oracleAllowedIds("w1-in-chain");
1176
+ const conditionFalse = await oracleAllowedIds("w1-not-in-chain");
1177
+ expect(conditionTrue.length).toBeGreaterThan(0);
1178
+ expect(conditionFalse.length).toBeGreaterThan(0);
1179
+ expect(conditionTrue.length + conditionFalse.length).toBeLessThan(
1180
+ SEEDS.length
1181
+ );
1182
+
1183
+ // The else-branch is what a bare `NOT` over the chain filter over-grants: it is TRUE for
1184
+ // every parentless row, so each of these returned the 17 missing-parent seeds on top.
1185
+ expect(await filteredIdsFor(ternary(chainIn, FALSE, TRUE))).toEqual(
1186
+ conditionFalse
1187
+ );
1188
+ expect(
1189
+ await filteredIdsFor(
1190
+ ternary(new PlanExpression("not", [chainIn]), TRUE, FALSE)
1191
+ )
1192
+ ).toEqual(conditionFalse);
1193
+ // A `not` condition in false-branch position: the double negation collapses back to the
1194
+ // positive membership, which excludes the parentless rows by itself.
1195
+ expect(
1196
+ await filteredIdsFor(
1197
+ ternary(new PlanExpression("not", [chainIn]), FALSE, TRUE)
1198
+ )
1199
+ ).toEqual(conditionTrue);
1200
+
1201
+ // A conjunction condition needs De Morgan, not an outer NOT with the hops ANDed beside it:
1202
+ // CEL's `&&` absorbs an erroring operand when the other is FALSE, so a parentless row with
1203
+ // aBool=false makes the whole condition definitively FALSE and DOES select the else-branch.
1204
+ const aBoolFalse = SEEDS.filter((seed) => !seed.aBool).map((seed) => seed.id);
1205
+ expect(aBoolFalse.length).toBeGreaterThan(0);
1206
+ expect(
1207
+ await filteredIdsFor(
1208
+ ternary(
1209
+ new PlanExpression("and", [
1210
+ chainIn,
1211
+ new PlanExpressionVariable("request.resource.attr.aBool"),
1212
+ ]),
1213
+ FALSE,
1214
+ TRUE
1215
+ )
1216
+ )
1217
+ ).toEqual([...new Set([...conditionFalse, ...aBoolFalse])].sort());
1218
+ });
1219
+
1220
+ // The to-one relation carries no corpus action yet — this is the expand half of
1221
+ // cerbos/query-plan-adapters#372's expand–contract — so nothing else in this file would notice a
1222
+ // seeder that stored no chain at all, or one that attached every parent to the wrong resource.
1223
+ // Read the two hops back through a real join rather than counting rows: a count cannot tell an
1224
+ // inner row carrying the corpus's values from one carrying the root's own columns, which is
1225
+ // exactly the flat-column-alias failure this relation exists to make visible.
1226
+ test("the seeded to-one chain matches the corpus relation", async () => {
1227
+ const withParent = SEEDS.filter((seed) => parentSeedOf(seed) !== undefined);
1228
+ const withInner = SEEDS.filter(
1229
+ (seed) => parentSeedOf(parentSeedOf(seed)) !== undefined
1230
+ );
1231
+ expect(withParent.length).toBeGreaterThan(0);
1232
+ expect(withInner.length).toBeGreaterThan(0);
1233
+ expect(withParent.length).toBeLessThan(SEEDS.length);
1234
+
1235
+ const joined = await prisma.adversarialResource.findMany({
1236
+ select: {
1237
+ id: true,
1238
+ parent: {
1239
+ select: { aString: true, inner: { select: { aString: true } } },
1240
+ },
1241
+ },
1242
+ });
1243
+ const stored = Object.fromEntries(
1244
+ joined.map((row) => [
1245
+ row.id,
1246
+ [row.parent?.aString ?? null, row.parent?.inner?.aString ?? null],
1247
+ ])
1248
+ );
1249
+ const expected = Object.fromEntries(
1250
+ SEEDS.map((seed) => [
1251
+ seed.id,
1252
+ [
1253
+ parentSeedOf(seed)?.aString ?? null,
1254
+ parentSeedOf(parentSeedOf(seed))?.aString ?? null,
1255
+ ],
1256
+ ])
1257
+ );
1258
+ expect(stored).toEqual(expected);
1259
+ });
1260
+
577
1261
  test("oracle is not degenerate", async () => {
578
- // Guard the guard: at least one action must produce a non-empty, non-total oracle set,
1262
+ // Guard the guard: each of these actions must produce a non-empty, non-total oracle set,
579
1263
  // otherwise the differential comparison could pass vacuously (e.g. PDP denying all).
580
- for (const action of ["vf-le", "like-percent", "all-on-empty"]) {
581
- const ids = await oracleAllowedIds(action);
582
- expect(ids.length).toBeGreaterThan(0);
583
- expect(ids.length).toBeLessThan(SEEDS.length);
1264
+ //
1265
+ // Every entry is asserted to be an action Prisma actually oracle-compares. A list copied
1266
+ // from another harness drifts into naming shapes this adapter never compares, which guard
1267
+ // nothing (cerbos/query-plan-adapters#324); the membership assertion turns moving an action
1268
+ // into Prisma's `adapterUnsupported` set into a failure here rather than a silent no-op.
1269
+ for (const action of DEGENERACY_GUARD_ACTIONS) {
1270
+ expect(ORACLE_ACTIONS).toContain(action);
1271
+ await expectNonDegenerateOracle(action);
1272
+ }
1273
+ // Shapes Prisma refuses to translate, so there is no comparison behind them: these carry
1274
+ // PDP/policy liveness for their group only. Asserting the complement keeps the split
1275
+ // honest — an action Prisma gains support for must move up into the guard proper.
1276
+ for (const action of DEGENERACY_LIVENESS_PROBES) {
1277
+ expect(ORACLE_ACTIONS).not.toContain(action);
1278
+ await expectNonDegenerateOracle(action);
584
1279
  }
585
1280
  });
586
1281
  });