parse-stack-next 5.1.1 → 5.2.0

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