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

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,22 +439,11 @@ 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. It is enforced
443
- // on every surface addressed to one of these records
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:
444
444
  // /owners, /owners/:id, /owners/:id/pets, /owners/:id/relationships/pets
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.
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.
458
447
  return record => record.id !== 'angela' && record.id !== 'restricted';
459
448
  }
460
449
 
@@ -463,11 +452,7 @@ export default class GlobalAccess {
463
452
  // inert. Deliberately NO `?? record.owner` fallback: accepting the raw
464
453
  // shape as well as the resolved one would absorb a resolution regression
465
454
  // silently, which is exactly what blinded this fixture before.
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;
455
+ if (model === 'animal') return record => record.owner?.id !== 'restricted';
471
456
 
472
457
  // Allows full access to all calls that don't match any of the above conditions
473
458
  return ['read', 'create', 'update', 'delete'];
@@ -680,11 +665,9 @@ A `throw` inside `access()` is a **denial**, not a 500.
680
665
  A function return value is a **per-record predicate**, and it is enforced on
681
666
  every endpoint that is addressed to a record — not only on the collection.
682
667
 
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
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
688
671
  [Known limitations](#known-limitations) and
689
672
  [#207](https://github.com/abofs/stonyx-orm/issues/207).
690
673
 
@@ -692,42 +675,19 @@ modified: a write to a *different* collection can still re-parent one. See
692
675
  |---|---|
693
676
  | `GET /:models` | omitted from the collection |
694
677
  | `GET /:models/:id` | `404` |
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 |
678
+ | `GET /:models/:id/{relationship}` | `404` the **addressed** record is filtered, not the related one |
679
+ | `GET /:models/:id/relationships/{relationship}` | `404` same |
697
680
  | `PATCH /:models/:id` | `404`, no attribute is applied |
698
681
  | `DELETE /:models/:id` | `404`, the record is not removed and no SQL `DELETE` is issued |
699
682
  | `POST /:models` | `403`, and a record **this request inserted** is rolled back — see [Known limitations](#known-limitations) for why the rollback is conditional |
700
683
 
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
-
720
684
  **Denied record-level requests return 404, not 403.** This is deliberate and it
721
685
  is the property most easily "improved" away. 403 would confirm that the record
722
686
  exists to a caller who is not allowed to know that, which turns the filter into
723
687
  an existence oracle: `404` means "no such record", `403` means "there is one and
724
688
  it is not yours". Every status on a record route must therefore be identical for
725
689
  "filtered out" and "does not exist" — including `DELETE`, which is why deleting
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".
690
+ a record that never existed also returns 404 rather than 204.
731
691
 
732
692
  `POST` is the one exception and returns **403**, because 404 on a mounted
733
693
  collection route is indistinguishable from "model not mounted" — a genuinely
@@ -961,117 +921,59 @@ per-record filter. An input you cannot identify must **deny**.
961
921
  operation and which record the request addresses. The five variants above are
962
922
  the five ways that has been observed to fail open so far. Tracked as
963
923
  [#202](https://github.com/abofs/stonyx-orm/issues/202).
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
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
1054
939
  ([#234](https://github.com/abofs/stonyx-orm/issues/234)). The ORM now resolves
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
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
1059
966
  filtered-out relationship is **indistinguishable from a genuinely empty one** —
1060
967
  an emptied `hasMany` is `data: []` and an emptied `belongsTo` is `data: null`,
1061
968
  both **keeping their `links`**, which are built from the serialized record's
1062
- own id and never from the related one. Nothing errors and no status changes,
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,
1063
974
  because throwing here would be an existence oracle *and* would throw out of
1064
975
  the enclosing `JSON.stringify`.
1065
976
 
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
-
1075
977
  **That resolves the right class; it does not guarantee a model-correct
1076
978
  answer, and the failure direction is not the safe one.** Only a predicate that
1077
979
  *reads* `context.model` can answer about the model it was asked about — see
@@ -1092,56 +994,50 @@ per-record filter. An input you cannot identify must **deny**.
1092
994
  *permitted* related record, which is recorded in the release notes as a
1093
995
  breaking change.
1094
996
 
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,
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.**
1105
1018
  [#233](https://github.com/abofs/stonyx-orm/issues/233) owns whether a
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).
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.**
1026
+ [#245](https://github.com/abofs/stonyx-orm/issues/245) owns this channel,
1027
+ and **it is open as this is written**. `relationships.*.data` is a structure
1028
+ this module builds, so it can be filtered; a computed property is arbitrary
1029
+ consumer code returning an arbitrary value. Whether that makes the channel a
1030
+ **framework defect** the ORM should close by handing computed getters a
1031
+ verdict, or by refusing to run them while a filter is in force or a
1032
+ **consumer contract** the ORM should only document, is the question #245
1033
+ must decide. **This README does not decide it; neither reading should be
1034
+ read out of the text here.** Measured on this repo's own fixture, where the
1035
+ `animal` model has a `get tag()` that interpolates `owner.id`: **every**
1036
+ animal document on **every** surface including the ones above — carries
1037
+ `attributes.tag: "angela's small dog"` for an owner that answers `404`. That
1038
+ measurement is where #245 starts, and it holds whichever way the decision
1039
+ lands. Until it lands, if your access rules hide a record, audit your
1040
+ computed properties for its identifiers.
1145
1041
  - **A bare `toJSON()` still emits unfiltered linkage, and that is deliberate.**
1146
1042
  `Record.toJSON()` **applies** a verdict; it never **resolves** one. It has no
1147
1043
  request, and the documented `access()` contract permits a predicate to read
@@ -1235,11 +1131,29 @@ read all of these.
1235
1131
 
1236
1132
  #### `Record.toJSON()` does not filter relationship linkage unless you pass a verdict
1237
1133
 
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)`,
1134
+ **The framework resolves a verdict for you on every request-bound surface that
1135
+ serializes a record through `toJSON()`. You own it everywhere else.**
1136
+
1137
+ Those surfaces are `GET /:models`, `GET /:models/:id`, both shapes of
1138
+ `GET /:models/:id/{relationship}`, the `POST /:models` and `PATCH /:models/:id`
1139
+ **response documents**, and every record inside an `?include=` **`included`**
1140
+ array ([#234](https://github.com/abofs/stonyx-orm/issues/234) for the four
1141
+ reads, [#235](https://github.com/abofs/stonyx-orm/issues/235) for the two
1142
+ writes and `included`). Each resolves a linkage verdict and passes it to
1143
+ `toJSON()` for you.
1144
+
1145
+ **`GET /:models/:id/relationships/{relationship}` is not on that list, and its
1146
+ state is not this section's to report.** It builds its `{ type, id }` objects by
1147
+ hand instead of calling `toJSON()`, so the `linkage` **option** never reaches it
1148
+ — whatever that route filters, it filters itself. And because its linkage *is*
1149
+ its primary data, filtering it is a **membership** decision rather than a
1150
+ linkage one. Membership on both relationship route families is owned by
1151
+ [#232](https://github.com/abofs/stonyx-orm/issues/232) (PR
1152
+ [#247](https://github.com/abofs/stonyx-orm/pull/247), in flight as this is
1153
+ written); read that issue for its state rather than inferring it here, because
1154
+ this section describes only what `toJSON()` filters.
1155
+
1156
+ Any other path to a document — `JSON.stringify(record)`, `res.json(record)`,
1243
1157
  `console.log(record)`, a custom route, a queue payload, a websocket frame —
1244
1158
  calls `toJSON()` with no verdict, and **the no-verdict document names every
1245
1159
  related id, including records hidden on every one of their own surfaces**
@@ -1252,31 +1166,6 @@ default, the default is the unfiltered document, and a filtered relationship is
1252
1166
  byte-identical to a genuinely empty one — so nothing on the wire distinguishes
1253
1167
  "filtered" from "forgotten".
1254
1168
 
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
-
1280
1169
  Do this:
1281
1170
 
1282
1171
  ```js
@@ -1564,13 +1453,15 @@ GET /animals/1
1564
1453
  #### Limitations
1565
1454
 
1566
1455
  - Only available on GET endpoints (not POST/PATCH)
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).
1456
+ - **`included` is access-filtered on one of the two questions, not both.** What
1457
+ a record already in `included` may **name** in its own
1458
+ `relationships.*.data` is filtered
1459
+ ([#235](https://github.com/abofs/stonyx-orm/issues/235)) `?include=` no
1460
+ longer republishes ids the primary document withholds. Whether a resource
1461
+ appears in `included` **at all** is *membership* and is still unfiltered
1462
+ ([#233](https://github.com/abofs/stonyx-orm/issues/233)): a record that is
1463
+ 404 on its own routes is still served as an `included` resource, attributes
1464
+ and all. See [Consumer Contracts](#consumer-contracts).
1574
1465
 
1575
1466
  ## Lifecycle Hooks
1576
1467
 
@@ -65,19 +65,9 @@
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.
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.
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).
81
71
  *
82
72
  * SUPERSEDED 2026-09-01 BY abofs/stonyx-orm#236/#237, AND KEPT FOR THE
83
73
  * CONSTRAINT IT STATES RATHER THAN AS A DESCRIPTION OF THE CODE. The context