parse-stack-next 5.7.6 → 5.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1148 -0
  3. data/README.md +39 -23
  4. data/docs/TEST_SERVER.md +2 -2
  5. data/docs/acl_clp_guide.md +7 -0
  6. data/docs/atlas_vector_search_guide.md +181 -13
  7. data/docs/caching.md +50 -0
  8. data/docs/client_sdk_guide.md +11 -0
  9. data/docs/mcp_guide.md +332 -9
  10. data/docs/mongodb_direct_guide.md +27 -0
  11. data/docs/usage_guide.md +38 -0
  12. data/docs/webhooks_guide.md +126 -17
  13. data/lib/parse/acl_scope.rb +230 -50
  14. data/lib/parse/agent/approval_gate.rb +0 -0
  15. data/lib/parse/agent/constraint_translator.rb +42 -15
  16. data/lib/parse/agent/describe.rb +11 -3
  17. data/lib/parse/agent/field_names.rb +53 -0
  18. data/lib/parse/agent/field_policy.rb +74 -0
  19. data/lib/parse/agent/mcp_deployments.rb +426 -0
  20. data/lib/parse/agent/mcp_dispatcher.rb +8 -1
  21. data/lib/parse/agent/mcp_rack_app.rb +424 -45
  22. data/lib/parse/agent/mcp_server.rb +23 -1
  23. data/lib/parse/agent/mcp_subscriptions.rb +124 -6
  24. data/lib/parse/agent/metadata_registry.rb +67 -8
  25. data/lib/parse/agent/prompt_hardening.rb +9 -3
  26. data/lib/parse/agent/tools.rb +378 -29
  27. data/lib/parse/agent.rb +298 -21
  28. data/lib/parse/api/batch.rb +10 -1
  29. data/lib/parse/api/schema.rb +23 -4
  30. data/lib/parse/api/sessions.rb +6 -2
  31. data/lib/parse/api/users.rb +88 -14
  32. data/lib/parse/atlas_search/index_manager.rb +5 -1
  33. data/lib/parse/atlas_search/protected_paths.rb +236 -0
  34. data/lib/parse/atlas_search.rb +144 -24
  35. data/lib/parse/authorization.rb +289 -4
  36. data/lib/parse/cache/sub_cache.rb +26 -0
  37. data/lib/parse/client/authentication.rb +19 -1
  38. data/lib/parse/client/batch.rb +264 -35
  39. data/lib/parse/client/body_builder.rb +21 -0
  40. data/lib/parse/client/caching.rb +371 -27
  41. data/lib/parse/client/request.rb +26 -14
  42. data/lib/parse/client/response.rb +49 -6
  43. data/lib/parse/client.rb +227 -40
  44. data/lib/parse/clp_scope.rb +317 -25
  45. data/lib/parse/console.rb +2 -2
  46. data/lib/parse/embeddings/voyage.rb +181 -17
  47. data/lib/parse/graphql/type_generator.rb +3 -0
  48. data/lib/parse/live_query/client.rb +72 -2
  49. data/lib/parse/lock_backend.rb +4 -3
  50. data/lib/parse/model/acl.rb +119 -21
  51. data/lib/parse/model/associations/belongs_to.rb +25 -3
  52. data/lib/parse/model/associations/collection_proxy.rb +146 -17
  53. data/lib/parse/model/associations/has_many.rb +49 -9
  54. data/lib/parse/model/associations/has_one.rb +3 -1
  55. data/lib/parse/model/associations/pointer_collection_proxy.rb +145 -17
  56. data/lib/parse/model/associations/relation_collection_proxy.rb +229 -38
  57. data/lib/parse/model/bytes.rb +13 -5
  58. data/lib/parse/model/classes/role.rb +95 -0
  59. data/lib/parse/model/classes/session.rb +304 -1
  60. data/lib/parse/model/classes/user.rb +104 -8
  61. data/lib/parse/model/core/actions.rb +287 -70
  62. data/lib/parse/model/core/builder.rb +100 -8
  63. data/lib/parse/model/core/create_lock.rb +27 -2
  64. data/lib/parse/model/core/describe.rb +2 -0
  65. data/lib/parse/model/core/fetching.rb +50 -5
  66. data/lib/parse/model/core/field_guards.rb +16 -8
  67. data/lib/parse/model/core/pluralized_aliases.rb +8 -4
  68. data/lib/parse/model/core/properties.rb +488 -39
  69. data/lib/parse/model/core/querying.rb +7 -0
  70. data/lib/parse/model/core/schema.rb +5 -3
  71. data/lib/parse/model/core/search_indexing.rb +63 -0
  72. data/lib/parse/model/core/vector_searchable.rb +35 -6
  73. data/lib/parse/model/file.rb +9 -2
  74. data/lib/parse/model/geopoint.rb +61 -13
  75. data/lib/parse/model/model.rb +160 -9
  76. data/lib/parse/model/object.rb +317 -18
  77. data/lib/parse/model/phone.rb +54 -5
  78. data/lib/parse/model/pointer.rb +40 -6
  79. data/lib/parse/model/push.rb +11 -1
  80. data/lib/parse/mongodb.rb +189 -61
  81. data/lib/parse/pipeline_security.rb +415 -26
  82. data/lib/parse/query/constraint.rb +31 -1
  83. data/lib/parse/query/constraints.rb +89 -59
  84. data/lib/parse/query/cursor.rb +3 -1
  85. data/lib/parse/query/operation.rb +62 -8
  86. data/lib/parse/query/ordering.rb +34 -6
  87. data/lib/parse/query.rb +1871 -167
  88. data/lib/parse/retrieval/agent_tool.rb +225 -8
  89. data/lib/parse/retrieval/benchmark.rb +149 -0
  90. data/lib/parse/retrieval/profiles.rb +320 -0
  91. data/lib/parse/retrieval/retriever.rb +10 -1
  92. data/lib/parse/retrieval.rb +2 -0
  93. data/lib/parse/schema/search_index_migrator.rb +23 -5
  94. data/lib/parse/schema.rb +74 -18
  95. data/lib/parse/stack/tasks.rb +6 -4
  96. data/lib/parse/stack/version.rb +1 -1
  97. data/lib/parse/stack.rb +91 -14
  98. data/lib/parse/two_factor_auth/user_extension.rb +14 -2
  99. data/lib/parse/two_factor_auth.rb +11 -0
  100. data/lib/parse/vector_search/hybrid.rb +110 -33
  101. data/lib/parse/vector_search/index_definition.rb +237 -0
  102. data/lib/parse/vector_search.rb +259 -23
  103. data/lib/parse/webhooks/payload.rb +136 -6
  104. data/lib/parse/webhooks/replay_protection.rb +58 -20
  105. data/lib/parse/webhooks.rb +715 -40
  106. metadata +8 -1
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
 
@@ -683,6 +880,15 @@ The approval gate is a pluggable `agent.approval_gate` consulted inside
683
880
  unit-testable with a fake approver. `Parse::Agent::MCPElicitationGate` is the
684
881
  spec-native implementation; `Parse::Agent::NullGate` (the default) approves.
685
882
 
883
+ A sub-agent built with `parent:` and no gate of its own uses its parent's gate,
884
+ read at call time, so the gate the dispatcher installs on the parent for a
885
+ `tools/call` also covers any sub-agent a tool builds during that call. Before
886
+ 5.8.1 a sub-agent fell back to `NullGate` and skipped approval. Assigning a
887
+ gate on the sub-agent itself overrides this, and assigning `nil` removes the
888
+ override so the sub-agent uses its parent's gate again. The dispatcher
889
+ snapshots and restores the agent's own gate (`approval_gate_override`), so
890
+ driving a sub-agent through `tools/call` never pins its parent's gate onto it.
891
+
686
892
  Round-trip over the streaming transport:
687
893
 
688
894
  1. A `tools/call` for a gated tier pauses before execution. The server builds an
@@ -847,6 +1053,51 @@ session's logs. `DELETE` on the session forgets it.
847
1053
 
848
1054
  ---
849
1055
 
1056
+ ## Server Field Names (`field_names: :server`)
1057
+
1058
+ `Parse::Agent.new(field_names: :server)` asks for data fields in the exact
1059
+ names Parse returns or the model declares through its `field_map`
1060
+ (`createdAt`, `totalPlays`, an alias such as `ExternalID`), with no
1061
+ snake_case conversion anywhere a tool would otherwise apply one. Omit it (or
1062
+ pass `:default`) to keep each tool's existing output; any other value raises
1063
+ `ArgumentError`. Sub-agents inherit the mode unless they set their own.
1064
+
1065
+ ```ruby
1066
+ analytics = Parse::Agent.new(field_names: :server, permissions: :readonly)
1067
+ ```
1068
+
1069
+ | Output | Default | `field_names: :server` |
1070
+ |---|---|---|
1071
+ | `query_class` / `get_object` rows, Atlas and `semantic_search` source records, exports (CSV headers) | Parse field names | Parse field names (unchanged) |
1072
+ | A Parse object returned by an `agent_method` (`call_method`) | Parse field names and values | Parse field names and values |
1073
+ | An `AggregationResult` returned by an `agent_method` | snake_case keys | keys exactly as the aggregation returned them |
1074
+
1075
+ Most tool output already carried server names, so the visible difference is
1076
+ in values an `agent_method` returns. Two `call_method` serialization fixes
1077
+ ship alongside: a returned Parse object is now emitted with its values (it
1078
+ previously emitted the model's field-type map, e.g. `"title" => "string"`),
1079
+ and a returned `AggregationResult` is emitted as a Hash (it was previously its
1080
+ `inspect` String).
1081
+
1082
+ The option changes **data-field keys only**:
1083
+
1084
+ * MCP protocol keys and SDK envelope keys (`chunks`, `documents`,
1085
+ `object_id`, `next_call`, ...) keep their contracts.
1086
+ * Date, Pointer, and File formatting, vector visibility, and metadata
1087
+ redaction are unchanged.
1088
+ * "Server names" means the application-facing Parse names, never raw MongoDB
1089
+ storage columns such as `_p_author`, `_rperm`, or `_session_token`.
1090
+ * Explicit export column aliases still win over automatic naming.
1091
+
1092
+ Naming is presentation only. ACL/CLP, `protectedFields`, the class
1093
+ `agent_fields` ceiling, and per-agent `fields:` policies resolve against the
1094
+ canonical field names before anything is formatted, so a user-scoped agent
1095
+ and a master-key agent with the same policy return the same fields in either
1096
+ mode. Like `fields:`, the mode is scoped to each tool call, so agents with
1097
+ different modes can run concurrently in one process.
1098
+
1099
+ ---
1100
+
850
1101
  ## Built-in Agent Hardening & Telemetry
851
1102
 
852
1103
  5.2 adds several agent-side controls, all configured on `Parse::Agent`:
@@ -1095,6 +1346,12 @@ MCPRackApp (per-request factory)
1095
1346
 
1096
1347
  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
1348
 
1349
+ ### Deployment factories already share a limiter per principal
1350
+
1351
+ `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.
1352
+
1353
+ 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.
1354
+
1098
1355
  ### The solution
1099
1356
 
1100
1357
  Inject a shared, externally-stateful limiter:
@@ -1398,7 +1655,7 @@ agent = Parse::Agent.new(methods: [:archive, "Project.set_client_description"])
1398
1655
  agent = Parse::Agent.new(methods: { except: ["Account.delete_account"] })
1399
1656
  ```
1400
1657
 
1401
- Entries are bare method names (`:archive` — matches the method on any class) or qualified names (`"Project.archive"` — matches only on that class). Both forms coexist in the same Set; matching is an OR.
1658
+ Entries are bare method names (`:archive`, which matches the method on any class) or qualified names (`"Project.archive"`, which matches only on that class). Both forms coexist in the same Set; matching is an OR. The class part is compared by its Parse class name, so `"User.reset"` and `"_User.reset"` name the same method (likewise `Role`, `Session`, and `Installation`), whichever spelling the entry or the `call_method` caller uses. An entry may also name the model by its Ruby constant (`"Artist.purge"` for `class Artist < Parse::Object; parse_class "Musician"; end`), which is compared by its Parse class name too. A qualified entry whose class resolves to no loaded model warns at construction and is matched by its class part as written. Before 5.8.1 an entry was matched against the raw string the caller passed, so `methods: { except: ["_User.reset"] }` could be bypassed by calling it on `"User"`.
1402
1659
 
1403
1660
  The filter **narrows declared methods** — it cannot expose a method that was not declared via the `agent_method` DSL, and it cannot bypass tier checks (`agent_can_call?`) or env-gates (`PARSE_AGENT_ALLOW_WRITE_TOOLS`, `PARSE_AGENT_ALLOW_SCHEMA_OPS`). A filtered-out invocation returns `error_code: :tool_filtered`.
1404
1661
 
@@ -1416,7 +1673,9 @@ class Project < Parse::Object
1416
1673
  # _wperm so the update only sees rows the agent's scope is allowed
1417
1674
  # to modify, defense-in-depth alongside Parse Server's own ACL.
1418
1675
  Audit.all(**agent.acl_scope_kwargs).each { |a| a.cancel! } if agent&.acl_scope
1419
- update!(archived_at: Time.now, archive_reason: reason)
1676
+ self.archived = true # property :archived, :boolean
1677
+ self.archive_reason = reason # property :archive_reason, :string
1678
+ save
1420
1679
  { archived: true, objectId: id }
1421
1680
  end
1422
1681
  end
@@ -1464,7 +1723,7 @@ Parse::Agent.new(classes: { only: [Pots] }, strict_class_filter: true) # per-in
1464
1723
 
1465
1724
  `except:` is never validated — an operator may proactively block a class not yet loaded.
1466
1725
 
1467
- **Sub-agent inheritance: intersect, never widen.** Unlike `tools:` (where a sub-agent's filter overrides the parent's outright), `classes:` is **intersected** with the parent's effective set so a sub-agent can NEVER widen the parent's data reach. A child `only:` that has no overlap with the parent's `only:` raises `ArgumentError` at construction. A child that omits `classes:` inherits the parent's filter verbatim. `except:` sets are unioned (a sub-agent cannot un-deny a class the parent denied). The asymmetry with `tools:` is intentional — class reach is data scope, closer to `permissions:` than to the UX-scoping `tools:` filter.
1726
+ **Sub-agent inheritance: intersect, never widen.** Like `tools:` and `methods:`, `classes:` is **intersected** with the parent's effective set so a sub-agent can NEVER widen the parent's data reach. A child `only:` that has no overlap with the parent's `only:` raises `ArgumentError` at construction. A child that omits `classes:` inherits the parent's filter verbatim. `except:` sets are unioned (a sub-agent cannot un-deny a class the parent denied).
1468
1727
 
1469
1728
  **Schema-catalog filtering.** `get_all_schemas` omits classes outside the per-agent allowlist from the catalog response so the LLM doesn't waste a tool call discovering classes it would be refused on.
1470
1729
 
@@ -1543,6 +1802,7 @@ Parse::Agent::Tools.register(
1543
1802
  | `session_token` | Yes (security-critical) | Without it, a session-token parent silently produces a master-key sub-agent — the constructor default is `nil`, which means master-key mode. This was the v4.2 advisor-flagged blocker; do not undo. |
1544
1803
  | `acl_user` (v4.4.0) | Yes (security-critical) | When the parent was constructed with `acl_user:` and the child supplies none of `session_token:` / `acl_user:` / `acl_role:`, the parent's identity inherits verbatim. Inheritance is conditional on the child supplying NO identity at all — explicit overrides on the child resolve normally and then face the subset check below. |
1545
1804
  | `acl_role` (v4.4.0) | Yes (security-critical) | Same rule as `acl_user`. A child that omits identity inherits the parent's role scope; one that supplies its own identity falls through to the subset check. |
1805
+ | `master_atlas` | Yes (security-critical) | `nil` inherits the parent's setting and `false` drops it. `true` is accepted only when the parent has it: since 5.8.1 a sub-agent of a parent without `master_atlas` raises `ArgumentError`, because master-key faceting would return every row's buckets and results under the parent's scoped identity. |
1546
1806
  | `tenant_id` | Yes (security-critical) | Without it, a tenant-bound parent produces an unbound sub-agent that escapes `agent_tenant_scope` rules. |
1547
1807
  | `recursion_depth` | Always (decremented) | The parent's budget is authoritative — the explicit `recursion_depth:` kwarg is ignored on inherited construction. |
1548
1808
 
@@ -1552,12 +1812,74 @@ Parse::Agent::Tools.register(
1552
1812
  |-------|---------|
1553
1813
  | `permissions` | The default of `:readonly` means `Parse::Agent.new(parent: write_agent)` produces a `:readonly` sub-agent. A sub-agent is at most as privileged as the parent by tier; this is enforced by a clamp check at construction, not by inheritance. An explicit override is accepted only if `≤ parent.permissions` — `Parse::Agent.new(parent: readonly_parent, permissions: :admin)` raises `ArgumentError`. Pass `permissions: parent.permissions` to maintain parity intentionally. |
1554
1814
  | `client` | The constructor default `:default` resolves to the same client in standard single-app deployments. Explicit passes through. |
1555
- | `tools:` / `methods:` filters | The whole point of constructing a sub-agent is usually to give it a NARROWER surface. Explicit passes through. |
1815
+
1816
+ **`tools:` and `methods:` narrow only (5.8.1).** A sub-agent's `tools:` and `methods:` filters can only remove from what its parent allows. A child `tools: { only: }` is intersected with the parent's allowlist and `except:` sets are unioned; a child that omits `tools:` inherits the parent's filter. For `methods:`, the parent's filters stay in force alongside the child's, so a call must pass every one (entries may be bare or `Class.method`, which plain set intersection would match wrongly). A child `only:` list with nothing its parent allows (after the parent's allowlist and `except:` lists apply) raises `ArgumentError` at construction; an explicitly empty `only: []` is the strictest narrowing and is accepted. Before 5.8.1 a child's filter replaced the parent's, so a sub-agent could enable a tool or agent method within its tier that the parent was not given.
1556
1817
 
1557
1818
  **The clamp invariant:** `sub.permissions ≤ parent.permissions` always holds. The default `:readonly` is always safe regardless of parent tier; only explicit overrides hit the clamp check, and overrides that exceed the parent's tier raise at construction. This is the structural guarantee that a `delegate_to_subagent` chain cannot escape the parent's tier through sub-agent construction — the only path to a more-privileged agent is at the MCP factory, where the explicit elevation is auditable.
1558
1819
 
1559
1820
  **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
1821
 
1822
+ ### `fields:` per-agent field narrowing (5.8)
1823
+
1824
+ A class's `agent_fields` is the most any agent may read. `fields:` narrows
1825
+ that ceiling for one agent, so two MCP deployments in the same process can
1826
+ expose different subsets of the same model: a user-facing assistant sees
1827
+ less than an analytics endpoint. Effective access is the intersection of the
1828
+ caller's Parse ACL/CLP, the class `agent_fields`, the agent's `fields:`
1829
+ policy, its tenant scope, and its tool and method filters.
1830
+
1831
+ ```ruby
1832
+ class Customer < Parse::Object
1833
+ property :display_name, :string
1834
+ property :timezone, :string
1835
+ property :plan, :string
1836
+ property :billing_email, :string
1837
+ agent_fields :display_name, :timezone, :plan, :billing_email # ceiling for every agent
1838
+ end
1839
+
1840
+ assistant = Parse::Agent.new(session_token: token,
1841
+ fields: { Customer => %i[display_name timezone] })
1842
+ analytics = Parse::Agent.new(permissions: :readonly,
1843
+ fields: { Customer => %i[display_name plan] })
1844
+ ```
1845
+
1846
+ * **Narrow only.** A field outside `agent_fields` stays hidden even if a
1847
+ policy lists it. A class without `agent_fields` is narrowed to exactly the
1848
+ listed fields (plus `objectId`, `createdAt`, `updatedAt`).
1849
+ * **`default:`** narrows every class the policy does not name.
1850
+ * **Sub-agents intersect.** `Parse::Agent.new(parent: assistant, fields: ...)`
1851
+ sees only fields both policies permit; omitting `fields:` inherits the
1852
+ parent's policy.
1853
+ * **Everywhere the class allowlist applied.** Query projection, `keys:`,
1854
+ include projections, aggregation pipelines, Atlas Search fields,
1855
+ `get_schema` and `completion/complete` field names, exports,
1856
+ `agent.describe`, and `semantic_search` chunk text, reranker input, and
1857
+ filter fields all use the effective set.
1858
+ * **No inference through filters.** `query_class`, `count_objects`, and
1859
+ `export_data` refuse a `where:` or `order:` on a field outside the
1860
+ effective set with `:field_denied` (filtering or sorting on a hidden field
1861
+ reveals it through which rows match). `group_by`, `distinct`, and
1862
+ aggregation already refused. This also applies to the class ceiling with
1863
+ no `fields:` policy.
1864
+ * **Writes stay on declared methods.** `fields:` governs what the agent
1865
+ reads; writes still go through `agent_method`s and the per-agent
1866
+ `methods:` filter.
1867
+
1868
+ The policy is applied for the duration of each tool call (fiber-local), so
1869
+ agents serving concurrent requests never see each other's policy.
1870
+
1871
+ **Known limits.**
1872
+
1873
+ * `call_method` projects returned objects (anything carrying `className` and
1874
+ `objectId`) through their class policy, but rows a method returns from its
1875
+ own aggregation (`$group` output, `$lookup` documents) are the method
1876
+ author's responsibility: the SDK cannot tell which class such a row came
1877
+ from.
1878
+ * `get_schema` lists index keys and class-level permission entries that may
1879
+ name fields outside the agent's policy (names only, never values).
1880
+
1881
+ ---
1882
+
1561
1883
  ### Developer introspection — `agent.describe` / `describe_for` / `would_permit?` (v4.4.0)
1562
1884
 
1563
1885
  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.
@@ -2474,6 +2796,7 @@ Every tool call dispatched through `Agent#execute` fires the `"parse.agent.tool_
2474
2796
  | `:tools_except` | Array<Symbol> | v4.3.0+ — when the agent was constructed with `tools: { except: [...] }`. |
2475
2797
  | `:methods_only` | Array<String> | v4.3.0+ — when the agent was constructed with `methods: { only: [...] }`. Bare names and `"Class.method"` qualified names mix. |
2476
2798
  | `:methods_except` | Array<String> | v4.3.0+ — when the agent was constructed with `methods: { except: [...] }`. |
2799
+ | `:methods_layers` | Array<Hash> | 5.8.1+, sub-agents that inherit at least one `methods:` filter (including a single parent layer when the sub-agent has none of its own). Every `methods:` filter in force, parent layers first and the agent's own last, each `{only:, except:}` with sorted name strings (nil when absent). `:methods_only` / `:methods_except` describe only the agent's own layer. `describe[:methods][:layers]` carries the same list. |
2477
2800
  | `:filters` | Hash<String,Array<String>> | v4.4.0+ — when the agent was constructed with `filters: {...}`. Maps each filtered class name (or `"default"`) to the list of FIELD NAMES the filter constrains. Filter VALUES are intentionally NOT echoed — `filters: { Account => { user_id: "abc123" } }` would otherwise emit the user-identifying value on every audit-log line. Subscribers that need the actual constraint can call `agent.filter_for(class_name)` directly. |
2478
2801
  | `:denial_kind` | Symbol | v4.3.0+, AccessDenied failure path only — one of `:hidden_class` (global `agent_hidden`), `:class_filter` (per-agent `classes:` narrowing), `:field_denied` (outside `agent_fields`), or `:storage_form_field_ref` (referenced `_p_*` pointer-storage column). Lets SOC tooling distinguish operator narrowing from policy-level denials without parsing the message prose. |
2479
2802
 
@@ -2483,7 +2806,7 @@ Every tool call dispatched through `Agent#execute` fires the `"parse.agent.tool_
2483
2806
 
2484
2807
  **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
2808
 
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.
2809
+ **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
2810
 
2488
2811
  - **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
2812
 
@@ -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