parse-stack-next 5.8.0 → 5.8.1

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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +318 -0
  3. data/README.md +25 -19
  4. data/docs/caching.md +50 -0
  5. data/docs/mcp_guide.md +15 -3
  6. data/docs/webhooks_guide.md +59 -7
  7. data/lib/parse/acl_scope.rb +71 -9
  8. data/lib/parse/agent/describe.rb +8 -2
  9. data/lib/parse/agent/mcp_dispatcher.rb +8 -1
  10. data/lib/parse/agent.rb +205 -20
  11. data/lib/parse/atlas_search/index_manager.rb +5 -1
  12. data/lib/parse/atlas_search.rb +49 -1
  13. data/lib/parse/authorization.rb +235 -3
  14. data/lib/parse/cache/sub_cache.rb +26 -0
  15. data/lib/parse/client/authentication.rb +19 -1
  16. data/lib/parse/client/batch.rb +35 -2
  17. data/lib/parse/client.rb +26 -2
  18. data/lib/parse/clp_scope.rb +37 -3
  19. data/lib/parse/live_query/client.rb +72 -2
  20. data/lib/parse/lock_backend.rb +4 -3
  21. data/lib/parse/model/associations/collection_proxy.rb +19 -11
  22. data/lib/parse/model/associations/has_many.rb +11 -0
  23. data/lib/parse/model/associations/pointer_collection_proxy.rb +36 -0
  24. data/lib/parse/model/associations/relation_collection_proxy.rb +97 -12
  25. data/lib/parse/model/classes/role.rb +23 -0
  26. data/lib/parse/model/classes/session.rb +279 -19
  27. data/lib/parse/model/classes/user.rb +33 -12
  28. data/lib/parse/model/core/actions.rb +19 -4
  29. data/lib/parse/model/core/fetching.rb +29 -2
  30. data/lib/parse/model/core/field_guards.rb +16 -8
  31. data/lib/parse/model/object.rb +52 -1
  32. data/lib/parse/model/push.rb +11 -1
  33. data/lib/parse/mongodb.rb +19 -1
  34. data/lib/parse/query/constraint.rb +1 -1
  35. data/lib/parse/query/constraints.rb +31 -27
  36. data/lib/parse/query.rb +793 -55
  37. data/lib/parse/stack/version.rb +1 -1
  38. data/lib/parse/stack.rb +19 -0
  39. data/lib/parse/vector_search/hybrid.rb +74 -15
  40. data/lib/parse/vector_search.rb +214 -7
  41. data/lib/parse/webhooks/payload.rb +43 -0
  42. data/lib/parse/webhooks.rb +312 -9
  43. metadata +1 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ca6cbc923754826337ad1abf67ad900d15ac483cef4d03536862923491eaf64c
4
- data.tar.gz: 3c5c10805ec33190f755e8a745c4b6cbac2fd2bd458f1032b57e125e86ed6e73
3
+ metadata.gz: de8e9c2d24346754146f36a8c24b272840456010e08bf86f71b11d38741abad8
4
+ data.tar.gz: 83df07a458c9e00cae15f63fe12d4e8c19bc6913f60b297e1fa402985b2073ce
5
5
  SHA512:
6
- metadata.gz: a34e92d57d8cfbd3ba87bbe21baad8995a7dd1f3aba09a980ab3ed364ab961e5f8934a6bd73f4c097895cdfc5a427e5e4b3c01e0405c6647731384bbb9fd2747
7
- data.tar.gz: f7898ed1327fb0f316bda290f36b1cf8a4ab74d4d510243b276e408dd7d7d95e1e97e96e13d5c784b39c83c262574b57c34ce8b4d15983603e19194d477883fe
6
+ metadata.gz: ead735d8d48a495293b810f2b6100327869ae68051d9e1aaee87b43d51aeb51b1323e6f29a9b823a40ec0ac15b52bfc91eb838009524239bd7f1f42a63d7e02d
7
+ data.tar.gz: 0666b77620a76d2b19a81a5a43f3a9cd61f94661568c269ac07e612c71ff25642c4759d93f0e0b7d7370bb3930447f1fa39ed3eb986fa9bf73a63a072009cbb8
data/CHANGELOG.md CHANGED
@@ -1,5 +1,323 @@
1
1
  ## parse-stack-next Changelog
2
2
 
3
+ ### 5.8.1
4
+
5
+ A correctness release for webhooks, sessions, associations, queries, search,
6
+ and agent policies. No new defaults and no API changes; every corrected
7
+ outcome is listed under Behavior Notes.
8
+
9
+ #### Webhooks
10
+
11
+ - **FIXED**: A client create through a `before_save` webhook whose handler
12
+ returns `parse_object` now gets the ACL its class policy resolves, exactly
13
+ as an SDK-side create would. Owner-based policies (`:owner_else_private`,
14
+ the default, plus `:owner_else_public` and `:owner_but_public_read`) take the
15
+ owner from the declared `owner:` field, or from the requesting user when that
16
+ field is empty. Before, a signed-in client got the policy's no-owner
17
+ fallback (`{}` under `:owner_else_private`) and could not read its own
18
+ record. A request without a user, or a master-key request, gets the policy
19
+ fallback. A `_User` class declared with `owner: :self` is owned by the new
20
+ user, never the requester. An ACL the client sent is kept, and an ACL the
21
+ handler sets always wins (through `acl=` or mass assignment), even when it
22
+ equals the class default. A `guard :acl, :master_only` revert on a create
23
+ returns the ACL to the class policy instead of `{}`. A handler-assigned ACL
24
+ is written whatever the handler returns (`parse_object`, `true`, `nil`, or
25
+ a Hash that does not set `ACL` itself).
26
+ - **FIXED**: When a `before_save` handler changes a record, a sub-document the
27
+ client changed is written back as one-level dotted paths (`"meta.count":
28
+ {...}`) instead of the whole field, so concurrent writes to other sub-keys
29
+ survive. A removed sub-key is written as a `Delete`. Dotted keys in a
30
+ returned Hash are folded into the field or `field.sub` write they belong to,
31
+ with operators (`Increment`, `Add`, `AddUnique`, `Remove`, `Delete`) applied
32
+ rather than stored. Hash keys may use Ruby names.
33
+
34
+ #### Sessions and master-key scope
35
+
36
+ - **FIXED**: Deleting `Parse::Session` or `Parse::User` objects, one at a time
37
+ or with a batch `Array#destroy`, drops their identity-cache entries when the
38
+ delete succeeds or reports the row already gone. A denied delete leaves the
39
+ cache alone. A session that does not carry its token or owner (built from
40
+ its objectId alone, fetched without the master key, or fetched with `keys:`
41
+ leaving them out) has them looked up first, in one query per batch: with the
42
+ master key when the client has one, otherwise with the delete's `session:`.
43
+ A lookup that finds no row leaves nothing to forget; only a lookup that
44
+ fails resets the client's identity cache, at most once every 5 seconds per
45
+ client. When the lookup finds no row, the delete uses the owner recorded
46
+ when a `_Session` query or `Parse::Session.session` last fetched that
47
+ session through the same client (only the user id, never the token, in a
48
+ store separate from the identity plane,
49
+ `client.authorization.session_owner_cache`). Without one, a batch delete
50
+ that applied or reported the row gone falls back to the rate-limited reset;
51
+ a denied delete never resets, and a single `destroy` never resets. The
52
+ looked-up token is used only for the delete, is never kept on the object,
53
+ and never enters the response cache. A token resolution in flight when an
54
+ invalidation lands, in this process or, on the Redis identity plane, in any
55
+ other, does not cache its answer: the resolver checks a shared invalidation
56
+ marker before and after writing and evicts its own write when the marker
57
+ moved.
58
+ - **FIXED**: `Parse::User#signup!`, signup on save, and `upgrade_anonymous!`
59
+ store `created_at` / `updated_at` as `Parse::Date` values. Reading
60
+ `updated_at` on a newly signed-up user no longer marks it dirty.
61
+ - **FIXED**: Inside `Parse.without_master_key`, an explicit `master: true`
62
+ passed to `Parse::MongoDB.aggregate`, `Parse::ACLScope`, `Parse::Query`
63
+ direct reads, vector and hybrid search, Atlas Search, or agent tools for
64
+ master-key agents no longer runs as master. The call runs in the public
65
+ scope, as the REST request does once the block strips its master key, and
66
+ the `parse.mongodb.aggregate` notification reports `master_dropped: true`.
67
+ The SDK's own metadata reads keep the master key without lifting the block:
68
+ the class schema and role graph read to enforce a scope, index statistics,
69
+ and the Atlas Search index listing. Before, a schema read refused inside the
70
+ block was cached and denied every scoped mongo-direct read of that class,
71
+ for all callers, for 5 seconds. `Parse::AtlasSearch.faceted_search` raises
72
+ `FacetedSearchNotACLSafe` inside the block, because its bucket counts are
73
+ not ACL-filtered. The metadata marker rides only on the outgoing request
74
+ copy, never on `response.request` or an error's request. A schema read that
75
+ fails inside the block is retried at most every 2 seconds there. A LiveQuery
76
+ admin client that first connected inside the block keeps withholding its
77
+ master key on every reconnect until `allow_master_key_connection!`.
78
+
79
+ #### Agent policies
80
+
81
+ - **FIXED**: A sub-agent's `tools:` and `methods:` filters now only narrow its
82
+ parent's, as `classes:` already did. A child `tools: { only: }` is
83
+ intersected with the parent's allowlist and `except:` sets are unioned; for
84
+ `methods:` the parent's filters stay in force alongside the child's. Before,
85
+ the child's filter replaced the parent's, so a sub-agent could enable a tool
86
+ or `agent_method` within its tier that the parent was not given. A child
87
+ `only:` list with nothing its parent allows raises `ArgumentError` at
88
+ construction.
89
+ - **FIXED**: A sub-agent built with `parent:` uses its parent's approval gate,
90
+ read at call time, unless given its own. Before, it fell back to
91
+ `NullGate`, so a sub-agent built inside an MCP `tools/call` ran tools in
92
+ `require_approval_for` tiers without approval.
93
+ - **FIXED**: Qualified `methods:` entries compare the class by its Parse class
94
+ name, so `"User.x"` and `"_User.x"` (and the `Role`, `Session`, and
95
+ `Installation` aliases) match whichever spelling the entry or the
96
+ `call_method` caller uses. Before, `methods: { except: ["_User.x"] }` could
97
+ be bypassed by calling the method on `"User"`.
98
+ - **FIXED**: A sub-agent can no longer turn on `master_atlas` when its parent
99
+ does not have it. `Parse::Agent.new(parent: scoped_parent, master_atlas:
100
+ true)` raises `ArgumentError`; before, the child's `atlas_faceted_search` ran
101
+ with master-key semantics and returned every row under the parent's scoped
102
+ identity. Omitting `master_atlas` still inherits, and `false` still drops
103
+ it.
104
+ - **FIXED**: The MCP dispatcher snapshots and restores an agent's own approval
105
+ gate, so driving a sub-agent through `tools/call` no longer pins its
106
+ parent's gate onto it. Assigning `approval_gate = nil` makes a sub-agent use
107
+ its parent's gate again.
108
+ - **FIXED**: Qualified `methods:` entries may name a model by its Ruby
109
+ constant (`"Artist.purge"` for a class whose `parse_class` is `"Musician"`)
110
+ and are compared by Parse class name, so such an `except:` entry is no
111
+ longer bypassed through the Parse class name. An entry whose class resolves
112
+ to no loaded model warns at construction.
113
+ - **IMPROVED**: The `parse.agent.tool_call` audit payload adds
114
+ `methods_layers` whenever a sub-agent inherits a `methods:` filter
115
+ (including a single parent layer), and `describe[:methods][:layers]` lists
116
+ every `methods:` filter in force, parent first.
117
+
118
+ #### Associations
119
+
120
+ - **FIXED**: `add!`, `add_unique!`, and `remove!` on a
121
+ `has_many through: :array` collection take the array Parse Server returns,
122
+ as plain array properties already did. A stale local copy is corrected,
123
+ objects already fetched locally are kept, and the field is not marked
124
+ changed. The array is adopted only when every entry is a pointer to the
125
+ declared class; otherwise the operation is applied locally.
126
+ - **FIXED**: Saving a relation collection on its own (`owner.likes.save`)
127
+ sends the staged additions and removals and returns whether they were
128
+ applied. Before, it silently sent nothing.
129
+ - **FIXED**: `Parse::Object#clear_changes!` (also called by `reload!`) drops
130
+ a relation's staged additions and removals and clears array collection
131
+ dirty state. Before, discarded relation changes were sent with the next
132
+ change to that relation. A fetch (`fetch!`, partial fetch, autofetch, with
133
+ or without `preserve_changes:`) keeps a relation's staged additions and
134
+ removals and leaves the relation marked changed, including when the response
135
+ carries the relation's descriptor, so the next save sends them.
136
+ `rollback!` drops a relation's staged additions and removals too, including
137
+ after a fetch.
138
+ - **IMPROVED**: `RelationCollectionProxy#save` accepts `session:` to send the
139
+ staged operations as a given user (`nil` for no session token). Relation
140
+ writes on an owner whose objectId was assigned client-side during a create
141
+ are staged rather than sent until the create returns.
142
+ - **FIXED**: Atomic relation operations (`add!` / `remove!` on a
143
+ `through: :relation` collection) write to the relation's Parse column when
144
+ its name differs from the Ruby name (`has_many :liked_items` maps to
145
+ `likedItems`, or a relation declared with `field:`). Before, they targeted
146
+ the Ruby name.
147
+
148
+ #### Queries
149
+
150
+ - **FIXED**: An OR whose branch has no constraints now matches every row.
151
+ Previously `query | Model.query` and `Parse::Query.or(query, Model.query)`
152
+ dropped that branch and returned only the other side. An empty receiver of
153
+ `|` still starts a new OR, as `or_where` does, so
154
+ `[q1, q2].reduce(Model.query, :|)` builds `q1 OR q2`.
155
+ - **FIXED**: `Model.query(:or => [{...}, {...}])` builds a `$or` group ANDed
156
+ with the query's other constraints, instead of a constraint on a literal
157
+ field named `or`. Branches can be constraint hashes, `Parse::Query` objects
158
+ of the same class, or arrays of constraints.
159
+ - **FIXED**: A constraint that needs the aggregation pipeline or mongo-direct
160
+ (`readable_by`, `:ACL.readable_by`, `:field.array_size`, the mongo-direct geo
161
+ operators) raises `ArgumentError` inside an OR (`:or`, `or_where`, `|`,
162
+ `Parse::Query.or`). Before, the branch was emitted as `{}` and matched every
163
+ row, or lost part of its conditions, including in push targeting, which
164
+ could reach every installation.
165
+ - **FIXED**: `Parse::Query#clone`, `|`, `or_where`, `Parse::Query.or`, `:or`
166
+ branches (including Hash branches with `session:` / `use_master_key:` keys
167
+ or nested scoped queries), and subqueries (`$inQuery`, `$notInQuery`,
168
+ `$select`, `$dontSelect`) keep the query's authority and application:
169
+ session token, a session-bound client from `Parse::Client#become`, scoped
170
+ user or role, master-key flag, read preference, and the Parse application
171
+ the query targets. Queries combined this way must run under the same
172
+ authority against the same application. An unscoped query takes the
173
+ other's authority, and different authorities or applications raise
174
+ `ArgumentError`. Before, a session combined with `scope_to_user` ran as the
175
+ scoped user, a `become`-client query composed into an OR ran as the master
176
+ key, a scoped subquery ran under the outer query's master key, and a clone
177
+ could send one application's session token to the default application.
178
+ Changing the authority or client of a combined query raises and leaves the
179
+ query unchanged. A combined query re-checks its authority before it runs,
180
+ and explicit auth passed to `results_direct`, `count_direct`,
181
+ `distinct_direct`, or `atlas_search` must match it.
182
+ - **FIXED**: LiveQuery subscriptions and push targeting refuse constraints that
183
+ only the aggregation pipeline or mongo-direct can run (`array_size`,
184
+ `set_equals`, `not_empty`, `elem_match`, `readable_by`, mongo-direct geo
185
+ operators). Before, those constraints were dropped and the subscription or
186
+ push reached a wider audience. A subquery with such a constraint is also
187
+ refused, where it used to match every row. `Parse::Query#subscribe` and
188
+ `client.subscribe(query)` run under the query's own session or `become`
189
+ client, and such a subscription is refused with `ArgumentError` when the
190
+ LiveQuery client targets a different Parse application id than the query,
191
+ so one application's session token is never sent to another application's
192
+ LiveQuery server.
193
+ - **FIXED**: A field repeated in `order` is sent once, at its first position
194
+ with the last direction given, matching what MongoDB applies on direct
195
+ reads. String forms such as `"-title"` are parsed as descending, so they
196
+ deduplicate too, and the direct `$sort` stage sorts on the real field.
197
+ `order("title,-plays")` is parsed as two fields.
198
+ - **FIXED**: A bare objectId compared to a declared pointer field
199
+ (`Song.query(:artist => "abc123")`, `:artist.ne`, `:artist.in`) now matches
200
+ on REST, in `Parse::Push` targeting, and in LiveQuery subscriptions from
201
+ `Parse::Query#subscribe`, `Model.subscribe`, and `client.subscribe(query)`.
202
+ Equality and `$in` used to return no rows and `$ne` every row, while direct
203
+ and aggregate reads already matched. The new
204
+ `Parse::Query#compile_rest_where` returns the where clause in that form.
205
+
206
+ #### Search
207
+
208
+ - **FIXED**: Atlas Search (`Parse::AtlasSearch.search`, `search_with_stage`,
209
+ `autocomplete`, `Parse::Query#atlas_search`) and native hybrid search apply
210
+ the CLP `pointerFields` / `readUserFields` ownership constraint before the
211
+ page is cut. For native hybrid, the lexical input filters before its
212
+ per-input `$limit`, and the fused result is filtered again before the final
213
+ `$limit`. Rows used to be filtered after the page was cut, so when the
214
+ top-ranked hits belonged to other users a scoped caller got a short or empty
215
+ page. Inaccessible rows are still never returned.
216
+ - **IMPROVED**: `Parse::VectorSearch.search` and native hybrid search enforce
217
+ the `pointerFields` ownership constraint server-side across the whole
218
+ candidate window. When every owner field is declared as a scalar pointer
219
+ (`belongs_to`) and its storage path (`_p_<field>`) is a `type: "filter"`
220
+ field of the vectorSearch index, the constraint also goes
221
+ into `$vectorSearch.filter`, so other users' rows no longer use up the
222
+ candidate window. The prefilter is used only while the index is READY and
223
+ serving its latest definition. If Atlas refuses it (for example mid-rebuild),
224
+ the search reruns once without it and skips it for the index cache TTL. A
225
+ failed index lookup is also remembered for that TTL.
226
+
227
+ - **FIXED**: A `$vectorSearch` refusal caused by the caller's own
228
+ `vector_filter` on an unindexed path no longer switches off the owner
229
+ prefilter for every caller on that index or clears the index cache; only a
230
+ refusal naming an owner `_p_<field>` path does. Native hybrid search uses
231
+ `Parse::VectorSearch.default_index` when the vector branch names no index.
232
+
233
+ #### Documentation
234
+
235
+ - **FIXED**: README and query YARD examples use `:field.pointer_id` and
236
+ `:field.array_size`, the names the DSL has used since 5.8.0.
237
+
238
+ #### Behavior Notes
239
+
240
+ - An OR that includes an unconstrained branch now returns every row. The one
241
+ exception is an empty receiver of `|`, which starts the OR. Code that passed
242
+ an empty query for "no filter" should leave that branch out. `:or => []`
243
+ matches no rows.
244
+ - A pipeline-only constraint inside an OR now raises `ArgumentError`. Apply it
245
+ outside the OR, or run the branches as separate queries.
246
+ - Combining queries that run under different authorities or against
247
+ different Parse applications now raises `ArgumentError`. That covers OR
248
+ composition, `:or` Hash branches, and subqueries, and includes different
249
+ session tokens, a session against `scope_to_user` / `scope_to_role` / the
250
+ master key, and different scoped users or roles. Setting a new session
251
+ token, master-key flag, scope, or client on a query that took its authority
252
+ from another query also raises, as does passing different explicit auth to
253
+ a direct or Atlas terminal. Query-shaping options (`limit`, `order`, `keys`,
254
+ `skip`, `include`) inside an `:or` branch raise.
255
+ - LiveQuery subscriptions, push targeting, and subqueries with pipeline-only
256
+ constraints raise `ArgumentError`. Subscribing to a `scope_to_user` /
257
+ `scope_to_role` query, or passing a `session_token:` that differs from the
258
+ query's session, raises.
259
+ - REST queries, LiveQuery subscriptions, and push targeting that compare a
260
+ bare objectId to a declared pointer field now return the matching rows.
261
+ Undeclared fields are unchanged. If a model declares `belongs_to` for a
262
+ field whose server column holds a plain String, a bare string compared to
263
+ that field is now sent as a Pointer and stops matching those values;
264
+ declare such a field as a `:string` property instead.
265
+ - Code that relied on `master: true` keeping master authority inside
266
+ `Parse.without_master_key` should wrap that call in `Parse.with_master_key`.
267
+ A dropped `master: true` raises `ACLRequired` under either
268
+ `Parse::ACLScope.require_session_token` or
269
+ `Parse::AtlasSearch.require_session_token`, with a message naming the block,
270
+ and no longer emits the no-ACL banner. The role-graph helpers
271
+ (`users_in_role_subtree` and related) raise `ACLRequired` for
272
+ `master: true` inside the block; pass `as:` instead.
273
+ - A LiveQuery connection that already connected with the master key outside
274
+ `Parse.without_master_key` stays elevated for subscriptions made inside the
275
+ block, because Parse Server authorizes per connection; the SDK warns.
276
+ - `Parse.without_master_key` guards against accidental master-key use and is
277
+ not an isolation boundary.
278
+ - `Parse::Role.all_for_user` and `Parse::Role#all_parent_role_names` without
279
+ `master:` or `as:` return the full role closure inside
280
+ `Parse.without_master_key`, because the role graph is read as SDK metadata.
281
+ - `Parse::Session._preload_identity_for_destroy!` and the
282
+ `_after_batch_destroy` hooks are now private.
283
+ - A session deleted elsewhere whose owner this process never recorded keeps
284
+ resolving until the next rate-limited reset or `identity_cache_ttl`.
285
+ - A custom identity plane gets the cross-process revocation-race guarantee by
286
+ implementing `invalidation_nonce` and `bump_invalidation_nonce`, as the
287
+ built-in Redis plane does. One with generation counters only is covered for
288
+ single-token and per-user invalidations, but a lookup racing another
289
+ process's full reset is bounded by `identity_cache_ttl` when the plane's
290
+ `clear` also deletes its counters.
291
+ - A sub-agent's `tools:` or `methods:` `only:` list that leaves nothing once
292
+ the parent's `only:` and `except:` lists apply now raises `ArgumentError`.
293
+ An explicitly empty `only: []` is accepted. A sub-agent passing
294
+ `master_atlas: true` under a parent without it raises `ArgumentError`.
295
+ - Parse Server sends a `before_save` webhook only the resulting value of a
296
+ dotted operator. When the handler changes the record, the SDK writes back
297
+ changed sub-keys one level deep and deletes removed ones by path. Two
298
+ concurrent writes inside the same sub-key (including deeper paths under it)
299
+ can still overwrite each other, an array is written whole, and a changed
300
+ sub-key carrying a typed value (Date, Bytes, Pointer) makes the whole field
301
+ be written. A client's whole replace of a sub-document becomes a merge. The
302
+ save response reports the dotted keys; the JavaScript SDK applies them, and
303
+ other clients may need a fetch. A handler operator that cannot be folded,
304
+ or two conflicting dotted keys from the handler, fail the save with an
305
+ error.
306
+ - A `before_save` handler that returns `true`, `nil`, or a Hash keeps the
307
+ client's write as sent, so a client create without an ACL through such a
308
+ handler is stored public read and write, as before, unless the handler
309
+ assigns an ACL on `parse_object`.
310
+ - Saving a relation collection on its own sends removals and additions as two
311
+ requests. If the second fails, the first stays applied and the unsent
312
+ operations stay staged.
313
+ - Owner fields stored as arrays of pointers, undeclared, or not filter-indexed
314
+ get no `$vectorSearch` prefilter. Vector search then fills results only from
315
+ its existing candidate window.
316
+ - A custom identity cache with neither `invalidate_value` nor generation
317
+ support (`generation`, `generation_current?`, `bump_generation`) only
318
+ expires a user's tokens at `identity_cache_ttl` after a password change,
319
+ account deletion, `logout_all!`, or a batch destroy of sessions.
320
+
3
321
  ### 5.8.0
4
322
 
5
323
  #### Breaking Changes
data/README.md CHANGED
@@ -3873,8 +3873,8 @@ If you want to see if a particular field contains a specific Parse::Object (poin
3873
3873
  q.where :field => Parse::Pointer.new("_User", "anObjectId")
3874
3874
  # alias using subclass helper
3875
3875
  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"
3876
+ # alias using the `pointer_id` constraint. We will infer :user maps to class "_User" (Parse::User)
3877
+ q.where :user.pointer_id => "anObjectId"
3878
3878
  ```
3879
3879
 
3880
3880
  #### Less Than
@@ -3976,15 +3976,15 @@ Match arrays by their length:
3976
3976
 
3977
3977
  ```ruby
3978
3978
  # Exact size
3979
- q.where :tags.size => 2 # arrays with exactly 2 elements
3979
+ q.where :tags.array_size => 2 # arrays with exactly 2 elements
3980
3980
 
3981
3981
  # 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
3982
+ q.where :tags.array_size => { gt: 3 } # size > 3
3983
+ q.where :tags.array_size => { gte: 2 } # size >= 2
3984
+ q.where :tags.array_size => { lt: 5 } # size < 5
3985
+ q.where :tags.array_size => { lte: 4 } # size <= 4
3986
+ q.where :tags.array_size => { ne: 0 } # size != 0
3987
+ q.where :tags.array_size => { gte: 2, lt: 10 } # range: 2 <= size < 10
3988
3988
 
3989
3989
  # Empty/non-empty shortcuts (index-friendly)
3990
3990
  q.where :tags.arr_empty => true # empty arrays (uses { field: [] })
@@ -4033,7 +4033,7 @@ All array constraints work with `has_many :through => :array` relations:
4033
4033
  Product.query(:categories.set_equals => [cat1, cat2])
4034
4034
 
4035
4035
  # Find products with more than 3 categories
4036
- Product.query(:categories.size => { gt: 3 })
4036
+ Product.query(:categories.array_size => { gt: 3 })
4037
4037
  ```
4038
4038
 
4039
4039
  **Note:** Array constraints using aggregation pipelines require MongoDB 3.6+.
@@ -4213,7 +4213,7 @@ Event.where(:updated_at.between_dates => [1.week.ago, Time.now])
4213
4213
  ```
4214
4214
 
4215
4215
  #### 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:
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 `pointer_id` constraint. This will assume that the name of the field matches a particular Parse class you have defined. Assume the following:
4217
4217
 
4218
4218
  ```ruby
4219
4219
  # where this Parse object equals the object in the column `field`.
@@ -4221,18 +4221,18 @@ q.where :field => Parse::Pointer("Field", "someObjectId")
4221
4221
  # => "field":{"__type":"Pointer","className":"Field","objectId":"someObjectId"}}
4222
4222
 
4223
4223
  # alias, shorthand when we infer `:field` maps to `Field` parse class.
4224
- q.where :field.id => "someObjectId"
4224
+ q.where :field.pointer_id => "someObjectId"
4225
4225
  # => "field":{"__type":"Pointer","className":"Field","objectId":"someObjectId"}}
4226
4226
 
4227
4227
  ```
4228
4228
  It is always important to be thoughtful in naming column names in associations as
4229
4229
  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:
4230
+ code. `pointer_id` also accepts any object or pointer object. These are all equivalent:
4231
4231
 
4232
4232
  ```ruby
4233
4233
  q.where :user => User.pointer("xyx123")
4234
- q.where :user.id => "xyx123"
4235
- q.where :user.id => User.pointer("xyx123")
4234
+ q.where :user.pointer_id => "xyx123"
4235
+ q.where :user.pointer_id => User.pointer("xyx123")
4236
4236
  # All produce
4237
4237
  # => "user":{"__type":"Pointer","className":"_User","objectId":"xyx123"}}
4238
4238
  ```
@@ -4260,15 +4260,15 @@ In some cases, you do not have the Parse object, but you have its `objectId`. Yo
4260
4260
 
4261
4261
  ```ruby
4262
4262
  # shorthand if you are using convention. Will infer class `Artist`
4263
- Song.all :artist.id => artist_id
4263
+ Song.all :artist.pointer_id => artist_id
4264
4264
 
4265
4265
  # other approaches, same result
4266
4266
  Song.all :artist => Artist.pointer(artist_id)
4267
4267
  Song.all :artist => Parse::Pointer.new("Artist", artist_id)
4268
4268
 
4269
- # "id" safely pointers and strings for supporting these types of API patterns
4269
+ # `pointer_id` safely accepts pointers and strings for these kinds of API patterns
4270
4270
  def find_songs(artist)
4271
- Song.all :artist.id => artist
4271
+ Song.all :artist.pointer_id => artist
4272
4272
  end
4273
4273
 
4274
4274
  # all ok
@@ -5813,7 +5813,7 @@ Event.query(:event_date.gt => Time.now).results_direct
5813
5813
  Event.query(:event_date.gte => start_date, :event_date.lte => end_date).results_direct
5814
5814
 
5815
5815
  # Array operators
5816
- Song.query(:tags.size => 3).results_direct
5816
+ Song.query(:tags.array_size => 3).results_direct
5817
5817
  Song.query(:tags.contains_all => ["rock", "classic"]).results_direct
5818
5818
  Song.query(:tags.empty_or_nil => true).results_direct
5819
5819
 
@@ -6101,6 +6101,12 @@ Parse.client.authorization.invalidate(token)
6101
6101
  Parse.client.authorization.invalidate_user_roles(user_id)
6102
6102
  ```
6103
6103
 
6104
+ A custom identity plane that implements neither `invalidate_value` nor
6105
+ generation support (`generation`, `generation_current?`, `bump_generation`)
6106
+ can only forget one token at a time. Revoking every session
6107
+ of a user (password change, account deletion, `logout_all!`, a batch destroy of
6108
+ sessions) is then bounded by `identity_cache_ttl` rather than immediate.
6109
+
6104
6110
  Both caches default to per-process memory. With a keyspaced
6105
6111
  `Parse::Cache::Redis` you can move them to the shared backend so every worker
6106
6112
  resolves against the same view, and let webhook triggers invalidate them
data/docs/caching.md CHANGED
@@ -358,6 +358,56 @@ Two behaviors to know before you rely on these:
358
358
  triggers depend on. The deprecated `Parse::AtlasSearch::Session.invalidate` /
359
359
  `.invalidate_user_roles` forms still work through 5.x: they delegate to the
360
360
  default client's context, so they can only ever address `Parse.client`.
361
+ * A custom identity plane you write yourself needs `get`, `set`, and
362
+ `invalidate`. Revoking one token (logout, `Parse::Session#destroy` on a
363
+ session that carries its token) works with those alone. Revoking every token
364
+ of a user (password change, account deletion, `logout_all!`, destroying a
365
+ session fetched without its token, or a batch `Array#destroy` of sessions or
366
+ users) also needs either `invalidate_value(user_id)` or generation support
367
+ (`generation`, `generation_current?`, and `bump_generation`), because the
368
+ plane is keyed by token. A plane with neither keeps resolving
369
+ those tokens until their cached entries expire, so revocation there is
370
+ bounded by `identity_cache_ttl`, not immediate. Lower the TTL if that window
371
+ is too long.
372
+ * A session or user delete drops its cached identity when the delete succeeds
373
+ or reports the row already gone ("object not found"); a denied delete leaves
374
+ the cache alone. A session fetched without its token is looked up first
375
+ (with the master key when the client has one, otherwise with the delete's
376
+ `session:`). When the lookup finds no row (the session is already gone or
377
+ not visible), the delete uses the owner recorded when a `_Session` query or
378
+ `Parse::Session.session` last fetched that session through the same client.
379
+ Only the owner's user id is recorded, never the token, for as long as an
380
+ identity entry lives. The records sit in their own store
381
+ (`client.authorization.session_owner_cache`, process-local by default, set
382
+ a shared store to share them), never in the identity plane, so no session
383
+ token can read one. With no
384
+ recorded owner, a batch delete that reports the row deleted or "object not
385
+ found" clears the client's identity and role cache, and so does a lookup
386
+ that fails outright. Either reset happens at most once every 5 seconds per
387
+ client, so an endpoint that deletes caller-supplied session ids cannot flush
388
+ a shared plane on every request. A single `destroy` cannot tell "already
389
+ gone" from "denied", so it uses the recorded owner but never resets. The
390
+ residual window is a session deleted elsewhere whose owner this process
391
+ never recorded: its cached token resolves until the next reset or until
392
+ `identity_cache_ttl`.
393
+ * A token resolution that is in flight when an invalidation lands does not
394
+ cache its answer, so it cannot put a just-revoked token back. Every
395
+ invalidation moves a marker before it drops entries, and the resolver
396
+ checks the marker before writing and again after, evicting its own write
397
+ when it moved. On the Redis identity plane the marker is a random nonce
398
+ shared by every process, replaced on each invalidation, so this also holds
399
+ for invalidations made by other processes, including a full reset. A custom
400
+ plane gets the same cross-process guarantee by implementing
401
+ `invalidation_nonce` (return the current marker String, or nil when none is
402
+ set) and `bump_invalidation_nonce` (store and return a new random String).
403
+ A custom plane with generation support but no nonce shares a generation
404
+ counter instead. That covers single-token and per-user invalidations made
405
+ by other processes, but not a full reset if the plane's `clear` also deletes
406
+ its generation counters: the counter restarts at a value an in-flight
407
+ lookup may already hold, so a lookup racing another process's reset can
408
+ keep its answer until `identity_cache_ttl`. A plane with neither gets the
409
+ guarantee within one process only, and across processes it is bounded by
410
+ `identity_cache_ttl`.
361
411
 
362
412
  On a Redis outage these planes behave differently from the response cache.
363
413
  The response cache degrades to a passthrough request; the identity and role
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
 
@@ -1714,7 +1723,7 @@ Parse::Agent.new(classes: { only: [Pots] }, strict_class_filter: true) # per-in
1714
1723
 
1715
1724
  `except:` is never validated — an operator may proactively block a class not yet loaded.
1716
1725
 
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.
1726
+ **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
1727
 
1719
1728
  **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
1729
 
@@ -1793,6 +1802,7 @@ Parse::Agent::Tools.register(
1793
1802
  | `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
1803
  | `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
1804
  | `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. |
1805
+ | `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
1806
  | `tenant_id` | Yes (security-critical) | Without it, a tenant-bound parent produces an unbound sub-agent that escapes `agent_tenant_scope` rules. |
1797
1807
  | `recursion_depth` | Always (decremented) | The parent's budget is authoritative — the explicit `recursion_depth:` kwarg is ignored on inherited construction. |
1798
1808
 
@@ -1802,7 +1812,8 @@ Parse::Agent::Tools.register(
1802
1812
  |-------|---------|
1803
1813
  | `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
1814
  | `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. |
1815
+
1816
+ **`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
1817
 
1807
1818
  **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
1819
 
@@ -2785,6 +2796,7 @@ Every tool call dispatched through `Agent#execute` fires the `"parse.agent.tool_
2785
2796
  | `:tools_except` | Array<Symbol> | v4.3.0+ — when the agent was constructed with `tools: { except: [...] }`. |
2786
2797
  | `:methods_only` | Array<String> | v4.3.0+ — when the agent was constructed with `methods: { only: [...] }`. Bare names and `"Class.method"` qualified names mix. |
2787
2798
  | `:methods_except` | Array<String> | v4.3.0+ — when the agent was constructed with `methods: { except: [...] }`. |
2799
+ | `: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
2800
  | `: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
2801
  | `: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
2802