@stonyx/orm 0.3.2-beta.159 → 0.3.2-beta.160

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
@@ -439,11 +439,22 @@ export default class GlobalAccess {
439
439
  // does not remove a rule loudly, it turns a deny into an ALLOW, silently.
440
440
  if (recordId === 'archived') return false;
441
441
 
442
- // Returning a function plugs it in as a per-record filter, and it is
443
- // enforced on every surface addressed to one of these records:
442
+ // Returning a function plugs it in as a per-record filter. It is enforced
443
+ // on every surface addressed to one of these records
444
444
  // /owners, /owners/:id, /owners/:id/pets, /owners/:id/relationships/pets
445
- // A rejected record is 404 on record routes the same status as a record
446
- // that does not exist — so the filter is not an existence oracle.
445
+ // AND, since #232, on every surface that reaches one of these records
446
+ // as the RELATED resource of another model:
447
+ // /animals/:id/owner, /animals/:id/relationships/owner
448
+ // Both readings are the same rule: an owner this predicate rejects is
449
+ // withheld wherever she is reachable, not only on /owners.
450
+ //
451
+ // NOTHING HERE IS AN EXISTENCE ORACLE, AND THE SPELLING DIFFERS BY WHOSE
452
+ // RECORD IS BEING REJECTED. A rejected ADDRESSED record is 404 — the same
453
+ // status as a record that does not exist. A rejected RELATED record is
454
+ // `data: null` at 200 — byte-identical to a relationship that is
455
+ // genuinely empty, because on those routes 404 is already the answer for
456
+ // a PARENT that does not exist. In both cases "rejected" and "not there"
457
+ // are the same answer, which is the property that matters.
447
458
  return record => record.id !== 'angela' && record.id !== 'restricted';
448
459
  }
449
460
 
@@ -452,7 +463,11 @@ export default class GlobalAccess {
452
463
  // inert. Deliberately NO `?? record.owner` fallback: accepting the raw
453
464
  // shape as well as the resolved one would absorb a resolution regression
454
465
  // silently, which is exactly what blinded this fixture before.
455
- if (model === 'animal') return record => record.owner?.id !== 'restricted';
466
+ // `record.id !== 18` hides one animal whose OWNER is permitted. It is the
467
+ // fixture that makes the `hasMany` half of the relationship-route rules
468
+ // observable: gina is served, animal 18 is not, and every surface that
469
+ // names gina's pets has to drop it.
470
+ if (model === 'animal') return record => record.owner?.id !== 'restricted' && record.id !== 18;
456
471
 
457
472
  // Allows full access to all calls that don't match any of the above conditions
458
473
  return ['read', 'create', 'update', 'delete'];
@@ -665,9 +680,11 @@ A `throw` inside `access()` is a **denial**, not a 500.
665
680
  A function return value is a **per-record predicate**, and it is enforced on
666
681
  every endpoint that is addressed to a record — not only on the collection.
667
682
 
668
- It is evaluated against the record the route is *addressed to*, **on that model
669
- only**. It is not a guarantee that a hidden record cannot be reached or modified:
670
- a write to a *different* collection can still re-parent one. See
683
+ It is evaluated against the record the route is *addressed to*. On the two
684
+ relationship route families it is **also** evaluated against the **related**
685
+ record, by that record's *own* model's predicate see the two `{relationship}`
686
+ rows below. It is not a guarantee that a hidden record cannot be reached or
687
+ modified: a write to a *different* collection can still re-parent one. See
671
688
  [Known limitations](#known-limitations) and
672
689
  [#207](https://github.com/abofs/stonyx-orm/issues/207).
673
690
 
@@ -675,19 +692,42 @@ a write to a *different* collection can still re-parent one. See
675
692
  |---|---|
676
693
  | `GET /:models` | omitted from the collection |
677
694
  | `GET /:models/:id` | `404` |
678
- | `GET /:models/:id/{relationship}` | `404` the **addressed** record is filtered, not the related one |
679
- | `GET /:models/:id/relationships/{relationship}` | `404` same |
695
+ | `GET /:models/:id/{relationship}` | the **addressed** record → `404`. The **related** record `200` with `data: null` for a `belongsTo`, or dropped from the array for a `hasMany` |
696
+ | `GET /:models/:id/relationships/{relationship}` | same, on the linkage objects |
680
697
  | `PATCH /:models/:id` | `404`, no attribute is applied |
681
698
  | `DELETE /:models/:id` | `404`, the record is not removed and no SQL `DELETE` is issued |
682
699
  | `POST /:models` | `403`, and a record **this request inserted** is rolled back — see [Known limitations](#known-limitations) for why the rollback is conditional |
683
700
 
701
+ **A withheld related record is not an error, and that is the same rule.** The
702
+ addressed record is filtered with `404` and the related one with `data: null`
703
+ because in both cases the answer must be **identical to the answer for a record
704
+ that does not exist**. A `belongsTo` whose target is genuinely absent already
705
+ answers `200 {"data": null}`; a `hasMany` with no members already answers `200`
706
+ with an empty array. Withholding therefore has to be spelled the same way, or
707
+ the route becomes an existence oracle for a record on a collection the caller
708
+ may have no access to at all. Measured before this was closed — unauthenticated,
709
+ zero query parameters, one request each:
710
+
711
+ ```
712
+ GET /traits/1/tag [target absent] -> 200 application/json 68 bytes
713
+ GET /traits/2/tag [target denied] -> 404 text/plain 9 bytes
714
+ ```
715
+
716
+ `tag` is a model with **no route mounted at all**, so those two requests were the
717
+ only way to ask about it — and they answered differently. Both now answer
718
+ `200 {"data": null}`.
719
+
684
720
  **Denied record-level requests return 404, not 403.** This is deliberate and it
685
721
  is the property most easily "improved" away. 403 would confirm that the record
686
722
  exists to a caller who is not allowed to know that, which turns the filter into
687
723
  an existence oracle: `404` means "no such record", `403` means "there is one and
688
724
  it is not yours". Every status on a record route must therefore be identical for
689
725
  "filtered out" and "does not exist" — including `DELETE`, which is why deleting
690
- a record that never existed also returns 404 rather than 204.
726
+ a record that never existed also returns 404 rather than 204, and including the
727
+ **related** record on the two relationship families, which is why a denied
728
+ `belongsTo` target is `200 {"data": null}` rather than `404`: on that route
729
+ `404` is the answer for a parent that does not exist, so it is `data: null`, and
730
+ not the status, that carries "no target you may see".
691
731
 
692
732
  `POST` is the one exception and returns **403**, because 404 on a mounted
693
733
  collection route is indistinguishable from "model not mounted" — a genuinely
@@ -921,15 +961,109 @@ per-record filter. An input you cannot identify must **deny**.
921
961
  operation and which record the request addresses. The five variants above are
922
962
  the five ways that has been observed to fail open so far. Tracked as
923
963
  [#202](https://github.com/abofs/stonyx-orm/issues/202).
924
- - **Related and included records are not filtered.** The predicate is evaluated
925
- against the record the route is *addressed to*. `GET /animals/1/owner`,
926
- `GET /animals/1/relationships/owner` and `?include=owner` all serialize the
927
- related record without resolving that model's own access class, so a filter on
928
- `/owners` does not hide an owner reached through `/animals`. Tracked as
929
- [#196](https://github.com/abofs/stonyx-orm/issues/196), which covers
930
- `include=`, related-resource routes and relationship-linkage routes. This is
931
- **membership** — whether the related resource is served at all and it is a
932
- different question from which ids a document may *name*, immediately below.
964
+ - **The two relationship route families now resolve the *related* model's own
965
+ access class `GET /:models/:id/{relationship}` and
966
+ `GET /:models/:id/relationships/{relationship}`**
967
+ ([#232](https://github.com/abofs/stonyx-orm/issues/232)). This is
968
+ **membership**: the related resource is the route's *primary* data, so the
969
+ filter decides whether it is served at all, not merely which ids a document
970
+ may name. A denied `hasMany` member is **dropped from the array** and nothing
971
+ in the relationship marks the drop: `links` intact, no `errors` member, same
972
+ status, and an array of survivors shaped exactly like one from a parent that
973
+ only ever had those members. A denied `belongsTo` target answers **`200` with
974
+ `data: null`**, byte-identical to a target that is genuinely absent, for the
975
+ same reason.
976
+
977
+ **That is a claim about the relationship, not about the document, and the gap
978
+ is measurable in this repo's own fixture.** `owner` declares a computed
979
+ `totalPets` returning `this.pets.length`, which reads the **store** and is
980
+ never filtered. Measured on this branch, unauthenticated, at zero query
981
+ parameters: `GET /owners/gina` answers `attributes.totalPets: 5` while its
982
+ `relationships.pets.data` names **four** ids, and both relationship routes
983
+ serve the same four. The relationship discloses nothing; the document it
984
+ arrives in discloses that exactly one child was withheld. Do not read
985
+ "indistinguishable" as a property of the response — it is a property of the
986
+ relationship member alone. The other channels in the same class are
987
+ [#245](https://github.com/abofs/stonyx-orm/issues/245) (computed attributes,
988
+ which is the one measured above),
989
+ [#233](https://github.com/abofs/stonyx-orm/issues/233) (`included`
990
+ membership) and [#246](https://github.com/abofs/stonyx-orm/issues/246) (the
991
+ absence of `attributes.<fk>` on a `POST` response), all open. **Audit your
992
+ computed properties before you treat a dropped member as unobservable.** Nothing on either family errors and no status changes — the
993
+ status on these routes belongs to the **parent**, and `data` carries the
994
+ answer about the related record. The `/relationships/` family built its `{type, id}` by
995
+ hand rather than through `toJSON()`, which is why the linkage filter shipped in
996
+ [#234](https://github.com/abofs/stonyx-orm/issues/234) did not reach it.
997
+
998
+ Before this, both families served a record hidden on every one of its own
999
+ surfaces, in full, from another model's route, at **zero query parameters**.
1000
+ The severe case is a model **claimed by no access class**: `getAccess()`
1001
+ returns `undefined`, no route is mounted for it at all, and it was still
1002
+ readable as a related resource — a collection the consumer deliberately never
1003
+ exposed.
1004
+
1005
+ **The `belongsTo` shape is not an existence oracle, and it was measured
1006
+ rather than reasoned about.** An earlier revision of this fix answered `404`
1007
+ for a denied target, which made it distinguishable from a target that is
1008
+ genuinely absent. Unauthenticated, zero query parameters, one request each, on
1009
+ `tag` — a model with **no route mounted at all**:
1010
+
1011
+ ```
1012
+ GET /traits/1/tag [target absent] -> 200 application/json 68 bytes
1013
+ GET /traits/2/tag [target denied] -> 404 text/plain 9 bytes
1014
+ ```
1015
+
1016
+ `GET /traits/1` and `GET /traits/2` report `relationships.tag = {"data":null}`
1017
+ byte-identical modulo the id, because
1018
+ [#234](https://github.com/abofs/stonyx-orm/issues/234) closed that oracle
1019
+ deliberately — so this route was the one remaining way to ask which of those
1020
+ two nulls was a denial. Under `data: null` both requests answer `200`, same
1021
+ content-type, same content-length, same bytes modulo the parent id the caller
1022
+ put in the URL. It discloses nothing further: `links` on these routes are
1023
+ derived entirely from the parent and the relationship name, there is no `meta`
1024
+ and there are no counts. This also brings the two families back into line with
1025
+ the module-wide rule under [Filter functions](#filter-functions) — *every
1026
+ status on a record route must be identical for "filtered out" and "does not
1027
+ exist"* — which the `404` spelling was an exception to.
1028
+
1029
+ **Per-record denies for a related resource are not expressible.** A predicate resolved for a
1030
+ related resource on these routes receives `recordId: null` and a `request`
1031
+ whose `params` name a record of a **different model**. So the inputs it has
1032
+ are the model name, the operation and the request — and **a rule that needs to
1033
+ know *which* related record it is being asked about cannot be written**.
1034
+ Model-level denies (`return false` for a model) work. Request-level denies (a
1035
+ rule reading a header, a tenant, the method) work. The per-record **filter**
1036
+ shape works too — `access()` may return a function, and that function receives
1037
+ the whole record, id included. What does not work is branching on the record's
1038
+ identity *before* returning, because `access()` is not told it.
1039
+
1040
+ This is not an oversight and it is not closed here. The verdict is resolved
1041
+ **once per type**, cached, before any record has been examined — a `hasMany`
1042
+ related-resource route returns many records of one type, so seeding `recordId`
1043
+ from a record would let the first one decide the context for all of them. The
1044
+ rule the framework holds to is: **`recordId` may name a record only where the
1045
+ route addresses exactly one record of the model being asked about.** That is
1046
+ true for `GET /owners/{id}`, false for linkage, and false for a `hasMany`
1047
+ related-resource route.
1048
+
1049
+ - **`?include=` records are still not filtered — the relationship routes now
1050
+ are.** *Re-specified by [#232](https://github.com/abofs/stonyx-orm/issues/232);
1051
+ the sentence this replaces said all three surfaces were unfiltered, and two of
1052
+ them no longer are.* `GET /animals/1/owner` and
1053
+ `GET /animals/1/relationships/owner` resolve the related model's own access
1054
+ class (see the bullet above). **`?include=owner` still does not**: it
1055
+ serializes the related record without resolving that class, so a filter on
1056
+ `/owners` does not hide an owner reached through `?include=` on `/animals`.
1057
+ There are **two** open questions here and they are owned separately —
1058
+ [#233](https://github.com/abofs/stonyx-orm/issues/233), the remaining child of
1059
+ [#196](https://github.com/abofs/stonyx-orm/issues/196), owns whether a
1060
+ resource enters `included` **at all** (membership), and
1061
+ [#235](https://github.com/abofs/stonyx-orm/issues/235) owns the
1062
+ `relationships.*.data` emitted **inside** a record that is already there
1063
+ (linkage). Neither closes the other, and following only #233 will not lead you
1064
+ to the second. Membership — whether the related resource is served at all — is
1065
+ a different question from which ids a document may *name*, immediately
1066
+ below.
933
1067
  - **Relationship linkage is filtered on every request-bound surface that
934
1068
  serializes a record — the reads, the two writes, and `included`.** A
935
1069
  document's `relationships.*.data` used to publish the id of every related
@@ -974,6 +1108,15 @@ per-record filter. An input you cannot identify must **deny**.
974
1108
  because throwing here would be an existence oracle *and* would throw out of
975
1109
  the enclosing `JSON.stringify`.
976
1110
 
1111
+ **[#232](https://github.com/abofs/stonyx-orm/issues/232) holds to the same
1112
+ spelling on the routes where that linkage is the *primary* data.** A denied
1113
+ member is dropped from the `hasMany` array and a denied `belongsTo` target is
1114
+ `data: null`, at `200`, `links` intact — so the claim above is true of both
1115
+ `GET /:models/:id/{relationship}` shapes as *routes* and not only as linkage
1116
+ emitted inside somebody else's document. An earlier revision of #232 answered
1117
+ `404` on the `belongsTo` shape and did contradict this paragraph; that is
1118
+ measured and closed in the #232 bullet above.
1119
+
977
1120
  **That resolves the right class; it does not guarantee a model-correct
978
1121
  answer, and the failure direction is not the safe one.** Only a predicate that
979
1122
  *reads* `context.model` can answer about the model it was asked about — see
@@ -1038,6 +1181,15 @@ per-record filter. An input you cannot identify must **deny**.
1038
1181
  measurement is where #245 starts, and it holds whichever way the decision
1039
1182
  lands. Until it lands, if your access rules hide a record, audit your
1040
1183
  computed properties for its identifiers.
1184
+ - **The absence of `attributes.<fk>` on a `POST` response proves a hidden
1185
+ record exists** — [#246](https://github.com/abofs/stonyx-orm/issues/246).
1186
+ `createHandler` copies each supplied relationship's raw id into
1187
+ `attributes`; when the related record resolves, the value is consumed and
1188
+ does not appear, and when it does not resolve, it survives. The oracle runs
1189
+ in the negative space, so nothing this list's surfaces withhold is
1190
+ *published* — the **absence** is the signal. Pre-existing, and it does not
1191
+ compose with the two relationship families above: they emit no `attributes`
1192
+ for a related record at all.
1041
1193
  - **A bare `toJSON()` still emits unfiltered linkage, and that is deliberate.**
1042
1194
  `Record.toJSON()` **applies** a verdict; it never **resolves** one. It has no
1043
1195
  request, and the documented `access()` contract permits a predicate to read
@@ -1166,6 +1318,57 @@ default, the default is the unfiltered document, and a filtered relationship is
1166
1318
  byte-identical to a genuinely empty one — so nothing on the wire distinguishes
1167
1319
  "filtered" from "forgotten".
1168
1320
 
1321
+ **And `linkage` cannot reach a document you build by hand.** It is an *option to
1322
+ `toJSON()`*, so it filters only what goes through `toJSON()`. The ORM's own
1323
+ `GET /:models/:id/relationships/{relationship}` route is the worked example: its
1324
+ primary data *is* linkage, it assembles `{ type, id }` directly rather than
1325
+ serializing a record, and it therefore resolves and applies the verdict itself
1326
+ ([#232](https://github.com/abofs/stonyx-orm/issues/232)). If you assemble
1327
+ linkage the same way anywhere — a custom relationship route, a projection, a
1328
+ hand-built document — **passing `linkage` to `toJSON()` does nothing for it and
1329
+ nothing warns**. Build the filter and consult it before you emit an id:
1330
+
1331
+ ```js
1332
+ const linkage = createLinkageFilter(request);
1333
+
1334
+ if (related && linkage(related.__model.__name, related)) {
1335
+ data = { type: related.__model.__name, id: related.id };
1336
+ } else {
1337
+ data = null; // withheld and genuinely-empty must be the SAME answer
1338
+ }
1339
+ ```
1340
+
1341
+ The `else` branch is the part that is easy to get wrong. Answering `404`, `403`
1342
+ or an `errors` member for the withheld case makes the route an **existence
1343
+ oracle** — see [Filter functions](#filter-functions) for the rule and for the
1344
+ measurement that closed it on this route.
1345
+
1346
+ **A per-record deny for a *related* resource cannot be expressed, and nothing
1347
+ tells you so at the point you would write it.** `createLinkageFilter` resolves
1348
+ the related model's access class by **type**: `context.recordId` is `null`, and
1349
+ `request.params` names a record of a **different model** — the one the route is
1350
+ addressed to. So `access()` is handed the model, the operation and the request,
1351
+ and **a rule that has to know *which* related record it is being asked about
1352
+ cannot be written**. Model-level denies (`return false` for a model) work.
1353
+ Request-level denies (a header, a tenant, the method) work. The per-record
1354
+ **filter** shape works too — `access()` may return a function, and that function
1355
+ receives the whole record, id included. What does not work is branching on the
1356
+ record's identity *before* returning, because `access()` is not told it.
1357
+
1358
+ This is a fixed property of the mechanism rather than a defect awaiting a fix.
1359
+ The verdict is resolved **once per type** and cached before any record has been
1360
+ examined, so seeding `recordId` from a record would let the first member of a
1361
+ `hasMany` decide the context for all of them. The rule the framework holds to is
1362
+ **`recordId` may name a record only where the route addresses exactly one record
1363
+ of the model being asked about** — true for `GET /owners/{id}`, false for
1364
+ linkage, and false for a `hasMany` related-resource route. The consumer-facing
1365
+ consequence is the part to check: a predicate that branches on `recordId` sees
1366
+ `null` here and takes whichever branch `null` takes, with no warning, and if
1367
+ that branch grants then it **grants**. Express the rule as a returned filter
1368
+ function instead. Stated again, with the same label, under
1369
+ [Known limitations](#known-limitations)
1370
+ ([#232](https://github.com/abofs/stonyx-orm/issues/232)).
1371
+
1169
1372
  Do this:
1170
1373
 
1171
1374
  ```js
@@ -1283,10 +1486,31 @@ they are recorded here.
1283
1486
  return 404. Only affects function-style `access` users, for whom the old
1284
1487
  behaviour was the bypass.
1285
1488
 
1286
- "Seven surfaces" means the seven endpoints of **the filtered model**. A write
1287
- to another collection can still reach one of its records through a
1288
- relationship — [#207](https://github.com/abofs/stonyx-orm/issues/207), which
1289
- is **not** closed here.
1489
+ "Seven surfaces" means the seven endpoints of **the filtered model**
1490
+ `GET /:models`, `GET /:models/:id`, `GET /:models/:id/{relationship}`,
1491
+ `GET /:models/:id/relationships/{relationship}`, `POST /:models`,
1492
+ `PATCH /:models/:id` and `DELETE /:models/:id`. That count is still seven and
1493
+ is still the model's own endpoints, but **it is no longer the whole
1494
+ population**: the boundary moved outward rather than the number changing, and
1495
+ this sentence used to be read as saying a filtered model's predicate is
1496
+ consulted nowhere else. It now is, in two further places, both from
1497
+ *another* model's routes —
1498
+
1499
+ - on **both relationship route families**, where the related record is the
1500
+ primary data and the filtered model's own class decides whether it is
1501
+ served at all (breaking change 9 below,
1502
+ [#232](https://github.com/abofs/stonyx-orm/issues/232)); and
1503
+ - on the `relationships.*.data` **linkage** of every request-bound surface
1504
+ that serializes a record through `toJSON()`
1505
+ ([#234](https://github.com/abofs/stonyx-orm/issues/234),
1506
+ [#235](https://github.com/abofs/stonyx-orm/issues/235)), which decides
1507
+ which ids another model's document may name.
1508
+
1509
+ A **write** to another collection can still reach one of its records through
1510
+ a relationship — [#207](https://github.com/abofs/stonyx-orm/issues/207),
1511
+ which is **not** closed here. That is the half of the old sentence that
1512
+ survives, and it is a read/write asymmetry now rather than a blanket
1513
+ statement about cross-model reach.
1290
1514
  5. **A predicate that throws is treated as a denial** rather than propagating to
1291
1515
  Express's default 500 handler. So is an `access()` that throws.
1292
1516
  6. **`access()` returning a bare string is one permission, not full access.**
@@ -1361,6 +1585,54 @@ they are recorded here.
1361
1585
  `stonyx/log`. Previously this case threw out of the handler and express
1362
1586
  answered `500` with a stack trace.
1363
1587
 
1588
+ 9. **Both relationship route families now resolve the *related* model's own
1589
+ access class, so a related record that is hidden on its own routes is no
1590
+ longer served through another model's.**
1591
+ [#232](https://github.com/abofs/stonyx-orm/issues/232). Affects
1592
+ function-style `access` users with relationships between filtered models. The
1593
+ old behaviour was a bypass at **zero query parameters** and with no
1594
+ `include=`: measured on `dev @ 8dda5d6`, `GET /animals/1/owner` returned
1595
+ angela's full document and `GET /animals/1/relationships/owner` returned
1596
+ `{"type":"owner","id":"angela"}`, while `GET /owners/angela` answered `404`.
1597
+ The severe case is a model claimed by **no** access class — `getAccess()`
1598
+ returns `undefined`, no route is mounted for it at all, and it was still
1599
+ readable as a related resource.
1600
+
1601
+ **The shapes, on both families.** A denied `hasMany` member is **dropped from
1602
+ the array**: `200`, `links` intact, no `errors` member. A denied `belongsTo`
1603
+ target answers **`200` with `data: null`**, byte-identical to a target that
1604
+ genuinely does not exist — same status, same bytes modulo the parent id the
1605
+ caller put in the URL. `404` on these routes is now reserved for the
1606
+ **parent**.
1607
+
1608
+ **`data: null` and not `404`, deliberately.** The 404 spelling was an
1609
+ existence oracle and was measured as one on this branch: unauthenticated, no
1610
+ query string, one request each, against `tag` — the model with no route
1611
+ mounted at all — `GET /traits/1/tag` (absent) answered `200`
1612
+ `application/json` at 68 bytes while `GET /traits/2/tag` (denied) answered
1613
+ `404` `text/plain` at 9 bytes, and the document surface reported both as
1614
+ `{"data":null}`. It also brings these two routes into line with this module's
1615
+ rule that every status on a record route is identical for filtered-out and
1616
+ does-not-exist (see [Filter functions](#filter-functions)), which the `404`
1617
+ spelling was the one exception to.
1618
+
1619
+ **What to check before you upgrade.** If a consumer reaches a related record
1620
+ through `GET /:models/:id/{relationship}` that it cannot reach on that
1621
+ record's own collection route, it was relying on the bypass and will now get
1622
+ `data: null` or a shorter array. And the related model's class is resolved
1623
+ through the same `Orm.instance.getAccess` path as the linkage filter, so it
1624
+ inherits the same arity limit: a **single-argument** predicate answers about
1625
+ the collection the request was *addressed to*, not the one it was asked
1626
+ about, and that is the direction that **grants**. See
1627
+ [Known limitations](#known-limitations) and
1628
+ [#221](https://github.com/abofs/stonyx-orm/issues/221).
1629
+
1630
+ **Not closed here:** whether a related resource appears in `included` at all
1631
+ ([#233](https://github.com/abofs/stonyx-orm/issues/233)), and the
1632
+ re-parenting write ([#207](https://github.com/abofs/stonyx-orm/issues/207)).
1633
+ A **per-record** deny for a related resource is not expressible on these
1634
+ routes at all — see [Consumer Contracts](#consumer-contracts).
1635
+
1364
1636
  ### Include Parameter (Sideloading Relationships)
1365
1637
 
1366
1638
  The ORM supports JSON API-compliant relationship sideloading via the `include` query parameter. This reduces the need for multiple API requests by embedding related records in a single response.
@@ -65,9 +65,19 @@
65
65
  * warning below sanctions. This repo's own fixture has such a rule: its
66
66
  * `/archived` deny cannot be expressed from the context alone, and a predicate
67
67
  * migrated to context-only would silently drop it, turning a deny into an
68
- * allow. The related-resource and `?include=` surfaces serve ANOTHER model's
69
- * records under `model: 'owner'`, and the context gives no signal of that
70
- * (abofs/stonyx-orm#196).
68
+ * allow.
69
+ *
70
+ * THE RELATED-RESOURCE HALF OF THAT SENTENCE IS NOW OUT OF DATE AND IS
71
+ * CORRECTED HERE RATHER THAN DELETED. Both relationship route families resolve
72
+ * the RELATED model's own access class and ask it
73
+ * `{ model: <related>, operation: 'read', recordId: null }`
74
+ * (abofs/stonyx-orm#232), so those surfaces no longer serve another model's
75
+ * records under `model: 'owner'` unexamined. What the context still gives no
76
+ * signal of is WHICH related record is being asked about -- `recordId` is
77
+ * `null` there and `request.params` names a record of a different model. See
78
+ * `AccessContext.recordId` in ./types/orm-types.ts for the full statement of
79
+ * that limit. `?include=` is still unfiltered and is abofs/stonyx-orm#233 /
80
+ * #235.
71
81
  *
72
82
  * SUPERSEDED 2026-09-01 BY abofs/stonyx-orm#236/#237, AND KEPT FOR THE
73
83
  * CONSTRAINT IT STATES RATHER THAN AS A DESCRIPTION OF THE CODE. The context
@@ -65,9 +65,19 @@
65
65
  * warning below sanctions. This repo's own fixture has such a rule: its
66
66
  * `/archived` deny cannot be expressed from the context alone, and a predicate
67
67
  * migrated to context-only would silently drop it, turning a deny into an
68
- * allow. The related-resource and `?include=` surfaces serve ANOTHER model's
69
- * records under `model: 'owner'`, and the context gives no signal of that
70
- * (abofs/stonyx-orm#196).
68
+ * allow.
69
+ *
70
+ * THE RELATED-RESOURCE HALF OF THAT SENTENCE IS NOW OUT OF DATE AND IS
71
+ * CORRECTED HERE RATHER THAN DELETED. Both relationship route families resolve
72
+ * the RELATED model's own access class and ask it
73
+ * `{ model: <related>, operation: 'read', recordId: null }`
74
+ * (abofs/stonyx-orm#232), so those surfaces no longer serve another model's
75
+ * records under `model: 'owner'` unexamined. What the context still gives no
76
+ * signal of is WHICH related record is being asked about -- `recordId` is
77
+ * `null` there and `request.params` names a record of a different model. See
78
+ * `AccessContext.recordId` in ./types/orm-types.ts for the full statement of
79
+ * that limit. `?include=` is still unfiltered and is abofs/stonyx-orm#233 /
80
+ * #235.
71
81
  *
72
82
  * SUPERSEDED 2026-09-01 BY abofs/stonyx-orm#236/#237, AND KEPT FOR THE
73
83
  * CONSTRAINT IT STATES RATHER THAN AS A DESCRIPTION OF THE CODE. The context
@@ -1314,21 +1324,102 @@ export default class OrmRequest extends Request {
1314
1324
  return 404;
1315
1325
  const relatedData = record.__relationships[relationshipName];
1316
1326
  const baseUrl = getBaseUrl(request);
1317
- // LINKAGE ONLY. This filter decides which ids the emitted documents may
1318
- // NAME in their own `relationships.*.data`; it does NOT decide whether
1319
- // the related records themselves are served -- that is the parent-only
1320
- // filtering this route has done since #190, and widening it to the
1321
- // related record is abofs/stonyx-orm#196.
1327
+ // ONE FILTER, TWO JOBS, AND abofs/stonyx-orm#232 IS THE SECOND ONE.
1328
+ //
1329
+ // As LINKAGE (#234) it decides which ids the emitted documents may NAME
1330
+ // in their own `relationships.*.data`. As MEMBERSHIP (this issue) it
1331
+ // decides whether the related record is served here AT ALL -- the
1332
+ // related resource is PRIMARY data on this route, so there is no
1333
+ // linkage-consistency question to answer separately.
1334
+ //
1335
+ // Until #232 this route filtered only the PARENT, so a record its own
1336
+ // model's predicate hides was served in full from another model's
1337
+ // route, at ZERO query parameters. Measured on dev @ 8dda5d6:
1338
+ //
1339
+ // GET /owners/angela -> 404
1340
+ // GET /animals/1/owner -> 200, owner:angela, full attributes
1341
+ // GET /traits/2/tag -> 200, a model NO access class
1342
+ // claims, on a collection that has
1343
+ // no mounted route at all
1344
+ //
1345
+ // ARGUMENT ONE IS THE LIVE REQUEST, NOT A DERIVED ONE. A fabricated
1346
+ // request addressing the RELATED resource was the original design and
1347
+ // it is dropped: #241 removed the shipped fixture's read of argument
1348
+ // one, so a fabricated value changes nothing it could observe.
1349
+ // `createLinkageFilter` is also a published public export
1350
+ // (src/index.ts) whose resolution granularity is per TYPE; supplying a
1351
+ // per-RECORD request would mean widening it, which takes a consumer
1352
+ // `access()` from ~2 calls to ~7 on a plain `GET /animals`. That is a
1353
+ // separate, consumer-visible story.
1354
+ //
1355
+ // GUARDED BY OWN-PROPERTY IDENTITY, NOT BY THE #234 AC13 PIN. That pin
1356
+ // (test/unit/linkage-verdict-test.ts, `strictEqual(seen[0].request,
1357
+ // READ_REQUEST)`) calls `createLinkageFilter` DIRECTLY, so it pins the
1358
+ // function's pass-through and constrains no call site -- an earlier
1359
+ // revision of this comment cited it for this decision and was wrong.
1360
+ // `Object.create(request)` here measured 1015 / 0 with nothing red.
1361
+ // test/integration/orm-test.ts, `#232 AC9`, now asserts that the object
1362
+ // the predicate is handed OWNS `params` (`Object.hasOwn`) and has
1363
+ // nothing request-shaped behind it on the prototype chain. A derived
1364
+ // request inherits `params` -- so it satisfies every value assertion
1365
+ // there -- and reds on those two. Measured: with the derived request in
1366
+ // place, 1014 / 1, and that one is this guard.
1367
+ //
1368
+ // THE RESIDUAL THAT FOLLOWS FROM THAT IS DISCLOSED, NOT PAPERED OVER.
1369
+ // `recordId` is `null` here and the request names a record of a
1370
+ // DIFFERENT model, so a consumer predicate can express a model-level or
1371
+ // a request-level deny for a related resource, but NOT a per-record
1372
+ // one. README.md and docs/usage-patterns.md say so; a ledger assertion
1373
+ // in test/unit/relationship-route-access-test.ts keeps them saying it.
1322
1374
  const linkage = createLinkageFilter(request);
1375
+ // FAIL CLOSED ON A RECORD WHOSE TYPE CANNOT BE NAMED. `isLinkable` is
1376
+ // keyed on the model name; without one there is no predicate to ask,
1377
+ // and an unidentifiable input must never be the permissive path.
1378
+ const isLinkable = (r) => {
1379
+ const type = r.__model?.__name;
1380
+ return typeof type === 'string' && type !== '' && linkage(type, r);
1381
+ };
1323
1382
  let data;
1324
1383
  if (info.isArray) {
1325
- // hasMany - return array
1384
+ // hasMany - return array, MINUS the members this caller may not see.
1385
+ // Dropped, never errored: the result is byte-identical to a genuinely
1386
+ // empty relationship, so this route is not an existence oracle.
1326
1387
  const related = Array.isArray(relatedData) ? relatedData.filter(isOrmRecord) : [];
1327
- data = related.map(r => r.toJSON?.({ baseUrl, linkage }));
1388
+ data = related.filter(isLinkable).map(r => r.toJSON?.({ baseUrl, linkage }));
1328
1389
  }
1329
1390
  else {
1330
- // belongsTo - return single or null
1331
- data = isOrmRecord(relatedData) ? relatedData.toJSON?.({ baseUrl, linkage }) : null;
1391
+ // belongsTo - return single or null. A DENIED target is `data: null`,
1392
+ // BYTE-IDENTICAL to a relationship that is genuinely empty, for the
1393
+ // same reason the hasMany branch above drops rather than errors: this
1394
+ // route must not be an existence oracle for the RELATED record.
1395
+ //
1396
+ // THE OTHER SPELLING WAS 404 AND IT WAS MEASURED AS A DISCLOSURE.
1397
+ // Unauthenticated, zero query parameters, one request each, on `tag`
1398
+ // -- the model with no route mounted at all, which is exactly what
1399
+ // #240 AC5 exists to protect:
1400
+ //
1401
+ // GET /traits/1/tag [ABSENT] -> 200 application/json len 68
1402
+ // GET /traits/2/tag [DENIED] -> 404 text/plain len 9
1403
+ //
1404
+ // and `GET /traits/1` and `GET /traits/2` both report
1405
+ // `relationships.tag = {"data":null}` byte-identical modulo the id,
1406
+ // because #234 closed THAT oracle deliberately. A 404 here would let
1407
+ // a caller ask which of those two nulls was a denial. Under
1408
+ // `data: null` the pair closes completely: 200/200, same
1409
+ // content-type, same content-length, bodies identical modulo the
1410
+ // parent id the caller put in the URL. It opens nothing -- `links`
1411
+ // are entirely parent-derived, there is no `meta` and no counts.
1412
+ //
1413
+ // This is also what README.md's module-wide rule already demanded:
1414
+ // every status on a record route must be identical for filtered-out
1415
+ // and does-not-exist. The route now CONFORMS to that rule rather than
1416
+ // carving an exception out of it.
1417
+ if (!isOrmRecord(relatedData))
1418
+ data = null;
1419
+ else if (!isLinkable(relatedData))
1420
+ data = null;
1421
+ else
1422
+ data = relatedData.toJSON?.({ baseUrl, linkage });
1332
1423
  }
1333
1424
  return {
1334
1425
  links: { self: `${baseUrl}/${pluralizedModel}/${request.params.id}/${dasherizedName}` },
@@ -1392,17 +1483,43 @@ export default class OrmRequest extends Request {
1392
1483
  return 404;
1393
1484
  const relatedData = record.__relationships[relationshipName];
1394
1485
  const baseUrl = getBaseUrl(request);
1486
+ // THE ONE READ SURFACE THAT DOES NOT GO THROUGH `toJSON()`. It builds
1487
+ // `{ type, id }` BY HAND, which is why #234's linkage filter never
1488
+ // reached it and why this half belongs to abofs/stonyx-orm#232 rather
1489
+ // than to #234: on this route the linkage IS the primary data of an
1490
+ // opt-in request, so filtering it changes the route's MEMBERSHIP
1491
+ // semantics, not the ids named inside somebody else's document.
1492
+ //
1493
+ // DELIBERATELY NOT STATED AS A COUNT. README.md's Consumer Contracts
1494
+ // section enumerates the surfaces on which the framework resolves a
1495
+ // verdict and hands it to `toJSON()`, and that enumeration GROWS --
1496
+ // abofs/stonyx-orm#235 adds the two write handlers and the `included`
1497
+ // records. This route is not on that list under any count, because it
1498
+ // never calls `toJSON()`: whatever it filters, it filters here. A
1499
+ // number written into this comment would be false the next time that
1500
+ // list changes, and the README already carries the enumeration.
1501
+ //
1502
+ // Same filter, same argument-one decision, same residual as
1503
+ // `/:id/{relationship}` above -- read the block there.
1504
+ const linkage = createLinkageFilter(request);
1505
+ const isLinkable = (r) => {
1506
+ const type = r.__model?.__name;
1507
+ return typeof type === 'string' && type !== '' && linkage(type, r);
1508
+ };
1395
1509
  let data;
1396
1510
  if (info.isArray) {
1397
1511
  // hasMany - return array of linkage objects
1398
1512
  const related = Array.isArray(relatedData) ? relatedData.filter(isOrmRecord) : [];
1399
1513
  data = related
1400
1514
  .filter((r) => Boolean(r.__model))
1515
+ .filter(isLinkable)
1401
1516
  .map(r => ({ type: r.__model.__name, id: r.id }));
1402
1517
  }
1403
1518
  else {
1404
- // belongsTo - return single linkage or null
1405
- if (isOrmRecord(relatedData) && relatedData.__model) {
1519
+ // belongsTo - return single linkage or null. A DENIED target is
1520
+ // `data: null`, indistinguishable from a genuinely empty one -- see
1521
+ // the measured oracle in the `/:id/{relationship}` block above.
1522
+ if (isOrmRecord(relatedData) && relatedData.__model && isLinkable(relatedData)) {
1406
1523
  data = { type: relatedData.__model.__name, id: relatedData.id };
1407
1524
  }
1408
1525
  else {
@@ -340,10 +340,36 @@ export interface AccessContext {
340
340
  * repaired here. A predicate must not read `undefined` here as "collection",
341
341
  * and nothing in this contract makes it safe to read the two keys as one key.
342
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.
343
+ * IT NAMES WHICH RECORD OF THE MODEL BEING ASKED ABOUT, NOT WHICH SURFACE,
344
+ * AND THE ANSWER DEPENDS ON WHICH MODEL IS BEING ASKED ABOUT.
345
+ *
346
+ * For the ask about the ROUTE'S OWN model, all three of `GET /owners/gina`,
347
+ * `GET /owners/gina/pets` and `GET /owners/gina/relationships/pets` carry
348
+ * `recordId: 'gina'` -- `auth()` reads it off `request.params`.
349
+ *
350
+ * FOR THE ASK ABOUT A RELATED MODEL, IT IS `null`, AND THAT IS A LIMIT ON
351
+ * WHAT A PREDICATE CAN EXPRESS (abofs/stonyx-orm#232). The two relationship
352
+ * route families resolve the RELATED model's own predicate -- `animal` on
353
+ * `GET /owners/gina/pets`, `owner` on `GET /animals/4/owner` -- and that ask
354
+ * carries `recordId: null` while `request.params` names a record of a
355
+ * DIFFERENT model. So a predicate answering about a related model gets the
356
+ * model name, the operation and the request, and CANNOT branch on which
357
+ * related record it is being asked about.
358
+ *
359
+ * The rule, so it is not re-derived wrong: `recordId` may name a record only
360
+ * where the route addresses exactly one record OF THE MODEL BEING ASKED
361
+ * ABOUT. A `hasMany` related-resource route returns many records of one type
362
+ * and the verdict is resolved ONCE PER TYPE, before any record is examined --
363
+ * seeding it from a record would let the first one decide for all of them.
364
+ *
365
+ * What still works, and what does not, is pinned as behaviour by `#232 AC10`
366
+ * in test/integration/orm-test.ts and stated for consumers in README.md:
367
+ * model-level denies work, request-level denies work, and the per-record
368
+ * FILTER shape works because `access()` may return a function and that
369
+ * function receives the whole record. Branching on identity BEFORE returning
370
+ * does not.
371
+ *
372
+ * `?include=` is a separate surface and is abofs/stonyx-orm#233 / #235.
347
373
  */
348
374
  recordId: string | number | null;
349
375
  }
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "stonyx-async",
5
5
  "stonyx-module"
6
6
  ],
7
- "version": "0.3.2-beta.159",
7
+ "version": "0.3.2-beta.160",
8
8
  "description": "",
9
9
  "main": "dist/index.js",
10
10
  "type": "module",
@@ -65,9 +65,19 @@
65
65
  * warning below sanctions. This repo's own fixture has such a rule: its
66
66
  * `/archived` deny cannot be expressed from the context alone, and a predicate
67
67
  * migrated to context-only would silently drop it, turning a deny into an
68
- * allow. The related-resource and `?include=` surfaces serve ANOTHER model's
69
- * records under `model: 'owner'`, and the context gives no signal of that
70
- * (abofs/stonyx-orm#196).
68
+ * allow.
69
+ *
70
+ * THE RELATED-RESOURCE HALF OF THAT SENTENCE IS NOW OUT OF DATE AND IS
71
+ * CORRECTED HERE RATHER THAN DELETED. Both relationship route families resolve
72
+ * the RELATED model's own access class and ask it
73
+ * `{ model: <related>, operation: 'read', recordId: null }`
74
+ * (abofs/stonyx-orm#232), so those surfaces no longer serve another model's
75
+ * records under `model: 'owner'` unexamined. What the context still gives no
76
+ * signal of is WHICH related record is being asked about -- `recordId` is
77
+ * `null` there and `request.params` names a record of a different model. See
78
+ * `AccessContext.recordId` in ./types/orm-types.ts for the full statement of
79
+ * that limit. `?include=` is still unfiltered and is abofs/stonyx-orm#233 /
80
+ * #235.
71
81
  *
72
82
  * SUPERSEDED 2026-09-01 BY abofs/stonyx-orm#236/#237, AND KEPT FOR THE
73
83
  * CONSTRAINT IT STATES RATHER THAN AS A DESCRIPTION OF THE CODE. The context
@@ -1433,21 +1443,101 @@ export default class OrmRequest extends Request {
1433
1443
  const relatedData = record.__relationships[relationshipName];
1434
1444
  const baseUrl = getBaseUrl(request);
1435
1445
 
1436
- // LINKAGE ONLY. This filter decides which ids the emitted documents may
1437
- // NAME in their own `relationships.*.data`; it does NOT decide whether
1438
- // the related records themselves are served -- that is the parent-only
1439
- // filtering this route has done since #190, and widening it to the
1440
- // related record is abofs/stonyx-orm#196.
1446
+ // ONE FILTER, TWO JOBS, AND abofs/stonyx-orm#232 IS THE SECOND ONE.
1447
+ //
1448
+ // As LINKAGE (#234) it decides which ids the emitted documents may NAME
1449
+ // in their own `relationships.*.data`. As MEMBERSHIP (this issue) it
1450
+ // decides whether the related record is served here AT ALL -- the
1451
+ // related resource is PRIMARY data on this route, so there is no
1452
+ // linkage-consistency question to answer separately.
1453
+ //
1454
+ // Until #232 this route filtered only the PARENT, so a record its own
1455
+ // model's predicate hides was served in full from another model's
1456
+ // route, at ZERO query parameters. Measured on dev @ 8dda5d6:
1457
+ //
1458
+ // GET /owners/angela -> 404
1459
+ // GET /animals/1/owner -> 200, owner:angela, full attributes
1460
+ // GET /traits/2/tag -> 200, a model NO access class
1461
+ // claims, on a collection that has
1462
+ // no mounted route at all
1463
+ //
1464
+ // ARGUMENT ONE IS THE LIVE REQUEST, NOT A DERIVED ONE. A fabricated
1465
+ // request addressing the RELATED resource was the original design and
1466
+ // it is dropped: #241 removed the shipped fixture's read of argument
1467
+ // one, so a fabricated value changes nothing it could observe.
1468
+ // `createLinkageFilter` is also a published public export
1469
+ // (src/index.ts) whose resolution granularity is per TYPE; supplying a
1470
+ // per-RECORD request would mean widening it, which takes a consumer
1471
+ // `access()` from ~2 calls to ~7 on a plain `GET /animals`. That is a
1472
+ // separate, consumer-visible story.
1473
+ //
1474
+ // GUARDED BY OWN-PROPERTY IDENTITY, NOT BY THE #234 AC13 PIN. That pin
1475
+ // (test/unit/linkage-verdict-test.ts, `strictEqual(seen[0].request,
1476
+ // READ_REQUEST)`) calls `createLinkageFilter` DIRECTLY, so it pins the
1477
+ // function's pass-through and constrains no call site -- an earlier
1478
+ // revision of this comment cited it for this decision and was wrong.
1479
+ // `Object.create(request)` here measured 1015 / 0 with nothing red.
1480
+ // test/integration/orm-test.ts, `#232 AC9`, now asserts that the object
1481
+ // the predicate is handed OWNS `params` (`Object.hasOwn`) and has
1482
+ // nothing request-shaped behind it on the prototype chain. A derived
1483
+ // request inherits `params` -- so it satisfies every value assertion
1484
+ // there -- and reds on those two. Measured: with the derived request in
1485
+ // place, 1014 / 1, and that one is this guard.
1486
+ //
1487
+ // THE RESIDUAL THAT FOLLOWS FROM THAT IS DISCLOSED, NOT PAPERED OVER.
1488
+ // `recordId` is `null` here and the request names a record of a
1489
+ // DIFFERENT model, so a consumer predicate can express a model-level or
1490
+ // a request-level deny for a related resource, but NOT a per-record
1491
+ // one. README.md and docs/usage-patterns.md say so; a ledger assertion
1492
+ // in test/unit/relationship-route-access-test.ts keeps them saying it.
1441
1493
  const linkage = createLinkageFilter(request);
1442
1494
 
1495
+ // FAIL CLOSED ON A RECORD WHOSE TYPE CANNOT BE NAMED. `isLinkable` is
1496
+ // keyed on the model name; without one there is no predicate to ask,
1497
+ // and an unidentifiable input must never be the permissive path.
1498
+ const isLinkable = (r: OrmRecord) => {
1499
+ const type = (r as { __model?: { __name?: string } }).__model?.__name;
1500
+
1501
+ return typeof type === 'string' && type !== '' && linkage(type, r);
1502
+ };
1503
+
1443
1504
  let data: unknown;
1444
1505
  if (info.isArray) {
1445
- // hasMany - return array
1506
+ // hasMany - return array, MINUS the members this caller may not see.
1507
+ // Dropped, never errored: the result is byte-identical to a genuinely
1508
+ // empty relationship, so this route is not an existence oracle.
1446
1509
  const related = Array.isArray(relatedData) ? relatedData.filter(isOrmRecord) : [];
1447
- data = related.map(r => r.toJSON?.({ baseUrl, linkage }));
1510
+ data = related.filter(isLinkable).map(r => r.toJSON?.({ baseUrl, linkage }));
1448
1511
  } else {
1449
- // belongsTo - return single or null
1450
- data = isOrmRecord(relatedData) ? relatedData.toJSON?.({ baseUrl, linkage }) : null;
1512
+ // belongsTo - return single or null. A DENIED target is `data: null`,
1513
+ // BYTE-IDENTICAL to a relationship that is genuinely empty, for the
1514
+ // same reason the hasMany branch above drops rather than errors: this
1515
+ // route must not be an existence oracle for the RELATED record.
1516
+ //
1517
+ // THE OTHER SPELLING WAS 404 AND IT WAS MEASURED AS A DISCLOSURE.
1518
+ // Unauthenticated, zero query parameters, one request each, on `tag`
1519
+ // -- the model with no route mounted at all, which is exactly what
1520
+ // #240 AC5 exists to protect:
1521
+ //
1522
+ // GET /traits/1/tag [ABSENT] -> 200 application/json len 68
1523
+ // GET /traits/2/tag [DENIED] -> 404 text/plain len 9
1524
+ //
1525
+ // and `GET /traits/1` and `GET /traits/2` both report
1526
+ // `relationships.tag = {"data":null}` byte-identical modulo the id,
1527
+ // because #234 closed THAT oracle deliberately. A 404 here would let
1528
+ // a caller ask which of those two nulls was a denial. Under
1529
+ // `data: null` the pair closes completely: 200/200, same
1530
+ // content-type, same content-length, bodies identical modulo the
1531
+ // parent id the caller put in the URL. It opens nothing -- `links`
1532
+ // are entirely parent-derived, there is no `meta` and no counts.
1533
+ //
1534
+ // This is also what README.md's module-wide rule already demanded:
1535
+ // every status on a record route must be identical for filtered-out
1536
+ // and does-not-exist. The route now CONFORMS to that rule rather than
1537
+ // carving an exception out of it.
1538
+ if (!isOrmRecord(relatedData)) data = null;
1539
+ else if (!isLinkable(relatedData)) data = null;
1540
+ else data = relatedData.toJSON?.({ baseUrl, linkage });
1451
1541
  }
1452
1542
 
1453
1543
  return {
@@ -1513,16 +1603,44 @@ export default class OrmRequest extends Request {
1513
1603
  const relatedData = record.__relationships[relationshipName];
1514
1604
  const baseUrl = getBaseUrl(request);
1515
1605
 
1606
+ // THE ONE READ SURFACE THAT DOES NOT GO THROUGH `toJSON()`. It builds
1607
+ // `{ type, id }` BY HAND, which is why #234's linkage filter never
1608
+ // reached it and why this half belongs to abofs/stonyx-orm#232 rather
1609
+ // than to #234: on this route the linkage IS the primary data of an
1610
+ // opt-in request, so filtering it changes the route's MEMBERSHIP
1611
+ // semantics, not the ids named inside somebody else's document.
1612
+ //
1613
+ // DELIBERATELY NOT STATED AS A COUNT. README.md's Consumer Contracts
1614
+ // section enumerates the surfaces on which the framework resolves a
1615
+ // verdict and hands it to `toJSON()`, and that enumeration GROWS --
1616
+ // abofs/stonyx-orm#235 adds the two write handlers and the `included`
1617
+ // records. This route is not on that list under any count, because it
1618
+ // never calls `toJSON()`: whatever it filters, it filters here. A
1619
+ // number written into this comment would be false the next time that
1620
+ // list changes, and the README already carries the enumeration.
1621
+ //
1622
+ // Same filter, same argument-one decision, same residual as
1623
+ // `/:id/{relationship}` above -- read the block there.
1624
+ const linkage = createLinkageFilter(request);
1625
+ const isLinkable = (r: OrmRecord) => {
1626
+ const type = (r as { __model?: { __name?: string } }).__model?.__name;
1627
+
1628
+ return typeof type === 'string' && type !== '' && linkage(type, r);
1629
+ };
1630
+
1516
1631
  let data: unknown;
1517
1632
  if (info.isArray) {
1518
1633
  // hasMany - return array of linkage objects
1519
1634
  const related = Array.isArray(relatedData) ? relatedData.filter(isOrmRecord) : [];
1520
1635
  data = related
1521
1636
  .filter((r): r is OrmRecord & { __model: { __name: string } } => Boolean(r.__model))
1637
+ .filter(isLinkable)
1522
1638
  .map(r => ({ type: r.__model.__name, id: r.id }));
1523
1639
  } else {
1524
- // belongsTo - return single linkage or null
1525
- if (isOrmRecord(relatedData) && relatedData.__model) {
1640
+ // belongsTo - return single linkage or null. A DENIED target is
1641
+ // `data: null`, indistinguishable from a genuinely empty one -- see
1642
+ // the measured oracle in the `/:id/{relationship}` block above.
1643
+ if (isOrmRecord(relatedData) && relatedData.__model && isLinkable(relatedData)) {
1526
1644
  data = { type: relatedData.__model.__name, id: relatedData.id };
1527
1645
  } else {
1528
1646
  data = null;
@@ -350,10 +350,36 @@ export interface AccessContext {
350
350
  * repaired here. A predicate must not read `undefined` here as "collection",
351
351
  * and nothing in this contract makes it safe to read the two keys as one key.
352
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.
353
+ * IT NAMES WHICH RECORD OF THE MODEL BEING ASKED ABOUT, NOT WHICH SURFACE,
354
+ * AND THE ANSWER DEPENDS ON WHICH MODEL IS BEING ASKED ABOUT.
355
+ *
356
+ * For the ask about the ROUTE'S OWN model, all three of `GET /owners/gina`,
357
+ * `GET /owners/gina/pets` and `GET /owners/gina/relationships/pets` carry
358
+ * `recordId: 'gina'` -- `auth()` reads it off `request.params`.
359
+ *
360
+ * FOR THE ASK ABOUT A RELATED MODEL, IT IS `null`, AND THAT IS A LIMIT ON
361
+ * WHAT A PREDICATE CAN EXPRESS (abofs/stonyx-orm#232). The two relationship
362
+ * route families resolve the RELATED model's own predicate -- `animal` on
363
+ * `GET /owners/gina/pets`, `owner` on `GET /animals/4/owner` -- and that ask
364
+ * carries `recordId: null` while `request.params` names a record of a
365
+ * DIFFERENT model. So a predicate answering about a related model gets the
366
+ * model name, the operation and the request, and CANNOT branch on which
367
+ * related record it is being asked about.
368
+ *
369
+ * The rule, so it is not re-derived wrong: `recordId` may name a record only
370
+ * where the route addresses exactly one record OF THE MODEL BEING ASKED
371
+ * ABOUT. A `hasMany` related-resource route returns many records of one type
372
+ * and the verdict is resolved ONCE PER TYPE, before any record is examined --
373
+ * seeding it from a record would let the first one decide for all of them.
374
+ *
375
+ * What still works, and what does not, is pinned as behaviour by `#232 AC10`
376
+ * in test/integration/orm-test.ts and stated for consumers in README.md:
377
+ * model-level denies work, request-level denies work, and the per-record
378
+ * FILTER shape works because `access()` may return a function and that
379
+ * function receives the whole record. Branching on identity BEFORE returning
380
+ * does not.
381
+ *
382
+ * `?include=` is a separate surface and is abofs/stonyx-orm#233 / #235.
357
383
  */
358
384
  recordId: string | number | null;
359
385
  }