parse-stack-next 5.1.1 → 5.2.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 (60) hide show
  1. checksums.yaml +4 -4
  2. data/.env.sample +12 -0
  3. data/.env.test +4 -4
  4. data/CHANGELOG.md +630 -0
  5. data/Gemfile +3 -0
  6. data/Gemfile.lock +6 -1
  7. data/README.md +226 -39
  8. data/Rakefile +56 -10
  9. data/docs/atlas_vector_search_guide.md +110 -9
  10. data/docs/mcp_guide.md +504 -0
  11. data/docs/mongodb_direct_guide.md +66 -1
  12. data/docs/mongodb_index_optimization_guide.md +22 -1
  13. data/docs/usage_guide.md +15 -0
  14. data/lib/parse/agent/approval_gate.rb +0 -0
  15. data/lib/parse/agent/constraint_translator.rb +90 -19
  16. data/lib/parse/agent/describe.rb +1 -0
  17. data/lib/parse/agent/errors.rb +16 -0
  18. data/lib/parse/agent/mcp_client.rb +9 -0
  19. data/lib/parse/agent/mcp_dispatcher.rb +139 -7
  20. data/lib/parse/agent/mcp_rack_app.rb +621 -17
  21. data/lib/parse/agent/mcp_subscriptions.rb +607 -0
  22. data/lib/parse/agent/metadata_dsl.rb +58 -0
  23. data/lib/parse/agent/metadata_registry.rb +141 -1
  24. data/lib/parse/agent/prompt_hardening.rb +213 -0
  25. data/lib/parse/agent/result_formatter.rb +18 -3
  26. data/lib/parse/agent/tools.rb +167 -24
  27. data/lib/parse/agent.rb +692 -21
  28. data/lib/parse/client/request.rb +55 -4
  29. data/lib/parse/client/response.rb +4 -0
  30. data/lib/parse/client.rb +205 -7
  31. data/lib/parse/model/classes/installation.rb +27 -10
  32. data/lib/parse/model/classes/user.rb +8 -0
  33. data/lib/parse/model/core/actions.rb +65 -13
  34. data/lib/parse/model/core/embed_managed.rb +19 -14
  35. data/lib/parse/model/core/indexing.rb +108 -16
  36. data/lib/parse/model/core/querying.rb +29 -0
  37. data/lib/parse/model/model.rb +34 -3
  38. data/lib/parse/model/object.rb +42 -0
  39. data/lib/parse/query.rb +90 -24
  40. data/lib/parse/retrieval/agent_tool.rb +369 -0
  41. data/lib/parse/retrieval/chunk.rb +74 -0
  42. data/lib/parse/retrieval/chunker.rb +208 -0
  43. data/lib/parse/retrieval/retriever.rb +274 -0
  44. data/lib/parse/retrieval.rb +10 -0
  45. data/lib/parse/schema.rb +69 -20
  46. data/lib/parse/stack/version.rb +2 -2
  47. data/lib/parse/webhooks/payload.rb +62 -34
  48. data/lib/parse/webhooks.rb +15 -3
  49. data/parse-stack-next.gemspec +1 -1
  50. data/scripts/docker/docker-compose.atlas.yml +14 -10
  51. data/scripts/docker/docker-compose.test.yml +24 -20
  52. data/scripts/docker/mongo-init.js +3 -3
  53. data/scripts/start-parse.sh +10 -0
  54. data/scripts/start_mcp_server.rb +1 -1
  55. data/scripts/test_server_connection.rb +1 -1
  56. data/scripts/vector_prototype/create_vector_index.js +1 -1
  57. data/scripts/vector_prototype/fetch_embeddings.py +2 -2
  58. data/scripts/vector_prototype/query_prototype.rb +1 -1
  59. data/scripts/vector_prototype/run.sh +4 -4
  60. metadata +10 -2
data/CHANGELOG.md CHANGED
@@ -1,5 +1,635 @@
1
1
  ## parse-stack-next Changelog
2
2
 
3
+ ### 5.2.1
4
+
5
+ #### Webhook trigger handlers now receive the full Parse object
6
+
7
+ Webhook trigger payloads (`beforeSave`/`afterSave`/`beforeDelete`/`afterDelete`)
8
+ are delivered by Parse Server and authenticated by the webhook key, so they are
9
+ trusted, server-authoritative state. Previously the payload was filtered through
10
+ the wide mass-assignment denylist, which stripped server-issued
11
+ `createdAt`/`updatedAt` (and other non-credential fields) before the handler
12
+ could see them. That broke `Parse::Object#existed?` and `#new?` inside
13
+ `afterSave` handlers — `existed?` always returned `false` and `new?` always
14
+ returned `true`, regardless of whether the object was created or updated — and
15
+ hid the object's timestamps and ACL from handler code.
16
+
17
+ - **FIXED**: `afterSave`/`beforeSave` handlers now receive the full object as
18
+ Parse Server sends it (`createdAt`, `updatedAt`, `ACL`, internal fields).
19
+ `Parse::Object#existed?` and `#new?` are now reliable inside `afterSave`
20
+ handlers. (Genuine credentials — session tokens and password hashes — are
21
+ still stripped, and `Parse::User` continues to protect `authData` on
22
+ `payload.user`.)
23
+ - **NEW**: `afterSave` handlers on an updated object now carry dirty tracking
24
+ relative to the prior state, so `title_changed?`, `changed`, and `changes`
25
+ work inside `afterSave` the same way they already did inside `beforeSave`.
26
+ - **CHANGED**: Inbound webhook trigger payloads are now scrubbed of genuine
27
+ credential material only (`sessionToken`, `_hashed_password`,
28
+ `_password_history`) rather than the full mass-assignment denylist. Protection
29
+ against persisting forged privileged fields remains on the write path: a save
30
+ emits only declared, dirty-tracked properties, and an after-trigger response
31
+ is `true`/`false`, so forged `_rperm`/`_wperm`/`authData` cannot be persisted
32
+ through a handler. This applies only to the inbound webhook trigger payload;
33
+ client login/signup responses are unaffected and still return session tokens.
34
+ - **CHANGED**: In an `afterSave` handler, `new?` now correctly returns `false`
35
+ (the object is already persisted) where the previous timestamp-stripping bug
36
+ made it return `true`. Use `existed?` to distinguish create from update inside
37
+ `afterSave` (`existed? == false` for a create, `true` for an update); `new?`
38
+ is intended for `beforeSave`.
39
+ - **CHANGED**: Dirty-gated `after_save` side effects now fire on client/REST-
40
+ initiated saves where they previously silently no-op'd. With timestamps and
41
+ dirty tracking restored, a callback such as `after_save { notify if
42
+ title_changed? }` will now activate for objects created or updated via REST /
43
+ JS cloud code, not only for Ruby-model saves.
44
+
45
+ ```ruby
46
+ Parse::Webhooks.route :after_save, "Post" do
47
+ post = parse_object
48
+
49
+ if post.existed? # now reliable: false on create, true on update
50
+ NotificationService.changed(post) if post.title_changed?
51
+ else
52
+ post.create_default_associations!
53
+ end
54
+ true
55
+ end
56
+ ```
57
+
58
+ #### Lifecycle callbacks run in ActiveModel order for client-initiated saves
59
+
60
+ Parse Server exposes no separate `beforeCreate`/`afterCreate` triggers — only
61
+ `beforeSave` and `afterSave`. The webhook layer now runs the model lifecycle
62
+ callbacks for a client-initiated create in the canonical ActiveModel order:
63
+ `before_save` → `before_create` (in the `beforeSave` webhook) then
64
+ `after_create` → `after_save` (in the `afterSave` webhook).
65
+
66
+ - **FIXED**: `before_create` callbacks now run for client/REST/JS/Auth0-created
67
+ objects. The `beforeSave` webhook runs `before_create` after `before_save` for
68
+ new objects (an object with no `original`); previously `before_create` never
69
+ fired for non-Ruby creates, so create-time setup written as `before_create`
70
+ was silently skipped.
71
+ - **FIXED**: `after_save` no longer double-fires on client-initiated saves. The
72
+ `beforeSave` webhook entry point previously ran the full save callback chain,
73
+ firing `after_save` during `beforeSave` in addition to the `afterSave`
74
+ webhook. It now runs the before phase only.
75
+ - **NEW**: `Parse::Object#run_before_save_callbacks` and
76
+ `#run_before_create_callbacks` — the before-phase counterparts to the existing
77
+ `run_after_save_callbacks` / `run_after_create_callbacks`.
78
+ - **CHANGED**: `Parse::Object#prepare_save!` is retained as a back-compat alias
79
+ for `run_before_save_callbacks` and now runs the before phase only (it no
80
+ longer also fires `after_save`). The before-phase runners honor `:if`/`:unless`
81
+ callback conditions and the callback terminator.
82
+ - **NOTE**: the webhook layer runs `before_save`/`before_create` and
83
+ `after_create`/`after_save`, but not `before_update`/`after_update` — those
84
+ `:update`-specific callbacks fire only on Ruby-model saves, not for
85
+ client-initiated (REST/JS/Auth0) saves. Use `before_save`/`after_save` (which
86
+ run for every save) and branch on `existed?` if you need update-only logic.
87
+
88
+ ### 5.2.0
89
+
90
+ #### Retrieval layer — `Parse::Retrieval` (`Parse::RAG`)
91
+
92
+ A safe-by-default retrieval-augmented-generation path on top of the existing
93
+ vector-search stack. `Parse::Retrieval.retrieve` embeds a natural-language
94
+ query, runs Atlas `$vectorSearch` through the existing ACL/CLP-enforcing
95
+ `find_similar`, and splits each retrieved document's text field into scored,
96
+ citable chunks. Chunking is a presentation step applied after retrieval —
97
+ embedding remains one-vector-per-record, so every chunk inherits its parent
98
+ document's single score.
99
+
100
+ - **NEW**: `Parse::Retrieval::Chunker::FixedSizeOverlap(size:, overlap:, by:, max_chunks_per_document:)`
101
+ — a fixed-size sliding-window chunker with overlap, `by: :chars` (default) or
102
+ `by: :tokens`. Subclass `Parse::Retrieval::Chunker::Base` for custom
103
+ strategies. The `max_chunks_per_document` cap (default 200) truncates with a
104
+ signal rather than raising, bounding the one-document-to-many-chunks
105
+ amplification surface. (`lib/parse/retrieval/chunker.rb`)
106
+ - **NEW**: `Parse::Retrieval.retrieve(query:, klass:, field:, k:, filter:, vector_filter:, chunker:, tenant_scope:, score_quantize:, **scope_opts)`
107
+ returns `Array<Parse::Retrieval::Chunk>` (`{ id, score, content, source, metadata }`).
108
+ ACL is enforced mongo-direct inside `find_similar`; scope kwargs
109
+ (`session_token:` / `acl_user:` / `acl_role:` / `master:`) pass through. A
110
+ tenant scope is merged into the Atlas pre-filter, closing the cross-tenant
111
+ existence side channel. (`lib/parse/retrieval/retriever.rb`)
112
+ - **NEW**: `agent_searchable field:, filter_fields:` model macro opts a class in
113
+ to the agent retrieval tool and declares which fields an agent may filter on.
114
+ (`lib/parse/agent/metadata_dsl.rb`)
115
+ - **NEW**: `semantic_search` agent tool (`permission: :readonly`,
116
+ `client_safe: true`) routing through `Parse::Retrieval.retrieve` with the full
117
+ agent security envelope: searchable-class allowlist, recursive underscore-key
118
+ refusal and filter-field allowlist on caller input, `field_allowlist`
119
+ projection plus tenant-scope re-assertion on every returned record, and score
120
+ quantization in non-admin contexts. (`lib/parse/retrieval/agent_tool.rb`)
121
+ - **NEW**: `semantic_search` accepts `text_field:` to pick which embedded text
122
+ source to chunk and return as `content` — required for models that embed more
123
+ than one text field (previously such a model raised `AmbiguousTextField` on
124
+ every call with no way to disambiguate from the tool). The value is
125
+ constrained to the class's declared `embed` sources so it can't surface a
126
+ field the model never opted into embedding.
127
+ - **FIXED**: `semantic_search` now forwards `max_chunks_per_document` to the
128
+ chunker (it was silently dropped on the agent path, so the per-document chunk
129
+ cap could not be tuned through the tool). (`lib/parse/retrieval/agent_tool.rb`)
130
+ - **IMPROVED**: parameter-name aliases smooth over inconsistencies across the
131
+ surface — `Parse::Agent.new` accepts `permission:` (alias of `permissions:`)
132
+ and `impersonation_user:` / `impersonation_mint:` / `impersonate_label:`
133
+ (aliases of `impersonate_user:` / `impersonate_mint:` / `impersonation_label:`);
134
+ `Parse::Agent::Tools.register` accepts `permissions:` (alias of `permission:`);
135
+ and `semantic_search` accepts `klass:` / `class:` for `class_name` and the
136
+ chunker's own `size:` / `overlap:` / `by:` names. Canonical names are
137
+ unchanged. (`lib/parse/agent.rb`, `lib/parse/agent/tools.rb`,
138
+ `lib/parse/retrieval/agent_tool.rb`)
139
+ - **NEW**: `Parse::RAG` is a discoverability alias for `Parse::Retrieval`.
140
+ - **NEW**: `rerank:` and `hybrid:` are reserved on `retrieve` and raise
141
+ `NotImplementedError` if supplied, locking the API shape for later releases.
142
+
143
+ #### MCP elicitation — human-in-the-loop approval for destructive operations
144
+
145
+ `:write` / `:admin` tier tool calls can now require human approval before they
146
+ run, using the MCP 2025-06-18 spec-native `elicitation/create` channel. The
147
+ server sends the proposed dry-run diff to the client over the listening stream
148
+ and blocks until the approver accepts or rejects.
149
+
150
+ - **NEW**: `Parse::Agent.require_approval_for = [:write, :admin]` opts tiers into
151
+ approval. Off by default, so existing clients are unaffected.
152
+ - **NEW**: A pluggable approval gate (`Parse::Agent#approval_gate`) consulted by
153
+ `Parse::Agent#execute`, reachable on the non-MCP path and unit-testable with a
154
+ fake approver. `Parse::Agent::MCPElicitationGate` is the spec-native
155
+ implementation; `Parse::Agent::NullGate` (the default) approves.
156
+ (`lib/parse/agent/approval_gate.rb`)
157
+ - **NEW**: `call_method` resolves the *effective* tier from the target
158
+ `agent_method`'s declared permission, so write/admin methods invoked through
159
+ the readonly `call_method` tool are gated correctly. The approval diff reuses
160
+ the existing dry-run preview.
161
+ - **NEW**: MCPRackApp captures the client's `elicitation` capability at
162
+ `initialize`, routes the client's reply (a method-less JSON-RPC response) into
163
+ a session-bound pending registry, and accepts an `approval_timeout:`.
164
+ (`lib/parse/agent/mcp_rack_app.rb`, `lib/parse/agent/mcp_dispatcher.rb`,
165
+ `lib/parse/agent/mcp_subscriptions.rb`)
166
+ - **SECURITY**: fails closed — when approval is required but the client did not
167
+ advertise the capability, no listening stream is open, the transport is
168
+ non-streaming, or the approver times out, the destructive operation is
169
+ refused, never silently executed. Replies are session-bound so one session
170
+ cannot answer another's prompt.
171
+
172
+ #### Agent roadmap: impersonation, prompt hardening, telemetry, provenance
173
+
174
+ - **NEW**: Agent impersonation for user-scoped queries.
175
+ `Parse::Agent.new(impersonate_user:, impersonate_mint:, impersonation_label:)`
176
+ and `agent.impersonate(user)` / `agent.stop_impersonating!` resolve a real
177
+ session token for the target `_User` (reusing an active `_Session`, or
178
+ minting a restricted one with `impersonate_mint: true`) and bind it as if
179
+ `session_token:` had been passed. Fails closed: requires a master-key client,
180
+ rejects non-`_User` pointers, and refuses rather than widening to master-key
181
+ posture when no session resolves. An `impersonation_label:` audit tag (also
182
+ usable with `acl_role:`) surfaces on the `parse.agent.tool_call` payload.
183
+ Session lookups run through the agent's own client (not the process-default
184
+ `Parse.client`) so impersonation is correct under multi-client setups, and
185
+ `impersonated_user_id` is stamped only after a token resolves, so a failed
186
+ `impersonate` leaves the agent's identity unchanged. (`lib/parse/agent.rb`)
187
+ - **SECURITY**: aggregate tools (`aggregate`, `group_by`, `distinct`, and the
188
+ pipeline export path) route through the SDK's ACL-enforcing mongo-direct path
189
+ for ANY non-master identity — session-token agents and runtime-impersonated
190
+ agents included, not only `acl_user:` / `acl_role:`. Parse Server's REST
191
+ aggregate endpoint enforces no per-row ACL, so this closes the gap where a
192
+ scoped agent whose ACL context wasn't eagerly resolved could otherwise reach
193
+ the unenforced REST path. (`lib/parse/agent.rb`, `lib/parse/agent/tools.rb`)
194
+ - **NEW**: `Parse::Agent::PromptHardening` — `sanitize_schema_for_llm` (drops
195
+ non-identifier field names, strips control/zero-width chars, caps and
196
+ marker-wraps descriptions) hooked into `get_schema`/`get_all_schemas`;
197
+ `scrub_marker_injection` neutralizing embedded wrapper markers in untrusted
198
+ tool content (`Parse::Agent.prompt_marker_strict` to refuse instead);
199
+ operator-curated `Parse::Agent.prompt_injection_canaries` that emit
200
+ `parse.agent.prompt_injection_detected` (and refuse when
201
+ `canary_action = :refuse`); `Parse::Agent::PROMPT_VERSION` surfaced via
202
+ `agent.describe[:prompt][:version]`; and a one-time warning when
203
+ `allowed_llm_endpoints` is left unrestricted. The `allowed_llm_endpoints`
204
+ allowlist matches on the request's `scheme://host:port` origin (the path is
205
+ ignored), so an entry like `https://api.openai.com` authorizes any path on
206
+ that host but not `https://api.openai.com.evil.com` or
207
+ `…openai.com@evil.com`; a malformed endpoint or entry is a fail-closed miss.
208
+ (`lib/parse/agent/prompt_hardening.rb`)
209
+ - **NEW**: Embedding-cost telemetry on `parse.agent.tool_call` — embedding calls
210
+ made inside a tool span contribute `embed_calls`, `embed_tokens`, and (when
211
+ `Parse::Agent.embed_cost_per_million_tokens` is set) `embed_cost_usd`,
212
+ attributed via a thread-local accumulator fed by the `parse.embeddings.embed`
213
+ notification. (`lib/parse/agent.rb`)
214
+ - **NEW**: Optional per-row `_source` provenance (`{ class, tool, object_id }`)
215
+ on read-tool results, enabled with `Parse::Agent.include_source_provenance`
216
+ (default off). Stamped after field-allowlist projection and redaction across
217
+ `query_class`, `get_objects`, `aggregate`, `atlas_text_search`, and
218
+ `semantic_search`. (`lib/parse/agent/tools.rb`, `lib/parse/retrieval/agent_tool.rb`)
219
+ - **NEW**: General-purpose server-initiated notification stream.
220
+ `MCPRackApp.new(notifications: true)` opens the GET listening-stream bus
221
+ without enabling LiveQuery resource subscriptions, and
222
+ `MCPRackApp#notify(session_id, method:, params:)` pushes arbitrary
223
+ `notifications/*` events to a session. (`lib/parse/agent/mcp_rack_app.rb`)
224
+ - **SECURITY**: the MCP listening stream is now owner-bound. A session
225
+ established through `initialize` is bound to that caller's principal, and
226
+ only the same principal may later open its server→client SSE stream; a
227
+ different authenticated caller who knows or guesses the `Mcp-Session-Id` is
228
+ refused with `403`. An id never seen by `initialize` (the decoupled
229
+ `notifications:` bus) is claimed trust-on-first-use by the first principal to
230
+ attach, so a second principal can no longer evict or shadow an existing
231
+ listener. The principal is derived from the agent's scope (session_token →
232
+ acl_user → acl_role); a new `MCPRackApp.new(principal_resolver:)` callable
233
+ lets a master-key deployment that authenticates users upstream supply a real
234
+ per-user principal (without it, bare master-key agents share one principal
235
+ and owner-binding is a no-op among them). The binding registry is
236
+ per-process and does not span Puma workers or survive restart — the same
237
+ scope as the cancellation registry — so in a cluster the `initialize`
238
+ binding degrades to trust-on-first-use. The GET stream must carry the same
239
+ credential as `initialize`. (`lib/parse/agent/mcp_rack_app.rb`)
240
+ - **IMPROVED**: operator fail-loud lints for two silent misconfigurations.
241
+ (1) A `:write`/`:admin` agent served over MCP with `Parse::Agent.require_approval_for`
242
+ empty emits a one-time `[Parse::Agent:SECURITY]` warning (every write runs
243
+ ungated). (2) When any class declares `agent_tenant_scope`, a class explicitly
244
+ exposed to agents (via `agent_fields` or `agent_searchable`) that declares none
245
+ emits a one-time per-class warning on the query path (the search path already
246
+ raises `MissingTenantScope`) — surfacing the silent cross-tenant pass-through
247
+ instead of leaving it to leaked rows. The warning is gated to agent-exposed
248
+ classes so system/incidental classes a tool merely touches don't create noise.
249
+ (`lib/parse/agent/mcp_rack_app.rb`, `lib/parse/agent/metadata_registry.rb`)
250
+ - **NEW**: MCP approval round-trips emit a `parse.agent.approval`
251
+ `ActiveSupport::Notifications` event carrying `tool`, `effective_permission`,
252
+ `correlation_id`, `timeout`, `outcome`, and `reason`, with the measured wait
253
+ as the event duration — so a non-answering client holding a dispatcher thread
254
+ for the full `approval_timeout` is observable. (`lib/parse/agent/approval_gate.rb`)
255
+
256
+ #### Token economy — leaner tool surface, responses, and retrieval
257
+
258
+ The MCP surface is paid for in LLM context tokens. This batch cuts the fixed
259
+ per-session tool tax, trims per-row response overhead, and guards retrieval
260
+ against silent context blowout.
261
+
262
+ - **NEW**: `Parse::Agent.new(tools: :lean)` — a named tool-surface profile that
263
+ narrows `:readonly` to the six core read tools (`get_all_schemas`,
264
+ `get_schema`, `query_class`, `count_objects`, `get_object`, `aggregate`),
265
+ taking a full `tools/list` payload from ~7.9K context tokens to ~2.6K (~67%).
266
+ Profiles (`Parse::Agent::TOOL_PROFILES`) are Symbol-only, compose with the
267
+ permission tier (narrow-only), and raise on an unknown name rather than
268
+ silently exposing the full surface. (`lib/parse/agent.rb`)
269
+ - **IMPROVED**: read-tool responses now strip the raw `ACL` map before reaching
270
+ the model — it is operationally useless to a model (effective authority is
271
+ enforced server-side) and is pure token overhead plus a minor role/user-id
272
+ disclosure. (`lib/parse/agent/result_formatter.rb`)
273
+ - **FIXED**: `get_objects` and the Atlas Search tools now normalize rows through
274
+ the same `simplify_object` form `query_class` uses (compact pointers, ISO
275
+ dates, ACL stripped) instead of shipping raw wire-form with `__type` dicts.
276
+ (`lib/parse/agent/tools.rb`)
277
+ - **CHANGED**: the `semantic_search` result hoists each chunk's parent record
278
+ **once** into a `documents` map keyed by `objectId` instead of duplicating the
279
+ full source on every chunk — map a chunk to its source via
280
+ `metadata.object_id`. Saves the repeated-source cost on every multi-chunk
281
+ document. (`lib/parse/retrieval/agent_tool.rb`)
282
+ - **NEW**: `semantic_search` `max_total_tokens:` budget (default 20,000;
283
+ estimated chars/4) trims the lowest-ranked chunks so a few long documents
284
+ can't silently blow the context window, adding `budget_truncated: true` /
285
+ `budget_dropped: <n>` when it trims. Pass `0` to disable.
286
+ (`lib/parse/retrieval/agent_tool.rb`)
287
+ - **IMPROVED**: a failing `tools/call` now forwards its structured
288
+ `error_code` / `retry_after` / `details` on the MCP error envelope under
289
+ `_meta` (`parse.error_code`, `parse.retry_after`, `parse.details`) so clients
290
+ can branch deterministically and honor `retry_after` without parsing prose.
291
+ (`lib/parse/agent/mcp_dispatcher.rb`)
292
+ - **IMPROVED**: `get_schema` on a mistyped class name raises a `ValidationError`
293
+ carrying a "Did you mean: …?" hint (near matches from locally-known classes),
294
+ letting the model self-correct in one retry instead of a full
295
+ `get_all_schemas` sweep. (`lib/parse/agent/tools.rb`)
296
+ - **NEW**: `Parse::Agent.measure_embeddings { … }` scopes embedding usage
297
+ (`{ calls:, tokens:, cost_usd: }`) around arbitrary work on the calling
298
+ thread — capturing corpus/ingestion embeds fired at `Model.save` time that
299
+ the per-tool-call telemetry does not span. `Parse::Agent.embed_cost_usd(tokens)`
300
+ exposes the rate conversion. (`lib/parse/agent.rb`)
301
+
302
+ #### Expanded integration test coverage
303
+
304
+ - **IMPROVED**: Added end-to-end coverage for cloud-function error scenarios —
305
+ a bare cloud throw, a typed `Parse.Error`, and an application-defined error
306
+ code all surface as `Parse::Error::CloudCodeError` with the wire code,
307
+ message, and HTTP status preserved, and a `beforeSave` validation rejection
308
+ propagates to the object save path.
309
+ (`test/lib/parse/cloud_function_errors_integration_test.rb`)
310
+ - **IMPROVED**: Added "disruptive" integration tests that exercise a real Parse
311
+ Server outage and restart against a live container: connectivity predicates
312
+ flip to false and recover, in-flight requests raise a connection-class error,
313
+ and a registered webhook survives a server restart with idempotent
314
+ re-registration. Run via `rake test:integration:disruptive` so they never
315
+ interleave with the rest of the suite.
316
+ (`test/lib/parse/network_failure_disruptive_test.rb`,
317
+ `test/lib/parse/webhook_restart_disruptive_test.rb`)
318
+
319
+ #### `unique_index_on` — declarative correctness floor for `first_or_create!`
320
+
321
+ `Parse::Object` subclasses can now declare the MongoDB unique index that backs
322
+ the `first_or_create!` / `create_or_update!` race fix directly on the model,
323
+ with intent-revealing syntax. The Redis-backed `synchronize:` lock is a latency
324
+ optimization that collapses concurrent callers in the common path; the unique
325
+ index is the correctness floor that holds when the lock is bypassed — a Redis
326
+ outage, a TTL expiring mid-write, or a `synchronize: false` caller. With the
327
+ index in place a duplicate insert surfaces as Parse error 137 (DuplicateValue),
328
+ which `first_or_create!` already rescues and resolves to the winning row, so the
329
+ net invariant — exactly one row, every caller sees the same id — holds under any
330
+ race.
331
+
332
+ - **NEW**: `unique_index_on(*fields, sparse: false, partial: nil, name: nil)`
333
+ declares a unique index on the exact dedup tuple. It is thin sugar over
334
+ `mongo_index(*fields, unique: true, …)`, sharing the same registration,
335
+ validation (sensitive-field guard, pointer auto-rewrite to `_p_<field>`,
336
+ parallel-array / relation / `_id` rejection), and `apply_indexes!` writer
337
+ path. (`lib/parse/model/core/indexing.rb`)
338
+ - **NEW**: The default is non-sparse, keeping the index key identical to the
339
+ query `first_or_create!` re-runs on recovery so a 137 always maps to a row the
340
+ recovery query can find. `partial:` is the documented escape hatch for
341
+ "unique within a subset" (e.g. unique email per tenant, where tenant-less rows
342
+ may repeat); `sparse:` only changes behavior for rows missing the entire
343
+ tuple, which the create path never produces.
344
+
345
+ #### MCP resource subscriptions bridged to LiveQuery
346
+
347
+ The MCP server can now serve `resources/subscribe` and push
348
+ `notifications/resources/updated` over a server→client channel, backed by
349
+ Parse LiveQuery. A client subscribes to a class's `count` or `samples`
350
+ resource; when the underlying data changes, the server emits one coarse
351
+ update for that URI and the client re-reads the resource. Disabled by default
352
+ and only advertised when LiveQuery is enabled and available, so the
353
+ `resources.subscribe` capability is never claimed unless the server can
354
+ actually deliver.
355
+
356
+ - **NEW**: `Parse::Agent::MCPRackApp.new(resource_subscriptions: true)` accepts
357
+ a long-lived `GET` (`Accept: text/event-stream`, `Mcp-Session-Id`) as the
358
+ listening stream that carries `notifications/resources/updated`, and routes
359
+ `resources/subscribe` / `resources/unsubscribe` to a LiveQuery bridge.
360
+ Requires a streaming-capable Rack server (Puma, Falcon) and
361
+ `Parse.live_query_enabled = true`. (`lib/parse/agent/mcp_rack_app.rb`)
362
+ - **NEW**: `Parse::Agent::MCPSubscriptions::Manager` coordinates per-session
363
+ subscriptions, derives LiveQuery credentials from the subscribing agent,
364
+ debounces bursts per `(session, uri)` into a single update, and tears down
365
+ LiveQuery subscriptions when the listening stream closes or the session is
366
+ terminated via `DELETE`. (`lib/parse/agent/mcp_subscriptions.rb`)
367
+ - **NEW**: `resources.subscribe` is advertised on `initialize` only when a
368
+ supported subscription manager is wired; the WEBrick `MCPServer` and the
369
+ Rack app with subscriptions disabled continue to advertise `subscribe:
370
+ false`. (`lib/parse/agent/mcp_dispatcher.rb`)
371
+ - **SECURITY**: the LiveQuery bridge enforces the SDK's scope asymmetry —
372
+ session-token agents subscribe with their token (Parse Server filters events
373
+ to readable rows), master-key agents subscribe over a dedicated admin
374
+ LiveQuery connection, and `acl_user:` / `acl_role:` agents are refused
375
+ because Parse Server LiveQuery has no act-as-user / act-as-role handshake.
376
+ Because Parse Server fixes ACL-bypass at connect time (there is no
377
+ per-subscription master key), master- and session-scoped subscriptions are
378
+ routed to separate connections; the bridge fails closed rather than open a
379
+ mis-scoped channel. (`lib/parse/agent/mcp_subscriptions.rb`)
380
+ - **SECURITY**: `resources/subscribe` enforces the same class-authorization gate
381
+ as the read path before opening any socket — `agent_hidden` declarations and
382
+ the per-agent `classes:` allowlist are checked via
383
+ `Tools.assert_class_accessible!`, so a hidden or out-of-allowlist class (e.g.
384
+ `parse://_Session/count`) can no longer become a change/timing oracle through
385
+ a subscription that bypasses the tool surface. A denial opens no socket.
386
+ (`lib/parse/agent/mcp_subscriptions.rb`, `lib/parse/agent/mcp_dispatcher.rb`)
387
+ - **SECURITY**: the master-key subscription branch is bound to the agent's own
388
+ master-key authority. Because the admin LiveQuery client backfills the
389
+ process-global master key, an unprivileged / client-mode agent (no master key
390
+ on its own client) in a no-scope posture is now refused rather than allowed to
391
+ borrow the global key for an ACL-bypassing admin socket. Symmetrically, a
392
+ session-token subscription is refused if the shared scoped LiveQuery client is
393
+ itself an admin connection (`config.use_master_key = true`), so it can never
394
+ ride an ACL-bypassing socket. The subscribe and cap checks are also
395
+ re-validated under the lock after the network subscribe so a torn-down session
396
+ can't be resurrected into a leaked socket. (`lib/parse/agent/mcp_subscriptions.rb`)
397
+ - **NEW**: only `count` and `samples` resources are subscribable; `schema` is
398
+ rejected because schema changes are not LiveQuery events. A per-session
399
+ subscription cap (default 100) bounds the footprint of a client that
400
+ subscribes but never opens its listening stream.
401
+
402
+ #### Fix wrong pointer/relation `targetClass` for built-in system-class associations
403
+
404
+ A built-in association to a Parse Server system class could freeze the wrong
405
+ `targetClass` into the generated schema. `Parse::Installation`'s
406
+ `belongs_to :user` (and, by the same mechanism, `Parse::Session#user` and
407
+ `Parse::Role#users`) resolved its target through `:user.to_parse_class`,
408
+ which depends on `Parse::User` already being registered. Because the gem
409
+ loads the built-in classes before `user.rb`, the lookup fell back to the
410
+ camelized literal `"User"` instead of the Parse storage name `"_User"` and
411
+ stored it in the association `references` / `relations` map. That literal was
412
+ then emitted as the pointer column's `targetClass`, so a fresh schema push
413
+ created `_Installation.user` with `targetClass: "User"` — and Parse Server
414
+ rejected every `_User` pointer save against it (`expected Pointer<User> but
415
+ got Pointer<_User>`). This is the root cause underlying the spurious
416
+ className-mismatch warnings addressed cosmetically in 5.1.1.
417
+
418
+ - **FIXED**: `belongs_to` / `has_many` / `has_one` associations to a Parse
419
+ Server system class (`User`, `Role`, `Session`, `Installation`, and the
420
+ other built-ins) now resolve to the correct leading-underscore storage name
421
+ regardless of class load order. `Parse::Installation.references[:user]`,
422
+ `Parse::Session.references[:user]`, and `Parse::Role.relations[:users]` now
423
+ hold `"_User"`, so the emitted schema pointer/relation `targetClass` is
424
+ `"_User"` and `_User` pointer saves are accepted.
425
+ (`lib/parse/model/model.rb`)
426
+ - **FIXED**: `String#to_parse_class` / `Symbol#to_parse_class` now resolve a
427
+ built-in system class by name even when its Ruby class is not yet
428
+ registered, restoring the documented contract
429
+ (`"users".to_parse_class(singularize: true) # => "_User"`). A registered
430
+ class with a custom `parse_class` table mapping still takes precedence, so
431
+ application classes are never rewritten. (`lib/parse/model/model.rb`)
432
+ - **NEW**: `Parse::Model::SYSTEM_CLASS_MAP` maps each built-in's camelized
433
+ bare name to its storage name; it is consulted by `to_parse_class` only as
434
+ a fallback when class resolution would otherwise fail. (`lib/parse/model/model.rb`)
435
+ - **CHANGED**: The one-time `_Installation` CLP advisory is now
436
+ operation-aware. `set_clp` and `set_class_access` warn only for the
437
+ operations Parse Server ignores on `_Installation` (`find`, `create`,
438
+ `update`, `delete`); configuring the operations it honors (`get`, `count`,
439
+ `addField`) no longer emits a warning. The pointer-permission helpers
440
+ `set_read_user_fields` / `set_write_user_fields` still warn, since they have
441
+ no reliable owner identity to bind to on `_Installation`.
442
+ (`lib/parse/model/classes/installation.rb`)
443
+
444
+ This corrects schema generation going forward. An environment whose schema was
445
+ already pushed with the wrong `targetClass` must have that schema re-pushed
446
+ (for example by re-running the schema upgrade) after upgrading; an existing
447
+ pointer column's `targetClass` cannot be altered in place and must be replaced.
448
+
449
+ #### Harden `$relatedTo` against cross-class access on agent and mongo-direct paths
450
+
451
+ The `$relatedTo` query operator reaches across to a second class — the owning
452
+ object whose relation is being read — but, unlike the other cross-class
453
+ operators (`$inQuery` / `$notInQuery` / `$select` / `$dontSelect`), the class
454
+ named by its `object` pointer was not run through the agent accessibility
455
+ policy. An agent narrowed by `classes:` (or with a class declared
456
+ `agent_hidden`) could therefore name a relation anchored on a class outside its
457
+ allowlist. The same operator on the mongo-direct path was passed through to
458
+ MongoDB verbatim, where it failed with an opaque "unknown operator" error.
459
+
460
+ - **FIXED**: `Parse::Agent::ConstraintTranslator` now validates the owning-object
461
+ class of a `$relatedTo` constraint against the agent's accessibility policy,
462
+ matching how `$inQuery` / `$select` are already gated. The check fails closed
463
+ when the owning class cannot be resolved from the `object` pointer.
464
+ (`lib/parse/agent/constraint_translator.rb`)
465
+ - **FIXED**: the constraint translator now classifies each key of a constraint
466
+ hash independently instead of only validating operators when *every* key in
467
+ the hash is an operator. An operator sharing a hash with a non-operator field
468
+ key — reachable as a `$or` / `$and` / `$nor` array element — was previously
469
+ routed to the field branch and skipped operator validation, so a blocked
470
+ operator (e.g. `$where`) or an off-allowlist cross-class / relation reference
471
+ could pass through alongside a throwaway field key. Operators are now
472
+ validated and dispatched at every nesting level regardless of sibling keys.
473
+ (`lib/parse/agent/constraint_translator.rb`)
474
+ - **FIXED**: `$relatedTo` on the mongo-direct query path
475
+ (`results_direct` / `count_direct` / `distinct_direct`) now raises a clear
476
+ `ArgumentError` explaining that Parse Relations are resolved server-side, so
477
+ the query must run via REST. This replaces the previous opaque MongoDB error
478
+ and forecloses a future `$lookup` rewrite that could bypass the row-level
479
+ `_rperm` / `protectedFields` enforcement the rest of that path applies.
480
+ (`lib/parse/query.rb`)
481
+
482
+ #### Schema migration — correct wire-column names and a one-way convergence check
483
+
484
+ The migration tooling derived every wire column name with `camelize(:lower)`,
485
+ which ignored custom `field:` mappings and double-counted multi-word
486
+ properties (each property is registered under both its snake_case key and its
487
+ camelCase wire alias). Wire names are now resolved through the model's
488
+ `field_map`, so each column is emitted exactly once and custom mappings are
489
+ honored.
490
+
491
+ - **FIXED**: `Parse::Schema.migration(Klass).preview` / `.operations` no longer
492
+ list multi-word fields twice (e.g. `ADD FIELD unitPrice` appeared once per
493
+ alias). Each declared property now produces exactly one operation.
494
+ (`lib/parse/schema.rb`)
495
+ - **FIXED**: `auto_upgrade!` and `Migration#apply!` now create the column a
496
+ property actually serializes to. For `property :unit_price, field: "price_usd"`
497
+ the migrator previously created a phantom `unitPrice` (or `priceUsd`) column
498
+ instead of the declared `price_usd`; it now creates `price_usd`. Pointer
499
+ columns are likewise emitted at their true wire name. (`lib/parse/schema.rb`)
500
+ - **FIXED**: `SchemaDiff#missing_on_server` is keyed by the canonical property
501
+ name with no camelCase alias duplicates.
502
+ - **NEW**: `Parse::Schema::SchemaDiff#server_covers_local?` — a one-way
503
+ convergence check (`missing_on_server.empty? && type_mismatches.empty?`) for
504
+ CI pipelines. Unlike `in_sync?` (which is strict and bidirectional, so it
505
+ reports `false` whenever the server has columns the model does not declare —
506
+ e.g. a dashboard-added field), `server_covers_local?` answers the question a
507
+ deploy gate actually asks: "is every field my model declares present on the
508
+ server?" (`lib/parse/schema.rb`)
509
+ - **FIXED**: `Migration#needed?` is now defined in terms of
510
+ `server_covers_local?`, so a server that is a superset of the model no longer
511
+ produces a "needed" migration with zero operations.
512
+
513
+ #### Aggregation — `group_by` class methods and operation-aware table headers
514
+
515
+ - **NEW**: `Klass.group_by` and `Klass.group_by_date` class-method delegators,
516
+ mirroring the existing `Klass.distinct` / `Klass.count_distinct` delegation.
517
+ `Post.group_by(:category).count` now works without the explicit
518
+ `Post.query.group_by(...)` form. (`lib/parse/model/core/querying.rb`)
519
+ - **FIXED**: `Parse::GroupedResult#to_table` derives the value-column header
520
+ from the aggregation operation (`Average`, `Sum`, `Min`, `Max`, …) instead of
521
+ always printing `Count`, so an averaged table is no longer mislabeled. An
522
+ explicit `headers:` override still takes precedence, and results constructed
523
+ without an operation continue to default to `Count`. (`lib/parse/query.rb`)
524
+
525
+ #### Connectivity probes — `Parse.connected?` / `Parse.reachable?`
526
+
527
+ - **NEW**: `Parse.reachable?` / `Parse::Client#reachable?` — a no-credentials
528
+ liveness probe that hits the server health endpoint; passes whenever the
529
+ server is up, even if the configured `application_id` / REST key is wrong.
530
+ - **NEW**: `Parse.connected?` / `Parse::Client#connected?` — a connectivity
531
+ probe that by default hits the health endpoint, so it returns `true` whenever
532
+ the server is up, regardless of Class-Level-Permission configuration. (A
533
+ `_User` find is unreliable as the default probe: locking `_User` finds to the
534
+ master key via CLP is standard production hardening and would make the probe
535
+ report "not connected" on a perfectly healthy, correctly-configured server.)
536
+ Pass an endpoint — `connected?("classes/_User")`, or
537
+ `Parse.connected?(:default, "classes/_User")` — to additionally validate
538
+ credentials against a class the configured key can read; the probe runs
539
+ `limit: 0` (never pulls rows) and routes through the auth stack, so a wrong
540
+ `application_id` / REST key returns `false`. Connection failures, timeouts,
541
+ and API errors all return `false` rather than raising; genuine programming
542
+ errors still propagate. (`lib/parse/client.rb`)
543
+
544
+ #### `request_password_reset` — documented failure mode
545
+
546
+ - **IMPROVED**: `Parse::User.request_password_reset` (and the instance form)
547
+ now document that they raise `Parse::Error::ServiceUnavailableError` when the
548
+ server returns 500/503 (for example, when no email adapter is configured), so
549
+ callers branching on the documented Boolean return know to rescue it.
550
+ (`lib/parse/model/classes/user.rb`)
551
+
552
+ #### `Parse::Client#request` — bounded, idempotency-aware retries on 500/503/429
553
+
554
+ - **FIXED**: a request that received a persistent `500`/`503`
555
+ (`ServiceUnavailableError`) or `429` (`RequestLimitExceededError`) retried
556
+ **forever** instead of giving up after `retry_limit` attempts. The retry path
557
+ uses Ruby's `retry` keyword, which re-runs the method body; because the retry
558
+ counter was initialized inside that re-run, every attempt reset it back to
559
+ `retry_limit`. The counter is now initialized once, above the retried block,
560
+ so the countdown is preserved — a persistent failure makes exactly
561
+ `retry_limit + 1` attempts and then raises. An explicit `opts[:retry]` count
562
+ is likewise honored and bounded. (`lib/parse/client.rb`)
563
+ - **FIXED**: the backoff delay collapsed to zero (or negative, so no
564
+ sleep at all) whenever a caller passed `opts: { retry: N }` with `N` above the
565
+ client's `retry_limit`, because the backoff multiplier was derived from
566
+ `retry_limit` rather than the effective starting budget. The multiplier now
567
+ uses the actual starting count, so every retry backs off by a strictly
568
+ positive, growing delay. (Backoff is linear in the attempt number —
569
+ `RETRY_DELAY × attempt` — with ±25% jitter.) (`lib/parse/client.rb`)
570
+ - **CHANGED**: retries are now idempotency-aware, so a transient server error
571
+ can't double-apply a write. A `429` re-sends regardless of method (the server
572
+ provably discarded the request). For an ambiguous failure — a `500`/`503` or a
573
+ dropped connection, where the write may already have applied — only idempotent
574
+ requests are re-sent: `GET` and `DELETE` always, a `PUT` update only when its
575
+ body carries no atomic operation (`Increment` / `Add` / `AddUnique` /
576
+ `Remove` / relation ops), and `POST` (object create / batch) never.
577
+ (`lib/parse/client.rb`)
578
+ - **FIXED**: a request that hit a read timeout was not retried. Faraday 2.x
579
+ raises `Faraday::TimeoutError` (a `Faraday::Error`, not a `Faraday::ClientError`)
580
+ for `Timeout::Error` / `Errno::ETIMEDOUT`, which the retry clause didn't list,
581
+ so a timed-out request propagated raw instead of being retried (when
582
+ idempotent) and wrapped as `Parse::Error::ConnectionError`. The clause now
583
+ catches `Faraday::TimeoutError`. Connection-*refused* (`Faraday::ConnectionFailed`)
584
+ is intentionally still not retried — it is a non-transient failure and
585
+ retrying it only adds backoff latency. (`lib/parse/client.rb`)
586
+ - **NEW**: `Parse::Request.assume_server_idempotency` — an explicit opt-in that
587
+ makes writes retry-safe end-to-end on ambiguous failures. The SDK already
588
+ sends a stable `X-Parse-Request-Id` (`_RB_<uuid>`) on POST/PUT/PATCH by
589
+ default (`Parse::Request.enable_request_id`), and now reuses the SAME id on
590
+ every retry attempt, so when Parse Server is configured with
591
+ `idempotencyOptions` covering the targeted paths, a replayed write is
592
+ deduplicated server-side — the second delivery never creates a duplicate.
593
+ With this flag set (default `false`, also settable via
594
+ `enable_idempotency!(assume_server_dedup: true)` / `configure_idempotency`),
595
+ the retry guard treats any request carrying a request-id header as retry-safe
596
+ regardless of method — so a `POST` create or an atomic-op `PUT` that hits a
597
+ `500`/`503` or a request timeout is transparently retried. Left off, behavior
598
+ is unchanged (the client never assumes the server dedups). On the rare
599
+ ambiguous-success case (the first attempt landed but its response was lost),
600
+ Parse Server answers the replay with a `Duplicate request` (Parse code `159`),
601
+ which the SDK now surfaces as a typed, catchable `Parse::Error::DuplicateRequestError`
602
+ meaning "the original write already applied" — re-fetch by your own key if you
603
+ need the resulting object (Parse Server does not echo the original response on
604
+ a duplicate). Proven end-to-end by
605
+ `test/lib/parse/client/idempotent_retry_integration_test.rb` against a test
606
+ stack configured with path-scoped server idempotency.
607
+ (`lib/parse/client/request.rb`, `lib/parse/client.rb`)
608
+ - **CHANGED**: two error-surface changes above are behavioral, not just
609
+ additive — review downstream `rescue` chains when upgrading. (1) Parse code
610
+ `159` now raises `Parse::Error::DuplicateRequestError`; previously it fell
611
+ through and returned a response with `success? == false`. Callers that branch
612
+ on `response.success?` / `response.error` instead of rescuing will now see an
613
+ exception — rescue `DuplicateRequestError` and treat it as "already applied."
614
+ (2) A read timeout now raises `Parse::Error::ConnectionError` (after an
615
+ idempotency-gated retry); previously `Faraday::TimeoutError` propagated raw,
616
+ so any `rescue Faraday::TimeoutError` becomes dead code. (`lib/parse/client.rb`)
617
+ - **NEW**: `Parse::Error::DuplicateRequestError` (Parse code `159`,
618
+ `Parse::Response::ERROR_DUPLICATE_REQUEST`) — raised when Parse Server's
619
+ request-id idempotency layer rejects a duplicate `X-Parse-Request-Id`. The
620
+ duplicate is not applied a second time; the original request already
621
+ succeeded. (`lib/parse/client.rb`, `lib/parse/client/response.rb`)
622
+ - **IMPROVED**: `first_or_create!` and `create_or_update!` now recover
623
+ transparently from a `DuplicateRequestError`. These methods already carry the
624
+ identifying `query_attrs`, so when a transparently-retried create lands but
625
+ loses its response (and the replay is rejected with 159), they re-find the row
626
+ the original attempt created and return it — turning the duplicate into a
627
+ successful find instead of a raised error. Applies on both the synchronized
628
+ (`synchronize:`) and unsynchronized paths, and composes with the existing
629
+ duplicate-*value* (unique-index) recovery. A plain `save!` still surfaces
630
+ `DuplicateRequestError` (a generic create has no natural key to re-fetch by;
631
+ catch it and re-query your own unique field). (`lib/parse/model/core/actions.rb`)
632
+
3
633
  ### 5.1.1
4
634
 
5
635
  #### Suppress spurious className-mismatch warnings for system-class underscore aliases
data/Gemfile CHANGED
@@ -12,6 +12,9 @@ group :test, :development do
12
12
  gem "minitest-mock"
13
13
  gem 'minitest-reporters'
14
14
  gem "pry"
15
+ # bundler-audit: scans Gemfile.lock against the ruby-advisory-db for known
16
+ # CVEs. Used by the upstream-watch skill and dependency review.
17
+ gem "bundler-audit", ">= 0.9"
15
18
  gem "yard", ">= 0.9.11"
16
19
  # Rack 3 removed Rack::Server (used by `yard server`); the rackup gem
17
20
  # restores it. Drop this once YARD's server adapter stops referencing it.