parse-stack-next 5.8.0 → 5.8.2

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.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +630 -0
  3. data/README.md +31 -19
  4. data/docs/caching.md +90 -5
  5. data/docs/mcp_guide.md +22 -5
  6. data/docs/mongodb_direct_guide.md +29 -0
  7. data/docs/webhooks_guide.md +94 -9
  8. data/lib/parse/acl_scope.rb +71 -9
  9. data/lib/parse/agent/constraint_translator.rb +22 -21
  10. data/lib/parse/agent/describe.rb +8 -2
  11. data/lib/parse/agent/errors.rb +17 -0
  12. data/lib/parse/agent/mcp_dispatcher.rb +8 -1
  13. data/lib/parse/agent/mcp_server.rb +47 -6
  14. data/lib/parse/agent.rb +521 -70
  15. data/lib/parse/api/batch.rb +70 -2
  16. data/lib/parse/atlas_search/index_manager.rb +5 -1
  17. data/lib/parse/atlas_search.rb +50 -1
  18. data/lib/parse/authorization.rb +235 -3
  19. data/lib/parse/cache/sub_cache.rb +26 -0
  20. data/lib/parse/client/authentication.rb +19 -1
  21. data/lib/parse/client/batch.rb +263 -11
  22. data/lib/parse/client/body_builder.rb +100 -15
  23. data/lib/parse/client/caching.rb +53 -3
  24. data/lib/parse/client/request.rb +59 -0
  25. data/lib/parse/client/response.rb +63 -5
  26. data/lib/parse/client.rb +173 -33
  27. data/lib/parse/clp_scope.rb +37 -3
  28. data/lib/parse/live_query/client.rb +142 -11
  29. data/lib/parse/lock_backend.rb +4 -3
  30. data/lib/parse/model/associations/collection_proxy.rb +19 -11
  31. data/lib/parse/model/associations/has_many.rb +11 -0
  32. data/lib/parse/model/associations/pointer_collection_proxy.rb +36 -0
  33. data/lib/parse/model/associations/relation_collection_proxy.rb +97 -12
  34. data/lib/parse/model/classes/role.rb +23 -0
  35. data/lib/parse/model/classes/session.rb +307 -19
  36. data/lib/parse/model/classes/user.rb +33 -12
  37. data/lib/parse/model/core/actions.rb +41 -5
  38. data/lib/parse/model/core/fetching.rb +29 -2
  39. data/lib/parse/model/core/field_guards.rb +16 -8
  40. data/lib/parse/model/file.rb +96 -19
  41. data/lib/parse/model/object.rb +52 -1
  42. data/lib/parse/model/push.rb +11 -1
  43. data/lib/parse/mongodb.rb +43 -2
  44. data/lib/parse/pipeline_security.rb +157 -21
  45. data/lib/parse/query/constraint.rb +1 -1
  46. data/lib/parse/query/constraints.rb +665 -41
  47. data/lib/parse/query.rb +802 -60
  48. data/lib/parse/stack/tasks.rb +1 -1
  49. data/lib/parse/stack/version.rb +1 -1
  50. data/lib/parse/stack.rb +23 -0
  51. data/lib/parse/vector_search/hybrid.rb +78 -16
  52. data/lib/parse/vector_search.rb +215 -7
  53. data/lib/parse/webhooks/payload.rb +82 -8
  54. data/lib/parse/webhooks/registration.rb +26 -6
  55. data/lib/parse/webhooks/replay_protection.rb +19 -9
  56. data/lib/parse/webhooks.rb +329 -13
  57. metadata +1 -1
data/README.md CHANGED
@@ -3069,8 +3069,13 @@ songs.save
3069
3069
 
3070
3070
  # you can also destroy a set of objects
3071
3071
  songs.destroy
3072
+
3073
+ # send every write as a specific user (ACL and CLP enforced)
3074
+ songs.save(session: user)
3072
3075
  ```
3073
3076
 
3077
+ Each write in a batch keeps the credentials it was built for. Parse Server runs a `POST /batch` under one credential, so objects whose classes use clients with different credentials (for example a class bound to `Parse.client.become(token)`; identically configured clients are merged), or raw `Parse::Request`s carrying different `session_token:` or `use_master_key:` options, are sent as separate batch calls, each with its own credentials. Responses still come back in request order.
3078
+
3074
3079
  ### Magic `save_all`
3075
3080
  By default, all Parse queries have a maximum fetch limit of 1000. While using the `:max` option, Parse-Stack can increase this up to 11,000. In the cases where you need to update a large number of objects, you can utilize the `Parse::Object#save_all` method to fetch, modify and save objects.
3076
3081
 
@@ -3131,6 +3136,7 @@ end
3131
3136
  - **Mixed operations**: Support create, update, and delete operations in single transaction
3132
3137
  - **Error handling**: Comprehensive error handling with meaningful exception messages
3133
3138
  - **Object ID assignment**: New objects automatically receive their `objectId`, `createdAt`, and `updatedAt` from the server response after successful transaction
3139
+ - **One credential**: A transaction runs with the credentials of its objects' class client, or as a given user with `Parse::Object.transaction(session: user)`. Objects bound to clients with different credentials raise `Parse::BatchOperation::MixedAuthorityError` before anything is sent, because a transaction cannot be split.
3134
3140
 
3135
3141
  ```ruby
3136
3142
  # Transaction with custom retry limit and error handling
@@ -3873,8 +3879,8 @@ If you want to see if a particular field contains a specific Parse::Object (poin
3873
3879
  q.where :field => Parse::Pointer.new("_User", "anObjectId")
3874
3880
  # alias using subclass helper
3875
3881
  q.where :field => Parse::User.pointer("anObjectId")
3876
- # alias using `:id` constraint. We will infer :user maps to class "_User" (Parse::User)
3877
- q.where :user.id => "anObjectId"
3882
+ # alias using the `pointer_id` constraint. We will infer :user maps to class "_User" (Parse::User)
3883
+ q.where :user.pointer_id => "anObjectId"
3878
3884
  ```
3879
3885
 
3880
3886
  #### Less Than
@@ -3976,15 +3982,15 @@ Match arrays by their length:
3976
3982
 
3977
3983
  ```ruby
3978
3984
  # Exact size
3979
- q.where :tags.size => 2 # arrays with exactly 2 elements
3985
+ q.where :tags.array_size => 2 # arrays with exactly 2 elements
3980
3986
 
3981
3987
  # Size comparisons
3982
- q.where :tags.size => { gt: 3 } # size > 3
3983
- q.where :tags.size => { gte: 2 } # size >= 2
3984
- q.where :tags.size => { lt: 5 } # size < 5
3985
- q.where :tags.size => { lte: 4 } # size <= 4
3986
- q.where :tags.size => { ne: 0 } # size != 0
3987
- q.where :tags.size => { gte: 2, lt: 10 } # range: 2 <= size < 10
3988
+ q.where :tags.array_size => { gt: 3 } # size > 3
3989
+ q.where :tags.array_size => { gte: 2 } # size >= 2
3990
+ q.where :tags.array_size => { lt: 5 } # size < 5
3991
+ q.where :tags.array_size => { lte: 4 } # size <= 4
3992
+ q.where :tags.array_size => { ne: 0 } # size != 0
3993
+ q.where :tags.array_size => { gte: 2, lt: 10 } # range: 2 <= size < 10
3988
3994
 
3989
3995
  # Empty/non-empty shortcuts (index-friendly)
3990
3996
  q.where :tags.arr_empty => true # empty arrays (uses { field: [] })
@@ -4033,7 +4039,7 @@ All array constraints work with `has_many :through => :array` relations:
4033
4039
  Product.query(:categories.set_equals => [cat1, cat2])
4034
4040
 
4035
4041
  # Find products with more than 3 categories
4036
- Product.query(:categories.size => { gt: 3 })
4042
+ Product.query(:categories.array_size => { gt: 3 })
4037
4043
  ```
4038
4044
 
4039
4045
  **Note:** Array constraints using aggregation pipelines require MongoDB 3.6+.
@@ -4213,7 +4219,7 @@ Event.where(:updated_at.between_dates => [1.week.ago, Time.now])
4213
4219
  ```
4214
4220
 
4215
4221
  #### Matches Object Id
4216
- Sometimes you want to find rows where a particular Parse object exists. You can do so by passing a the Parse::Object subclass or a Parse::Pointer. In some cases you may only have the "objectId" of the record you are looking for. For convenience, you can also use the `id` constraint. This will assume that the name of the field matches a particular Parse class you have defined. Assume the following:
4222
+ Sometimes you want to find rows where a particular Parse object exists. You can do so by passing a the Parse::Object subclass or a Parse::Pointer. In some cases you may only have the "objectId" of the record you are looking for. For convenience, you can also use the `pointer_id` constraint. This will assume that the name of the field matches a particular Parse class you have defined. Assume the following:
4217
4223
 
4218
4224
  ```ruby
4219
4225
  # where this Parse object equals the object in the column `field`.
@@ -4221,18 +4227,18 @@ q.where :field => Parse::Pointer("Field", "someObjectId")
4221
4227
  # => "field":{"__type":"Pointer","className":"Field","objectId":"someObjectId"}}
4222
4228
 
4223
4229
  # alias, shorthand when we infer `:field` maps to `Field` parse class.
4224
- q.where :field.id => "someObjectId"
4230
+ q.where :field.pointer_id => "someObjectId"
4225
4231
  # => "field":{"__type":"Pointer","className":"Field","objectId":"someObjectId"}}
4226
4232
 
4227
4233
  ```
4228
4234
  It is always important to be thoughtful in naming column names in associations as
4229
4235
  close to their foreign Parse class names. This enables more expressive syntax while reducing
4230
- code. The `id` also supports any object or pointer object. These are all equivalent:
4236
+ code. `pointer_id` also accepts any object or pointer object. These are all equivalent:
4231
4237
 
4232
4238
  ```ruby
4233
4239
  q.where :user => User.pointer("xyx123")
4234
- q.where :user.id => "xyx123"
4235
- q.where :user.id => User.pointer("xyx123")
4240
+ q.where :user.pointer_id => "xyx123"
4241
+ q.where :user.pointer_id => User.pointer("xyx123")
4236
4242
  # All produce
4237
4243
  # => "user":{"__type":"Pointer","className":"_User","objectId":"xyx123"}}
4238
4244
  ```
@@ -4260,15 +4266,15 @@ In some cases, you do not have the Parse object, but you have its `objectId`. Yo
4260
4266
 
4261
4267
  ```ruby
4262
4268
  # shorthand if you are using convention. Will infer class `Artist`
4263
- Song.all :artist.id => artist_id
4269
+ Song.all :artist.pointer_id => artist_id
4264
4270
 
4265
4271
  # other approaches, same result
4266
4272
  Song.all :artist => Artist.pointer(artist_id)
4267
4273
  Song.all :artist => Parse::Pointer.new("Artist", artist_id)
4268
4274
 
4269
- # "id" safely pointers and strings for supporting these types of API patterns
4275
+ # `pointer_id` safely accepts pointers and strings for these kinds of API patterns
4270
4276
  def find_songs(artist)
4271
- Song.all :artist.id => artist
4277
+ Song.all :artist.pointer_id => artist
4272
4278
  end
4273
4279
 
4274
4280
  # all ok
@@ -5813,7 +5819,7 @@ Event.query(:event_date.gt => Time.now).results_direct
5813
5819
  Event.query(:event_date.gte => start_date, :event_date.lte => end_date).results_direct
5814
5820
 
5815
5821
  # Array operators
5816
- Song.query(:tags.size => 3).results_direct
5822
+ Song.query(:tags.array_size => 3).results_direct
5817
5823
  Song.query(:tags.contains_all => ["rock", "classic"]).results_direct
5818
5824
  Song.query(:tags.empty_or_nil => true).results_direct
5819
5825
 
@@ -6101,6 +6107,12 @@ Parse.client.authorization.invalidate(token)
6101
6107
  Parse.client.authorization.invalidate_user_roles(user_id)
6102
6108
  ```
6103
6109
 
6110
+ A custom identity plane that implements neither `invalidate_value` nor
6111
+ generation support (`generation`, `generation_current?`, `bump_generation`)
6112
+ can only forget one token at a time. Revoking every session
6113
+ of a user (password change, account deletion, `logout_all!`, a batch destroy of
6114
+ sessions) is then bounded by `identity_cache_ttl` rather than immediate.
6115
+
6104
6116
  Both caches default to per-process memory. With a keyspaced
6105
6117
  `Parse::Cache::Redis` you can move them to the shared backend so every worker
6106
6118
  resolves against the same view, and let webhook triggers invalidate them
data/docs/caching.md CHANGED
@@ -230,6 +230,38 @@ caching path the two never meet. Three points are worth knowing anyway.
230
230
 
231
231
  ## Auth scoping of cached responses
232
232
 
233
+ ### Session reads are not cached by default
234
+
235
+ A read made with a session token is never read from or stored in the response
236
+ cache unless the application opts in:
237
+
238
+ ```ruby
239
+ Parse::Middleware::Caching.cache_session_requests = true # every client
240
+ Parse.setup(cache_session_requests: true, ...) # one client
241
+ ```
242
+
243
+ Only `true` enables it (a `"true"` String from an environment variable does
244
+ not). A client's own `cache_session_requests:` option overrides the class
245
+ default in either direction.
246
+
247
+ A cached answer is served without contacting Parse Server, so it cannot notice
248
+ that the session was revoked (logout, `Parse::Session#destroy`, `logout_all!`,
249
+ a password change) or that the user lost a role or row access through a change
250
+ this process did not make. With the default, every REST session read reaches
251
+ Parse Server, which checks the token and the current ACLs and CLPs each time.
252
+ Mongo-direct reads, including agent tools that route there, do not go through
253
+ this cache; they resolve the session through the identity plane, whose
254
+ revocation behavior is described in
255
+ [Identity and role caching](#identity-and-role-caching). This
256
+ applies however the session reached the request: an explicit `session_token:`,
257
+ `Parse.with_session`, or a client bound to a session. Master-key and
258
+ anonymous reads cache as before, and writes made with a session still
259
+ invalidate cached entries. Turning the option on trades that guarantee for
260
+ speed: a revoked or narrowed session keeps reading its cached responses until
261
+ they expire, so keep `expires:` short if you enable it.
262
+
263
+ ### The auth discriminator
264
+
233
265
  The cache key carries an auth discriminator, and this is a correctness property
234
266
  rather than an optimization.
235
267
 
@@ -251,9 +283,9 @@ URL digest is placed before the discriminator, so every auth variant of one
251
283
  resource shares a prefix, which is exactly what lets a write invalidate the
252
284
  resource for all callers with a single pattern delete.
253
285
 
254
- One consequence worth planning for: a heavily multi-user endpoint produces one
255
- cache entry per session per URL. Cardinality scales with active sessions, not
256
- with distinct resources.
286
+ One consequence worth planning for, once session caching is enabled: a heavily
287
+ multi-user endpoint produces one cache entry per session per URL. Cardinality
288
+ scales with active sessions, not with distinct resources.
257
289
 
258
290
  ## Identity and role caching
259
291
 
@@ -358,6 +390,56 @@ Two behaviors to know before you rely on these:
358
390
  triggers depend on. The deprecated `Parse::AtlasSearch::Session.invalidate` /
359
391
  `.invalidate_user_roles` forms still work through 5.x: they delegate to the
360
392
  default client's context, so they can only ever address `Parse.client`.
393
+ * A custom identity plane you write yourself needs `get`, `set`, and
394
+ `invalidate`. Revoking one token (logout, `Parse::Session#destroy` on a
395
+ session that carries its token) works with those alone. Revoking every token
396
+ of a user (password change, account deletion, `logout_all!`, destroying a
397
+ session fetched without its token, or a batch `Array#destroy` of sessions or
398
+ users) also needs either `invalidate_value(user_id)` or generation support
399
+ (`generation`, `generation_current?`, and `bump_generation`), because the
400
+ plane is keyed by token. A plane with neither keeps resolving
401
+ those tokens until their cached entries expire, so revocation there is
402
+ bounded by `identity_cache_ttl`, not immediate. Lower the TTL if that window
403
+ is too long.
404
+ * A session or user delete drops its cached identity when the delete succeeds
405
+ or reports the row already gone ("object not found"); a denied delete leaves
406
+ the cache alone. A session fetched without its token is looked up first
407
+ (with the master key when the client has one, otherwise with the delete's
408
+ `session:`). When the lookup finds no row (the session is already gone or
409
+ not visible), the delete uses the owner recorded when a `_Session` query or
410
+ `Parse::Session.session` last fetched that session through the same client.
411
+ Only the owner's user id is recorded, never the token, for as long as an
412
+ identity entry lives. The records sit in their own store
413
+ (`client.authorization.session_owner_cache`, process-local by default, set
414
+ a shared store to share them), never in the identity plane, so no session
415
+ token can read one. With no
416
+ recorded owner, a batch delete that reports the row deleted or "object not
417
+ found" clears the client's identity and role cache, and so does a lookup
418
+ that fails outright. Either reset happens at most once every 5 seconds per
419
+ client, so an endpoint that deletes caller-supplied session ids cannot flush
420
+ a shared plane on every request. A single `destroy` cannot tell "already
421
+ gone" from "denied", so it uses the recorded owner but never resets. The
422
+ residual window is a session deleted elsewhere whose owner this process
423
+ never recorded: its cached token resolves until the next reset or until
424
+ `identity_cache_ttl`.
425
+ * A token resolution that is in flight when an invalidation lands does not
426
+ cache its answer, so it cannot put a just-revoked token back. Every
427
+ invalidation moves a marker before it drops entries, and the resolver
428
+ checks the marker before writing and again after, evicting its own write
429
+ when it moved. On the Redis identity plane the marker is a random nonce
430
+ shared by every process, replaced on each invalidation, so this also holds
431
+ for invalidations made by other processes, including a full reset. A custom
432
+ plane gets the same cross-process guarantee by implementing
433
+ `invalidation_nonce` (return the current marker String, or nil when none is
434
+ set) and `bump_invalidation_nonce` (store and return a new random String).
435
+ A custom plane with generation support but no nonce shares a generation
436
+ counter instead. That covers single-token and per-user invalidations made
437
+ by other processes, but not a full reset if the plane's `clear` also deletes
438
+ its generation counters: the counter restarts at a value an in-flight
439
+ lookup may already hold, so a lookup racing another process's reset can
440
+ keep its answer until `identity_cache_ttl`. A plane with neither gets the
441
+ guarantee within one process only, and across processes it is bounded by
442
+ `identity_cache_ttl`.
361
443
 
362
444
  On a Redis outage these planes behave differently from the response cache.
363
445
  The response cache degrades to a passthrough request; the identity and role
@@ -653,8 +735,10 @@ Work down this list.
653
735
  so deliberately.
654
736
  5. Is this a `fetch!` or `reload!`? Those default to write-only mode, which by
655
737
  design never reads from the cache. Use `fetch_cache!` to accept a cached body.
656
- 6. Is the caller a different session? Entries are not shared across auth
657
- identities, so the first request for each session is always a miss.
738
+ 6. Is the read made with a session token? Session reads bypass the cache
739
+ unless `Parse::Middleware::Caching.cache_session_requests = true`. With it
740
+ on, entries are not shared across auth identities, so the first request for
741
+ each session is always a miss.
658
742
  7. Is the store process-local? A Moneta memory store behind several workers hits
659
743
  only when the same worker handles the repeat.
660
744
  8. Has something set `Parse::Middleware::Caching.enabled = false`? That is the
@@ -675,6 +759,7 @@ All events are `ActiveSupport::Notifications`.
675
759
  | `parse.cache.store` | the same, plus `duration_ms` |
676
760
  | `parse.cache.delete` | the same, emitted on an invalidating write |
677
761
  | `parse.cache.error` | the same, plus `error` (the exception class name only) |
762
+ | `parse.cache.bypass` | the same, plus `reason: :session` for a session read kept out of the cache |
678
763
  | `parse.cache.evict` | `pattern_digest`, `deleted`, `duration_ms` |
679
764
  | `parse.synchronize_create.acquired` | `key_digest`, `wait_ms` |
680
765
  | `parse.synchronize_create.contended` | `key_digest`, `elapsed_ms` |
data/docs/mcp_guide.md CHANGED
@@ -880,6 +880,15 @@ The approval gate is a pluggable `agent.approval_gate` consulted inside
880
880
  unit-testable with a fake approver. `Parse::Agent::MCPElicitationGate` is the
881
881
  spec-native implementation; `Parse::Agent::NullGate` (the default) approves.
882
882
 
883
+ A sub-agent built with `parent:` and no gate of its own uses its parent's gate,
884
+ read at call time, so the gate the dispatcher installs on the parent for a
885
+ `tools/call` also covers any sub-agent a tool builds during that call. Before
886
+ 5.8.1 a sub-agent fell back to `NullGate` and skipped approval. Assigning a
887
+ gate on the sub-agent itself overrides this, and assigning `nil` removes the
888
+ override so the sub-agent uses its parent's gate again. The dispatcher
889
+ snapshots and restores the agent's own gate (`approval_gate_override`), so
890
+ driving a sub-agent through `tools/call` never pins its parent's gate onto it.
891
+
883
892
  Round-trip over the streaming transport:
884
893
 
885
894
  1. A `tools/call` for a gated tier pauses before execution. The server builds an
@@ -1646,7 +1655,7 @@ agent = Parse::Agent.new(methods: [:archive, "Project.set_client_description"])
1646
1655
  agent = Parse::Agent.new(methods: { except: ["Account.delete_account"] })
1647
1656
  ```
1648
1657
 
1649
- Entries are bare method names (`:archive` — matches the method on any class) or qualified names (`"Project.archive"` — matches only on that class). Both forms coexist in the same Set; matching is an OR.
1658
+ Entries are bare method names (`:archive`, which matches the method on any class) or qualified names (`"Project.archive"`, which matches only on that class). Both forms coexist in the same Set; matching is an OR. The class part is compared by its Parse class name, so `"User.reset"` and `"_User.reset"` name the same method (likewise `Role`, `Session`, and `Installation`), whichever spelling the entry or the `call_method` caller uses. An entry may also name the model by its Ruby constant (`"Artist.purge"` for `class Artist < Parse::Object; parse_class "Musician"; end`), which is compared by its Parse class name too. A qualified entry whose class resolves to no loaded model warns at construction and is matched by its class part as written. Before 5.8.1 an entry was matched against the raw string the caller passed, so `methods: { except: ["_User.reset"] }` could be bypassed by calling it on `"User"`.
1650
1659
 
1651
1660
  The filter **narrows declared methods** — it cannot expose a method that was not declared via the `agent_method` DSL, and it cannot bypass tier checks (`agent_can_call?`) or env-gates (`PARSE_AGENT_ALLOW_WRITE_TOOLS`, `PARSE_AGENT_ALLOW_SCHEMA_OPS`). A filtered-out invocation returns `error_code: :tool_filtered`.
1652
1661
 
@@ -1663,7 +1672,12 @@ class Project < Parse::Object
1663
1672
  # Forward the agent's scope to any internal query — pre-filtering by
1664
1673
  # _wperm so the update only sees rows the agent's scope is allowed
1665
1674
  # to modify, defense-in-depth alongside Parse Server's own ACL.
1666
- Audit.all(**agent.acl_scope_kwargs).each { |a| a.cancel! } if agent&.acl_scope
1675
+ # acl_scope? is true for any scoped agent, including a session agent whose
1676
+ # token has not resolved yet (acl_scope itself would raise
1677
+ # Parse::Agent::UnresolvedIdentity for that one); acl_scope_kwargs
1678
+ # forwards the identity, and the scoped read fails closed if it cannot
1679
+ # be resolved.
1680
+ Audit.all(**agent.acl_scope_kwargs).each { |a| a.cancel! } if agent&.acl_scope?
1667
1681
  self.archived = true # property :archived, :boolean
1668
1682
  self.archive_reason = reason # property :archive_reason, :string
1669
1683
  save
@@ -1714,7 +1728,7 @@ Parse::Agent.new(classes: { only: [Pots] }, strict_class_filter: true) # per-in
1714
1728
 
1715
1729
  `except:` is never validated — an operator may proactively block a class not yet loaded.
1716
1730
 
1717
- **Sub-agent inheritance: intersect, never widen.** Unlike `tools:` (where a sub-agent's filter overrides the parent's outright), `classes:` is **intersected** with the parent's effective set so a sub-agent can NEVER widen the parent's data reach. A child `only:` that has no overlap with the parent's `only:` raises `ArgumentError` at construction. A child that omits `classes:` inherits the parent's filter verbatim. `except:` sets are unioned (a sub-agent cannot un-deny a class the parent denied). The asymmetry with `tools:` is intentional — class reach is data scope, closer to `permissions:` than to the UX-scoping `tools:` filter.
1731
+ **Sub-agent inheritance: intersect, never widen.** Like `tools:` and `methods:`, `classes:` is **intersected** with the parent's effective set so a sub-agent can NEVER widen the parent's data reach. A child `only:` that has no overlap with the parent's `only:` raises `ArgumentError` at construction. A child that omits `classes:` inherits the parent's filter verbatim. `except:` sets are unioned (a sub-agent cannot un-deny a class the parent denied).
1718
1732
 
1719
1733
  **Schema-catalog filtering.** `get_all_schemas` omits classes outside the per-agent allowlist from the catalog response so the LLM doesn't waste a tool call discovering classes it would be refused on.
1720
1734
 
@@ -1793,6 +1807,7 @@ Parse::Agent::Tools.register(
1793
1807
  | `session_token` | Yes (security-critical) | Without it, a session-token parent silently produces a master-key sub-agent — the constructor default is `nil`, which means master-key mode. This was the v4.2 advisor-flagged blocker; do not undo. |
1794
1808
  | `acl_user` (v4.4.0) | Yes (security-critical) | When the parent was constructed with `acl_user:` and the child supplies none of `session_token:` / `acl_user:` / `acl_role:`, the parent's identity inherits verbatim. Inheritance is conditional on the child supplying NO identity at all — explicit overrides on the child resolve normally and then face the subset check below. |
1795
1809
  | `acl_role` (v4.4.0) | Yes (security-critical) | Same rule as `acl_user`. A child that omits identity inherits the parent's role scope; one that supplies its own identity falls through to the subset check. |
1810
+ | `master_atlas` | Yes (security-critical) | `nil` inherits the parent's setting and `false` drops it. `true` is accepted only when the parent has it: since 5.8.1 a sub-agent of a parent without `master_atlas` raises `ArgumentError`, because master-key faceting would return every row's buckets and results under the parent's scoped identity. |
1796
1811
  | `tenant_id` | Yes (security-critical) | Without it, a tenant-bound parent produces an unbound sub-agent that escapes `agent_tenant_scope` rules. |
1797
1812
  | `recursion_depth` | Always (decremented) | The parent's budget is authoritative — the explicit `recursion_depth:` kwarg is ignored on inherited construction. |
1798
1813
 
@@ -1802,7 +1817,8 @@ Parse::Agent::Tools.register(
1802
1817
  |-------|---------|
1803
1818
  | `permissions` | The default of `:readonly` means `Parse::Agent.new(parent: write_agent)` produces a `:readonly` sub-agent. A sub-agent is at most as privileged as the parent by tier; this is enforced by a clamp check at construction, not by inheritance. An explicit override is accepted only if `≤ parent.permissions` — `Parse::Agent.new(parent: readonly_parent, permissions: :admin)` raises `ArgumentError`. Pass `permissions: parent.permissions` to maintain parity intentionally. |
1804
1819
  | `client` | The constructor default `:default` resolves to the same client in standard single-app deployments. Explicit passes through. |
1805
- | `tools:` / `methods:` filters | The whole point of constructing a sub-agent is usually to give it a NARROWER surface. Explicit passes through. |
1820
+
1821
+ **`tools:` and `methods:` narrow only (5.8.1).** A sub-agent's `tools:` and `methods:` filters can only remove from what its parent allows. A child `tools: { only: }` is intersected with the parent's allowlist and `except:` sets are unioned; a child that omits `tools:` inherits the parent's filter. For `methods:`, the parent's filters stay in force alongside the child's, so a call must pass every one (entries may be bare or `Class.method`, which plain set intersection would match wrongly). A child `only:` list with nothing its parent allows (after the parent's allowlist and `except:` lists apply) raises `ArgumentError` at construction; an explicitly empty `only: []` is the strictest narrowing and is accepted. Before 5.8.1 a child's filter replaced the parent's, so a sub-agent could enable a tool or agent method within its tier that the parent was not given.
1806
1822
 
1807
1823
  **The clamp invariant:** `sub.permissions ≤ parent.permissions` always holds. The default `:readonly` is always safe regardless of parent tier; only explicit overrides hit the clamp check, and overrides that exceed the parent's tier raise at construction. This is the structural guarantee that a `delegate_to_subagent` chain cannot escape the parent's tier through sub-agent construction — the only path to a more-privileged agent is at the MCP factory, where the explicit elevation is auditable.
1808
1824
 
@@ -2785,6 +2801,7 @@ Every tool call dispatched through `Agent#execute` fires the `"parse.agent.tool_
2785
2801
  | `:tools_except` | Array<Symbol> | v4.3.0+ — when the agent was constructed with `tools: { except: [...] }`. |
2786
2802
  | `:methods_only` | Array<String> | v4.3.0+ — when the agent was constructed with `methods: { only: [...] }`. Bare names and `"Class.method"` qualified names mix. |
2787
2803
  | `:methods_except` | Array<String> | v4.3.0+ — when the agent was constructed with `methods: { except: [...] }`. |
2804
+ | `:methods_layers` | Array<Hash> | 5.8.1+, sub-agents that inherit at least one `methods:` filter (including a single parent layer when the sub-agent has none of its own). Every `methods:` filter in force, parent layers first and the agent's own last, each `{only:, except:}` with sorted name strings (nil when absent). `:methods_only` / `:methods_except` describe only the agent's own layer. `describe[:methods][:layers]` carries the same list. |
2788
2805
  | `:filters` | Hash<String,Array<String>> | v4.4.0+ — when the agent was constructed with `filters: {...}`. Maps each filtered class name (or `"default"`) to the list of FIELD NAMES the filter constrains. Filter VALUES are intentionally NOT echoed — `filters: { Account => { user_id: "abc123" } }` would otherwise emit the user-identifying value on every audit-log line. Subscribers that need the actual constraint can call `agent.filter_for(class_name)` directly. |
2789
2806
  | `:denial_kind` | Symbol | v4.3.0+, AccessDenied failure path only — one of `:hidden_class` (global `agent_hidden`), `:class_filter` (per-agent `classes:` narrowing), `:field_denied` (outside `agent_fields`), or `:storage_form_field_ref` (referenced `_p_*` pointer-storage column). Lets SOC tooling distinguish operator narrowing from policy-level denials without parsing the message prose. |
2790
2807
 
@@ -3427,7 +3444,7 @@ Parse::Agent.rack_app(logger: Rails.logger) { |env| ... }
3427
3444
 
3428
3445
  **Sub-agent auth-scope inheritance and permissions clamp (v4.2).** When a tool handler constructs a sub-agent with `Parse::Agent.new(parent: agent, ...)`, the sub inherits `session_token` and `tenant_id` from the parent unless explicitly overridden. Without this inheritance, a session-token parent would silently produce a master-key sub-agent — the constructor default `session_token: nil` resolves to master-key mode — escalating privilege through the very kwarg meant to close sub-agent footguns. Explicit overrides still work (`Parse::Agent.new(parent: agent, session_token: nil)` produces a master-key sub if that is genuinely what the handler wants), but the default is fail-safe inheritance. `permissions:` is NOT inherited and defaults to `:readonly`, but the constructor enforces a clamp: an explicit `permissions:` override on a sub-agent is accepted only if `≤ parent.permissions`, otherwise `ArgumentError` is raised at construction. The clamp is the structural guarantee that a delegation chain cannot escape the parent's tier through sub-agent construction. See [Per-Agent Tool Filtering & Sub-Agent Delegation](#per-agent-tool-filtering--sub-agent-delegation-v42) for the full inheritance table.
3429
3446
 
3430
- **Agent-level ACL scope: `session_token:` / `acl_user:` / `acl_role:` (v4.4.0).** `Parse::Agent.new` accepts three mutually-exclusive identity inputs. `session_token:` round-trips Parse Server's `/users/me` at construction (or defers to per-call REST if the server is unreachable). `acl_user:` takes a `Parse::User` or User-pointer and expands the user's role subscription via `Parse::Role.all_for_user` — no token round-trip, the SDK enforces the resulting `_rperm` filter itself. `acl_role:` is service-account-style scoping — no user_id, just the role plus parent-role inheritance. Master-key posture (none of the three supplied) remains the default and still emits the one-time `[Parse::Agent:SECURITY]` banner at construction. Every built-in tool reads `agent.acl_scope_kwargs` (single point of truth) to forward identity into `Parse::MongoDB.aggregate`, `Parse::Query#results_direct`, and `Parse::AtlasSearch.{search,autocomplete}`. Developer-registered tool handlers and `agent_method` bodies can reach `agent.acl_scope`, `agent.acl_permission_strings`, `agent.acl_read_match_stage` (a `_rperm` `$match`), or `agent.acl_write_match_stage` (a `_wperm` `$match`) to apply the agent's identity to their own queries.
3447
+ **Agent-level ACL scope: `session_token:` / `acl_user:` / `acl_role:` (v4.4.0).** `Parse::Agent.new` accepts three mutually-exclusive identity inputs. `session_token:` round-trips Parse Server's `/users/me` at construction. If the server is unreachable then, the agent retries on the first tool call that needs its permissions; if that also fails, the call raises `Parse::Agent::UnresolvedIdentity` (reported as `:access_denied`, `kind: :unresolved_identity`) instead of running without the SDK-side check, and further calls within the next few seconds are refused without another lookup (5.8.2). `acl_user:` takes a `Parse::User` or User-pointer and expands the user's role subscription via `Parse::Role.all_for_user`. It makes no token round-trip, and the SDK enforces the resulting `_rperm` filter itself. `acl_role:` is service-account-style scoping: no user_id, just the role plus parent-role inheritance. Master-key posture (none of the three supplied) remains the default and still emits the one-time `[Parse::Agent:SECURITY]` banner at construction. Every built-in tool reads `agent.acl_scope_kwargs` (single point of truth) to forward identity into `Parse::MongoDB.aggregate`, `Parse::Query#results_direct`, and `Parse::AtlasSearch.{search,autocomplete}`. Developer-registered tool handlers and `agent_method` bodies can reach `agent.acl_scope`, `agent.acl_permission_strings`, `agent.acl_read_match_stage` (a `_rperm` `$match`), or `agent.acl_write_match_stage` (a `_wperm` `$match`) to apply the agent's identity to their own queries.
3431
3448
 
3432
3449
  **ACL composition on the mongo-direct aggregate path (v4.4.0).** When `aggregate` routes through `Parse::MongoDB.aggregate` (the default when `Parse::MongoDB.enabled?` is true), the agent layer derives the auth posture from the agent instance and forwards it to ACLScope — session-tokened / acl_user / acl_role agents get the same row-level `_rperm` `$match` injection regardless of identity mode; master-key agents pass `master: true` (the agent's class/field/tenant/canonical-filter gates are the security boundary for that posture). The posture is built in `Parse::Agent#acl_scope_kwargs`, not from tool-call JSON arguments; LLM-supplied `master:`, `session_token:`, `acl_user:`, or `acl_role:` kwargs are silently swallowed by the tool signature's `**_kwargs` catchall and never reach `Parse::MongoDB.aggregate`. An LLM cannot escalate from a scoped posture to master-key by injecting `master: true` into the tool arguments.
3433
3450
 
@@ -287,6 +287,9 @@ raw = Parse::MongoDB.find(
287
287
  Convenience wrapper around `db.find`. Accepts `limit:`, `skip:`, `sort:`,
288
288
  `projection:`, `hint:`, `max_time_ms:`. When `:limit` is omitted the call applies
289
289
  `DEFAULT_FIND_LIMIT = 1000` and warns; pass `limit: 0` to opt out.
290
+ `find` reads raw documents unscoped, so it raises `Parse::ACLScope::ACLRequired`
291
+ inside `Parse.without_master_key`; `Parse::MongoDB.indexes` (metadata only) is
292
+ allowed there.
290
293
 
291
294
  ### Forcing an index with `hint`
292
295
 
@@ -761,6 +764,32 @@ legitimate read stages — but they read from arbitrary collections. Never
761
764
  pass attacker-controlled input into a pipeline; build the pipeline in
762
765
  trusted code and interpolate only validated values.
763
766
 
767
+ #### Regex patterns and the default time limit
768
+
769
+ Every caller-supplied regex on these paths (`$regex` strings, Ruby
770
+ `Regexp` and BSON regex values, and the `regex` operand of `$regexMatch`,
771
+ `$regexFind`, and `$regexFindAll`) goes through `Parse::RegexSecurity`,
772
+ which parses the pattern and refuses shapes that backtrack heavily on
773
+ PCRE: nested or overlapping repeats such as `(a+)+` or `(a|aa)+`, more
774
+ than three unbounded quantifiers in a row with no separating literal
775
+ (`\d+\d+\d+\d+`), subroutine calls (`\g<1>`), inline comments, and
776
+ extended mode. Escaped literal text, such as the patterns `starts_with`,
777
+ `ends_with`, and `contains` build, always passes the shape check.
778
+
779
+ A pattern check cannot prove every regex is cheap, so mongo-direct reads
780
+ whose filter or pipeline carries a regex that is not escaped literal text
781
+ also get a server-side `max_time_ms` of
782
+ `Parse::PipelineSecurity.default_regex_max_time_ms` (5000 ms by default)
783
+ when the caller passed none. This covers `Parse::MongoDB.aggregate` and
784
+ `find`, `results_direct`, `count_direct`, Atlas Search, and vector and
785
+ hybrid search. Set the attribute to a different value, or to `nil` to
786
+ turn the default off; an explicit `max_time_ms:` always wins. A query that
787
+ runs past the limit raises `Parse::MongoDB::ExecutionTimeout`.
788
+
789
+ REST queries are executed by Parse Server, which applies no regex check of
790
+ its own. Set `databaseOptions.maxTimeMS` in the Parse Server configuration
791
+ to bound those.
792
+
764
793
  ### Layer 2: Row-level ACL enforcement (`Parse::ACLScope`) — scoped only
765
794
 
766
795
  When `Parse::MongoDB.aggregate` is called with `session_token:`,
@@ -210,6 +210,36 @@ run Parse::Webhooks
210
210
  See [`examples/webhook_server.rb`](../examples/webhook_server.rb) for a complete,
211
211
  runnable setup.
212
212
 
213
+ ### Authenticating requests, and what `master?` means
214
+
215
+ Requests are accepted only when `X-Parse-Webhook-Key` matches
216
+ `Parse::Webhooks.key` (`PARSE_SERVER_WEBHOOK_KEY`). With no key configured the
217
+ Rack app refuses every request unless you opt in with
218
+ `Parse::Webhooks.allow_unauthenticated = true` (or
219
+ `PARSE_WEBHOOK_ALLOW_UNAUTHENTICATED=true`), which is meant for local
220
+ development and tests. A signing secret does not replace the key: with no key
221
+ configured, a signed request is still refused unless `allow_unauthenticated`
222
+ is on. When both are configured, the key must match and the signature must
223
+ verify.
224
+
225
+ `payload.master?` is true only when the body says the master key was used (a
226
+ JSON `true`; Parse Server never sends a string) AND
227
+ the request was authenticated: the webhook key matched, or a configured
228
+ signature (`PARSE_WEBHOOK_SIGNING_SECRET`) verified. On unauthenticated ingress
229
+ any caller can put `"master": true` in the body, so `master?` returns false
230
+ there. Master-only field guards, ACL owner resolution, and handler checks
231
+ built on `master?` therefore treat such a request as a normal client request.
232
+ The Ruby-initiated callback dedup uses `master?` too, so a forged `_RB_`
233
+ request id plus `"master": true` cannot make a request skip model callbacks
234
+ (including a `before_save` that would reject it). `payload.claimed_master?`
235
+ returns the body's raw claim and `payload.authenticated?` reports whether the
236
+ request was authenticated; both are for diagnostics only.
237
+
238
+ The trade-off: on unauthenticated ingress, a save made by the SDK itself is
239
+ not recognized as Ruby-initiated, so its model callbacks can run twice (once
240
+ in process, once through the webhook). Configure a webhook key or a signing
241
+ secret to keep the dedup.
242
+
213
243
  ## Auditing trigger coverage
214
244
 
215
245
  The wiring above has three independent moving parts, and a callback runs
@@ -284,13 +314,65 @@ database untouched. When the handler changed fields, the reply is the client's
284
314
  full write with your changes layered on top: operators on fields you did not
285
315
  touch are passed through as operators, fields the model does not declare (and
286
316
  `_User` signup fields such as `password` and `authData`) are kept, and a field
287
- you set back to its stored value is dropped from the write. Two limits come
288
- from what Parse Server sends the webhook: a field you rewrite is written as an
289
- absolute value, and an operator on a dotted sub-key (`"meta.count"`) arrives
290
- only as its resulting sub-document and is written back that way. Edits made
291
- in place on `parse_object` count even if the handler returns `true`. On a
292
- create, returning `parse_object` also writes the model's declared defaults
293
- (including its default ACL) for fields the client did not send.
317
+ you set back to its stored value is dropped from the write. A field you
318
+ rewrite is written as an absolute value. Edits made in place on `parse_object`
319
+ count even if the handler returns `true`.
320
+
321
+ An operator on a dotted sub-key (`"meta.count"`) arrives only as its resulting
322
+ sub-document, so on an update the SDK writes back just the sub-keys that
323
+ changed, as dotted paths, and a removed sub-key as a `Delete`. The split is one
324
+ level deep: a changed sub-key is written whole at `field.sub`, nested objects
325
+ included, because Parse Server rebuilds the afterSave object from a dotted key
326
+ only one level deep. Concurrent writes to other sub-keys of the field survive.
327
+ Some limits remain:
328
+
329
+ - Two concurrent writes inside the same sub-key can still overwrite each
330
+ other, including two operators on deeper paths under it
331
+ (`meta.count.value` and `meta.count.other`).
332
+ - An array is written whole, so a concurrent `Add` to an array inside a
333
+ sub-document is lost.
334
+ - When a changed sub-key would carry a typed value (a Date, Bytes, Pointer, or
335
+ other `__type` value), the whole field is written instead, because Parse
336
+ Server stores a dotted typed value without converting it. Unchanged typed
337
+ values and deletes do not trigger this.
338
+ - A client that replaces a whole sub-document gets a merge: the changed
339
+ sub-keys are written and the sub-keys it knew about and removed are
340
+ deleted, so a sub-key another request added in the meantime survives.
341
+ - Parse Server reports the reply's dotted keys back to the client in the save
342
+ response. The JavaScript SDK applies them; other clients may need to fetch
343
+ the object to see the merged value.
344
+ - A dotted key in a Hash the handler returns (`{ "meta.y" => 5 }`) is folded
345
+ into the parent path the reply writes whole. A key deeper than one level
346
+ (`"meta.count.value"`) is folded into a `meta.count` write built from the
347
+ pending object, and on a create every dotted key folds into its whole
348
+ field. An operator folded this way (`{"__op" => "Increment"}`, `Add`,
349
+ `AddUnique`, `Remove`, `Delete`) is applied to the value it replaces; an
350
+ operator that cannot be applied there, or two of your own keys that
351
+ conflict (`"meta.a" => 5` with `"meta.a.b" => 6`), fails the save with an
352
+ error. Hash keys may use Ruby names (`acl:`); they are mapped to the
353
+ stored field names.
354
+
355
+ On a create, returning `parse_object` also writes the model's declared
356
+ defaults for fields the client did not send, including its ACL policy. Parse
357
+ Server stores a create without an `ACL` as public read and write, so the reply
358
+ always carries the ACL your policy resolves, exactly as an SDK-side create
359
+ would. Owner-based policies (`:owner_else_private`, the default, as well as
360
+ `:owner_else_public` and `:owner_but_public_read`) take the owner from the
361
+ declared `owner:` field and, when that is empty, from the user who made the
362
+ request, so a signed-in client owns what it creates. A request without a user
363
+ gets the policy's fallback (master-key only under `:owner_else_private`), and so
364
+ does a master-key request. A `_User` class declared with `owner: :self` is
365
+ owned by the new user, never by the requester: the reply carries the policy's
366
+ ACL without an owner entry and Parse Server adds the new user's own read and
367
+ write. An ACL the client sent is kept, and an ACL the handler sets always
368
+ wins, whether through `acl=` or mass assignment. A `guard :acl, :master_only`
369
+ revert on a create returns the ACL to the class policy rather than to `{}`.
370
+ Built-in classes such as `_User` that have no policy of their own keep Parse
371
+ Server's defaults. A handler that returns `true`, `nil`, or a Hash keeps the client's
372
+ write as sent, so a create without an ACL stays public; return
373
+ `parse_object` to apply the policy. An ACL the handler assigns on
374
+ `parse_object` (`parse_object.acl = Parse::ACL.new`) is written whatever the
375
+ handler returns, unless a returned Hash sets `ACL` itself.
294
376
 
295
377
  `after_destroy` callbacks run in the `after_delete` handler (once per delivery,
296
378
  skipped for deletes the SDK itself made, whose callbacks already ran locally).
@@ -434,8 +516,11 @@ master key). It has already run the model's `before_save` / `after_save` /
434
516
  The intent is to keep trigger logic local when possible and run it exactly once.
435
517
  Note that any logic in the **webhook block itself** still runs; only the
436
518
  duplicate ActiveModel callback pass is skipped. A spoofed `_RB_` marker without
437
- the master key does not get this treatment — the callbacks run in the webhook as
438
- usual.
519
+ the master key does not get this treatment, and neither does a claimed master
520
+ key on an unauthenticated request (no webhook key match and no verified
521
+ signature): the callbacks run in the webhook as usual. On unauthenticated
522
+ ingress this means SDK-initiated saves run their callbacks twice; configure a
523
+ webhook key or signing secret to keep the dedup.
439
524
 
440
525
  ### 2. Server-initiated replay / freshness protection (inbound)
441
526