parse-stack-next 4.5.0 → 5.0.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 (108) hide show
  1. checksums.yaml +4 -4
  2. data/.bundle/config +2 -0
  3. data/.env.sample +17 -3
  4. data/.github/workflows/codeql.yml +44 -0
  5. data/.github/workflows/docs.yml +39 -0
  6. data/.github/workflows/release.yml +32 -0
  7. data/.github/workflows/ruby.yml +8 -6
  8. data/.gitignore +4 -0
  9. data/.vscode/settings.json +3 -0
  10. data/CHANGELOG.md +305 -72
  11. data/Gemfile.lock +10 -3
  12. data/LICENSE.txt +1 -1
  13. data/README.md +190 -219
  14. data/Rakefile +1 -1
  15. data/SECURITY.md +30 -0
  16. data/assets/parse-stack-next-avatar.png +0 -0
  17. data/assets/parse-stack-next-avatar.svg +37 -0
  18. data/assets/parse-stack-next-banner.png +0 -0
  19. data/assets/parse-stack-next-banner.svg +45 -0
  20. data/assets/parse-stack-next-social-preview.png +0 -0
  21. data/docs/atlas_vector_search_guide.md +511 -0
  22. data/docs/client_sdk_guide.md +1320 -0
  23. data/docs/mcp_guide.md +225 -104
  24. data/docs/mongodb_direct_guide.md +21 -4
  25. data/docs/usage_guide.md +585 -0
  26. data/examples/transaction_example.rb +28 -28
  27. data/lib/parse/acl_scope.rb +2 -2
  28. data/lib/parse/agent/mcp_rack_app.rb +184 -16
  29. data/lib/parse/agent/metadata_dsl.rb +16 -16
  30. data/lib/parse/agent/pipeline_validator.rb +28 -1
  31. data/lib/parse/agent/prompts.rb +5 -5
  32. data/lib/parse/agent/tools.rb +287 -14
  33. data/lib/parse/agent.rb +209 -12
  34. data/lib/parse/api/analytics.rb +27 -5
  35. data/lib/parse/api/files.rb +6 -2
  36. data/lib/parse/api/push.rb +21 -4
  37. data/lib/parse/api/server.rb +59 -0
  38. data/lib/parse/api/users.rb +26 -2
  39. data/lib/parse/atlas_search/index_manager.rb +84 -0
  40. data/lib/parse/atlas_search.rb +37 -9
  41. data/lib/parse/cache/pool.rb +88 -0
  42. data/lib/parse/cache/redis.rb +249 -0
  43. data/lib/parse/client/body_builder.rb +94 -0
  44. data/lib/parse/client/caching.rb +109 -9
  45. data/lib/parse/client/response.rb +27 -0
  46. data/lib/parse/client.rb +74 -3
  47. data/lib/parse/console.rb +203 -0
  48. data/lib/parse/embeddings/cohere.rb +484 -0
  49. data/lib/parse/embeddings/fixture.rb +130 -0
  50. data/lib/parse/embeddings/jina.rb +454 -0
  51. data/lib/parse/embeddings/local_http.rb +492 -0
  52. data/lib/parse/embeddings/openai.rb +520 -0
  53. data/lib/parse/embeddings/provider.rb +264 -0
  54. data/lib/parse/embeddings/qwen.rb +431 -0
  55. data/lib/parse/embeddings/voyage.rb +550 -0
  56. data/lib/parse/embeddings.rb +225 -0
  57. data/lib/parse/graphql/scalars.rb +53 -0
  58. data/lib/parse/graphql/type_generator.rb +264 -0
  59. data/lib/parse/graphql.rb +48 -0
  60. data/lib/parse/live_query/client.rb +24 -5
  61. data/lib/parse/live_query/subscription.rb +17 -6
  62. data/lib/parse/live_query.rb +9 -4
  63. data/lib/parse/model/associations/collection_proxy.rb +2 -2
  64. data/lib/parse/model/associations/has_many.rb +32 -1
  65. data/lib/parse/model/associations/has_one.rb +17 -0
  66. data/lib/parse/model/associations/pointer_collection_proxy.rb +3 -3
  67. data/lib/parse/model/classes/user.rb +307 -11
  68. data/lib/parse/model/clp.rb +1 -1
  69. data/lib/parse/model/core/create_lock.rb +14 -2
  70. data/lib/parse/model/core/embed_managed.rb +296 -0
  71. data/lib/parse/model/core/fetching.rb +4 -4
  72. data/lib/parse/model/core/indexing.rb +53 -14
  73. data/lib/parse/model/core/parse_reference.rb +3 -3
  74. data/lib/parse/model/core/properties.rb +70 -1
  75. data/lib/parse/model/core/querying.rb +57 -1
  76. data/lib/parse/model/core/vector_searchable.rb +285 -0
  77. data/lib/parse/model/file.rb +16 -4
  78. data/lib/parse/model/model.rb +26 -10
  79. data/lib/parse/model/object.rb +63 -6
  80. data/lib/parse/model/pointer.rb +16 -2
  81. data/lib/parse/model/shortnames.rb +2 -0
  82. data/lib/parse/model/validations/uniqueness_validator.rb +3 -3
  83. data/lib/parse/model/vector.rb +102 -0
  84. data/lib/parse/mongodb.rb +90 -8
  85. data/lib/parse/pipeline_security.rb +59 -2
  86. data/lib/parse/query/constraints.rb +16 -14
  87. data/lib/parse/query/ordering.rb +1 -1
  88. data/lib/parse/query.rb +137 -64
  89. data/lib/parse/stack/generators/templates/model.erb +2 -2
  90. data/lib/parse/stack/generators/templates/model_installation.rb +1 -1
  91. data/lib/parse/stack/generators/templates/model_role.rb +1 -1
  92. data/lib/parse/stack/generators/templates/model_session.rb +1 -1
  93. data/lib/parse/stack/generators/templates/parse.rb +1 -1
  94. data/lib/parse/stack/generators/templates/webhooks.rb +1 -1
  95. data/lib/parse/stack/version.rb +1 -1
  96. data/lib/parse/stack.rb +375 -73
  97. data/lib/parse/two_factor_auth/user_extension.rb +5 -2
  98. data/lib/parse/vector_search.rb +341 -0
  99. data/parse-stack-next.gemspec +10 -9
  100. data/scripts/docker/docker-compose.test.yml +18 -0
  101. data/scripts/start-parse.sh +6 -0
  102. data/scripts/vector_prototype/create_vector_index.js +105 -0
  103. data/scripts/vector_prototype/fetch_embeddings.py +241 -0
  104. data/scripts/vector_prototype/fixture_manifest.json +9 -0
  105. data/scripts/vector_prototype/query_prototype.rb +84 -0
  106. data/scripts/vector_prototype/run.sh +34 -0
  107. metadata +77 -5
  108. data/parse-stack.png +0 -0
data/CHANGELOG.md CHANGED
@@ -1,9 +1,242 @@
1
- ## Parse-Stack Changelog
1
+ ## parse-stack-next Changelog
2
+
3
+ ### 5.0.1
4
+
5
+ #### Redis cache wrapper compatibility with `Parse::CreateLock`
6
+
7
+ - **FIXED**: `Parse::CreateLock.synchronize` (and therefore `first_or_create!` / `create_or_update!`) failed to acquire cross-process locks when the configured cache was a `Parse::Cache::Redis` wrapper. The lock implementation calls `store.create(key, owner, expires: ttl)` (Moneta's atomic SETNX), but the wrapper only forwarded `[]`, `key?`, `delete`, and `store` to the pooled Moneta backend. Every acquire raised `NoMethodError: undefined method 'create' for an instance of Parse::Cache::Redis`, which the lock caught, logged as `[Parse::CreateLock] acquire error (NoMethodError)`, and treated as contention — so the call spun on the polling loop until the wait budget elapsed and raised `Parse::CreateLockTimeoutError`. (`lib/parse/cache/redis.rb`, `lib/parse/cache/pool.rb`)
8
+ - **FIXED**: `Parse::CreateLock.degraded_store?` classified the `Parse::Cache::Redis` wrapper as a healthy cross-process store (the wrapper has no Moneta `.adapter` chain to walk and its class name does not match the `Memory`/`Null` heuristic), so the lock never fell back to the in-process `Mutex` path when `#create` was unavailable. The detector now special-cases the `Parse::Cache::Redis` wrapper and additionally treats any store that does not respond to `#create` as degraded, so older custom store implementations that pre-date this requirement degrade gracefully instead of timing out. (`lib/parse/model/core/create_lock.rb`)
9
+ - **NEW**: `Parse::Cache::Redis#create` and `Parse::Cache::Pool#create` forward atomic SETNX semantics to the pooled Moneta-Redis store. `#increment` is forwarded on both for Moneta surface parity so counter / rate-limit use cases work transparently through the pool. (`lib/parse/cache/redis.rb`, `lib/parse/cache/pool.rb`)
10
+ - **CHANGED**: The one-time `[Parse::CreateLock:SECURITY]` warning emitted when no `PARSE_STACK_LOCK_SECRET` is configured against a Redis-backed store now also documents the lock-pinning risk that arises when the response cache and lock store share a Redis DB. Without an HMAC secret the lock keys are a plain SHA256 digest of `(app_id, parse_class, principal, query_attrs)` — guessable for any caller who knows the schema — so an adversary with write access to `Parse.cache` can plant `parse-stack:foc:v1:<sha>` to suppress `first_or_create!` / `create_or_update!` for a tuple until TTL expiry. The warning now tells operators to either set `PARSE_STACK_LOCK_SECRET` or point `Parse.synchronize_create_store` at a separate Redis DB. (`lib/parse/model/core/create_lock.rb`)
11
+ - **NEW**: `Parse::Cache::Redis#clear(scope:)` accepts an explicit `scope:` namespace argument that SCAN-deletes `<scope>:*` regardless of how the wrapper was constructed. This is the targeted escape hatch for ops tooling and multi-tenant deployments where the wrapper was built without a configured `@namespace` but the caller still wants to evict a specific prefix without `FLUSHDB`-ing siblings (or wiping the `parse-stack:foc:v1:*` create-lock keys that live on the same DB). Trailing `:` in the input is stripped so `"tenant_x"` and `"tenant_x:"` are equivalent. The `scope:` argument is strictly validated and raises `ArgumentError` when it is not a `String`, is empty (or `":"` only), or contains Redis SCAN glob metacharacters (`*`, `?`, `[`, `]`, `\`) or a NUL byte — otherwise `scope: "*"` would expand the SCAN pattern and delete every key on the DB, defeating the whole point of keeping `flush_db!` as the explicit wide-blast-radius escape hatch. The no-argument form preserves the previous semantics — namespace-scoped SCAN-delete when `@namespace` is set, full `FLUSHDB` otherwise — so existing `Parse::Client#clear_cache!` callers are unaffected. (`lib/parse/cache/redis.rb`)
12
+
13
+ ### 5.0.0
14
+
15
+ #### Client-mode `Parse::Agent`
16
+
17
+ - **NEW**: `Parse::Agent` now supports a *client mode* — an agent constructed against a `Parse::Client` that carries no `master_key` and a non-empty `session_token:`. In this mode every tool dispatched routes through a session-token REST endpoint that Parse Server natively authorizes (ACL + CLP + protectedFields), so the SDK does not need a master-key fallback. The dispatchable tool set is a small, deliberate allowlist: the read tools `list_tools`, `get_object`, `get_objects`, `query_class`, `count_objects`, `get_sample_objects`, and the mutation tools `create_object`, `update_object`, `delete_object` (additionally gated by the new `allow_mutations:` kwarg). Generic `call_method`, aggregate, atlas-search, schema-introspection, and explain tools are refused at the dispatch ceiling because they require either the master key or a direct MongoDB connection — neither of which a client-mode agent has. (`lib/parse/agent.rb`)
18
+ - **NEW**: `allow_mutations:` constructor kwarg on `Parse::Agent.new`. Per-agent mutation gate that AND-composes with the existing process-level env vars (`PARSE_AGENT_ALLOW_WRITE_TOOLS` and `PARSE_AGENT_ALLOW_RAW_CRUD`). Default is `false` in client mode (default-deny, opt in per agent) and `true` in master-key mode (back-compat — existing master-key agents continue to use the env vars alone). Explicit `allow_mutations: false` on a master-key agent disables raw CRUD for that agent even when the env vars are set. Sub-agents cannot widen the parent's gate; `Parse::Agent.new(parent: writable, allow_mutations: true)` raises `ArgumentError` when the parent's gate is `false`. (`lib/parse/agent.rb`)
19
+ - **NEW**: `Parse::Agent#client_mode?` and `Parse::Agent#allow_mutations?` readers expose the resolved posture so factories, MCP rack apps, and custom tool handlers can branch on it without inspecting the underlying client.
20
+ - **NEW**: `client_safe:` kwarg on `Parse::Agent::Tools.register(...)`. Custom tools default to master-key-only — a registered tool is refused at the client-mode dispatch ceiling unless the author explicitly declares `client_safe: true`, in which case the handler is responsible for routing through `agent.client` with `agent.session_token` (never the master key). The companion `Parse::Agent::Tools.client_safe?(name)` predicate reports whether a built-in or registered tool is eligible for client-mode dispatch. (`lib/parse/agent/tools.rb`)
21
+ - **CHANGED**: `Parse::Agent.new` now refuses `acl_user:` and `acl_role:` when the underlying client has no `master_key`, regardless of whether `session_token:` was also supplied. Both are unverified constructor assertions the SDK can only honor via master-key REST; there is no session-token equivalent on Parse Server's REST surface. The error message points the caller at `session_token:` or at switching to a master-key client. The previous behavior was to accept the kwargs and fail per-call at first REST dispatch with a less actionable error. (`lib/parse/agent.rb`)
22
+ - **CHANGED**: The existing `WRITE_GATED_TOOLS` dispatch check (`create_object` / `update_object` / `delete_object`) now AND-composes with the per-agent `@allow_mutations` ivar in addition to the existing `PARSE_AGENT_ALLOW_WRITE_TOOLS` and `PARSE_AGENT_ALLOW_RAW_CRUD` env vars. The error response enumerates whichever gates are still missing so operators can see exactly which knob is off. (`lib/parse/agent.rb`)
23
+ - **CHANGED**: When a tool is refused by both the operator's per-instance `tools: { only: / except: }` filter AND a deeper gate (the client-mode mutation gate, the mode ceiling), the dispatch refusal now prefers the operator-filter explanation with `:tool_filtered`. Without operator-filter precedence, an operator who had narrowed `tools: { except: [:create_object] }` and left `allow_mutations:` at its default `false` was told "set `allow_mutations: true`" — a fix that would not actually help, because the operator's own filter was the binding gate. The new ordering surfaces the right knob first. (`lib/parse/agent.rb`)
24
+ - **NEW**: Unit coverage in `test/lib/parse/agent_client_mode_test.rb` (35 cases) — client-mode detection trigger, refusal of `acl_user:` / `acl_role:` on a no-master-key client, dispatch refusal for `call_method` / `aggregate` / `atlas_text_search` / `get_all_schemas`, allow-through for `query_class` and `list_tools`, `create_object` refusal without `allow_mutations`, master-key-default-`true` vs client-mode-default-`false` for `allow_mutations`, sub-agent widening refusal, sub-agent inherit-on-omit, sub-agent narrowing, sub-agent inheriting client mode from parent, custom-tool default-refused, custom-tool allowed with `client_safe: true`, the `Tools.client_safe?` predicate over the built-in catalog, the `allowed_tools` catalog filter, the operator `tools:` filter intersecting (and unable to widen) the client-mode ceiling, parity between the LLM-facing `tool_definitions` and dispatch-time `allowed_tools`, the `agent_hidden` class refusal layering correctly under the client-mode ceiling, the message-shape distinction between the mode-ceiling refusal (names the tool) and the class-accessibility refusal (echoes the requested class name), operator-filter precedence over both the mutation-gate and mode-ceiling messages, and a regression pin that LLM-supplied `session_token:` / `use_master_key:` / `acl_user:` in tool-call JSON cannot mutate the agent's `request_opts`.
25
+
26
+ #### Ambient session token + imperative console login
27
+
28
+ - **NEW**: `Parse.with_session(token) { … }` runs the supplied block with a fiber-local ambient session token. Inside the block, every Parse request that does not explicitly pass `session_token:` and does not explicitly request `use_master_key: true` is sent with this token — equivalent to threading `session_token:` through every call site, but block-scoped. `token` may be a String, a `Parse::User` (its `session_token` is read), a `Parse::Session`, or `nil`. Passing `nil` blanks the ambient inside the block, useful for performing one anonymous call inside an otherwise session-scoped region. Nested blocks save and restore the previous value on exit (LIFO), and the `ensure` clause guarantees cleanup even when the block raises. (`lib/parse/stack.rb`)
29
+ - **NEW**: `Parse.login(username, password, mfa_token: nil)` and `Parse.logout(revoke: true)` are imperative companions to `with_session` intended for REPL and Rake-console use. `login` stashes the resulting session token and user on the current fiber so subsequent calls in the IRB main fiber are auth-scoped to that user without further plumbing; `logout` clears both and, by default, revokes the token server-side via `POST /parse/logout`. When `mfa_token:` is supplied the credentials are submitted via the MFA endpoint; when the server requires MFA and none is supplied, `Parse::MFA::RequiredError` is raised so the caller can prompt for the code and retry. (`lib/parse/stack.rb`)
30
+ - **NEW**: `Parse.current_session_token` and `Parse.current_user` accessors expose the ambient set by `Parse.login` / `Parse.with_session` for the current fiber. `current_user` is populated only by the imperative `Parse.login` path — block-scoped `with_session(token)` carries a token without a user object and intentionally does not populate the user cache. (`lib/parse/stack.rb`)
31
+ - **NEW**: `Parse::User#with_session { … }` instance sugar wraps `Parse.with_session(self.session_token)`. Raises `Parse::Error::AuthenticationError` with a "requires an authenticated session" message when called on a user that does not carry a `session_token`, failing closed rather than silently dropping into an anonymous block. (`lib/parse/model/classes/user.rb`)
32
+ - **CHANGED**: `Parse::Client#request` now resolves an ambient session token from `Parse.current_session_token` when the caller did not pass an explicit `session_token:` and did not pass `use_master_key: true`. Resolution order is: (1) explicit per-call `session_token:`, (2) fiber-local ambient, (3) no session token (master key or anonymous, per existing rules). Explicit `use_master_key: true` skips the ambient entirely so `admin.do_thing(use_master_key: true)` nested inside a `with_session(user)` block sends as admin, not as the ambient user. When a session token is in play — explicit or ambient — the request also sets `X-Disable-Parse-Master-Key: true` so the auth context cannot silently widen. (`lib/parse/client.rb`)
33
+ - **CHANGED**: `Parse::Object.subscribe(where:, fields:, session_token:, client:)` now picks up `Parse.current_session_token` when `session_token:` is omitted, so LiveQuery subscriptions opened inside a `with_session` block (or after `Parse.login`) are ACL-aware as that user without the caller threading the token through. An explicit `session_token: nil` still suppresses the ambient. (`lib/parse/model/core/querying.rb`)
34
+ - **NEW**: `Parse.watch(klass, where: {}, on: nil, fields: nil, session_token: nil) { |event, obj| … }` opens a LiveQuery subscription and blocks the current thread until SIGINT (Ctrl-C), emitting arriving events to `$stdout` by default or to the supplied block. `on:` accepts a Symbol or Array of Symbols selecting which event types to subscribe to (default `[:create, :update, :delete, :enter, :leave]`). The SIGINT handler is installed via `Signal.trap("INT")` for the lifetime of the call and the prior handler is restored on exit, so library users can wrap `watch` inside their own signal-handling code without losing it. Returns the count of events delivered before the caller interrupted or the subscription was torn down. Also exposed as `Klass.watch(**)` for any `Parse::Object` subclass. (`lib/parse/console.rb`)
35
+ - **NEW**: `Parse.wait_for(klass, where: {}, on: nil, timeout: nil, fields: nil, session_token: nil) { |obj| predicate } -> Parse::Object` opens a LiveQuery subscription, blocks until the first event whose object satisfies the optional predicate arrives, then returns that object. Default event set is `[:create, :enter]`; pass `on: :update` for status-flip watching. `timeout:` raises `Timeout::Error` on elapse. A predicate that raises inside the LiveQuery callback thread propagates back to the parked caller through the internal queue and triggers the `ensure`-clause unsubscribe; an `:error` event from the subscription likewise wakes the caller and raises. Also exposed as `Klass.wait_for(**)`. (`lib/parse/console.rb`)
36
+ - **NEW**: Auth-resolution order is documented end-to-end on the new APIs' YARD: explicit kwarg > fiber-local ambient > `Parse.client_mode` flag > master key (when configured). Ruby 3.2+ Fiber storage semantics — child fibers and new threads' root fibers inherit a copy of the parent's storage at creation time; mutations inside the child do not escape back to the parent — are codified in `test/lib/parse/client_rest_with_session_integration_test.rb#test_ambient_fiber_storage_semantics`, which pins the contract that the parallel `find` path's pre-spawn snapshot of the ambient is the only safe pattern for parallel reads under a session.
37
+ - **NEW**: Integration coverage in `test/lib/parse/client_rest_with_session_integration_test.rb` (9 cases) — ambient session flows through to a plain class-level read with no explicit kwarg, ambient does not leak outside the block, nested blocks restore the outer token, explicit kwarg wins over ambient (and the ambient path stays scoped to the outer user), `User#with_session` sugar, `User#with_session` on an unauthenticated user fails closed, `with_session(nil)` blanks the ambient inside the block, imperative `Parse.login` / `Parse.logout` for console use, and the Fiber-storage / Thread-inheritance contract. Unit coverage in `test/lib/parse/console_test.rb` (9 cases) stubs `klass.subscribe` with a fake subscription and covers default-event registration, predicate-skip behavior, predicate-raise propagation, `timeout:` elapse, subscription-emitted `:error` propagation, explicit `on:` overrides defaults, `watch` registering all five default events and tolerating handler errors without tearing the subscription down, and the non-subscribable-class guard.
38
+
39
+ #### RAG foundation — `:vector` property, embeddings registry, `find_similar`, `embed` DSL
40
+
41
+ - **NEW**: `Parse::Vector` value class and `:vector` property data type. Declare a dense numeric embedding on any `Parse::Object` subclass with `property :embedding, :vector, dimensions: 1536, provider: :openai, model: "text-embedding-3-small", similarity: :cosine`. The value class enforces finite-Numeric elements at construction (no NaN, no ±Infinity), caps dimensions at 16384, and serializes as a plain JSON array so the underlying MongoDB document stays a BSON array. `validates_each` on the property compares assigned vectors' dimensions against the declared `dimensions:` so shape errors raise at save time rather than at Atlas. (`lib/parse/model/vector.rb`, `lib/parse/model/core/properties.rb`)
42
+ - **NEW**: `Parse::Embeddings` provider registry with six text-embedding adapters out of the box plus a zero-network fixture. Every concrete provider extends `Parse::Embeddings::Provider`, runs response-shape validation against the declared `dimensions:`, suppresses Faraday's env-proxy autodiscovery by default (opt in via `allow_faraday_proxy:`), refuses `http://` base URLs without `allow_insecure_base_url: true`, redacts `@api_key` from `#inspect`, and emits the `parse.embeddings.embed` AS::N event described below.
43
+ - `Parse::Embeddings::Fixture` — deterministic, zero-network, auto-registered as `:fixture` for tests. (`lib/parse/embeddings/fixture.rb`)
44
+ - `Parse::Embeddings::OpenAI` — `text-embedding-3-small` (1536), `text-embedding-3-large` (3072, Matryoshka via `dimensions:`), and legacy `text-embedding-ada-002`. Forwards `OpenAI-Organization` / `OpenAI-Project` headers when supplied. (`lib/parse/embeddings/openai.rb`)
45
+ - `Parse::Embeddings::Cohere` — v3 family (`embed-english-v3.0`, `embed-multilingual-v3.0`, and their `-light-v3.0` siblings, 1024 / 384 dim) plus `embed-v4.0` (1536 native, 128k token context, Matryoshka-truncatable to {256, 512, 1024, 1536} via `dimensions:`, forwarded as `output_dimension` on the wire and omitted at native width). `embed-v4.0` is Cohere's text+image multimodal endpoint at the network boundary, but this release wires up the **text path only** — image inputs remain out of scope until v5.1's multimodal `embed_image` contract lands. Distinguishes `input_type:` at the wire (`search_query` / `search_document` / `classification` / `clustering`), tolerates both the `embeddings: { float: [...] }` and bare-array response shapes, and adds `Cohere-Api-Key` to `Parse::Middleware::BodyBuilder::REDACTED_HEADERS` for the vendor-header proxy case. (`lib/parse/embeddings/cohere.rb`)
46
+ - `Parse::Embeddings::Voyage` — full voyage-4 family (`voyage-4-large` 2048 incl. Matryoshka, `voyage-4` 1024, `voyage-4-lite` 512, `voyage-4-nano` 256), voyage-3 family (`voyage-3-large`, `voyage-3`, `voyage-3-lite`), domain models (`voyage-code-3`, `voyage-finance-2`, `voyage-law-2`), and `voyage-multimodal-3` (1024 dim, 32k token context). `voyage-multimodal-3` routes to Voyage's separate `/v1/multimodalembeddings` endpoint with a wrapped `inputs: [{ content: [{ type: "text", text: ... }] }]` envelope; this release exposes the **text path only** — image content rows are out of scope until v5.1. Maps `input_type: :search_query` / `:search_document` to Voyage's `query` / `document` (other SDK symbols omit the field). `voyage-4-nano` is open-weight on Hugging Face (Apache 2.0) and can be self-hosted behind `LocalHTTP`. Adds `Voyage-Api-Key` to `REDACTED_HEADERS`. (`lib/parse/embeddings/voyage.rb`)
47
+ - `Parse::Embeddings::Jina` — text-capable Jina rows only: `jina-embeddings-v3` (1024, Matryoshka 32–1024), `jina-embeddings-v4` (2048, Matryoshka), the v5 family (`jina-embeddings-v5-text-{small,nano}`, `jina-embeddings-v5-omni-{small,nano}` — omni accepts plain-text inputs through this provider), and `jina-code-embeddings-{0.5b,1.5b}`. Distinguishes `input_type:` via Jina's `task` field (`retrieval.query` / `retrieval.passage` / `classification` / `separation`). Rerankers (`jina-reranker-*`), `jina-vlm`, `jina-clip-v2`, and `ReaderLM-v2` are out of scope for the `embed_text` contract and not exposed here. (`lib/parse/embeddings/jina.rb`)
48
+ - `Parse::Embeddings::Qwen` — `qwen3-embedding-0.6b` (1024), `qwen3-embedding-4b` (2560), `qwen3-embedding-8b` (4096). Targets Alibaba Cloud DashScope's OpenAI-compatible endpoint (`/compatible-mode/v1/embeddings`); operators in mainland China should override `base_url:` to `https://dashscope.aliyuncs.com/compatible-mode/v1`. Every Qwen3-Embedding row is Matryoshka-capable. The same checkpoints are published open-weight on Hugging Face under Apache 2.0 — self-host with `LocalHTTP`. (`lib/parse/embeddings/qwen.rb`)
49
+ - `Parse::Embeddings::LocalHTTP` — generic OpenAI-compatible client for self-hosted gateways (Ollama, LM Studio, vLLM, Text Embeddings Inference, llama.cpp). Configure-time SSRF gate reuses `Parse::File.resolve_addresses` and `Parse::File::BLOCKED_CIDRS` to refuse loopback / RFC1918 / link-local / cloud-metadata / CGNAT / IPv6 ULA bases unless the operator opts in with `allow_private_endpoint: true` (which also emits a `Kernel#warn` audit line on registration). `allow_insecure_base_url: true` is required to point at public-but-cleartext `http://` hosts. Tolerates response envelopes that omit the per-row `index` field (vLLM, llama.cpp variants). (`lib/parse/embeddings/local_http.rb`)
50
+
51
+ Register providers with the one-liner `Parse::Embeddings.register(:name, instance)` or the block form `Parse::Embeddings.configure { |c| c.providers[:name] = … }`. Provider lookups are lazy — declaring `provider: :openai` on a property does not require the OpenAI provider to be registered until first use. (`lib/parse/embeddings.rb`, `lib/parse/embeddings/provider.rb`)
52
+ - **NEW**: `Klass.find_similar(vector:/text:, k:, field:, filter:, vector_filter:, index:, **scope_opts)` class method on any `Parse::Object` subclass that declares a `:vector` property. Resolves the vector field automatically when the class has exactly one; auto-discovers the covering Atlas vectorSearch index via `Parse::AtlasSearch::IndexCatalog.find_vector_index`; validates the query vector's shape against the declared `dimensions:`. Returns `[Klass]` with each instance carrying `vector_score` (the Atlas `vectorSearchScore`). Accepts `text:` as an overload — the text is sent to the field's declared `provider:` with `input_type: :search_query` and the resulting vector replaces `vector:` transparently. ACL/CLP enforcement is inherited from `Parse::VectorSearch.search`, which routes through `Parse::MongoDB` (REST `/aggregate` is master-key-only and bypasses ACL/CLP — the mongo-direct path is the only one with first-class enforcement for scoped agents). (`lib/parse/model/core/vector_searchable.rb`, `lib/parse/vector_search.rb`)
53
+ - **NEW**: `embed *source_fields, into: :vector_property, input_type: :search_document, digest_field: nil` class macro. Declares a managed embedding: the listed source fields are concatenated on save (joined with `"\n\n"`, blank values skipped), SHA-256-digested, and only re-embedded when the digest changes. Auto-declares a `<into>_digest` `:string` sibling property to track the source-content digest. A `before_save` callback runs the digest check per directive and is a no-op when sources haven't changed (zero provider calls on update-only saves). Direct assignment to the managed vector field raises `Parse::Core::EmbedManaged::ProtectedFieldError` — the write path is locked behind the digest-tracked recompute so the stored vector can never silently desync from its source. (`lib/parse/model/core/embed_managed.rb`)
54
+ - **NOTE**: `embed` produces exactly **one vector per record** in v5.0. All source fields are concatenated into a single string passed to the provider. There is no built-in chunker — long-form source text whose concatenation exceeds the provider's per-call token budget will be truncated provider-side and the resulting vector will represent only the leading portion. Two patterns supported in v5.0: pre-chunk client-side and write each chunk as its own `Parse::Object` record, or maintain a dedicated `Chunk` subclass that belongs_to the parent with its own `embed` declaration. A built-in chunker plus a `semantic_search` agent tool are scheduled for v5.1.
55
+ - **NEW**: `Parse::AtlasSearch::IndexCatalog` extended to enumerate Atlas vectorSearch indexes (`find_vector_index`, `list_vector_indexes`) alongside its existing text-search index catalog. Operators define indexes once via `Parse::AtlasSearch::IndexCatalog.create_index(collection, definition)`; `find_similar` resolves the covering index by class + field at query time. (`lib/parse/atlas_search/index_catalog.rb`)
56
+ - **NEW**: `Parse::Middleware::BodyBuilder.redact` now compacts numeric-only Arrays of length ≥ 32 to `"<vector dims=N>"` in logged request/response bodies. Covers `$vectorSearch.queryVector` in aggregate bodies, `:vector` field values on save/fetch payloads, and batched embedding-provider response shapes. The threshold sits well below every common embedding width (BGE-small 384, Cohere 1024, OpenAI small 1536, OpenAI large 3072) and well above any normal Parse Array property (tags, role pointer lists), and the all-Numeric guard prevents mangling of long string/object arrays. Two concerns drive this: a 1536-float embedding inlines as ~25 KB per logged row, and embeddings are reversible-by-similarity against a public model (an attacker scraping operator logs can recover topic / sentiment / sometimes near-verbatim short text). (`lib/parse/client/body_builder.rb`)
57
+ - **NEW**: `Parse::Query#add_constraint` now raises `Parse::VectorSearch::ConstraintNotSupported` when a constraint targets a declared `:vector` property with any operator other than `:exists` / `:null` (both legitimate for backfill queries). Equality, range (`$gt`/`$lt`/`$gte`/`$lte`), `$in`, `$nin`, `$ne`, and `$all` on a dense 1536-float array are at best surprising and at worst wrong — the SDK fails fast at query-build time and points the caller at `find_similar(vector:/text:)`. The check resolves the operand against both the local property symbol (`:body_embedding`) and the camelCased remote field name (`:bodyEmbedding`); ad-hoc queries against tables that don't resolve to a registered `Parse::Object` subclass remain unaffected. (`lib/parse/query.rb`, `lib/parse/vector_search.rb`)
58
+ - **NEW**: `parse.embeddings.embed` `ActiveSupport::Notifications` event emitted from every concrete `Parse::Embeddings::Provider` subclass via the new `Provider#instrument_embed(input_count, input_type, **extra)` helper. Payload shape — `{provider: "Parse::Embeddings::OpenAI", model:, dimensions:, input_count:, input_type:, total_tokens:, cached:, error:}` — deliberately parallels the existing `parse.agent.tool_call` token-cost block and `parse.mongodb.*` namespace, so a single subscription tree can budget LLM, query, and embedding spend together. `Parse::Embeddings::OpenAI` extracts `total_tokens` from the response `usage` envelope; `Parse::Embeddings::Fixture` emits with `total_tokens: nil` so the event tree shape is identical in tests and production. Subscriber discipline (synchronous, on the request thread; slow / raising subscribers block or fail the embed call) is documented on the `Provider#instrument_embed` YARD alongside the stable payload contract. Errors raised from inside the instrument block tag the payload with `error: exception.class.name` (never the message) before re-raising — same redaction discipline as the cache-error and tool-call paths. (`lib/parse/embeddings/provider.rb`, `lib/parse/embeddings/openai.rb`, `lib/parse/embeddings/fixture.rb`)
59
+ - **NEW**: Integration coverage for the embed save round-trip — `test/lib/parse/embed_managed_integration_test.rb` exercises first-save population, idempotent no-op on unrelated field changes, recompute on source-field change, the protected-field guard against a live Parse Server, and the all-sources-blank clear path. Unit coverage in `test/lib/parse/embed_managed_test.rb` (declaration validation, multi-source concat, provider error shape, dimension mismatch), `test/lib/parse/vector_constraint_refusal_test.rb` (operator allow-list and remote-field-name routing), `test/lib/parse/embeddings_test.rb` extended with five `parse.embeddings.embed` AS::N cases (Fixture emits a structurally complete event, pre-validation failures emit no event, `:provider` carries the class name not instance state, custom providers mutate `:total_tokens` / `:cached` via the yielded payload, block exceptions tag `:error` with class name), `test/lib/parse/embeddings_openai_test.rb` extended with three AS::N cases (`total_tokens` extracted from the `usage` envelope, network-failure path tags `:error` with the typed exception class, missing-usage shape leaves `:total_tokens` nil without failing the request), and the existing `test/lib/parse/security_hardening_test.rb` (extended with 7 vector-compaction cases including nested aggregate `queryVector`, embedded-JSON strings, and provider-response shapes).
60
+
61
+ #### Anonymous-user upgrade helper
62
+
63
+ - **NEW**: `Parse::User.anonymous_signup` creates and logs in a new anonymous user (the `authData.anonymous` provider) and returns the logged-in instance with a session token. A client-generated UUID is supplied for the provider payload via `SecureRandom.uuid`, so callers don't have to hand-roll the `authData` shape. (`lib/parse/model/classes/user.rb`)
64
+ - **NEW**: `Parse::User#upgrade_anonymous!(username:, password:, email: nil)` upgrades an anonymous account in place by sending a single `PUT /users/:id` that sets the credentials and explicitly unlinks the anonymous provider (`authData: { anonymous: nil }`) in the same request. The unlink is essential: leaving `authData.anonymous` attached after a username is assigned would let anyone who learned the anonymous id silently log in as the freshly-named account, a documented Parse foot-gun. The method guards on `require_self_session!`, an attached objectId, and `anonymous?` — non-anonymous users and detached `Parse::User.new` instances raise `Parse::Error::AuthenticationError` rather than performing an unauthorized PUT. On success, the server-rotated session token (when present) and the new `username` / `email` are applied narrowly; `password` is cleared from memory and `changes_applied!` runs so a subsequent `save` doesn't re-transmit credentials. Maps Parse Server's username-taken / email-taken / email-invalid / missing-field error codes to the existing `Parse::Error::*` exception family. (`lib/parse/model/classes/user.rb`)
65
+
66
+ #### New ACL policies: `:public_read` and `:owner_but_public_read`
67
+
68
+ - **NEW**: `acl_policy :public_read` stamps `{"*": {"read": true}}` on newly-created records — read-anywhere, no write through ACL (only the master key can mutate). Useful for catalog / lookup / reference tables that every client needs to read but no client should mutate. Distinct from `:public` (public R/W) and from `:owner_else_public` (owner R/W if resolvable, public R/W otherwise). (`lib/parse/model/object.rb`)
69
+ - **NEW**: `acl_policy :owner_but_public_read, owner: :author` stamps the resolved owner with R/W AND grants public read in the same ACL — `{"*": {"read": true}, "<ownerId>": {"read": true, "write": true}}`. Useful for publicly-viewable content authored by a single user. When no owner resolves at save (no `as:` and no resolvable `owner:` field), falls back to `:public_read` semantics — public read, master-key-only write — rather than the `:owner_else_*` family's all-or-nothing fallback. (`lib/parse/model/object.rb`)
70
+ - **CHANGED**: `VALID_ACL_POLICIES` is now `[:public, :public_read, :private, :owner_else_public, :owner_else_private, :owner_but_public_read]`. The class-level guard that warns when `owner:` is supplied to a non-owner policy now mentions all three owner-aware policies.
71
+
72
+ #### Client-mode REST hardening and `docs/client_sdk_guide.md`
73
+
74
+ - **NEW**: `docs/client_sdk_guide.md` is a full field manual for using `parse-stack-next` as an unprivileged Parse client — no master key in the process, every authorization decision made by Parse Server against the caller's session token. Covers no-master configuration, `Parse.with_session` and `Parse.client_mode`, sessionless vs session-scoped CRUD, query/find behavior under ACL and CLP, file uploads when `fileUpload.enableForAuthenticatedUser` is set, the surfaces that are master-key-only on Parse Server (`/aggregate`, `/schemas`, full `/sessions` enumeration, `/config` writes, `/push`), and recommended patterns for threading auth through cloud functions and LiveQuery subscriptions. Every claim in the guide is pinned by an integration test under `test/lib/parse/client_*_integration_test.rb`.
75
+ - **NEW**: `Parse.track_event(name, dimensions: {}, **opts)` is a top-level shortcut for `Parse::Client#send_analytics`. Sends an event to Parse Server's `POST /events/<name>` endpoint without callers having to reach into `Parse.client`. Dimensions are passed via the `dimensions:` keyword — loose symbol arguments would otherwise be absorbed by `**opts` under Ruby 3 keyword separation and would never reach the POST body. `event_name` is validated against `[\w\-\.]` to keep the value from escaping the `/events/` path segment. Parse Server's default `analyticsAdapter` is a no-op (events are accepted but neither persisted nor queryable through the SDK); the legacy parse.com eight-dimension cap does NOT apply to Parse Server out of the box. The underlying request is a blocking HTTP POST — wrap in a thread/Sidekiq job if you don't want it on the request path. (`lib/parse/stack.rb`)
76
+ - **NEW**: `Parse::Client#send_analytics(event_name, metrics = {}, **opts)` now accepts a keyword-options splat (e.g. `session_token:`, `use_master_key:`) so analytics calls can be threaded through a session-scoped client. The `event_name` is validated against `[\w\-\.]` at the SDK boundary. Existing `send_analytics(name, metrics)` callers are unchanged. (`lib/parse/api/analytics.rb`)
77
+ - **NEW**: `Parse::Response#permission_denied?` collapses Parse Server's three authorization-failure shapes (HTTP 401/403, code 119 `OPERATION_FORBIDDEN`, code 209 `INVALID_SESSION_TOKEN`) into one predicate so client-mode `rescue` blocks don't have to remember both the HTTP-status and code-only paths. Constants `ERROR_OPERATION_FORBIDDEN` and `ERROR_INVALID_SESSION_TOKEN` exported for explicit comparison. (`lib/parse/client/response.rb`)
78
+ - **NEW**: `Parse::Object.all_as(token, constraints = { limit: :max })` and `Parse::Object.first_as(token, constraints = {})` are kwarg-form conveniences over `.all(session_token: …)` / `.first(session_token: …)` so client-mode callers don't have to remember the constraint-key spelling. Both accept a `Parse::User`, `Parse::Session`, or raw token string. Both return `nil` when the token is blank — fail-loud behavior so a missing token surfaces as a typed nil rather than an empty-array (`.all`) or missing-record (`.first`) false negative. (`lib/parse/model/core/querying.rb`)
79
+ - **CHANGED**: `Parse::Query` no longer initializes `@use_master_key = true`. The init value is now `nil` (tri-state: "no caller preference"). For master-key clients in their default mode this is a no-op — the request layer still sends the master key when no caller has explicitly overridden it. For `Parse.client_mode = true` processes and `Parse.with_session(user) { … }` blocks, this fix is load-bearing: the previous `true` default caused `_opts` to forward `use_master_key: true` on every query, short-circuiting the request-layer client-mode and ambient-session resolution paths so queries silently went out master-key-stamped regardless of the operator's intent. `Query#use_master_key=` and the `use_master_key:` constraint key still flip the preference explicitly. (`lib/parse/query.rb`)
80
+ - **CHANGED**: `Parse::Query#assert_mongo_direct_routable!` treats a configured master key on the client as an ambient credential in server mode. Direct-only constraints (`$geoIntersects` with full `$geometry` against a non-GeoPoint column, Atlas Search-shaped operators, etc.) route through mongo-direct as long as `Parse.client_mode` is false and `use_master_key` was not explicitly set to `false` — server apps don't need to thread `use_master_key: true` through every query that hits a direct-only constraint. The gate raises `Parse::Query::MongoDirectRequired` for client-mode processes or queries that explicitly opt out of the master key without supplying a `session_token` / `.scope_to_user(user)` / `.scope_to_role(role)`. (`lib/parse/query.rb`)
81
+ - **CHANGED**: `Parse::Client#request` now resolves the auth context in three layers: (1) explicit per-call `use_master_key:` / `session_token:`, (2) the fiber-local ambient set by `Parse.with_session`, and (3) the process-wide `Parse.client_mode` flag. When `Parse.client_mode` is true, the master-key header is omitted unless the caller explicitly passes `use_master_key: true`. An explicit `use_master_key: true` skips the ambient — `admin.do_thing(use_master_key: true)` nested inside a `with_session(user)` block now sends as admin, not as the ambient user. (`lib/parse/client.rb`)
82
+ - **NEW**: `Parse::Client#request` raises `ArgumentError` on the kwarg-absorption footgun where API helpers' `**opts` splat captured a caller-passed `opts: { session_token: t }` as a single hash key named `:opts` rather than as the request options hash. The auth context buried under `:opts` then never reached the request — the call silently went out anonymous or master-key-stamped. Fails loudly with a message pointing at the correct keyword form. (`lib/parse/client.rb`)
83
+ - **CHANGED**: `Parse::API::Push#push` is now master-key-gated. Parse Server's `POST /parse/push` endpoint has no session-token authorization model — it accepts master-key requests only. A no-master client calling `Parse.client.push(...)` previously got a 403 from the server with the SDK silently forwarding the unauthorized call. The method now raises `Parse::Error::AuthenticationError` at the SDK boundary when no master key is configured, sets `use_master_key: true` by default, and threads through `headers:` and `**opts:`. (`lib/parse/api/push.rb`)
84
+ - **CHANGED**: `Parse::API::Files#create_file(fileName, data = {}, content_type = nil, **opts)` accepts a keyword-options splat so client-mode file uploads can carry `session_token:` when the Parse Server is configured with `fileUpload.enableForAuthenticatedUser`. (`lib/parse/api/files.rb`)
85
+ - **FIXED**: `Parse::API::Users#update_user` now forwards the caller-supplied `headers:` kwarg to `Parse::Client#request`. The headers argument was silently dropped on the PUT, so client-mode callers passing `X-Parse-Session-Token` via headers got an anonymous request. (`lib/parse/api/users.rb`)
86
+ - **FIXED**: `Parse::LiveQuery::Client.new(master_key: nil)` now genuinely runs the WebSocket handshake without a master key. Previously the `||=` resolution chain treated explicit `master_key: nil` as "not supplied" and fell back to the LiveQuery config or the parent Parse client's master key — a silent master-key-smuggling bug for client-mode subscriptions. A private `NOT_PROVIDED` sentinel now distinguishes "argument omitted" from "argument explicitly nil"; only the omitted case falls through to the configured defaults. (`lib/parse/live_query/client.rb`)
87
+ - **NEW**: 11 integration test files under `test/lib/parse/client_*_integration_test.rb` exercise the no-master REST surface end-to-end against a live Parse Server — CRUD, queries, ACL/CLP/role enforcement, auth flows, file uploads, analytics, cloud functions, LiveQuery, forbidden master-key-only paths, and anonymous-CLP edge cases. Plus `test/support/client_mode_helper.rb` extensions for spinning up sessioned and sessionless clients in tests.
88
+
89
+ #### `User#logout_all!` / `#sessions` / `#active_session_count` self-scoping under client mode
90
+
91
+ - **FIXED**: `Parse::User#logout_all!`, `#sessions`, and `#active_session_count` now wrap their `_Session` query/destroy traffic in `Parse.with_session(@session_token)` so client-mode callers (no master key configured) don't have to remember to wrap the call site themselves. Previously the SDK issued the `/classes/_Session` queries without threading the caller's session token, and a no-master client got a 401 from Parse Server (the request went out anonymous against an ACL-protected collection). The fix uses the instance's own `@session_token` — owner-only by construction — and is a no-op for master-key callers (the ambient resolution layer skips the master-key path entirely). (`lib/parse/model/classes/user.rb`)
92
+ - **FIXED**: `Parse::User#logout_all!` is now a two-phase delete: it first revokes all OTHER `_Session` rows for the user (via `Parse::Session.revoke_all_for_user(self, except: current_token)`) under the live token, then explicitly logs out the calling token via `Parse.client.logout(current_token)`. The previous single-loop destroy hit the calling session row mid-iteration, invalidated the token, and then 401'd on the remaining destroys — so a caller asking to revoke 5 sessions would actually revoke 1 (the first one the iterator happened to pick up) before falling over. The dedicated `POST /parse/logout` for the self-token is idempotent; `Parse::Error::InvalidSessionTokenError` from a server that already cleared the token as a side effect is swallowed. (`lib/parse/model/classes/user.rb`)
93
+ - **NEW**: Integration coverage in `test/lib/parse/client_rest_logout_all_integration_test.rb` (7 cases) — SDK guard fires on detached `Parse::User.new` instances with no session token for all three methods (the ATO vector of constructing a pointer and calling `logout_all!`), happy path under client mode completes end-to-end without a 401, `keep_current: true` preserves the in-memory `@session_token`, `active_session_count` returns a positive Integer including the just-issued login session, `#sessions` returns the user's own `_Session` rows as `Parse::Session` instances. Companion coverage in `test/lib/parse/client_rest_session_mutation_integration_test.rb` (4 cases) — cross-user `_Session` query/`for_user`/DELETE/UPDATE are all denied by Parse Server's per-row owner-only ACL when called under a different user's session token, pinning that the SDK's session-scoped query plumbing threads the token through correctly for native ACL enforcement to fire. Plus `client_rest_authdata_link_integration_test.rb` (3 cases) pinning `link_auth_data!` / `unlink_auth_data!` round-trip via session token under client mode (server-side state verified via master-key fetch since Parse Server's PUT `/users/:id` response only echoes `updatedAt`), and `client_rest_push_master_only_integration_test.rb` (3 cases) pinning the SDK-boundary `Parse::Error::AuthenticationError` raise for `Parse.client.push` under client mode.
94
+ - **NEW**: Nine additional integration test files covering the remaining client-mode REST surfaces:
95
+ - `client_rest_server_info_integration_test.rb` (3 cases) — `/parse/health` works credential-free under client mode, `/parse/serverInfo` requires master key (`AuthenticationError` raised with "master key" message) under client mode, master-key path returns a hash containing `parseServerVersion`.
96
+ - `client_rest_batch_integration_test.rb` (4 cases) — `batch_request` of inserts under session token returns per-sub-request success responses with assigned objectIds, `Array#save` routes through `/batch` under session-token auth, mixed insert+update batch threads the session header through every sub-request (verified via master-key readback of the post-update state), anonymous batch under a `create: { requiresAuthentication: true }` CLP is rejected per-sub-request (load-bearing negative control proving the positive tests aren't passing by virtue of an open CLP).
97
+ - `client_rest_cloud_function_integration_test.rb` (6 cases) — open cloud function callable under client mode, parameters forwarded, `Parse.with_session` makes `request.user` visible to the function body, `call_function_with_session` helper authenticates, `requireMaster: true` functions are rejected under client mode with a real positive Parse-error code on the wire (asserted, so the test will turn into `assert_raises` if a future Parse Server version starts returning HTTP 403 instead of HTTP 200 + code 141), and callable under master key.
98
+ - `client_rest_relation_acl_integration_test.rb` (2 cases) — `AddRelation` / `RemoveRelation` on a `has_many :through => :relation` honors the parent row's ACL: owner can mutate under session token, non-owner is rejected (raw `update_object` PUT with `__op: AddRelation` body, dodging the autofetch path so the AddRelation auth gate itself is exercised).
99
+ - `client_rest_pointer_permissions_integration_test.rb` (3 cases) — CLP `readUserFields` / `writeUserFields` enforcement under session-token auth: owner reads their own row + non-owner read returns empty results, non-owner update is rejected, non-owner cannot re-point the pointer-permission field (closes the owner-takeover vector).
100
+ - `client_rest_installation_acl_integration_test.rb` (3 cases) — client-mode caller can register an `_Installation` row (typical mobile SDK boot flow), owner-scoped Installation row not readable by other users, anonymous `find` across `_Installation` does not silently enumerate every device's row (the negative assertion is paired with a master-key positive control proving the seeded rows DO exist server-side, so the "filtered to nothing" branch can't pass vacuously).
101
+ - `client_rest_mfa_login_integration_test.rb` (2 cases, 1 capability-skip) — `login_with_mfa` SDK boundary doesn't short-circuit on non-MFA-enrolled users (response comes from the wire), full MFA flow gated on capability detection (Parse Server in the test Docker setup has no MFA adapter configured, so the deeper assertion skips with a note rather than passing for the wrong reason).
102
+ - `client_rest_oauth_autologin_integration_test.rb` (3 cases) — `Parse::User.autologin_service(:anonymous, …)` end-to-end under client mode returns a logged-in user with a session token that authenticates against `/users/me`, `anonymous_signup` convenience round-trips, `autologin_service(:facebook, fixture_token)` is rejected (no silent master-key smuggling on a provider Parse Server can't verify against the upstream IdP).
103
+ - `client_rest_cloud_job_integration_test.rb` (3 cases) — `trigger_job` under client mode and under session token both surface `Parse::Error::AuthenticationError` (Parse Server's `POST /jobs/<name>` is master-key-only by contract, and the SDK middleware translates the 403 into the typed exception), master-key path reaches the server end-to-end.
104
+
105
+ #### Cross-user `_User` hydration: `authData` strip and trusted self-fetch scope
106
+
107
+ - **FIXED**: `Parse::User` no longer surfaces another user's `authData` (Facebook / Apple / Google `access_token` / `id_token`, anonymous provider uuid) when the row is hydrated through a query, `Parse::User.find(other_id)`, or autofetch. Parse Server returns `authData` on `GET /users/:id` to any caller with ACL read on the row — the SDK previously hydrated it straight onto the in-memory object, so any code that JSON-rendered a fetched user (Rails views, agent tool output, batch payloads) leaked OAuth tokens to the wrong viewer. `Parse::User#apply_attributes!` now strips both `:authData` / `"authData"` and the symbol/string `:auth_data` keys on the default (untrusted) hydration path and does so on a duplicate of the caller's hash so server JSON the caller hangs onto for logging is not mutated underneath them. (`lib/parse/model/classes/user.rb`)
108
+ - **NEW**: `Parse::User.with_authdata_trust { … }` is the scoped opt-out for the strip — a thread-local flag that the legitimate self-fetch paths wrap around their `build` / `apply_attributes!` calls because the response IS the authenticating user and the authData genuinely belongs to them. `login`, `login!`, `session!`, `create`, `link_auth_data!`, `unlink_auth_data!`, instance `#login!`, and the MFA `login_with_mfa` path in `Parse::TwoFactorAuth::User` are all wrapped. The block restores the prior value via `ensure` (so an exception inside doesn't leave the flag stuck on) and nests cleanly (inner block exit restores to the outer trusted state, not to false). `Parse::User.authdata_trusted?` reads the flag for callers writing their own trust-scoped helpers. (`lib/parse/model/classes/user.rb`, `lib/parse/two_factor_auth/user_extension.rb`)
109
+ - **NEW**: `test/lib/parse/user_authdata_strip_test.rb` pins the strip behavior at the hydration layer (9 cases) — strip on the default path, strip on symbol-keyed payloads, preservation inside `with_authdata_trust`, no leak across block boundaries, prior-state restore on exception, nested block semantics, the `apply_attributes!` strip on existing instances, and the no-mutate-caller-hash contract.
110
+
111
+ #### `Parse::User.session!` rejects `session_token` in the opts hash
112
+
113
+ - **FIXED**: `Parse::User.session!(token, opts = {})` now raises `ArgumentError` when `opts` contains a `:session_token` (or `"session_token"`) key. The positional `token` argument was sent in the URL while the opts-hash token was sent as the `X-Parse-Session-Token` header — split-brain auth where a Rails `params.merge` or a poorly-typed downstream call would silently authenticate as a different user from the one named in the URL. The positional argument is the only source of truth; the kwarg path now fails closed. (`lib/parse/model/classes/user.rb`)
114
+
115
+ #### `request_password_reset` per-email rate limiter
116
+
117
+ - **FIXED**: `Parse::API::Users#request_password_reset(email)` now shares the login rate limiter, keyed as `pwreset:<email>`. Without the limiter, an attacker could flood `POST /requestPasswordReset` for a single victim as an email-spam vector, or probe many addresses to enumerate the user table (Parse Server's response is intentionally identical for found / not-found emails, so the SDK is the only place to apply pre-network throttling). The `pwreset:` namespace prefix prevents collision with the login counter — five password-reset attempts for `alice@example.com` no longer consume the login budget for username `alice@example.com`. The sixth attempt within the window raises the same rate-limit `RuntimeError` shape the login limiter uses, before the request leaves the SDK. (`lib/parse/api/users.rb`)
118
+ - **NEW**: `test/lib/parse/api_users_password_reset_rate_limit_test.rb` covers first-five-allowed, sixth-locks-out, independent counters per email, and the cross-endpoint isolation contract (4 cases).
119
+
120
+ #### `Parse::NOT_PROVIDED` promoted to a top-level sentinel
121
+
122
+ - **NEW**: `Parse::NOT_PROVIDED` is a frozen top-level sentinel for distinguishing "kwarg omitted" from "kwarg explicitly nil" across the SDK. Use it as a kwarg default in any helper where `nil` is a legitimate caller value that should NOT trigger a config fallback. `Parse::LiveQuery::Client`'s previously-private `NOT_PROVIDED` now aliases the top-level constant. (`lib/parse/stack.rb`, `lib/parse/live_query/client.rb`)
123
+
124
+ #### `Parse.client_mode` regression coverage
125
+
126
+ - **NEW**: `test/lib/parse/client_master_key_env_fallthrough_test.rb` (5 cases) pins the `Parse.client_mode` contract at the request-construction layer without spinning up a Parse Server — strict boolean coercion (only literal `true` enables the flag; common truthy values like the string `"true"` and `1` do not), `DISABLE_MASTER_KEY` header is set on every outbound request when the flag is on even with a master key configured at the client level, `use_master_key: true` is a per-call escape hatch that clears the suppression, and the default-off mode leaves the master key intact. Future regression of the `PARSE_SERVER_MASTER_KEY` / `PARSE_MASTER_KEY` fallthrough surfaces in this unit test rather than only at integration time.
127
+
128
+ #### `Parse::Cache::Redis` ergonomic Redis cache with built-in connection pool
129
+
130
+ - **NEW**: `Parse::Cache::Redis.new(url:, namespace: nil, pool_size: 5, pool_timeout: 5, **moneta_options)` is a Moneta-compatible cache that composes a `ConnectionPool` of `Moneta-Redis` backends with the optional `cache_namespace:` prefix in a single object. Pass it directly to `Parse.setup(cache:)`; the namespace is forwarded to the caching middleware automatically without a separate `cache_namespace:` option. (`lib/parse/cache/redis.rb`)
131
+ - **NEW**: `Parse::Cache::Pool` is the underlying primitive — a thin facade that delegates the four Moneta methods (`[]`, `key?`, `delete`, `store`) the Faraday caching middleware uses through `ConnectionPool#with`. Removes the single-connection bottleneck where multi-threaded Puma workers serialized on one Redis socket's mutex. The default `pool_size: 5` matches the Puma default thread count. The wrapper YARD documents per-request checkout cost: cache hit = 2 checkouts (`key?` + `[]`), GET miss + store = up to 5 checkouts, non-GET write = 3 checkouts; size `pool_size` against `RAILS_MAX_THREADS` and raise it if `ConnectionPool::TimeoutError` appears in `parse.cache.error` events. (`lib/parse/cache/pool.rb`, `lib/parse/cache/redis.rb`)
132
+ - **NEW**: `Parse::Cache::Pool#clear` and `Parse::Cache::Redis#clear` so `Parse::Client#clear_cache!` works against the wrapper. Implementation is a single pooled checkout that calls `clear` on the underlying Moneta-Redis store — all pooled connections share one Redis DB, so `FLUSHDB` on any one connection clears every pooled view. **`clear` is deliberately NOT namespace-scoped:** despite the wrapper carrying a `namespace:`, `clear` issues `FLUSHDB` on the backing Redis DB and evicts every entry — including any other Parse app sharing this Redis DB. The Redis-wrapper YARD calls this out and recommends SCAN-based per-namespace eviction for multi-tenant deployments. (`lib/parse/cache/pool.rb`, `lib/parse/cache/redis.rb`)
133
+ - **CHANGED**: `connection_pool` is now an explicit runtime dependency (previously transitive via `activesupport`). (`parse-stack-next.gemspec`)
134
+ - **CHANGED**: The caching middleware's graceful-degrade `rescue` now also catches `ConnectionPool::TimeoutError`, so a saturated pool falls back to a passthrough request rather than raising to the caller. (`lib/parse/client/caching.rb`)
135
+ - **NEW**: `test/lib/parse/cache_redis_wrapper_test.rb` (unit) covers namespace normalization, pool-size defaults, Moneta-interface conformance, `Parse.setup(cache: wrapper)` acceptance, `Pool#clear` flushing the backend, and `Redis#clear` returning self for chaining. `test/lib/parse/cache_redis_integration_test.rb` adds `test_redis_wrapper_auto_threads_namespace`, `test_pool_handles_concurrent_access` (20 threads × 50 ops), and `test_client_clear_cache_through_wrapper` (verifies `Parse::Client#clear_cache!` through the wrapper does not raise `NoMethodError`, flushes the namespaced entry, AND codifies the cross-tenant blast-radius behavior by seeding an unrelated tenant's key and asserting it is also evicted) against a live Redis container.
136
+
137
+ #### Cache instrumentation via `ActiveSupport::Notifications`
138
+
139
+ - **NEW**: The caching middleware emits `parse.cache.hit`, `parse.cache.miss`, `parse.cache.store`, `parse.cache.delete`, and `parse.cache.error` events, matching the existing `parse.mongodb.*` namespace convention. Payload schema (stable contract): `:event`, `:method`, `:namespace`, `:url_path`, `:duration_ms` on `store` events, `:error` (exception class name only) on `error` events, and `:reason` (`:empty_payload` or `:write_only`) on certain `miss` events. (`lib/parse/client/caching.rb`)
140
+ - **CHANGED**: The cache key itself is intentionally **never** emitted in payloads. Keys carry a hashed session-token prefix that would be a side-channel for "this user has data" enumeration. Query strings are also stripped from `:url_path` because Parse query JSON encoded there can be long or carry PII. Exception class names — never `message` or `backtrace` — are the only error information forwarded; some Moneta/Redis drivers echo the offending key in `e.message`, which would re-introduce the side-channel.
141
+ - **CHANGED**: The middleware's `puts "[Parse::Cache] Error: ..."` debug lines now log `e.class.name` only, matching what is emitted to AS::N. Same rationale — driver error messages sometimes echo the cache key. The hit-log line at `caching.rb:155` (opt-in via `Parse::Middleware::Caching.logging = true`) now logs `url.path` rather than the full `url.to_s` so query-string `where=` JSON does not land in stdout. (`lib/parse/client/caching.rb`)
142
+ - **CHANGED**: AS::N subscribers run **synchronously on the Faraday request thread**. The `instrument_cache` YARD documents this contract: a slow subscriber blocks every cached request, and an exception raised inside a subscriber surfaces as a request failure. Keep subscribers cheap (counters, in-memory accumulators) or push to non-blocking sinks like StatsD-over-UDP. The `:namespace` field is operator-configured and is observable to every subscriber — treat subscribers as you would your application log sink.
143
+ - **NEW**: `test/lib/parse/cache_redis_integration_test.rb#test_cache_emits_active_support_notifications` verifies hit/miss/store/delete events fire in order against a live Redis backend and asserts that payloads never include `:cache_key` and that `:url_path` carries no query string.
144
+
145
+ #### Redis cache key namespacing
146
+
147
+ - **NEW**: `Parse.setup` / `Parse::Client.new` accept a `cache_namespace:` option that prefixes every cache key as `<namespace>:<existing-prefix>:<url>`. Lets two Parse apps share a single Redis instance without colliding on identical resource paths (e.g. `mk:/classes/Song/abc`). Defaults to no namespace, preserving backward compatibility for single-app deployments. Explicit only — the SDK does not auto-derive a prefix from `app_id`. (`lib/parse/client.rb`, `lib/parse/client/caching.rb`)
148
+ - **CHANGED**: When `cache_namespace:` is set, the cache invalidation path on non-GET requests only deletes namespaced variants of the resource key. A PUT through one app's client no longer evicts another app's cached entry for the same path in a shared Redis. Unnamespaced deployments retain the prior delete behavior unchanged.
149
+ - **NEW**: `test/lib/parse/cache_redis_integration_test.rb` adds `test_namespaced_caches_dont_collide` and `test_same_namespace_still_shares` covering cross-app isolation, intra-app sharing, and cross-namespace invalidation safety against a live Redis container.
150
+
151
+ #### Removed: `Parse::Hyperdrive` remote-config helper
152
+
153
+ - **BREAKING**: `Parse::Hyperdrive.config!` is removed. The helper fetched a JSON document from a remote URL (`HYPERDRIVE_URL` or `CONFIG_URL`) and merged the result into the process `ENV` at boot. It carried real security weight that did not justify a vendor-specific shim in a general-purpose SDK: there was no allowlist over which env vars the response could set (so a compromised endpoint could write `PATH`, `RUBYLIB`, `LD_PRELOAD`, `BUNDLE_GEMFILE`, `PARSE_MASTER_KEY`, etc., handing the process to the attacker at next subprocess or `require`), no SSRF gate against internal hosts (unlike `Parse::Embeddings::LocalHTTP`, which reuses `Parse::File::BLOCKED_CIDRS`), no response-size cap before `JSON.parse`, and no authentication or signature on the fetch. Operators who relied on this should switch to a purpose-built secrets / config source — `dotenv` for local development, Rails encrypted credentials, Vault, AWS Secrets Manager, GCP Secret Manager, or platform-native config vars (Heroku, Render, Kubernetes Secrets) — all of which scope which keys are settable and authenticate the fetch. The `HYPERDRIVE_URL` and `CONFIG_URL` entries are removed from `.env.sample`. (`lib/parse/stack.rb`)
154
+
155
+ #### Gem renamed to `parse-stack-next`
156
+
157
+ - **BREAKING**: The gem is now published as `parse-stack-next` under the [neurosynq](https://github.com/neurosynq) organization. Update your `Gemfile` from `gem 'parse-stack'` to `gem 'parse-stack-next'`. The Ruby require path (`require 'parse/stack'`) and the `Parse::*` module namespace are unchanged, so application code and model classes do not need to be modified.
158
+ - **NEW**: `lib/parse-stack-next.rb` is the gem's auto-require entry point. `lib/parse-stack.rb` is retained as a back-compat shim for callers that manually `require 'parse-stack'`.
159
+ - **CHANGED**: Gemspec homepage now points at `https://github.com/neurosynq/parse-stack-next`. Authorship credits and license (MIT) are preserved from upstream.
160
+
161
+ #### Ruby 3.x Optimization
162
+
163
+ - **CHANGED**: `Parse::Model` no longer stores its parse-class lookup cache in a `@@model_cache` class variable. The cache now lives as a class-instance variable on `Parse::Model` (`@model_cache`) guarded by an explicit `Mutex`, matching the per-class state convention already used elsewhere in the SDK (see `Parse::ACLScope` `@no_acl_warned`). The cache is referenced through `Parse::Model.model_cache_mutex.synchronize` so subclass dispatch (`Parse::Object.find_class`) resolves the cache on the correct singleton — class-instance state is not inherited the way a `@@class_var` would be. Memoization semantics and the existing per-descendant `rescue` for anonymous-class `parse_class` raises are preserved. (`lib/parse/model/model.rb`)
164
+ - **CHANGED**: `Parse::LiveQuery::Subscription` request-id generation no longer uses `@@id_monitor` / `@@request_counter` class variables. The counter and the guarding `Monitor` are now class-instance state on the `Subscription` singleton, exposed through `Parse::LiveQuery::Subscription.next_request_id`. The instance method `#generate_request_id` delegates to it. Sequential, monotonically increasing request IDs across threads are preserved. (`lib/parse/live_query/subscription.rb`)
165
+ - **IMPROVED**: `Parse::Query::GeoIntersectsQueryConstraint#coerce_to_geojson` rewritten with Ruby 3 `case/in` pattern matching. The accepted-type branch (`Parse::GeoJSON::Geometry | Parse::Polygon | Parse::GeoPoint`) and the GeoJSON hash shape (`{ type: String => type, coordinates: Array => coords }` with the `ALLOWED_GEOJSON_TYPES` guard) are now expressed declaratively rather than as imperative type/key extraction. Wire-shape hashes (string keys from JSON) are still normalised to symbol keys before the inner pattern match — Ruby's hash patterns are symbol-key-first. Distinct error messages for "invalid GeoJSON Hash shape" versus "unsupported value type" are preserved. (`lib/parse/query/constraints.rb`)
166
+ - **CHANGED**: `lib/parse/client.rb` and `lib/parse/model/shortnames.rb` now carry the `# frozen_string_literal: true` magic comment, completing the frozen-string audit for shipping gem code. The remaining files without the comment under `lib/parse/stack/generators/templates/` are Rails generator templates, intentionally bare because they are scaffolds rendered into user applications.
167
+
168
+ #### Deprecation warning for unsupported Parse Server versions
169
+
170
+ - **NEW**: The SDK now emits a one-shot deprecation warning the first time `Parse::Client#server_info` resolves against a Parse Server running below the supported floor (currently `7.0.0`, tracking Parse Server N-2 against the 9.x current major). The warning lists the behaviors newer Parse Stack releases assume — CLP shape, aggregate envelope, `$vectorSearch`, schema endpoints — that may not be present on the connected server. (`lib/parse/api/server.rb`)
171
+ - **NEW**: `Parse.suppress_server_version_warning = true` (Ruby) and `PARSE_SUPPRESS_SERVER_VERSION_WARNING=true` (ENV) silence the warning for operators on a known-old Parse Server pinned for an explicit reason. The floor itself is overridable via `PARSE_DEPRECATED_SERVER_VERSION_BELOW=<version>` so an operator can lower or raise the gate without forking the SDK. (`lib/parse/stack.rb`, `lib/parse/api/server.rb`)
172
+ - **CHANGED**: The warning latches per `Parse::Client` instance via `@server_version_warned`, so a long-running process pays the formatting cost exactly once per client. `server_info!` (forced refresh) resets the latch, so re-evaluating against a freshly upgraded server re-emits or clears the warning as appropriate. The check fails closed on unparseable `parseServerVersion` strings (loose `\d+` semver compare on major.minor) — a wire-format surprise never raises out of `server_info`.
173
+
174
+ #### `mongo_relation_index :field, dedup: true` — compound `{owningId, relatedId}` unique
175
+
176
+ - **NEW**: `mongo_relation_index` accepts `dedup: true` to register a compound unique index on `{owningId: 1, relatedId: 1}` against the `_Join:<field>:<ClassName>` collection. Prevents duplicate-pair subscription in a Parse relation (the same `relatedId` cannot appear twice for a given `owningId`) without constraining cardinality the way a single-direction `unique:` would. Pairs with `bidirectional: true` to additionally index the reverse lookup; `dedup: true` and `bidirectional: true` together register all three declarations. (`lib/parse/model/core/indexing.rb`)
177
+ - **CHANGED**: `mongo_relation_index :field, unique: true` continues to raise `ArgumentError` — single-direction column uniqueness on a `_Join` collection breaks `has_many` semantics. The error message now points at `dedup: true` as the supported way to express duplicate-pair prevention. (`lib/parse/model/core/indexing.rb`)
178
+
179
+ ```ruby
180
+ class Project < Parse::Object
181
+ has_many :members, through: :relation, as: :user
182
+ mongo_relation_index :members, bidirectional: true, dedup: true
183
+ end
184
+ # Registers:
185
+ # _Join:members:Project { owningId: 1 } # find members by project
186
+ # _Join:members:Project { relatedId: 1 } # find projects by member
187
+ # _Join:members:Project { owningId: 1, relatedId: 1 } UNIQUE # one (project, member) pair
188
+ ```
189
+
190
+ #### LiveQuery documentation reframed (stable since 3.0.0)
191
+
192
+ - **CHANGED**: The "EXPERIMENTAL: This feature is not fully implemented" note on `Parse::LiveQuery` has been dropped — the WebSocket client and `Subscription`/`Client` surfaces have shipped and been stable since 3.0.0. The `Parse.live_query_enabled = true` opt-in toggle is preserved and reframed as a network-egress safety gate (the operator consciously enables outbound WebSocket connections), not a stability warning. `Parse::LiveQuery::NotEnabledError` message updated to match. No behavior change for callers that were already setting the toggle. (`lib/parse/live_query.rb`, `lib/parse/stack.rb`)
193
+
194
+ #### MCP health check endpoint helper
195
+
196
+ - **NEW**: `Parse::Agent::MCPRackApp.new(..., health_path: "/health")` registers a liveness probe. A `GET` to the exact configured path returns `200 {"status":"ok"}` without invoking the `agent_factory`, without consulting the `pre_auth_rate_limiter`, and without applying the `allowed_origins` / `require_custom_header` CSRF gates. Intended for Kubernetes/ECS/Consul/ELB liveness checks that need a cheap "is the process serving?" signal without provisioning an MCP session token. Defaults to `nil` (disabled). The response body is intentionally fingerprint-minimal — no version, no build, no dispatcher counters — because liveness probes don't need that information and exposing it widens the reconnaissance surface. (`lib/parse/agent/mcp_rack_app.rb`)
197
+
198
+ #### `parse.mongodb.aggregate` / `parse.mongodb.find` AS::N notifications
199
+
200
+ - **NEW**: `Parse::MongoDB.aggregate` now emits a `parse.mongodb.aggregate` `ActiveSupport::Notifications` event around its critical-path body. The payload carries `collection`, `scope` (`:master` / `:user` / `:role` / `:anon`), `stage_count`, `stage_types` (top-level operator names from the caller's pipeline, capped at 32 to bound cardinality), `result_count`, `max_time_ms`, and `read_preference`. The existing `parse.mongodb.role_graph` event nests as a child when role expansion runs inside an aggregate, so APM/OTel subscribers see the role walk as a span beneath its parent aggregate. (`lib/parse/mongodb.rb`)
201
+ - **NEW**: `Parse::MongoDB.find` now emits a `parse.mongodb.find` event with payload `collection`, `has_filter` (boolean — body excluded), `projection_keys` (column names only, never values), `limit`, `max_time_ms`, and `result_count`. The `find` payload deliberately has no `scope` field — `Parse::MongoDB.find` takes no ACL kwargs, so there is no resolution to label; shared subscribers that handle both event names must treat `payload[:scope]` as optional. (`lib/parse/mongodb.rb`)
202
+ - **CHANGED**: The payload schema for both events is a public contract — pipeline bodies, filter bodies, and projection values are deliberately excluded because they routinely embed user-id strings, session identifiers, tenant IDs, and search terms. Subscribers (including the bundled slow-query subscriber, `parse-stack-otel`, and operator-written instrumentation) can rely on the schema for span attributes and log-line formatting without re-validating PII safety per-callsite. The `stage_types` field is capped at `INSTRUMENT_STAGE_TYPES_LIMIT = 32` so a 10k-stage pipeline cannot bloat every subscriber's output.
203
+
204
+ #### MCP Streamable HTTP transport — session-id header rename, protocol-version validation, session lifecycle
205
+
206
+ - **BREAKING**: `MCPRackApp` now reads the MCP 2025-06-18 Streamable HTTP spec-canonical `Mcp-Session-Id` request header for conversation correlation; the pre-spec `X-MCP-Session-Id` header is no longer accepted. Clients that were sending `X-MCP-Session-Id` for cooperative cancellation or audit-log correlation must migrate to `Mcp-Session-Id` on the v5.0 upgrade — this is a clean rename with no fallback path. (`lib/parse/agent/mcp_rack_app.rb`)
207
+ - **NEW**: `MCPRackApp` validates the MCP 2025-06-18-required `MCP-Protocol-Version` header on every non-initialize request. Unsupported versions are refused with `400 Bad Request` and a `-32600` JSON-RPC envelope that round-trips the request id and names the offending version, BEFORE the agent factory is invoked (no Parse Server round-trip burned on malformed handshakes). Missing or empty headers are treated as back-compat per spec (server SHOULD assume `2025-03-26`); `initialize` and `notifications/cancelled` are exempt from the check because they are the negotiation surface itself. Supported versions are sourced from `Parse::Agent::MCPDispatcher::SUPPORTED_PROTOCOL_VERSIONS` (`2025-06-18`, `2025-03-26`, `2024-11-05`). (`lib/parse/agent/mcp_rack_app.rb`)
208
+ - **NEW**: `MCPRackApp` server-assigns a fresh `Mcp-Session-Id` on the `initialize` response when the client did not supply one. The id is a `SecureRandom.uuid`, bound to `agent.correlation_id`, and returned in the `Mcp-Session-Id` response header so the client can echo it on subsequent requests. A client-supplied `Mcp-Session-Id` on `initialize` is echoed back unchanged. A factory-bound `correlation_id` always wins over both — the factory is the authoritative source when the operator binds the id to an internal session record. Non-initialize responses do NOT carry the header (no per-reply leakage; the client already knows it). The SDK does not maintain a server-side session store: the id is best-effort correlation only, used for audit-log threading and cancellation routing, and subsequent requests carrying an "unknown" id are NOT refused. (`lib/parse/agent/mcp_rack_app.rb`)
209
+ - **NEW**: `MCPRackApp` accepts `DELETE /` for MCP-spec session termination. A `DELETE` carrying `Mcp-Session-Id` cancels every in-flight request registered under that correlation_id (via the new `CancellationRegistry#cancel_all_for`) and returns `204 No Content`. The header value is sanitized with the same URL-safe-ASCII regex used by `Parse::Agent#correlation_id=`; an invalid value returns `400`. A missing header returns `400 "Missing Mcp-Session-Id"`. The DELETE handler runs BEFORE the agent factory, so session-teardown traffic cannot force per-request agent construction. The previous behavior (DELETE → 405) is replaced. (`lib/parse/agent/mcp_rack_app.rb`)
210
+
211
+ #### MCP structured tool output for built-in tools (`structuredContent`)
212
+
213
+ - **NEW**: Built-in agent tools now declare an MCP `outputSchema` in `Parse::Agent::Tools::TOOL_DEFINITIONS` so the dispatcher mirrors their result Hash into `structuredContent` on `tools/call` responses per MCP 2025-06-18. Covered: `count_objects`, `get_object`, `get_objects`, `get_sample_objects`, `distinct`, `group_by`, `group_by_date`, `list_tools`, `get_all_schemas`, `get_schema`, and `query_class`. Remaining polymorphic-shape tools (`aggregate`, `explain_query`, `call_method`, `export_data`, `atlas_*`) continue to emit text-only content while their envelope shape stabilizes. (`lib/parse/agent/tools.rb`)
214
+ - **NEW**: `query_class`'s declared `outputSchema` is a permissive superset that admits both the default JSON row envelope (`{class_name, result_count, pagination, truncated, results, ...}`) and the `format: "csv"|"markdown"|"table"` text envelope (`{class_name, format, headers, row_count, output}`) within a single `type: "object"` root. MCP 2025-06-18 expects an object root on `outputSchema`, which precludes a top-level `oneOf`; clients that need to disambiguate inspect `format` (absent on the JSON envelope, present on text envelopes). Only `class_name` is required in the union. (`lib/parse/agent/tools.rb`)
215
+ - **NEW**: `get_schema`'s declared `outputSchema` describes the fixed outer envelope (`class_name`, `type`, `fields[]`, `indexes{}`, `permissions{}`) plus optional metadata keys (`description`, `usage`, `agent_methods[]`, `canonical_filter{}`, `agent_fields[]`, `agent_join_fields[]`, `relations{}`); inner `fields`/`indexes`/`permissions` shapes are declared as `additionalProperties: true` so per-field annotations (`allowed_values`, `large_field`, …) and Parse Server's CLP and index extensions remain forward-compatible. (`lib/parse/agent/tools.rb`)
216
+ - **NEW**: `get_all_schemas`'s declared `outputSchema` describes the catalog envelope (`{total, note, built_in[], custom[]}`) and the per-entry shape (`{name, fields, desc?, methods?}`). (`lib/parse/agent/tools.rb`)
217
+ - **CHANGED**: `Parse::Agent::Tools.output_schema_for(name)` now falls through to `TOOL_DEFINITIONS.dig(name, :output_schema)` when the registered-overlay lookup misses, so the dispatcher's `structuredContent` emission rule applies uniformly to built-in and application-registered tools. Custom registrations still override built-in declarations via the existing registry path. (`lib/parse/agent/tools.rb`)
218
+ - **NEW**: `tools_schema_validity_test` walks every declared `output_schema` to assert it is a JSON object schema and that nested `type: "array"` nodes carry an `items` definition. The same defect class that breaks OpenAI function-calling on input parameters also breaks any MCP client that validates `structuredContent` against the advertised `outputSchema`. (`test/lib/parse/agent/tools_schema_validity_test.rb`)
219
+ - **NEW**: End-to-end emission coverage in `mcp_dispatcher_test.rb` — `test_builtin_count_objects_emits_structuredContent`, `test_builtin_get_all_schemas_emits_structuredContent`, `test_builtin_get_schema_emits_structuredContent`, and two parallel tests for `query_class` covering both the default JSON row envelope and the `format: "csv"` text envelope. Each drives a real `tools/call` through the dispatcher and asserts `structuredContent` carries the expected shape, codifying the permissive-superset contract on `query_class`. (`test/lib/parse/agent/mcp_dispatcher_test.rb`)
220
+
221
+ #### `Parse::GraphQL::TypeGenerator` — graphql-ruby type generation from Parse schema
222
+
223
+ - **NEW**: `require "parse/graphql"` exposes `Parse::GraphQL::TypeGenerator.generate_all([Song, Album, Artist])`, which returns a `{parse_class_name => GraphQL::Schema::Object subclass}` registry generated from the local `Parse::Object` subclasses' property and association DSL. No network call to Parse Server is required; the generator reads `fields`, `field_map`, `references` (belongs_to), `has_one_associations`, `has_many_associations`, and `relations` directly. Field shape: scalars map to `GraphQL::Types::String/Int/Float/Boolean/ISO8601DateTime/ID`; `:file` to a `ParseFile { url: String!, name: String }` object type; `:geopoint` to a `ParseGeoPoint { latitude: Float!, longitude: Float! }` object type; `belongs_to` to a typed object field (`field :album, AlbumType`); `has_many` (all three storage modes — `:query`, `:array`, `:relation`) to a plain `[Type]` list, never a Relay connection (Parse pagination is offset-based, not cursor-based; faking cursors would mislead clients). The `:acl` field is intentionally omitted — authorization metadata does not belong in the public schema. `:array` / `:object` / `:vector` / `:polygon` columns without a declared element type fall through to a registered `JSON` scalar with a `warn`-level notice so authors can narrow the type if possible. The generator is two-pass (stub all types first, then add fields) so cross-class references resolve regardless of model declaration order. (`lib/parse/graphql.rb`, `lib/parse/graphql/scalars.rb`, `lib/parse/graphql/type_generator.rb`)
224
+ - **NEW**: `has_one` declarations now populate a `Klass.has_one_associations` class-level registry at DSL time (target class, foreign field, scope-only flag) — codegen no longer has to parse the generated method's closure to recover the association target. (`lib/parse/model/associations/has_one.rb`)
225
+ - **NEW**: `has_many` declarations now populate a `Klass.has_many_associations` class-level registry at DSL time for all three storage modes (`:query`, `:array`, `:relation`), capturing target class, storage mode, foreign field, and local field. Complements the existing `relations` hash, which only covered `through: :relation`. (`lib/parse/model/associations/has_many.rb`)
226
+ - **CHANGED**: `graphql` is a `development_dependency`, not a runtime dependency. `Parse::GraphQL.available?` mirrors the `Parse::MongoDB.gem_available?` soft-require pattern — operators who never opt into GraphQL codegen pay no load cost. Add `gem 'graphql', '~> 2.0'` to your Gemfile to enable the generator. (`parse-stack.gemspec`)
227
+ - **CHANGED**: Resolvers (query/mutation passthrough, Loaders, Relay Node interface, connection arguments) are intentionally deferred. Default `graphql-ruby` field resolution invokes the same-named method on the underlying Ruby object, and `Parse::Object` subclasses already expose typed accessors (`song.album`, `band.fans`) — so the generated types work in a consumer's schema without per-field resolver classes. Pagination via explicit `limit:` / `skip:` arguments on list fields will arrive with the query/mutation passthrough work.
228
+ - **NEW**: `:vector` columns (embeddings, bounded Float arrays) now emit as `[Float]` rather than falling through to the `JSON` scalar — preserves element-type information for clients consuming RAG/vector-search responses. `:bytes` columns (Parse's `{__type: Bytes, base64: ...}` wrapper) remain `JSON` with a `warn` so authors can declare a `:string` property holding the base64 instead. (`lib/parse/graphql/type_generator.rb`)
229
+ - **NEW**: `generate_all` now runs `detect_name_collisions!` after field emission and raises `RuntimeError` with the colliding Parse class names when two classes collapse to the same `graphql_name` (e.g. `My_Thing` and `MyThing`, since underscores are stripped to satisfy GraphQL's `[_A-Za-z][_0-9A-Za-z]*` identifier rules). Replaces graphql-ruby's generic `DuplicateNamesError` with a message that names the conflicting Parse classes. (`lib/parse/graphql/type_generator.rb`)
230
+
231
+ #### `Parse.slow_query_threshold_ms` — in-core slow query log
232
+
233
+ - **NEW**: `Parse.slow_query_threshold_ms = 250` (or `PARSE_SLOW_QUERY_THRESHOLD_MS=250` at boot) attaches a bundled subscriber to the `parse.mongodb.aggregate` and `parse.mongodb.find` AS::N events. Any event whose wall-clock duration exceeds the configured millisecond threshold logs a single `[Parse::MongoDB] SLOW` line at `warn` level through `Parse.logger`. The log line contains only payload metadata — no pipeline bodies, no filter bodies, no result rows. (`lib/parse/stack.rb`)
234
+ - **CHANGED**: The threshold is re-read on every event, so toggling `Parse.slow_query_threshold_ms = nil` at runtime silences the subscriber without resubscribing or restarting the process. The subscriber attaches at most once per process (guarded by `@slow_query_subscribed`), and is a no-op cheap pass-through when the threshold is `nil`. Operators who already subscribe to the raw AS::N events from their APM layer (Datadog, New Relic, OTel) can leave this knob unset and consume `parse.mongodb.aggregate` / `parse.mongodb.find` directly.
2
235
 
3
236
  ### 4.5.0
4
237
 
5
- - **CHANGED**: Gem renamed from `parse-stack` to `parse-stack-next` and published under the `neurosynq` organization. No functional changes beyond 4.4.3.
6
- - **CHANGED**: Repository home is now `github.com/neurosynq/parse-stack-next`.
238
+ - **CHANGED**: First release published as `parse-stack-next` on RubyGems under the `neurosynq` organization. No functional changes beyond 4.4.3 — this version exists as a clean rename baseline before the larger 5.0.0 feature set landed.
239
+ - **CHANGED**: Repository home is `github.com/neurosynq/parse-stack-next`.
7
240
 
8
241
  ### 4.4.3
9
242
 
@@ -19,23 +252,23 @@
19
252
 
20
253
  ```ruby
21
254
  # Biggest groups first
22
- Asset.where(:status => "active").group_by(:category).order(value: :desc).count
255
+ Document.where(:status => "active").group_by(:category).order(value: :desc).count
23
256
  # => {"image" => 142, "video" => 88, "audio" => 31}
24
257
 
25
258
  # Get the actual records per group, sorted by group size
26
- Asset.group_by(:category).order(size: :desc).list
27
- # => {"image" => [<Asset ...>, <Asset ...>], "video" => [<Asset ...>]}
259
+ Document.group_by(:category).order(size: :desc).list
260
+ # => {"image" => [<Document ...>, <Document ...>], "video" => [<Document ...>]}
28
261
 
29
262
  # Newest periods first
30
- Capture.group_by_date(:created_at, :day).order(key: :desc).count
263
+ Post.group_by_date(:created_at, :day).order(key: :desc).count
31
264
 
32
265
  # MongoDB-side sort on distinct
33
- Asset.where(...).distinct(:city, order: :asc)
266
+ Document.where(...).distinct(:city, order: :asc)
34
267
  ```
35
268
 
36
269
  #### Pointer-shape strictness and `$in` recursion fixes
37
270
 
38
- - **FIXED**: `Parse::Query#convert_constraints_for_aggregation` now recurses into `$and`, `$or`, and `$nor` combinator branches when rewriting pointer-column references. Previously a constraint shaped as `{ "$or" => [{ "team" => { "$in" => ["id1", "id2"] } }] }` shipped to MongoDB with `team` un-rewritten to the `_p_team` storage column and the bare strings un-prefixed — a silent zero-row result rather than an error. After 4.4.3 the rewrite walks the combinator tree, so a pointer-column `$in`/`$nin` wrapped in any boolean operator gets the same `ClassName$objectId` storage-form normalization as the top-level case. (`lib/parse/query.rb`)
271
+ - **FIXED**: `Parse::Query#convert_constraints_for_aggregation` now recurses into `$and`, `$or`, and `$nor` combinator branches when rewriting pointer-column references. Previously a constraint shaped as `{ "$or" => [{ "workspace" => { "$in" => ["id1", "id2"] } }] }` shipped to MongoDB with `workspace` un-rewritten to the `_p_workspace` storage column and the bare strings un-prefixed — a silent zero-row result rather than an error. After 4.4.3 the rewrite walks the combinator tree, so a pointer-column `$in`/`$nin` wrapped in any boolean operator gets the same `ClassName$objectId` storage-form normalization as the top-level case. (`lib/parse/query.rb`)
39
272
  - **NEW**: `Parse::Query::PointerShapeError` raised when a constraint value's shape cannot match the storage form of the targeted column — currently fired for bare objectId strings inside a `$in`/`$nin` array against a pointer column whose target class cannot be inferred from the local schema or from peer Pointer values in the same array. Such a query was previously a guaranteed silent zero. (`lib/parse/query.rb`)
40
273
  - **NEW**: `Parse.strict_pointer_shapes` global setting with `PARSE_STRICT_POINTER_SHAPES=true` ENV fallback. When true, `Parse::Query` raises `PointerShapeError` on impossible pointer shapes instead of silently passing the value through. Default false preserves historical behavior; recommended for test and CI environments. (`lib/parse/stack.rb`)
41
274
  - **CHANGED**: In compatibility mode (`Parse.strict_pointer_shapes` false), the SDK now emits a one-shot warning via `Parse.logger` for each `[table, field]` pair where an impossible pointer shape is detected. Keyed cache prevents log spam on repeated calls.
@@ -44,13 +277,13 @@ Asset.where(...).distinct(:city, order: :asc)
44
277
  ```ruby
45
278
  # 4.4.3 — pointer constraints inside a boolean combinator now rewrite correctly
46
279
  { "$or" => [
47
- { "team" => { "$in" => [Parse::Pointer.new("Team", "t1"), "t2"] } },
48
- { "team" => Parse::Pointer.new("Team", "t3") },
280
+ { "workspace" => { "$in" => [Parse::Pointer.new("Workspace", "t1"), "t2"] } },
281
+ { "workspace" => Parse::Pointer.new("Workspace", "t3") },
49
282
  ] }
50
283
  # ships to MongoDB as:
51
284
  { "$or" => [
52
- { "_p_team" => { "$in" => ["Team$t1", "Team$t2"] } },
53
- { "_p_team" => "Team$t3" },
285
+ { "_p_workspace" => { "$in" => ["Workspace$t1", "Workspace$t2"] } },
286
+ { "_p_workspace" => "Workspace$t3" },
54
287
  ] }
55
288
  ```
56
289
 
@@ -70,7 +303,7 @@ Asset.where(...).distinct(:city, order: :asc)
70
303
 
71
304
  ```ruby
72
305
  # 4.4.3 — group → filter → sort → limit now works against an allowlisted class
73
- Parse::Agent::Tools.enforce_pipeline_access_policy!("Capture", [
306
+ Parse::Agent::Tools.enforce_pipeline_access_policy!("Post", [
74
307
  { "$group" => { "_id" => "$author", "count" => { "$sum" => 1 } } },
75
308
  { "$match" => { "count" => { "$gte" => 5 } } },
76
309
  { "$sort" => { "count" => -1 } },
@@ -91,7 +324,7 @@ Parse::Agent::Tools.enforce_pipeline_access_policy!("Capture", [
91
324
 
92
325
  - **FIXED**: Output-alias keys on `$project`, `$addFields`, `$set`, and `$group` stages now pass through the direct-MongoDB translator verbatim. Previously the pipeline translator rewrote `$group` accumulator keys inconsistently with its downstream expression walker (the `$group` LHS was preserved while `$project` references to it were camelCased), and `$project` / `$addFields` aliases whose names happened to coincide with a declared pointer property were silently rewritten to the `_p_<name>` storage column. The user-visible failure mode was a pipeline that wrote `$group { contributor_set: { $addToSet: "$_p_user" } }` followed by `$project { count: { $size: "$contributor_set" } }` shipping to MongoDB with the `$group` accumulator preserved and the `$project` reference camelCased — `$size` then operated on a missing field and MongoDB raised `$size must be an array, but was of type: missing`. After 4.4.2, both sides survive verbatim. Result rows are keyed by the literal spelling the caller wrote into the pipeline, so `row["contributor_set"]` and `row["contributing_user_count"]` work without read-side translation. (`lib/parse/query.rb`)
93
326
  - **CHANGED**: `convert_field_for_direct_mongodb` (the expression-value rewriter that turns `$author` into `$_p_author` and `$createdAt` into `$_created_at`) is now schema-aware. A `$field` reference whose name is neither a declared Parse property on the class backing the query nor one of the universal built-ins (`objectId` / `createdAt` / `updatedAt`) passes through verbatim — pipeline-local aliases introduced by an upstream stage are recognized as such and survive the rewrite. References that DO correspond to a known schema entry are still translated through the same `format_field` + pointer-storage / built-in rules as before; storage-column references and Parse-property field translations are unchanged. (`lib/parse/query.rb`)
94
- - **NEW**: `Parse::Query#field_is_known_to_schema?(field)` — schema-membership predicate used by the expression-value rewriter. Fails open: if the Parse class can't be resolved (Ruby model not declared in this process), returns false and unknown names pass through, matching the pre-4.4.2 behavior in that path. (`lib/parse/query.rb`)
327
+ - **NEW**: `Parse::Query#field_is_known_to_schema?(field)` — schema-subscription predicate used by the expression-value rewriter. Fails open: if the Parse class can't be resolved (Ruby model not declared in this process), returns false and unknown names pass through, matching the pre-4.4.2 behavior in that path. (`lib/parse/query.rb`)
95
328
 
96
329
  ```ruby
97
330
  # 4.4.2 — output aliases survive, internal references match the alias,
@@ -113,8 +346,8 @@ Documented limitation: an alias whose name shadows a declared Parse property (e.
113
346
 
114
347
  ```ruby
115
348
  # 4.4.2 — restored: cache: TTL works inside query_attrs again
116
- org_config = OrganizationReportConfig.first_or_create!(
117
- organization: org, report_type: type, cache: 30.seconds,
349
+ tenant_config = TenantReportConfig.first_or_create!(
350
+ tenant: tenant, report_type: type, cache: 30.seconds,
118
351
  )
119
352
  ```
120
353
 
@@ -126,7 +359,7 @@ org_config = OrganizationReportConfig.first_or_create!(
126
359
 
127
360
  #### Filter-lock support for `Parse::Operation` keys in `synchronize: true`
128
361
 
129
- - **CHANGED**: `Parse::CreateLock` canonicalization now accepts `Parse::Operation` keys (e.g. `:project.exists => false`, `:email.gt => "x"`) in `query_attrs`. Previously these raised `Parse::CreateLockInvalidKey` at the boundary, which forced callers using operator predicates to disambiguate rows (`Role.first_or_create!({ team:, :project.exists => false, access_level: }, attrs, synchronize: true)`) to either drop `synchronize:` or restructure their constraints. The canonicalizer now encodes operation keys as `"<operand>\u0000op_<operator>"`, so two concurrent callers passing identical filter shapes hash to the same lock key. The lock keys the filter, not just an equality tuple; equivalence-class reasoning belongs to the MongoDB unique index. (`lib/parse/model/core/create_lock.rb`)
362
+ - **CHANGED**: `Parse::CreateLock` canonicalization now accepts `Parse::Operation` keys (e.g. `:project.exists => false`, `:email.gt => "x"`) in `query_attrs`. Previously these raised `Parse::CreateLockInvalidKey` at the boundary, which forced callers using operator predicates to disambiguate rows (`Role.first_or_create!({ workspace:, :project.exists => false, access_level: }, attrs, synchronize: true)`) to either drop `synchronize:` or restructure their constraints. The canonicalizer now encodes operation keys as `"<operand>\u0000op_<operator>"`, so two concurrent callers passing identical filter shapes hash to the same lock key. The lock keys the filter, not just an equality tuple; equivalence-class reasoning belongs to the MongoDB unique index. (`lib/parse/model/core/create_lock.rb`)
130
363
  - **FIXED**: Plain string keys containing embedded null bytes (`\u0000`) are now rejected at the boundary. Without this, a forged key like `"project\u0000op_exists"` would canonicalize to the same byte sequence as `:project.exists`, causing distinct queries to share a lock. Defense-in-depth alongside the existing dotted-key rejection.
131
364
  - **FIXED**: Duplicate `Parse::Operation` instances with the same operand+operator in one `query_attrs` Hash (e.g. `{:age.gt => 10, :age.gt => 20}`) now raise `Parse::CreateLockInvalidKey` instead of non-deterministically collapsing via Hash iteration order. `Parse::Operation` has no `eql?`/`hash` override, so distinct Ruby objects coexist as separate Hash entries; the canonicalizer detects the collision before JSON encoding.
132
365
  - **IMPROVED**: Duplicate-key error message now includes the Parse class name for faster debugging.
@@ -134,8 +367,8 @@ org_config = OrganizationReportConfig.first_or_create!(
134
367
  ```ruby
135
368
  # Now works — both callers serialize on the same lock
136
369
  Role.first_or_create!(
137
- { team: self, :project.exists => false, access_level: "read" },
138
- { name: "Team Reader" },
370
+ { workspace: self, :project.exists => false, access_level: "read" },
371
+ { name: "Workspace Reader" },
139
372
  synchronize: true,
140
373
  )
141
374
  ```
@@ -222,7 +455,7 @@ Role.first_or_create!(
222
455
 
223
456
  #### Mongo-Direct Role Graph Expansion
224
457
 
225
- - **NEW**: `Parse::Role.all_for_user` and `Parse::Role#all_users` now resolve role membership and the inheritance subtree via a single mongo-direct `$graphLookup` aggregation when `Parse::MongoDB.available?` and the SDK client has a master key configured. The forward direction (user → effective role names) walks UPWARD through `_Join:roles:_Role` from the user's direct memberships in `_Join:users:_Role`; the reverse direction (role → all effective members) walks DOWNWARD through `_Join:roles:_Role` and joins to `_Join:users:_Role`, filtering tombstoned `_User` rows server-side so soft-delete semantics match the Parse-Server-backed path. Replaces the previous N+1 BFS through Parse Server (one query per frontier role per level) with one round-trip; the win is concentrated on the ACL-scope construction in `lib/parse/query.rb` that runs on every mongo-direct query that auto-routes through ACL filtering. (`lib/parse/mongodb.rb`, `lib/parse/model/classes/role.rb`)
458
+ - **NEW**: `Parse::Role.all_for_user` and `Parse::Role#all_users` now resolve role subscription and the inheritance subtree via a single mongo-direct `$graphLookup` aggregation when `Parse::MongoDB.available?` and the SDK client has a master key configured. The forward direction (user → effective role names) walks UPWARD through `_Join:roles:_Role` from the user's direct subscriptions in `_Join:users:_Role`; the reverse direction (role → all effective members) walks DOWNWARD through `_Join:roles:_Role` and joins to `_Join:users:_Role`, filtering tombstoned `_User` rows server-side so soft-delete semantics match the Parse-Server-backed path. Replaces the previous N+1 BFS through Parse Server (one query per frontier role per level) with one round-trip; the win is concentrated on the ACL-scope construction in `lib/parse/query.rb` that runs on every mongo-direct query that auto-routes through ACL filtering. (`lib/parse/mongodb.rb`, `lib/parse/model/classes/role.rb`)
226
459
 
227
460
  ```ruby
228
461
  # Same call signature; mongo-direct fast path picked automatically.
@@ -246,7 +479,7 @@ Role.first_or_create!(
246
479
  ```
247
480
 
248
481
  - **NEW**: `Parse::AtlasSearch::Session` module resolves session tokens to user identities and cached role sets. Two cache layers — `session_token → user_id` (default TTL 3600s) and `user_id → role_names` (default TTL 120s) — amortize lookup cost across multiple tool calls in one turn. Configurable via `Parse::AtlasSearch.session_cache_ttl`, `Parse::AtlasSearch.role_cache_ttl`, and pluggable cache implementations via `Parse::AtlasSearch.session_cache=` / `role_cache=`. Apps with sub-TTL revocation requirements should call `Parse::AtlasSearch::Session.invalidate(token)` from their logout path. (`lib/parse/atlas_search/session.rb`)
249
- - **NEW**: `Parse::Role.all_for_user(user)` class method returns a `Set` of role names whose `role:NAME` permissions a user inherits, following Parse Server's role-inheritance direction: when role X holds role Y in its `roles` relation, users of Y inherit X's permissions. The traversal starts at the user's direct memberships and walks upward through every role whose `roles` relation contains a visited role, cycle-safe via a visited-id set and depth-capped via `max_depth:` (default 10). This is the correct primitive for building `_rperm` predicates — the prior helper that walked `role.all_child_roles` traversed the opposite direction. (`lib/parse/model/classes/role.rb`)
482
+ - **NEW**: `Parse::Role.all_for_user(user)` class method returns a `Set` of role names whose `role:NAME` permissions a user inherits, following Parse Server's role-inheritance direction: when role X holds role Y in its `roles` relation, users of Y inherit X's permissions. The traversal starts at the user's direct subscriptions and walks upward through every role whose `roles` relation contains a visited role, cycle-safe via a visited-id set and depth-capped via `max_depth:` (default 10). This is the correct primitive for building `_rperm` predicates — the prior helper that walked `role.all_child_roles` traversed the opposite direction. (`lib/parse/model/classes/role.rb`)
250
483
  - **NEW**: `Parse::User#acl_roles` thin wrapper around `Parse::Role.all_for_user(self)`. (`lib/parse/model/classes/user.rb`)
251
484
  - **NEW**: `Parse::Role#all_parent_role_names` instance method returns the role itself plus every transitive parent. Used by the `:ACL.readable_by => some_role` constraint to compose the correct permission set for queries scoped to a role. (`lib/parse/model/classes/role.rb`)
252
485
  - **NEW**: `Parse::ACL.read_predicate(permissions)` and `Parse::ACL.write_predicate(permissions)` class methods emit the canonical MongoDB `$or` subexpression that matches documents readable / writable by a permission set, including the `$exists: false` branch for public documents (Parse Server treats a missing `_rperm` / `_wperm` as public). Shared between the ACL query constraints and the Atlas Search ACL injection so the predicate shape is defined in one place. (`lib/parse/model/acl.rb`)
@@ -432,7 +665,7 @@ Mongo-direct queries (`Parse::MongoDB.aggregate`, `.geo_near`, `Parse::Query#res
432
665
  - **NEW**: Four auth kwargs accepted on every mongo-direct entry point — `Parse::MongoDB.aggregate`, `Parse::MongoDB.geo_near`, `Parse::Query#results_direct`, `Parse::Query#count_direct`:
433
666
  - `session_token:` — Parse session token. The SDK resolves it to the requesting user, expands the role inheritance chain via `Parse::Role.all_for_user`, builds the `_rperm` allow-set, and runs the three-layer ACL simulation. Identical resolution path Atlas Search uses, so the two stay in lock-step.
434
667
  - `master: true` — explicitly bypass all SDK-side enforcement. Required acknowledgment for analytics jobs, admin tooling, or other callers that legitimately need cross-user reach.
435
- - `acl_user:` — pre-resolved `Parse::User` / `Parse::Pointer` (no `/users/me` round-trip). The SDK still expands the user's full role membership via `Parse::Role.all_for_user(user, max_depth: 10)` — including transitively-inherited parent roles — so the resulting allow-set contains every `role:<name>` the user would carry under a session-tokened request. Used by `Parse::Query#scope_to_user` so the existing user-scoped path uses the same simulation pipeline.
668
+ - `acl_user:` — pre-resolved `Parse::User` / `Parse::Pointer` (no `/users/me` round-trip). The SDK still expands the user's full role subscription via `Parse::Role.all_for_user(user, max_depth: 10)` — including transitively-inherited parent roles — so the resulting allow-set contains every `role:<name>` the user would carry under a session-tokened request. Used by `Parse::Query#scope_to_user` so the existing user-scoped path uses the same simulation pipeline.
436
669
  - `acl_role:` — role-only scope (no user_id). Used by the new `Parse::Query#scope_to_role`. See below.
437
670
 
438
671
  Mutually exclusive; the SDK raises `ArgumentError` if more than one is supplied. When none is supplied AND `Parse::ACLScope.require_session_token = true`, the SDK raises `Parse::ACLScope::ACLRequired` instead of falling through to public-only mode.
@@ -650,7 +883,7 @@ Mongo-direct queries (`Parse::MongoDB.aggregate`, `.geo_near`, `Parse::Query#res
650
883
  ```
651
884
 
652
885
  - **NEW**: `bidirectional: true` registers TWO separate declarations under one DSL call — `{owningId: 1}` for the forward lookup ("what's related to this owner", the dominant pattern for most relations) and `{relatedId: 1}` for the reverse lookup ("which owners contain this related object"). The two declarations are independent in the migrator's plan output — drift on either direction is detected separately, and a manual drop of one doesn't affect the other. (`lib/parse/model/core/indexing.rb`)
653
- - **NEW**: `unique:` is explicitly rejected on `mongo_relation_index` — a single-direction unique index on a `has_many :through: :relation` field would say each owner can hold at most one related, contradicting `has_many` semantics. For no-duplicate-pair membership, declare a compound unique index directly via `Parse::MongoDB.create_index` on the join collection. (`lib/parse/model/core/indexing.rb`)
886
+ - **NEW**: `unique:` is explicitly rejected on `mongo_relation_index` — a single-direction unique index on a `has_many :through: :relation` field would say each owner can hold at most one related, contradicting `has_many` semantics. For no-duplicate-pair subscription, declare a compound unique index directly via `Parse::MongoDB.create_index` on the join collection. (`lib/parse/model/core/indexing.rb`)
654
887
  - **CHANGED**: `Parse::MongoDB.assert_collection_allowed!` regex extended to accept `_Join:<field>:<ParentClass>` shape (with optional underscore on the parent class for relations on Parse-internal classes like `_Role.users`). The Parse-internal denylist still applies to top-level class names regardless. (`lib/parse/mongodb.rb`)
655
888
  - **CHANGED**: `Parse::Schema::IndexMigrator` refactored to multi-collection. `plan` returns `Hash{collection_name => plan_hash}` instead of a single plan hash — one entry per unique target collection across the declaration list (parent's `parse_class` plus any `_Join:*` collections from `mongo_relation_index`). `apply!` returns a similarly-keyed result Hash. The per-collection logic is exposed as `plan_for(collection)` / `apply_for!(collection, drop: ...)` for callers that want one target. (`lib/parse/schema/index_migrator.rb`)
656
889
  - **CHANGED**: `Model.describe(:indexes, network: true)` output adds a `:relations` sub-key — a Hash keyed by `_Join:*` collection name carrying the same `declared / drift / parse_managed / capacity` structure the parent collection reports. Pretty-print extended to render relation sections under a `relation_indexes:` header. (`lib/parse/model/core/describe.rb`)
@@ -742,7 +975,7 @@ Mongo-direct queries (`Parse::MongoDB.aggregate`, `.geo_near`, `Parse::Query#res
742
975
 
743
976
  - **CHANGED**: `Parse::ACLScope.resolve_for_user` refuses pointers whose className is anything other than `_User` or its legacy `User` alias. The same check is mirrored at `Parse::Agent#initialize` on the `acl_user:` kwarg for fail-fast UX. Previously, any duck-typed object with a non-empty `#id` was accepted, and the foreign-class objectId landed in the resolved `permission_strings` — Parse objectIds are 10-char alphanumerics with no class-segregation, so a caller deriving `acl_user:` from a generic pointer field (`Order#owner_id`, an audit-log row reference, an event payload) opened a cross-class id-collision impersonation vector. Raises `ArgumentError` at the boundary. (`lib/parse/acl_scope.rb`, `lib/parse/agent.rb`)
744
977
 
745
- - **CHANGED**: `Parse::Agent` sub-agent widen-check now emits cardinality-only `ArgumentError` messages and routes the full permission-string diff through a new `ActiveSupport::Notifications` audit channel `parse.agent.subagent_widen_refused`. Previously both widen-refused branches interpolated child and parent `permission_strings` arrays verbatim into the exception message via `.inspect` — user objectIds and `role:<name>` strings landed in any exception sink (Bugsnag/Sentry/stdout). Audit-channel consumers retain full visibility without forcing exception sinks to capture PII. (`lib/parse/agent.rb`)
978
+ - **CHANGED**: `Parse::Agent` sub-agent widen-check now emits cardinality-only `ArgumentError` messages and routes the full permission-string diff through a new `ActiveSupport::Notifications` audit channel `parse.agent.subagent_widen_refused`. Previously both widen-refused branches interpolated child and parent `permission_strings` arrays verbatim into the exception message via `.inspect` — user objectIds and `role:<name>` strings landed in any exception sink (Bugsnag/Sentry/stdout). Audit-channel consumers retain full visibility without forcing exception sinks to post PII. (`lib/parse/agent.rb`)
746
979
 
747
980
  - **IMPROVED**: `Parse::AtlasSearch.search`, `.autocomplete`, and `.faceted_search` now accept a `read_preference:` kwarg and forward it to the underlying MongoDB collection via `.with(read: { mode: ... })`. `Parse::Query#atlas_search`, `#atlas_autocomplete`, and `#atlas_facets` thread the query's `@read_preference` into the options hash before delegating, with explicit-caller-override semantics. Completes the mongo-direct read-preference threading that the earlier `Query#results_direct` / `#count_direct` / `#distinct_direct` work didn't reach. (`lib/parse/atlas_search.rb`, `lib/parse/query.rb`)
748
981
 
@@ -789,7 +1022,7 @@ Mongo-direct queries (`Parse::MongoDB.aggregate`, `.geo_near`, `Parse::Query#res
789
1022
  - **NEW**: `Parse::Product` and `Parse::Session` are now marked `agent_hidden` by default. `_Product` is a vestigial Parse iOS in-app-purchase feature that almost no modern application uses, so exposing it on the agent surface just adds noise to schema listings and tool-selection prompts. `_Session` holds active session tokens; surfacing it to LLM-driven tooling under the master-key default risks leaking credentials and lets a confused agent enumerate active sessions. The marking happens in `lib/parse/agent.rb` after `Parse::Agent::MetadataDSL` is mixed into `Parse::Object`, so applications that subclass or reopen either class inherit the hidden status unless they explicitly re-enable visibility. (`lib/parse/agent.rb`, `lib/parse/model/classes/product.rb`)
790
1023
  - **NEW**: `agent_hidden(except: :master_key)` opt on the existing DSL. Marks a class hidden from session-bound agents (user-facing MCP, per-user tooling) while permitting master-key agents (internal admin / dev MCP / customer-support bots) to address it. This is the "internal admin tooling can see it, end-user-facing agents never can" tier — intended for collections like `_Session` where a debugging tool may legitimately need read access but no per-user agent ever should. The field-level `INTERNAL_FIELDS_DENYLIST` floor still strips credential columns regardless. `agent_hidden` with no opts remains unconditionally hidden (master-key included). Re-declaring with a different `except:` scope updates the registry (last-write-wins), so an application can relax the default `_Session` strict-hidden state with `Parse::Session.agent_hidden(except: :master_key)` without first unhiding. (`lib/parse/agent/metadata_dsl.rb`, `lib/parse/agent/metadata_registry.rb`, `lib/parse/agent/tools.rb`)
791
1024
  - **NEW**: `Parse::Agent::Tools.assert_class_accessible!(class_name, agent: nil)` now consults the agent's auth context to honor `agent_hidden(except: :master_key)`. A nil agent falls back to strict-hidden behavior (used at sites where no agent is in scope, e.g. registry introspection); the thirteen tool-dispatch callsites in `lib/parse/agent/tools.rb` (`query_class`, `count_objects`, `get_object`, `get_objects`, `get_sample_objects`, `aggregate`, `group_by`, `group_by_date`, `distinct`, `get_schema`, `export_data`, `call_method`, `explain_query`) now propagate `agent: agent` so the except-scope applies wherever the top-level dispatch gate fires. Nested defense-in-depth checks (include-resolution at `walk_pointer_path!`, `$lookup` from-target rewrite, pointer-expansion at `expand_pointer_pairs`) remain strict-hidden by design — those paths handle data the agent didn't explicitly request, and the relaxed scope deliberately does not apply there. (`lib/parse/agent/tools.rb`)
792
- - **NEW**: `Parse::Agent::MetadataRegistry.register_hidden_class(klass, except: nil)` accepts an `except:` keyword that records the per-class exception scope alongside the membership entry. `hidden_exception_for(class_name)` exposes the scope back to the dispatch gate. The mutex is shared with `@hidden_classes` so a re-declaration that swaps the except scope is atomic w.r.t. concurrent reads. (`lib/parse/agent/metadata_registry.rb`)
1025
+ - **NEW**: `Parse::Agent::MetadataRegistry.register_hidden_class(klass, except: nil)` accepts an `except:` keyword that records the per-class exception scope alongside the subscription entry. `hidden_exception_for(class_name)` exposes the scope back to the dispatch gate. The mutex is shared with `@hidden_classes` so a re-declaration that swaps the except scope is atomic w.r.t. concurrent reads. (`lib/parse/agent/metadata_registry.rb`)
793
1026
  - **NEW**: `agent_unhidden` class-method DSL on `Parse::Object` (added by `Parse::Agent::MetadataDSL`). Reverses a prior `agent_hidden` declaration by clearing the per-class hidden flag and removing the class from `Parse::Agent::MetadataRegistry`'s hidden set so every agent tool surface (`query_class`, `aggregate`, `get_schema`, `RelationGraph`, etc.) treats the class as visible again. The intended use is opt-in restoration of a class that parse-stack hides by default — e.g. an application that genuinely uses `_Product` can call `Parse::Product.agent_unhidden` once at boot to restore the previous behavior. Treated as a privileged operator action: a real state flip emits a `[Parse::Agent:SECURITY]` audit banner identifying the unhidden class and reminding the operator that master-key agents bypass per-row ACL/CLP enforcement (`agent_fields` / `agent_canonical_filter` / `tenant_id` are the only remaining boundary, plus the still-active `INTERNAL_FIELDS_DENYLIST` floor). The banner is silenceable via the same `Parse::Agent.suppress_master_key_warning = true` flag that silences the master-key construction banner. Returns `true` only when a previous hidden state was actually cleared, `false` for a no-op call on a never-hidden class (Hash#delete? semantics); no banner emits on a no-op so the warning isn't trained-away by repetition. (`lib/parse/agent/metadata_dsl.rb`, `lib/parse/agent/metadata_registry.rb`)
794
1027
  - **NEW**: `Parse::Agent::MetadataRegistry.unregister_hidden_class(klass)` removes a class from the hidden registry. Backs the `agent_unhidden` DSL but also callable directly when a deployment needs to drive the registry from outside class definitions. The change is what actually makes the class addressable from the tool surface again — the per-class `@agent_hidden` ivar by itself is not consulted by the tool dispatch. (`lib/parse/agent/metadata_registry.rb`)
795
1028
  - **FIXED**: Credential-column floor — `sessionToken` and `session_token` (no leading underscore; the columns the `_Session` class itself exposes) are now in `Parse::PipelineSecurity::INTERNAL_FIELDS_DENYLIST`. Previously only the `_User`-side internal columns (`_session_token`, `_sessionToken`) were listed, so a deliberate `agent_unhidden` on `_Session` plus a master-key `query_class("_Session")` returned rows with raw bearer tokens in every entry — full account takeover by impersonation. The denylist now covers both the system internal columns AND the wire-format Session-class properties. (`lib/parse/pipeline_security.rb`)
@@ -891,9 +1124,9 @@ Mongo-direct queries (`Parse::MongoDB.aggregate`, `.geo_near`, `Parse::Query#res
891
1124
 
892
1125
  - **FIXED**: `Parse::Agent::MetadataRegistry.field_allowlist` and `enriched_schema` previously compared snake_case `agent_fields` declarations (`:device_type`, `:app_name`) case-sensitively against Parse Server's lowerCamelCase wire-format column names (`"deviceType"`, `"appName"`). The mismatch silently stripped legitimate fields from `get_schema`, prevented server-side `keys:` projection from narrowing the response in `query_class` / `get_object` / `get_objects` / `get_sample_objects` / `export_data`, and caused `enforce_pipeline_access_policy!` to refuse legitimate aggregation pipelines that referenced the camelCase wire names. Every agent-visible model with multi-word snake_case `agent_fields` symbols was affected — the reproducer was `Parse::Installation` declaring `agent_fields :device_type, :app_name, :app_identifier, :app_version, :app_build_number` and observing that none of those columns survived in the schema the LLM received. The fix translates each allowlist entry through the class's `field_map` (Ruby symbol -> wire symbol, the same mapping the `property` DSL maintains) so that `property :device_type, :string` resolves correctly to `"deviceType"`, and explicit `property field:` aliases (`property :external_id, :string, field: :ExternalReferenceCode`) take priority over the columnize fallback so the custom wire name is preserved verbatim. `enriched_schema` now delegates to `field_allowlist` instead of duplicating the inline (broken) comparison, ensuring schema enrichment, `keys:` projection, and pipeline policy enforcement all share a single source of truth. (`lib/parse/agent/metadata_registry.rb`)
893
1126
  - **NEW**: Defense-in-depth — `Parse::Agent::MetadataRegistry.field_allowlist` now drops any allowlist entry that resolves to a `Parse::PipelineSecurity::INTERNAL_FIELDS_DENYLIST` wire name (`_hashed_password`, `_password_history`, `_session_token`, `_email_verify_token`, `_perishable_token`, `_failed_login_count`, `_account_lockout_expires_at`, `_rperm`, `_wperm`, `_tombstone`). A developer who accidentally maps a property to a Parse Server internal column (`property :pw, field: :_hashed_password`) and then lists it in `agent_fields` cannot leak that column through schema enrichment, projection, or pipeline references. The columnize path for snake_case entries already stripped the leading underscore safely; the explicit denylist closes the wire-name verbatim path. (`lib/parse/agent/metadata_registry.rb`)
894
- - **NEW**: `agent_join_fields` DSL — declares the narrower projection used when this class shows up as an included pointer on another class's read tool (`query_class` / `get_object` / `get_objects` / `export_data` + `include:`). The direct-query `agent_fields` allowlist is typically the full "what the agent may see" set; the join-projection list is the narrower "what's interesting when I'm a foreign key" set. Example: `_User` may surface 18 fields on a direct query, but when joined onto a `Membership` row the agent usually needs only `firstName`, `lastName`, `email`, `internalTag` — not the `teams[]` pointer array or the `iconImage` presigned URL. The subset invariant is enforced at class load time: every entry in `agent_join_fields` MUST also appear in `agent_fields` when both are declared, raising `ArgumentError` on violation. The direct-query allowlist is the upper bound; the join list can only tighten it, never widen it. Declaring `agent_join_fields` without `agent_fields` is allowed and means "no direct-query allowlist, but on a join project to these only." (`lib/parse/agent/metadata_dsl.rb`)
895
- - **NEW**: Keys-on-include auto-projection for `query_class`, `get_object`, `get_objects`, and `export_data`. When the caller passes `keys: ["user", ...] + include: ["user"]`, the SDK now rewrites `keys` to dotted-path projections against the joined class (`user.firstName, user.email, ...`) so Parse Server returns only the narrow set of subfields the agent actually needs instead of materializing the entire included row. The reported reproducer was `query_class(class_name: "Membership", keys: ["user", "title", "active", "createdAt"], include: ["user"])` against a 6-row Membership query — the included `_User` records carried full S3 presigned image URLs (~600 chars each), 17-entry `teams[]` pointer arrays, and 13 other fields per row, dominating the response payload while the agent only ever consumed `firstName`/`lastName`/`email`/`lastActiveAt`/`internalTag`. Resolution order on auto-projection: (1) joined class's `agent_join_fields`, (2) `agent_fields - agent_large_fields`, (3) when only `agent_large_fields` is declared, the joined class's known properties minus the large set ("strip mode"), (4) no annotations on the joined class — leave it fully materialized as before. The expansion fires only when the caller passes both `keys:` and `include:` and names the bare pointer in both; suppressed when the caller passes any `<pointer>.*` dotted path themselves ("I named exactly what I want") or when `keys:` is absent. Only one-hop (`include: ["user"]`) is auto-projected; multi-hop (`include: ["user.team"]`) leaves the deeper hop untouched so the rewrite stays bounded. (`lib/parse/agent/tools.rb`, `lib/parse/agent/metadata_registry.rb`, `lib/parse/agent/result_formatter.rb`)
896
- - **NEW**: `truncated_include_fields` response envelope key — populated on `query_class`, `get_object`, and `get_objects` responses whenever keys-on-include auto-projection narrowed any joined record. The value is a map of pointer field name to the list of wire-format field names that were actively dropped (e.g. `{ "user" => ["iconImage", "sourceImage", "teams"] }`), so the LLM can see what didn't come back and re-ask via explicit dotted paths (`keys: ["user.iconImage"]`) if it actually needs the dropped fields. Suppressed when no projection fired — keeps the envelope minimal for the common case. (`lib/parse/agent/result_formatter.rb`)
1127
+ - **NEW**: `agent_join_fields` DSL — declares the narrower projection used when this class shows up as an included pointer on another class's read tool (`query_class` / `get_object` / `get_objects` / `export_data` + `include:`). The direct-query `agent_fields` allowlist is typically the full "what the agent may see" set; the join-projection list is the narrower "what's interesting when I'm a foreign key" set. Example: `_User` may surface 18 fields on a direct query, but when joined onto a `Subscription` row the agent usually needs only `firstName`, `lastName`, `email`, `category` — not the `workspaces[]` pointer array or the `iconImage` presigned URL. The subset invariant is enforced at class load time: every entry in `agent_join_fields` MUST also appear in `agent_fields` when both are declared, raising `ArgumentError` on violation. The direct-query allowlist is the upper bound; the join list can only tighten it, never widen it. Declaring `agent_join_fields` without `agent_fields` is allowed and means "no direct-query allowlist, but on a join project to these only." (`lib/parse/agent/metadata_dsl.rb`)
1128
+ - **NEW**: Keys-on-include auto-projection for `query_class`, `get_object`, `get_objects`, and `export_data`. When the caller passes `keys: ["user", ...] + include: ["user"]`, the SDK now rewrites `keys` to dotted-path projections against the joined class (`user.firstName, user.email, ...`) so Parse Server returns only the narrow set of subfields the agent actually needs instead of materializing the entire included row. The reported reproducer was `query_class(class_name: "Subscription", keys: ["user", "title", "active", "createdAt"], include: ["user"])` against a 6-row Subscription query — the included `_User` records carried full S3 presigned image URLs (~600 chars each), 17-entry `workspaces[]` pointer arrays, and 13 other fields per row, dominating the response payload while the agent only ever consumed `firstName`/`lastName`/`email`/`lastActiveAt`/`category`. Resolution order on auto-projection: (1) joined class's `agent_join_fields`, (2) `agent_fields - agent_large_fields`, (3) when only `agent_large_fields` is declared, the joined class's known properties minus the large set ("strip mode"), (4) no annotations on the joined class — leave it fully materialized as before. The expansion fires only when the caller passes both `keys:` and `include:` and names the bare pointer in both; suppressed when the caller passes any `<pointer>.*` dotted path themselves ("I named exactly what I want") or when `keys:` is absent. Only one-hop (`include: ["user"]`) is auto-projected; multi-hop (`include: ["user.workspace"]`) leaves the deeper hop untouched so the rewrite stays bounded. (`lib/parse/agent/tools.rb`, `lib/parse/agent/metadata_registry.rb`, `lib/parse/agent/result_formatter.rb`)
1129
+ - **NEW**: `truncated_include_fields` response envelope key — populated on `query_class`, `get_object`, and `get_objects` responses whenever keys-on-include auto-projection narrowed any joined record. The value is a map of pointer field name to the list of wire-format field names that were actively dropped (e.g. `{ "user" => ["iconImage", "sourceImage", "workspaces"] }`), so the LLM can see what didn't come back and re-ask via explicit dotted paths (`keys: ["user.iconImage"]`) if it actually needs the dropped fields. Suppressed when no projection fired — keeps the envelope minimal for the common case. (`lib/parse/agent/result_formatter.rb`)
897
1130
  - **NEW**: `Parse::Agent::MetadataRegistry.join_projection_fields(class_name)` returns the wire-format projection set that drives keys-on-include auto-projection for a given joined class, plus the list of fields it actively drops and the resolution source (`:join_fields` / `:allowlist_minus_large` / `:field_map_minus_large`). Returns nil when the class has no annotations to project against. (`lib/parse/agent/metadata_registry.rb`)
898
1131
  - **NEW**: `Parse::Agent::Tools.apply_include_projection(class_name, keys, include)` is the shared helper used by every read tool that honors `include:` to rewrite `keys` for auto-projection and report per-pointer truncation metadata back to the response envelope. (`lib/parse/agent/tools.rb`)
899
1132
 
@@ -926,11 +1159,11 @@ Mongo-direct queries (`Parse::MongoDB.aggregate`, `.geo_near`, `Parse::Query#res
926
1159
  - **IMPROVED**: `group_by`, `group_by_date`, and `distinct` now push the result cap and sort into the wire-side MongoDB pipeline (`$sort` + `$limit` at `cap + 1` so server-side truncation is detectable on receipt). Previously the pipeline emitted only `$match` / `$unwind` / `$group`, returning every group over the wire before Ruby truncated to `limit:`. On high-cardinality fields this meant transferring tens of thousands of groups before discarding all but the configured cap. The wire-side limit also makes top-K queries (e.g. `sort: "value_desc", limit: 10`) execute as proper database-side top-K aggregations rather than Ruby-side post-sorts on an over-fetched result. (`lib/parse/agent/tools.rb`)
927
1160
  - **NEW**: `Parse::Agent::Tools::GROUP_DEFAULT_LIMIT`, `GROUP_MAX_LIMIT`, `DISTINCT_DEFAULT_LIMIT`, `DISTINCT_MAX_LIMIT`, `GROUP_OPERATIONS`, and `GROUP_DATE_INTERVALS` public constants document the result-set caps and supported operation / interval enums used by the three new tools. (`lib/parse/agent/tools.rb`)
928
1161
  - **FIXED**: `group_by`, `group_by_date`, and `distinct` now resolve snake_case field names to their Parse wire names via the class `field_map` before emitting the `_p_<wire>` storage column or bare wire reference. Previously a caller passing `field: "author_id"` against a class declaring `belongs_to :author_id` produced `"$_p_author_id"` in the pipeline — the real Mongo column is `_p_authorId`, so the aggregation returned nothing or null-bucketed silently. The same gap affected `value_field:` on `group_by` (e.g. `value_field: "play_count"` against a `:play_count -> :playCount` mapping produced `"$play_count"` and a null sum) and the date field on `group_by_date` (e.g. `field: "released_at"` produced `{"$year" => "$released_at"}` and a single null bucket). The fix mirrors the resolution pattern already used by `field_allowlist` and `enrich_fields`: translate the input through `klass.field_map` once and use the resolved wire name for both the storage-form `_p_*` column path and the bare reference fallthrough. (`lib/parse/agent/tools.rb`)
929
- - **FIXED**: `group_by_date` now rejects pointer, array, and relation fields with a `Parse::Agent::ValidationError` instead of silently null-bucketing. Passing a pointer field like `field: "author"` previously generated `{"$year" => "$author"}` in the pipeline — MongoDB evaluated that as null for every document, producing one null-bucket carrying the total row count and no useful date distribution. The new type-check resolves the class via `MetadataRegistry`, inspects `klass.fields[field_sym]` for `:pointer` / `:array` and `klass.relations` for relation membership, and raises with a message naming the offending field type. Scalar date fields (`:date`, `:timestamp`) are unaffected. (`lib/parse/agent/tools.rb`)
1162
+ - **FIXED**: `group_by_date` now rejects pointer, array, and relation fields with a `Parse::Agent::ValidationError` instead of silently null-bucketing. Passing a pointer field like `field: "author"` previously generated `{"$year" => "$author"}` in the pipeline — MongoDB evaluated that as null for every document, producing one null-bucket carrying the total row count and no useful date distribution. The new type-check resolves the class via `MetadataRegistry`, inspects `klass.fields[field_sym]` for `:pointer` / `:array` and `klass.relations` for relation subscription, and raises with a message naming the offending field type. Scalar date fields (`:date`, `:timestamp`) are unaffected. (`lib/parse/agent/tools.rb`)
930
1163
 
931
1164
  #### Agent Tools: Canonical Filter
932
1165
 
933
- - **NEW**: `agent_canonical_filter` DSL declares a per-class "valid state" Mongo `$match` predicate that every read tool applies BY DEFAULT to each call: `query_class`, `count_objects`, `aggregate`, `group_by`, `group_by_date`, `distinct`, `explain_query`, `get_sample_objects`, and both export modes (`export_via_query`, `export_via_aggregate`). Closes the silently-suspect-counts gap where an LLM dropping to raw aggregate or sampling over a soft-deleted class would include rows that `query_class` excludes via its model-scoped filter. The filter composes with caller-supplied `where:` via `$and` (so caller constraints add to it rather than replace it) and is prepended as a `$match` stage on aggregate pipelines after any tenant-scope match. ID-based reads (`get_object`, `get_objects`) intentionally do NOT apply the canonical filter — the caller named a specific objectId and is asking for that row regardless of "valid state" semantics. Declare with `agent_canonical_filter "isRemoved" => { "$ne" => true }, "onTimeline" => true` on the model class. (`lib/parse/agent/metadata_dsl.rb`, `lib/parse/agent/tools.rb`)
1166
+ - **NEW**: `agent_canonical_filter` DSL declares a per-class "valid state" Mongo `$match` predicate that every read tool applies BY DEFAULT to each call: `query_class`, `count_objects`, `aggregate`, `group_by`, `group_by_date`, `distinct`, `explain_query`, `get_sample_objects`, and both export modes (`export_via_query`, `export_via_aggregate`). Closes the silently-suspect-counts gap where an LLM dropping to raw aggregate or sampling over a soft-deleted class would include rows that `query_class` excludes via its model-scoped filter. The filter composes with caller-supplied `where:` via `$and` (so caller constraints add to it rather than replace it) and is prepended as a `$match` stage on aggregate pipelines after any tenant-scope match. ID-based reads (`get_object`, `get_objects`) intentionally do NOT apply the canonical filter — the caller named a specific objectId and is asking for that row regardless of "valid state" semantics. Declare with `agent_canonical_filter "archived" => { "$ne" => true }, "published" => true` on the model class. (`lib/parse/agent/metadata_dsl.rb`, `lib/parse/agent/tools.rb`)
934
1167
  - **NEW**: `apply_canonical_filter:` keyword argument on `query_class`, `count_objects`, and `aggregate` (default `true`). Pass `apply_canonical_filter: false` to opt a single call out of the canonical predicate — e.g., to count soft-deleted rows alongside live ones. The opt-out is per-call; the class-level default is "applied." The opt-out keyword is intentionally NOT exposed on `group_by` / `group_by_date` / `distinct` / `explain_query` / `get_sample_objects` / export tools: those surfaces are derived views where the canonical predicate must hold for the answer to be consistent with `query_class`, and a per-call escape hatch is reserved for the count/list/aggregate triad where consumer pagination already assumes a stable predicate. (`lib/parse/agent/tools.rb`)
935
1168
  - **NEW**: `get_schema` now surfaces the declared canonical filter as a `canonical_filter:` key in the response so callers that opt out can reproduce the predicate manually in their `where:`. (`lib/parse/agent/metadata_registry.rb`, `lib/parse/agent/result_formatter.rb`)
936
1169
  - **NEW**: `Parse::Agent::MetadataRegistry.canonical_filter(class_name)` returns the registered filter (or nil) for use by application code and tests. (`lib/parse/agent/metadata_registry.rb`)
@@ -956,7 +1189,7 @@ Mongo-direct queries (`Parse::MongoDB.aggregate`, `.geo_near`, `Parse::Query#res
956
1189
 
957
1190
  #### Agent Schema Documentation
958
1191
 
959
- - **NEW**: `_enum:` option on `property` documents the per-value semantics of an enum-shaped string column for an LLM. Accepts a Hash mapping each allowed value (Symbol or String) to a description, e.g. `property :grant, :string, _enum: { team: "Member of a team within the org", project: "Member of a project under a team", organization: "Member of the org as a whole" }`. Value keys are normalized to strings to match the wire-format shape an LLM will see in query constraints. Orthogonal to the existing `enum:` validation option — `enum:` constrains the value set, `_enum:` documents each one. Surfaced in `get_schema` field entries as `allowed_values: [{value, description}, ...]`. Intended for string-typed columns only: value keys are stringified unconditionally, so declaring `_enum:` on an integer/boolean column will surface string-shaped values that won't match the column in a `where:` filter. (`lib/parse/model/core/properties.rb`, `lib/parse/agent/metadata_dsl.rb`, `lib/parse/agent/metadata_registry.rb`, `lib/parse/agent/result_formatter.rb`)
1192
+ - **NEW**: `_enum:` option on `property` documents the per-value semantics of an enum-shaped string column for an LLM. Accepts a Hash mapping each allowed value (Symbol or String) to a description, e.g. `property :grant, :string, _enum: { workspace: "Member of a workspace within the tenant", project: "Member of a project under a workspace", tenant: "Member of the tenant as a whole" }`. Value keys are normalized to strings to match the wire-format shape an LLM will see in query constraints. Orthogonal to the existing `enum:` validation option — `enum:` constrains the value set, `_enum:` documents each one. Surfaced in `get_schema` field entries as `allowed_values: [{value, description}, ...]`. Intended for string-typed columns only: value keys are stringified unconditionally, so declaring `_enum:` on an integer/boolean column will surface string-shaped values that won't match the column in a `where:` filter. (`lib/parse/model/core/properties.rb`, `lib/parse/agent/metadata_dsl.rb`, `lib/parse/agent/metadata_registry.rb`, `lib/parse/agent/result_formatter.rb`)
960
1193
  - **NEW**: `get_schema` echoes the wire-format `agent_fields` allowlist as a top-level `agent_fields:` key on the response. The registry already enforced the allowlist by stripping non-allowed fields from the schema, but enforcement-by-omission left consumers guessing what they could write in `keys:` — repeated refusals on storage-form column names (`_p_*` pointer columns, other Parse-internal underscored fields) were the visible symptom. Listing the allowed wire names alongside the trimmed fields hash closes that gap. `ALWAYS_KEEP_FIELDS` (objectId / createdAt / updatedAt) are excluded from the echo to avoid noise. (`lib/parse/agent/metadata_registry.rb`, `lib/parse/agent/result_formatter.rb`)
961
1194
  - **NEW**: `get_schema` echoes the narrower `agent_join_fields` projection as a top-level `agent_join_fields:` key when declared on the class. Tells consumers "when this class is included on another class's query, these are the fields you'll see" so they can plan the include path without a follow-up `get_schema` call. (`lib/parse/agent/metadata_registry.rb`, `lib/parse/agent/result_formatter.rb`)
962
1195
  - **IMPROVED**: `get_schema` tool description now documents the wire-format vs storage-form distinction explicitly. When the response contains a top-level `agent_fields:` list, those are the only wire-format names accepted by query/aggregate tools; storage-form columns (e.g. `_p_*` pointer columns) and other Parse-internal underscored fields are never addressable. Includes a one-line note about the `allowed_values:` per-value enum documentation surface. (`lib/parse/agent/tools.rb`)
@@ -1112,8 +1345,8 @@ Mongo-direct queries (`Parse::MongoDB.aggregate`, `.geo_near`, `Parse::Query#res
1112
1345
 
1113
1346
  #### MFA Master-Key Disable Authorization Gate
1114
1347
 
1115
- - **NEW**: `Parse::User#disable_mfa_master_key!(authorized_by:, admin_role: nil)` replaces the previous `disable_mfa_admin!` method. The old name had no authorization gate — it unconditionally used the master key, so any code path that could call `current_user.disable_mfa_admin!` on an attacker-controlled `Parse::User` instance was a one-call IDOR primitive against any account in the system. The new method requires an `authorized_by:` keyword argument naming the operator performing the override (a persisted `Parse::User` or `Parse::Pointer` to a User); a non-User value, a missing argument, or an unsaved User raises `ArgumentError` before any request is issued. Optional `admin_role:` (a `Parse::Role` instance or role name) enforces a role-hierarchy membership check on the operator via `Parse::Role#all_users`, raising `Parse::MFA::ForbiddenError` when the operator is not a member. (`lib/parse/two_factor_auth/user_extension.rb`, `lib/parse/two_factor_auth.rb`)
1116
- - **NEW**: `Parse::MFA::ForbiddenError` (`< Parse::Error`) is raised when an operator fails the `admin_role:` membership check on `disable_mfa_master_key!`. (`lib/parse/two_factor_auth.rb`)
1348
+ - **NEW**: `Parse::User#disable_mfa_master_key!(authorized_by:, admin_role: nil)` replaces the previous `disable_mfa_admin!` method. The old name had no authorization gate — it unconditionally used the master key, so any code path that could call `current_user.disable_mfa_admin!` on an attacker-controlled `Parse::User` instance was a one-call IDOR primitive against any account in the system. The new method requires an `authorized_by:` keyword argument naming the operator performing the override (a persisted `Parse::User` or `Parse::Pointer` to a User); a non-User value, a missing argument, or an unsaved User raises `ArgumentError` before any request is issued. Optional `admin_role:` (a `Parse::Role` instance or role name) enforces a role-hierarchy subscription check on the operator via `Parse::Role#all_users`, raising `Parse::MFA::ForbiddenError` when the operator is not a member. (`lib/parse/two_factor_auth/user_extension.rb`, `lib/parse/two_factor_auth.rb`)
1349
+ - **NEW**: `Parse::MFA::ForbiddenError` (`< Parse::Error`) is raised when an operator fails the `admin_role:` subscription check on `disable_mfa_master_key!`. (`lib/parse/two_factor_auth.rb`)
1117
1350
  - **DEPRECATED**: `Parse::User#disable_mfa_admin!` is retained as a thin alias that emits a `Kernel#warn` deprecation notice and delegates to `disable_mfa_master_key!`. The alias forwards `authorized_by:` and `admin_role:` arguments through unchanged, so a caller migrating from the old name simply adds the required kwarg. Callers that relied on the no-argument form (`user.disable_mfa_admin!`) will see `ArgumentError` from the delegate — by design.
1118
1351
 
1119
1352
  #### MCP Path Routing and Pre-Auth DoS
@@ -1226,11 +1459,11 @@ This release introduces a declarative class-level ACL policy that resolves the d
1226
1459
  - **NEW**: `acl_policy :owner_else_private, owner: :self` (and the `:owner_else_public` variant) on `Parse::User` and its subclasses. The save-time resolver pre-generates a Parse-compatible `objectId` via `Parse::Core::ParseReference.generate_object_id` when `@id` is blank, then sets the ACL to `{ <self.id>: R/W }`. Combined with a narrow signup-body whitelist (see below) this enables single-roundtrip user creation with self-only ACL — the new user can edit their own profile but is invisible to all other clients. Declaring `owner: :self` on any non-User class raises `ArgumentError`. Orthogonal to `parse_reference precompute: true`: both can be declared together (they reuse the same id-generation helper), neither installs the other's side effects. (`lib/parse/model/object.rb`)
1227
1460
  - **CHANGED**: `Parse::User#signup_create` and `#signup!` now allow a client-supplied `objectId` and `ACL` through the signup request body only when the pair matches the narrow self-only ownership pattern that `acl_policy ..., owner: :self` produces: `objectId` is a 10-char Parse-format string and `ACL` is exactly `{ <objectId>: { "read": true, "write": true } }`. Any other combination — multiple ACL keys, public/role grants, half-permissions, mismatched id — still triggers the full strip (preserves the previous defense against client-planted permissive ACLs and colliding ids). `createdAt`/`updatedAt` remain stripped unconditionally. The matcher `Parse::User.signup_body_self_only_acl_safe?(body)` is exposed for callers that need to gate behavior on the same predicate. (`lib/parse/model/classes/user.rb`)
1228
1461
  - **NEW**: `Parse::Object.builtin_parse_class?` and `Parse::Object.builtin_acl_default_active?` class methods. The first returns `true` for the SDK's built-in Parse classes (`Parse::User`, `Parse::Installation`, `Parse::Session`, `Parse::Role`, `Parse::Product`, `Parse::PushStatus`, `Parse::Audience`); the second returns `true` when the class is a built-in AND the application has not customized its ACL via `acl_policy` or `set_default_acl`. Under those conditions the SDK leaves `obj.acl` nil so the save body omits the `ACL` field and Parse Server applies its own per-class defaults (most importantly, `_User` → self R/W + public read). Calling `acl_policy` or `set_default_acl` on a built-in re-enables the SDK's stamp/resolver, letting applications opt into custom ACL semantics for users, installations, etc. (`lib/parse/model/object.rb`, `lib/parse/model/classes/user.rb`)
1229
- - **NEW**: `Parse::Role` now declares `acl_policy :private`, so every new role is saved with a master-only ACL (`{}`) unless the caller passes an explicit ACL. Parse Server hard-codes `_Role` as requiring an `ACL` column (`SchemaController.requiredColumns`); the SDK previously left the field nil for built-in classes, causing save attempts to fail with "ACL is required." Master-only is the safe-by-construction default: anonymous clients cannot enumerate role names, walk membership joins, or reconstruct the authorization graph. Parse Server's internal role-membership expansion (`Auth#getRolesForUser`) uses master context, so ACL evaluation continues to work without a public-read grant. To opt into broader access, pass `acl:` to `Parse::Role.find_or_create` or assign `role.acl = ...` before save — the existing caller-wins precedence in the policy resolver leaves caller-supplied ACLs untouched. (`lib/parse/model/classes/role.rb`)
1462
+ - **NEW**: `Parse::Role` now declares `acl_policy :private`, so every new role is saved with a master-only ACL (`{}`) unless the caller passes an explicit ACL. Parse Server hard-codes `_Role` as requiring an `ACL` column (`SchemaController.requiredColumns`); the SDK previously left the field nil for built-in classes, causing save attempts to fail with "ACL is required." Master-only is the safe-by-construction default: anonymous clients cannot enumerate role names, walk subscription joins, or reconstruct the authorization graph. Parse Server's internal role-subscription expansion (`Auth#getRolesForUser`) uses master context, so ACL evaluation continues to work without a public-read grant. To opt into broader access, pass `acl:` to `Parse::Role.find_or_create` or assign `role.acl = ...` before save — the existing caller-wins precedence in the policy resolver leaves caller-supplied ACLs untouched. (`lib/parse/model/classes/role.rb`)
1230
1463
 
1231
1464
  #### Bug Fixes
1232
1465
 
1233
- - **FIXED**: `Parse::Query::Aggregation#results` on the `mongo_direct` path no longer decodes `$group` rows as fake `Parse::Object` instances. Previously, `convert_documents_to_parse` renamed the row's `_id` field to `objectId`, and the heuristic that distinguishes Parse documents from aggregation rows only checked for a non-nil `objectId`. When the `$group` key was a non-nil value (e.g., a pointer string like `"Team$abc123"`), the row was decoded as a `Parse::Object` with a fake `objectId` and every accumulator field that did not match a declared property was silently dropped — counts vanished, sums returned zero, debugging required reading the conversion source. `results` now branches per-row on the raw MongoDB document: rows with `_created_at` or `_updated_at` (Parse Server's row-level invariants) are decoded as Parse objects; rows without them are wrapped as `Parse::AggregationResult` with the original `_id` preserved. (`lib/parse/query.rb`, `lib/parse/mongodb.rb`)
1466
+ - **FIXED**: `Parse::Query::Aggregation#results` on the `mongo_direct` path no longer decodes `$group` rows as fake `Parse::Object` instances. Previously, `convert_documents_to_parse` renamed the row's `_id` field to `objectId`, and the heuristic that distinguishes Parse documents from aggregation rows only checked for a non-nil `objectId`. When the `$group` key was a non-nil value (e.g., a pointer string like `"Workspace$abc123"`), the row was decoded as a `Parse::Object` with a fake `objectId` and every accumulator field that did not match a declared property was silently dropped — counts vanished, sums returned zero, debugging required reading the conversion source. `results` now branches per-row on the raw MongoDB document: rows with `_created_at` or `_updated_at` (Parse Server's row-level invariants) are decoded as Parse objects; rows without them are wrapped as `Parse::AggregationResult` with the original `_id` preserved. (`lib/parse/query.rb`, `lib/parse/mongodb.rb`)
1234
1467
  - **NEW**: `Parse::MongoDB.convert_aggregation_document(doc)` helper that coerces MongoDB document values (BSON ObjectIds, dates, nested documents) without renaming `_id` to `objectId` or injecting `className`. Used internally by the `Aggregation#results` per-row branch; available for callers that want aggregation-shaped conversion. (`lib/parse/mongodb.rb`)
1235
1468
  - **FIXED**: `Parse::Agent::MCPDispatcher#handle_resources_list` now returns a populated resource catalog. The previous draft read `result[:data][:classes]` from the `get_all_schemas` agent response — a key that does not exist in the envelope `Parse::Agent::ResultFormatter#format_schemas` actually returns (`{total:, note:, built_in:, custom:}`). The bug caused every external MCP client (Claude Desktop, Cursor, Continue.dev, MCP Inspector) calling `resources/list` to receive an empty array, hiding the three resource URIs per Parse class (`parse://<Class>/schema`, `/count`, `/samples`) that the handler is meant to expose. The handler now concatenates `:custom` and `:built_in`, with a fallback to the legacy `:classes` key for callers that have overridden `get_all_schemas` to return the older shape. (`lib/parse/agent/mcp_dispatcher.rb`)
1236
1469
 
@@ -1240,7 +1473,7 @@ This release introduces a declarative class-level ACL policy that resolves the d
1240
1473
  - **FIXED**: When `Parse::Agent#execute`'s rate-limiter fallback fires (an injected limiter raises a non-`RateLimitExceeded` exception, e.g., a Redis connection failure), `retry_after` is now randomized between 1 and 5 seconds and the `limit`/`window` fields borrow the injected limiter's configured values when available. Previously the fallback emitted the literal `retry_after: 5, limit: 0, window: 0`, which let an attacker distinguish "real rate limit" from "your Redis backend is down" by observation, providing reconnaissance for backend outage probing. (`lib/parse/agent.rb`)
1241
1474
  - **FIXED**: `Parse::Agent::Prompts` now `require_relative "errors"` at the top of the file so a downstream caller that loads only `parse/agent/prompts` (e.g. for in-process prompt rendering without the MCP transport) can reach `Parse::Agent::ValidationError` without a `NameError`. The module documented standalone loadability but its renderers and validators referenced error constants that lived in a sibling file. (`lib/parse/agent/prompts.rb`)
1242
1475
  - **FIXED**: `Parse::Agent.new(rate_limiter: obj)` validates that `obj.respond_to?(:check!)` at construction time and raises `ArgumentError` otherwise. Previously a mistyped limiter raised `NoMethodError` on the first rate-limited request, which surfaced to the LLM client as a generic `-32603` internal error rather than a clear "your limiter integration is broken" boot-time failure. (`lib/parse/agent.rb`)
1243
- - **FIXED**: `Parse::Agent::Tools` now validates the `include:` parameter of `get_objects`, `query_class`, and `get_object` against a per-entry pattern (`\A[A-Za-z][A-Za-z0-9_.]{0,127}\z/`) and a max-field cap (`MAX_INCLUDE_FIELDS = 20`). Previously the values were joined verbatim and forwarded to Parse Server, letting an LLM caller submit `include: ["_session_token"]` or `include: ["a" * 4096, ...]` and have the strings flow into the query without validation. The validator raises `Parse::Agent::ValidationError` on malformed input. Legitimate dotted pointer paths (`author.team`) remain accepted. (`lib/parse/agent/tools.rb`)
1476
+ - **FIXED**: `Parse::Agent::Tools` now validates the `include:` parameter of `get_objects`, `query_class`, and `get_object` against a per-entry pattern (`\A[A-Za-z][A-Za-z0-9_.]{0,127}\z/`) and a max-field cap (`MAX_INCLUDE_FIELDS = 20`). Previously the values were joined verbatim and forwarded to Parse Server, letting an LLM caller submit `include: ["_session_token"]` or `include: ["a" * 4096, ...]` and have the strings flow into the query without validation. The validator raises `Parse::Agent::ValidationError` on malformed input. Legitimate dotted pointer paths (`author.workspace`) remain accepted. (`lib/parse/agent/tools.rb`)
1244
1477
  - **FIXED**: `Parse::Agent::MCPDispatcher#handle_prompts_get` enforces the same `MAX_TOOL_RESPONSE_BYTES = 4_194_304` cap on rendered prompt text that `handle_tools_call` enforces on tool results. A custom prompt renderer that returns a 5 MiB string now produces a `-32602` JSON-RPC error rather than buffering the oversized payload to the wire. (`lib/parse/agent/mcp_dispatcher.rb`)
1245
1478
 
1246
1479
  #### Export & Context Safety
@@ -1473,7 +1706,7 @@ Four pre-pentest hardening fixes covering MCP transport, tool-argument validatio
1473
1706
  - **FIXED**: `Parse::LiveQuery::Client` now verifies the TLS certificate matches the WebSocket host via `OpenSSL::SSL::SSLSocket#post_connection_check` after `connect`. Previously, the SSL context only set `verify_mode = VERIFY_PEER` and assigned `hostname` for SNI; SNI does not perform hostname verification, so any certificate signed by a CA in the default trust store for any hostname was accepted. This permitted active MITM of `wss://` LiveQuery sessions, exposing session tokens and authenticated subscription payloads. (`lib/parse/live_query/client.rb`)
1474
1707
  - **FIXED**: `Parse::LiveQuery::Client#establish_connection` now wraps socket setup in a rescue that closes both the TCP and SSL sockets on any failure during handshake (TLS connect, hostname check, or WebSocket handshake). Previously, a failed handshake leaked file descriptors on each retry; repeated `schedule_reconnect` attempts could exhaust the process fd budget. (`lib/parse/live_query/client.rb`)
1475
1708
  - **FIXED**: `SENSITIVE_FIELDS` in the log redaction filter extended to include `masterKey`, `master_key`, `apiKey`, `api_key`, `clientKey`, `client_key`, `javascriptKey`, `javascript_key`, `refreshToken`, and `refresh_token`. Webhook payloads, cloud function arguments, and server error strings containing any of these field names alongside their values are now filtered before being written to logs. The previous list covered only `password`, `token`, `sessionToken`, `session_token`, `access_token`, and `authData`. (`lib/parse/client/body_builder.rb`)
1476
- - **FIXED**: The "could not find mapping route" branch in `Parse::Webhooks#call!` no longer dumps the unredacted JSON payload to stdout. The log is now gated behind `Parse::Webhooks.logging` and the payload is routed through `Parse::Middleware::BodyBuilder.redact` before printing. Previously, a remote caller could trigger this branch by sending a malformed-but-valid payload and capture session tokens or auth data in process logs. (`lib/parse/webhooks.rb`)
1709
+ - **FIXED**: The "could not find mapping route" branch in `Parse::Webhooks#call!` no longer dumps the unredacted JSON payload to stdout. The log is now gated behind `Parse::Webhooks.logging` and the payload is routed through `Parse::Middleware::BodyBuilder.redact` before printing. Previously, a remote caller could trigger this branch by sending a malformed-but-valid payload and post session tokens or auth data in process logs. (`lib/parse/webhooks.rb`)
1477
1710
  - **FIXED**: The "no webhook key configured" warning emitted by the fail-closed path is now logged only once per process rather than per request. The previous draft logged the warning on every refused request, which an attacker could exploit to fill disk by hammering the endpoint. (`lib/parse/webhooks.rb`)
1478
1711
  - **FIXED**: `Parse::MongoDB.find` and `Parse::MongoDB.aggregate` now refuse filters and pipelines that contain `$where`, `$function`, or `$accumulator` at any nesting depth. These operators execute server-side JavaScript and bypass Parse Server ACL/CLP enforcement. A new `Parse::MongoDB::DeniedOperator` error is raised when one is detected. (`lib/parse/mongodb.rb`)
1479
1712
  - **FIXED**: `Parse::Object#attributes=` and `Parse::Object#apply_attributes!(hash, dirty_track: true)` now skip a denylist of server-managed and security-internal keys: `sessionToken`/`session_token`, `roles`, `_rperm`/`_wperm`, `_hashed_password`/`_password_history`, `authData`/`_auth_data`, `className`/`__type`, `createdAt`/`created_at`, and `updatedAt`/`updated_at`. The internal hydration path (`dirty_track: false`, used when building objects from server responses) still accepts these fields, so server-issued sessionTokens etc. flow through during decoding. The list is exposed as `Parse::Properties::PROTECTED_MASS_ASSIGNMENT_KEYS`. User-facing properties like `acl` and `objectId` are deliberately omitted — `Document.new(acl: my_acl)` is legitimate developer code. Rails applications receiving form input should use StrongParameters (`params.permit(...)`) to filter attacker-controlled keys before passing the hash to `Model.new` or `attributes=`. Previously, a Rails controller doing `MyModel.new(params)` could escalate via attacker-chosen `sessionToken`/`authData`/`_rperm`/etc. on any Parse::Object subclass. (`lib/parse/model/core/properties.rb`)
@@ -1501,7 +1734,7 @@ Four pre-pentest hardening fixes covering MCP transport, tool-argument validatio
1501
1734
  - **NEW**: `Parse::Error#initialize(code_or_message = nil, message = nil)` and `Parse::Error#code`. The base error class now accepts an optional Parse error code alongside a message. When both are passed, the formatted message is prefixed with `[code]` for log clarity, and the code is exposed via the `#code` reader. The legacy single-argument form (`raise Parse::Error, "msg"`) is preserved unchanged. Subclasses that define their own `initialize` (`CloudCodeError`, `UnfetchedFieldAccessError`, `AutofetchTriggeredError`) are unaffected. (`lib/parse/model/core/errors.rb`)
1502
1735
  - **NEW**: `guard` DSL on `Parse::Object` for declarative write protection of fields. Complements Parse Server's class-level `protectedFields` (which only hides values on read) by reverting disallowed client writes inside `before_save` webhook handling. Four modes are supported: `guard :field, :master_only` (never writable by clients; master-key requests bypass), `guard :field, :immutable` (writable on create, frozen on subsequent client updates; master bypasses), `guard :field, :always_immutable` (writable on create by anyone, then frozen for everyone including master-key updates — useful for canonical slugs, terminal state markers, or any value that must never change once set), and `guard :field, :set_once` (writable while the persisted value is blank, then locked forever — including against master-key writes — once a value is present; intended for fields populated by a derived after_create callback such as `parse_reference` where the canonical value depends on the server-assigned objectId). Both positional and keyword forms are accepted: `guard :slug, :immutable` or `guard :slug, mode: :immutable`. Reverts are a silent successful no-op from the client's perspective - the save proceeds with any unguarded changes intact - and a DEBUG-level log line is emitted for diagnosis. Handles scalar properties (including those declared with a `field:` remote-key override), properties with `default:` values (reverts fall back to the default rather than emitting a `__op: Delete`), the special `acl` field (`guard :acl, :master_only` reverts a non-master client's attempt to widen or lock the ACL while letting unguarded fields save normally), `belongs_to` pointers, and `has_many :through => :relation` fields including raw `__op: AddRelation` / `RemoveRelation` payloads. Guards inherit through subclasses; child declarations do not leak back to the parent. Guards run BEFORE the registered `before_save` handler block, so trusted server-side writes inside the block (the canonical `obj.created_by = current_user` pattern) are preserved while only client-supplied values are reverted. Declaring a guard automatically registers a `before_save` route for the class so `Parse::Webhooks.register_triggers!(endpoint)` picks it up; an explicit `webhook :before_save` block replaces the auto-registered stub. The `X-Parse-Request-Id` header is not consulted when deciding whether to apply guards, so a client-controlled `_RB_`-prefixed request id cannot bypass write protection. (`lib/parse/model/core/field_guards.rb`, `lib/parse/model/object.rb`, `lib/parse/webhooks.rb`, `lib/parse/webhooks/payload.rb`)
1503
1736
  - **NEW**: `Parse::Object.describe_access` class method. Returns a hash combining the class's CLP operations, `read_user_fields`/`write_user_fields`, and per-field read and write protection state. Each property entry surfaces its write-protection mode (`:open`, `:master_only`, `:immutable`, `:always_immutable`, or `:set_once` from the `guard` DSL) and which `protectedFields` patterns (if any) hide it on reads. Intended as a developer-ergonomics audit tool — CLP, `protect_fields`, `field_guards`, and `parse_reference` each touch a different aspect of access, and without a single inspection method you would have to read three separate parts of the class body to answer "who can write `owner`?". Inherits cleanly through subclasses. Reflects only what is declared locally in Ruby; CLP set server-side without a mirroring `set_clp` call locally will not appear. Conversely, the output is exactly what `update_clp!` would push. (`lib/parse/model/object.rb`)
1504
- - **NEW**: `parse_reference` DSL on `Parse::Object` for declarative self-referential identifier fields. When declared, a string property is added (default local name `parse_reference`, default remote column `parseReference`) and auto-populated with the canonical `"ClassName$objectId"` form via an `after_create` callback that issues a follow-up `update!` (bypassing the user save/create callback chain so an existing `after_save :send_email` on the class doesn't double-fire on every create). The value matches Parse Server's own internal pointer-column format (e.g. `_p_team = "Team$abc"`), which makes direct MongoDB lookups, `$lookup` joins, and cross-class analytics queries straightforward: a single equality match on one column instead of splitting strings or maintaining two separate fields. Costs two REST round-trips per new object (the first creates the row and returns the server-assigned objectId; the after_create writes the reference and triggers the `update!`), so it is opt-in per class — classes that don't call `parse_reference` get no field and no extra writes. Both the local property name and the remote column name are configurable: `parse_reference :ref` (custom local, remote defaults to camelCase) or `parse_reference :ref, field: "refKey"` (custom both). Auto-installs two protections at declaration time: `protect_fields("*", [field_name])` so non-master clients never see the column on reads, and `guard field_name, :set_once` so once the after_create populates the field, no further write (client or master) can change it. The protect_fields call merges with any existing `"*"` protected list rather than overwriting it. Works on `Parse::Object` subclasses generally; `Parse::User#signup!` goes through a distinct REST endpoint that bypasses the `:create` callback chain, so a User subclass declaring `parse_reference` must populate the field manually after signup (`user._assign_parse_reference!`). Subclass redeclaration of `parse_reference` is detected by inspecting the existing `_create_callbacks` chain; the after_create method is only registered once per class to avoid stacking duplicate writes on subclass instances. For objects created via `Parse::Object.transaction` or `Parse::Object.save_all` (both of which bypass the `:create` callback chain by setting `@id` directly), a batch helper `Klass.populate_parse_references!(objects)` is exposed to populate the reference for an array of already-saved objects with one `update!` per object. Companion helpers `Parse::Core::ParseReference.format(class_name, id)` and `.parse(string)` are exposed for building and splitting reference strings outside the property context. (`lib/parse/model/core/parse_reference.rb`, `lib/parse/model/object.rb`)
1737
+ - **NEW**: `parse_reference` DSL on `Parse::Object` for declarative self-referential identifier fields. When declared, a string property is added (default local name `parse_reference`, default remote column `parseReference`) and auto-populated with the canonical `"ClassName$objectId"` form via an `after_create` callback that issues a follow-up `update!` (bypassing the user save/create callback chain so an existing `after_save :send_email` on the class doesn't double-fire on every create). The value matches Parse Server's own internal pointer-column format (e.g. `_p_workspace = "Workspace$abc"`), which makes direct MongoDB lookups, `$lookup` joins, and cross-class analytics queries straightforward: a single equality match on one column instead of splitting strings or maintaining two separate fields. Costs two REST round-trips per new object (the first creates the row and returns the server-assigned objectId; the after_create writes the reference and triggers the `update!`), so it is opt-in per class — classes that don't call `parse_reference` get no field and no extra writes. Both the local property name and the remote column name are configurable: `parse_reference :ref` (custom local, remote defaults to camelCase) or `parse_reference :ref, field: "refKey"` (custom both). Auto-installs two protections at declaration time: `protect_fields("*", [field_name])` so non-master clients never see the column on reads, and `guard field_name, :set_once` so once the after_create populates the field, no further write (client or master) can change it. The protect_fields call merges with any existing `"*"` protected list rather than overwriting it. Works on `Parse::Object` subclasses generally; `Parse::User#signup!` goes through a distinct REST endpoint that bypasses the `:create` callback chain, so a User subclass declaring `parse_reference` must populate the field manually after signup (`user._assign_parse_reference!`). Subclass redeclaration of `parse_reference` is detected by inspecting the existing `_create_callbacks` chain; the after_create method is only registered once per class to avoid stacking duplicate writes on subclass instances. For objects created via `Parse::Object.transaction` or `Parse::Object.save_all` (both of which bypass the `:create` callback chain by setting `@id` directly), a batch helper `Klass.populate_parse_references!(objects)` is exposed to populate the reference for an array of already-saved objects with one `update!` per object. Companion helpers `Parse::Core::ParseReference.format(class_name, id)` and `.parse(string)` are exposed for building and splitting reference strings outside the property context. (`lib/parse/model/core/parse_reference.rb`, `lib/parse/model/object.rb`)
1505
1738
  - **NEW**: Class-level access DSL shortcuts on `Parse::Object` that compose around the existing `set_clp` primitive: `master_only_class!` (locks every CLP operation to master-key only -- the entire class is hidden from clients), `unlistable_class!` (locks `find` and `count` to master-key only while leaving other ops alone -- the `_Installation`-style pattern where clients can interact with individual records but cannot enumerate them), and `set_class_access(op: mode, ...)` for compact configuration of multiple operations at once. The `mode` argument accepts `:master`, `:public`, `:authenticated`, a single role name (String or Symbol, auto-prefixed with `role:`), or an Array of role names. Operations not listed are left at their current setting. Use these as starting points and then call `set_clp` directly for finer control (mixed roles, users, pointer-fields, requires_authentication). (`lib/parse/model/object.rb`)
1506
1739
  - **NEW**: `Parse::Webhooks.allow_unauthenticated` accessor. Set to `true` (or set the `PARSE_WEBHOOK_ALLOW_UNAUTHENTICATED=true` environment variable) to opt into the pre-4.0 permissive behavior of accepting webhook requests without a configured key. Intended for local development against a Parse Server without a `webhookKey` set; production deployments should configure a key. Setting `allow_unauthenticated = false` explicitly disables the env-var fallback. The `Parse::Webhooks.key=` writer also resets the one-shot "no webhook key configured" warning flag so deployments that configure the key after startup get a clean state. (`lib/parse/webhooks.rb`)
1507
1740
  - **NEW**: `Parse::AtlasSearch::IndexManager` cache now expires entries after 300 seconds (configurable via `Parse::AtlasSearch::IndexManager.cache_ttl = N`, or 0 to disable caching) and protects access with a `Mutex`. Previously the cache populated once at first lookup and never refreshed, so long-running workers could not see indexes built/dropped/renamed in the Atlas UI without a process restart, and concurrent first-time access could race on `@index_cache ||= {}`. (`lib/parse/atlas_search/index_manager.rb`)
@@ -1510,7 +1743,7 @@ Four pre-pentest hardening fixes covering MCP transport, tool-argument validatio
1510
1743
  - **NEW**: Agent-facing field allowlist and analytics usage hints on `Parse::Object`. Two new `Parse::Agent::MetadataDSL` class methods, `agent_fields :field1, :field2, ...` and `agent_usage "..."`, let a model declare which columns are analytics-relevant and provide LLM-specific guidance (enum values, denormalization caveats, recommended aggregations) distinct from the human-readable `agent_description`. When `agent_fields` is declared, `Parse::Agent::MetadataRegistry.enriched_schema` filters the schema's `fields` hash to the allowlist plus `objectId`/`createdAt`/`updatedAt`, strips noisy per-field metadata (`indexed`, empty `defaultValue`), and the agent's `query_class`, `get_object`, and `get_sample_objects` tools push the allowlist into the server-side `keys` projection — so the LLM never sees, and Parse Server never returns, fields the model owner considers noise. Caller-supplied `keys:` overrides the allowlist verbatim. Declaration is opt-in; classes without `agent_fields` retain previous behavior. Typical token reduction is 60-80% on `get_schema` and proportional savings on query result rows. (`lib/parse/agent/metadata_dsl.rb`, `lib/parse/agent/metadata_registry.rb`, `lib/parse/agent/tools.rb`, `lib/parse/agent/result_formatter.rb`)
1511
1744
  - **NEW**: Generic Parse-platform conventions baseline appended to the agent's default system prompt and exposed as a new `parse_conventions` MCP prompt. A single `Parse::Agent::PARSE_CONVENTIONS` constant teaches the LLM the shape of `objectId`/`createdAt`/`updatedAt`, the pointer JSON literal `{"__type":"Pointer","className":"X","objectId":"Y"}` and date literal `{"__type":"Date","iso":"..."}`, the role of `_User`/`_Role`, that `ACL` is a permission hash rather than user content, and that other `_`-prefixed classes are Parse internals to skip unless asked. The default system prompt grew from ~50 to ~167 tokens; MCP clients can fetch the same blurb on demand via `prompts/get parse_conventions`. (`lib/parse/agent.rb`, `lib/parse/agent/mcp_server.rb`)
1512
1745
  - **NEW**: `Parse::Agent::RelationGraph` derives a class-relationship graph from existing `belongs_to` and `has_many :through => :relation` declarations with zero additional DSL burden. Each edge is a hash `{from:, to:, via:, cardinality:, kind:}`; pointer edges are emitted from the target ("the one") to the owner ("the many") so the diagram reads naturally as `Company ─1:N→ User (User.company)`; relation columns are emitted as `N:M`. The `via` field always uses the on-the-wire camelCase column name (resolved through `field_map` for relations that declare a `field:` override), so the LLM can copy it directly into a Parse `where:` or `include:` clause. Surfaced two ways: (1) each enriched schema response now carries a `relations: {outgoing: [...], incoming: [...]}` block so `get_schema User` returns pointer context alongside fields, and (2) a new `parse_relations` MCP prompt renders a compact ASCII diagram of the whole graph or any explicit subset (`classes: "_User,Post,Company"`). System `_`-prefixed classes other than `_User`/`_Role` are filtered out by default to match the existing `explore_database` skip guidance, unless the model has explicitly opted in via `agent_visible`. The graph is built once per `get_all_schemas` call and threaded through per-class enrichment, so listing N schemas is O(N) rather than O(N^2). `has_many :through => :query` and `has_one` produce no schema column and are intentionally not emitted — they're already reflected by the inverse `belongs_to` edge on the other class. (`lib/parse/agent/relation_graph.rb`, `lib/parse/agent/metadata_registry.rb`, `lib/parse/agent/result_formatter.rb`, `lib/parse/agent/mcp_server.rb`)
1513
- - **NEW**: `Parse::Agent::MCPServer` now implements real `resources/read` and `prompts/get` handlers alongside the previously stub `resources/list` and `prompts/list`. `resources/list` returns three resources per Parse class — `parse://<ClassName>/schema`, `parse://<ClassName>/count`, and `parse://<ClassName>/samples` — and `resources/read` dispatches each to the appropriate agent tool (`get_schema`, `count_objects`, `get_sample_objects` with `limit: 5`) and returns the result as MCP `contents`. `prompts/list` advertises six analytics-oriented prompt templates (`explore_database`, `class_overview`, `count_by`, `recent_activity`, `find_relationship`, `created_in_range`) aimed at common superadmin questions like "how many users per team" and "when was the last project created"; `prompts/get` validates the supplied arguments and renders each into an MCP user message that instructs the LLM which tools to call with which arguments. The `count_by` prompt includes guidance on the `"ClassName$objectId"` literal returned by `$group` over pointer fields (because Parse Server's Mongo storage adapter stores pointer columns as `_p_<field>` with `$`-delimited string values), and the `explore_database` prompt tells the LLM to skip `_`-prefixed system classes other than `_User`/`_Role` to avoid slow or erroring counts on `_PushStatus`/`_JobStatus`/`_Audience`. The previous stub `resources/list` returned only class-name URIs with no read handler, and `prompts/list` returned two hardcoded prompts with no `prompts/get` handler. (`lib/parse/agent/mcp_server.rb`)
1746
+ - **NEW**: `Parse::Agent::MCPServer` now implements real `resources/read` and `prompts/get` handlers alongside the previously stub `resources/list` and `prompts/list`. `resources/list` returns three resources per Parse class — `parse://<ClassName>/schema`, `parse://<ClassName>/count`, and `parse://<ClassName>/samples` — and `resources/read` dispatches each to the appropriate agent tool (`get_schema`, `count_objects`, `get_sample_objects` with `limit: 5`) and returns the result as MCP `contents`. `prompts/list` advertises six analytics-oriented prompt templates (`explore_database`, `class_overview`, `count_by`, `recent_activity`, `find_relationship`, `created_in_range`) aimed at common superadmin questions like "how many users per workspace" and "when was the last project created"; `prompts/get` validates the supplied arguments and renders each into an MCP user message that instructs the LLM which tools to call with which arguments. The `count_by` prompt includes guidance on the `"ClassName$objectId"` literal returned by `$group` over pointer fields (because Parse Server's Mongo storage adapter stores pointer columns as `_p_<field>` with `$`-delimited string values), and the `explore_database` prompt tells the LLM to skip `_`-prefixed system classes other than `_User`/`_Role` to avoid slow or erroring counts on `_PushStatus`/`_JobStatus`/`_Audience`. The previous stub `resources/list` returned only class-name URIs with no read handler, and `prompts/list` returned two hardcoded prompts with no `prompts/get` handler. (`lib/parse/agent/mcp_server.rb`)
1514
1747
  - **CHANGED**: `Parse::PipelineSecurity` consolidates the three pre-existing pipeline validators (`Parse::Agent::PipelineValidator`, the inline `Parse::Query#validate_pipeline!`, and `Parse::MongoDB.assert_no_denied_operators!`) into one canonical implementation. The denylist `DENIED_OPERATORS = %w[$where $function $accumulator $out $merge $collMod $createIndex $dropIndex $planCacheSetFilter $planCacheClear]` is enforced recursively at any nesting depth — including inside `$facet.*`, `$lookup.pipeline`, `$unionWith.pipeline`, and `$graphLookup`. Two entry points: `Parse::PipelineSecurity.validate_pipeline!` (strict mode — stage allowlist + size/depth caps; call this when you are building an aggregation pipeline) and `Parse::PipelineSecurity.validate_filter!` (permissive mode — denylist only at any depth; call this when you are passing user input as a `$match` or `find` filter). `Parse::Query#aggregate` uses permissive mode so user code passing uncommon-but-legitimate read stages like `$densify` or `$fill` continues to work. `Parse::Agent::PipelineValidator` (strict mode), `Parse::Query::BLOCKED_PIPELINE_STAGES`, and `Parse::MongoDB::DENIED_OPERATORS` are retained as thin compatibility wrappers around the unified implementation. `Parse::Query::BLOCKED_PIPELINE_STAGES` now aliases the unified denylist, which adds `$where` to the previous set — callers reading the constant for introspection will see the expanded operator list. (`lib/parse/pipeline_security.rb`, `lib/parse/agent/pipeline_validator.rb`, `lib/parse/query.rb`, `lib/parse/mongodb.rb`, `lib/parse/atlas_search.rb`)
1515
1748
 
1516
1749
  #### Changes
@@ -1627,17 +1860,17 @@ query.where(genre: "rock").last_updated(limit: 3)
1627
1860
 
1628
1861
  ```ruby
1629
1862
  # Default behavior - pointers for storage (backward compatible)
1630
- capture.assets.as_json
1631
- # => [{"__type"=>"Pointer", "className"=>"Asset", "objectId"=>"abc"}, ...]
1863
+ post.assets.as_json
1864
+ # => [{"__type"=>"Pointer", "className"=>"Document", "objectId"=>"abc"}, ...]
1632
1865
 
1633
1866
  # Serialize with fetched fields (no autofetch, pointers stay as pointers)
1634
- capture.assets.as_json(pointers_only: false)
1867
+ post.assets.as_json(pointers_only: false)
1635
1868
  # => [{"objectId"=>"abc", "file"=>{...}, "caption"=>"My photo", ...}, ...]
1636
1869
 
1637
1870
  # In webhooks, manually override assets serialization:
1638
- cloud_results.map do |capture|
1639
- json = capture.as_json
1640
- json['assets'] = capture.assets.as_json(pointers_only: false) if capture.assets.any?
1871
+ cloud_results.map do |post|
1872
+ json = post.as_json
1873
+ json['assets'] = post.assets.as_json(pointers_only: false) if post.assets.any?
1641
1874
  json
1642
1875
  end
1643
1876
  ```
@@ -1769,7 +2002,7 @@ clp.as_json(include_defaults: true)
1769
2002
 
1770
2003
  - **FIXED**: `as_json(include_defaults: true)` now properly includes all operations even when no explicit `set_default_clp` is called. Previously, models with only `protect_fields` (no operation permissions) would send CLPs without operation keys, causing "Permission denied" errors. Now defaults to public access for all operations when `include_defaults: true`.
1771
2004
 
1772
- - **FIXED**: Test setup for role membership now correctly uses `add_users()` method for adding users to roles (roles use Parse Relations, not Array properties).
2005
+ - **FIXED**: Test setup for role subscription now correctly uses `add_users()` method for adding users to roles (roles use Parse Relations, not Array properties).
1773
2006
 
1774
2007
  ### 3.2.0
1775
2008
 
@@ -1909,29 +2142,29 @@ Product.where(:sku.ends_with => "v1.0")
1909
2142
 
1910
2143
  ```ruby
1911
2144
  # Fetch a pointer with caching enabled
1912
- capture = capture_pointer.fetch_cache!
2145
+ post = capture_pointer.fetch_cache!
1913
2146
 
1914
2147
  # Partial fetch with caching
1915
- capture = capture_pointer.fetch_cache!(keys: [:title, :status])
2148
+ post = capture_pointer.fetch_cache!(keys: [:title, :status])
1916
2149
 
1917
2150
  # With includes
1918
- capture = capture_pointer.fetch_cache!(keys: [:title], includes: [:project])
2151
+ post = capture_pointer.fetch_cache!(keys: [:title], includes: [:project])
1919
2152
  ```
1920
2153
 
1921
2154
  - **NEW**: Added `cache:` parameter to `Parse::Pointer#fetch`. This allows controlling caching behavior when fetching pointers, consistent with `Parse::Object#fetch!`.
1922
2155
 
1923
2156
  ```ruby
1924
2157
  # Fetch with full caching (read and write)
1925
- capture = pointer.fetch(cache: true)
2158
+ post = pointer.fetch(cache: true)
1926
2159
 
1927
2160
  # Fetch bypassing cache completely
1928
- capture = pointer.fetch(cache: false)
2161
+ post = pointer.fetch(cache: false)
1929
2162
 
1930
2163
  # Fetch with write-only cache (skip read, update cache)
1931
- capture = pointer.fetch(cache: :write_only)
2164
+ post = pointer.fetch(cache: :write_only)
1932
2165
 
1933
2166
  # Fetch with specific TTL
1934
- capture = pointer.fetch(cache: 300) # Cache for 5 minutes
2167
+ post = pointer.fetch(cache: 300) # Cache for 5 minutes
1935
2168
  ```
1936
2169
 
1937
2170
  ### 3.1.8
@@ -2313,7 +2546,7 @@ Post.query.readable_by("user123", mongo_direct: false).results
2313
2546
 
2314
2547
  #### ACL Dirty Tracking Improvements
2315
2548
 
2316
- - **FIXED**: `acl_was` now correctly captures the ACL state before in-place modifications. Previously, modifying an ACL in place (via `apply`, `apply_role`, etc.) caused `acl_was` to return the same mutated object as `acl`, making them appear identical.
2549
+ - **FIXED**: `acl_was` now correctly posts the ACL state before in-place modifications. Previously, modifying an ACL in place (via `apply`, `apply_role`, etc.) caused `acl_was` to return the same mutated object as `acl`, making them appear identical.
2317
2550
 
2318
2551
  ```ruby
2319
2552
  # Before fix: acl_was showed mutated state (wrong)
@@ -2330,18 +2563,18 @@ obj.acl_was.as_json # Now: {} (original empty state)
2330
2563
 
2331
2564
  ```ruby
2332
2565
  # Fetch object with existing ACL
2333
- membership = Membership.find(id)
2334
- original_acl = membership.acl.as_json # {"*"=>{"read"=>true}, ...}
2335
- membership.clear_changes!
2566
+ subscription = Subscription.find(id)
2567
+ original_acl = subscription.acl.as_json # {"*"=>{"read"=>true}, ...}
2568
+ subscription.clear_changes!
2336
2569
 
2337
2570
  # Rebuild ACL to same values (e.g., in before_save hook)
2338
- membership.acl = Parse::ACL.new
2339
- membership.acl.apply(:public, true, false)
2571
+ subscription.acl = Parse::ACL.new
2572
+ subscription.acl.apply(:public, true, false)
2340
2573
  # ... rebuild to same permissions ...
2341
2574
 
2342
2575
  # Object is NOT dirty if ACL content is identical
2343
- membership.acl_changed? # => false (content is the same)
2344
- membership.dirty? # => false (no actual changes)
2576
+ subscription.acl_changed? # => false (content is the same)
2577
+ subscription.dirty? # => false (no actual changes)
2345
2578
  ```
2346
2579
 
2347
2580
  - **NEW**: New objects always include ACL in changes (required for first save to server), even if content matches default.
@@ -2515,7 +2748,7 @@ role.remove_user(user).save
2515
2748
  role.add_users(user1, user2, user3).save
2516
2749
  role.remove_users(user1, user2).save
2517
2750
 
2518
- # Check membership
2751
+ # Check subscription
2519
2752
  role.has_user?(user) # => true
2520
2753
  ```
2521
2754
 
@@ -3477,11 +3710,11 @@ Added a `pointers_only` option to control serialization behavior:
3477
3710
 
3478
3711
  ```ruby
3479
3712
  # Default: Full objects preserved (for API responses)
3480
- team.members.as_json
3713
+ workspace.members.as_json
3481
3714
  # => [{"objectId"=>"abc", "name"=>"Alice", "email"=>"alice@test.com", ...}, ...]
3482
3715
 
3483
3716
  # With pointers_only: Converts to pointer format (for Parse storage/webhooks)
3484
- team.members.as_json(pointers_only: true)
3717
+ workspace.members.as_json(pointers_only: true)
3485
3718
  # => [{"__type"=>"Pointer", "className"=>"Member", "objectId"=>"abc"}, ...]
3486
3719
  ```
3487
3720
 
@@ -4642,7 +4875,7 @@ Parse Stack now includes Rails-style validations with a custom uniqueness valida
4642
4875
 
4643
4876
  - **NEW**: Scoped uniqueness (unique within a subset)
4644
4877
  ```ruby
4645
- validates :employee_id, uniqueness: { scope: :organization }
4878
+ validates :employee_id, uniqueness: { scope: :tenant }
4646
4879
  ```
4647
4880
 
4648
4881
  - **NEW**: Custom error messages
@@ -4987,7 +5220,7 @@ user = User.first(id: user_id, keys: [:id, :first_name, :last_name, :email])
4987
5220
  user.to_json # Only includes id, first_name, last_name, email (plus metadata)
4988
5221
 
4989
5222
  # Useful for webhook responses returning partial data
4990
- Parse::Webhooks.route :function, :getTeamMembers do
5223
+ Parse::Webhooks.route :function, :getWorkspaceMembers do
4991
5224
  users = User.all(:id.in => user_ids, keys: [:id, :first_name, :last_name, :icon_image])
4992
5225
  users # Returns only the requested fields, no autofetch triggered
4993
5226
  end
@@ -5127,13 +5360,13 @@ post.title_changed? # => true (dirty state preserved for unfetched
5127
5360
  #### Usage Examples: Query Partial Fetch
5128
5361
  ```ruby
5129
5362
  # Partial nested object (only name field, pointer auto-resolved)
5130
- Asset.first(keys: ["project.name"])
5363
+ Document.first(keys: ["project.name"])
5131
5364
 
5132
5365
  # Full nested object (includes required)
5133
- Asset.first(keys: [:project], includes: [:project])
5366
+ Document.first(keys: [:project], includes: [:project])
5134
5367
 
5135
5368
  # Multiple nested fields
5136
- Asset.first(keys: ["project.name", "project.status", "project.owner.email"])
5369
+ Document.first(keys: ["project.name", "project.status", "project.owner.email"])
5137
5370
  ```
5138
5371
 
5139
5372
  #### Query Validation Warnings
@@ -5199,7 +5432,7 @@ Parse.warn_on_query_issues = false
5199
5432
  - **NEW**: Autofetch triggers automatically when accessing unfetched fields on partially fetched objects
5200
5433
  - **NEW**: Nested partial fetch tracking for included objects via `keys:` parameter with dot notation
5201
5434
  - **NEW**: `nested_fetched_keys` / `nested_keys_for(field)` methods for tracking nested object fields
5202
- - **NEW**: `parse_keys_to_nested_keys` helper parses keys patterns like `["team.time_zone", "team.name"]`
5435
+ - **NEW**: `parse_keys_to_nested_keys` helper parses keys patterns like `["workspace.time_zone", "workspace.name"]`
5203
5436
  - **FIXED**: Objects fetched with `keys:` parameter no longer have dirty tracking for fields with default values
5204
5437
  - **FIXED**: `clear_changes!` now called after `apply_defaults!` to prevent false dirty tracking
5205
5438
  - **IMPROVED**: Before-save hooks can now reliably access unfetched fields (triggers autofetch)
@@ -5278,7 +5511,7 @@ Parse.warn_on_query_issues = false
5278
5511
  - Enhanced change tracking may affect existing webhook implementations
5279
5512
  - Transaction support changes object persistence patterns
5280
5513
  - **Minimum Ruby version is now 3.0+** (dropped support for Ruby < 3.0)
5281
- - **`distinct` method now returns object IDs directly by default** for pointer fields instead of full pointer hash objects like `{"__type"=>"Pointer", "className"=>"Team", "objectId"=>"abc123"}`. Use `distinct(field, return_pointers: true)` to get Parse::Pointer objects.
5514
+ - **`distinct` method now returns object IDs directly by default** for pointer fields instead of full pointer hash objects like `{"__type"=>"Pointer", "className"=>"Workspace", "objectId"=>"abc123"}`. Use `distinct(field, return_pointers: true)` to get Parse::Pointer objects.
5282
5515
  - **Updated to Faraday 2.x** and removed `faraday_middleware` dependency
5283
5516
  - **Fixed typo "constaint" to "constraint"** throughout codebase (method names may have changed)
5284
5517