@stonyx/orm 0.3.2-alpha.72 → 0.3.2-alpha.74

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.
@@ -69,6 +69,15 @@
69
69
  * records under `model: 'owner'`, and the context gives no signal of that
70
70
  * (abofs/stonyx-orm#196).
71
71
  *
72
+ * SUPERSEDED 2026-09-01 BY abofs/stonyx-orm#236/#237, AND KEPT FOR THE
73
+ * CONSTRAINT IT STATES RATHER THAN AS A DESCRIPTION OF THE CODE. The context
74
+ * now also carries `recordId` -- the DECODED route-parameter id, see
75
+ * `AccessContext.recordId` in ./types/orm-types.ts -- so the fixture's
76
+ * `/archived` deny IS expressible from the context alone, and the shipped
77
+ * sample no longer reads `request.path` at all. Retiring this wording WITH the
78
+ * measurement that retires it, rather than by deletion, is
79
+ * abofs/stonyx-orm#238.
80
+ *
72
81
  * `record` IS NOT IN THIS CONTEXT, deliberately. `auth()` runs after route
73
82
  * matching but BEFORE any handler executes (`@stonyx/rest-server`
74
83
  * `src/request.ts:58-60`), so nothing has been fetched yet -- supplying a
@@ -158,6 +167,11 @@
158
167
  * `/archived` SUB-PATH rule is still a string match, and abofs/stonyx-orm#228 is
159
168
  * a sixth spelling that gets past it.
160
169
  *
170
+ * SUPERSEDED 2026-09-01 BY abofs/stonyx-orm#236/#237: the `/archived` rule is
171
+ * no longer a string match against the request target -- it compares the
172
+ * decoded `recordId` the framework supplies -- and abofs/stonyx-orm#228 is
173
+ * CLOSED. Retirement of this wording: abofs/stonyx-orm#238.
174
+ *
161
175
  * An intermediate revision of the sample read `request.baseUrl` -- the mount
162
176
  * Express ACTUALLY MATCHED. That closed all five variants (no query string,
163
177
  * not mount-relative, unaffected by absolute-form, already carrying the
@@ -173,12 +187,26 @@
173
187
  * does not decode, so `GET /owners/%61rchived` steps past it. See the
174
188
  * normalisation paragraph below and abofs/stonyx-orm#228.
175
189
  *
190
+ * SUPERSEDED 2026-09-01 BY abofs/stonyx-orm#236/#237. Variant 3 lived in that
191
+ * one string comparison, and the comparison is gone: the sample compares the
192
+ * decoded `recordId`. Left standing rather than edited because the same
193
+ * "variant 3 survives" wording sits at four sites -- this header, README.md
194
+ * twice, and test/sample/access/global-access.ts -- three of which SHIP, so
195
+ * retiring one of four leaves the shipped copies contradicting each other.
196
+ * Retiring all four WITH their measurement is abofs/stonyx-orm#238.
197
+ *
176
198
  * ONE READ OF ARGUMENT ONE SURVIVES, AND IT MUST: `request.path`. It is
177
199
  * mount-relative and query-free, and it is for rules that distinguish SUB-PATHS
178
200
  * beneath the mount. The context names which model and which verb, NOT which
179
201
  * route, so the sample's `/archived` deny cannot be expressed from the context
180
202
  * alone and a context-ONLY rewrite would silently turn that deny into an allow.
181
203
  *
204
+ * SUPERSEDED 2026-09-01 BY abofs/stonyx-orm#236/#237: NO read of argument one
205
+ * survives in the shipped sample. `recordId` names WHICH RECORD the route was
206
+ * addressed to, so the `/archived` deny is expressible from the context alone
207
+ * -- and it still must not be dropped; expressible is not optional. Retirement
208
+ * of this wording: abofs/stonyx-orm#238.
209
+ *
182
210
  * NORMALISE THE WAY THE ROUTER DOES, AND CASE-FOLDING ALONE IS NOT THAT. The
183
211
  * sample lower-cases before comparing, because a matcher stricter than the
184
212
  * case-insensitive router can be stepped around. That closes the case gap only.
@@ -188,6 +216,20 @@
188
216
  * sample and is tracked as abofs/stonyx-orm#228; the `.toLowerCase()` is not a
189
217
  * complete normalisation recipe. Compare record ids at their real case.
190
218
  *
219
+ * DO NOT FOLLOW THE PARAGRAPH ABOVE. SUPERSEDED 2026-09-01 BY
220
+ * abofs/stonyx-orm#236/#237, and flagged here rather than merely dated because
221
+ * it is an INSTRUCTION, not a stale observation. `.toLowerCase()` on the access
222
+ * path was measured WRONG IN BOTH DIRECTIONS AT ONCE: with a distinct owner
223
+ * seeded at `ARCHIVED`, `GET /owners/ARCHIVED` was a false DENY on the wrong
224
+ * record and `GET /owners/%41RCHIVED` a false ALLOW on that same record. A
225
+ * record id is a VALUE, not a literal route segment, and express's
226
+ * `case sensitive routing` governs literal segments only. Compare
227
+ * `context.recordId` AS IT ARRIVES: do not case-fold it, do not decode it, do
228
+ * not derive it from `request.path`. `AccessContext.recordId` in
229
+ * ./types/orm-types.ts is the contract and says "Do NOT case-fold it"; the same
230
+ * published tarball ships both files, and THIS paragraph is the one that is
231
+ * wrong. Retiring it WITH its measurement is abofs/stonyx-orm#238.
232
+ *
191
233
  * `?? ''` is not a defence. It converts an absent request target into an empty
192
234
  * string, which matches no collection, which falls through to the permission
193
235
  * array -- a total grant. An input you cannot identify must DENY, and that
@@ -196,6 +238,12 @@
196
238
  * argument one. The sample returns `false` for an absent `model` AND for an
197
239
  * absent or non-string `request.path`, rather than falling through either way.
198
240
  *
241
+ * SUPERSEDED 2026-09-01 BY abofs/stonyx-orm#236/#237 as to WHAT is guarded --
242
+ * the principle is unchanged. The sample no longer reads `request.path`, so it
243
+ * returns `false` for an absent `model` AND for an absent `recordId`
244
+ * (`undefined`, the one spelling `auth()` never produces). Retirement of this
245
+ * wording: abofs/stonyx-orm#238.
246
+ *
199
247
  * THE REAL FIX IS abofs/stonyx-orm#202: `access()` should receive the model,
200
248
  * the operation and the record. Prefer the array shape (`['read']`) or `false`
201
249
  * until #202 lands; the function shape is what requires any matching at all.
@@ -219,7 +267,7 @@ import { getBeforeHooks, getAfterHooks } from './hooks.js';
219
267
  import type { HookContext } from './hooks.js';
220
268
  import config from 'stonyx/config';
221
269
  import log from 'stonyx/log';
222
- import type { OrmRecord, AccessContext, AccessFunction, AccessMethod, AccessOperation } from './types/orm-types.js';
270
+ import type { OrmRecord, AccessContext, AccessFunction, AccessMethod, AccessOperation, LinkageFilter } from './types/orm-types.js';
223
271
  import { isOrmRecord, NO_FREE_ID_ERROR } from './utils.js';
224
272
  import { interpretAccess, createLinkageFilter } from './access-verdict.js';
225
273
 
@@ -417,9 +465,9 @@ function buildResponse(
417
465
  data: unknown,
418
466
  includeParam: string | undefined,
419
467
  recordOrRecords: OrmRecord | OrmRecord[],
420
- options: { links?: { [key: string]: string }; baseUrl?: string } = {}
468
+ options: { links?: { [key: string]: string }; baseUrl?: string; linkage?: LinkageFilter } = {}
421
469
  ): JsonApiResponse {
422
- const { links, baseUrl } = options;
470
+ const { links, baseUrl, linkage } = options;
423
471
  const response: JsonApiResponse = { data };
424
472
 
425
473
  // Add top-level links
@@ -434,14 +482,49 @@ function buildResponse(
434
482
 
435
483
  const includedRecords = collectIncludedRecords(recordOrRecords, includes);
436
484
  if (includedRecords.length > 0) {
437
- // NO `linkage` ARGUMENT, deliberately, and abofs/stonyx-orm#235 owns adding
438
- // one. Until it does, a PERMITTED record here emits the full pre-#234
439
- // document: `GET /animals/1?include=owner` filters the primary document's
440
- // `owner.data` to `null` and then names `owner:angela` in `included`.
441
- // Whether a resource reaches this array at all is a different question
442
- // (membership, abofs/stonyx-orm#233) and closing that one does not close
443
- // this one.
444
- response.included = includedRecords.map(record => record.toJSON?.({ baseUrl }));
485
+ // LINKAGE, NOT MEMBERSHIP -- and the distinction is the whole reason this
486
+ // line is one story's and the line above it is another's
487
+ // (abofs/stonyx-orm#235 and #233 respectively).
488
+ //
489
+ // - WHICH RESOURCES REACH THIS ARRAY is decided by
490
+ // `collectIncludedRecords` on the line above. That is MEMBERSHIP, it is
491
+ // #233's, and it is deliberately untouched here: a hidden owner is
492
+ // still a member of `included` after this change. Pinned green by
493
+ // `[GUARD] #235 X1` so that #235 cannot close #233 incidentally.
494
+ // - WHAT A RECORD ALREADY IN THIS ARRAY MAY NAME in its own
495
+ // `relationships.*.data` is LINKAGE -- the same question #234 answers
496
+ // for the primary document -- and that is what the `linkage` option
497
+ // below decides. Before it, `GET /animals/1?include=owner,owner.pets`
498
+ // filtered the primary document's `owner.data` to `null` and then
499
+ // handed back nine PERMITTED animals in `included` each naming
500
+ // `{"type":"owner","id":"angela"}`. Neither #233 nor #234 closes that.
501
+ //
502
+ // THE FILTER IS THE CALLER'S, PASSED IN, NOT BUILT HERE. Both call sites
503
+ // already hold one for the primary document, and sharing it is what keeps
504
+ // the per-type verdict cache and the per-(type, id) decision cache alive
505
+ // across the primary document AND the sideload -- one verdict resolution
506
+ // per type for the whole response, pinned by `[GUARD] #235 C1`. Building a
507
+ // fresh filter here would resolve the consumer's `access()` once per
508
+ // included record instead.
509
+ //
510
+ // `linkage` IS OPTIONAL IN THE TYPE AND IS NOT OPTIONAL IN PRACTICE.
511
+ // Stating it precisely because the opposite claim stood here in an earlier
512
+ // draft of this change: BOTH of this function's callers supply a filter
513
+ // (`getCollectionHandler` and `getSingleHandler`, the only two), so the
514
+ // `undefined` branch has no live caller in this module today. It is
515
+ // optional so that omitting it degrades to the PRE-#234 document rather
516
+ // than to a denial -- `Record.toJSON` reads an ABSENT option as "no verdict
517
+ // was supplied" and emits linkage in full.
518
+ //
519
+ // WHAT IT MUST NEVER BE HANDED IS A NON-FUNCTION. `toJSON` does NOT read a
520
+ // non-function as absent: `Object.prototype.toString.call(linkage)` must be
521
+ // `'[object Function]'`, and anything else -- `null`, an `AsyncFunction`,
522
+ // and INCLUDING the primitive `true` -- DENIES every relationship on the
523
+ // document and logs once. `toJSON({ linkage: true })` emits `null` linkage.
524
+ // So do not "simplify" this to a boolean, and do not make it default to
525
+ // `true`: both spellings look like "allow everything" and mean the exact
526
+ // opposite (abofs/stonyx-orm#224).
527
+ response.included = includedRecords.map(record => record.toJSON?.({ baseUrl, linkage }));
445
528
  }
446
529
 
447
530
  return response;
@@ -652,7 +735,11 @@ export default class OrmRequest extends Request {
652
735
 
653
736
  return buildResponse(data, request.query?.include, recordsToReturn, {
654
737
  links: { self: `${baseUrl}/${pluralizedModel}` },
655
- baseUrl
738
+ baseUrl,
739
+ // THE SAME filter object the primary documents above were serialized
740
+ // with, deliberately: it carries the caches, and rebuilding one here
741
+ // would re-resolve every type (abofs/stonyx-orm#235).
742
+ linkage
656
743
  });
657
744
  };
658
745
 
@@ -670,29 +757,29 @@ export default class OrmRequest extends Request {
670
757
  const baseUrl = getBaseUrl(request);
671
758
  const linkage = createLinkageFilter(request);
672
759
 
673
- // `buildResponse` is deliberately NOT given the linkage filter, and the
674
- // residual that leaves is NOT the one #233 owns. Two different questions:
675
- //
676
- // - WHETHER A RESOURCE APPEARS in `included` at all is MEMBERSHIP ->
677
- // abofs/stonyx-orm#233.
678
- // - What a record already IN `included` may NAME is LINKAGE -- the same
679
- // question #234 answers for the primary document -- and it is
680
- // abofs/stonyx-orm#235, which also owns createHandler/updateHandler.
760
+ // `buildResponse` IS given the filter now (abofs/stonyx-orm#235), and it
761
+ // is the SAME object the primary document is serialized with -- one
762
+ // verdict per type for the whole response, sideload included.
681
763
  //
682
- // The residual, stated so the next reader does not have to derive it:
683
- // `buildResponse` calls `record.toJSON?.({ baseUrl })` with no `linkage`
684
- // argument, so a PERMITTED record in `included` emits the full pre-#234
685
- // document. Measured: `GET /animals/1?include=owner` returns
686
- // `owner.data: null` on the primary document and `owner:angela` in
687
- // `included`. One query parameter deep. Only the PRIMARY document's
688
- // linkage is filtered here.
764
+ // The boundary that remains, so the next reader does not have to derive
765
+ // it: this closes what a record already in `included` may NAME. WHETHER a
766
+ // resource appears in `included` at all is MEMBERSHIP and it is
767
+ // abofs/stonyx-orm#233's -- a hidden owner is still a member here.
768
+ // Neither question closes the other.
689
769
  return buildResponse(record.toJSON?.({ fields: modelFields, baseUrl, linkage }), request.query?.include, record, {
690
770
  links: { self: `${baseUrl}/${pluralizedModel}/${request.params.id}` },
691
- baseUrl
771
+ baseUrl,
772
+ linkage
692
773
  });
693
774
  };
694
775
 
695
- const createHandler: HandlerFn = async ({ body, query }, { filter }) => {
776
+ const createHandler: HandlerFn = async (request, { filter }) => {
777
+ // BOUND, not destructured (abofs/stonyx-orm#235). `HandlerFn` has always
778
+ // delivered the request as argument one; this handler simply discarded
779
+ // the binding, which is why its response document named ids every read
780
+ // surface withholds. `createLinkageFilter` needs the live request and
781
+ // there is no signature change involved in giving it one.
782
+ const { body, query } = request;
696
783
  const { type, id, attributes, relationships: rels } = (body?.data || {}) as {
697
784
  type?: string;
698
785
  id?: string | number;
@@ -947,10 +1034,29 @@ export default class OrmRequest extends Request {
947
1034
  return 403;
948
1035
  }
949
1036
 
950
- return { data: record.toJSON?.({ fields: modelFields }) };
1037
+ // The filter is built HERE, per invocation, and never hoisted into the
1038
+ // OrmRequest constructor where the other per-mount values live: a verdict
1039
+ // cached across requests answers a second caller with the first caller's
1040
+ // authorization (src/access-verdict.ts says so at the constructor an
1041
+ // implementer would reach for).
1042
+ //
1043
+ // AND IT IS BUILT AFTER `createRecord`, AFTER THE ROLLBACK WINDOW AND
1044
+ // AFTER `isDenied`, so the record is in its final form at the call. The
1045
+ // filter is lazy per type and per (type, id), so it cannot observe a
1046
+ // pre-write state even if it were built earlier.
1047
+ //
1048
+ // `fields` is passed here and NOT in `updateHandler`: the two handlers
1049
+ // are asymmetric on purpose (`updateHandler` has no `fieldsMap` in
1050
+ // scope), and a single copy-pasted wiring would drop it from one of them.
1051
+ return { data: record.toJSON?.({ fields: modelFields, linkage: createLinkageFilter(request) }) };
951
1052
  };
952
1053
 
953
- const updateHandler: HandlerFn = async ({ body, params }, { filter }) => {
1054
+ const updateHandler: HandlerFn = async (request, { filter }) => {
1055
+ // Bound rather than destructured, for the reason given in
1056
+ // `createHandler` above (abofs/stonyx-orm#235). `PATCH /animals/1`
1057
+ // returned 200 naming angela seconds after `GET /animals/1` returned
1058
+ // `owner.data: null` for the same record -- one HTTP verb apart.
1059
+ const { body, params } = request;
954
1060
  const found = await store.find(model, getId(params));
955
1061
  if (!found || !isOrmRecord(found)) return 404;
956
1062
  // Checked BEFORE any attribute is applied. 404 rather than 403 for the
@@ -1009,7 +1115,14 @@ export default class OrmRequest extends Request {
1009
1115
  }
1010
1116
  }
1011
1117
 
1012
- return { data: record.toJSON?.() };
1118
+ // No `fields` and no `baseUrl`, both unchanged: `updateHandler` has no
1119
+ // `fieldsMap` in scope, and adding `baseUrl` would put `links` on a
1120
+ // document that has never carried them -- an unrelated behaviour change.
1121
+ // #224 AC6's "emits `data: []` WITH links" is a statement about the READ
1122
+ // surfaces; on these two handlers a filtered relationship and a
1123
+ // genuinely-empty one are both a bare `{ data }`, which is what makes
1124
+ // them indistinguishable here too.
1125
+ return { data: record.toJSON?.({ linkage: createLinkageFilter(request) }) };
1013
1126
  };
1014
1127
 
1015
1128
  const deleteHandler: HandlerFn = async ({ params }, { filter }) => {
@@ -1340,6 +1453,28 @@ export default class OrmRequest extends Request {
1340
1453
  };
1341
1454
 
1342
1455
  // Relationship linkage route: GET /:id/relationships/{relationship}
1456
+ //
1457
+ // NO `linkage` FILTER HERE, AND IT IS NOT AN OVERSIGHT --
1458
+ // abofs/stonyx-orm#232 OWNS THIS ROUTE. The three sites that carry the
1459
+ // filter (`buildResponse`'s `included`, the related-resource branch
1460
+ // above, and the two write handlers) all call `record.toJSON()`, which is
1461
+ // where the `linkage` option is applied. This branch builds its
1462
+ // `{ type, id }` objects BY HAND and never calls `toJSON` at all, so it
1463
+ // cannot see a filter no matter who passes one.
1464
+ //
1465
+ // It is also a DIFFERENT QUESTION. Everywhere else, linkage is metadata
1466
+ // ABOUT a document. Here the linkage IS the primary data, so dropping an
1467
+ // entry is a MEMBERSHIP decision about what this route serves -- the same
1468
+ // class as abofs/stonyx-orm#233 and #196, not the class #234/#235 close.
1469
+ // That is why it is absent from #224 §2a's seven-site inventory.
1470
+ //
1471
+ // MEASURED, so the next person does not re-derive it: this route answers
1472
+ // `GET /animals/1/relationships/owner` with
1473
+ // `{"type":"owner","id":"angela"}` while `GET /owners/angela` is 404.
1474
+ // Wiring the filter in here takes the suite to 993/2 and turns the
1475
+ // `GET /animals/:id/relationships/owner returns relationship linkage`
1476
+ // test red -- which is #232's own reproduction, not a regression.
1477
+ // Pinned unchanged by `[GUARD] #235 X2` in test/integration/orm-test.ts.
1343
1478
  routes[`/:id/relationships/${dasherizedName}`] = async (request: OrmRequest$, { filter }: { [key: string]: unknown } = {}) => {
1344
1479
  const record = await store.find(model, getId(request.params)) as OrmRecord | undefined;
1345
1480
  if (!record) return 404;
@@ -1429,10 +1564,53 @@ export default class OrmRequest extends Request {
1429
1564
  // src/types/orm-types.ts. Nothing is fetched at this point and adding a
1430
1565
  // lookup here would put a store read in the middle of an authorization
1431
1566
  // path. The function return shape below IS the per-record hook.
1567
+ //
1568
+ // -------------------------------------------------------------------------
1569
+ // #236 -- `recordId`, the DECODED route-parameter id, for the same reason.
1570
+ //
1571
+ // WHICH RECORD is the third structural fact the framework already holds and
1572
+ // the consumer was left to re-derive, and re-deriving it failed OPEN. The
1573
+ // documented sample compared `request.path` -- the RAW, undecoded pathname
1574
+ // -- against a literal `/archived`, while the router DECODES `:id`. So
1575
+ // `GET /owners/%61rchived` walked past the deny and was dispatched as the
1576
+ // record `archived`: 200 with the record in full, and DELETE answered 204
1577
+ // with the record destroyed, unauthenticated. Four spellings measured, all
1578
+ // four through; 255 non-canonical spellings of that 8-character id decode
1579
+ // to the same key, so this was never a deny-list of one.
1580
+ //
1581
+ // TWO CONSUMER-SIDE NORMALISATIONS WERE MEASURED WRONG IN OPPOSITE
1582
+ // DIRECTIONS, which is the argument for doing it once, here.
1583
+ // `.toLowerCase()` case-folds a route-parameter VALUE on the axis that
1584
+ // governs literal SEGMENTS: with a distinct owner seeded at `ARCHIVED`,
1585
+ // `GET /owners/ARCHIVED` was a false DENY on the wrong record and
1586
+ // `GET /owners/%41RCHIVED` a false ALLOW on that same one.
1587
+ // `decodeURIComponent(request.path)` decodes THEN splits while the router
1588
+ // splits THEN decodes, so it over-denied `/owners/archived%2fx` -- 403 for
1589
+ // a genuinely distinct record. Failing closed there was luck, not design.
1590
+ //
1591
+ // `getId(request.params)` AND NOT `request.params.id`, for exactly the
1592
+ // reason `operation` is a `methodAccessMap` lookup: it is the SAME single
1593
+ // coercion the store lookup one layer down performs, so the predicate and
1594
+ // the dispatch cannot disagree about which record a request addresses.
1595
+ // The raw string would reintroduce that divergence on hex-shaped ids --
1596
+ // `GET /animals/0x2391` looks up record `9105`.
1597
+ //
1598
+ // NOTHING HERE PARSES THE REQUEST TARGET EITHER. `request.params` is what
1599
+ // the router matched, so a mount prefix, an absolute-form target, a query
1600
+ // string or a case-varied mount cannot move this value -- the same
1601
+ // guarantee `model` carries, by the same means.
1602
+ //
1603
+ // `null` and not `undefined` on a collection route, so the KEY IS ALWAYS
1604
+ // PRESENT -- the rule `operation`'s own docblock already establishes. A
1605
+ // context reaching a predicate WITHOUT the key therefore did not come from
1606
+ // here; it was hand-assembled by a caller resolving the predicate through
1607
+ // `Orm.instance.getAccess()`, and that absence stays deniable only because
1608
+ // `auth()` never produces it.
1432
1609
  // -------------------------------------------------------------------------
1433
1610
  const context: AccessContext = {
1434
1611
  model: this.model,
1435
1612
  operation: methodAccessMap[request.method],
1613
+ recordId: request.params && 'id' in request.params ? getId(request.params) : null,
1436
1614
  };
1437
1615
 
1438
1616
  let access: AccessMethod;
@@ -260,6 +260,102 @@ export interface AccessContext {
260
260
  * from one that classified the request and found nothing.
261
261
  */
262
262
  operation: AccessOperation | undefined;
263
+
264
+ /**
265
+ * The record this route was addressed to, as the store key -- or `null` on a
266
+ * collection route, which is addressed to no record (abofs/stonyx-orm#236).
267
+ *
268
+ * IT IS ALREADY DECODED, AND THAT IS THE WHOLE POINT. Express decodes route
269
+ * PARAMETERS while leaving `request.path` raw, so a consumer comparing
270
+ * `request.path` against a literal compares an undecoded string against a
271
+ * decoded dispatch. `GET /owners/%61rchived` reached such a comparison as
272
+ * `/%61rchived`, walked past a `/archived` deny, and was dispatched as the
273
+ * record `archived` -- 200 with the record in full, and `DELETE` destroyed
274
+ * it, unauthenticated. 255 non-canonical spellings of an 8-character id
275
+ * decode to the same key, so a deny-list of spellings is the wrong shape.
276
+ *
277
+ * SO DO NOT NORMALISE THIS, AND DO NOT NORMALISE ANYTHING ELSE INSTEAD:
278
+ *
279
+ * - Do NOT decode it. Express decodes exactly ONCE, which is what a route
280
+ * parameter means. `GET /owners/%2561rchived` is the legitimate id
281
+ * `%61rchived`, not a second-order spelling of `archived`; a predicate that
282
+ * decoded until stable would deny a record it was never asked about.
283
+ * - Do NOT case-fold it. A record id is a VALUE, not a literal route segment,
284
+ * and express's `case sensitive routing` governs literal segments only.
285
+ * With a distinct owner seeded at `ARCHIVED`, `.toLowerCase()` was measured
286
+ * wrong in BOTH directions at once: `GET /owners/ARCHIVED` 403 (a false
287
+ * deny, on the wrong record) and `GET /owners/%41RCHIVED` 200 (a false
288
+ * allow, on that same record).
289
+ * - Do NOT derive it from `request.path` or the request target. Decoding the
290
+ * whole path decodes THEN splits, while the router splits THEN decodes, so
291
+ * `/owners/archived%2fx` -- a genuinely distinct record whose id is
292
+ * `archived/x` -- was measured over-denied 403.
293
+ *
294
+ * IT IS `getId(request.params)`, BYTE FOR BYTE -- the same single coercion
295
+ * the store lookup uses, exactly as `operation` is the same `methodAccessMap`
296
+ * lookup the permission-array branch uses. The predicate and the dispatch
297
+ * therefore cannot disagree about which record a request addresses. Handing
298
+ * over the raw `request.params.id` instead would reintroduce that divergence
299
+ * on hex-shaped ids: `GET /animals/0x2391` looks up record `9105`.
300
+ *
301
+ * It inherits abofs/stonyx-orm#209 along with that coercion -- on a model
302
+ * declaring `id = attr('string')`, `'9107'` arrives here as the number
303
+ * `9107`. That is consistency WITH THE LOOKUP, which is the property this key
304
+ * exists to buy; it is not a defect to repair here.
305
+ *
306
+ * `null`, not `undefined`, on a collection route -- and the KEY IS ALWAYS
307
+ * PRESENT, the same rule `operation` states above. `auth()` always sets it,
308
+ * so a context arriving WITHOUT the key did not come from `auth()`: it was
309
+ * hand-assembled by a caller resolving the predicate through
310
+ * `Orm.instance.getAccess()`. That absence stays a distinguishable, deniable
311
+ * signal only because the framework never produces it.
312
+ *
313
+ * IT DISAGREES WITH THE HOOK VOCABULARY, AND NOT ONLY ON THE ABSENCE
314
+ * SPELLING. `HookContext.recordId` (`src/hooks.ts`) is an identically-named
315
+ * key on an identically-shaped context object, which is the exact
316
+ * configuration that makes `operation` fail-open shaped -- a hook sees
317
+ * `'get'` where `access()` sees `'read'`. An earlier revision of THIS
318
+ * docblock asserted the opposite ("here they AGREE... they differ in ONE way
319
+ * and it is the absence spelling"). That was measured false, in the fail-open
320
+ * direction, and it is corrected here rather than deleted.
321
+ *
322
+ * MEASURED over the live dispatch, before-hooks registered for all five
323
+ * operations on one model:
324
+ *
325
+ * before:list key ABSENT ('recordId' in context === false)
326
+ * before:get key ABSENT params={"id":"visible1"}
327
+ * before:create key ABSENT
328
+ * before:update key ABSENT params={"id":"visible2"}
329
+ * before:delete recordId="visible3"
330
+ * after:delete recordId="visible3"
331
+ *
332
+ * `_withHooks` assigns `context.recordId` at exactly TWO sites in
333
+ * `src/orm-request.ts`, and BOTH sit inside an `operation === 'delete'`
334
+ * branch. So the two keys differ in COVERAGE, on four of five operations: on
335
+ * a hook context the key is absent for get, list, create and update, while
336
+ * this key is present on every route `auth()` classifies. The absence
337
+ * spelling is the smaller half of the difference, not the whole of it.
338
+ *
339
+ * AND THAT INVERTS THE ARGUMENT ABOVE WHEN IT IS READ ACROSS THE TWO. Here,
340
+ * a missing `recordId` means "did not come from `auth()`" and is deniable.
341
+ * On a hook context it means "this is a get / list / create / update" -- an
342
+ * ordinary request. A consumer who writes the hook-side half of the same
343
+ * rule --
344
+ *
345
+ * beforeHook('update', 'owner', ctx => ctx.recordId === 'archived' ? 403 : undefined)
346
+ *
347
+ * -- gets a deny that NEVER FIRES: measured, `PATCH /owners/visible2` -> 200,
348
+ * with `ctx.recordId === undefined` while the addressed record sits in
349
+ * `ctx.params`. The hook side is abofs/stonyx-orm#242 and is deliberately not
350
+ * repaired here. A predicate must not read `undefined` here as "collection",
351
+ * and nothing in this contract makes it safe to read the two keys as one key.
352
+ *
353
+ * IT NAMES WHICH RECORD, NOT WHICH SURFACE. `GET /owners/gina`,
354
+ * `GET /owners/gina/pets` and `GET /owners/gina/relationships/pets` all
355
+ * carry `recordId: 'gina'`; the related-resource gap is abofs/stonyx-orm#196
356
+ * and is untouched by this key.
357
+ */
358
+ recordId: string | number | null;
263
359
  }
264
360
 
265
361
  /**