parse-stack-next 5.7.6 → 5.8.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 (97) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +830 -0
  3. data/README.md +14 -4
  4. data/docs/TEST_SERVER.md +2 -2
  5. data/docs/acl_clp_guide.md +7 -0
  6. data/docs/atlas_vector_search_guide.md +181 -13
  7. data/docs/client_sdk_guide.md +11 -0
  8. data/docs/mcp_guide.md +317 -6
  9. data/docs/mongodb_direct_guide.md +27 -0
  10. data/docs/usage_guide.md +38 -0
  11. data/docs/webhooks_guide.md +74 -17
  12. data/lib/parse/acl_scope.rb +159 -41
  13. data/lib/parse/agent/approval_gate.rb +0 -0
  14. data/lib/parse/agent/constraint_translator.rb +42 -15
  15. data/lib/parse/agent/describe.rb +3 -1
  16. data/lib/parse/agent/field_names.rb +53 -0
  17. data/lib/parse/agent/field_policy.rb +74 -0
  18. data/lib/parse/agent/mcp_deployments.rb +426 -0
  19. data/lib/parse/agent/mcp_rack_app.rb +424 -45
  20. data/lib/parse/agent/mcp_server.rb +23 -1
  21. data/lib/parse/agent/mcp_subscriptions.rb +124 -6
  22. data/lib/parse/agent/metadata_registry.rb +67 -8
  23. data/lib/parse/agent/prompt_hardening.rb +9 -3
  24. data/lib/parse/agent/tools.rb +378 -29
  25. data/lib/parse/agent.rb +93 -1
  26. data/lib/parse/api/batch.rb +10 -1
  27. data/lib/parse/api/schema.rb +23 -4
  28. data/lib/parse/api/sessions.rb +6 -2
  29. data/lib/parse/api/users.rb +88 -14
  30. data/lib/parse/atlas_search/protected_paths.rb +236 -0
  31. data/lib/parse/atlas_search.rb +95 -23
  32. data/lib/parse/authorization.rb +54 -1
  33. data/lib/parse/client/batch.rb +231 -35
  34. data/lib/parse/client/body_builder.rb +21 -0
  35. data/lib/parse/client/caching.rb +371 -27
  36. data/lib/parse/client/request.rb +26 -14
  37. data/lib/parse/client/response.rb +49 -6
  38. data/lib/parse/client.rb +201 -38
  39. data/lib/parse/clp_scope.rb +281 -23
  40. data/lib/parse/console.rb +2 -2
  41. data/lib/parse/embeddings/voyage.rb +181 -17
  42. data/lib/parse/graphql/type_generator.rb +3 -0
  43. data/lib/parse/model/acl.rb +119 -21
  44. data/lib/parse/model/associations/belongs_to.rb +25 -3
  45. data/lib/parse/model/associations/collection_proxy.rb +138 -17
  46. data/lib/parse/model/associations/has_many.rb +38 -9
  47. data/lib/parse/model/associations/has_one.rb +3 -1
  48. data/lib/parse/model/associations/pointer_collection_proxy.rb +109 -17
  49. data/lib/parse/model/associations/relation_collection_proxy.rb +134 -28
  50. data/lib/parse/model/bytes.rb +13 -5
  51. data/lib/parse/model/classes/role.rb +72 -0
  52. data/lib/parse/model/classes/session.rb +43 -0
  53. data/lib/parse/model/classes/user.rb +78 -3
  54. data/lib/parse/model/core/actions.rb +269 -67
  55. data/lib/parse/model/core/builder.rb +100 -8
  56. data/lib/parse/model/core/create_lock.rb +27 -2
  57. data/lib/parse/model/core/describe.rb +2 -0
  58. data/lib/parse/model/core/fetching.rb +21 -3
  59. data/lib/parse/model/core/pluralized_aliases.rb +8 -4
  60. data/lib/parse/model/core/properties.rb +488 -39
  61. data/lib/parse/model/core/querying.rb +7 -0
  62. data/lib/parse/model/core/schema.rb +5 -3
  63. data/lib/parse/model/core/search_indexing.rb +63 -0
  64. data/lib/parse/model/core/vector_searchable.rb +35 -6
  65. data/lib/parse/model/file.rb +9 -2
  66. data/lib/parse/model/geopoint.rb +61 -13
  67. data/lib/parse/model/model.rb +160 -9
  68. data/lib/parse/model/object.rb +265 -17
  69. data/lib/parse/model/phone.rb +54 -5
  70. data/lib/parse/model/pointer.rb +40 -6
  71. data/lib/parse/mongodb.rb +170 -60
  72. data/lib/parse/pipeline_security.rb +415 -26
  73. data/lib/parse/query/constraint.rb +30 -0
  74. data/lib/parse/query/constraints.rb +58 -32
  75. data/lib/parse/query/cursor.rb +3 -1
  76. data/lib/parse/query/operation.rb +62 -8
  77. data/lib/parse/query/ordering.rb +34 -6
  78. data/lib/parse/query.rb +1100 -134
  79. data/lib/parse/retrieval/agent_tool.rb +225 -8
  80. data/lib/parse/retrieval/benchmark.rb +149 -0
  81. data/lib/parse/retrieval/profiles.rb +320 -0
  82. data/lib/parse/retrieval/retriever.rb +10 -1
  83. data/lib/parse/retrieval.rb +2 -0
  84. data/lib/parse/schema/search_index_migrator.rb +23 -5
  85. data/lib/parse/schema.rb +74 -18
  86. data/lib/parse/stack/tasks.rb +6 -4
  87. data/lib/parse/stack/version.rb +1 -1
  88. data/lib/parse/stack.rb +72 -14
  89. data/lib/parse/two_factor_auth/user_extension.rb +14 -2
  90. data/lib/parse/two_factor_auth.rb +11 -0
  91. data/lib/parse/vector_search/hybrid.rb +36 -18
  92. data/lib/parse/vector_search/index_definition.rb +237 -0
  93. data/lib/parse/vector_search.rb +46 -17
  94. data/lib/parse/webhooks/payload.rb +93 -6
  95. data/lib/parse/webhooks/replay_protection.rb +58 -20
  96. data/lib/parse/webhooks.rb +412 -40
  97. metadata +8 -1
data/docs/mcp_guide.md CHANGED
@@ -396,6 +396,177 @@ Common uses for the direct dispatcher:
396
396
 
397
397
  ---
398
398
 
399
+ ## Deployment Patterns
400
+
401
+ `MCPRackApp.new(agent_factory: ...)` accepts any factory. For the two common
402
+ production shapes, two factories package the identity rules so a deployment
403
+ cannot drift from one access mode into the other by accident:
404
+
405
+ | Factory | Use it for | Identity | Data access |
406
+ |---|---|---|---|
407
+ | `MCPRackApp.user_scoped` | Personal assistants, application users | A Parse session token on every request; missing or invalid is 401 | The user's own ACL/CLP, enforced by Parse Server on REST and by the SDK on mongo-direct paths. No master-key fallback |
408
+ | `MCPRackApp.master_analytics` | Analytics, reporting, trusted operational tools | A verified operator from a required `principal_resolver`; unresolved is 401 | Master authority, narrowed by the agent's `tools:`, `classes:`, `filters:`, and field policy. Read-only by default |
409
+
410
+ Both factories take `agent_options:` (extra `Parse::Agent.new` options such
411
+ as `tools:`, `methods:`, `classes:`, `filters:`) and pass the remaining
412
+ keywords to `MCPRackApp.new` (`transport:`, `logger:`, `allowed_origins:`,
413
+ ...). Options that set identity or authority are refused in
414
+ `agent_options:` with `ArgumentError` at construction: `session_token`,
415
+ `acl_user`, `acl_role`, `impersonate_user`, `impersonation_user`,
416
+ `impersonate_mint`, `impersonation_mint`, `impersonate_label`,
417
+ `impersonation_label`, `tenant_id`, `client`, `permissions`, `permission`, and
418
+ `parent`. `user_scoped` also refuses `master_atlas` and `allow_mutations`,
419
+ since a signed-in user's agent never receives authority beyond its session.
420
+ The factory owns the Rack options `agent_factory:`, `principal_resolver:`,
421
+ `listening_stream_revalidator:`, and `listening_stream_revalidate_interval:`,
422
+ so passing any of them is refused as well. A deliberate single-operator
423
+ master-key endpoint is still built with `MCPRackApp.new` directly.
424
+
425
+ ### Personal assistant (user-scoped)
426
+
427
+ ```ruby
428
+ # config/routes.rb (Rails), or `run` it from config.ru
429
+ MCP_APP = Parse::Agent::MCPRackApp.user_scoped(
430
+ transport: :streamable_http,
431
+ permissions: :write, # writes still go only through agent_methods
432
+ tenant_from: ->(env, user_id) { Workspace.id_for_user(user_id) }, # pinned server-side
433
+ agent_options: { classes: %w[Post Comment], methods: %w[archive] },
434
+ )
435
+ mount MCP_APP, at: "/mcp"
436
+ ```
437
+
438
+ ```ruby
439
+ # The only write this assistant can make, declared by the application.
440
+ class Post < Parse::Object
441
+ property :archived, :boolean
442
+ property :archive_reason, :string
443
+
444
+ agent_method :archive, permission: :write, supports_dry_run: true, permitted_keys: [:reason]
445
+ def archive(reason:, agent: nil, dry_run: false, **)
446
+ return { would: "archive #{id}", reason: reason } if dry_run
447
+ self.archived = true
448
+ self.archive_reason = reason
449
+ save
450
+ end
451
+ end
452
+
453
+ # Require a human approval (MCP elicitation) before any :write call runs.
454
+ Parse::Agent.require_approval_for = [:write]
455
+ ```
456
+
457
+ The client sends the user's session token as `Authorization: Bearer
458
+ <token>` (or `X-Parse-Session-Token`; pass `session_token_from: ->(env) {
459
+ ... }` for anything else). Every request builds a fresh
460
+ `Parse::Agent.new(session_token: token, ...)`, so the agent acts as that user
461
+ and tool arguments cannot change who it is or which tenant it is bound to.
462
+ `tenant_from` returning nil refuses the request rather than running unscoped.
463
+
464
+ On logout the token stops validating, so the next request gets 401 and any
465
+ open listening stream closes within the revalidation interval. To reconnect,
466
+ the client logs in again and starts a new MCP session (`initialize`) with the
467
+ new token. A session id is bound to the principal that initialized it, so
468
+ the new token cannot take over the old session, and the old session's
469
+ subscriptions are torn down when its stream closes (or reaped as orphans,
470
+ below).
471
+
472
+ ### Read-only analytics endpoint (master key)
473
+
474
+ ```ruby
475
+ ANALYTICS = Parse::Agent::MCPRackApp.master_analytics(
476
+ # Required. Resolve the VERIFIED operator behind the request. Returning
477
+ # nil or "" refuses the request with 401.
478
+ principal_resolver: ->(_agent, env) { env["warden"]&.user&.email },
479
+ transport: :streamable_http,
480
+ agent_options: {
481
+ classes: %w[Post Subscription PostMetric],
482
+ tools: %w[query_class count_objects group_by group_by_date distinct aggregate get_schema],
483
+ },
484
+ )
485
+
486
+ # Audit: every tool call emits parse.agent.tool_call.
487
+ ActiveSupport::Notifications.subscribe("parse.agent.tool_call") do |*, payload|
488
+ AuditLog.record(tool: payload[:tool], correlation_id: payload[:correlation_id])
489
+ end
490
+ ```
491
+
492
+ `principal_resolver:` is a required keyword: `master_analytics` raises
493
+ `ArgumentError` at construction when it is missing or does not respond to
494
+ `#call`. Without one every master-key agent has the same
495
+ fingerprint, so two operators sharing the endpoint could attach to, approve,
496
+ or cancel each other's sessions. The operator identity governs session
497
+ ownership and audit; master authority governs what data the agent can reach,
498
+ and the configured tools and classes govern what it may actually do. It
499
+ defaults to `permissions: :readonly`.
500
+
501
+ The master-key check runs per request, not at construction. The factory
502
+ resolves its client on each request (the `client:` you passed, else
503
+ `Parse.client` at that moment), and any request whose client has no master key
504
+ is answered with 401. Construction succeeds even when the master key is not
505
+ configured yet.
506
+
507
+ ### Session ownership
508
+
509
+ Under both factories, an `Mcp-Session-Id` is bound to the principal that
510
+ initialized it (the session token for `user_scoped`, the resolved operator for
511
+ `master_analytics`). Another caller who knows or chooses the same id cannot:
512
+
513
+ - re-initialize it (403),
514
+ - attach its listening stream (403),
515
+ - cancel its in-flight requests (`notifications/cancelled` is a silent 202 no-op),
516
+ - answer its approval prompts (the elicitation reply is a silent 202 no-op),
517
+ - change its log level.
518
+
519
+ A session with an attached listening stream or a pending approval keeps its
520
+ owner binding for as long as it is live. When the owner registry is full of
521
+ live sessions, a new `initialize` or listening stream is refused with `503`
522
+ rather than displacing one (see Capacity and eviction under the listening
523
+ stream's owner binding, below).
524
+
525
+ ### Revocation intervals
526
+
527
+ How quickly a logged-out, expired, or revoked session (or a role change)
528
+ stops having effect, for a `user_scoped` deployment:
529
+
530
+ | Path | `session_validation: :per_request` (default) | `session_validation: :cached` |
531
+ |---|---|---|
532
+ | New MCP request (any tool) | Next request: the token is re-checked against Parse Server (`GET /users/me`, response cache bypassed) | Identity-cache TTL (`identity_cache_ttl`, default 3600s), or immediately when the `Parse::Cache::Invalidation` `after_logout` / `_User` hooks evict the token |
533
+ | REST tools within an accepted request | Immediate: Parse Server validates the token on every call | Immediate |
534
+ | Mongo-direct reads (identity) | Next request: a failed check also evicts the token from `client.authorization` | Same as the row above |
535
+ | Mongo-direct reads (role change) | Role-cache TTL (`role_cache_ttl`, default 30s), or immediately when the `_Role` invalidation hook fires | Same |
536
+ | Open listening stream / subscriptions | Within `session_revalidate_interval` (default 60s): the stream is re-checked and closed, tearing down its LiveQuery subscriptions | Same (stream re-checks always ask Parse Server) |
537
+
538
+ `:per_request` costs one `/users/me` call per MCP request; `:cached` saves it
539
+ at the price of the identity-TTL window when invalidation hooks are not
540
+ installed. The TTLs are configured with
541
+ `Parse::Authorization.configure(identity_cache_ttl:, role_cache_ttl:)`.
542
+
543
+ ### Orphaned subscriptions
544
+
545
+ A session that subscribes to resources but never opens its listening stream
546
+ (or subscribes again after the stream closed) holds LiveQuery subscriptions
547
+ with nowhere to deliver them. Normal teardown (unsubscribe, stream close,
548
+ `DELETE`) does not cover that case, so the subscription manager reaps such
549
+ sessions after a grace period: `orphan_ttl:` on
550
+ `Parse::Agent::MCPSubscriptions::Manager` (default 300 seconds, `nil` to
551
+ disable). Reaping runs whenever a session subscribes or attaches a stream,
552
+ and can be triggered with `manager.reap_orphans!`. A session that attaches
553
+ its stream within the grace period keeps its subscriptions.
554
+
555
+ ### Operational notes for deployment factories
556
+
557
+ * **Validation load.** `user_scoped` with the default
558
+ `session_validation: :per_request` asks Parse Server (`GET /users/me`) on
559
+ every request, including requests carrying a random bearer string. Put a
560
+ `pre_auth_rate_limiter:` in front of a public endpoint, or use
561
+ `session_validation: :cached` and rely on the identity cache's TTL and
562
+ invalidation hooks.
563
+ * **Outages fail closed.** A Parse Server error while validating a session is
564
+ treated as an invalid session: the request gets 401 and a listening stream
565
+ is closed at its next revalidation. Clients reconnect once Parse Server is
566
+ back.
567
+
568
+ ---
569
+
399
570
  ## Connecting Claude Desktop (stdio bridge)
400
571
 
401
572
  Parse Stack speaks MCP over **HTTP** (the standalone server and the
@@ -659,10 +830,36 @@ The principal fingerprint is derived, in order, from: an operator-supplied
659
830
  per-`MCPRackApp` instance and **single-process** — it does not span Puma workers
660
831
  or survive a restart. In a clustered deployment the `initialize` POST and the
661
832
  `GET` stream may land on different workers, so the initialize-binding degrades
662
- to TOFU there. The registry is LRU-bounded (default 10,000 sessions) so a stream
663
- of `initialize`-without-`DELETE` sessions cannot grow it without limit; evicting
664
- an active owner just downgrades that id to TOFU on its next attach. Blank
665
- session ids or blank fingerprints fail closed.
833
+ to TOFU there. Blank session ids or blank fingerprints fail closed.
834
+
835
+ **Capacity and eviction.** The registry is LRU-bounded (default 10,000
836
+ sessions) so a stream of `initialize`-without-`DELETE` sessions cannot grow it
837
+ without limit. Only idle bindings are evicted. A live session (one with an
838
+ attached listening stream or a pending approval prompt) is pinned and keeps its
839
+ owner binding under any amount of LRU pressure, so a flood of new sessions
840
+ cannot strip a victim's owner and then answer its approvals or cancel its
841
+ requests. An evicted idle id is unbound, and the next principal to attach a
842
+ stream to it claims it TOFU. When every binding is live and the registry is
843
+ full, new bindings are refused rather than displacing a live one: `initialize`
844
+ and the listening-stream `GET` answer `503` (`-32000`, "Session capacity
845
+ exhausted"). Clients retry once sessions close or are terminated with
846
+ `DELETE`.
847
+
848
+ **Per-principal bound.** Each principal may hold at most 100 session
849
+ bindings. Past that, its own least recently used idle binding is evicted (or
850
+ the new one refused with `503` when all of its bindings are live), so one
851
+ caller flooding `initialize` cannot push other principals' sessions out of
852
+ the registry. `initialize` and `resources/subscribe` are charged against the
853
+ principal's rate limiter and answer `429` when it is exhausted; with
854
+ `user_scoped` and `master_analytics` that limiter is shared across the
855
+ principal's requests.
856
+
857
+ **Requests on another principal's session.** Any POST whose `Mcp-Session-Id`
858
+ is bound to a different principal is refused with `403`.
859
+ `resources/subscribe` additionally requires a session the caller established,
860
+ through `initialize` or by attaching its listening stream, because each
861
+ subscription holds a LiveQuery socket and a slot in the global session limit.
862
+ A stateless client that never initializes can still call tools.
666
863
 
667
864
  ---
668
865
 
@@ -847,6 +1044,51 @@ session's logs. `DELETE` on the session forgets it.
847
1044
 
848
1045
  ---
849
1046
 
1047
+ ## Server Field Names (`field_names: :server`)
1048
+
1049
+ `Parse::Agent.new(field_names: :server)` asks for data fields in the exact
1050
+ names Parse returns or the model declares through its `field_map`
1051
+ (`createdAt`, `totalPlays`, an alias such as `ExternalID`), with no
1052
+ snake_case conversion anywhere a tool would otherwise apply one. Omit it (or
1053
+ pass `:default`) to keep each tool's existing output; any other value raises
1054
+ `ArgumentError`. Sub-agents inherit the mode unless they set their own.
1055
+
1056
+ ```ruby
1057
+ analytics = Parse::Agent.new(field_names: :server, permissions: :readonly)
1058
+ ```
1059
+
1060
+ | Output | Default | `field_names: :server` |
1061
+ |---|---|---|
1062
+ | `query_class` / `get_object` rows, Atlas and `semantic_search` source records, exports (CSV headers) | Parse field names | Parse field names (unchanged) |
1063
+ | A Parse object returned by an `agent_method` (`call_method`) | Parse field names and values | Parse field names and values |
1064
+ | An `AggregationResult` returned by an `agent_method` | snake_case keys | keys exactly as the aggregation returned them |
1065
+
1066
+ Most tool output already carried server names, so the visible difference is
1067
+ in values an `agent_method` returns. Two `call_method` serialization fixes
1068
+ ship alongside: a returned Parse object is now emitted with its values (it
1069
+ previously emitted the model's field-type map, e.g. `"title" => "string"`),
1070
+ and a returned `AggregationResult` is emitted as a Hash (it was previously its
1071
+ `inspect` String).
1072
+
1073
+ The option changes **data-field keys only**:
1074
+
1075
+ * MCP protocol keys and SDK envelope keys (`chunks`, `documents`,
1076
+ `object_id`, `next_call`, ...) keep their contracts.
1077
+ * Date, Pointer, and File formatting, vector visibility, and metadata
1078
+ redaction are unchanged.
1079
+ * "Server names" means the application-facing Parse names, never raw MongoDB
1080
+ storage columns such as `_p_author`, `_rperm`, or `_session_token`.
1081
+ * Explicit export column aliases still win over automatic naming.
1082
+
1083
+ Naming is presentation only. ACL/CLP, `protectedFields`, the class
1084
+ `agent_fields` ceiling, and per-agent `fields:` policies resolve against the
1085
+ canonical field names before anything is formatted, so a user-scoped agent
1086
+ and a master-key agent with the same policy return the same fields in either
1087
+ mode. Like `fields:`, the mode is scoped to each tool call, so agents with
1088
+ different modes can run concurrently in one process.
1089
+
1090
+ ---
1091
+
850
1092
  ## Built-in Agent Hardening & Telemetry
851
1093
 
852
1094
  5.2 adds several agent-side controls, all configured on `Parse::Agent`:
@@ -1095,6 +1337,12 @@ MCPRackApp (per-request factory)
1095
1337
 
1096
1338
  The same problem exists in miniature whenever a tool handler constructs a sub-agent inside its block — a fresh `Parse::Agent.new` produces a fresh limiter, so an attacker who can induce delegation amplifies the per-process budget linearly with delegation depth × branching. The v4.2 `parent:` kwarg closes that case automatically (see [Per-Agent Tool Filtering & Sub-Agent Delegation](#per-agent-tool-filtering--sub-agent-delegation-v42)); the shared external limiter pattern below covers the cross-request case at the MCPRackApp boundary.
1097
1339
 
1340
+ ### Deployment factories already share a limiter per principal
1341
+
1342
+ `MCPRackApp.user_scoped` and `MCPRackApp.master_analytics` do not have this problem. Each keeps one in-process `RateLimiter` per principal (the validated user id for `user_scoped`, the resolved operator for `master_analytics`) and hands it to every agent it builds for that principal, so `rate_limit:` and `rate_window:` in `agent_options:` accumulate across requests. The registry is LRU-bounded (default 10,000 principals); an evicted principal starts a fresh window. Passing your own `rate_limiter:` in `agent_options:` bypasses the registry and uses that limiter as-is, which is the way to share one budget across processes.
1343
+
1344
+ The workarounds below apply to a custom `agent_factory:` lambda (including `Parse::Agent.rack_app do |env| ... end`), which builds a fresh agent per request and gets no shared limiter unless you inject one.
1345
+
1098
1346
  ### The solution
1099
1347
 
1100
1348
  Inject a shared, externally-stateful limiter:
@@ -1416,7 +1664,9 @@ class Project < Parse::Object
1416
1664
  # _wperm so the update only sees rows the agent's scope is allowed
1417
1665
  # to modify, defense-in-depth alongside Parse Server's own ACL.
1418
1666
  Audit.all(**agent.acl_scope_kwargs).each { |a| a.cancel! } if agent&.acl_scope
1419
- update!(archived_at: Time.now, archive_reason: reason)
1667
+ self.archived = true # property :archived, :boolean
1668
+ self.archive_reason = reason # property :archive_reason, :string
1669
+ save
1420
1670
  { archived: true, objectId: id }
1421
1671
  end
1422
1672
  end
@@ -1558,6 +1808,67 @@ Parse::Agent::Tools.register(
1558
1808
 
1559
1809
  **ACL-scope subset invariant (v4.4.0):** when the parent carries a resolved ACL scope (session_token / acl_user / acl_role), an explicit child override must resolve to a `permission_strings` set that is a SUBSET of the parent's. A tool handler that tries `Parse::Agent.new(parent: user_scoped, acl_role: "admin")` raises `ArgumentError` at construction because the child's claim set would include `"role:admin"`, which the parent's claim set does not. The same applies to a different `acl_user:` (different user_id), or to a child that resolves to master-key while the parent was scoped. This closes the analogous footgun for the acl_user / acl_role identity axis — the precedent of session_token swap is misleading because session tokens are externally verified by Parse Server, while `acl_user:` and `acl_role:` are unverified constructor assertions. A master-key parent (`@acl_scope.nil?`) allows any child scope because the parent already has unrestricted reach.
1560
1810
 
1811
+ ### `fields:` per-agent field narrowing (5.8)
1812
+
1813
+ A class's `agent_fields` is the most any agent may read. `fields:` narrows
1814
+ that ceiling for one agent, so two MCP deployments in the same process can
1815
+ expose different subsets of the same model: a user-facing assistant sees
1816
+ less than an analytics endpoint. Effective access is the intersection of the
1817
+ caller's Parse ACL/CLP, the class `agent_fields`, the agent's `fields:`
1818
+ policy, its tenant scope, and its tool and method filters.
1819
+
1820
+ ```ruby
1821
+ class Customer < Parse::Object
1822
+ property :display_name, :string
1823
+ property :timezone, :string
1824
+ property :plan, :string
1825
+ property :billing_email, :string
1826
+ agent_fields :display_name, :timezone, :plan, :billing_email # ceiling for every agent
1827
+ end
1828
+
1829
+ assistant = Parse::Agent.new(session_token: token,
1830
+ fields: { Customer => %i[display_name timezone] })
1831
+ analytics = Parse::Agent.new(permissions: :readonly,
1832
+ fields: { Customer => %i[display_name plan] })
1833
+ ```
1834
+
1835
+ * **Narrow only.** A field outside `agent_fields` stays hidden even if a
1836
+ policy lists it. A class without `agent_fields` is narrowed to exactly the
1837
+ listed fields (plus `objectId`, `createdAt`, `updatedAt`).
1838
+ * **`default:`** narrows every class the policy does not name.
1839
+ * **Sub-agents intersect.** `Parse::Agent.new(parent: assistant, fields: ...)`
1840
+ sees only fields both policies permit; omitting `fields:` inherits the
1841
+ parent's policy.
1842
+ * **Everywhere the class allowlist applied.** Query projection, `keys:`,
1843
+ include projections, aggregation pipelines, Atlas Search fields,
1844
+ `get_schema` and `completion/complete` field names, exports,
1845
+ `agent.describe`, and `semantic_search` chunk text, reranker input, and
1846
+ filter fields all use the effective set.
1847
+ * **No inference through filters.** `query_class`, `count_objects`, and
1848
+ `export_data` refuse a `where:` or `order:` on a field outside the
1849
+ effective set with `:field_denied` (filtering or sorting on a hidden field
1850
+ reveals it through which rows match). `group_by`, `distinct`, and
1851
+ aggregation already refused. This also applies to the class ceiling with
1852
+ no `fields:` policy.
1853
+ * **Writes stay on declared methods.** `fields:` governs what the agent
1854
+ reads; writes still go through `agent_method`s and the per-agent
1855
+ `methods:` filter.
1856
+
1857
+ The policy is applied for the duration of each tool call (fiber-local), so
1858
+ agents serving concurrent requests never see each other's policy.
1859
+
1860
+ **Known limits.**
1861
+
1862
+ * `call_method` projects returned objects (anything carrying `className` and
1863
+ `objectId`) through their class policy, but rows a method returns from its
1864
+ own aggregation (`$group` output, `$lookup` documents) are the method
1865
+ author's responsibility: the SDK cannot tell which class such a row came
1866
+ from.
1867
+ * `get_schema` lists index keys and class-level permission entries that may
1868
+ name fields outside the agent's policy (names only, never values).
1869
+
1870
+ ---
1871
+
1561
1872
  ### Developer introspection — `agent.describe` / `describe_for` / `would_permit?` (v4.4.0)
1562
1873
 
1563
1874
  Three helpers on every agent for answering "why is this agent refusing this call?" and "what can this agent actually see?" without parsing audit payloads or tracing through tool implementations. NOT exposed to the LLM — operator-side observability only.
@@ -2483,7 +2794,7 @@ Every tool call dispatched through `Agent#execute` fires the `"parse.agent.tool_
2483
2794
 
2484
2795
  **Server-assigned on `initialize`:** when the client omits the header on the `initialize` request, `MCPRackApp` generates a UUID, binds it to `agent.correlation_id`, and returns it in the `Mcp-Session-Id` response header. Clients echo that id on subsequent requests. A client-supplied `Mcp-Session-Id` on `initialize` is echoed back unchanged; a factory-bound `correlation_id` always wins over both. Only the `initialize` response carries the header — non-init responses don't, so the id is never leaked on every reply. The SDK does not maintain a server-side session store: the id is best-effort correlation only (audit threading + cancellation routing), and a subsequent request carrying an "unknown" id is NOT refused.
2485
2796
 
2486
- **Session termination via `DELETE /`:** a `DELETE` carrying `Mcp-Session-Id` cancels every in-flight request registered under that correlation id and returns `204 No Content`. The header value is sanitized with the same regex as the request setter; missing or invalid values return `400`. The DELETE handler runs before the agent factory, so teardown traffic cannot force per-request agent construction.
2797
+ **Session termination via `DELETE /`:** a `DELETE` carrying `Mcp-Session-Id` cancels every in-flight request registered under that correlation id and returns `204 No Content`. The header value is sanitized with the same regex as the request setter; missing or invalid values return `400`. Since 5.8, termination is gated like every other session operation: it passes the Origin policy, authenticates through the agent factory (`401` when the factory refuses), and is refused with `403` when the session is bound to a different principal. An unclaimed session can be terminated by any authenticated caller.
2487
2798
 
2488
2799
  - **Factory path (for application-bound sessions):** application code that already has an internal session identifier can override the client-supplied header by setting it inside the agent factory:
2489
2800
 
@@ -469,6 +469,33 @@ The `Parse::MongoDB.to_mongodb_date(value)` helper coerces `Date`,
469
469
  `DateTime`, `Time`, ISO 8601 strings, and Unix timestamps to a UTC `Time`
470
470
  suitable for matching.
471
471
 
472
+ ### MongoDB 9.0 null semantics
473
+
474
+ MongoDB 9.0 changed how `null` comparisons treat a dotted path that runs
475
+ through an array. When the path resolves to no non-null value (the field is
476
+ an empty array, an array of scalars, or contains a nested array), it now
477
+ compares equal to `null`. For `{ meta: [] }` or `{ meta: [1] }`,
478
+ `{ "meta.owner": null }` now matches and `{ "meta.owner": { $ne: null } }` no
479
+ longer does. `$exists` is unchanged, and `$lookup` equality follows the new
480
+ rule. This applies to REST queries (Parse Server passes them to MongoDB) and
481
+ mongo-direct queries alike.
482
+
483
+ The SDK never adds a `null` comparison on a dotted path by itself; ACL
484
+ scoping only checks the top-level `_rperm`. It compiles one only when you
485
+ name a dotted field. Which constraints are affected:
486
+
487
+ | Constraint on a dotted field | Compiles to | Changed in 9.0 |
488
+ |---|---|---|
489
+ | `:"meta.owner" => nil` | `{ "meta.owner": null }` | Yes |
490
+ | `:"meta.owner".not => nil`, `.null => false` | `{ $ne: null }` | Yes |
491
+ | `:"meta.owner".in => [nil, ...]` | `{ $in: [null, ...] }` | Yes |
492
+ | `:"meta.items".empty_or_nil`, `.not_empty` | pipeline with `$eq` / `$ne: null` | Yes |
493
+ | `:"meta.owner".null => true`, `.exists => false` | `{ $exists: false }` | No |
494
+
495
+ If a dotted path in your queries can traverse an array, review those
496
+ queries before upgrading, or use `.exists` when you mean "the field is
497
+ present".
498
+
472
499
  ---
473
500
 
474
501
  ## Pointer joins and `parse_reference`
data/docs/usage_guide.md CHANGED
@@ -118,6 +118,44 @@ Song.query.aggregate([
118
118
  ])
119
119
  ```
120
120
 
121
+ ### Field names in aggregation results
122
+
123
+ Custom aggregation rows (`$group`, `$project`, ...) come back as
124
+ `Parse::AggregationResult`. By default `#to_h` converts field names to
125
+ snake_case symbols. Pass `field_names: :server` to `#results` to keep the
126
+ names exactly as the aggregation returned them (5.8):
127
+
128
+ ```ruby
129
+ agg = Song.query.aggregate([
130
+ { "$group" => { "_id" => "$genre", "totalPlays" => { "$sum" => "$plays" } } },
131
+ ])
132
+
133
+ row = agg.results.first
134
+ row.to_h # => { _id: "Rock", total_plays: 500 } (default)
135
+
136
+ row = agg.results(field_names: :server).first
137
+ row.to_h # => { "_id" => "Rock", "totalPlays" => 500 }
138
+ row["totalPlays"] # => 500
139
+ row.total_plays # => 500 (a snake_case name still resolves when it matches one key)
140
+ ```
141
+
142
+ In `:server` mode keys are Strings, nested values are untouched, and keys
143
+ whose snake_case forms collide all survive. A name that is itself a key always
144
+ wins: with both `"totalPlays"` and `"total_plays"` stored, `row.total_plays`
145
+ reads `"total_plays"`. A snake_case name that is not itself a key but matches
146
+ more than one key (`"totalPlays"` and `"TotalPlays"` both stored) is
147
+ ambiguous: the method form (`row.total_plays`) raises `ArgumentError` naming
148
+ the candidates instead of guessing, and `row["total_plays"]` returns nil. Read
149
+ such a field by its exact key. Parse::Object rows are unaffected; `#as_json` already
150
+ returns their server field names. This is a presentation option only, and it
151
+ is separate from:
152
+
153
+ * `#raw`, which returns the unwrapped Hash exactly as received;
154
+ * `Parse::Query.field_formatter`, which controls how field names in OUTGOING
155
+ queries are compiled, not response keys;
156
+ * the REST aggregate `raw_field_names:` / `raw_values:` flags, which change
157
+ what Parse Server itself returns (and are never enabled by `field_names:`).
158
+
121
159
  ## Transactions
122
160
 
123
161
  ```ruby
@@ -87,10 +87,18 @@ one-to-one — the SDK maps between them.
87
87
  - **`beforeFind` / `afterFind` are result-side, not object-side.** Unlike the
88
88
  save/delete triggers, a find payload carries no single `object` — `beforeFind`
89
89
  exposes the incoming `query` (via `payload.query`) and `afterFind` exposes the
90
- matched rows (via `payload.objects`). And unlike `afterSave` (whose return
91
- value Parse Server ignores), **`afterFind` is result-rewriting**: whatever the
92
- handler returns *replaces* the rows sent to the client, so it can filter or
93
- redact results. It also adds a webhook round-trip to every matching query, so
90
+ matched rows (via `payload.objects`). **Over an HTTP webhook, `afterFind` can
91
+ observe the rows or deny the query, but it cannot rewrite them.** Parse Server
92
+ passes every row a webhook returns through `toJSONwithObjects`, which turns
93
+ any plain JSON object into `{}`, so returned rows would reach the client
94
+ blank. The SDK therefore always replies "keep the rows" (`{}`, with no
95
+ `success` key). Returning `nil`, `true`, or the unchanged `payload.objects`
96
+ all pass the rows through. Returning a different set of rows (dropping or
97
+ adding some) denies the query with an error instead, so rows a handler meant
98
+ to hide are never returned. To restrict rows, constrain the query in
99
+ `beforeFind` or with ACLs/CLPs; to block a query, call `error!` or return
100
+ `false`. (In-process `Parse.Cloud.afterFind` cloud code is not subject to this
101
+ limit.) It also adds a webhook round-trip to every matching query, so
94
102
  register it deliberately.
95
103
 
96
104
  One non-obvious detail the SDK handles for you: **Parse Server does not put the
@@ -104,11 +112,9 @@ one-to-one — the SDK maps between them.
104
112
  Because the class is resolved from the route, declared `:vector` columns are
105
113
  stripped from `afterFind` `payload.objects` by default, exactly as they are
106
114
  from `object`/`original`/`update` on the other triggers (a
107
- `vector_visibility :public` class keeps them). One consequence to keep in
108
- mind: an `afterFind` handler that returns `payload.objects` to pass results
109
- through passes the *vector-scrubbed* rows on to the client — which matches the
110
- `as_json` default (an `owner_only` class never exposes vectors anyway). Return
111
- your own array if you need different columns.
115
+ `vector_visibility :public` class keeps them). This affects only what the
116
+ handler sees. The rows the client receives are the ones Parse Server matched,
117
+ unchanged by the webhook.
112
118
 
113
119
  - **Auth triggers (`beforeLogin` / `afterLogin` / `afterLogout` /
114
120
  `beforePasswordResetRequest`) and LiveQuery triggers (`beforeConnect` /
@@ -259,10 +265,50 @@ abort "Webhook coverage gaps detected" if inert.positive?
259
265
  ## Returning a value from a handler
260
266
 
261
267
  A handler block runs with `self` bound to the `Parse::Webhooks::Payload`, so
262
- inside it you can call `parse_object`, `params`, `error!`, etc. directly. The
263
- value the handler produces is what Parse Server receives: for `before_save`,
264
- return the (possibly mutated) `parse_object` to allow the write, or `false` /
265
- `error!` to reject it.
268
+ inside it you can call `parse_object`, `params`, `error!`, etc. directly. What
269
+ the handler returns decides the reply Parse Server receives:
270
+
271
+ | Trigger | Allow | Change | Reject |
272
+ |---------|-------|--------|--------|
273
+ | `before_save` | `true`, `nil`, or an unchanged `parse_object` | the mutated `parse_object`, or a Hash of field overrides | `false` or `error!` |
274
+ | `before_delete` | anything else | n/a | `false`, `error!`, or a `before_destroy` callback that returns `false` |
275
+ | `after_find` | `nil`, `true`, or the unchanged `payload.objects` | not possible over HTTP (see above) | `false` or `error!` |
276
+ | function | the return value is the function result | | `error!` |
277
+
278
+ How a `before_save` change reaches Parse Server: Parse Server *replaces* the
279
+ pending write with the object a beforeSave webhook returns. The SDK therefore
280
+ never replies with only your changes. When nothing changed, it replies "keep
281
+ the write as sent", so the client's atomic operators (`Increment`, `Add`,
282
+ `Remove`, `Delete`, relation operators) and any undeclared fields reach the
283
+ database untouched. When the handler changed fields, the reply is the client's
284
+ full write with your changes layered on top: operators on fields you did not
285
+ touch are passed through as operators, fields the model does not declare (and
286
+ `_User` signup fields such as `password` and `authData`) are kept, and a field
287
+ you set back to its stored value is dropped from the write. Two limits come
288
+ from what Parse Server sends the webhook: a field you rewrite is written as an
289
+ absolute value, and an operator on a dotted sub-key (`"meta.count"`) arrives
290
+ only as its resulting sub-document and is written back that way. Edits made
291
+ in place on `parse_object` count even if the handler returns `true`. On a
292
+ create, returning `parse_object` also writes the model's declared defaults
293
+ (including its default ACL) for fields the client did not send.
294
+
295
+ `after_destroy` callbacks run in the `after_delete` handler (once per delivery,
296
+ skipped for deletes the SDK itself made, whose callbacks already ran locally).
297
+ `before_destroy` callbacks run in `before_delete` when the handler returns
298
+ `parse_object`.
299
+
300
+ `error!(message, code: 137)` attaches a Parse error code to the error reply
301
+ and to the raised `Parse::Webhooks::ResponseError#code`. Parse Server's HTTP
302
+ webhook adapter currently reports every webhook error to the client as code
303
+ 141 (`SCRIPT_FAILED`), so the code is mainly useful to in-process callers such
304
+ as `Parse::Webhooks.run_function`. Any other exception a handler raises is
305
+ answered with a generic `{"error": "Webhook handler failed."}` reply (the
306
+ exception class and a redacted message are logged), so internal details never
307
+ reach the client.
308
+
309
+ A request for a function with no registered handler is answered with an
310
+ error. A trigger with no registered handler passes the operation through
311
+ unchanged.
266
312
 
267
313
  You can set that value either with an explicit `return` or by letting it be the
268
314
  block's last expression — both work:
@@ -396,10 +442,14 @@ usual.
396
442
  This protects the webhook endpoint against **replayed inbound POSTs** —
397
443
  `lib/parse/webhooks/replay_protection.rb`:
398
444
 
399
- - **Always-on body + request-id dedup.** A bounded LRU records a digest of each
400
- `(request_id, body)`; a duplicate seen within `replay_window_seconds` is
401
- rejected with `"Webhook replay detected."`. No cooperation from Parse Server is
402
- required; this stops in-window replays.
445
+ - **Nonce-keyed dedup.** When a delivery carries an `X-Parse-Request-Id` or
446
+ `X-Parse-Webhook-Nonce` header, a bounded LRU records a digest of
447
+ `(nonce, body)`; a duplicate seen within `replay_window_seconds` is rejected
448
+ with `"Webhook replay detected."`. Parse Server sends neither header on its
449
+ own deliveries, so add one in the proxy or wrapper that delivers webhooks if
450
+ you want dedup. Without a nonce the SDK does not dedup on the body alone:
451
+ two legitimate identical requests (the same function called twice with the
452
+ same params) cannot be told apart from a replay that way.
403
453
  - **Opt-in HMAC freshness verification.** Set a `signing_secret` and the receiver
404
454
  verifies two headers:
405
455
  - `X-Parse-Webhook-Timestamp` — Unix epoch seconds; requests outside
@@ -407,6 +457,13 @@ This protects the webhook endpoint against **replayed inbound POSTs** —
407
457
  - `X-Parse-Webhook-Signature` — hex HMAC-SHA256 of `"#{timestamp}.#{body}"`
408
458
  keyed with the signing secret.
409
459
 
460
+ A signed delivery is deduplicated on its signature, so a captured request
461
+ cannot be replayed inside the skew window, even with an altered or missing
462
+ nonce. To send identical bodies more than once in the same second, include
463
+ an `X-Parse-Webhook-Nonce` header and sign
464
+ `"#{timestamp}.#{nonce}.#{body}"` instead; each delivery then has its own
465
+ signature, and a signature cannot be reused under another nonce.
466
+
410
467
  ```ruby
411
468
  Parse::Webhooks::ReplayProtection.signing_secret = ENV["PARSE_WEBHOOK_SIGNING_SECRET"]
412
469
  Parse::Webhooks::ReplayProtection.replay_window_seconds = 120