parse-stack-next 5.7.6 → 5.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1148 -0
  3. data/README.md +39 -23
  4. data/docs/TEST_SERVER.md +2 -2
  5. data/docs/acl_clp_guide.md +7 -0
  6. data/docs/atlas_vector_search_guide.md +181 -13
  7. data/docs/caching.md +50 -0
  8. data/docs/client_sdk_guide.md +11 -0
  9. data/docs/mcp_guide.md +332 -9
  10. data/docs/mongodb_direct_guide.md +27 -0
  11. data/docs/usage_guide.md +38 -0
  12. data/docs/webhooks_guide.md +126 -17
  13. data/lib/parse/acl_scope.rb +230 -50
  14. data/lib/parse/agent/approval_gate.rb +0 -0
  15. data/lib/parse/agent/constraint_translator.rb +42 -15
  16. data/lib/parse/agent/describe.rb +11 -3
  17. data/lib/parse/agent/field_names.rb +53 -0
  18. data/lib/parse/agent/field_policy.rb +74 -0
  19. data/lib/parse/agent/mcp_deployments.rb +426 -0
  20. data/lib/parse/agent/mcp_dispatcher.rb +8 -1
  21. data/lib/parse/agent/mcp_rack_app.rb +424 -45
  22. data/lib/parse/agent/mcp_server.rb +23 -1
  23. data/lib/parse/agent/mcp_subscriptions.rb +124 -6
  24. data/lib/parse/agent/metadata_registry.rb +67 -8
  25. data/lib/parse/agent/prompt_hardening.rb +9 -3
  26. data/lib/parse/agent/tools.rb +378 -29
  27. data/lib/parse/agent.rb +298 -21
  28. data/lib/parse/api/batch.rb +10 -1
  29. data/lib/parse/api/schema.rb +23 -4
  30. data/lib/parse/api/sessions.rb +6 -2
  31. data/lib/parse/api/users.rb +88 -14
  32. data/lib/parse/atlas_search/index_manager.rb +5 -1
  33. data/lib/parse/atlas_search/protected_paths.rb +236 -0
  34. data/lib/parse/atlas_search.rb +144 -24
  35. data/lib/parse/authorization.rb +289 -4
  36. data/lib/parse/cache/sub_cache.rb +26 -0
  37. data/lib/parse/client/authentication.rb +19 -1
  38. data/lib/parse/client/batch.rb +264 -35
  39. data/lib/parse/client/body_builder.rb +21 -0
  40. data/lib/parse/client/caching.rb +371 -27
  41. data/lib/parse/client/request.rb +26 -14
  42. data/lib/parse/client/response.rb +49 -6
  43. data/lib/parse/client.rb +227 -40
  44. data/lib/parse/clp_scope.rb +317 -25
  45. data/lib/parse/console.rb +2 -2
  46. data/lib/parse/embeddings/voyage.rb +181 -17
  47. data/lib/parse/graphql/type_generator.rb +3 -0
  48. data/lib/parse/live_query/client.rb +72 -2
  49. data/lib/parse/lock_backend.rb +4 -3
  50. data/lib/parse/model/acl.rb +119 -21
  51. data/lib/parse/model/associations/belongs_to.rb +25 -3
  52. data/lib/parse/model/associations/collection_proxy.rb +146 -17
  53. data/lib/parse/model/associations/has_many.rb +49 -9
  54. data/lib/parse/model/associations/has_one.rb +3 -1
  55. data/lib/parse/model/associations/pointer_collection_proxy.rb +145 -17
  56. data/lib/parse/model/associations/relation_collection_proxy.rb +229 -38
  57. data/lib/parse/model/bytes.rb +13 -5
  58. data/lib/parse/model/classes/role.rb +95 -0
  59. data/lib/parse/model/classes/session.rb +304 -1
  60. data/lib/parse/model/classes/user.rb +104 -8
  61. data/lib/parse/model/core/actions.rb +287 -70
  62. data/lib/parse/model/core/builder.rb +100 -8
  63. data/lib/parse/model/core/create_lock.rb +27 -2
  64. data/lib/parse/model/core/describe.rb +2 -0
  65. data/lib/parse/model/core/fetching.rb +50 -5
  66. data/lib/parse/model/core/field_guards.rb +16 -8
  67. data/lib/parse/model/core/pluralized_aliases.rb +8 -4
  68. data/lib/parse/model/core/properties.rb +488 -39
  69. data/lib/parse/model/core/querying.rb +7 -0
  70. data/lib/parse/model/core/schema.rb +5 -3
  71. data/lib/parse/model/core/search_indexing.rb +63 -0
  72. data/lib/parse/model/core/vector_searchable.rb +35 -6
  73. data/lib/parse/model/file.rb +9 -2
  74. data/lib/parse/model/geopoint.rb +61 -13
  75. data/lib/parse/model/model.rb +160 -9
  76. data/lib/parse/model/object.rb +317 -18
  77. data/lib/parse/model/phone.rb +54 -5
  78. data/lib/parse/model/pointer.rb +40 -6
  79. data/lib/parse/model/push.rb +11 -1
  80. data/lib/parse/mongodb.rb +189 -61
  81. data/lib/parse/pipeline_security.rb +415 -26
  82. data/lib/parse/query/constraint.rb +31 -1
  83. data/lib/parse/query/constraints.rb +89 -59
  84. data/lib/parse/query/cursor.rb +3 -1
  85. data/lib/parse/query/operation.rb +62 -8
  86. data/lib/parse/query/ordering.rb +34 -6
  87. data/lib/parse/query.rb +1871 -167
  88. data/lib/parse/retrieval/agent_tool.rb +225 -8
  89. data/lib/parse/retrieval/benchmark.rb +149 -0
  90. data/lib/parse/retrieval/profiles.rb +320 -0
  91. data/lib/parse/retrieval/retriever.rb +10 -1
  92. data/lib/parse/retrieval.rb +2 -0
  93. data/lib/parse/schema/search_index_migrator.rb +23 -5
  94. data/lib/parse/schema.rb +74 -18
  95. data/lib/parse/stack/tasks.rb +6 -4
  96. data/lib/parse/stack/version.rb +1 -1
  97. data/lib/parse/stack.rb +91 -14
  98. data/lib/parse/two_factor_auth/user_extension.rb +14 -2
  99. data/lib/parse/two_factor_auth.rb +11 -0
  100. data/lib/parse/vector_search/hybrid.rb +110 -33
  101. data/lib/parse/vector_search/index_definition.rb +237 -0
  102. data/lib/parse/vector_search.rb +259 -23
  103. data/lib/parse/webhooks/payload.rb +136 -6
  104. data/lib/parse/webhooks/replay_protection.rb +58 -20
  105. data/lib/parse/webhooks.rb +715 -40
  106. metadata +8 -1
@@ -89,6 +89,9 @@ module Parse
89
89
  if klass.nil?
90
90
  klass = ::Class.new(Parse::Object)
91
91
  Parse::Generated.const_set(parse_class_name, klass)
92
+ # The default parse_class comes from the Ruby name, which here is
93
+ # namespaced ("Parse::Generated::Post"). Bind the server class name.
94
+ klass.parse_class(className.to_s)
92
95
  end
93
96
  unless klass.is_a?(Class) && klass <= Parse::Object
94
97
  raise ArgumentError, "Resolved class #{klass.inspect} for #{className.inspect} is not a Parse::Object subclass"
@@ -106,22 +109,111 @@ module Parse
106
109
  class_fields = klass.field_map.values + [:className]
107
110
  fields.each do |field, type|
108
111
  field = field.to_sym
109
- key = field.to_s.underscore.to_sym
110
112
  next if base_fields.include?(field) || class_fields.include?(field)
113
+ next unless type.respond_to?(:[]) && type[:type].present?
111
114
 
112
- data_type = type[:type].downcase.to_sym
113
- if data_type == :pointer
114
- klass.belongs_to key, as: safe_target_class(type[:targetClass]), field: field
115
- elsif data_type == :relation
116
- klass.has_many key, through: :relation, as: safe_target_class(type[:targetClass]), field: field
117
- else
118
- klass.property key, data_type, field: field
115
+ data_type = type[:type].to_s.downcase.to_sym
116
+ # A model's field registry shares one namespace between Ruby
117
+ # names and server columns, so a column whose name another column
118
+ # already claimed as its Ruby name (`foo_bar` after `fooBar`)
119
+ # cannot be mapped. Skip it instead of aborting the whole build.
120
+ if klass.fields.key?(field) || klass.field_map.key?(field)
121
+ builder_warn "skipping column #{className}.#{field}: its name is already " \
122
+ "used by another column of the same class"
123
+ next
124
+ end
125
+ key = safe_accessor_name(klass, field, data_type)
126
+ next if key.nil?
127
+
128
+ begin
129
+ if data_type == :pointer
130
+ klass.belongs_to key, as: safe_target_class(type[:targetClass]), field: field
131
+ elsif data_type == :relation
132
+ klass.has_many key, through: :relation, as: safe_target_class(type[:targetClass]), field: field
133
+ else
134
+ # A renamed accessor must not get an alias under the column
135
+ # name, which is the method it was renamed to avoid.
136
+ opts = { field: field }
137
+ opts[:alias] = false if key.to_s != field.to_s.underscore
138
+ klass.property key, data_type, **opts
139
+ end
140
+ rescue StandardError, SystemStackError => e
141
+ builder_warn "skipping column #{className}.#{field}: #{e.class}: #{e.message}"
142
+ next
143
+ end
144
+ # Hydration from server JSON dispatches on `<column>_set_attribute!`.
145
+ # The association and property DSLs only alias that hook when the
146
+ # column name itself is free as a method, so add it for a renamed
147
+ # accessor (`class` -> `class_field`).
148
+ setter = :"#{field}_set_attribute!"
149
+ target = :"#{key}_set_attribute!"
150
+ if setter != target && !klass.method_defined?(setter) && klass.method_defined?(target)
151
+ klass.send(:alias_method, setter, target)
119
152
  end
120
153
  class_fields.push(field)
121
154
  end
122
155
  klass
123
156
  end
124
157
 
158
+ # Suffix appended to a column's Ruby accessor name when the natural
159
+ # (underscored) name is unusable.
160
+ RENAMED_ACCESSOR_SUFFIX = "_field"
161
+
162
+ # @!visibility private
163
+ # Chooses the Ruby accessor name for server column `field`. The natural
164
+ # name is `field.underscore`. It is replaced with `<name>_field` (then
165
+ # `<name>_field2`, ...) when it would shadow a method every
166
+ # Parse::Object relies on (`class`, `hash`, `save`, `send`,
167
+ # `object_id`, `changes`, ...), when a boolean column's class-level
168
+ # scope would shadow a class method (`freeze`, `name`), or when another
169
+ # column already claimed it (`fooBar` and `foo_bar` both underscore to
170
+ # `foo_bar`). The server column name is unchanged (it is kept with
171
+ # `field:`). Returns nil, after a warning, when no safe name exists.
172
+ # @return [Symbol, nil]
173
+ def self.safe_accessor_name(klass, field, data_type)
174
+ natural = field.to_s.underscore
175
+ unless natural.match?(/\A[a-z_][a-zA-Z0-9_]*\z/)
176
+ builder_warn "skipping column #{klass.parse_class}.#{field}: not a valid Ruby method name"
177
+ return nil
178
+ end
179
+ candidates = [natural, "#{natural}#{RENAMED_ACCESSOR_SUFFIX}"]
180
+ candidates.concat((2..9).map { |n| "#{natural}#{RENAMED_ACCESSOR_SUFFIX}#{n}" })
181
+ name = candidates.find { |c| !accessor_conflict?(klass, c.to_sym, data_type) }
182
+ if name.nil?
183
+ builder_warn "skipping column #{klass.parse_class}.#{field}: no free Ruby accessor name"
184
+ return nil
185
+ end
186
+ if name != natural
187
+ builder_warn "column #{klass.parse_class}.#{field} is exposed as ##{name} " \
188
+ "because ##{natural} would clash with an existing method or column"
189
+ end
190
+ name.to_sym
191
+ end
192
+
193
+ # @!visibility private
194
+ # Whether defining accessor `key` on `klass` would replace an existing
195
+ # method or reuse a name another column already maps to.
196
+ def self.accessor_conflict?(klass, key, data_type)
197
+ return true if klass.fields.key?(key) || klass.field_map.key?(key)
198
+ return true if klass.respond_to?(:relations) && klass.relations.key?(key)
199
+ instance_names = [key, :"#{key}=", :"#{key}_changed?", :"#{key}_was"]
200
+ instance_names << :"#{key}?" if data_type == :boolean
201
+ return true if instance_names.any? { |m| method_taken?(klass, m) }
202
+ # Boolean columns also get a class-level scope named after the key.
203
+ return true if data_type == :boolean && klass.respond_to?(key, true)
204
+ false
205
+ end
206
+
207
+ # @!visibility private
208
+ def self.method_taken?(klass, name)
209
+ klass.method_defined?(name) || klass.private_method_defined?(name)
210
+ end
211
+
212
+ # @!visibility private
213
+ def self.builder_warn(message)
214
+ warn "[Parse::Model::Builder] #{message}"
215
+ end
216
+
125
217
  # @!visibility private
126
218
  # Validates a server-returned `targetClass` string before forwarding
127
219
  # it to `belongs_to`/`has_many`. Returns `nil` for missing or
@@ -34,11 +34,18 @@ module Parse
34
34
  # material against snapshot/MONITOR exposure should set
35
35
  # `PARSE_STACK_LOCK_SECRET` or `Parse.synchronize_create_secret`.
36
36
  module CreateLock
37
+ # Floor for the lock lease, in seconds. When no `ttl:` is given the
38
+ # lease is sized from the client's request budget instead (see
39
+ # {.default_ttl}); this is the minimum it can be.
37
40
  DEFAULT_TTL = 3
38
41
  DEFAULT_WAIT = 2.0
39
42
  DEFAULT_POLL_BASE = 0.05
40
43
  DEFAULT_POLL_JITTER = 0.015
41
- MAX_TTL = 30
44
+ # Ceiling for the lock lease, in seconds. The lease only matters when a
45
+ # holder dies without releasing; a normal exit always releases.
46
+ MAX_TTL = 300
47
+ # Requests the locked block is sized for: the find, then the create.
48
+ LOCKED_REQUESTS = 2
42
49
  MAX_WAIT = 30
43
50
  MAX_PAYLOAD_BYTES = 8_192
44
51
  MAX_DEPTH = 4
@@ -60,7 +67,7 @@ module Parse
60
67
  def synchronize(parse_class:, query_attrs:, options: {}, session_token: nil, master_key: nil, &block)
61
68
  raise ArgumentError, "block required" unless block_given?
62
69
 
63
- ttl = clamp(Integer(options[:ttl] || DEFAULT_TTL), 1, MAX_TTL)
70
+ ttl = options[:ttl] ? clamp(Integer(options[:ttl]), 1, MAX_TTL) : default_ttl
64
71
  wait = clamp(Float(options[:wait] || DEFAULT_WAIT), 0.0, MAX_WAIT)
65
72
  on_degraded = options[:on_degraded] || :warn
66
73
 
@@ -115,6 +122,24 @@ module Parse
115
122
  end
116
123
  end
117
124
 
125
+ # The lease used when no `ttl:` is given. The lock is not renewed
126
+ # while held, so the lease must outlast the block it guards: a find
127
+ # and a create, each of which may run through every retry at the full
128
+ # request timeout. A lease shorter than that expires mid-block, lets a
129
+ # second caller in, and produces the duplicate the lock exists to
130
+ # prevent. The result is clamped to DEFAULT_TTL..MAX_TTL. Pass `ttl:`
131
+ # to override it.
132
+ # @param client [Parse::Client, nil] the client whose timeouts and
133
+ # retry limit size the lease; the default client when nil.
134
+ # @return [Integer] seconds.
135
+ def default_ttl(client = nil)
136
+ client ||= Parse::Client.client
137
+ budget = client.respond_to?(:request_time_budget) ? client.request_time_budget : 0
138
+ clamp((budget * LOCKED_REQUESTS).ceil, DEFAULT_TTL, MAX_TTL)
139
+ rescue StandardError
140
+ DEFAULT_TTL
141
+ end
142
+
118
143
  # Canonical lock key for the given inputs. Public for tests.
119
144
  # @return [String]
120
145
  def canonical_key(parse_class:, query_attrs:, session_token: nil, master_key: nil)
@@ -145,6 +145,8 @@ module Parse
145
145
  unless defined?(Parse::MongoDB) && Parse::MongoDB.respond_to?(:enabled?) && Parse::MongoDB.enabled?
146
146
  return { available: false, reason: :mongodb_not_enabled }
147
147
  end
148
+ # Parse::AtlasSearch is loaded on first use, not with the SDK.
149
+ require_relative "../../atlas_search"
148
150
  indexes = Parse::AtlasSearch::IndexManager.list_indexes(parse_class)
149
151
  {
150
152
  available: true,
@@ -55,6 +55,9 @@ module Parse
55
55
  # @param preserve_changes [Boolean] if true, re-apply local dirty values to fetched fields.
56
56
  # By default (false), fetched fields accept server values and local changes are discarded.
57
57
  # Unfetched fields always preserve their dirty state regardless of this setting.
58
+ # A relation's staged additions and removals are kept in both modes (they are
59
+ # operations, not field values) and the relation stays marked changed, so the
60
+ # next save sends them. Use {Parse::Object#reload!} or `clear_changes!` to drop them.
58
61
  # @param opts [Hash] a set of options to pass to the client request.
59
62
  # @option opts [Boolean, Symbol] :cache (:write_only) caching mode:
60
63
  # - :write_only (default) - skip cache read, but update cache with fresh data
@@ -87,7 +90,11 @@ module Parse
87
90
  includes_array = includes.present? ? Array(includes) : nil
88
91
 
89
92
  # Build formatted keys once (reused for query and tracking)
90
- formatted_keys = keys_array&.map { |k| Parse::Query.format_field(k) }
93
+ formatted_keys = if keys_array
94
+ Parse::Query.with_field_aliases(parse_class) do
95
+ keys_array.map { |k| Parse::Query.format_field(k) }
96
+ end
97
+ end
91
98
 
92
99
  # Validate keys against model fields if validation is enabled
93
100
  # Skip validation if warnings are disabled (nothing to report)
@@ -203,15 +210,26 @@ module Parse
203
210
 
204
211
  # Apply attributes from server (only keys in result get updated)
205
212
  apply_attributes!(result, dirty_track: false)
213
+ # The server row is now the source of truth for the ACL. A full
214
+ # fetch whose response has no ACL key is a public (ACL-less) row, so
215
+ # drop any locally stamped default rather than misreport it. Either
216
+ # way the record exists, so the save-time default-ACL resolver must
217
+ # not replace its ACL.
218
+ if !is_partial_fetch && !result.key?("ACL") && !result.key?(:ACL)
219
+ @acl = nil
220
+ end
221
+ @_acl_pristine = false
206
222
  record_authorization_hydration!(result, partial: is_partial_fetch) if
207
223
  respond_to?(:record_authorization_hydration!)
208
224
 
209
225
  begin
210
- clear_changes!
226
+ # Not clear_changes!: that also drops a relation's staged
227
+ # additions and removals, which a fetch must keep.
228
+ clear_dirty_tracking!
211
229
  rescue => e
212
230
  # Log the error for debugging purposes
213
231
  warn "[Parse::Fetch] Warning: clear_changes! failed: #{e.class}: #{e.message}"
214
- # If clear_changes! fails, manually reset change tracking
232
+ # If clearing fails, manually reset change tracking
215
233
  @changed_attributes = {} if instance_variable_defined?(:@changed_attributes)
216
234
  @mutations_from_database = nil if instance_variable_defined?(:@mutations_from_database)
217
235
  @mutations_before_last_save = nil if instance_variable_defined?(:@mutations_before_last_save)
@@ -253,9 +271,31 @@ module Parse
253
271
  end
254
272
  end
255
273
 
274
+ # A relation whose proxy still holds staged additions or removals
275
+ # must stay dirty, or the next save sends nothing. The loop above
276
+ # cannot guarantee it: when the response carries the relation's
277
+ # descriptor, re-applying the same proxy is not a change.
278
+ remark_staged_relations_dirty!
279
+
256
280
  self
257
281
  end
258
282
 
283
+ # @!visibility private
284
+ # Marks dirty every relation whose proxy holds staged additions or
285
+ # removals. Used after a fetch has cleared the record's dirty tracking.
286
+ def remark_staged_relations_dirty!
287
+ return unless respond_to?(:relations)
288
+ relations.each_key do |key|
289
+ ivar = :"@#{key}"
290
+ next unless instance_variable_defined?(ivar)
291
+ proxy = instance_variable_get(ivar)
292
+ next unless proxy.respond_to?(:staged_changes?) && proxy.staged_changes?
293
+ next if changed.include?(key.to_s)
294
+ will_change_method = "#{key}_will_change!"
295
+ send(will_change_method) if respond_to?(will_change_method)
296
+ end
297
+ end
298
+
259
299
  # Fetches the object with explicit caching enabled.
260
300
  # This is a convenience method that calls fetch! with cache: true.
261
301
  # Use this when you want to leverage cached responses for better performance.
@@ -318,7 +358,9 @@ module Parse
318
358
  def fetch_json(keys: nil, includes: nil)
319
359
  query = {}
320
360
  if keys.present?
321
- keys_array = Array(keys).map { |k| Parse::Query.format_field(k) }
361
+ keys_array = Parse::Query.with_field_aliases(parse_class) do
362
+ Array(keys).map { |k| Parse::Query.format_field(k) }
363
+ end
322
364
  query[:keys] = keys_array.join(",")
323
365
  end
324
366
  if includes.present?
@@ -432,9 +474,12 @@ module Parse
432
474
  # Autofetch if object is a pointer OR was selectively fetched
433
475
  # Skip if autofetch is disabled for this instance
434
476
  needs_fetch = pointer? || has_selective_keys?
477
+ # A pointer never autofetches for its ACL. A partially fetched object
478
+ # does when the ACL was left out, so code that edits it starts from
479
+ # the record's real ACL instead of nil.
435
480
  return unless needs_fetch &&
436
481
  !autofetch_disabled? &&
437
- key != :acl &&
482
+ !(key == :acl && pointer?) &&
438
483
  !Parse::Properties::BASE_KEYS.include?(key) &&
439
484
  respond_to?(:fetch)
440
485
 
@@ -197,20 +197,28 @@ module Parse
197
197
 
198
198
  if respond_to?(:relations) && relations[field_sym]
199
199
  proxy = public_send(field_sym)
200
- # Reset the pending add/remove ledger that backs
201
- # relation_change_operations. The proxy itself has no public reset
202
- # API for these (its rollback!/restore_attributes path expects
203
- # setters that don't exist for additions/removals), so we clear
204
- # them directly and then drop the proxy's dirty markers.
205
- proxy.instance_variable_set(:@additions, []) if proxy.instance_variable_defined?(:@additions)
206
- proxy.instance_variable_set(:@removals, []) if proxy.instance_variable_defined?(:@removals)
200
+ # Drop the pending add/remove ledger that backs
201
+ # relation_change_operations, and the proxy's dirty markers. The
202
+ # relation proxy's clear_changes! also reloads its list on next
203
+ # access, since the loaded list included the discarded items. A
204
+ # handler that reads the relation after the revert therefore
205
+ # triggers one query for the stored members (on a saved owner).
207
206
  proxy.clear_changes! if proxy.respond_to?(:clear_changes!)
208
207
  clear_attribute_changes([field_str])
209
208
  return
210
209
  end
211
210
 
212
211
  if is_new
213
- public_send("#{field_str}=", nil)
212
+ if field_sym == :acl && !self.class.builtin_acl_default_active?
213
+ # The class's ACL policy still decides a new record's ACL: put
214
+ # back the init-time default stamp and let the save-time
215
+ # resolver run, instead of a nil ACL that a reply would send
216
+ # as `{}` (master-key only).
217
+ public_send(:acl=, self.class.default_acls.as_json)
218
+ @_acl_pristine = true
219
+ else
220
+ public_send("#{field_str}=", nil)
221
+ end
214
222
  else
215
223
  restore_attributes([field_str])
216
224
  end
@@ -4,12 +4,16 @@
4
4
  module Parse
5
5
  # Global `const_missing` hook that lazily resolves the plural form of a
6
6
  # {Parse::Object} subclass constant to that class. Referencing `Posts`
7
- # when a class `Post` exists installs `Posts` as an alias for `Post` on
8
- # the referencing module and returns it, so query entry points like
7
+ # when a class `Post` exists installs `Posts` as an alias for `Post` and
8
+ # returns it, so query entry points like
9
9
  # `Posts.where(...).count` work without any per-model boilerplate.
10
10
  #
11
- # The hook is prepended onto `Module` so it applies to constant lookups
12
- # in any namespace (top-level and nested). It is tightly guarded: every
11
+ # The hook is prepended onto `Module` so a plural reference resolves from
12
+ # any namespace, but the alias constant is only ever installed in the
13
+ # namespace that defines the singular class (`::Posts` for a top-level
14
+ # `Post`, `Blog::Posts` for `Blog::Post`). A lookup from an unrelated
15
+ # module never adds a constant to that module, and a frozen namespace is
16
+ # skipped rather than raising `FrozenError`. It is tightly guarded: every
13
17
  # path that is not a plural-of-a-Parse-class falls through to `super`,
14
18
  # preserving normal `NameError` and autoloader (Zeitwerk/classic)
15
19
  # behavior. The whole feature is gated on {Parse.pluralized_aliases?} so