@stonyx/orm 0.3.2-alpha.70 → 0.3.2-alpha.71

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
@@ -325,19 +325,6 @@ Access classes define models and provide custom filtering/authorization logic.
325
325
  > deny — which folds case but does not decode, so `GET /owners/%61rchived` steps
326
326
  > past it ([#228](https://github.com/abofs/stonyx-orm/issues/228)).
327
327
  >
328
- > **Superseded 2026-09-01 by [#236](https://github.com/abofs/stonyx-orm/issues/236) /
329
- > [#237](https://github.com/abofs/stonyx-orm/issues/237), and left standing rather
330
- > than rewritten.** Both claims above are now false: `#228` is **closed**, and the
331
- > one string comparison variant 3 lived in is gone — the sample below compares the
332
- > **decoded `recordId`** the access context supplies, so there is no comparison
333
- > left to step around. The paragraph about `request.path` further down is
334
- > superseded the same way. Nothing here is deleted because the same "variant 3
335
- > survives" wording sits at four sites (this file twice, `src/orm-request.ts`, and
336
- > the test fixture) and retiring one of four leaves the shipped copies
337
- > contradicting each other; retiring all four **with the measurement that retires
338
- > them** is [#238](https://github.com/abofs/stonyx-orm/issues/238), which also owns
339
- > this blockquote and the reference section below.
340
- >
341
328
  > That is still a stopgap. **The real fix is
342
329
  > [#202](https://github.com/abofs/stonyx-orm/issues/202)** — `access()` should
343
330
  > receive the model, the operation and the record, so there is nothing to
@@ -350,14 +337,6 @@ Access classes define models and provide custom filtering/authorization logic.
350
337
  > context alone** and a context-only rewrite would silently turn it into an
351
338
  > allow.
352
339
  >
353
- > **Superseded 2026-09-01 by [#236](https://github.com/abofs/stonyx-orm/issues/236) /
354
- > [#237](https://github.com/abofs/stonyx-orm/issues/237)** — see the dated note
355
- > above. No read of argument **one** survives in the sample below: the context
356
- > carries `recordId`, the decoded route-parameter id, so the `/archived` deny **is**
357
- > expressible from the context alone. It still must not be dropped — expressible is
358
- > not optional. Retirement of this wording:
359
- > [#238](https://github.com/abofs/stonyx-orm/issues/238).
360
- >
361
340
  > The same warning is repeated at the top of `src/orm-request.ts`, which ships;
362
341
  > the longer write-up in `docs/usage-patterns.md` does **not** ship, so this
363
342
  > README and that source header are the two copies a consumer sees.
@@ -371,73 +350,66 @@ Access classes define models and provide custom filtering/authorization logic.
371
350
  export default class GlobalAccess {
372
351
  models = ['owner', 'animal'];
373
352
 
374
- access(request, { model, operation, recordId }) {
353
+ access(request, { model, operation }) {
375
354
  // `model` is the model this route was mounted for. It is assigned once, at
376
355
  // mount time, and no request can influence it — not a mount prefix, not a
377
356
  // query string, not a case-varied path, not an absolute-form request
378
- // target. `recordId` is the record this route was ADDRESSED TO, decoded by
379
- // the router and coerced to the key the store lookup uses. Nothing below
380
- // parses anything, and since abofs/stonyx-orm#236 nothing below reads
381
- // argument one AT ALL. Variants 1, 2, 4 and 5 were already unconstructible;
382
- // the sub-path STRING COMPARISON that variant 3 lived in is gone too,
383
- // replaced by a comparison against the decoded id. Retiring the "variant 3
384
- // survives" wording at the four sites that still carry it — with the
385
- // measurement that retires it, rather than by deletion — is
386
- // abofs/stonyx-orm#238.
357
+ // target. Nothing below parses anything, so variants 1, 2, 4 and 5 are not
358
+ // constructible against this predicate any more they are history, not
359
+ // rules to follow. VARIANT 3 IS THE EXCEPTION AND THE CLAIM IS NARROWER
360
+ // THAN IT WAS: a matcher stricter than the router can still be stepped
361
+ // around, because the sub-path rule below is still a string comparison.
362
+ // Case is handled; percent-encoding is not abofs/stonyx-orm#228.
387
363
  //
388
364
  // `operation` is destructured to name the whole contract at the point of
389
- // use. This sample's rules are per-model and per-record rather than
365
+ // use. This sample's rules are per-model and per-sub-path rather than
390
366
  // per-verb, so it does not branch on it; the permission array at the bottom
391
367
  // is where the verb is answered.
392
368
 
393
- // FAIL CLOSED ON AN UNIDENTIFIABLE MODEL. `model` is absent for any caller
394
- // that resolved this predicate without supplying the context, and a request
395
- // this function cannot identify DENIES rather than falling through to the
396
- // CRUD grant at the bottom. An unidentifiable input must never be the
397
- // permissive path.
369
+ // FAIL CLOSED ON ARGUMENT TWO. `model` is absent for any caller that
370
+ // resolved this predicate without supplying the context, and a request this
371
+ // function cannot identify DENIES rather than falling through to the CRUD
372
+ // grant at the bottom. An unidentifiable input must never be the permissive
373
+ // path. Argument ONE is guarded at its own read, below — this guard does
374
+ // not cover it.
398
375
  if (typeof model !== 'string' || model === '') return false;
399
376
 
400
377
  if (model === 'owner') {
401
- // FAIL CLOSED ON AN ABSENT `recordId` TOO, AND `undefined` IS THE ONLY
402
- // SPELLING OF ABSENT. `auth()` ALWAYS sets the key `null` on a
403
- // collection route, which is addressed to no record — so `undefined`
404
- // means the context did not come from `auth()`: it was hand-assembled by
405
- // a caller resolving this predicate through the documented
406
- // `Orm.instance.getAccess()` path. Letting that through would fall
407
- // straight to the per-record filter below, which is a DENY becoming an
408
- // ALLOW. This is the same rule the old guard on `request.path` enforced,
409
- // moved to the argument this predicate now actually reads.
410
- if (recordId === undefined) return false;
411
-
412
- // THE `/archived` DENY, EXPRESSED AGAINST THE DECODED ID. It used to be
413
- // `request.path.toLowerCase()` compared against `'/archived'`, and that
414
- // was wrong in both directions at once.
415
- //
416
- // TOO PERMISSIVE: express sets `request.path` from the RAW pathname while
417
- // the router DECODES `:id`, so `GET /owners/%61rchived` reached the
418
- // comparison as `/%61rchived`, walked past the deny and was dispatched as
419
- // the record `archived` — 200 with the record in full, and DELETE
420
- // answered 204 with the record DESTROYED, unauthenticated. 255
421
- // non-canonical spellings of that 8-character id decode to the same key,
422
- // so no deny-list of spellings was ever going to close it.
378
+ // The context names WHICH MODEL and WHICH VERB not which route. Six
379
+ // distinct owner surfaces produce one identical context, so a rule that
380
+ // depends on the SUB-PATH still needs argument one. `request.path` is
381
+ // mount-relative and query-free, and it is the one read of the raw
382
+ // request the README sanctions. false 403 for the whole request.
423
383
  //
424
- // TOO STRICT: a record id is a VALUE, not a literal route segment, and
425
- // express's `case sensitive routing` governs literal segments only. With
426
- // a distinct owner seeded at `ARCHIVED`, the `.toLowerCase()` 403'd
427
- // `GET /owners/ARCHIVED` — the wrong record — while still admitting
428
- // `GET /owners/%41RCHIVED`, the same record encoded.
384
+ // THIS DENY CANNOT BE EXPRESSED FROM THE CONTEXT ALONE. Migrating it away
385
+ // does not remove a rule, it turns a deny into an ALLOW, silently.
429
386
  //
430
- // SO DO NOT NORMALISE `recordId`. It is already decoded, exactly ONCE,
431
- // which is what a route parameter means: `/owners/%2561rchived` is the
432
- // legitimate id `%61rchived`, and decoding until stable would deny it. Do
433
- // not case-fold it. Do not rebuild it from `request.path` decoding the
434
- // whole path decodes THEN splits while the router splits THEN decodes,
435
- // which over-denies the distinct record at `/owners/archived%2fx`.
387
+ // FAIL CLOSED ON ARGUMENT ONE TOO. The guard above covers the context;
388
+ // this one covers the request, and since #202 they are two different
389
+ // objects. A caller that resolves this predicate through the documented
390
+ // `Orm.instance.getAccess()` path and hand-assembles a request can supply
391
+ // a perfectly valid context with no usable `path` and
392
+ // `String(request.path ?? '')` is then `''`, which matches no sub-path
393
+ // rule and falls straight through to the per-record filter below. That is
394
+ // a DENY becoming an ALLOW. An input this function cannot identify DENIES,
395
+ // whichever ARGUMENT it arrived on — which is also why the `?? ''` this
396
+ // file's header condemns does not appear below.
397
+ if (typeof request?.path !== 'string' || request.path === '') return false;
398
+
399
+ // Lower-cased because the router matched case-insensitively, so a
400
+ // case-sensitive rule here would be stricter than the router and could be
401
+ // stepped around.
436
402
  //
437
- // THE DENY IS NOW EXPRESSIBLE FROM THE CONTEXT ALONE, which is exactly
438
- // what `recordId` bought and it still must not be dropped. Deleting it
439
- // does not remove a rule loudly, it turns a deny into an ALLOW, silently.
440
- if (recordId === 'archived') return false;
403
+ // CASE-FOLDING ALONE IS NOT A SUFFICIENT NORMALISATION, and this line is
404
+ // not a recipe for one. Express sets `request.path` from the RAW pathname
405
+ // while the router DECODES `:id`, so `GET /owners/%61rchived` reaches this
406
+ // comparison as `/%61rchived`, walks past the deny, and is dispatched as
407
+ // the record `archived` — abofs/stonyx-orm#228. A matcher must normalise
408
+ // the way the router that dispatched the request does. Record ids are
409
+ // case-sensitive and must be compared at their real case.
410
+ const path = request.path.toLowerCase();
411
+
412
+ if (path === '/archived' || path.startsWith('/archived/')) return false;
441
413
 
442
414
  // Returning a function plugs it in as a per-record filter, and it is
443
415
  // enforced on every surface addressed to one of these records:
@@ -520,16 +492,6 @@ access class shipped with this repo has such a rule: its `/archived` deny
520
492
  **cannot be expressed from the context alone**, and a predicate migrated to
521
493
  context-only would silently drop it — a deny becoming an allow.
522
494
 
523
- **Superseded 2026-09-01 by [#236](https://github.com/abofs/stonyx-orm/issues/236) /
524
- [#237](https://github.com/abofs/stonyx-orm/issues/237).** The context also carries
525
- `recordId` — the record this route was addressed to, already decoded — so the
526
- `/archived` deny **is** expressible from the context alone, and the shipped sample
527
- no longer reads `request.path`. The full contract is `AccessContext.recordId` in
528
- `src/types/orm-types.ts`, which ships. This section — the signature, the key table
529
- and this paragraph — is corrected by
530
- [#238](https://github.com/abofs/stonyx-orm/issues/238); the pointer is here because
531
- what it currently says is an instruction, and the instruction is wrong.
532
-
533
495
  Note also that the related-resource and `?include=` surfaces serve *another
534
496
  model's* records under `model: 'owner'`, and the context gives a predicate no
535
497
  signal that it is authorizing a related-resource route. That is
@@ -825,17 +787,6 @@ these values should be matched on:
825
787
  | `GET http://anything.example/owners/angela` | `http://anything.example/angela` | `http://anything.example/owners/angela` | `/owners` | `/angela` |
826
788
  | `GET /api/animals/22` (`ORM_REST_ROUTE=/api`) | `/22` | `/api/animals/22` | `/api/animals` | `/22` |
827
789
 
828
- **Superseded 2026-09-01 by [#236](https://github.com/abofs/stonyx-orm/issues/236) /
829
- [#237](https://github.com/abofs/stonyx-orm/issues/237) — "Variant 3 survives" above,
830
- and the two paragraphs below, are no longer true.** The access context now carries
831
- `recordId`, the **decoded** route-parameter id, and the sample compares against it:
832
- the one string comparison variant 3 lived in is gone, `#228` is **closed**, and no
833
- read of argument one survives in the sample. The wording is left standing rather
834
- than deleted because it appears at four sites (this file twice,
835
- `src/orm-request.ts`, and the test fixture) and retiring one of four leaves the
836
- shipped copies contradicting each other; retiring all four **with the measurement
837
- that retires them** is [#238](https://github.com/abofs/stonyx-orm/issues/238).
838
-
839
790
  **One read of argument one survives, and it must: `request.path`.** It is
840
791
  mount-relative and query-free, and it is for rules that distinguish **sub-paths**
841
792
  beneath the mount — as the `/archived` deny in the sample above does. The context
@@ -855,20 +806,6 @@ comparison as `/%61rchived`, walks past the deny, and is dispatched as the recor
855
806
  `.toLowerCase()` there as a complete normalisation recipe.** Record ids are
856
807
  case-sensitive and must be compared at their real case.
857
808
 
858
- **Do not follow the two paragraphs above — superseded 2026-09-01 by
859
- [#236](https://github.com/abofs/stonyx-orm/issues/236) /
860
- [#237](https://github.com/abofs/stonyx-orm/issues/237).** They are *instructions*,
861
- not merely stale observations, which is why this note is louder than a date. The
862
- sample no longer reads `request.path` and no longer calls `.toLowerCase()` on
863
- anything it compares: `.toLowerCase()` was measured wrong in **both directions at
864
- once** — with a distinct owner seeded at `ARCHIVED`, `GET /owners/ARCHIVED` was a
865
- false **deny** on the wrong record and `GET /owners/%41RCHIVED` a false **allow**
866
- on that same record. Compare `recordId` **as it arrives**: do not case-fold it, do
867
- not decode it, do not derive it from `request.path`. The contract is
868
- `AccessContext.recordId` in `src/types/orm-types.ts`, which ships and says "Do NOT
869
- case-fold it". Retirement of this wording, with its measurement:
870
- [#238](https://github.com/abofs/stonyx-orm/issues/238).
871
-
872
809
  **Fail closed on anything you cannot identify — on *either* argument.**
873
810
  `String(request.originalUrl ?? '')` was once added here to stop a `TypeError`,
874
811
  and it traded fail-closed for fail-**open**: an empty string matched no
@@ -927,7 +864,97 @@ per-record filter. An input you cannot identify must **deny**.
927
864
  related record without resolving that model's own access class, so a filter on
928
865
  `/owners` does not hide an owner reached through `/animals`. Tracked as
929
866
  [#196](https://github.com/abofs/stonyx-orm/issues/196), which covers
930
- `include=`, related-resource routes and relationship-linkage routes.
867
+ `include=`, related-resource routes and relationship-linkage routes. This is
868
+ **membership** — whether the related resource is served at all — and it is a
869
+ different question from which ids a document may *name*, immediately below.
870
+ - **Relationship linkage is filtered on the four request-bound read surfaces,
871
+ and only there.** A document's `relationships.*.data` used to publish the id
872
+ of every related record unconditionally, so a record hidden on every one of
873
+ its own surfaces was still named inside another model's document — with no
874
+ `include=`, no relationship route and no query string
875
+ ([#234](https://github.com/abofs/stonyx-orm/issues/234)). The ORM now resolves
876
+ the **related** model's own access class on `GET /:models`, `GET /:models/:id`
877
+ and both `GET /:models/:id/{relationship}` shapes, and asks it
878
+ `{ model: <related>, operation: 'read' }`. An unresolvable class
879
+ (`getAccess()` → `undefined`) and a predicate that throws both **deny**. A
880
+ filtered-out relationship is **indistinguishable from a genuinely empty one** —
881
+ an emptied `hasMany` is `data: []` and an emptied `belongsTo` is `data: null`,
882
+ both **keeping their `links`**, which are built from the serialized record's
883
+ own id and never from the related one. Nothing errors and no status changes,
884
+ because throwing here would be an existence oracle *and* would throw out of
885
+ the enclosing `JSON.stringify`.
886
+
887
+ **That resolves the right class; it does not guarantee a model-correct
888
+ answer, and the failure direction is not the safe one.** Only a predicate that
889
+ *reads* `context.model` can answer about the model it was asked about — see
890
+ [Passing the context makes a model-correct answer *possible*](#passing-the-context-makes-a-model-correct-answer-possible)
891
+ above. A **single-argument predicate remains the default in every consumer
892
+ tree**, it identifies its collection from the request, and asked about
893
+ `owner` on a request dispatched to `/animals` it answers about **animals**.
894
+ Measured against this repo's own fixture with an arity-1 predicate registered
895
+ for `owner`: `GET /owners` correctly returns `["gina","michael","bob"]` while
896
+ `GET /animals/1` returns `owner.data {"type":"owner","id":"angela"}` — the
897
+ #234 defect, on the #234 surface, after the #234 fix. This is not a
898
+ regression (the id was published unconditionally before), it cannot be fixed
899
+ from this side, and the signal that surfaces such a predicate is
900
+ [#221](https://github.com/abofs/stonyx-orm/issues/221) /
901
+ [#213](https://github.com/abofs/stonyx-orm/issues/213). **Migrate your
902
+ predicates to read the context before relying on this filter.** A migrated,
903
+ context-reading predicate degrades the other way — it can over-deny a
904
+ *permitted* related record, which is recorded in the release notes as a
905
+ breaking change.
906
+
907
+ **Not yet covered, and each one still publishes ids the surfaces above
908
+ withhold:**
909
+
910
+ - **`included`** — [#235](https://github.com/abofs/stonyx-orm/issues/235). A
911
+ permitted record sideloaded by `?include=` emits its **own**
912
+ `relationships.*.data` unfiltered, so `GET /animals/1?include=owner,owner.pets`
913
+ returns `owner.data: null` on the primary document and then names angela in
914
+ `included`, along with eight permitted animals that each name
915
+ `{"type":"owner","id":"angela"}`. Separately,
916
+ [#233](https://github.com/abofs/stonyx-orm/issues/233) owns whether a
917
+ resource appears in `included` **at all** — that is membership, a different
918
+ question, and following it will not lead you to this residual.
919
+ - **The `POST`/`PATCH` response documents** —
920
+ [#235](https://github.com/abofs/stonyx-orm/issues/235). `createHandler` and
921
+ `updateHandler` destructure the request rather than binding it, so wiring
922
+ them needs a signature change rather than an argument. Until then **one HTTP
923
+ verb defeats the filter on the same record**: measured, `GET /animals/1`
924
+ returns `owner.data: null` and `PATCH /animals/1` returns **200 naming
925
+ angela**, seconds apart, with no query string and no relationship route. Any
926
+ caller who can read a record can also write it and be handed the id the read
927
+ withheld.
928
+ - **`GET /:models/:id/relationships/{relationship}`**, whose *primary data* is
929
+ linkage, so filtering it is a **membership** decision —
930
+ [#232](https://github.com/abofs/stonyx-orm/issues/232), the filed child of
931
+ [#196](https://github.com/abofs/stonyx-orm/issues/196).
932
+ - **A bare `toJSON()` still emits unfiltered linkage, and that is deliberate.**
933
+ `Record.toJSON()` **applies** a verdict; it never **resolves** one. It has no
934
+ request, and the documented `access()` contract permits a predicate to read
935
+ one — the sample in this README does, for its sub-path rule — so a filter
936
+ resolved inside `toJSON()` denies *permitted* records rather than hidden ones
937
+ (measured: 967 → 964, all three failures over-denials). `toJSON` is also the
938
+ `JSON.stringify` hook, so `JSON.stringify(record)`, `res.json(record)` and
939
+ `console.log(JSON.stringify(record))` reach it with a **string** in the
940
+ options slot and have no syntactic place to pass a verdict. The no-argument
941
+ call therefore returns the pre-#234 document unchanged. Fail-closed by default
942
+ is not available either: `Orm.instance.accessFunctions` is `{}` in any process
943
+ that never ran `setup-rest-server` — a CLI, an SQL-only process, a test — so
944
+ it would empty every relationship on every document in processes with no REST
945
+ surface to protect. Closing the residual means moving JSON:API serialization
946
+ **off** the `toJSON` name, tracked as
947
+ [#230](https://github.com/abofs/stonyx-orm/issues/230). If you hand a `Record`
948
+ to an untrusted consumer, serialize it through the REST layer, or resolve a
949
+ verdict with the **exported** `createLinkageFilter(request)` and pass it as
950
+ the `linkage` option — do not write your own reading of `access()`. This is a
951
+ consumer obligation with no signal when it lapses; it is stated once, in full,
952
+ under [Consumer Contracts](#consumer-contracts) below.
953
+ - **`format()` and `serialize()` are deliberately not filtered, and must stay
954
+ that way.** `format()` is the **persistence** path — its output is what
955
+ `Orm.db.save()` writes to disk — so applying an access filter there would
956
+ write a truncated database. That is **data loss**, not disclosure prevention.
957
+ Neither method appears anywhere in the REST response path.
931
958
  - **A before-hook that returns a value short-circuits the request.** On write
932
959
  operations addressed to a record the filter is consulted first, so a hook
933
960
  cannot answer for a record the caller may not see. On reads it is not, so a
@@ -986,6 +1013,80 @@ per-record filter. An input you cannot identify must **deny**.
986
1013
  back, and without the guard a denied `403` would delete a record the request
987
1014
  did not create.
988
1015
 
1016
+ ### Consumer Contracts
1017
+
1018
+ Obligations this package **cannot enforce**, where nothing fails, warns or
1019
+ changes shape when a consumer omits them. One place, findable, per
1020
+ `quality.md` rule 2 — if you are relying on `@stonyx/orm` for access control,
1021
+ read all of these.
1022
+
1023
+ #### `Record.toJSON()` does not filter relationship linkage unless you pass a verdict
1024
+
1025
+ **The framework owns this on four surfaces. You own it everywhere else.**
1026
+
1027
+ `GET /:models`, `GET /:models/:id` and both `GET /:models/:id/{relationship}`
1028
+ shapes resolve a linkage verdict and pass it to `toJSON()` for you. Any other
1029
+ path to a document — `JSON.stringify(record)`, `res.json(record)`,
1030
+ `console.log(record)`, a custom route, a queue payload, a websocket frame —
1031
+ calls `toJSON()` with no verdict, and **the no-verdict document names every
1032
+ related id, including records hidden on every one of their own surfaces**
1033
+ ([#234](https://github.com/abofs/stonyx-orm/issues/234)). That default is
1034
+ deliberate and cannot be inverted; the reasons are in
1035
+ [Known limitations](#known-limitations) above.
1036
+
1037
+ **There is no signal when you omit it.** `linkage` is optional, absent is the
1038
+ default, the default is the unfiltered document, and a filtered relationship is
1039
+ byte-identical to a genuinely empty one — so nothing on the wire distinguishes
1040
+ "filtered" from "forgotten".
1041
+
1042
+ Do this:
1043
+
1044
+ ```js
1045
+ import { createLinkageFilter } from '@stonyx/orm';
1046
+
1047
+ // `request` is the live request the caller was authorised against. The verdict
1048
+ // is REQUEST-SCOPED: build one per request and never cache it across requests,
1049
+ // or a second caller is answered with the first caller's authorization.
1050
+ const linkage = createLinkageFilter(request);
1051
+
1052
+ res.json({ data: record.toJSON({ baseUrl, linkage }) });
1053
+ ```
1054
+
1055
+ Not this:
1056
+
1057
+ ```js
1058
+ // A second, unreviewed reading of access(). It will drift from the one in
1059
+ // src/access-verdict.ts, and it will drift in consumer code where no reviewer
1060
+ // of this repository will ever see it.
1061
+ const linkage = (type, r) => Orm.instance.getAccess(type)?.(request)?.(r) ?? true;
1062
+ ```
1063
+
1064
+ **`linkage` is validated, and an unusable value DENIES.** `undefined` means "no
1065
+ verdict supplied" and emits today's document. Any other non-function — `null`,
1066
+ `0`, `false`, `''`, `true`, a string, an object — drops **all** linkage on that
1067
+ document and logs. This matters because `null` is the natural return of a
1068
+ resolver that could not resolve a session: it used to be read as "absent" and
1069
+ emit the full document silently.
1070
+
1071
+ #### A predicate that ignores `context.model` makes cross-model resolution GRANT
1072
+
1073
+ The linkage filter above asks the **related** model's access class the
1074
+ model-correct question, but only a predicate that *reads*
1075
+ [`context.model`](#the-access-context-second-argument) can give a model-correct
1076
+ answer. A single-argument predicate identifies its collection from the request
1077
+ and therefore answers about the collection the request was *addressed to* —
1078
+ which is the direction that **grants**. Measured, and worked through in
1079
+ [Known limitations](#known-limitations). There is no boot-time warning yet
1080
+ ([#221](https://github.com/abofs/stonyx-orm/issues/221)). **Migrate your
1081
+ predicates to the two-argument contract.**
1082
+
1083
+ #### `format()` and `serialize()` are never filtered, by design
1084
+
1085
+ They are the persistence path. Do not hand their output to an untrusted
1086
+ consumer, and do not add a filter to them — `Orm.db.save()` writes `format()`
1087
+ output to disk, so filtering there is data loss rather than disclosure
1088
+ prevention.
1089
+
989
1090
  ### Breaking changes
990
1091
 
991
1092
  These land in the next published build. There is no changelog or release-notes channel yet
@@ -1195,6 +1296,13 @@ GET /animals/1
1195
1296
  #### Limitations
1196
1297
 
1197
1298
  - Only available on GET endpoints (not POST/PATCH)
1299
+ - **`included` records are not access-filtered, on either question.** Whether a
1300
+ resource appears in `included` at all is
1301
+ [#233](https://github.com/abofs/stonyx-orm/issues/233); a record that *is*
1302
+ permitted still emits its **own** `relationships.*.data` unfiltered, so
1303
+ `?include=` republishes ids the primary document withholds
1304
+ ([#235](https://github.com/abofs/stonyx-orm/issues/235)). See
1305
+ [Consumer Contracts](#consumer-contracts).
1198
1306
 
1199
1307
  ## Lifecycle Hooks
1200
1308
 
@@ -1249,16 +1357,6 @@ Each hook receives a context object with comprehensive information:
1249
1357
  - It contains a deep copy of the record's state **before** the operation executes (captured before the `before` hook fires)
1250
1358
  - The deep copy is created via JSON serialization (`JSON.parse(JSON.stringify())`) to ensure complete isolation
1251
1359
  - For `delete` operations, `recordId` is provided in after hooks since the record may no longer exist in the store
1252
- - **`context.recordId` here is NOT `AccessContext.recordId`.** Same name, same-shaped
1253
- object, different coverage: `_withHooks` sets this key **only** under
1254
- `operation === 'delete'`, so on `get` / `list` / `create` / `update` the key is
1255
- **absent** — `beforeHook('update', 'owner', ctx => ctx.recordId === 'archived' ? 403 : undefined)`
1256
- never fires (measured: `PATCH /owners/{id}` → 200 with `ctx.recordId === undefined`
1257
- and the id sitting in `ctx.params`). The access context, by contrast, carries
1258
- `recordId` on every route it classifies and spells absence as `null`, never
1259
- `undefined`. Tracked as
1260
- [#242](https://github.com/abofs/stonyx-orm/issues/242); see
1261
- `AccessContext.recordId` in `src/types/orm-types.ts` for the other side.
1262
1360
  - `oldState` is captured as a deep copy of the record's data before the operation, providing access to the previous field values
1263
1361
 
1264
1362
  ### Usage Examples
@@ -0,0 +1,59 @@
1
+ import type { AccessMethod, AccessOperation, LinkageFilter } from './types/orm-types.js';
2
+ /**
3
+ * The classified reading of one `access()` return value.
4
+ *
5
+ * `granted: false` is a total denial. `granted: true` with no `filter` is an
6
+ * unconditional grant. `granted: true` WITH a filter means "grant, subject to
7
+ * this per-record predicate" -- the function return shape, which is the
8
+ * per-record hook `AccessContext` deliberately does not provide.
9
+ */
10
+ export interface AccessVerdict {
11
+ granted: boolean;
12
+ filter?: (record: unknown) => boolean;
13
+ }
14
+ /**
15
+ * Classify one `access()` return value. Extracted verbatim from `auth()`, which
16
+ * now calls this; the branch ORDER is load-bearing and is preserved exactly.
17
+ *
18
+ * `operation` is the verb being authorised. `undefined` -- reachable, because
19
+ * express delivers HEAD to the GET handler and `methodAccessMap` has no entry
20
+ * for it -- falls through `permitted.includes(undefined)` to a denial, which is
21
+ * the same answer `auth()` gave before the extraction.
22
+ */
23
+ export declare function interpretAccess(access: AccessMethod, operation: AccessOperation | undefined): AccessVerdict;
24
+ /**
25
+ * Build a request-scoped linkage filter.
26
+ *
27
+ * TWO CACHES, AND BOTH ARE LOAD-BEARING RATHER THAN AN OPTIMISATION:
28
+ *
29
+ * - one verdict per TYPE. Resolving means CALLING the consumer's `access()`,
30
+ * which is arbitrary code with arbitrary cost and which the module has
31
+ * already had to guard for throwing.
32
+ * - one decision per `(type, id)`. `included` is deduplicated by
33
+ * `buildResponse`; LINKAGE is not deduplicated at all, so it re-asks once
34
+ * per record. Measured on a bare `GET /animals` with no `include=`:
35
+ * 48 linkage entries -> 7 distinct `(type, id)` pairs (owner 20, trait 28),
36
+ * a 6.9x reduction and 41 predicate calls saved.
37
+ *
38
+ * The `(type, id)` cache is a `Map` per type keyed on the RAW id, not on a
39
+ * template-string composite. `Map` compares with SameValueZero, so the numeric
40
+ * id `1` and the string id `'1'` stay DISTINCT, where `` `${type}:${id}` `` --
41
+ * or a bare `String(id)` -- collapses them onto one entry and answers the second
42
+ * record with the first record's verdict.
43
+ *
44
+ * WHAT THAT DOES AND DOES NOT PROTECT. It cannot cross MODELS. `decisions` is
45
+ * already partitioned per type by `byType`, so a composite key inside a per-type
46
+ * map is one-to-one with the raw one and no owner's verdict could ever answer
47
+ * for an animal -- the claim that once stood here. The real exposure is narrower
48
+ * and entirely WITHIN one model: two records of the same type whose ids differ
49
+ * only by JavaScript type, which a per-record predicate may legitimately answer
50
+ * differently about (an id read off a JSON body is a string; the same id
51
+ * assigned by the server is a number). Pinned by unit assertion, because this
52
+ * fixture cannot produce the collision on its own -- `owner` ids are strings and
53
+ * `animal` ids are numbers.
54
+ *
55
+ * SCOPE IS ONE REQUEST. The filter closes over the request and must not outlive
56
+ * it -- a verdict cached across requests would answer a second caller with the
57
+ * first caller's authorization.
58
+ */
59
+ export declare function createLinkageFilter(request: unknown): LinkageFilter;