parse-stack-next 5.0.1 → 5.1.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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/.github/ISSUE_TEMPLATE/bug_report.yml +105 -0
  3. data/.github/ISSUE_TEMPLATE/feature_request.yml +67 -0
  4. data/.github/dependabot.yml +13 -0
  5. data/.github/workflows/codeql.yml +1 -1
  6. data/.github/workflows/docs.yml +3 -3
  7. data/.github/workflows/release.yml +14 -3
  8. data/.github/workflows/ruby.yml +1 -1
  9. data/.gitignore +1 -0
  10. data/.yardopts +19 -0
  11. data/CHANGELOG.md +833 -0
  12. data/Gemfile +3 -0
  13. data/Gemfile.lock +8 -5
  14. data/README.md +15 -0
  15. data/Rakefile +5 -1
  16. data/docs/acl_clp_guide.md +553 -0
  17. data/docs/atlas_vector_search_guide.md +123 -22
  18. data/docs/client_sdk_guide.md +201 -5
  19. data/docs/usage_guide.md +21 -0
  20. data/docs/yard-template/default/fulldoc/html/css/common.css +1222 -0
  21. data/docs/yard-template/default/fulldoc/html/css/full_list.css +387 -0
  22. data/lib/parse/agent/tools.rb +153 -1
  23. data/lib/parse/cache/redis.rb +53 -0
  24. data/lib/parse/client/caching.rb +18 -1
  25. data/lib/parse/client.rb +79 -12
  26. data/lib/parse/embeddings/cohere.rb +143 -6
  27. data/lib/parse/embeddings/provider.rb +20 -2
  28. data/lib/parse/embeddings/voyage.rb +102 -0
  29. data/lib/parse/embeddings.rb +332 -1
  30. data/lib/parse/live_query/client.rb +167 -4
  31. data/lib/parse/live_query/configuration.rb +12 -0
  32. data/lib/parse/live_query/subscription.rb +55 -2
  33. data/lib/parse/live_query.rb +123 -1
  34. data/lib/parse/lock.rb +342 -0
  35. data/lib/parse/lock_backend.rb +308 -0
  36. data/lib/parse/model/associations/belongs_to.rb +2 -2
  37. data/lib/parse/model/associations/has_many.rb +1 -1
  38. data/lib/parse/model/classes/audience.rb +5 -0
  39. data/lib/parse/model/classes/installation.rb +122 -0
  40. data/lib/parse/model/classes/job_schedule.rb +3 -1
  41. data/lib/parse/model/classes/job_status.rb +4 -1
  42. data/lib/parse/model/classes/push_status.rb +4 -1
  43. data/lib/parse/model/classes/session.rb +7 -0
  44. data/lib/parse/model/classes/user.rb +204 -0
  45. data/lib/parse/model/core/create_lock.rb +28 -146
  46. data/lib/parse/model/core/embed_managed.rb +162 -13
  47. data/lib/parse/model/core/parse_reference.rb +17 -1
  48. data/lib/parse/model/core/querying.rb +26 -2
  49. data/lib/parse/model/file.rb +523 -18
  50. data/lib/parse/model/model.rb +41 -0
  51. data/lib/parse/model/object.rb +2 -2
  52. data/lib/parse/query.rb +31 -1
  53. data/lib/parse/stack/version.rb +1 -1
  54. data/lib/parse/stack.rb +98 -1
  55. data/lib/parse/webhooks/payload.rb +2 -1
  56. data/parse-stack-next.gemspec +2 -2
  57. metadata +17 -7
data/CHANGELOG.md CHANGED
@@ -1,5 +1,838 @@
1
1
  ## parse-stack-next Changelog
2
2
 
3
+ ### 5.1.1
4
+
5
+ #### Suppress spurious className-mismatch warnings for system-class underscore aliases
6
+
7
+ Parse Server stores its built-in classes under leading-underscore names
8
+ (`_User`, `_Role`, `_Session`, `_Installation`) while the SDK and model
9
+ declarations refer to them without the underscore (`User`, `Role`, and so
10
+ on). These are the same class, but five className-mismatch warn sites
11
+ compared the two forms as raw strings, so building a server-sent pointer for
12
+ a `belongs_to :user` association (for example `Parse::Installation#user`,
13
+ added in 5.1.0) logged a pair of harmless-but-noisy warnings such as
14
+ `expected className="User", ignoring incoming className="_User"` on every
15
+ read. Autoload order contributed: when an association is declared,
16
+ `Parse::User` may not yet be registered, so the captured class name falls
17
+ back to `"User"` rather than `"_User"`.
18
+
19
+ - **FIXED**: Building a pointer whose declared class and incoming class differ
20
+ only by Parse Server's leading-underscore system prefix (`User`/`_User`,
21
+ `Role`/`_Role`, `Session`/`_Session`, `Installation`/`_Installation`) no
22
+ longer emits a className-mismatch warning. `Parse::Object.build`,
23
+ `Array#parse_objects`, the `belongs_to` getter and setter, and the
24
+ `has_many` relation builder now treat the two forms as the same class.
25
+ (`lib/parse/model/object.rb`,
26
+ `lib/parse/model/associations/belongs_to.rb`,
27
+ `lib/parse/model/associations/has_many.rb`)
28
+ - **NEW**: `Parse::Model.same_parse_class?(a, b)` returns true when two Parse
29
+ class-name strings denote the same class — string-equal, related by exactly
30
+ one Parse Server system-prefix underscore, or both resolving to the same
31
+ registered `Parse::Object` subclass (covering custom `parse_class` table
32
+ mappings). This is the canonical equality the warn sites now gate on.
33
+ (`lib/parse/model/model.rb`)
34
+ - **CHANGED**: The className-mismatch type-confusion guard is preserved. A
35
+ pointer to a genuinely different class shoved into a typed slot (for example
36
+ a `_Session` or `_Role` pointer in a `User`-typed association, whether from
37
+ hostile server JSON or mass assignment) still produces the warning, because
38
+ distinct classes — `nil`, and malformed names that differ by more than a
39
+ single system-prefix underscore such as `__User` — continue to compare
40
+ unequal. The warning is advisory: every call site builds the object from the
41
+ declared class regardless of the incoming className, so the equality check
42
+ only governs whether the mismatch is logged.
43
+
44
+ ### 5.1.0
45
+
46
+ #### `Parse::File` — URL normalization, presigned-URL stash, leak hardening
47
+
48
+ The model is hardened so signed URLs never persist in `@url` — they
49
+ get stripped to a canonical bare URL and stashed separately with a
50
+ data-driven expiry parsed from the URL's own query parameters. The
51
+ single URL normalization point applies uniformly to every writer
52
+ (caller-side `url=`, hydration `attributes=`) so the rule is the same
53
+ whether the file pointer arrived from Parse Server's REST surface
54
+ (which may include a freshly-signed URL when Parse Server's
55
+ S3FilesAdapter is configured with `presignedUrl: true`) or from a
56
+ direct caller-side assignment. The change also lays groundwork for
57
+ pluggable storage-adapter work in a later release without committing
58
+ that surface area in this one.
59
+
60
+ **Migration callout — `@url` value change:** the `@url` field now
61
+ drops signed-URL query parameters before storage. Any application
62
+ code that assigned a presigned URL directly to `Parse::File#url=`
63
+ (uncommon, but possible when wiring up custom file-serve flows) will
64
+ find that `file.url` now returns the bare canonical URL; the
65
+ original signed URL is available via the new `file.presigned_url`
66
+ accessor with its expiry in `file.presigned_url_expires_at`. The
67
+ `Parse::File#to_s` / `<%= file %>` ERB rendering path is unchanged
68
+ in shape (still returns `@url`), but the value emitted is now the
69
+ canonical bare URL rather than whatever was assigned — apps relying
70
+ on inline ERB to render a freshly-signed URL must switch to
71
+ `file.presigned_url` (or the new
72
+ `Parse::File#presigned_url_valid?` predicate) explicitly.
73
+
74
+ **Migration callout — error reporter payload shape:**
75
+ `Parse::File#inspect` no longer includes the URL at all. Anything
76
+ that captures exception inspect output — Sentry, Honeybadger,
77
+ Rollbar, Bugsnag, Rails' default error pages, custom log scrubbers
78
+ — will see a different payload shape the day an app upgrades to
79
+ 5.1.0. Tests that pattern-matched on `@url='https://...'` in
80
+ inspect output will need to be updated, and dashboards / alerts
81
+ that grouped errors by inspect string fingerprints will see a
82
+ one-time shift in fingerprint values. The new format emits
83
+ `@name`, `@mime_type`, `@contents` (presence), and `@url=set|blank`
84
+ — enough to debug, none of the URL content.
85
+
86
+ - **NEW**: `Parse::File#url=` and `Parse::File#attributes=` now route
87
+ through a single private normalization point. When the incoming URL
88
+ carries a recognized signed-URL query parameter
89
+ (`X-Amz-Signature`, `X-Amz-Credential`, `X-Amz-Security-Token`,
90
+ `AWSAccessKeyId`, `Key-Pair-Id`), the query string is stripped
91
+ entirely; the bare canonical URL is stored in `@url`; the original
92
+ signed URL is stashed in `@presigned_url` with its expiry parsed
93
+ into `@presigned_url_expires_at`. The expiry is data-driven —
94
+ computed from `X-Amz-Date + X-Amz-Expires` (SigV4) or `Expires`
95
+ (SigV2 / CloudFront) — never hardcoded SDK-side. The `@url`
96
+ invariant is now structural: the field never holds a short-TTL
97
+ signed URL. (`lib/parse/model/file.rb`)
98
+ - **NEW**: `Parse::File#presigned_url` and
99
+ `Parse::File#presigned_url_expires_at` accessors expose the
100
+ stashed signed URL and its parsed expiry. Useful today for apps
101
+ with Parse Server's `S3FilesAdapter` configured with
102
+ `presignedUrl: true`: the SDK normalizes the URL Parse Server
103
+ hands back on every read, the bare canonical value lands in
104
+ `@url`, and the freshly-signed URL is available via
105
+ `file.presigned_url` until `file.presigned_url_expires_at`. The
106
+ stash is invalidated automatically on any URL reassignment
107
+ (signed → canonical, signed → signed, or assignment to nil), so
108
+ callers reading `file.presigned_url` are never handed a value
109
+ staler than `@url`. (`lib/parse/model/file.rb`)
110
+ - **NEW**: `Parse::File#presigned_url_valid?(buffer: 60)` returns
111
+ true when `@presigned_url` is set and `@presigned_url_expires_at`
112
+ is at least `buffer` seconds in the future. Default 60 seconds —
113
+ a margin that absorbs network RTT, client clock skew, and one
114
+ retry. Eliminates the hand-rolled `expires_at && expires_at -
115
+ Time.now.utc > N` pattern every caller would otherwise write to
116
+ gate a refetch. (`lib/parse/model/file.rb`)
117
+ - **NEW**: `Parse::File.signed_url_policy` global accessor controls
118
+ how the URL normalization point reacts to incoming signed URLs.
119
+ Values: `:strip` (default — strip and stash, the pragmatic
120
+ behavior for any deployment where Parse Server's S3FilesAdapter
121
+ returns presigned URLs on read) or `:raise` (refuse the
122
+ assignment with `SignedUrlError`). Strict mode for apps that can
123
+ guarantee Parse Server is NOT issuing signed URLs and want a
124
+ loud failure on any signed-URL assignment instead of silent
125
+ normalization. The policy applies uniformly to both
126
+ caller-side `url=` and hydration `attributes=` — asymmetric
127
+ writer behavior was an explicit anti-goal. (`lib/parse/model/file.rb`)
128
+ - **NEW**: `Parse::File.parse_presigned_expiry(url)` class method
129
+ extracts the expiry time (UTC) from any signed URL by parsing its
130
+ own query parameters. Supports SigV4 and SigV2 / CloudFront
131
+ shapes. Returns nil for URLs without parseable presigned-URL
132
+ expiry data. (`lib/parse/model/file.rb`)
133
+ - **FIXED**: `Parse::File#saved?` basename comparison now strips the
134
+ URL's query string before computing basename, so short-TTL
135
+ presigned URLs that Parse Server's S3FilesAdapter returns on every
136
+ read (`https://bucket.s3.../doc.pdf?X-Amz-Signature=...`) don't
137
+ break `saved?` on reload. The signature bytes used to leak into
138
+ the comparison and cause false negatives.
139
+ (`lib/parse/model/file.rb`)
140
+ - **FIXED**: `Hash#parse_file?` strips the URL's query string before
141
+ the basename equality check so presigned URLs round-trip cleanly
142
+ through file-pointer recognition. Previously the signature bytes
143
+ leaked into the comparison and could cause false negatives.
144
+ (`lib/parse/model/file.rb`)
145
+ - **FIXED**: `Parse::File#save` now routes the file-create response
146
+ URL through the same normalization point as `url=` / `attributes=`.
147
+ Parse Server's S3FilesAdapter can return a freshly-signed URL in the
148
+ create response (not only on read); the save writer previously
149
+ assigned it verbatim to `@url` — and baked the signature query
150
+ string into `@name` via `File.basename` when the response omitted a
151
+ name — bypassing the `@url`-is-always-canonical invariant and the
152
+ `signed_url_policy = :raise` guard. The save writer now strips and
153
+ stashes like every other writer, derives any fallback name from the
154
+ canonical URL, and honors strict mode. (`lib/parse/model/file.rb`)
155
+ - **CHANGED**: `Parse::File#inspect` no longer includes the full
156
+ `@url` string. Inspect output lands in exception messages, Rails
157
+ error pages, log captures, and every error reporter the app uses
158
+ (Sentry / Honeybadger / Rollbar / Bugsnag); defaulting to URL
159
+ emission is a future leak waiting to happen even after the
160
+ normalization guarantees `@url` is canonical. The new format
161
+ emits `@name`, `@mime_type`, `@contents` (presence), and
162
+ `@url=set|blank`. See the migration callout above for the
163
+ error-reporter payload shift.
164
+ (`lib/parse/model/file.rb`)
165
+ - **NEW**: `Parse::File::SignedUrlError` is raised when
166
+ `signed_url_policy = :raise` is set and an incoming URL carries
167
+ a signed-URL signature parameter. Apps that want strict-mode
168
+ enforcement no longer need to subclass or monkey-patch — flip
169
+ the policy and the SDK's normalization point does the work.
170
+ `Parse::File.url_signature_param?(url_string)` and the
171
+ `SIGNATURE_QUERY_PARAMS` constant remain public for caller-side
172
+ custom detection logic. (`lib/parse/model/file.rb`)
173
+ - **NEW**: `Parse::File.log_filter` returns a frozen `Regexp` that
174
+ matches any plain-text HTTP(S) URL carrying an unambiguously
175
+ AWS-style signed-URL parameter — SigV4 (`X-Amz-Signature`,
176
+ `X-Amz-Credential`, `X-Amz-Security-Token`, `X-Amz-Algorithm`,
177
+ `X-Amz-Date`, `X-Amz-Expires`, `X-Amz-SignedHeaders`), legacy
178
+ SigV2 (`AWSAccessKeyId`), or CloudFront (`Key-Pair-Id`). Designed
179
+ to be plugged into `lograge` / `semantic_logger` / custom log
180
+ scrubbers so accidental `Rails.logger.info(file_url)` calls do
181
+ not leak read capabilities into log aggregators. Bare
182
+ `Signature=` / `Policy=` query params are intentionally NOT
183
+ matched on their own — they collide with too many unrelated app
184
+ conventions (webhook signatures, privacy_policy form fields);
185
+ CloudFront URLs always carry `Key-Pair-Id` alongside `Signature`
186
+ / `Policy`, which IS matched. (`lib/parse/model/file.rb`)
187
+ - **NEW**: `Parse::File.log_filter_strict` returns the same
188
+ signature-detection regex but ALSO accepts the JSON-encoded
189
+ query separator (`\u0026` for `&`). Required for scrubbing
190
+ error-reporter event bodies (Sentry, Honeybadger, Rollbar,
191
+ Bugsnag) where the URL string has been JSON-encoded once before
192
+ reaching the scrubber and the literal `&` appears as `\u0026`.
193
+ The default `log_filter` would silently miss those — operators
194
+ shipping to a JSON-encoding error reporter should wire
195
+ `log_filter_strict` into the before-send hook. (`lib/parse/model/file.rb`)
196
+ - **NEW**: `Parse::File.filter_parameter_names` returns an
197
+ `Array<Regexp>` for `Rails.application.config.filter_parameters`.
198
+ Defaults to AWS-prefixed names only (`X-Amz-*`, `AWSAccessKeyId`,
199
+ `Key-Pair-Id`) so the list never over-redacts a Rails app's
200
+ privacy_policy / e-signature / policy_id form fields. Companion
201
+ `Parse::File.cloudfront_signed_param_names` returns the bare
202
+ `Signature` / `Policy` / `Expires` regexes as an opt-in
203
+ extension for CloudFront-heavy deployments that have confirmed
204
+ no app-side collision. (`lib/parse/model/file.rb`)
205
+ - **CHANGED**: `Parse::File#to_s` is deliberately left returning
206
+ `@url` unchanged — ERB templates and `<img src="<%= file %>">`
207
+ callers continue to work. Combined with the URL normalization
208
+ above, this makes the "@url is canonical" invariant structural
209
+ rather than convention-based — `to_s` cannot leak a signed URL
210
+ because such a URL is stripped before reaching `@url`.
211
+
212
+ #### `Parse::Lock` — public TTL-bounded mutual-exclusion primitive
213
+
214
+ - **NEW**: `Parse::Lock.acquire(key, ttl:, wait:, on_degraded:) { … }`
215
+ exposes the Redis-backed lock previously hidden inside
216
+ `first_or_create!` / `create_or_update!` as a first-class
217
+ primitive. TTL-bounded (1..30s, default 3s), with in-process
218
+ `Mutex` fallback when the configured cache is process-local
219
+ (Moneta `Memory` / `Null` / nil), and fails closed —
220
+ acquisition errors are caught, treated as "not acquired", and
221
+ surface as `Parse::Lock::TimeoutError` once the `wait:` budget
222
+ elapses. Block-form only (no token-based `try_acquire`); release
223
+ is automatic on normal return, exception, `throw`/`break`, or
224
+ any `ensure`-path exit. Keys are SHA-256-hashed before hitting
225
+ the store so sensitive identifiers (user IDs, request IDs,
226
+ webhook idempotency keys) don't appear verbatim in `KEYS *`
227
+ output (obfuscation, not authentication — see the YARD for the
228
+ guessable-input-space caveat). Documented use cases: bulk-import
229
+ dedup, cron-job singletons, external-API idempotency, anywhere
230
+ two processes might race the same logical operation. Built on
231
+ `Parse::LockBackend` (see below); namespace prefix
232
+ `parse-stack:lock:v1:` is distinct from `first_or_create!`'s
233
+ `parse-stack:foc:v1:`, so the two APIs cannot collide even on
234
+ literally-equal-named keys. (`lib/parse/lock.rb`)
235
+ - **NEW**: `Parse::Lock::TimeoutError` and
236
+ `Parse::Lock::UnavailableError` — namespaced under `Parse::Lock`
237
+ so the peer-not-base relationship to
238
+ `Parse::CreateLockTimeoutError` / `Parse::CreateLockUnavailableError`
239
+ is unambiguous in the name itself (a caller seeing
240
+ `Parse::Lock::TimeoutError` cannot reasonably read it as a base
241
+ of `Parse::CreateLockTimeoutError`). Both inherit from
242
+ `Parse::Error`; rescue chains targeting `Parse::Error` continue
243
+ to catch them. (`lib/parse/lock.rb`)
244
+ - **NEW**: `Parse::LockBackend` — `@api private` module hosting the
245
+ shared lock primitives (`lock_store`, `degraded_store?`,
246
+ `handle_degraded`, `try_acquire`, `release`, `poll_interval`,
247
+ `process_mutex`, `lock_secret_for`, `configured_secret`,
248
+ `auto_secret`, `warn_plain_sha_once`). Both `Parse::Lock` and
249
+ `Parse::CreateLock` consume the backend directly instead of one
250
+ reaching into the other's privates. The extraction eliminates
251
+ the `.send(:private)` coupling that the v5.1.0 round-2 review
252
+ called out as fragile — any future refactor of the SETNX
253
+ semantics, the degraded-detection heuristic, the in-process-Mutex
254
+ fallback registry, OR the HMAC secret resolution happens in
255
+ exactly one place. `Parse::CreateLock` retains only its
256
+ CreateLock-specific helpers (`clamp` for input range).
257
+ (`lib/parse/lock_backend.rb`, `lib/parse/model/core/create_lock.rb`)
258
+ - **NEW**: `Parse::Lock.acquire(secret:)` — HMAC keying option.
259
+ `:auto` (default) picks up the operator-configured
260
+ `PARSE_STACK_LOCK_SECRET` / `Parse.synchronize_create_secret`,
261
+ auto-derives a per-process secret for degraded stores, falls
262
+ back to plain SHA-256 with a one-time `[Parse::Lock:SECURITY]`
263
+ warn for cross-process stores without a configured secret. A
264
+ `String` value overrides the resolution per call; `nil`
265
+ explicitly opts out of HMAC (no warn — the opt-out is
266
+ deliberate). Closes the parity gap with `Parse::CreateLock`: an
267
+ operator setting one secret hardens both APIs without a second
268
+ config knob. Different secrets isolate locks on the same raw
269
+ key — useful for multi-tenant deployments sharing one Redis
270
+ where tenants must not block each other on coincidentally-equal
271
+ lock names. Real-Redis integration coverage in
272
+ `test/lib/parse/lock_redis_integration_test.rb` (Queue-gated to
273
+ eliminate sleep-based race flakes: HMAC-keyed entry shape,
274
+ plain-SHA opt-out, cross-process contention, fast-fail under
275
+ `wait: 0`, different-secret isolation, shared-secret-with-CreateLock
276
+ via env var, namespace separation from `first_or_create!`, atomic
277
+ compare-and-delete under a simulated lease-expiry race, and the
278
+ TTL-overrun warning). Explicit `secret:` kwarg
279
+ values are length-validated at the boundary —
280
+ `Parse::Lock::SECRET_MIN_BYTES` (= 16) is the floor for any
281
+ caller-supplied HMAC key. A `secret: "a"` misconfiguration is
282
+ refused with `ArgumentError` rather than silently degrading the
283
+ lock-pinning resistance HMAC keying is supposed to provide. The
284
+ operator-configured `PARSE_STACK_LOCK_SECRET` path is not
285
+ length-checked (different threat model — process-boot
286
+ configuration, not per-call argument). The `on_degraded:` YARD
287
+ now documents the asymmetric-degradation residual risk: if two
288
+ processes target the same Redis but disagree on degraded
289
+ detection, they derive different store keys for the same raw
290
+ key and silently fail to mutually exclude — mitigated by
291
+ uniform `Parse.synchronize_create_store` configuration or
292
+ `on_degraded: :raise`. (`lib/parse/lock.rb`)
293
+ - **NEW**: `Parse::Lock` and `Parse::LockBackend` are autoloaded —
294
+ `Parse::Lock.acquire(…)` works without an explicit
295
+ `require 'parse/lock'`. (`lib/parse/stack.rb`)
296
+ - **FIXED**: lock release is now an **atomic compare-and-delete**.
297
+ `Parse::Cache::Redis` gains raw-Redis `lock_acquire` (`SET NX EX`)
298
+ and `lock_release` (a Lua compare-and-delete), and `Parse::LockBackend`
299
+ routes both ends through them. The previous release read the owner
300
+ token and deleted in two separate commands; a holder whose lease
301
+ expired and was re-acquired by another holder between the two could
302
+ delete the new holder's live lock. The Lua CAD makes a stale owner's
303
+ release a guaranteed no-op. The raw path also uses plain-string keys
304
+ and values (bypassing Moneta's marshal transformers) so acquire and
305
+ release share one encoding and the keys are human-inspectable in
306
+ Redis. Non-Redis (raw-Moneta) stores keep the documented best-effort
307
+ GET-then-DEL bounded by the short TTL.
308
+ (`lib/parse/cache/redis.rb`, `lib/parse/lock_backend.rb`)
309
+ - **FIXED**: `Parse::Lock.acquire` no longer over-promises exactly-once
310
+ execution. The contract is now documented as mutual exclusion with a
311
+ DEADLINE: if the critical section outruns `ttl:`, the lease expires
312
+ mid-block and a second caller can acquire concurrently. The block now
313
+ receives its owner token (`acquire(key) { |token| … }`) for callers
314
+ who want to fence against a token-checking resource, and a
315
+ `[Parse::Lock]` warning is emitted on release when the section
316
+ overran its TTL (mutual exclusion was not guaranteed for the overrun
317
+ window). The misleading "two webhook deliveries can't double-charge"
318
+ example is replaced with an idempotency-required example.
319
+ (`lib/parse/lock.rb`)
320
+
321
+ #### LiveQuery — BREAKING: ACL-scoped by default; plus ergonomics (autoload, error context, signal-safe shutdown)
322
+
323
+ - **NEW**: `Parse::LiveQuery` is now autoloaded — `Parse::LiveQuery.configure { … }`
324
+ works without an explicit `require 'parse/live_query'`. The
325
+ autoload is purely a file-loading convenience and does NOT open
326
+ any network connection; a WebSocket only opens when
327
+ `Parse.live_query_enabled = true` AND a
328
+ `Parse::LiveQuery::Client` is instantiated (typically via
329
+ `Klass.subscribe { … }`). The opt-in toggle's security shape is
330
+ preserved. (`lib/parse/stack.rb`)
331
+ - **BREAKING**: LiveQuery connections are now **ACL-scoped by
332
+ default** — the connect frame no longer carries the master key
333
+ merely because one is configured. Parse Server resolves
334
+ master-key (ACL/CLP-bypass) authorization once, per CONNECTION,
335
+ from the connect frame (`_handleConnect` → `client.hasMasterKey`);
336
+ once set, EVERY subscription on that socket bypasses ACL/CLP and
337
+ returns every matching object regardless of its ACL. Prior
338
+ versions sent the master key on the connect frame whenever one was
339
+ present, so a `Parse.setup(master_key: …)` process silently
340
+ elevated session-token subscriptions the caller believed were
341
+ ACL-scoped. To get the old admin/event-tap behavior, build an
342
+ explicit admin connection:
343
+ `Parse::LiveQuery::Client.new(use_master_key: true)` or
344
+ `Parse::LiveQuery.configure { |c| c.use_master_key = true }`.
345
+ Admin connections emit a one-time `[Parse::LiveQuery:SECURITY]`
346
+ warning at connect. For a process that needs both scoped and
347
+ admin streams, use two separate clients.
348
+ (`lib/parse/live_query/client.rb`,
349
+ `lib/parse/live_query/configuration.rb`)
350
+ - **NEW**: `Parse::LiveQuery::Client.new(use_master_key: true)`,
351
+ the `config.use_master_key` toggle, and the
352
+ `Client#use_master_key` / `Client#admin_connection?` predicates
353
+ make the admin (ACL-bypassing) posture explicit and inspectable.
354
+ `admin_connection?` is the single source of truth for "will this
355
+ socket bypass ACL/CLP" — true only when the opt-in is set AND a
356
+ usable master key is present.
357
+ (`lib/parse/live_query/client.rb`,
358
+ `lib/parse/live_query/configuration.rb`)
359
+ - **CHANGED**: `Query#subscribe` / `Klass.subscribe` / `Client#subscribe`
360
+ still accept `use_master_key:`, but it is now an **intent assertion**,
361
+ not a per-subscription wire credential. Parse Server has no
362
+ per-subscription master key, so the subscribe frame NEVER carries
363
+ `masterKey` (sending it was a no-op that put a privileged credential
364
+ on the wire for zero effect). The flag is satisfied only on an admin
365
+ connection (where the whole socket is already elevated); on a
366
+ non-admin connection, `use_master_key: true` emits a one-time
367
+ `[Parse::LiveQuery:SECURITY]` warning and the subscription stays
368
+ ACL-scoped. Passing a `session_token:` on an admin connection
369
+ likewise warns — those results are NOT scoped to that token.
370
+ (`lib/parse/model/core/querying.rb`, `lib/parse/query.rb`,
371
+ `lib/parse/live_query/client.rb`, `lib/parse/live_query/subscription.rb`)
372
+ - **FIXED**: `Parse::LiveQuery::Client#inspect` and
373
+ `Subscription#inspect` now redact credentials. The default `inspect`
374
+ dumped every instance variable, exposing `@master_key`, `@client_key`,
375
+ and per-subscription `@session_token` in plaintext anywhere an object
376
+ was rendered — a log line, a backtrace, a Rails error page, or an
377
+ APM/error reporter (Sentry / Honeybadger / Rollbar / Bugsnag). The
378
+ custom `inspect` emits only non-secret diagnostics (url, state,
379
+ `admin_connection`, subscription count, request id, class name) and
380
+ `[REDACTED]` for any secret, matching the redaction
381
+ `Configuration#to_h` already applied. (`lib/parse/live_query/client.rb`,
382
+ `lib/parse/live_query/subscription.rb`)
383
+ - **NEW**: `Klass.subscribe`, `Query#subscribe`, and
384
+ `Parse::LiveQuery::Client#subscribe` all accept an optional `&block`
385
+ yielded the freshly-constructed `Subscription` **before** the
386
+ subscribe frame is sent to the server, so callbacks registered
387
+ inside the block (`sub.on(:create) { … }`) are wired before any
388
+ server event can arrive on the request_id. Order matters and is
389
+ tested — yielding AFTER the wire send would race a fast server
390
+ response against the callback registration on a hot socket. The
391
+ capture-then-wire form (`sub = Post.subscribe(…); sub.on(…)`)
392
+ still works for callers that prefer it. Matches the Parse JS
393
+ client's block-form convention. **If the block raises, the
394
+ subscription is rolled back out of the client's internal
395
+ `@subscriptions` registry before the exception propagates** —
396
+ without the rollback, the next reconnect's `resubscribe_all`
397
+ would silently wire-send the ghost subscription to the server
398
+ (round-3 review finding). (`lib/parse/live_query/client.rb`,
399
+ `lib/parse/query.rb`, `lib/parse/model/core/querying.rb`)
400
+
401
+ ```ruby
402
+ Post.subscribe(where: { published: true }) do |sub|
403
+ sub.on(:create) { |obj| puts "new: #{obj.id}" }
404
+ sub.on(:update) { |obj, _prev| puts "updated: #{obj.id}" }
405
+ end
406
+ ```
407
+ - **CHANGED**: `Parse::LiveQuery::SubscriptionError` now carries
408
+ `request_id` and `class_name` as structured attributes, and the
409
+ `message` is auto-prefixed with `request_id=<n> class=<X>` when
410
+ the constructor receives either. `Subscription#fail!` promotes
411
+ String errors from the server (e.g. `"Permission denied (code: 101)"`)
412
+ to typed instances carrying both the request id and the class
413
+ the subscription targeted — a single-line log captures enough
414
+ operational context to debug a permission denial without
415
+ re-correlating the raw server string against the subscription
416
+ registry. Backwards compatible — bare `SubscriptionError.new("…")`
417
+ callers (no context) preserve the verbatim message.
418
+ (`lib/parse/live_query.rb`, `lib/parse/live_query/subscription.rb`)
419
+ - **NEW**: `Parse::LiveQuery.run_until_signal!(client:, signals:,
420
+ shutdown_timeout:, poll_interval:) { |client| … }` is a
421
+ signal-safe shutdown helper for long-running subscribe sessions
422
+ (rake-task-style consumers, `rake livequery:tail`, etc.). The
423
+ raw idiom — calling `client.unsubscribe` / `client.close` from
424
+ inside a `Signal.trap` block — raises `ThreadError: can't be
425
+ called from trap context` on macOS / MRI on platforms that
426
+ enforce `:signal_safe?`, because the trap context cannot
427
+ acquire the client's internal `Monitor`. This helper bundles
428
+ the safe pattern: install minimal trap handlers that only push
429
+ a sentinel onto a `Queue`, poll the sentinel from the main
430
+ thread, and run `client.shutdown(timeout:)` on the main thread
431
+ in an `ensure` block. Restores prior trap handlers on exit so
432
+ re-running the helper (in tests, or in a parent process that
433
+ traps SIGINT itself) does not leak our handler. Defaults to
434
+ trapping `INT` and `TERM`; configurable via `signals:`.
435
+ Yields the client to the block before the wait loop starts so
436
+ subscription setup is not racing the trap installation.
437
+ (`lib/parse/live_query.rb`)
438
+
439
+ #### MCP — `structuredContent` outputSchemas for 5 more tools
440
+
441
+ - **NEW**: `output_schema` declarations on five additional built-in
442
+ tools so the MCP dispatcher auto-mirrors their result Hash into
443
+ `structuredContent` per MCP 2025-06-18: `aggregate`,
444
+ `export_data`, `atlas_text_search`, `atlas_autocomplete`,
445
+ `atlas_faceted_search`. Each schema is `type: "object"` with
446
+ every nested `type: "array"` declaring `items:` (so OpenAI's
447
+ strict tool-list validation and MCP client outputSchema
448
+ validation both accept them), and uses `additionalProperties: true`
449
+ on result-row entries to remain honest about the open shape of
450
+ arbitrary `$project` / `$group` / `$lookup` output. Brings the
451
+ built-in MCP tool coverage to sixteen of the catalog;
452
+ `call_method` (structurally polymorphic per application return)
453
+ and `explain_query` (MongoDB-version-dependent shape) remain
454
+ text-only by design. End-to-end emission coverage in
455
+ `test/lib/parse/agent/mcp_dispatcher_test.rb` (five new
456
+ `test_builtin_<tool>_emits_structuredContent` cases drive each
457
+ tool's `tools/call` path through the dispatcher); static
458
+ validity coverage in `test/lib/parse/agent/tools_schema_validity_test.rb`
459
+ walks the new schemas with the existing JSON-Schema-object-root
460
+ and array-items-present invariants. Builds on the eleven
461
+ v5.0.0 tools (`count_objects`, `get_object`, `get_objects`,
462
+ `get_sample_objects`, `distinct`, `group_by`, `group_by_date`,
463
+ `list_tools`, `get_all_schemas`, `get_schema`, `query_class`).
464
+ (`lib/parse/agent/tools.rb`)
465
+
466
+ #### Caching — tenant-aware namespacing
467
+
468
+ - **NEW**: `Parse.with_cache_tenant(scope) { … }` sets an ambient
469
+ cache-tenant scope for the duration of the block;
470
+ `Parse.current_cache_tenant` reads it. When set, the
471
+ `Parse::Middleware::Caching` middleware composes the tenant
472
+ into the cache key as `<base-namespace>:T:<tenant>:…` so a
473
+ multi-tenant Parse application sharing one Redis (or any
474
+ Moneta-backed cache) gets per-tenant key isolation without
475
+ per-tenant `Parse::Client.new` plumbing. A SCAN-delete over
476
+ `<base-namespace>:T:<tenant>:*` evicts exactly one tenant
477
+ cleanly; the existing `<base-namespace>:*` SCAN still evicts
478
+ the whole app. The `T:` discriminator is unambiguously
479
+ distinguishable from session-token hex prefixes (32-char hex)
480
+ and `mk:`, so legacy cache entries written before this feature
481
+ cannot re-hydrate into a tenanted request and vice versa.
482
+ Fiber-local — composes safely with `async` and concurrent web
483
+ frameworks; restored on block exit even when the block raises,
484
+ even when the owning Thread is killed mid-block (`Thread#kill`
485
+ runs `ensure` clauses, which matters for Puma's recycled thread
486
+ pool). Scope set in Fiber A is NOT visible to a concurrently-
487
+ running Fiber B; scope set in Thread A is NOT visible to Thread
488
+ B or the main thread — both explicitly tested.
489
+ AS::N payload (`parse.cache.{hit,miss,store,delete,error}`)
490
+ carries `:cache_tenant` so subscribers can budget cache
491
+ performance per tenant. Strictly a key-namespacing mechanism —
492
+ no access-control semantics; tenant isolation at the data
493
+ layer is the job of `agent_tenant_scope` and ACL/CLP.
494
+ (`lib/parse/stack.rb`, `lib/parse/client/caching.rb`)
495
+
496
+ #### Image embedding — `embed_image` DSL + Voyage multimodal-3 (URL-only)
497
+
498
+ The setup order is **(1) `Parse::Embeddings.allowed_image_hosts = […]` →
499
+ (2) `Parse::Embeddings.trust_provider_url_fetch = "PROVIDER_EGRESS_VERIFIED"`
500
+ → (3) declare `embed_image` on the model**. Skipping the allowlist or
501
+ the sentinel raises a typed error from the validator at save time;
502
+ each error message tells the operator which prerequisite is missing.
503
+
504
+ - **NEW**: `Parse::Embeddings::Cohere#embed_image(sources, input_type:, allow_insecure:)`
505
+ routes image URLs through Cohere's `/v2/embed` multimodal endpoint
506
+ for the `embed-v4.0` model (1536 native dim, Matryoshka-capable;
507
+ shares vector space with the text-input path on the same model).
508
+ Wire shape uses OpenAI-style nested
509
+ `{ type: "image_url", image_url: { url: ... } }` content rows —
510
+ different from {Voyage#embed_image}'s flat-String form, identical
511
+ high-level SDK contract (caller passes `Array<String>` URLs).
512
+ Refuses v3 models (text-only) with `BadRequestError` before any
513
+ network call; guards oversized batches (>96 per Cohere docs);
514
+ validates every URL up-front via
515
+ `Parse::Embeddings.validate_image_url!`. Internal
516
+ `Cohere#post_embeddings` grows a `path:` kwarg so the text path
517
+ continues to use `/v1/embed` while images route to `/v2/embed`.
518
+ (`lib/parse/embeddings/cohere.rb`)
519
+ - **NEW**: `Parse::Embeddings::Voyage#embed_image(sources, input_type:, allow_insecure:)`
520
+ routes image URLs through Voyage's `/v1/multimodalembeddings`
521
+ endpoint for the `voyage-multimodal-3` model (1024-dim, shares
522
+ vector space with the text-input path that already shipped). The
523
+ SDK does NOT download image bytes — URL-only is the v5.1 path
524
+ (bytes-fetch with MIME-sniff + EXIF stripping is the v5.3 path).
525
+ Calling `embed_image` on a text-only model raises a clear
526
+ `BadRequestError` before any network call. The provider reports
527
+ `modalities == %i[text image]` for the multimodal model and
528
+ `[:text]` for text-only models. (`lib/parse/embeddings/voyage.rb`)
529
+ - **NEW**: `Parse::Embeddings.validate_image_url!(url, allow_insecure:)`
530
+ is the canonical URL validator used by every `embed_image` path.
531
+ Layered checks, ordered cheap-first: (1) sentinel-gated
532
+ `trust_provider_url_fetch` opt-in must be set; (2) URL parses as
533
+ `https://` (or `http://` with `allow_insecure: true`, for local
534
+ dev only); (3) no userinfo; (4) host extracted via `uri.hostname`
535
+ so IPv6 literals are unbracketed and compare uniformly; (5) the
536
+ host is not an obfuscated IP form (`0x7f.0.0.1`, `127.1`,
537
+ `2130706433` — all rejected with `:host_blocked` BEFORE reaching
538
+ the resolver to keep operator logs honest about the failure mode);
539
+ (6) host matches `Parse::Embeddings.allowed_image_hosts` (string
540
+ match, no syscall — runs before the resolver hop so non-allowlisted
541
+ hosts can't amplify DNS traffic); (7) port in
542
+ `Parse::File.allowed_remote_ports`; (8) host resolves only to
543
+ addresses outside `Parse::File::BLOCKED_CIDRS` — delegated to
544
+ `Parse::File.assert_host_allowed!` so the SSRF mechanism is
545
+ shared, not parallelized. Returns the canonicalized URL String so
546
+ callers store/forward exactly what was validated. Failures raise
547
+ `Parse::Embeddings::InvalidImageURL` carrying a `:reason` Symbol
548
+ (`:scheme`, `:port`, `:userinfo`, `:host_blocked`,
549
+ `:host_not_allowlisted`, `:parse`); sentinel-off raises
550
+ `Parse::Embeddings::ConfirmationRequired`.
551
+ (`lib/parse/embeddings.rb`)
552
+ - **NEW**: `Parse::Embeddings.trust_provider_url_fetch=` sentinel-
553
+ gated opt-in for forwarding image URLs to embedding providers.
554
+ Assigning the exact frozen String `"PROVIDER_EGRESS_VERIFIED"`
555
+ unlocks; any other value (`true`, `"true"`, `1`, a non-matching
556
+ String) raises `Parse::Embeddings::ConfirmationRequired`. Mirrors
557
+ the `acl: :off` sentinel pattern — an operator unintentionally
558
+ flipping the gate via `ENV` interpolation is refused, making
559
+ accidental enablement impossible. Threat model: image-URL
560
+ forwarding hands an attacker-controlled URL (chat input, agent
561
+ tool argument, user-submitted document field) to a third-party
562
+ provider that will then issue an HTTP request from its own
563
+ network. Even with the CIDR / port / host allowlist enforced at
564
+ SDK-validation time, the provider's actual fetch happens later
565
+ (DNS-rebinding window) and can follow redirects the SDK never
566
+ saw — operators must consciously acknowledge the residual risk.
567
+ (`lib/parse/embeddings.rb`)
568
+ - **NEW**: `Parse::Embeddings.allowed_image_hosts=` allowlist
569
+ defining which CDN hostnames `validate_image_url!` will accept.
570
+ Entries beginning with `.` match suffixes (`.cloudfront.net`
571
+ matches `foo.cloudfront.net` and `cloudfront.net`); entries
572
+ without a leading `.` are exact. **Empty allowlist denies every
573
+ host** — opposite default from `Parse::File.allowed_remote_hosts`
574
+ (where empty means "any public host"). The asymmetry is
575
+ deliberate: image URLs that reach this validator typically
576
+ originate from attacker-controlled inputs, so opening the
577
+ surface requires an explicit operator declaration of which
578
+ CDNs are trusted. Frozen after assignment, case-insensitive
579
+ matching, reset by `Parse::Embeddings.reset!`.
580
+ (`lib/parse/embeddings.rb`)
581
+ - **NEW**: `embed_image source_field, into: :vector_property, input_type:
582
+ :search_document, digest_field: nil, allow_insecure: false` class
583
+ macro on `Parse::Object` subclasses. Mirrors `embed` but for
584
+ `:file`-typed sources. The source
585
+ property must be `:file` (text sources go through `embed`); the
586
+ target must be a declared `:vector` property with `provider:`
587
+ metadata. On `before_save`: extracts the file's URL, runs it
588
+ through `validate_image_url!`, and calls `Provider#embed_image`.
589
+ **Digest is the SHA-256 of the URL String, not the file bytes** —
590
+ replacing the `Parse::File` with one pointing at a different URL
591
+ re-embeds; resaving the same URL is a no-op (zero provider calls).
592
+ Cloud-stored Parse files have stable URLs unless overwritten, so
593
+ this matches typical upload behavior. If you mutate bytes at the
594
+ same URL (PUT-replace on S3 without renaming), null the digest
595
+ field to force re-embed. Reuses the existing `EmbedManaged` writer
596
+ guard, before_save registration, and protected-field semantics —
597
+ direct assignment to the managed vector raises `ProtectedFieldError`
598
+ as with text `embed`. (`lib/parse/model/core/embed_managed.rb`)
599
+ - **NEW**: `Parse::Core::EmbedManaged::EmbedDirective` gains
600
+ `modality:` (`nil`/`:text` for `embed`, `:image` for `embed_image`)
601
+ and `allow_insecure:` fields. `recompute_embedding!` dispatches on
602
+ modality, calling either `embed_text` or `embed_image`. The
603
+ source-input builder splits into `build_source_text` (existing,
604
+ concatenates text fields) and the new image-URL path (extracts
605
+ `file.url` and returns it raw — validation runs once, inside the
606
+ provider's `embed_image` call, to avoid double-resolving every
607
+ URL through DNS). Backwards compatible — every existing `embed`
608
+ directive continues to use the text path with no behavior change.
609
+ (`lib/parse/model/core/embed_managed.rb`)
610
+ - **CHANGED**: Base `Parse::Embeddings::Provider#embed_image` signature is
611
+ now `(sources, input_type:, allow_insecure: false, **opts)`.
612
+ `allow_insecure:` is documented as a contract kwarg —
613
+ `EmbedManaged.call_provider` unconditionally forwards it from the
614
+ directive, so future provider overrides must accept it (explicitly
615
+ or via `**opts`) or the managed-embedding save path will raise
616
+ `ArgumentError: unknown keyword`. Existing `Voyage#embed_image`
617
+ already accepts `allow_insecure:` explicitly. No other built-in
618
+ provider overrides `embed_image` yet, so this is a forward-compat
619
+ contract, not a breaking change. (`lib/parse/embeddings/provider.rb`)
620
+ - **NEW**: Voyage `embed_image` refuses oversized batches
621
+ (`sources.length > @embed_batch_size`, default 128) before any
622
+ validation or network call, with a clear "split and retry" error.
623
+ The text path goes through `embed_text_batched` which chunks
624
+ automatically; the image path has no chunker in v5.1, so a
625
+ direct-API caller passing 200 URLs gets a typed error instead of
626
+ a silent 400 from Voyage. (`lib/parse/embeddings/voyage.rb`)
627
+ - **NEW**: Integration coverage for the image-embedding save
628
+ round-trip — `test/lib/parse/embed_managed_image_integration_test.rb`
629
+ exercises Parse::File upload to the Docker Parse Server, the
630
+ before_save → validate → provider → vector-persist path, idempotent
631
+ no-op on unchanged URL, re-embed on file reassignment with a
632
+ different URL, the writer guard against a live server, and clean
633
+ save abort (no half-written record) when the sentinel is unset
634
+ or the URL is not in the allowlist. Unit coverage in
635
+ `test/lib/parse/embeddings_image_url_validation_test.rb` (36
636
+ cases: sentinel gate, allowlist semantics, every validator
637
+ failure mode including obfuscated-IP forms and the
638
+ allowlist-before-resolve ordering),
639
+ `test/lib/parse/embeddings_voyage_image_test.rb` (15 cases:
640
+ multimodal-model gating, wire envelope, canonicalized-URL
641
+ forwarding, allow_insecure precedence, batch-size guard),
642
+ `test/lib/parse/embeddings_cohere_image_test.rb` (16 cases:
643
+ parallel coverage for Cohere `embed-v4.0` plus the nested
644
+ `image_url: { url: }` envelope assertion, `/v2/embed` endpoint
645
+ routing, and an AS::N billed-input-tokens passthrough),
646
+ `test/lib/parse/embed_managed_image_test.rb` (24 cases:
647
+ declaration validation including `:file`-only source check,
648
+ digest semantics, writer guard, security wiring,
649
+ `embed` + `embed_image` co-declaration on the same record).
650
+
651
+ #### Client setup fixes — `Parse.setup` and `live_query_url`
652
+
653
+ - **FIXED**: `Parse.setup` (the module-level helper) silently no-op'd on every
654
+ call after the first. The implementation routed through `Parse::Client.new`,
655
+ whose constructor registers itself with `Parse::Client.clients[:default] ||= self`
656
+ — so once a default was set, subsequent `Parse.setup` invocations built a
657
+ new client, ran all the Faraday and LiveQuery configuration, and then
658
+ threw the result away because `||=` would not overwrite. The class-level
659
+ `Parse::Client.setup` uses `=` and did overwrite, so the two entry points
660
+ behaved differently despite being documented as equivalent. `Parse.setup`
661
+ now delegates to `Parse::Client.setup`, so re-configuring the default
662
+ client (Rake tasks that need to point at a prod URL after a development
663
+ initializer ran, multi-tenant boot, test isolation) works without
664
+ manually clearing `Parse::Client.clients[:default]` first. The `||=`
665
+ guard in `Parse::Client#initialize` is preserved so ad-hoc
666
+ `Parse::Client.new(...)` for secondary clients still does not hijack
667
+ the `:default` slot. (`lib/parse/client.rb`)
668
+ - **FIXED**: Passing `live_query_url:` (or any `live_query: {...}` options)
669
+ to `Parse.setup` / `Parse::Client.new` raised
670
+ `ArgumentError: wrong number of arguments (given 1, expected 0)`.
671
+ `Parse::Client#configure_live_query` was calling
672
+ `Parse::LiveQuery.configure(url:, application_id:, client_key:, master_key:, **opts)`
673
+ with keyword arguments, but `Parse::LiveQuery.configure` takes no
674
+ arguments and only yields a configuration block. The configuration is
675
+ now applied through the block form, assigning each option via the
676
+ `Parse::LiveQuery::Configuration` setters. Boot-time LiveQuery
677
+ configuration via `Parse.setup(live_query_url: ...)` now matches the
678
+ documented behavior. (`lib/parse/client.rb`)
679
+ - **FIXED**: `live_query_url:` (top-level) now correctly wins over
680
+ `live_query: { url: ... }` when both are passed. The first pass of
681
+ the block-form rewrite iterated `live_query_opts` after applying the
682
+ resolved URL, so the loop would re-write `config.url` from the hash
683
+ and silently invert the documented precedence. The hash's `:url`
684
+ key is now skipped in the loop and the resolved URL is applied last.
685
+ (`lib/parse/client.rb`)
686
+ - **NEW**: `Parse::Client#configure_live_query` now refuses an explicit
687
+ `ws://` URL against a non-loopback host unless
688
+ `live_query: { allow_insecure: true }` is also passed. The downstream
689
+ `Parse::LiveQuery::Client#derive_websocket_url` path already enforced
690
+ this for URLs derived from a Parse Server `http://` URL, but an
691
+ explicit `live_query: { url: "ws://prod-host" }` (or top-level
692
+ `live_query_url: "ws://prod-host"` / `PARSE_LIVE_QUERY_URL=ws://...`)
693
+ bypassed the check. The connect frame carries the master key and any
694
+ session token in cleartext on a non-TLS socket, so the explicit-URL
695
+ path now applies the same guard with the same `LOOPBACK_HOSTS`
696
+ exemption (`localhost`, `127.0.0.1`, `::1`, `[::1]`, `0.0.0.0`) and
697
+ the same `allow_insecure` escape hatch. (`lib/parse/client.rb`)
698
+ - **NEW**: `Parse::Client#configure_live_query` now warns on unknown
699
+ `live_query: { ... }` keys instead of silently dropping them. The
700
+ pre-fix kwargs form raised `ArgumentError: unknown keyword` on a
701
+ typo, so e.g. `live_query: { ssl_min_versoin: :TLSv1_3 }` would have
702
+ failed loudly; the block-form rewrite silently dropped them, leaving
703
+ the operator's intent invisible. The warning enumerates the unknown
704
+ keys and lists the valid setter surface; the call still proceeds so
705
+ this is a soft failure, not a hard one. (`lib/parse/client.rb`)
706
+
707
+ #### `Parse::Installation` and `Parse::User` — `user` pointer association
708
+
709
+ - **NEW**: `Parse::Installation` now declares `belongs_to :user`,
710
+ exposing the `user` pointer that Parse Server populates on
711
+ `_Installation` when the row is created or updated by an
712
+ authenticated client. The association is purely ergonomics — read
713
+ `installation.user` to find which user a device is currently signed
714
+ in as, write `installation.user = user; installation.save` from a
715
+ master-key context for targeted push grouping. The YARD prose calls
716
+ out the existing caveat from the class-level CLP notes: the `user`
717
+ pointer is not a reliable owner identity (devices outlive sessions
718
+ and can change users), so it should not be used for ACL or CLP
719
+ scoping. (`lib/parse/model/classes/installation.rb`)
720
+ - **NEW**: `Parse::User` now declares `has_many :installations, as: :installation`
721
+ as the query-form symmetric association. Each access issues a
722
+ `find` against `_Installation` for `where(user: self)`. Because
723
+ Parse Server hardcodes `_Installation` `find` to master-key-only at
724
+ the REST layer, this association only returns rows under a
725
+ master-key client; sessioned / sessionless clients get an empty
726
+ array (or fail closed under scoped agents). Useful for targeted
727
+ push — finding every device a user is signed into. The YARD
728
+ documents both the master-key requirement and the owner-identity
729
+ caveat. (`lib/parse/model/classes/user.rb`)
730
+
731
+ #### `_User` field-visibility DSL — `master_only_fields` and `self_visible_fields`
732
+
733
+ - **NEW**: `Parse::User.master_only_fields(*fields)` declares fields that
734
+ should be hidden from query/get responses for every non-master caller,
735
+ including the owning user themselves. Useful for admin-only metadata
736
+ living on `_User` (e.g. internal scoring, moderation notes). Expands
737
+ internally to a `protect_fields "*"` entry. Effective only when Parse
738
+ Server is started with `protectedFieldsOwnerExempt: false` — the
739
+ default `true` exempts the owning user from every `protectedFields`
740
+ rule on `_User` and would silently negate the protection. The SDK
741
+ documents the dependency on the helper's YARD and surfaces it in the
742
+ one-time advisory described below. (`lib/parse/model/classes/user.rb`)
743
+ - **NEW**: `Parse::User.self_visible_fields(*fields, via: :self)`
744
+ declares fields that should be hidden from public, role, and other-
745
+ user callers but visible to the owning user on their own row.
746
+ Expands internally to a `protect_fields "*"` plus a
747
+ `protect_fields "userField:<via>"` pair whose intersection resolves
748
+ to "owner sees the field, nobody else does". Requires (a) Parse
749
+ Server option `protectedFieldsOwnerExempt: false` and (b) a
750
+ self-pointer field on `_User` (default field name `:self`) populated
751
+ by a `beforeSave('_User')` Cloud Code trigger. The SDK cannot
752
+ install either — both are server-side configuration — and the helper
753
+ documents both prerequisites inline. (`lib/parse/model/classes/user.rb`)
754
+ - **NEW**: One-time process-scoped advisories when the new DSL helpers
755
+ or raw `protect_fields` are called on `Parse::User`:
756
+ - First invocation of `master_only_fields` or `self_visible_fields`
757
+ surfaces the `protectedFieldsOwnerExempt: false` server-option
758
+ prerequisite. With the default `true`, the owning user is silently
759
+ exempted from every `protectedFields` rule on `_User`, so a field
760
+ declared master-only would still be visible to the user themselves
761
+ on their own row. The SDK cannot introspect Parse Server's startup
762
+ options, so the advisory fires at class declaration so it's
763
+ surfaceable before deploy.
764
+ - First invocation of `self_visible_fields` also surfaces the
765
+ self-pointer prerequisite: the `via:` field has to exist on `_User`
766
+ and be populated by a `beforeSave('_User')` Cloud Code trigger,
767
+ AND pre-existing user rows need a one-shot backfill before the
768
+ `userField:<via>` group matches them.
769
+ - Direct calls to `protect_fields` on `Parse::User` outside the
770
+ helpers point the caller at `master_only_fields` /
771
+ `self_visible_fields` plus the same `protectedFieldsOwnerExempt`
772
+ reminder. Behavior is otherwise unchanged. The helpers themselves
773
+ set a class-level bypass flag so the raw-protect_fields advisory
774
+ does not double-fire. Internal SDK callers (e.g. the
775
+ `parse_reference` embedded-reference DSL auto-install in
776
+ `lib/parse/model/core/parse_reference.rb`) also bypass the
777
+ raw-protect_fields advisory so gem boot stays quiet on apps that
778
+ use embedded references on `_User`. (`lib/parse/model/classes/user.rb`,
779
+ `lib/parse/model/core/parse_reference.rb`)
780
+
781
+ #### `_Installation` CLP advisory
782
+
783
+ - **NEW**: One-time process-scoped advisory emitted from
784
+ `Parse::Installation` when any of `set_clp`, `set_class_access`,
785
+ `set_read_user_fields`, or `set_write_user_fields`
786
+ is invoked on the class. Parse Server hardcodes `find` and `delete`
787
+ on `_Installation` to master-key-only at the REST layer
788
+ (`SharedRest.js`), and gates `create` / `update` on the
789
+ `X-Parse-Installation-Id` header rather than CLP — so most CLP
790
+ changes on `_Installation` either do nothing or break the SDK's
791
+ device-registration flow. The advisory enumerates which operations
792
+ CLP actually controls on this class (`get`, `count`, `addField`,
793
+ `protectedFields`) and points the caller at the
794
+ `beforeSave('_Installation')` Cloud Code pattern for login-required
795
+ write policy. Behavior is otherwise unchanged. (`lib/parse/model/classes/installation.rb`)
796
+
797
+ #### Documentation
798
+
799
+ - **NEW**: `docs/acl_clp_guide.md` is the canonical reference for ACL,
800
+ CLP, `protectedFields`, role hierarchy, and field-guard write
801
+ protection across parse-stack-next. Covers the five enforcement
802
+ layers (CLP, ACL, `protectedFields`, field guards, master-key
803
+ bypass); the system-class CLP matrix (which classes actually honor
804
+ CLP versus the ones hardcoded master-key-only at the REST layer:
805
+ `_JobStatus`, `_PushStatus`, `_Hooks`, `_GlobalConfig`,
806
+ `_GraphQLConfig`, `_JobSchedule`, `_Audience`, `_Idempotency`,
807
+ `_Join:*`); the `_Installation` hardcoded asymmetry; the `_User`
808
+ field-visibility recipe with `protectedFieldsOwnerExempt` and the
809
+ self-pointer pattern; role hierarchy direction (the
810
+ `inherits_capabilities_from!` vs `add_child_role` distinction); the
811
+ field-guard modes and their webhook dependency; the REST-aggregate
812
+ vs `Parse::MongoDB.aggregate` enforcement asymmetry (REST aggregate
813
+ is master-key-only and enforces NEITHER CLP nor ACL nor
814
+ `protectedFields`); Atlas Search inheriting SDK-side enforcement
815
+ through the mongo-direct path; and a pitfalls section. (`docs/acl_clp_guide.md`)
816
+ - **CHANGED**: `docs/client_sdk_guide.md` §4 now opens with a banner
817
+ pointing readers at the new comprehensive ACL/CLP guide. Sections
818
+ added in this release for `_Installation` CLP semantics (§6.3),
819
+ the full system-class CLP matrix (§6.5), and the `_User` field-
820
+ visibility recipe with the intersection-resolution table (§6.6)
821
+ remain in the SDK guide as a client-mode quickstart. (`docs/client_sdk_guide.md`)
822
+ - **CHANGED**: YARD `@note` on `Parse::JobStatus`, `Parse::PushStatus`,
823
+ `Parse::Audience`, and `Parse::JobSchedule` now states that the
824
+ class is hardcoded master-key-only at Parse Server's REST layer and
825
+ that CLP changes are ignored. YARD on `Parse::Session` documents the
826
+ non-master find auto-scoping to `user = <current user>`, so CLP
827
+ cannot grant cross-user session visibility. YARD on
828
+ `Parse::Installation` carries the full operation-by-operation CLP
829
+ effectiveness table. (`lib/parse/model/classes/job_status.rb`,
830
+ `lib/parse/model/classes/push_status.rb`,
831
+ `lib/parse/model/classes/audience.rb`,
832
+ `lib/parse/model/classes/job_schedule.rb`,
833
+ `lib/parse/model/classes/session.rb`,
834
+ `lib/parse/model/classes/installation.rb`)
835
+
3
836
  ### 5.0.1
4
837
 
5
838
  #### Redis cache wrapper compatibility with `Parse::CreateLock`