Releases: neurosynq/parse-stack-next
Release list
v5.5.3 - Dependency Updates
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-ruby1.3.6 → 1.3.7,faraday2.14.2 → 2.14.3,i18n1.14.8 → 1.15.1,json2.19.8 → 2.19.9, andredis-client0.29.0 → 0.30.0.
Commit: 39c4a84
Author: Adrian Curtin
Date: June 18, 2026
v5.5.2 - Large Aggregation Pipelines and with_session Scoping
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 customaggregatepipeline 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 withInvalid 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 asrawValuesare preserved as booleans). The historical URL-encoded override is unchanged forfindand other endpoints, which Parse Server already decodes correctly.
Aggregations inside Parse.with_session blocks are now scoped
- FIXED:
group_by_date,group_by,distinct, andcount(aggregation branch) now detect the ambient session token set byParse.with_sessionand treat the query as scoped — consistent with howParse::Client#requestalready scopes REST find/get/count calls in the same block. Previously the scoping checks consulted only the query instance's ownsession_token=/scope_to_user/scope_to_roleand ignored the fiber-local ambient session, so an aggregation inside awith_sessionblock 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 withParse::Query::MongoDirectRequiredrather than silently leaking rows. - FIXED:
group_by_datenow also fails closed (Parse::Query::MongoDirectRequired) when the query is scoped but mongo-direct is unavailable — matching the existing behavior ofgroup_by,distinct, andcount. Previouslygroup_by_datesilently fell back to the REST/aggregateendpoint in that case. - FIXED: A regression introduced in 5.5.1 where
group_by_date,group_by, and pipeline-based aggregations called inside aParse.with_sessionblock returned empty results{}. The ambient session token was forwarded as an HTTP session-token header (suppressing the master key), causing Parse Server's REST/aggregateendpoint — which is master-key-only — to return a 401/403. The REST aggregate call sites now forceuse_master_key: trueso the ambient cannot suppress it, unless the caller explicitly setuse_master_key: false. - CHANGED: An explicit
use_master_key: trueon a query now takes precedence over the ambientParse.with_sessiontoken when deciding whether an aggregation is scoped, mirroringParse::Client#request. A deliberate admin aggregation executed inside awith_sessionblock is treated as a master-key call and is no longer forced onto mongo-direct. - FIXED:
GroupBy#rawroutes scoped queries (including those insideParse.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/aggregateendpoint.
Commit: e32211a
Author: Adrian Curtin
Date: June 11, 2026
v5.5.1 - Security Hardening: ACL Scoping, Cache, and Pipeline Guards
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/$geoIntersectsquery) now honors the ambient session token set byParse.with_session(token). Previously the mongo-direct auth resolver consulted only the query's ownsession_token=/scope_to_user/scope_to_roleand 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 samewith_sessionblock was correctly scoped. The resolver now mirrorsParse::Client#requestprecedence: an explicit per-query token wins, then the ambient session, then the master-key fallback; an explicituse_master_key: trueis a deliberate admin call and still skips the ambient. Routing also accepts the ambient on non-master clients (Parse.client_modeor 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
:booleanproperty assigned a string now coerces via ActiveModel's boolean caster instead of raw Ruby truthiness. Previously the coercion wasval ? true : false, so the strings"false","0", and"off"— exactly what arrives on a Rails-form or query-string ingestion path — all coerced totrue, silently flipping a boolean the wrong way (for example anarchivedflag or an application-defined access gate). String forms now map correctly ("false"/"0"/"off"tofalse), 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/ACLthrough 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 doingrecord.attributes = paramswithout StrongParameters — lets an attacker grant unintended access by sending anACLkey ({"ACL" => {"*" => {"write" => true}}}). The behavior is unchanged this release (the ACL is still applied), but the supported path is the explicitrecord.acl = ...setter, and a future release may block ACL mass-assignment. The constructor formKlass.new(acl: ...)is unaffected and does not warn.
Redis cache values serialized as JSON instead of Marshal
- FIXED:
Parse::Cache::Redisnow serializes cached HTTP responses as JSON rather than Marshal. The Moneta-Redis store Marshals values by default, so every cache hit ranMarshal.loadon 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 onParse::Client.new/Parse.setupnow builds aParse::Cache::Redisstore instead of a bareMoneta.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::MonetaStorenow JSON-encodes cached embedding vectors instead of relying on the Moneta store's default Marshal value serializer, closing the sameMarshal.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 recommendsvalue_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 viaParse::Cache::Redisorvalue_serializer: nilso a rawMoneta.new(:Redis, ...)no longer leaves Marshal on the read path.
Internal columns stripped from joined documents on mongo-direct reads
- FIXED:
Parse::MongoDB.aggregatenow 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._Useror_Session) into an arbitrary alias via$lookup/$graphLookup/$unionWithand read back password hashes, OAuth tokens, and session tokens: the per-classprotectedFieldsstrip 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 newParse::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$matchfield name are now refused unconditionally on the mongo-direct path — even on a pipeline running withallow_internal_fields: true(the flag that lets SDK-emitted_rperm/_wpermreferences through forreadable_by_role/publicly_readable). Previously the*_directterminals (count_direct,results_direct,distinct_direct, the direct group-by helpers) passedallow_internal_fields: trueunconditionally, so a query whosewherereferenced a credential column compiled into a$matchkey 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 byallow_internal_fields, soreadable_by_rolestill works. - FIXED:
Parse::Query#aggregateand#aggregate_from_querynow treat a scoped query (session_token/scope_to_user/scope_to_role) as authoritative over an explicitmongo_direct: false. Previously passingmongo_direct: falseon a scoped aggregation skipped the fail-closed guard and routed to Parse Server's master-key-only REST/aggregateendpoint, running the aggregation unscoped (no ACL, CLP, orprotectedFields). A scoped aggregation now promotes to mongo-direct, or fails closed withParse::Query::MongoDirectRequiredwhen direct Mongo is unavailable; unscoped callers can still opt out to REST withmongo_direct: false.
Additional hardening
- FIXED: Request/response body logging now redacts credentials. At
:debuglevel the logging middleware emitted login/signup request bodies (cleartextpassword) and auth response bodies (sessionToken,authData, MFA secrets); the body path now runs through the sameBodyBuilder.redactscrubber the header path already used, before truncation. - FIXED: The
_UserREST endpoints (fetch_user/update_user/delete_user) now validate theobjectIdagainstParse::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 inDENIED_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/$unionWithjoin target inParse::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.1carries a non-loopbackOriginand is refused; native clients (which send noOrigin) and local browser UIs are unaffected. A one-time warning points operators atMCP_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
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_byoperator 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 ofwriteable_byshould passstrict: trueor use the:writable_by_exactoperator.
Changes
Image embeddings: SDK-side bytes pipeline
- NEW:
Parse::Embeddings::ImageFetch— the SDK-side image download layer for image embeddings. Downloads ride the existingParse::File.safe_open_urlSSRF 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 HTTPContent-Typeheader is never consulted, so a.jpgURL serving HTML is refused outright. - NEW:
embed_image ..., source: :bytesdeclaration 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 thetrust_provider_url_fetchsentinel is not required. The file's host must still be inParse::Embeddings.allowed_image_hosts(deny-all when empty). - NEW: EXIF/XMP metadata stripping, default ON for the bytes path. JPEG APP1 segments, PNG
eXIfchunks, and WebPEXIF/XMPRIFF 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 withexif_strip: false. - NEW:
Voyage#embed_imageandCohere#embed_imageaccept 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!acceptsmode: :fetchfor SDK-side downloads — same host allowlist, obfuscated-IP screen, and port/CIDR checks as the default:forwardmode, 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. Unlikeembed_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. Withonly_stale: truethe walk skips rows whose recorded provenance already matches the current provider, model, and dimensions, making a partially failed migration resumable. - NEW:
embed/embed_imageauto-declare an<into>_meta:objectsibling property recording{ provider, model, dimensions, modality, embedded_at }on every recompute. This is the provenance recordreembed!(only_stale: true)reads, and it tells operational tooling which model produced any stored vector. Override the name withmeta_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 raisesBatchEmbedder::BatchFailedcarryingbatch_indexandcompleted_countso a resumable job knows where to pick up. Supportsretry_on:overrides and anon_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 throughfind_similar(text:),hybrid_search(text:), andParse::Retrieval.retrievethen skip the provider round-trip. Cache hits emit the standardparse.embeddings.embednotification withcached: 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_searchauto-discovers an index, the SDK compares the index'snumDimensionsandsimilarityagainst the:vectorproperty declaration and, for classes with anagent_tenant_scope, confirms the scope field is declared as atype: "filter"path. Governed byParse::VectorSearch.index_drift_policy::warn(default) emits a one-shot warning,:raiseraises on every query against a drifted index,:ignoreskips verification.
Hybrid search hardening
- FIXED: on the opt-in native
$rankFusionpath, a scoped (non-master) caller's_hybrid_scoreis 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
$rankFusionsupport 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, aparse.embeddings.spend_cap_warningnotification 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_searchagent-tool boundary; directfind_similar(text:),hybrid_search(text:), andParse::Retrieval.retrievecallers now charge through the shared query-embed path, withSpendCap.with_prechargedpreventing double-billing at the agent-tool boundary. - NEW: pointer-value translation for caller-supplied retrieval filters.
Parse::Retrieval.retrievenow rewrites Parse pointer values —Parse::Pointer/Parse::Objectinstances 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::SearchIndexMigratorauto-includes the model's registeredagent_tenant_scopefield as atype: "filter"path when planning or applyingvectorSearchindex declarations; existing indexes missing the path surface asdrifted:in the plan instead of failing at query time.
Opt-in Unicode regex matching for text constraints
- NEW:
starts_with,contains,ends_with, andlike/regexaccept an opt-in{ value:, unicode: true }form that appends theuflag 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, andpublicly_writableno 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/_wpermreferences; SDK-built ACL pipelines are now sanctioned while caller-supplied pipelines remain subject to the full denylist. - FIXED:
Query#countnow routes ACL permission filters through the direct MongoDB path, mirroringQuery#results— previously an ACL count was sent to Parse Server's REST aggregate endpoint, which cannot express a$matchon_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_writableno 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_rpermis public. The constraints now carry an$exists: trueguard, ...
v5.4.1 - afterSave Callbacks Ignore the Handler Return Value
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
afterSavewebhook handler that returns aParse::Object(instead oftrueornil) no longer silently skips the model'safter_createandafter_savecallbacks 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 recommendedbeforeSavepattern, easy to copy into anafterSaveby mistake — previously cancelled dirty-gated side effects such asafter_save :send_email. The response is also normalized totrue, 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
afterSaveresponse body and resolves success even if the handler raises, so anafterSavehandler'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.
endCommit: d1779f1
Author: Adrian Curtin
Date: June 7, 2026
v5.4.0 - Parse Server 8/9 Compatibility, Hybrid-Search RAG, MCP Streaming
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: passadmin_role:(the library verifies the operator's role membership) orallow_unverified: true(assert the operator was authorized out-of-band). Callers that previously passed onlyauthorized_by:now raiseParse::MFA::ForbiddenError. Migration: addadmin_role:orallow_unverified: true. - BREAKING: cloud-function results now decode
__type-encoded Parse objects back intoParse::Object/Parse::Pointer(Parse Server 8.0 began encoding returned objects; 9.0 made it unconditional). This makesParse.call_functionresults ORM-typed for registered classes when read in-process in Ruby: a field read goes through the property getter, so anenum/symbolize:property yields a Symbol (:active, not"active"), a date yields aParse::Date, and a pointer yields aParse::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 wholeParse::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_prefnow rides the REST query body (readPreference), not just theX-Parse-Read-Preferenceheader — 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
keyssubscription option (Parse Server 7.0 renamed it fromfields), withfieldskept as an alias for older servers — a projected subscription was otherwise silently receiving every column on 7.0+. - NEW:
Parse.server_supports?(:capability)andParse.server_features(plus client-level equivalents) expose a capability probe built on the memoizedserverInfofetch, so future server changes can be feature-gated rather than discovered by breakage. It prefers the advertisedfeaturesblock and falls back to version inference, failing open to the current server line when the version is unknown. - IMPROVED:
Query#explainsurfaces actionable guidance against Parse Server 9.0'sallowPublicExplain: falsedefault — a proactive one-shot warning, a reactive message, and the same guidance from theexplain_queryagent 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::Payloadgains matching predicates (before_login?…after_event?, plusauth_trigger?/live_query_trigger?), aneventaccessor,clients/subscriptionsconnection counters, and captures the top-levelsessionTokenthat connect/subscribe carry into#session_token(so#user_client/#user_agentwork, while keeping the token out ofas_jsonand the request log). Dispatch matches Parse Server's response contract: the body is ignored for all seven, so abefore*handler returningfalse(which Parse Server would resolve as{success:false}and allow) is converted to a rejection. None of these run ActiveModelsave/create/destroycallbacks. - FIXED:
beforeFind/afterFindwebhook triggers now route. Parse Server omits the class name from the find payload entirely, so the SDK could not resolveparse_classand the dispatcher never invoked the handler; the class is now threaded from the webhook URL path (<endpoint>/<trigger>/<className>) into thePayload. This is also a correctness fix: an unroutedafterFindreturned{"success": true}instead of an objects array, which Parse Server rejects — so a registeredafterFindpreviously broke every matching query with a connection error. The path segment is charset-validated before use as a routing key. - FIXED:
:vectorcolumns are now stripped fromafterFindwebhook payloadobjects(the route-derived class is the only way to resolve the model, since the find payload carries no className;vector_visibility :publicclasses 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/afterCreateare no longer presented as registerable webhook triggers (Parse Server has no such type); they remain ActiveModel callbacks that run inside thebeforeSave/afterSavehandler. 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, andlocal_only_callbacks. Returns a Hash, or a human-readable summary withpretty: true;network: falseaudits 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 viainstance_exec, so a barereturnraisedLocalJumpErrorwhen the handler was defined inside a method (initializer, class body, config block); they now run as a method on the payload, givingreturnordinary semantics. The legacy idioms (last expression,next,break) still set the result, andselfis still the payload. - NEW:
payload.after_response { … }(aliasdefer) runs work after the webhook response is sent, off the client's critical path (search indexing, cache warming, fan-out). Usesrack.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 oncreate_object/update_object,call_function/call_function_with_session, andParse.call_function— serialized toX-Parse-Cloud-Contextand exposed to Cloud Code triggers;Webhooks::Payload#contextreads it on the receive side. - NEW:
Parse::User#verify_password(password)/Parse::API::Users#verify_password(username, password)validate credentials viaPOST /verifyPassword(credentials in the body, mirroringlogin, so the plaintext password stays out of URLs and logs) without minting a session — a step-up / re-auth primitive. - NEW:
Parse::Error::EmailNotVerifiedErrorfromParse::User.login!distinguishes "verify your email" (preventLoginWithUnverifiedEmail, code 205) from bad credentials. It subclassesParse::Error::AuthenticationError, so existingrescue AuthenticationErrorhandlers keep catching it (non-breaking). - NEW:
Query#exclude_keys(*fields)(excludeKeys), LiveQuerysubscribe(watch: [...])(update events only when named fields change, 7.0+),Query#aggregate(pipeline, raw_values:, raw_field_names:)(9.9.0rawValues/rawFieldNames),Query#hint(index_name)(REST + mongo-direct), and the:field.contained_by => [...]($containedBy) constraint. - IMPROVED:
Query#exclude_keysnow also takes effect on the mongo-direct read path (results_direct,first_direct, and aggregations that auto-promote to direct MongoDB). Because MongoDB's$projectis 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_keysremains a result-shaping convenience, not an ACL/CLP boundary — usekeysorprotectedFieldsto 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$vectorSearchbranch via reciprocal-rank fusion (RRF). Each branch enforces ACL / CLP /protectedFieldsindependently 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$rankFusiondetection via a cached behavioural probe, not version-string parsing). - NEW:
Parse::Retrieval::Rerankercross-encoder protocol wit...
v5.3.0 - Run as the Calling User, Pluralized Aliases, Pointer Associations
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. Previouslyas:was a silent no-op and the field defaulted to:string. If a deployed app usedproperty … as:and saved records, Parse Server pinned that column asStringon 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 usedproperty … 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 arenilfor a master-key request (which carries no user), and the token is removed frompayload.user/payload.objectand never appears inpayload.as_json,#inspect, or the request log. - NEW:
Parse::Webhooks::Payload#user_agentreturns a non-masterParse::Agentscoped 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 unlessallow_mutations: true. Returnsnilwhen there is no token. - NEW:
Parse::Webhooks::Payload#user_clientreturns a non-masterParse::Clientwith the caller's token bound, so even raw REST calls through it are authorized as the user with no further ceremony. Returnsnilwhen there is no token.
User-scoped clients
- NEW:
Parse::Client.newaccepts asession_token:option that binds a token to the client, applied as the lowest-priority auth fallback on every request. Explicit per-callsession_token:, aParse.with_sessionblock, and an explicituse_master_key: trueall take precedence. Pair it withmaster_key: nilto 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#anonymousreturns the same shape with no token (unauthenticated REST) to drop a bound identity. - NEW:
Parse::User#session_clientreturns a user-scoped client straight from a logged-in user — the client-side counterpart ofpayload.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 passingsession_token:per call — the client-receiver flavor ofParse.with_session/Parse::User#with_session. - NEW:
rake client:consoleopens 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#inspectandParse::Webhooks::Payload#inspectredact 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 strayinspectcannot leak them. - FIXED: an explicit
master_key: nilno longer re-inherits the process master key fromPARSE_SERVER_MASTER_KEY/PARSE_MASTER_KEY, so a client built for user scoping stays non-master. A whitespace-onlysession_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(...).countworks for a classPost. The alias is created lazily on first reference and is the same class object as the singular (Posts.equal?(Post)istrue), so it adds noParse::Object.descendantsentry and registers no separate Parse schema class. - NEW:
pluralized_alias!class macro for explicit opt-in (a custom plural, a class whose name ends ins, or a namespaced model), andParse.pluralized_aliasesconfiguration (defaulttrue; opt out withParse.pluralized_aliases = falseorPARSE_PLURALIZED_ALIASES=false). The automatic path is conservative: a class whose name already ends insis skipped, and a plural that does not singularize to a known subclass falls through to a normalNameError.
Pointer associations declared with property … as:
- FIXED:
property <name>, as: <class>andproperty <name>, :pointernow declare a pointer association identical to the equivalentbelongs_to— same:pointerfield type, remote column, target-class reference, getter/setter, dirty tracking, and{__type: "Pointer", …}wire form. Previouslyas:was ignored and the field defaulted to:stringwith no warning (see Breaking Changes above). - NEW:
belongs_toand the delegatingproperty … as:raise anArgumentErrorat declaration time whenas:names a scalar data type such asas: :string. The guard fires only on an explicitas:, so existingbelongs_to :fielddeclarations are unaffected. - NEW:
Parse.validate_associations!verifies that everybelongs_to/property … as:pointer target and everyhas_many … through: :relationtarget 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_tonow stores_description:and_enum:agent metadata, which previously onlypropertyretained.
Behavior Notes
with_session(andParse.with_session) scope REST-routed operations as the user. Mongo-direct queries (results_direct,aggregate, Atlas search) resolve auth from the query's ownsession_token:/acl_user:and otherwise run in master mode — scope them explicitly with a per-querysession_token:or a scopedParse::Agent. To run a query as a user via the master key without minting a token, useParse::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
endCommit: d803262
Author: Adrian Curtin
Date: June 5, 2026
v5.2.1 - Webhook Triggers Receive the Full Parse Object
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/beforeSavehandlers 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 — soParse::Object#existed?always returnedfalseand#new?always returnedtrueinsideafterSave. Both are now reliable. - NEW:
afterSavehandlers on an updated object carry dirty tracking relative to the prior state, sotitle_changed?,changed, andchangeswork insideafterSavethe same way they already did insidebeforeSave. - 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 istrue/false, so forged_rperm/_wperm/authDatacannot be persisted through a handler. This applies only to the inbound trigger payload — client login/signup responses are unaffected and still return session tokens, andParse::Usercontinues to protectauthDataonpayload.user.
Lifecycle callbacks in ActiveModel order
- FIXED:
before_createcallbacks now run for objects created by any client (REST / JS cloud code / Auth0 / iOS). ThebeforeSavewebhook runsbefore_createafterbefore_savefor new objects; previously it never fired for non-Ruby creates, so create-time setup written asbefore_createwas silently skipped. - FIXED:
after_saveno longer double-fires on client-initiated saves. ThebeforeSaveentry point previously ran the full save callback chain, firingafter_saveduringbeforeSavein addition to theafterSavewebhook. It now runs the before phase only. - NEW:
Parse::Object#run_before_save_callbacksand#run_before_create_callbacks— the before-phase counterparts torun_after_save_callbacks/run_after_create_callbacks. They honor:if/:unlessconditions and the callback terminator. - CHANGED:
Parse::Object#prepare_save!is retained as a back-compat alias forrun_before_save_callbacksand now runs the before phase only.
Behavior Notes
- In an
afterSavehandler,new?now correctly returnsfalse(the object is persisted). Useexisted?to distinguish create from update insideafterSave(existed? == falsefor a create);new?is intended forbeforeSave. - Dirty-gated
after_saveside effects now fire on client/REST-initiated saves where they previously silently no-op'd. A callback such asafter_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 notbefore_update/after_update— those:update-specific callbacks fire only on Ruby-model saves, since Parse Server exposes nobeforeUpdate/afterUpdatetrigger. For update logic that must run for all clients, usebefore_save/after_saveand branch onexisted?.
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
endCommit: f3153d5
Author: Adrian Curtin
Date: June 5, 2026
v5.2.0 - Retrieval (RAG), MCP Subscriptions & Elicitation, Agent Hardening
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$vectorSearchthrough the ACL/CLP-enforcingfind_similar, and splits each retrieved document into scored, citableParse::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: :charsdefault, or:tokens), subclassable fromChunker::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_searchagent tool (permission: :readonly,client_safe: true) with the full security envelope — searchable-class allowlist, recursive underscore-key refusal, filter-field allowlist,field_allowlistprojection, 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 declaredembedsources. - NEW:
Parse::RAGdiscoverability alias;rerank:/hybrid:reserved onretrieve(raiseNotImplementedError) 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 pluggableParse::Agent#approval_gateis consulted by#executewith the dry-run diff;NullGateis the default andMCPElicitationGatethe spec-native (MCP 2025-06-18elicitation/create) implementation. - NEW:
call_methodresolves the effective tier from the target method's declared permission, so write/admin methods invoked through the readonlycall_methodtool 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;AccessDeniedmaps 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:)andagent.impersonate(user)/stop_impersonating!resolve a real_Sessiontoken for a_User(reusing an active session or minting a restricted one). Fails closed: requires a master-key client, rejects non-_Userpointers, and refuses rather than widening to master-key posture when no session resolves. The audit label surfaces on theparse.agent.tool_callpayload. - 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::PromptHardening—sanitize_schema_for_llm(drops non-identifier field names, strips control/zero-width chars, caps and marker-wraps descriptions),scrub_marker_injectionwith aprompt_marker_strictrefuse mode, operator-curatedprompt_injection_canaries(emit telemetry or refuse), a surfacedPROMPT_VERSION, and anallowed_llm_endpointsorigin 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_onhelper (non-sparse by default) as the correctness floor forfirst_or_create!/create_or_update!. - IMPROVED: Mutex-guarded index registry refactor avoids duplicate
createIndexdeclarations 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_MAPand aString#to_parse_classfallback to underscored storage names (e.g._User) prevent pointer/relationtargetClassfrom freezing the literal"User"and correct the emitted schema. - CHANGED:
_InstallationCLP 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
$relatedToowning-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_mismatchesiterates the model'sfield_mapto 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_byclass-method delegators and operation-aware table headers. - CHANGED:
Parse.connected?probes the health endpoint by default (reports reachable even under hardened CLP); addsParse.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 stableX-Parse-Request-Id,Parse::Error::DuplicateRequestError, and replay recovery infirst_or_create/create_or_update. Also retries onFaraday::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-auditadded to the development/test bundle.
Test infrastructure
- CHANGED: The integration stack moved to a dedicated
29xxxhost-port block and is namespaced viaPSNEXT_PREFIX(defaultpsnext-it) — Compose project, container names, and volumes — so it never collides with another Parse instance on the host. Adds.env.testdefaults and README usage/troubleshooting notes.
Commit: 476a181
Author: Adrian Curtin
Date: June 4, 2026
v5.1.1 - System-Class className Warning Fix
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, thebelongs_togetter and setter, and thehas_manyrelation 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 registeredParse::Objectsubclass (covering customparse_classtable 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
_Sessionor_Rolepointer in aUser-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