@stonyx/orm 0.3.2-alpha.76 → 0.3.2-alpha.77

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,59 +961,117 @@ 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.
933
- - **Relationship linkage is filtered on every request-bound surface that
934
- serializes a record the reads, the two writes, and `included`.** A
935
- document's `relationships.*.data` used to publish the id of every related
936
- record unconditionally, so a record hidden on every one of its own surfaces
937
- was still named inside another model's document with no `include=`, no
938
- relationship route and no query string
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** — the result
971
+ is shaped exactly like a genuinely empty relationship, `links` intact, no
972
+ `errors` member, same status. A denied `belongsTo` target answers **`200` with
973
+ `data: null`**, byte-identical to a target that is genuinely absent, for the
974
+ same reason. Nothing on either family errors and no status changes — the
975
+ status on these routes belongs to the **parent**, and `data` carries the
976
+ answer about the related record. The `/relationships/` family built its `{type, id}` by
977
+ hand rather than through `toJSON()`, which is why the linkage filter shipped in
978
+ [#234](https://github.com/abofs/stonyx-orm/issues/234) did not reach it.
979
+
980
+ Before this, both families served a record hidden on every one of its own
981
+ surfaces, in full, from another model's route, at **zero query parameters**.
982
+ The severe case is a model **claimed by no access class**: `getAccess()`
983
+ returns `undefined`, no route is mounted for it at all, and it was still
984
+ readable as a related resource — a collection the consumer deliberately never
985
+ exposed.
986
+
987
+ **The `belongsTo` shape is not an existence oracle, and it was measured
988
+ rather than reasoned about.** An earlier revision of this fix answered `404`
989
+ for a denied target, which made it distinguishable from a target that is
990
+ genuinely absent. Unauthenticated, zero query parameters, one request each, on
991
+ `tag` — a model with **no route mounted at all**:
992
+
993
+ ```
994
+ GET /traits/1/tag [target absent] -> 200 application/json 68 bytes
995
+ GET /traits/2/tag [target denied] -> 404 text/plain 9 bytes
996
+ ```
997
+
998
+ `GET /traits/1` and `GET /traits/2` report `relationships.tag = {"data":null}`
999
+ byte-identical modulo the id, because
1000
+ [#234](https://github.com/abofs/stonyx-orm/issues/234) closed that oracle
1001
+ deliberately — so this route was the one remaining way to ask which of those
1002
+ two nulls was a denial. Under `data: null` both requests answer `200`, same
1003
+ content-type, same content-length, same bytes modulo the parent id the caller
1004
+ put in the URL. It discloses nothing further: `links` on these routes are
1005
+ derived entirely from the parent and the relationship name, there is no `meta`
1006
+ and there are no counts. This also brings the two families back into line with
1007
+ the module-wide rule under [Filter functions](#filter-functions) — *every
1008
+ status on a record route must be identical for "filtered out" and "does not
1009
+ exist"* — which the `404` spelling was an exception to.
1010
+
1011
+ **Per-record denies for a related resource are not expressible.** A predicate resolved for a
1012
+ related resource on these routes receives `recordId: null` and a `request`
1013
+ whose `params` name a record of a **different model**. So the inputs it has
1014
+ are the model name, the operation and the request — and **a rule that needs to
1015
+ know *which* related record it is being asked about cannot be written**.
1016
+ Model-level denies (`return false` for a model) work. Request-level denies (a
1017
+ rule reading a header, a tenant, the method) work. The per-record **filter**
1018
+ shape works too — `access()` may return a function, and that function receives
1019
+ the whole record, id included. What does not work is branching on the record's
1020
+ identity *before* returning, because `access()` is not told it.
1021
+
1022
+ This is not an oversight and it is not closed here. The verdict is resolved
1023
+ **once per type**, cached, before any record has been examined — a `hasMany`
1024
+ related-resource route returns many records of one type, so seeding `recordId`
1025
+ from a record would let the first one decide the context for all of them. The
1026
+ rule the framework holds to is: **`recordId` may name a record only where the
1027
+ route addresses exactly one record of the model being asked about.** That is
1028
+ true for `GET /owners/{id}`, false for linkage, and false for a `hasMany`
1029
+ related-resource route.
1030
+
1031
+ - **`?include=` records are still not filtered — the relationship routes now
1032
+ are.** *Re-specified by [#232](https://github.com/abofs/stonyx-orm/issues/232);
1033
+ the sentence this replaces said all three surfaces were unfiltered, and two of
1034
+ them no longer are.* `GET /animals/1/owner` and
1035
+ `GET /animals/1/relationships/owner` resolve the related model's own access
1036
+ class (see the bullet above). **`?include=owner` still does not**: it
1037
+ serializes the related record without resolving that class, so a filter on
1038
+ `/owners` does not hide an owner reached through `?include=` on `/animals`.
1039
+ There are **two** open questions here and they are owned separately —
1040
+ [#233](https://github.com/abofs/stonyx-orm/issues/233), the remaining child of
1041
+ [#196](https://github.com/abofs/stonyx-orm/issues/196), owns whether a
1042
+ resource enters `included` **at all** (membership), and
1043
+ [#235](https://github.com/abofs/stonyx-orm/issues/235) owns the
1044
+ `relationships.*.data` emitted **inside** a record that is already there
1045
+ (linkage). Neither closes the other, and following only #233 will not lead you
1046
+ to the second. Membership — whether the related resource is served at all — is
1047
+ a different question from which ids a document may *name*, immediately
1048
+ below.
1049
+ - **Relationship linkage is filtered on the four request-bound read surfaces,
1050
+ and only there.** A document's `relationships.*.data` used to publish the id
1051
+ of every related record unconditionally, so a record hidden on every one of
1052
+ its own surfaces was still named inside another model's document — with no
1053
+ `include=`, no relationship route and no query string
939
1054
  ([#234](https://github.com/abofs/stonyx-orm/issues/234)). The ORM now resolves
940
- the **related** model's own access class on `GET /:models`, `GET /:models/:id`,
941
- both `GET /:models/:id/{relationship}` shapes, the `POST /:models` and
942
- `PATCH /:models/:id` **response documents**, and every record inside an
943
- `?include=` **`included`** array
944
- ([#235](https://github.com/abofs/stonyx-orm/issues/235)), and asks it
945
- `{ model: <related>, operation: 'read' }`. **`operation` is `'read'` even on a
946
- write route, and that is correct rather than an oversight** — the question
947
- asked of the *related* model is "may this caller **read** this id", not "may
948
- they update it". An access class that grants `['create']` but not `['read']`
949
- on the related model therefore denies that linkage on its own `POST`
950
- response; that is the fail-closed direction. Do **not** wire these handlers to
951
- `methodAccessMap[request.method]`: it would ask a different question on a
952
- write route than on a read route, which is the two-vocabularies failure
953
- `createLinkageFilter` exists to prevent. An unresolvable class
954
- (`getAccess()` → `undefined`) and a predicate that throws both **deny**.
955
-
956
- **What the two write surfaces cost before #235, measured rather than
957
- described:** one HTTP verb defeated the filter on the same record. On
958
- `dev @ 8dda5d6`, seconds apart, with no query string and no relationship
959
- route, `GET /animals/1` returned `owner.data: null` while `PATCH /animals/1`
960
- returned **200 naming angela**. Any caller who could read a record could also
961
- write it and be handed the id the read withheld. That consequence is kept here
962
- after the fix, and stated as a measurement, because **naming the two handlers
963
- is not a substitute for it** — a reader who is told only that `POST` and
964
- `PATCH` are now covered cannot tell what was wrong, and a reviewer cannot tell
965
- whether the fix addressed it. A
1055
+ the **related** model's own access class on `GET /:models`, `GET /:models/:id`
1056
+ and both `GET /:models/:id/{relationship}` shapes, and asks it
1057
+ `{ model: <related>, operation: 'read' }`. An unresolvable class
1058
+ (`getAccess()` `undefined`) and a predicate that throws both **deny**. A
966
1059
  filtered-out relationship is **indistinguishable from a genuinely empty one** —
967
1060
  an emptied `hasMany` is `data: []` and an emptied `belongsTo` is `data: null`,
968
1061
  both **keeping their `links`**, which are built from the serialized record's
969
- own id and never from the related one. On the two **write** surfaces there are
970
- no `links` to keep: neither handler passes a `baseUrl`, so a filtered and a
971
- genuinely-empty relationship are both a bare `{ "data": … }` there. That is
972
- pre-existing and deliberate — adding `baseUrl` to the write handlers would be
973
- an unrelated change to their response shape. Nothing errors and no status changes,
1062
+ own id and never from the related one. Nothing errors and no status changes,
974
1063
  because throwing here would be an existence oracle *and* would throw out of
975
1064
  the enclosing `JSON.stringify`.
976
1065
 
1066
+ **[#232](https://github.com/abofs/stonyx-orm/issues/232) holds to the same
1067
+ spelling on the routes where that linkage is the *primary* data.** A denied
1068
+ member is dropped from the `hasMany` array and a denied `belongsTo` target is
1069
+ `data: null`, at `200`, `links` intact — so the claim above is true of both
1070
+ `GET /:models/:id/{relationship}` shapes as *routes* and not only as linkage
1071
+ emitted inside somebody else's document. An earlier revision of #232 answered
1072
+ `404` on the `belongsTo` shape and did contradict this paragraph; that is
1073
+ measured and closed in the #232 bullet above.
1074
+
977
1075
  **That resolves the right class; it does not guarantee a model-correct
978
1076
  answer, and the failure direction is not the safe one.** Only a predicate that
979
1077
  *reads* `context.model` can answer about the model it was asked about — see
@@ -994,45 +1092,56 @@ per-record filter. An input you cannot identify must **deny**.
994
1092
  *permitted* related record, which is recorded in the release notes as a
995
1093
  breaking change.
996
1094
 
997
- **Not yet covered by #235. Each still publishes ids the surfaces above
998
- withhold, except where its own owning issue has since closed it the first
999
- entry names an issue that is in flight as this is written:**
1000
-
1001
- - **`GET /:models/:id/relationships/{relationship}`, and its state is #232's
1002
- to report rather than this entry's.**
1003
- [#232](https://github.com/abofs/stonyx-orm/issues/232) owns the
1004
- relationships-linkage route. Its *primary data* is linkage,
1005
- so filtering it is a **membership** decision which is why it is the filed
1006
- child of [#196](https://github.com/abofs/stonyx-orm/issues/196) and not of
1007
- #234. The route builds its `{type, id}` objects by hand and never calls
1008
- `toJSON`, so the `linkage` **option** never reaches it; whatever that route
1009
- filters, it filters itself. Measured **on `dev @ 8dda5d6`**, the commit
1010
- #235 branched from: `GET /animals/1/relationships/owner` answered
1011
- `{"type":"owner","id":"angela"}` while `GET /owners/angela` was `404`. That
1012
- measurement is pinned to a commit on purpose, so that it does not quietly
1013
- become a false claim about `dev`. **PR
1014
- [#247](https://github.com/abofs/stonyx-orm/pull/247) is in flight against
1015
- this entry**; if it has landed, this route is covered and the bullet #247
1016
- adds above supersedes this one.
1017
- - **Whether a related resource appears in `included` at all.**
1095
+ **Not yet covered.** The first two still publish ids the surfaces above
1096
+ withhold; the third is struck through because it **is** covered now, and is
1097
+ kept for the reason given under it:
1098
+
1099
+ - **`included`** — [#235](https://github.com/abofs/stonyx-orm/issues/235). A
1100
+ permitted record sideloaded by `?include=` emits its **own**
1101
+ `relationships.*.data` unfiltered, so `GET /animals/1?include=owner,owner.pets`
1102
+ returns `owner.data: null` on the primary document and then names angela in
1103
+ `included`, along with eight permitted animals that each name
1104
+ `{"type":"owner","id":"angela"}`. Separately,
1018
1105
  [#233](https://github.com/abofs/stonyx-orm/issues/233) owns whether a
1019
- related resource appears in `included`. #235 filters what a record
1020
- *already in* `included` may **name**; a hidden record is still a
1021
- **member** of that array. The two are different questions and neither closes
1022
- the other: after #235, `GET /animals/1?include=owner,owner.pets` returns
1023
- `owner.data: null` on every permitted animal it sideloads **and still
1024
- includes the hidden owner as a resource**.
1025
- - **A computed attribute that interpolates a related record's id.** This is a
1026
- **consumer-side** residual and the ORM cannot close it. `relationships.*.data`
1027
- is a structure this module builds, so it can be filtered; a computed
1028
- property is arbitrary consumer code returning an arbitrary value, and
1029
- deciding which substrings of it are identifiers is not something the
1030
- framework can do. Measured on this repo's own fixture, where the `animal`
1031
- model has a `get tag()` that interpolates `owner.id`: **every** animal
1032
- document on **every** surface including the ones above — carries
1033
- `attributes.tag: "angela's small dog"` for an owner that answers `404`. If
1034
- your access rules hide a record, audit your computed properties for its
1035
- identifiers.
1106
+ resource appears in `included` **at all** that is membership, a different
1107
+ question, and following it will not lead you to this residual.
1108
+ - **The `POST`/`PATCH` response documents**
1109
+ [#235](https://github.com/abofs/stonyx-orm/issues/235). `createHandler` and
1110
+ `updateHandler` destructure the request rather than binding it, so wiring
1111
+ them needs a signature change rather than an argument. Until then **one HTTP
1112
+ verb defeats the filter on the same record**: measured, `GET /animals/1`
1113
+ returns `owner.data: null` and `PATCH /animals/1` returns **200 naming
1114
+ angela**, seconds apart, with no query string and no relationship route. Any
1115
+ caller who can read a record can also write it and be handed the id the read
1116
+ withheld.
1117
+ - **A computed property can republish a hidden related record's id into
1118
+ `attributes`** [#245](https://github.com/abofs/stonyx-orm/issues/245).
1119
+ `Record.toJSON()` runs consumer computed properties and writes their return
1120
+ values straight into `attributes` with no verdict applied. A computed
1121
+ property is ordinary consumer code with `this` bound to the record, so it
1122
+ can read any resolved relationship and emit the id this list's surfaces
1123
+ withheld — through a channel that filters `relationships.*.data` and never
1124
+ sees it. The shipped sample's `animal.tag` getter does exactly that. **This
1125
+ is reachable on `dev` once this pull request merges**, because
1126
+ [#240](https://github.com/abofs/stonyx-orm/issues/240) fixture 1 puts a
1127
+ hidden child (animal 18) under a **permitted** parent for the first time.
1128
+ - **The absence of `attributes.<fk>` on a `POST` response proves a hidden
1129
+ record exists** — [#246](https://github.com/abofs/stonyx-orm/issues/246).
1130
+ `createHandler` copies each supplied relationship's raw id into
1131
+ `attributes`; when the related record resolves, the value is consumed and
1132
+ does not appear, and when it does not resolve, it survives. The oracle runs
1133
+ in the negative space, so nothing this list's surfaces withhold is
1134
+ *published* — the **absence** is the signal. Pre-existing, and it does not
1135
+ compose with the two relationship families above: they emit no `attributes`
1136
+ for a related record at all.
1137
+ - ~~**`GET /:models/:id/relationships/{relationship}`**~~ — **covered as of
1138
+ [#232](https://github.com/abofs/stonyx-orm/issues/232)**, together with
1139
+ `GET /:models/:id/{relationship}`. Left in place rather than deleted because
1140
+ this list is what a reader consults to find out what is *not* covered, and a
1141
+ silently vanishing entry reads as an entry that was never there. Its
1142
+ *primary data* is linkage, so filtering it was a **membership** decision —
1143
+ which is why it belonged to #232 and not to
1144
+ [#234](https://github.com/abofs/stonyx-orm/issues/234).
1036
1145
  - **A bare `toJSON()` still emits unfiltered linkage, and that is deliberate.**
1037
1146
  `Record.toJSON()` **applies** a verdict; it never **resolves** one. It has no
1038
1147
  request, and the documented `access()` contract permits a predicate to read
@@ -1126,29 +1235,11 @@ read all of these.
1126
1235
 
1127
1236
  #### `Record.toJSON()` does not filter relationship linkage unless you pass a verdict
1128
1237
 
1129
- **The framework resolves a verdict for you on every request-bound surface that
1130
- serializes a record through `toJSON()`. You own it everywhere else.**
1131
-
1132
- Those surfaces are `GET /:models`, `GET /:models/:id`, both shapes of
1133
- `GET /:models/:id/{relationship}`, the `POST /:models` and `PATCH /:models/:id`
1134
- **response documents**, and every record inside an `?include=` **`included`**
1135
- array ([#234](https://github.com/abofs/stonyx-orm/issues/234) for the four
1136
- reads, [#235](https://github.com/abofs/stonyx-orm/issues/235) for the two
1137
- writes and `included`). Each resolves a linkage verdict and passes it to
1138
- `toJSON()` for you.
1139
-
1140
- **`GET /:models/:id/relationships/{relationship}` is not on that list, and its
1141
- state is not this section's to report.** It builds its `{ type, id }` objects by
1142
- hand instead of calling `toJSON()`, so the `linkage` **option** never reaches it
1143
- — whatever that route filters, it filters itself. And because its linkage *is*
1144
- its primary data, filtering it is a **membership** decision rather than a
1145
- linkage one. Membership on both relationship route families is owned by
1146
- [#232](https://github.com/abofs/stonyx-orm/issues/232) (PR
1147
- [#247](https://github.com/abofs/stonyx-orm/pull/247), in flight as this is
1148
- written); read that issue for its state rather than inferring it here, because
1149
- this section describes only what `toJSON()` filters.
1150
-
1151
- Any other path to a document — `JSON.stringify(record)`, `res.json(record)`,
1238
+ **The framework owns this on four surfaces. You own it everywhere else.**
1239
+
1240
+ `GET /:models`, `GET /:models/:id` and both `GET /:models/:id/{relationship}`
1241
+ shapes resolve a linkage verdict and pass it to `toJSON()` for you. Any other
1242
+ path to a document `JSON.stringify(record)`, `res.json(record)`,
1152
1243
  `console.log(record)`, a custom route, a queue payload, a websocket frame —
1153
1244
  calls `toJSON()` with no verdict, and **the no-verdict document names every
1154
1245
  related id, including records hidden on every one of their own surfaces**
@@ -1161,6 +1252,31 @@ default, the default is the unfiltered document, and a filtered relationship is
1161
1252
  byte-identical to a genuinely empty one — so nothing on the wire distinguishes
1162
1253
  "filtered" from "forgotten".
1163
1254
 
1255
+ **And `linkage` cannot reach a document you build by hand.** It is an *option to
1256
+ `toJSON()`*, so it filters only what goes through `toJSON()`. The ORM's own
1257
+ `GET /:models/:id/relationships/{relationship}` route is the worked example: its
1258
+ primary data *is* linkage, it assembles `{ type, id }` directly rather than
1259
+ serializing a record, and it therefore resolves and applies the verdict itself
1260
+ ([#232](https://github.com/abofs/stonyx-orm/issues/232)). If you assemble
1261
+ linkage the same way anywhere — a custom relationship route, a projection, a
1262
+ hand-built document — **passing `linkage` to `toJSON()` does nothing for it and
1263
+ nothing warns**. Build the filter and consult it before you emit an id:
1264
+
1265
+ ```js
1266
+ const linkage = createLinkageFilter(request);
1267
+
1268
+ if (related && linkage(related.__model.__name, related)) {
1269
+ data = { type: related.__model.__name, id: related.id };
1270
+ } else {
1271
+ data = null; // withheld and genuinely-empty must be the SAME answer
1272
+ }
1273
+ ```
1274
+
1275
+ The `else` branch is the part that is easy to get wrong. Answering `404`, `403`
1276
+ or an `errors` member for the withheld case makes the route an **existence
1277
+ oracle** — see [Filter functions](#filter-functions) for the rule and for the
1278
+ measurement that closed it on this route.
1279
+
1164
1280
  Do this:
1165
1281
 
1166
1282
  ```js
@@ -1448,15 +1564,13 @@ GET /animals/1
1448
1564
  #### Limitations
1449
1565
 
1450
1566
  - Only available on GET endpoints (not POST/PATCH)
1451
- - **`included` is access-filtered on one of the two questions, not both.** What
1452
- a record already in `included` may **name** in its own
1453
- `relationships.*.data` is filtered
1454
- ([#235](https://github.com/abofs/stonyx-orm/issues/235)) `?include=` no
1455
- longer republishes ids the primary document withholds. Whether a resource
1456
- appears in `included` **at all** is *membership* and is still unfiltered
1457
- ([#233](https://github.com/abofs/stonyx-orm/issues/233)): a record that is
1458
- 404 on its own routes is still served as an `included` resource, attributes
1459
- and all. See [Consumer Contracts](#consumer-contracts).
1567
+ - **`included` records are not access-filtered, on either question.** Whether a
1568
+ resource appears in `included` at all is
1569
+ [#233](https://github.com/abofs/stonyx-orm/issues/233); a record that *is*
1570
+ permitted still emits its **own** `relationships.*.data` unfiltered, so
1571
+ `?include=` republishes ids the primary document withholds
1572
+ ([#235](https://github.com/abofs/stonyx-orm/issues/235)). See
1573
+ [Consumer Contracts](#consumer-contracts).
1460
1574
 
1461
1575
  ## Lifecycle Hooks
1462
1576
 
@@ -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