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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
@@ -267,7 +277,7 @@ import { getBeforeHooks, getAfterHooks } from './hooks.js';
267
277
  import type { HookContext } from './hooks.js';
268
278
  import config from 'stonyx/config';
269
279
  import log from 'stonyx/log';
270
- import type { OrmRecord, AccessContext, AccessFunction, AccessMethod, AccessOperation, LinkageFilter } from './types/orm-types.js';
280
+ import type { OrmRecord, AccessContext, AccessFunction, AccessMethod, AccessOperation } from './types/orm-types.js';
271
281
  import { isOrmRecord, NO_FREE_ID_ERROR } from './utils.js';
272
282
  import { interpretAccess, createLinkageFilter } from './access-verdict.js';
273
283
 
@@ -465,9 +475,9 @@ function buildResponse(
465
475
  data: unknown,
466
476
  includeParam: string | undefined,
467
477
  recordOrRecords: OrmRecord | OrmRecord[],
468
- options: { links?: { [key: string]: string }; baseUrl?: string; linkage?: LinkageFilter } = {}
478
+ options: { links?: { [key: string]: string }; baseUrl?: string } = {}
469
479
  ): JsonApiResponse {
470
- const { links, baseUrl, linkage } = options;
480
+ const { links, baseUrl } = options;
471
481
  const response: JsonApiResponse = { data };
472
482
 
473
483
  // Add top-level links
@@ -482,49 +492,14 @@ function buildResponse(
482
492
 
483
493
  const includedRecords = collectIncludedRecords(recordOrRecords, includes);
484
494
  if (includedRecords.length > 0) {
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 }));
495
+ // NO `linkage` ARGUMENT, deliberately, and abofs/stonyx-orm#235 owns adding
496
+ // one. Until it does, a PERMITTED record here emits the full pre-#234
497
+ // document: `GET /animals/1?include=owner` filters the primary document's
498
+ // `owner.data` to `null` and then names `owner:angela` in `included`.
499
+ // Whether a resource reaches this array at all is a different question
500
+ // (membership, abofs/stonyx-orm#233) and closing that one does not close
501
+ // this one.
502
+ response.included = includedRecords.map(record => record.toJSON?.({ baseUrl }));
528
503
  }
529
504
 
530
505
  return response;
@@ -735,11 +710,7 @@ export default class OrmRequest extends Request {
735
710
 
736
711
  return buildResponse(data, request.query?.include, recordsToReturn, {
737
712
  links: { self: `${baseUrl}/${pluralizedModel}` },
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
713
+ baseUrl
743
714
  });
744
715
  };
745
716
 
@@ -757,29 +728,29 @@ export default class OrmRequest extends Request {
757
728
  const baseUrl = getBaseUrl(request);
758
729
  const linkage = createLinkageFilter(request);
759
730
 
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.
731
+ // `buildResponse` is deliberately NOT given the linkage filter, and the
732
+ // residual that leaves is NOT the one #233 owns. Two different questions:
733
+ //
734
+ // - WHETHER A RESOURCE APPEARS in `included` at all is MEMBERSHIP ->
735
+ // abofs/stonyx-orm#233.
736
+ // - What a record already IN `included` may NAME is LINKAGE -- the same
737
+ // question #234 answers for the primary document -- and it is
738
+ // abofs/stonyx-orm#235, which also owns createHandler/updateHandler.
763
739
  //
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.
740
+ // The residual, stated so the next reader does not have to derive it:
741
+ // `buildResponse` calls `record.toJSON?.({ baseUrl })` with no `linkage`
742
+ // argument, so a PERMITTED record in `included` emits the full pre-#234
743
+ // document. Measured: `GET /animals/1?include=owner` returns
744
+ // `owner.data: null` on the primary document and `owner:angela` in
745
+ // `included`. One query parameter deep. Only the PRIMARY document's
746
+ // linkage is filtered here.
769
747
  return buildResponse(record.toJSON?.({ fields: modelFields, baseUrl, linkage }), request.query?.include, record, {
770
748
  links: { self: `${baseUrl}/${pluralizedModel}/${request.params.id}` },
771
- baseUrl,
772
- linkage
749
+ baseUrl
773
750
  });
774
751
  };
775
752
 
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;
753
+ const createHandler: HandlerFn = async ({ body, query }, { filter }) => {
783
754
  const { type, id, attributes, relationships: rels } = (body?.data || {}) as {
784
755
  type?: string;
785
756
  id?: string | number;
@@ -1034,29 +1005,10 @@ export default class OrmRequest extends Request {
1034
1005
  return 403;
1035
1006
  }
1036
1007
 
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) }) };
1008
+ return { data: record.toJSON?.({ fields: modelFields }) };
1052
1009
  };
1053
1010
 
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;
1011
+ const updateHandler: HandlerFn = async ({ body, params }, { filter }) => {
1060
1012
  const found = await store.find(model, getId(params));
1061
1013
  if (!found || !isOrmRecord(found)) return 404;
1062
1014
  // Checked BEFORE any attribute is applied. 404 rather than 403 for the
@@ -1115,14 +1067,7 @@ export default class OrmRequest extends Request {
1115
1067
  }
1116
1068
  }
1117
1069
 
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) }) };
1070
+ return { data: record.toJSON?.() };
1126
1071
  };
1127
1072
 
1128
1073
  const deleteHandler: HandlerFn = async ({ params }, { filter }) => {
@@ -1429,21 +1374,101 @@ export default class OrmRequest extends Request {
1429
1374
  const relatedData = record.__relationships[relationshipName];
1430
1375
  const baseUrl = getBaseUrl(request);
1431
1376
 
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.
1377
+ // ONE FILTER, TWO JOBS, AND abofs/stonyx-orm#232 IS THE SECOND ONE.
1378
+ //
1379
+ // As LINKAGE (#234) it decides which ids the emitted documents may NAME
1380
+ // in their own `relationships.*.data`. As MEMBERSHIP (this issue) it
1381
+ // decides whether the related record is served here AT ALL -- the
1382
+ // related resource is PRIMARY data on this route, so there is no
1383
+ // linkage-consistency question to answer separately.
1384
+ //
1385
+ // Until #232 this route filtered only the PARENT, so a record its own
1386
+ // model's predicate hides was served in full from another model's
1387
+ // route, at ZERO query parameters. Measured on dev @ 8dda5d6:
1388
+ //
1389
+ // GET /owners/angela -> 404
1390
+ // GET /animals/1/owner -> 200, owner:angela, full attributes
1391
+ // GET /traits/2/tag -> 200, a model NO access class
1392
+ // claims, on a collection that has
1393
+ // no mounted route at all
1394
+ //
1395
+ // ARGUMENT ONE IS THE LIVE REQUEST, NOT A DERIVED ONE. A fabricated
1396
+ // request addressing the RELATED resource was the original design and
1397
+ // it is dropped: #241 removed the shipped fixture's read of argument
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.
1417
+ //
1418
+ // THE RESIDUAL THAT FOLLOWS FROM THAT IS DISCLOSED, NOT PAPERED OVER.
1419
+ // `recordId` is `null` here and the request names a record of a
1420
+ // DIFFERENT model, so a consumer predicate can express a model-level or
1421
+ // a request-level deny for a related resource, but NOT a per-record
1422
+ // one. README.md and docs/usage-patterns.md say so; a ledger assertion
1423
+ // in test/unit/relationship-route-access-test.ts keeps them saying it.
1437
1424
  const linkage = createLinkageFilter(request);
1438
1425
 
1426
+ // FAIL CLOSED ON A RECORD WHOSE TYPE CANNOT BE NAMED. `isLinkable` is
1427
+ // keyed on the model name; without one there is no predicate to ask,
1428
+ // and an unidentifiable input must never be the permissive path.
1429
+ const isLinkable = (r: OrmRecord) => {
1430
+ const type = (r as { __model?: { __name?: string } }).__model?.__name;
1431
+
1432
+ return typeof type === 'string' && type !== '' && linkage(type, r);
1433
+ };
1434
+
1439
1435
  let data: unknown;
1440
1436
  if (info.isArray) {
1441
- // hasMany - return array
1437
+ // hasMany - return array, MINUS the members this caller may not see.
1438
+ // Dropped, never errored: the result is byte-identical to a genuinely
1439
+ // empty relationship, so this route is not an existence oracle.
1442
1440
  const related = Array.isArray(relatedData) ? relatedData.filter(isOrmRecord) : [];
1443
- data = related.map(r => r.toJSON?.({ baseUrl, linkage }));
1441
+ data = related.filter(isLinkable).map(r => r.toJSON?.({ baseUrl, linkage }));
1444
1442
  } else {
1445
- // belongsTo - return single or null
1446
- data = isOrmRecord(relatedData) ? relatedData.toJSON?.({ baseUrl, linkage }) : null;
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
1455
+ //
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.
1469
+ if (!isOrmRecord(relatedData)) data = null;
1470
+ else if (!isLinkable(relatedData)) data = null;
1471
+ else data = relatedData.toJSON?.({ baseUrl, linkage });
1447
1472
  }
1448
1473
 
1449
1474
  return {
@@ -1453,47 +1478,6 @@ export default class OrmRequest extends Request {
1453
1478
  };
1454
1479
 
1455
1480
  // 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.
1497
1481
  routes[`/:id/relationships/${dasherizedName}`] = async (request: OrmRequest$, { filter }: { [key: string]: unknown } = {}) => {
1498
1482
  const record = await store.find(model, getId(request.params)) as OrmRecord | undefined;
1499
1483
  if (!record) return 404;
@@ -1502,16 +1486,44 @@ export default class OrmRequest extends Request {
1502
1486
  const relatedData = record.__relationships[relationshipName];
1503
1487
  const baseUrl = getBaseUrl(request);
1504
1488
 
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.
1504
+ //
1505
+ // Same filter, same argument-one decision, same residual as
1506
+ // `/:id/{relationship}` above -- read the block there.
1507
+ const linkage = createLinkageFilter(request);
1508
+ const isLinkable = (r: OrmRecord) => {
1509
+ const type = (r as { __model?: { __name?: string } }).__model?.__name;
1510
+
1511
+ return typeof type === 'string' && type !== '' && linkage(type, r);
1512
+ };
1513
+
1505
1514
  let data: unknown;
1506
1515
  if (info.isArray) {
1507
1516
  // hasMany - return array of linkage objects
1508
1517
  const related = Array.isArray(relatedData) ? relatedData.filter(isOrmRecord) : [];
1509
1518
  data = related
1510
1519
  .filter((r): r is OrmRecord & { __model: { __name: string } } => Boolean(r.__model))
1520
+ .filter(isLinkable)
1511
1521
  .map(r => ({ type: r.__model.__name, id: r.id }));
1512
1522
  } else {
1513
- // belongsTo - return single linkage or null
1514
- if (isOrmRecord(relatedData) && relatedData.__model) {
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)) {
1515
1527
  data = { type: relatedData.__model.__name, id: relatedData.id };
1516
1528
  } else {
1517
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
  }