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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -439,11 +439,22 @@ export default class GlobalAccess {
439
439
  // does not remove a rule loudly, it turns a deny into an ALLOW, silently.
440
440
  if (recordId === 'archived') return false;
441
441
 
442
- // Returning a function plugs it in as a per-record filter, and it is
443
- // enforced on every surface addressed to one of these records:
442
+ // Returning a function plugs it in as a per-record filter. It is enforced
443
+ // on every surface addressed to one of these records
444
444
  // /owners, /owners/:id, /owners/:id/pets, /owners/:id/relationships/pets
445
- // A rejected record is 404 on record routes the same status as a record
446
- // that does not exist — so the filter is not an existence oracle.
445
+ // AND, since #232, on every surface that reaches one of these records
446
+ // as the RELATED resource of another model:
447
+ // /animals/:id/owner, /animals/:id/relationships/owner
448
+ // Both readings are the same rule: an owner this predicate rejects is
449
+ // withheld wherever she is reachable, not only on /owners.
450
+ //
451
+ // NOTHING HERE IS AN EXISTENCE ORACLE, AND THE SPELLING DIFFERS BY WHOSE
452
+ // RECORD IS BEING REJECTED. A rejected ADDRESSED record is 404 — the same
453
+ // status as a record that does not exist. A rejected RELATED record is
454
+ // `data: null` at 200 — byte-identical to a relationship that is
455
+ // genuinely empty, because on those routes 404 is already the answer for
456
+ // a PARENT that does not exist. In both cases "rejected" and "not there"
457
+ // are the same answer, which is the property that matters.
447
458
  return record => record.id !== 'angela' && record.id !== 'restricted';
448
459
  }
449
460
 
@@ -669,9 +680,11 @@ A `throw` inside `access()` is a **denial**, not a 500.
669
680
  A function return value is a **per-record predicate**, and it is enforced on
670
681
  every endpoint that is addressed to a record — not only on the collection.
671
682
 
672
- It is evaluated against the record the route is *addressed to*, **on that model
673
- only**. It is not a guarantee that a hidden record cannot be reached or modified:
674
- a write to a *different* collection can still re-parent one. See
683
+ It is evaluated against the record the route is *addressed to*. On the two
684
+ relationship route families it is **also** evaluated against the **related**
685
+ record, by that record's *own* model's predicate see the two `{relationship}`
686
+ rows below. It is not a guarantee that a hidden record cannot be reached or
687
+ modified: a write to a *different* collection can still re-parent one. See
675
688
  [Known limitations](#known-limitations) and
676
689
  [#207](https://github.com/abofs/stonyx-orm/issues/207).
677
690
 
@@ -679,19 +692,42 @@ a write to a *different* collection can still re-parent one. See
679
692
  |---|---|
680
693
  | `GET /:models` | omitted from the collection |
681
694
  | `GET /:models/:id` | `404` |
682
- | `GET /:models/:id/{relationship}` | `404` the **addressed** record is filtered, not the related one |
683
- | `GET /:models/:id/relationships/{relationship}` | `404` same |
695
+ | `GET /:models/:id/{relationship}` | the **addressed** record → `404`. The **related** record `200` with `data: null` for a `belongsTo`, or dropped from the array for a `hasMany` |
696
+ | `GET /:models/:id/relationships/{relationship}` | same, on the linkage objects |
684
697
  | `PATCH /:models/:id` | `404`, no attribute is applied |
685
698
  | `DELETE /:models/:id` | `404`, the record is not removed and no SQL `DELETE` is issued |
686
699
  | `POST /:models` | `403`, and a record **this request inserted** is rolled back — see [Known limitations](#known-limitations) for why the rollback is conditional |
687
700
 
701
+ **A withheld related record is not an error, and that is the same rule.** The
702
+ addressed record is filtered with `404` and the related one with `data: null`
703
+ because in both cases the answer must be **identical to the answer for a record
704
+ that does not exist**. A `belongsTo` whose target is genuinely absent already
705
+ answers `200 {"data": null}`; a `hasMany` with no members already answers `200`
706
+ with an empty array. Withholding therefore has to be spelled the same way, or
707
+ the route becomes an existence oracle for a record on a collection the caller
708
+ may have no access to at all. Measured before this was closed — unauthenticated,
709
+ zero query parameters, one request each:
710
+
711
+ ```
712
+ GET /traits/1/tag [target absent] -> 200 application/json 68 bytes
713
+ GET /traits/2/tag [target denied] -> 404 text/plain 9 bytes
714
+ ```
715
+
716
+ `tag` is a model with **no route mounted at all**, so those two requests were the
717
+ only way to ask about it — and they answered differently. Both now answer
718
+ `200 {"data": null}`.
719
+
688
720
  **Denied record-level requests return 404, not 403.** This is deliberate and it
689
721
  is the property most easily "improved" away. 403 would confirm that the record
690
722
  exists to a caller who is not allowed to know that, which turns the filter into
691
723
  an existence oracle: `404` means "no such record", `403` means "there is one and
692
724
  it is not yours". Every status on a record route must therefore be identical for
693
725
  "filtered out" and "does not exist" — including `DELETE`, which is why deleting
694
- a record that never existed also returns 404 rather than 204.
726
+ a record that never existed also returns 404 rather than 204, and including the
727
+ **related** record on the two relationship families, which is why a denied
728
+ `belongsTo` target is `200 {"data": null}` rather than `404`: on that route
729
+ `404` is the answer for a parent that does not exist, so it is `data: null`, and
730
+ not the status, that carries "no target you may see".
695
731
 
696
732
  `POST` is the one exception and returns **403**, because 404 on a mounted
697
733
  collection route is indistinguishable from "model not mounted" — a genuinely
@@ -933,9 +969,11 @@ per-record filter. An input you cannot identify must **deny**.
933
969
  filter decides whether it is served at all, not merely which ids a document
934
970
  may name. A denied `hasMany` member is **dropped from the array** — the result
935
971
  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
972
+ `errors` member, same status. A denied `belongsTo` target answers **`200` with
973
+ `data: null`**, byte-identical to a target that is genuinely absent, for the
974
+ same reason. Nothing on either family errors and no status changes — the
975
+ status on these routes belongs to the **parent**, and `data` carries the
976
+ answer about the related record. The `/relationships/` family built its `{type, id}` by
939
977
  hand rather than through `toJSON()`, which is why the linkage filter shipped in
940
978
  [#234](https://github.com/abofs/stonyx-orm/issues/234) did not reach it.
941
979
 
@@ -946,12 +984,29 @@ per-record filter. An input you cannot identify must **deny**.
946
984
  readable as a related resource — a collection the consumer deliberately never
947
985
  exposed.
948
986
 
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.
987
+ **The `belongsTo` shape is not an existence oracle, and it was measured
988
+ rather than reasoned about.** An earlier revision of this fix answered `404`
989
+ for a denied target, which made it distinguishable from a target that is
990
+ genuinely absent. Unauthenticated, zero query parameters, one request each, on
991
+ `tag`a model with **no route mounted at all**:
992
+
993
+ ```
994
+ GET /traits/1/tag [target absent] -> 200 application/json 68 bytes
995
+ GET /traits/2/tag [target denied] -> 404 text/plain 9 bytes
996
+ ```
997
+
998
+ `GET /traits/1` and `GET /traits/2` report `relationships.tag = {"data":null}`
999
+ byte-identical modulo the id, because
1000
+ [#234](https://github.com/abofs/stonyx-orm/issues/234) closed that oracle
1001
+ deliberately — so this route was the one remaining way to ask which of those
1002
+ two nulls was a denial. Under `data: null` both requests answer `200`, same
1003
+ content-type, same content-length, same bytes modulo the parent id the caller
1004
+ put in the URL. It discloses nothing further: `links` on these routes are
1005
+ derived entirely from the parent and the relationship name, there is no `meta`
1006
+ and there are no counts. This also brings the two families back into line with
1007
+ the module-wide rule under [Filter functions](#filter-functions) — *every
1008
+ status on a record route must be identical for "filtered out" and "does not
1009
+ exist"* — which the `404` spelling was an exception to.
955
1010
 
956
1011
  **Per-record denies for a related resource are not expressible.** A predicate resolved for a
957
1012
  related resource on these routes receives `recordId: null` and a `request`
@@ -981,10 +1036,15 @@ per-record filter. An input you cannot identify must **deny**.
981
1036
  class (see the bullet above). **`?include=owner` still does not**: it
982
1037
  serializes the related record without resolving that class, so a filter on
983
1038
  `/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
1039
+ There are **two** open questions here and they are owned separately —
1040
+ [#233](https://github.com/abofs/stonyx-orm/issues/233), the remaining child of
1041
+ [#196](https://github.com/abofs/stonyx-orm/issues/196), owns whether a
1042
+ resource enters `included` **at all** (membership), and
1043
+ [#235](https://github.com/abofs/stonyx-orm/issues/235) owns the
1044
+ `relationships.*.data` emitted **inside** a record that is already there
1045
+ (linkage). Neither closes the other, and following only #233 will not lead you
1046
+ to the second. Membership — whether the related resource is served at all — is
1047
+ a different question from which ids a document may *name*, immediately
988
1048
  below.
989
1049
  - **Relationship linkage is filtered on the four request-bound read surfaces,
990
1050
  and only there.** A document's `relationships.*.data` used to publish the id
@@ -1003,6 +1063,15 @@ per-record filter. An input you cannot identify must **deny**.
1003
1063
  because throwing here would be an existence oracle *and* would throw out of
1004
1064
  the enclosing `JSON.stringify`.
1005
1065
 
1066
+ **[#232](https://github.com/abofs/stonyx-orm/issues/232) holds to the same
1067
+ spelling on the routes where that linkage is the *primary* data.** A denied
1068
+ member is dropped from the `hasMany` array and a denied `belongsTo` target is
1069
+ `data: null`, at `200`, `links` intact — so the claim above is true of both
1070
+ `GET /:models/:id/{relationship}` shapes as *routes* and not only as linkage
1071
+ emitted inside somebody else's document. An earlier revision of #232 answered
1072
+ `404` on the `belongsTo` shape and did contradict this paragraph; that is
1073
+ measured and closed in the #232 bullet above.
1074
+
1006
1075
  **That resolves the right class; it does not guarantee a model-correct
1007
1076
  answer, and the failure direction is not the safe one.** Only a predicate that
1008
1077
  *reads* `context.model` can answer about the model it was asked about — see
@@ -1023,8 +1092,9 @@ per-record filter. An input you cannot identify must **deny**.
1023
1092
  *permitted* related record, which is recorded in the release notes as a
1024
1093
  breaking change.
1025
1094
 
1026
- **Not yet covered, and each one still publishes ids the surfaces above
1027
- withhold:**
1095
+ **Not yet covered.** The first two still publish ids the surfaces above
1096
+ withhold; the third is struck through because it **is** covered now, and is
1097
+ kept for the reason given under it:
1028
1098
 
1029
1099
  - **`included`** — [#235](https://github.com/abofs/stonyx-orm/issues/235). A
1030
1100
  permitted record sideloaded by `?include=` emits its **own**
@@ -1044,6 +1114,26 @@ per-record filter. An input you cannot identify must **deny**.
1044
1114
  angela**, seconds apart, with no query string and no relationship route. Any
1045
1115
  caller who can read a record can also write it and be handed the id the read
1046
1116
  withheld.
1117
+ - **A computed property can republish a hidden related record's id into
1118
+ `attributes`** — [#245](https://github.com/abofs/stonyx-orm/issues/245).
1119
+ `Record.toJSON()` runs consumer computed properties and writes their return
1120
+ values straight into `attributes` with no verdict applied. A computed
1121
+ property is ordinary consumer code with `this` bound to the record, so it
1122
+ can read any resolved relationship and emit the id this list's surfaces
1123
+ withheld — through a channel that filters `relationships.*.data` and never
1124
+ sees it. The shipped sample's `animal.tag` getter does exactly that. **This
1125
+ is reachable on `dev` once this pull request merges**, because
1126
+ [#240](https://github.com/abofs/stonyx-orm/issues/240) fixture 1 puts a
1127
+ hidden child (animal 18) under a **permitted** parent for the first time.
1128
+ - **The absence of `attributes.<fk>` on a `POST` response proves a hidden
1129
+ record exists** — [#246](https://github.com/abofs/stonyx-orm/issues/246).
1130
+ `createHandler` copies each supplied relationship's raw id into
1131
+ `attributes`; when the related record resolves, the value is consumed and
1132
+ does not appear, and when it does not resolve, it survives. The oracle runs
1133
+ in the negative space, so nothing this list's surfaces withhold is
1134
+ *published* — the **absence** is the signal. Pre-existing, and it does not
1135
+ compose with the two relationship families above: they emit no `attributes`
1136
+ for a related record at all.
1047
1137
  - ~~**`GET /:models/:id/relationships/{relationship}`**~~ — **covered as of
1048
1138
  [#232](https://github.com/abofs/stonyx-orm/issues/232)**, together with
1049
1139
  `GET /:models/:id/{relationship}`. Left in place rather than deleted because
@@ -1162,6 +1252,31 @@ default, the default is the unfiltered document, and a filtered relationship is
1162
1252
  byte-identical to a genuinely empty one — so nothing on the wire distinguishes
1163
1253
  "filtered" from "forgotten".
1164
1254
 
1255
+ **And `linkage` cannot reach a document you build by hand.** It is an *option to
1256
+ `toJSON()`*, so it filters only what goes through `toJSON()`. The ORM's own
1257
+ `GET /:models/:id/relationships/{relationship}` route is the worked example: its
1258
+ primary data *is* linkage, it assembles `{ type, id }` directly rather than
1259
+ serializing a record, and it therefore resolves and applies the verdict itself
1260
+ ([#232](https://github.com/abofs/stonyx-orm/issues/232)). If you assemble
1261
+ linkage the same way anywhere — a custom relationship route, a projection, a
1262
+ hand-built document — **passing `linkage` to `toJSON()` does nothing for it and
1263
+ nothing warns**. Build the filter and consult it before you emit an id:
1264
+
1265
+ ```js
1266
+ const linkage = createLinkageFilter(request);
1267
+
1268
+ if (related && linkage(related.__model.__name, related)) {
1269
+ data = { type: related.__model.__name, id: related.id };
1270
+ } else {
1271
+ data = null; // withheld and genuinely-empty must be the SAME answer
1272
+ }
1273
+ ```
1274
+
1275
+ The `else` branch is the part that is easy to get wrong. Answering `404`, `403`
1276
+ or an `errors` member for the withheld case makes the route an **existence
1277
+ oracle** — see [Filter functions](#filter-functions) for the rule and for the
1278
+ measurement that closed it on this route.
1279
+
1165
1280
  Do this:
1166
1281
 
1167
1282
  ```js
@@ -65,9 +65,19 @@
65
65
  * warning below sanctions. This repo's own fixture has such a rule: its
66
66
  * `/archived` deny cannot be expressed from the context alone, and a predicate
67
67
  * migrated to context-only would silently drop it, turning a deny into an
68
- * allow. The related-resource and `?include=` surfaces serve ANOTHER model's
69
- * records under `model: 'owner'`, and the context gives no signal of that
70
- * (abofs/stonyx-orm#196).
68
+ * allow.
69
+ *
70
+ * THE RELATED-RESOURCE HALF OF THAT SENTENCE IS NOW OUT OF DATE AND IS
71
+ * CORRECTED HERE RATHER THAN DELETED. Both relationship route families resolve
72
+ * the RELATED model's own access class and ask it
73
+ * `{ model: <related>, operation: 'read', recordId: null }`
74
+ * (abofs/stonyx-orm#232), so those surfaces no longer serve another model's
75
+ * records under `model: 'owner'` unexamined. What the context still gives no
76
+ * signal of is WHICH related record is being asked about -- `recordId` is
77
+ * `null` there and `request.params` names a record of a different model. See
78
+ * `AccessContext.recordId` in ./types/orm-types.ts for the full statement of
79
+ * that limit. `?include=` is still unfiltered and is abofs/stonyx-orm#233 /
80
+ * #235.
71
81
  *
72
82
  * SUPERSEDED 2026-09-01 BY abofs/stonyx-orm#236/#237, AND KEPT FOR THE
73
83
  * CONSTRAINT IT STATES RATHER THAN AS A DESCRIPTION OF THE CODE. The context
@@ -65,9 +65,19 @@
65
65
  * warning below sanctions. This repo's own fixture has such a rule: its
66
66
  * `/archived` deny cannot be expressed from the context alone, and a predicate
67
67
  * migrated to context-only would silently drop it, turning a deny into an
68
- * allow. The related-resource and `?include=` surfaces serve ANOTHER model's
69
- * records under `model: 'owner'`, and the context gives no signal of that
70
- * (abofs/stonyx-orm#196).
68
+ * allow.
69
+ *
70
+ * THE RELATED-RESOURCE HALF OF THAT SENTENCE IS NOW OUT OF DATE AND IS
71
+ * CORRECTED HERE RATHER THAN DELETED. Both relationship route families resolve
72
+ * the RELATED model's own access class and ask it
73
+ * `{ model: <related>, operation: 'read', recordId: null }`
74
+ * (abofs/stonyx-orm#232), so those surfaces no longer serve another model's
75
+ * records under `model: 'owner'` unexamined. What the context still gives no
76
+ * signal of is WHICH related record is being asked about -- `recordId` is
77
+ * `null` there and `request.params` names a record of a different model. See
78
+ * `AccessContext.recordId` in ./types/orm-types.ts for the full statement of
79
+ * that limit. `?include=` is still unfiltered and is abofs/stonyx-orm#233 /
80
+ * #235.
71
81
  *
72
82
  * SUPERSEDED 2026-09-01 BY abofs/stonyx-orm#236/#237, AND KEPT FOR THE
73
83
  * CONSTRAINT IT STATES RATHER THAN AS A DESCRIPTION OF THE CODE. The context
@@ -1266,13 +1276,25 @@ export default class OrmRequest extends Request {
1266
1276
  // ARGUMENT ONE IS THE LIVE REQUEST, NOT A DERIVED ONE. A fabricated
1267
1277
  // request addressing the RELATED resource was the original design and
1268
1278
  // 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.
1279
+ // one, so a fabricated value changes nothing it could observe.
1280
+ // `createLinkageFilter` is also a published public export
1281
+ // (src/index.ts) whose resolution granularity is per TYPE; supplying a
1282
+ // per-RECORD request would mean widening it, which takes a consumer
1283
+ // `access()` from ~2 calls to ~7 on a plain `GET /animals`. That is a
1284
+ // separate, consumer-visible story.
1285
+ //
1286
+ // GUARDED BY OWN-PROPERTY IDENTITY, NOT BY THE #234 AC13 PIN. That pin
1287
+ // (test/unit/linkage-verdict-test.ts, `strictEqual(seen[0].request,
1288
+ // READ_REQUEST)`) calls `createLinkageFilter` DIRECTLY, so it pins the
1289
+ // function's pass-through and constrains no call site -- an earlier
1290
+ // revision of this comment cited it for this decision and was wrong.
1291
+ // `Object.create(request)` here measured 1015 / 0 with nothing red.
1292
+ // test/integration/orm-test.ts, `#232 AC9`, now asserts that the object
1293
+ // the predicate is handed OWNS `params` (`Object.hasOwn`) and has
1294
+ // nothing request-shaped behind it on the prototype chain. A derived
1295
+ // request inherits `params` -- so it satisfies every value assertion
1296
+ // there -- and reds on those two. Measured: with the derived request in
1297
+ // place, 1014 / 1, and that one is this guard.
1276
1298
  //
1277
1299
  // THE RESIDUAL THAT FOLLOWS FROM THAT IS DISCLOSED, NOT PAPERED OVER.
1278
1300
  // `recordId` is `null` here and the request names a record of a
@@ -1297,23 +1319,36 @@ export default class OrmRequest extends Request {
1297
1319
  data = related.filter(isLinkable).map(r => r.toJSON?.({ baseUrl, linkage }));
1298
1320
  }
1299
1321
  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.
1322
+ // belongsTo - return single or null. A DENIED target is `data: null`,
1323
+ // BYTE-IDENTICAL to a relationship that is genuinely empty, for the
1324
+ // same reason the hasMany branch above drops rather than errors: this
1325
+ // route must not be an existence oracle for the RELATED record.
1326
+ //
1327
+ // THE OTHER SPELLING WAS 404 AND IT WAS MEASURED AS A DISCLOSURE.
1328
+ // Unauthenticated, zero query parameters, one request each, on `tag`
1329
+ // -- the model with no route mounted at all, which is exactly what
1330
+ // #240 AC5 exists to protect:
1331
+ //
1332
+ // GET /traits/1/tag [ABSENT] -> 200 application/json len 68
1333
+ // GET /traits/2/tag [DENIED] -> 404 text/plain len 9
1304
1334
  //
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.
1335
+ // and `GET /traits/1` and `GET /traits/2` both report
1336
+ // `relationships.tag = {"data":null}` byte-identical modulo the id,
1337
+ // because #234 closed THAT oracle deliberately. A 404 here would let
1338
+ // a caller ask which of those two nulls was a denial. Under
1339
+ // `data: null` the pair closes completely: 200/200, same
1340
+ // content-type, same content-length, bodies identical modulo the
1341
+ // parent id the caller put in the URL. It opens nothing -- `links`
1342
+ // are entirely parent-derived, there is no `meta` and no counts.
1343
+ //
1344
+ // This is also what README.md's module-wide rule already demanded:
1345
+ // every status on a record route must be identical for filtered-out
1346
+ // and does-not-exist. The route now CONFORMS to that rule rather than
1347
+ // carving an exception out of it.
1313
1348
  if (!isOrmRecord(relatedData))
1314
1349
  data = null;
1315
1350
  else if (!isLinkable(relatedData))
1316
- return 404;
1351
+ data = null;
1317
1352
  else
1318
1353
  data = relatedData.toJSON?.({ baseUrl, linkage });
1319
1354
  }
@@ -1331,13 +1366,21 @@ export default class OrmRequest extends Request {
1331
1366
  return 404;
1332
1367
  const relatedData = record.__relationships[relationshipName];
1333
1368
  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.
1369
+ // THE ONE READ SURFACE THAT DOES NOT GO THROUGH `toJSON()`. It builds
1370
+ // `{ type, id }` BY HAND, which is why #234's linkage filter never
1371
+ // reached it and why this half belongs to abofs/stonyx-orm#232 rather
1372
+ // than to #234: on this route the linkage IS the primary data of an
1373
+ // opt-in request, so filtering it changes the route's MEMBERSHIP
1374
+ // semantics, not the ids named inside somebody else's document.
1375
+ //
1376
+ // DELIBERATELY NOT STATED AS A COUNT. README.md's Consumer Contracts
1377
+ // section enumerates the surfaces on which the framework resolves a
1378
+ // verdict and hands it to `toJSON()`, and that enumeration GROWS --
1379
+ // abofs/stonyx-orm#235 adds the two write handlers and the `included`
1380
+ // records. This route is not on that list under any count, because it
1381
+ // never calls `toJSON()`: whatever it filters, it filters here. A
1382
+ // number written into this comment would be false the next time that
1383
+ // list changes, and the README already carries the enumeration.
1341
1384
  //
1342
1385
  // Same filter, same argument-one decision, same residual as
1343
1386
  // `/:id/{relationship}` above -- read the block there.
@@ -1356,10 +1399,10 @@ export default class OrmRequest extends Request {
1356
1399
  .map(r => ({ type: r.__model.__name, id: r.id }));
1357
1400
  }
1358
1401
  else {
1359
- // belongsTo - return single linkage or null
1360
- if (isOrmRecord(relatedData) && relatedData.__model) {
1361
- if (!isLinkable(relatedData))
1362
- return 404;
1402
+ // belongsTo - return single linkage or null. A DENIED target is
1403
+ // `data: null`, indistinguishable from a genuinely empty one -- see
1404
+ // the measured oracle in the `/:id/{relationship}` block above.
1405
+ if (isOrmRecord(relatedData) && relatedData.__model && isLinkable(relatedData)) {
1363
1406
  data = { type: relatedData.__model.__name, id: relatedData.id };
1364
1407
  }
1365
1408
  else {
@@ -340,10 +340,36 @@ export interface AccessContext {
340
340
  * repaired here. A predicate must not read `undefined` here as "collection",
341
341
  * and nothing in this contract makes it safe to read the two keys as one key.
342
342
  *
343
- * IT NAMES WHICH RECORD, NOT WHICH SURFACE. `GET /owners/gina`,
344
- * `GET /owners/gina/pets` and `GET /owners/gina/relationships/pets` all
345
- * carry `recordId: 'gina'`; the related-resource gap is abofs/stonyx-orm#196
346
- * and is untouched by this key.
343
+ * IT NAMES WHICH RECORD OF THE MODEL BEING ASKED ABOUT, NOT WHICH SURFACE,
344
+ * AND THE ANSWER DEPENDS ON WHICH MODEL IS BEING ASKED ABOUT.
345
+ *
346
+ * For the ask about the ROUTE'S OWN model, all three of `GET /owners/gina`,
347
+ * `GET /owners/gina/pets` and `GET /owners/gina/relationships/pets` carry
348
+ * `recordId: 'gina'` -- `auth()` reads it off `request.params`.
349
+ *
350
+ * FOR THE ASK ABOUT A RELATED MODEL, IT IS `null`, AND THAT IS A LIMIT ON
351
+ * WHAT A PREDICATE CAN EXPRESS (abofs/stonyx-orm#232). The two relationship
352
+ * route families resolve the RELATED model's own predicate -- `animal` on
353
+ * `GET /owners/gina/pets`, `owner` on `GET /animals/4/owner` -- and that ask
354
+ * carries `recordId: null` while `request.params` names a record of a
355
+ * DIFFERENT model. So a predicate answering about a related model gets the
356
+ * model name, the operation and the request, and CANNOT branch on which
357
+ * related record it is being asked about.
358
+ *
359
+ * The rule, so it is not re-derived wrong: `recordId` may name a record only
360
+ * where the route addresses exactly one record OF THE MODEL BEING ASKED
361
+ * ABOUT. A `hasMany` related-resource route returns many records of one type
362
+ * and the verdict is resolved ONCE PER TYPE, before any record is examined --
363
+ * seeding it from a record would let the first one decide for all of them.
364
+ *
365
+ * What still works, and what does not, is pinned as behaviour by `#232 AC10`
366
+ * in test/integration/orm-test.ts and stated for consumers in README.md:
367
+ * model-level denies work, request-level denies work, and the per-record
368
+ * FILTER shape works because `access()` may return a function and that
369
+ * function receives the whole record. Branching on identity BEFORE returning
370
+ * does not.
371
+ *
372
+ * `?include=` is a separate surface and is abofs/stonyx-orm#233 / #235.
347
373
  */
348
374
  recordId: string | number | null;
349
375
  }
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "stonyx-async",
5
5
  "stonyx-module"
6
6
  ],
7
- "version": "0.3.2-alpha.75",
7
+ "version": "0.3.2-alpha.77",
8
8
  "description": "",
9
9
  "main": "dist/index.js",
10
10
  "type": "module",
@@ -65,9 +65,19 @@
65
65
  * warning below sanctions. This repo's own fixture has such a rule: its
66
66
  * `/archived` deny cannot be expressed from the context alone, and a predicate
67
67
  * migrated to context-only would silently drop it, turning a deny into an
68
- * allow. The related-resource and `?include=` surfaces serve ANOTHER model's
69
- * records under `model: 'owner'`, and the context gives no signal of that
70
- * (abofs/stonyx-orm#196).
68
+ * allow.
69
+ *
70
+ * THE RELATED-RESOURCE HALF OF THAT SENTENCE IS NOW OUT OF DATE AND IS
71
+ * CORRECTED HERE RATHER THAN DELETED. Both relationship route families resolve
72
+ * the RELATED model's own access class and ask it
73
+ * `{ model: <related>, operation: 'read', recordId: null }`
74
+ * (abofs/stonyx-orm#232), so those surfaces no longer serve another model's
75
+ * records under `model: 'owner'` unexamined. What the context still gives no
76
+ * signal of is WHICH related record is being asked about -- `recordId` is
77
+ * `null` there and `request.params` names a record of a different model. See
78
+ * `AccessContext.recordId` in ./types/orm-types.ts for the full statement of
79
+ * that limit. `?include=` is still unfiltered and is abofs/stonyx-orm#233 /
80
+ * #235.
71
81
  *
72
82
  * SUPERSEDED 2026-09-01 BY abofs/stonyx-orm#236/#237, AND KEPT FOR THE
73
83
  * CONSTRAINT IT STATES RATHER THAN AS A DESCRIPTION OF THE CODE. The context
@@ -1385,13 +1395,25 @@ export default class OrmRequest extends Request {
1385
1395
  // ARGUMENT ONE IS THE LIVE REQUEST, NOT A DERIVED ONE. A fabricated
1386
1396
  // request addressing the RELATED resource was the original design and
1387
1397
  // 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.
1398
+ // one, so a fabricated value changes nothing it could observe.
1399
+ // `createLinkageFilter` is also a published public export
1400
+ // (src/index.ts) whose resolution granularity is per TYPE; supplying a
1401
+ // per-RECORD request would mean widening it, which takes a consumer
1402
+ // `access()` from ~2 calls to ~7 on a plain `GET /animals`. That is a
1403
+ // separate, consumer-visible story.
1404
+ //
1405
+ // GUARDED BY OWN-PROPERTY IDENTITY, NOT BY THE #234 AC13 PIN. That pin
1406
+ // (test/unit/linkage-verdict-test.ts, `strictEqual(seen[0].request,
1407
+ // READ_REQUEST)`) calls `createLinkageFilter` DIRECTLY, so it pins the
1408
+ // function's pass-through and constrains no call site -- an earlier
1409
+ // revision of this comment cited it for this decision and was wrong.
1410
+ // `Object.create(request)` here measured 1015 / 0 with nothing red.
1411
+ // test/integration/orm-test.ts, `#232 AC9`, now asserts that the object
1412
+ // the predicate is handed OWNS `params` (`Object.hasOwn`) and has
1413
+ // nothing request-shaped behind it on the prototype chain. A derived
1414
+ // request inherits `params` -- so it satisfies every value assertion
1415
+ // there -- and reds on those two. Measured: with the derived request in
1416
+ // place, 1014 / 1, and that one is this guard.
1395
1417
  //
1396
1418
  // THE RESIDUAL THAT FOLLOWS FROM THAT IS DISCLOSED, NOT PAPERED OVER.
1397
1419
  // `recordId` is `null` here and the request names a record of a
@@ -1418,21 +1440,34 @@ export default class OrmRequest extends Request {
1418
1440
  const related = Array.isArray(relatedData) ? relatedData.filter(isOrmRecord) : [];
1419
1441
  data = related.filter(isLinkable).map(r => r.toJSON?.({ baseUrl, linkage }));
1420
1442
  } 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.
1443
+ // belongsTo - return single or null. A DENIED target is `data: null`,
1444
+ // BYTE-IDENTICAL to a relationship that is genuinely empty, for the
1445
+ // same reason the hasMany branch above drops rather than errors: this
1446
+ // route must not be an existence oracle for the RELATED record.
1447
+ //
1448
+ // THE OTHER SPELLING WAS 404 AND IT WAS MEASURED AS A DISCLOSURE.
1449
+ // Unauthenticated, zero query parameters, one request each, on `tag`
1450
+ // -- the model with no route mounted at all, which is exactly what
1451
+ // #240 AC5 exists to protect:
1452
+ //
1453
+ // GET /traits/1/tag [ABSENT] -> 200 application/json len 68
1454
+ // GET /traits/2/tag [DENIED] -> 404 text/plain len 9
1425
1455
  //
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.
1456
+ // and `GET /traits/1` and `GET /traits/2` both report
1457
+ // `relationships.tag = {"data":null}` byte-identical modulo the id,
1458
+ // because #234 closed THAT oracle deliberately. A 404 here would let
1459
+ // a caller ask which of those two nulls was a denial. Under
1460
+ // `data: null` the pair closes completely: 200/200, same
1461
+ // content-type, same content-length, bodies identical modulo the
1462
+ // parent id the caller put in the URL. It opens nothing -- `links`
1463
+ // are entirely parent-derived, there is no `meta` and no counts.
1464
+ //
1465
+ // This is also what README.md's module-wide rule already demanded:
1466
+ // every status on a record route must be identical for filtered-out
1467
+ // and does-not-exist. The route now CONFORMS to that rule rather than
1468
+ // carving an exception out of it.
1434
1469
  if (!isOrmRecord(relatedData)) data = null;
1435
- else if (!isLinkable(relatedData)) return 404;
1470
+ else if (!isLinkable(relatedData)) data = null;
1436
1471
  else data = relatedData.toJSON?.({ baseUrl, linkage });
1437
1472
  }
1438
1473
 
@@ -1451,13 +1486,21 @@ export default class OrmRequest extends Request {
1451
1486
  const relatedData = record.__relationships[relationshipName];
1452
1487
  const baseUrl = getBaseUrl(request);
1453
1488
 
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.
1489
+ // THE ONE READ SURFACE THAT DOES NOT GO THROUGH `toJSON()`. It builds
1490
+ // `{ type, id }` BY HAND, which is why #234's linkage filter never
1491
+ // reached it and why this half belongs to abofs/stonyx-orm#232 rather
1492
+ // than to #234: on this route the linkage IS the primary data of an
1493
+ // opt-in request, so filtering it changes the route's MEMBERSHIP
1494
+ // semantics, not the ids named inside somebody else's document.
1495
+ //
1496
+ // DELIBERATELY NOT STATED AS A COUNT. README.md's Consumer Contracts
1497
+ // section enumerates the surfaces on which the framework resolves a
1498
+ // verdict and hands it to `toJSON()`, and that enumeration GROWS --
1499
+ // abofs/stonyx-orm#235 adds the two write handlers and the `included`
1500
+ // records. This route is not on that list under any count, because it
1501
+ // never calls `toJSON()`: whatever it filters, it filters here. A
1502
+ // number written into this comment would be false the next time that
1503
+ // list changes, and the README already carries the enumeration.
1461
1504
  //
1462
1505
  // Same filter, same argument-one decision, same residual as
1463
1506
  // `/:id/{relationship}` above -- read the block there.
@@ -1477,10 +1520,10 @@ export default class OrmRequest extends Request {
1477
1520
  .filter(isLinkable)
1478
1521
  .map(r => ({ type: r.__model.__name, id: r.id }));
1479
1522
  } else {
1480
- // belongsTo - return single linkage or null
1481
- if (isOrmRecord(relatedData) && relatedData.__model) {
1482
- if (!isLinkable(relatedData)) return 404;
1483
-
1523
+ // belongsTo - return single linkage or null. A DENIED target is
1524
+ // `data: null`, indistinguishable from a genuinely empty one -- see
1525
+ // the measured oracle in the `/:id/{relationship}` block above.
1526
+ if (isOrmRecord(relatedData) && relatedData.__model && isLinkable(relatedData)) {
1484
1527
  data = { type: relatedData.__model.__name, id: relatedData.id };
1485
1528
  } else {
1486
1529
  data = null;
@@ -350,10 +350,36 @@ export interface AccessContext {
350
350
  * repaired here. A predicate must not read `undefined` here as "collection",
351
351
  * and nothing in this contract makes it safe to read the two keys as one key.
352
352
  *
353
- * IT NAMES WHICH RECORD, NOT WHICH SURFACE. `GET /owners/gina`,
354
- * `GET /owners/gina/pets` and `GET /owners/gina/relationships/pets` all
355
- * carry `recordId: 'gina'`; the related-resource gap is abofs/stonyx-orm#196
356
- * and is untouched by this key.
353
+ * IT NAMES WHICH RECORD OF THE MODEL BEING ASKED ABOUT, NOT WHICH SURFACE,
354
+ * AND THE ANSWER DEPENDS ON WHICH MODEL IS BEING ASKED ABOUT.
355
+ *
356
+ * For the ask about the ROUTE'S OWN model, all three of `GET /owners/gina`,
357
+ * `GET /owners/gina/pets` and `GET /owners/gina/relationships/pets` carry
358
+ * `recordId: 'gina'` -- `auth()` reads it off `request.params`.
359
+ *
360
+ * FOR THE ASK ABOUT A RELATED MODEL, IT IS `null`, AND THAT IS A LIMIT ON
361
+ * WHAT A PREDICATE CAN EXPRESS (abofs/stonyx-orm#232). The two relationship
362
+ * route families resolve the RELATED model's own predicate -- `animal` on
363
+ * `GET /owners/gina/pets`, `owner` on `GET /animals/4/owner` -- and that ask
364
+ * carries `recordId: null` while `request.params` names a record of a
365
+ * DIFFERENT model. So a predicate answering about a related model gets the
366
+ * model name, the operation and the request, and CANNOT branch on which
367
+ * related record it is being asked about.
368
+ *
369
+ * The rule, so it is not re-derived wrong: `recordId` may name a record only
370
+ * where the route addresses exactly one record OF THE MODEL BEING ASKED
371
+ * ABOUT. A `hasMany` related-resource route returns many records of one type
372
+ * and the verdict is resolved ONCE PER TYPE, before any record is examined --
373
+ * seeding it from a record would let the first one decide for all of them.
374
+ *
375
+ * What still works, and what does not, is pinned as behaviour by `#232 AC10`
376
+ * in test/integration/orm-test.ts and stated for consumers in README.md:
377
+ * model-level denies work, request-level denies work, and the per-record
378
+ * FILTER shape works because `access()` may return a function and that
379
+ * function receives the whole record. Branching on identity BEFORE returning
380
+ * does not.
381
+ *
382
+ * `?include=` is a separate surface and is abofs/stonyx-orm#233 / #235.
357
383
  */
358
384
  recordId: string | number | null;
359
385
  }