@stonyx/orm 0.3.2-alpha.87 → 0.3.2-alpha.89

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
@@ -985,10 +985,13 @@ per-record filter. An input you cannot identify must **deny**.
985
985
  "indistinguishable" as a property of the response — it is a property of the
986
986
  relationship member alone. The other channels in the same class are
987
987
  [#245](https://github.com/abofs/stonyx-orm/issues/245) (computed attributes,
988
- which is the one measured above),
989
- [#233](https://github.com/abofs/stonyx-orm/issues/233) (`included`
990
- membership) and [#246](https://github.com/abofs/stonyx-orm/issues/246) (the
991
- absence of `attributes.<fk>` on a `POST` response), all open. **Audit your
988
+ which is the one measured above) and
989
+ [#246](https://github.com/abofs/stonyx-orm/issues/246) (the
990
+ `attributes.<fk>` echo of an unresolved `belongsTo` target) — **both still
991
+ open**. `included` membership was the third,
992
+ [#233](https://github.com/abofs/stonyx-orm/issues/233), and it is **closed**:
993
+ a related record its own model's access class rejects is dropped at the
994
+ traversal's push site and is no longer a member. **Audit your
992
995
  computed properties before you treat a dropped member as unobservable.** Nothing on either family errors and no status changes — the
993
996
  status on these routes belongs to the **parent**, and `data` carries the
994
997
  answer about the related record. The `/relationships/` family built its `{type, id}` by
@@ -1046,24 +1049,35 @@ per-record filter. An input you cannot identify must **deny**.
1046
1049
  true for `GET /owners/{id}`, false for linkage, and false for a `hasMany`
1047
1050
  related-resource route.
1048
1051
 
1049
- - **`?include=` records are still not filtered the relationship routes now
1050
- are.** *Re-specified by [#232](https://github.com/abofs/stonyx-orm/issues/232);
1051
- the sentence this replaces said all three surfaces were unfiltered, and two of
1052
- them no longer are.* `GET /animals/1/owner` and
1052
+ - **`?include=` records are filtered on both questions now, and so are the
1053
+ relationship routes.** *Re-specified twice, each time by the story that
1054
+ falsified it, and recorded here rather than deleted. The original said all
1055
+ three surfaces were unfiltered.
1056
+ [#232](https://github.com/abofs/stonyx-orm/issues/232) made two of them
1057
+ filtered and the bullet became "**`?include=` records are still not
1058
+ filtered — the relationship routes now are**", with the body "**`?include=owner`
1059
+ still does not**: it serializes the related record without resolving that
1060
+ class, so a filter on `/owners` does not hide an owner reached through
1061
+ `?include=` on `/animals`."
1062
+ [#233](https://github.com/abofs/stonyx-orm/issues/233) falsified that half
1063
+ too.* `GET /animals/1/owner` and
1053
1064
  `GET /animals/1/relationships/owner` resolve the related model's own access
1054
- class (see the bullet above). **`?include=owner` still does not**: it
1055
- serializes the related record without resolving that class, so a filter on
1056
- `/owners` does not hide an owner reached through `?include=` on `/animals`.
1057
- There are **two** open questions here and they are owned separately —
1058
- [#233](https://github.com/abofs/stonyx-orm/issues/233), the remaining child of
1065
+ class (see the bullet above). **`?include=owner` now resolves it as well**,
1066
+ at the traversal's push site, so a filter on `/owners` *does* hide an owner
1067
+ reached through `?include=` on `/animals`: measured on this branch,
1068
+ `GET /animals/1?include=owner` answers `200` with **no `included` array at
1069
+ all**, where before it served the hidden owner's full document.
1070
+ There were **two** questions here and they were owned separately — #233, the
1071
+ remaining child of
1059
1072
  [#196](https://github.com/abofs/stonyx-orm/issues/196), owns whether a
1060
1073
  resource enters `included` **at all** (membership), and
1061
1074
  [#235](https://github.com/abofs/stonyx-orm/issues/235) owns the
1062
1075
  `relationships.*.data` emitted **inside** a record that is already there
1063
- (linkage). Neither closes the other, and following only #233 will not lead you
1064
- to the second. Membership whether the related resource is served at all — is
1065
- a different question from which ids a document may *name*, immediately
1066
- below.
1076
+ (linkage). **Both have now landed, and neither closed the other** they are
1077
+ still answered by different mechanisms at different sites, and a resource can
1078
+ legitimately be a member while its own linkage is filtered. Membership
1079
+ whether the related resource is served at all — remains a different question
1080
+ from which ids a document may *name*, immediately below.
1067
1081
  - **Relationship linkage is filtered on every request-bound surface that
1068
1082
  serializes a record — the reads, the two writes, and `included`.** A
1069
1083
  document's `relationships.*.data` used to publish the id of every related
@@ -1117,6 +1131,59 @@ per-record filter. An input you cannot identify must **deny**.
1117
1131
  `404` on the `belongsTo` shape and did contradict this paragraph; that is
1118
1132
  measured and closed in the #232 bullet above.
1119
1133
 
1134
+ **[#233](https://github.com/abofs/stonyx-orm/issues/233) owns whether a
1135
+ related resource appears in `included` at all, and that question is now
1136
+ answered too.** #235 filters what a record *already in* `included` may
1137
+ **name**; #233 decides **membership**. They remain different questions
1138
+ answered by different mechanisms and neither closes the other — a resource
1139
+ can legitimately be a member while its own linkage is filtered. A related
1140
+ resource is judged by **its own** model's access class at the traversal's
1141
+ push site, so a record that class's **per-record filter** rejects is not a
1142
+ member, and **the
1143
+ subtree beneath it is never traversed**: dropping a parent *after* descending
1144
+ through it would publish that parent's exact child set. Measured on
1145
+ `dev @ c106cf9`, `GET /animals/1?include=owner,owner.pets` returned nine
1146
+ resources — the hidden owner plus her eight animals
1147
+ `[1, 3, 7, 10, 11, 15, 17, 20]`, which *is* her `pets` array, reconstructed
1148
+ for a caller who is `404` on the parent. It now returns no `included` array
1149
+ at all. A model **no access class claims** (`getAccess()` → `undefined`) is
1150
+ denied on this path too, so a collection the consumer never exposed is not
1151
+ reachable as a sideloaded resource either. **Drop, never error:** a pruned
1152
+ sideload is byte-identical to a genuinely empty one — same `200`, the same
1153
+ top-level keys, no `included` member on either, no `errors` — so its absence
1154
+ carries no signal about whether the record exists.
1155
+
1156
+ **Read "per-record filter" literally — it is not "everything the
1157
+ record-addressed route refuses", and the difference is measurable.** The
1158
+ linkage ask carries `recordId: null`, so a deny expressed as a
1159
+ request-scoped `return false` cannot fire on this path at all. The shipped
1160
+ sample expresses the `archived` owner's deny that way, and measured
1161
+ unauthenticated on this branch — byte-identically on `dev @ b23cfec`, so this
1162
+ is not a regression this change introduces —
1163
+ `GET /owners/archived` is `403` while `GET /animals/9500?include=owner`
1164
+ answers `200` with her full document. That is
1165
+ [#243](https://github.com/abofs/stonyx-orm/issues/243)'s mechanism, and #243
1166
+ records it on `GET /owners` only; it reaches `included` membership and the
1167
+ `#232` related-resource route the same way. **#233 does not close it, and a
1168
+ consumer who needs `?include=` to honour a per-record deny must express that
1169
+ deny as a filter.**
1170
+
1171
+ **And read "carries no signal" as a property of the `included` member, the
1172
+ same way [Known limitations](#known-limitations) already asks you to read
1173
+ "indistinguishable" — not as a property of the whole response.** The prune is
1174
+ unobservable in `included`; the *rest* of the document is a separate
1175
+ question, and the same computed-and-serialized-attribute channels recorded
1176
+ above still apply to it. Measured counter-example on this very fixture pair:
1177
+ `GET /traits/2?include=tag` prunes the unclaimed `tag` from `included` and
1178
+ still serves `attributes.tag: "never-mounted"`, while `GET /traits/1` has no
1179
+ `tag` key at all — an existence oracle in `attributes`, from
1180
+ `src/serializer.ts` echoing the raw foreign key of a `belongsTo` target that
1181
+ did not resolve. That is
1182
+ [#246](https://github.com/abofs/stonyx-orm/issues/246)'s mechanism reached on
1183
+ a **read**, with [#248](https://github.com/abofs/stonyx-orm/issues/248) as
1184
+ its precondition — **audit your attributes before you treat a pruned
1185
+ sideload as unobservable.**
1186
+
1120
1187
  **That resolves the right class; it does not guarantee a model-correct
1121
1188
  answer, and the failure direction is not the safe one.** Only a predicate that
1122
1189
  *reads* `context.model` can answer about the model it was asked about — see
@@ -1157,14 +1224,6 @@ per-record filter. An input you cannot identify must **deny**.
1157
1224
  [#247](https://github.com/abofs/stonyx-orm/pull/247) is in flight against
1158
1225
  this entry**; if it has landed, this route is covered and the bullet #247
1159
1226
  adds above supersedes this one.
1160
- - **Whether a related resource appears in `included` at all.**
1161
- [#233](https://github.com/abofs/stonyx-orm/issues/233) owns whether a
1162
- related resource appears in `included`. #235 filters what a record
1163
- *already in* `included` may **name**; a hidden record is still a
1164
- **member** of that array. The two are different questions and neither closes
1165
- the other: after #235, `GET /animals/1?include=owner,owner.pets` returns
1166
- `owner.data: null` on every permitted animal it sideloads **and still
1167
- includes the hidden owner as a resource**.
1168
1227
  - **A computed attribute that interpolates a related record's id.**
1169
1228
  [#245](https://github.com/abofs/stonyx-orm/issues/245) owns this channel,
1170
1229
  and **it is open as this is written**. `relationships.*.data` is a structure
@@ -1720,20 +1779,41 @@ GET /animals/1
1720
1779
  2. Recursively traverses relationships depth-first
1721
1780
  3. Deduplication still by type+id (no duplicates in included array)
1722
1781
  4. Gracefully handles null/missing relationships at any depth
1723
- 5. Each included record gets full `toJSON()` representation
1782
+ 5. Each related record is judged by **its own** model's access class at the
1783
+ push site before it is added, so a denied record neither enters `included`
1784
+ nor becomes a parent at the next depth
1785
+ ([#233](https://github.com/abofs/stonyx-orm/issues/233)) — see the
1786
+ Limitations below for what "denied" does and does not cover
1787
+ 6. Each included record gets full `toJSON()` representation, with its
1788
+ **linkage filtered** by the same verdict object the primary document was
1789
+ serialized with ([#235](https://github.com/abofs/stonyx-orm/issues/235))
1724
1790
 
1725
1791
  #### Limitations
1726
1792
 
1727
1793
  - Only available on GET endpoints (not POST/PATCH)
1728
- - **`included` is access-filtered on one of the two questions, not both.** What
1729
- a record already in `included` may **name** in its own
1794
+ - **`included` is access-filtered on both of the two questions.** *Re-specified
1795
+ by [#233](https://github.com/abofs/stonyx-orm/issues/233). The sentence this
1796
+ replaces said membership "is still unfiltered (#233): a record that is 404 on
1797
+ its own routes is still served as an `included` resource, attributes and
1798
+ all."* What a record already in `included` may **name** in its own
1730
1799
  `relationships.*.data` is filtered
1731
1800
  ([#235](https://github.com/abofs/stonyx-orm/issues/235)) — `?include=` no
1732
1801
  longer republishes ids the primary document withholds. Whether a resource
1733
- appears in `included` **at all** is *membership* and is still unfiltered
1734
- ([#233](https://github.com/abofs/stonyx-orm/issues/233)): a record that is
1735
- 404 on its own routes is still served as an `included` resource, attributes
1736
- and all. See [Consumer Contracts](#consumer-contracts).
1802
+ appears in `included` **at all** is *membership*, and #233 filters it: a
1803
+ related resource is judged by **its own** model's access class at the
1804
+ traversal's push site, so a record that class's **per-record filter** rejects
1805
+ is not a member, and the subtree beneath it is never traversed.
1806
+ - **Membership is filtered by the per-record filter, not by everything a
1807
+ record-addressed route refuses.** A deny expressed as a request-scoped
1808
+ `return false` — the shape the shipped sample uses for the `archived`
1809
+ owner — is **not** expressible on this path, because the linkage ask carries
1810
+ `recordId: null`. Measured on this branch and byte-identically on `dev`:
1811
+ `GET /owners/archived` is `403` while `GET /animals/9500?include=owner`
1812
+ serves her document in full. That is
1813
+ [#243](https://github.com/abofs/stonyx-orm/issues/243)'s mechanism, not
1814
+ #233's, and it reaches every linkage surface rather than only `GET /owners`.
1815
+ Express a per-record deny as a **filter** if you need `?include=` to honour
1816
+ it. See [Consumer Contracts](#consumer-contracts).
1737
1817
 
1738
1818
  ## Lifecycle Hooks
1739
1819
 
@@ -76,8 +76,18 @@
76
76
  * signal of is WHICH related record is being asked about -- `recordId` is
77
77
  * `null` there and `request.params` names a record of a different model. See
78
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.
79
+ * that limit.
80
+ *
81
+ * AND THE `?include=` HALF IS NOW OUT OF DATE TOO, CORRECTED THE SAME WAY. It
82
+ * read: "`?include=` is still unfiltered and is abofs/stonyx-orm#233 / #235."
83
+ * Both have landed. #235 filters what a record already in `included` may NAME,
84
+ * and #233 filters MEMBERSHIP at the traversal's push site -- see
85
+ * `traverseIncludePath` below. The ask is the same shape as the
86
+ * related-resource one and carries the same limit: `recordId` is `null`, so a
87
+ * deny expressible only as a request-scoped `return false` -- which is how the
88
+ * shipped sample spells `/archived` -- still cannot fire on this path. That
89
+ * residual is abofs/stonyx-orm#243's, not #233's, and it is measured
90
+ * byte-identical on `dev`.
81
91
  *
82
92
  * SUPERSEDED 2026-09-01 BY abofs/stonyx-orm#236/#237, AND KEPT FOR THE
83
93
  * CONSTRAINT IT STATES RATHER THAN AS A DESCRIPTION OF THE CODE. The context
@@ -76,8 +76,18 @@
76
76
  * signal of is WHICH related record is being asked about -- `recordId` is
77
77
  * `null` there and `request.params` names a record of a different model. See
78
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.
79
+ * that limit.
80
+ *
81
+ * AND THE `?include=` HALF IS NOW OUT OF DATE TOO, CORRECTED THE SAME WAY. It
82
+ * read: "`?include=` is still unfiltered and is abofs/stonyx-orm#233 / #235."
83
+ * Both have landed. #235 filters what a record already in `included` may NAME,
84
+ * and #233 filters MEMBERSHIP at the traversal's push site -- see
85
+ * `traverseIncludePath` below. The ask is the same shape as the
86
+ * related-resource one and carries the same limit: `recordId` is `null`, so a
87
+ * deny expressible only as a request-scoped `return false` -- which is how the
88
+ * shipped sample spells `/archived` -- still cannot fire on this path. That
89
+ * residual is abofs/stonyx-orm#243's, not #233's, and it is measured
90
+ * byte-identical on `dev`.
81
91
  *
82
92
  * SUPERSEDED 2026-09-01 BY abofs/stonyx-orm#236/#237, AND KEPT FOR THE
83
93
  * CONSTRAINT IT STATES RATHER THAN AS A DESCRIPTION OF THE CODE. The context
@@ -445,17 +455,27 @@ function buildResponse(data, includeParam, recordOrRecords, options = {}) {
445
455
  const includes = parseInclude(includeParam);
446
456
  if (includes.length === 0)
447
457
  return response;
448
- const includedRecords = collectIncludedRecords(recordOrRecords, includes);
458
+ // THE SAME FILTER OBJECT DECIDES MEMBERSHIP AND LINKAGE, AND IT IS PASSED TO
459
+ // BOTH (abofs/stonyx-orm#233). It carries #234's per-type verdict cache and
460
+ // per-(type, id) decision cache, so the traversal below and the `toJSON`
461
+ // calls beneath it share one resolution of the consumer's `access()` per
462
+ // type for the whole response. Building a second filter here would double
463
+ // every predicate call and, worse, could answer the two questions
464
+ // differently about the same record.
465
+ const includedRecords = collectIncludedRecords(recordOrRecords, includes, linkage);
449
466
  if (includedRecords.length > 0) {
450
467
  // LINKAGE, NOT MEMBERSHIP -- and the distinction is the whole reason this
451
468
  // line is one story's and the line above it is another's
452
469
  // (abofs/stonyx-orm#235 and #233 respectively).
453
470
  //
454
471
  // - WHICH RESOURCES REACH THIS ARRAY is decided by
455
- // `collectIncludedRecords` on the line above. That is MEMBERSHIP, it is
456
- // #233's, and it is deliberately untouched here: a hidden owner is
457
- // still a member of `included` after this change. Pinned green by
458
- // `[GUARD] #235 X1` so that #235 cannot close #233 incidentally.
472
+ // `collectIncludedRecords` on the line above. That is MEMBERSHIP and
473
+ // it is #233's. As of #233 that call is given the SAME `linkage`
474
+ // filter, so a hidden owner is no longer a member: she is dropped at
475
+ // the push site and her subtree is never traversed. Pinned by
476
+ // `[DEFECT] #233 AC2` and `[DEFECT] #233 AC4`; the re-specification of
477
+ // `[GUARD] #235 X1`, which pinned the PRE-#233 answer here, is in that
478
+ // same test.
459
479
  // - WHAT A RECORD ALREADY IN THIS ARRAY MAY NAME in its own
460
480
  // `relationships.*.data` is LINKAGE -- the same question #234 answers
461
481
  // for the primary document -- and that is what the `linkage` option
@@ -498,9 +518,45 @@ function buildResponse(data, includeParam, recordOrRecords, options = {}) {
498
518
  return response;
499
519
  }
500
520
  /**
501
- * Recursively traverse an include path and collect related records
521
+ * Recursively traverse an include path and collect related records.
522
+ *
523
+ * ---------------------------------------------------------------------------
524
+ * THE `linkage` FILTER DECIDES MEMBERSHIP HERE (abofs/stonyx-orm#233)
525
+ * ---------------------------------------------------------------------------
526
+ * A resource reaches `included` because some record NAMED it, and until #233
527
+ * being named was the whole test. That made `?include=` a restoration of every
528
+ * record the read surfaces withhold: `GET /owners/angela` is 404 and
529
+ * `GET /animals/1?include=owner` returned her document in full, attributes and
530
+ * all. Measured on dev @ 8dda5d6, over the live router.
531
+ *
532
+ * FILTERED AT THE PUSH SITE, AND THE SITE MATTERS. The obvious alternative --
533
+ * let the traversal run and filter `collectIncludedRecords`' RETURN value --
534
+ * closes the membership half and leaves the worse half open: dropping a parent
535
+ * AFTER traversing through it publishes that parent's exact child set. On this
536
+ * repo's own fixture `GET /animals/1?include=owner,owner.pets` names angela's
537
+ * eight animals `[1, 3, 7, 10, 11, 15, 17, 20]`, which IS her `pets` array,
538
+ * reconstructed from a resource the caller may not read. So a denied record is
539
+ * `continue`d before it is pushed to `included` AND before it is pushed to
540
+ * `nextRecords`, which is what prunes the subtree.
541
+ *
542
+ * A DENIED RECORD IS DELIBERATELY NOT ADDED TO `seen`. `seen` is the
543
+ * deduplicator for records that DID enter `included`; putting a denial in it
544
+ * would conflate "already emitted" with "withheld", and the `else if` branch
545
+ * below would then push a denied record into `nextRecords` for deeper
546
+ * traversal -- re-opening the prune this function just closed. Re-asking is
547
+ * free: #234's filter caches per `(type, id)`, so the second ask is a `Map`
548
+ * hit and not a call into the consumer's `access()`.
549
+ *
550
+ * ABSENT FILTER MEANS PRE-#233 BEHAVIOUR, NOT A DENIAL. `linkage` is optional
551
+ * for the same reason it is optional on `buildResponse` and on
552
+ * `Record.toJSON`: an absent option means "no verdict was supplied", and the
553
+ * honest degradation is the document that shipped before, not an empty one.
554
+ * Both of `buildResponse`'s callers -- `getCollectionHandler` and
555
+ * `getSingleHandler`, the only two -- supply it, which is pinned by
556
+ * `[GUARD] #233 AC8`. What must never arrive here is a non-function; the
557
+ * guard below is the fail-closed reading of one.
502
558
  */
503
- function traverseIncludePath(currentRecords, includePath, depth, seen, included) {
559
+ function traverseIncludePath(currentRecords, includePath, depth, seen, included, linkage) {
504
560
  if (depth >= includePath.length)
505
561
  return; // Reached end of path
506
562
  const relationshipName = includePath[depth];
@@ -524,6 +580,19 @@ function traverseIncludePath(currentRecords, includePath, depth, seen, included)
524
580
  continue;
525
581
  const type = relatedRecord.__model.__name;
526
582
  const id = relatedRecord.id;
583
+ // MEMBERSHIP AND PRUNE, abofs/stonyx-orm#233. `continue` skips BOTH
584
+ // pushes below -- the record does not enter `included` and it does not
585
+ // become a parent at the next depth.
586
+ //
587
+ // FAIL CLOSED ON A RECORD WHOSE TYPE CANNOT BE NAMED, the same reading
588
+ // #232's `isLinkable` uses on the relationship routes: `type` is the key
589
+ // the verdict is resolved under, so a missing or empty one means there
590
+ // is no predicate to ask and no way to ask it. Denying is the only safe
591
+ // answer, and it is only reachable while a filter is in force -- with no
592
+ // filter this whole check is skipped and the pre-#233 document is
593
+ // emitted unchanged.
594
+ if (linkage && !(typeof type === 'string' && type !== '' && linkage(type, relatedRecord)))
595
+ continue;
527
596
  // Initialize Set for this type if needed
528
597
  let seenIds = seen.get(type);
529
598
  if (!seenIds) {
@@ -544,10 +613,10 @@ function traverseIncludePath(currentRecords, includePath, depth, seen, included)
544
613
  }
545
614
  // If there are more segments in the path, recursively process
546
615
  if (depth < includePath.length - 1 && nextRecords.length > 0) {
547
- traverseIncludePath(nextRecords, includePath, depth + 1, seen, included);
616
+ traverseIncludePath(nextRecords, includePath, depth + 1, seen, included, linkage);
548
617
  }
549
618
  }
550
- function collectIncludedRecords(data, includes) {
619
+ function collectIncludedRecords(data, includes, linkage) {
551
620
  if (!includes || includes.length === 0)
552
621
  return [];
553
622
  if (!data)
@@ -558,7 +627,7 @@ function collectIncludedRecords(data, includes) {
558
627
  const records = Array.isArray(data) ? data : [data];
559
628
  // Process each include path
560
629
  for (const includePath of includes) {
561
- traverseIncludePath(records, includePath, 0, seen, included);
630
+ traverseIncludePath(records, includePath, 0, seen, included, linkage);
562
631
  }
563
632
  return included;
564
633
  }
@@ -699,11 +768,17 @@ export default class OrmRequest extends Request {
699
768
  // is the SAME object the primary document is serialized with -- one
700
769
  // verdict per type for the whole response, sideload included.
701
770
  //
702
- // The boundary that remains, so the next reader does not have to derive
703
- // it: this closes what a record already in `included` may NAME. WHETHER a
771
+ // The boundary, so the next reader does not have to derive it: this
772
+ // closes what a record already in `included` may NAME. WHETHER a
704
773
  // resource appears in `included` at all is MEMBERSHIP and it is
705
- // abofs/stonyx-orm#233's -- a hidden owner is still a member here.
706
- // Neither question closes the other.
774
+ // abofs/stonyx-orm#233's. THAT IS NOW CLOSED TOO, and the same `linkage`
775
+ // object closes it: `buildResponse` hands this filter to
776
+ // `collectIncludedRecords`, which denies at the push site. Corrected
777
+ // rather than deleted -- this comment read "a hidden owner is still a
778
+ // member here", which is the sentence the identical copy in
779
+ // `buildResponse` carried and which #233 falsified in both places. They
780
+ // are still two questions and neither closes the other: a record can be
781
+ // a member while its own linkage is filtered.
707
782
  return buildResponse(record.toJSON?.({ fields: modelFields, baseUrl, linkage }), request.query?.include, record, {
708
783
  links: { self: `${baseUrl}/${pluralizedModel}/${request.params.id}` },
709
784
  baseUrl,
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "stonyx-async",
5
5
  "stonyx-module"
6
6
  ],
7
- "version": "0.3.2-alpha.87",
7
+ "version": "0.3.2-alpha.89",
8
8
  "description": "",
9
9
  "main": "dist/index.js",
10
10
  "type": "module",
@@ -76,8 +76,18 @@
76
76
  * signal of is WHICH related record is being asked about -- `recordId` is
77
77
  * `null` there and `request.params` names a record of a different model. See
78
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.
79
+ * that limit.
80
+ *
81
+ * AND THE `?include=` HALF IS NOW OUT OF DATE TOO, CORRECTED THE SAME WAY. It
82
+ * read: "`?include=` is still unfiltered and is abofs/stonyx-orm#233 / #235."
83
+ * Both have landed. #235 filters what a record already in `included` may NAME,
84
+ * and #233 filters MEMBERSHIP at the traversal's push site -- see
85
+ * `traverseIncludePath` below. The ask is the same shape as the
86
+ * related-resource one and carries the same limit: `recordId` is `null`, so a
87
+ * deny expressible only as a request-scoped `return false` -- which is how the
88
+ * shipped sample spells `/archived` -- still cannot fire on this path. That
89
+ * residual is abofs/stonyx-orm#243's, not #233's, and it is measured
90
+ * byte-identical on `dev`.
81
91
  *
82
92
  * SUPERSEDED 2026-09-01 BY abofs/stonyx-orm#236/#237, AND KEPT FOR THE
83
93
  * CONSTRAINT IT STATES RATHER THAN AS A DESCRIPTION OF THE CODE. The context
@@ -490,17 +500,27 @@ function buildResponse(
490
500
  const includes = parseInclude(includeParam);
491
501
  if (includes.length === 0) return response;
492
502
 
493
- const includedRecords = collectIncludedRecords(recordOrRecords, includes);
503
+ // THE SAME FILTER OBJECT DECIDES MEMBERSHIP AND LINKAGE, AND IT IS PASSED TO
504
+ // BOTH (abofs/stonyx-orm#233). It carries #234's per-type verdict cache and
505
+ // per-(type, id) decision cache, so the traversal below and the `toJSON`
506
+ // calls beneath it share one resolution of the consumer's `access()` per
507
+ // type for the whole response. Building a second filter here would double
508
+ // every predicate call and, worse, could answer the two questions
509
+ // differently about the same record.
510
+ const includedRecords = collectIncludedRecords(recordOrRecords, includes, linkage);
494
511
  if (includedRecords.length > 0) {
495
512
  // LINKAGE, NOT MEMBERSHIP -- and the distinction is the whole reason this
496
513
  // line is one story's and the line above it is another's
497
514
  // (abofs/stonyx-orm#235 and #233 respectively).
498
515
  //
499
516
  // - WHICH RESOURCES REACH THIS ARRAY is decided by
500
- // `collectIncludedRecords` on the line above. That is MEMBERSHIP, it is
501
- // #233's, and it is deliberately untouched here: a hidden owner is
502
- // still a member of `included` after this change. Pinned green by
503
- // `[GUARD] #235 X1` so that #235 cannot close #233 incidentally.
517
+ // `collectIncludedRecords` on the line above. That is MEMBERSHIP and
518
+ // it is #233's. As of #233 that call is given the SAME `linkage`
519
+ // filter, so a hidden owner is no longer a member: she is dropped at
520
+ // the push site and her subtree is never traversed. Pinned by
521
+ // `[DEFECT] #233 AC2` and `[DEFECT] #233 AC4`; the re-specification of
522
+ // `[GUARD] #235 X1`, which pinned the PRE-#233 answer here, is in that
523
+ // same test.
504
524
  // - WHAT A RECORD ALREADY IN THIS ARRAY MAY NAME in its own
505
525
  // `relationships.*.data` is LINKAGE -- the same question #234 answers
506
526
  // for the primary document -- and that is what the `linkage` option
@@ -545,14 +565,51 @@ function buildResponse(
545
565
  }
546
566
 
547
567
  /**
548
- * Recursively traverse an include path and collect related records
568
+ * Recursively traverse an include path and collect related records.
569
+ *
570
+ * ---------------------------------------------------------------------------
571
+ * THE `linkage` FILTER DECIDES MEMBERSHIP HERE (abofs/stonyx-orm#233)
572
+ * ---------------------------------------------------------------------------
573
+ * A resource reaches `included` because some record NAMED it, and until #233
574
+ * being named was the whole test. That made `?include=` a restoration of every
575
+ * record the read surfaces withhold: `GET /owners/angela` is 404 and
576
+ * `GET /animals/1?include=owner` returned her document in full, attributes and
577
+ * all. Measured on dev @ 8dda5d6, over the live router.
578
+ *
579
+ * FILTERED AT THE PUSH SITE, AND THE SITE MATTERS. The obvious alternative --
580
+ * let the traversal run and filter `collectIncludedRecords`' RETURN value --
581
+ * closes the membership half and leaves the worse half open: dropping a parent
582
+ * AFTER traversing through it publishes that parent's exact child set. On this
583
+ * repo's own fixture `GET /animals/1?include=owner,owner.pets` names angela's
584
+ * eight animals `[1, 3, 7, 10, 11, 15, 17, 20]`, which IS her `pets` array,
585
+ * reconstructed from a resource the caller may not read. So a denied record is
586
+ * `continue`d before it is pushed to `included` AND before it is pushed to
587
+ * `nextRecords`, which is what prunes the subtree.
588
+ *
589
+ * A DENIED RECORD IS DELIBERATELY NOT ADDED TO `seen`. `seen` is the
590
+ * deduplicator for records that DID enter `included`; putting a denial in it
591
+ * would conflate "already emitted" with "withheld", and the `else if` branch
592
+ * below would then push a denied record into `nextRecords` for deeper
593
+ * traversal -- re-opening the prune this function just closed. Re-asking is
594
+ * free: #234's filter caches per `(type, id)`, so the second ask is a `Map`
595
+ * hit and not a call into the consumer's `access()`.
596
+ *
597
+ * ABSENT FILTER MEANS PRE-#233 BEHAVIOUR, NOT A DENIAL. `linkage` is optional
598
+ * for the same reason it is optional on `buildResponse` and on
599
+ * `Record.toJSON`: an absent option means "no verdict was supplied", and the
600
+ * honest degradation is the document that shipped before, not an empty one.
601
+ * Both of `buildResponse`'s callers -- `getCollectionHandler` and
602
+ * `getSingleHandler`, the only two -- supply it, which is pinned by
603
+ * `[GUARD] #233 AC8`. What must never arrive here is a non-function; the
604
+ * guard below is the fail-closed reading of one.
549
605
  */
550
606
  function traverseIncludePath(
551
607
  currentRecords: OrmRecord[],
552
608
  includePath: string[],
553
609
  depth: number,
554
610
  seen: Map<string, Set<string | number>>,
555
- included: OrmRecord[]
611
+ included: OrmRecord[],
612
+ linkage?: LinkageFilter
556
613
  ): void {
557
614
  if (depth >= includePath.length) return; // Reached end of path
558
615
 
@@ -578,6 +635,19 @@ function traverseIncludePath(
578
635
  const type = relatedRecord.__model.__name;
579
636
  const id = relatedRecord.id as string | number;
580
637
 
638
+ // MEMBERSHIP AND PRUNE, abofs/stonyx-orm#233. `continue` skips BOTH
639
+ // pushes below -- the record does not enter `included` and it does not
640
+ // become a parent at the next depth.
641
+ //
642
+ // FAIL CLOSED ON A RECORD WHOSE TYPE CANNOT BE NAMED, the same reading
643
+ // #232's `isLinkable` uses on the relationship routes: `type` is the key
644
+ // the verdict is resolved under, so a missing or empty one means there
645
+ // is no predicate to ask and no way to ask it. Denying is the only safe
646
+ // answer, and it is only reachable while a filter is in force -- with no
647
+ // filter this whole check is skipped and the pre-#233 document is
648
+ // emitted unchanged.
649
+ if (linkage && !(typeof type === 'string' && type !== '' && linkage(type, relatedRecord))) continue;
650
+
581
651
  // Initialize Set for this type if needed
582
652
  let seenIds = seen.get(type);
583
653
  if (!seenIds) {
@@ -599,11 +669,15 @@ function traverseIncludePath(
599
669
 
600
670
  // If there are more segments in the path, recursively process
601
671
  if (depth < includePath.length - 1 && nextRecords.length > 0) {
602
- traverseIncludePath(nextRecords, includePath, depth + 1, seen, included);
672
+ traverseIncludePath(nextRecords, includePath, depth + 1, seen, included, linkage);
603
673
  }
604
674
  }
605
675
 
606
- function collectIncludedRecords(data: OrmRecord | OrmRecord[], includes: string[][]): OrmRecord[] {
676
+ function collectIncludedRecords(
677
+ data: OrmRecord | OrmRecord[],
678
+ includes: string[][],
679
+ linkage?: LinkageFilter
680
+ ): OrmRecord[] {
607
681
  if (!includes || includes.length === 0) return [];
608
682
  if (!data) return [];
609
683
 
@@ -615,7 +689,7 @@ function collectIncludedRecords(data: OrmRecord | OrmRecord[], includes: string[
615
689
 
616
690
  // Process each include path
617
691
  for (const includePath of includes) {
618
- traverseIncludePath(records, includePath, 0, seen, included);
692
+ traverseIncludePath(records, includePath, 0, seen, included, linkage);
619
693
  }
620
694
 
621
695
  return included;
@@ -775,11 +849,17 @@ export default class OrmRequest extends Request {
775
849
  // is the SAME object the primary document is serialized with -- one
776
850
  // verdict per type for the whole response, sideload included.
777
851
  //
778
- // The boundary that remains, so the next reader does not have to derive
779
- // it: this closes what a record already in `included` may NAME. WHETHER a
852
+ // The boundary, so the next reader does not have to derive it: this
853
+ // closes what a record already in `included` may NAME. WHETHER a
780
854
  // resource appears in `included` at all is MEMBERSHIP and it is
781
- // abofs/stonyx-orm#233's -- a hidden owner is still a member here.
782
- // Neither question closes the other.
855
+ // abofs/stonyx-orm#233's. THAT IS NOW CLOSED TOO, and the same `linkage`
856
+ // object closes it: `buildResponse` hands this filter to
857
+ // `collectIncludedRecords`, which denies at the push site. Corrected
858
+ // rather than deleted -- this comment read "a hidden owner is still a
859
+ // member here", which is the sentence the identical copy in
860
+ // `buildResponse` carried and which #233 falsified in both places. They
861
+ // are still two questions and neither closes the other: a record can be
862
+ // a member while its own linkage is filtered.
783
863
  return buildResponse(record.toJSON?.({ fields: modelFields, baseUrl, linkage }), request.query?.include, record, {
784
864
  links: { self: `${baseUrl}/${pluralizedModel}/${request.params.id}` },
785
865
  baseUrl,