Skip to content

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.

  • 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. GiftIntentTTL still 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_FULL before completion history exceeds MaxReceiptHistoryBytes (default 1 MiB); recent IDs are not evicted to make room. An unresolved receipt retried after its record expires can grant again. PurchaseIdTTL and MaxProcessedPurchaseIds do 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 reserved ReceiptReservations field 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 carry Scribe.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. ExtendTimed replicates deadline changes by default; false suppresses that update. Value observers do not tick with the clock. Server-only timers, cooldowns, and internal claims remain private. The new unsaved _ScribeSession.Timed field changes the schema hash: deploy server and client together.

  • Added RetiredProducts to preserve historical receipt/gift handlers without offering new sales. Existing paid gift credits remain redeemable; new gifts without a credit refuse with Scribe.GiftReason.ProductRetired.

  • Added the optional ScribeLeaderstats addon. 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. ScribeTelemetry monitors it by default every 30 seconds and sends board degradation/recovery reports through the new Leaderboards route. 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.Stop now 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 ServerStore and reporting it as an unknown option. Store templates now infer field and accessor types on server and client, including typed template modules; client types hide ServerOnly fields. Store roots named Get, 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 LbWritesSkipped diagnostics and corrected the capacity simulation’s ordered-read budget queries and separate standard/ordered experience allowances, with sustained traffic regressions for small servers.

  • 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. Defer retains 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 follows LogLevel. Added LogRingLevel, defaulting to Warn in production and Debug in Studio. Queue overflow preserves higher-severity entries where possible and counts LogSinkDropped.

  • 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.

  • 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.

  • The Studio debug hooks are off unless the new StudioHook = true option 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 _Scribe root gained a Boards child, 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.Erase removes 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 reason erasing and the ErasingMessage kick text, and a join on the erasing server waits at most EraseJoinTimeout seconds 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. Under BudgetPolicy = "Defer" each removal waits for the OrderedRemove budget 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 second Erase while 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.PurchaseReason and Scribe.GiftReason answer 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.PromptPurchase still refuses with the shorter Scribe.ProductState codes, because a shop branches on those rather than showing them. Reported by a community member.

  • Each GitHub release attaches ScribeTelemetry-Addon.rbxm and ScribeUIAdapters-Addon.rbxm beside Scribe.rbxm, labelled as add-ons on the release page, so the optional pieces can be inserted in Studio without a checkout. wally.toml still publishes src alone.

  • EraseJoinTimeout and ErasingMessage options, the erasing lifecycle reason with Scribe.Reason.Erasing, and the PROFILE_ERASE_RESUMED and PROFILE_ERASE_PROGRESS_FAIL log codes, all for the resumable erase described under behaviour changes.

  • Scribe.GetProfileKeyPrefix(), the ProfileKeyPrefix the running server bundle was started with. ScribeTelemetry redacts every context field named Key or ending in Key, 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 own PROFILE_OVERWRITTEN message no longer names the key, which its context carries. A failed request’s error text reaches GetStats().Destinations[name].LastError with 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 a Run and an optional Writes = true that puts it behind the plugin’s write toggle. The Studio hook answers ListAddons and AddonAction, running an action under the plugin’s own write attribution, and the plugin’s Diagnostics tab gains a telemetry preview row. ScribeTelemetry registers Status and Preview on start and removes them on Stop.

  • ScribeTelemetry: telemetry:Preview(destination, kind?) sends synthetic examples of every report design to one destination, from the real builders and through the normal queue, titled PREVIEW: with a field saying the data is synthetic, never mentioning a role, and without touching the summary baseline, performance streaks, groups or mention cooldowns. kind is All, a group such as Health, or one scenario such as Outage; previews count in GetStats().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 a JobId, carries a Server field 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, and Mode = "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 between Daily and Weekly keeps 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, explicit RefreshNow callers share one waiting refresh, nothing waiting starts after Stop, and a refresh in flight at Stop goes 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 the BudgetPolicy gate. It composes with Period, Replicate and GetMyRank, and it is what the guide used to send to Scribe.Shared.

  • Data.GetLeaderboardRefreshIn(name): seconds until a board next reads its store, 0 while a due refresh waits on budget, nil before the first cycle is scheduled or for an unknown name. Data.GetLeaderboardResetIn(name): seconds until a periodic board’s period ends on the PeriodReset clock, always above zero, nil for a board with no period.

  • Three client timers, InitApplyDuration, DiffApplyDuration and SharedApplyDuration, one sample per frame applied: elapsed time from decode to the end of the dispatch, with inline Changed and Observe listeners counted in full and an OnSharedChanged handler to its first yield. Readable from Scribe.GetPercentiles() in a LocalScript; the client debug hook answers GetMetrics like 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 than BytesOutPerSend times a guess at the audience.

  • Every distribution Scribe.GetPercentiles() reports carries Samples, Age and Window beside 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.

  • BytesOut split by frame kind, one counter per kind the wire has (BytesOutDiff, BytesOutInit, BytesOutSharedDiff and the rest), plus BytesOutResync for the handshake bytes spent repairing a client rather than joining one.

  • Per-command metrics, named after the command: CommandDuration:<name>, CommandActive:<name> and CommandErrors:<name> on the server, aggregated across bundles, and RequestTimeouts:<name> (with a RequestTimeouts total) 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 as RequestTimeouts:Other. Both Studio debug hooks carry Percentiles beside the counters, in the GetMetrics reply 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.SessionState and Scribe.Visibility, so a caller can branch on a named constant instead of pasting a string. Scribe.LifecycleReason is added as the matching name for Scribe.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 _Scribe root, 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 one Changed pass per container, as Server.Batch does 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 in addons/telemetry/, posts Scribe’s health transitions, every Error and Fatal entry, 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 has Test, Flush, GetStats and Stop, and a handle disabled in Studio answers GetStats with the same zeroed counters as a running one. Typed end to end under the new solver: Options, Handle, Stats and the ScribeModule slice 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. Url is 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 from discord.com says so in GetStats), or a service of your own. The guide records the live checks that remain unperformed.

  • Data.Exchange.Discard treated 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 proof Attempt makes, and refuses with a reason naming the side whose save did not complete.

  • A Mode = "NoSave" bundle never saved its own session but still let UpdateOffline, RestoreVersion, Erase and SendMessage write 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 log NOSAVE_WRITE_REFUSED; the gift receipt answers NotProcessedYet, so a live server delivers it.

  • A ResetData wipe 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 the isNew flag OnPlayerInit receives 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 Send writes 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’s SharedInit and again in the next SharedDiff; a write to the new player made while their own SharedInit was 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 Insert landed as two elements and a Remove, 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 whose Send runs game code that writes the store while the Init is prepared: however many such writes there are, each lands past the boundary.

  • PROFILE_SCHEMA_VIOLATION reports 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 two GetAsync reads 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, Set and Update typed 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 typed Action: string as “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 to Set is 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 a Scribe.Enum field, whose members are enforced at runtime but type as string.

  • A board refresh in flight at a Data.Erase, whether parked on GetSortedAsync or 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 RequestOnce replay 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 same reply-encode-failed it 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.

  • FlushDuration records 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 through Data.ServerStore saw 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; OnSharedChanged fires 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.

  • 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/ to addons/ui/ and are named for the file you copy in: ScribeVide.luau, ScribeReact.luau and ScribeFusion.luau. addons/ now holds every official add-on, with addons/README.md as the index; wally.toml still publishes src alone. 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 the Value, which is what a scope is for. It used to build the Value in 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.

  • 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 now path.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 Purchase and PromptGift reason tables say which members are for the player and which mean the call itself is wrong.

  • The cost guide indexed .Status on Scribe.GetStatus(), which returns the status string itself. The visibility guide now states what a Shared root 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.

  • PromptPurchase’s refusal reasons are now the Scribe.ProductState strings: player data not loaded became not-loaded and player already owns "VIP" became owned. 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 read Scribe.ProductState instead. Scribe.PurchaseReason and Scribe.GiftReason gain PaidRandomRestricted and PolicyPending.
  • PaidRandom = true on a product declares a paid random item (a pass grants a fixed perk and refuses the flag at startup). PromptPurchase refuses it for a player whose policy restricts paid random items, and while that policy is not yet known. Purchase refuses a spec carrying the flag, which is the in-experience currency half of Roblox’s rule. PromptGift refuses 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 logged PAID_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_FAIL once, 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 and Data.GetProductState(name) on the client answer one of Scribe.ProductState: purchasable, owned, paid-random-restricted, policy-pending or not-loaded. Every value but the first is exactly the reason PromptPurchase refuses 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. GetPolicyInfoAsync is the test seam, on both realms. Proposed by a community member.

  • 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’s OnChildChanged key and value unions over every child, and the union of every numeric leaf path behind Stat and Cost.Path. Past twelve children the pair is typed string and any?; past 32 numeric paths the path type is plain string. 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.

  • A write to the server store from inside Data.Transaction is 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 returns true; the cross-player refusal, which points at the durable outbox instead, is unchanged.

  • Assigning to an accessor is now a type error. data.Coins = 5 and data.Coins.Set = f report 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.

  • ServerStore declares a second tree owned by the server rather than by any player. One table for the whole server, reached as Data.ServerStore.Wave with 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.ServerOnly keeps a store root off the wire. Scribe.Session, Scribe.Shared, Scribe.Timed, Scribe.Dynamic, a name the template already declares, a _Scribe prefix, and a Scribe.Derived reading across the boundary are all refused at startup with a message naming the field. Containers, records, bounds, Scribe.Big and same-side derived fields work normally.

    A bundle that declares no ServerStore builds no store tree, exposes no Data.ServerStore on either realm, and does not pay so much as a function call per frame for the feature.

  • ImportLegacyData can 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 carrying AwaitBudget(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.GetState gained 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. OnPlayerInit receives the same context as a fourth argument, because ImportLegacyData only 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: UpdateAsync is reserved at the online session count, since its worst case is the shutdown drain saving every session at once. The new MigrationConcurrency option (default 2) bounds how many imports run at once, which is the only hard limit available, because a hook that never calls AwaitBudget cannot be paced. A hook running past thirty seconds now warns SLOW_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 publishes src only, 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, useEffect and useBinding. It avoids useSyncExternalStore, the React 18 hook for exactly this, because that port does not export it. useScribeBinding updates 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 an ImportLegacyData hook that threw each have their own wording now, and each has its own option: RateLimitedMessage, MigrationFailureMessage, SchemaFailureMessage and LegacyImportFailureMessage.

    VersionAheadPolicy = "Kick" is split out too, as VersionAheadMessage. 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. StartSessionAsync answers 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.

    LoadFailureMessage still 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. KickOnSessionEnd had 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 is SessionStolenMessage, decided by the same LocalSessionEnd flag Scribe already uses to tell a local release from a steal. A session that went away mid-load gets SessionInterruptedMessage, 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 to SessionEndMessage. Wording your load failures does not silently reword your session ends.

  • migration.AwaitBudget now 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 GetSortedAsync allowance held back from a migrating ImportLegacyData hook 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 optional Release for it.

  • The budget pacer reports itself: BudgetWaiters, BudgetQueued, BudgetGranted, BudgetTimedOut and a BudgetWaitSeconds distribution, 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.GetProductInfo returns the whole Marketplace table, so UserBasePriceInRobux and PriceDiscountDetails are there for a “was 100, now 80” button, and Data.ObserveProductInfo is the reactive form for a shop that should repaint when the value lands. Data.PrefetchProductInfo warms a list, or everything declared, as one batch.

    Client-only on purpose. A live server’s GetProductInfoAsync has 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 price nil rather than falling back to the catalog number, and logs PRODUCT_INFO_FAIL. Prices are re-read once, the first time the local player’s HasRobloxSubscription flips.

  • A non-finite number is refused as a command argument. typeof(0/0) is "number", so a bare "number" in Args accepted NaN and handed it to the handler, where every comparison against it is false: a guard written as if amount > 0 takes 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, since Scribe.Int and Scribe.Number already refused non-finite values. Now refused whatever the spec says, "any" included, because no number Scribe can store is non-finite. Scribe.Derived was 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 Args and in Scribe.Derived inputs. 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 Args that left the middle argument decoded and handed to the handler without a type check, because the arity check read # and admitted it. For Scribe.Derived it silently narrowed the field: { "A", nil, "B" } became a two-input derived, the “inputs must be strings” check never saw the gap, and Compute took 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. The Args half was reported by a community member against 2.1.1; the Scribe.Derived half was found by sweeping for the same shape.

  • Owns, OwnsAsync and ObserveOwned raise 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 hit warnedOwnsKeys[key] = true inside 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 behind DevMode and never ran, so the same call answered false and 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, so Owns(player, passId) and a missing config field name themselves. Reported against 2.1.1.

  • A Scribe.Big inside a container’s element shape, as in Scribe.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 the Scribe(...) 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.

  • 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 ServerStore under 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.ExchangeableSpec is exported, and the Exchangeable option is now declared on ScribeOptions. 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 on Path, Kind, Count, Identity and Ignore.
  • Data.PromptPurchase, Data.Exchange and Data.Stop were missing from the type Scribe.Server returns, 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.Exchange is now typed in full, including what Open hands back. Data.PromptPurchase and Data.Exchange were new in 2.1.0; Data.Stop had been missing since before 2.0.0.
  • 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.OpenExchange declares State as "Claimed" | "Staked" | "Delivering" rather than string, so a game can narrow on it. Assigning what Data.Exchange.Open returns to a { Scribe.OpenExchange } did not type-check before this.

  • 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}. A Scribe.Big board 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.Big declared 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.

  • Scribe’s reserved _Scribe root gained two subkeys, Exchange for a trade while it is in flight and ExchangeInbox for 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 logs SCHEMA_MISMATCH and 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 ResetData option now refuses to wipe a profile that holds an in-flight or undelivered exchange. The load reports EXCHANGE_RESET_REFUSED at 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.RestoreVersion now returns false with a reason, and logs PROFILE_RESTORE_FAIL, while the profile holds an in-flight or undelivered exchange, whatever RollBackReserved is 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. PromptGamePassPurchaseFinished reports that the purchase dialog closed rather than that a transaction completed, and a game pass has no ProcessReceipt to 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 logs PASS_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 Products and Passes now 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 OnPlayerInit hook is handed the profile data directly, before the accessor tree that refuses reserved writes exists, so that hook and a Scribe.Dynamic factory 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 as EXCHANGE_INIT_TAMPER at Error, which is what a starter kit clearing _Scribe to start clean looks like. Only the exchange ledger is put back: receipts, perks and the rest of that root are not.

  • Data.Exchange.Attempt moves 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 Exchangeable option is the allowlist of what a game may exchange, and nothing outside it can be traded. Each entry names a Path and a Kind, 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 Key leg moves one whole entry of a DictOf, MapOf, SetOf or ArrayOf. A Qty leg moves an Amount off a balance, which must be a Scribe.Int field declaring a Min. A Stack leg moves part of one entry of a DictOf or MapOf, splitting the count held on that entry and leaving the rest of it behind.

  • A Stack declaration names Count, the element field holding how many, and must then classify every other field the element declares: Identity for a field that travels with both halves of a split, Ignore for 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 a Scribe.Number, on a Scribe.Big, on a Scribe.Optional or on a field declaring no Min; a Scribe.Flags or a derived field; a non-persisted root; a Scribe.Timed field or an Evict container anywhere in the subtree; and anything under Scribe’s reserved _Scribe root.

  • Every leg of both baskets is checked against the Exchangeable declarations, 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.Open reports what one player still has in flight: an entry per exchange carrying Id, a State of Claimed, Staked or Delivering, the Partner UserId, Staked for what they handed over, Owed for what they are owed, and Since. 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.Settle and Data.Exchange.Redirect are operator verbs for an exchange the automatic machinery cannot finish, and all three act on profiles loaded on the server they are called from. Discard drops an abandoned claim that never took value, and refuses an exchange that already has a verdict or that holds escrowed value. Settle forces Commit or Abort on one that cannot resolve itself, has no default verdict, and refuses a Commit when only one side is loaded or when a side holds escrow with no take recorded. Redirect lands 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.PromptPurchase prompts 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 against Products and Passes, 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 own Data.Owns check; a product with no Grants is 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 LoadDuration metric records how long a profile took to become ready, in seconds, and reads through Scribe.GetMetrics and Scribe.GetPercentiles like 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 as SLOW_LOAD, naming the player and how long it took. That threshold is fixed and sits well below LoadTimeout, 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. An Aborted exchange 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_RESOLVED records each exchange that reaches a terminal state, EXCHANGE_UNRESOLVED reports one that cannot, and EXCHANGE_PARKED reports a settled exchange whose delivery has nowhere to land, which is the condition Data.Exchange.Redirect exists 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 Stack leg a merge that would pass the count’s Max or land on an entry whose other fields do not match the one being delivered.

  • Ignore works on a Key leg 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 a Key leg: one left unlisted simply travels, which is always conserving.

  • Moving a whole stack is the same Stack leg with Amount equal 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 a Key declaration as well. Where the count declares a Min, 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.OpenExchange and Scribe.OpenLeg are exported types, so a basket you build and the entries Data.Exchange.Open hands back both type-check. The LogCode union gained eight exchange codes alongside SLOW_LOAD and PASS_PURCHASE_UNCONFIRMED, and LogCategory gained Exchanges.

  • A bundle whose Mode is Mock or NoSave serves 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 logs EXCHANGE_STORE_UNAVAILABLE and refuses attempts rather than falling back to a store no other server can see.

  • The new FlushDuration metric records what one frame of replication cost, in seconds, and reads through Scribe.GetMetrics and Scribe.GetPercentiles. Nothing is recorded for a frame that flushed nothing, so the distribution describes busy frames rather than the average frame, and the existing FlushEntriesPerFrame and FlushQueuedPerFrame counts 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 Scribe memory 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 Stack on a SetOf or ArrayOf, neither of which has a keyed stack to split; a Count naming a field that is not a Scribe.Int declaring a Min; a Count that is also ignored; a name in Count, Identity or Ignore that the element does not declare; a field listed in both Identity and Ignore; a container field listed in Identity, where a split would duplicate the whole collection; Ignore on a quantity leg; and Count or Identity on a leg that is not a Stack.

  • 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 PassPurchasesUnconfirmed metric counts finished game pass purchases that the ownership check would not confirm, and the new PurchasePrompts metric counts the prompts Data.PromptPurchase opened. Both are reported by Scribe.GetMetrics.

  • Scribe.Short renders a quantity the way a player reads it, as 1.5K or 100M, and takes either a plain number or a Scribe.Big value, so a balance label no longer has to branch on which numeric type the field happens to be declared as. Scribe.SetShortSuffixes replaces the suffix table it and a big value’s Short method render with, for a game whose convention past T is not Scribe’s Qa, 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 for loop over an accessor, as in for 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 reached Set with 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 names Get for a read-only walk and Clone for a table you may edit. Calling a node with two nil arguments is refused for the same reason, while a deliberate Set(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.Flush returned false for data that was on disk, and the receipt path reads that same answer before it decides whether to report PurchaseGranted.

  • In DevMode, UNDECLARED_PERK warned about a product whose Grants names a declared pass, and about that same name passed to Data.GrantPerk. A game pass cannot be transferred, so granting a perk of the pass’s own name is how a gift confers it, and Data.Owns already answers across both namespaces. A declared pass name is now accepted in both places without a warning.

  • The PROFILE_LOADED log entry now carries LoadSeconds, the time between the player joining and their data being ready, so a sink added with Scribe.AddLogSink can attribute one slow join without reading the metric.
  • 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, Identity and Ignore on 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 why Settle refuses 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, FlushDuration and ProfileSize distributions and why the p99 is the number to read, the SLOW_LOAD and PROFILE_SIZE warnings, Scribe.GetStatus and Scribe.GetBudgetSnapshot, what the single Scribe.Flush MicroProfiler label and the Scribe memory category do and do not cover, and what Scribe deliberately does not measure.

  • The configuration guide gained an Exchanging section covering the new Exchangeable option and every field of a leg spec: Path, Kind, Count, Identity and Ignore. It states that an ignored field is destroyed in transit rather than escrowed, and that a Stack element field named in neither Identity nor Ignore refuses 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 both Products and Passes, 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_UNCONFIRMED is 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 for SLOW_LOAD and PASS_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.

  • 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_MISMATCH instead 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.toml when you upgrade.

  • Game code can no longer write anywhere inside Scribe’s reserved _Scribe root, 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.OnMessage or 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.RestoreVersion no longer rolls the reserved _Scribe root 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 new RollBackReserved option restores the old behaviour when that root is itself what needs repairing.

  • A migration step that changes the reserved _Scribe root now has that change discarded and the stored root kept, reported as MIGRATION_RESERVED_DISCARDED. A migration that rebuilt the profile from its own key list used to destroy receipt idempotency and paid gifts in silence.

  • Data.SendMessage now returns false and logs MESSAGE_QUEUE_FULL when 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 Min or Max on Scribe.Number, or a MaxLength that is not a non-negative integer, now fails to compile. This fails at startup rather than in production, and a negative MaxLength previously deleted the end of every value it was applied to.

  • Data.UpdateOffline now 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.WaitForData can now answer still-loading where it used to answer timeout. A load that is merely slow is worth retrying, so code branching on timeout should 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_PARTIAL and counted in ReceiptsPartial. It used to be retried, which compounded the writes it had already made.

  • Two pass names sharing one gamepass Id now fail to boot, matching the refusal products have always had for a duplicate Id. 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.

  • tostring on a Scribe.Big now keeps the fractional part instead of rounding to a whole number, so a third of ten prints as 3.33333333333333. The numbers inside bounds error messages change with it.

  • Dividing a Scribe.Big by zero now raises instead of returning nil.

  • A Set that writes the value a field already holds no longer fires Changed or queues a replication op on a Scribe.Big, a flags field or a datatype field. Those three used to fire where the identical no-op on an integer cost nothing.

  • SchemaPolicy now defaults to Warn under DevMode and 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.Request now returns Scribe.RequestFailed as a third value whenever the refusal is Scribe’s rather than your handler’s. Only a caller that forwards the results of Data.Request straight 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.Derived declares 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.RequestOnce sends 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 through RequestOnce. The requirement is symmetric, so a key sent to a plain command and a keyless call to an idempotent one are both refused.

  • PurchaseSpec gained an optional IdempotencyKey, and a repeat under the same key returns exactly what the first call returned and spends nothing. The new PurchaseClaimTTL and MaxPurchaseClaims options govern how long a claim is kept and how many may be live on one profile.

  • Data.Stop releases 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.Number gained a Precision option 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.CFrame gained Precision = "exact", which packs every component bit for bit at 49 bytes instead of the default 13 or 29. Scribe.Datatypes.Pack takes the same value as an optional third argument.

  • A Scribe.Big value now supports Pow for a non-negative integer exponent and Log10. Both are reads that return a new value, and Pow refuses a fractional, negative or non-finite exponent.

  • The new SchemaPolicy option checks stored data against the template when a profile loads. Only a table mixing array indices with string keys ends the session under Reject, and under that setting a bounded Scribe.Big outside 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_OVERSIZE once 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 matching MaxOutboundBytes setting.

  • Scribe.GetPercentiles returns the P50, P90 and P99 of each recorded metric, which GetMetrics could not report. It is computed over the most recent 256 samples per name, so it does not agree with the all-time count.

  • Scribe.GetBudgetSnapshot reports the DataStore request allowance the engine currently gives, by request type. Its Available field is false when the engine could not be asked at all.

  • Scribe.AddLogSink now 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.PurchaseReason and Scribe.GiftReason name the fixed refusals of Data.Request, Data.Purchase and Data.PromptGift, each with a matching exported type.

  • The new ImportLegacyData option 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 LoadTimeout option bounds how long a profile load is given, defaulting to 120 seconds with a floor of 60.

  • The new LogRingSize option sets how many recent entries GetRecentLogs retains, which used to be fixed at 512.

  • The new MaxOutboundBytes option caps the bytes in one outbound frame before fragmentation, defaulting to 65536 with a floor of 256.

  • The new MaxInboundRetainedBytes option caps how much memory one inbound client frame may cause the server to retain, defaulting to sixteen times MaxInboundBytes.

  • The new BudgetPolicy option, whose only value is Defer, paces the two leaderboard background loops against the DataStore request allowance. It deliberately touches no save path.

  • The new IsRunning option overrides the RunService:IsRunning() default, and is the seam a storybook or a test harness uses to pick which half of the bundle gets built.

  • A leaderboard Stat may 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_RESYNC log entry and a MirrorResyncs metric record every time Scribe rebuilds a client’s copy of the data after a send failed.

  • In DevMode, Scribe now warns with GRANT_SEEDED_ELEMENT when a purchase grant creates a container element merely by writing through its key, which is what a stale or mistyped id looks like.

  • The LogCode union gained thirty nine new codes across persistence, integrity, replication, transport, commands, monetization, gifting and leaderboards, and LogCategory gained Derived. 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.DeliveryUnconfirmed and logs GIFT_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 MessageAsync now 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_UNCONFIRMED with the error.

  • MESSAGE_QUEUE_FULL and MESSAGE_SEND_FAIL claimed 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 carry Context.ProvablyClean, and the new MessageQueueFullAmbiguous counter 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 WipeGuardPolicy set to Block, Data.Flush could return true for a save that had swapped the payload and left the old value on the key. Monetization answers PurchaseGranted off that boolean, so a paid grant could be acknowledged without ever being stored.

  • A run of AwaitSave calls, or of Client.Request round trips, against a store or transport that answered immediately could walk the calling thread into the engine’s task.defer re-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’s PurchaseGranted answer with it. Neither path wakes its caller through the defer queue any more.

  • Duplicate receipts are logged again at Info as RECEIPT_DUPLICATE, alongside the ReceiptsDuplicate counter 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.RestoreVersion could 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.UpdateOffline refused forever when a player’s session had been left behind by a crashed server, while Erase and RestoreVersion already 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.OnMessage handler 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 reports MESSAGE_HANDLER_STALLED naming 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 Insert that 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 its MaxItems lost every surplus entry to a single refused Insert.

  • Insert with a non-number position raised a raw Luau error rather than the Scribe message written for it.

  • A whole-table Set on a container did not fire OnKeyAdded or OnKeyRemoved for the keys it added or dropped, and now does. Clear, Insert and Remove still do not, which is a known gap.

  • A template root field named Raw or Stop collided with Scribe’s own API and was shadowed in silence. Both are now reported as API_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_SIZE warning 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, and PROFILE_UNPERSISTABLE is still what reports that the data cannot be saved.

  • Data.Flush now returns true immediately and spends no DataStore request when the profile is already on disk with nothing written since. Force = true still 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_FAIL and LB_WRITE_DROPPED are 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.

  • ProfileStoreIndex and ProfileKeyPrefix are 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 WipeGuardShrinkRatio outside the accepted range is now clamped and logged as WIPE_GUARD_RATIO_CLAMPED instead of being used as given.

  • The Args entry 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.

  • Changed and Observe on the root accessor now emit a dev warning when the subscription is expensive.

  • 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 Set call on a flags member that does not exist, so anyone following it hit a runtime error on their first attempt. Every guide now uses the Disable spelling.

  • 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 teaches ImportLegacyData, 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_VIOLATION returned nothing. The search separator now splits them.

  • Value.Add, Value.Enable, Value.Disable, Value.Multiply and Value.Divide are documented for the first time. They are not new, only newly written up.

  • Value.Update now 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 Shared root, 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_LIMITED and INBOUND_OVERSIZE_LIMIT.

Released 2026-08-07.

  • Scribe.Session stopped 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, and Scribe.Shared(Scribe.Session(v)) is runtime state everyone sees and nothing saves. Combining ServerOnly and Shared on 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.Big stores a value past the exact double range as a mantissa and exponent pair, with arithmetic, comparison and display that keep working past it.
  • Scribe.Flags stores a named set as a packed bitmask.
  • Scribe.SetOf and Scribe.MapOf joined ArrayOf and DictOf as typed containers.
  • OnChildChanged reports every child transition of a container individually, where the container’s own Changed coalesces them.

Released 2026-08-01.

  • Client accessors no longer materialize a ServerOnly field from its declared default.

Released 2026-07-31.

  • Leaderboard RefreshInterval is clamped to a floor rather than accepted as written, and the clamp is reported as LB_INTERVAL_CLAMPED. Reading a board name that is not declared is reported as LB_UNKNOWN_BOARD.
  • The guides were corrected on Get() and write-through accessors, including a caution that a table handed back by Get() is not a live handle to stored data.

Released 2026-07-29.

  • The new OnCooldownEnded signal 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.UpdateOffline reported success for a write that never landed, and the offline receipt path turned that into PurchaseGranted. It now reports the store failure, and a failed offline write is counted against health.

Released 2026-07-28.

  • Data.WaitForData and Data.Flush gained timeout arguments.
  • ProfileKeyPrefix handling in the options table was corrected.

Released 2026-07-23.

  • Scribe.Configure sets process-wide options that belong to the process rather than to a bundle.
  • Monetization receipt handling was reworked and a strict mode added.
  • A Mode that overrides the older individual flags is reported as MODE_OVERRIDES_LEGACY, and two bundles asking for different save intervals as SAVE_INTERVAL_CONFLICT.

Released 2026-07-21.

  • Scribe.ArrayOf and Scribe.DictOf declare typed containers whose entries have a shape, and Scribe.Optional marks a field that has no default and may simply be absent.

Released 2026-07-20.

  • The new OnOwnershipChanged signal 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 as COMMAND_REPLY_TRUNCATED, a leaderboard score outside the storable range as LB_SCORE_OUT_OF_RANGE, and a sustained run of malformed frames as MALFORMED_FRAME_LIMIT.

Released 2026-07-18.

  • OwnsAsync checks gamepass ownership against Roblox on every call, where Owns answers 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.Dynamic seeds 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 Source or Sink event reaches Roblox without a separate call.
  • The wipe guard reports WIPE_GUARD_TRIPPED, WIPE_GUARD_BLOCKED, WIPE_GUARD_CLEARED and WIPE_GUARD_FORCED.

Released 2026-07-15.

First published release.