Skip to content

Releases: neurosynq/parse-stack-next

v5.5.3 - Dependency Updates

Choose a tag to compare

@AdrianCurtin AdrianCurtin released this 18 Jun 20:25

Dependency Updates

A maintenance release that refreshes locked dependencies to their latest compatible versions. No SDK behavior changes.

Changes

  • CHANGED: Bumped locked dependencies to their latest compatible releases — concurrent-ruby 1.3.6 → 1.3.7, faraday 2.14.2 → 2.14.3, i18n 1.14.8 → 1.15.1, json 2.19.8 → 2.19.9, and redis-client 0.29.0 → 0.30.0.

Commit: 39c4a84
Author: Adrian Curtin
Date: June 18, 2026

v5.5.2 - Large Aggregation Pipelines and with_session Scoping

Choose a tag to compare

@AdrianCurtin AdrianCurtin released this 11 Jun 19:15

Aggregation Pipeline Encoding and Parse.with_session Scoping Fixes

A focused fix release for aggregations. Large aggregation pipelines that exceeded the request URL limit were silently failing with "Invalid aggregate stage '0'", and aggregations run inside a Parse.with_session block were not being scoped to the ambient session. Both are corrected, and the aggregation scoping checks now mirror the auth precedence used everywhere else in the SDK.

Changes

Large aggregation pipelines no longer fail with "Invalid aggregate stage '0'"

  • FIXED: An aggregation whose request URL exceeds ~2KB (for example a group_by, group_by_date, distinct, or custom aggregate pipeline with a large $in / $match) is rewritten from a GET to a POST carrying _method=GET, with the query moved into the request body. The pipeline was sent in the body as a URL-encoded string, but Parse Server's aggregate endpoint only JSON-decodes query-string params, not body params — so the pipeline arrived as a raw string and was rejected with Invalid aggregate stage '0', causing the aggregation to return an empty result. The long-URL override now sends a JSON body for the aggregate endpoint so the pipeline is delivered as a real array (boolean params such as rawValues are preserved as booleans). The historical URL-encoded override is unchanged for find and other endpoints, which Parse Server already decodes correctly.

Aggregations inside Parse.with_session blocks are now scoped

  • FIXED: group_by_date, group_by, distinct, and count (aggregation branch) now detect the ambient session token set by Parse.with_session and treat the query as scoped — consistent with how Parse::Client#request already scopes REST find/get/count calls in the same block. Previously the scoping checks consulted only the query instance's own session_token= / scope_to_user / scope_to_role and ignored the fiber-local ambient session, so an aggregation inside a with_session block ran unscoped as the master key and returned all rows regardless of ACL. When scoped and mongo-direct is available the aggregation auto-promotes (ACL/CLP enforced); when scoped and mongo-direct is unavailable it fails closed with Parse::Query::MongoDirectRequired rather than silently leaking rows.
  • FIXED: group_by_date now also fails closed (Parse::Query::MongoDirectRequired) when the query is scoped but mongo-direct is unavailable — matching the existing behavior of group_by, distinct, and count. Previously group_by_date silently fell back to the REST /aggregate endpoint in that case.
  • FIXED: A regression introduced in 5.5.1 where group_by_date, group_by, and pipeline-based aggregations called inside a Parse.with_session block returned empty results {}. The ambient session token was forwarded as an HTTP session-token header (suppressing the master key), causing Parse Server's REST /aggregate endpoint — which is master-key-only — to return a 401/403. The REST aggregate call sites now force use_master_key: true so the ambient cannot suppress it, unless the caller explicitly set use_master_key: false.
  • CHANGED: An explicit use_master_key: true on a query now takes precedence over the ambient Parse.with_session token when deciding whether an aggregation is scoped, mirroring Parse::Client#request. A deliberate admin aggregation executed inside a with_session block is treated as a master-key call and is no longer forced onto mongo-direct.
  • FIXED: GroupBy#raw routes scoped queries (including those inside Parse.with_session) through the same enforcement path as the other aggregation terminals and consistently returns an array of raw rows, instead of bypassing ACL/CLP enforcement on the master-key-only REST /aggregate endpoint.

Commit: e32211a
Author: Adrian Curtin
Date: June 11, 2026

v5.5.1 - Security Hardening: ACL Scoping, Cache, and Pipeline Guards

Choose a tag to compare

@AdrianCurtin AdrianCurtin released this 10 Jun 21:55

Security Hardening Patch

A focused security hardening release. Mongo-direct queries inside Parse.with_session now respect the ambient session token instead of falling through to master-key reads, the Redis HTTP and embedding caches drop Marshal in favor of JSON serialization, credential columns are recursively stripped from joined sub-documents, and several aggregation-terminal and credential-oracle paths are tightened.

Changes

Mongo-direct reads inside Parse.with_session are now scoped, not master

  • FIXED: A query that auto-routes to the mongo-direct path because of a direct-only constraint (for example a geo $near / $geoIntersects query) now honors the ambient session token set by Parse.with_session(token). Previously the mongo-direct auth resolver consulted only the query's own session_token= / scope_to_user / scope_to_role and ignored the fiber-local ambient session, so in server mode it fell through to a master-key read with no ACL/CLP enforcement — returning rows the session was not permitted to see, even though every REST query in the same with_session block was correctly scoped. The resolver now mirrors Parse::Client#request precedence: an explicit per-query token wins, then the ambient session, then the master-key fallback; an explicit use_master_key: true is a deliberate admin call and still skips the ambient. Routing also accepts the ambient on non-master clients (Parse.client_mode or a user-scoped client), so such a query runs scoped rather than raising.

Boolean property coercion no longer treats the string "false" as true

  • FIXED: A :boolean property assigned a string now coerces via ActiveModel's boolean caster instead of raw Ruby truthiness. Previously the coercion was val ? true : false, so the strings "false", "0", and "off" — exactly what arrives on a Rails-form or query-string ingestion path — all coerced to true, silently flipping a boolean the wrong way (for example an archived flag or an application-defined access gate). String forms now map correctly ("false"/"0"/"off" to false), a blank string is treated as unset (nil), and native booleans from Parse wire JSON pass through unchanged.

Deprecation warning for setting ACL via mass-assignment

  • DEPRECATED: Setting acl/ACL through mass-assignment (Parse::Object#attributes=) now emits a one-time security warning. Mass-assigning an ACL from a caller-supplied hash — for example a controller doing record.attributes = params without StrongParameters — lets an attacker grant unintended access by sending an ACL key ({"ACL" => {"*" => {"write" => true}}}). The behavior is unchanged this release (the ACL is still applied), but the supported path is the explicit record.acl = ... setter, and a future release may block ACL mass-assignment. The constructor form Klass.new(acl: ...) is unaffected and does not warn.

Redis cache values serialized as JSON instead of Marshal

  • FIXED: Parse::Cache::Redis now serializes cached HTTP responses as JSON rather than Marshal. The Moneta-Redis store Marshals values by default, so every cache hit ran Marshal.load on the bytes returned by Redis. Against a shared, unauthenticated, or plaintext-redis:// cache, an attacker able to write the cache could plant a crafted Marshal payload that executed code on deserialization. The wrapper now disables Moneta's value serializer (value_serializer: nil) and JSON-encodes/decodes values itself; an undecodable value (including any legacy Marshal entry) is treated as a cache miss rather than deserialized. Cache keys are unchanged. No application code changes are required; existing cached entries are transparently refetched and re-stored in the new format on first access.
  • FIXED: The cache: "redis://..." shorthand on Parse::Client.new / Parse.setup now builds a Parse::Cache::Redis store instead of a bare Moneta.new(:Redis, ...), so it gets the same JSON value serialization and is not subject to the Marshal deserialization issue above.
  • CHANGED: The caching middleware stores response entries with string keys so they round-trip losslessly through the JSON serialization. Reads accept both string and legacy symbol keys.
  • FIXED: Parse::Embeddings::Cache::MonetaStore now JSON-encodes cached embedding vectors instead of relying on the Moneta store's default Marshal value serializer, closing the same Marshal.load-on-read deserialization vector for the embedding cache (whose key is derived from often-user-supplied text). It also emits a one-time warning when handed a Marshal-serializing store and recommends value_serializer: nil.
  • CHANGED: Documentation for Redis-backed caches, the embedding cache, and the synchronize-create lock store (Parse.synchronize_create_store) now builds the Redis store via Parse::Cache::Redis or value_serializer: nil so a raw Moneta.new(:Redis, ...) no longer leaves Marshal on the read path.

Internal columns stripped from joined documents on mongo-direct reads

  • FIXED: Parse::MongoDB.aggregate now recursively strips Parse-internal credential columns (_hashed_password, _session_token, _auth_data_*, _rperm/_wperm, ...) from every result row and every embedded sub-document for scoped (non-master) callers. Previously a scoped caller could embed a foreign class (e.g. _User or _Session) into an arbitrary alias via $lookup / $graphLookup / $unionWith and read back password hashes, OAuth tokens, and session tokens: the per-class protectedFields strip is keyed on the outer class, and the ACL sub-document walk only drops ACL-failing sub-documents, so neither covered the aliased foreign document. A new Parse::PipelineSecurity.redact_internal_fields_deep! runs as the final redaction step. Structural columns (_id, _p_*, _acl, timestamps) are preserved, so object and ACL reconstruction are unaffected; master-key reads are unchanged.

Hardened mongo-direct aggregation terminals

  • FIXED: Credential columns (_hashed_password, _session_token, _auth_data_*, _email_verify_token, _perishable_token, ...) used as a $match field name are now refused unconditionally on the mongo-direct path — even on a pipeline running with allow_internal_fields: true (the flag that lets SDK-emitted _rperm/_wperm references through for readable_by_role / publicly_readable). Previously the *_direct terminals (count_direct, results_direct, distinct_direct, the direct group-by helpers) passed allow_internal_fields: true unconditionally, so a query whose where referenced a credential column compiled into a $match key that bypassed the internal-field screen — a count/match oracle that could bisect a bcrypt hash or session token. The ACL columns (_rperm/_wperm/_tombstone) remain gated by allow_internal_fields, so readable_by_role still works.
  • FIXED: Parse::Query#aggregate and #aggregate_from_query now treat a scoped query (session_token / scope_to_user / scope_to_role) as authoritative over an explicit mongo_direct: false. Previously passing mongo_direct: false on a scoped aggregation skipped the fail-closed guard and routed to Parse Server's master-key-only REST /aggregate endpoint, running the aggregation unscoped (no ACL, CLP, or protectedFields). A scoped aggregation now promotes to mongo-direct, or fails closed with Parse::Query::MongoDirectRequired when direct Mongo is unavailable; unscoped callers can still opt out to REST with mongo_direct: false.

Additional hardening

  • FIXED: Request/response body logging now redacts credentials. At :debug level the logging middleware emitted login/signup request bodies (cleartext password) and auth response bodies (sessionToken, authData, MFA secrets); the body path now runs through the same BodyBuilder.redact scrubber the header path already used, before truncation.
  • FIXED: The _User REST endpoints (fetch_user / update_user / delete_user) now validate the objectId against Parse::API::PathSegment.object_id! before interpolating it into the path, matching the object endpoints. A crafted objectId can no longer traverse to a different endpoint on a subsequent request.
  • CHANGED: $sessionToken / $session_token (the camelCase forms of the session-token column) are now in DENIED_FIELD_REFS, so they cannot be laundered through a $-field reference in a pipeline.
  • IMPROVED: The internal-collection floor (_SCHEMA / _Hooks / _GlobalConfig / _Audit / ...) is now enforced unconditionally on every $lookup / $graphLookup / $unionWith join target in Parse::ACLScope, not only when lookup-rewriting runs. This closes a defense-in-depth gap where an internal class whose CLP lookup returned no policy could otherwise have been joinable on the direct path.
  • IMPROVED: When the MCP agent server is started on an unauthenticated loopback bind with no Origin/custom-header gate configured, it now defaults to a loopback-only Origin policy. A browser DNS-rebinding attack against 127.0.0.1 carries a non-loopback Origin and is refused; native clients (which send no Origin) and local browser UIs are unaffected. A one-time warning points operators at MCP_API_KEY / allowed_origins: / require_custom_header: for routable deployments.

Commit: a68b53d
Author: Adrian Curtin
Date: June 10, 2026

v5.5.0 - Image Embeddings, Migration Tooling, ACL Hardening

Choose a tag to compare

@AdrianCurtin AdrianCurtin released this 10 Jun 04:49

Feature Release

A multimodal embeddings release: an SDK-side image bytes pipeline with magic-byte MIME verification and EXIF stripping, bulk re-embedding tooling for provider/model migrations, batch orchestration and query-embed caching, and vector index drift detection. It also carries a broad ACL permission query hardening pass, hybrid search scoped-score fixes, retrieval spend-cap coverage, and opt-in Unicode regex matching.

Breaking Changes

  • BREAKING: the British-spelled :ACL.writeable_by operator now resolves to the same public-inclusive, role-expanding implementation as :ACL.writable_by. Previously the one-letter spelling difference selected a separate, strict, non-role-expanding constraint, so the two spellings silently produced different result sets. Migration: code that relied on the old strict behavior of writeable_by should pass strict: true or use the :writable_by_exact operator.

Changes

Image embeddings: SDK-side bytes pipeline

  • NEW: Parse::Embeddings::ImageFetch — the SDK-side image download layer for image embeddings. Downloads ride the existing Parse::File.safe_open_url SSRF primitive (CIDR blocks, port allowlist, DNS-rebinding re-check, size caps, timeouts), and the MIME type is determined exclusively by magic-byte sniffing (JPEG/PNG/GIF/WebP) cross-checked against the URL extension. The HTTP Content-Type header is never consulted, so a .jpg URL serving HTML is refused outright.
  • NEW: embed_image ..., source: :bytes declaration mode. The SDK downloads, verifies, and metadata-strips the image, then forwards it to the provider as a base64 data URI — no third-party URL egress, so the trust_provider_url_fetch sentinel is not required. The file's host must still be in Parse::Embeddings.allowed_image_hosts (deny-all when empty).
  • NEW: EXIF/XMP metadata stripping, default ON for the bytes path. JPEG APP1 segments, PNG eXIf chunks, and WebP EXIF/XMP RIFF chunks are removed before the bytes leave the process, so GPS coordinates and device serial numbers in user photos never reach the embedding provider. Opt out per declaration with exif_strip: false.
  • NEW: Voyage#embed_image and Cohere#embed_image accept fetched-bytes sources alongside URL Strings, and the two forms may be mixed in one batch.
  • NEW: Parse::Embeddings.allowed_image_types= — MIME allowlist for the bytes path (default JPEG/PNG/GIF/WebP; SVG deliberately excluded as script-capable active content).
  • ENHANCED: Parse::Embeddings.validate_image_url! accepts mode: :fetch for SDK-side downloads — same host allowlist, obfuscated-IP screen, and port/CIDR checks as the default :forward mode, minus the provider-egress sentinel that does not apply when no URL is forwarded.

Embedding-model migration tooling

  • NEW: Class.reembed!(field:, batch_size:, limit:, where:, only_stale:, save_opts:) — bulk re-embed for provider/model migrations. Unlike embed_pending! (which only fills null vectors), reembed! walks every row with objectId-cursor pagination and clears the digest sibling so the save-path recompute cannot skip the provider call. With only_stale: true the walk skips rows whose recorded provenance already matches the current provider, model, and dimensions, making a partially failed migration resumable.
  • NEW: embed / embed_image auto-declare an <into>_meta :object sibling property recording { provider, model, dimensions, modality, embedded_at } on every recompute. This is the provenance record reembed!(only_stale: true) reads, and it tells operational tooling which model produced any stored vector. Override the name with meta_field:.

Bulk embedding and query-embed caching

  • NEW: Parse::Embeddings::BatchEmbedder — batch-level orchestration for bulk embedding jobs: batch slicing, requests-per-minute pacing, and batch-level exponential backoff with jitter on rate-limit and transient errors. A batch that exhausts its attempts raises BatchEmbedder::BatchFailed carrying batch_index and completed_count so a resumable job knows where to pick up. Supports retry_on: overrides and an on_progress: callback.
  • NEW: Parse::Embeddings::Cache — opt-in process-local embedding cache keyed by (provider, model, dimensions, input_type, SHA-256(input)). Cache.enable!(max_entries:, ttl:) activates an LRU + TTL store; repeated identical query embeds through find_similar(text:), hybrid_search(text:), and Parse::Retrieval.retrieve then skip the provider round-trip. Cache hits emit the standard parse.embeddings.embed notification with cached: true. Input text is hashed before keying, so plaintext queries never land in a shared store.

Vector index drift detection

  • NEW: first-query verification of deployed Atlas vectorSearch indexes. When find_similar / hybrid_search auto-discovers an index, the SDK compares the index's numDimensions and similarity against the :vector property declaration and, for classes with an agent_tenant_scope, confirms the scope field is declared as a type: "filter" path. Governed by Parse::VectorSearch.index_drift_policy: :warn (default) emits a one-shot warning, :raise raises on every query against a drifted index, :ignore skips verification.

Hybrid search hardening

  • FIXED: on the opt-in native $rankFusion path, a scoped (non-master) caller's _hybrid_score is now recomputed from the post-ACL visible ordering instead of surfacing the raw fused score, which encoded a surviving row's rank among rows the caller cannot read — a cross-tenant inference channel. Master-key results and the default client-side RRF path are unchanged.
  • FIXED: the $rankFusion support probe no longer classifies MongoDB authorization errors as "stage unsupported", which could cache the wrong verdict for the probe TTL. Matching is narrowed to unambiguous unknown-stage phrases; any other failure surfaces through the real query, with the client-side path as the standing fallback.

Retrieval spend-cap and filter hardening

  • NEW: Parse::Embeddings::SpendCap.configure(..., warn_at: 0.8) — soft-cap alerting. When a charge pushes a tenant's in-window usage across the given fraction of its hard limit, a parse.embeddings.spend_cap_warning notification fires, once per crossing and re-arming as the window rolls off — an operator alerting hook that fires before the hard refuse trips.
  • NEW: Parse::Embeddings::Cache::MonetaStore — persistent-L2 adapter for the embedding cache. Wraps any Moneta-compatible store with key namespacing and TTL forwarding so query-embed entries are shared across processes and restarts. Fail-open: a backend error degrades to a cache miss, never a failed embed.
  • NEW: embedding spend-cap coverage on every query-embed path. The per-tenant cap was previously charged only at the semantic_search agent-tool boundary; direct find_similar(text:), hybrid_search(text:), and Parse::Retrieval.retrieve callers now charge through the shared query-embed path, with SpendCap.with_precharged preventing double-billing at the agent-tool boundary.
  • NEW: pointer-value translation for caller-supplied retrieval filters. Parse::Retrieval.retrieve now rewrites Parse pointer values — Parse::Pointer / Parse::Object instances and wire-form pointer hashes, including inside $in / $eq / $ne — into their MongoDB storage form, so { owner: some_user } actually matches rows. Previously a pointer-valued filter silently matched nothing.
  • IMPROVED: Parse::Schema::SearchIndexMigrator auto-includes the model's registered agent_tenant_scope field as a type: "filter" path when planning or applying vectorSearch index declarations; existing indexes missing the path surface as drifted: in the plan instead of failing at query time.

Opt-in Unicode regex matching for text constraints

  • NEW: starts_with, contains, ends_with, and like/regex accept an opt-in { value:, unicode: true } form that appends the u flag to the compiled $options, enabling correct multibyte case-insensitive matching for accented and non-Latin text. The bare-value forms compile exactly as before, so existing queries are unchanged. Honored by Parse Server 8.3.0+ over REST and MongoDB 6.1+ on the mongo-direct path.

ACL permission query hardening

  • FIXED: readable_by, writable_by, readable_by_role, writable_by_role, publicly_readable, and publicly_writable no longer raise a pipeline-security error when they auto-route through the direct MongoDB path. The internal-fields denylist that protects user-supplied pipelines was also rejecting these SDK-generated _rperm / _wperm references; SDK-built ACL pipelines are now sanctioned while caller-supplied pipelines remain subject to the full denylist.
  • FIXED: Query#count now routes ACL permission filters through the direct MongoDB path, mirroring Query#results — previously an ACL count was sent to Parse Server's REST aggregate endpoint, which cannot express a $match on _rperm / _wperm.
  • FIXED: the scalar aggregation terminals — sum, average, min, max, distinct, count_distinct — now honor ACL permission filters and scoped queries. Most seriously, a scoped terminal (scope_to_user(u).sum(:plays)) previously reached the master-key-only REST aggregate endpoint, which enforces neither ACL nor CLP, so the aggregate ran unscoped over rows the caller cannot read. Scoped terminals now route to mongo-direct and fail closed (Parse::Query::MongoDirectRequired) when mongo-direct is unavailable, rather than silently bypassing enforcement.
  • FIXED: not_publicly_readable / not_publicly_writable no longer return the rows they are meant to exclude. They compiled to a bare $nin, which matches documents where the permission field is absent — and a missing _rperm is public. The constraints now carry an $exists: true guard, ...
Read more

v5.4.1 - afterSave Callbacks Ignore the Handler Return Value

Choose a tag to compare

@AdrianCurtin AdrianCurtin released this 07 Jun 15:30

afterSave callbacks no longer depend on the handler return value

An afterSave webhook handler that returns a Parse::Object instead of true/nil no longer suppresses the model's after_create / after_save ActiveModel callbacks on client-initiated saves. Parse Server discards the afterSave response body entirely — it resolves the request as a success even when the handler throws — so the handler's return value carries no signal back to the server and must not gate callback dispatch.

Changes

  • FIXED: An afterSave webhook handler that returns a Parse::Object (instead of true or nil) no longer silently skips the model's after_create and after_save callbacks on client/REST/JS-initiated saves. The dispatch decision now depends only on the request origin, never on what the handler returned. A trusted Ruby-initiated save still skips the webhook-side callbacks (the local save chain already fired them, avoiding a double-run), while a client-initiated save always fires them. Returning the object — the recommended beforeSave pattern, easy to copy into an afterSave by mistake — previously cancelled dirty-gated side effects such as after_save :send_email. The response is also normalized to true, so a returned object can no longer leak into the response body or the debug log.

Behavior Notes

  • This affects only client/REST/JS-initiated saves, where the SDK never ran the model callbacks locally. Trusted Ruby-model saves are unchanged — their callbacks still run exactly once, through the local save chain.
  • Parse Server ignores the afterSave response body and resolves success even if the handler raises, so an afterSave handler's return value is not a control signal. Where a handler must signal failure, raise — do not rely on the return value.

Code Example

class Post < Parse::Object
  property :title
  after_save :reindex          # now fires for client/REST/JS saves regardless
                               # of the afterSave webhook block's return value
end

Parse::Webhooks.route :after_save, "Post" do
  parse_object                 # returning the object no longer suppresses :reindex
  # Prefer returning `true` — Parse Server discards the afterSave response anyway.
end

Commit: d1779f1
Author: Adrian Curtin
Date: June 7, 2026

v5.4.0 - Parse Server 8/9 Compatibility, Hybrid-Search RAG, MCP Streaming

Choose a tag to compare

@AdrianCurtin AdrianCurtin released this 06 Jun 19:51

Feature Release

A Parse Server 8.x / 9.x compatibility pass with a capability-detection layer, full webhook trigger coverage (including the auth and LiveQuery triggers and a fix that makes beforeFind / afterFind actually route), hybrid (lexical + vector) retrieval with reranking, and a one-switch MCP Streamable HTTP transport with disconnection hardening. It also carries MFA, email-verification, Parse::Audience, and console fixes.

Breaking Changes

  • BREAKING: Parse::User#disable_mfa_master_key! now fails closed. Because it bypasses MFA verification via the master key, it refuses to run without an authorization signal: pass admin_role: (the library verifies the operator's role membership) or allow_unverified: true (assert the operator was authorized out-of-band). Callers that previously passed only authorized_by: now raise Parse::MFA::ForbiddenError. Migration: add admin_role: or allow_unverified: true.
  • BREAKING: cloud-function results now decode __type-encoded Parse objects back into Parse::Object / Parse::Pointer (Parse Server 8.0 began encoding returned objects; 9.0 made it unconditional). This makes Parse.call_function results ORM-typed for registered classes when read in-process in Ruby: a field read goes through the property getter, so an enum / symbolize: property yields a Symbol (:active, not "active"), a date yields a Parse::Date, and a pointer yields a Parse::Pointer, where a pre-5.4.0 caller read a raw JSON String/Hash. HTTP/JSON consumers are unaffected — JSON has no symbols, so values re-serialize to strings on the way out; webhook trigger returns are also unaffected (those go to Parse Server, not through the result decoder). Decoding is conservative: an unregistered class is left as a raw Hash and plain data passes through untouched. Migration: a cloud function whose JSON shape is a contract should return an explicit plain Hash and coerce typed fields (status: obj.status.to_s) rather than whole Parse::Objects; Ruby callers reading typed fields should expect the ORM type or normalize at the read site.

Changes

Parse Server 8.x / 9.x compatibility

  • FIXED: Query#read_pref now rides the REST query body (readPreference), not just the X-Parse-Read-Preference header — Parse Server maps no such header, so over REST the preference was silently ignored and every scoped read hit the primary. The mongo-direct path was already correct.
  • FIXED: LiveQuery field projection now emits the keys subscription option (Parse Server 7.0 renamed it from fields), with fields kept as an alias for older servers — a projected subscription was otherwise silently receiving every column on 7.0+.
  • NEW: Parse.server_supports?(:capability) and Parse.server_features (plus client-level equivalents) expose a capability probe built on the memoized serverInfo fetch, so future server changes can be feature-gated rather than discovered by breakage. It prefers the advertised features block and falls back to version inference, failing open to the current server line when the version is unknown.
  • IMPROVED: Query#explain surfaces actionable guidance against Parse Server 9.0's allowPublicExplain: false default — a proactive one-shot warning, a reactive message, and the same guidance from the explain_query agent tool — instead of a bare 403. Suppressed for master-key and unknown-version calls.

Webhook trigger coverage

  • NEW: first-class routing for the non-object trigger shapes — the authentication triggers (beforeLogin, afterLogin, afterLogout, beforePasswordResetRequest) and the LiveQuery triggers (beforeConnect, beforeSubscribe, afterEvent). Parse::Webhooks::Payload gains matching predicates (before_login?after_event?, plus auth_trigger? / live_query_trigger?), an event accessor, clients / subscriptions connection counters, and captures the top-level sessionToken that connect/subscribe carry into #session_token (so #user_client / #user_agent work, while keeping the token out of as_json and the request log). Dispatch matches Parse Server's response contract: the body is ignored for all seven, so a before* handler returning false (which Parse Server would resolve as {success:false} and allow) is converted to a rejection. None of these run ActiveModel save / create / destroy callbacks.
  • FIXED: beforeFind / afterFind webhook triggers now route. Parse Server omits the class name from the find payload entirely, so the SDK could not resolve parse_class and the dispatcher never invoked the handler; the class is now threaded from the webhook URL path (<endpoint>/<trigger>/<className>) into the Payload. This is also a correctness fix: an unrouted afterFind returned {"success": true} instead of an objects array, which Parse Server rejects — so a registered afterFind previously broke every matching query with a connection error. The path segment is charset-validated before use as a routing key.
  • FIXED: :vector columns are now stripped from afterFind webhook payload objects (the route-derived class is the only way to resolve the model, since the find payload carries no className; vector_visibility :public classes keep them).
  • NEW: file (@File) and connection (@Connect) triggers now have a full register / fetch / delete lifecycle; Parse::API::PathSegment.trigger_class_name! accepts the @-prefixed pseudo-classes.
  • CHANGED: beforeCreate / afterCreate are no longer presented as registerable webhook triggers (Parse Server has no such type); they remain ActiveModel callbacks that run inside the beforeSave / afterSave handler. Registering a create trigger now raises a clear error pointing to the save trigger.
  • NEW: Parse::Webhooks.trigger_audit — a master-key operator audit that cross-references three sources of trigger truth across every registered class (a model's ActiveModel callbacks, the locally registered webhook blocks, and the triggers registered with Parse Server) and reports where they drift. It surfaces the non-obvious rule that a callback runs server-side for non-Ruby clients only when both a local webhook block and the matching server trigger are registered. Findings: callbacks_inert, route_not_registered, orphan_server_trigger, and local_only_callbacks. Returns a Hash, or a human-readable summary with pretty: true; network: false audits against local routes without a master key.

Webhook handler ergonomics

  • IMPROVED: a registered webhook handler can now use an explicit return value. Handlers previously ran via instance_exec, so a bare return raised LocalJumpError when the handler was defined inside a method (initializer, class body, config block); they now run as a method on the payload, giving return ordinary semantics. The legacy idioms (last expression, next, break) still set the result, and self is still the payload.
  • NEW: payload.after_response { … } (alias defer) runs work after the webhook response is sent, off the client's critical path (search indexing, cache warming, fan-out). Uses rack.after_reply (Puma / Unicorn) when available, else a detached thread; callbacks run in registration order, are isolated, and fire only on the success path. In-process only — use a durable queue for work that must happen.

Parse Server feature coverage

  • NEW: context: propagation on create_object / update_object, call_function / call_function_with_session, and Parse.call_function — serialized to X-Parse-Cloud-Context and exposed to Cloud Code triggers; Webhooks::Payload#context reads it on the receive side.
  • NEW: Parse::User#verify_password(password) / Parse::API::Users#verify_password(username, password) validate credentials via POST /verifyPassword (credentials in the body, mirroring login, so the plaintext password stays out of URLs and logs) without minting a session — a step-up / re-auth primitive.
  • NEW: Parse::Error::EmailNotVerifiedError from Parse::User.login! distinguishes "verify your email" (preventLoginWithUnverifiedEmail, code 205) from bad credentials. It subclasses Parse::Error::AuthenticationError, so existing rescue AuthenticationError handlers keep catching it (non-breaking).
  • NEW: Query#exclude_keys(*fields) (excludeKeys), LiveQuery subscribe(watch: [...]) (update events only when named fields change, 7.0+), Query#aggregate(pipeline, raw_values:, raw_field_names:) (9.9.0 rawValues / rawFieldNames), Query#hint(index_name) (REST + mongo-direct), and the :field.contained_by => [...] ($containedBy) constraint.
  • IMPROVED: Query#exclude_keys now also takes effect on the mongo-direct read path (results_direct, first_direct, and aggregations that auto-promote to direct MongoDB). Because MongoDB's $project is allowlist-only, the SDK applies the denylist as a recursive post-fetch sanitize over the decoded results; decode-critical reserved fields are never stripped. exclude_keys remains a result-shaping convenience, not an ACL/CLP boundary — use keys or protectedFields to keep a field from leaving the database.

Retrieval (RAG): hybrid search and reranking

  • NEW: Class.hybrid_search(text:, lexical:, vector:, k:, fusion:) fuses a lexical Atlas Search branch with a $vectorSearch branch via reciprocal-rank fusion (RRF). Each branch enforces ACL / CLP / protectedFields independently before fusion, so fused rows are already access-filtered. Results carry #hybrid_score, #hybrid_ranks, and (when the branch contributed) #vector_score / #search_score.
  • NEW: Parse::VectorSearch::Hybrid.rrf (pure fusion math) and .rank_fusion_supported? (Atlas 8.0+ native $rankFusion detection via a cached behavioural probe, not version-string parsing).
  • NEW: Parse::Retrieval::Reranker cross-encoder protocol wit...
Read more

v5.3.0 - Run as the Calling User, Pluralized Aliases, Pointer Associations

Choose a tag to compare

@AdrianCurtin AdrianCurtin released this 05 Jun 17:21

Feature Release

Webhook handlers and client-side callers can now opt into acting on the server as the calling user. Parse Server embeds the caller's live session token in every trigger and function webhook fired by a logged-in user; this release captures it to build non-master, session-scoped clients and agents that Parse Server authorizes with full ACL, CLP, and protectedFields enforcement — no application master key. Plural model-constant aliases let a class read naturally as a query entry point, and pointer associations declared with property … as: now serialize correctly (a narrow breaking change).

Breaking Changes

  • BREAKING: a field declared with property … as: now serializes as a Pointer ({__type: "Pointer", …}) instead of a String. Previously as: was a silent no-op and the field defaulted to :string. If a deployed app used property … as: and saved records, Parse Server pinned that column as String on first write, and a Pointer write against it is now rejected with error 111 (schema type mismatch). Drop or migrate that column to a Pointer type before deploying. Apps that never used property … as: are unaffected — the option was a silent no-op, so no working pointer behavior depended on it.

Changes

Run webhook handlers as the calling user

  • NEW: Parse::Webhooks::Payload#session_token / #session_token? expose the caller's session token, captured from the inbound payload before credential scrubbing. They are nil for a master-key request (which carries no user), and the token is removed from payload.user / payload.object and never appears in payload.as_json, #inspect, or the request log.
  • NEW: Parse::Webhooks::Payload#user_agent returns a non-master Parse::Agent scoped to the caller. Because it has no master key and a bound session token, it runs in client mode — every query is authorized as the user, read-only unless allow_mutations: true. Returns nil when there is no token.
  • NEW: Parse::Webhooks::Payload#user_client returns a non-master Parse::Client with the caller's token bound, so even raw REST calls through it are authorized as the user with no further ceremony. Returns nil when there is no token.

User-scoped clients

  • NEW: Parse::Client.new accepts a session_token: option that binds a token to the client, applied as the lowest-priority auth fallback on every request. Explicit per-call session_token:, a Parse.with_session block, and an explicit use_master_key: true all take precedence. Pair it with master_key: nil to build a user-scoped client.
  • NEW: Parse::Client#become(session_token) returns a new non-master client that mirrors the receiver's connection settings (server_url/app_id/api_key) and binds the given token. Parse::Client#anonymous returns the same shape with no token (unauthenticated REST) to drop a bound identity.
  • NEW: Parse::User#session_client returns a user-scoped client straight from a logged-in user — the client-side counterpart of payload.user_client (e.g. Parse::User.login(u, p).session_client).
  • NEW: Parse::Client#with_session { … } runs a block with the client's bound token active as the ambient session, so REST-routed operations (find/get/count/save) inside it are authorized as the user without passing session_token: per call — the client-receiver flavor of Parse.with_session / Parse::User#with_session.
  • NEW: rake client:console opens an interactive console whose default client is bound to a user (session token, login, or anonymous), so every query in the session runs with that user's ACL / CLP rather than the master key. Helpers: client, whoami, as_master { … }.

Credential safety

  • FIXED: Parse::Client#inspect and Parse::Webhooks::Payload#inspect redact the master key and session token (and, for the payload, the pre-scrub raw credentials) instead of printing them in cleartext, so an error reporter or stray inspect cannot leak them.
  • FIXED: an explicit master_key: nil no longer re-inherits the process master key from PARSE_SERVER_MASTER_KEY / PARSE_MASTER_KEY, so a client built for user scoping stays non-master. A whitespace-only session_token: is treated as no token, so it can never silently fall through to the master key.

Pluralized class-name aliases

  • NEW: referencing the plural form of a model constant resolves to that class, so the plural reads as a query entry point — Posts.where(...).count works for a class Post. The alias is created lazily on first reference and is the same class object as the singular (Posts.equal?(Post) is true), so it adds no Parse::Object.descendants entry and registers no separate Parse schema class.
  • NEW: pluralized_alias! class macro for explicit opt-in (a custom plural, a class whose name ends in s, or a namespaced model), and Parse.pluralized_aliases configuration (default true; opt out with Parse.pluralized_aliases = false or PARSE_PLURALIZED_ALIASES=false). The automatic path is conservative: a class whose name already ends in s is skipped, and a plural that does not singularize to a known subclass falls through to a normal NameError.

Pointer associations declared with property … as:

  • FIXED: property <name>, as: <class> and property <name>, :pointer now declare a pointer association identical to the equivalent belongs_to — same :pointer field type, remote column, target-class reference, getter/setter, dirty tracking, and {__type: "Pointer", …} wire form. Previously as: was ignored and the field defaulted to :string with no warning (see Breaking Changes above).
  • NEW: belongs_to and the delegating property … as: raise an ArgumentError at declaration time when as: names a scalar data type such as as: :string. The guard fires only on an explicit as:, so existing belongs_to :field declarations are unaffected.
  • NEW: Parse.validate_associations! verifies that every belongs_to / property … as: pointer target and every has_many … through: :relation target resolves to a known Parse class, reporting any unresolved target. Meant to run once after all models load (boot, CI, or a rake task).
  • IMPROVED: belongs_to now stores _description: and _enum: agent metadata, which previously only property retained.

Behavior Notes

  • with_session (and Parse.with_session) scope REST-routed operations as the user. Mongo-direct queries (results_direct, aggregate, Atlas search) resolve auth from the query's own session_token: / acl_user: and otherwise run in master mode — scope them explicitly with a per-query session_token: or a scoped Parse::Agent. To run a query as a user via the master key without minting a token, use Parse::Query#scope_to_user(user).

Code Example

# In a webhook handler — act as the calling user (ACL/CLP enforced, no master key):
Parse::Webhooks.route :after_save, "Post" do
  next true unless session_token?          # master-key save -> no caller token
  recent = user_agent.execute(:query_class, class_name: "Post", limit: 20)
  true
end

# Client side — a user-scoped client object, or a block:
client = Parse::User.login(username, password).session_client
Parse::Query.new("Post", client: client).results

Parse::User.login(username, password).with_session do
  Post.query.count             # counts only the user's readable Posts
end

Commit: d803262
Author: Adrian Curtin
Date: June 5, 2026

v5.2.1 - Webhook Triggers Receive the Full Parse Object

Choose a tag to compare

@AdrianCurtin AdrianCurtin released this 05 Jun 02:10

Patch Release

Webhook trigger handlers now receive the full, server-authoritative Parse object — restoring createdAt/updatedAt, ACL, and dirty tracking inside afterSave — and the model lifecycle callbacks run in canonical ActiveModel order across the beforeSave/afterSave triggers (Parse Server has no separate create/update triggers). Only genuine credentials are stripped from the inbound payload.

Changes

Full object in webhook triggers

  • FIXED: afterSave/beforeSave handlers now receive the full object as Parse Server sends it (createdAt, updatedAt, ACL, internal fields). Previously the trigger payload was filtered through the wide mass-assignment denylist, which stripped the server-issued timestamps — so Parse::Object#existed? always returned false and #new? always returned true inside afterSave. Both are now reliable.
  • NEW: afterSave handlers on an updated object carry dirty tracking relative to the prior state, so title_changed?, changed, and changes work inside afterSave the same way they already did inside beforeSave.
  • CHANGED: Inbound webhook trigger payloads are now scrubbed of genuine credential material only (sessionToken, _hashed_password, _password_history) rather than the full mass-assignment denylist. Protection against persisting forged privileged fields stays on the write path: a save emits only declared, dirty-tracked properties, and an after-trigger response is true/false, so forged _rperm/_wperm/authData cannot be persisted through a handler. This applies only to the inbound trigger payload — client login/signup responses are unaffected and still return session tokens, and Parse::User continues to protect authData on payload.user.

Lifecycle callbacks in ActiveModel order

  • FIXED: before_create callbacks now run for objects created by any client (REST / JS cloud code / Auth0 / iOS). The beforeSave webhook runs before_create after before_save for new objects; previously it never fired for non-Ruby creates, so create-time setup written as before_create was silently skipped.
  • FIXED: after_save no longer double-fires on client-initiated saves. The beforeSave entry point previously ran the full save callback chain, firing after_save during beforeSave in addition to the afterSave webhook. It now runs the before phase only.
  • NEW: Parse::Object#run_before_save_callbacks and #run_before_create_callbacks — the before-phase counterparts to run_after_save_callbacks / run_after_create_callbacks. They honor :if/:unless conditions and the callback terminator.
  • CHANGED: Parse::Object#prepare_save! is retained as a back-compat alias for run_before_save_callbacks and now runs the before phase only.

Behavior Notes

  • In an afterSave handler, new? now correctly returns false (the object is persisted). Use existed? to distinguish create from update inside afterSave (existed? == false for a create); new? is intended for beforeSave.
  • Dirty-gated after_save side effects now fire on client/REST-initiated saves where they previously silently no-op'd. A callback such as after_save { notify if title_changed? } will now activate for objects created or updated via REST / JS cloud code, not only for Ruby-model saves.
  • The webhook layer runs before_save / before_create / after_create / after_save, but not before_update / after_update — those :update-specific callbacks fire only on Ruby-model saves, since Parse Server exposes no beforeUpdate / afterUpdate trigger. For update logic that must run for all clients, use before_save / after_save and branch on existed?.

Code Example

Parse::Webhooks.route :after_save, "Post" do
  post = parse_object

  if post.existed?            # reliable: false on create, true on update
    Search.reindex(post) if post.title_changed?
  else
    post.create_default_associations!
  end
  true
end

Commit: f3153d5
Author: Adrian Curtin
Date: June 5, 2026

v5.2.0 - Retrieval (RAG), MCP Subscriptions & Elicitation, Agent Hardening

Choose a tag to compare

@AdrianCurtin AdrianCurtin released this 04 Jun 16:12
476a181

Feature Release

Adds a safe-by-default retrieval (RAG) layer with an agent semantic_search tool, MCP resource subscriptions bridged to LiveQuery, human-in-the-loop approval for destructive tool calls via MCP elicitation, agent impersonation and prompt hardening, bounded idempotency-aware client retries, and a fully isolated Docker integration stack. Also corrects pointer/relation targetClass resolution for built-in system classes.

Changes

Retrieval layer — Parse::Retrieval (Parse::RAG)

  • NEW: Parse::Retrieval.retrieve(query:, klass:, field:, k:, filter:, vector_filter:, chunker:, tenant_scope:, score_quantize:, **scope_opts) embeds a query, runs Atlas $vectorSearch through the ACL/CLP-enforcing find_similar, and splits each retrieved document into scored, citable Parse::Retrieval::Chunks. Embedding stays one-vector-per-record; chunking is a post-retrieval presentation step. A merged tenant scope closes the cross-tenant existence side channel.
  • NEW: Parse::Retrieval::Chunker::FixedSizeOverlap(size:, overlap:, by:, max_chunks_per_document:) sliding-window chunker (by: :chars default, or :tokens), subclassable from Chunker::Base. The per-document cap truncates with a signal rather than raising.
  • NEW: agent_searchable field:, filter_fields: model macro opts a class into the agent retrieval tool and declares the fields an agent may filter on.
  • NEW: semantic_search agent tool (permission: :readonly, client_safe: true) with the full security envelope — searchable-class allowlist, recursive underscore-key refusal, filter-field allowlist, field_allowlist projection, tenant-scope re-assertion on every returned record, and score quantization in non-admin contexts. text_field: disambiguates multi-embed models, constrained to the class's declared embed sources.
  • NEW: Parse::RAG discoverability alias; rerank: / hybrid: reserved on retrieve (raise NotImplementedError) to lock the API shape.

MCP elicitation — human-in-the-loop approval for destructive operations

  • NEW: Parse::Agent.require_approval_for = [:write, :admin] opts tiers into approval (off by default). A pluggable Parse::Agent#approval_gate is consulted by #execute with the dry-run diff; NullGate is the default and MCPElicitationGate the spec-native (MCP 2025-06-18 elicitation/create) implementation.
  • NEW: call_method resolves the effective tier from the target method's declared permission, so write/admin methods invoked through the readonly call_method tool are gated correctly.
  • SECURITY: Fails closed — a missing client capability, no open listening stream, a non-streaming transport, or an approver timeout all refuse the operation rather than executing it. Replies are session-bound so one session cannot answer another's prompt.

MCP resource subscriptions bridged to LiveQuery

  • NEW: Opt-in MCP resource subscriptions backed by Parse LiveQuery.
  • SECURITY: Class authorization (agent_hidden / per-agent allowlist + CLP) is enforced before any socket opens; AccessDenied maps to JSON-RPC -32602. A master-key (admin) LiveQuery branch requires the agent's own client to present a usable master key. Subscription state is re-checked and stored under a lock to avoid resurrecting torn-down sessions, duplicates, or cap breaches. A process-wide session cap and a listening-stream soft cap bound resource growth.

Agent roadmap: impersonation, prompt hardening, telemetry, provenance

  • NEW: Agent impersonation — Parse::Agent.new(impersonate_user:, impersonate_mint:, impersonation_label:) and agent.impersonate(user) / stop_impersonating! resolve a real _Session token for a _User (reusing an active session or minting a restricted one). Fails closed: requires a master-key client, rejects non-_User pointers, and refuses rather than widening to master-key posture when no session resolves. The audit label surfaces on the parse.agent.tool_call payload.
  • SECURITY: Aggregate tools (aggregate, group_by, distinct, pipeline export) route through the ACL-enforcing mongo-direct path for ANY non-master identity — session-token and impersonated agents included — closing the unenforced REST-aggregate gap.
  • NEW: Parse::Agent::PromptHardeningsanitize_schema_for_llm (drops non-identifier field names, strips control/zero-width chars, caps and marker-wraps descriptions), scrub_marker_injection with a prompt_marker_strict refuse mode, operator-curated prompt_injection_canaries (emit telemetry or refuse), a surfaced PROMPT_VERSION, and an allowed_llm_endpoints origin allowlist with a one-time unrestricted-endpoint warning. Adds embedding-cost telemetry and optional per-row source provenance.

unique_index_on — declarative correctness floor for first_or_create!

  • NEW: Model-level unique_index_on helper (non-sparse by default) as the correctness floor for first_or_create! / create_or_update!.
  • IMPROVED: Mutex-guarded index registry refactor avoids duplicate createIndex declarations under concurrent class loading.

Fix wrong pointer/relation targetClass for built-in system-class associations

  • FIXED: Built-in class associations now resolve correctly regardless of load order. SYSTEM_CLASS_MAP and a String#to_parse_class fallback to underscored storage names (e.g. _User) prevent pointer/relation targetClass from freezing the literal "User" and correct the emitted schema.
  • CHANGED: _Installation CLP advisory is operation-aware — warns only for server-ignored operations (find/create/update/delete) and stays silent for effective ops.

Hardened constraint and $relatedTo handling

  • IMPROVED: Per-key operator validation, explicit $relatedTo owning-class accessibility checks on the agent and mongo-direct paths, and clearer errors for mongo-direct $relatedTo.

Schema migration — correct wire-column names and convergence check

  • FIXED: type_mismatches iterates the model's field_map to resolve custom wire names instead of camelizing property names; transient schema-fetch failures no longer cache a fallback.
  • NEW: One-way server_covers_local? convergence check.

Aggregation and connectivity probes

  • NEW: group_by class-method delegators and operation-aware table headers.
  • CHANGED: Parse.connected? probes the health endpoint by default (reports reachable even under hardened CLP); adds Parse.reachable? and an endpoint parameter for data-class credential validation.

Parse::Client#request — bounded, idempotency-aware retries

  • FIXED: Infinite retry loop removed; retry budget is hoisted and exponential-backoff math is correct (positive backoff with jitter) when callers override the retry count.
  • CHANGED: Retries are idempotency-aware — GET/DELETE always, PUT only without an atomic __op, POST never for ambiguous failures, 429 always. Adds an opt-in idempotent-retry mode with a stable X-Parse-Request-Id, Parse::Error::DuplicateRequestError, and replay recovery in first_or_create / create_or_update. Also retries on Faraday::TimeoutError.

Token economy and integration coverage

  • IMPROVED: Leaner tool surface, responses, and retrieval payloads; new integration coverage for retrieval, elicitation/approval, subscription authorization, retry/idempotency/connectivity, prompt hardening, and resource caps. bundler-audit added to the development/test bundle.

Test infrastructure

  • CHANGED: The integration stack moved to a dedicated 29xxx host-port block and is namespaced via PSNEXT_PREFIX (default psnext-it) — Compose project, container names, and volumes — so it never collides with another Parse instance on the host. Adds .env.test defaults and README usage/troubleshooting notes.

Commit: 476a181
Author: Adrian Curtin
Date: June 4, 2026

v5.1.1 - System-Class className Warning Fix

Choose a tag to compare

@AdrianCurtin AdrianCurtin released this 03 Jun 20:54

Suppress spurious className-mismatch warnings for system-class aliases

Parse Server stores its built-in classes under leading-underscore names (_User, _Role, _Session, _Installation) while the SDK and model declarations refer to them without the underscore (User, Role, and so on). These are the same class, but the SDK's className-mismatch warn sites compared the two forms as raw strings — so building a server-sent pointer for a belongs_to :user association (for example Parse::Installation#user, added in v5.1.0) logged a pair of harmless-but-noisy warnings such as expected className="User", ignoring incoming className="_User" on every read. Autoload order contributed: when an association is declared, Parse::User may not yet be registered, so the captured class name falls back to "User" rather than "_User".

Changes

  • FIXED: Building a pointer whose declared class and incoming class differ only by Parse Server's leading-underscore system prefix (User/_User, Role/_Role, Session/_Session, Installation/_Installation) no longer emits a className-mismatch warning. Parse::Object.build, Array#parse_objects, the belongs_to getter and setter, and the has_many relation builder now treat the two forms as the same class.
  • NEW: Parse::Model.same_parse_class?(a, b) returns true when two Parse class-name strings denote the same class — string-equal, related by exactly one Parse Server system-prefix underscore, or both resolving to the same registered Parse::Object subclass (covering custom parse_class table mappings). This is the canonical equality the warn sites now gate on.
  • CHANGED: The className-mismatch type-confusion guard is preserved. A pointer to a genuinely different class shoved into a typed slot (for example a _Session or _Role pointer in a User-typed association, whether from hostile server JSON or mass assignment) still produces the warning, because distinct classes — nil, and malformed names that differ by more than a single system-prefix underscore such as __User — continue to compare unequal. The warning is advisory: every call site builds the object from the declared class regardless of the incoming className, so the equality check only governs whether the mismatch is logged.

Commit: c490ac8
Author: Adrian Curtin
Date: June 3, 2026