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

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
@@ -452,11 +452,7 @@ export default class GlobalAccess {
452
452
  // inert. Deliberately NO `?? record.owner` fallback: accepting the raw
453
453
  // shape as well as the resolved one would absorb a resolution regression
454
454
  // silently, which is exactly what blinded this fixture before.
455
- // `record.id !== 18` hides one animal whose OWNER is permitted. It is the
456
- // fixture that makes the `hasMany` half of the relationship-route rules
457
- // observable: gina is served, animal 18 is not, and every surface that
458
- // names gina's pets has to drop it.
459
- if (model === 'animal') return record => record.owner?.id !== 'restricted' && record.id !== 18;
455
+ if (model === 'animal') return record => record.owner?.id !== 'restricted';
460
456
 
461
457
  // Allows full access to all calls that don't match any of the above conditions
462
458
  return ['read', 'create', 'update', 'delete'];
@@ -925,81 +921,56 @@ per-record filter. An input you cannot identify must **deny**.
925
921
  operation and which record the request addresses. The five variants above are
926
922
  the five ways that has been observed to fail open so far. Tracked as
927
923
  [#202](https://github.com/abofs/stonyx-orm/issues/202).
928
- - **The two relationship route families now resolve the *related* model's own
929
- access class `GET /:models/:id/{relationship}` and
930
- `GET /:models/:id/relationships/{relationship}`**
931
- ([#232](https://github.com/abofs/stonyx-orm/issues/232)). This is
932
- **membership**: the related resource is the route's *primary* data, so the
933
- filter decides whether it is served at all, not merely which ids a document
934
- may name. A denied `hasMany` member is **dropped from the array** — the result
935
- is shaped exactly like a genuinely empty relationship, `links` intact, no
936
- `errors` member, same status. A denied `belongsTo` target answers **404**, the
937
- same status the route already answered for a denied parent and for a parent
938
- that does not exist. The `/relationships/` family built its `{type, id}` by
939
- hand rather than through `toJSON()`, which is why the linkage filter shipped in
940
- [#234](https://github.com/abofs/stonyx-orm/issues/234) did not reach it.
941
-
942
- Before this, both families served a record hidden on every one of its own
943
- surfaces, in full, from another model's route, at **zero query parameters**.
944
- The severe case is a model **claimed by no access class**: `getAccess()`
945
- returns `undefined`, no route is mounted for it at all, and it was still
946
- readable as a related resource — a collection the consumer deliberately never
947
- exposed.
948
-
949
- **Residual on the `belongsTo` shape, stated rather than left to be found:** a
950
- denied target answers 404 while a genuinely *absent* one answers 200 with
951
- `data: null`, so those two cases are distinguishable. That asymmetry is
952
- inherited — a denied *parent* has always answered 404 while an existing parent
953
- with an empty relationship answers 200 — and changing it is a change to this
954
- module's whole spelling of denial, not to these two routes.
955
-
956
- **Per-record denies for a related resource are not expressible.** A predicate resolved for a
957
- related resource on these routes receives `recordId: null` and a `request`
958
- whose `params` name a record of a **different model**. So the inputs it has
959
- are the model name, the operation and the request — and **a rule that needs to
960
- know *which* related record it is being asked about cannot be written**.
961
- Model-level denies (`return false` for a model) work. Request-level denies (a
962
- rule reading a header, a tenant, the method) work. The per-record **filter**
963
- shape works too — `access()` may return a function, and that function receives
964
- the whole record, id included. What does not work is branching on the record's
965
- identity *before* returning, because `access()` is not told it.
966
-
967
- This is not an oversight and it is not closed here. The verdict is resolved
968
- **once per type**, cached, before any record has been examined — a `hasMany`
969
- related-resource route returns many records of one type, so seeding `recordId`
970
- from a record would let the first one decide the context for all of them. The
971
- rule the framework holds to is: **`recordId` may name a record only where the
972
- route addresses exactly one record of the model being asked about.** That is
973
- true for `GET /owners/{id}`, false for linkage, and false for a `hasMany`
974
- related-resource route.
975
-
976
- - **`?include=` records are still not filtered — the relationship routes now
977
- are.** *Re-specified by [#232](https://github.com/abofs/stonyx-orm/issues/232);
978
- the sentence this replaces said all three surfaces were unfiltered, and two of
979
- them no longer are.* `GET /animals/1/owner` and
980
- `GET /animals/1/relationships/owner` resolve the related model's own access
981
- class (see the bullet above). **`?include=owner` still does not**: it
982
- serializes the related record without resolving that class, so a filter on
983
- `/owners` does not hide an owner reached through `?include=` on `/animals`.
984
- Tracked as [#233](https://github.com/abofs/stonyx-orm/issues/233), the
985
- remaining child of [#196](https://github.com/abofs/stonyx-orm/issues/196).
986
- This is **membership** — whether the related resource is served at all — and
987
- it is a different question from which ids a document may *name*, immediately
988
- below.
989
- - **Relationship linkage is filtered on the four request-bound read surfaces,
990
- and only there.** A document's `relationships.*.data` used to publish the id
991
- of every related record unconditionally, so a record hidden on every one of
992
- its own surfaces was still named inside another model's document — with no
993
- `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
994
939
  ([#234](https://github.com/abofs/stonyx-orm/issues/234)). The ORM now resolves
995
- the **related** model's own access class on `GET /:models`, `GET /:models/:id`
996
- and both `GET /:models/:id/{relationship}` shapes, and asks it
997
- `{ model: <related>, operation: 'read' }`. An unresolvable class
998
- (`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
999
966
  filtered-out relationship is **indistinguishable from a genuinely empty one** —
1000
967
  an emptied `hasMany` is `data: []` and an emptied `belongsTo` is `data: null`,
1001
968
  both **keeping their `links`**, which are built from the serialized record's
1002
- 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,
1003
974
  because throwing here would be an existence oracle *and* would throw out of
1004
975
  the enclosing `JSON.stringify`.
1005
976
 
@@ -1023,35 +994,45 @@ per-record filter. An input you cannot identify must **deny**.
1023
994
  *permitted* related record, which is recorded in the release notes as a
1024
995
  breaking change.
1025
996
 
1026
- **Not yet covered, and each one still publishes ids the surfaces above
1027
- withhold:**
1028
-
1029
- - **`included`** — [#235](https://github.com/abofs/stonyx-orm/issues/235). A
1030
- permitted record sideloaded by `?include=` emits its **own**
1031
- `relationships.*.data` unfiltered, so `GET /animals/1?include=owner,owner.pets`
1032
- returns `owner.data: null` on the primary document and then names angela in
1033
- `included`, along with eight permitted animals that each name
1034
- `{"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.**
1035
1018
  [#233](https://github.com/abofs/stonyx-orm/issues/233) owns whether a
1036
- resource appears in `included` **at all** that is membership, a different
1037
- question, and following it will not lead you to this residual.
1038
- - **The `POST`/`PATCH` response documents**
1039
- [#235](https://github.com/abofs/stonyx-orm/issues/235). `createHandler` and
1040
- `updateHandler` destructure the request rather than binding it, so wiring
1041
- them needs a signature change rather than an argument. Until then **one HTTP
1042
- verb defeats the filter on the same record**: measured, `GET /animals/1`
1043
- returns `owner.data: null` and `PATCH /animals/1` returns **200 naming
1044
- angela**, seconds apart, with no query string and no relationship route. Any
1045
- caller who can read a record can also write it and be handed the id the read
1046
- withheld.
1047
- - ~~**`GET /:models/:id/relationships/{relationship}`**~~ **covered as of
1048
- [#232](https://github.com/abofs/stonyx-orm/issues/232)**, together with
1049
- `GET /:models/:id/{relationship}`. Left in place rather than deleted because
1050
- this list is what a reader consults to find out what is *not* covered, and a
1051
- silently vanishing entry reads as an entry that was never there. Its
1052
- *primary data* is linkage, so filtering it was a **membership** decision —
1053
- which is why it belonged to #232 and not to
1054
- [#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.** 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.
1055
1036
  - **A bare `toJSON()` still emits unfiltered linkage, and that is deliberate.**
1056
1037
  `Record.toJSON()` **applies** a verdict; it never **resolves** one. It has no
1057
1038
  request, and the documented `access()` contract permits a predicate to read
@@ -1145,11 +1126,29 @@ read all of these.
1145
1126
 
1146
1127
  #### `Record.toJSON()` does not filter relationship linkage unless you pass a verdict
1147
1128
 
1148
- **The framework owns this on four surfaces. You own it everywhere else.**
1149
-
1150
- `GET /:models`, `GET /:models/:id` and both `GET /:models/:id/{relationship}`
1151
- shapes resolve a linkage verdict and pass it to `toJSON()` for you. Any other
1152
- path to a document `JSON.stringify(record)`, `res.json(record)`,
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)`,
1153
1152
  `console.log(record)`, a custom route, a queue payload, a websocket frame —
1154
1153
  calls `toJSON()` with no verdict, and **the no-verdict document names every
1155
1154
  related id, including records hidden on every one of their own surfaces**
@@ -1449,13 +1448,15 @@ GET /animals/1
1449
1448
  #### Limitations
1450
1449
 
1451
1450
  - Only available on GET endpoints (not POST/PATCH)
1452
- - **`included` records are not access-filtered, on either question.** Whether a
1453
- resource appears in `included` at all is
1454
- [#233](https://github.com/abofs/stonyx-orm/issues/233); a record that *is*
1455
- permitted still emits its **own** `relationships.*.data` unfiltered, so
1456
- `?include=` republishes ids the primary document withholds
1457
- ([#235](https://github.com/abofs/stonyx-orm/issues/235)). See
1458
- [Consumer Contracts](#consumer-contracts).
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).
1459
1460
 
1460
1461
  ## Lifecycle Hooks
1461
1462
 
@@ -424,7 +424,7 @@ function normalizeBodyId(id) {
424
424
  return coerceId(id);
425
425
  }
426
426
  function buildResponse(data, includeParam, recordOrRecords, options = {}) {
427
- const { links, baseUrl } = options;
427
+ const { links, baseUrl, linkage } = options;
428
428
  const response = { data };
429
429
  // Add top-level links
430
430
  if (links) {
@@ -437,14 +437,49 @@ function buildResponse(data, includeParam, recordOrRecords, options = {}) {
437
437
  return response;
438
438
  const includedRecords = collectIncludedRecords(recordOrRecords, includes);
439
439
  if (includedRecords.length > 0) {
440
- // NO `linkage` ARGUMENT, deliberately, and abofs/stonyx-orm#235 owns adding
441
- // one. Until it does, a PERMITTED record here emits the full pre-#234
442
- // document: `GET /animals/1?include=owner` filters the primary document's
443
- // `owner.data` to `null` and then names `owner:angela` in `included`.
444
- // Whether a resource reaches this array at all is a different question
445
- // (membership, abofs/stonyx-orm#233) and closing that one does not close
446
- // this one.
447
- response.included = includedRecords.map(record => record.toJSON?.({ baseUrl }));
440
+ // LINKAGE, NOT MEMBERSHIP -- and the distinction is the whole reason this
441
+ // line is one story's and the line above it is another's
442
+ // (abofs/stonyx-orm#235 and #233 respectively).
443
+ //
444
+ // - WHICH RESOURCES REACH THIS ARRAY is decided by
445
+ // `collectIncludedRecords` on the line above. That is MEMBERSHIP, it is
446
+ // #233's, and it is deliberately untouched here: a hidden owner is
447
+ // still a member of `included` after this change. Pinned green by
448
+ // `[GUARD] #235 X1` so that #235 cannot close #233 incidentally.
449
+ // - WHAT A RECORD ALREADY IN THIS ARRAY MAY NAME in its own
450
+ // `relationships.*.data` is LINKAGE -- the same question #234 answers
451
+ // for the primary document -- and that is what the `linkage` option
452
+ // below decides. Before it, `GET /animals/1?include=owner,owner.pets`
453
+ // filtered the primary document's `owner.data` to `null` and then
454
+ // handed back nine PERMITTED animals in `included` each naming
455
+ // `{"type":"owner","id":"angela"}`. Neither #233 nor #234 closes that.
456
+ //
457
+ // THE FILTER IS THE CALLER'S, PASSED IN, NOT BUILT HERE. Both call sites
458
+ // already hold one for the primary document, and sharing it is what keeps
459
+ // the per-type verdict cache and the per-(type, id) decision cache alive
460
+ // across the primary document AND the sideload -- one verdict resolution
461
+ // per type for the whole response, pinned by `[GUARD] #235 C1`. Building a
462
+ // fresh filter here would resolve the consumer's `access()` once per
463
+ // included record instead.
464
+ //
465
+ // `linkage` IS OPTIONAL IN THE TYPE AND IS NOT OPTIONAL IN PRACTICE.
466
+ // Stating it precisely because the opposite claim stood here in an earlier
467
+ // draft of this change: BOTH of this function's callers supply a filter
468
+ // (`getCollectionHandler` and `getSingleHandler`, the only two), so the
469
+ // `undefined` branch has no live caller in this module today. It is
470
+ // optional so that omitting it degrades to the PRE-#234 document rather
471
+ // than to a denial -- `Record.toJSON` reads an ABSENT option as "no verdict
472
+ // was supplied" and emits linkage in full.
473
+ //
474
+ // WHAT IT MUST NEVER BE HANDED IS A NON-FUNCTION. `toJSON` does NOT read a
475
+ // non-function as absent: `Object.prototype.toString.call(linkage)` must be
476
+ // `'[object Function]'`, and anything else -- `null`, an `AsyncFunction`,
477
+ // and INCLUDING the primitive `true` -- DENIES every relationship on the
478
+ // document and logs once. `toJSON({ linkage: true })` emits `null` linkage.
479
+ // So do not "simplify" this to a boolean, and do not make it default to
480
+ // `true`: both spellings look like "allow everything" and mean the exact
481
+ // opposite (abofs/stonyx-orm#224).
482
+ response.included = includedRecords.map(record => record.toJSON?.({ baseUrl, linkage }));
448
483
  }
449
484
  return response;
450
485
  }
@@ -626,7 +661,11 @@ export default class OrmRequest extends Request {
626
661
  const data = recordsToReturn.map(record => record.toJSON?.({ fields: modelFields, baseUrl, linkage }));
627
662
  return buildResponse(data, request.query?.include, recordsToReturn, {
628
663
  links: { self: `${baseUrl}/${pluralizedModel}` },
629
- baseUrl
664
+ baseUrl,
665
+ // THE SAME filter object the primary documents above were serialized
666
+ // with, deliberately: it carries the caches, and rebuilding one here
667
+ // would re-resolve every type (abofs/stonyx-orm#235).
668
+ linkage
630
669
  });
631
670
  };
632
671
  const getSingleHandler = async (request, { filter }) => {
@@ -642,28 +681,28 @@ export default class OrmRequest extends Request {
642
681
  const modelFields = fieldsMap.get(pluralizedModel) || fieldsMap.get(model);
643
682
  const baseUrl = getBaseUrl(request);
644
683
  const linkage = createLinkageFilter(request);
645
- // `buildResponse` is deliberately NOT given the linkage filter, and the
646
- // residual that leaves is NOT the one #233 owns. Two different questions:
647
- //
648
- // - WHETHER A RESOURCE APPEARS in `included` at all is MEMBERSHIP ->
649
- // abofs/stonyx-orm#233.
650
- // - What a record already IN `included` may NAME is LINKAGE -- the same
651
- // question #234 answers for the primary document -- and it is
652
- // abofs/stonyx-orm#235, which also owns createHandler/updateHandler.
684
+ // `buildResponse` IS given the filter now (abofs/stonyx-orm#235), and it
685
+ // is the SAME object the primary document is serialized with -- one
686
+ // verdict per type for the whole response, sideload included.
653
687
  //
654
- // The residual, stated so the next reader does not have to derive it:
655
- // `buildResponse` calls `record.toJSON?.({ baseUrl })` with no `linkage`
656
- // argument, so a PERMITTED record in `included` emits the full pre-#234
657
- // document. Measured: `GET /animals/1?include=owner` returns
658
- // `owner.data: null` on the primary document and `owner:angela` in
659
- // `included`. One query parameter deep. Only the PRIMARY document's
660
- // linkage is filtered here.
688
+ // The boundary that remains, so the next reader does not have to derive
689
+ // it: this closes what a record already in `included` may NAME. WHETHER a
690
+ // resource appears in `included` at all is MEMBERSHIP and it is
691
+ // abofs/stonyx-orm#233's -- a hidden owner is still a member here.
692
+ // Neither question closes the other.
661
693
  return buildResponse(record.toJSON?.({ fields: modelFields, baseUrl, linkage }), request.query?.include, record, {
662
694
  links: { self: `${baseUrl}/${pluralizedModel}/${request.params.id}` },
663
- baseUrl
695
+ baseUrl,
696
+ linkage
664
697
  });
665
698
  };
666
- const createHandler = async ({ body, query }, { filter }) => {
699
+ const createHandler = async (request, { filter }) => {
700
+ // BOUND, not destructured (abofs/stonyx-orm#235). `HandlerFn` has always
701
+ // delivered the request as argument one; this handler simply discarded
702
+ // the binding, which is why its response document named ids every read
703
+ // surface withholds. `createLinkageFilter` needs the live request and
704
+ // there is no signature change involved in giving it one.
705
+ const { body, query } = request;
667
706
  const { type, id, attributes, relationships: rels } = (body?.data || {});
668
707
  if (!type)
669
708
  return 400; // Bad request
@@ -902,9 +941,28 @@ export default class OrmRequest extends Request {
902
941
  }
903
942
  return 403;
904
943
  }
905
- return { data: record.toJSON?.({ fields: modelFields }) };
944
+ // The filter is built HERE, per invocation, and never hoisted into the
945
+ // OrmRequest constructor where the other per-mount values live: a verdict
946
+ // cached across requests answers a second caller with the first caller's
947
+ // authorization (src/access-verdict.ts says so at the constructor an
948
+ // implementer would reach for).
949
+ //
950
+ // AND IT IS BUILT AFTER `createRecord`, AFTER THE ROLLBACK WINDOW AND
951
+ // AFTER `isDenied`, so the record is in its final form at the call. The
952
+ // filter is lazy per type and per (type, id), so it cannot observe a
953
+ // pre-write state even if it were built earlier.
954
+ //
955
+ // `fields` is passed here and NOT in `updateHandler`: the two handlers
956
+ // are asymmetric on purpose (`updateHandler` has no `fieldsMap` in
957
+ // scope), and a single copy-pasted wiring would drop it from one of them.
958
+ return { data: record.toJSON?.({ fields: modelFields, linkage: createLinkageFilter(request) }) };
906
959
  };
907
- const updateHandler = async ({ body, params }, { filter }) => {
960
+ const updateHandler = async (request, { filter }) => {
961
+ // Bound rather than destructured, for the reason given in
962
+ // `createHandler` above (abofs/stonyx-orm#235). `PATCH /animals/1`
963
+ // returned 200 naming angela seconds after `GET /animals/1` returned
964
+ // `owner.data: null` for the same record -- one HTTP verb apart.
965
+ const { body, params } = request;
908
966
  const found = await store.find(model, getId(params));
909
967
  if (!found || !isOrmRecord(found))
910
968
  return 404;
@@ -961,7 +1019,14 @@ export default class OrmRequest extends Request {
961
1019
  updateRecord(record, relUpdates, { _skipAutoPersist: true });
962
1020
  }
963
1021
  }
964
- return { data: record.toJSON?.() };
1022
+ // No `fields` and no `baseUrl`, both unchanged: `updateHandler` has no
1023
+ // `fieldsMap` in scope, and adding `baseUrl` would put `links` on a
1024
+ // document that has never carried them -- an unrelated behaviour change.
1025
+ // #224 AC6's "emits `data: []` WITH links" is a statement about the READ
1026
+ // surfaces; on these two handlers a filtered relationship and a
1027
+ // genuinely-empty one are both a bare `{ data }`, which is what makes
1028
+ // them indistinguishable here too.
1029
+ return { data: record.toJSON?.({ linkage: createLinkageFilter(request) }) };
965
1030
  };
966
1031
  const deleteHandler = async ({ params }, { filter }) => {
967
1032
  // Coerced ONCE. `getId(params)` was evaluated twice here -- once to find
@@ -1245,77 +1310,21 @@ export default class OrmRequest extends Request {
1245
1310
  return 404;
1246
1311
  const relatedData = record.__relationships[relationshipName];
1247
1312
  const baseUrl = getBaseUrl(request);
1248
- // ONE FILTER, TWO JOBS, AND abofs/stonyx-orm#232 IS THE SECOND ONE.
1249
- //
1250
- // As LINKAGE (#234) it decides which ids the emitted documents may NAME
1251
- // in their own `relationships.*.data`. As MEMBERSHIP (this issue) it
1252
- // decides whether the related record is served here AT ALL -- the
1253
- // related resource is PRIMARY data on this route, so there is no
1254
- // linkage-consistency question to answer separately.
1255
- //
1256
- // Until #232 this route filtered only the PARENT, so a record its own
1257
- // model's predicate hides was served in full from another model's
1258
- // route, at ZERO query parameters. Measured on dev @ 8dda5d6:
1259
- //
1260
- // GET /owners/angela -> 404
1261
- // GET /animals/1/owner -> 200, owner:angela, full attributes
1262
- // GET /traits/2/tag -> 200, a model NO access class
1263
- // claims, on a collection that has
1264
- // no mounted route at all
1265
- //
1266
- // ARGUMENT ONE IS THE LIVE REQUEST, NOT A DERIVED ONE. A fabricated
1267
- // request addressing the RELATED resource was the original design and
1268
- // it is dropped: #241 removed the shipped fixture's read of argument
1269
- // one, so a fabricated value changes nothing it could observe, and
1270
- // test/unit/linkage-verdict-test.ts:484 pins the predicate as receiving
1271
- // the live request BY IDENTITY. `createLinkageFilter` is also a
1272
- // published public export (src/index.ts) whose resolution granularity
1273
- // is per TYPE; supplying a per-RECORD request would mean widening it,
1274
- // which takes a consumer `access()` from ~2 calls to ~7 on a plain
1275
- // `GET /animals`. That is a separate, consumer-visible story.
1276
- //
1277
- // THE RESIDUAL THAT FOLLOWS FROM THAT IS DISCLOSED, NOT PAPERED OVER.
1278
- // `recordId` is `null` here and the request names a record of a
1279
- // DIFFERENT model, so a consumer predicate can express a model-level or
1280
- // a request-level deny for a related resource, but NOT a per-record
1281
- // one. README.md and docs/usage-patterns.md say so; a ledger assertion
1282
- // in test/unit/relationship-route-access-test.ts keeps them saying it.
1313
+ // LINKAGE ONLY. This filter decides which ids the emitted documents may
1314
+ // NAME in their own `relationships.*.data`; it does NOT decide whether
1315
+ // the related records themselves are served -- that is the parent-only
1316
+ // filtering this route has done since #190, and widening it to the
1317
+ // related record is abofs/stonyx-orm#196.
1283
1318
  const linkage = createLinkageFilter(request);
1284
- // FAIL CLOSED ON A RECORD WHOSE TYPE CANNOT BE NAMED. `isLinkable` is
1285
- // keyed on the model name; without one there is no predicate to ask,
1286
- // and an unidentifiable input must never be the permissive path.
1287
- const isLinkable = (r) => {
1288
- const type = r.__model?.__name;
1289
- return typeof type === 'string' && type !== '' && linkage(type, r);
1290
- };
1291
1319
  let data;
1292
1320
  if (info.isArray) {
1293
- // hasMany - return array, MINUS the members this caller may not see.
1294
- // Dropped, never errored: the result is byte-identical to a genuinely
1295
- // empty relationship, so this route is not an existence oracle.
1321
+ // hasMany - return array
1296
1322
  const related = Array.isArray(relatedData) ? relatedData.filter(isOrmRecord) : [];
1297
- data = related.filter(isLinkable).map(r => r.toJSON?.({ baseUrl, linkage }));
1323
+ data = related.map(r => r.toJSON?.({ baseUrl, linkage }));
1298
1324
  }
1299
1325
  else {
1300
- // belongsTo - return single or null. A DENIED target is 404, the same
1301
- // status this route already returns for a denied PARENT and for a
1302
- // parent that does not exist, and the same status the related
1303
- // record's own route answers.
1304
- //
1305
- // KNOWN RESIDUAL, stated here because it is the one place a reader
1306
- // will look: 404 is distinguishable from a relationship that is
1307
- // genuinely EMPTY, which answers 200 with `data: null`. So a caller
1308
- // can tell "there is a target you may not see" from "there is no
1309
- // target". That asymmetry is inherited -- a denied PARENT has always
1310
- // answered 404 while an existing parent with an empty relationship
1311
- // answers 200 -- and closing it is a change to the module's whole
1312
- // denial spelling, not to this route.
1313
- if (!isOrmRecord(relatedData))
1314
- data = null;
1315
- else if (!isLinkable(relatedData))
1316
- return 404;
1317
- else
1318
- data = relatedData.toJSON?.({ baseUrl, linkage });
1326
+ // belongsTo - return single or null
1327
+ data = isOrmRecord(relatedData) ? relatedData.toJSON?.({ baseUrl, linkage }) : null;
1319
1328
  }
1320
1329
  return {
1321
1330
  links: { self: `${baseUrl}/${pluralizedModel}/${request.params.id}/${dasherizedName}` },
@@ -1323,6 +1332,47 @@ export default class OrmRequest extends Request {
1323
1332
  };
1324
1333
  };
1325
1334
  // Relationship linkage route: GET /:id/relationships/{relationship}
1335
+ //
1336
+ // NO `linkage` FILTER FROM abofs/stonyx-orm#235, AND THAT IS A SCOPE
1337
+ // BOUNDARY RATHER THAN AN OVERSIGHT -- abofs/stonyx-orm#232 OWNS THIS
1338
+ // ROUTE, and PR #247 is IN FLIGHT against it in this same sprint. If you
1339
+ // are reading this after #247 landed, the filtering below is #232's and
1340
+ // this note records why it was never #235's to add.
1341
+ //
1342
+ // The three sites #235 does own (`buildResponse`'s `included`, the
1343
+ // related-resource branch above, and the two write handlers) all reach
1344
+ // the filter through `record.toJSON()`, which is where the `linkage`
1345
+ // OPTION is applied. This branch builds its `{ type, id }` objects BY
1346
+ // HAND and never calls `toJSON` at all, so the `linkage` option cannot
1347
+ // reach it -- whatever this route filters, it has to filter itself, which
1348
+ // is precisely why doing so is a separate change with a separate owner.
1349
+ //
1350
+ // It is also a DIFFERENT QUESTION. Everywhere #235 touches, linkage is
1351
+ // metadata ABOUT a document. Here the linkage IS the primary data, so
1352
+ // dropping an entry is a MEMBERSHIP decision about what this route
1353
+ // serves -- the same class as abofs/stonyx-orm#233 and #196, not the
1354
+ // class #234/#235 close. That is why it is absent from #224 §2a's
1355
+ // seven-site inventory.
1356
+ //
1357
+ // MEASURED, so the next person does not re-derive it. Against this
1358
+ // branch's baseline of 1011/0, wiring `createLinkageFilter` into the
1359
+ // belongsTo branch below takes the suite to 1009/2, reddening
1360
+ // `[GUARD] #235 X2` and the
1361
+ // `GET /animals/:id/relationships/owner returns relationship linkage`
1362
+ // test -- the latter is #232's own reproduction, not a regression.
1363
+ //
1364
+ // THE BASELINE IS QUOTED WITH THE RESULT BECAUSE AN EARLIER REVISION OF
1365
+ // THIS COMMENT SAID 993/2 AND SHIPPED IT. This file lands in consumers'
1366
+ // `node_modules`, so a wrong number here is a wrong number in the
1367
+ // published package. 993+2 = 995 is the DEV baseline, carried over from
1368
+ // a branch on which `[GUARD] #235 X2` does not exist. A pass/fail pair
1369
+ // with no baseline beside it cannot be checked by reading, which is how
1370
+ // it survived three artifacts and a review; the qualitative claim was
1371
+ // right the whole time and only the count was wrong.
1372
+ //
1373
+ // `[GUARD] #235 X2` in test/integration/orm-test.ts pins the OWNERSHIP
1374
+ // BOUNDARY here rather than this route's current answer, so that it
1375
+ // survives #247 landing. Read its comment before changing it.
1326
1376
  routes[`/:id/relationships/${dasherizedName}`] = async (request, { filter } = {}) => {
1327
1377
  const record = await store.find(model, getId(request.params));
1328
1378
  if (!record)
@@ -1331,35 +1381,17 @@ export default class OrmRequest extends Request {
1331
1381
  return 404;
1332
1382
  const relatedData = record.__relationships[relationshipName];
1333
1383
  const baseUrl = getBaseUrl(request);
1334
- // THE ONLY ONE OF THE FOUR READ SURFACES THAT DOES NOT GO THROUGH
1335
- // `toJSON()`. It builds `{ type, id }` BY HAND, which is why #234's
1336
- // linkage filter never reached it and why this half belongs to
1337
- // abofs/stonyx-orm#232 rather than to #234: on this route the linkage
1338
- // IS the primary data of an opt-in request, so filtering it changes the
1339
- // route's MEMBERSHIP semantics, not the ids named inside somebody
1340
- // else's document.
1341
- //
1342
- // Same filter, same argument-one decision, same residual as
1343
- // `/:id/{relationship}` above -- read the block there.
1344
- const linkage = createLinkageFilter(request);
1345
- const isLinkable = (r) => {
1346
- const type = r.__model?.__name;
1347
- return typeof type === 'string' && type !== '' && linkage(type, r);
1348
- };
1349
1384
  let data;
1350
1385
  if (info.isArray) {
1351
1386
  // hasMany - return array of linkage objects
1352
1387
  const related = Array.isArray(relatedData) ? relatedData.filter(isOrmRecord) : [];
1353
1388
  data = related
1354
1389
  .filter((r) => Boolean(r.__model))
1355
- .filter(isLinkable)
1356
1390
  .map(r => ({ type: r.__model.__name, id: r.id }));
1357
1391
  }
1358
1392
  else {
1359
1393
  // belongsTo - return single linkage or null
1360
1394
  if (isOrmRecord(relatedData) && relatedData.__model) {
1361
- if (!isLinkable(relatedData))
1362
- return 404;
1363
1395
  data = { type: relatedData.__model.__name, id: relatedData.id };
1364
1396
  }
1365
1397
  else {
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "stonyx-async",
5
5
  "stonyx-module"
6
6
  ],
7
- "version": "0.3.2-alpha.75",
7
+ "version": "0.3.2-alpha.76",
8
8
  "description": "",
9
9
  "main": "dist/index.js",
10
10
  "type": "module",
@@ -267,7 +267,7 @@ import { getBeforeHooks, getAfterHooks } from './hooks.js';
267
267
  import type { HookContext } from './hooks.js';
268
268
  import config from 'stonyx/config';
269
269
  import log from 'stonyx/log';
270
- import type { OrmRecord, AccessContext, AccessFunction, AccessMethod, AccessOperation } from './types/orm-types.js';
270
+ import type { OrmRecord, AccessContext, AccessFunction, AccessMethod, AccessOperation, LinkageFilter } from './types/orm-types.js';
271
271
  import { isOrmRecord, NO_FREE_ID_ERROR } from './utils.js';
272
272
  import { interpretAccess, createLinkageFilter } from './access-verdict.js';
273
273
 
@@ -465,9 +465,9 @@ function buildResponse(
465
465
  data: unknown,
466
466
  includeParam: string | undefined,
467
467
  recordOrRecords: OrmRecord | OrmRecord[],
468
- options: { links?: { [key: string]: string }; baseUrl?: string } = {}
468
+ options: { links?: { [key: string]: string }; baseUrl?: string; linkage?: LinkageFilter } = {}
469
469
  ): JsonApiResponse {
470
- const { links, baseUrl } = options;
470
+ const { links, baseUrl, linkage } = options;
471
471
  const response: JsonApiResponse = { data };
472
472
 
473
473
  // Add top-level links
@@ -482,14 +482,49 @@ function buildResponse(
482
482
 
483
483
  const includedRecords = collectIncludedRecords(recordOrRecords, includes);
484
484
  if (includedRecords.length > 0) {
485
- // NO `linkage` ARGUMENT, deliberately, and abofs/stonyx-orm#235 owns adding
486
- // one. Until it does, a PERMITTED record here emits the full pre-#234
487
- // document: `GET /animals/1?include=owner` filters the primary document's
488
- // `owner.data` to `null` and then names `owner:angela` in `included`.
489
- // Whether a resource reaches this array at all is a different question
490
- // (membership, abofs/stonyx-orm#233) and closing that one does not close
491
- // this one.
492
- response.included = includedRecords.map(record => record.toJSON?.({ baseUrl }));
485
+ // LINKAGE, NOT MEMBERSHIP -- and the distinction is the whole reason this
486
+ // line is one story's and the line above it is another's
487
+ // (abofs/stonyx-orm#235 and #233 respectively).
488
+ //
489
+ // - WHICH RESOURCES REACH THIS ARRAY is decided by
490
+ // `collectIncludedRecords` on the line above. That is MEMBERSHIP, it is
491
+ // #233's, and it is deliberately untouched here: a hidden owner is
492
+ // still a member of `included` after this change. Pinned green by
493
+ // `[GUARD] #235 X1` so that #235 cannot close #233 incidentally.
494
+ // - WHAT A RECORD ALREADY IN THIS ARRAY MAY NAME in its own
495
+ // `relationships.*.data` is LINKAGE -- the same question #234 answers
496
+ // for the primary document -- and that is what the `linkage` option
497
+ // below decides. Before it, `GET /animals/1?include=owner,owner.pets`
498
+ // filtered the primary document's `owner.data` to `null` and then
499
+ // handed back nine PERMITTED animals in `included` each naming
500
+ // `{"type":"owner","id":"angela"}`. Neither #233 nor #234 closes that.
501
+ //
502
+ // THE FILTER IS THE CALLER'S, PASSED IN, NOT BUILT HERE. Both call sites
503
+ // already hold one for the primary document, and sharing it is what keeps
504
+ // the per-type verdict cache and the per-(type, id) decision cache alive
505
+ // across the primary document AND the sideload -- one verdict resolution
506
+ // per type for the whole response, pinned by `[GUARD] #235 C1`. Building a
507
+ // fresh filter here would resolve the consumer's `access()` once per
508
+ // included record instead.
509
+ //
510
+ // `linkage` IS OPTIONAL IN THE TYPE AND IS NOT OPTIONAL IN PRACTICE.
511
+ // Stating it precisely because the opposite claim stood here in an earlier
512
+ // draft of this change: BOTH of this function's callers supply a filter
513
+ // (`getCollectionHandler` and `getSingleHandler`, the only two), so the
514
+ // `undefined` branch has no live caller in this module today. It is
515
+ // optional so that omitting it degrades to the PRE-#234 document rather
516
+ // than to a denial -- `Record.toJSON` reads an ABSENT option as "no verdict
517
+ // was supplied" and emits linkage in full.
518
+ //
519
+ // WHAT IT MUST NEVER BE HANDED IS A NON-FUNCTION. `toJSON` does NOT read a
520
+ // non-function as absent: `Object.prototype.toString.call(linkage)` must be
521
+ // `'[object Function]'`, and anything else -- `null`, an `AsyncFunction`,
522
+ // and INCLUDING the primitive `true` -- DENIES every relationship on the
523
+ // document and logs once. `toJSON({ linkage: true })` emits `null` linkage.
524
+ // So do not "simplify" this to a boolean, and do not make it default to
525
+ // `true`: both spellings look like "allow everything" and mean the exact
526
+ // opposite (abofs/stonyx-orm#224).
527
+ response.included = includedRecords.map(record => record.toJSON?.({ baseUrl, linkage }));
493
528
  }
494
529
 
495
530
  return response;
@@ -700,7 +735,11 @@ export default class OrmRequest extends Request {
700
735
 
701
736
  return buildResponse(data, request.query?.include, recordsToReturn, {
702
737
  links: { self: `${baseUrl}/${pluralizedModel}` },
703
- baseUrl
738
+ baseUrl,
739
+ // THE SAME filter object the primary documents above were serialized
740
+ // with, deliberately: it carries the caches, and rebuilding one here
741
+ // would re-resolve every type (abofs/stonyx-orm#235).
742
+ linkage
704
743
  });
705
744
  };
706
745
 
@@ -718,29 +757,29 @@ export default class OrmRequest extends Request {
718
757
  const baseUrl = getBaseUrl(request);
719
758
  const linkage = createLinkageFilter(request);
720
759
 
721
- // `buildResponse` is deliberately NOT given the linkage filter, and the
722
- // residual that leaves is NOT the one #233 owns. Two different questions:
760
+ // `buildResponse` IS given the filter now (abofs/stonyx-orm#235), and it
761
+ // is the SAME object the primary document is serialized with -- one
762
+ // verdict per type for the whole response, sideload included.
723
763
  //
724
- // - WHETHER A RESOURCE APPEARS in `included` at all is MEMBERSHIP ->
725
- // abofs/stonyx-orm#233.
726
- // - What a record already IN `included` may NAME is LINKAGE -- the same
727
- // question #234 answers for the primary document -- and it is
728
- // abofs/stonyx-orm#235, which also owns createHandler/updateHandler.
729
- //
730
- // The residual, stated so the next reader does not have to derive it:
731
- // `buildResponse` calls `record.toJSON?.({ baseUrl })` with no `linkage`
732
- // argument, so a PERMITTED record in `included` emits the full pre-#234
733
- // document. Measured: `GET /animals/1?include=owner` returns
734
- // `owner.data: null` on the primary document and `owner:angela` in
735
- // `included`. One query parameter deep. Only the PRIMARY document's
736
- // linkage is filtered here.
764
+ // The boundary that remains, so the next reader does not have to derive
765
+ // it: this closes what a record already in `included` may NAME. WHETHER a
766
+ // resource appears in `included` at all is MEMBERSHIP and it is
767
+ // abofs/stonyx-orm#233's -- a hidden owner is still a member here.
768
+ // Neither question closes the other.
737
769
  return buildResponse(record.toJSON?.({ fields: modelFields, baseUrl, linkage }), request.query?.include, record, {
738
770
  links: { self: `${baseUrl}/${pluralizedModel}/${request.params.id}` },
739
- baseUrl
771
+ baseUrl,
772
+ linkage
740
773
  });
741
774
  };
742
775
 
743
- const createHandler: HandlerFn = async ({ body, query }, { filter }) => {
776
+ const createHandler: HandlerFn = async (request, { filter }) => {
777
+ // BOUND, not destructured (abofs/stonyx-orm#235). `HandlerFn` has always
778
+ // delivered the request as argument one; this handler simply discarded
779
+ // the binding, which is why its response document named ids every read
780
+ // surface withholds. `createLinkageFilter` needs the live request and
781
+ // there is no signature change involved in giving it one.
782
+ const { body, query } = request;
744
783
  const { type, id, attributes, relationships: rels } = (body?.data || {}) as {
745
784
  type?: string;
746
785
  id?: string | number;
@@ -995,10 +1034,29 @@ export default class OrmRequest extends Request {
995
1034
  return 403;
996
1035
  }
997
1036
 
998
- return { data: record.toJSON?.({ fields: modelFields }) };
1037
+ // The filter is built HERE, per invocation, and never hoisted into the
1038
+ // OrmRequest constructor where the other per-mount values live: a verdict
1039
+ // cached across requests answers a second caller with the first caller's
1040
+ // authorization (src/access-verdict.ts says so at the constructor an
1041
+ // implementer would reach for).
1042
+ //
1043
+ // AND IT IS BUILT AFTER `createRecord`, AFTER THE ROLLBACK WINDOW AND
1044
+ // AFTER `isDenied`, so the record is in its final form at the call. The
1045
+ // filter is lazy per type and per (type, id), so it cannot observe a
1046
+ // pre-write state even if it were built earlier.
1047
+ //
1048
+ // `fields` is passed here and NOT in `updateHandler`: the two handlers
1049
+ // are asymmetric on purpose (`updateHandler` has no `fieldsMap` in
1050
+ // scope), and a single copy-pasted wiring would drop it from one of them.
1051
+ return { data: record.toJSON?.({ fields: modelFields, linkage: createLinkageFilter(request) }) };
999
1052
  };
1000
1053
 
1001
- const updateHandler: HandlerFn = async ({ body, params }, { filter }) => {
1054
+ const updateHandler: HandlerFn = async (request, { filter }) => {
1055
+ // Bound rather than destructured, for the reason given in
1056
+ // `createHandler` above (abofs/stonyx-orm#235). `PATCH /animals/1`
1057
+ // returned 200 naming angela seconds after `GET /animals/1` returned
1058
+ // `owner.data: null` for the same record -- one HTTP verb apart.
1059
+ const { body, params } = request;
1002
1060
  const found = await store.find(model, getId(params));
1003
1061
  if (!found || !isOrmRecord(found)) return 404;
1004
1062
  // Checked BEFORE any attribute is applied. 404 rather than 403 for the
@@ -1057,7 +1115,14 @@ export default class OrmRequest extends Request {
1057
1115
  }
1058
1116
  }
1059
1117
 
1060
- return { data: record.toJSON?.() };
1118
+ // No `fields` and no `baseUrl`, both unchanged: `updateHandler` has no
1119
+ // `fieldsMap` in scope, and adding `baseUrl` would put `links` on a
1120
+ // document that has never carried them -- an unrelated behaviour change.
1121
+ // #224 AC6's "emits `data: []` WITH links" is a statement about the READ
1122
+ // surfaces; on these two handlers a filtered relationship and a
1123
+ // genuinely-empty one are both a bare `{ data }`, which is what makes
1124
+ // them indistinguishable here too.
1125
+ return { data: record.toJSON?.({ linkage: createLinkageFilter(request) }) };
1061
1126
  };
1062
1127
 
1063
1128
  const deleteHandler: HandlerFn = async ({ params }, { filter }) => {
@@ -1364,76 +1429,21 @@ export default class OrmRequest extends Request {
1364
1429
  const relatedData = record.__relationships[relationshipName];
1365
1430
  const baseUrl = getBaseUrl(request);
1366
1431
 
1367
- // ONE FILTER, TWO JOBS, AND abofs/stonyx-orm#232 IS THE SECOND ONE.
1368
- //
1369
- // As LINKAGE (#234) it decides which ids the emitted documents may NAME
1370
- // in their own `relationships.*.data`. As MEMBERSHIP (this issue) it
1371
- // decides whether the related record is served here AT ALL -- the
1372
- // related resource is PRIMARY data on this route, so there is no
1373
- // linkage-consistency question to answer separately.
1374
- //
1375
- // Until #232 this route filtered only the PARENT, so a record its own
1376
- // model's predicate hides was served in full from another model's
1377
- // route, at ZERO query parameters. Measured on dev @ 8dda5d6:
1378
- //
1379
- // GET /owners/angela -> 404
1380
- // GET /animals/1/owner -> 200, owner:angela, full attributes
1381
- // GET /traits/2/tag -> 200, a model NO access class
1382
- // claims, on a collection that has
1383
- // no mounted route at all
1384
- //
1385
- // ARGUMENT ONE IS THE LIVE REQUEST, NOT A DERIVED ONE. A fabricated
1386
- // request addressing the RELATED resource was the original design and
1387
- // it is dropped: #241 removed the shipped fixture's read of argument
1388
- // one, so a fabricated value changes nothing it could observe, and
1389
- // test/unit/linkage-verdict-test.ts:484 pins the predicate as receiving
1390
- // the live request BY IDENTITY. `createLinkageFilter` is also a
1391
- // published public export (src/index.ts) whose resolution granularity
1392
- // is per TYPE; supplying a per-RECORD request would mean widening it,
1393
- // which takes a consumer `access()` from ~2 calls to ~7 on a plain
1394
- // `GET /animals`. That is a separate, consumer-visible story.
1395
- //
1396
- // THE RESIDUAL THAT FOLLOWS FROM THAT IS DISCLOSED, NOT PAPERED OVER.
1397
- // `recordId` is `null` here and the request names a record of a
1398
- // DIFFERENT model, so a consumer predicate can express a model-level or
1399
- // a request-level deny for a related resource, but NOT a per-record
1400
- // one. README.md and docs/usage-patterns.md say so; a ledger assertion
1401
- // in test/unit/relationship-route-access-test.ts keeps them saying it.
1432
+ // LINKAGE ONLY. This filter decides which ids the emitted documents may
1433
+ // NAME in their own `relationships.*.data`; it does NOT decide whether
1434
+ // the related records themselves are served -- that is the parent-only
1435
+ // filtering this route has done since #190, and widening it to the
1436
+ // related record is abofs/stonyx-orm#196.
1402
1437
  const linkage = createLinkageFilter(request);
1403
1438
 
1404
- // FAIL CLOSED ON A RECORD WHOSE TYPE CANNOT BE NAMED. `isLinkable` is
1405
- // keyed on the model name; without one there is no predicate to ask,
1406
- // and an unidentifiable input must never be the permissive path.
1407
- const isLinkable = (r: OrmRecord) => {
1408
- const type = (r as { __model?: { __name?: string } }).__model?.__name;
1409
-
1410
- return typeof type === 'string' && type !== '' && linkage(type, r);
1411
- };
1412
-
1413
1439
  let data: unknown;
1414
1440
  if (info.isArray) {
1415
- // hasMany - return array, MINUS the members this caller may not see.
1416
- // Dropped, never errored: the result is byte-identical to a genuinely
1417
- // empty relationship, so this route is not an existence oracle.
1441
+ // hasMany - return array
1418
1442
  const related = Array.isArray(relatedData) ? relatedData.filter(isOrmRecord) : [];
1419
- data = related.filter(isLinkable).map(r => r.toJSON?.({ baseUrl, linkage }));
1443
+ data = related.map(r => r.toJSON?.({ baseUrl, linkage }));
1420
1444
  } else {
1421
- // belongsTo - return single or null. A DENIED target is 404, the same
1422
- // status this route already returns for a denied PARENT and for a
1423
- // parent that does not exist, and the same status the related
1424
- // record's own route answers.
1425
- //
1426
- // KNOWN RESIDUAL, stated here because it is the one place a reader
1427
- // will look: 404 is distinguishable from a relationship that is
1428
- // genuinely EMPTY, which answers 200 with `data: null`. So a caller
1429
- // can tell "there is a target you may not see" from "there is no
1430
- // target". That asymmetry is inherited -- a denied PARENT has always
1431
- // answered 404 while an existing parent with an empty relationship
1432
- // answers 200 -- and closing it is a change to the module's whole
1433
- // denial spelling, not to this route.
1434
- if (!isOrmRecord(relatedData)) data = null;
1435
- else if (!isLinkable(relatedData)) return 404;
1436
- else data = relatedData.toJSON?.({ baseUrl, linkage });
1445
+ // belongsTo - return single or null
1446
+ data = isOrmRecord(relatedData) ? relatedData.toJSON?.({ baseUrl, linkage }) : null;
1437
1447
  }
1438
1448
 
1439
1449
  return {
@@ -1443,6 +1453,47 @@ export default class OrmRequest extends Request {
1443
1453
  };
1444
1454
 
1445
1455
  // Relationship linkage route: GET /:id/relationships/{relationship}
1456
+ //
1457
+ // NO `linkage` FILTER FROM abofs/stonyx-orm#235, AND THAT IS A SCOPE
1458
+ // BOUNDARY RATHER THAN AN OVERSIGHT -- abofs/stonyx-orm#232 OWNS THIS
1459
+ // ROUTE, and PR #247 is IN FLIGHT against it in this same sprint. If you
1460
+ // are reading this after #247 landed, the filtering below is #232's and
1461
+ // this note records why it was never #235's to add.
1462
+ //
1463
+ // The three sites #235 does own (`buildResponse`'s `included`, the
1464
+ // related-resource branch above, and the two write handlers) all reach
1465
+ // the filter through `record.toJSON()`, which is where the `linkage`
1466
+ // OPTION is applied. This branch builds its `{ type, id }` objects BY
1467
+ // HAND and never calls `toJSON` at all, so the `linkage` option cannot
1468
+ // reach it -- whatever this route filters, it has to filter itself, which
1469
+ // is precisely why doing so is a separate change with a separate owner.
1470
+ //
1471
+ // It is also a DIFFERENT QUESTION. Everywhere #235 touches, linkage is
1472
+ // metadata ABOUT a document. Here the linkage IS the primary data, so
1473
+ // dropping an entry is a MEMBERSHIP decision about what this route
1474
+ // serves -- the same class as abofs/stonyx-orm#233 and #196, not the
1475
+ // class #234/#235 close. That is why it is absent from #224 §2a's
1476
+ // seven-site inventory.
1477
+ //
1478
+ // MEASURED, so the next person does not re-derive it. Against this
1479
+ // branch's baseline of 1011/0, wiring `createLinkageFilter` into the
1480
+ // belongsTo branch below takes the suite to 1009/2, reddening
1481
+ // `[GUARD] #235 X2` and the
1482
+ // `GET /animals/:id/relationships/owner returns relationship linkage`
1483
+ // test -- the latter is #232's own reproduction, not a regression.
1484
+ //
1485
+ // THE BASELINE IS QUOTED WITH THE RESULT BECAUSE AN EARLIER REVISION OF
1486
+ // THIS COMMENT SAID 993/2 AND SHIPPED IT. This file lands in consumers'
1487
+ // `node_modules`, so a wrong number here is a wrong number in the
1488
+ // published package. 993+2 = 995 is the DEV baseline, carried over from
1489
+ // a branch on which `[GUARD] #235 X2` does not exist. A pass/fail pair
1490
+ // with no baseline beside it cannot be checked by reading, which is how
1491
+ // it survived three artifacts and a review; the qualitative claim was
1492
+ // right the whole time and only the count was wrong.
1493
+ //
1494
+ // `[GUARD] #235 X2` in test/integration/orm-test.ts pins the OWNERSHIP
1495
+ // BOUNDARY here rather than this route's current answer, so that it
1496
+ // survives #247 landing. Read its comment before changing it.
1446
1497
  routes[`/:id/relationships/${dasherizedName}`] = async (request: OrmRequest$, { filter }: { [key: string]: unknown } = {}) => {
1447
1498
  const record = await store.find(model, getId(request.params)) as OrmRecord | undefined;
1448
1499
  if (!record) return 404;
@@ -1451,36 +1502,16 @@ export default class OrmRequest extends Request {
1451
1502
  const relatedData = record.__relationships[relationshipName];
1452
1503
  const baseUrl = getBaseUrl(request);
1453
1504
 
1454
- // THE ONLY ONE OF THE FOUR READ SURFACES THAT DOES NOT GO THROUGH
1455
- // `toJSON()`. It builds `{ type, id }` BY HAND, which is why #234's
1456
- // linkage filter never reached it and why this half belongs to
1457
- // abofs/stonyx-orm#232 rather than to #234: on this route the linkage
1458
- // IS the primary data of an opt-in request, so filtering it changes the
1459
- // route's MEMBERSHIP semantics, not the ids named inside somebody
1460
- // else's document.
1461
- //
1462
- // Same filter, same argument-one decision, same residual as
1463
- // `/:id/{relationship}` above -- read the block there.
1464
- const linkage = createLinkageFilter(request);
1465
- const isLinkable = (r: OrmRecord) => {
1466
- const type = (r as { __model?: { __name?: string } }).__model?.__name;
1467
-
1468
- return typeof type === 'string' && type !== '' && linkage(type, r);
1469
- };
1470
-
1471
1505
  let data: unknown;
1472
1506
  if (info.isArray) {
1473
1507
  // hasMany - return array of linkage objects
1474
1508
  const related = Array.isArray(relatedData) ? relatedData.filter(isOrmRecord) : [];
1475
1509
  data = related
1476
1510
  .filter((r): r is OrmRecord & { __model: { __name: string } } => Boolean(r.__model))
1477
- .filter(isLinkable)
1478
1511
  .map(r => ({ type: r.__model.__name, id: r.id }));
1479
1512
  } else {
1480
1513
  // belongsTo - return single linkage or null
1481
1514
  if (isOrmRecord(relatedData) && relatedData.__model) {
1482
- if (!isLinkable(relatedData)) return 404;
1483
-
1484
1515
  data = { type: relatedData.__model.__name, id: relatedData.id };
1485
1516
  } else {
1486
1517
  data = null;