Repository navigation
v5.8.0 - Agent Access Policies, Retrieval Profiles, Security Hardening - #61
Merged
Merged
Conversation
…rors Contextualized embedding requests now pack whole documents within the document cap, the response-size budget, and an estimated 120k input-token budget (three bytes per token, a conservative estimate; the SDK has no tokenizer). When Voyage still rejects a request as too large, the request is halved by document and each half resent, with results kept aligned to the input and the halving bounded. A document rejected as too large on its own raises an actionable BadRequestError naming its index. BadRequestError now carries the HTTP status and the provider's error text (bounded and terminal-sanitized), with request_too_large? and input_too_long? predicates. Only positively identified size errors (Voyage's batch-size and tokens-per-batch messages, or HTTP 413) trigger a split; any other 400, including a single input longer than the context window, propagates unchanged.
Adds Parse::VectorSearch::IndexDefinition (build / preview / diff) and Parse::Schema.vector_index_definition, which derive an Atlas vectorSearch definition from the :vector property (path, dimensions, similarity, quantization), agent_searchable filter_fields (pointer fields use their _p_ storage path), and the agent_tenant_scope field. Output is deterministic. The vector_search_index model macro registers a generated declaration that SearchIndexMigrator plans and applies explicitly, with the definition computed at plan time. :vector properties accept an optional quantization: (:scalar or :binary), validated at declaration and emitted only into the index definition; stored data and write paths are unchanged. First-query drift verification now reports a quantization mismatch between declaration and live index.
Parse::Agent.new(fields: { Klass => [:a, :b], default: [...] }) narrows the
class agent_fields ceiling for one agent, so MCP deployments in one process
can expose different subsets of the same models. A policy can never widen
past the ceiling, and a sub-agent intersects its parent's policy.
MetadataRegistry.field_allowlist now returns the effective set for the
agent whose tool is executing (Tools.invoke scopes the policy fiber-locally
via Parse::Agent::FieldPolicy), so projection, where/keys checks, pipelines,
Atlas Search fields, schema output, include projections, exports, describe,
and semantic_search chunk text and filter fields all narrow without
per-site changes. class_field_allowlist exposes the unnarrowed ceiling.
Closes a hidden-field inference gap: query_class, count_objects, and
export now refuse a caller where:/order: on a field outside the effective
allowlist (group_by, distinct, and aggregation already did). Fixes
assert_fields_in_allowlist!, which passed its refusal Hash into
AccessDenied's class-name slot, garbling the message and dropping kind.
Parse::Query looked up its table's model with Parse::Model.const_get(@table), which raises for Parse class names that are not valid Ruby constants ("contacts", "_User") and misses models whose parse_class differs from the Ruby class name. Most call sites rescued the error to nil, so pointer fields were silently treated as plain fields: mongo-direct pipelines addressed `owner` instead of `_p_owner` and pointer values were not converted to storage form. Query#table_model_class now resolves through Parse::Model.find_class, falling back to the constant lookup.
MCPRackApp.user_scoped builds agents from a Parse session token on every request. A missing, blank, invalid, expired, or revoked token is refused with 401 before any agent is built, with no master-key fallback. Tokens are re-checked against Parse Server per request by default (response cache bypassed; a failure also evicts the token from client.authorization), or through the identity cache with session_validation: :cached. tenant_from pins tenant_id server-side and fails closed. Long-lived listening streams are re-checked every session_revalidate_interval (default 60s) and closed, tearing down their subscriptions, once the session stops validating. MCPRackApp.master_analytics builds read-only (by default) master-key agents and refuses to construct without a principal_resolver, so operators sharing an endpoint have distinct session ownership; an unresolved operator gets 401. Both factories forward agent_options to Parse::Agent.new and refuse options that set identity or authority. Cancellation and elicitation replies now require the session's owner: a caller who knows or chose another principal's Mcp-Session-Id gets a silent 202 without cancelling its requests or answering its approvals. MCPSubscriptions::Manager reaps orphaned sessions (subscriptions held with no listening stream attached) after orphan_ttl (default 300s), checking attachment and removing subscriptions under one lock so a concurrent attach cannot race the reap. Documents both patterns, session ownership, revocation intervals, and orphan reaping in the MCP guide.
Parse::Retrieval::Profiles registers named, server-configured strategies for the semantic_search tool (k/max_k, hybrid, a reranker referenced by registered name, candidate and top_n counts, per-document text cap, timeout, failure mode, response budget), validated at registration. The tool gains an optional profile: argument; without it behavior is unchanged. Profiles never accept caller-supplied providers, endpoints, or credentials. BudgetedReranker cuts each document's text before it leaves the process, charges estimated rerank tokens to the tenant's SpendCap, bounds the call with a timeout, and on failure either keeps the retrieval order with an observable rerank_fallback flag or raises. Each call emits one sanitized parse.retrieval.search event. Parse::Retrieval::Benchmark scores profiles on labeled cases (recall@k, MRR, hit rate, latency, forbidden-id violations, estimated tokens), with a small fixture set.
Parse::Authorization#lookup_user_id called current_user without cache: false, so with the response cache enabled a revoked or expired token could re-resolve from a cached /users/me response after its identity entry was evicted or invalidated. The identity plane is now the only cache on the path, so its TTL and invalidation hooks bound revocation.
A signed-in user reads a field directly through Parse; a session-token agent narrowed with fields: neither returns it nor allows filtering on it; a broader analytics agent in the same process can expose it.
Parse::Agent.new(field_names: :server) and Aggregation#results(field_names: :server) return data fields in the exact names Parse returns or the model declares through field_map, with no snake_case conversion. Omission keeps every API's existing output; unsupported values raise. The agent mode is scoped per tool call (Parse::Agent::FieldNames, alongside FieldPolicy in Tools.invoke), sub-agents inherit it, and it never changes access restrictions, which resolve against canonical names before formatting. AggregationResult gains a :server mode: String keys kept verbatim, nested values untouched, colliding snake_case forms (totalPlays and total_plays) both preserved, and a snake_case method name matching several keys raises instead of guessing. Fixes call_method serialization: a returned Parse::Object was built from #attributes (the model's field-type map), so it emitted field types instead of values; it now serializes from #as_json. A returned AggregationResult was emitted as its inspect String; it is now a Hash in the agent's naming mode.
…ontracts Integration runs (rake test:integration) now check Parse Server /health before each file and after each failure. A server that is down stops the run, names the file it went down before or during, and fails the task instead of letting later files skip. A skip-log reporter (PSNEXT_SKIP_LOG) records every skip reason, and skips caused by an unreachable Parse Server, MongoDB, or Redis fail their file; Atlas-only, missing-key, and ffmpeg skips stay skips (PSNEXT_FAIL_ON_INFRA_SKIP=false reports without failing). The test stack's Redis cache adapter no longer crashes Parse Server when its client is closed: it opens the client lazily, waits for readiness with a bound, and treats any command failure as a cache miss. The db-1 guard is unchanged. Release workflow: a validate job requires the tag to match version.rb, a successful Gem Tests run on the exact commit, and a passing unit suite on that commit before the gem is pushed. Adds a scheduled/manual provider contract workflow (never on pull_request) and ActiveModel/ActiveSupport 7.1, 7.2, and 8.0 compatibility lanes (json < 3 there, since ActiveSupport before 8.1 passes quirks_mode, which json 3 rejects). Adds an MCP client smoke test over real HTTP (initialize through DELETE, including completion, logging, and a streamed tools/call) and pins the dotted-path null comparisons MongoDB 9.0 changed, with a guide note.
Parse::Query.format_field camel-cased every key, ignoring a model's explicit field: names, so where/order/keys/include on account_id or authId_sub compiled to accountId/authIdSub and matched nothing. While a query builds or compiles, it now publishes its model's explicit aliases (field_map entries that differ from the default formatting) in a fiber-local scope that format_field consults, for both Ruby and remote names. Subqueries scope to their own class. Mongo-direct entry points are wrapped with their exact signatures so client: stays introspectable.
…eams - call_method results project every embedded object through its own class's effective allowlist, not only the top-level object. - $inQuery/$notInQuery/$select/$dontSelect predicates and $select keys are checked against their target class's effective allowlist. - Deployment factories keep one rate limiter per principal (bounded LRU) so rate_limit accumulates across requests; an injected rate_limiter is honored. - Listening-stream heartbeat and revalidation threads start under the close lock and never after the stream closed. - A superseded listening stream (reconnect overlap) detaches nothing; the latest stream owns the session and DELETE still tears it down.
…vectors - Field policies keep exact server alias names (PublicText) verbatim. - A caller's k can no longer raise retrieval above rerank_candidates. - Listener register/unregister run under the manager lock, so an old stream's teardown cannot remove a replacement stream's delivery. - Vector search, hybrid search, index discovery, drift checks, and the index generator use a vector property's stored column (bodyEmbedding), not its Ruby name; the guide's index example is corrected. - Session DELETE passes the Origin policy, authenticates through the agent factory, and is refused for a session owned by another principal. - The response budget counts parent documents; under a profile it is mandatory. Failed searches emit a sanitized retrieval event.
…ling Security review: - atlas_text_search checks filter: against the allowlist and defaults to the readable fields; faceted queries and hybrid profile lexical branches are restricted the same way; explain_query checks where:. - fields: String keys resolve to the Parse class name (_User); nested agent scopes intersect instead of replacing. - Owner bindings of live sessions (listener or pending approval) are pinned against LRU eviction; user_scoped refuses master_atlas/allow_mutations. Correctness review: - The where:/order: guard resolves keys the way queries send them, so snake_case and _p_ keys are no longer wrongly refused. - The alias scope covers sum/average/min/max and group-by helpers, aliases only explicit field: names (formatter nil and built-ins unchanged), never an internal column, keeps wrapped private methods private, and keys its cache by map contents. - Profiles refuse max_k above the tool cap; IndexDefinition.diff matches the vector entry by path and reports malformed values as drift; benchmark token counts are per runner thread and MRR is cut off at k. - Release validation requires the latest Gem Tests run to have succeeded; core infrastructure skips are classified before legitimate-skip wording.
CodeQL flagged the reassignment of current as useless because it does not follow the lookup closure that reads it. Clearing and replacing the set in place is equivalent (the closure takes a copy) and leaves nothing to flag.
…review Project unsaved embedded objects, refuse text search with no readable fields, keep strict profile budgets, restrict hybrid lexical fields to readable ones, refuse new sessions with 503 when the owner registry is full, honor an explicit nil rate_limiter, require principal_resolver for master_analytics, and refuse rerank options without a reranker. Relabel the upgrade-affecting changes as breaking with migration notes.
…Parse Server 9.10.3 Refuse POSTs on another principal's session and subscriptions on sessions the caller did not establish, bound session bindings per principal, and charge initialize and subscribe to the principal limiter. Check $relatedTo keys against field policy, strip internal columns from aggregation rows, and refuse scoped Atlas text searches that would match on protected fields. Cap semantic_search query length, refuse the admin tier in user_scoped, wake the stream revalidator instead of killing it, and keep field policy scopes in inheritable fiber storage. Resolve protectedFields the way Parse Server does, pin the test stack to Parse Server 9.10.3 with LiveQuery CLP roles enabled, and add REST regression coverage. Run query blocks outside the field-alias scope, give fetch and cursor paths their own class scope, and cache alias and class lookups. Close the mutation-test gaps and correct the guides.
Parse Number columns hold floats, so :number properties and schema-built Number fields no longer truncate to integers. The cache middleware no longer references Redis error classes when Redis is not loaded, and Parse::Object.new accepts objects such as ActionController::Parameters through to_h.
The wrappers interpolated method names and a table expression into module_eval source. They now take a proc for the table lookup, with the same per-call cost.
…s, search, models Agent tools resolve where, order, and filter keys the same way they check them, project pass-through aggregation rows to the allowlist, refuse $$ROOT references and hidden $lookup join keys, bound semantic filters by agent_fields, and count chunk metadata in profile budgets. MCP sessions under user_scoped are keyed by the verified user id, a Parse Server outage no longer rejects or evicts a session, unknown sessions get 404, owner activity refreshes the eviction order, the shared master-key principal is not capped per principal, stream attach is charged before it claims an id, sessions holding subscriptions are pinned, and stream bodies only detach a listener they attached. Atlas and vector search refuse protected-field paths, filters, sorts, highlights, and facets for scoped callers on every entry point. Add a :number property type that keeps integral values as Integer, fix the increment helpers, make persisted? mean the record exists, restrict hash-like init input and the as: owner option, report the existing type in the redefinition warning, and widen the cache store error fallback.
Deep-copy permissions in ACL#initialize_copy so rollback! and change history see the real previous ACL, make delete resolve public, role, and user keys, have Permission mutators mark the ACL changed, stop apply_role double-prefixing, read the string "false" as a denial, and align eql?/hash with ==.
…ings Mass assignment can no longer change an object's id and assign_attributes applies the protected-key filter. Unsaved objects compare by identity, destroy keeps the id and sets destroyed?, reads no longer dirty records, ACL revocations after a save are sent, batch saves apply the owner policy, and save_all reports failures and visits every record. Typecasting keeps nils in arrays, tracks in-place hash edits, encodes nested dates, refuses values a type cannot represent instead of guessing, and fixes GeoPoint, phone, Bytes, and File handling. Transactions are sent atomically in one batch, chunk failures stay on their own requests, identical requests are no longer collapsed, 502/504 are errors, retries only replay routes the server deduplicates, and the create lock covers a full request. Queries resolve field names with one rule, merge same-field constraints, keep or_where and and constraints, honor limit(0), page with an objectId tiebreaker, window aggregates before grouping, and no longer mutate on first.
… crashes Load when Rails is defined without railties, move the query DSL into modules included in Symbol so Ruby and other libraries keep their own methods (Symbol#id and Symbol#size become pointer_id and array_size), create pluralized aliases only in the model's own namespace, carry targetClass and the right column types through schema migration, keep the model builder working on clashing column names, load Atlas Search where it is used, make webrick optional for enable_mcp!, always scrub the untrusted tool-result marker, and name Parse::User relative to the Parse namespace.
The trailing-slash pattern in api_relative_path backtracked polynomially on long runs of "/" (CodeQL rb/polynomial-redos).
Relation adds and removes are cleared after save and deduplicated, staged without loading the relation, and never queried with a null owner. Array collections mark clear() dirty, keep atomic add!/remove! results in step with the server, and validate item classes; belongs_to refuses wrong-class values and accepts objectId strings; relations with field: read the remote column; rollback! works on relations; nested partial fetches cover multi-word belongs_to; query has_many on an unsaved owner returns a chainable empty query; CollectionProxy#replace is added; and setters on a bare Pointer raise instead of writing a hidden copy. The transient-revalidation test now feeds each check its outcome rather than racing a short interval.
A before_save that changes nothing keeps the client's write, and one that does replies with the full write plus its changes so operators, undeclared fields, and signup fields survive. after_find always keeps the rows and can deny instead of crashing Parse Server or blanking results. Replay dedup needs a request id or nonce header, before_delete can deny, after_destroy fires on afterDelete, parse_query reads only where, the response log is redacted, unrouted functions error, unexpected exceptions return a JSON error, and error! carries a code. Atomic add!/add_unique!/remove! on a plain array adopt the array Parse Server returns, correcting a stale local copy.
Logins, MFA verification, password reset, and signup are never sent with the master key, so Parse Server no longer skips MFA and overwrites the enrolled secret. An explicit session token is never replaced by the ambient or bound one, with_session(nil) runs anonymously (including on mongo-direct and parallel fetches), and logout, password changes, user deletion, session destroys, and role saves invalidate cached identities and role closures. The response cache never stores users/me, keys include the app id and credential, writes retire every variant of a resource, and MFA login errors map to MFA exceptions. Mongo-direct reads are scoped to a client's bound session or the public scope instead of master, refuse filters, sorts, and joins on protected fields, enforce readUserFields and pointer permissions before paging, strip included classes' protected fields and the default _User email, fetch CLPs with the master key, and decode includes, files, and dotted keys like REST. Session tokens are redacted from inspect output.
…am slots, signed replays, partial-fetch ACL An atomic add! on an array with unsaved edits applies the op locally instead of adopting the server array, so the edits are still saved. Reassigning a mutable property after an in-place edit keeps the edit. Listening streams count and uncount under the close lock, so a close racing startup no longer leaks a capacity slot. Signed webhook deliveries are deduplicated on their signature, so altering the unsigned nonce no longer permits a replay. Reading acl on a partially fetched object fetches the stored ACL instead of returning nil, so code that edits it cannot replace the record's real ACL; pointers still never fetch for it. Also initialize obj before the early return in the afterDelete chain (CodeQL) and restore unset webhook env vars in the replay test teardown.
…edential and class version send_request and request(req) apply a Parse::Request's own options, so a request with a session token and use_master_key: false no longer sends the master key. Keyspaced cache keys now carry the credential digest the legacy layout already used. Every write retires cached query results for its class (updates, deletes, creates, and batch sub-requests) through a per-class version in both layouts, bumped again after the response, and a GET re-sent as a POST method override no longer counts as a write.
…ected fields A user keeps their own protected _User fields only when no caller stage can rewrite _id, and joined _User rows are decided on their stored _id before caller stages run. Scoped pipelines refuse $$ROOT and $$CURRENT copies, $$ROOT paths into protected fields, and $getField reads of protected or computed names, and each $facet branch starts by stripping protected fields. Joins into another class enforce its readUserFields and pointerFields ownership, strip its protected fields at the head of the join, refuse on a denied CLP or a $graphLookup into a protected class, and check sub-pipeline references against the joined class.
…s, accept nonce-signed webhooks An update returns only updatedAt, so a partially fetched record keeps its fetched-keys tracking after a save and adds the saved fields to it; the ACL the partial fetch left out is still fetched on read, instead of reading nil and letting an added grant replace existing ones. Atomic role.users and role.roles add!/remove! invalidate cached role closures like a save does. A webhook signature may cover the delivery nonce (ts.nonce.body), so identical bodies sent in the same second each get their own signature; the ts.body form is still accepted.
… restore revoked data A cold-cache GET read its resource and class versions only after the response, so a read that overlapped an ACL write adopted the write's new versions and stored its older, private body under them. Reads now create or read their versions before dispatch (set-if-absent where the store supports it), store only under those versions, and skip the store when a version changed in flight, in both cache layouts.
…, translate nested subqueries Scoped mongo-direct pipelines on classes with protected fields accept a $getField, $setField, or $unsetField name only as a plain string or a $literal string, so a name read from the document cannot alias a protected field. Direct reads, counts, distinct, the auto-route, and the Atlas bridge respect Parse.without_master_key and resolve to the session or public scope as REST does. Subqueries nested in $and, $or, or $nor compile to their own lookups on the direct path, and positions that cannot be translated, plus nested subqueries in query-derived aggregations, fail closed instead of reaching MongoDB raw.
…ion aborts Real Parse Server transactions need a replica set, so the test MongoDB now starts as a single-node replica set with a generated key file and a healthcheck that initiates it, and every test URI uses directConnection=true. Parse Server runs a transaction's requests concurrently on one session, which MongoDB intermittently rejects; Parse Server aborts and answers a bare 500, so the transaction retry loop now resends on that as it does on a 251 conflict. The deleted-object integration tests now check the destroy contract (id kept, destroyed?, save refused, fetch raises), and the mixed transaction test no longer points at an object created in the same transaction.
Parse Server answers every aborted transaction with "Internal server error", so once retries run out the transaction raises a Parse::Error that names the replica-set requirement and points to Array#save for a non-atomic batch, keeping the original error as its cause.
…pts in A bare 500 from Parse Server does not prove a transaction was not applied (a failed commit looks the same), and a gateway 502/503/504 can arrive after it committed. The transaction retry now resends only on a structured 251 conflict code, never on message text, and resends on a 500 only with transaction(retry_server_errors: true). The error raised after a 500 says the outcome is unknown, names the replica-set requirement, and points to Array#save. ServiceUnavailableError now carries its response and HTTP status.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Release 5.8.0: configurable MCP access and better retrieval, Parse Server 9.10.3 alignment, and a hardening pass over inherited model, query, batch, ACL, and host-integration code found by an audit of the original parse-stack internals.
Per-agent access policies
Parse::Agent.new(fields: { Customer => %i[display_name timezone] })narrows a class'sagent_fieldsfor one agent. A policy never widens pastagent_fields; sub-agents and nested agents intersect, and threads or fibers a tool starts inherit it.semantic_searchchunk text, reranker input, and filter fields.query_class,count_objects,export_data,explain_query, text and faceted search, subqueries ($inQuery,$select,$relatedTo), hybrid profiles, and aggregation ($$ROOTreferences,$lookupjoin keys, and pass-through pipelines). Keys are checked and queried under one name rule.Deployment patterns
MCPRackApp.user_scoped(signed-in users, sessions owned by the verified user id, no master-key fallback, admin tier refused) andMCPRackApp.master_analytics(requires an operatorprincipal_resolver).initialize, subscribe, and stream attach, pinned live and subscribed sessions, and revalidation that tolerates a Parse Server outage without dropping streams or evicting tokens.Retrieval
semantic_search(hybrid, reranking by registered name, budgets that count chunk metadata, observable fallback), a sanitizedparse.retrieval.searchevent, andParse::Retrieval::Benchmark.Index tooling and output naming
Parse::VectorSearch::IndexDefinitiongenerates Atlas vector index definitions with preview, diff,quantization:, and drift detection.field_names: :serverreturns data under exact server field names.Parse Server 9.10.3 alignment and protected fields
CLPScope.protected_fields_forresolvesprotectedFieldsas Parse Server does.Audit fixes to inherited code
persisted?,destroy, equality, and cache keys follow ActiveRecord; reads no longer dirty records.:numbertype keeps integers and decimals; in-place hash edits, nils in arrays, nested dates, GeoPoint keywords, and US phone numbers are stored as assigned; values a type cannot represent raise instead of being guessed.rollback!,delete(:public)/delete(role), and owner policy on batch saves.or_whereandandkeep constraints,limit(0)returns nothing, paging has an objectId tiebreaker, and aggregate helpers window before grouping.Symbol#id/Symbol#size, pluralized aliases stay in the model's namespace, schema migration and the model builder handle relations and clashing columns, and Atlas loads where it is used.with_session(nil)is anonymous, and logouts, password changes, deletions, and role saves invalidate cached identities and roles.users/meis never cached, and writes retire every cached variant and every cached query over the class.readUserFieldsand pointer permissions before paging, refuse filters, sorts, copies, and joins that touch protected fields, apply joined classes' ownership rules, and decode rows like REST.before_savekeeps the client's write,after_findcan no longer crash Parse Server or blank rows,before_deletecan deny, and signed deliveries cannot be replayed.aclafter a partial fetch loads the stored ACL.Fixes that also affect earlier releases
field:name as declared;Parse::Queryresolves models throughparse_class; vector search uses a vector property's stored column; the cache middleware no longer raisesNameErrorwithout Redis.Release tooling
Behavior changes for upgrading apps
CHANGELOG.md lists each with migration guidance. The ones most likely to need code changes:
agent_fields; MCP requests on another principal's session get 403 and unknown sessions 404.fields:when the caller has protected fields.limit(0)returns nothing, and aggregate helpers apply order, skip, and limit before grouping.Symbol#idandSymbol#sizeare gone (:field.pointer_id,:field.array_size).Parse::Properties::TypecastError; unsaved objects compare by identity;destroykeeps the id; a pointer to an unsaved object fails the save; rows without anACLkey get a nilacl;as:accepts only server-side owners.Array#save/destroyraise on arrays with no Parse objects.with_session(nil)runs anonymously; signup no longer sends the master key.before_savewebhooks keep the client's write;after_findcan no longer rewrite rows; unregistered functions error.add!/remove!return booleans, associations refuse wrong-class values, and pointer setters raise.Testing
bundle exec rake test:unitpasses on each commit.destroyclearing the id, and the transaction tests passed only because the SDK never sent the transaction flag. Both are updated; the test MongoDB now runs as a single-node replica set, and the transaction file passes repeatedly.Upgrade notes
Parse::Object.transactionnow runs as a real Parse Server transaction, which needs MongoDB as a replica set (or mongos). On a standalone server it fails rather than running without atomicity; the error names the replica-set requirement and points toArray#savefor a non-atomic batch.transaction(retry_server_errors: true)opts into resending on a 500 for writes that are safe to repeat. Gateway 502/503/504 responses are never resent.