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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -325,6 +325,19 @@ Access classes define models and provide custom filtering/authorization logic.
325
325
  > deny — which folds case but does not decode, so `GET /owners/%61rchived` steps
326
326
  > past it ([#228](https://github.com/abofs/stonyx-orm/issues/228)).
327
327
  >
328
+ > **Superseded 2026-09-01 by [#236](https://github.com/abofs/stonyx-orm/issues/236) /
329
+ > [#237](https://github.com/abofs/stonyx-orm/issues/237), and left standing rather
330
+ > than rewritten.** Both claims above are now false: `#228` is **closed**, and the
331
+ > one string comparison variant 3 lived in is gone — the sample below compares the
332
+ > **decoded `recordId`** the access context supplies, so there is no comparison
333
+ > left to step around. The paragraph about `request.path` further down is
334
+ > superseded the same way. Nothing here is deleted because the same "variant 3
335
+ > survives" wording sits at four sites (this file twice, `src/orm-request.ts`, and
336
+ > the test fixture) and retiring one of four leaves the shipped copies
337
+ > contradicting each other; retiring all four **with the measurement that retires
338
+ > them** is [#238](https://github.com/abofs/stonyx-orm/issues/238), which also owns
339
+ > this blockquote and the reference section below.
340
+ >
328
341
  > That is still a stopgap. **The real fix is
329
342
  > [#202](https://github.com/abofs/stonyx-orm/issues/202)** — `access()` should
330
343
  > receive the model, the operation and the record, so there is nothing to
@@ -337,6 +350,14 @@ Access classes define models and provide custom filtering/authorization logic.
337
350
  > context alone** and a context-only rewrite would silently turn it into an
338
351
  > allow.
339
352
  >
353
+ > **Superseded 2026-09-01 by [#236](https://github.com/abofs/stonyx-orm/issues/236) /
354
+ > [#237](https://github.com/abofs/stonyx-orm/issues/237)** — see the dated note
355
+ > above. No read of argument **one** survives in the sample below: the context
356
+ > carries `recordId`, the decoded route-parameter id, so the `/archived` deny **is**
357
+ > expressible from the context alone. It still must not be dropped — expressible is
358
+ > not optional. Retirement of this wording:
359
+ > [#238](https://github.com/abofs/stonyx-orm/issues/238).
360
+ >
340
361
  > The same warning is repeated at the top of `src/orm-request.ts`, which ships;
341
362
  > the longer write-up in `docs/usage-patterns.md` does **not** ship, so this
342
363
  > README and that source header are the two copies a consumer sees.
@@ -350,66 +371,73 @@ Access classes define models and provide custom filtering/authorization logic.
350
371
  export default class GlobalAccess {
351
372
  models = ['owner', 'animal'];
352
373
 
353
- access(request, { model, operation }) {
374
+ access(request, { model, operation, recordId }) {
354
375
  // `model` is the model this route was mounted for. It is assigned once, at
355
376
  // mount time, and no request can influence it — not a mount prefix, not a
356
377
  // query string, not a case-varied path, not an absolute-form request
357
- // target. Nothing below parses anything, so variants 1, 2, 4 and 5 are not
358
- // constructible against this predicate any more they are history, not
359
- // rules to follow. VARIANT 3 IS THE EXCEPTION AND THE CLAIM IS NARROWER
360
- // THAN IT WAS: a matcher stricter than the router can still be stepped
361
- // around, because the sub-path rule below is still a string comparison.
362
- // Case is handled; percent-encoding is not abofs/stonyx-orm#228.
378
+ // target. `recordId` is the record this route was ADDRESSED TO, decoded by
379
+ // the router and coerced to the key the store lookup uses. Nothing below
380
+ // parses anything, and since abofs/stonyx-orm#236 nothing below reads
381
+ // argument one AT ALL. Variants 1, 2, 4 and 5 were already unconstructible;
382
+ // the sub-path STRING COMPARISON that variant 3 lived in is gone too,
383
+ // replaced by a comparison against the decoded id. Retiring the "variant 3
384
+ // survives" wording at the four sites that still carry it — with the
385
+ // measurement that retires it, rather than by deletion — is
386
+ // abofs/stonyx-orm#238.
363
387
  //
364
388
  // `operation` is destructured to name the whole contract at the point of
365
- // use. This sample's rules are per-model and per-sub-path rather than
389
+ // use. This sample's rules are per-model and per-record rather than
366
390
  // per-verb, so it does not branch on it; the permission array at the bottom
367
391
  // is where the verb is answered.
368
392
 
369
- // FAIL CLOSED ON ARGUMENT TWO. `model` is absent for any caller that
370
- // resolved this predicate without supplying the context, and a request this
371
- // function cannot identify DENIES rather than falling through to the CRUD
372
- // grant at the bottom. An unidentifiable input must never be the permissive
373
- // path. Argument ONE is guarded at its own read, below — this guard does
374
- // not cover it.
393
+ // FAIL CLOSED ON AN UNIDENTIFIABLE MODEL. `model` is absent for any caller
394
+ // that resolved this predicate without supplying the context, and a request
395
+ // this function cannot identify DENIES rather than falling through to the
396
+ // CRUD grant at the bottom. An unidentifiable input must never be the
397
+ // permissive path.
375
398
  if (typeof model !== 'string' || model === '') return false;
376
399
 
377
400
  if (model === 'owner') {
378
- // The context names WHICH MODEL and WHICH VERB not which route. Six
379
- // distinct owner surfaces produce one identical context, so a rule that
380
- // depends on the SUB-PATH still needs argument one. `request.path` is
381
- // mount-relative and query-free, and it is the one read of the raw
382
- // request the README sanctions. false 403 for the whole request.
401
+ // FAIL CLOSED ON AN ABSENT `recordId` TOO, AND `undefined` IS THE ONLY
402
+ // SPELLING OF ABSENT. `auth()` ALWAYS sets the key `null` on a
403
+ // collection route, which is addressed to no record — so `undefined`
404
+ // means the context did not come from `auth()`: it was hand-assembled by
405
+ // a caller resolving this predicate through the documented
406
+ // `Orm.instance.getAccess()` path. Letting that through would fall
407
+ // straight to the per-record filter below, which is a DENY becoming an
408
+ // ALLOW. This is the same rule the old guard on `request.path` enforced,
409
+ // moved to the argument this predicate now actually reads.
410
+ if (recordId === undefined) return false;
411
+
412
+ // THE `/archived` DENY, EXPRESSED AGAINST THE DECODED ID. It used to be
413
+ // `request.path.toLowerCase()` compared against `'/archived'`, and that
414
+ // was wrong in both directions at once.
383
415
  //
384
- // THIS DENY CANNOT BE EXPRESSED FROM THE CONTEXT ALONE. Migrating it away
385
- // does not remove a rule, it turns a deny into an ALLOW, silently.
416
+ // TOO PERMISSIVE: express sets `request.path` from the RAW pathname while
417
+ // the router DECODES `:id`, so `GET /owners/%61rchived` reached the
418
+ // comparison as `/%61rchived`, walked past the deny and was dispatched as
419
+ // the record `archived` — 200 with the record in full, and DELETE
420
+ // answered 204 with the record DESTROYED, unauthenticated. 255
421
+ // non-canonical spellings of that 8-character id decode to the same key,
422
+ // so no deny-list of spellings was ever going to close it.
386
423
  //
387
- // FAIL CLOSED ON ARGUMENT ONE TOO. The guard above covers the context;
388
- // this one covers the request, and since #202 they are two different
389
- // objects. A caller that resolves this predicate through the documented
390
- // `Orm.instance.getAccess()` path and hand-assembles a request can supply
391
- // a perfectly valid context with no usable `path` — and
392
- // `String(request.path ?? '')` is then `''`, which matches no sub-path
393
- // rule and falls straight through to the per-record filter below. That is
394
- // a DENY becoming an ALLOW. An input this function cannot identify DENIES,
395
- // whichever ARGUMENT it arrived on — which is also why the `?? ''` this
396
- // file's header condemns does not appear below.
397
- if (typeof request?.path !== 'string' || request.path === '') return false;
398
-
399
- // Lower-cased because the router matched case-insensitively, so a
400
- // case-sensitive rule here would be stricter than the router and could be
401
- // stepped around.
424
+ // TOO STRICT: a record id is a VALUE, not a literal route segment, and
425
+ // express's `case sensitive routing` governs literal segments only. With
426
+ // a distinct owner seeded at `ARCHIVED`, the `.toLowerCase()` 403'd
427
+ // `GET /owners/ARCHIVED` the wrong record while still admitting
428
+ // `GET /owners/%41RCHIVED`, the same record encoded.
402
429
  //
403
- // CASE-FOLDING ALONE IS NOT A SUFFICIENT NORMALISATION, and this line is
404
- // not a recipe for one. Express sets `request.path` from the RAW pathname
405
- // while the router DECODES `:id`, so `GET /owners/%61rchived` reaches this
406
- // comparison as `/%61rchived`, walks past the deny, and is dispatched as
407
- // the record `archived` abofs/stonyx-orm#228. A matcher must normalise
408
- // the way the router that dispatched the request does. Record ids are
409
- // case-sensitive and must be compared at their real case.
410
- const path = request.path.toLowerCase();
411
-
412
- if (path === '/archived' || path.startsWith('/archived/')) return false;
430
+ // SO DO NOT NORMALISE `recordId`. It is already decoded, exactly ONCE,
431
+ // which is what a route parameter means: `/owners/%2561rchived` is the
432
+ // legitimate id `%61rchived`, and decoding until stable would deny it. Do
433
+ // not case-fold it. Do not rebuild it from `request.path` decoding the
434
+ // whole path decodes THEN splits while the router splits THEN decodes,
435
+ // which over-denies the distinct record at `/owners/archived%2fx`.
436
+ //
437
+ // THE DENY IS NOW EXPRESSIBLE FROM THE CONTEXT ALONE, which is exactly
438
+ // what `recordId` bought — and it still must not be dropped. Deleting it
439
+ // does not remove a rule loudly, it turns a deny into an ALLOW, silently.
440
+ if (recordId === 'archived') return false;
413
441
 
414
442
  // Returning a function plugs it in as a per-record filter, and it is
415
443
  // enforced on every surface addressed to one of these records:
@@ -492,6 +520,16 @@ access class shipped with this repo has such a rule: its `/archived` deny
492
520
  **cannot be expressed from the context alone**, and a predicate migrated to
493
521
  context-only would silently drop it — a deny becoming an allow.
494
522
 
523
+ **Superseded 2026-09-01 by [#236](https://github.com/abofs/stonyx-orm/issues/236) /
524
+ [#237](https://github.com/abofs/stonyx-orm/issues/237).** The context also carries
525
+ `recordId` — the record this route was addressed to, already decoded — so the
526
+ `/archived` deny **is** expressible from the context alone, and the shipped sample
527
+ no longer reads `request.path`. The full contract is `AccessContext.recordId` in
528
+ `src/types/orm-types.ts`, which ships. This section — the signature, the key table
529
+ and this paragraph — is corrected by
530
+ [#238](https://github.com/abofs/stonyx-orm/issues/238); the pointer is here because
531
+ what it currently says is an instruction, and the instruction is wrong.
532
+
495
533
  Note also that the related-resource and `?include=` surfaces serve *another
496
534
  model's* records under `model: 'owner'`, and the context gives a predicate no
497
535
  signal that it is authorizing a related-resource route. That is
@@ -787,6 +825,17 @@ these values should be matched on:
787
825
  | `GET http://anything.example/owners/angela` | `http://anything.example/angela` | `http://anything.example/owners/angela` | `/owners` | `/angela` |
788
826
  | `GET /api/animals/22` (`ORM_REST_ROUTE=/api`) | `/22` | `/api/animals/22` | `/api/animals` | `/22` |
789
827
 
828
+ **Superseded 2026-09-01 by [#236](https://github.com/abofs/stonyx-orm/issues/236) /
829
+ [#237](https://github.com/abofs/stonyx-orm/issues/237) — "Variant 3 survives" above,
830
+ and the two paragraphs below, are no longer true.** The access context now carries
831
+ `recordId`, the **decoded** route-parameter id, and the sample compares against it:
832
+ the one string comparison variant 3 lived in is gone, `#228` is **closed**, and no
833
+ read of argument one survives in the sample. The wording is left standing rather
834
+ than deleted because it appears at four sites (this file twice,
835
+ `src/orm-request.ts`, and the test fixture) and retiring one of four leaves the
836
+ shipped copies contradicting each other; retiring all four **with the measurement
837
+ that retires them** is [#238](https://github.com/abofs/stonyx-orm/issues/238).
838
+
790
839
  **One read of argument one survives, and it must: `request.path`.** It is
791
840
  mount-relative and query-free, and it is for rules that distinguish **sub-paths**
792
841
  beneath the mount — as the `/archived` deny in the sample above does. The context
@@ -806,6 +855,20 @@ comparison as `/%61rchived`, walks past the deny, and is dispatched as the recor
806
855
  `.toLowerCase()` there as a complete normalisation recipe.** Record ids are
807
856
  case-sensitive and must be compared at their real case.
808
857
 
858
+ **Do not follow the two paragraphs above — superseded 2026-09-01 by
859
+ [#236](https://github.com/abofs/stonyx-orm/issues/236) /
860
+ [#237](https://github.com/abofs/stonyx-orm/issues/237).** They are *instructions*,
861
+ not merely stale observations, which is why this note is louder than a date. The
862
+ sample no longer reads `request.path` and no longer calls `.toLowerCase()` on
863
+ anything it compares: `.toLowerCase()` was measured wrong in **both directions at
864
+ once** — with a distinct owner seeded at `ARCHIVED`, `GET /owners/ARCHIVED` was a
865
+ false **deny** on the wrong record and `GET /owners/%41RCHIVED` a false **allow**
866
+ on that same record. Compare `recordId` **as it arrives**: do not case-fold it, do
867
+ not decode it, do not derive it from `request.path`. The contract is
868
+ `AccessContext.recordId` in `src/types/orm-types.ts`, which ships and says "Do NOT
869
+ case-fold it". Retirement of this wording, with its measurement:
870
+ [#238](https://github.com/abofs/stonyx-orm/issues/238).
871
+
809
872
  **Fail closed on anything you cannot identify — on *either* argument.**
810
873
  `String(request.originalUrl ?? '')` was once added here to stop a `TypeError`,
811
874
  and it traded fail-closed for fail-**open**: an empty string matched no
@@ -1387,6 +1450,16 @@ Each hook receives a context object with comprehensive information:
1387
1450
  - It contains a deep copy of the record's state **before** the operation executes (captured before the `before` hook fires)
1388
1451
  - The deep copy is created via JSON serialization (`JSON.parse(JSON.stringify())`) to ensure complete isolation
1389
1452
  - For `delete` operations, `recordId` is provided in after hooks since the record may no longer exist in the store
1453
+ - **`context.recordId` here is NOT `AccessContext.recordId`.** Same name, same-shaped
1454
+ object, different coverage: `_withHooks` sets this key **only** under
1455
+ `operation === 'delete'`, so on `get` / `list` / `create` / `update` the key is
1456
+ **absent** — `beforeHook('update', 'owner', ctx => ctx.recordId === 'archived' ? 403 : undefined)`
1457
+ never fires (measured: `PATCH /owners/{id}` → 200 with `ctx.recordId === undefined`
1458
+ and the id sitting in `ctx.params`). The access context, by contrast, carries
1459
+ `recordId` on every route it classifies and spells absence as `null`, never
1460
+ `undefined`. Tracked as
1461
+ [#242](https://github.com/abofs/stonyx-orm/issues/242); see
1462
+ `AccessContext.recordId` in `src/types/orm-types.ts` for the other side.
1390
1463
  - `oldState` is captured as a deep copy of the record's data before the operation, providing access to the previous field values
1391
1464
 
1392
1465
  ### Usage Examples
@@ -144,7 +144,37 @@ function resolveVerdict(request, type) {
144
144
  return DENIED;
145
145
  let access;
146
146
  try {
147
- access = predicate(request, { model: type, operation: 'read' });
147
+ // `recordId: null`, AND NOT `request.params.id`. THE TEMPTING WRONG ANSWER
148
+ // IS RIGHT THERE, so this is pinned by assertion as well as by comment --
149
+ // test/unit/linkage-verdict-test.ts, `#234 + #241 -- recordId is null`.
150
+ //
151
+ // `AccessContext.recordId` (src/types/orm-types.ts, abofs/stonyx-orm#236 /
152
+ // #241) means "the record THIS ROUTE WAS ADDRESSED TO, as the store key of
153
+ // the model being authorised", and `null` means "addressed to no record".
154
+ // The id sitting on the request in hand names the PRIMARY record, which
155
+ // belongs to a DIFFERENT model -- `GET /owners/gina` carries
156
+ // `params.id === 'gina'`, and the ask being made HERE is about `animal` or
157
+ // `trait`. Filling this in from the request would hand the related model's
158
+ // predicate an id belonging to another model, which is byte-for-byte the
159
+ // cross-model confusion abofs/stonyx-orm#202 introduced this context to
160
+ // eliminate: the predicate would compare an owner's id against its own
161
+ // records and answer a question nobody asked. There is no record of THIS
162
+ // model addressed by this request, so `null` is the honest value -- the
163
+ // same spelling `auth()` uses for a collection route.
164
+ //
165
+ // NOR ANY RECORD'S OWN ID, WHICH IS THE SECOND-MOST TEMPTING ANSWER. This
166
+ // verdict is resolved ONCE PER TYPE and cached in `byType` below, before
167
+ // any record has been looked at; there is no per-record `AccessContext`
168
+ // built anywhere on this path. Seeding it from the first record of a type
169
+ // would let that record's identity answer for every later record of the
170
+ // same type -- the same "one record's verdict answers for another" defect
171
+ // the `decisions` raw-key argument below exists to prevent, just one level
172
+ // coarser. And it is unnecessary: `AccessContext` deliberately carries no
173
+ // `record` because auth-time and record-time are separate decision points,
174
+ // and the per-record point already receives the WHOLE record, id included,
175
+ // through `verdict.filter(record)`. A predicate that wants a record's id
176
+ // has the contract's own channel for it.
177
+ access = predicate(request, { model: type, operation: 'read', recordId: null });
148
178
  }
149
179
  catch (error) {
150
180
  log.error?.(`[@stonyx/orm] access() threw while resolving linkage for model "${type}" -- denying. ${error instanceof Error ? error.message : String(error)}`);
package/dist/hooks.d.ts CHANGED
@@ -20,7 +20,21 @@ export interface HookContext {
20
20
  state?: Record<string, unknown>;
21
21
  /** Previous record state (available in update hooks). */
22
22
  oldState?: unknown;
23
- /** Target record ID for single-record operations. */
23
+ /**
24
+ * Target record ID for single-record operations.
25
+ *
26
+ * SET ONLY UNDER `delete`. `_withHooks` assigns this key in the two
27
+ * `operation === 'delete'` branches and nowhere else, so on `get`, `list`,
28
+ * `create` and `update` the key is ABSENT -- not `undefined`-valued, absent.
29
+ * A hook rule written as `ctx.recordId === '<id>'` never fires on an update;
30
+ * the addressed id is in `ctx.params`. Tracked as abofs/stonyx-orm#242.
31
+ *
32
+ * @see AccessContext.recordId in ./types/orm-types.ts -- an identically-named
33
+ * key on an identically-shaped context object, and NOT interchangeable with
34
+ * this one: it is present on every route `auth()` classifies, and spells
35
+ * absence as `null` rather than `undefined`. They differ in coverage on four
36
+ * of five operations, not only in the absence spelling.
37
+ */
24
38
  recordId?: string | number;
25
39
  /** Response data (available in after hooks). */
26
40
  response?: unknown;
@@ -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.
@@ -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.
@@ -1306,10 +1354,53 @@ export default class OrmRequest extends Request {
1306
1354
  // src/types/orm-types.ts. Nothing is fetched at this point and adding a
1307
1355
  // lookup here would put a store read in the middle of an authorization
1308
1356
  // path. The function return shape below IS the per-record hook.
1357
+ //
1358
+ // -------------------------------------------------------------------------
1359
+ // #236 -- `recordId`, the DECODED route-parameter id, for the same reason.
1360
+ //
1361
+ // WHICH RECORD is the third structural fact the framework already holds and
1362
+ // the consumer was left to re-derive, and re-deriving it failed OPEN. The
1363
+ // documented sample compared `request.path` -- the RAW, undecoded pathname
1364
+ // -- against a literal `/archived`, while the router DECODES `:id`. So
1365
+ // `GET /owners/%61rchived` walked past the deny and was dispatched as the
1366
+ // record `archived`: 200 with the record in full, and DELETE answered 204
1367
+ // with the record destroyed, unauthenticated. Four spellings measured, all
1368
+ // four through; 255 non-canonical spellings of that 8-character id decode
1369
+ // to the same key, so this was never a deny-list of one.
1370
+ //
1371
+ // TWO CONSUMER-SIDE NORMALISATIONS WERE MEASURED WRONG IN OPPOSITE
1372
+ // DIRECTIONS, which is the argument for doing it once, here.
1373
+ // `.toLowerCase()` case-folds a route-parameter VALUE on the axis that
1374
+ // governs literal SEGMENTS: with a distinct owner seeded at `ARCHIVED`,
1375
+ // `GET /owners/ARCHIVED` was a false DENY on the wrong record and
1376
+ // `GET /owners/%41RCHIVED` a false ALLOW on that same one.
1377
+ // `decodeURIComponent(request.path)` decodes THEN splits while the router
1378
+ // splits THEN decodes, so it over-denied `/owners/archived%2fx` -- 403 for
1379
+ // a genuinely distinct record. Failing closed there was luck, not design.
1380
+ //
1381
+ // `getId(request.params)` AND NOT `request.params.id`, for exactly the
1382
+ // reason `operation` is a `methodAccessMap` lookup: it is the SAME single
1383
+ // coercion the store lookup one layer down performs, so the predicate and
1384
+ // the dispatch cannot disagree about which record a request addresses.
1385
+ // The raw string would reintroduce that divergence on hex-shaped ids --
1386
+ // `GET /animals/0x2391` looks up record `9105`.
1387
+ //
1388
+ // NOTHING HERE PARSES THE REQUEST TARGET EITHER. `request.params` is what
1389
+ // the router matched, so a mount prefix, an absolute-form target, a query
1390
+ // string or a case-varied mount cannot move this value -- the same
1391
+ // guarantee `model` carries, by the same means.
1392
+ //
1393
+ // `null` and not `undefined` on a collection route, so the KEY IS ALWAYS
1394
+ // PRESENT -- the rule `operation`'s own docblock already establishes. A
1395
+ // context reaching a predicate WITHOUT the key therefore did not come from
1396
+ // here; it was hand-assembled by a caller resolving the predicate through
1397
+ // `Orm.instance.getAccess()`, and that absence stays deniable only because
1398
+ // `auth()` never produces it.
1309
1399
  // -------------------------------------------------------------------------
1310
1400
  const context = {
1311
1401
  model: this.model,
1312
1402
  operation: methodAccessMap[request.method],
1403
+ recordId: request.params && 'id' in request.params ? getId(request.params) : null,
1313
1404
  };
1314
1405
  let access;
1315
1406
  try {
@@ -251,6 +251,101 @@ export interface AccessContext {
251
251
  * from one that classified the request and found nothing.
252
252
  */
253
253
  operation: AccessOperation | undefined;
254
+ /**
255
+ * The record this route was addressed to, as the store key -- or `null` on a
256
+ * collection route, which is addressed to no record (abofs/stonyx-orm#236).
257
+ *
258
+ * IT IS ALREADY DECODED, AND THAT IS THE WHOLE POINT. Express decodes route
259
+ * PARAMETERS while leaving `request.path` raw, so a consumer comparing
260
+ * `request.path` against a literal compares an undecoded string against a
261
+ * decoded dispatch. `GET /owners/%61rchived` reached such a comparison as
262
+ * `/%61rchived`, walked past a `/archived` deny, and was dispatched as the
263
+ * record `archived` -- 200 with the record in full, and `DELETE` destroyed
264
+ * it, unauthenticated. 255 non-canonical spellings of an 8-character id
265
+ * decode to the same key, so a deny-list of spellings is the wrong shape.
266
+ *
267
+ * SO DO NOT NORMALISE THIS, AND DO NOT NORMALISE ANYTHING ELSE INSTEAD:
268
+ *
269
+ * - Do NOT decode it. Express decodes exactly ONCE, which is what a route
270
+ * parameter means. `GET /owners/%2561rchived` is the legitimate id
271
+ * `%61rchived`, not a second-order spelling of `archived`; a predicate that
272
+ * decoded until stable would deny a record it was never asked about.
273
+ * - Do NOT case-fold it. A record id is a VALUE, not a literal route segment,
274
+ * and express's `case sensitive routing` governs literal segments only.
275
+ * With a distinct owner seeded at `ARCHIVED`, `.toLowerCase()` was measured
276
+ * wrong in BOTH directions at once: `GET /owners/ARCHIVED` 403 (a false
277
+ * deny, on the wrong record) and `GET /owners/%41RCHIVED` 200 (a false
278
+ * allow, on that same record).
279
+ * - Do NOT derive it from `request.path` or the request target. Decoding the
280
+ * whole path decodes THEN splits, while the router splits THEN decodes, so
281
+ * `/owners/archived%2fx` -- a genuinely distinct record whose id is
282
+ * `archived/x` -- was measured over-denied 403.
283
+ *
284
+ * IT IS `getId(request.params)`, BYTE FOR BYTE -- the same single coercion
285
+ * the store lookup uses, exactly as `operation` is the same `methodAccessMap`
286
+ * lookup the permission-array branch uses. The predicate and the dispatch
287
+ * therefore cannot disagree about which record a request addresses. Handing
288
+ * over the raw `request.params.id` instead would reintroduce that divergence
289
+ * on hex-shaped ids: `GET /animals/0x2391` looks up record `9105`.
290
+ *
291
+ * It inherits abofs/stonyx-orm#209 along with that coercion -- on a model
292
+ * declaring `id = attr('string')`, `'9107'` arrives here as the number
293
+ * `9107`. That is consistency WITH THE LOOKUP, which is the property this key
294
+ * exists to buy; it is not a defect to repair here.
295
+ *
296
+ * `null`, not `undefined`, on a collection route -- and the KEY IS ALWAYS
297
+ * PRESENT, the same rule `operation` states above. `auth()` always sets it,
298
+ * so a context arriving WITHOUT the key did not come from `auth()`: it was
299
+ * hand-assembled by a caller resolving the predicate through
300
+ * `Orm.instance.getAccess()`. That absence stays a distinguishable, deniable
301
+ * signal only because the framework never produces it.
302
+ *
303
+ * IT DISAGREES WITH THE HOOK VOCABULARY, AND NOT ONLY ON THE ABSENCE
304
+ * SPELLING. `HookContext.recordId` (`src/hooks.ts`) is an identically-named
305
+ * key on an identically-shaped context object, which is the exact
306
+ * configuration that makes `operation` fail-open shaped -- a hook sees
307
+ * `'get'` where `access()` sees `'read'`. An earlier revision of THIS
308
+ * docblock asserted the opposite ("here they AGREE... they differ in ONE way
309
+ * and it is the absence spelling"). That was measured false, in the fail-open
310
+ * direction, and it is corrected here rather than deleted.
311
+ *
312
+ * MEASURED over the live dispatch, before-hooks registered for all five
313
+ * operations on one model:
314
+ *
315
+ * before:list key ABSENT ('recordId' in context === false)
316
+ * before:get key ABSENT params={"id":"visible1"}
317
+ * before:create key ABSENT
318
+ * before:update key ABSENT params={"id":"visible2"}
319
+ * before:delete recordId="visible3"
320
+ * after:delete recordId="visible3"
321
+ *
322
+ * `_withHooks` assigns `context.recordId` at exactly TWO sites in
323
+ * `src/orm-request.ts`, and BOTH sit inside an `operation === 'delete'`
324
+ * branch. So the two keys differ in COVERAGE, on four of five operations: on
325
+ * a hook context the key is absent for get, list, create and update, while
326
+ * this key is present on every route `auth()` classifies. The absence
327
+ * spelling is the smaller half of the difference, not the whole of it.
328
+ *
329
+ * AND THAT INVERTS THE ARGUMENT ABOVE WHEN IT IS READ ACROSS THE TWO. Here,
330
+ * a missing `recordId` means "did not come from `auth()`" and is deniable.
331
+ * On a hook context it means "this is a get / list / create / update" -- an
332
+ * ordinary request. A consumer who writes the hook-side half of the same
333
+ * rule --
334
+ *
335
+ * beforeHook('update', 'owner', ctx => ctx.recordId === 'archived' ? 403 : undefined)
336
+ *
337
+ * -- gets a deny that NEVER FIRES: measured, `PATCH /owners/visible2` -> 200,
338
+ * with `ctx.recordId === undefined` while the addressed record sits in
339
+ * `ctx.params`. The hook side is abofs/stonyx-orm#242 and is deliberately not
340
+ * repaired here. A predicate must not read `undefined` here as "collection",
341
+ * and nothing in this contract makes it safe to read the two keys as one key.
342
+ *
343
+ * IT NAMES WHICH RECORD, NOT WHICH SURFACE. `GET /owners/gina`,
344
+ * `GET /owners/gina/pets` and `GET /owners/gina/relationships/pets` all
345
+ * carry `recordId: 'gina'`; the related-resource gap is abofs/stonyx-orm#196
346
+ * and is untouched by this key.
347
+ */
348
+ recordId: string | number | null;
254
349
  }
255
350
  /**
256
351
  * A consumer `access()` predicate.
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "stonyx-async",
5
5
  "stonyx-module"
6
6
  ],
7
- "version": "0.3.2-alpha.72",
7
+ "version": "0.3.2-alpha.73",
8
8
  "description": "",
9
9
  "main": "dist/index.js",
10
10
  "type": "module",
@@ -162,7 +162,37 @@ function resolveVerdict(request: unknown, type: string): AccessVerdict {
162
162
  let access: AccessMethod;
163
163
 
164
164
  try {
165
- access = predicate(request, { model: type, operation: 'read' });
165
+ // `recordId: null`, AND NOT `request.params.id`. THE TEMPTING WRONG ANSWER
166
+ // IS RIGHT THERE, so this is pinned by assertion as well as by comment --
167
+ // test/unit/linkage-verdict-test.ts, `#234 + #241 -- recordId is null`.
168
+ //
169
+ // `AccessContext.recordId` (src/types/orm-types.ts, abofs/stonyx-orm#236 /
170
+ // #241) means "the record THIS ROUTE WAS ADDRESSED TO, as the store key of
171
+ // the model being authorised", and `null` means "addressed to no record".
172
+ // The id sitting on the request in hand names the PRIMARY record, which
173
+ // belongs to a DIFFERENT model -- `GET /owners/gina` carries
174
+ // `params.id === 'gina'`, and the ask being made HERE is about `animal` or
175
+ // `trait`. Filling this in from the request would hand the related model's
176
+ // predicate an id belonging to another model, which is byte-for-byte the
177
+ // cross-model confusion abofs/stonyx-orm#202 introduced this context to
178
+ // eliminate: the predicate would compare an owner's id against its own
179
+ // records and answer a question nobody asked. There is no record of THIS
180
+ // model addressed by this request, so `null` is the honest value -- the
181
+ // same spelling `auth()` uses for a collection route.
182
+ //
183
+ // NOR ANY RECORD'S OWN ID, WHICH IS THE SECOND-MOST TEMPTING ANSWER. This
184
+ // verdict is resolved ONCE PER TYPE and cached in `byType` below, before
185
+ // any record has been looked at; there is no per-record `AccessContext`
186
+ // built anywhere on this path. Seeding it from the first record of a type
187
+ // would let that record's identity answer for every later record of the
188
+ // same type -- the same "one record's verdict answers for another" defect
189
+ // the `decisions` raw-key argument below exists to prevent, just one level
190
+ // coarser. And it is unnecessary: `AccessContext` deliberately carries no
191
+ // `record` because auth-time and record-time are separate decision points,
192
+ // and the per-record point already receives the WHOLE record, id included,
193
+ // through `verdict.filter(record)`. A predicate that wants a record's id
194
+ // has the contract's own channel for it.
195
+ access = predicate(request, { model: type, operation: 'read', recordId: null });
166
196
  } catch (error) {
167
197
  log.error?.(`[@stonyx/orm] access() threw while resolving linkage for model "${type}" -- denying. ${error instanceof Error ? error.message : String(error)}`);
168
198
 
package/src/hooks.ts CHANGED
@@ -37,7 +37,21 @@ export interface HookContext {
37
37
  state?: Record<string, unknown>;
38
38
  /** Previous record state (available in update hooks). */
39
39
  oldState?: unknown;
40
- /** Target record ID for single-record operations. */
40
+ /**
41
+ * Target record ID for single-record operations.
42
+ *
43
+ * SET ONLY UNDER `delete`. `_withHooks` assigns this key in the two
44
+ * `operation === 'delete'` branches and nowhere else, so on `get`, `list`,
45
+ * `create` and `update` the key is ABSENT -- not `undefined`-valued, absent.
46
+ * A hook rule written as `ctx.recordId === '<id>'` never fires on an update;
47
+ * the addressed id is in `ctx.params`. Tracked as abofs/stonyx-orm#242.
48
+ *
49
+ * @see AccessContext.recordId in ./types/orm-types.ts -- an identically-named
50
+ * key on an identically-shaped context object, and NOT interchangeable with
51
+ * this one: it is present on every route `auth()` classifies, and spells
52
+ * absence as `null` rather than `undefined`. They differ in coverage on four
53
+ * of five operations, not only in the absence spelling.
54
+ */
41
55
  recordId?: string | number;
42
56
  /** Response data (available in after hooks). */
43
57
  response?: unknown;
@@ -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.
@@ -1429,10 +1477,53 @@ export default class OrmRequest extends Request {
1429
1477
  // src/types/orm-types.ts. Nothing is fetched at this point and adding a
1430
1478
  // lookup here would put a store read in the middle of an authorization
1431
1479
  // path. The function return shape below IS the per-record hook.
1480
+ //
1481
+ // -------------------------------------------------------------------------
1482
+ // #236 -- `recordId`, the DECODED route-parameter id, for the same reason.
1483
+ //
1484
+ // WHICH RECORD is the third structural fact the framework already holds and
1485
+ // the consumer was left to re-derive, and re-deriving it failed OPEN. The
1486
+ // documented sample compared `request.path` -- the RAW, undecoded pathname
1487
+ // -- against a literal `/archived`, while the router DECODES `:id`. So
1488
+ // `GET /owners/%61rchived` walked past the deny and was dispatched as the
1489
+ // record `archived`: 200 with the record in full, and DELETE answered 204
1490
+ // with the record destroyed, unauthenticated. Four spellings measured, all
1491
+ // four through; 255 non-canonical spellings of that 8-character id decode
1492
+ // to the same key, so this was never a deny-list of one.
1493
+ //
1494
+ // TWO CONSUMER-SIDE NORMALISATIONS WERE MEASURED WRONG IN OPPOSITE
1495
+ // DIRECTIONS, which is the argument for doing it once, here.
1496
+ // `.toLowerCase()` case-folds a route-parameter VALUE on the axis that
1497
+ // governs literal SEGMENTS: with a distinct owner seeded at `ARCHIVED`,
1498
+ // `GET /owners/ARCHIVED` was a false DENY on the wrong record and
1499
+ // `GET /owners/%41RCHIVED` a false ALLOW on that same one.
1500
+ // `decodeURIComponent(request.path)` decodes THEN splits while the router
1501
+ // splits THEN decodes, so it over-denied `/owners/archived%2fx` -- 403 for
1502
+ // a genuinely distinct record. Failing closed there was luck, not design.
1503
+ //
1504
+ // `getId(request.params)` AND NOT `request.params.id`, for exactly the
1505
+ // reason `operation` is a `methodAccessMap` lookup: it is the SAME single
1506
+ // coercion the store lookup one layer down performs, so the predicate and
1507
+ // the dispatch cannot disagree about which record a request addresses.
1508
+ // The raw string would reintroduce that divergence on hex-shaped ids --
1509
+ // `GET /animals/0x2391` looks up record `9105`.
1510
+ //
1511
+ // NOTHING HERE PARSES THE REQUEST TARGET EITHER. `request.params` is what
1512
+ // the router matched, so a mount prefix, an absolute-form target, a query
1513
+ // string or a case-varied mount cannot move this value -- the same
1514
+ // guarantee `model` carries, by the same means.
1515
+ //
1516
+ // `null` and not `undefined` on a collection route, so the KEY IS ALWAYS
1517
+ // PRESENT -- the rule `operation`'s own docblock already establishes. A
1518
+ // context reaching a predicate WITHOUT the key therefore did not come from
1519
+ // here; it was hand-assembled by a caller resolving the predicate through
1520
+ // `Orm.instance.getAccess()`, and that absence stays deniable only because
1521
+ // `auth()` never produces it.
1432
1522
  // -------------------------------------------------------------------------
1433
1523
  const context: AccessContext = {
1434
1524
  model: this.model,
1435
1525
  operation: methodAccessMap[request.method],
1526
+ recordId: request.params && 'id' in request.params ? getId(request.params) : null,
1436
1527
  };
1437
1528
 
1438
1529
  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
  /**