parse-stack-next 4.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (178) hide show
  1. checksums.yaml +7 -0
  2. data/.bundle/config +2 -0
  3. data/.env.sample +112 -0
  4. data/.env.test +10 -0
  5. data/.github/workflows/ruby.yml +36 -0
  6. data/.gitignore +49 -0
  7. data/.ruby-version +1 -0
  8. data/.solargraph.yml +22 -0
  9. data/CHANGELOG.md +5816 -0
  10. data/Gemfile +30 -0
  11. data/Gemfile.lock +175 -0
  12. data/LICENSE.txt +23 -0
  13. data/Makefile +63 -0
  14. data/README.md +5655 -0
  15. data/Rakefile +573 -0
  16. data/bin/console +38 -0
  17. data/bin/parse-console +136 -0
  18. data/bin/server +17 -0
  19. data/bin/setup +7 -0
  20. data/config/parse-config.json +12 -0
  21. data/docs/TEST_SERVER.md +271 -0
  22. data/docs/_config.yml +1 -0
  23. data/docs/mcp_guide.md +3484 -0
  24. data/docs/mongodb_direct_guide.md +1348 -0
  25. data/docs/mongodb_index_optimization_guide.md +631 -0
  26. data/examples/transaction_example.rb +219 -0
  27. data/lib/parse/acl_scope.rb +728 -0
  28. data/lib/parse/agent/cancellation_token.rb +80 -0
  29. data/lib/parse/agent/constraint_translator.rb +480 -0
  30. data/lib/parse/agent/describe.rb +420 -0
  31. data/lib/parse/agent/errors.rb +133 -0
  32. data/lib/parse/agent/mcp_client.rb +557 -0
  33. data/lib/parse/agent/mcp_dispatcher.rb +1023 -0
  34. data/lib/parse/agent/mcp_rack_app.rb +1143 -0
  35. data/lib/parse/agent/mcp_server.rb +376 -0
  36. data/lib/parse/agent/metadata_audit.rb +259 -0
  37. data/lib/parse/agent/metadata_dsl.rb +733 -0
  38. data/lib/parse/agent/metadata_registry.rb +794 -0
  39. data/lib/parse/agent/pipeline_validator.rb +82 -0
  40. data/lib/parse/agent/prompts.rb +351 -0
  41. data/lib/parse/agent/rate_limiter.rb +158 -0
  42. data/lib/parse/agent/relation_graph.rb +162 -0
  43. data/lib/parse/agent/result_formatter.rb +453 -0
  44. data/lib/parse/agent/tools.rb +5489 -0
  45. data/lib/parse/agent.rb +3249 -0
  46. data/lib/parse/api/aggregate.rb +79 -0
  47. data/lib/parse/api/all.rb +26 -0
  48. data/lib/parse/api/analytics.rb +18 -0
  49. data/lib/parse/api/batch.rb +33 -0
  50. data/lib/parse/api/cloud_functions.rb +58 -0
  51. data/lib/parse/api/config.rb +125 -0
  52. data/lib/parse/api/files.rb +29 -0
  53. data/lib/parse/api/hooks.rb +117 -0
  54. data/lib/parse/api/objects.rb +146 -0
  55. data/lib/parse/api/path_segment.rb +75 -0
  56. data/lib/parse/api/push.rb +20 -0
  57. data/lib/parse/api/schema.rb +49 -0
  58. data/lib/parse/api/server.rb +50 -0
  59. data/lib/parse/api/sessions.rb +24 -0
  60. data/lib/parse/api/users.rb +250 -0
  61. data/lib/parse/atlas_search/index_manager.rb +353 -0
  62. data/lib/parse/atlas_search/result.rb +204 -0
  63. data/lib/parse/atlas_search/search_builder.rb +604 -0
  64. data/lib/parse/atlas_search/session.rb +253 -0
  65. data/lib/parse/atlas_search.rb +995 -0
  66. data/lib/parse/client/authentication.rb +97 -0
  67. data/lib/parse/client/batch.rb +234 -0
  68. data/lib/parse/client/body_builder.rb +240 -0
  69. data/lib/parse/client/caching.rb +203 -0
  70. data/lib/parse/client/logging.rb +293 -0
  71. data/lib/parse/client/profiling.rb +181 -0
  72. data/lib/parse/client/protocol.rb +91 -0
  73. data/lib/parse/client/request.rb +233 -0
  74. data/lib/parse/client/response.rb +208 -0
  75. data/lib/parse/client.rb +1104 -0
  76. data/lib/parse/clp_scope.rb +361 -0
  77. data/lib/parse/live_query/circuit_breaker.rb +256 -0
  78. data/lib/parse/live_query/client.rb +1001 -0
  79. data/lib/parse/live_query/configuration.rb +224 -0
  80. data/lib/parse/live_query/event.rb +115 -0
  81. data/lib/parse/live_query/event_queue.rb +272 -0
  82. data/lib/parse/live_query/health_monitor.rb +214 -0
  83. data/lib/parse/live_query/logging.rb +149 -0
  84. data/lib/parse/live_query/subscription.rb +294 -0
  85. data/lib/parse/live_query.rb +163 -0
  86. data/lib/parse/lookup_rewriter.rb +445 -0
  87. data/lib/parse/model/acl.rb +968 -0
  88. data/lib/parse/model/associations/belongs_to.rb +275 -0
  89. data/lib/parse/model/associations/collection_proxy.rb +435 -0
  90. data/lib/parse/model/associations/has_many.rb +597 -0
  91. data/lib/parse/model/associations/has_one.rb +158 -0
  92. data/lib/parse/model/associations/pointer_collection_proxy.rb +134 -0
  93. data/lib/parse/model/associations/relation_collection_proxy.rb +177 -0
  94. data/lib/parse/model/bytes.rb +62 -0
  95. data/lib/parse/model/classes/audience.rb +262 -0
  96. data/lib/parse/model/classes/installation.rb +363 -0
  97. data/lib/parse/model/classes/job_schedule.rb +153 -0
  98. data/lib/parse/model/classes/job_status.rb +264 -0
  99. data/lib/parse/model/classes/product.rb +75 -0
  100. data/lib/parse/model/classes/push_status.rb +263 -0
  101. data/lib/parse/model/classes/role.rb +751 -0
  102. data/lib/parse/model/classes/session.rb +201 -0
  103. data/lib/parse/model/classes/user.rb +943 -0
  104. data/lib/parse/model/clp.rb +544 -0
  105. data/lib/parse/model/core/actions.rb +1268 -0
  106. data/lib/parse/model/core/builder.rb +139 -0
  107. data/lib/parse/model/core/create_lock.rb +386 -0
  108. data/lib/parse/model/core/describe.rb +382 -0
  109. data/lib/parse/model/core/enhanced_change_tracking.rb +159 -0
  110. data/lib/parse/model/core/errors.rb +38 -0
  111. data/lib/parse/model/core/fetching.rb +566 -0
  112. data/lib/parse/model/core/field_guards.rb +220 -0
  113. data/lib/parse/model/core/indexing.rb +382 -0
  114. data/lib/parse/model/core/parse_reference.rb +407 -0
  115. data/lib/parse/model/core/properties.rb +809 -0
  116. data/lib/parse/model/core/querying.rb +491 -0
  117. data/lib/parse/model/core/schema.rb +202 -0
  118. data/lib/parse/model/core/search_indexing.rb +174 -0
  119. data/lib/parse/model/date.rb +88 -0
  120. data/lib/parse/model/email.rb +213 -0
  121. data/lib/parse/model/file.rb +527 -0
  122. data/lib/parse/model/geojson.rb +271 -0
  123. data/lib/parse/model/geopoint.rb +261 -0
  124. data/lib/parse/model/model.rb +260 -0
  125. data/lib/parse/model/object.rb +2068 -0
  126. data/lib/parse/model/phone.rb +520 -0
  127. data/lib/parse/model/pointer.rb +443 -0
  128. data/lib/parse/model/polygon.rb +406 -0
  129. data/lib/parse/model/push.rb +975 -0
  130. data/lib/parse/model/shortnames.rb +8 -0
  131. data/lib/parse/model/time_zone.rb +141 -0
  132. data/lib/parse/model/validations/uniqueness_validator.rb +97 -0
  133. data/lib/parse/model/validations.rb +96 -0
  134. data/lib/parse/mongodb.rb +2300 -0
  135. data/lib/parse/pipeline_security.rb +554 -0
  136. data/lib/parse/query/constraint.rb +198 -0
  137. data/lib/parse/query/constraints.rb +3279 -0
  138. data/lib/parse/query/cursor.rb +434 -0
  139. data/lib/parse/query/n_plus_one_detector.rb +445 -0
  140. data/lib/parse/query/operation.rb +104 -0
  141. data/lib/parse/query/ordering.rb +66 -0
  142. data/lib/parse/query.rb +7028 -0
  143. data/lib/parse/schema/index_migrator.rb +291 -0
  144. data/lib/parse/schema/search_index_migrator.rb +289 -0
  145. data/lib/parse/schema.rb +494 -0
  146. data/lib/parse/stack/generators/rails.rb +40 -0
  147. data/lib/parse/stack/generators/templates/model.erb +51 -0
  148. data/lib/parse/stack/generators/templates/model_installation.rb +4 -0
  149. data/lib/parse/stack/generators/templates/model_role.rb +4 -0
  150. data/lib/parse/stack/generators/templates/model_session.rb +4 -0
  151. data/lib/parse/stack/generators/templates/model_user.rb +11 -0
  152. data/lib/parse/stack/generators/templates/parse.rb +12 -0
  153. data/lib/parse/stack/generators/templates/webhooks.rb +10 -0
  154. data/lib/parse/stack/railtie.rb +18 -0
  155. data/lib/parse/stack/tasks.rb +563 -0
  156. data/lib/parse/stack/version.rb +11 -0
  157. data/lib/parse/stack.rb +455 -0
  158. data/lib/parse/two_factor_auth/user_extension.rb +449 -0
  159. data/lib/parse/two_factor_auth.rb +310 -0
  160. data/lib/parse/webhooks/payload.rb +360 -0
  161. data/lib/parse/webhooks/registration.rb +199 -0
  162. data/lib/parse/webhooks/replay_protection.rb +189 -0
  163. data/lib/parse/webhooks.rb +510 -0
  164. data/lib/parse-stack-next.rb +5 -0
  165. data/lib/parse-stack.rb +5 -0
  166. data/parse-stack-next.gemspec +82 -0
  167. data/parse-stack.png +0 -0
  168. data/scripts/debug-ips.js +35 -0
  169. data/scripts/docker/Dockerfile.parse +13 -0
  170. data/scripts/docker/atlas-init.js +284 -0
  171. data/scripts/docker/docker-compose.atlas.yml +76 -0
  172. data/scripts/docker/docker-compose.test.yml +106 -0
  173. data/scripts/docker/mongo-init.js +21 -0
  174. data/scripts/eval_mcp_with_lm_studio.rb +274 -0
  175. data/scripts/start-parse.sh +90 -0
  176. data/scripts/start_mcp_server.rb +78 -0
  177. data/scripts/test_server_connection.rb +82 -0
  178. metadata +377 -0
@@ -0,0 +1,733 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ module Parse
5
+ class Agent
6
+ # DSL module that adds agent metadata capabilities to Parse::Object models.
7
+ # Allows models to self-document with descriptions and expose safe methods
8
+ # to the Parse Agent for LLM interaction.
9
+ #
10
+ # @example Define a model with agent metadata
11
+ # class Team < Parse::Object
12
+ # agent_description "A group of users contributing to a Project"
13
+ #
14
+ # property :name, :string, description: "The team's display name"
15
+ # property :member_count, :integer, description: "Number of active members"
16
+ #
17
+ # agent_method :active_projects, "Returns projects currently in progress"
18
+ # agent_method :member_names, "Returns array of member display names"
19
+ #
20
+ # def self.active_projects
21
+ # Project.query(status: "active")
22
+ # end
23
+ #
24
+ # def member_names
25
+ # members.map(&:display_name)
26
+ # end
27
+ # end
28
+ #
29
+ module MetadataDSL
30
+ def self.included(base)
31
+ base.extend(ClassMethods)
32
+ end
33
+
34
+ module ClassMethods
35
+ # Mark this class as visible to agents.
36
+ # Only classes marked with agent_visible will be included in schema listings.
37
+ # If no classes are marked, all classes are shown (backwards compatible).
38
+ #
39
+ # @example Mark a class as agent-visible
40
+ # class Song < Parse::Object
41
+ # agent_visible
42
+ # agent_description "A music track"
43
+ # end
44
+ #
45
+ # @return [Boolean] true
46
+ def agent_visible
47
+ @agent_visible = true
48
+ Parse::Agent::MetadataRegistry.register_visible_class(self)
49
+ true
50
+ end
51
+
52
+ # Check if this class is marked as visible to agents
53
+ # @return [Boolean]
54
+ def agent_visible?
55
+ @agent_visible == true
56
+ end
57
+
58
+ # Mark this class as hidden from agent tools. Hidden classes are
59
+ # filtered out of `get_all_schemas`, refused by `query_class` /
60
+ # `count_objects` / `get_object` / `get_objects` / `get_sample_objects` /
61
+ # `aggregate` / `explain_query` / `get_schema` with a sanitized
62
+ # `:permission_denied` error response, and excluded from the
63
+ # `RelationGraph` prompt diagram.
64
+ #
65
+ # Unlike `agent_visible` (which is opt-in for diagram-walking only),
66
+ # `agent_hidden` is a hard access denial. Use it for classes that
67
+ # contain PII the agent must never touch — student SSN tables,
68
+ # internal billing records, password reset tokens, etc.
69
+ #
70
+ # Records still exist in the database; only the agent surface is
71
+ # blocked. Direct application code (Parse::Object#query, Parse::MongoDB)
72
+ # is unaffected.
73
+ #
74
+ # @example Hide a PII class from every agent surface
75
+ # class StudentSSN < Parse::Object
76
+ # parse_class "StudentSSN"
77
+ # property :student_name, :string
78
+ # property :ssn, :string
79
+ # agent_hidden
80
+ # end
81
+ #
82
+ # @param except [Symbol, nil] when set to `:master_key`, session-bound
83
+ # agents refuse this class but master-key agents are allowed through.
84
+ # This is the "internal admin tooling can see it, user-facing agents
85
+ # never can" tier — intended for collections like `_Session` where a
86
+ # dev-MCP / customer-support tool may legitimately need read access
87
+ # but no end-user-bound agent ever should. The field-level
88
+ # `INTERNAL_FIELDS_DENYLIST` floor (sessionToken, _hashed_password,
89
+ # etc.) still applies, so even master-key reads cannot exfiltrate
90
+ # credential columns.
91
+ # @return [Boolean] true
92
+ def agent_hidden(except: nil)
93
+ @agent_hidden = true
94
+ @agent_hidden_except = case except
95
+ when nil then nil
96
+ when :master_key, "master_key" then :master_key
97
+ else
98
+ raise ArgumentError,
99
+ "agent_hidden(except:) accepts only :master_key (got #{except.inspect})"
100
+ end
101
+ Parse::Agent::MetadataRegistry.register_hidden_class(self, except: @agent_hidden_except)
102
+ true
103
+ end
104
+
105
+ # Reverse a previous `agent_hidden` declaration on this class. Clears the
106
+ # per-class hidden flag and removes the class from the registry's hidden
107
+ # set so that every agent tool surface treats the class as visible again
108
+ # (subject to the per-tool `agent_fields` allowlist and other policy).
109
+ # The field-level `INTERNAL_FIELDS_DENYLIST` floor still strips
110
+ # credential columns from every response.
111
+ #
112
+ # The intended use is to opt back in to a built-in class that
113
+ # parse-stack marks hidden by default — for example `Parse::Product`,
114
+ # which is hidden in `lib/parse/agent.rb` because the `_Product`
115
+ # collection is a vestigial iOS IAP feature, but an application that
116
+ # actually does use the collection can call:
117
+ #
118
+ # Parse::Product.agent_unhidden
119
+ #
120
+ # at boot time (after `require 'parse/stack'`) to expose it. The same
121
+ # mechanism applies to any application-defined class that was marked
122
+ # `agent_hidden` and needs to be re-enabled for a specific deployment.
123
+ #
124
+ # @return [Boolean] true if a previous `agent_hidden` declaration was
125
+ # actually reversed; false when the class was not hidden to begin
126
+ # with (idempotent no-op). Matches `Hash#delete?`/`Set#delete?`
127
+ # "did anything change" semantics so callers can branch on the
128
+ # return value.
129
+ def agent_unhidden
130
+ was_hidden = @agent_hidden == true
131
+ @agent_hidden = false
132
+ @agent_hidden_except = nil
133
+ Parse::Agent::MetadataRegistry.unregister_hidden_class(self)
134
+ # Only audit on a real state flip — calling `agent_unhidden` on a
135
+ # class that was never hidden is a no-op and shouldn't emit a banner
136
+ # that trains operators to suppress the warning globally.
137
+ if was_hidden && !(defined?(Parse::Agent) && Parse::Agent.respond_to?(:suppress_master_key_warning?) && Parse::Agent.suppress_master_key_warning?)
138
+ warn "[Parse::Agent:SECURITY] #{name} (#{respond_to?(:parse_class) ? parse_class : name}) was marked agent_unhidden — " \
139
+ "this class is now reachable from every agent tool surface (query_class, aggregate, get_schema, etc.). " \
140
+ "Master-key agents bypass per-row ACL/CLP enforcement, so per-class agent_fields / agent_canonical_filter / " \
141
+ "tenant_id are the only remaining access boundary. Credential columns are still stripped by the " \
142
+ "INTERNAL_FIELDS_DENYLIST floor regardless of class visibility. Confirm this is intentional. " \
143
+ "Silence with Parse::Agent.suppress_master_key_warning = true."
144
+ end
145
+ was_hidden
146
+ end
147
+
148
+ # Check if this class is hidden from agent tools.
149
+ # @return [Boolean]
150
+ def agent_hidden?
151
+ @agent_hidden == true
152
+ end
153
+
154
+ # The exception scope a previous `agent_hidden(except: ...)` declared,
155
+ # or nil when the class is unconditionally hidden / not hidden at all.
156
+ # Currently the only supported value is `:master_key`.
157
+ # @return [Symbol, nil]
158
+ def agent_hidden_except
159
+ @agent_hidden_except
160
+ end
161
+
162
+ # Set or get the class-level description for agent context.
163
+ # This description helps LLMs understand what this class represents.
164
+ #
165
+ # @example Set a description
166
+ # agent_description "A music track in the catalog"
167
+ #
168
+ # @example Get the description
169
+ # Song.agent_description # => "A music track in the catalog"
170
+ #
171
+ # @param text [String, nil] the description to set, or nil to get
172
+ # @return [String, nil] the current description
173
+ def agent_description(text = nil)
174
+ if text
175
+ @agent_description = text.to_s.freeze
176
+ else
177
+ @agent_description
178
+ end
179
+ end
180
+
181
+ # Property descriptions are stored in Parse::Properties module.
182
+ # This method is provided there via the `property` DSL with `_description:` option.
183
+ # @see Parse::Properties::ClassMethods#property_descriptions
184
+
185
+ # Declare which fields are surfaced to agent tools for this class.
186
+ # When set, agent schema enrichment trims the field list down to this
187
+ # allowlist (plus the always-on `objectId`/`createdAt`/`updatedAt`), and
188
+ # agent query/fetch tools push the allowlist into the server-side `keys`
189
+ # projection unless the caller passed an explicit `keys:` override.
190
+ # Called without arguments, returns the current allowlist.
191
+ #
192
+ # @example Limit agent visibility to analytics-relevant fields
193
+ # class Team < Parse::Object
194
+ # agent_fields :name, :status, :member_count, :owner
195
+ # end
196
+ #
197
+ # @param names [Array<Symbol, String>] field names to allow
198
+ # @return [Array<Symbol>] the resulting allowlist
199
+ def agent_fields(*names)
200
+ return @agent_field_allowlist ||= [] if names.empty?
201
+ @agent_field_allowlist = names.flatten.map(&:to_sym).freeze
202
+ # If agent_join_fields was declared earlier in the class body, the
203
+ # subset invariant must still hold once agent_fields lands. Re-check
204
+ # so declaration order doesn't matter.
205
+ assert_agent_join_fields_subset!
206
+ @agent_field_allowlist
207
+ end
208
+
209
+ # Read-only accessor for the agent field allowlist.
210
+ # @return [Array<Symbol>] the allowlist (empty if not declared)
211
+ def agent_field_allowlist
212
+ @agent_field_allowlist || []
213
+ end
214
+
215
+ # Declare a narrower projection used when this class shows up as an
216
+ # included pointer on another class's query (`query_class` /
217
+ # `get_object` / `get_objects` / `get_sample_objects` /
218
+ # `export_data` + `include:`). When the agent asks for
219
+ # `keys: ["user", ...] + include: ["user"]`, the SDK auto-rewrites
220
+ # `keys` to dotted paths (`user.firstName, user.email, ...`) so the
221
+ # joined record is projected to exactly the fields listed here.
222
+ #
223
+ # This sits one tier tighter than `agent_fields`. The direct-query
224
+ # allowlist is typically the full "what the agent may see" set;
225
+ # the join-projection list is the narrower "what's interesting when
226
+ # I'm a foreign key" set. Example: `_User` may surface 18 fields on
227
+ # a direct query, but when it's joined onto a `Membership` row the
228
+ # agent usually only needs `firstName`, `lastName`, `email`,
229
+ # `internalTag` — not the `teams[]` pointer array or the
230
+ # `iconImage` presigned URL.
231
+ #
232
+ # **Subset invariant**: when both `agent_fields` and
233
+ # `agent_join_fields` are declared, every entry in
234
+ # `agent_join_fields` MUST also appear in `agent_fields`. The
235
+ # direct-query allowlist is the upper bound on what the agent ever
236
+ # sees; the join list can only tighten that, never widen it.
237
+ # Violations raise `ArgumentError` at class load time. Declaring
238
+ # `agent_join_fields` without `agent_fields` is allowed — it means
239
+ # "no direct-query allowlist, but on a join project to these only."
240
+ #
241
+ # When `agent_join_fields` is NOT declared, the auto-projection
242
+ # falls back to `agent_fields - agent_large_fields` (or, when only
243
+ # `agent_large_fields` is declared, to `field_map.keys -
244
+ # agent_large_fields`). Callers can always opt out per call by
245
+ # passing dotted-path keys (`keys: ["user.iconImage"]`), which
246
+ # signals explicit intent and suppresses auto-expansion for that
247
+ # pointer.
248
+ #
249
+ # @example
250
+ # class Membership < Parse::Object
251
+ # belongs_to :user
252
+ # property :title, :string
253
+ # property :active, :boolean
254
+ # # …
255
+ # end
256
+ #
257
+ # # In the _User reopen / customization:
258
+ # class Parse::User
259
+ # agent_fields :first_name, :last_name, :email, :icon_image,
260
+ # :source_image, :teams, :organizations, :last_active_at,
261
+ # :internal_tag
262
+ # agent_large_fields :icon_image, :source_image
263
+ # agent_join_fields :first_name, :last_name, :email,
264
+ # :last_active_at, :internal_tag
265
+ # end
266
+ #
267
+ # @param names [Array<Symbol, String>] field names to project on join
268
+ # @return [Array<Symbol>] the resulting join-projection list
269
+ def agent_join_fields(*names)
270
+ return @agent_join_field_list ||= [] if names.empty?
271
+ @agent_join_field_list = names.flatten.map(&:to_sym).freeze
272
+ assert_agent_join_fields_subset!
273
+ @agent_join_field_list
274
+ end
275
+
276
+ # Read-only accessor for the agent join-projection list.
277
+ # @return [Array<Symbol>] the list (empty if not declared)
278
+ def agent_join_field_list
279
+ @agent_join_field_list || []
280
+ end
281
+
282
+ # Declare fields known to carry large payloads (full text, embedded
283
+ # documents, base64 blobs, long descriptions). Schema introspection
284
+ # annotates these with `large_field: true` so an LLM client can
285
+ # project them away proactively in its first `query_class` call
286
+ # rather than discovering the size by hitting the dispatcher's
287
+ # response cap. Has no effect on Pointer/Relation type fields —
288
+ # the stored value is a small reference; size only materializes
289
+ # via `include:` resolution, which is a query-time concern.
290
+ # Called without arguments, returns the current list.
291
+ #
292
+ # @example Flag the long-text fields up-front
293
+ # class Article < Parse::Object
294
+ # property :title, :string
295
+ # property :body, :string
296
+ # property :raw_html, :string
297
+ # agent_large_fields :body, :raw_html
298
+ # end
299
+ #
300
+ # @param names [Array<Symbol, String>] field names known to be large
301
+ # @return [Array<Symbol>] the resulting list
302
+ def agent_large_fields(*names)
303
+ return @agent_large_fields ||= [] if names.empty?
304
+ @agent_large_fields = names.flatten.map(&:to_sym).freeze
305
+ end
306
+
307
+ # Read-only accessor for the large-field list.
308
+ # @return [Array<Symbol>] the declared large fields (empty if none)
309
+ def agent_large_field_list
310
+ @agent_large_fields || []
311
+ end
312
+
313
+ # Declare a canonical "valid state" filter for this class that the
314
+ # agent's read tools (`query_class`, `count_objects`, `aggregate`)
315
+ # apply BY DEFAULT to every call. Closes the silently-suspect-
316
+ # counts gap: when a class soft-deletes via `isRemoved`, hides
317
+ # rows via `on_timeline: false`, or has any other always-applied
318
+ # validity predicate, the canonical filter ensures an LLM that
319
+ # drops to raw aggregate doesn't accidentally include the
320
+ # excluded rows.
321
+ #
322
+ # The filter is a MongoDB-style match expression (the same shape
323
+ # `query_class`'s `where:` argument accepts). When applied:
324
+ # - `query_class` / `count_objects`: merged with the caller's
325
+ # `where:` via top-level `$and` so caller constraints
326
+ # compose rather than override.
327
+ # - `aggregate`: prepended as a `$match` stage at index 0
328
+ # (after tenant-scope injection).
329
+ #
330
+ # Callers opt out per call with `apply_canonical_filter: false`.
331
+ # The filter is also surfaced via `get_schema` so an opt-out
332
+ # caller can reproduce it manually.
333
+ #
334
+ # @example
335
+ # class Capture < Parse::Object
336
+ # property :isRemoved, :boolean
337
+ # property :onTimeline, :boolean
338
+ # agent_canonical_filter "isRemoved" => { "$ne" => true },
339
+ # "onTimeline" => true
340
+ # end
341
+ #
342
+ # @param filter [Hash, nil] a where-style hash. Pass nil to
343
+ # read the current value.
344
+ # @return [Hash, nil] the filter, or nil when not declared.
345
+ def agent_canonical_filter(filter = nil)
346
+ return @agent_canonical_filter if filter.nil?
347
+ raise ArgumentError, "agent_canonical_filter expects a Hash, got #{filter.class}" unless filter.is_a?(Hash)
348
+ # Validate at registration time so a developer misconfiguration
349
+ # (e.g. `$where`, `$function`, or an internal-field key) fails at
350
+ # app boot rather than silently bypassing PipelineValidator at
351
+ # request time. The filter is treated like a permissive pipeline
352
+ # node: server-side JS operators and internal-field keys are refused;
353
+ # normal Mongo query operators ($ne, $gt, $exists, etc.) are allowed.
354
+ begin
355
+ Parse::PipelineSecurity.validate_filter!(filter)
356
+ rescue Parse::PipelineSecurity::Error => e
357
+ raise ArgumentError, "agent_canonical_filter rejected: #{e.message}"
358
+ end
359
+ @agent_canonical_filter = filter.transform_keys(&:to_s).freeze
360
+ end
361
+
362
+ # Read-only accessor for the canonical filter.
363
+ # @return [Hash, nil] the filter as String-keyed Hash, or nil
364
+ def agent_canonical_filter_for_apply
365
+ @agent_canonical_filter
366
+ end
367
+
368
+ # Opt this class out of the global COLLSCAN refusal check.
369
+ # Intended for small lookup tables (Roles, Config) where full scans
370
+ # are acceptable and an index is not needed.
371
+ #
372
+ # @example
373
+ # class AppConfig < Parse::Object
374
+ # agent_allow_collscan true
375
+ # end
376
+ #
377
+ # @param value [Boolean] true to allow COLLSCANs for this class
378
+ # @return [Boolean] the current setting
379
+ def agent_allow_collscan(value = nil)
380
+ return @agent_allow_collscan if value.nil?
381
+ @agent_allow_collscan = value == true
382
+ end
383
+
384
+ # Check whether COLLSCANs are explicitly permitted for this class.
385
+ # @return [Boolean]
386
+ def agent_allow_collscan?
387
+ @agent_allow_collscan == true
388
+ end
389
+
390
+ # Class-level analytics usage hint, surfaced inside agent schema output.
391
+ # Distinct from `agent_description` (a short human summary): use this for
392
+ # specific guidance the LLM needs to query the class well — enum values,
393
+ # denormalization caveats, recommended aggregations, etc.
394
+ #
395
+ # @example
396
+ # agent_usage <<~USAGE
397
+ # `status` values: "active" | "archived" | "frozen".
398
+ # `member_count` is denormalized; recompute via _User pointer.
399
+ # USAGE
400
+ #
401
+ # @param text [String, nil] the usage text to set, or nil to read
402
+ # @return [String, nil] the current usage hint
403
+ def agent_usage(text = nil)
404
+ return @agent_usage unless text
405
+ @agent_usage = text.to_s.strip.freeze
406
+ end
407
+
408
+ # Storage hash for agent-allowed methods.
409
+ # Maps method names (symbols) to their metadata hashes.
410
+ #
411
+ # @return [Hash<Symbol, Hash>]
412
+ def agent_methods
413
+ @agent_methods ||= {}
414
+ end
415
+
416
+ # Permission levels for agent methods (matches Parse::Agent permission levels)
417
+ AGENT_METHOD_PERMISSIONS = %i[readonly write admin].freeze
418
+
419
+ # Patterns that suggest a method performs write operations
420
+ # Used to warn developers who may have misclassified a method as readonly
421
+ WRITE_METHOD_PATTERNS = [
422
+ /save/i, /update/i, /delete/i, /destroy/i, /create/i, /remove/i,
423
+ /insert/i, /upsert/i, /modify/i, /set/i, /clear/i, /reset/i,
424
+ /add/i, /append/i, /push/i, /increment/i, /decrement/i,
425
+ ].freeze
426
+
427
+ # Mark a method as callable by the agent with an optional description.
428
+ # Only methods marked with this DSL can be invoked via the `call_method` tool.
429
+ #
430
+ # @example Mark a readonly class method (default)
431
+ # agent_method :find_popular, "Find songs with over 1000 plays"
432
+ #
433
+ # @example Mark an instance method requiring write permission
434
+ # agent_method :update_play_count, "Increment play count", permission: :write
435
+ #
436
+ # @example Mark a method requiring admin permission
437
+ # agent_method :reset_all_counts, "Reset all play counts to zero", permission: :admin
438
+ #
439
+ # @example Mark a write method that explicitly supports dry-run preview
440
+ # agent_method :archive, "Archive this record", permission: :admin, supports_dry_run: true
441
+ # def archive(dry_run: false)
442
+ # return { would_archive: id } if dry_run
443
+ # self.status = "archived"; save!
444
+ # end
445
+ #
446
+ # @param method_name [Symbol, String] the name of the method to expose
447
+ # @param description [String, nil] optional description for LLM context
448
+ # @param permission [Symbol] required permission level (:readonly, :write, :admin)
449
+ # @param supports_dry_run [Boolean] whether the method accepts dry_run: true for
450
+ # preview-only execution. When false (default), passing dry_run: true in
451
+ # arguments is refused at dispatch time with :invalid_argument.
452
+ # @param permitted_keys [Array<Symbol,String>, nil] when provided,
453
+ # +call_method+ refuses any +arguments+ key not in this list.
454
+ # Without this, an LLM (or a prompt-injection payload) can
455
+ # pass arbitrary keys through a method that splats with +**+,
456
+ # reaching protected columns like +_hashed_password+ or +ACL+.
457
+ # Highly recommended on any +agent_write+/+agent_admin+ method
458
+ # that takes a kwargs splat.
459
+ # @param parameters [Hash, nil] when provided, a JSON Schema (as a
460
+ # Ruby Hash) describing the +arguments+ object. Surfaced in
461
+ # +tools/list+ so the LLM submits properly-shaped inputs and
462
+ # stricter MCP clients can validate before dispatch.
463
+ # @return [Hash] the method metadata
464
+ def agent_method(method_name, description = nil, permission: :readonly,
465
+ supports_dry_run: false, permitted_keys: nil, parameters: nil)
466
+ method_sym = method_name.to_sym
467
+
468
+ unless AGENT_METHOD_PERMISSIONS.include?(permission)
469
+ raise ArgumentError, "Invalid permission level: #{permission}. Must be one of: #{AGENT_METHOD_PERMISSIONS.join(", ")}"
470
+ end
471
+
472
+ if permitted_keys && !permitted_keys.is_a?(Array)
473
+ raise ArgumentError, "permitted_keys must be an Array of Symbol/String, got #{permitted_keys.class}"
474
+ end
475
+
476
+ # Determine if this is an instance or class method
477
+ # Note: method_defined? checks instance methods, respond_to? checks class methods
478
+ method_type = if method_defined?(method_sym)
479
+ :instance
480
+ elsif respond_to?(method_sym) || singleton_methods.include?(method_sym)
481
+ :class
482
+ else
483
+ # Method not yet defined - we'll check again at runtime
484
+ :unknown
485
+ end
486
+
487
+ agent_methods[method_sym] = {
488
+ description: description&.to_s&.freeze,
489
+ type: method_type,
490
+ permission: permission,
491
+ supports_dry_run: supports_dry_run == true,
492
+ permitted_keys: permitted_keys&.map(&:to_sym)&.freeze,
493
+ parameters: parameters,
494
+ }
495
+ end
496
+
497
+ # Convenience method: mark a method as readonly-accessible (default)
498
+ #
499
+ # WARNING: This method checks if the method name suggests write behavior
500
+ # (save, update, delete, etc.) and emits a warning. This helps developers
501
+ # catch potential security misconfigurations early.
502
+ #
503
+ # @example
504
+ # agent_readonly :find_popular, "Find songs with over 1000 plays"
505
+ #
506
+ # @param method_name [Symbol, String] the method to expose
507
+ # @param description [String, nil] optional description
508
+ # @return [Hash] the method metadata
509
+ def agent_readonly(method_name, description = nil)
510
+ method_str = method_name.to_s
511
+
512
+ # Warn if method name suggests it performs write operations
513
+ if WRITE_METHOD_PATTERNS.any? { |pattern| method_str.match?(pattern) }
514
+ warn "[Parse::Agent::MetadataDSL] WARNING: Method '#{method_name}' on #{name} " \
515
+ "is marked as agent_readonly but its name suggests it may perform writes. " \
516
+ "Consider using agent_write or agent_admin if this method modifies data."
517
+ end
518
+
519
+ agent_method(method_name, description, permission: :readonly)
520
+ end
521
+
522
+ # Convenience method: mark a method as requiring write permission
523
+ #
524
+ # @example
525
+ # agent_write :update_play_count, "Increment the play count"
526
+ #
527
+ # @param method_name [Symbol, String] the method to expose
528
+ # @param description [String, nil] optional description
529
+ # @return [Hash] the method metadata
530
+ def agent_write(method_name, description = nil)
531
+ agent_method(method_name, description, permission: :write)
532
+ end
533
+
534
+ # Convenience method: mark a method as requiring admin permission
535
+ #
536
+ # @example
537
+ # agent_admin :reset_all_counts, "Reset all play counts to zero"
538
+ #
539
+ # @param method_name [Symbol, String] the method to expose
540
+ # @param description [String, nil] optional description
541
+ # @return [Hash] the method metadata
542
+ def agent_admin(method_name, description = nil)
543
+ agent_method(method_name, description, permission: :admin)
544
+ end
545
+
546
+ # Declare a tenant scope rule for this class.
547
+ #
548
+ # When declared, every agent read tool (query_class, count_objects,
549
+ # get_sample_objects, export_data query-mode, aggregate, get_object,
550
+ # get_objects) will enforce that data access is limited to the agent's
551
+ # bound tenant. An agent with no tenant binding (tenant_id: nil) hitting
552
+ # a scoped class is refused with :access_denied unless the bypass
553
+ # condition is satisfied.
554
+ #
555
+ # @param field [Symbol, String] the Parse field to scope on (e.g. :org_id)
556
+ # @param from [Proc] callable receiving the agent, returning the scope value
557
+ # (return nil to mean "this agent has no tenant binding")
558
+ #
559
+ # @example
560
+ # class Order < Parse::Object
561
+ # property :org_id, :string
562
+ # agent_tenant_scope :org_id, from: ->(agent) { agent.tenant_id }
563
+ # end
564
+ #
565
+ def agent_tenant_scope(field, from:)
566
+ unless from.respond_to?(:call)
567
+ raise ArgumentError, "agent_tenant_scope :from must be a callable (Proc/lambda)"
568
+ end
569
+ parse_class_name = respond_to?(:parse_class) ? parse_class : name
570
+ Parse::Agent::MetadataRegistry.register_tenant_scope(parse_class_name, field, from: from)
571
+ end
572
+
573
+ # Declare a bypass condition for this class's tenant scope.
574
+ #
575
+ # When the block returns truthy for the given agent, tenant scope
576
+ # enforcement is skipped entirely for that agent on this class.
577
+ # A bypass block that raises is treated as not-bypassed (fail closed).
578
+ #
579
+ # Without a bypass declaration, any agent whose tenant_id is nil
580
+ # hitting a scoped class is refused.
581
+ #
582
+ # @yield [agent] the agent instance
583
+ # @yieldreturn [Boolean] truthy to bypass, falsy to enforce
584
+ #
585
+ # @example Allow admin agents to read across tenants
586
+ # class Order < Parse::Object
587
+ # agent_tenant_scope :org_id, from: ->(agent) { agent.tenant_id }
588
+ # agent_tenant_scope_bypass { |agent| agent.permissions == :admin }
589
+ # end
590
+ #
591
+ def agent_tenant_scope_bypass(&block)
592
+ raise ArgumentError, "agent_tenant_scope_bypass requires a block" unless block_given?
593
+ parse_class_name = respond_to?(:parse_class) ? parse_class : name
594
+ Parse::Agent::MetadataRegistry.register_tenant_scope_bypass(parse_class_name, block)
595
+ end
596
+
597
+ # Check if this model has any agent metadata defined.
598
+ #
599
+ # @return [Boolean] true if any metadata is present
600
+ def has_agent_metadata?
601
+ !agent_description.nil? ||
602
+ !agent_usage.nil? ||
603
+ !property_descriptions.empty? ||
604
+ !property_enum_descriptions.empty? ||
605
+ !agent_methods.empty? ||
606
+ !agent_field_allowlist.empty? ||
607
+ !agent_join_field_list.empty?
608
+ end
609
+
610
+ # Get all agent metadata as a hash for serialization.
611
+ #
612
+ # @return [Hash] all agent metadata
613
+ def agent_metadata
614
+ {
615
+ description: agent_description,
616
+ usage: agent_usage,
617
+ property_descriptions: property_descriptions.dup,
618
+ property_enum_descriptions: property_enum_descriptions.dup,
619
+ methods: agent_methods.dup,
620
+ field_allowlist: agent_field_allowlist.dup,
621
+ join_field_list: agent_join_field_list.dup,
622
+ }
623
+ end
624
+
625
+ private
626
+
627
+ # @api private
628
+ # Subset invariant: agent_join_fields entries must all appear in
629
+ # agent_fields when both are declared. The direct-query allowlist
630
+ # is the upper bound on what the agent sees; the join list can only
631
+ # tighten that, never widen it. Raises ArgumentError when violated,
632
+ # at class-load time, so the error surfaces immediately rather than
633
+ # at the first agent query.
634
+ def assert_agent_join_fields_subset!
635
+ return unless @agent_join_field_list&.any?
636
+ return unless @agent_field_allowlist&.any?
637
+ extras = @agent_join_field_list - @agent_field_allowlist
638
+ return if extras.empty?
639
+ raise ArgumentError,
640
+ "agent_join_fields must be a subset of agent_fields on #{self}; " \
641
+ "#{extras.inspect} appears in agent_join_fields but not in agent_fields. " \
642
+ "The direct-query allowlist is the upper bound; the join-projection list " \
643
+ "can only tighten it."
644
+ end
645
+
646
+ public
647
+
648
+ # Check if a specific method is allowed for agent invocation.
649
+ #
650
+ # @param method_name [Symbol, String] the method name to check
651
+ # @return [Boolean] true if the method is agent-allowed
652
+ def agent_method_allowed?(method_name)
653
+ agent_methods.key?(method_name.to_sym)
654
+ end
655
+
656
+ # Get metadata for a specific agent-allowed method.
657
+ #
658
+ # @param method_name [Symbol, String] the method name
659
+ # @return [Hash, nil] the method metadata or nil if not allowed
660
+ def agent_method_info(method_name)
661
+ agent_methods[method_name.to_sym]
662
+ end
663
+
664
+ # Check if an agent with given permission can call a specific method.
665
+ # Permission hierarchy: admin > write > readonly
666
+ #
667
+ # @param method_name [Symbol, String] the method to check
668
+ # @param agent_permission [Symbol] the agent's permission level
669
+ # @return [Boolean] true if the agent can call this method
670
+ def agent_can_call?(method_name, agent_permission)
671
+ method_info = agent_methods[method_name.to_sym]
672
+ return false unless method_info
673
+
674
+ required_permission = method_info[:permission] || :readonly
675
+ permission_allows?(agent_permission, required_permission)
676
+ end
677
+
678
+ # Get all methods available to an agent with given permission level.
679
+ #
680
+ # @param agent_permission [Symbol] the agent's permission level
681
+ # @return [Hash<Symbol, Hash>] methods the agent can call
682
+ def agent_methods_for(agent_permission)
683
+ agent_methods.select do |_name, info|
684
+ permission_allows?(agent_permission, info[:permission] || :readonly)
685
+ end
686
+ end
687
+
688
+ private
689
+
690
+ # Check if agent_permission level can access required_permission level.
691
+ # Permission hierarchy: admin > write > readonly
692
+ #
693
+ # @param agent_permission [Symbol] what the agent has
694
+ # @param required_permission [Symbol] what the method requires
695
+ # @return [Boolean]
696
+ def permission_allows?(agent_permission, required_permission)
697
+ hierarchy = { readonly: 0, write: 1, admin: 2 }
698
+ agent_level = hierarchy[agent_permission] || 0
699
+ required_level = hierarchy[required_permission] || 0
700
+ agent_level >= required_level
701
+ end
702
+ end
703
+
704
+ # Instance method to access class-level agent description
705
+ #
706
+ # @return [String, nil]
707
+ def agent_description
708
+ self.class.agent_description
709
+ end
710
+
711
+ # Instance method to access class-level property descriptions
712
+ #
713
+ # @return [Hash<Symbol, String>]
714
+ def property_descriptions
715
+ self.class.property_descriptions
716
+ end
717
+
718
+ # Instance method to access class-level per-value enum descriptions
719
+ #
720
+ # @return [Hash<Symbol, Hash{String => String}>]
721
+ def property_enum_descriptions
722
+ self.class.property_enum_descriptions
723
+ end
724
+
725
+ # Instance method to access class-level agent methods
726
+ #
727
+ # @return [Hash<Symbol, Hash>]
728
+ def agent_methods
729
+ self.class.agent_methods
730
+ end
731
+ end
732
+ end
733
+ end