@web-ts-toolkit/access-router 0.51.0 → 0.53.0

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
@@ -2,6 +2,28 @@
2
2
 
3
3
  ACL-aware Express routers and in-memory data services for Mongoose-backed APIs.
4
4
 
5
+ **Start here:** [Quick Start](#quick-start) · [Imports](#import-styles) ·
6
+ [Configuration layers](#configuration-layers) · [Public exports](#main-exports)
7
+
8
+ **Guide:** [Operation guards](#basic-and-advanced-operation-access) ·
9
+ [Data routers](#data-routers) · [Root batches](#root-batch-bounds) ·
10
+ [Runtime isolation](#default-runtime-vs-isolated-runtime) ·
11
+ [Validation and OpenAPI](#request-validation-and-openapi)
12
+
13
+ **Business contracts:** [Service authorization](#trusted-service-authorization) ·
14
+ [Mutation input](#independent-mutation-input-authority) ·
15
+ [Subdocuments](#dedicated-subdocument-authorization) ·
16
+ [Includes and counts](#include-cardinality) · [Correlated includes](#correlated-includes) ·
17
+ [Virtuals](#virtuals-computed-fields) · [Hook admission](#hook-admission-and-reentrant-service-calls) ·
18
+ [Persistence limits](#persistence-concurrency-and-resource-limits)
19
+
20
+ **Migration:** [Removed overrides](#breaking-release-service-overrides-migration) ·
21
+ [Server-owned hydration](#breaking-release-server-owned-hydration) ·
22
+ [Named helpers](#breaking-release-named-helpers-and-default-behavior) ·
23
+ [Distinct values](#distinct-whole-value-request-authorization) ·
24
+ [Numeric grouped counts](#numeric-grouped-count-service-api-breaking-migration) ·
25
+ [`skim`](#skim-compatibility-no-op)
26
+
5
27
  ## Installation
6
28
 
7
29
  ```sh
@@ -13,97 +35,1498 @@ Peer dependencies:
13
35
  - `express >= 5`
14
36
  - `mongoose >= 8`
15
37
 
16
- ## Highlights
38
+ Use Node.js 22 or newer. The package ships bundled CommonJS/ESM entrypoints and
39
+ matching declarations; prefer default `acl` for the shared runtime or named
40
+ `createAccessRuntime` for isolation.
17
41
 
18
- - generated model CRUD routers
19
- - generated in-memory data routers
20
- - access control, field permissions, and request-time hooks
21
- - root batch router for grouped operations
22
- - request validation adapters
23
- - generated OpenAPI JSON and Swagger UI routes
42
+ TypeScript applications also need `typescript`, `@types/node`, and `@types/express`
43
+ as development dependencies. MongoDB must be reachable for model operations;
44
+ in-memory data routers need no database connection. Install an external validator
45
+ such as AJV only when using its adapter.
24
46
 
25
47
  ## Quick Start
26
48
 
49
+ A public-read catalog needs a model, route guards, and field rules. Save this as
50
+ `server.mts` and compile with TypeScript/NodeNext, or run it directly with Node 22.18+.
51
+
27
52
  <!-- doc-example: partial -->
28
53
 
29
54
  ```ts
30
55
  import express from 'express';
31
56
  import mongoose from 'mongoose';
32
- import acl, { permissionsPlugin } from '@web-ts-toolkit/access-router';
33
-
34
- // 1. Configure how request permissions are resolved globally.
35
- acl.setGlobalOptions({
36
- requestPermissionField: '_permissions',
37
- // Set to false only when legacy clients populate models without createRouter().
38
- requireRegisteredPopulateModels: true,
39
- globalPermissions(req) {
40
- return req.headers.user === 'admin' ? ['isAdmin'] : [];
41
- },
42
- });
43
-
44
- // 2. Register a Mongoose model and schema. The schema opts in to the
45
- // permissions plugin so generated routers can enforce field access.
46
- const userSchema = new mongoose.Schema({ name: String, role: String, public: Boolean });
47
- userSchema.plugin(permissionsPlugin, { modelName: 'User' });
48
- const UserModel = mongoose.model('User', userSchema);
49
-
50
- // 3. Create routers. Pass either the model name or the mongoose.Model
51
- // instance. Passing the instance preserves an explicit connection
52
- // (useful for multi-connection setups) and registers it with the
53
- // active runtime.
54
- const userRouter = acl.createRouter('User', {
55
- basePath: '/users',
56
- operationAccess: { list: true, create: true, read: true, update: true, delete: true },
57
- permissionSchema: { name: true, role: 'isAdmin', public: true },
58
- });
57
+ import { createAccessRuntime } from '@web-ts-toolkit/access-router';
59
58
 
60
- const fruitRouter = acl.createDataRouter('fruit', {
61
- basePath: '/fruit',
62
- idParam: 'id',
63
- idField: 'id',
59
+ const api = createAccessRuntime();
60
+ const Product = mongoose.model('Product', new mongoose.Schema({ name: String, internalCost: Number }));
61
+ const products = api.createRouter(Product, {
62
+ basePath: '/products',
64
63
  operationAccess: { list: true, read: true },
65
- data: [{ id: 'apple', name: 'Apple', public: true }],
66
- permissionSchema: { id: true, name: 'isAdmin', public: true },
67
- });
68
-
69
- const docsRouter = acl.createOpenApiRouter({
70
- title: 'Example API',
71
- version: '1.0.0',
64
+ permissionSchema: { name: true },
72
65
  });
73
66
 
74
- // 4. Mount routers under an Express app.
75
67
  const app = express();
76
68
  app.use(express.json());
77
- app.use(userRouter.routes);
78
- app.use(fruitRouter.routes);
79
- app.use(docsRouter);
80
-
81
- // 5. Connect to MongoDB before accepting traffic. A failed connection throws
82
- // and exits before the server calls `app.listen`, so the service never
83
- // publishes routes it cannot serve.
84
- const port = Number(process.env.PORT ?? 3000);
85
- const mongoUrl = process.env.MONGODB_URL ?? 'mongodb://localhost:27017/example';
69
+ app.use(products.routes); // includes this runtime's request middleware
70
+
86
71
  try {
87
- await mongoose.connect(mongoUrl);
88
- } catch (err) {
89
- console.error(`Failed to connect to MongoDB at ${mongoUrl}:`, err);
72
+ await mongoose.connect(process.env.MONGODB_URL ?? 'mongodb://127.0.0.1:27017/example');
73
+ } catch (error) {
74
+ console.error(error);
90
75
  process.exit(1);
91
76
  }
77
+ app.listen(3000);
78
+ ```
79
+
80
+ `GET /products?select=name&limit=20` returns `{ data, meta }`; `GET /products/:id`
81
+ returns one readable product. `_id` is retained by default, and `internalCost`
82
+ requires its own field permission. Unconfigured write operations are denied.
83
+ Passing the model instance registers it in this runtime and preserves its Mongoose
84
+ connection. [String-name factories](#createrouter-overloads) need a registered model.
85
+
86
+ For actor-specific rules, resolve grants from your application's authenticated
87
+ server-side actor in `globalPermissions`, then use those permission names in the
88
+ rules. The public-read example needs neither authentication nor a schema plugin.
89
+
90
+ ## Import styles
91
+
92
+ <!-- doc-example: partial -->
92
93
 
93
- app.listen(port, () => {
94
- console.log(`API listening on http://localhost:${port}`);
94
+ ```ts
95
+ import acl from '@web-ts-toolkit/access-router'; // preferred shared-runtime API
96
+ import { createAccessRuntime, fromZod } from '@web-ts-toolkit/access-router'; // isolated API / helpers
97
+ import { parseBody, Codes, MIDDLEWARE } from '@web-ts-toolkit/access-router/advanced';
98
+ import { copyAndDepopulate } from '@web-ts-toolkit/access-router/processors';
99
+ ```
100
+
101
+ `import { acl }` is the same value as the default export. In CommonJS use
102
+ `const { acl, createAccessRuntime } = require('@web-ts-toolkit/access-router')`;
103
+ `require(...)` itself is the export object, not the callable `acl` value.
104
+
105
+ | Entrypoint | Use it for |
106
+ | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
107
+ | `@web-ts-toolkit/access-router` | Runtime/router factories, guards, validation adapters, public configuration and authorization types. |
108
+ | `@web-ts-toolkit/access-router/advanced` | Additional request/service/wire types (`ModelRequest`, `RootQueryEntry`, `FindOptions`, `Defaults`, `Include`), enums, symbols and parsers. No runtime factory or standalone `Service` class export. |
109
+ | `@web-ts-toolkit/access-router/processors` | Named document processors and their option/output types; [typed example](#subpath-import-example). |
110
+
111
+ Use package-name imports, not `src/*` or `dist/*` paths. The three entrypoints have
112
+ ESM/CJS JavaScript and matching `.d.mts`/`.d.ts` declarations selected by `exports`.
113
+ See [Main Exports](#main-exports) for the inventory. Acquire services from
114
+ `router.getService(req)` or `req.macl.getService(name)` after the owning middleware
115
+ initializes the request.
116
+
117
+ ## Configuration layers
118
+
119
+ `const api = createAccessRuntime()` returns a callable **runtime-bound API**;
120
+ `api.runtime` is its owning `AccessRuntime`. Default `acl` has the same API over
121
+ `defaultRuntime`. Prefer methods on that API or `router.runtime` when configuring
122
+ a particular runtime. Standalone named option helpers use the active runtime in
123
+ package-managed execution, falling back to `defaultRuntime` outside it.
124
+
125
+ | Layer / public type | Where to put it | Scope and examples |
126
+ | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
127
+ | Runtime/global — `GlobalOptions` | `api.setGlobalOptions({...})`, `api.setGlobalOption(key, value)`; `api.set` is an alias for these forms | That runtime's request grants (`globalPermissions`, `requestPermissionField`), logger, registered-populate policy and `requestComplexity`. |
128
+ | Model defaults — `DefaultModelRouterOptions` | `api.setDefaultModelOptions({...})`, `api.setDefaultModelOption(path, value)` | Shared model starting values such as `listHardLimit`, IDs, route segments, `operationAccess`, `hydration` and metadata posture. Data/root routers do not inherit them. |
129
+ | Model router — `ModelRouterOptions<Model, Virtuals>` | `api.createRouter(Model, {...})`, then `router.setOptions`, `router.setOption` / `router.set` | One registered model's guards, `permissionSchema`, hooks, virtuals, request schemas, `hydration` and method defaults. Routers for the same model/runtime share its stored options. |
130
+ | Data router — `DataRouterOptions<Data>` | `api.createDataRouter(name, {...})`, its setters, or `api.runtime.setDataOptions` / `setDataOption` | Owned in-memory `data`, list/read guards, request-only fields/filters and decorators. No model `defaults` or model authorization policy. Set `idParam` and `idField` explicitly for data identifier routes. |
131
+ | Root router — `RootRouterOptions` | Single-argument `api.createRouter({ basePath, operationAccess, ... })` | The batch endpoint's scalar guard and batch/order/whole-operation limits; each target keeps its own policy. |
132
+ | Per-method defaults — `Defaults<Model>` from `/advanced` | `ModelRouterOptions.defaults`, e.g. `defaults.publicListArgs` or `defaults.findOptions` | Query/presentation defaults for the **named method**, not hydration, global settings or trusted authority. Public wrappers and internal methods have separate buckets. |
133
+ | Wire query / `args` / `options` | GET query, advanced body, or root entry | Request-controlled selection, sorting, pagination and allowed presentation options; placement and spelling depend on transport (below). |
134
+ | Trusted per-call policy — `ServiceAuthorization`, `MutationAuthorization`, etc. | The internal service's final `options.authorization` | Server-owned, method-specific row/input/output/related authority. Absent dimensions use request authority. Never a router default, HTTP option or reusable permission mutation. |
135
+
136
+ ### Assignment, precedence and capture
137
+
138
+ 1. **Assignment is shallow.** Object setters replace each supplied top-level value
139
+ and keep other top-level keys. `setOptions({ operationAccess: { read: true } })`
140
+ replaces the whole stored guard object; it does not merge its old siblings.
141
+ Model option assignment is `{ ...runtimeModelDefaults, ...storedModelOptions,
142
+ ...suppliedOptions }`. Changing runtime defaults does not broadcast over values
143
+ already stored on a model. Data assignment has no model-default layer.
144
+ 2. **Dotted setters update one path copy-on-write.** Use
145
+ `router.setOption('operationAccess.basicRead', false)` to keep sibling rules,
146
+ or pass `undefined` to restore lookup fallback. Property helpers such as
147
+ `router.operationAccess('basicRead', false)` are for defined values; use
148
+ `set`/`setOption` for undefined. Updating a path beneath a scalar starts an
149
+ object; use a `default` entry to retain a broad fallback. Dotted keys belong in
150
+ setter arguments, not flat constructor-object properties.
151
+ 3. **Lookup is not a deep merge.** Generic nested option/hook lookup uses the
152
+ stored exact path → its parent's `.default` → the parent value (bare hook or
153
+ shorthand). Only `undefined` falls through, not false/null. The generic model
154
+ getter uses the exact runtime-model-default path as its final fallback if the
155
+ stored parent is also absent. `getExactModelOption` instead checks just the
156
+ exact stored path, then the same runtime-default path; `getExactDataOption`
157
+ checks only the stored path. `getDefaultModelOption` is exact. **Route guards
158
+ have their own resolver:** exact variant (including exact model-default
159
+ fallback) → base exact/`.default`/shorthand. A default variant can therefore
160
+ outrank a model's base rule. See the [full precedence](#variant-and-base-fallback),
161
+ including closed [subdocument rules](#subdocument-field-precedence).
162
+ 4. **Method values resolve with `??`.** At each service resolver a non-nullish
163
+ supplied property wins over that method's `defaults` bucket, then its built-in
164
+ fallback. False, zero, `''` and `[]` are supplied values; their normal validation
165
+ and selection meanings still apply. Public `_list`/`_read`/`_create`/`_update`
166
+ resolve `public*Args`/`public*Options` first, then forward resolved values to
167
+ internal methods. A still-undefined query setting can reach the internal bucket;
168
+ an already resolved built-in (such as public list `includeCount: false`) takes
169
+ precedence over that internal default. By-ID delegations similarly forward
170
+ resolved values. Hydration is separate model configuration, not a method value.
171
+ Public `_upsert` uses the public create or update bucket for its branch; trusted
172
+ `upsert` has `upsertArgs`/`upsertOptions`. There is no `publicUpsertOptions` bucket.
173
+ 5. **Metadata posture is absolute.** Model `stripPermissionsField: true` removes
174
+ the entire output metadata field; `disableFieldPermissions: true` suppresses
175
+ `_view`/`_edit` computation. They win over per-call flags and method defaults.
176
+ Internal authorization and full document grants still run.
177
+
178
+ The supported `Defaults` buckets are `findArgs`/`findOptions`,
179
+ `findOneArgs`/`findOneOptions`, `findByIdArgs`/`findByIdOptions`,
180
+ `createArgs`/`createOptions`, `updateOneArgs`/`updateOneOptions`,
181
+ `updateByIdArgs`/`updateByIdOptions`, `upsertArgs`/`upsertOptions`, `existsOptions`,
182
+ and the four `publicList`, `publicRead`, `publicCreate`, `publicUpdate` Args/Options
183
+ pairs. There is no general `defaults.options`, count, or data-method bucket.
184
+ An explicit `service.defaults` assignment replaces the entire runtime defaults bag
185
+ for that service; it does not merge buckets. Neither form selects hydration.
186
+
187
+ <!-- doc-example: partial -->
188
+
189
+ ```ts
190
+ import type { ModelRouterOptions } from '@web-ts-toolkit/access-router';
191
+
192
+ const options = {
193
+ operationAccess: { list: true, read: true },
194
+ permissionSchema: { name: true },
195
+ defaults: {
196
+ publicListArgs: { select: ['name'], limit: 20 },
197
+ publicListOptions: { includePermissions: false },
198
+ findOptions: { includeCount: true }, // internal find; public list resolves its own flag
199
+ },
200
+ } satisfies ModelRouterOptions;
201
+ ```
202
+
203
+ `router.options` is a frozen **construction-time snapshot**. Runtime option getters
204
+ return frozen stored snapshots; read them again for current values. Ordinary plain
205
+ option containers are copied/frozen; hook functions, Mongoose models and other
206
+ non-plain handles keep their identities, and the logger is retained by reference.
207
+ Data has the stronger
208
+ [data-specific ownership contract](#in-memory-data-ownership), including detached
209
+ native-valued getter copies.
210
+
211
+ Route guards and request-schema lookups read live policy. A retained model service
212
+ reads its originating runtime's current presentation options/defaults on each call;
213
+ model authorization calls capture
214
+ their current policy/configuration before awaiting hooks and keep it coherent for
215
+ that call. Each document/scope/access stage has separate grants. A data service
216
+ captures its data version at construction. Acquire a new service for replacement
217
+ data. Request permission initialization is reused within its owning request/runtime.
218
+ Base-filter results are reused for the same model/access and policy function;
219
+ replacing that function starts a fresh cache entry. Hook/persistence
220
+ limits are captured together on first work-state use and retained by that owner.
221
+
222
+ Model services and public services retain their originating core, actor, row cache,
223
+ model connection and registry even if another runtime later initializes the same
224
+ Express request. This includes scalar counts/distinct, ID lookups, mutations, includes,
225
+ populate, subdocuments and field helpers. Services acquired later from a retained core
226
+ also belong to that core. The request's current `macl` slot still belongs to the new
227
+ runtime; acquiring a service there uses the new runtime's independent authority.
228
+ Output handles remain bound to their original request/runtime/model/scope and cannot
229
+ be reused by the replacement runtime.
230
+
231
+ Route-shape keys (`modelName`/`dataName`, `basePath`, `parentPath`, `idParam`,
232
+ `queryRouteSegment`, and model `mutationRouteSegment`) are fixed at construction;
233
+ router setters reject them. Runtime storage edits do not rebuild already-mounted
234
+ Express routes. `parentPath` supplies the external prefix for route metadata/logging;
235
+ mount that prefix in Express yourself. Live `idField`/`resolveIdFilter` change lookup
236
+ behavior rather than route shape.
237
+
238
+ ### Wire placement and option names
239
+
240
+ | Intent | Basic GET query | Advanced body | Root entry |
241
+ | ------------------------ | -------------------------------------------------- | --------------------------------------------------------------- | ----------------------------------- |
242
+ | Selection / sort | `select=name`, `sort=name%20-createdAt` | `select`, `sort` at body top level | `args.select`, `args.sort` |
243
+ | Pagination | `skip`, `limit`, `page`, `page_size` | `skip`, `limit`, `page`, `pageSize` | Same camelCase keys under `args` |
244
+ | List totals | `include_count=true` | `options.includeCount: true` | `options.includeCount: true` |
245
+ | Extra pagination headers | `include_extra_headers=true` | `options.includeExtraHeaders: true` | No per-entry HTTP-header option |
246
+ | Model metadata | `include_permissions`, `include_field_permissions` | `options.includePermissions`, `options.includeFieldPermissions` | Same camelCase keys under `options` |
247
+ | Read-miss fallback | `try_list` on model identifier GET | `options.tryList` on model read | `options.tryList` on model read |
248
+
249
+ GET flag values are strings `true`/`false`; body/root flags are booleans. The table
250
+ describes list/read fields on routes that support them, not arbitrary forwarding.
251
+ GET lists start with `{}` as their client filter; use an advanced body for `filter`,
252
+ `populate` or `include`. Model GET-by-ID also accepts `select`; basic data GET-by-ID
253
+ does not project query selection (use its advanced read). Neither `lean` nor
254
+ `hydration` is a supported service/wire option; use server model configuration.
255
+ Mutation bodies use
256
+ top-level `data`/`select`/`populate`/`tasks` and their own allowed `options`; see
257
+ [advanced mutation validation](#advanced-mutation-validation).
258
+
259
+ List responses are `{ data, meta }`. Request `includeCount` explicitly on the wire
260
+ to expose `meta.totalCount`; a service default alone is not an HTTP metadata switch.
261
+ Internal list `count` is returned-row count; `totalCount` is the authorized match
262
+ total when computed. Direct service `find(query, args, options)` keeps the predicate
263
+ separate from both objects. Root entries keep `target`, `name`, base `op`, `filter`/
264
+ `id`/`data` at entry top level; `args` and `options` are separate siblings.
265
+
266
+ ### Names with different jobs
267
+
268
+ | Names | Deliberate distinction |
269
+ | ----------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
270
+ | `operationAccess` / `permissionSchema` / `authorization` | Route entry / request field rules / explicit server-only per-call authority. An entry grant does not grant fields or related targets. |
271
+ | `select` / `alwaysSelectFields` / virtual `dependsOn` | Desired output / private persisted hook inputs / scoped getter dependencies. Fetching a value grants no output authority. |
272
+ | `baseFilter` / `overrideFilter` | Request row scope / ordinary-query transform. Trusted rows replace only the former; the transform still runs once and false stays terminal. |
273
+ | `idParam` / `idField` / `resolveIdFilter` | Express parameter name / lookup field / custom identifier-to-filter hook. A callable resolver takes precedence over `idField`; otherwise lookup uses that field (model default `_id`). |
274
+ | `requestPermissionField` / `documentPermissionField` / `docPermissions` | Request grant storage / serialized document metadata path / hook producing full internal document grants. Metadata flags affect exposure, not authority. |
275
+ | `requestSchemas` / `validate` / `prepare` / `transform` | HTTP parsing / admitted model-input validation / trusted data preparation / live-document mutation before save. |
276
+ | `populate` / `include` / `related` | Declared Mongoose references / output-only related query attachments / independent trusted target policy for an exact requested path. |
277
+ | `access` / `populateAccess` | Method-specific effective row/field access / target populate access (`list` or `read` on the wire). Neither chooses a basic/advanced route variant. |
278
+ | Model `hydration` / `skim` / metadata flags | Server-owned Mongoose fetch choice / deprecated per-call no-op / serialization and field-map choices. All preserve authorization. |
279
+ | `maxConcurrentOperations` / `maxHookConcurrency` / `maxBulkConcurrency` | Whole root operations per order group / request-runtime hook chains / request-runtime leaf persistence operations. Separate counted units and pools. |
280
+ | `count` / `includeCount` / `countByFieldValues` | One exact numeric query result / list match-total option / bounded numeric counts per original key alias. Returned-row counts and overlapping per-key counts are not union totals. |
281
+
282
+ ### `skim`: compatibility no-op
283
+
284
+ `skim` remains accepted in existing service/default and supported wire option slots
285
+ for compatibility, but is **deprecated and has no behavioral effect** in canonical
286
+ model operations. True, false and omission all keep field/row authorization, document
287
+ grants, virtuals and decoration. Remove it from new code. Use model `hydration`
288
+ configuration for fetch mode, `includePermissions`/`includeFieldPermissions` for metadata, or the
289
+ router's absolute metadata postures; none skips authorization. Existing examples
290
+ that retain `skim` below demonstrate this compatibility contract.
291
+
292
+ ### Breaking release: server-owned hydration
293
+
294
+ **Removed:** per-call `lean` from `PublicListOptions`, `PublicReadOptions`,
295
+ `FindOptions`, `FindOneOptions`, inherited `FindByIdOptions`, and
296
+ `SubdocumentParentOptions`. There is no renamed per-call, client, GET/body/root,
297
+ include or populate hydration switch. Raw Mongoose `.lean()` remains supported.
298
+
299
+ Configure the optional **model-level** `hydration: ModelHydrationOptions` record.
300
+ Both it and `HydrationMode = 'lean' | 'hydrated'` are exported from the package root
301
+ and `/advanced`. Exactly these five independent entries are supported:
302
+
303
+ | Entry | Built-in mode | Applies to |
304
+ | ------------ | ------------- | ------------------------------------------------------------------------------------------------- |
305
+ | `publicList` | `'lean'` | Public `_list`, direct/root model lists |
306
+ | `publicRead` | `'hydrated'` | Public `_read`/`_readFilter`, identifier/filter routes and both attempts of read-to-list fallback |
307
+ | `find` | `'hydrated'` | Internal service `find` |
308
+ | `findOne` | `'hydrated'` | Internal service `findOne` |
309
+ | `findById` | `'hydrated'` | Internal service `findById`, independently of `findOne` |
310
+
311
+ <!-- doc-example: partial -->
312
+
313
+ ```ts
314
+ import mongoose from 'mongoose';
315
+ import { createAccessRuntime, type ModelRouterOptions } from '@web-ts-toolkit/access-router';
316
+ import type { ModelRequest } from '@web-ts-toolkit/access-router/advanced';
317
+
318
+ const api = createAccessRuntime();
319
+ const Thing = mongoose.model('Thing', new mongoose.Schema({ name: String }));
320
+ // Server-owned hydration migration: configure once, not around each request.
321
+ const options = {
322
+ operationAccess: { list: true, read: true },
323
+ permissionSchema: { name: true },
324
+ // Before: defaults.publicListOptions.lean = false; findOneOptions.lean = true.
325
+ hydration: { publicList: 'hydrated', findOne: 'lean', find: 'lean' },
326
+ defaults: { publicListOptions: { includeCount: true } },
327
+ } satisfies ModelRouterOptions;
328
+ const router = api.createRouter(Thing, options);
329
+ router.setOption('hydration.publicRead', 'lean'); // Keeps the other stored entries.
330
+
331
+ export async function findThings(req: ModelRequest) {
332
+ // req must be initialized by this runtime's middleware (for example router.routes).
333
+ const service = router.getService(req);
334
+ // Before: service.defaults = { findOptions: { lean: true, includeCount: true } }.
335
+ // Fetch mode is now model-wide hydration.find above, not service-instance state.
336
+ service.defaults = { findOptions: { includeCount: true } };
337
+ return service.find({}); // No fetch-mode option.
338
+ }
339
+ ```
340
+
341
+ Supported writers are model construction/router setters,
342
+ `api.runtime.setModelOptions` / `setModelOption`, and starting defaults through
343
+ `api.setDefaultModelOptions` / `setDefaultModelOption` (also on `api.runtime`).
344
+ `GlobalOptions`, data/root router options, method `defaults`, `service.defaults`
345
+ and client defaults are not hydration scopes. Decorator applications use the same
346
+ record through `@Router`, per-model/default `@RouterOptions`, `@ModelOption` and
347
+ `@DefaultModelOption`; see `@web-ts-toolkit/access-router-deco`'s installed README
348
+ for its compiling configuration example.
349
+ No dedicated hydration decorator is needed.
350
+
351
+ Assignment follows `{ ...runtimeStartingDefaults, ...storedModelOptions,
352
+ ...incomingModelOptions }`: a supplied hydration record **replaces** the previous
353
+ record, not individual members. Omitted/undefined entries use the matrix's
354
+ built-ins, never an older record's members or generic nested `.default` lookup.
355
+ For example, replacing `{ publicList: 'hydrated' }` with `{ find: 'lean' }`
356
+ restores public lists to their built-in lean mode. The five exact
357
+ `hydration.<entry>` setters change one stored member; member `undefined` clears
358
+ that override and whole-record `undefined` clears the setting. Starting-default
359
+ changes do not broadcast over an existing stored record. Default providers obey
360
+ the same shallow semantics and existing decorator precedence.
361
+
362
+ Only plain records with those five keys and mode strings (or `undefined`) are
363
+ valid; scalars, null, arrays, unknown keys and invalid modes throw a path-bearing
364
+ `TypeError` **before publication**. Failed updates preserve the prior snapshot;
365
+ failed decorator bootstrap rolls back package-controlled state. Fresh service
366
+ calls capture their owning runtime/model's current configuration; suspended calls,
367
+ read fallback and already-captured related work retain their capture, even if
368
+ another runtime later replaces `req.macl`.
369
+
370
+ **Defaults migration:** move each old `defaults.<bucket>.lean` entry as follows;
371
+ convert `true` to `'lean'` and `false` to `'hydrated'`:
372
+
373
+ | Old bucket | New model setting |
374
+ | ------------------- | ---------------------- |
375
+ | `publicListOptions` | `hydration.publicList` |
376
+ | `publicReadOptions` | `hydration.publicRead` |
377
+ | `findOptions` | `hydration.find` |
378
+ | `findOneOptions` | `hydration.findOne` |
379
+ | `findByIdOptions` | `hydration.findById` |
380
+
381
+ Keep unrelated presentation defaults in their existing buckets. Old `lean` keys,
382
+ even `undefined`, and `hydration` keys in method-option buckets throw migration
383
+ diagnostics instead of being translated or silently ignored. This covers model
384
+ setters, synchronous `service.defaults` assignment, and later mutation of an
385
+ assigned defaults bag detected before querying. Other methods explain their fixed
386
+ mode rather than offer a replacement knob. Moving a former `service.defaults`
387
+ override changes **all services for that model in its runtime**, not just one
388
+ service instance; make that choice deliberately, never emulate request-local mode
389
+ by changing shared configuration around a call.
390
+
391
+ **Old JavaScript/wire inputs:** stale `lean` or `hydration` controls in permissive
392
+ service/direct/root/`$$sq`/legacy-include/populate slots are inert and never select
393
+ fetch mode. Strict correlated includes still reject nonempty execution options,
394
+ including these keys. Ordinary business fields named `lean` or `hydration` remain
395
+ valid in filters, selections, mutations and task data. Private execution choices
396
+ are not added to public query/context metadata or response DTOs.
397
+
398
+ | Fixed/related stage | Fetch contract (not a new setting) |
399
+ | --------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
400
+ | Legacy/correlated include list/read targets | Fixed lean; read fallback disabled for correlated reads |
401
+ | `$$sq` list/read target | Target's captured `publicList`/`publicRead`; no read fallback |
402
+ | Mongoose populate, including nested targets | Inherits the originating parent query's mode, not target hydration configuration |
403
+ | `getParentDoc(id, sub, args?, { access? })`, `findRawParentDoc({ filter, select, populate })` | Raw hydrated parent/null; not finalized DTOs or permission metadata. `getParentDoc` keeps its row/projection/populate checks; the low-level raw adapter helper is not an authorization boundary |
404
+ | Dedicated subdocument read/mutation parent; `readSub` populate | Fixed hydrated parent/live document; post-save visibility re-fetch alone stays fixed lean |
405
+ | Create/new, update/upsert mutation lookup/save, delete hooks | Live hydrated documents for setters, validation, saves and deletion, regardless of read configuration |
406
+ | Exists/public-upsert existence, field-membership queries | Fixed lean `_id` lookups; count/distinct/aggregates do not hydrate document results |
407
+
408
+ **Value caveat:** finalized model results and output-stage hook views are
409
+ plain objects in both modes; this is not a promise of identical values. Hydration
410
+ can apply missing schema defaults, init hooks and schema getters/`toObject`
411
+ transforms that bare lean skips. Package virtuals, authorization, selection,
412
+ decoration and metadata rules still run in both modes. Test your application's
413
+ schema/plugins before switching; no universal JSON parity or measured speedup is
414
+ claimed. Client save-capable `Model` wrappers are a separate client feature.
415
+
416
+ ### Finalized permissions alias
417
+
418
+ **Compatibility expansion:** when a package-finalized model row already carries
419
+ serialized permission metadata, `row.permissions` aliases that finalized value at
420
+ `documentPermissionField` (default `_permissions`, also dotted paths such as
421
+ `meta.grants`), independently of hydration and plugin installation. It is a
422
+ non-enumerable, writable, configurable own data property: JSON, `Object.keys` and
423
+ object spread omit the generated alias. It is a convenience value, **not authority
424
+ or a live binding** after application reassignment.
425
+
426
+ Exposure pruning, metadata flags, field-map disabling and empty/placeholder
427
+ metadata apply before aliasing; private/unfiltered grants are never copied from
428
+ raw documents. `stripPermissionsField: true` suppresses both canonical metadata
429
+ and its generated alias. Existing `permissions` output wins, including selected
430
+ business fields, package virtuals and include attachments/descriptors. A canonical
431
+ path of `permissions` or `permissions.grants` keeps its existing enumerable
432
+ property; no separate alias is created.
433
+
434
+ Package-owned copies/related attachments preserve the alias **where metadata is
435
+ already present**; ordinary populated/dedicated subdocument outputs do not gain
436
+ metadata just to create one. Arbitrary application copies/transform replacements
437
+ and client `Model` wrappers need not retain non-enumerable properties. The optional
438
+ [Mongoose plugin](#optional-permissions-plugin) is a separate raw-document getter,
439
+ not a prerequisite for this finalized alias.
440
+
441
+ ### Root batch bounds
442
+
443
+ Create the batch endpoint with the **single-options** overload,
444
+ `api.createRouter({ basePath: '/batch', operationAccess: true })`, and mount its
445
+ `.routes`. Use the same runtime that registered its model/data targets. POST an array:
446
+
447
+ ```json
448
+ [
449
+ {
450
+ "target": "model",
451
+ "name": "Product",
452
+ "op": "list",
453
+ "order": 0,
454
+ "args": { "select": ["name"], "limit": 20 },
455
+ "options": { "includeCount": true }
456
+ }
457
+ ]
458
+ ```
459
+
460
+ `maxBatchEntries` defaults to **100**. `maxOrderGroups` defaults to **25** and applies
461
+ **two** bounds: at most 25 distinct order groups, and every `order` is an integer
462
+ in **0..25 inclusive** (omission is 0). Thus a lone `order: 26` rejects, and using
463
+ all 26 values from 0 through 25 also rejects. `maxConcurrentOperations` defaults
464
+ to **10** whole operations within each group. Constructor limit values must be
465
+ positive safe integers; invalid values fall back to these defaults. Excess batch/
466
+ order input returns BadRequest before entry dispatch.
467
+
468
+ Groups run in ascending order; within a group operations may run concurrently.
469
+ Results retain input indices/order, with each entry's `statusCode` and public
470
+ `result` inside the HTTP 200 batch response. Root entries use base operation guards,
471
+ not basic/advanced variant names. Batches are non-transactional: an unexpected
472
+ rejection can leave already-committed writes. Hook and persistence work also obey
473
+ their separate [request/runtime limits](#persistence-concurrency-and-resource-limits).
474
+
475
+ Runtime `requestComplexity` additionally defaults to `maxDepth: 8`, `maxNodes: 500`,
476
+ `maxLogicalClauses: 50`, `maxInValues: 100`, `maxBulkItems: 100`,
477
+ `maxIncludeCount: 10`, `maxSubQueryCount: 10`, `maxCorrelatedQueries: 100`, and
478
+ `maxCorrelatedDepth: 5`. Both concurrency defaults are 10. These configurable budgets
479
+ normalize invalid/non-positive/unsafe integers to their built-in defaults; zero does
480
+ not disable enforcement. The fixed grouped-count input bounds and 64-scope hook
481
+ reentry bound are separate contracts, documented in their sections.
482
+
483
+ ## Trusted service authorization
484
+
485
+ Use these contracts for tenant-scoped business services, protected input/output,
486
+ related targets and same-execution document decisions. For a complete executable
487
+ example, see [Runnable authorization and handle reuse](#runnable-authorization-and-handle-reuse).
488
+
489
+ ### Public request admission and backend options
490
+
491
+ All authorization input described below is **trusted server-only**. Acquire a
492
+ service through `router.getService(req)` or `req.macl.getService(modelName)` after
493
+ the owning runtime's middleware initializes the request. Keep policy in server
494
+ code; never copy it from a request body/query, OpenAPI option, client or React call.
495
+
496
+ Generated routes, root batches, public service wrappers (`_list`, `_read`,
497
+ `_readFilter`, mutations and `_new`), include/subquery options and populate
498
+ descriptors are request-only. Supplying `authorization`, `overrides`,
499
+ `documentAuthorization`, or private prepared/execution controls in their control
500
+ slots returns BadRequest, including after a custom request validator transforms an
501
+ envelope. Legacy include overrides that were silently ignored now fail admission.
502
+ Unknown options are not forwarded to internal target services.
503
+
504
+ Router `defaults` (including dotted setters) support query/presentation settings,
505
+ not per-call authorization or legacy overrides. Move backend authority out of
506
+ defaults and public/nested request options into explicit internal service calls.
507
+ Direct service row/output/related policies, named output helpers and internal result
508
+ contexts are available. The complete service `overrides` container is removed in
509
+ this breaking release; see the migration table below.
510
+
511
+ These restrictions apply to control slots, not business fields: payloads and
512
+ filters may still contain persisted fields named `authorization` or `overrides`,
513
+ and selections may request those names under ordinary field permissions. The
514
+ configured model `overrideFilter` hook/setter remains supported.
515
+
516
+ For populate and legacy/correlated includes, `authorization.related` is keyed by
517
+ the exact requested relationship/attachment path. Each entry must name the exact
518
+ registered target `model`; unused, ambiguous and mismatched entries reject before
519
+ target persistence. Child `operation`, `rows` and `outputFields` policies are
520
+ independent and default to request authority. A parent relationship-field grant
521
+ permits the source field but does not authorize target fields or operations.
522
+ Nested policies belong under the immediate target's `related` entry. Count targets
523
+ accept only `model`, `operation` and `rows`.
524
+
525
+ Target output grants remain selection-limited, including schema-hidden fields and
526
+ virtuals. Populate match, include filter, ref IDs and join/correlation constraints
527
+ remain ANDed with the target scope after query transformation in every row mode.
528
+ Populate policies also apply after create/update/upsert. Neither parent nor related
529
+ grants flow into request-controlled `$$sq` queries. For a same-execution target pick,
530
+ use `result.context.getDocumentAuthorization(child)` (or the corresponding list-row
531
+ context) with **that target model's** helper. Private dependencies and join keys do
532
+ not become output grants; related copies retain their own document results.
533
+
534
+ The existing `requireRegisteredPopulateModels: false` configuration remains an
535
+ explicit compatibility opt-out for requested unregistered populate targets. Those
536
+ values retain the descriptor's projection, have no target authorization handle,
537
+ and cannot receive `related` policies. It does not make an unassociated object
538
+ passed to a raw-policy helper into authorized target output.
539
+
540
+ ### Selection, grants and document results
541
+
542
+ For business fields, output is **selected AND (request-authorized OR explicitly
543
+ granted)**. `outputFields.mode` is `request` (the default), additive `grant`, or
544
+ `unrestricted` over selected eligible catalog fields. An empty grant adds nothing.
545
+ With omitted select, normal defined request fields plus explicit grant paths are
546
+ candidates; unrestricted uses the captured persisted/registered-virtual catalog.
547
+ Explicit selections/exclusions and `requireExplicitSelect` still apply. Grants
548
+ cannot fabricate missing values or guarantee a required result field exists.
549
+
550
+ Backend model `genSelect(access, select, options)` and its `genQuerySelect` alias
551
+ accept only named output-planning options, for example
552
+ `{ authorization: { outputFields: { mode: 'grant', paths: ['internalNote'] } } }`.
553
+ They return a persisted **fetch projection**, including private identity and
554
+ dependency inputs; this is not an output allowlist. Grants are additive to
555
+ request rules, never add to an explicit selection, and never override an
556
+ exclusion. A container grant covers its selected eligible subtree; a descendant
557
+ grant admits only the minimal ancestor structure, not sibling fields. Optional
558
+ `scopePath: ['contacts']` uses response segments; grant paths remain relative to
559
+ the owning model. The catalog includes declared `select: false` fields, embedded
560
+ schemas and registered virtuals; safe explicitly named Mixed/Map branches are
561
+ supported. Virtual names themselves never enter the database projection.
562
+
563
+ An opaque Mixed/Map root grant covers its existing safe subtree, subject to
564
+ descendant exclusions such as `-payload.secret`. A descendant-only selection or
565
+ grant does not authorize a scalar ancestor value: that value is omitted (or a
566
+ `null` placeholder in an array). A grant to the root itself can authorize its
567
+ scalar value when that root is selected.
568
+
569
+ Planning defers document-dependent rules and never runs permission functions or
570
+ virtual getters. Document hooks receive projected input: configure additional
571
+ persisted hook dependencies with `alwaysSelectFields`. Internal dependency fetches
572
+ do not grant output, even when the same path is also a private include join key.
573
+ Captured row/document hooks retain exact-access → `.default` → bare-hook resolution;
574
+ only an undefined entry falls through. Omitted/undefined helper options now use the
575
+ same canonical request-mode output planner as `{}`; positional helpers are removed.
576
+ Backend model output helpers also accept named options:
577
+ `pickAllowedFields(doc, access, { authorization })`, its `trimOutputFields` alias,
578
+ and `genAllowedFields(doc, access, { authorization, purpose: 'output' })`. Core
579
+ provides the same signatures with `modelName` first. They filter existing values,
580
+ including nested/array paths; they do not apply a selection, compute virtuals,
581
+ restore absent fields, check rows, or authorize writes. Omitted/undefined options
582
+ start fresh request-mode output evaluation. `genAllowedFields` defaults to
583
+ `{ purpose: 'output' }`; an explicitly supplied options object still requires that
584
+ purpose. It reports **existing output paths**, not a schema/input allowlist. Null or
585
+ undefined documents yield `[]` (pick/trim preserve the nullish value) without hooks.
586
+
587
+ The shared finalizer now consumes retained authorization plans and privately
588
+ memoizes full document grants and field decisions. For a subsequent same-execution
589
+ pick, use `{ documentAuthorization }` with the package-issued output context's
590
+ handle, rather than resupplying the raw policy. Handles validate the unchanged
591
+ output identity, request/runtime/model, scope, access and document stage; caller
592
+ spreads, matching IDs and `_permissions` are not provenance. Raw policy on a known
593
+ trimmed output retains unconditional request fields and explicit grants, but does
594
+ not rerun the document hook or recover document-dependent decisions. Metadata
595
+ stripping/exposure switches are independent of internal grants. Target models
596
+ require their own authorization; parent grants cover the relationship only.
597
+
598
+ Internal `findOne`/`findById`/`find`, `create`, `updateOne`/`updateById`, `upsert`
599
+ and `new` now consume the captured call policy throughout output finalization.
600
+ Single results expose `context.documentAuthorization`; list/create results expose
601
+ one `contexts[index]` per row. These opaque handles are non-enumerable context
602
+ properties, never attached to `data`. `context.getDocumentAuthorization(value)`
603
+ looks up an unchanged registered output in that result's graph.
604
+
605
+ <!-- doc-example: partial -->
606
+
607
+ ```ts
608
+ import type { ModelRequest } from '@web-ts-toolkit/access-router/advanced';
609
+ declare const req: ModelRequest;
610
+ const service = req.macl.getService<{ status?: string; internalNote?: string }>('Order');
611
+ declare const orderId: string;
612
+ const result = await service.findOne(
613
+ { _id: orderId },
614
+ { select: ['status', 'internalNote'] },
615
+ { authorization: { outputFields: { mode: 'grant', paths: ['internalNote'] } } },
616
+ );
617
+ if (result.success) {
618
+ const picked = await service.pickAllowedFields(result.data, 'read', {
619
+ documentAuthorization: result.context.documentAuthorization,
620
+ });
621
+ console.log(picked);
622
+ }
623
+ ```
624
+
625
+ The selected stored note survives false/missing request rules; `status` still
626
+ requires normal request authorization. Ordinary document grants now apply even
627
+ under `skim` and metadata-disabled/stripped postures. Declare private hook inputs
628
+ with `alwaysSelectFields`; later handle picks reuse the original results after
629
+ those inputs are removed. Metadata describes request permissions, not backend grants.
630
+
631
+ Mutations use ordinary `args.select`. Output grants never admit protected writes;
632
+ validation, prepare/transform/afterPersist/change hooks and save semantics remain.
633
+ Update admission and post-write output have separate document stages. Upsert keeps
634
+ its initiating operation with create/update branch accesses. `new()` continues to
635
+ ignore `select`, including empty/exclusion projections. Trusted presentation hooks
636
+ run after finalization and may intentionally remove/construct output. There is no
637
+ automatic second authorization pass; arbitrary mutations/replacements do not acquire
638
+ provenance. For an unchanged output, use its handle at the **output access**: `read`
639
+ for ordinary reads/create/update/upsert output, `list` for lists or reads using list
640
+ access, and `create` for `new()`.
641
+
642
+ The immutable **call policy** captures authority once for planning and execution.
643
+ Each document/scope/access stage separately owns its hook grants and field decisions;
644
+ two list rows never share those results. Reusing the same raw policy is fresh
645
+ evaluation, not a substitute for a document handle. A fresh unassociated input must
646
+ contain the projected hook dependencies; a recognized trimmed output cannot rerun
647
+ the hook to reconstruct lost decisions. Use either `authorization` or
648
+ `documentAuthorization` in a named helper, never both. When extracting policy from a
649
+ full call, construct `{ outputFields: policy.outputFields }` for output-only helpers;
650
+ `create` accepts input/output/related, and `new` accepts output only.
651
+
652
+ ### Independent mutation input authority
653
+
654
+ Trusted server code can use **`authorization.inputFields`** on model `create`,
655
+ `updateOne`, `updateById`, and both `upsert` branches. `MutationAuthorization<TModel,
656
+ TVirtuals>` adds this dimension to the read policy; `CreateAuthorization` also
657
+ accepts it but rejects `rows`. `ServiceAuthorization` remains the read policy.
658
+ Reads (even with a mutation access name), `new`, output helpers, related read/count
659
+ targets and public options reject input authority, including reusable typed policies.
660
+
661
+ <!-- doc-example: partial -->
662
+
663
+ ```ts
664
+ import type { MutationAuthorization } from '@web-ts-toolkit/access-router';
665
+ import type { ModelRequest } from '@web-ts-toolkit/access-router/advanced';
666
+ type Order = { status: string; internalNote?: string; contacts: { email: string; ssn: string }[] };
667
+ declare const req: ModelRequest;
668
+ declare const orderId: string;
669
+ const service = req.macl.getService<Order>('Order');
670
+ const authorization = {
671
+ inputFields: { mode: 'grant', paths: ['internalNote', 'contacts.email'] },
672
+ } as const satisfies MutationAuthorization<Order>;
673
+ const result = await service.updateById(
674
+ orderId,
675
+ {
676
+ status: 'reviewed', // ordinary request permission still applies
677
+ internalNote: 'server-owned decision', // explicit input grant
678
+ contacts: [{ email: 'corrected@example.test' }], // patches existing element 0
679
+ },
680
+ { select: ['status', 'internalNote'] },
681
+ { authorization },
682
+ );
683
+ // The note is persisted, but still omitted from output if the actor cannot read it.
684
+ // Add a separate outputFields grant if this call also needs to return the note.
685
+ ```
686
+
687
+ Input modes are `request` (also omitted/undefined), additive `grant` with readonly
688
+ `paths`, and `unrestricted`. An empty grant adds nothing; ordinary request-authorized
689
+ fields still write. `unrestricted` admits eligible persisted roots, not arbitrary
690
+ keys on a `strict: false` document. It grants no rows, output, query/sort fields,
691
+ route guards, related operations or `$$sq` targets, and never stamps an upsert scope
692
+ into inserted data. `args.select` affects output only.
693
+
694
+ `InputFieldPath<TModel>` and `InputFieldAuthorization<TModel>` use bounded persisted
695
+ model paths without the virtual generic. Runtime validation also checks JS/dynamic
696
+ policies against the actual schema, embedded schemas and virtual registry. Use
697
+ `contacts.email`, not indices, brackets, `sub`, signed tokens or wildcards. Safe
698
+ Mixed/Map descendants are supported; reference interiors belong to another model.
699
+ Unknown paths, virtuals (including Mongoose aliases), `_id`, version keys,
700
+ permission metadata (including configured dotted paths), prototype/Mongoose keys
701
+ and execution/prepared-policy metadata cannot be elevated. Business fields named
702
+ `authorization` and `overrides` remain payload data.
703
+
704
+ Request-input compatibility also excludes a scope's registered virtual names through
705
+ Mixed/plain nesting: a root `receipt` virtual excludes submitted `bag.receipt`, while
706
+ entering a registered child scope uses that child's virtual names. This applies to
707
+ request/granted/unrestricted input and prepared JS/Map containers. Explicit grants
708
+ to these aliases reject; descendant patches leave unrelated existing siblings intact.
709
+
710
+ | Input boundary | Persistence behavior |
711
+ | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
712
+ | Leaf (`profile.email`) | Replaces only the supplied leaf, including null/undefined. Omission does nothing; denied siblings survive. Necessary object ancestors may be initialized, but an existing scalar ancestor is not replaced. |
713
+ | Object container (`profile`) | Replaces its eligible subtree; omitted descendants are removed. Empty/null replacement is allowed. |
714
+ | Array container (`contacts`, or an authorized ancestor) | Replaces the array, including order, length, null/empty values and omitted child fields. Elevated replacements omit supplied embedded IDs; Mongoose generates IDs unless ordinary request authority already admits them. |
715
+ | Array descendant (`contacts.email`) | Patches existing object elements **by index**, never by supplied ID. Short inputs preserve trailing elements; excess elements, holes, null/scalar slots and scalar/invalid ancestors make no structural change. The same rule applies to nested/Mixed arrays. |
716
+
717
+ A descendant grant cannot create an absent array, **including on create or the
718
+ upsert-create branch**. Grant the array/ancestor container, or use existing request
719
+ container permission, to create/resize it. A narrower grant never narrows ordinary
720
+ request authority: an already request-authorized container retains replacement
721
+ semantics. Nested client JSON is required; literal dotted update keys are not updates.
722
+
723
+ Validation sees admitted data; prepare, transform, Mongoose casting/validation/save,
724
+ afterPersist and change hooks remain active. Trusted prepare additions and explicit
725
+ transform setters keep their business-write contract. Input/prepare values are
726
+ isolated; virtuals and control metadata are excluded even in permissive whole
727
+ containers and prepared JS values. Trusted prepare additions are business writes,
728
+ not a second input-policy grant.
729
+ JS class/hydrated containers are normalized to their data before admission; native
730
+ Date/Buffer/BSON leaves retain their types. Functions/symbols are not persisted input.
731
+ Mutation payloads, including class/Map contents and sparse arrays, obey
732
+ `requestComplexity` before parsing and input hooks; cycles return BadRequest. Input
733
+ admission and prepared-container sanitization also have hard structural ceilings of
734
+ 100 levels and 20,000 nodes. Grants do not bypass these limits. Unmodified persisted
735
+ virtual values outside the write boundary are preserved; they remain excluded from
736
+ ordinary output.
737
+ Pre-write update grants resolve once from the full hydrated persisted document, with input
738
+ structure (including schema-hidden roots and embedded fields) fetched privately. They are passed through
739
+ `context.docPermissions`, not stored on the document or reconstructed from input
740
+ `_permissions`. Create keeps request-only pre-document rules and resolves its document
741
+ hook after persistence. Post-write output gets fresh document grants; input authority
742
+ does not become an output handle or forge `_view`/`_edit` permissions.
743
+
744
+ **Migration:** a write formerly requiring trusted preparation can instead grant the
745
+ specific input path explicitly. An output grant alone still cannot write. Keep read
746
+ policies typed as `ServiceAuthorization`; use `MutationAuthorization` for updates/
747
+ upserts and `CreateAuthorization` for creates. Derive `{ outputFields }` for output
748
+ helpers rather than forwarding a mutation policy. Dedicated subdocument authorization
749
+ uses the scoped API below; model-root input policies are not forwarded into it.
750
+
751
+ ### Dedicated subdocument authorization
752
+
753
+ Internal `listSub`, `readSub`, `createSub`, `updateSub`, `bulkUpdateSub` and `deleteSub`
754
+ accept a final options object with **`authorization: { parent, child, visibility }`**.
755
+ Use `router.getService(req)` / `req.macl.getService<Model>(name)`. The public service,
756
+ root batches, generated basic/advanced routes, nested descriptors, custom-validator
757
+ envelopes and defaults reject these capabilities. Business payload properties named
758
+ `authorization` or `overrides` keep their ordinary meaning.
759
+
760
+ The named subdocument must be a **top-level embedded document array**, validated
761
+ against Mongoose (not a reference array, object, dotted path or virtual). Known-model
762
+ literal names infer the child and virtual shapes; unknown models keep runtime checks.
763
+ `SubdocumentModel<Parent, 'contacts'>` extracts the child type. Child field/row paths
764
+ are **relative to that child** (`email`, `preferences.theme`), while parent row paths
765
+ use the owning model (`tenantId`). Policies have no recursive `subdocuments` tree.
766
+
767
+ <!-- doc-example: partial -->
768
+
769
+ ```ts
770
+ import type { SubdocumentMutationAuthorization } from '@web-ts-toolkit/access-router';
771
+ import type { ModelRequest } from '@web-ts-toolkit/access-router/advanced';
772
+ type Order = { tenantId: string; contacts: { email: string; ssn: string }[] };
773
+ declare const req: ModelRequest;
774
+ declare const orderId: string;
775
+ declare const contactId: string;
776
+ declare const tenantId: string; // trusted server value
777
+ const parent = {
778
+ operation: { mode: 'grant' },
779
+ rows: { mode: 'scope', filter: { tenantId } },
780
+ } as const;
781
+ const authorization = {
782
+ parent, // only the owning parent's update gate/rows
783
+ child: {
784
+ operation: { mode: 'grant' }, // only contacts.update
785
+ inputFields: { mode: 'grant', paths: ['email'] },
786
+ // child update rows still use the actor's request policy
787
+ },
788
+ visibility: {
789
+ parent, // explicitly authorize the separate parent READ stage
790
+ child: {
791
+ operation: { mode: 'grant' }, // separate contacts.read gate
792
+ outputFields: { mode: 'grant', paths: ['email'] },
793
+ },
794
+ },
795
+ } as const satisfies SubdocumentMutationAuthorization<Order, Order['contacts'][number]>;
796
+ const result = await req.macl
797
+ .getService<Order>('Order')
798
+ .updateSub(orderId, 'contacts', contactId, { email: 'corrected@example.test' }, { authorization });
799
+ // Omit visibility to retain request-only post-write read gates/rows/fields.
800
+ // A successful hidden write returns data: null; do not retry merely for null output.
801
+ ```
802
+
803
+ | Internal method | Parent gate/rows | Child gate/rows and fields | Response |
804
+ | ----------------------------------------------------------------- | ---------------- | ---------------------------------------- | ------------------------------------------------------------- |
805
+ | `listSub(id, sub, { filter?, select?, authorization? })` | read | list; `outputFields` | Filtered list, visible count |
806
+ | `readSub(id, sub, subId, { select?, populate?, authorization? })` | read | read; `outputFields` | One selected child |
807
+ | `createSub(id, sub, data, { addFirst?, authorization? })` | update | create; `inputFields`; **no child rows** | Visible full stored array, including existing rows |
808
+ | `updateSub(id, sub, subId, data, { authorization? })` | update | update; `inputFields` | Visible updated child or successful `null` |
809
+ | `bulkUpdateSub(id, sub, data, { authorization? })` | update | update; `inputFields` | Visible targeted children in stored order, or successful `[]` |
810
+ | `deleteSub(id, sub, subId, { authorization? })` | update | delete; no fields | Removed ID; no visibility policy |
811
+
812
+ Supplying `authorization` (even `{}`) opts a trusted internal call into the two
813
+ listed **base-operation** gates. An omitted dimension uses request authority;
814
+ `operation: { mode: 'grant' }` permits exactly its gate. Calls with no supplied
815
+ policy retain their historical route-owned initiating guards. The generated
816
+ basic/advanced guards continue to choose their own request-only route entry.
817
+ Parent and child operation grants imply neither each other nor row/input/output
818
+ grants. Parent fields are inapplicable; they reject even in request mode.
819
+
820
+ Each rows dimension supports request/scope/unrestricted. `overrideFilter` still
821
+ runs once at its access stage; the resolved parent identifier, selected child
822
+ ID(s), owner scope and owning-array membership cannot be erased by rewriting,
823
+ including in-place mutation. False query/identifier/scope/transform is terminal.
824
+ Scope predicates are inert (no `$$sq`/`$$date` expansion), validate against their
825
+ own schema, and obey request complexity depth/node/logical-clause/`$in` budgets.
826
+ Grant arrays obey those budgets too. Invalid paths, unsupported dimensions and
827
+ malformed visibility policies reject **before any write**.
828
+
829
+ Mutation visibility is separate: `visibility.parent` supplies parent **read**
830
+ operation/rows; `visibility.child` supplies child **read** operation/rows and
831
+ `outputFields`. **Create also requires child list** operation/rows, independently
832
+ configured with `visibility.list`. Update/bulk do not require list and reject that
833
+ dimension. Visibility inherits no mutation authority; absent dimensions use request
834
+ policy. Successful hidden create/bulk responses are `data: [], count: 0`, and
835
+ successful hidden single updates are `data: null`. Counts measure returned rows.
836
+
837
+ Child input uses the input rules above: descendant grants preserve denied siblings
838
+ and patch existing nested array objects by index; explicit nested-container grants
839
+ replace their eligible subtree. Only create/delete change the **outer** array's
840
+ membership. Virtuals/control metadata/identity cannot be elevated. Dedicated input
841
+ also retains the virtual-name scrub within Mixed/plain nesting; grants matching
842
+ those aliases reject before effects. Update document grants resolve once from the
843
+ original hydrated **owning parent**, including hidden
844
+ persisted inputs; create retains request-only pre-document admission. Output
845
+ resolves a fresh raw-parent read-stage result, independently of serialized metadata.
846
+ Virtual access is list/read/create/update respectively; output fields use list for
847
+ list and read otherwise, with document grants using read. Shared finalization
848
+ remains active. Dedicated operations retain Mongoose casting/validation/save hooks;
849
+ they do not invoke model-root validate/prepare/transform/afterPersist chains on a child.
850
+
851
+ `readSub` populate targets always use their **own request authority**. Child output
852
+ grants admit the relationship only; they never elevate target fields, rows or gates.
853
+ Dedicated parent/child/visibility policies reject `related`. For embedded output
854
+ inside a model-root read, use its existing `related` target policy contract.
855
+
856
+ **Types/migration:** root and `/advanced` export `SubdocumentReadAuthorization`,
857
+ `SubdocumentMutationAuthorization`, `SubdocumentCreateAuthorization`,
858
+ `SubdocumentDeleteAuthorization`, `SubdocumentVisibilityAuthorization` and
859
+ `SubdocumentCreateVisibilityAuthorization`. Their generics are `<Parent, Child,
860
+ ChildVirtuals>` (delete has no virtual generic). `Subdocument*Options` are now
861
+ internal; use `PublicSubdocumentListOptions`, `PublicSubdocumentReadOptions`,
862
+ `PublicSubdocumentCreateOptions` and the empty `PublicSubdocumentMutationOptions`
863
+ for public adapters. Keep policies as server constants, not spreads of request
864
+ options. Positional helper overloads are removed; see the named-helper migration
865
+ and request-only data compatibility contract below.
866
+
867
+ ### Runnable authorization and handle reuse
868
+
869
+ This complete example uses an installed package, Express and MongoDB. Save it as
870
+ `authorization-example.mts` and run `node authorization-example.mts` on Node 22.18+
871
+ (or compile it with TypeScript/NodeNext first). `MONGODB_URL` defaults to local
872
+ MongoDB. It creates and removes its own uniquely named example database, serves one
873
+ loopback test request, and exits after its assertions pass. The route's policy and
874
+ example actor (`alice`) are server-owned constants, never HTTP inputs.
875
+
876
+ <!-- doc-example: complete-runtime -->
877
+
878
+ ```ts
879
+ import assert from 'node:assert/strict';
880
+ import { randomUUID } from 'node:crypto';
881
+ import { once } from 'node:events';
882
+ import type { Server } from 'node:http';
883
+ import express from 'express';
884
+ import mongoose from 'mongoose';
885
+ import {
886
+ createAccessRuntime,
887
+ type OutputAuthorization,
888
+ type ServiceAuthorization,
889
+ type MutationAuthorization,
890
+ type SubdocumentMutationAuthorization,
891
+ } from '@web-ts-toolkit/access-router';
892
+ import type { ModelRequest } from '@web-ts-toolkit/access-router/advanced';
893
+
894
+ type Order = {
895
+ status?: string;
896
+ internalNote?: string;
897
+ ownerId?: string;
898
+ tenantId: string;
899
+ contacts?: { email?: string; ssn?: string }[];
900
+ customer?: mongoose.Types.ObjectId;
901
+ };
902
+ type Virtuals = { summary: string };
903
+
904
+ async function runServiceAuthorizationExample() {
905
+ const connection = await mongoose
906
+ .createConnection(process.env.MONGODB_URL ?? 'mongodb://127.0.0.1:27017', {
907
+ dbName: `authorization_example_${randomUUID().replaceAll('-', '')}`,
908
+ })
909
+ .asPromise();
910
+ let server: Server | undefined;
911
+ try {
912
+ const api = createAccessRuntime();
913
+ api.setGlobalOptions({ globalPermissions: () => [] }); // no user grant for the note
914
+ const Customer = connection.model(
915
+ 'ExampleCustomer',
916
+ new mongoose.Schema({
917
+ name: String,
918
+ billingEmail: String,
919
+ tenantId: String,
920
+ }),
921
+ );
922
+ const OrderModel = connection.model<Order>(
923
+ 'ExampleOrder',
924
+ new mongoose.Schema<Order>({
925
+ status: String,
926
+ internalNote: { type: String, select: false },
927
+ ownerId: String,
928
+ tenantId: String,
929
+ contacts: [{ email: String, ssn: String }],
930
+ customer: { type: mongoose.Schema.Types.ObjectId, ref: Customer.modelName },
931
+ }),
932
+ );
933
+ const customerRouter = api.createRouter(Customer, {
934
+ operationAccess: { read: false },
935
+ baseFilter: { read: () => false },
936
+ permissionSchema: { name: true, billingEmail: false },
937
+ stripPermissionsField: true,
938
+ });
939
+ let documentHookCalls = 0;
940
+ let queryTransforms = 0;
941
+ const router = api.createRouter<Order, Virtuals>(OrderModel, {
942
+ basePath: '/orders',
943
+ operationAccess: { read: true },
944
+ hydration: { findOne: 'lean' },
945
+ stripPermissionsField: true,
946
+ baseFilter: { read: () => ({ tenantId: 'request-tenant' }) },
947
+ overrideFilter: {
948
+ read: (filter) => {
949
+ queryTransforms++;
950
+ return filter;
951
+ },
952
+ },
953
+ alwaysSelectFields: { read: ['ownerId'] }, // private projected document-hook input
954
+ docPermissions: {
955
+ read: (doc) => {
956
+ documentHookCalls++;
957
+ return { owner: doc !== null && typeof doc === 'object' && 'ownerId' in doc && doc.ownerId === 'alice' };
958
+ },
959
+ },
960
+ permissionSchema: { status: { read: 'owner' }, internalNote: false, summary: false },
961
+ virtuals: {
962
+ summary: {
963
+ dependsOn: ['status'],
964
+ read: (doc) => (doc.status === undefined ? undefined : `Status: ${doc.status}`),
965
+ },
966
+ },
967
+ });
968
+ const customer = await Customer.create({ name: 'Ada', billingEmail: 'billing@example.test', tenantId: 'backend' });
969
+ const rows = await OrderModel.insertMany([
970
+ {
971
+ status: 'ready',
972
+ internalNote: 'server note',
973
+ ownerId: 'alice',
974
+ tenantId: 'backend',
975
+ customer: customer._id,
976
+ contacts: [{ email: 'alice@example.test', ssn: 'hidden' }],
977
+ },
978
+ { status: 'closed', internalNote: 'other note', ownerId: 'bob', tenantId: 'backend' },
979
+ { status: 'draft', ownerId: 'alice', tenantId: 'backend' }, // note is absent
980
+ ]);
981
+ const output = {
982
+ outputFields: { mode: 'grant', paths: ['internalNote'] },
983
+ } as const satisfies OutputAuthorization<Order>;
984
+ const authorization = {
985
+ ...output,
986
+ rows: { mode: 'scope', filter: { tenantId: 'backend' } },
987
+ } as const satisfies ServiceAuthorization<Order, Virtuals>;
988
+ const quiet = { skim: true, includePermissions: false, includeFieldPermissions: false };
989
+ const selected = { select: ['status', 'internalNote', '-_id'] };
990
+
991
+ async function verifyServiceAuthorization(req: ModelRequest) {
992
+ const service = router.getService(req); // alternatively req.macl.getService<Order, Virtuals>(OrderModel.modelName)
993
+ const result = await service.findOne({ _id: rows[0]._id }, selected, { ...quiet, authorization });
994
+ assert.ok(result.success);
995
+ assert.deepEqual(result.data, { status: 'ready', internalNote: 'server note' });
996
+ // ownerId and _permissions are absent, yet this handle retains both decisions.
997
+ const reuse = { documentAuthorization: result.context.documentAuthorization };
998
+ const picked = await service.pickAllowedFields(result.data, 'read', reuse);
999
+ assert.deepEqual(await service.trimOutputFields(picked, 'read', reuse), result.data);
1000
+ assert.equal(documentHookCalls, 1);
1001
+ const fresh = await service.pickAllowedFields(result.data, 'read', { authorization: output });
1002
+ assert.deepEqual(fresh, { internalNote: 'server note' }); // no original owner decision
1003
+ assert.equal(documentHookCalls, 1); // never rerun the hook against this trimmed DTO
1004
+
1005
+ const deniedStatus = await service.findOne({ _id: rows[1]._id }, selected, { ...quiet, authorization });
1006
+ assert.ok(deniedStatus.success);
1007
+ assert.deepEqual(deniedStatus.data, { internalNote: 'other note' });
1008
+ const unselected = await service.findOne(
1009
+ { _id: rows[0]._id },
1010
+ { select: ['status', '-_id'] },
1011
+ { ...quiet, authorization },
1012
+ );
1013
+ assert.ok(unselected.success);
1014
+ assert.deepEqual(unselected.data, { status: 'ready' }); // grant does not select the note
1015
+ const absent = await service.findOne({ _id: rows[2]._id }, selected, { ...quiet, authorization });
1016
+ assert.ok(absent.success);
1017
+ assert.deepEqual(absent.data, { status: 'draft' }); // grant does not create a value
1018
+ const requestRows = await service.findOne({ _id: rows[0]._id }, selected, { ...quiet, authorization: output });
1019
+ assert.equal(requestRows.success, false); // output grant cannot replace request row scope
1020
+ if (!requestRows.success) assert.equal(requestRows.code, 'not_found');
1021
+
1022
+ router.setOption('permissionSchema', { status: { read: 'owner' } }); // note rule now missing
1023
+ const missingRule = await service.findOne({ _id: rows[0]._id }, selected, { ...quiet, authorization });
1024
+ assert.ok(missingRule.success);
1025
+ assert.equal(missingRule.data.internalNote, 'server note');
1026
+ const before = queryTransforms;
1027
+ await service.findOne({ _id: rows[0]._id }, selected, {
1028
+ ...quiet,
1029
+ authorization: { ...output, rows: { mode: 'unrestricted' } },
1030
+ });
1031
+ assert.equal(queryTransforms, before + 1); // unrestricted still runs overrideFilter
1032
+
1033
+ const related = await service.findOne(
1034
+ { _id: rows[0]._id },
1035
+ {
1036
+ select: ['summary', 'contacts.email', 'customer', '-_id'],
1037
+ populate: [{ path: 'customer', select: ['name', 'billingEmail'] }],
1038
+ },
1039
+ {
1040
+ ...quiet,
1041
+ authorization: {
1042
+ rows: authorization.rows,
1043
+ outputFields: { mode: 'grant', paths: ['internalNote', 'summary', 'contacts.email', 'customer'] },
1044
+ related: {
1045
+ customer: {
1046
+ model: Customer.modelName,
1047
+ operation: { mode: 'grant' },
1048
+ rows: { mode: 'scope', filter: { tenantId: 'backend' } },
1049
+ outputFields: { mode: 'grant', paths: ['billingEmail'] },
1050
+ },
1051
+ },
1052
+ },
1053
+ },
1054
+ );
1055
+ assert.ok(related.success);
1056
+ assert.equal(Reflect.get(related.data, 'summary'), 'Status: ready');
1057
+ assert.equal(related.data.internalNote, undefined); // granted, still unselected
1058
+ assert.equal(related.data.contacts?.[0].email, 'alice@example.test');
1059
+ assert.equal(related.data.contacts?.[0].ssn, undefined);
1060
+ const child: unknown = Reflect.get(related.data, 'customer');
1061
+ assert.ok(child && typeof child === 'object');
1062
+ const childHandle = related.context.getDocumentAuthorization(child);
1063
+ assert.ok(childHandle);
1064
+ const childPick = await customerRouter
1065
+ .getService(req)
1066
+ .pickAllowedFields(child, 'read', { documentAuthorization: childHandle });
1067
+ assert.equal(Reflect.get(childPick, 'billingEmail'), 'billing@example.test');
1068
+ assert.equal(Reflect.get(childPick, 'tenantId'), undefined);
1069
+
1070
+ // Input authority is independent: patch the existing first contact, preserve ssn,
1071
+ // persist the denied note, and grant neither field in the mutation response.
1072
+ const input = {
1073
+ inputFields: { mode: 'grant', paths: ['internalNote', 'contacts.email'] },
1074
+ } as const satisfies MutationAuthorization<Order, Virtuals>;
1075
+ const updated = await service.updateOne(
1076
+ { _id: rows[0]._id },
1077
+ {
1078
+ internalNote: 'backend write',
1079
+ contacts: [{ email: 'corrected@example.test' }],
1080
+ },
1081
+ { select: ['internalNote', 'contacts.email', '-_id'] },
1082
+ { ...quiet, authorization: input },
1083
+ );
1084
+ assert.ok(updated.success);
1085
+ assert.equal(updated.data.internalNote, undefined);
1086
+ const persisted = await OrderModel.collection.findOne({ _id: rows[0]._id });
1087
+ assert.equal(persisted?.internalNote, 'backend write');
1088
+ assert.equal(persisted?.contacts?.[0].email, 'corrected@example.test');
1089
+ assert.equal(persisted?.contacts?.[0].ssn, 'hidden');
1090
+
1091
+ // A dedicated child write has independent parent/child gates and read visibility.
1092
+ const persistedContact = persisted?.contacts?.[0];
1093
+ assert.ok(persistedContact);
1094
+ const contactId = String(Reflect.get(persistedContact, '_id'));
1095
+ const childPolicy = {
1096
+ parent: { operation: { mode: 'grant' } }, // parent update gate only
1097
+ child: { operation: { mode: 'grant' }, inputFields: { mode: 'grant', paths: ['email'] } },
1098
+ } as const satisfies SubdocumentMutationAuthorization<Order, NonNullable<Order['contacts']>[number]>;
1099
+ const hiddenChild = await service.updateSub(
1100
+ String(rows[0]._id),
1101
+ 'contacts',
1102
+ contactId,
1103
+ { email: 'dedicated@example.test' },
1104
+ { authorization: childPolicy },
1105
+ );
1106
+ assert.ok(hiddenChild.success);
1107
+ assert.equal(hiddenChild.data, null); // ordinary parent read rows still deny this tenant
1108
+ const visibleChild = await service.readSub(String(rows[0]._id), 'contacts', contactId, {
1109
+ select: ['email'],
1110
+ authorization: {
1111
+ parent: { rows: { mode: 'scope', filter: { tenantId: 'backend' } } },
1112
+ child: { operation: { mode: 'grant' }, outputFields: { mode: 'grant', paths: ['email'] } },
1113
+ },
1114
+ });
1115
+ assert.ok(visibleChild.success);
1116
+ assert.ok(visibleChild.data && typeof visibleChild.data === 'object');
1117
+ assert.equal(Reflect.get(visibleChild.data, 'email'), 'dedicated@example.test');
1118
+ const childStored = await OrderModel.collection.findOne({ _id: rows[0]._id });
1119
+ assert.equal(childStored?.contacts?.[0].email, 'dedicated@example.test');
1120
+ assert.equal(childStored?.contacts?.[0].ssn, 'hidden');
1121
+ }
1122
+
1123
+ router.router.post('/verify', async (req, res) => {
1124
+ await verifyServiceAuthorization(req as ModelRequest); // router middleware has initialized req.macl
1125
+ res.json({ ok: true });
1126
+ });
1127
+ const app = express();
1128
+ app.use(express.json());
1129
+ app.use(router.routes);
1130
+ server = app.listen(0, '127.0.0.1');
1131
+ await once(server, 'listening');
1132
+ const address = server.address();
1133
+ assert.ok(address && typeof address !== 'string');
1134
+ const response = await fetch(`http://127.0.0.1:${address.port}/orders/verify`, { method: 'POST' });
1135
+ assert.equal(response.status, 200, await response.text());
1136
+ } finally {
1137
+ if (server) await new Promise<void>((resolve, reject) => server!.close((err) => (err ? reject(err) : resolve())));
1138
+ await connection.dropDatabase(); // only the unique example database created above
1139
+ await connection.close();
1140
+ }
1141
+ }
1142
+
1143
+ void runServiceAuthorizationExample().catch((error) => {
1144
+ console.error(error);
1145
+ process.exitCode = 1;
95
1146
  });
96
1147
  ```
97
1148
 
1149
+ The target policy explicitly supplies **operation, row and output** authority. A
1150
+ parent `customer` grant only admits that relationship; ref IDs and requested match/
1151
+ join predicates still constrain target rows. A virtual grant permits selected getter
1152
+ evaluation with its private dependencies, not database filtering/sorting or writes.
1153
+ Getter failure/undefined and missing dependencies can still omit its output.
1154
+
1155
+ ### Backend row authorization and filter migration
1156
+
1157
+ Internal `find`, `findOne`, `findById`, `updateOne`, `updateById` and `upsert`
1158
+ accept a per-call `options.authorization.rows` policy:
1159
+
1160
+ <!-- doc-example: partial -->
1161
+
1162
+ ```ts
1163
+ import type { ModelRequest } from '@web-ts-toolkit/access-router/advanced';
1164
+ declare const req: ModelRequest;
1165
+ const service = req.macl.getService<{ status: string; tenantId: string }>('Order');
1166
+ declare const tenantId: string;
1167
+ const result = await service.find(
1168
+ { status: 'open' },
1169
+ { select: ['status'], limit: 20 },
1170
+ {
1171
+ includeCount: true,
1172
+ authorization: { rows: { mode: 'scope', filter: { tenantId } } },
1173
+ },
1174
+ );
1175
+ ```
1176
+
1177
+ | Row mode | Effective predicate |
1178
+ | ------------------- | ----------------------------------------------------------------------- |
1179
+ | `request` (default) | Configured query transform AND request `baseFilter` |
1180
+ | `scope` | Same query transform AND supplied trusted scope, replacing `baseFilter` |
1181
+ | `unrestricted` | Same query transform, without request `baseFilter` |
1182
+
1183
+ `overrideFilter.<access>` always transforms the ordinary predicate **once**. It
1184
+ can intentionally rewrite query input, and its restrictions/false denial remain
1185
+ effective in every row mode. Mandatory reference/join constraints and trusted
1186
+ scope are ANDed afterward. A trusted scope is an inert database predicate:
1187
+ `$$sq`/`$$date` values in it are never expanded. `{}` explicitly allows all rows
1188
+ within the transformed query; null/undefined/malformed scope filters reject.
1189
+
1190
+ False query, identifier, or trusted scope is terminal before parser/identifier
1191
+ work as applicable. Transform/base-filter false is terminal before primary
1192
+ persistence or subsequent populate/include dispatch, but a read/list transform
1193
+ receives the parsed query: necessary template subqueries may already have run.
1194
+ Mutation lookup predicates retain their existing inert, non-client-parsing
1195
+ behavior; mutation **data** still follows normal parsing and write admission.
1196
+ An allowed upsert miss may create; lookup scope does not stamp values into input.
1197
+
1198
+ Private prepared execution reuses the authorized predicate for upsert's update
1199
+ branch, include reads/lists/counts and `find.totalCount`. Correlated read/list
1200
+ templates parse before parent substitution; substituted marker-shaped parent data
1201
+ stays inert. Correlated count templates retain their non-parsing behavior. These
1202
+ tokens are identity-bound internal objects, never arguments, JSON options or DTO
1203
+ properties. Standalone `exists`, `count`, `delete` and `distinct` retain their
1204
+ request-policy signatures; sort/distinct permission checks remain independent
1205
+ of output grants. Original request/user permissions remain intact across calls.
1206
+
1207
+ **Breaking-release migration:** old object `overrides.filter` bypassed both
1208
+ filter hooks. Replace it with an ordinary predicate plus explicit row policy.
1209
+ Move replaceable user row restrictions from `overrideFilter` into `baseFilter`;
1210
+ there is no query-transform bypass option. Use `findOne`/`updateOne` for custom
1211
+ lookup predicates instead of `overrides.idFilter`. Release guidance is maintained
1212
+ here and in the task record.
1213
+
1214
+ ### Distinct: whole-value request authorization
1215
+
1216
+ `GET /distinct/:field`, `POST /distinct/:field`, root `op: 'distinct'`, and trusted
1217
+ `service.distinct(field, { filter })` share the same pre-query field admission.
1218
+ Distinct returns Mongo's **raw complete values**; it does not trim nested fields or
1219
+ re-deduplicate modified objects. This preserves distinct equivalence and the returned
1220
+ `count`. An unsafe field returns `forbidden` (HTTP/root-entry **403**) before any
1221
+ distinct database call, even when the supplied filter would currently match no rows.
1222
+
1223
+ - The requested persisted path needs an explicit request `read` rule, either a flat
1224
+ dotted key or a nested `.sub` entry. A child grant does not authorize its parent.
1225
+ Nested entries use ordinary ancestor gates and exact dotted-rule precedence.
1226
+ Scalar `_id` retains its implicit identity access.
1227
+ - Declared scalar values (including native BSON scalar types), scalar reference IDs,
1228
+ and scalar arrays are eligible. Closed nested objects, single-nested documents and
1229
+ embedded document arrays require request-proven access to **every** declared returned
1230
+ descendant. Missing or denied child rules reject the entire distinct operation.
1231
+ - Mixed values/descendants, arrays containing Mixed, whole Maps, unknown types,
1232
+ recursive object schemas, discriminator-dependent composites and non-strict composites
1233
+ cannot prove a complete safe value and reject, including when nested inside an otherwise
1234
+ allowed container. A shared discriminator scalar path must have a scalar type in every
1235
+ registered variant. An explicitly granted named Map key may qualify when its schema value
1236
+ is a scalar or a fully allowed closed object. Admission never infers a type from the
1237
+ current stored values.
1238
+ - Named nested policy restrictions are checked even when that path is absent from the
1239
+ current Mongoose schema: stale/unknown policy branches do not authorize raw stored keys.
1240
+ - Permission metadata paths and their containers, protected stored descendants, and
1241
+ registered virtual paths and their containers reject. Distinct never runs getters.
1242
+ - Boolean grants and permission expressions already satisfied by the original request
1243
+ can authorize disclosure. Document-dependent grants and **all field-rule callbacks**
1244
+ reject: executing a callback with fabricated empty document grants cannot prove that
1245
+ the returned values are readable. Neither `docPermissions`, backend `outputFields`
1246
+ grants nor a prior document handle supplies distinct authority. Evaluation failures
1247
+ also deny; they do not retry through a more permissive field allowlist.
1248
+
1249
+ For example, this `permissionSchema` permits `distinct('profile.public')` but rejects
1250
+ `distinct('profile')` and `distinct('profile.secret')`:
1251
+
1252
+ ```json
1253
+ { "profile": { "read": true, "sub": { "public": true, "secret": false } } }
1254
+ ```
1255
+
1256
+ **Breaking-release migration:** request-only field callbacks previously ran with empty
1257
+ document grants. Move those decisions into `globalPermissions` and use a declarative
1258
+ permission key in `permissionSchema`. Query a permitted scalar child when a composite
1259
+ contains private fields. The generated operation guards, request `read` row filters,
1260
+ filter validation/casting and virtual-predicate exclusion still apply; distinct accepts
1261
+ no backend authorization option.
1262
+
1263
+ ### Breaking release: service overrides migration
1264
+
1265
+ Explicit authorization and complete service-container removal ship together.
1266
+ `overrides` is absent from internal service argument/default types and is rejected
1267
+ at service entry with an actionable `bad_request` ErrorResult **before parsing,
1268
+ identifier generation, child dispatch or persistence**. This includes `{}`, `null`,
1269
+ `undefined`, inherited/non-enumerable JS slots, and mixtures with valid new
1270
+ authorization. Delete the old property entirely; setting it to `undefined` is not
1271
+ a migration. Moving it from args to options also rejects. There is no deprecated
1272
+ adapter or alternate legacy authority path.
1273
+
1274
+ | Removed usage (before) | Replacement (after) |
1275
+ | ---------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1276
+ | `overrides.select: ['internalNote']` | `findOne(query, { select: ['status', 'internalNote'] }, { authorization: { outputFields: { mode: 'grant', paths: ['internalNote'] } } })`. Selection describes output; the grant supplies additive authority. |
1277
+ | `overrides.filter: trustedPredicate` for row authority | Pass the desired ordinary query, then `authorization: { rows: { mode: 'scope', filter: trustedScope } }` or `rows: { mode: 'unrestricted' }`. The configured query transform still runs once. |
1278
+ | `overrides.filter: preparedPredicate` for package query reuse | Package-owned upsert/includes/counts now use authenticated private prepared-query execution. Backend consumers pass ordinary predicates to service methods; no prepared/skip flag is exposed. |
1279
+ | `overrides.populate: [{ path: 'customer', select: ['name', 'internalNote'] }]` | Ordinary `populate` plus a parent `outputFields` grant for `customer` when needed and `related.customer: { model: 'Customer', operation: { mode: 'grant' }, outputFields: { mode: 'grant', paths: ['internalNote'] } }` for the independently needed target capabilities. |
1280
+ | `overrides.idFilter: { slug }` | `findOne({ slug }, args, options)` / `updateOne({ slug }, data, args, options)` with explicit row policy when needed. By-ID calls always use normal identifier resolution. |
1281
+ | `overrides.effectiveSelect: ['status']` | `create(data, { select: ['status'] }, options)` / `updateOne(query, data, { select: ['status'] }, options)` / `upsert(query, data, { select: ['status'] }, options)`. Public `returningAll: false` computes this ordinary selection once. |
1282
+ | `overrides.operation: 'upsert'` | Call `upsert(query, data, args, options)`. Private execution retains initiating `upsert` while create/update branch accesses govern getters, output and document grants. |
1283
+ | `pickAllowedFields(doc, 'read', ['internalNote'])` using helper `baseFields` as visibility authority | `pickAllowedFields(doc, 'read', { authorization: { outputFields: { mode: 'grant', paths: ['internalNote'] } } })`, or reuse the original `documentAuthorization` handle for same-execution document decisions. |
1284
+ | `genSelect('read', select, true)` using helper `skipChecks` for provisional fetching | `genSelect('read', select, { authorization: { outputFields: { mode: 'request' } } })` / `genQuerySelect(...)`. Named planning defers document-dependent rules; use an explicit output grant when backend visibility is required. |
1285
+
1286
+ The helper overloads in the last two rows are also **removed in this next breaking
1287
+ release**, with the named replacements available now. JavaScript booleans, arrays,
1288
+ retired named bypass keys, and extra positional arguments reject with actionable
1289
+ BadRequest errors, including `undefined` followed by an extra argument. TypeScript
1290
+ rejects the removed signatures. There is no adapter or deprecation interval.
1291
+
1292
+ Old nullish filter/ID overrides meant normal generation: omit the container and
1293
+ omit `authorization.rows` (or use `{ mode: 'request' }`). Old terminal
1294
+ `overrides.filter: false` becomes an ordinary `false` query or
1295
+ `authorization.rows: { mode: 'scope', filter: false }`; the latter also prevents
1296
+ by-ID resolution and persistence-bearing client subqueries. A normal identifier
1297
+ hook returning false remains terminal in every row mode. `{ mode: 'scope', filter: {} }`
1298
+ is an explicit all-row scope within the transformed query; a supplied null/undefined
1299
+ scope filter is BadRequest, not request fallback. Output grants never revive row denial.
1300
+
1301
+ For example, migrate population through explicit source and target policies:
1302
+
1303
+ <!-- doc-example: partial -->
1304
+
1305
+ ```ts
1306
+ import type { ModelRequest } from '@web-ts-toolkit/access-router/advanced';
1307
+ declare const req: ModelRequest;
1308
+ declare const orderId: string;
1309
+ const service = req.macl.getService<{ status: string; customer: string }>('Order');
1310
+ const result = await service.findOne(
1311
+ { _id: orderId },
1312
+ { select: ['status', 'customer'], populate: [{ path: 'customer', select: ['name', 'internalNote'] }] },
1313
+ {
1314
+ authorization: {
1315
+ outputFields: { mode: 'grant', paths: ['customer'] },
1316
+ related: {
1317
+ customer: {
1318
+ model: 'Customer',
1319
+ operation: { mode: 'grant' },
1320
+ outputFields: { mode: 'grant', paths: ['internalNote'] },
1321
+ },
1322
+ },
1323
+ },
1324
+ },
1325
+ );
1326
+ ```
1327
+
1328
+ Target rows still use request scope here; explicitly scope them if needed. Parent
1329
+ authority never grants target fields/operations implicitly. Ordinary populate now
1330
+ uses the same target-policy contract for reads and post-create/update/upsert output;
1331
+ the old create behavior that ignored `overrides.populate` has been removed.
1332
+
1333
+ **Ordinary document-grant behavior also changes in this release:** previously a
1334
+ skim read with metadata disabled and no virtual candidates could skip the document
1335
+ hook, losing otherwise allowed persisted fields. Full internal document grants
1336
+ are now resolved once per projected document/access stage regardless of `skim`,
1337
+ `includePermissions`, `includeFieldPermissions`, stripping or exposed-key limits.
1338
+ Only serialized metadata changes with those settings. Configure required private
1339
+ hook inputs with `alwaysSelectFields`; same-execution handle picks retain the
1340
+ original decisions after those inputs and `_permissions` are removed. Temporary
1341
+ backend grants do not forge request-user `_view`/`_edit` or document-hook metadata.
1342
+
1343
+ Output grants cannot write protected fields. Explicit model mutation `inputFields`
1344
+ and dedicated parent/child/visibility policies are independent opt-in capabilities;
1345
+ validation and persistence hooks remain active. `new()` retains its ignored-select
1346
+ template behavior; standalone row-only methods retain request-policy signatures.
1347
+ Business payload/filter properties called `overrides` or `authorization` remain
1348
+ valid under ordinary rules.
1349
+
1350
+ ### Breaking release: named helpers and default behavior
1351
+
1352
+ Model `Base`/`Service`/`PublicService` expose these finite signatures; Core prepends
1353
+ `modelName`. The option types are exported from both the root and `/advanced`:
1354
+
1355
+ | Helper | Supported final argument / default |
1356
+ | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
1357
+ | `genSelect(access, select?, options?)`, `genQuerySelect(...)` | `OutputPlanningOptions<TModel, TVirtuals>`, defaults to `{}` |
1358
+ | `pickAllowedFields(doc, access, options?)`, `trimOutputFields(...)` | `OutputFieldOptions<TModel, TVirtuals>`, defaults to `{}` |
1359
+ | `genAllowedFields(doc, access, options?)` | `AllowedOutputFieldOptions<TModel, TVirtuals>`, defaults to `{ purpose: 'output' }`; supplied objects require the purpose |
1360
+ | `genPopulate(access?, populate?, options?)` | Request-only `PopulatePlanningOptions`, defaults to `{}`; only `scopePath` is supported |
1361
+
1362
+ **Default model calls changed.** There is no hidden no-option legacy path:
1363
+
1364
+ - Planning defers document-dependent rules without running permission functions,
1365
+ excludes definitely denied fields, strips virtual names from DB projections and
1366
+ retains canonical private identity/dependency inputs. Omitted select uses ordinary
1367
+ request candidates, or only identity output when `requireExplicitSelect` is true.
1368
+ For compatibility, explicit null/empty select considers all candidates even with
1369
+ that setting, still subject to request rules or explicit policy. It is no grant.
1370
+ A fetch projection never proves response or population permission.
1371
+ - Pick/trim isolate hydrated or plain inputs rather than mutating persistence
1372
+ documents. Fresh raw input runs the document-permissions hook; serialized
1373
+ `_permissions` cannot grant fields. Nested/array output rules, private `_id`
1374
+ retention and configured metadata posture match named output behavior. On known
1375
+ trimmed output, use its handle to retain document-dependent decisions. Unsupported
1376
+ scalar/array documents reject. `genAllowedFields` only reports existing paths.
1377
+ - Request/input and query-field checks remain separate. Migrate writes to service
1378
+ mutations with independent `authorization.inputFields`; output picks never admit
1379
+ writes. Distinct/sort and request-user `_view`/`_edit` do not inherit output grants.
1380
+
1381
+ Scope migration uses **ordinary response segments**: replace
1382
+ `genSelect('read', select, false, ['contacts', 'sub'])` with
1383
+ `genSelect('read', select, { scopePath: ['contacts'] })` **for output planning**.
1384
+ Model output grants remain relative to the owning model (`contacts.email`).
1385
+ Replace `genPopulate('read', descriptors, ['contacts', 'sub'])` with
1386
+ `genPopulate('read', descriptors, { scopePath: ['contacts'] })`. This helper keeps
1387
+ source/target request checks; a privately fetched denied source still cannot populate.
1388
+ Use normal service `populate` plus scoped `authorization.related` for explicit
1389
+ backend target authority. The obsolete raw `buildVirtualPlan`/`buildSubVirtualPlan`
1390
+ service builders are removed; use named fetch helpers. Retained plans, private
1391
+ identity/metadata/join retention and document-result issuance belong to package execution.
1392
+
1393
+ #### Request-only data helpers
1394
+
1395
+ `DataCore` keeps `genSelect`, `genAllowedFields`, and `pickAllowedFields` with a
1396
+ leading `dataName`; `DataService` also provides the `genQuerySelect` and
1397
+ `trimOutputFields` aliases. Each accepts optional **`DataFieldOptions`** as its last
1398
+ argument, defaulting to `{}`. Its only supported setting is readonly
1399
+ `scopePath: ['contacts']`, selecting the `contacts.sub` permission-schema branch.
1400
+ Selection, input document and resulting field paths are relative to that scope.
1401
+ `DataFieldOptions` is exported from root and `/advanced`.
1402
+
1403
+ These helpers retain the **request-only data evaluator**: no Mongoose registration
1404
+ or catalog, model document hooks, virtual computation, or DataService authorization.
1405
+ Permission functions receive the original request as `this` and only its permissions
1406
+ argument. Allowed-fields returns a schema allowlist even with a null/empty document;
1407
+ pick/trim select existing values from it. An allowed container remains a whole value;
1408
+ use a scoped helper to filter a child. `_id` and metadata require ordinary data-field
1409
+ rules, with no automatic retention.
1410
+
1411
+ Data `genSelect` returns the intersection of normalized requested tokens and
1412
+ request-authorized schema fields. **Compatibility:** omitted/null/empty,
1413
+ exclusion-only, unknown-only and denied-only selections can produce `[]`.
1414
+ Data `find`/`findOne` interpret an empty intersection as **no additional projection
1415
+ after request trimming**, returning all request-authorized fields. A non-empty
1416
+ intersection restricts output to those admitted requested fields. Row filtering,
1417
+ sort checks, counts, page-only shaping and hook concurrency retain their existing rules.
1418
+
1419
+ Data positional `skipChecks` never elevated data fields; remove that argument.
1420
+ Old arbitrary `baseFields` elevation is **removed and unsupported**, with no new
1421
+ bypass switch or data-grant capability. Model output policies/handles, positional
1422
+ arrays/booleans, unknown options and extra arguments reject on data helpers too.
1423
+
1424
+ <!-- doc-example: partial -->
1425
+
1426
+ ```ts
1427
+ import type { DataFieldOptions } from '@web-ts-toolkit/access-router';
1428
+ import type { DataRequest } from '@web-ts-toolkit/access-router/advanced';
1429
+ declare const req: DataRequest;
1430
+ const service = req.dacl.getService('Directory');
1431
+ const options = { scopePath: ['contacts'] } as const satisfies DataFieldOptions;
1432
+ const select = await service.genQuerySelect('read', ['email'], options);
1433
+ const fields = await service.genAllowedFields(null, 'read', options);
1434
+ const contact = await service.pickAllowedFields({ email: 'a@example.test', private: 'hidden' }, 'read', options);
1435
+ // Results follow Directory.permissionSchema.contacts.sub request rules.
1436
+ console.log(select, fields, contact);
1437
+ ```
1438
+
1439
+ ## Data routers
1440
+
1441
+ For small reference datasets, create an in-memory list/read router. Its field
1442
+ policy is request-only; the [runnable data-router split](#runnable-data-router-split)
1443
+ also exercises basic/advanced guards and live setters without MongoDB.
1444
+
1445
+ <!-- doc-example: partial -->
1446
+
1447
+ ```ts
1448
+ import { createAccessRuntime } from '@web-ts-toolkit/access-router';
1449
+
1450
+ const api = createAccessRuntime();
1451
+ const fruitRouter = api.createDataRouter('fruit', {
1452
+ basePath: '/fruit',
1453
+ idParam: 'id',
1454
+ idField: 'id',
1455
+ operationAccess: { list: true, read: true },
1456
+ data: [{ id: 'apple', name: 'Apple', public: true }],
1457
+ permissionSchema: { id: true, name: true, public: true },
1458
+ });
1459
+ // Mount fruitRouter.routes on an Express app after express.json().
1460
+ ```
1461
+
98
1462
  In-memory data routers default `listHardLimit` to `1000`, matching model routers. A list request without
99
1463
  `limit`/`pageSize`, or with a malformed limit passed through a trusted service call, is capped at that finite
100
1464
  default unless the router sets `listHardLimit` explicitly. `meta.totalCount` still reflects the full authorized
101
- match set when counts are requested; only returned rows are trimmed and decorated. Per-row data trim/decorate
102
- hooks run with bounded concurrency controlled by `requestComplexity.maxHookConcurrency` (default `10`).
103
- Runtime options and data-router records are copied when configured, and option getter results are frozen
104
- snapshots. Mutating the original options object, a fetched options snapshot, or the original `data` array does
105
- not change live runtime policy or served in-memory records. Replace configured data through `router.data(next)`
106
- or `setDataOption(name, 'data', next)`.
1465
+ match set when counts are requested; only returned rows are trimmed and decorated. Data request-field
1466
+ selection/trimming and `decorate`/`decorateAll` share request/runtime hook admission with model output
1467
+ work, controlled by `requestComplexity.maxHookConcurrency` (default `10`). See
1468
+ [Hook admission and reentrant service calls](#hook-admission-and-reentrant-service-calls) for the counted
1469
+ chain and nested-call contract. Data has no model document-grant or virtual hook family.
1470
+
1471
+ **Basic data-list queries.** `GET /fruit?select=id&sort=-id&limit=10&include_count=true` returns the same
1472
+ selected rows and pagination metadata as `POST /fruit/__query` with this body:
1473
+
1474
+ ```json
1475
+ { "select": ["id"], "sort": "-id", "limit": 10, "options": { "includeCount": true } }
1476
+ ```
1477
+
1478
+ GET `select` accepts comma/whitespace-separated fields or repeated parameters. GET `sort` is one
1479
+ space-separated field string, with `-` for descending order. Sorting uses list field permissions,
1480
+ including permitted fields omitted from `select`; malformed or denied sort fields return HTTP 400.
1481
+ Filtering and sorting precede pagination, and `totalCount` counts the entire filtered set. Without
1482
+ selection or sorting, lists retain their list-allowed fields and configuration order. Data's existing
1483
+ empty-selection/intersection behavior still applies: no additional projection after request field trimming.
1484
+
1485
+ ### In-memory data ownership
1486
+
1487
+ Configuration owns a copy of `data`, including nested values in pre-frozen
1488
+ caller containers. Later changes to the original records cannot change served data. Replace data through
1489
+ `router.data(next)` or `runtime.setDataOption(name, 'data', next)`; `undefined` clears it. Nested setters such as
1490
+ `router.data('0.profile', nextProfile)` replace data copy-on-write and retain other rows. A service captures one
1491
+ data version at construction: replacing data leaves existing services/snapshots on their original version,
1492
+ while newly constructed services use the replacement.
1493
+
1494
+ Supported data is an **acyclic ordinary array** containing these values:
1495
+
1496
+ - Plain objects (including null-prototype objects), ordinary/sparse arrays, strings, booleans, numbers,
1497
+ bigint, null and undefined. Own enumerable string-keyed properties are copied; accessors are evaluated
1498
+ once during admission and stored as values. Non-enumerable plain properties are ignored.
1499
+ - Exact built-in `Date`, `Buffer`, `Map`, `Set`, `RegExp`, fixed non-shared `ArrayBuffer`, `DataView`, and
1500
+ typed arrays: `Int8Array`/`Int16Array`/`Int32Array`, `Uint8Array`/`Uint16Array`/`Uint32Array`,
1501
+ `Uint8ClampedArray`, `Float32Array`/`Float64Array`, `BigInt64Array`/`BigUint64Array`.
1502
+ Map keys/values and Set entries are recursively owned; Date timestamps and RegExp source/flags/numeric
1503
+ `lastIndex` survive. Binary views and Buffers copy their visible bytes into independent, exact-sized
1504
+ backing storage with offset zero. Sharing between distinct views/backing buffers is not retained.
1505
+
1506
+ Functions, symbol values/keys, cycles, proxies, arbitrary classes (including Mongoose documents/ObjectIds),
1507
+ native subclasses/custom native properties, foreign object/native prototypes, WeakMap/WeakSet/Promise and
1508
+ other unlisted instances are rejected inside `data`. Shared, resizable and detached binary storage also rejects.
1509
+ Admission throws a path-bearing `TypeError` before publishing any keys of that data-options update;
1510
+ an accessor that throws propagates its error and likewise leaves the update unapplied. Convert unsupported
1511
+ application objects into supported data first. This data-specific contract preserves the identities of
1512
+ hook functions, Mongoose models, validators and runtime handles in ordinary options.
1513
+
1514
+ Option getter results and `router.options` remain frozen snapshots. **Data snapshot plain containers are
1515
+ frozen; native-valued properties return fresh, detached mutable copies on each access.** This includes
1516
+ `runtime.getDataOptions(name).data`, `getDataSnapshot`, `getDataOption`/`getExactDataOption`, and bootstrap
1517
+ snapshots. Mutating a native copy or its Map/Set contents cannot alter that snapshot or later readers;
1518
+ native identity across property reads is not stable. Repeated ordinary option/snapshot reads reuse the
1519
+ safe frozen data view without traversing the whole dataset.
1520
+
1521
+ `DataService.find`, `findOne` and `findById` return independently mutable selected data, including nested
1522
+ objects/arrays and supported natives. Detachment happens **after field trimming, selection and pagination**,
1523
+ so only returned values are copied; different returned rows also remain independent when their configured
1524
+ inputs shared references. Direct/root response `decorate` and `decorateAll` hooks may mutate those nested
1525
+ outputs in place. DataService's request-only field rules and empty-selection semantics still apply.
1526
+ Native preservation is an in-process contract; HTTP uses normal Express JSON serialization. Use trusted
1527
+ decoration to choose wire representations for values such as Map, Set or bigint.
1528
+
1529
+ ## Denial and request validation
107
1530
 
108
1531
  ACL filter hooks may return `false` to deny a query. That denial is terminal through identifier, override, and
109
1532
  base-filter composition. `overrideFilter` is a trusted hook that may replace an ordinary client or identifier
@@ -112,12 +1535,11 @@ that denial. Returning `false` from `overrideFilter` also denies the query. Empt
112
1535
  normal unrestricted meaning unless a base filter restricts them.
113
1536
 
114
1537
  **Model service denial contract:** internal `findOne`/`find`/`updateOne`/`upsert`
115
- `args.overrides.filter` and `findById`/`updateById` `args.overrides.idFilter` distinguish
116
- `false` from omitted/`undefined`/`null`. False returns a `forbidden` ErrorResult without
117
- fallback filter/identifier generation or persistence (including client subqueries).
118
- Nullish overrides use normal generation. Trusted object filter overrides replace the
119
- generated filter; object **idFilter** overrides replace identifier resolution but still
120
- receive row policy. These service arguments are trusted application inputs.
1538
+ ordinary false queries, false identifier results and explicit false row scopes are
1539
+ terminal. Early known false returns a `forbidden` ErrorResult without fallback
1540
+ generation or persistence-bearing parsing. Omitted row authority uses request policy;
1541
+ scope/unrestricted replace only its base scope. The removed service `overrides`
1542
+ control instead returns `bad_request`, including when its old filter value is false.
121
1543
 
122
1544
  `Service.exists(filter, { access: 'read', includeId: false })` returns a successful
123
1545
  boolean for allowed queries; `includeId: true` returns an `{ _id }` record or `null`.
@@ -134,6 +1556,8 @@ Generated direct routes map Forbidden to HTTP 403; root batches use per-entry 40
134
1556
  HTTP 200. **Custom Express routes own HTTP serialization:** returning an ErrorResult or
135
1557
  calling `res.json(result)` does not automatically set HTTP 403; map its code explicitly.
136
1558
 
1559
+ ### Advanced mutation validation
1560
+
137
1561
  Advanced mutation validators drive dispatch: nested `data` validators run first, then the whole-body (`default`)
138
1562
  validator sees that transformed envelope, and the service persists only the final parsed `data`, `select`,
139
1563
  `populate`, `tasks`, and allowed `options`. Missing body options fall back to `returning_all`/`include_permissions`
@@ -248,9 +1672,15 @@ handling rather than permission fallback.
248
1672
  ```ts
249
1673
  import acl, { type ModelRouterOptions } from '@web-ts-toolkit/access-router';
250
1674
 
1675
+ declare module '@web-ts-toolkit/access-router' {
1676
+ interface AccessRouterRequestExtensions {
1677
+ actor?: { permissions: string[] }; // populated by application authentication middleware
1678
+ }
1679
+ }
1680
+
251
1681
  acl.setGlobalOptions({
252
1682
  globalPermissions(req) {
253
- return req.headers.user === 'admin' ? ['isAdmin', 'canReadDetails'] : [];
1683
+ return req.actor?.permissions ?? [];
254
1684
  },
255
1685
  });
256
1686
  const permissionBased = {
@@ -376,7 +1806,8 @@ Use `set`/`setOption` with undefined to clear a variant; the two-argument proper
376
1806
  helper is intended for defined values.
377
1807
 
378
1808
  `router.options` is the frozen **construction-time** snapshot. Options getter
379
- results are also frozen snapshots; fetch again for current stored values. Ordinary
1809
+ results are also frozen snapshots; data native-valued properties return detached copies
1810
+ on access as described above. Fetch again for current stored values. Ordinary
380
1811
  `getModelOption`/`getDataOption` use generic nested-option fallback, not the
381
1812
  route-specific variant-to-base resolver. Exact getters inspect the variant path
382
1813
  (including exact model-default fallback). Request guards read live runtime values.
@@ -491,16 +1922,47 @@ Root entrypoint (`@web-ts-toolkit/access-router`):
491
1922
  - `guard(...)` and `GuardModelCondition`, `GuardModelConditionID` types
492
1923
  - `combineRoutes(...)` and `createOpenApiRouter(runtime, options)` (note: prefer `acl.createOpenApiRouter(options)` for the default runtime)
493
1924
  - validation adapters: `defineRequestSchema(...)`, `fromZod(...)`, `fromYup(...)`, `fromJoi(...)`, `fromAjv(...)`, `fromStandardSchema(...)`, `fromValibot(...)`, `fromArkType(...)`, `fromIoTs(...)`, `fromSuperstruct(...)`, `fromVine(...)`
494
- - `permissionsPlugin` (src/plugins.ts) — Mongoose schema plugin used as `schema.plugin(permissionsPlugin, { modelName })`
1925
+ - `permissionsPlugin` — optional Mongoose metadata alias; [role and usage](#optional-permissions-plugin)
495
1926
  - logger helpers: `redactFilter`, `redactPayload`, `safeStringify`, `isLevelEnabled` + `OpLogContext` type
496
1927
  - option helpers: `setGlobalOptions`, `setGlobalOption`, `getGlobalOptions`, `getGlobalOption`, `setModelOptions`, `setModelOption`, `getModelOptions`, `getModelOption`, `getModelNames`, `getModelJsonSchema`, `setDefaultModelOptions`, `setDefaultModelOption`, `getDefaultModelOptions`, `getDefaultModelOption`
497
1928
  - route-policy types: `OperationAccess`, `SubOperationAccess`, `Validation`, `GuardHook`, `RouteVariantAccess`, `ModelRouterOptions`, `DefaultModelRouterOptions`, `DataRouterOptions`, `ExtendedModelRouterOptions`, `ExtendedDefaultModelRouterOptions`, and `ExtendedDataRouterOptions` for typed setters; `PermissionSchema` / `FieldOperationAccess` are separate base-operation field grants. These types are also available from `/advanced`.
1929
+ - fetch configuration types (also `/advanced`): `HydrationMode`, `ModelHydrationOptions` — server-owned model/default configuration, never per-call/wire options.
1930
+ - trusted service policy types (also `/advanced`): `ServiceAuthorization` (reads), `MutationAuthorization` (update/upsert), `CreateAuthorization`, `InputFieldAuthorization`, `InputFieldPath`, `OutputAuthorization`, `FieldAuthorization`, `RowAuthorization`, `OperationAuthorization`, `RelatedAuthorization`, `OutputFieldPath`. Input paths use persisted types only; output paths also include virtuals. Unknown models have an explicit runtime-validated string fallback; grant arrays may be readonly.
1931
+ - named helper/result types (also `/advanced`): `OutputPlanningOptions`, `OutputFieldOptions`, `AllowedOutputFieldOptions`, opaque `DocumentAuthorization`, `ServiceOutputContext`, `ServiceOutputDecorator`, `AuthorizedSingleResult`, `AuthorizedListResult`, `NewArgs`, `NewOptions`. Acquire the service from a router/request; there is no standalone `Service` export or public issuer/execution/prepared API.
498
1932
 
499
1933
  Subpath entrypoints:
500
1934
 
501
1935
  - `@web-ts-toolkit/access-router/advanced` — request/service/option interfaces and include wire types, symbols (`MIDDLEWARE`, `DATA_MIDDLEWARE`, `PERMISSIONS`, ...), enums (`Codes`, `StatusCodes`), and validation helpers (`parseBody`, `parseQuery`, `parseBodyWithSchema`, request schemas). Runtime-context functions are internal; use root exports such as `createAccessRuntime`, `AccessRuntime`, and `defaultRuntime` for runtime ownership. `/advanced` does not export runtime instances or router-creation helpers.
502
1936
  - `@web-ts-toolkit/access-router/processors` — `copyAndDepopulate`, `copyPaths`, `movePaths`, `sliceArrays`, `countPaths`, `maskPaths` and processor option types (`ProcessCopy`, `CopyAndDepopulateOptions`, `CopyAndDepopulateOutput`, `ProcessorOptions`, `SliceProcessorOptions`, `ProcessSlice`, `ProcessCount`, `ProcessMask`, `ProcessorOutput`) for transforming populated documents.
503
1937
 
1938
+ ## Request validation and OpenAPI
1939
+
1940
+ Generated routes already validate their supported envelopes. Supply `requestSchemas`
1941
+ for application parsing; `validate` is a separate admitted-write hook. The root
1942
+ exports adapters for Zod, Yup, Joi, AJV, Standard Schema, Valibot, ArkType, io-ts,
1943
+ Superstruct and Vine. For example, with Zod installed:
1944
+
1945
+ <!-- doc-example: complete-runtime -->
1946
+
1947
+ ```ts
1948
+ import { z } from 'zod';
1949
+ import { fromZod, defineRequestSchema } from '@web-ts-toolkit/access-router';
1950
+ const validator = fromZod(z.object({ name: z.string() }));
1951
+ const schema = defineRequestSchema(validator);
1952
+ ```
1953
+
1954
+ Pass such a schema to the relevant `requestSchemas` key (for example `create` or
1955
+ `advancedCreateData`). [Advanced mutation parsing](#advanced-mutation-validation)
1956
+ describes the nested-data/whole-envelope order and allowed options.
1957
+
1958
+ After registering routers, mount `api.createOpenApiRouter({ title: 'Example API',
1959
+ version: '1.0.0' })` directly with `app.use(...)` — it returns an Express router,
1960
+ not an object with `.routes`. Defaults expose `/openapi.json` and `/docs`;
1961
+ `jsonPath`/`docsPath` customize them and `docsPath: false` disables Swagger UI.
1962
+ The standalone named function is `createOpenApiRouter(api.runtime, options)`;
1963
+ the runtime-bound method takes only `options`. Route guards and server-only
1964
+ authorization policies are not client input fields in the generated schema.
1965
+
504
1966
  ## AJV validation contract
505
1967
 
506
1968
  Install `ajv` in your application when using `fromAjv`; it is not a mandatory
@@ -587,9 +2049,12 @@ adopts that exact global model instance into its registry on first lookup.
587
2049
  Cross-runtime composition on a shared request keeps independent runtime-owned state. When one request passes through
588
2050
  middleware from runtime A and then runtime B, B initializes its own core, resolves its own `globalPermissions`, and
589
2051
  uses its own base-filter cache; it never reuses A's permissions or cached filters, even when both runtimes use the
590
- same `requestPermissionField` name. Repeating the same runtime's middleware on one request reuses that runtime's state
591
- without rerunning its resolver. Values already present in `requestPermissionField` before any runtime middleware runs
592
- are treated as application-supplied and preserved. Migration: if you relied on B silently inheriting A's resolved
2052
+ same `requestPermissionField` name. A sync/async `globalPermissions` result of `null` or `undefined` establishes an empty
2053
+ permission map, replacing any prior runtime-owned grants. Missing or non-callable resolvers follow the same empty-grants
2054
+ rule. Middleware awaits asynchronous resolution before publishing the new core and continuing. Repeating the same
2055
+ runtime's middleware on one request reuses that runtime's state without rerunning its resolver. Permission maps (including
2056
+ `{}`) supplied in `requestPermissionField` before any runtime middleware runs are treated as application-owned and
2057
+ preserved. Migration: if you relied on B silently inheriting A's resolved
593
2058
  permissions on a shared request, set those permissions explicitly before the chain or configure B's `globalPermissions`
594
2059
  to resolve them.
595
2060
 
@@ -603,18 +2068,52 @@ to resolve them.
603
2068
 
604
2069
  ```ts
605
2070
  import mongoose from 'mongoose';
606
- import acl, { permissionsPlugin } from '@web-ts-toolkit/access-router';
2071
+ import { createAccessRuntime } from '@web-ts-toolkit/access-router';
607
2072
 
608
2073
  // `uri` is the MongoDB connection string for the non-default connection.
609
2074
  const uri = process.env.MONGODB_URL_TENANT ?? 'mongodb://localhost:27017/tenant';
610
2075
  const conn = await mongoose.createConnection(uri).asPromise();
611
2076
  const schema = new mongoose.Schema({ name: String });
612
- schema.plugin(permissionsPlugin, { modelName: 'TenantUser' });
613
2077
  const TenantUser = conn.model('TenantUser', schema);
614
2078
 
615
- const tenantRouter = acl.createRouter(TenantUser, { basePath: '/tenant-users' });
2079
+ const api = createAccessRuntime();
2080
+ const tenantRouter = api.createRouter(TenantUser, {
2081
+ basePath: '/tenant-users',
2082
+ operationAccess: { list: true, read: true },
2083
+ permissionSchema: { name: true },
2084
+ });
616
2085
  ```
617
2086
 
2087
+ `createRouter(options)` — the single-options overload creates a root batch router;
2088
+ see [Root batch bounds](#root-batch-bounds). Model/data/root factories return an
2089
+ object whose `.routes` is mounted in Express.
2090
+
2091
+ ### Optional permissions plugin
2092
+
2093
+ `permissionsPlugin` only adds a Mongoose getter alias (default `permissions`) for
2094
+ the model's configured document metadata slot (default `_permissions`). It reads
2095
+ the existing `_doc[documentPermissionField]` value using active-runtime option
2096
+ lookup, falling back to the default runtime outside package-managed execution.
2097
+ It does not compute grants, authenticate an actor, or enable field enforcement;
2098
+ generated routers and services already enforce their configured policies.
2099
+
2100
+ <!-- doc-example: partial -->
2101
+
2102
+ ```ts
2103
+ import mongoose from 'mongoose';
2104
+ import { permissionsPlugin } from '@web-ts-toolkit/access-router';
2105
+
2106
+ const schema = new mongoose.Schema({ name: String });
2107
+ schema.plugin(permissionsPlugin, { modelName: 'User', virtualPermissionField: 'permissions' });
2108
+ ```
2109
+
2110
+ Use it only when application code needs that hydrated-document alias. Package output
2111
+ finalization supplies its own [non-enumerable alias](#finalized-permissions-alias)
2112
+ from exposed metadata in both modes, without the plugin. The raw getter retains
2113
+ literal `_doc[documentPermissionField]` lookup (not dotted-path traversal).
2114
+ `virtualPermissionField` names only that raw getter, not the finalized alias or the
2115
+ package's computed `virtuals` API.
2116
+
618
2117
  ## Include Cardinality
619
2118
 
620
2119
  Advanced read/list requests can attach related model data with `include` entries.
@@ -624,10 +2123,26 @@ Advanced read/list requests can attach related model data with `include` entries
624
2123
  Legacy count joins use one grouped aggregate for all parents per include execution.
625
2124
  They count distinct target documents in the authorized count scope: duplicate local
626
2125
  keys, repeated foreign array entries, or one document matching multiple local keys
627
- do not inflate a parent's count. No matches produce `0`.
2126
+ do not inflate a parent's count. The same target may count once for each of several
2127
+ parents. No matches produce `0`. Mongo computes the numbers per requested parent-key
2128
+ group and returns only bounded group ordinals and counts; target IDs are never
2129
+ accumulated or returned. Result size depends on the requested groups, even when the
2130
+ matching targets' IDs would exceed Mongo's BSON document limit.
2131
+
2132
+ Group membership uses Mongo's native query `$in` semantics after schema casting:
2133
+ scalar membership, whole-array and literal-object equality, null/missing fields,
2134
+ and dotted traversal through embedded arrays. Repeated nested matches still count
2135
+ one target once per parent. For example, `countByFieldValues('direct', [['a', 'b']],
2136
+ {}, 'count')` counts a stored `direct: ['a', 'b']` under alias `'a,b'`; a count include
2137
+ joining scalar `'a'` to `contacts.keys` matches `contacts: [{ keys: ['a', 'b'] },
2138
+ { keys: ['a'] }]` once. Legacy include local arrays retain their one-level flattening
2139
+ into group keys; the public helper treats each input value as a single operand.
2140
+ One authorized candidate stream feeds at most 1,000 native match/count facets.
2141
+ Predicate work scales with candidate documents × requested groups; each facet emits
2142
+ at most one numeric row and never collects target documents or IDs.
628
2143
 
629
2144
  The target Mongoose schema casts both the complete authorized filter (including
630
- tenant filters) and foreign-key operands, including after array unwind. ObjectId/String
2145
+ tenant filters) and foreign-key operands used in every group. ObjectId/String
631
2146
  joins and query setters retain association to the original parent keys. Invalid
632
2147
  schema operands/filters produce controlled `BadRequest` (direct HTTP 400; root entry
633
2148
  `statusCode: 400`, `code: 'bad_request'`) rather than misleading zero counts. Count
@@ -640,6 +2155,70 @@ belongs to the aggregate operation. Express router count authorization through
640
2155
  `operationAccess` and row-filter hooks. Arbitrary pipelines passed to
641
2156
  `router.model.aggregate(...)` are not automatically cast.
642
2157
 
2158
+ ### Numeric grouped-count service API (breaking migration)
2159
+
2160
+ `service.countByFieldValues(foreignField, values, filter = {}, access = 'list')`
2161
+ now returns `Promise<SingleResult<Map<string, number>> | ErrorResult>`. Its `values` parameter
2162
+ accepts readonly arrays. Each distinct original `String(value)` alias has a numeric
2163
+ count, including `0` for misses, in first-occurrence order. Equal original aliases
2164
+ use their first operand; different aliases normalized to the same schema value (for
2165
+ example `'001'` and `'1'` on a Number field) receive equal counts. A target with a
2166
+ repeated foreign array value counts once for that alias. Inputs are snapshotted
2167
+ before awaited row hooks.
2168
+
2169
+ **Migration:** replace `result.data.get(key)?.size` with `result.data.get(key) ?? 0`
2170
+ and change `Map<string, Set<string>>` consumers to `Map<string, number>`. There is no
2171
+ ID-set compatibility path. Per-key counts can overlap and must not be summed to
2172
+ obtain a multi-key union: a legacy `op: 'count'` include on an array-valued local field
2173
+ counts that entire parent's union in Mongo. Its grouped executor and prepared-query
2174
+ capabilities remain package-private. For a standalone single union, the existing
2175
+ `service.count({ foreignField: { $in: keys } }, 'count')` expresses one numeric count
2176
+ (use the real field name in the filter).
2177
+
2178
+ <!-- doc-example: partial -->
2179
+
2180
+ ```ts
2181
+ import type { ModelRequest } from '@web-ts-toolkit/access-router/advanced';
2182
+
2183
+ export async function countInvoiceLines(req: ModelRequest) {
2184
+ const service = req.macl.getService<{ invoiceIds: string[] }>('InvoiceLine');
2185
+ const result = await service.countByFieldValues('invoiceIds', ['invoice-a', 'invoice-b'], {}, 'count');
2186
+ if (!result.success) return result;
2187
+ const invoiceACount: number = result.data.get('invoice-a') ?? 0;
2188
+ return { invoiceACount, counts: Object.fromEntries(result.data) };
2189
+ }
2190
+ ```
2191
+
2192
+ This service helper resolves the requested **row access**, defaulting to `list`;
2193
+ pass `count` to use the count row policy. Custom entrypoints still establish their
2194
+ operation guard. The helper accepts no authorization-options or prepared-token
2195
+ argument. Legacy includes retain the target count operation guard, independent
2196
+ related authority, mandatory join/descriptor predicates and terminal-false behavior.
2197
+
2198
+ `service.count(filter, access = 'list')` likewise returns one numeric `SingleResult`
2199
+ under the requested row policy. In contrast, legacy `countTrusted(authorizedFilter)`
2200
+ expects a complete, already-authorized server predicate: it does not run client
2201
+ parsing or `baseFilter`/`overrideFilter`. It accepts no policy/options argument and
2202
+ still treats false as terminal. It is not the executor used by package includes;
2203
+ those use private, authenticated prepared queries.
2204
+
2205
+ Both entry paths enforce fixed per-execution limits before aggregation: **1,000
2206
+ groups** (parents, or input values for the public helper), **10,000 key occurrences
2207
+ before deduplication**, **64 KiB per BSON-encoded key or original alias**, **1 MiB
2208
+ cumulative BSON-key plus alias bytes**, **depth 8 / 20,000 visited key nodes**, and
2209
+ **1,024 UTF-8 bytes for `foreignField`**. Both original inputs and schema-cast operands
2210
+ expanded into parent groups must fit. Scalar/native BSON equality keys and bounded
2211
+ literal values retain owning-schema casting; regex/executable values are not equality
2212
+ join operands. A plain object operand whose first field begins with `$` also rejects
2213
+ before persistence, as Mongo's query `$in` does not accept expression documents; an
2214
+ object containing both `$ref` and `$id` retains Mongo's literal DBRef exception. Dollar
2215
+ fields inside nested literal objects/arrays, or after an ordinary first field, remain
2216
+ data. These checks apply to raw and schema-cast operands. Exceeding a bound returns
2217
+ controlled `BadRequest`/HTTP 400 without target persistence: counts are never truncated
2218
+ or split into per-parent queries.
2219
+ These limits are independent of list pagination; a parent query above 1,000 rows
2220
+ must be narrowed before requesting a legacy count include.
2221
+
643
2222
  ### Safe include output paths
644
2223
 
645
2224
  Treat `path` as output only: put an invoice's related count at `lineItemCount`, for
@@ -657,6 +2236,16 @@ envelope), even if the relationship would be empty. Malformed reads do not retry
657
2236
  through list access. Ordinary output-field replacement remains supported; generated
658
2237
  include data cannot grant source-field permission.
659
2238
 
2239
+ Include output is tracked separately from the original projected source. Document
2240
+ permission hooks and virtual dependencies see the source values even when a successful
2241
+ attachment replaces the same business field (for example, `restricted`). Only results
2242
+ actually produced by include execution are attached after source selection and field
2243
+ filtering, with the target's own authorization and document handle. A legacy read miss
2244
+ or omitted result never turns an existing source value into an attachment: that field
2245
+ still requires source selection and permission. Correlated empty results remain explicit
2246
+ `null`/`[]`/`0` attachments. This separation also applies to nested targets and each list
2247
+ row; include output does not mutate fetched persistence documents.
2248
+
660
2249
  ## Correlated Includes
661
2250
 
662
2251
  Besides the legacy `localField`/`foreignField` joins, `include` entries accept
@@ -737,8 +2326,8 @@ Semantics:
737
2326
 
738
2327
  The TypeScript wire types (`ParentRef`, `CorrelatedInclude`, `Include`) are
739
2328
  reachable without deep imports via `@web-ts-toolkit/access-router/advanced`
740
- (which re-exports `./interfaces`). See the website `services`,
741
- `configuration`, `validation`, and `openapi` pages for the full contract.
2329
+ (which re-exports the public interfaces). The shipped guide above defines the
2330
+ contract; the website has additional service/configuration/validation examples.
742
2331
 
743
2332
  ## Nested updates and trusted hooks
744
2333
 
@@ -885,10 +2474,12 @@ getter fields are type errors (and configuration rejections at runtime).
885
2474
  Virtual names are never added to persisted `Filter`, sort/distinct inputs, or
886
2475
  client write types.
887
2476
 
888
- Lean/hydrated parity: getters always receive an isolated, stable plain-object
2477
+ Lean/hydrated normalization: getters always receive an isolated, stable plain-object
889
2478
  view (`toObject({ virtuals: false })` for documents, recursively isolated
890
2479
  copies for lean results, with `Date`/`Buffer`/`ObjectId` cloned by their own
891
2480
  constructors), so package virtuals compute for both lean and hydrated results.
2481
+ Schema defaults/getters/transforms/init hooks can still change their input values;
2482
+ see the [hydration value caveat](#breaking-release-server-owned-hydration).
892
2483
  Only returned field values are committed via document-aware helpers;
893
2484
  `undefined` omits the field; a throw omits it fail-closed with a structural
894
2485
  log (no raw values or secret-bearing text). Sibling virtual values are never
@@ -946,6 +2537,15 @@ service boundary. `delete` (identity only), `exists`, `distinct`, `count`, and
946
2537
  `countTrusted` never run getters. No submitted or computed virtual value is
947
2538
  persisted.
948
2539
 
2540
+ Filter cleanup distinguishes field predicates from literal data. Empty-object
2541
+ equality (`{ reconciliation: {} }`), literal arrays, and `$eq`/`$in` operands
2542
+ retain their meaning, including data keys with the same spelling as virtuals.
2543
+ Root/dotted virtual predicates and embedded `$elemMatch` query fields are still
2544
+ excluded. The nested-field shorthand under a registered embedded container also
2545
+ excludes its virtual fields; use `$eq` for literal whole-container equality there.
2546
+ Only clauses emptied by an actual virtual removal are pruned. An originally empty
2547
+ object or logical branch is retained; a wholly virtual ordinary filter becomes `{}`.
2548
+
949
2549
  Trusted `decorate`/`decorateAll`/tasks boundary: virtuals evaluate before
950
2550
  `decorate`/`decorateAll`/tasks; internal-only dependency fields are stripped
951
2551
  before those hooks see the object. Trusted hooks may deliberately construct new
@@ -958,16 +2558,57 @@ data over per-row database calls. On a 24-row list with 2 getters at
958
2558
  25 driver reads (1 list + 24 per-row) with peak active getter work 4; the same
959
2559
  label via `populate: [{ path: 'addrId', select: ['label'] }]` and a getter
960
2560
  reading the finalized `doc.addrId` object issued 2 driver reads (batched `$in`)
961
- with identical output and the same peak bound. `maxHookConcurrency` (default
962
- `10`) is a per-request/per-runtime ceiling on concurrently awaited getter +
963
- row-finalization orchestration only — not a per-row multiplier, not leaf
964
- persistence admission, not a process-wide connection limit; trusted getter
965
- DB/network I/O runs outside the leaf persistence ceiling. Bounds limit
966
- simultaneous work, not query counts: they do not collapse N+1 into 1.
967
- Nested/related/embedded work shares the same bounded scheduling (limit-1
968
- completes without deadlock; input order is preserved). Parent getters see only
969
- finalized related/embedded outputs and must not rely on a related model's
970
- stripped private fields.
2561
+ with identical output and the same peak bound. Bounds do not collapse N+1
2562
+ into 1. Parent getters see only finalized related/embedded outputs and must
2563
+ not rely on a related model's stripped private fields.
2564
+
2565
+ ### Hook admission and reentrant service calls
2566
+
2567
+ `requestComplexity.maxHookConcurrency` (default `10`) limits **independently
2568
+ admitted hook chains per request/runtime**. Independent services, root-batch
2569
+ entries, legacy/correlated includes, populate and embedded/subdocument outputs
2570
+ share one private pool. It covers:
2571
+
2572
+ - Model `docPermissions`, including pre-write, output-helper and owning-parent stages;
2573
+ - Virtual getters;
2574
+ - Model/data `decorate` and `decorateAll` chains and service output decorator callbacks;
2575
+ - Data request-field selection/trimming stages (their field rules run sequentially).
2576
+
2577
+ Other guards, row/identifier filters, model field-rule callbacks and write
2578
+ lifecycle hooks are outside this hook pool. Row/traversal maps have finite
2579
+ **per-call** worker bounds, with stable output/context indices; their waiting
2580
+ orchestration frames are not globally counted as hook permits.
2581
+
2582
+ **Awaiting another service from a hook is supported, including at limit `1`.**
2583
+ Its descendant hooks reuse the admitted chain through private async context.
2584
+ Every live callback scope admits **one immediate child branch at a time**, FIFO,
2585
+ including that child's descendant drain. Thus `Promise.all` of nested service
2586
+ calls cannot bypass the limit: sibling hook stages serialize in that chain.
2587
+ At most `maxHookConcurrency` independent leaf branches are admitted; recursively
2588
+ waiting ancestor callbacks still exist and do **not** consume extra permits.
2589
+ The setting does not promise that all unsettled callback frames fit within it.
2590
+
2591
+ Reentrant depth is capped at **64 admission scopes per chain**, including internal
2592
+ hook-stage wrappers, independently of client template/structure depth limits.
2593
+ Overflow rejects through the existing hook failure policy (getters omit their
2594
+ field, document-grant hooks fail closed, decorator errors propagate). Settlement
2595
+ closes a scope to new reentry and drains already-queued child hooks before releasing
2596
+ its branch, even on error. Detached continuations of closed scopes reacquire normal
2597
+ root admission. Await work started by hooks and avoid cross-branch dependency cycles;
2598
+ sibling hooks must not wait for each other to start. Root and per-frame child queues
2599
+ each transfer permits FIFO; descendants progress within their existing chain before
2600
+ unrelated queued roots.
2601
+
2602
+ Hook and persistence limits are normalized and captured together when the private
2603
+ request/runtime work state is first used. Configuration changes affect new requests;
2604
+ existing owners retain their captured limits regardless of ambient runtime changes.
2605
+ Separate requests and runtimes have independent pools. Service hook delegates retain
2606
+ their originating core when another runtime reinitializes the same request's core slots.
2607
+ `maxBulkConcurrency` remains the separate leaf adapter/document persistence ceiling:
2608
+ no persistence permit is held
2609
+ over hooks or recursive orchestration. Trusted hooks' own direct DB/network fan-out,
2610
+ total queued work and query counts are not bounded by these settings. Neither is a
2611
+ process-wide connection limit or an automatic batching mechanism.
971
2612
 
972
2613
  ### Virtuals compatibility notes
973
2614
 
@@ -995,13 +2636,9 @@ not silent backward compatibility:
995
2636
  `_create` picks by explicit `select` after decorate/tasks and `_update`
996
2637
  picks by explicit `select` (else the implicit `returningAll: false` pick);
997
2638
  `new()` continues to ignore `args.select` (all applicable `create` virtuals
998
- are considered, subject to explicit exclusion). Any future honoring of
2639
+ are considered, subject to field authorization, not `args.select` exclusions). Any future honoring of
999
2640
  `new.args.select` is an explicit contract change, not applied here.
1000
- - Release notes for this feature are generated from conventional commits via
1001
- `pnpm changelog` (`repo-toolkit-changelog`) into the workspace
1002
- `CHANGELOG.md`; that generated file is intentionally not hand-edited in this
1003
- task (per repository constraint). The compatibility notes above live in this
1004
- README section as the shipped consumer record.
2641
+ - This README is the shipped compatibility and migration record for these contracts.
1005
2642
 
1006
2643
  ## Persistence concurrency and resource limits
1007
2644
 
@@ -1049,26 +2686,12 @@ Persistence admission is shared across nested work within each request/runtime,
1049
2686
  timing may change. It does not provide transactions, idempotency or optimistic
1050
2687
  concurrency protection for competing edits.
1051
2688
 
1052
- ## Import styles
1053
-
1054
- The package ships both a default export and named exports:
1055
-
1056
- <!-- doc-example: partial -->
1057
-
1058
- ```ts
1059
- // default export (preferred for the runtime API)
1060
- import acl from '@web-ts-toolkit/access-router';
1061
-
1062
- // named exports (useful when you only need specific helpers)
1063
- import { createAccessRuntime, fromZod } from '@web-ts-toolkit/access-router';
1064
- ```
1065
-
1066
- ### Subpath import example
2689
+ ## Subpath import example
1067
2690
 
1068
2691
  <!-- doc-example: complete-runtime -->
1069
2692
 
1070
2693
  ```ts
1071
- import { copyAndDepopulate } from '@web-ts-toolkit/access-router/processors';
2694
+ import { copyAndDepopulate, type CopyAndDepopulateOutput } from '@web-ts-toolkit/access-router/processors';
1072
2695
 
1073
2696
  type DepopulatedItems = {
1074
2697
  items: string[];
@@ -1081,6 +2704,9 @@ const { items, itemsSnapshot } = copyAndDepopulate<DepopulatedItems>(
1081
2704
  { mutable: false },
1082
2705
  );
1083
2706
  // `items` is now `['a1']`; `itemsSnapshot` holds the original objects.
2707
+ const conservative: CopyAndDepopulateOutput = copyAndDepopulate({ items: [{ _id: 'a1' }] }, [
2708
+ { src: 'items', dest: 'itemsSnapshot' },
2709
+ ]);
1084
2710
  ```
1085
2711
 
1086
2712
  Without an explicit output type, `copyAndDepopulate(...)` returns the conservative `CopyAndDepopulateOutput`