Changelog
Released 2026-09-24.
Safer purchase and gift recovery, fewer leaderboard requests, client purchase prompts and countdowns, roblox-ts support, and an optional leaderstats add-on.
Behaviour changes
Section titled “Behaviour changes”-
Unresolved gifts now block another paid gift or ordinary purchase prompt for the same product, including after a rejoin or the old two-minute window. Gift and purchase dialogs share a guard; cancellation saves the removal of its unbound gift record before releasing the guard. Failed saves block new prompts and are retried by the next prompt attempt. Unresolved gift aims no longer expire, and full aim storage refuses new gifts while preserving pending destinations.
GiftIntentTTLstill controls archival, not cancellation. An earlier ordinary purchase awaiting its receipt remains a documented correlation limitation. -
Completed receipt and gift IDs are retained for 30 days as timestamped records. Expired records are removed during profile loads and receipt operations, without a background sweep. New grants defer with
RECEIPT_HISTORY_FULLbefore completion history exceedsMaxReceiptHistoryBytes(default 1 MiB); recent IDs are not evicted to make room. An unresolved receipt retried after its record expires can grant again.PurchaseIdTTLandMaxProcessedPurchaseIdsdo not control this fixed window. Legacy timestamped records keep their age; compact string IDs start their window when safely migrated on a load/write. If timestamp metadata would exceed the profile-size guard, unknown-age IDs stay protected. No automatic archive is added, and previously evicted IDs cannot be reconstructed. Paid gift receipts save their exact recipient and reserve sender history capacity before delivery, so a late retry cannot lose its destination when an intent expires or a later gift replaces it. Pending receipt bindings do not expire with completed history. The first online delivery adds a buyer save; ordinary self-purchases are unchanged. The new reservedReceiptReservationsfield changes the schema hash: deploy server and client together, and upgrade every server handling the same profiles. -
Big numeric strings now reject inputs over 256 bytes, including whitespace, before parsing. Zero detection and telemetry profile-key redaction use bounded scans. Telemetry omits strings over 4096 bytes before redaction instead of processing or partially exposing oversized text. Number inputs and stored Big values are unchanged.
-
Added roblox-ts declarations and npm packaging for the shared Luau runtime, with schema inference, server/client APIs, diagnostics, and separate optional React, Vide, Fusion, telemetry, and leaderstats packages. CI checks declarations and runs compiled TypeScript consumers against the Luau implementation. npm publication remains gated on the initial registry and trusted-publisher setup.
-
Added client
Data.PromptPurchase(name)for declared products and passes. The server checks eligibility and opens the local player’s prompt through its existing purchase API. Duplicate pending requests and open prompts are refused. Request failures carryScribe.RequestFailed; a timeout may occur after the prompt opens, and Scribe does not retry automatically. Receipts, grants, and prompt metrics remain server-side. Includes Luau types. -
Client timed-field
Active()now reports the actual timer and remaining seconds from the replicated deadline.ExtendTimedreplicates deadline changes by default;falsesuppresses that update. Value observers do not tick with the clock. Server-only timers, cooldowns, and internal claims remain private. The new unsaved_ScribeSession.Timedfield changes the schema hash: deploy server and client together. -
Added
RetiredProductsto preserve historical receipt/gift handlers without offering new sales. Existing paid gift credits remain redeemable; new gifts without a credit refuse withScribe.GiftReason.ProductRetired. -
Added the optional
ScribeLeaderstatsaddon. Map display names to data paths to keep Roblox’s player list updated, with cleanup on leave or stop. -
Added
node.Child(key)for exact child access, including keys that collide with method names, and named arithmetic/comparison methods on Big values as Luau conveniences. -
Added
Scribe.GetLeaderboardSnapshot()for cached status across active bundles, identified by bundle ID and board name, without DataStore reads.ScribeTelemetrymonitors it by default every 30 seconds and sends board degradation/recovery reports through the newLeaderboardsroute. Monitoring is bounded and configurable; disappearing boards do not imply recovery. Summaries distinguish profile health from board health and add leaderboard activity, per-board backlogs, receipt-history refusals, log-sink failures and join/leave timing percentiles. Default warnings now include leaderboard failures/budget pressure, slow leaving callbacks and receipt retries. Added leaderboard and diagnostic warning previews. Older Scribe versions without the snapshot API remain supported, with board information marked unavailable.
-
Client.Stopnow discards queued product-info work, prevents further retries, and releases waiting info and price reads with nil. Late Marketplace responses are ignored. -
Invalid recipient IDs in stored gift intents or aims now hold the receipt for repair with
GIFT_INVALID_RECIPIENT, before saving a new receipt destination or attempting delivery. Based on the validation contributed in #16. -
Fixed the public constructor ignoring
ServerStoreand reporting it as an unknown option. Store templates now infer field and accessor types on server and client, including typed template modules; client types hideServerOnlyfields. Store roots namedGet,Changed, or another accessor method are now reachable. The exported API types accept an optional second store-template type. If you already declare a store, deploy server and client together: the previously ignored fields now form part of the compiled schema. -
Receipts for buyers joining or loading on this server now wait for Ready before granting, bounded by
LoadTimeout(default 120 seconds, minimum 60). Departure, failed loads, stop, and timeout leave the receipt pending; the receipt timeout does not cancel profile loading. External purchase channels cannot consume unrelated product-keyed gift intents or aims. Saved destinations for an exact purchase ID still settle, and receipts without a channel retain their previous routing behavior. External-sales setup and restrictions are documented. -
Fixed exchange verdict IDs exceeding Roblox’s 50-character key limit. New exchanges use bounded GUIDs; intact legacy overlong escrows recover through compatible verdict keys. Existing valid keys and escrow IDs stay unchanged. Operator settlement verifies the pair and basket before applying a verdict. Deploy across all servers and preserve stuck escrow.
-
Leaderboards retain transiently failed scores with capped backoff rather than abandoning them after three failures. Added server
GetLeaderboardStatus(name)for independent board health, cached-result age and pending/rejected writes. Generated store names are validated. -
Added
LbWritesSkippeddiagnostics and corrected the capacity simulation’s ordered-read budget queries and separate standard/ordered experience allowances, with sustained traffic regressions for small servers.
Changed
Section titled “Changed”-
Global leaderboards now keep the latest pending score and skip encoded scores that match the last successful write, including unchanged peak scores, rounded scores, and departure writes. Added per-board
WriteInterval(default 30 seconds, minimum 1) to limit changed-score writes per player/store during play. Departures bypass this interval for pending changed scores. Includes Luau declarations. -
Global leaderboard background reads and writes automatically share budget pacing across bundles on the same server, leaving headroom instead of relying on
BudgetPolicy = "Defer". Previous-period reads are checked individually, and a deferred refresh remains due.Deferretains faster draining of eligible backlogs when allowance permits. Shutdown drains and explicit debug refreshes still attempt their work immediately. -
Log sinks filter before dispatch and use one worker with a bounded queue per registration.
AddLogSink(fn, { Level, MaxQueued })selects severity and capacity; default severity followsLogLevel. AddedLogRingLevel, defaulting to Warn in production and Debug in Studio. Queue overflow preserves higher-severity entries where possible and countsLogSinkDropped. -
Slow joins and leaving callbacks now report while still waiting, with per-player loading phases and duration measurements. Session-lock timing and final-save ordering are unchanged.
-
RBXM, Wally, and npm releases omit long API documentation blocks and retain concise source comments, types, and directives. Repository sources keep the full documentation. Source-line maps accompany the models and packages for debugging.
-
Updated Luau LSP to 1.70.0 with matching Roblox type definitions in CI. Type helpers now satisfy the newer checker without weakening the public API’s type checks.
Documentation
Section titled “Documentation”-
Added roblox-ts and leaderstats guides covering installation, typed templates, and addon setup. npm publishing requirements are recorded in CONTRIBUTING.md.
-
Updated the gifting and monetization guides with receipt retention, retired products, external sales, unresolved gift handling, and the remaining purchase-correlation limitation.
-
Documented release staging and source-line maps, including how packaged error lines map back to the original source. The API documentation stays in the repository and on the site.
Released 2026-09-17.
Daily and weekly leaderboards, a board scoped to the server you are on, a resumable Data.Erase,
an optional Discord telemetry add-on, and two community reports addressed: a naming pass over the
strings the public API returns, and a typing fix so a caller’s narrower type is accepted wherever
an accessor takes a value in.
Behaviour changes
Section titled “Behaviour changes”-
The Studio debug hooks are off unless the new
StudioHook = trueoption is set. They used to attach in every Studio session, and each keeps rings of two thousand ops, logs and sends, a metrics ring and a mirror of every player’s data from the moment the bundle starts, whether or not the plugin is open. A play-test without the plugin no longer pays for that. The option is inert outside Studio, so it can stay on in a published place; the plugin’s “not detected” message says to set it. -
The reserved
_Scriberoot gained aBoardschild, which changes the schema hash both realms compare at handshake. Deploy your server and client together, as with any template change, even if you declare no periodic board. -
Data.Eraseremoves leaderboard entries before the profile rather than after. A periodic board is swept over the periods the profile records, so a sweep that fails has to leave the profile for the retry. A failed erase now returns(false, reason)with the profile intact, where it used to leave the profile gone and a board entry behind. The sweep can take minutes for a long periodic history, so the erase now writes a marker into the profile first: a join that loads a marked profile on any server is refused with the new reasonerasingand theErasingMessagekick text, and a join on the erasing server waits at mostEraseJoinTimeoutseconds before the same refusal. The marker records which boards are done and where an unfinished one stopped, saved every twenty-five removals and on any failure, so a retry after a failure or a shutdown resumes there. UnderBudgetPolicy = "Defer"each removal waits for theOrderedRemovebudget rather than a fixed pause. The marker is taken in one compare-and-set with the read that decides it and carries a five-minute lease, renewed at every checkpoint: a secondErasewhile it is live is refused, a failed attempt drops it, a lapsed one is taken over with its progress, and every checkpoint and the final removal verify it, so an attempt whose erase another server finished, or whose profile a rejoin recreated meanwhile, stops instead of deleting the fresh data. Every key minted by this build carries a generation id in its store metadata (earlier keys read as having none and keep working), and the offline compare-and-set fingerprints it, so a key removed and recreated between a read and its write no longer passes; the same compare-and-set can mint a key, which is how an erase of a user with no profile places its marker. Renewals that keep failing stop the sweep. The erase still reads the key once more before removing it and refuses if a session is live. -
Scribe.PurchaseReasonandScribe.GiftReasonanswer their two paid random refusals in sentences rather than codes. Both are sentence unions, whose members read as they are, and"paid-random-restricted"and"policy-pending"broke that promise. They now read “paid random items are not available for this account” and “cannot check account settings right now; try again in a moment”.Data.PromptPurchasestill refuses with the shorterScribe.ProductStatecodes, because a shop branches on those rather than showing them. Reported by a community member.
-
Each GitHub release attaches
ScribeTelemetry-Addon.rbxmandScribeUIAdapters-Addon.rbxmbesideScribe.rbxm, labelled as add-ons on the release page, so the optional pieces can be inserted in Studio without a checkout.wally.tomlstill publishessrcalone. -
EraseJoinTimeoutandErasingMessageoptions, theerasinglifecycle reason withScribe.Reason.Erasing, and thePROFILE_ERASE_RESUMEDandPROFILE_ERASE_PROGRESS_FAILlog codes, all for the resumable erase described under behaviour changes. -
Scribe.GetProfileKeyPrefix(), theProfileKeyPrefixthe running server bundle was started with.ScribeTelemetryredacts every context field namedKeyor ending inKey, and the grouping subject built from one, whatever they hold, since a profile key may carry any prefix or none; in message text it redacts the configured prefix, asked of Scribe on every entry, and any word ending in an underscore. Scribe’s ownPROFILE_OVERWRITTENmessage no longer names the key, which its context carries. A failed request’s error text reachesGetStats().Destinations[name].LastErrorwith any URL replaced and cut on a character boundary. When a limit every webhook shares is full, the lowest-priority item queued on any webhook goes first, oldest among equals, so a preview waiting on one webhook makes room for a real report on another rather than the report’s own predecessor, and the evictions are planned first, so a report the limits could not admit even after every evictable item went is refused with the queue untouched. An embed whose fields fill the 6000 characters gets an empty description, not a lone ellipsis over the limit. -
Scribe.RegisterAddon(name, actions)registers actions an add-on offers to the Scribe Studio plugin, each with aRunand an optionalWrites = truethat puts it behind the plugin’s write toggle. The Studio hook answersListAddonsandAddonAction, running an action under the plugin’s own write attribution, and the plugin’s Diagnostics tab gains a telemetry preview row.ScribeTelemetryregistersStatusandPreviewon start and removes them onStop. -
ScribeTelemetry:telemetry:Preview(destination, kind?)sends synthetic examples of every report design to one destination, from the real builders and through the normal queue, titledPREVIEW:with a field saying the data is synthetic, never mentioning a role, and without touching the summary baseline, performance streaks, groups or mention cooldowns.kindisAll, a group such asHealth, or one scenario such asOutage; previews count inGetStats().Previews. A preview queues below every real report, so a full queue refuses it rather than displacing an alert, and a real report displaces a queued preview first. A report that arrives while a preview is being delivered is a real report. -
ScribeTelemetry: every embed from a live server, one with a place id and aJobId, carries aServerfield whose link launches the game into that server; health reports and summaries carry the place id and the Scribe version, and the footer is down to the report id, the place version, the environment and the time in UTC. The report id now starts with the server’s first eight characters. -
Daily and weekly leaderboards.
Period = "Daily"or"Weekly"on a board writes to a store named for the period (LB_WinsDaily_d20708), so a rollover is a new name and nothing is reset or deleted. What goes on the board is what the player did this period, not the stat:Mode = "Gain"(the default) writes the increase since the period began, floored at 0, andMode = "Peak"the highest value seen. The baseline lives in the profile, so it survives leave and rejoin and resets when a player returns after a rollover.Data.GetLeaderboard(name, limit, -1)reads the previous period on the server.PeriodReset = { UtcOffset, WeekStart }sets the clock, one per bundle. Old period stores are left in place; the guide has the storage math and the RTBF template for lifetime boards. A board changed betweenDailyandWeeklykeeps the old range on record, so an erase still sweeps what the profile wrote under the old kind, and the previous period is read at most twice per rollover, counting only reads that succeeded. One refresh of a board runs at a time and at most one waits: a scheduled slot that finds the last refresh still running is dropped until the next interval, explicitRefreshNowcallers share one waiting refresh, nothing waiting starts afterStop, and a refresh in flight atStopgoes no further after its current read, so two refreshes never race over the caches or the previous-period reads and a slow store cannot build a queue. -
Scope = "Server"on a board ranks the players on this server in memory, with no store behind it and no request spent on it: five seconds between refreshes by default, one at the floor, exempt from the read guard, the migration reserve and theBudgetPolicygate. It composes withPeriod,ReplicateandGetMyRank, and it is what the guide used to send toScribe.Shared. -
Data.GetLeaderboardRefreshIn(name): seconds until a board next reads its store,0while a due refresh waits on budget,nilbefore the first cycle is scheduled or for an unknown name.Data.GetLeaderboardResetIn(name): seconds until a periodic board’s period ends on thePeriodResetclock, always above zero,nilfor a board with no period. -
Three client timers,
InitApplyDuration,DiffApplyDurationandSharedApplyDuration, one sample per frame applied: elapsed time from decode to the end of the dispatch, with inlineChangedandObservelisteners counted in full and anOnSharedChangedhandler to its first yield. Readable fromScribe.GetPercentiles()in aLocalScript; the client debug hook answersGetMetricslike the server’s. And a server counter,BytesOut: the bytes the transport accepted, fragment headers included, summed over every recipient, so delivery cost is one number rather thanBytesOutPerSendtimes a guess at the audience. -
Every distribution
Scribe.GetPercentiles()reports carriesSamples,AgeandWindowbeside its percentiles: how many samples the window holds, seconds since the newest, and the seconds the ring spans from oldest to newest, so a p99 over a burst and one over an afternoon no longer read the same. -
BytesOutsplit by frame kind, one counter per kind the wire has (BytesOutDiff,BytesOutInit,BytesOutSharedDiffand the rest), plusBytesOutResyncfor the handshake bytes spent repairing a client rather than joining one. -
Per-command metrics, named after the command:
CommandDuration:<name>,CommandActive:<name>andCommandErrors:<name>on the server, aggregated across bundles, andRequestTimeouts:<name>(with aRequestTimeoutstotal) on the client, where a timeout is the one thing the server cannot see. Client request names are unbounded, so the named timeout series stop at 32 and a later name counts asRequestTimeouts:Other. Both Studio debug hooks carryPercentilesbeside the counters, in theGetMetricsreply and in every metrics tick, and the client hook now streams metrics ticks at all (client hook protocol 2). The Studio plugin’s Diagnostics panel shows the distributions with their windows, the per-command table and bytes by frame kind, and opens in the client view with that client’s own timers. -
Frozen tables for the six string unions that had none:
Scribe.OpKind,Scribe.LogLevel,Scribe.LogCategory,Scribe.Status,Scribe.SessionStateandScribe.Visibility, so a caller can branch on a named constant instead of pasting a string.Scribe.LifecycleReasonis added as the matching name forScribe.Reason, which predates the convention; both stay. Requested by community members. -
Data.UpdateOffline(userId, fn, { Validate = true })checks the callback’s change against the template before committing, as a live write would, and refuses with the offending path in the reason and the findings ({ Path, Kind, Detail }) as a third return. It judges the change rather than the profile: a field that was already wrong passes through while the callback leaves it alone, and making it worse, growing an over-cap container, or a profile with too many violations to compare refuses; it also refuses a change under the reserved_Scriberoot, a write to a derived or Session field, and a profile whose schema version is not this build’s. Nothing is clamped, a refusal writes nothing and does not count against service health, and raw stays the default, which is what Scribe’s own offline paths use. Proposed by a community member. -
Data.Batch(fn)on the client. Local writes inside it coalesce into oneChangedpass per container, asServer.Batchdoes on the server, so a UI that updates several fields at once fires its listeners once. Frames from the server were already applied as one batch each; this covers the optimistic writes the client makes itself. Nothing on the wire changes. Asked for by a community member. -
ScribeTelemetry, an optional server add-on inaddons/telemetry/, posts Scribe’s health transitions, everyErrorandFatalentry, an allowlist of warnings, performance rules and periodic summaries to Discord webhooks. Destinations are named, each category routes to one, several or none with a default for the rest, repeats fold into follow-ups, a role can be mentioned on a cooldown, player identifiers and webhook URLs are redacted by default, and delivery is bounded: queues per webhook and overall, Discord’s rate limits and backoff honoured per webhook, a dead webhook dropped rather than retried. The handle hasTest,Flush,GetStatsandStop, and a handle disabled in Studio answersGetStatswith the same zeroed counters as a running one. Typed end to end under the new solver:Options,Handle,Statsand theScribeModuleslice it reads are exported, and the type fixtures cover them. Built on the public diagnostics API alone, disabled in Studio unless allowed, and not part of the package: copy the folder in.Urlis any http or https endpoint that takes Discord’s webhook JSON: Discord itself, a proxy in front of it (Discord refuses requests from Roblox servers, and a 403 fromdiscord.comsays so inGetStats), or a service of your own. The guide records the live checks that remain unperformed.
-
Data.Exchange.Discardtreated a verdict read that failed as a verdict that did not exist, and accepted a Claimed record whose take already named incoming value. In a one-way transfer the receiver escrows nothing, so once a Commit had released the giver’s escrow, a discard during a store outage deleted the receiver’s take and the item was gone from both profiles with nothing left to say so. A failed read now refuses the discard, and a record with a take cannot be unclaimed. -
Data.Exchange.Settle(id, "Commit")proposed the verdict from the in-memory records without proving either was saved, so a crash after the Commit could lose the recipient’s only copy. It now forces a save of both profiles first, the same proofAttemptmakes, and refuses with a reason naming the side whose save did not complete. -
A
Mode = "NoSave"bundle never saved its own session but still letUpdateOffline,RestoreVersion,EraseandSendMessagewrite the real store, and a gift receipt for an offline recipient queued the gift in that recipient’s real mailbox. All five now refuse under NoSave and logNOSAVE_WRITE_REFUSED; the gift receipt answersNotProcessedYet, so a live server delivers it. -
A
ResetDatawipe refused for an exchange in flight still stamped the profile at the current version, so every pending migration was skipped, and the stamp kept them from running on any later join. Dynamic seeds, the migration shadow and theisNewflagOnPlayerInitreceives keyed on the option the same way, so a starter kit was granted a second time. Every step now keys on whether the wipe happened. -
A gamepass ownership check that answered “not owned” after a purchase had been confirmed cleared the cache, so a player who bought a pass during the join scan, or during a slow
OwnsAsync, lost it until the next session. Ownership only gains for a session, and both paths now fold their answer into the cache instead of overwriting it. -
Log messages over 400 bytes were cut by byte, which could split a multi-byte character and leave a string no sink could JSON-encode; the telemetry add-on then dropped the alert. The cut now backs off to a character boundary.
-
A custom transport whose
Sendwrites Shared data from inside the call could double or lose an op. A write to another player made while a joiner’s handshake was going out reached the joiner twice, once in that player’sSharedInitand again in the nextSharedDiff; a write to the new player made while their ownSharedInitwas going out was cleared with the queue and reached nobody. Each other player’s snapshot is now taken and their queue detached in one step before any send, with the detached ops going to everyone but the joiner, and a new player’s queue is cleared before the broadcast rather than after it, so a write made during any of those sends reaches every client once, with the next flush. -
A server store op queued when a joiner’s Init was taken reached that client twice: the snapshot already held it, and the frame’s store flush then broadcast it to the now-ready joiner as well, so an
Insertlanded as two elements and aRemove, which is by index, took a different element out. The ops queued before the snapshot are now sent to the other clients after it is taken, which also covers a custom transport whoseSendruns game code that writes the store while the Init is prepared: however many such writes there are, each lands past the boundary. -
PROFILE_SCHEMA_VIOLATIONreports an overlong dictionary key at the key’s own path (Resources.Obsidian) rather than at the dictionary’s, so two long keys are two findings and a log line names the key it means. -
A stopped bundle no longer writes the process-wide DataStore budget reserves. A session ending after
Data.Stop(), its final save landing late, tore down through the reserve sync and reinstalled twoGetAsyncreads for a bundle that was gone, which starved whatever paced on the budget next. Only a process that stops one bundle and keeps running could see it. -
A caller’s narrower type is accepted where an accessor takes a value in.
Insert,RemoveValue,Find,Has,SetandUpdatetyped their input as the element’s mutable shape, and Luau treats a mutable property as invariant, so{ Action: "invite" | "deny" }was refused by an element typedAction: stringas “not exactly string”. Those parameters are read-only now, which is what they always were: Scribe reads the table it is handed and never writes back into it, and a read-only property accepts any subtype. The transform is deep, so a nested narrower field passes too. A map (DictOf) handed toSetis the one place this does not reach: the type runtime cannot build a read-only indexer, and an indexer stays invariant. Reported by a community member against aScribe.Enumfield, whose members are enforced at runtime but type asstring. -
A board refresh in flight at a
Data.Erase, whether parked onGetSortedAsyncor on a name lookup, could put the erased user back in the cached board when it returned. A refresh now drops anyone erased since it began before it publishes. -
A
RequestOncereplay could carry values other than the original reply’s. The idempotency record kept the handler’s return tables by reference, so a handler that went on mutating a table it had returned changed what a retry received. The record holds the encoded reply and a replay re-sends it under its own correlation, so a table the wire could not carry, a cyclic one included, replays as the samereply-encode-failedit answered the first time. -
A snapshot that a client’s Hello retry delivered after a refused first attempt no longer triggers a second, redundant snapshot from the repair loop: the request the failure left behind is met by the one that landed.
-
FlushDurationrecords a frame that flushed only the server store, or only re-sent a snapshot to a client being repaired. It was gated on player entries having flushed, so a game driving most of its traffic throughData.ServerStoresaw those frames missing from the distribution, and a resync spike never appeared in it at all. -
The signal runner thread kept the first fire’s arguments alive for the session. It was started with them as its call arguments, which stay on its stack for its lifetime;
OnSharedChangedfires a full clone of a player’s shared data, so the pinned object could be large. The runner is primed to its first yield before it carries a payload, the fix GoodSignal itself shipped.
Changed
Section titled “Changed”-
An idle bundle does no periodic work. The leaderboard write pacer parks while its queue is empty and wakes on the next score; no leaderboard worker starts without a board, and a bundle with only server boards starts the refresh loop alone. The timed sweep and the exchange sweep visit only the entries holding a timer or an open exchange, and park when there are none. The replication flush visits only the entries with something queued or a repair due. The pass-purchase listener connects only when a pass is declared, and a bundle without passes settles ownership on the loader’s thread instead of a spawned one. A departed player leaves every tracking set at once, Stop clears them, neither a sweep that was yielding nor a send that failed re-adds a torn-down entry or marks a stopped bundle, and a frame with nothing to flush returns before it is timed. The Studio hook’s leaderboard reply carries
Workers, so the plugin’s Boards panel can say whether the pacer is parked. Autosaves, session-lock maintenance and load-time recovery are unchanged. Under the test harness an idle bundle with one player made 44 timed waits in ten frames before and none after. -
The UI adapters moved from
adapters/toaddons/ui/and are named for the file you copy in:ScribeVide.luau,ScribeReact.luauandScribeFusion.luau.addons/now holds every official add-on, withaddons/README.mdas the index;wally.tomlstill publishessrcalone. Copy-in files, so nothing changes for a game until it copies again. -
The Fusion adapter registers its disconnect in the 0.3 scope it is given, so
doCleanup(scope)stops the Scribe listener along with theValue, which is what a scope is for. It used to build theValuein the scope and leave the listener to a manual call. The disconnect is still returned, and a second call is a no-op, so disconnecting early stays safe. A copy-in file, so it takes effect when you copy it again. Proposed by a community member.
Documentation
Section titled “Documentation”-
A new CONTRIBUTING.md records how a public string is named: PascalCase for a state or category, kebab-case for a code a caller branches on, and a lower-case sentence for the answer a call gives when it did not proceed. It also states that a union never mixes the three, which is what the fix above restores.
-
The UI adapter snippets said
require(path.to.Vide)(vide), which reads as though you require the framework and pass it to itself. The placeholder is nowpath.to.ScribeVide, naming the adapter file you copied in, which is what the argument was always for. -
CONTRIBUTING.md’s naming section now states the principle the casing rules follow from, a stable identifier a caller branches on against text a game shows, and stops calling every code a refusal and every sentence player-facing. The
PurchaseandPromptGiftreason tables say which members are for the player and which mean the call itself is wrong. -
The cost guide indexed
.StatusonScribe.GetStatus(), which returns the status string itself. The visibility guide now states what aSharedroot costs: one frame to every other player per frame in which it changed, since writes coalesce, and every other player’s roots to each joiner.
Released 2026-09-04.
Paid random items, gated on every purchase path, and one query that tells a shop what the prompt would say before it prompts.
Behaviour changes
Section titled “Behaviour changes”PromptPurchase’s refusal reasons are now theScribe.ProductStatestrings:player data not loadedbecamenot-loadedandplayer already owns "VIP"becameowned. The two interpolated reasons, an unknown name and an engine-refused prompt, are unchanged. No export ever promised the old text, but a caller comparing it should readScribe.ProductStateinstead.Scribe.PurchaseReasonandScribe.GiftReasongainPaidRandomRestrictedandPolicyPending.
-
PaidRandom = trueon a product declares a paid random item (a pass grants a fixed perk and refuses the flag at startup).PromptPurchaserefuses it for a player whose policy restricts paid random items, and while that policy is not yet known.Purchaserefuses a spec carrying the flag, which is the in-experience currency half of Roblox’s rule.PromptGiftrefuses a restricted buyer, and a recipient who is not on this server, whose policy cannot be read. A receipt for a flagged product from a restricted player, which only a prompt made outside Scribe can produce, is granted and loggedPAID_RANDOM_RECEIPT.The policy is read once per player, off the join path, and only when a flagged entry is declared. Unknown refuses: a read in flight or failed answers
policy-pending. Three attempts with backoff,POLICY_READ_FAILonce, one re-arm at most every 30 seconds. Unflagged entries never consult it. Nothing on the wire and nothing in the profile. -
Data.GetProductState(player, name)on the server andData.GetProductState(name)on the client answer one ofScribe.ProductState:purchasable,owned,paid-random-restricted,policy-pendingornot-loaded. Every value but the first is exactly the reasonPromptPurchaserefuses with, so a greyed button and the prompt behind it cannot disagree. The client computes it from the mirror and the local player’s own policy read, so a shop needs no round trip;Data.ObserveProductState(name, callback)is the reactive form.GetPolicyInfoAsyncis the test seam, on both realms. Proposed by a community member.
Changed
Section titled “Changed”-
A template can be much larger before Luau reports “Code is too complex to typecheck” at the
Scribe(...)call. What scaled with the template was not the accessor nodes, which were already built once per shape, but two unions built from them: a record’sOnChildChangedkey and value unions over every child, and the union of every numeric leaf path behindStatandCost.Path. Past twelve children the pair is typedstringandany?; past 32 numeric paths the path type is plainstring. Measured with the same analyzer CI runs: a template of identical leaves stopped checking at 48 roots and now passes 256; one of structurally distinct records moved from 8 to 32, containers from 8 to 16. Every diagnostic inside those limits is unchanged, and nothing at runtime is touched. A template that still reaches the wall has an escape hatch, in the templates guide: name the options, cast the call and annotate the half you use, which checks past 256 roots of either shape. -
A write into a container no longer renders its path into a string on the way in. The string existed for the error message, and is now built only when the key is refused.
Released 2026-09-01.
A server-owned store, so state that belongs to nobody stops having to live on a player. A template
can also now hold a Scribe.Big anywhere, including inside a container’s element shape, which used
to put the whole file past the Luau type solver’s budget and silently stop it being checked. The
change that buys it also makes assigning to an accessor a type error, which is the one thing to
read before upgrading.
Behaviour changes
Section titled “Behaviour changes”-
A write to the server store from inside
Data.Transactionis refused, and the transaction that contained it aborts. A rollback restores one player’s tree, and the store is neither that tree nor saved to any key, so a store write made inside a transaction would outlive an abort. The refusal names the store and says to write it before the transaction or after it returnstrue; the cross-player refusal, which points at the durable outbox instead, is unchanged. -
Assigning to an accessor is now a type error.
data.Coins = 5anddata.Coins.Set = freport that the property is read-only, where before they type-checked and did something worse than nothing: the accessor metatable has no__newindex, so the assignment wrote over the accessor and permanently shadowed the real one for that instance. Nothing that works today stops working;data.Coins.Set(5)and every other method are unchanged. If a script does assign to an accessor, it was already broken and the error is telling you where.
-
ServerStoredeclares a second tree owned by the server rather than by any player. One table for the whole server, reached asData.ServerStore.Wavewith no player argument on either realm, written on the server and replicated to every client as ordinary diffs. It exists from the moment the bundle is built, so the server can write it with nobody in the game, and a joiner receives it in the same Init snapshot that carries their own profile.Nothing in it is saved. A store root is in no profile payload and no session store, it does not mark a profile dirty, and it is gone when the server closes. It compiles through the same pass as the template, so store fields share one id space and one schema hash with the rest of the bundle and store drift fails the Hello handshake like any other drift.
Scribe.ServerOnlykeeps a store root off the wire.Scribe.Session,Scribe.Shared,Scribe.Timed,Scribe.Dynamic, a name the template already declares, a_Scribeprefix, and aScribe.Derivedreading across the boundary are all refused at startup with a message naming the field. Containers, records, bounds,Scribe.Bigand same-side derived fields work normally.A bundle that declares no
ServerStorebuilds no store tree, exposes noData.ServerStoreon either realm, and does not pay so much as a function call per frame for the feature. -
ImportLegacyDatacan pace itself on the DataStore request budget. A game adopting Scribe may read several legacy stores for one joining player, a main store plus a sharded copy plus a board per OrderedDataStore, and twenty players joining at once throttle each other and everything else on the server. The hook now receives a third argument carryingAwaitBudget(requestType, count?, timeout?), which yields until that many requests are spare.Waiting callers are served first come, first served, per request type, so a player who has been waiting is never overtaken by one who just joined and a hook wanting five requests is never passed by one wanting one. A grant is debited against a short-lived ledger, because the engine’s budget reading has not moved between granting a caller and that caller issuing its request, and granting the next one off the same reading would put the stampede back. When the engine cannot be asked at all the call grants rather than parking, which is the difference between a headless server working and hanging.
Data.GetStategained a second return:"Importing"while the hook runs and"ImportThrottled"while it is queued, nil at every other time. It is additive, so single-value callers are unchanged. Relay it to your loading screen yourself; Scribe cannot replicate to a client with no data yet.OnPlayerInitreceives the same context as a fourth argument, becauseImportLegacyDataonly fires for a player who has never saved and a game already on Scribe has nowhere else to finish moving.Migrations are held off the allowance Scribe needs for itself:
UpdateAsyncis reserved at the online session count, since its worst case is the shutdown drain saving every session at once. The newMigrationConcurrencyoption (default 2) bounds how many imports run at once, which is the only hard limit available, because a hook that never callsAwaitBudgetcannot be paced. A hook running past thirty seconds now warnsSLOW_IMPORT, so one patiently waiting on budget and one that has hung stop looking identical. -
Copy-in UI framework adapters for Vide, React and Fusion, in
adapters/. Not part of the package: wally publishessrconly, so these are files you copy into your game rather than something installed with Scribe. Each takes your framework as an argument, since it sits at a path in your project Scribe cannot know.They are short because the client mirror already has the right shape, and three properties that make them safe are now pinned by specs rather than assumed:
Get()returns a referentially stable value (a fresh table per call would make a React memo or a Vide derived recompute forever), that stability survives a resync rebuilding the mirror, and one frame of writes arrives as one notification, so no adapter debounces.The React adapter targets jsdotlua React 17.2.1 and uses
useState,useEffectanduseBinding. It avoidsuseSyncExternalStore, the React 18 hook for exactly this, because that port does not export it.useScribeBindingupdates a property without re-rendering the component. Fusion covers both calling conventions: pass a scope on 0.3, omit it on 0.2. -
A kick after a failed load now says WHICH failure it was. One sentence covered five different outcomes, so “we couldn’t load your data” read the same whether the DataStore was rate limiting the server for a few seconds or the profile was never going to load at all. A throttled load, a migration that could not complete, a
SchemaPolicy = "Reject"rejection and anImportLegacyDatahook that threw each have their own wording now, and each has its own option:RateLimitedMessage,MigrationFailureMessage,SchemaFailureMessageandLegacyImportFailureMessage.VersionAheadPolicy = "Kick"is split out too, asVersionAheadMessage. It used to wear the migration wording, which is the wrong way round: no migration ran and nothing is broken, the profile was written by a newer deploy and this server is the old one. During a staged rollout that is the message players actually see.The rate-limited case is read from the store rather than guessed.
StartSessionAsyncanswers nil and carries no reason of its own, so Scribe uses the class of the last DataStore error reported for that key, and only a 3xx throttling code gets the “busy, rejoin in a moment” wording. A 5xx is the service failing rather than throttling and keeps the ordinary load message.LoadFailureMessagestill overrides all five, so a game that set it keeps one voice and nothing about its configuration changes. Games that read kick text in support tooling should note the four new defaults. -
The session-end kick says which kind of ending it was.
KickOnSessionEndhad one sentence, “your data session has ended”, which hid the cause it nearly always is: another server holds the profile now, because the player opened the experience somewhere else. That isSessionStolenMessage, decided by the sameLocalSessionEndflag Scribe already uses to tell a local release from a steal. A session that went away mid-load getsSessionInterruptedMessage, because a player kicked before their data ever arrived never had a session to lose.The two families stay separate: load causes fall back to
LoadFailureMessage, session-end causes toSessionEndMessage. Wording your load failures does not silently reword your session ends. -
migration.AwaitBudgetnow returns a release alongside its verdict. Calling it once the reads have been issued hands the allowance straight to the next waiting player, instead of leaving a timer to presume the grant spent. It is optional, safe to call twice and safe to call when nothing was granted, so hooks written against the old single return keep working. -
The
GetSortedAsyncallowance held back from a migratingImportLegacyDatahook now follows the leaderboards a game actually declares, one read per board and capped, instead of a flat four. A game with no leaderboards reserves nothing, so its migration gets the whole ordered-read pool. -
Data.Stop()on the client, the counterpart of the server’s. It releases the transport listener, the Hello retry loop, the subscription watch and the mirror’s listeners. A storybook that remounts a bundle per story, or a test suite that builds many, no longer stacks a listener per build. Custom transports can implement an optionalReleasefor it. -
The budget pacer reports itself:
BudgetWaiters,BudgetQueued,BudgetGranted,BudgetTimedOutand aBudgetWaitSecondsdistribution, so a migrating server’s queue is visible as a whole rather than one player’s phase at a time. -
Per-player prices on the client.
Data.GetPrice("VIP")answers what this player is charged, after any Roblox Plus discount (10% for two months, then 20%) and any regional pricing (30% to 100% of the listed price).Data.GetProductInforeturns the whole Marketplace table, soUserBasePriceInRobuxandPriceDiscountDetailsare there for a “was 100, now 80” button, andData.ObserveProductInfois the reactive form for a shop that should repaint when the value lands.Data.PrefetchProductInfowarms a list, or everything declared, as one batch.Client-only on purpose. A live server’s
GetProductInfoAsynchas no player in context and answers the base catalog price, while a Studio server answers the personalized one, so the obvious server-side cache is correct in Play Solo and wrong in production for everyone holding a discount. Reads are named by the pass or product you declared, Scribe picks the InfoType, and reads asked for in one frame are batched into a single burst. A read that fails leaves the pricenilrather than falling back to the catalog number, and logsPRODUCT_INFO_FAIL. Prices are re-read once, the first time the local player’sHasRobloxSubscriptionflips.
-
A non-finite number is refused as a command argument.
typeof(0/0)is"number", so a bare"number"inArgsaccepted NaN and handed it to the handler, where every comparison against it is false: a guard written asif amount > 0takes neither branch, so both arms of a check can be skipped. Infinity passed the same way, and NaN survives the wire intact, so a client could genuinely send one. Declarators were never affected, sinceScribe.IntandScribe.Numberalready refused non-finite values. Now refused whatever the spec says,"any"included, because no number Scribe can store is non-finite.Scribe.Derivedwas already covered at both ends: a compute returning NaN fails at startup, and one that goes non-finite at runtime raises rather than storing it. -
A gap in a developer-declared list is refused where it used to pass silently, in command
Argsand inScribe.Derivedinputs. Both read the list two ways that disagree:#counts past a gap written in a constructor ({ "number", nil, "string" }is 3) and stops short of one written by assignment (t[1],t[3]is 1), while every loop over the list iterates the array part and skips whatever is missing.For
Argsthat left the middle argument decoded and handed to the handler without a type check, because the arity check read#and admitted it. ForScribe.Derivedit silently narrowed the field:{ "A", nil, "B" }became a two-input derived, the “inputs must be strings” check never saw the gap, andComputetook nil for that parameter forever. Neither is remotely reachable, since a developer has to declare the gap, but in both cases the declaration quietly meant something other than what it read as. Use"any"for a command argument you do not want checked. TheArgshalf was reported by a community member against 2.1.1; theScribe.Derivedhalf was found by sweeping for the same shape. -
Owns,OwnsAsyncandObserveOwnedraise a named error when the key is not a string, instead of failing two different ways depending on where they ran. In Studio a nil key hitwarnedOwnsKeys[key] = trueinside the unknown-key warning and raised “table index is nil” from inside Scribe, naming neither the caller nor the argument. In production that warning sits behindDevModeand never ran, so the same call answeredfalseand an ownership gate quietly denied a player who had paid, with nothing logged anywhere. Both realms now say which API was called and what type arrived, soOwns(player, passId)and a missing config field name themselves. Reported against 2.1.1. -
A
Scribe.Biginside a container’s element shape, as inScribe.DictOf({ Id = Scribe.String(""), Amount = Scribe.Big(0) }), put a two root template past the type solver’s budget. The file reported “Code is too complex to typecheck” at theScribe(...)call and lost autocomplete and every other diagnostic. The same wall caught a plain record holding two bigs, and a big nested three levels deep with no container involved at all. All of them compile now.The cause was not the size of the big type, which is why every attempt to trim it failed. An accessor property carried both a read and a write type, so comparing two instantiations of the accessor tree had to prove each side exactly the other, in both directions, at every property and every level, with the self-referential big expanded at each mention. Accessor properties are now read-only, which makes that comparison covariant. Diagnostics for ordinary mistakes are unchanged.
Documentation
Section titled “Documentation”- A new guide, The Server Store, covers declaring one, reading it on both realms, what it refuses
and why, and what a store write costs in memory, bytes on the wire and time in a flush. The
configuration guide lists
ServerStoreunder Core.
Released 2026-08-28.
A types and tooling release. Three members of the server API were missing from the type
Scribe.Server hands back, so calling them from a strict-mode script was a type error even though
they worked at runtime. It also raises the template size at which Luau gives up type-checking your
game entirely.
Scribe.ExchangeableSpecis exported, and theExchangeableoption is now declared onScribeOptions. The option always worked, and Luau never rejected it because it does not check extra keys in an options literal, but naming the type means a game can build the allowlist as a typed table separately from the options table and get completion onPath,Kind,Count,IdentityandIgnore.
Data.PromptPurchase,Data.ExchangeandData.Stopwere missing from the typeScribe.Serverreturns, so a strict-mode script calling any of them reported the key as not found in the Data table while the call itself worked at runtime.Data.Exchangeis now typed in full, including whatOpenhands back.Data.PromptPurchaseandData.Exchangewere new in 2.1.0;Data.Stophad been missing since before 2.0.0.
Changed
Section titled “Changed”-
A template can now be roughly three times larger before Luau reports “Code is too complex to typecheck” at the
Scribe(...)call and silently stops checking the file. The accessor type is built once per distinct shape instead of once per field, which matters because a template repeats declarators heavily and Luau treats each one as a separate type. Measured on a template of mixed records and containers, the limit moved from 12 roots to over 32; a template whose roots are all structurally different still gains about half again. Nothing about the types changes: every diagnostic is identical, across every declarator. -
Scribe.OpenExchangedeclaresStateas"Claimed" | "Staked" | "Delivering"rather thanstring, so a game can narrow on it. Assigning whatData.Exchange.Openreturns to a{ Scribe.OpenExchange }did not type-check before this.
Documentation
Section titled “Documentation”-
The leaderboards guide covers Roblox’s built-in leaderboard UI, which renders a persistent leaderboard with no UI code by reading an OrderedDataStore directly. It needs a store name of
LB_<BoardName>and a key template of{UserId}. AScribe.Bigboard cannot be shown that way, because it stores a packed integer rather than the score, and the guide says so. -
The big numbers and containers guides record that a
Scribe.Bigdeclared as a field of a container’s element shape puts a template past the Luau type solver’s budget, so the file stops being type-checked. It is a type-checking limit only and the code runs correctly either way. The guides give the shapes that do fit, including a container whose element is the big itself.
Released 2026-08-28.
This release adds exchanges: two players who are both loaded on the same server hand over two baskets
of value, and one verdict decides both sides, so nothing is duplicated or destroyed. It also adds
Data.PromptPurchase, confirms a finished game pass purchase before crediting it, and adds the
measurements a game needs to tell what Scribe costs it in a running server. Scribe’s reserved root
gained two subkeys for the exchange ledger, which changes the schema hash the two realms compare
during the handshake, so the server and the client must be deployed together, even by a game that
declares nothing exchangeable. The largest fix is that a generalized for loop over an accessor used
to empty the container it was reading.
Behaviour changes
Section titled “Behaviour changes”-
Scribe’s reserved
_Scriberoot gained two subkeys,Exchangefor a trade while it is in flight andExchangeInboxfor value that has settled to a player but has not been delivered onto a game path yet. Both realms fold that root into the schema hash they compare during the handshake, so a client built from 2.0.0 and a server built from 2.1.0 derive different hashes, the server logsSCHEMA_MISMATCHand refuses to replicate to that client. Deploy the server and the client together. The wire protocol version itself is unchanged, so on a mixed deploy the server loads and saves normally while the player’s client copy of the data never arrives and stays at template defaults. -
The
ResetDataoption now refuses to wipe a profile that holds an in-flight or undelivered exchange. The load reportsEXCHANGE_RESET_REFUSEDat Error, naming how many of each the profile holds, and leaves the stored data as it was. Resolve or discard the exchange first, because the wipe would take the reserved root with it, and with it the only record that the staked value ever existed. -
Data.RestoreVersionnow returns false with a reason, and logsPROFILE_RESTORE_FAIL, while the profile holds an in-flight or undelivered exchange, whateverRollBackReservedis set to. A restore rolls game data back and deliberately keeps the live reserved root, which for an exchange is wrong in both directions: it can leave a stake sitting in the inventory and in escrow at once, or take an item back out from under a delivery that has already been cleared. The exchange has to reach a terminal state first. -
A finished game pass purchase is now confirmed with an ownership check before anything is credited.
PromptGamePassPurchaseFinishedreports that the purchase dialog closed rather than that a transaction completed, and a game pass has noProcessReceiptto be authoritative, so an unconfirmed close used to write a permanent Robux purchase-log entry for money that may never have been spent. A check that does not confirm the purchase now credits nothing, writes no purchase-log entry and logsPASS_PURCHASE_UNCONFIRMED, and a genuine purchase the ownership API has not caught up with has the player’s ownership restored on their next load, because the join scan re-resolves every declared pass. The check yields, so ownership is credited once it returns rather than in the same frame the purchase signal fires. -
A name declared in both
ProductsandPassesnow fails to boot. A prompt resolves by name, so the same name in two tables would leave table order deciding what the player is charged for. Rename one of them. -
An
OnPlayerInithook is handed the profile data directly, before the accessor tree that refuses reserved writes exists, so that hook and aScribe.Dynamicfactory are the one place Scribe’s in-flight exchange ledger is reachable. A change either one makes to that ledger is now put back and reported asEXCHANGE_INIT_TAMPERat Error, which is what a starter kit clearing_Scribeto start clean looks like. Only the exchange ledger is put back: receipts, perks and the rest of that root are not.
-
Data.Exchange.Attemptmoves value between two players who are both loaded on the same server. Each side hands over a basket of legs naming what that player gives, both baskets are staked out of the two profiles, and one verdict key decides the whole exchange, so every participant, on every server and on every retry, reads the same answer. Nothing is duplicated or destroyed, but the resolution is deliberately not time bounded: an exchange interrupted at the wrong moment finishes on a later load or on the periodic sweep, and until then the staked value sits where the game can still show it to the player. A profile may hold only one exchange at a time. -
The new
Exchangeableoption is the allowlist of what a game may exchange, and nothing outside it can be traded. Each entry names aPathand aKind, and a path may name a field or a container but may never reach through a container into one of its entries. The list says which kinds of thing may move, never whether one particular item may, so ownership and any untradeable marker of your own are still yours to check. -
An exchange basket is a list of legs, and a leg is one of three kinds. A
Keyleg moves one whole entry of aDictOf,MapOf,SetOforArrayOf. AQtyleg moves anAmountoff a balance, which must be aScribe.Intfield declaring aMin. AStackleg moves part of one entry of aDictOforMapOf, splitting the count held on that entry and leaving the rest of it behind. -
A
Stackdeclaration namesCount, the element field holding how many, and must then classify every other field the element declares:Identityfor a field that travels with both halves of a split,Ignorefor one that does not travel at all. A field in neither fails the game at boot and is named. A split duplicates whatever it does not drop, and nothing at runtime can tell a duplicated tag from a minted resource, because the count itself is exactly conserved either way. An element that is a bare number is the one shape with nothing to declare, because the stored value is the count. -
A declaration Scribe cannot move safely fails the game at boot, naming the entry, and is logged as
EXCHANGE_REGISTRATION_REFUSED. Refused at startup: a misspelled path, which would otherwise resolve to a parent and exchange a field nobody named; a path that reaches through a container, because crediting a key the receiver does not hold would seed the whole element from its defaults; a quantity on a container, on aScribe.Number, on aScribe.Big, on aScribe.Optionalor on a field declaring noMin; aScribe.Flagsor a derived field; a non-persisted root; aScribe.Timedfield or anEvictcontainer anywhere in the subtree; and anything under Scribe’s reserved_Scriberoot. -
Every leg of both baskets is checked against the
Exchangeabledeclarations, by path and by kind, before a slot is claimed or any value moves. A basket may therefore be built straight from what a client asked for: an undeclared path, an undeclared kind or a malformed leg produces a refusal that costs the players nothing and leaves nothing behind. -
Data.Exchange.Openreports what one player still has in flight: an entry per exchange carryingId, aStateofClaimed,StakedorDelivering, thePartnerUserId,Stakedfor what they handed over,Owedfor what they are owed, andSince. Staked value leaves the balance on purpose and resolution is not time bounded, so this is what a game shows a player whose exchange has stalled: without it their items simply look to them like they vanished. Every table it hands back is freshly built and aliases nothing Scribe holds, so it is safe to keep or mutate. -
Data.Exchange.Discard,Data.Exchange.SettleandData.Exchange.Redirectare operator verbs for an exchange the automatic machinery cannot finish, and all three act on profiles loaded on the server they are called from.Discarddrops an abandoned claim that never took value, and refuses an exchange that already has a verdict or that holds escrowed value.SettleforcesCommitorAborton one that cannot resolve itself, has no default verdict, and refuses aCommitwhen only one side is loaded or when a side holds escrow with no take recorded.Redirectlands a parked delivery on a different key of the same container, and refuses a set, where the key is the value, and a parked delivery holding more than one key leg. -
Data.PromptPurchaseprompts a player to buy a declared product or pass for themselves, by the name you gave it rather than its numeric Id. The name resolves againstProductsandPasses, and Scribe makes the matching engine call, so a shop button does not have to know which table an item lives in. It refuses something the player already owns, so a caller does not have to pair every prompt with its ownData.Ownscheck; a product with noGrantsis a consumable, has nothing to own, and always prompts. An unknown name, a player whose data is not loaded, something the player already owns and a prompt the engine refused all come back as(false, reason)rather than raising. Prompting is all it does, and for a product the grant still happens on the receipt, so a player who buys and then leaves is granted on their next load. -
The new
LoadDurationmetric records how long a profile took to become ready, in seconds, and reads throughScribe.GetMetricsandScribe.GetPercentileslike any other distribution. It is measured from the moment the player joined rather than from the DataStore call, so it covers the queue, the retries and the migration chain, which is what the player actually waited through. A load taking ten seconds or longer also warns asSLOW_LOAD, naming the player and how long it took. That threshold is fixed and sits well belowLoadTimeout, so it reports joins that are merely slow rather than only the ones that end in a kick. -
An attempt answers
Committed,Aborted, or nil with a reason. AnAbortedexchange is a finished operation rather than a failure left to clean up: every basket has been returned to its owner. A nil is one of two things, and the reason says which. Either the exchange was refused before anything moved, which is by far the common case and costs the players nothing, or no verdict could be established, in which case the value is in escrow and the exchange resolves itself on a later load or on the sweep. -
The refusals that cost nothing are a player exchanging with themselves, two empty baskets, a player who already has an exchange in flight, a player who is not loaded on this server, and a player already holding eight undelivered exchanges, which is the cap. An attempt also carries a deadline of about twenty seconds: one that reaches it aborts rather than going on to commit, and every basket comes back to its owner.
-
An exchange interrupted part way resolves itself the next time either profile loads, and a background sweep does the same for sessions that never end.
EXCHANGE_RESOLVEDrecords each exchange that reaches a terminal state,EXCHANGE_UNRESOLVEDreports one that cannot, andEXCHANGE_PARKEDreports a settled exchange whose delivery has nowhere to land, which is the conditionData.Exchange.Redirectexists for. The last two are announced once per exchange per server rather than on every sweep. -
A delivery that cannot be applied parks in the receiving player’s inbox and is retried on every load and on the sweep, rather than being clamped, evicted or dropped. That covers a container at its cap, a destination key already occupied, and for a
Stackleg a merge that would pass the count’sMaxor land on an entry whose other fields do not match the one being delivered. -
Ignoreworks on aKeyleg as well, for a field that describes the owner’s relationship to an item rather than the item, such as a locked marker. The field is dropped where the item is staked, the one point at which the giver’s copy is still readable, and the receiver’s copy starts from the element’s declared default. An ignored field is destroyed in transit rather than held, escrowed or returned by an abort, so nothing that represents value belongs in it. Listing fields is optional on aKeyleg: one left unlisted simply travels, which is always conserving. -
Moving a whole stack is the same
Stackleg withAmountequal to what is held, and it removes the key outright rather than leaving a zero count entry the player still appears to own, so a container that stacks does not need aKeydeclaration as well. Where the count declares aMin, a partial move that would leave either half below that floor is refused before anything moves, and that includes the half being moved, not only the remainder. -
Scribe.ExchangeLeg,Scribe.OpenExchangeandScribe.OpenLegare exported types, so a basket you build and the entriesData.Exchange.Openhands back both type-check. TheLogCodeunion gained eight exchange codes alongsideSLOW_LOADandPASS_PURCHASE_UNCONFIRMED, andLogCategorygainedExchanges. -
A bundle whose
ModeisMockorNoSaveserves the exchange verdict from memory under the same first writer wins contract, so an exchange resolves the same way in Studio as it does in production. A server that cannot reach the verdict store logsEXCHANGE_STORE_UNAVAILABLEand refuses attempts rather than falling back to a store no other server can see. -
The new
FlushDurationmetric records what one frame of replication cost, in seconds, and reads throughScribe.GetMetricsandScribe.GetPercentiles. Nothing is recorded for a frame that flushed nothing, so the distribution describes busy frames rather than the average frame, and the existingFlushEntriesPerFrameandFlushQueuedPerFramecounts still say how much work there was. -
A frame of replication now appears in the MicroProfiler under a single label,
Scribe.Flush, covering the flush across every player in that frame. It is the only label Scribe adds, because a profiler annotation does not survive a yield: work that waits, such as a profile load or a migration you wrote, is reported through a metric instead. -
Scribe’s own long-lived threads now report their allocations under a
Scribememory category in the Developer Console, covering the profile load, the leaderboard refresh and write pacer loops, the timed sweep and the exchange sweep. Roblox charges an allocation to the thread that is running, so a write your own code makes stays under your own category: the tag shows what Scribe does on its own rather than the total cost of the data layer. -
The stack declarations are refused at boot on the same terms: a
Stackon aSetOforArrayOf, neither of which has a keyed stack to split; aCountnaming a field that is not aScribe.Intdeclaring aMin; aCountthat is also ignored; a name inCount,IdentityorIgnorethat the element does not declare; a field listed in bothIdentityandIgnore; a container field listed inIdentity, where a split would duplicate the whole collection;Ignoreon a quantity leg; andCountorIdentityon a leg that is not aStack. -
Each exchange writes one key to a DataStore named
ClaimExchangeVerdicts. The first writer wins and every later proposal reads that answer back rather than overwriting it, and Scribe never deletes one, because deleting a verdict can only be justified by knowing both sides settled and a settled side can still revert. Budget for one key per exchange. -
An exchange in flight locks nothing. Neither profile is frozen and no write is refused, so both players carry on playing throughout, and the only thing either of them can observe is that what they staked has left their data until the exchange settles.
-
The new
PassPurchasesUnconfirmedmetric counts finished game pass purchases that the ownership check would not confirm, and the newPurchasePromptsmetric counts the promptsData.PromptPurchaseopened. Both are reported byScribe.GetMetrics. -
Scribe.Shortrenders a quantity the way a player reads it, as1.5Kor100M, and takes either a plain number or aScribe.Bigvalue, so a balance label no longer has to branch on which numeric type the field happens to be declared as.Scribe.SetShortSuffixesreplaces the suffix table it and a big value’sShortmethod render with, for a game whose convention pastTis not Scribe’sQa,Qi,Sx. The list must be non-empty, every entry must be a string, and the first entry must be the empty string, because that is the tier a plain number renders in. It applies to every later render in the realm that calls it, so call it once at startup, and on the client too if the client formats its own labels.
-
A generalized
forloop over an accessor, as infor key, entry in data.Inventory do, emptied the container it was meant to read. An accessor carried no iterator, so Luau fell back to calling it, and that call reachedSetwith a nil value and deleted the node. The loop body never ran, so the whole statement read as a harmless no-op while the deletion replicated and saved. Iterating an accessor now raises, and the error namesGetfor a read-only walk andClonefor a table you may edit. Calling a node with two nil arguments is refused for the same reason, while a deliberateSet(nil)with one argument still clears the value. -
A save that handed its session to another server part way through was reported as having failed, even though its bytes had already reached the key. When another server starts a session for a profile this one still holds, which is what a teleport or a quick rejoin produces, it requests a force load, and the save that noticed the request released the session without recording that its own write had landed.
Data.Flushreturned false for data that was on disk, and the receipt path reads that same answer before it decides whether to reportPurchaseGranted. -
In
DevMode,UNDECLARED_PERKwarned about a product whoseGrantsnames a declared pass, and about that same name passed toData.GrantPerk. A game pass cannot be transferred, so granting a perk of the pass’s own name is how a gift confers it, andData.Ownsalready answers across both namespaces. A declared pass name is now accepted in both places without a warning.
Changed
Section titled “Changed”- The
PROFILE_LOADEDlog entry now carriesLoadSeconds, the time between the player joining and their data being ready, so a sink added withScribe.AddLogSinkcan attribute one slow join without reading the metric.
Documentation
Section titled “Documentation”-
A new guide, Exchange, covers moving value between two players who are both loaded on the same server: what an exchange promises and what it deliberately does not, the three leg kinds, declaring
Count,IdentityandIgnoreon a stack, why the allowlist answers whether a kind of thing may move and never whether this particular item may, what a player sees while an exchange is in flight, and the three operator verbs, including whySettlerefuses to guess a verdict. It also gives the rule that a listener on an exchangeable path must not yield, because container listeners fire inside the transaction the exchange runs in and a yield rolls it back. -
A new guide, What It Costs, covers measuring Scribe in a running game rather than guessing: the
LoadDuration,SaveDuration,FlushDurationandProfileSizedistributions and why the p99 is the number to read, theSLOW_LOADandPROFILE_SIZEwarnings,Scribe.GetStatusandScribe.GetBudgetSnapshot, what the singleScribe.FlushMicroProfiler label and theScribememory category do and do not cover, and what Scribe deliberately does not measure. -
The configuration guide gained an Exchanging section covering the new
Exchangeableoption and every field of a leg spec:Path,Kind,Count,IdentityandIgnore. It states that an ignored field is destroyed in transit rather than escrowed, and that aStackelement field named in neitherIdentitynorIgnorerefuses to start and names the field. It points at the Exchange guide for the full list of shapes Scribe will not let you declare exchangeable, each with the reason it cannot be moved safely. -
The monetization guide no longer teaches prompting a sale with a numeric product id. It now teaches
Data.PromptPurchase, which takes the name you declared, resolves it across bothProductsandPasses, refuses something the player already owns, and answers(false, reason)rather than raising. A new section explains that a finished game pass purchase is confirmed with an ownership check before anything is credited, that a genuine purchase the ownership API has not caught up with is credited on the player’s next load, and that a name declared in both tables fails at startup.PASS_PURCHASE_UNCONFIRMEDis written up alongside the other monetization log codes. -
The cross key transactions decision table now sends a two sided trade to the Exchange guide for two players on one server, where it previously said nothing covered that case. The log code reference gained a matching Exchange section for the eight
EXCHANGE_codes, which record where an in flight exchange currently is rather than any loss of value, and gained rows forSLOW_LOADandPASS_PURCHASE_UNCONFIRMED. -
The containers guide now warns that a container listener which yields closes the thread of any open transaction and rolls that whole transaction back, including the write that fired the listener, and that the error names the transaction body rather than the listener, so the file you go looking in is the wrong one. A listener that raises instead is logged while the transaction still commits.
-
The documentation build now fails when a guide calls a
Scribe.member the package does not export, so a guide can no longer teach an entry point that does not exist.
Released 2026-08-24.
This release closes a long list of defects in the money, persistence and replication paths, and adds derived fields, idempotent commands, narrowed float replication and a schema check for stored data. It also changes a number of behaviours that existing games depend on, including the replication wire format, so read the behaviour changes below before upgrading. A game that uses neither monetization nor offline writes will find most of its risk in the wire format change and the template compile rules.
Behaviour changes
Section titled “Behaviour changes”-
The replication protocol version moved from 1 to 6, so a server and a client built from different Scribe versions now refuse each other and log
PROTOCOL_MISMATCHinstead of mis-decoding frames. Deploy the server and the client together, because a mixed deploy leaves players unable to load. -
Scribe now ships its own patched copy of ProfileStore inside the package, so the ProfileStore Wally dependency is no longer required. Remove it from your
wally.tomlwhen you upgrade. -
Game code can no longer write anywhere inside Scribe’s reserved
_Scriberoot, and every mutator on such a path now raises an error naming the path and the API that owns that state. Reading a table inside that root also hands back a detached copy rather than the live stored table. -
A transaction can no longer touch a second player’s data. Opening a transaction on another player, or writing to one from inside an open transaction, now raises and rolls the transaction back, and the error points at the durable outbox as the way to move value between players.
-
A cross-server message is no longer acknowledged when nothing is connected to
Data.OnMessageor when a handler raises. It stays on the key and is offered again on the player’s next load, so a handler must now tolerate seeing the same message twice. -
Data.RestoreVersionno longer rolls the reserved_Scriberoot back with the game data. Granted receipts, paid gifts, perks, the purchase log and running cooldowns are carried across from the live profile, and the newRollBackReservedoption restores the old behaviour when that root is itself what needs repairing. -
A migration step that changes the reserved
_Scriberoot now has that change discarded and the stored root kept, reported asMIGRATION_RESERVED_DISCARDED. A migration that rebuilt the profile from its own key list used to destroy receipt idempotency and paid gifts in silence. -
Data.SendMessagenow returns false and logsMESSAGE_QUEUE_FULLwhen the recipient’s offline inbox is at its cap. It used to report success after throwing a message away. -
A template that declares a non-finite
MinorMaxonScribe.Number, or aMaxLengththat is not a non-negative integer, now fails to compile. This fails at startup rather than in production, and a negativeMaxLengthpreviously deleted the end of every value it was applied to. -
Data.UpdateOfflinenow commits as a compare-and-set, so the session check and the write are a single DataStore call. It gained one refusal reason, that the profile changed while the update was being prepared, and a refusal now writes nothing at all. -
Data.WaitForDatacan now answerstill-loadingwhere it used to answertimeout. A load that is merely slow is worth retrying, so code branching ontimeoutshould handle both. -
Cooldown and claim keys passed to the public timed API are now refused if they contain invalid UTF-8 or begin with
@, which is reserved for Scribe’s own idempotency claims. Rename any key of yours that starts with that character. -
A write that would leave a container holding both array indices and string keys is now refused, whether it arrives as a keyed write or as an
Insert. That shape loses half its contents on save. -
A product grant that yields and then fails part way is now settled as granted, logged as
GRANT_PARTIALand counted inReceiptsPartial. It used to be retried, which compounded the writes it had already made. -
Two pass names sharing one gamepass
Idnow fail to boot, matching the refusal products have always had for a duplicateId. Give each pass its own gamepass or register it once, because an in-experience purchase reports only the Id and used to credit whichever of the two names Scribe registered last. -
tostringon aScribe.Bignow keeps the fractional part instead of rounding to a whole number, so a third of ten prints as3.33333333333333. The numbers inside bounds error messages change with it. -
Dividing a
Scribe.Bigby zero now raises instead of returning nil. -
A
Setthat writes the value a field already holds no longer firesChangedor queues a replication op on aScribe.Big, a flags field or a datatype field. Those three used to fire where the identical no-op on an integer cost nothing. -
SchemaPolicynow defaults toWarnunderDevModeand stays off on live servers, so a Studio session reports stored data that no longer matches the template. An explicit setting still wins in both directions. -
Data.Requestnow returnsScribe.RequestFailedas a third value whenever the refusal is Scribe’s rather than your handler’s. Only a caller that forwards the results ofData.Requeststraight into another call needs to change. -
In edit mode, meaning a storybook or the command bar, a bundle now builds the client half instead of the server half. Building the server half used to create the transport folder and RemoteEvents in ReplicatedStorage and leave the client stub raising.
-
Scribe.Deriveddeclares a field that Scribe computes from other declared fields instead of accepting writes. It is never persisted or migrated, it recomputes when an input changes, and every mutator is absent from its type and raises at runtime. -
Client.RequestOncesends a command tagged with a caller-supplied idempotency key, so the server runs the handler at most once per key and answers repeats with the original reply. Keys must be non-empty, valid UTF-8 and at most 64 bytes. -
A command spec now accepts
Idempotent = true, which makes the command require a key sent throughRequestOnce. The requirement is symmetric, so a key sent to a plain command and a keyless call to an idempotent one are both refused. -
PurchaseSpecgained an optionalIdempotencyKey, and a repeat under the same key returns exactly what the first call returned and spends nothing. The newPurchaseClaimTTLandMaxPurchaseClaimsoptions govern how long a claim is kept and how many may be live on one profile. -
Data.Stopreleases everything a bundle holds on the process, including the background loops, the Players and MarketplaceService listeners and the transport channel claim. A game never needs it, but a test suite or a simulation that builds many bundles does. -
Scribe.Numbergained aPrecisionoption that narrows a replicated field to four, two or one bytes. The server keeps the full double it was given and only the client copy is quantized, so do not compare the two for equality. -
Scribe.CFramegainedPrecision = "exact", which packs every component bit for bit at 49 bytes instead of the default 13 or 29.Scribe.Datatypes.Packtakes the same value as an optional third argument. -
A
Scribe.Bigvalue now supportsPowfor a non-negative integer exponent andLog10. Both are reads that return a new value, andPowrefuses a fractional, negative or non-finite exponent. -
The new
SchemaPolicyoption checks stored data against the template when a profile loads. Only a table mixing array indices with string keys ends the session underReject, and under that setting a boundedScribe.Bigoutside its bounds also refuses the load. -
An outbound frame larger than the outbound budget is now split into fragments and reassembled by the client, where it previously could not be sent at all. A frame needing more than sixteen fragments logs
OUTBOUND_OVERSIZEonce per server. -
A custom transport may now declare
MaxFrameBytes, and Scribe keeps every frame under it. An adapter whose own framing inflates the buffer can carry its ceiling with it instead of having to be paired with a matchingMaxOutboundBytessetting. -
Scribe.GetPercentilesreturns the P50, P90 and P99 of each recorded metric, whichGetMetricscould not report. It is computed over the most recent 256 samples per name, so it does not agree with the all-time count. -
Scribe.GetBudgetSnapshotreports the DataStore request allowance the engine currently gives, by request type. ItsAvailablefield is false when the engine could not be asked at all. -
Scribe.AddLogSinknow returns a function that removes the sink again, so a sink with a lifetime no longer stays registered for the life of the server. -
Scribe.RequestReason,Scribe.PurchaseReasonandScribe.GiftReasonname the fixed refusals ofData.Request,Data.PurchaseandData.PromptGift, each with a matching exported type. -
The new
ImportLegacyDataoption adopts data from another library once, before Scribe has ever saved for that player. The adopted profile then runs the full migration chain. -
The new
LoadTimeoutoption bounds how long a profile load is given, defaulting to 120 seconds with a floor of 60. -
The new
LogRingSizeoption sets how many recent entriesGetRecentLogsretains, which used to be fixed at 512. -
The new
MaxOutboundBytesoption caps the bytes in one outbound frame before fragmentation, defaulting to 65536 with a floor of 256. -
The new
MaxInboundRetainedBytesoption caps how much memory one inbound client frame may cause the server to retain, defaulting to sixteen timesMaxInboundBytes. -
The new
BudgetPolicyoption, whose only value isDefer, paces the two leaderboard background loops against the DataStore request allowance. It deliberately touches no save path. -
The new
IsRunningoption overrides theRunService:IsRunning()default, and is the seam a storybook or a test harness uses to pick which half of the bundle gets built. -
A leaderboard
Statmay now name a derived field, provided that field reads only persisted inputs. One that reads session-only state is refused at startup. -
A new
MIRROR_RESYNClog entry and aMirrorResyncsmetric record every time Scribe rebuilds a client’s copy of the data after a send failed. -
In
DevMode, Scribe now warns withGRANT_SEEDED_ELEMENTwhen a purchase grant creates a container element merely by writing through its key, which is what a stale or mistyped id looks like. -
The
LogCodeunion gained thirty nine new codes across persistence, integrity, replication, transport, commands, monetization, gifting and leaderboards, andLogCategorygainedDerived. No existing code was removed.
Purchases and gifts.
-
A gift receipt that Roblox retried more than an hour after the purchase was granted to the buyer instead of the recipient, so one payment could produce two grants. The recipient is now recorded durably for as long as the receipt can still be retried.
-
A second gift prompt for the same product could delete the first gift’s record while it was still being delivered, so that receipt landed with nothing to aim it at and the perk went to the buyer. Gift records are now cleared by their own identity rather than by their slot.
-
The gift prompt cap counted only archived records, so a buyer could arm a full set of pending gifts against a handful of free slots and lose the recipients at the next sweep. The cap now counts pending intents alongside the records they will become.
-
A Robux gift to a player whose inbox was full is now held for Roblox to retry, instead of being destroyed with the buyer’s escrow already cleared.
-
A gift spent from a paid gift credit could grant twice when the delivery write committed and then lost its answer: the credit was handed back, the buyer was told to try again, and the retry queued a second gift under a fresh id the recipient could not deduplicate. Only a delivery that provably wrote nothing is refunded now; one that cannot be confirmed keeps the credit spent, answers
GiftReason.DeliveryUnconfirmedand logsGIFT_CREDIT_UNCONFIRMED. -
A gift spent from a paid gift credit could still grant twice when the recipient’s inbox was reported full after the delivery had already been queued, and could instead swallow the credit when the send was refused at a closing server’s door without ever being attempted. Both came of the store answering the same thing in every case, so
MessageAsyncnow also reports whether anything could have been written, and the credit comes back only where nothing can have been. -
A gift spent from a paid gift credit was swallowed when the recipient’s inbox was full and the send had been throttled first. Whether anything could have been written was inferred from how many attempts the store had made rather than from what those attempts did, and a throttled request is dropped before it reaches storage, so a single one turned a delivery that provably never happened into one that could not be confirmed. Each failed attempt is now classified by its error, and only a request rejected outright or dropped at the throttle queue counts as having written nothing.
-
A gift delivery that raised, rather than returning a failure, unwound past the refund decision entirely: the credit stayed spent with nothing logged, counted or reported, and the caller saw a script error instead of a refusal. It now settles as unconfirmed, keeps the credit spent because a raise cannot prove the gift did not go out, and logs
GIFT_CREDIT_UNCONFIRMEDwith the error. -
MESSAGE_QUEUE_FULLandMESSAGE_SEND_FAILclaimed the message had not been delivered whatever had happened, including for a refusal reported after an earlier attempt in the same call had already queued it. Both lines now say which of the two occurred and carryContext.ProvablyClean, and the newMessageQueueFullAmbiguouscounter isolates the refusals that may be hiding a delivery. -
Two copies of the same receipt arriving at once could each run the grant, so a player who paid once received the product twice. The second copy is now refused while the first is still running and logs
RECEIPT_IN_FLIGHT. -
A receipt for an offline player could be granted twice when two servers decided from the same stored snapshot, because the duplicate marker was only checked before the write. It is now checked again inside the write itself.
-
With
WipeGuardPolicyset toBlock,Data.Flushcould return true for a save that had swapped the payload and left the old value on the key. Monetization answersPurchaseGrantedoff that boolean, so a paid grant could be acknowledged without ever being stored. -
A run of
AwaitSavecalls, or ofClient.Requestround trips, against a store or transport that answered immediately could walk the calling thread into the engine’stask.deferre-entrancy ceiling. Past that point the engine accepts the call, reports success and never runs the callback, so the waiting thread was never woken and hung for the rest of the server’s life. On the save path that took a receipt’sPurchaseGrantedanswer with it. Neither path wakes its caller through the defer queue any more. -
Duplicate receipts are logged again at Info as
RECEIPT_DUPLICATE, alongside theReceiptsDuplicatecounter they had lost touch with.
Saving and offline writes.
-
When two saves for one player overlapped, a write made during the first could be reported as already on disk after that save failed. The dirty flag is now accumulated across every save in flight and cleared only once they all drain.
-
A load that failed closed used to rewrite the stored profile on its way out, backfilling template defaults and advancing the key version, so the evidence a developer needed was gone. Those paths now release the lock without writing.
-
Erasing a player’s data while they were still playing left that session permanently stuck, accepting writes it silently discarded and never releasing. The session now ends cleanly and the erased key stays erased.
-
Data.RestoreVersioncould take a live session’s data away from it when the player joined between the last check and the write. The check and the write are now a single operation. -
Data.UpdateOfflinerefused forever when a player’s session had been left behind by a crashed server, whileEraseandRestoreVersionalready recovered from the same state. It now proceeds once the abandoned session is older than the dead session threshold. -
Two servers writing to the same offline player within the same second could both report success while one of the writes was silently discarded. Offline writes now carry a write counter that catches this.
-
An offline write made from a snapshot taken before an operator restored an older version could silently undo that restore.
-
An offline write that your callback declined used to still mint a new key version and spend part of the write budget. A decline now writes nothing at all.
-
A refused offline write or a full inbox used to be reported as a DataStore error, which counted toward service health and could push a server into Outage, where it refuses Robux grants.
-
A
Data.OnMessagehandler that yields and never returns silently withheld the acknowledgement, so the message came back on every load with nothing ever logged. The session end now reportsMESSAGE_HANDLER_STALLEDnaming how many were outstanding. -
A second cross-server request arriving within six seconds of the first was discarded rather than queued, so its effect waited for the next autosave instead of landing within about a second. Those requests are now collapsed into a single save.
Replication.
- A replication frame that the transport refused was lost from the server queue and never reached the player, leaving that client’s copy of the data permanently wrong. Scribe now notices the failed send and rebuilds the client from a fresh snapshot.
Everything else.
-
An array
Insertthat Scribe refused, for a nil item, a fractional position or a value the element schema rejects, still evicted an entry first. An array already past itsMaxItemslost every surplus entry to a single refusedInsert. -
Insertwith a non-number position raised a raw Luau error rather than the Scribe message written for it. -
A whole-table
Seton a container did not fireOnKeyAddedorOnKeyRemovedfor the keys it added or dropped, and now does.Clear,InsertandRemovestill do not, which is a known gap. -
A template root field named
RaworStopcollided with Scribe’s own API and was shadowed in silence. Both are now reported asAPI_NAME_COLLISION, and the log entry names the field. -
A bundle that failed to build, for example on a mistyped option, left the Default transport channel claimed. Fixing the option and pressing Play again reported that another Scribe instance already held the channel.
-
A profile holding a key that was neither a string nor a number got no size estimate at all, so the
PROFILE_SIZEwarning that exists to fire before the DataStore ceiling was silently skipped for exactly that profile. The size walk asserted every non-string key was a number and raised on a boolean, table or function key; it now charges such a key a fixed cost and keeps measuring, andPROFILE_UNPERSISTABLEis still what reports that the data cannot be saved.
Changed
Section titled “Changed”-
Data.Flushnow returns true immediately and spends no DataStore request when the profile is already on disk with nothing written since.Force = truestill always goes to the store. -
A leaderboard’s first refresh is now staggered across servers, so a fleet does not read one board in unison. The interval between later refreshes is unchanged and exact.
-
Leaderboard store failures no longer log once per attempt.
LB_READ_FAIL,LB_WRITE_FAILandLB_WRITE_DROPPEDare throttled to one entry per code every 30 seconds, and that entry carries how many it suppressed. The counters still record every attempt. Studio with API access switched off refuses every call for the whole session, so that case is reported once and names the setting to change, rather than repeating for as long as the place is open. -
ProfileStoreIndexandProfileKeyPrefixare now validated only on the server, so a shared bundle module can set them behind a server check and keep the live DataStore name out of client bytecode. -
A
WipeGuardShrinkRatiooutside the accepted range is now clamped and logged asWIPE_GUARD_RATIO_CLAMPEDinstead of being used as given. -
The
Argsentry of a command spec is typed as an array of any instead of an array of string. Most of the declarators were type errors under the previous typing even though the runtime validator accepted them. -
ChangedandObserveon the root accessor now emit a dev warning when the subscription is expensive.
Documentation
Section titled “Documentation”-
The guides were rebuilt so that every example describes one small adventure game with a single shared template. Nine guides are new: values, containers, datatypes, big numbers, time, profiles, gifting, derived and transactions.
-
The getting started guide taught a
Setcall on a flags member that does not exist, so anyone following it hit a runtime error on their first attempt. Every guide now uses theDisablespelling. -
The migrating guide taught importing from another library inside
OnPlayerInit, guarded by a boolean in your own template. That hook runs after reconcile, after the migration chain and after the stored shape check, so imported data met none of them. It now teachesImportLegacyData, which adopts the record before all three and needs no guard field. -
Site search never split on underscores, so searching for a log code such as
PROFILE_SCHEMA_VIOLATIONreturned nothing. The search separator now splits them. -
Value.Add,Value.Enable,Value.Disable,Value.MultiplyandValue.Divideare documented for the first time. They are not new, only newly written up. -
Value.Updatenow carries a warning that the transform receives the live stored table on a table field, so a transform that mutates it and then raises leaves the change in the profile with nothing reported. -
A new guide covers cross key transactions and gives a decision procedure for whether a feature needs one, with the shipped purchase path as a worked example.
-
The configuration guide now states the trade-off in publishing a value through a
Sharedroot, because the number moving is itself information every client in the server can read.
Released 2026-08-09.
- Receipt idempotency ids are now held with a TTL and evicted once the log is full, reported as
PURCHASE_ID_EVICTED, so a long-lived profile stops growing its purchase log without bound. - The untrusted inbound path gained rate limiting and an oversize cap, reported as
INBOUND_RATE_LIMITEDandINBOUND_OVERSIZE_LIMIT.
Released 2026-08-07.
Scribe.Sessionstopped being a visibility of its own and became a modifier that composes with one.Scribe.ServerOnly(Scribe.Session(v))is runtime state only the server sees, andScribe.Shared(Scribe.Session(v))is runtime state everyone sees and nothing saves. CombiningServerOnlyandSharedon the same field is a startup error.- Command handling was reworked alongside it.
Released 2026-08-04.
The largest release of the 1.x line.
Scribe.Bigstores a value past the exact double range as a mantissa and exponent pair, with arithmetic, comparison and display that keep working past it.Scribe.Flagsstores a named set as a packed bitmask.Scribe.SetOfandScribe.MapOfjoinedArrayOfandDictOfas typed containers.OnChildChangedreports every child transition of a container individually, where the container’s ownChangedcoalesces them.
Released 2026-08-01.
- Client accessors no longer materialize a
ServerOnlyfield from its declared default.
Released 2026-07-31.
- Leaderboard
RefreshIntervalis clamped to a floor rather than accepted as written, and the clamp is reported asLB_INTERVAL_CLAMPED. Reading a board name that is not declared is reported asLB_UNKNOWN_BOARD. - The guides were corrected on
Get()and write-through accessors, including a caution that a table handed back byGet()is not a live handle to stored data.
Released 2026-07-29.
- The new
OnCooldownEndedsignal fires when a cooldown lapses while the player is online. A cooldown that lapsed while they were away does not fire it, because “ended” would misdescribe time the player was not there to spend. Data.UpdateOfflinereported success for a write that never landed, and the offline receipt path turned that intoPurchaseGranted. It now reports the store failure, and a failed offline write is counted against health.
1.0.12
Section titled “1.0.12”Released 2026-07-28.
Data.WaitForDataandData.Flushgained timeout arguments.ProfileKeyPrefixhandling in the options table was corrected.
1.0.11
Section titled “1.0.11”Released 2026-07-23.
Scribe.Configuresets process-wide options that belong to the process rather than to a bundle.- Monetization receipt handling was reworked and a strict mode added.
- A
Modethat overrides the older individual flags is reported asMODE_OVERRIDES_LEGACY, and two bundles asking for different save intervals asSAVE_INTERVAL_CONFLICT.
1.0.10
Section titled “1.0.10”Released 2026-07-21.
Scribe.ArrayOfandScribe.DictOfdeclare typed containers whose entries have a shape, andScribe.Optionalmarks a field that has no default and may simply be absent.
Released 2026-07-20.
- The new
OnOwnershipChangedsignal reports a gamepass or a granted perk changing hands.
Released 2026-07-19.
- Replication and error handling were reworked. A profile over the size ceiling is reported as
PROFILE_TOO_LARGE, a command reply that had to be cut short asCOMMAND_REPLY_TRUNCATED, a leaderboard score outside the storable range asLB_SCORE_OUT_OF_RANGE, and a sustained run of malformed frames asMALFORMED_FRAME_LIMIT.
Released 2026-07-18.
OwnsAsyncchecks gamepass ownership against Roblox on every call, whereOwnsanswers from the warm cache.- Publishing to Wally moved to a workflow that refuses to publish unless the version declarations agree.
Released 2026-07-17.
- Hello handshake failures are logged with the reason they failed, and a Scribe running without
access to the transport is detected and reported as
SANDBOXED.
Released 2026-07-16.
- Default value validation was fixed for datatype fields nested inside a record.
Released 2026-07-15.
Scribe.Dynamicseeds a per-profile default from a factory that runs once, when the profile is created, rather than from a value shared by every profile.
Released 2026-07-15.
- Economy analytics emit automatically on a tagged currency mutation, so a
SourceorSinkevent reaches Roblox without a separate call. - The wipe guard reports
WIPE_GUARD_TRIPPED,WIPE_GUARD_BLOCKED,WIPE_GUARD_CLEAREDandWIPE_GUARD_FORCED.
Released 2026-07-15.
First published release.