parse-stack-next 5.6.0 → 5.7.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 (121) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +489 -0
  3. data/README.md +378 -6
  4. data/docs/caching.md +748 -0
  5. data/examples/basic_client.rb +3 -3
  6. data/examples/basic_server.rb +3 -3
  7. data/examples/live_query_listener.rb +12 -12
  8. data/examples/rag_chatbot.rb +14 -14
  9. data/examples/transaction_example.rb +44 -45
  10. data/examples/webhook_server.rb +3 -3
  11. data/lib/parse/access.rb +493 -0
  12. data/lib/parse/acl_scope.rb +125 -52
  13. data/lib/parse/agent/approval_gate.rb +0 -0
  14. data/lib/parse/agent/cancellation_token.rb +3 -3
  15. data/lib/parse/agent/constraint_translator.rb +4 -4
  16. data/lib/parse/agent/describe.rb +34 -34
  17. data/lib/parse/agent/errors.rb +9 -9
  18. data/lib/parse/agent/mcp_client.rb +61 -59
  19. data/lib/parse/agent/mcp_dispatcher.rb +89 -101
  20. data/lib/parse/agent/mcp_rack_app.rb +92 -93
  21. data/lib/parse/agent/mcp_server.rb +1 -1
  22. data/lib/parse/agent/mcp_subscriptions.rb +22 -22
  23. data/lib/parse/agent/metadata_audit.rb +1 -2
  24. data/lib/parse/agent/metadata_dsl.rb +7 -7
  25. data/lib/parse/agent/metadata_registry.rb +12 -12
  26. data/lib/parse/agent/prompt_hardening.rb +4 -4
  27. data/lib/parse/agent/prompts.rb +10 -10
  28. data/lib/parse/agent/result_formatter.rb +4 -4
  29. data/lib/parse/agent/tools.rb +493 -400
  30. data/lib/parse/agent.rb +157 -116
  31. data/lib/parse/api/hooks.rb +1 -1
  32. data/lib/parse/api/server.rb +2 -2
  33. data/lib/parse/api/users.rb +2 -2
  34. data/lib/parse/atlas_search/index_manager.rb +1 -1
  35. data/lib/parse/atlas_search/session.rb +40 -218
  36. data/lib/parse/atlas_search.rb +181 -60
  37. data/lib/parse/authorization.rb +466 -0
  38. data/lib/parse/cache/invalidation.rb +219 -0
  39. data/lib/parse/cache/keyspace.rb +306 -0
  40. data/lib/parse/cache/moneta_surface.rb +126 -0
  41. data/lib/parse/cache/pool.rb +48 -5
  42. data/lib/parse/cache/redis.rb +425 -10
  43. data/lib/parse/cache/scoped_view.rb +443 -0
  44. data/lib/parse/cache/sub_cache.rb +264 -0
  45. data/lib/parse/cache/upstream_roles.rb +230 -0
  46. data/lib/parse/client/authentication.rb +1 -1
  47. data/lib/parse/client/body_builder.rb +1 -2
  48. data/lib/parse/client/caching.rb +84 -5
  49. data/lib/parse/client.rb +327 -189
  50. data/lib/parse/clp_scope.rb +225 -28
  51. data/lib/parse/console.rb +3 -3
  52. data/lib/parse/embeddings/batch_embedder.rb +3 -3
  53. data/lib/parse/embeddings/cache.rb +17 -17
  54. data/lib/parse/embeddings/cohere.rb +21 -22
  55. data/lib/parse/embeddings/image_fetch.rb +22 -21
  56. data/lib/parse/embeddings/jina.rb +21 -21
  57. data/lib/parse/embeddings/local_http.rb +6 -7
  58. data/lib/parse/embeddings/media_file.rb +5 -4
  59. data/lib/parse/embeddings/openai.rb +5 -5
  60. data/lib/parse/embeddings/provider.rb +1 -1
  61. data/lib/parse/embeddings/qwen.rb +10 -10
  62. data/lib/parse/embeddings/spend_cap.rb +2 -3
  63. data/lib/parse/embeddings/streaming_body.rb +11 -11
  64. data/lib/parse/embeddings/video_source.rb +7 -6
  65. data/lib/parse/embeddings/voyage.rb +59 -61
  66. data/lib/parse/embeddings.rb +28 -27
  67. data/lib/parse/graphql/type_generator.rb +1 -1
  68. data/lib/parse/graphql.rb +5 -5
  69. data/lib/parse/live_query/client.rb +1 -1
  70. data/lib/parse/live_query.rb +1 -1
  71. data/lib/parse/lock.rb +15 -18
  72. data/lib/parse/lock_backend.rb +1 -1
  73. data/lib/parse/lookup_rewriter.rb +6 -6
  74. data/lib/parse/model/associations/belongs_to.rb +4 -0
  75. data/lib/parse/model/associations/collection_proxy.rb +3 -0
  76. data/lib/parse/model/associations/has_many.rb +4 -0
  77. data/lib/parse/model/classes/role.rb +227 -28
  78. data/lib/parse/model/classes/user.rb +60 -1
  79. data/lib/parse/model/clp.rb +4 -4
  80. data/lib/parse/model/core/actions.rb +366 -113
  81. data/lib/parse/model/core/create_lock.rb +0 -2
  82. data/lib/parse/model/core/describe.rb +57 -57
  83. data/lib/parse/model/core/embed_managed.rb +21 -23
  84. data/lib/parse/model/core/fetching.rb +6 -0
  85. data/lib/parse/model/core/indexing.rb +14 -14
  86. data/lib/parse/model/core/parse_reference.rb +1 -1
  87. data/lib/parse/model/core/properties.rb +6 -2
  88. data/lib/parse/model/core/querying.rb +1 -1
  89. data/lib/parse/model/core/schema.rb +2 -2
  90. data/lib/parse/model/core/search_indexing.rb +2 -2
  91. data/lib/parse/model/core/vector_searchable.rb +4 -5
  92. data/lib/parse/model/file.rb +21 -15
  93. data/lib/parse/model/geojson.rb +2 -2
  94. data/lib/parse/model/geopoint.rb +1 -0
  95. data/lib/parse/model/object.rb +136 -66
  96. data/lib/parse/model/pointer.rb +2 -0
  97. data/lib/parse/model/polygon.rb +3 -6
  98. data/lib/parse/model/push.rb +2 -2
  99. data/lib/parse/model/vector.rb +3 -1
  100. data/lib/parse/mongodb.rb +512 -282
  101. data/lib/parse/pipeline_security.rb +3 -0
  102. data/lib/parse/query/constraints.rb +46 -46
  103. data/lib/parse/query.rb +146 -59
  104. data/lib/parse/retrieval/agent_tool.rb +34 -20
  105. data/lib/parse/retrieval/chunk.rb +1 -0
  106. data/lib/parse/retrieval/reranker/cohere.rb +11 -11
  107. data/lib/parse/retrieval/reranker.rb +3 -4
  108. data/lib/parse/retrieval/retriever.rb +4 -6
  109. data/lib/parse/schema/index_migrator.rb +26 -26
  110. data/lib/parse/schema/search_index_migrator.rb +19 -19
  111. data/lib/parse/stack/tasks.rb +6 -6
  112. data/lib/parse/stack/version.rb +1 -1
  113. data/lib/parse/stack.rb +10 -10
  114. data/lib/parse/vector_search/hybrid.rb +66 -41
  115. data/lib/parse/vector_search.rb +14 -11
  116. data/lib/parse/webhooks/payload.rb +1 -0
  117. data/lib/parse/webhooks/registration.rb +4 -4
  118. data/lib/parse/webhooks/trigger_audit.rb +50 -49
  119. data/lib/parse/webhooks.rb +120 -5
  120. data/parse-stack-next.gemspec +6 -6
  121. metadata +10 -1
@@ -112,8 +112,8 @@ module Parse
112
112
  items: {
113
113
  type: "object",
114
114
  properties: {
115
- name: { type: "string" },
116
- category: { type: "string" },
115
+ name: { type: "string" },
116
+ category: { type: "string" },
117
117
  description: { type: "string" },
118
118
  },
119
119
  required: %w[name category description],
@@ -138,8 +138,8 @@ module Parse
138
138
  parameters: {
139
139
  type: "object",
140
140
  properties: {
141
- names: { type: "array", items: { type: "string" },
142
- description: "Optional. Restrict the output to these class names (exact match)." },
141
+ names: { type: "array", items: { type: "string" },
142
+ description: "Optional. Restrict the output to these class names (exact match)." },
143
143
  prefix: { type: "string",
144
144
  description: "Optional. Restrict the output to class names that start with this prefix (case-sensitive)." },
145
145
  },
@@ -148,16 +148,16 @@ module Parse
148
148
  output_schema: {
149
149
  type: "object",
150
150
  properties: {
151
- total: { type: "integer", minimum: 0 },
152
- note: { type: "string" },
151
+ total: { type: "integer", minimum: 0 },
152
+ note: { type: "string" },
153
153
  built_in: {
154
154
  type: "array",
155
155
  items: {
156
156
  type: "object",
157
157
  properties: {
158
- name: { type: "string" },
159
- fields: { type: "integer", minimum: 0 },
160
- desc: { type: "string" },
158
+ name: { type: "string" },
159
+ fields: { type: "integer", minimum: 0 },
160
+ desc: { type: "string" },
161
161
  methods: { type: "integer", minimum: 0 },
162
162
  },
163
163
  required: %w[name fields],
@@ -169,9 +169,9 @@ module Parse
169
169
  items: {
170
170
  type: "object",
171
171
  properties: {
172
- name: { type: "string" },
173
- fields: { type: "integer", minimum: 0 },
174
- desc: { type: "string" },
172
+ name: { type: "string" },
173
+ fields: { type: "integer", minimum: 0 },
174
+ desc: { type: "string" },
175
175
  methods: { type: "integer", minimum: 0 },
176
176
  },
177
177
  required: %w[name fields],
@@ -205,18 +205,18 @@ module Parse
205
205
  output_schema: {
206
206
  type: "object",
207
207
  properties: {
208
- class_name: { type: "string" },
209
- type: { type: "string" },
208
+ class_name: { type: "string" },
209
+ type: { type: "string" },
210
210
  description: { type: "string" },
211
- usage: { type: "string" },
212
- fields: { type: "array", items: { type: "object", additionalProperties: true } },
213
- indexes: { type: "object", additionalProperties: true },
211
+ usage: { type: "string" },
212
+ fields: { type: "array", items: { type: "object", additionalProperties: true } },
213
+ indexes: { type: "object", additionalProperties: true },
214
214
  permissions: { type: "object", additionalProperties: true },
215
- agent_methods: { type: "array", items: { type: "object", additionalProperties: true } },
216
- canonical_filter: { type: "object", additionalProperties: true },
217
- agent_fields: { type: "array", items: { type: "string" } },
215
+ agent_methods: { type: "array", items: { type: "object", additionalProperties: true } },
216
+ canonical_filter: { type: "object", additionalProperties: true },
217
+ agent_fields: { type: "array", items: { type: "string" } },
218
218
  agent_join_fields: { type: "array", items: { type: "string" } },
219
- relations: { type: "object", additionalProperties: true },
219
+ relations: { type: "object", additionalProperties: true },
220
220
  },
221
221
  required: %w[class_name type fields indexes permissions],
222
222
  },
@@ -249,13 +249,13 @@ module Parse
249
249
  keys: { type: "array", items: { type: "string" } },
250
250
  include: { type: "array", items: { type: "string" } },
251
251
  apply_canonical_filter: { type: "boolean",
252
- description: "Default true. When true and the class declares an " \
253
- "agent_canonical_filter, it is merged into the where via " \
254
- "$and so the caller's constraints compose rather than override." },
252
+ description: "Default true. When true and the class declares an " \
253
+ "agent_canonical_filter, it is merged into the where via " \
254
+ "$and so the caller's constraints compose rather than override." },
255
255
  format: { type: "string", enum: %w[json csv markdown table],
256
- description: "Output format. Defaults to 'json' (structured row envelope). When set to " \
257
- "csv/markdown/table the response carries {format:, headers:, row_count:, output:} " \
258
- "instead of the row envelope; columns are inferred from the first row." },
256
+ description: "Output format. Defaults to 'json' (structured row envelope). When set to " \
257
+ "csv/markdown/table the response carries {format:, headers:, row_count:, output:} " \
258
+ "instead of the row envelope; columns are inferred from the first row." },
259
259
  },
260
260
  required: ["class_name"],
261
261
  },
@@ -271,35 +271,35 @@ module Parse
271
271
  output_schema: {
272
272
  type: "object",
273
273
  properties: {
274
- class_name: { type: "string" },
274
+ class_name: { type: "string" },
275
275
  # json envelope
276
- result_count: { type: "integer", minimum: 0 },
276
+ result_count: { type: "integer", minimum: 0 },
277
277
  pagination: {
278
278
  type: "object",
279
279
  properties: {
280
- limit: { type: "integer", minimum: 0 },
281
- skip: { type: "integer", minimum: 0 },
280
+ limit: { type: "integer", minimum: 0 },
281
+ skip: { type: "integer", minimum: 0 },
282
282
  has_more: { type: "boolean" },
283
283
  },
284
284
  required: %w[limit skip has_more],
285
285
  },
286
- truncated: { type: "boolean" },
287
- truncated_note: { type: "string" },
286
+ truncated: { type: "boolean" },
287
+ truncated_note: { type: "string" },
288
288
  truncated_include_fields: { type: "object", additionalProperties: true },
289
289
  next_call: {
290
290
  type: "object",
291
291
  properties: {
292
- tool: { type: "string" },
292
+ tool: { type: "string" },
293
293
  arguments: { type: "object", additionalProperties: true },
294
294
  },
295
295
  required: %w[tool arguments],
296
296
  },
297
297
  results: { type: "array", items: { type: "object", additionalProperties: true } },
298
298
  # csv / markdown / table envelope
299
- format: { type: "string", enum: %w[csv markdown table] },
300
- headers: { type: "array", items: { type: "string" } },
299
+ format: { type: "string", enum: %w[csv markdown table] },
300
+ headers: { type: "array", items: { type: "string" } },
301
301
  row_count: { type: "integer", minimum: 0 },
302
- output: { type: "string" },
302
+ output: { type: "string" },
303
303
  },
304
304
  required: %w[class_name],
305
305
  },
@@ -327,8 +327,8 @@ module Parse
327
327
  output_schema: {
328
328
  type: "object",
329
329
  properties: {
330
- class_name: { type: "string" },
331
- count: { type: "integer", minimum: 0 },
330
+ class_name: { type: "string" },
331
+ count: { type: "integer", minimum: 0 },
332
332
  constraints: { type: "object" },
333
333
  },
334
334
  required: %w[class_name count constraints],
@@ -349,11 +349,11 @@ module Parse
349
349
  object_id: { type: "string" },
350
350
  include: { type: "array", items: { type: "string" } },
351
351
  apply_canonical_filter: { type: "boolean",
352
- description: "Default true. When true and the class declares an " \
353
- "agent_canonical_filter, the fetch is rewritten as a " \
354
- "find_objects with where: { objectId: id, ...filter } " \
355
- "so a filtered-out row returns 'not found'. Set to false " \
356
- "to bypass the predicate and fetch the row directly." },
352
+ description: "Default true. When true and the class declares an " \
353
+ "agent_canonical_filter, the fetch is rewritten as a " \
354
+ "find_objects with where: { objectId: id, ...filter } " \
355
+ "so a filtered-out row returns 'not found'. Set to false " \
356
+ "to bypass the predicate and fetch the row directly." },
357
357
  },
358
358
  required: ["class_name", "object_id"],
359
359
  },
@@ -361,10 +361,10 @@ module Parse
361
361
  type: "object",
362
362
  properties: {
363
363
  class_name: { type: "string" },
364
- object_id: { type: "string" },
364
+ object_id: { type: "string" },
365
365
  created_at: { type: %w[string null] },
366
366
  updated_at: { type: %w[string null] },
367
- object: { type: "object" },
367
+ object: { type: "object" },
368
368
  truncated_include_fields: { type: "object" },
369
369
  },
370
370
  required: %w[class_name object_id object],
@@ -387,10 +387,10 @@ module Parse
387
387
  ids: { type: "array", items: { type: "string" }, description: "Array of objectId values (max 50, dedup'd)" },
388
388
  include: { type: "array", items: { type: "string" }, description: "Pointer fields to include/resolve" },
389
389
  apply_canonical_filter: { type: "boolean",
390
- description: "Default true. When true and the class declares an " \
391
- "agent_canonical_filter, it composes with the objectId $in " \
392
- "constraint via $and so 'invalid state' rows are filtered out " \
393
- "(they appear in the :missing array). Set to false to bypass." },
390
+ description: "Default true. When true and the class declares an " \
391
+ "agent_canonical_filter, it composes with the objectId $in " \
392
+ "constraint via $and so 'invalid state' rows are filtered out " \
393
+ "(they appear in the :missing array). Set to false to bypass." },
394
394
  },
395
395
  required: ["class_name", "ids"],
396
396
  },
@@ -403,9 +403,9 @@ module Parse
403
403
  additionalProperties: { type: "object" },
404
404
  description: "Map of objectId => fetched object. Empty when no ids resolved.",
405
405
  },
406
- missing: { type: "array", items: { type: "string" } },
406
+ missing: { type: "array", items: { type: "string" } },
407
407
  requested: { type: "integer", minimum: 0 },
408
- found: { type: "integer", minimum: 0 },
408
+ found: { type: "integer", minimum: 0 },
409
409
  truncated_include_fields: { type: "object" },
410
410
  },
411
411
  required: %w[class_name objects missing requested found],
@@ -430,10 +430,10 @@ module Parse
430
430
  output_schema: {
431
431
  type: "object",
432
432
  properties: {
433
- class_name: { type: "string" },
433
+ class_name: { type: "string" },
434
434
  sample_count: { type: "integer", minimum: 0 },
435
- samples: { type: "array", items: { type: "object" } },
436
- note: { type: "string" },
435
+ samples: { type: "array", items: { type: "object" } },
436
+ note: { type: "string" },
437
437
  },
438
438
  required: %w[class_name sample_count samples],
439
439
  },
@@ -456,29 +456,29 @@ module Parse
456
456
  type: "object",
457
457
  properties: {
458
458
  class_name: { type: "string" },
459
- pipeline: { type: "array", items: { type: "object" } },
459
+ pipeline: { type: "array", items: { type: "object" } },
460
460
  compact_pointers: { type: "boolean",
461
- description: "Default true. When true, storage-form pointer columns (`_p_*`) are " \
462
- "rewritten and the envelope carries a `pointer_classes` map. Set to " \
463
- "false to receive raw Mongo shapes." },
461
+ description: "Default true. When true, storage-form pointer columns (`_p_*`) are " \
462
+ "rewritten and the envelope carries a `pointer_classes` map. Set to " \
463
+ "false to receive raw Mongo shapes." },
464
464
  apply_canonical_filter: { type: "boolean",
465
- description: "Default true. When true and the class declares an " \
466
- "agent_canonical_filter, it is prepended as a $match stage so " \
467
- "the pipeline starts from the class's 'valid state' subset. " \
468
- "Set to false to operate on the full collection." },
465
+ description: "Default true. When true and the class declares an " \
466
+ "agent_canonical_filter, it is prepended as a $match stage so " \
467
+ "the pipeline starts from the class's 'valid state' subset. " \
468
+ "Set to false to operate on the full collection." },
469
469
  },
470
470
  required: ["class_name", "pipeline"],
471
471
  },
472
472
  output_schema: {
473
473
  type: "object",
474
474
  properties: {
475
- class_name: { type: "string" },
475
+ class_name: { type: "string" },
476
476
  pipeline_stages: { type: "integer", minimum: 0 },
477
- result_count: { type: "integer", minimum: 0 },
477
+ result_count: { type: "integer", minimum: 0 },
478
478
  # `route` is :mongo_direct or :parse_server but serializes
479
479
  # to a Symbol-shaped String in JSON envelopes; declare it
480
480
  # permissively as string.
481
- route: { type: "string", description: "Routing tag: 'mongo_direct' or 'parse_server'." },
481
+ route: { type: "string", description: "Routing tag: 'mongo_direct' or 'parse_server'." },
482
482
  # Aggregation result rows are class-shape-dependent and may
483
483
  # be the output of arbitrary $project / $group / $lookup
484
484
  # stages. Object envelopes with open property sets are the
@@ -493,8 +493,8 @@ module Parse
493
493
  description: "Optional. Field-name → Parse-class-name map when compact_pointers is on.",
494
494
  },
495
495
  auto_limited: { type: "boolean" },
496
- auto_limit: { type: "integer", minimum: 1 },
497
- hint: { type: "string" },
496
+ auto_limit: { type: "integer", minimum: 1 },
497
+ hint: { type: "string" },
498
498
  },
499
499
  required: %w[class_name pipeline_stages result_count route results],
500
500
  },
@@ -554,55 +554,55 @@ module Parse
554
554
  parameters: {
555
555
  type: "object",
556
556
  properties: {
557
- class_name: { type: "string" },
558
- field: { type: "string", description: "Field to group by (wire-format name; pointers auto-detected)" },
559
- operation: {
557
+ class_name: { type: "string" },
558
+ field: { type: "string", description: "Field to group by (wire-format name; pointers auto-detected)" },
559
+ operation: {
560
560
  type: "string",
561
561
  enum: %w[count sum avg average min max],
562
562
  description: "Aggregation to apply per group. Default: count.",
563
563
  },
564
- value_field: { type: "string", description: "Required for sum/avg/min/max — the field to aggregate within each group." },
565
- where: { type: "object", description: "Optional constraints applied via $match before grouping." },
564
+ value_field: { type: "string", description: "Required for sum/avg/min/max — the field to aggregate within each group." },
565
+ where: { type: "object", description: "Optional constraints applied via $match before grouping." },
566
566
  flatten_arrays: { type: "boolean", description: "When true, $unwind the field before grouping so individual array elements are counted." },
567
- sort: {
567
+ sort: {
568
568
  type: "string",
569
569
  enum: %w[value_desc value_asc key_desc key_asc],
570
570
  description: "Sort the result. Use value_desc for top-K. Default: server-natural order.",
571
571
  },
572
- limit: { type: "integer", description: "Cap the number of groups returned. Default: 200, max: 1000." },
573
- dry_run: { type: "boolean", description: "When true, return the constructed MongoDB pipeline without executing it. Use to inspect / hand-modify before running via the aggregate tool." },
572
+ limit: { type: "integer", description: "Cap the number of groups returned. Default: 200, max: 1000." },
573
+ dry_run: { type: "boolean", description: "When true, return the constructed MongoDB pipeline without executing it. Use to inspect / hand-modify before running via the aggregate tool." },
574
574
  apply_canonical_filter: { type: "boolean",
575
- description: "Default true. When true and the class declares an " \
576
- "agent_canonical_filter, it is prepended as a $match stage " \
577
- "so the group operates only on the class's 'valid state' " \
578
- "subset. Set to false to group across the full collection." },
575
+ description: "Default true. When true and the class declares an " \
576
+ "agent_canonical_filter, it is prepended as a $match stage " \
577
+ "so the group operates only on the class's 'valid state' " \
578
+ "subset. Set to false to group across the full collection." },
579
579
  },
580
580
  required: ["class_name", "field"],
581
581
  },
582
582
  output_schema: {
583
583
  type: "object",
584
584
  properties: {
585
- class_name: { type: "string" },
586
- field: { type: "string" },
587
- operation: { type: "string" },
588
- group_count: { type: "integer", minimum: 0 },
585
+ class_name: { type: "string" },
586
+ field: { type: "string" },
587
+ operation: { type: "string" },
588
+ group_count: { type: "integer", minimum: 0 },
589
589
  groups: {
590
590
  type: "array",
591
591
  items: {
592
592
  type: "object",
593
593
  properties: {
594
- key: {},
594
+ key: {},
595
595
  value: { type: %w[number null] },
596
596
  },
597
597
  required: %w[key value],
598
598
  },
599
599
  },
600
- value_field: { type: "string" },
601
- pointer_class: { type: "string" },
600
+ value_field: { type: "string" },
601
+ pointer_class: { type: "string" },
602
602
  flatten_arrays: { type: "boolean" },
603
- sort: { type: "string" },
604
- truncated: { type: "boolean" },
605
- limit: { type: "integer" },
603
+ sort: { type: "string" },
604
+ truncated: { type: "boolean" },
605
+ limit: { type: "integer" },
606
606
  },
607
607
  required: %w[class_name field operation group_count groups limit],
608
608
  },
@@ -621,60 +621,60 @@ module Parse
621
621
  parameters: {
622
622
  type: "object",
623
623
  properties: {
624
- class_name: { type: "string" },
625
- field: { type: "string", description: "Date field to bucket on (e.g. 'createdAt', 'updatedAt', or a custom Date column)." },
626
- interval: {
624
+ class_name: { type: "string" },
625
+ field: { type: "string", description: "Date field to bucket on (e.g. 'createdAt', 'updatedAt', or a custom Date column)." },
626
+ interval: {
627
627
  type: "string",
628
628
  enum: %w[year month week day hour minute second],
629
629
  description: "Bucket size.",
630
630
  },
631
- operation: {
631
+ operation: {
632
632
  type: "string",
633
633
  enum: %w[count sum avg average min max],
634
634
  description: "Aggregation per bucket. Default: count.",
635
635
  },
636
636
  value_field: { type: "string", description: "Required for sum/avg/min/max — the field to aggregate within each bucket." },
637
- where: { type: "object", description: "Optional constraints applied via $match before grouping." },
638
- timezone: { type: "string", description: "IANA tz name (e.g. 'America/New_York') or fixed offset ('+05:00'). Default: UTC." },
639
- sort: {
637
+ where: { type: "object", description: "Optional constraints applied via $match before grouping." },
638
+ timezone: { type: "string", description: "IANA tz name (e.g. 'America/New_York') or fixed offset ('+05:00'). Default: UTC." },
639
+ sort: {
640
640
  type: "string",
641
641
  enum: %w[key_asc key_desc value_asc value_desc],
642
642
  description: "Sort the result. Default: key_asc (chronological).",
643
643
  },
644
- limit: { type: "integer", description: "Cap the number of buckets returned. Default: 200, max: 1000." },
645
- dry_run: { type: "boolean", description: "When true, return the constructed MongoDB pipeline without executing it." },
644
+ limit: { type: "integer", description: "Cap the number of buckets returned. Default: 200, max: 1000." },
645
+ dry_run: { type: "boolean", description: "When true, return the constructed MongoDB pipeline without executing it." },
646
646
  apply_canonical_filter: { type: "boolean",
647
- description: "Default true. When true and the class declares an " \
648
- "agent_canonical_filter, it is prepended as a $match stage " \
649
- "so the buckets reflect only the class's 'valid state' " \
650
- "subset. Set to false to bucket the full collection." },
647
+ description: "Default true. When true and the class declares an " \
648
+ "agent_canonical_filter, it is prepended as a $match stage " \
649
+ "so the buckets reflect only the class's 'valid state' " \
650
+ "subset. Set to false to bucket the full collection." },
651
651
  },
652
652
  required: ["class_name", "field", "interval"],
653
653
  },
654
654
  output_schema: {
655
655
  type: "object",
656
656
  properties: {
657
- class_name: { type: "string" },
658
- field: { type: "string" },
659
- interval: { type: "string" },
660
- operation: { type: "string" },
661
- group_count: { type: "integer", minimum: 0 },
657
+ class_name: { type: "string" },
658
+ field: { type: "string" },
659
+ interval: { type: "string" },
660
+ operation: { type: "string" },
661
+ group_count: { type: "integer", minimum: 0 },
662
662
  groups: {
663
663
  type: "array",
664
664
  items: {
665
665
  type: "object",
666
666
  properties: {
667
- key: { type: %w[string null] },
667
+ key: { type: %w[string null] },
668
668
  value: { type: %w[number null] },
669
669
  },
670
670
  required: %w[key value],
671
671
  },
672
672
  },
673
- value_field: { type: "string" },
674
- timezone: { type: "string" },
675
- sort: { type: "string" },
676
- truncated: { type: "boolean" },
677
- limit: { type: "integer" },
673
+ value_field: { type: "string" },
674
+ timezone: { type: "string" },
675
+ sort: { type: "string" },
676
+ truncated: { type: "boolean" },
677
+ limit: { type: "integer" },
678
678
  },
679
679
  required: %w[class_name field interval operation group_count groups sort limit],
680
680
  },
@@ -692,35 +692,35 @@ module Parse
692
692
  type: "object",
693
693
  properties: {
694
694
  class_name: { type: "string" },
695
- field: { type: "string", description: "Field to extract distinct values from (wire-format name; pointers auto-detected)." },
696
- where: { type: "object", description: "Optional constraints applied via $match before distinct." },
697
- sort: {
695
+ field: { type: "string", description: "Field to extract distinct values from (wire-format name; pointers auto-detected)." },
696
+ where: { type: "object", description: "Optional constraints applied via $match before distinct." },
697
+ sort: {
698
698
  type: "string",
699
699
  enum: %w[asc desc],
700
700
  description: "Sort the returned values alphanumerically. Default: server-natural order.",
701
701
  },
702
- limit: { type: "integer", description: "Cap the number of distinct values returned. Default: 1000, max: 5000." },
703
- dry_run: { type: "boolean", description: "When true, return the constructed MongoDB pipeline without executing it." },
702
+ limit: { type: "integer", description: "Cap the number of distinct values returned. Default: 1000, max: 5000." },
703
+ dry_run: { type: "boolean", description: "When true, return the constructed MongoDB pipeline without executing it." },
704
704
  apply_canonical_filter: { type: "boolean",
705
- description: "Default true. When true and the class declares an " \
706
- "agent_canonical_filter, it is prepended as a $match stage " \
707
- "so values are extracted only from the class's 'valid state' " \
708
- "subset. Set to false to extract across the full collection." },
705
+ description: "Default true. When true and the class declares an " \
706
+ "agent_canonical_filter, it is prepended as a $match stage " \
707
+ "so values are extracted only from the class's 'valid state' " \
708
+ "subset. Set to false to extract across the full collection." },
709
709
  },
710
710
  required: ["class_name", "field"],
711
711
  },
712
712
  output_schema: {
713
713
  type: "object",
714
714
  properties: {
715
- class_name: { type: "string" },
716
- field: { type: "string" },
717
- count: { type: "integer", minimum: 0 },
718
- values: { type: "array",
719
- items: { type: %w[string number boolean null] } },
715
+ class_name: { type: "string" },
716
+ field: { type: "string" },
717
+ count: { type: "integer", minimum: 0 },
718
+ values: { type: "array",
719
+ items: { type: %w[string number boolean null] } },
720
720
  pointer_class: { type: "string" },
721
- sort: { type: "string" },
722
- truncated: { type: "boolean" },
723
- limit: { type: "integer" },
721
+ sort: { type: "string" },
722
+ truncated: { type: "boolean" },
723
+ limit: { type: "integer" },
724
724
  },
725
725
  required: %w[class_name field count values limit],
726
726
  },
@@ -744,16 +744,16 @@ module Parse
744
744
  properties: {
745
745
  class_name: { type: "string" },
746
746
  # query mode
747
- where: { type: "object" },
748
- keys: { type: "array", items: { type: "string" } },
749
- include: { type: "array", items: { type: "string" } },
750
- order: { type: "string" },
751
- limit: { type: "integer" },
752
- skip: { type: "integer" },
747
+ where: { type: "object" },
748
+ keys: { type: "array", items: { type: "string" } },
749
+ include: { type: "array", items: { type: "string" } },
750
+ order: { type: "string" },
751
+ limit: { type: "integer" },
752
+ skip: { type: "integer" },
753
753
  # aggregate mode (mutually exclusive with where/keys/order/limit)
754
754
  pipeline: { type: "array", items: { type: "object" } },
755
755
  # output control
756
- columns: {
756
+ columns: {
757
757
  type: "array",
758
758
  # Each entry is either a string (used as both path and header) or a
759
759
  # single-entry { "<field>" => "<Header>" } object for renaming.
@@ -768,7 +768,7 @@ module Parse
768
768
  description: "Column spec. Each entry is either a string (field name, used as header) " \
769
769
  "or an object {field => header} to rename. Dotted paths supported.",
770
770
  },
771
- format: {
771
+ format: {
772
772
  type: "string",
773
773
  enum: %w[csv markdown table],
774
774
  description: "Output format. Defaults to 'csv'.",
@@ -788,16 +788,16 @@ module Parse
788
788
  class_name: { type: "string" },
789
789
  # `format` is one of csv|markdown|table; same shape as the
790
790
  # input enum.
791
- format: { type: "string", enum: %w[csv markdown table] },
791
+ format: { type: "string", enum: %w[csv markdown table] },
792
792
  headers: { type: "array", items: { type: "string" } },
793
793
  row_count: { type: "integer", minimum: 0 },
794
794
  # The serialized output is the formatted CSV / Markdown /
795
795
  # text-table string itself — clients render it as-is.
796
- output: { type: "string" },
797
- truncated: { type: "boolean" },
796
+ output: { type: "string" },
797
+ truncated: { type: "boolean" },
798
798
  available_rows: { type: "integer", minimum: 0 },
799
- row_cap: { type: "integer", minimum: 1 },
800
- hint: { type: "string" },
799
+ row_cap: { type: "integer", minimum: 1 },
800
+ hint: { type: "string" },
801
801
  },
802
802
  required: %w[class_name format headers row_count output],
803
803
  },
@@ -819,14 +819,14 @@ module Parse
819
819
  type: "object",
820
820
  properties: {
821
821
  class_name: { type: "string" },
822
- query: { type: "string", description: "Search query text. Non-empty." },
823
- fields: {
822
+ query: { type: "string", description: "Search query text. Non-empty." },
823
+ fields: {
824
824
  type: "array",
825
825
  items: { type: "string" },
826
826
  description: "Optional. Restrict search to these fields. When omitted, all indexed fields are " \
827
827
  "searched. Subject to the class's agent_fields allowlist when one is declared.",
828
828
  },
829
- limit: {
829
+ limit: {
830
830
  type: "integer",
831
831
  description: "Optional. Max results, default 10, hard cap 20.",
832
832
  },
@@ -834,16 +834,16 @@ module Parse
834
834
  type: "string",
835
835
  description: "Optional. Field to return highlight snippets for. Subject to agent_fields allowlist.",
836
836
  },
837
- filter: {
837
+ filter: {
838
838
  type: "object",
839
839
  description: "Optional. Additional MongoDB filter applied after the $search stage. Same security " \
840
840
  "validation as aggregate's pipeline: no $where / $function / $accumulator.",
841
841
  },
842
842
  apply_canonical_filter: { type: "boolean",
843
- description: "Default true. When true and the class declares an " \
844
- "agent_canonical_filter, it is AND-merged into the " \
845
- "post-$search $match (alongside any caller filter:) " \
846
- "so search results come from the 'valid state' subset only." },
843
+ description: "Default true. When true and the class declares an " \
844
+ "agent_canonical_filter, it is AND-merged into the " \
845
+ "post-$search $match (alongside any caller filter:) " \
846
+ "so search results come from the 'valid state' subset only." },
847
847
  },
848
848
  required: %w[class_name query],
849
849
  },
@@ -851,7 +851,7 @@ module Parse
851
851
  type: "object",
852
852
  properties: {
853
853
  class_name: { type: "string" },
854
- count: { type: "integer", minimum: 0 },
854
+ count: { type: "integer", minimum: 0 },
855
855
  # Each row is a Parse object projected through the class's
856
856
  # agent_fields allowlist, with an Atlas-supplied `score`
857
857
  # numeric and an optional `highlights` array when the
@@ -875,7 +875,7 @@ module Parse
875
875
  type: "object",
876
876
  properties: {
877
877
  value: { type: "string" },
878
- type: { type: "string", description: "'hit' or 'text' per Atlas spec." },
878
+ type: { type: "string", description: "'hit' or 'text' per Atlas spec." },
879
879
  },
880
880
  required: %w[value],
881
881
  },
@@ -908,25 +908,25 @@ module Parse
908
908
  type: "object",
909
909
  properties: {
910
910
  class_name: { type: "string" },
911
- query: { type: "string", description: "Prefix to autocomplete against. Non-empty." },
912
- field: {
911
+ query: { type: "string", description: "Prefix to autocomplete against. Non-empty." },
912
+ field: {
913
913
  type: "string",
914
914
  description: "Field name configured for autocomplete in the search index. Must be in " \
915
915
  "agent_fields allowlist when one is declared.",
916
916
  },
917
- limit: {
917
+ limit: {
918
918
  type: "integer",
919
919
  description: "Optional. Max suggestions, default 10, hard cap 20.",
920
920
  },
921
- fuzzy: {
921
+ fuzzy: {
922
922
  type: "boolean",
923
923
  description: "Optional. Enable single-edit fuzzy matching. Default false.",
924
924
  },
925
925
  apply_canonical_filter: { type: "boolean",
926
- description: "Default true. When true and the class declares an " \
927
- "agent_canonical_filter, it is applied as a post-$search " \
928
- "$match so autocomplete suggestions exclude 'invalid state' " \
929
- "rows that the rest of the read-tool surface hides." },
926
+ description: "Default true. When true and the class declares an " \
927
+ "agent_canonical_filter, it is applied as a post-$search " \
928
+ "$match so autocomplete suggestions exclude 'invalid state' " \
929
+ "rows that the rest of the read-tool surface hides." },
930
930
  },
931
931
  required: %w[class_name query field],
932
932
  },
@@ -934,12 +934,12 @@ module Parse
934
934
  type: "object",
935
935
  properties: {
936
936
  class_name: { type: "string" },
937
- field: { type: "string" },
937
+ field: { type: "string" },
938
938
  # `suggestions` is the list of distinct field values that
939
939
  # matched the autocomplete query (deduped, ordered by Atlas
940
940
  # ranking). Strings only — autocomplete operates on text.
941
941
  suggestions: { type: "array", items: { type: "string" } },
942
- count: { type: "integer", minimum: 0 },
942
+ count: { type: "integer", minimum: 0 },
943
943
  # Full matching Parse objects, projected through the class
944
944
  # agent_fields allowlist.
945
945
  results: {
@@ -968,29 +968,29 @@ module Parse
968
968
  type: "object",
969
969
  properties: {
970
970
  class_name: { type: "string" },
971
- query: { type: "string", description: "Optional. Search query text; pass empty for match-all." },
972
- facets: {
971
+ query: { type: "string", description: "Optional. Search query text; pass empty for match-all." },
972
+ facets: {
973
973
  type: "object",
974
974
  description: "Facet definitions keyed by facet name. Each value is " \
975
975
  "{ type: 'string'|'number'|'date', path: <field>, boundaries?: [...] }. " \
976
976
  "Paths must be in agent_fields allowlist when one is declared.",
977
977
  },
978
- limit: {
978
+ limit: {
979
979
  type: "integer",
980
980
  description: "Optional. Max documents in the result list (NOT bucket counts), default 10, max 20.",
981
981
  },
982
982
  apply_canonical_filter: { type: "boolean",
983
- description: "Default true. When the class declares an " \
984
- "agent_canonical_filter, this tool refuses by default. " \
985
- "Pass false to acknowledge that $searchMeta bucket counts " \
986
- "WILL include rows the canonical filter normally hides." },
983
+ description: "Default true. When the class declares an " \
984
+ "agent_canonical_filter, this tool refuses by default. " \
985
+ "Pass false to acknowledge that $searchMeta bucket counts " \
986
+ "WILL include rows the canonical filter normally hides." },
987
987
  },
988
988
  required: %w[class_name facets],
989
989
  },
990
990
  output_schema: {
991
991
  type: "object",
992
992
  properties: {
993
- class_name: { type: "string" },
993
+ class_name: { type: "string" },
994
994
  # $searchMeta lower-bound count. May be approximate for very
995
995
  # large corpora — Atlas documents this; downstream clients
996
996
  # should treat it as informative, not a precise total.
@@ -1013,7 +1013,7 @@ module Parse
1013
1013
  required: %w[buckets],
1014
1014
  },
1015
1015
  },
1016
- count: { type: "integer", minimum: 0 },
1016
+ count: { type: "integer", minimum: 0 },
1017
1017
  results: {
1018
1018
  type: "array",
1019
1019
  items: { type: "object", additionalProperties: true },
@@ -1030,12 +1030,12 @@ module Parse
1030
1030
  # `category:` value via `Tools.register(category: "...")`; the
1031
1031
  # default for un-categorized registrations is "custom".
1032
1032
  BUILTIN_CATEGORIES = {
1033
- "schema" => "Class introspection — discover available classes, fields, indexes, and permissions.",
1034
- "query" => "Read-only data access — fetch records, counts, samples, and execution plans.",
1033
+ "schema" => "Class introspection — discover available classes, fields, indexes, and permissions.",
1034
+ "query" => "Read-only data access — fetch records, counts, samples, and execution plans.",
1035
1035
  "aggregate" => "MongoDB aggregation pipelines for grouping, statistics, and joins.",
1036
- "mutation" => "Domain-action methods declared via agent_method.",
1037
- "export" => "Bulk data export in CSV, Markdown, or fixed-width text.",
1038
- "custom" => "Application-registered tools not assigned to a built-in category.",
1036
+ "mutation" => "Domain-action methods declared via agent_method.",
1037
+ "export" => "Bulk data export in CSV, Markdown, or fixed-width text.",
1038
+ "custom" => "Application-registered tools not assigned to a built-in category.",
1039
1039
  }.freeze
1040
1040
 
1041
1041
  # ============================================================
@@ -1173,12 +1173,12 @@ module Parse
1173
1173
 
1174
1174
  REGISTRY_MUTEX.synchronize do
1175
1175
  @registry[sym] = {
1176
- definition: definition,
1177
- permission: permission,
1178
- timeout: timeout.to_i,
1179
- handler: handler,
1176
+ definition: definition,
1177
+ permission: permission,
1178
+ timeout: timeout.to_i,
1179
+ handler: handler,
1180
1180
  output_schema: output_schema,
1181
- client_safe: client_safe == true,
1181
+ client_safe: client_safe == true,
1182
1182
  }
1183
1183
  end
1184
1184
  notify_subscribers
@@ -1496,7 +1496,7 @@ module Parse
1496
1496
  # leading-substring filter. Both nil/empty means no filter.
1497
1497
  if names.respond_to?(:any?) && names.any?
1498
1498
  name_set = names.map(&:to_s)
1499
- schemas = schemas.select { |s| name_set.include?(s["className"]) }
1499
+ schemas = schemas.select { |s| name_set.include?(s["className"]) }
1500
1500
  end
1501
1501
  if prefix.is_a?(String) && !prefix.empty?
1502
1502
  schemas = schemas.select { |s| s["className"].to_s.start_with?(prefix) }
@@ -1610,7 +1610,7 @@ module Parse
1610
1610
  raise Parse::Agent::AccessDenied.new(
1611
1611
  class_name,
1612
1612
  "Class '#{class_name}' is outside this agent's classes: allowlist",
1613
- kind: :class_filter
1613
+ kind: :class_filter,
1614
1614
  )
1615
1615
  end
1616
1616
 
@@ -1633,6 +1633,7 @@ module Parse
1633
1633
  end
1634
1634
  end
1635
1635
  end
1636
+
1636
1637
  module_function :assert_class_accessible!
1637
1638
 
1638
1639
  # NEW-TOOLS-9: validate an object_id argument format. objectIds in Parse
@@ -1648,6 +1649,7 @@ module Parse
1648
1649
  "Must be 1-32 characters of letters, digits, hyphens, or underscores."
1649
1650
  end
1650
1651
  end
1652
+
1651
1653
  module_function :assert_object_id!
1652
1654
 
1653
1655
  # NEW-TOOLS-9: validate a method_name argument format.
@@ -1662,6 +1664,7 @@ module Parse
1662
1664
  "letters, digits, and underscores (max 128 chars, optional !/?/= suffix)."
1663
1665
  end
1664
1666
  end
1667
+
1665
1668
  module_function :assert_method_name!
1666
1669
 
1667
1670
  # Resolve the effective tenant scope for a class and agent.
@@ -1678,6 +1681,7 @@ module Parse
1678
1681
  def resolve_tenant_scope!(agent, class_name)
1679
1682
  Parse::Agent::MetadataRegistry.resolve_tenant_scope(class_name, agent)
1680
1683
  end
1684
+
1681
1685
  module_function :resolve_tenant_scope!
1682
1686
 
1683
1687
  # Merge tenant scope into a caller-supplied `where:` hash (or nil).
@@ -1697,8 +1701,8 @@ module Parse
1697
1701
  # @raise [Parse::Agent::AccessDenied]
1698
1702
  def apply_tenant_scope_to_where(where, scope, class_name)
1699
1703
  return where unless scope
1700
- field = scope[:field]
1701
- value = scope[:value]
1704
+ field = scope[:field]
1705
+ value = scope[:value]
1702
1706
  field_str = field.to_s
1703
1707
  field_sym = field.to_sym
1704
1708
  # Also check the camelCase wire form (e.g. org_id -> orgId) because an LLM
@@ -1712,10 +1716,10 @@ module Parse
1712
1716
 
1713
1717
  where_h = (where || {})
1714
1718
  # Collect candidate values from all four key forms (snake str/sym, camel str/sym).
1715
- candidate_keys = [field_str, field_sym, camel_str, camel_sym]
1716
- present_keys = candidate_keys.select { |k| where_h.key?(k) }
1717
- caller_value = present_keys.any? ? where_h[present_keys.first] : nil
1718
- field_present = present_keys.any?
1719
+ candidate_keys = [field_str, field_sym, camel_str, camel_sym]
1720
+ present_keys = candidate_keys.select { |k| where_h.key?(k) }
1721
+ caller_value = present_keys.any? ? where_h[present_keys.first] : nil
1722
+ field_present = present_keys.any?
1719
1723
 
1720
1724
  if !field_present
1721
1725
  # Case 1: field absent in any form — inject using snake_case string key.
@@ -1731,6 +1735,7 @@ module Parse
1731
1735
  )
1732
1736
  end
1733
1737
  end
1738
+
1734
1739
  module_function :apply_tenant_scope_to_where
1735
1740
 
1736
1741
  # Prepend a $match tenant scope stage at index 0 of an aggregation pipeline.
@@ -1747,10 +1752,11 @@ module Parse
1747
1752
  # as Parse Server's stored field names). Camelize the scope field so the
1748
1753
  # prepended $match stage is structurally equivalent to what the LLM would
1749
1754
  # write when querying by the field directly.
1750
- field = scope[:field].to_s
1751
- wire_key = field.gsub(/_([a-z])/) { Regexp.last_match(1).upcase }
1755
+ field = scope[:field].to_s
1756
+ wire_key = field.gsub(/_([a-z])/) { Regexp.last_match(1).upcase }
1752
1757
  [{ "$match" => { wire_key => scope[:value] } }] + pipeline
1753
1758
  end
1759
+
1754
1760
  module_function :apply_tenant_scope_to_pipeline
1755
1761
 
1756
1762
  # Default-deny joins under an active tenant scope. A field-based
@@ -1787,6 +1793,7 @@ module Parse
1787
1793
  )
1788
1794
  end
1789
1795
  end
1796
+
1790
1797
  module_function :assert_joins_tenant_safe!
1791
1798
 
1792
1799
  # Yield each join-target collection name found in `$lookup` /
@@ -1824,6 +1831,7 @@ module Parse
1824
1831
  end
1825
1832
  end
1826
1833
  end
1834
+
1827
1835
  module_function :each_join_target
1828
1836
 
1829
1837
  # === Per-agent filter and canonical filter helpers ===
@@ -1863,6 +1871,7 @@ module Parse
1863
1871
  per_agent = agent && agent.respond_to?(:filter_for) ? agent.filter_for(class_name) : nil
1864
1872
  compose_filter_into_where(where, per_agent, class_name, helper_name: "apply_per_agent_filter_to_where")
1865
1873
  end
1874
+
1866
1875
  module_function :apply_per_agent_filter_to_where
1867
1876
 
1868
1877
  # Merge the class's canonical "valid state" filter (declared via
@@ -1881,6 +1890,7 @@ module Parse
1881
1890
  canonical = Parse::Agent::MetadataRegistry.canonical_filter(class_name)
1882
1891
  compose_filter_into_where(where, canonical, class_name, helper_name: "apply_canonical_filter_to_where")
1883
1892
  end
1893
+
1884
1894
  module_function :apply_canonical_filter_to_where
1885
1895
 
1886
1896
  # @api private
@@ -1908,6 +1918,7 @@ module Parse
1908
1918
  "got #{where.class}"
1909
1919
  end
1910
1920
  end
1921
+
1911
1922
  module_function :compose_filter_into_where
1912
1923
 
1913
1924
  # Prepend the per-agent per-class filter (declared via
@@ -1919,6 +1930,7 @@ module Parse
1919
1930
  per_agent = agent && agent.respond_to?(:filter_for) ? agent.filter_for(class_name) : nil
1920
1931
  compose_filter_into_pipeline(pipeline, per_agent)
1921
1932
  end
1933
+
1922
1934
  module_function :apply_per_agent_filter_to_pipeline
1923
1935
 
1924
1936
  # Prepend the class's canonical filter as a `$match` stage.
@@ -1930,6 +1942,7 @@ module Parse
1930
1942
  canonical = Parse::Agent::MetadataRegistry.canonical_filter(class_name)
1931
1943
  compose_filter_into_pipeline(pipeline, canonical)
1932
1944
  end
1945
+
1933
1946
  module_function :apply_canonical_filter_to_pipeline
1934
1947
 
1935
1948
  # @api private
@@ -1945,6 +1958,7 @@ module Parse
1945
1958
  [match_stage] + pipeline
1946
1959
  end
1947
1960
  end
1961
+
1948
1962
  module_function :compose_filter_into_pipeline
1949
1963
 
1950
1964
  # Verify that a fetched record's scope field matches the bound scope value.
@@ -1956,8 +1970,8 @@ module Parse
1956
1970
  # @raise [Parse::Agent::AccessDenied]
1957
1971
  def assert_record_in_tenant_scope!(record, scope, class_name)
1958
1972
  return unless scope
1959
- field = scope[:field].to_s
1960
- value = scope[:value]
1973
+ field = scope[:field].to_s
1974
+ value = scope[:value]
1961
1975
  # Parse Server returns camelCase field names on the wire (e.g. orgId for
1962
1976
  # the Ruby field org_id). A mongo-direct hit (semantic_search's raw
1963
1977
  # $vectorSearch path) instead carries the field under its STORAGE column
@@ -1972,9 +1986,9 @@ module Parse
1972
1986
  camel_field = field.gsub(/_([a-z])/) { Regexp.last_match(1).upcase }
1973
1987
  # Assign unconditionally (the modifier-if is the RHS, yielding nil when
1974
1988
  # false) so neither local is ever read before initialization.
1975
- klass = (Parse::Model.find_class(class_name) if defined?(Parse::Model))
1976
- mapped = (klass.field_map[field.to_sym].to_s if klass.respond_to?(:field_map))
1977
- rec_value = if record.is_a?(Hash)
1989
+ klass = (Parse::Model.find_class(class_name) if defined?(Parse::Model))
1990
+ mapped = (klass.field_map[field.to_sym].to_s if klass.respond_to?(:field_map))
1991
+ rec_value = if record.is_a?(Hash)
1978
1992
  keys = [field, camel_field]
1979
1993
  keys << mapped if mapped && !mapped.empty?
1980
1994
  found = keys.find { |k| record.key?(k) }
@@ -1987,6 +2001,7 @@ module Parse
1987
2001
  )
1988
2002
  end
1989
2003
  end
2004
+
1990
2005
  module_function :assert_record_in_tenant_scope!
1991
2006
 
1992
2007
  # Walk an aggregation pipeline and enforce two boundaries that
@@ -2027,6 +2042,7 @@ module Parse
2027
2042
  agent: agent,
2028
2043
  )
2029
2044
  end
2045
+
2030
2046
  module_function :enforce_pipeline_access_policy!
2031
2047
 
2032
2048
  # @api private
@@ -2039,6 +2055,7 @@ module Parse
2039
2055
  return nil unless allowlist && allowlist.any?
2040
2056
  allowlist.map(&:to_s) | Parse::Agent::MetadataRegistry::ALWAYS_KEEP_FIELDS
2041
2057
  end
2058
+
2042
2059
  module_function :compute_source_allowlist_for
2043
2060
 
2044
2061
  # @api private
@@ -2072,6 +2089,7 @@ module Parse
2072
2089
  end
2073
2090
  end
2074
2091
  end
2092
+
2075
2093
  module_function :walk_pipeline_with_state!
2076
2094
 
2077
2095
  # @api private
@@ -2087,6 +2105,7 @@ module Parse
2087
2105
  set |= available
2088
2106
  set
2089
2107
  end
2108
+
2090
2109
  module_function :effective_permitted_set
2091
2110
 
2092
2111
  # @api private
@@ -2185,6 +2204,7 @@ module Parse
2185
2204
  [[], false]
2186
2205
  end
2187
2206
  end
2207
+
2188
2208
  module_function :stage_field_delta
2189
2209
 
2190
2210
  # @api private
@@ -2193,6 +2213,7 @@ module Parse
2193
2213
  def keys_excluding_operators(hash)
2194
2214
  hash.keys.map(&:to_s).reject { |k| k.empty? || k.start_with?("$") }
2195
2215
  end
2216
+
2196
2217
  module_function :keys_excluding_operators
2197
2218
 
2198
2219
  # @api private
@@ -2203,18 +2224,21 @@ module Parse
2203
2224
  def root_keys_excluding_operators(hash)
2204
2225
  keys_excluding_operators(hash).map { |k| k.split(".").first }.uniq
2205
2226
  end
2227
+
2206
2228
  module_function :root_keys_excluding_operators
2207
2229
 
2208
2230
  # @api private
2209
2231
  def projection_is_inclusion?(expr)
2210
2232
  expr == 1 || expr == true
2211
2233
  end
2234
+
2212
2235
  module_function :projection_is_inclusion?
2213
2236
 
2214
2237
  # @api private
2215
2238
  def projection_is_exclusion?(expr)
2216
2239
  expr == 0 || expr == false
2217
2240
  end
2241
+
2218
2242
  module_function :projection_is_exclusion?
2219
2243
 
2220
2244
  # @api private
@@ -2225,6 +2249,7 @@ module Parse
2225
2249
  def project_is_exclusion_only?(value)
2226
2250
  value.any? && value.values.all? { |v| projection_is_exclusion?(v) }
2227
2251
  end
2252
+
2228
2253
  module_function :project_is_exclusion_only?
2229
2254
 
2230
2255
  # @api private
@@ -2236,6 +2261,7 @@ module Parse
2236
2261
  included = value.reject { |_, v| projection_is_exclusion?(v) }
2237
2262
  root_keys_excluding_operators(included)
2238
2263
  end
2264
+
2239
2265
  module_function :project_introduced_roots
2240
2266
 
2241
2267
  # @api private
@@ -2255,6 +2281,7 @@ module Parse
2255
2281
  )
2256
2282
  end
2257
2283
  end
2284
+
2258
2285
  module_function :assert_output_key_not_internal!
2259
2286
 
2260
2287
  # @api private
@@ -2269,8 +2296,8 @@ module Parse
2269
2296
  # that pass only `permitted_fields:` (the existing single-stage
2270
2297
  # test surface).
2271
2298
  def walk_pipeline_stage!(stage, permitted_fields:, agent: nil,
2272
- source_permitted: nil, available: [],
2273
- source_addressable: true)
2299
+ source_permitted: nil, available: [],
2300
+ source_addressable: true)
2274
2301
  return unless stage.is_a?(Hash)
2275
2302
  stage.each do |op, value|
2276
2303
  case op.to_s
@@ -2279,8 +2306,7 @@ module Parse
2279
2306
  # `{ "$unionWith" => "Collection" }`. Extract that target too,
2280
2307
  # otherwise the underscore denylist, hidden?, class-filter
2281
2308
  # allowlist, and CLP-find gates below silently skip it.
2282
- target =
2283
- if value.is_a?(Hash)
2309
+ target = if value.is_a?(Hash)
2284
2310
  value["from"] || value[:from] || value["coll"] || value[:coll]
2285
2311
  elsif value.is_a?(String)
2286
2312
  value
@@ -2310,7 +2336,7 @@ module Parse
2310
2336
  raise Parse::Agent::AccessDenied.new(
2311
2337
  target_str,
2312
2338
  "Pipeline target '#{target_str}' is outside this agent's classes: allowlist",
2313
- kind: :class_filter
2339
+ kind: :class_filter,
2314
2340
  )
2315
2341
  end
2316
2342
  # CLP gate for joined classes. A $lookup / $graphLookup /
@@ -2474,6 +2500,7 @@ module Parse
2474
2500
  end
2475
2501
  end
2476
2502
  end
2503
+
2477
2504
  module_function :walk_pipeline_stage!
2478
2505
 
2479
2506
  # @api private
@@ -2505,6 +2532,7 @@ module Parse
2505
2532
  end
2506
2533
  end
2507
2534
  end
2535
+
2508
2536
  module_function :check_match_keys_for_restricted_fields!
2509
2537
 
2510
2538
  # @api private
@@ -2526,6 +2554,7 @@ module Parse
2526
2554
  expr.each_value { |v| check_expression_for_restricted_fields!(v, permitted_fields) }
2527
2555
  end
2528
2556
  end
2557
+
2529
2558
  module_function :check_expression_for_restricted_fields!
2530
2559
 
2531
2560
  # @api private
@@ -2551,7 +2580,7 @@ module Parse
2551
2580
  msg = +"#{context} '#{fname}' (#{root.inspect}) outside agent_fields allowlist"
2552
2581
  if permitted_fields.is_a?(Array) && permitted_fields.any?
2553
2582
  preview = permitted_fields.first(ALLOWLIST_PREVIEW_CAP)
2554
- suffix = permitted_fields.size > ALLOWLIST_PREVIEW_CAP ?
2583
+ suffix = permitted_fields.size > ALLOWLIST_PREVIEW_CAP ?
2555
2584
  " (+#{permitted_fields.size - ALLOWLIST_PREVIEW_CAP} more)" : ""
2556
2585
  msg << ". Allowed: #{preview.join(", ")}#{suffix}"
2557
2586
  end
@@ -2568,13 +2597,14 @@ module Parse
2568
2597
  end
2569
2598
  end
2570
2599
  {
2571
- message: msg,
2572
- kind: kind,
2573
- denied_field: root.is_a?(String) ? root : root.to_s,
2574
- allowed_fields: permitted_fields.is_a?(Array) ? permitted_fields.first(ALLOWLIST_PREVIEW_CAP).map(&:to_s) : nil,
2600
+ message: msg,
2601
+ kind: kind,
2602
+ denied_field: root.is_a?(String) ? root : root.to_s,
2603
+ allowed_fields: permitted_fields.is_a?(Array) ? permitted_fields.first(ALLOWLIST_PREVIEW_CAP).map(&:to_s) : nil,
2575
2604
  suggested_rewrite: suggested,
2576
2605
  }
2577
2606
  end
2607
+
2578
2608
  module_function :build_allowlist_refusal
2579
2609
 
2580
2610
  # @api private
@@ -2585,12 +2615,13 @@ module Parse
2585
2615
  info = build_allowlist_refusal(context, fname, root, permitted_fields)
2586
2616
  raise Parse::Agent::AccessDenied.new(
2587
2617
  nil, info[:message],
2588
- kind: info[:kind],
2589
- denied_field: info[:denied_field],
2590
- allowed_fields: info[:allowed_fields],
2618
+ kind: info[:kind],
2619
+ denied_field: info[:denied_field],
2620
+ allowed_fields: info[:allowed_fields],
2591
2621
  suggested_rewrite: info[:suggested_rewrite],
2592
2622
  )
2593
2623
  end
2624
+
2594
2625
  module_function :raise_allowlist_refusal!
2595
2626
 
2596
2627
  # Resolve each dotted `include:` path through belongs_to / has_one
@@ -2613,6 +2644,7 @@ module Parse
2613
2644
  walk_pointer_path!(klass, path.to_s.split("."), agent: agent)
2614
2645
  end
2615
2646
  end
2647
+
2616
2648
  module_function :assert_include_paths_accessible!
2617
2649
 
2618
2650
  # Auto-projection for `keys: + include:`. When the caller passed a
@@ -2709,6 +2741,7 @@ module Parse
2709
2741
  effective = (keys_str | appended)
2710
2742
  { effective_keys: effective, truncated: truncated }
2711
2743
  end
2744
+
2712
2745
  module_function :apply_include_projection
2713
2746
 
2714
2747
  # @api private
@@ -2718,11 +2751,12 @@ module Parse
2718
2751
  def resolve_pointer_target(parent_klass, pointer_field)
2719
2752
  refs = parent_klass.references
2720
2753
  return nil unless refs
2721
- seg_str = pointer_field.to_s
2754
+ seg_str = pointer_field.to_s
2722
2755
  camel_str = seg_str.include?("_") ? seg_str.gsub(/_([a-z])/) { Regexp.last_match(1).upcase } : seg_str
2723
2756
  target = refs[seg_str.to_sym] || refs[seg_str] || refs[camel_str.to_sym] || refs[camel_str]
2724
2757
  target&.to_s
2725
2758
  end
2759
+
2726
2760
  module_function :resolve_pointer_target
2727
2761
 
2728
2762
  # @api private
@@ -2734,7 +2768,7 @@ module Parse
2734
2768
  # `references` is keyed by the Parse field name (camelCase). Accept
2735
2769
  # both forms: snake_case as Ruby methods are usually named, and
2736
2770
  # camelCase as it appears on the wire and in the schema.
2737
- seg_str = seg.to_s
2771
+ seg_str = seg.to_s
2738
2772
  camel_str = seg_str.include?("_") ? seg_str.gsub(/_([a-z])/) { Regexp.last_match(1).upcase } : seg_str
2739
2773
  target = refs[seg_str.to_sym] || refs[seg_str] || refs[camel_str.to_sym] || refs[camel_str]
2740
2774
  return unless target
@@ -2754,7 +2788,7 @@ module Parse
2754
2788
  raise Parse::Agent::AccessDenied.new(
2755
2789
  target_name,
2756
2790
  "Pointer-include target '#{target_name}' is outside this agent's classes: allowlist",
2757
- kind: :class_filter
2791
+ kind: :class_filter,
2758
2792
  )
2759
2793
  end
2760
2794
  current = begin
@@ -2765,6 +2799,7 @@ module Parse
2765
2799
  return unless current
2766
2800
  end
2767
2801
  end
2802
+
2768
2803
  module_function :walk_pointer_path!
2769
2804
 
2770
2805
  # Post-fetch defense-in-depth: walk the result data and replace any
@@ -2792,6 +2827,7 @@ module Parse
2792
2827
  # rows. The walker scrubs them post-fetch.
2793
2828
  walk_and_redact(data, hidden, agent: agent)
2794
2829
  end
2830
+
2795
2831
  module_function :redact_hidden_classes!
2796
2832
 
2797
2833
  # Parse-on-Mongo pointer column shape: a string value paired with a
@@ -2863,6 +2899,7 @@ module Parse
2863
2899
  obj
2864
2900
  end
2865
2901
  end
2902
+
2866
2903
  module_function :walk_and_redact
2867
2904
 
2868
2905
  # Compact Parse-on-Mongo storage-form pointer columns.
@@ -2923,6 +2960,7 @@ module Parse
2923
2960
  rewrite_pointer_columns!(data, compressible)
2924
2961
  compressible
2925
2962
  end
2963
+
2926
2964
  module_function :compact_pointers!
2927
2965
 
2928
2966
  # @api private
@@ -2971,6 +3009,7 @@ module Parse
2971
3009
  obj.each { |v| scan_for_pointer_columns(v, acc) }
2972
3010
  end
2973
3011
  end
3012
+
2974
3013
  module_function :scan_for_pointer_columns
2975
3014
 
2976
3015
  # @api private
@@ -3006,6 +3045,7 @@ module Parse
3006
3045
  obj.each { |v| rewrite_pointer_columns!(v, compressible) }
3007
3046
  end
3008
3047
  end
3048
+
3009
3049
  module_function :rewrite_pointer_columns!
3010
3050
 
3011
3051
  # Discovery: return a lightweight catalog of every tool this agent
@@ -3023,13 +3063,13 @@ module Parse
3023
3063
  rows = defs.map do |entry|
3024
3064
  fn = entry[:function] || entry
3025
3065
  {
3026
- name: fn[:name],
3027
- category: fn[:category] || "custom",
3066
+ name: fn[:name],
3067
+ category: fn[:category] || "custom",
3028
3068
  description: fn[:description],
3029
3069
  }
3030
3070
  end
3031
3071
  {
3032
- tools: rows,
3072
+ tools: rows,
3033
3073
  categories: BUILTIN_CATEGORIES,
3034
3074
  }
3035
3075
  end
@@ -3080,6 +3120,7 @@ module Parse
3080
3120
  hidden = reg.respond_to?(:hidden_class_names) ? Array(reg.hidden_class_names) : []
3081
3121
  names.compact.map(&:to_s).uniq - hidden.map(&:to_s)
3082
3122
  end
3123
+
3083
3124
  module_function :known_class_names_for_suggestions
3084
3125
 
3085
3126
  # Up to `limit` known class names within a small edit distance of the
@@ -3096,6 +3137,7 @@ module Parse
3096
3137
  .first(limit)
3097
3138
  .map(&:first)
3098
3139
  end
3140
+
3099
3141
  module_function :suggest_class_names
3100
3142
 
3101
3143
  # Compact iterative Levenshtein distance.
@@ -3113,6 +3155,7 @@ module Parse
3113
3155
  end
3114
3156
  prev[b.length]
3115
3157
  end
3158
+
3116
3159
  module_function :name_edit_distance
3117
3160
 
3118
3161
  # ============================================================
@@ -3148,7 +3191,7 @@ module Parse
3148
3191
  # the effective where (with scope injected) is what everything else sees.
3149
3192
  # TRACK-AGENT-7 split: per-agent filter is UNCONDITIONAL; canonical
3150
3193
  # filter remains LLM-controllable via apply_canonical_filter:.
3151
- scope = resolve_tenant_scope!(agent, class_name)
3194
+ scope = resolve_tenant_scope!(agent, class_name)
3152
3195
  effective_where = apply_tenant_scope_to_where(where, scope, class_name)
3153
3196
  effective_where = apply_per_agent_filter_to_where(effective_where, class_name, agent: agent)
3154
3197
  effective_where = apply_canonical_filter_to_where(effective_where, class_name, agent: agent) if apply_canonical_filter
@@ -3159,7 +3202,6 @@ module Parse
3159
3202
  if effective_where && !effective_where.empty? &&
3160
3203
  Parse::Agent.refuse_collscan? &&
3161
3204
  !MetadataRegistry.allow_collscan?(class_name)
3162
-
3163
3205
  refusal = collscan_preflight(agent, class_name, effective_where)
3164
3206
  return refusal if refusal
3165
3207
  end
@@ -3184,8 +3226,7 @@ module Parse
3184
3226
  validated_keys = validate_keys!(keys)
3185
3227
  allowlist = MetadataRegistry.field_allowlist(class_name)
3186
3228
  caller_keys = validated_keys&.any? ? validated_keys : nil
3187
- effective_keys =
3188
- if allowlist && allowlist.any?
3229
+ effective_keys = if allowlist && allowlist.any?
3189
3230
  permitted = allowlist.map(&:to_s) | MetadataRegistry::ALWAYS_KEEP_FIELDS
3190
3231
  caller_keys ? (caller_keys & permitted) : allowlist.map(&:to_s)
3191
3232
  else
@@ -3228,8 +3269,7 @@ module Parse
3228
3269
  end
3229
3270
 
3230
3271
  with_timeout(:query_class) do
3231
- results =
3232
- if agent.respond_to?(:acl_scope_requires_direct?) && agent.acl_scope_requires_direct?
3272
+ results = if agent.respond_to?(:acl_scope_requires_direct?) && agent.acl_scope_requires_direct?
3233
3273
  # Auto-route through Parse::MongoDB.aggregate so ACLScope's
3234
3274
  # `_rperm` $match injection runs — REST find_objects has
3235
3275
  # no "act as role" affordance for acl_user/acl_role agents.
@@ -3275,23 +3315,24 @@ module Parse
3275
3315
  # standard text-export envelope shape.
3276
3316
  def format_query_results_as(format, class_name, results)
3277
3317
  col_specs = results.any? ? infer_export_columns_from(results.first) : []
3278
- headers = col_specs.map { |s| s[:header] }
3279
- rows = results.map do |obj|
3318
+ headers = col_specs.map { |s| s[:header] }
3319
+ rows = results.map do |obj|
3280
3320
  col_specs.map { |s| stringify_export_value(extract_export_value(obj, s[:path])) }
3281
3321
  end
3282
3322
  output = case format
3283
- when "csv" then format_export_csv(headers, rows)
3323
+ when "csv" then format_export_csv(headers, rows)
3284
3324
  when "markdown" then format_export_markdown(headers, rows)
3285
- when "table" then format_export_text_table(headers, rows)
3325
+ when "table" then format_export_text_table(headers, rows)
3286
3326
  end
3287
3327
  {
3288
3328
  class_name: class_name,
3289
- format: format,
3290
- headers: headers,
3291
- row_count: rows.size,
3292
- output: output,
3329
+ format: format,
3330
+ headers: headers,
3331
+ row_count: rows.size,
3332
+ output: output,
3293
3333
  }
3294
3334
  end
3335
+
3295
3336
  module_function :format_query_results_as
3296
3337
 
3297
3338
  # Count objects in a Parse class
@@ -3304,7 +3345,7 @@ module Parse
3304
3345
  assert_class_accessible!(class_name, agent: agent, op: :count)
3305
3346
  # Tenant scope enforcement. TRACK-AGENT-7 split: per-agent filter is
3306
3347
  # UNCONDITIONAL, canonical filter is LLM-controllable.
3307
- scope = resolve_tenant_scope!(agent, class_name)
3348
+ scope = resolve_tenant_scope!(agent, class_name)
3308
3349
  effective_where = apply_tenant_scope_to_where(where, scope, class_name)
3309
3350
  effective_where = apply_per_agent_filter_to_where(effective_where, class_name, agent: agent)
3310
3351
  effective_where = apply_canonical_filter_to_where(effective_where, class_name, agent: agent) if apply_canonical_filter
@@ -3317,8 +3358,7 @@ module Parse
3317
3358
  query[:where] = translated_where.to_json
3318
3359
  end
3319
3360
 
3320
- count =
3321
- if agent.respond_to?(:acl_scope_requires_direct?) && agent.acl_scope_requires_direct?
3361
+ count = if agent.respond_to?(:acl_scope_requires_direct?) && agent.acl_scope_requires_direct?
3322
3362
  execute_count_via_direct(agent, class_name, where: translated_where)
3323
3363
  else
3324
3364
  response = agent.client.find_objects(class_name, query, **agent.request_opts)
@@ -3344,7 +3384,7 @@ module Parse
3344
3384
  # @return [Hash] the object data
3345
3385
  # @raise [Parse::Agent::ValidationError] for invalid class_name or object_id
3346
3386
  def get_object(agent, class_name:, object_id:, include: nil,
3347
- apply_canonical_filter: true, **_kwargs)
3387
+ apply_canonical_filter: true, **_kwargs)
3348
3388
  assert_class_accessible!(class_name, agent: agent, op: :get)
3349
3389
  assert_object_id!(object_id)
3350
3390
  # Resolve tenant scope early so we can verify after fetch.
@@ -3395,15 +3435,13 @@ module Parse
3395
3435
  # Compose the objectId match into the filter; a hit returns exactly
3396
3436
  # one row, a filtered-out match returns zero rows (treated as not-
3397
3437
  # found below, identical to the genuine missing-row case).
3398
- combined_where =
3399
- if composed_filter.is_a?(Hash) && composed_filter.key?("$and")
3438
+ combined_where = if composed_filter.is_a?(Hash) && composed_filter.key?("$and")
3400
3439
  { "$and" => composed_filter["$and"] + [{ "objectId" => object_id }] }
3401
3440
  else
3402
3441
  { "$and" => [composed_filter, { "objectId" => object_id }] }
3403
3442
  end
3404
3443
  translated_combined = ConstraintTranslator.translate(combined_where, agent)
3405
- rows =
3406
- if agent.respond_to?(:acl_scope_requires_direct?) && agent.acl_scope_requires_direct?
3444
+ rows = if agent.respond_to?(:acl_scope_requires_direct?) && agent.acl_scope_requires_direct?
3407
3445
  execute_find_via_direct(
3408
3446
  agent, class_name,
3409
3447
  where: translated_combined, limit: 1,
@@ -3461,7 +3499,7 @@ module Parse
3461
3499
  assert_record_in_tenant_scope!(result, scope, class_name)
3462
3500
 
3463
3501
  ResultFormatter.format_object(class_name, result,
3464
- truncated_include_fields: truncated_includes)
3502
+ truncated_include_fields: truncated_includes)
3465
3503
  end
3466
3504
 
3467
3505
  # Batch-fetch multiple Parse objects by id in a single query.
@@ -3475,7 +3513,7 @@ module Parse
3475
3513
  # @raise [Parse::Agent::ValidationError] if class_name invalid, ids not an Array,
3476
3514
  # any id has invalid format, or more than 50 unique ids are requested
3477
3515
  def get_objects(agent, class_name:, ids: nil, include: [],
3478
- apply_canonical_filter: true, **_kwargs)
3516
+ apply_canonical_filter: true, **_kwargs)
3479
3517
  assert_class_accessible!(class_name, agent: agent, op: :get)
3480
3518
  # Resolve tenant scope early — verified post-fetch (oracle-prevention).
3481
3519
  # TRACK-AGENT-1 / TRACK-AGENT-6 / TRACK-AGENT-7 fix: per-agent
@@ -3500,12 +3538,12 @@ module Parse
3500
3538
  # Short-circuit on empty array — no query needed
3501
3539
  if ids.empty?
3502
3540
  return {
3503
- class_name: class_name,
3504
- objects: {},
3505
- missing: [],
3506
- requested: 0,
3507
- found: 0,
3508
- }
3541
+ class_name: class_name,
3542
+ objects: {},
3543
+ missing: [],
3544
+ requested: 0,
3545
+ found: 0,
3546
+ }
3509
3547
  end
3510
3548
 
3511
3549
  unique_ids = ids.uniq
@@ -3533,9 +3571,9 @@ module Parse
3533
3571
  # three layers reach the server in one query. Then route through
3534
3572
  # ConstraintTranslator so snake_case keys are camelized to Parse
3535
3573
  # Server wire format (mirrors count_objects / query_class).
3536
- base_in_where = { "objectId" => { "$in" => unique_ids } }
3537
- composed = apply_per_agent_filter_to_where(base_in_where, class_name, agent: agent)
3538
- composed = apply_canonical_filter_to_where(composed, class_name, agent: agent) if apply_canonical_filter
3574
+ base_in_where = { "objectId" => { "$in" => unique_ids } }
3575
+ composed = apply_per_agent_filter_to_where(base_in_where, class_name, agent: agent)
3576
+ composed = apply_canonical_filter_to_where(composed, class_name, agent: agent) if apply_canonical_filter
3539
3577
  translated_where = ConstraintTranslator.translate(composed, agent)
3540
3578
 
3541
3579
  # Build query
@@ -3560,8 +3598,7 @@ module Parse
3560
3598
  query[:keys] = effective_keys.join(",") if effective_keys&.any?
3561
3599
 
3562
3600
  with_timeout(:get_objects) do
3563
- rows =
3564
- if agent.respond_to?(:acl_scope_requires_direct?) && agent.acl_scope_requires_direct?
3601
+ rows = if agent.respond_to?(:acl_scope_requires_direct?) && agent.acl_scope_requires_direct?
3565
3602
  # Feed the translated (already-composed) where into the
3566
3603
  # direct-route helper. The $in constraint composes with
3567
3604
  # the per-agent / canonical filter via the same $and the
@@ -3657,8 +3694,7 @@ module Parse
3657
3694
  allowlist = MetadataRegistry.field_allowlist(class_name)
3658
3695
  query[:keys] = allowlist.join(",") if allowlist&.any?
3659
3696
 
3660
- rows =
3661
- if agent.respond_to?(:acl_scope_requires_direct?) && agent.acl_scope_requires_direct?
3697
+ rows = if agent.respond_to?(:acl_scope_requires_direct?) && agent.acl_scope_requires_direct?
3662
3698
  execute_find_via_direct(
3663
3699
  agent, class_name,
3664
3700
  where: translated_where, limit: limit, order: "-createdAt",
@@ -3713,7 +3749,7 @@ module Parse
3713
3749
  AGGREGATE_DEFAULT_MONGO_DIRECT = true
3714
3750
 
3715
3751
  def aggregate(agent, class_name:, pipeline:, rewrite_lookups: nil, compact_pointers: true,
3716
- apply_canonical_filter: true, mongo_direct: AGGREGATE_DEFAULT_MONGO_DIRECT,
3752
+ apply_canonical_filter: true, mongo_direct: AGGREGATE_DEFAULT_MONGO_DIRECT,
3717
3753
  **_kwargs)
3718
3754
  assert_class_accessible!(class_name, agent: agent, op: :find)
3719
3755
  # SECURITY: Validate pipeline BEFORE execution.
@@ -3740,12 +3776,12 @@ module Parse
3740
3776
  # Tenant scope enforcement: prepend a $match stage at index 0.
3741
3777
  # Done after pipeline validation so the injected stage doesn't
3742
3778
  # interfere with the validator's denylist walk.
3743
- scope = resolve_tenant_scope!(agent, class_name)
3779
+ scope = resolve_tenant_scope!(agent, class_name)
3744
3780
  # Default-deny joins into tenant-incompatible classes BEFORE the
3745
3781
  # outer $match is prepended (the outer scope can't reach a join
3746
3782
  # sub-pipeline).
3747
3783
  assert_joins_tenant_safe!(pipeline, scope)
3748
- scoped_pipeline = apply_tenant_scope_to_pipeline(pipeline, scope)
3784
+ scoped_pipeline = apply_tenant_scope_to_pipeline(pipeline, scope)
3749
3785
 
3750
3786
  # Per-agent filter (declared via Parse::Agent.new(filters: ...)) is
3751
3787
  # UNCONDITIONAL — TRACK-AGENT-7 split. No LLM kwarg can drop it.
@@ -3773,7 +3809,6 @@ module Parse
3773
3809
  if Parse::Agent.refuse_collscan? &&
3774
3810
  !MetadataRegistry.allow_collscan?(class_name) &&
3775
3811
  (match_stage = effective_pipeline.first&.dig("$match"))&.any?
3776
-
3777
3812
  refusal = collscan_preflight(agent, class_name, match_stage)
3778
3813
  return refusal if refusal
3779
3814
  end
@@ -3876,10 +3911,10 @@ module Parse
3876
3911
  # and the hint text is ~200 bytes on every call.
3877
3912
  if auto_limited && results.size >= AGGREGATE_DEFAULT_LIMIT
3878
3913
  result[:auto_limited] = true
3879
- result[:auto_limit] = AGGREGATE_DEFAULT_LIMIT
3880
- result[:hint] = "Pipeline auto-bounded with $limit:#{AGGREGATE_DEFAULT_LIMIT} (no terminal $limit/$count supplied). " \
3881
- "Add an explicit { \"$limit\": N } stage at the end of your pipeline to control the cap, " \
3882
- "or call count_objects first to size the result before fetching rows."
3914
+ result[:auto_limit] = AGGREGATE_DEFAULT_LIMIT
3915
+ result[:hint] = "Pipeline auto-bounded with $limit:#{AGGREGATE_DEFAULT_LIMIT} (no terminal $limit/$count supplied). " \
3916
+ "Add an explicit { \"$limit\": N } stage at the end of your pipeline to control the cap, " \
3917
+ "or call count_objects first to size the result before fetching rows."
3883
3918
  end
3884
3919
  result
3885
3920
  end
@@ -3898,6 +3933,7 @@ module Parse
3898
3933
  return [pipeline, false] if op == "$limit" || op == "$count"
3899
3934
  [pipeline + [{ "$limit" => AGGREGATE_DEFAULT_LIMIT }], true]
3900
3935
  end
3936
+
3901
3937
  module_function :ensure_aggregate_terminal_limit
3902
3938
 
3903
3939
  # ============================================================
@@ -3908,28 +3944,28 @@ module Parse
3908
3944
  # grouped count (think tags, statuses, customer ids), so it gets
3909
3945
  # a larger ceiling while group_by/group_by_date stay aligned with
3910
3946
  # AGGREGATE_DEFAULT_LIMIT for context-safety.
3911
- GROUP_DEFAULT_LIMIT = 200
3912
- GROUP_MAX_LIMIT = 1000
3947
+ GROUP_DEFAULT_LIMIT = 200
3948
+ GROUP_MAX_LIMIT = 1000
3913
3949
  DISTINCT_DEFAULT_LIMIT = 1000
3914
- DISTINCT_MAX_LIMIT = 5000
3950
+ DISTINCT_MAX_LIMIT = 5000
3915
3951
 
3916
3952
  # Supported aggregation operations for group_by / group_by_date.
3917
3953
  # Maps the LLM-facing name to the MongoDB accumulator operator.
3918
3954
  GROUP_OPERATIONS = {
3919
- "count" => "$sum", # value_field is ignored; accumulator is { $sum: 1 }
3920
- "sum" => "$sum",
3921
- "avg" => "$avg",
3955
+ "count" => "$sum", # value_field is ignored; accumulator is { $sum: 1 }
3956
+ "sum" => "$sum",
3957
+ "avg" => "$avg",
3922
3958
  "average" => "$avg",
3923
- "min" => "$min",
3924
- "max" => "$max",
3959
+ "min" => "$min",
3960
+ "max" => "$max",
3925
3961
  }.freeze
3926
3962
 
3927
3963
  GROUP_DATE_INTERVALS = %w[year month week day hour minute second].freeze
3928
3964
 
3929
3965
  # Group records by a field and aggregate. See TOOL_DEFINITIONS[:group_by].
3930
3966
  def group_by(agent, class_name:, field:, operation: nil, value_field: nil,
3931
- where: nil, flatten_arrays: false, sort: nil, limit: nil,
3932
- dry_run: false, apply_canonical_filter: true, **_kwargs)
3967
+ where: nil, flatten_arrays: false, sort: nil, limit: nil,
3968
+ dry_run: false, apply_canonical_filter: true, **_kwargs)
3933
3969
  assert_class_accessible!(class_name, agent: agent, op: :find)
3934
3970
  validated_field = validate_group_field!(field, name: :field)
3935
3971
  op_key, accumulator = resolve_group_operation!(operation, value_field)
@@ -3961,9 +3997,9 @@ module Parse
3961
3997
  append_sort_limit!(pipeline, sort_choice: sort_choice, cap: cap, default_sort: nil)
3962
3998
 
3963
3999
  return dry_run_envelope(class_name: class_name, pipeline: pipeline, params: {
3964
- field: validated_field, operation: op_key, value_field: value_field,
3965
- flatten_arrays: flatten_arrays, sort: sort_choice, limit: cap,
3966
- }) if dry_run
4000
+ field: validated_field, operation: op_key, value_field: value_field,
4001
+ flatten_arrays: flatten_arrays, sort: sort_choice, limit: cap,
4002
+ }) if dry_run
3967
4003
 
3968
4004
  result = run_aggregation_for_group_tool!(
3969
4005
  agent,
@@ -3995,27 +4031,27 @@ module Parse
3995
4031
  group_count: groups.size,
3996
4032
  groups: groups.map { |k, v| { key: normalize_group_key(k), value: v } },
3997
4033
  }
3998
- envelope[:value_field] = value_field if value_field
3999
- envelope[:pointer_class] = pointer_class if pointer_class
4034
+ envelope[:value_field] = value_field if value_field
4035
+ envelope[:pointer_class] = pointer_class if pointer_class
4000
4036
  envelope[:flatten_arrays] = true if flatten_arrays
4001
- envelope[:sort] = sort_choice if sort_choice
4002
- envelope[:truncated] = true if truncated
4003
- envelope[:limit] = cap
4037
+ envelope[:sort] = sort_choice if sort_choice
4038
+ envelope[:truncated] = true if truncated
4039
+ envelope[:limit] = cap
4004
4040
  envelope
4005
4041
  end
4006
4042
 
4007
4043
  # Group records by a date field bucketed at an interval. See
4008
4044
  # TOOL_DEFINITIONS[:group_by_date].
4009
4045
  def group_by_date(agent, class_name:, field:, interval:, operation: nil,
4010
- value_field: nil, where: nil, timezone: nil, sort: nil,
4011
- limit: nil, dry_run: false, apply_canonical_filter: true, **_kwargs)
4046
+ value_field: nil, where: nil, timezone: nil, sort: nil,
4047
+ limit: nil, dry_run: false, apply_canonical_filter: true, **_kwargs)
4012
4048
  assert_class_accessible!(class_name, agent: agent, op: :find)
4013
- validated_field = validate_group_field!(field, name: :field)
4014
- interval_sym = validate_group_date_interval!(interval)
4049
+ validated_field = validate_group_field!(field, name: :field)
4050
+ interval_sym = validate_group_date_interval!(interval)
4015
4051
  op_key, accumulator = resolve_group_operation!(operation, value_field)
4016
- cap = clamp_group_limit(limit, default: GROUP_DEFAULT_LIMIT, max: GROUP_MAX_LIMIT)
4017
- sort_choice = normalize_group_sort(sort) || "key_asc"
4018
- tz = validate_timezone!(timezone)
4052
+ cap = clamp_group_limit(limit, default: GROUP_DEFAULT_LIMIT, max: GROUP_MAX_LIMIT)
4053
+ sort_choice = normalize_group_sort(sort) || "key_asc"
4054
+ tz = validate_timezone!(timezone)
4019
4055
 
4020
4056
  referenced = [validated_field]
4021
4057
  referenced << validate_group_field!(value_field, name: :value_field) if value_field
@@ -4058,9 +4094,9 @@ module Parse
4058
4094
  append_sort_limit!(pipeline, sort_choice: sort_choice, cap: cap, default_sort: "key_asc")
4059
4095
 
4060
4096
  return dry_run_envelope(class_name: class_name, pipeline: pipeline, params: {
4061
- field: validated_field, interval: interval_sym.to_s, operation: op_key,
4062
- value_field: value_field, timezone: tz, sort: sort_choice, limit: cap,
4063
- }) if dry_run
4097
+ field: validated_field, interval: interval_sym.to_s, operation: op_key,
4098
+ value_field: value_field, timezone: tz, sort: sort_choice, limit: cap,
4099
+ }) if dry_run
4064
4100
 
4065
4101
  result = run_aggregation_for_group_tool!(
4066
4102
  agent,
@@ -4089,25 +4125,25 @@ module Parse
4089
4125
  groups: groups.map { |k, v| { key: k, value: v } },
4090
4126
  }
4091
4127
  envelope[:value_field] = value_field if value_field
4092
- envelope[:timezone] = tz if tz
4093
- envelope[:sort] = sort_choice
4094
- envelope[:truncated] = true if truncated
4095
- envelope[:limit] = cap
4128
+ envelope[:timezone] = tz if tz
4129
+ envelope[:sort] = sort_choice
4130
+ envelope[:truncated] = true if truncated
4131
+ envelope[:limit] = cap
4096
4132
  envelope
4097
4133
  end
4098
4134
 
4099
4135
  # Return distinct values of a field. See TOOL_DEFINITIONS[:distinct].
4100
4136
  def distinct(agent, class_name:, field:, where: nil, sort: nil, limit: nil,
4101
- dry_run: false, apply_canonical_filter: true, **_kwargs)
4137
+ dry_run: false, apply_canonical_filter: true, **_kwargs)
4102
4138
  assert_class_accessible!(class_name, agent: agent, op: :find)
4103
4139
  validated_field = validate_group_field!(field, name: :field)
4104
4140
  cap = clamp_group_limit(limit, default: DISTINCT_DEFAULT_LIMIT, max: DISTINCT_MAX_LIMIT)
4105
4141
  sort_choice = case sort.to_s
4106
- when "asc", "desc" then sort.to_s
4107
- when "", "none", nil then nil
4108
- else raise Parse::Agent::ValidationError,
4109
- "Invalid sort #{sort.inspect}. Must be 'asc' or 'desc'."
4110
- end
4142
+ when "asc", "desc" then sort.to_s
4143
+ when "", "none", nil then nil
4144
+ else raise Parse::Agent::ValidationError,
4145
+ "Invalid sort #{sort.inspect}. Must be 'asc' or 'desc'."
4146
+ end
4111
4147
 
4112
4148
  assert_fields_in_allowlist!(class_name, [validated_field])
4113
4149
  assert_where_fields_in_allowlist!(class_name, where)
@@ -4126,14 +4162,14 @@ module Parse
4126
4162
  # append_sort_limit! (which expects key_*/value_*). Distinct has
4127
4163
  # no value column to sort on.
4128
4164
  wire_sort = case sort_choice
4129
- when "asc" then "key_asc"
4130
- when "desc" then "key_desc"
4131
- end
4165
+ when "asc" then "key_asc"
4166
+ when "desc" then "key_desc"
4167
+ end
4132
4168
  append_sort_limit!(pipeline, sort_choice: wire_sort, cap: cap, default_sort: nil)
4133
4169
 
4134
4170
  return dry_run_envelope(class_name: class_name, pipeline: pipeline, params: {
4135
- field: validated_field, sort: sort_choice, limit: cap,
4136
- }) if dry_run
4171
+ field: validated_field, sort: sort_choice, limit: cap,
4172
+ }) if dry_run
4137
4173
 
4138
4174
  result = run_aggregation_for_group_tool!(
4139
4175
  agent,
@@ -4160,9 +4196,9 @@ module Parse
4160
4196
  values: values,
4161
4197
  }
4162
4198
  envelope[:pointer_class] = pointer_class if pointer_class
4163
- envelope[:sort] = sort_choice if sort_choice
4164
- envelope[:truncated] = true if truncated
4165
- envelope[:limit] = cap
4199
+ envelope[:sort] = sort_choice if sort_choice
4200
+ envelope[:truncated] = true if truncated
4201
+ envelope[:limit] = cap
4166
4202
  envelope
4167
4203
  end
4168
4204
 
@@ -4184,6 +4220,7 @@ module Parse
4184
4220
  end
4185
4221
  s
4186
4222
  end
4223
+
4187
4224
  module_function :validate_group_field!
4188
4225
 
4189
4226
  # @api private
@@ -4192,10 +4229,11 @@ module Parse
4192
4229
  unless GROUP_DATE_INTERVALS.include?(sym.to_s)
4193
4230
  raise Parse::Agent::ValidationError,
4194
4231
  "interval #{interval.inspect} is invalid. Must be one of " \
4195
- "#{GROUP_DATE_INTERVALS.join(', ')}."
4232
+ "#{GROUP_DATE_INTERVALS.join(", ")}."
4196
4233
  end
4197
4234
  sym
4198
4235
  end
4236
+
4199
4237
  module_function :validate_group_date_interval!
4200
4238
 
4201
4239
  # @api private
@@ -4212,6 +4250,7 @@ module Parse
4212
4250
  end
4213
4251
  s
4214
4252
  end
4253
+
4215
4254
  module_function :validate_timezone!
4216
4255
 
4217
4256
  # @api private
@@ -4223,7 +4262,7 @@ module Parse
4223
4262
  unless GROUP_OPERATIONS.key?(op_raw)
4224
4263
  raise Parse::Agent::ValidationError,
4225
4264
  "operation #{operation.inspect} is invalid. Must be one of " \
4226
- "#{GROUP_OPERATIONS.keys.uniq.join(', ')}."
4265
+ "#{GROUP_OPERATIONS.keys.uniq.join(", ")}."
4227
4266
  end
4228
4267
  op_key = op_raw == "average" ? "avg" : op_raw
4229
4268
  if op_key == "count"
@@ -4236,6 +4275,7 @@ module Parse
4236
4275
  [op_key, { GROUP_OPERATIONS[op_raw] => "$__VALUE__" }] # placeholder; substituted by build_group_pipeline
4237
4276
  end
4238
4277
  end
4278
+
4239
4279
  module_function :resolve_group_operation!
4240
4280
 
4241
4281
  # @api private
@@ -4244,10 +4284,11 @@ module Parse
4244
4284
  allowed = %w[value_desc value_asc key_desc key_asc]
4245
4285
  unless allowed.include?(sort.to_s)
4246
4286
  raise Parse::Agent::ValidationError,
4247
- "sort #{sort.inspect} is invalid. Must be one of #{allowed.join(', ')}."
4287
+ "sort #{sort.inspect} is invalid. Must be one of #{allowed.join(", ")}."
4248
4288
  end
4249
4289
  sort.to_s
4250
4290
  end
4291
+
4251
4292
  module_function :normalize_group_sort
4252
4293
 
4253
4294
  # @api private
@@ -4259,6 +4300,7 @@ module Parse
4259
4300
  end
4260
4301
  n
4261
4302
  end
4303
+
4262
4304
  module_function :clamp_group_limit
4263
4305
 
4264
4306
  # @api private
@@ -4273,6 +4315,7 @@ module Parse
4273
4315
  permitted = allowlist.map(&:to_s) | MetadataRegistry::ALWAYS_KEEP_FIELDS
4274
4316
  check_match_keys_for_restricted_fields!(where, permitted)
4275
4317
  end
4318
+
4276
4319
  module_function :assert_where_fields_in_allowlist!
4277
4320
 
4278
4321
  # @api private
@@ -4292,6 +4335,7 @@ module Parse
4292
4335
  end
4293
4336
  end
4294
4337
  end
4338
+
4295
4339
  module_function :assert_fields_in_allowlist!
4296
4340
 
4297
4341
  # @api private
@@ -4316,6 +4360,7 @@ module Parse
4316
4360
  wire
4317
4361
  end
4318
4362
  end
4363
+
4319
4364
  module_function :resolve_aggregation_field
4320
4365
 
4321
4366
  # @api private
@@ -4349,6 +4394,7 @@ module Parse
4349
4394
  pipeline << { "$group" => group_stage }
4350
4395
  pipeline
4351
4396
  end
4397
+
4352
4398
  module_function :build_group_pipeline
4353
4399
 
4354
4400
  # @api private
@@ -4374,17 +4420,18 @@ module Parse
4374
4420
  { "year" => op.call("$year"), "month" => op.call("$month"), "day" => op.call("$dayOfMonth") }
4375
4421
  when :hour
4376
4422
  { "year" => op.call("$year"), "month" => op.call("$month"),
4377
- "day" => op.call("$dayOfMonth"), "hour" => op.call("$hour") }
4423
+ "day" => op.call("$dayOfMonth"), "hour" => op.call("$hour") }
4378
4424
  when :minute
4379
- { "year" => op.call("$year"), "month" => op.call("$month"),
4380
- "day" => op.call("$dayOfMonth"), "hour" => op.call("$hour"),
4425
+ { "year" => op.call("$year"), "month" => op.call("$month"),
4426
+ "day" => op.call("$dayOfMonth"), "hour" => op.call("$hour"),
4381
4427
  "minute" => op.call("$minute") }
4382
4428
  when :second
4383
- { "year" => op.call("$year"), "month" => op.call("$month"),
4384
- "day" => op.call("$dayOfMonth"), "hour" => op.call("$hour"),
4429
+ { "year" => op.call("$year"), "month" => op.call("$month"),
4430
+ "day" => op.call("$dayOfMonth"), "hour" => op.call("$hour"),
4385
4431
  "minute" => op.call("$minute"), "second" => op.call("$second") }
4386
4432
  end
4387
4433
  end
4434
+
4388
4435
  module_function :build_date_group_expression
4389
4436
 
4390
4437
  # @api private
@@ -4398,17 +4445,18 @@ module Parse
4398
4445
  return "null" unless key.is_a?(Hash)
4399
4446
  y, mo, d = key["year"], key["month"], key["day"]
4400
4447
  h, mi, s = key["hour"], key["minute"], key["second"]
4401
- wk = key["week"]
4448
+ wk = key["week"]
4402
4449
  case interval
4403
- when :month then (y.nil? || mo.nil?) ? "null" : sprintf("%04d-%02d", y, mo)
4404
- when :week then (y.nil? || wk.nil?) ? "null" : sprintf("%04d-W%02d", y, wk)
4405
- when :day then (y.nil? || mo.nil? || d.nil?) ? "null" : sprintf("%04d-%02d-%02d", y, mo, d)
4406
- when :hour then (y.nil? || mo.nil? || d.nil? || h.nil?) ? "null" : sprintf("%04d-%02d-%02d %02d:00", y, mo, d, h)
4450
+ when :month then (y.nil? || mo.nil?) ? "null" : sprintf("%04d-%02d", y, mo)
4451
+ when :week then (y.nil? || wk.nil?) ? "null" : sprintf("%04d-W%02d", y, wk)
4452
+ when :day then (y.nil? || mo.nil? || d.nil?) ? "null" : sprintf("%04d-%02d-%02d", y, mo, d)
4453
+ when :hour then (y.nil? || mo.nil? || d.nil? || h.nil?) ? "null" : sprintf("%04d-%02d-%02d %02d:00", y, mo, d, h)
4407
4454
  when :minute then (y.nil? || mo.nil? || d.nil? || h.nil? || mi.nil?) ? "null" : sprintf("%04d-%02d-%02d %02d:%02d", y, mo, d, h, mi)
4408
4455
  when :second then (y.nil? || mo.nil? || d.nil? || h.nil? || mi.nil? || s.nil?) ? "null" : sprintf("%04d-%02d-%02d %02d:%02d:%02d", y, mo, d, h, mi, s)
4409
4456
  else "null"
4410
4457
  end
4411
4458
  end
4459
+
4412
4460
  module_function :format_date_key
4413
4461
 
4414
4462
  # @api private
@@ -4423,7 +4471,7 @@ module Parse
4423
4471
  # - { rows: [...] } on success
4424
4472
  # - { refused: true, reason: ..., ... } when COLLSCAN refused
4425
4473
  def run_aggregation_for_group_tool!(agent, class_name:, pipeline:, tool:,
4426
- apply_canonical_filter: true)
4474
+ apply_canonical_filter: true)
4427
4475
  scope = resolve_tenant_scope!(agent, class_name)
4428
4476
  assert_joins_tenant_safe!(pipeline, scope)
4429
4477
  scoped = apply_tenant_scope_to_pipeline(pipeline, scope)
@@ -4458,12 +4506,11 @@ module Parse
4458
4506
  defined?(Parse::MongoDB) && Parse::MongoDB.enabled?
4459
4507
 
4460
4508
  with_timeout(tool) do
4461
- rows =
4462
- if use_direct
4509
+ rows = if use_direct
4463
4510
  translated = Parse::Query.new(class_name).send(
4464
4511
  :translate_pipeline_for_direct_mongodb, scoped,
4465
4512
  )
4466
- raw_rows = Parse::MongoDB.aggregate(class_name, translated, **agent.acl_scope_kwargs)
4513
+ raw_rows = Parse::MongoDB.aggregate(class_name, translated, **mongo_direct_auth_kwargs(agent))
4467
4514
  raw_rows.map { |raw| Parse::MongoDB.convert_aggregation_document(raw) }
4468
4515
  else
4469
4516
  response = agent.client.aggregate_pipeline(class_name, scoped, **agent.request_opts)
@@ -4474,6 +4521,7 @@ module Parse
4474
4521
  { rows: rows }
4475
4522
  end
4476
4523
  end
4524
+
4477
4525
  module_function :run_aggregation_for_group_tool!
4478
4526
 
4479
4527
  # @api private
@@ -4491,6 +4539,7 @@ module Parse
4491
4539
  rewritten = pairs.map { |k, v| [k.is_a?(String) ? k.sub(/\A#{cls}\$/, "") : k, v] }
4492
4540
  [cls, rewritten]
4493
4541
  end
4542
+
4494
4543
  module_function :extract_pointer_class!
4495
4544
 
4496
4545
  # @api private
@@ -4511,6 +4560,7 @@ module Parse
4511
4560
  redacted_pairs = pairs.map { |_k, v| [nil, v] }
4512
4561
  [nil, redacted_pairs]
4513
4562
  end
4563
+
4514
4564
  module_function :redact_hidden_pointer_groups!
4515
4565
 
4516
4566
  # @api private
@@ -4531,12 +4581,13 @@ module Parse
4531
4581
  effective_sort = sort_choice || default_sort
4532
4582
  if effective_sort
4533
4583
  direction = effective_sort.end_with?("_desc") ? -1 : 1
4534
- key = effective_sort.start_with?("value") ? "value" : "_id"
4584
+ key = effective_sort.start_with?("value") ? "value" : "_id"
4535
4585
  pipeline << { "$sort" => { key => direction } }
4536
4586
  end
4537
4587
  pipeline << { "$limit" => cap + 1 }
4538
4588
  pipeline
4539
4589
  end
4590
+
4540
4591
  module_function :append_sort_limit!
4541
4592
 
4542
4593
  # @api private
@@ -4556,6 +4607,7 @@ module Parse
4556
4607
  "to the aggregate tool (modified as needed) for full pipeline control.",
4557
4608
  }
4558
4609
  end
4610
+
4559
4611
  module_function :dry_run_envelope
4560
4612
 
4561
4613
  # @api private
@@ -4564,12 +4616,13 @@ module Parse
4564
4616
  return pairs if sort_choice.nil?
4565
4617
  case sort_choice
4566
4618
  when "value_desc" then pairs.sort_by { |_, v| -sort_key_numeric(v) }
4567
- when "value_asc" then pairs.sort_by { |_, v| sort_key_numeric(v) }
4568
- when "key_desc" then pairs.sort_by { |k, _| sort_key_for(k) }.reverse
4569
- when "key_asc" then pairs.sort_by { |k, _| sort_key_for(k) }
4619
+ when "value_asc" then pairs.sort_by { |_, v| sort_key_numeric(v) }
4620
+ when "key_desc" then pairs.sort_by { |k, _| sort_key_for(k) }.reverse
4621
+ when "key_asc" then pairs.sort_by { |k, _| sort_key_for(k) }
4570
4622
  else pairs
4571
4623
  end
4572
4624
  end
4625
+
4573
4626
  module_function :sort_groups
4574
4627
 
4575
4628
  # @api private
@@ -4577,24 +4630,26 @@ module Parse
4577
4630
  # last (regardless of direction; callers reverse for desc).
4578
4631
  def sort_key_for(value)
4579
4632
  case value
4580
- when nil then [1, ""]
4581
- when Numeric then [0, value]
4582
- when String then [0, value]
4583
- else [0, value.to_s]
4633
+ when nil then [1, ""]
4634
+ when Numeric then [0, value]
4635
+ when String then [0, value]
4636
+ else [0, value.to_s]
4584
4637
  end
4585
4638
  end
4639
+
4586
4640
  module_function :sort_key_for
4587
4641
 
4588
4642
  # @api private
4589
4643
  def sort_key_numeric(value)
4590
4644
  case value
4591
4645
  when Numeric then value
4592
- when nil then 0
4646
+ when nil then 0
4593
4647
  else
4594
4648
  n = Float(value) rescue 0
4595
4649
  n
4596
4650
  end
4597
4651
  end
4652
+
4598
4653
  module_function :sort_key_numeric
4599
4654
 
4600
4655
  # @api private
@@ -4604,6 +4659,7 @@ module Parse
4604
4659
  def normalize_group_key(key)
4605
4660
  key.nil? ? "null" : key
4606
4661
  end
4662
+
4607
4663
  module_function :normalize_group_key
4608
4664
 
4609
4665
  # ============================================================
@@ -4680,36 +4736,36 @@ module Parse
4680
4736
  end
4681
4737
 
4682
4738
  available_rows = rows.size
4683
- truncated = available_rows > effective_cap
4684
- rows = rows.first(effective_cap) if truncated
4739
+ truncated = available_rows > effective_cap
4740
+ rows = rows.first(effective_cap) if truncated
4685
4741
 
4686
- column_specs = normalize_export_columns(columns, rows.first)
4687
- headers = column_specs.map { |spec| spec[:header] }
4742
+ column_specs = normalize_export_columns(columns, rows.first)
4743
+ headers = column_specs.map { |spec| spec[:header] }
4688
4744
  extracted_rows = rows.map do |row|
4689
4745
  column_specs.map { |spec| stringify_export_value(extract_export_value(row, spec[:path])) }
4690
4746
  end
4691
4747
 
4692
4748
  output = case format_s
4693
- when "csv" then format_export_csv(headers, extracted_rows)
4749
+ when "csv" then format_export_csv(headers, extracted_rows)
4694
4750
  when "markdown" then format_export_markdown(headers, extracted_rows)
4695
- when "table" then format_export_text_table(headers, extracted_rows)
4751
+ when "table" then format_export_text_table(headers, extracted_rows)
4696
4752
  end
4697
4753
 
4698
4754
  result = {
4699
4755
  class_name: class_name,
4700
- format: format_s,
4701
- headers: headers,
4702
- row_count: extracted_rows.size,
4703
- output: output,
4756
+ format: format_s,
4757
+ headers: headers,
4758
+ row_count: extracted_rows.size,
4759
+ output: output,
4704
4760
  }
4705
4761
  if truncated
4706
- result[:truncated] = true
4762
+ result[:truncated] = true
4707
4763
  result[:available_rows] = available_rows
4708
- result[:row_cap] = effective_cap
4709
- result[:hint] = "Output truncated at row_cap=#{effective_cap} of #{available_rows} available rows. " \
4710
- "Narrow with where:/pipeline filters, or set row_cap: explicitly (max #{MAX_EXPORT_ROW_CAP}). " \
4711
- "For full exports of larger sets use the operator-facing rake mcp:tool[export_data,...] " \
4712
- "directly rather than reading the rows back through the LLM."
4764
+ result[:row_cap] = effective_cap
4765
+ result[:hint] = "Output truncated at row_cap=#{effective_cap} of #{available_rows} available rows. " \
4766
+ "Narrow with where:/pipeline filters, or set row_cap: explicitly (max #{MAX_EXPORT_ROW_CAP}). " \
4767
+ "For full exports of larger sets use the operator-facing rake mcp:tool[export_data,...] " \
4768
+ "directly rather than reading the rows back through the LLM."
4713
4769
  end
4714
4770
  result
4715
4771
  end
@@ -4720,7 +4776,7 @@ module Parse
4720
4776
  # query_class returns a ResultFormatter-wrapped hash; we want the raw rows.
4721
4777
  query = {}
4722
4778
  query[:limit] = [limit || Agent::DEFAULT_LIMIT, Agent::MAX_LIMIT].min
4723
- query[:skip] = skip if skip && skip > 0
4779
+ query[:skip] = skip if skip && skip > 0
4724
4780
  query[:order] = order if order
4725
4781
 
4726
4782
  # NEW-TOOLS-5: validate keys: against identifier regex before
@@ -4731,8 +4787,7 @@ module Parse
4731
4787
  validated_keys = validate_keys!(keys)
4732
4788
  allowlist = MetadataRegistry.field_allowlist(class_name)
4733
4789
  caller_keys = validated_keys&.any? ? validated_keys : nil
4734
- effective_keys =
4735
- if allowlist && allowlist.any?
4790
+ effective_keys = if allowlist && allowlist.any?
4736
4791
  permitted = allowlist.map(&:to_s) | MetadataRegistry::ALWAYS_KEEP_FIELDS
4737
4792
  caller_keys ? (caller_keys & permitted) : allowlist.map(&:to_s)
4738
4793
  else
@@ -4783,6 +4838,7 @@ module Parse
4783
4838
 
4784
4839
  redact_hidden_classes!(rows, agent: agent)
4785
4840
  end
4841
+
4786
4842
  module_function :export_via_query
4787
4843
 
4788
4844
  # @api private
@@ -4818,7 +4874,7 @@ module Parse
4818
4874
  translated = Parse::Query.new(class_name).send(
4819
4875
  :translate_pipeline_for_direct_mongodb, effective_pipeline,
4820
4876
  )
4821
- raw_rows = Parse::MongoDB.aggregate(class_name, translated, **agent.acl_scope_kwargs)
4877
+ raw_rows = Parse::MongoDB.aggregate(class_name, translated, **mongo_direct_auth_kwargs(agent))
4822
4878
  rows = raw_rows.map { |raw| Parse::MongoDB.convert_aggregation_document(raw) }
4823
4879
  else
4824
4880
  response = agent.client.aggregate_pipeline(class_name, effective_pipeline, **agent.request_opts)
@@ -4829,6 +4885,7 @@ module Parse
4829
4885
 
4830
4886
  redact_hidden_classes!(rows, agent: agent)
4831
4887
  end
4888
+
4832
4889
  module_function :export_via_aggregate
4833
4890
 
4834
4891
  # @api private
@@ -4882,6 +4939,7 @@ module Parse
4882
4939
  end
4883
4940
  end
4884
4941
  end
4942
+
4885
4943
  module_function :normalize_export_columns
4886
4944
 
4887
4945
  # @api private
@@ -4925,6 +4983,7 @@ module Parse
4925
4983
  end
4926
4984
  s
4927
4985
  end
4986
+
4928
4987
  module_function :validate_export_column_path!
4929
4988
 
4930
4989
  # @api private
@@ -4936,6 +4995,7 @@ module Parse
4936
4995
  { path: k.to_s, header: k.to_s }
4937
4996
  end
4938
4997
  end
4998
+
4939
4999
  module_function :infer_export_columns_from
4940
5000
 
4941
5001
  # @api private
@@ -4952,19 +5012,21 @@ module Parse
4952
5012
  end
4953
5013
  end
4954
5014
  end
5015
+
4955
5016
  module_function :extract_export_value
4956
5017
 
4957
5018
  # @api private
4958
5019
  def stringify_export_value(value)
4959
5020
  case value
4960
- when nil then ""
4961
- when String then value
4962
- when Hash, Array then value.to_json
5021
+ when nil then ""
5022
+ when String then value
5023
+ when Hash, Array then value.to_json
4963
5024
  when Time, DateTime then value.iso8601
4964
- when Date then value.to_s
4965
- else value.to_s
5025
+ when Date then value.to_s
5026
+ else value.to_s
4966
5027
  end
4967
5028
  end
5029
+
4968
5030
  module_function :stringify_export_value
4969
5031
 
4970
5032
  # @api private
@@ -4975,6 +5037,7 @@ module Parse
4975
5037
  rows.each { |r| csv << r }
4976
5038
  end
4977
5039
  end
5040
+
4978
5041
  module_function :format_export_csv
4979
5042
 
4980
5043
  # @api private
@@ -4986,6 +5049,7 @@ module Parse
4986
5049
  rows.each { |r| lines << "| #{r.map { |c| c.to_s.gsub(/\r?\n/, " ").gsub(/([\\|])/, '\\\\\1') }.join(" | ")} |" }
4987
5050
  lines.join("\n")
4988
5051
  end
5052
+
4989
5053
  module_function :format_export_markdown
4990
5054
 
4991
5055
  # @api private
@@ -5000,6 +5064,7 @@ module Parse
5000
5064
  }
5001
5065
  ([sep, fmt.call(headers), sep] + rows.map(&fmt) + [sep]).join("\n")
5002
5066
  end
5067
+
5003
5068
  module_function :format_export_text_table
5004
5069
 
5005
5070
  # Explain a query's execution plan
@@ -5148,11 +5213,10 @@ module Parse
5148
5213
  # remains responsible for any internal queries it runs —
5149
5214
  # this gate is the BOUNDARY check at the method-name level.
5150
5215
  if agent.respond_to?(:acl_permission_strings)
5151
- clp_op =
5152
- case required_perm
5153
- when :admin then :delete
5154
- when :write then :update
5155
- else :find
5216
+ clp_op = case required_perm
5217
+ when :admin then :delete
5218
+ when :write then :update
5219
+ else :find
5156
5220
  end
5157
5221
  perms = agent.acl_permission_strings
5158
5222
  unless Parse::CLPScope.permits?(class_name.to_s, clp_op, perms)
@@ -5257,22 +5321,22 @@ module Parse
5257
5321
  end
5258
5322
  end
5259
5323
  return {
5260
- class_name: class_name,
5261
- method: method_name,
5262
- object_id: object_id,
5263
- dry_run: true,
5264
- supports_real_dry_run: false,
5265
- would_call: {
5266
- class: class_name,
5267
- method: method_name,
5268
- type: method_info[:type]&.to_s,
5269
- object_id: object_id,
5270
- args: preview_args,
5271
- },
5272
- note: "The method '#{class_name}.#{method_name}' did not declare supports_dry_run: true, so no method-side preview is available. " \
5273
- "This response confirms the call would pass the permission/args/object gates the agent enforces; the method body was NOT invoked. " \
5274
- "Remove dry_run to execute the operation for real.",
5275
- }
5324
+ class_name: class_name,
5325
+ method: method_name,
5326
+ object_id: object_id,
5327
+ dry_run: true,
5328
+ supports_real_dry_run: false,
5329
+ would_call: {
5330
+ class: class_name,
5331
+ method: method_name,
5332
+ type: method_info[:type]&.to_s,
5333
+ object_id: object_id,
5334
+ args: preview_args,
5335
+ },
5336
+ note: "The method '#{class_name}.#{method_name}' did not declare supports_dry_run: true, so no method-side preview is available. " \
5337
+ "This response confirms the call would pass the permission/args/object gates the agent enforces; the method body was NOT invoked. " \
5338
+ "Remove dry_run to execute the operation for real.",
5339
+ }
5276
5340
  end
5277
5341
 
5278
5342
  # If the method didn't declare dry-run support and the caller
@@ -5308,8 +5372,7 @@ module Parse
5308
5372
  # Master agents (no token) run unbound by design; acl_user/acl_role
5309
5373
  # write/admin instance methods were already refused above.
5310
5374
  token = agent.respond_to?(:session_token) ? agent.session_token : nil
5311
- result =
5312
- if token.is_a?(String) && !token.strip.empty?
5375
+ result = if token.is_a?(String) && !token.strip.empty?
5313
5376
  Parse.with_session(token) { invoke.call }
5314
5377
  else
5315
5378
  invoke.call
@@ -5554,6 +5617,7 @@ module Parse
5554
5617
  s
5555
5618
  end
5556
5619
  end
5620
+
5557
5621
  module_function :validate_keys!
5558
5622
 
5559
5623
  # Validate an include array (pointer fields to resolve) before forwarding
@@ -5658,6 +5722,7 @@ module Parse
5658
5722
  acc[k] = v if allowed.include?(ks)
5659
5723
  end
5660
5724
  end
5725
+
5661
5726
  module_function :project_object_to_allowlist
5662
5727
 
5663
5728
  # Stamp each row hash with an SDK-added `_source` provenance
@@ -5682,13 +5747,14 @@ module Parse
5682
5747
  next if row.key?("_source") || row.key?(:_source)
5683
5748
  oid = row[id_key] || row[id_key.to_sym]
5684
5749
  row["_source"] = {
5685
- "class" => class_name.to_s,
5686
- "tool" => tool.to_s,
5750
+ "class" => class_name.to_s,
5751
+ "tool" => tool.to_s,
5687
5752
  "object_id" => oid,
5688
5753
  }
5689
5754
  end
5690
5755
  rows
5691
5756
  end
5757
+
5692
5758
  module_function :stamp_source!
5693
5759
 
5694
5760
  # ============================================================
@@ -5743,7 +5809,7 @@ module Parse
5743
5809
  # half can shadow the other.
5744
5810
  effective_filter = compose_atlas_filter(
5745
5811
  filter, class_name, agent: agent,
5746
- apply_canonical_filter: apply_canonical_filter,
5812
+ apply_canonical_filter: apply_canonical_filter,
5747
5813
  )
5748
5814
 
5749
5815
  opts = { limit: limit }.merge(auth)
@@ -5759,7 +5825,7 @@ module Parse
5759
5825
 
5760
5826
  # @api private
5761
5827
  def atlas_autocomplete(agent, class_name:, query:, field:, limit: nil, fuzzy: nil,
5762
- apply_canonical_filter: true, **_kwargs)
5828
+ apply_canonical_filter: true, **_kwargs)
5763
5829
  assert_class_accessible!(class_name, agent: agent, op: :find)
5764
5830
  unless query.is_a?(String) && !query.strip.empty?
5765
5831
  raise Parse::Agent::ValidationError, "query must be a non-empty string"
@@ -5777,7 +5843,7 @@ module Parse
5777
5843
  # a $match stage AFTER the ACL $match (see atlas_search.rb:385-388).
5778
5844
  effective_filter = compose_atlas_filter(
5779
5845
  nil, class_name, agent: agent,
5780
- apply_canonical_filter: apply_canonical_filter,
5846
+ apply_canonical_filter: apply_canonical_filter,
5781
5847
  )
5782
5848
 
5783
5849
  opts = { limit: limit }.merge(auth)
@@ -5792,7 +5858,7 @@ module Parse
5792
5858
 
5793
5859
  # @api private
5794
5860
  def atlas_faceted_search(agent, class_name:, facets:, query: "", limit: nil,
5795
- apply_canonical_filter: true, **_kwargs)
5861
+ apply_canonical_filter: true, **_kwargs)
5796
5862
  assert_class_accessible!(class_name, agent: agent, op: :find)
5797
5863
  # Faceted Atlas Search cannot ACL-filter $searchMeta bucket
5798
5864
  # counts (see Parse::AtlasSearch::FacetedSearchNotACLSafe), so
@@ -5825,7 +5891,7 @@ module Parse
5825
5891
  unless active_filters.empty?
5826
5892
  raise Parse::Agent::AccessDenied.new(
5827
5893
  class_name,
5828
- "atlas_faceted_search cannot enforce #{active_filters.join(' / ')} on " \
5894
+ "atlas_faceted_search cannot enforce #{active_filters.join(" / ")} on " \
5829
5895
  "$searchMeta bucket counts (the matched documents are not in the output " \
5830
5896
  "stream). Use atlas_text_search (which applies these filters via $match) " \
5831
5897
  "or pass apply_canonical_filter: false (and arrange your agent without a " \
@@ -5848,6 +5914,10 @@ module Parse
5848
5914
  result = Parse::AtlasSearch.faceted_search(
5849
5915
  class_name, query.to_s, facets,
5850
5916
  limit: limit, master: true,
5917
+ # Master mode still has to name its application: the binding
5918
+ # guard compares the client, not the posture, and an unnamed
5919
+ # caller is refused once two applications are in play.
5920
+ **agent_client_kwarg(agent),
5851
5921
  )
5852
5922
  format_atlas_faceted_results(class_name, result)
5853
5923
  end
@@ -5871,7 +5941,7 @@ module Parse
5871
5941
  # compatibility with the helper signature (tests may pass it);
5872
5942
  # it's no longer used in the body.
5873
5943
  def atlas_auth_options!(agent, tool: nil)
5874
- agent.acl_scope_kwargs
5944
+ agent.direct_auth_kwargs
5875
5945
  end
5876
5946
 
5877
5947
  # @api private
@@ -5935,7 +6005,7 @@ module Parse
5935
6005
  # @param include [Array<String>, nil] pointer paths to $lookup.
5936
6006
  # @return [Array<Hash>]
5937
6007
  def execute_find_via_direct(agent, class_name, where: nil, limit: nil,
5938
- skip: 0, order: nil, keys: nil, include: nil)
6008
+ skip: 0, order: nil, keys: nil, include: nil)
5939
6009
  q = Parse::Query.new(class_name)
5940
6010
  q.limit(limit) if limit && limit > 0
5941
6011
  q.skip(skip) if skip && skip > 0
@@ -5968,9 +6038,10 @@ module Parse
5968
6038
  pipeline = [translated_match] + pipeline
5969
6039
  end
5970
6040
 
5971
- raw_rows = Parse::MongoDB.aggregate(class_name, pipeline, **agent.acl_scope_kwargs)
6041
+ raw_rows = Parse::MongoDB.aggregate(class_name, pipeline, **mongo_direct_auth_kwargs(agent))
5972
6042
  raw_rows.map { |raw| Parse::MongoDB.convert_aggregation_document(raw) }
5973
6043
  end
6044
+
5974
6045
  module_function :execute_find_via_direct
5975
6046
 
5976
6047
  # @api private
@@ -5991,8 +6062,7 @@ module Parse
5991
6062
  # found") via {#assert_record_in_tenant_scope!}, matching get_object.
5992
6063
  def fetch_call_method_receiver(agent, klass, class_name, object_id)
5993
6064
  scope = resolve_tenant_scope!(agent, class_name)
5994
- result =
5995
- if agent.respond_to?(:acl_scope_requires_direct?) && agent.acl_scope_requires_direct?
6065
+ result = if agent.respond_to?(:acl_scope_requires_direct?) && agent.acl_scope_requires_direct?
5996
6066
  where_id = ConstraintTranslator.translate({ "objectId" => object_id }, agent)
5997
6067
  rows = execute_find_via_direct(agent, class_name, where: where_id, limit: 1)
5998
6068
  rows && rows.first
@@ -6008,6 +6078,7 @@ module Parse
6008
6078
  assert_record_in_tenant_scope!(result, scope, class_name)
6009
6079
  (klass || Parse::Object).build(result, class_name)
6010
6080
  end
6081
+
6011
6082
  module_function :fetch_call_method_receiver
6012
6083
 
6013
6084
  # @api private
@@ -6024,14 +6095,26 @@ module Parse
6024
6095
  pipeline << translated_match
6025
6096
  end
6026
6097
  pipeline << { "$count" => "count" }
6027
- raw_rows = Parse::MongoDB.aggregate(class_name, pipeline, **agent.acl_scope_kwargs)
6098
+ raw_rows = Parse::MongoDB.aggregate(class_name, pipeline, **mongo_direct_auth_kwargs(agent))
6028
6099
  return 0 if raw_rows.empty?
6029
6100
  raw_rows.first["count"] || 0
6030
6101
  end
6102
+
6031
6103
  module_function :execute_count_via_direct
6032
6104
 
6105
+ # @api private
6106
+ # `{ client: agent.client }`, or empty when the agent cannot name one.
6107
+ def agent_client_kwarg(agent)
6108
+ return {} unless agent.respond_to?(:client)
6109
+ client = agent.client
6110
+ client ? { client: client } : {}
6111
+ rescue StandardError
6112
+ {}
6113
+ end
6114
+
6033
6115
  def mongo_direct_auth_kwargs(agent)
6034
- # Single point of truth: delegate to agent.acl_scope_kwargs.
6116
+ # Single point of truth: delegate to agent.direct_auth_kwargs,
6117
+ # which is acl_scope_kwargs plus the agent's own client.
6035
6118
  # The agent emits exactly one of {session_token:}, {acl_user:},
6036
6119
  # {acl_role:}, or {master: true} based on construction. The old
6037
6120
  # session_token-or-master pairing is preserved as the
@@ -6039,7 +6122,16 @@ module Parse
6039
6122
  # acl_user/acl_role scopes also flow through correctly so
6040
6123
  # ACLScope's `_rperm` $match runs on direct mongo aggregations
6041
6124
  # regardless of which identity input the agent was constructed
6042
- # with.
6125
+ # with. Prefers direct_auth_kwargs, which adds the agent's own client
6126
+ # so the read is resolved and checked against that application.
6127
+ #
6128
+ # Falls back for agent-shaped objects that predate it: agents are
6129
+ # duck-typed at this boundary (tests and host applications supply
6130
+ # stand-ins), and requiring a new method of all of them to gain the
6131
+ # client would break every one that has not been updated. Without a
6132
+ # client the read is simply an unidentified caller, which
6133
+ # Parse::MongoDB's guard already models.
6134
+ return agent.direct_auth_kwargs if agent.respond_to?(:direct_auth_kwargs)
6043
6135
  agent.acl_scope_kwargs
6044
6136
  end
6045
6137
 
@@ -6086,8 +6178,8 @@ module Parse
6086
6178
  def compose_atlas_filter(caller_filter, class_name, agent:, apply_canonical_filter: true)
6087
6179
  per_agent = agent && agent.respond_to?(:filter_for) ? agent.filter_for(class_name) : nil
6088
6180
  canonical = apply_canonical_filter ?
6089
- Parse::Agent::MetadataRegistry.canonical_filter(class_name) :
6090
- nil
6181
+ Parse::Agent::MetadataRegistry.canonical_filter(class_name) :
6182
+ nil
6091
6183
 
6092
6184
  parts = []
6093
6185
  parts << per_agent.dup if per_agent && !per_agent.empty?
@@ -6097,9 +6189,10 @@ module Parse
6097
6189
  case parts.size
6098
6190
  when 0 then nil
6099
6191
  when 1 then parts.first
6100
- else { "$and" => parts }
6192
+ else { "$and" => parts }
6101
6193
  end
6102
6194
  end
6195
+
6103
6196
  module_function :compose_atlas_filter
6104
6197
 
6105
6198
  # @api private