Reference
Changelog
What changed in each simulator release, newest first.
Slack
v0.0.19 — 2026-10-12
-
A world can carry its own timeline. A
--seedfile, or a whole one posted toPOST /_rystic/seed, may addcredentials, binding presented credentials to actors, andevents: calls the twin makes on its own API at set sim times as one of those actors, through the same handlers and egress as your requests. A top-levelcredentialsoreventskey the twin used to ignore now takes effect.RYSTIC_PACE=<speed>plays the clock, and the timeline, in real time, and an event’satmay be+<duration>./_rystic/inject,--injectandRYSTIC_INJECTnow needstate_setinstead ofscenarios. (RYS-2422) -
A timeline call can hand what it minted to later events. A
callevent may addbind,{"<name>": "<JSON Pointer>"}, saving fields of its answer, such as an ID the twin minted; a later event uses one as${name}in a call’s path, headers or body or in a seed event’s record keys and values (a register name cannot be bound). A schedule naming a${name}no earlier call binds is refused at load, and an event whose binding call failed records an error instead of firing. (RYS-2505) -
A clock jump fires each timeline event at its own time. When one
POST /_rystic/clockor paced tick passes several events, each fires with the clock at itsat, so the records it creates carry that time, and the clock then lands where the move asked. An event already due when the clock moves fires at the current time. (RYS-2520) -
A clock jump stamps each timeline event at its exact instant. When a clock move passes timeline events, the records each one makes carry exactly its
at, to the millisecond, whatever traffic came before it, so one jump leaves the same records as stepping the clock to each event in turn. (RYS-2542) -
A timeline call can carry raw bytes, XML or a JSON array. A
callevent may sendbody_textorbody_base64instead ofbody; the twin sends it as written, under the route’s own content type (XML, bytes, JSON or a form), so a scheduled call can upload an object’s bytes or post an XML document, and${name}fills inbody_text. Abodymay be a JSON array where the route reads one. A call that answers XML binds from its elements by JSON Pointer, such as/InitiateMultipartUploadResult/UploadId. (RYS-2523)
v0.0.18 — 2026-10-11
-
The verified UI installs and starts with
rystic ui slack. The Slack client built against Slack’s own client is a Pro and Enterprise deliverable: it ships as the imageregistry.rystic.ai/slack-twin-ui:<ver>beside the twin’s, pulled with the samedocker login, andrystic uiruns it against a running instance (or, without Docker, the UI’s archive on your Node 22+). A Free key is refused withfeature_not_in_plan, naming theverified_uientitlement; the Web API and the embedded state UI stay on every plan. (RYS-2403) -
The scripts in this archive are MIT-licensed.
check.shand the archive’s other scripts are published under the MIT License at https://github.com/Rystic-Inc/rystic-scripts , so you can copy, change and share them.LICENSE.txtno longer covers them; it still governs the twin itself.
v0.0.17 — 2026-10-10
-
Scheduled messages post when the twin’s clock reaches them. A
chat.scheduleMessageposts once, as its author and in its thread, when simulator time reachespost_at, and then leaveschat.scheduledMessages.list; a cancelled message never posts. (RYS-1572) -
Ephemeral posts reach the activity stream, and stream starts keep their chunks. A successful
chat.postEphemeralemits oneslack.message.ephemeralevent on/_rystic/eventsnaming its recipient, and never enters history. Achat.startStreamwithchunksand nomarkdown_textstores the chunks’ text as the message text. (RYS-1575) -
Closing a DM closes it only for you.
conversations.closecloses the DM for the caller alone and leaves the other participants’ view untouched;conversations.openon it reopens it for the caller with their read position kept. (RYS-1570) -
Deleting a message removes its pin and its reactions, as Slack does. After
chat.delete,pins.liston the channel no longer lists the message, andreactions.geton it answersmessage_not_found. A thread parent kept as a tombstone, because it still has replies, keeps both; that case is not yet measured. The pin behavior was measured against a live workspace. (RYS-1858) -
Conversation answers name your workspace’s team, and list and info agree.
conversations.create,invite,join,rename,setTopicandsetPurposeanswershared_team_idswith your workspace’s team and leave out thelatestand unread counts Slack omits there;conversations.listandconversations.infogive one conversation the samecreatorandupdated, whoever asks, and a new group DM is namedmpdm-…from its members’ handles.setPurpose,renameandjoinleave their system message in channel history, and a streamed message carries your bot’sbot_id,app_idandbot_profile, reads back asin_progresswith the text sent so far untilchat.stopStream, and finishes ascompleted. (RYS-885) -
A deleted file leaves a tombstone on its share, and a file names its uploader’s team. After
files.delete, a message that shared the file keeps it as{"id": …, "mode": "tombstone"}, as Slack does, andfiles.infoanswers the uploader’s team inuser_teaminstead of a placeholder. (RYS-891) -
Refusals carry Slack’s diagnostics, and a published view names your app. Every
invalid_argumentsrefusal carriesresponse_metadata.messages,conversations.inviterefusals carry the per-usererrorsarray, andviews.publishanswers your app’sapp_idandbot_idrather than fixed placeholders. Apost_atthat is not a timestamp onchat.scheduleMessage, a presence other thanautoorawayonusers.setPresence, andmarkdown_textbesidechunksonchat.stopStreamanswerinvalid_arguments, as Slack does.conversations.invitewithforce=truepasses over an unknown user only while another named user can still be added, so beside members alone it answersuser_not_found. (RYS-889) -
Answers carry what Slack’s documented responses fix. Where a handler leaves a field absent or null, the twin answers with the constant, the documented default or the
oktag the method’s documented response fixes for that status, error envelopes included; a value the handler wrote is never replaced. (RYS-1905) -
Tokens are read and refused the way Slack reads them. A token that is not a Slack token (
Bearer x,Bearer token) now answersinvalid_authinstead of being accepted. A malformedAuthorizationheader —Beareralone, or a second one — answersnot_authed, as live Slack answersBeareralone on every method butapi.test, which it answersok, though live Slack refuses a second header withinvalid_auth; a header under another scheme, such asBasic, presents no token and answersnot_authedtoo. A form-encodedtokennow wins over the header, and of two form tokens the last, as live Slack answers;api.teststill answers without a token but refuses an invalid one. Rotatedxoxe.xoxb-/xoxe.xoxp-, app-levelxapp-and workflowxwfp-tokens are recognised kinds, so an app-level token onusers.identityanswersnot_allowed_token_type. (RYS-865) -
A write moves the records it relates to, as Slack does. A stream started in a thread (
chat.startStreamwiththread_ts), or a file shared into one (files.completeUploadExternalwiththread_ts, naming its one conversation inchannel_idorchannels), moves its parent’sreply_count,reply_users,reply_users_countandlatest_reply, as a posted reply does, and the file’s share message is a reply in that thread.pins.removetakes the channel off the message’spinned_to, soconversations.historycounts one pin fewer.conversations.infoanswers yourlast_read, whichconversations.markmoves. (RYS-1909) -
An empty workspace holds the bot every token acts as. A twin started without a scenario, and the workspace
POST /_rystic/resetwipes to, now hold one bot user,tb-member-user-idof teamtb-member-team-id, and every token acts as it. A method that names it now finds it:users.listlists it, andusers.infoandusers.profile.get, among others, answer it instead ofuser_not_found. A user token’susers.identityanswers it instead ofinvalid_auth, and what you create is authored by it: a channel’screator, and a message’suser,team,bot_idandapp_id, where a mintedU00000001of teamT00000002authored them before. The workspace still holds no team record, soauth.testanswersinvalid_authas before. A seed that keys its own caller undermemberreplaces this bot; one that keys every user by itsidkeeps it beside them, and every token still acts as it. (RYS-1911) -
HOWTO.md counts every route the twin answers. Its coverage sentence reads 274 routes where it read 73: each of the 67 modelled methods counts at
GETandPOSTon both/<method>and/api/<method>, asGET /_rystic/routeslists them. The twin answers what it did before. (RYS-1918) -
A file is shared only once its bytes are uploaded, and a /me message reads back as Slack’s.
files.completeUploadExternalon a file whose bytes never reached itsupload_urlstill answersok, but leaves the file untyped and unshared, posts no message and moves no thread parent, as Slack does: upload the bytes first to share a file. Achat.meMessagemessage reads back with your app’sapp_id, arich_textblock and noteam, andreactions.getanswers a message’spinned_towhile it is pinned. (RYS-1909) -
A first close answers its flags, an in-flight stream reads back empty, a thread parent carries its thread_ts, a broadcast reply and a /me edit answer no bot_profile, and an IM carries is_archived.
conversations.closeanswers the first close of an open DM withno_op: falseandalready_closed: falsebesideok.reactions.geton a stream still in flight answers an emptytextwithstreaming_state: in_progress, the bot’sbot_idandapp_id, andparent_user_idon a reply. A thread’s parent answersthread_tsequal to its owntsonce any reply joins it, a streamed reply included.conversations.historylists a broadcast reply (reply_broadcast) with nobot_profileorteam, andchat.updateof a/memessage answers neither. An IM’sconversations.infocarriesis_archived, and itslatestbot message’sbot_profilecarries aname. (RYS-885)
v0.0.16 — 2026-10-02
-
Socket Mode envelope ids come from their own range. The first envelope the twin sends carries
envelope_idcdf8b689-d7dc-46e6-8000-000000000001rather than00000001-0000-4000-8001-000000000001, and later ones count up in their last digits. A client that acknowledges the id it receives sees no difference; a test that pinned an envelope id needs the new one. (RYS-1593) -
Post as a person with
X-Rystic-Surface: client. Achat.postMessagemade as a seeded non-bot user with the headerX-Rystic-Surface: clientstores and emits the message that user would type in the Slack client:user,team,text,blocksand aclient_msg_id, with nobot_id,app_idorbot_profile, so an app that ignores bot messages starts a turn on itsmessageandapp_mentionevents. Posts without the header keep their app attribution, as on Slack. (RYS-1663)
v0.0.15 — 2026-10-01
-
check.shfails fast on a port that never answers. Its port check and boot wait give each/_rystic/versionprobe two seconds, so an address where something accepts connections but never replies fails the check with a message instead of hanging. (RYS-1444) -
Your bot receives Events API events, over Socket Mode or at a request URL. Run the twin with
SLACK_TWIN_SOCKET_MODE=1and point Bolt at it with anyxapp-token, or setSLACK_TWIN_EVENTS_URLandSLACK_TWIN_SIGNING_SECRETto have each event POSTed signed. Messages, edits, deletes, mentions, reactions, pins, file shares and channel joins, leaves, creates, renames and archives emit the events Slack sends for them. (RYS-1605)
v0.0.14 — 2026-09-30
-
Inspect offers an identity view, and an injected 429 can carry
Retry-After./_rystic/inspect?view=identitiesfolds identical actor-label aliases for display; the default export retains all records so a reset followed by seeding restores caller identity. A network profile’srejecttakes validatedheaders, so an injected429can send theRetry-Aftera client’s backoff reads. The docs now say what file downloads answer, howstate_reactionis keyed, and that a deleted file’s share message keeps its full file. (RYS-1262) -
File URLs serve the bytes. A
GETof a file’surl_privateorurl_private_downloadwith a Bearer token returns the bytes you uploaded, under the file’smimetype(the download form as an attachment); without a token it answers401, and a file whose bytes never reached the twin answers404.files.completeUploadExternalnow returns the file’smimetype,mode,pretty_type,is_publicandurl_private_download, as Slack does. (RYS-1265) -
Thread identities and query errors. Thread replies preserve an existing bot ID from the caller. Missing-argument errors from permalink, file-info, and email-lookup reads, and invalid-limit errors from reactions-list and user-conversations reads, include populated response diagnostics; replies and file-info reads reject non-integer
limitvalues. (RYS-1263, RYS-1264) -
The state UI shows the workspace as a Slack client. On Pro and Enterprise,
/_rystic/uiopens a live Slack-style view of the twin — channels, threads, DMs, Block Kit, App Home and modals — where you can view as any seeded user and post, reply, react, pin, edit or join as them through the Web API, beside Your Bot, Scheduled messages and Views cards. (RYS-1327) -
The state UI takes the rystic.ai look.
/_rystic/uiand its Your Bot, Scheduled messages and Views cards now read in the site’s paper, ink and Space Grotesk instead of the dark theme; the workspace card keeps Slack’s own look. (RYS-1332) -
Unmodified clients reach the twin at Slack’s hostnames.
rystic run slack-twin --interceptboots the twin behind the rystic CLI’s HTTPS gateway, andrystic exec -- <command>routes that command’s calls toslack.comandslack-files.comto it, for supported SDKs using their default transports. (RYS-1398) -
Missing and invalid arguments answer Slack’s errors. A call missing a required argument answers
invalid_argumentswith Slack’sresponse_metadata.messages([ERROR] missing required field: channel), exceptusers.infowithoutuser, which answersuser_not_foundas Slack does.chat.postMessagewith no text, blocks or attachments answersno_text,reactions.addwith an emoji name the workspace doesn’t know answersinvalid_name, andusers.getPresencewithoutuserreports the caller’s own presence. (RYS-1267) -
Thread and message reads reflect earlier writes. A
reply_broadcastreply appears in channel history as athread_broadcastcarrying its parent asroot. Deleting a reply updates its parent’sreply_count,reply_usersandlatest_reply; deleting a parent that has replies leaves a tombstone (“This message was deleted.”, fromUSLACKBOT) that keeps its thread, and deleting it again answersmessage_not_found. An updated message carries noparseorlink_names, and a post answers nousernameunless you sent one. (RYS-1264, RYS-1266, RYS-1268, RYS-900) -
Pins list newest first, and Slackbot is a listed user.
pins.listlists the most recently pinned item first, pins made in the same second included, and each pinned message carriespinned_info.users.listlists the built-in Slackbot first, as the same objectusers.inforeturns forUSLACKBOT(is_botfalse, Slack’s avatars). Lists the twin doesn’t otherwise order come back in the order their records were created, the same on every run. (RYS-1266, RYS-1268) -
History windows and reply reads follow Slack.
conversations.historywitholdestreturns the messages just after it, not the channel’s newest, and its cursor pages forward from there, so a bot that catches up from a checkpoint sees every message once.conversations.replieswith a reply’stsreturns that reply alone, not its whole thread. (RYS-1266) -
Long messages are truncated at 4,000 characters. A message longer than 4,000 characters is stored, and read back from history, as its first 4,000 characters; the post’s own answer still echoes the full text, as Slack’s does. (RYS-1267)
v0.0.13 — 2026-09-27
-
Several agents can share one workspace, each under its own token.
POST /_rystic/credentialsbinds a token to a user, and every call with that token acts as that user, with no Rystic header.GET /_rystic/callslists every request since reset with the identity that made it, for grading. See Identities in the Slack docs. (RYS-1202) -
Conversation access follows each identity. Private conversation reads respect current membership, including after leaving; human user tokens can read public history without joining. Opening a human–bot DM from either identity reuses the same conversation, including an existing seeded DM. (RYS-1215)
-
Private conversations stay hidden from non-members everywhere.
conversations.listshows a private channel, DM or group DM only to its members, as on Slack, and a non-member posting into one getschannel_not_found, the same answer reads give. A refused post no longer writes bot ids onto the caller. (RYS-1236)
v0.0.12 — 2026-09-26
- HOWTO.md and
check.shstart from an API key. HOWTO.md leads withrystic login rys_…, says which control-plane routes Free, Pro and Enterprise reach and whatPOST /_rystic/resetdoes on each, documents seeding (twelve registers,state_triggerincluded, and an inspect dump posted back as it is) and the token rules as served (xoxb-/xoxp-kinds,not_allowed_token_type, the form-bodytoken=fallback), and states that the bundled MCP package, rebound todesign_studio, is not yet re-qualified. SCENARIOS.md namesdesign_studioas the world Free boots.check.shruns the Free and Pro build on your API key (RYSTIC_TOKEN, or the keyrystic loginstored) as well as the Enterprise build on its license file, looked up in the CLI’s order. (RYS-1204)
v0.0.11 — 2026-09-26
- Only
design_studioships as a named scenario. The older scenarios are now test-only fixtures and are no longer accepted by--scenario,RYSTIC_SCENARIO, orPOST /_rystic/scenario. Tests and evaluations can load their unchanged register maps and clocks fromsim/testdata/scenarios/through the control plane. (RYS-1177)
v0.0.10 — 2026-09-25
-
The free edition is replaced by self-serve on the metered edition. A Rystic account’s token runs the metered build:
rystic login rys_…once, thenrystic runordocker run -e RYSTIC_TOKEN=…. It carries every scenario and control-plane route your plan includes, and bills active 30-second blocks against the plan’s allowance. The fixed-world free download and its usage report are gone. (RYS-1097) -
design_studiois the same studio on every twin. Its people, clients and project dates now come from one shared world, so the clients, invoice numbers and dates#financenames match what the Stripe, PayPal, Resend and Lob twins hold for Website Design Contractors. The archived project channels were regenerated with each job’s real page count: the workspace now carries 2,951 messages (was 2,959). (RYS-1171)
v0.0.9 — 2026-09-22
- No change to the twin’s behaviour. Same control plane, scenario pack,
seeds, worlds and license gate as v0.0.8, and the free edition’s
design_studioworld is unchanged. The bundled MCP package pins the same korotovsky/slack-mcp-server v1.3.0 and the same thirteen cases; each receipt its workflow checker writes now carries aschema_version.
v0.0.8 — 2026-09-22
- A fourth world,
design_studio: somebody else’s whole Slack../slack-twin --scenario design_studioboots into a web design studio a year in — its own team (TWDC0000001), its own cast of forty-three people and bots, and its own bot token,studiobot. Sixty conversations: five company channels, five craft channels, ten live client projects, two Slack Connect channels shared with clients, four private channels, five DMs, a group DM and twenty-eight archived projects, carrying 2,959 messages with the pins, bookmarks, files, threads, reactions and queued messages a year leaves behind. Six questions are waiting in it that only a reader of this workspace can answer — who is on call, when a client launches, how a site is rolled back — and every one is answered by a record inside the binary. The threeRystic Shopworlds are unchanged, message for message, so a suite pinned to theirtsvalues keeps passing.SCENARIOS.mdhas the workspace’s channels, people and the six questions.
v0.0.7 — 2026-09-21
- No change to the licensed twin’s behaviour. Same control plane,
scenario pack, seeds, worlds and license gate as v0.0.6.
/_rystic/versiongains anedition:line — empty on a licensed build — so a caller can tell a licensed twin from the free edition without inspecting anything else.
v0.0.6 — 2026-09-21
-
A pinned native MCP integration. The release includes the korotovsky/slack-mcp-server v1.3.0 bot-token profile, native caller, bounded local gateway, workflow checker and exact thirteen-case pack. The declared tools discover channels, read history and threads, and post replies. Package manifests pin the binaries, tool manifest, model and shipped fixture files. See the bundled MCP README for configuration and explicit exclusions, including hosted Slack MCP and search.
-
Protected runtime contract 9. A trusted harness can require a private gateway credential on all twin requests and preserve service URL identities while routing the native caller locally. Service credentials supplied in accepted form bodies follow the same modelled authentication rules.
-
Measured thread and message fixes. Posting updates stored parent thread registers; history and replies preserve those values. Repeated-parent cursor pages, public block metadata and the single-section Markdown fallback used by the pinned caller follow the controlled workflow evidence. The API test response no longer echoes the bearer token.
Measured scope. The native local workflow passes 13/13 cases, a fresh read-only comparison passes 8/8, and the Linux caller cell passes seven containment checks. The original controlled live comparison and exact local replay retain their separate model pins. These are scoped MCP results, not a new full-surface Slack fidelity score. Released Harbor reset/isolation and a real-agent rollout remain separate qualification gates; packaging alone does not certify them.
Qualification addendum, 2026-09-21. The published 0.0.6 package now passes 13/13 shipped cases without a compiler in the offline Linux amd64 Harbor image. Two-episode isolation, three repeated resets, in-flight cancellation, native crash, gateway timeout and cleanup pass. The unchanged held-out task’s MCP reference earns 1.0; null, wrong-thread, duplicate-reply and forbidden-edit controls fail their intended checks. The support card and dated receipts identify exact artifacts and limitations. One real held-out Opus rollout earns 1.0 with 17 MCP replies under the original grader. The source workflow ended red because its checker compared a short row ID with Harbor’s qualified name; the retained original trace passes the corrected exact-name check. Both receipts and source hashes are retained. No learning/generalization claim is made, and the release archives remain unchanged.
v0.0.3 — 2026-09-19
- A third world,
refund_desk_xl../slack-twin --scenario refund_desk_xlboots into the same#refund-desk(CRYSOPS0003) over a busier week: twelve users, 45 top-level messages and five threads across five days, and seventeen open requests naming fourteen objects in stripe-twin’sbusy_account— the ninerefund_desknames plus the paused v1 webhook endpoint, a completed and an expired checkout session, an unpaid cart and the production endpoint. The shapes an ops bot has to leave alone are all on the record: a status question already answered in its thread, the bot’s own earlier summary naming a charge, a refund word with no payment id behind it, the same customer asked about twice, and two objects in one message.refund_deskandops_channelare unchanged, message for message, so a suite pinned to theirtsvalues keeps passing.SCENARIOS.mdlists the new world’s requests and what each resolves to on the Stripe twin.
v0.0.2 — 2026-09-18
-
Two named worlds, baked in.
./slack-twin --scenario refund_deskboots into#refund-desk: one workspace, nine users, and a channel where eight people have left 19 messages — chatter, Stripe status questions and five refund requests, every payment id a real record in stripe-twin’sbusy_accountworld — with the refund handbook pinned and one request already answered in its thread.ops_channelis#ops: the same people, 14 messages of chatter and status questions and not one refund request, several of the questions answerable only from another twin. Yourxoxb-token is the workspace’s ops bot (auth.test→UBOTOPS0001) and already a member, soconversations.historyanswers without a join. Slack has no seed API and a bot cannot post as somebody else, so a channel with a history other people wrote is exactly the state a bot token cannot reach on its own.--list-scenariosnames them;RYSTIC_SCENARIO=<name>does the same in a container. -
SCENARIOS.md. One page in the archive naming each world, its channel and cast, what every request points at on the Stripe twin, and how to re-pin the handbook with a different refund window. -
conversations.historylists top-level messages only. Thread replies no longer appear in a channel’s history; they live underconversations.replies, as on Slack, withthread_broadcastreplies the one exception. A bot’s own posts now carry thebot_idandapp_idthatauth.testreports for its token, and each user’stz_label/tz_offsetfollow theirtz.
v0.0.1 — 2026-09-17
The first release of the Slack twin — an early cut, shipped so integrations can start against it while fidelity work continues. Read the measurement paragraph below before relying on a method’s exact response shape.
-
A local Slack Web API for your bot’s integration tests. 67 methods in the bot dialect across 11 resources:
conversations.*(create, join, invite, kick, leave, archive, rename, topic and purpose, history, replies, members, mark, open, close, info, list),chat.*(postMessage, update, delete, postEphemeral, meMessage, getPermalink, scheduled messages, and the startStream/appendStream/stopStream trio),users.*,reactions.*,pins.*,bookmarks.*, the external file upload pair withfiles.info/list/delete,views.open/publish/update,auth.test,api.test,team.info,team.profile.get,dnd.infoanddnd.teamInfo. Point your Slack SDK’s base URL at the twin and your existing suite runs against it unchanged — form-encoded and JSON bodies both work, as they do live. -
Slack’s error envelope, faithfully. Slack answers failures as HTTP 200 with
{"ok": false, "error": "channel_not_found"}, so a client branches on the error string and never the status code. The twin does the same for every modelled method — a missing channel, a missing token (not_authed), a user-token-only method called with a bot token — so your error handling is exercised against the shape it will meet live. -
It never reaches the network, and it needs no app. Any well-formed bot token (
xoxb-…) is accepted and the twin dials nothing — which is the point: a suite that hitsslack.com/apifails when a channel it created last week cannot be deleted (Slack has no channel delete), and one that posts to a shared test workspace fails when a colleague’s run posts first. -
Seed the workspace your test needs.
POST /_rystic/seedtakes a register map — channels, memberships, messages, users, the team, reactions, pins, bookmarks, files, scheduled messages, views — and lands it directly.GET /_rystic/registerslists what this twin accepts a seed for; a register the model does not carry is refused with a status rather than silently dropped. -
The same seed twice is the same workspace twice.
POST /_rystic/resetempties the twin,POST /_rystic/seed-rngpins the generator so channel ids repeat run to run, andPOST /_rystic/clockfixes the twin’s notion of now — which matters more here than elsewhere, because a message’stsis both its id and its clock reading. Two identical seed flows leave byte-identical/_rystic/inspectdumps.
What is measured, and what is not. Fidelity is scored differentially
against real slack.com/api on a throwaway workspace, per response field,
not by eyeball: the last full-suite round was 161 of 221 probes on
2026-09-17. That is an early number and this release ships it as-is. The
misses cluster where the model is known to be thin: response fields the
Slack SDK samples list but a live response omits, cursor and ts values
that are volatile by design, and the reads whose live evidence came from a
newly seeded workspace. HOWTO.md lists the known shortfalls under
Limitations. Rounds are re-run as the model is repaired and each release
states its own number.
Not in this release. The Events API and Socket Mode, admin.*
(Enterprise Grid), Slack Connect and shared channels, user tokens (xoxp-)
and the methods only they can succeed at — users.profile.set,
users.setPhoto, users.deletePhoto, users.identity — which answer their
documented ok:false branch, as they do live under a bot token.
GitHub
v0.0.12 — 2026-10-12
-
The scripts in this archive are MIT-licensed.
check.shand the archive’s other scripts are published under the MIT License at https://github.com/Rystic-Inc/rystic-scripts , so you can copy, change and share them.LICENSE.txtno longer covers them; it still governs the twin itself. -
bots/test_bot.pyboots your installed Kalshi twin. With neitherKALSHI_BASE_URLnorKALSHI_TWIN_BINset, it runs the kalshi-twin versionrystic pull kalshi-twininstalled, with the API key or licensecheck.shwould find, instead of trying to build the twin from source. When that twin fails to start, the test prints its log. -
A world can carry its own timeline. A
--seedfile, or a whole one posted toPOST /_rystic/seed, may addcredentials, binding presented credentials to actors, andevents: calls the twin makes on its own API at set sim times as one of those actors, through the same handlers and egress as your requests. A top-levelcredentialsoreventskey the twin used to ignore now takes effect.RYSTIC_PACE=<speed>plays the clock, and the timeline, in real time, and an event’satmay be+<duration>./_rystic/inject,--injectandRYSTIC_INJECTnow needstate_setinstead ofscenarios. (RYS-2422) -
A timeline call can hand what it minted to later events. A
callevent may addbind,{"<name>": "<JSON Pointer>"}, saving fields of its answer, such as an ID the twin minted; a later event uses one as${name}in a call’s path, headers or body or in a seed event’s record keys and values (a register name cannot be bound). A schedule naming a${name}no earlier call binds is refused at load, and an event whose binding call failed records an error instead of firing. (RYS-2505) -
github-pulls_open_queueplays a timeline. Over the hour after the pinned clock, tb-reviewer-login approves #41, tb-contributor thanks the reviewer on it, tb-maintainer requests changes on #42, retitles the draft #43 and squash-merges #41 intotb-base-branch, each through the REST API under a token the world binds to that login; advance the clock to watch the queue move. (RYS-2459) -
A clock jump fires each timeline event at its own time. When one
POST /_rystic/clockor paced tick passes several events, each fires with the clock at itsat, so the records it creates carry that time, and the clock then lands where the move asked. An event already due when the clock moves fires at the current time. (RYS-2520) -
A clock jump stamps each timeline event at its exact instant. When a clock move passes timeline events, the records each one makes carry exactly its
at, to the millisecond, whatever traffic came before it, so one jump leaves the same records as stepping the clock to each event in turn. (RYS-2542) -
A timeline call can carry raw bytes, XML or a JSON array. A
callevent may sendbody_textorbody_base64instead ofbody; the twin sends it as written, under the route’s own content type (XML, bytes, JSON or a form), so a scheduled call can upload an object’s bytes or post an XML document, and${name}fills inbody_text. Abodymay be a JSON array where the route reads one. A call that answers XML binds from its elements by JSON Pointer, such as/InitiateMultipartUploadResult/UploadId. (RYS-2523) -
A read-only collaborator cannot remove review requests.
DELETE /repos/{owner}/{repo}/pulls/{pull_number}/requested_reviewersrefuses a caller who can only read the repository with403 Must have write access to repository., as requesting reviewers already did. (RYS-2133) -
Refusals and issue search answer as GitHub does. A self-approval answers errors
["Review Can not approve your own pull request"], a review comment on a path outside the diff["Path could not be resolved"], an invalideventon review createUnprocessable Entityand on submitValidation Failedwith GitHub’s errors — each measured against GitHub. Issue search appliesreason:completed,reason:"not planned"andowner:, which it used to read as text. A search qualifier the twin does not evaluate still answers501, now in GitHub’s error envelope with amessage. (RYS-2134) -
GET /user/codespaceslists your codespaces. It answers{total_count, codespaces}with every codespace the caller created from a pull request, newest first, paged byper_pageandpageand narrowed byrepository_id; without a credential it answers401 Requires authentication. (RYS-2135) -
Other search types are refused, and what is out of scope is listed. GraphQL
searchwith atypeother thanISSUEorISSUE_ADVANCEDis refused with an error namingg_search_type_served, where it used to answer issues. Code browsing, code and commit search and issue subscriptions are recorded as out of scope under Limitations. (RYS-2136)
v0.0.11 — 2026-10-10
-
Seeded worlds read like GitHub’s own records. Every URL a seeded user, organization, membership, repository, commit, file, review, team or check run carries sits on GitHub’s hosts — the API ones rewritten to the twin’s origin, as created records already were — so a client that completes a record lazily (PyGithub’s
pull.user.name) comes back to the twin instead of failing onexample.invalid. A pull request’shead.repoandbase.repoare the whole repository object (fork,clone_url,ssh_url,pushed_atand the rest), itsuserand everyownerthe whole simple-user, andcommentsandreview_commentscount exactly the rows the comment routes list. The importedgithub-pulls_garrytan_gbrainworld keeps itsexample.invalidURLs. (RYS-1248) -
A body that is not JSON is refused, not applied as an empty edit.
PATCH /repos/{owner}/{repo}/issues/{issue_number}andPATCH /repos/{owner}/{repo}/pulls/{pull_number}answer bytes their JSON decoder rejects with GitHub’s400 {"message":"Problems parsing JSON"}before looking anything up, and write nothing — both used to answer 200 and bumpupdated_at. A truncated object is not that case: GitHub reads a body missing its closing brace as the object and a dangling value as a value its schema check refuses, and the sealed probe sends what no JSON grammar accepts. APATCHon a seeded pull request answers the projectionGETrenders, with all eight_linksand its derivedmerge_commit_sha, not the record as seeded. (RYS-1251) -
A pull request serves its diff and patch.
GET /repos/{owner}/{repo}/pulls/{pull_number}withAccept: application/vnd.github.diff(or.v3.diff) answers the unified diff as text underContent-Type: application/vnd.github.diff; charset=utf-8,.patchthe format-patch mbox of its commits, andapplication/vnd.github.full+jsonthe JSON body withbody_htmlandbody_text— including calls made by Octokit’spulls.get({mediaType:{format:"diff"}}). (RYS-1247) -
The Limitations say what the twin serves. The docs page and the HOWTO state where a seeded URL leads (to the twin, where
GET /users/{login}and every other route outside its coverage answers404), the headers it sends unlike GitHub (noETagor304, noX-GitHub-Request-Id,X-RateLimit-*only onGET /rate_limit, nocharseton JSON), the past-the-last-pageLink, the list query values and the requested-reviewers readback no round has measured, the truncated JSON bodies GitHub reads leniently, theDate:of a commit made through the API, and whatbody_htmlrenders from. (RYS-1246) -
The diff and patch representations read as GitHub writes them.
GET /repos/{owner}/{repo}/pulls/{pull_number}underAccept: application/vnd.github.diffnames eachindexline’s blobs by their first seven characters, and under.patchnumbers a lone commit[PATCH], carries thecreate modeline in the stat block and ends after the diff with no version trailer — measured byte for byte against the real service — so a tool that parses GitHub’sformat-patchoutput parses the twin’s. The one remaining difference is theDate:header of a commit created through the API: the twin stamps it in UTC, GitHub in the committing account’s time zone. (RYS-1247) -
A seeded draft pull request is mergeable. In
github-pulls_open_queue, the draft #43 answersmergeable: true, mergeable_state: "clean"— the settled values GitHub computes for a draft, measured against the real service on a full-suite round — where it answeredmergeable: null, mergeable_state: "unknown"on every read, so a merge bot that polls untilmergeableresolves now stops. A draft opened through the API already answered the settled values. (RYS-1252) -
The pull-request list falls back to GitHub’s defaults.
page=0,per_page=0, a non-numeric page and an unknownstateorsortonGET /repos/{owner}/{repo}/pullsanswer200with the defaults, as GitHub does, where the twin answered422 Validation Failed; and aheadvalue withoutuser:ororganization:is ignored rather than matched against the branch name, sohead=<ref>lists every open pull request as it does on GitHub. Each value is a sealed probe confirmed against the real service. An unknownstateon the issues list stays422, which is what GitHub answers there. (RYS-1249) -
A review comment’s review exists. A top-level comment through
POST /repos/{owner}/{repo}/pulls/{pull_number}/commentscreates the COMMENTED review itspull_request_review_idnames, soGET …/pulls/{pull_number}/reviews/{review_id}answers that review and the reviews list includes it, as GitHub does. (RYS-1250) -
A submitted review clears its author from the review request. Once a requested reviewer submits a review through
POST /repos/{owner}/{repo}/pulls/{pull_number}/reviews,GET …/pulls/{pull_number}/requested_reviewersand the pull request’srequested_reviewersno longer list them, as GitHub documents, sogh pr viewdrops their “(Requested)” mark and a bot waiting on the request sees it clear. A pending review leaves the request standing until it is submitted. (RYS-1250) -
What a pull request’s conversation page reads now answers.
GET .../issues/{n}/eventsandGET .../issues/{n}/timelinelist a pull request’s or issue’s events and its derived timeline (comments, reviews, review comments, commits);Accept: application/vnd.github.html+json,.text+jsonand.full+jsonanswerbody_htmlandbody_text;GET /repos/{owner}/{repo}answerspermissionsfor the caller; and everyavatar_urlleads to an image the twin serves itself.github-pulls_garrytan_gbrainnow carries every pull request’s conversation comments and issue events, and its collaborators are the logins GitHub marks as holding push, so other users read. (RYS-1827) -
Organization access is consistent. Membership and repository routes match the caller’s login and membership state without case sensitivity, ignore memberships without a user, and keep organization affiliation when the caller is also a collaborator. Organization admins receive admin permissions on their repositories; billing managers and pending invitations grant no repository access.
/user/orgs,/user/memberships/orgs, and/user/reposerror responses includestatus. (RYS-1717) -
Answers carry what GitHub’s response schemas fix. Where a handler leaves a field absent or null, the twin answers with the required constant, the required field’s documented default, or the variant tag that GitHub’s response schema fixes for that route and status, error envelopes included; a value the handler wrote is never replaced. (RYS-1905)
-
A body’s media type renders the same under its v3 spelling.
Accept: application/vnd.github.v3.full+json,.v3.html+json,.v3.text+jsonand.v3.raw+jsonanswer an issue’s, pull request’s, comment’s or review’s body exactly as the unversioned media types do:body_htmlandbody_textare one GitHub-flavored Markdown rendering ofbody, so a client that names the API version in its media type gets the rendered fields. (RYS-1247) -
Page links name the page size you asked for. On the page-numbered reads (
GET /search/issues,GET /user/repos,GET /user/orgs,GET /user/memberships/orgs, and an issue’seventsandtimeline), eachLinktarget carries theper_pagethe request sent, as GitHub does, even above the 100 items a page holds. Aper_pagethat is not a positive integer names the size served. (RYS-1253) -
Pull request mutations answer the pull request’s reviews.
mergePullRequest,convertPullRequestToDraft,markPullRequestReadyForReviewandcreatePullRequestanswerreviews,latestReviews,latestOpinionatedReviewsandreviewDecisionfrom the reviews submitted on the pull request, asrepository.pullRequestdoes, instead of always empty. (RYS-1304) -
X-GitHub-Api-Versionis checked on every route, as GitHub does.2022-11-28(the default when the header is absent) and2026-03-10are accepted; any other value answers400with GitHub’s error body before the operation runs. (RYS-1914) -
A read answers
If-None-Matchwith304. AGETcarries a weakETagwhere GitHub’s does; send it back inIf-None-Matchand an unchanged read answers304 Not Modifiedwith no body, so a client’s cache revalidates against the twin as it does against GitHub.GET /search/issuesandGET /rate_limitcarry noETag, as on GitHub, so a conditional request there reads the answer again.If-Modified-Sinceis not answered. (RYS-1253) -
Web URLs lead back to the twin.
html_url,_links.html,clone_urland every other URL ongithub.comor one of its subdomains (docs.github.com,codeload.github.com) now names the twin’s origin, asapi.github.comURLs already did;RYSTIC_PRESERVE_SERVICE_URLS=1keeps GitHub’s hosts. Throughrystic run --intercept,github.comand its subdomains route to the twin. (RYS-1910) -
Past the last page,
Linkpoints back at it. On the page-numbered reads (GET /search/issues,GET /user/repos,GET /user/orgs,GET /user/memberships/orgs, and an issue’seventsandtimeline), a page past the end names the last page as bothprevandlast, and page 1 asfirst, as GitHub does. (RYS-1249) -
Repositories and commit authors read as GitHub embeds them.
GET /user/reposlists each repository as GitHub’s full repository object with the caller’spermissions; a pull request’sbase.repoandhead.repo, on its reads and onPATCH …/pulls/{pull_number}, carry neitherpermissionsnorcollaborators; andGET …/pulls/{pull_number}/commitsnames each commit’sauthorandcommitterby GitHub’s 19-field user. (RYS-1248) -
A pull request’s
404carries only GitHub’s keys. A404on a pull request, or on its commits, files, comments, reviews, review comments, reactions or check-run annotations, answers exactlymessage,documentation_urlandstatus, as GitHub does, without theurlthe twin added.GET …/issues/{issue_number}/eventsanswers its404with the same three keys. (RYS-1253) -
GET /userandGET /rate_limitanswer what GitHub does. The authenticated caller reads its private-user view, withplan,disk_usage,collaborators, the private repository and gist counts,two_factor_authenticationandnotification_email, andGET /rate_limitlists every resource category GitHub answers,audit_log,audit_log_streamingandenterprise_token_inventoryincluded. (RYS-1905) -
The Limitations name the codespace timestamps. The docs page and the HOWTO say that a codespace’s
created_at,updated_atandlast_used_atare written in UTC, where GitHub writes them at the account’s UTC offset, so a client compares them as times rather than strings. (RYS-1253) -
A path ending in a slash answers
404, as on GitHub.GET /repos/{owner}/{repo}/pulls/, or any route with a trailing slash, answers GitHub’s404 Not Found(message,documentation_urlandstatus) where the twin served the route without the slash; drop the slash to reach it. (RYS-1253) -
GET /repos/{owner}/{repo}answers GitHub’s full repository object. The repository read carries all 96 of its fields, the merge-commit settings and the*_urltemplates among them, and names itsownerby GitHub’s 19-field user. Its URLs on GitHub’s hosts lead back to the twin, exceptgit_urlandssh_url, which keep GitHub’sgit://and SSH addresses. (RYS-1253) -
A push runs the repository’s CI checks. A push, and a pull request that opens, moves or reopens, now starts the repository’s own
.github/workflows: one check run per job asgithub-actions, named as GitHub names it, completed from the twin’s evaluation engine — a verdict the world already recorded for the same tree replays, a surrogate you register (POST /_rystic/eval/evaluators, run byrystic evaluator) evaluates, and anything else showsneutral. A push also renders GitHub’spushevent intoGET /_rystic/egress. (RYS-2061) -
CI evidence stays tied to the current submission. Agent-created check runs and results from older submissions no longer count as trusted RL reward evidence; rerequests retain their prerequisites, workflow conditions retain their context, and executable files keep their permissions. Evaluation documents now require schema version 2 and a task scope; import reference history before starting an episode, and pin command evaluators to a container image digest and platform. Local execution requires explicit trusted opt-in, while calibration requirements can specify sample counts, coverage and applicability.
v0.0.10 — 2026-10-05
-
Search applies the rest of GitHub’s grammar, and refuses what it cannot evaluate.
GET /search/issuesandgh pr list --searchnow apply negation (-author:,-label:,-is:draft), repeated qualifiers as GitHub combines them, the advancedAND/OR/ parentheses syntaxghsends, free text within:title|body|comments,head:,base:,comments:,no:label,review:,involves:,mentions:,commenter:and in-querysort:, each measured against api.github.com; an unknown login or repository answers GitHub’s422over REST and matches nothing over GraphQL. A qualifier the twin cannot evaluate answers501with anX-Rystic-Markerinstead of quietly broadening the result, REST search pages with GitHub’sLinkheader, so a client that follows it gets every page, and a pull request found by search or read through the Issues routes carriesdraftand its ownnode_id. (RYS-1620) -
A codespace’s
environment_idcomes from its own range. The first codespace you create gets81cebaf1-e762-4d0c-8000-000000000001rather than00000001-0000-4000-8001-000000000001. Ids still count up in their last digits and restart on reset; a test that pinned an old id needs the new one. (RYS-1593) -
Seeded teams report
type: "organization". The teamsgithub-pulls_org_teamsseeds, whichGET /repos/{owner}/{repo}/pulls/{pull_number}/requested_reviewersreturns, carriedtype: "Team", a value GitHub never sends. They now carry"organization", GitHub’s ownership type for an organization’s team and the one the twin gives a team it was not seeded with, so a client typed on that field’s enum accepts them. (RYS-1600) -
The authenticated user’s repositories and organizations answer.
GET /user/reposlists the repositories the caller owns or collaborates on, with GitHub’svisibility,affiliation,type,sort,direction,since,beforeand paging parameters, andGET /user/orgslists the caller’s organizations. Both answer401without a credential, as GitHub does, sogh api user/reposandgh api user/orgsno longer 404. (RYS-1654) -
Organizations have members.
GET /user/orgslists the organizations the caller holds an active membership in,GET /user/memberships/orgsanswers those memberships with theirroleandstate, andGET /user/repos?affiliation=organization_memberreads the same membership.github-pulls_org_teamsseeds the organization its teams belong to,tb-org, withtb-owner,tb-reviewer-loginandtb-maintaineras members, and its teams’ URLs name it; every other world seeds none, so those lists stay empty there. (RYS-1664)
v0.0.9 — 2026-10-01
-
check.shfails fast on a port that never answers. Its port check and boot wait give each/_rystic/versionprobe two seconds, so an address where something accepts connections but never replies fails the check with a message instead of hanging. (RYS-1444) -
The repository, the rate limit, the API root and the authenticated user answer.
GET /repos/{owner}/{repo},GET /rate_limit,GET /andGET /userare served as GitHub serves them, so an agent’s first probes no longer 404 andgh auth statusreports a login instead of an invalid token. The rate limit reports a full budget that resets at the next hour of the twin’s clock, and URL templates in responses keep their braces, as GitHub writes them. (RYS-1530) -
gh pr listandgh issue listrun with their filter flags. With--author,--assignee,--label,--draftor--search, gh first asks forSearchType’s values, and the twin answeredField 'enumValues' doesn't exist on type '__Type'. It now answers with GitHub’s values, so the list runs through the twin’s search. HOWTO.md lists the qualifiers that search applies. (RYS-1532) -
Issue search over REST, and both searches apply date qualifiers.
GET /search/issuesanswers the world’s issues and pull requests withtotal_count, paging andsort. It and the GraphQLsearchthatgh pr list --searchsends now applycreated:,updated:,closed:andmerged:ranges,draft:andis:unmerged, so counting pull requests in a date window matches the world instead of ignoring the range. (RYS-1531)
v0.0.8 — 2026-09-30
-
The state UI shows the repository as GitHub. On Pro and Enterprise,
/_rystic/uiopens GitHub-style Pull requests and Issues pages for the twin — a pull request’s conversation and merge box, commits, checks and diff with review threads and check annotations — where you can act as any seeded user: comment, review, comment on a line, react, request reviewers, merge, close, lock and set a check run’s status through the twin’s own API, beside Actors & calls and Webhook events cards. (RYS-1331) -
Unmodified clients reach the twin at GitHub’s hostnames.
rystic run github-pulls-twin --interceptboots the twin behind the rystic CLI’s HTTPS gateway, andrystic exec -- <command>routes that command’s calls toapi.github.comandgithub.comto it:gh,git clone https://github.com/...,curland SDKs with their default base URL, when their transport honors the proxy and CA settings. (RYS-1398) -
Free boots the imported
garrytan/gbrainrepository.--scenario github-pulls_garrytan_gbrain(orRYSTIC_SCENARIO) now boots it on Free, andPOST /_rystic/resetputs back whichever world the twin booted — issues, comments, pull requests, reviews, check runs, pushed branches and git objects included, with the next issue and pull request numbers where the boot left them. (RYS-1393)
v0.0.7 — 2026-09-26
- HOWTO.md and
check.shstart from an API key. HOWTO.md leads withrystic login rys_…, says which worlds and control-plane routes Free, Pro and Enterprise reach, and corrects the auth rules: Codespaces need aBearerortokenheader, and a login the world does not seed is refused authored writes, merges, dismissals and review requests but not a PATCH, a check run or a reaction. SCENARIOS.md marksgithub-pulls_open_queueas the one Free world and keys a pull request file<number>#<path>, and both give the id bands new records get.check.shruns the Free and Pro build on your API key (RYSTIC_TOKEN, or the keyrystic loginstored) as well as the Enterprise build on its license file, looked up in the CLI’s order. (RYS-1204)
v0.0.6 — 2026-09-25
-
More complete pull-request responses and token authentication. Pull-request and review responses include missing user and repository fields. GraphQL pull-request creation errors retain the nullable payload alongside errors, and merge and review-dismissal refusals match observed response envelopes. Codespaces can be deleted by name. Clients can authenticate with either GitHub’s
Bearerortokenauthorization label. (RYS-1068) -
The free edition is replaced by self-serve on the metered edition. A Rystic account’s token runs the metered build:
rystic login rys_…once, thenrystic runordocker run -e RYSTIC_TOKEN=…. It carries every scenario and control-plane route your plan includes, and bills active 30-second blocks against the plan’s allowance. The fixed-world free download and its usage report are gone. (RYS-1097)
v0.0.5 — 2026-09-24
gh repo cloneworks with gh 2.101.0 and later. That release’s clone query selectsRepository.sshUrlon the repository and its parent and stops at the schema when the field is missing, so against 0.0.4 the clone failed before it started. The twin now answerssshUrlas GitHub spells it,git@<host>:<owner>/<repo>.git, on every repository.
v0.0.4 — 2026-09-23
- A request GitHub’s schema refuses gets GitHub’s answer. In 0.0.3,
eight routes answered a required field left out, a value of the wrong
type, or one outside its enum with
422 {"message": "Validation Failed", "errors": [...]}. GitHub refuses those before the resource sees them, with422 {"message": "Invalid request.\n\n<lines>"}and noerrorskey:"title" wasn't supplied.,For 'properties/body', 5 is not a string.,For 'properties/side', "UP" is not a member of ["LEFT", "RIGHT"].The twin now answers the same way when you create or edit an issue, create or edit an issue comment, lock an issue, open a pull request, create a review comment, or react to one. Validation Failed witherrors[]stays where GitHub sends it, for a value the schema admits and the resource refuses: a blank title, an unknown branch, a duplicate. Editing an issue with an unknownstate_reasonanswers GitHub’s own422 {"message": "Please specify a valid state reason."}.
v0.0.3 — 2026-09-22
-
Every world is a real git repository. Each scenario’s repository is served at
/{owner}/{repo}.git/on the twin’s port:git clone http://localhost:8080/tb-owner/tb-repo.gitandgit fetch origin refs/pull/{n}/headwalk git’s dumb HTTP protocol,git pushspeaks the smart one, and every sha the API reports is a commit you can check out. Pushing to an open pull request’s head branch moves the pull request — head sha,commits,files, asynchronizenotification — exactly as GitHub does; pushing to a protected branch is refused. A pull request’sfiles[].patchand its commits’ diffs now come from the same commits, so the two never disagree. -
The
ghCLI works against the twin.POST /graphqlanswers the queries and mutations theghporcelain sends, sogh pr list,gh pr view,gh pr checkout,gh pr checks,gh pr comment,gh pr review,gh pr merge,gh repo clone,gh issue create,gh issue listandgh issue commentrun unchanged, and every mutation writes through the same handler its REST route uses, so whatghdid shows on the REST surface. Point it at the twin with environment alone —ghspeaks plain HTTP togithub.localhostand honours a proxy, and git honours the lowercase spelling:export GH_HOST=github.localhost GH_TOKEN=ghp_anything \ HTTP_PROXY=http://localhost:8080 http_proxy=http://localhost:8080 gh pr list -R tb-owner/tb-repoThe GraphQL surface is the subset
ghspeaks, not GitHub’s schema; a field outside it answers GitHub’sundefinedFielderror.gh issue view --commentslists the comments the store holds. -
The API’s writes land in the repository you clone. Creating a pull request, retargeting its base, merging it and updating its branch all move the served git repository now, so a
git fetchafter any of them finds what the API described: a merge is a commit onorigin/<base>carrying the head’s changes — withmerge_methodmergethe base and the head are its two parents, whilesquashandrebaseland single-parent commits — and a pull request opened on a branch you pushed carries that branch’s commits and files.gh pr mergefollowed bygit pullbehaves as it does against GitHub. Fetching still changes nothing —refs/pull/{n}/mergeis derived when a client asks for it and never written — so a clone leaves the world exactly as it found it. -
Issues and their comments over REST.
/repos/{owner}/{repo}/issueslists and creates them,/issues/{n}gets and updates one —statecloses and reopens it, with astate_reason— and/issues/{n}/locklocks and unlocks its conversation. Comments are/issues/{n}/commentsto list and create,/issues/commentsfor a whole repository’s, and/issues/comments/{id}to get, edit or delete one. Pull requests are issues here as they are on GitHub: they come back in the list and by number, each carrying apull_requestkey, and/issues/{n}/commentsis where a pull request’s conversation comments live — the routegh pr commentwrites to, distinct from the diff-anchored review comments. Issues and pull requests draw their numbers from one sequence per repository, and a repository with issues disabled answers 410 on the issue routes; pull-request numbers and their comments are unaffected. The eighth world,github-pulls_issue_tracker, seeds five issues and a commented pull request around that shared sequence. -
A real repository to review:
github-pulls_garrytan_gbrain. The first imported world — the 300 most recently updated pull requests ofgarrytan/gbrainas GitHub served them (121 open, 15 merged), their 5,693 changed files with GitHub’s own diffs, 6,266 check runs, 140 real users, and the repository itself as a packfile:git clone http://localhost:8080/garrytan/gbrain.gitdownloads one 85 MB pack in about two seconds,git fetch origin refs/pull/N/headreaches any imported pull request, andgh pr checkoutlands on the API’s head sha. Ids, logins and shas are GitHub’s.import_repo.py --git --gzipis how another repository becomes a world. -
Who may write is one rule, answered as GitHub answers it. A caller’s standing on a repository — the owner is admin, a collaborator holds its declared permission, everyone else the world knows holds read, a login the world does not seed has none — is consulted by every guarded write. Merging, dismissing a review and requesting reviewers refuse a credential naming nobody with
401 Bad credentialsand a caller without write access with403, where before they accepted both; the writes that record an author no longer create issues, pulls, reviews or comments withuser: null. Standing is asked of the resolved user, never theX-Rystic-Actorlabel, so a review can no longer be requested from the pull request’s own author. Every world seeds all four documented logins, andtb-contributoris a user without write access, as the name says. From the same review round: an issue’s and a pull request’s ids are one rule, and created rows mint into their own bands; error bodies are GitHub’s three families, and an unknown path answers in the service’s voice; a pull request’s mergeability, its reviews and its check runs report what the world actually holds.
v0.0.2 — 2026-09-18
-
Two worlds whose diffs carry a real program.
github-pulls_bot_pr_brokenseeds a merged pull request (#7) whose single file is a working Kalshi market-maker bot —files[].patchis the whole ~150-line Python file — and an open pull request (#8) that changes one line so the buy and sell legs swap sides, with a redbot-smokecheck run whose failure annotation points at that line.github-pulls_bot_pr_queueopens five such pull requests (#11–#15), one bug each: swapped sides, a syntax error, the wrong orders path, prices in cents, a required order field dropped. The twin has no contents API, so this is how code comes out of it: read the base, apply the change, run the result. Seven worlds now;--list-scenariosnames them. -
The bot ships as plain files too.
bots/bot.py, the fivebots/variants/*.pyandbots/test_bot.pyride the archive beside the binary, andrystic features github-pulls-twindescribes them underreference bot. The test runs the bot for 30 s against akalshi-twinin itstight_bookworld and requires a bid and an ask resting at once, no crossed quote and nothing left resting; every variant is held to failing that. -
SCENARIOS.md. One page naming every baked-in world and what it is for, with ids, the cast and the bot-world recipe — the cheat sheet the Kalshi and Stripe twins already carried. -
Writes on a seeded pull request now land. In 0.0.1 every baked-in world keyed its pull requests and branches by bare number and name while the handlers look them up as
<owner>/<repo>#<number>and<owner>/<repo>#<name>, so aGETon a seeded pull request resolved butPATCH, merge, review dismiss, reply and reviewer requests on it answered 404. The worlds are re-keyed the way the handlers read them, andGET /_rystic/registersstates the key grammar. One consequence that matches GitHub: opening a second pull request on a head that already has an open one is 422; a head whose pull request is closed takes a new one.
v0.0.1 — 2026-09-17
The first release of the GitHub Pulls twin.
-
A local GitHub pull-request surface for your review automation. 37 routes across 13 resources under
/repos/{owner}/{repo}: pull requests (list, create, get, update, files, commits, merge, update-branch), reviews and their submit and dismiss events, review comments with replies and reactions, requested reviewers, check runs with annotations and re-requests, and codespaces. Point your GitHub client’s base URL at the twin — PyGithub, octokit andghall take one — and your existing suite runs against it unchanged. Nothing you send it counts against a rate limit, lands in a real repository, or needs a token. -
Five worlds baked in.
--list-scenariosnames them: an open queue of four pull requests, a finished review thread with reactions, a head sha with four check runs standing, a merged-and-closed backlog, and org teams requested for review.--scenario <name>boots straight into one, so a test starts from the review state it needs with no seeding step. -
Review as one user, approve as another. GitHub’s review rules depend on who is calling — an author cannot approve their own pull request, only a user with write access can dismiss a review. The twin reads the caller from the
X-Rystic-Actorheader and applies the seeded permissions, so the permission branches of your integration are testable without a second real account. -
It never reaches the network, and it needs no token. Any
Authorizationheader is accepted, and the twin dials nothing — which is the point: a suite that hitsapi.github.comfails when the hourly limit is spent, and one that creates throwaway repositories leaves them behind. -
Seed the world your test needs.
POST /_rystic/seedtakes a register map — repositories, branches, commits, users, teams, pull requests, reviews, comments, reactions, check runs — and lands it directly.GET /_rystic/registerslists what this twin accepts a seed for; a register the model does not carry is refused with a status rather than silently dropped. -
The same seed twice is the same world twice.
POST /_rystic/resetempties the twin,POST /_rystic/seed-rngpins the generator so ids, node_ids and merge shas repeat run to run, andPOST /_rystic/clockfixes the twin’s notion of now socreated_atandupdated_atare values your test chose. Two identical seed flows leave byte-identical/_rystic/inspectdumps.
What is measured, and what is not. Fidelity is scored differentially
against real api.github.com on a per-run throwaway repository, per response
field, not by eyeball: the last full-suite round was 136 of 138 probes on
2026-09-06, and the suite has since grown to 140 probes. Known shortfalls
are listed in HOWTO.md under Limitations — there is no git behind the
records, so diffs and file lists are seeded facts and merging does not
rewrite branch history; no webhooks are delivered. Rounds are re-run daily
and each release states its own number.
Not in this release. Issues, issue comments, labels as a resource, commit statuses, branch protection and repository or organization administration are not modelled.
Kalshi
v0.0.40 — 2026-10-12
-
Scheduled calls reach the Trade API, and a world can carry its own timeline. A
callevent inPOST /_rystic/injector--injectmay name a Trade API route and act as the actor itsasnames, through the same handlers and WebSocket frames as your orders, and an event’satmay be+<duration>. A--seedfile, or a whole one posted toPOST /_rystic/seed, may carry its owncredentialsandevents, and a top-level key of either name the twin used to ignore now takes effect.RYSTIC_PACE=<speed>plays the clock in real time./_rystic/inject,--injectandRYSTIC_INJECTnow needstate_setinstead ofscenarios. (RYS-2422) -
A timeline call can hand what it minted to later events. A
callevent may addbind,{"<name>": "<JSON Pointer>"}, saving fields of its answer, such as an ID the twin minted; a later event uses one as${name}in a call’s path, headers or body or in a seed event’s record keys and values (a register name cannot be bound). A schedule naming a${name}no earlier call binds is refused at load, and an event whose binding call failed records an error instead of firing. (RYS-2505) -
A clock jump fires each timeline event at its own time. When one
POST /_rystic/clockor paced tick passes several events, each fires with the clock at itsat, so the records it creates carry that time, and the clock then lands where the move asked. An event already due when the clock moves fires at the current time. (RYS-2520) -
A clock jump stamps each timeline event at its exact instant. When a clock move passes timeline events, the records each one makes carry exactly its
at, to the millisecond, whatever traffic came before it, so one jump leaves the same records as stepping the clock to each event in turn. A live feed batch that passes an event stops for it, so it fires between the frames either side. (RYS-2542) -
A timeline call can carry raw bytes, XML or a JSON array. A
callevent may sendbody_textorbody_base64instead ofbody; the twin sends it as written, under the route’s own content type (XML, bytes, JSON or a form), so a scheduled call can upload an object’s bytes or post an XML document, and${name}fills inbody_text. Abodymay be a JSON array where the route reads one. A call that answers XML binds from its elements by JSON Pointer, such as/InitiateMultipartUploadResult/UploadId. (RYS-2523) -
GET /account/limitsshows the advanced API usage level once you upgrade. AfterPOST /account/api_usage_level/upgrade, the limits read answersusage_tier: "advanced", Kalshi’s advanced read and write buckets, and the advanced grant ingrants, as Kalshi does. A client can now confirm its upgrade through the public API. (RYS-2249) -
A triggered order group refuses new orders until it is reset. After
PUT /portfolio/order_groups/{id}/trigger, an order naming that group inorder_group_idis refused and does not rest, as Kalshi documents;PUT /portfolio/order_groups/{id}/resetadmits orders again. A bot that relied on the twin accepting orders into a triggered group now sees the refusal. (RYS-2248)
v0.0.39 — 2026-10-11
-
The verified UI installs and starts with
rystic ui kalshi. Kalshi Accurate, the client built against Kalshi’s own interface, is a Pro and Enterprise deliverable: it ships as the imageregistry.rystic.ai/kalshi-twin-ui:<ver>beside the twin’s, pulled with the samedocker login, andrystic uiruns it against a running instance (or, without Docker, the UI’s archive with its public market snapshot on your Node 22.12+). A key whose plan lacks theverified_uientitlement is refused withfeature_not_in_plan. (RYS-2403) -
The scripts in this archive are MIT-licensed.
check.shand the archive’s other scripts are published under the MIT License at https://github.com/Rystic-Inc/rystic-scripts , so you can copy, change and share them.LICENSE.txtno longer covers them; it still governs the twin itself.
v0.0.38 — 2026-10-11
- Kalshi is on Pro and Enterprise. A Free API key is refused
product_not_in_planforrystic pull kalshi-twinand a start. On Pro and Enterprise every world, control route and the verified Kalshi UI are open, and without--scenariothe twin boots empty. (RYS-2400)
v0.0.37 — 2026-10-09
-
The live feeder separates the exchange’s delay from its own. The feeder’s
[status]line now reportsupstream_lag_ms(receive time minus the exchange stamp), the queue depth, how long the simulator takes per batch, reconnects, and every exchange error by code. The feeder also keeps reading the socket while it subscribes, so a reconnect no longer leaves the exchange buffering for it. A frame the exchange skipped resubscribes its socket for fresh snapshots instead of leaving the book behind. Needs a CLI and simulator from this release. (RYS-1782) -
Replay keeps pace with the feed under order load. Placing, cancelling and listing orders no longer slows down as a session’s order history grows, and a replayed frame costs the same however many orders and positions are open in other markets. A large
/_replay/feedbatch no longer holds order requests until it finishes: they run between chunks of at most 1,000 frames. (RYS-1805) -
The live feeder names a series or market listed as an event, and a carry it ignores. A series (
KXBTCD) or an open market ticker in--live-eventsorRYSTIC_LIVE_EVENTS_FILEis now named in the feeder log with the flag that carries it, instead of being reported as an event not yet open. Setting--live-carrybeside named markets or events logs that the carry is ignored. (RYS-1937) -
A subaccount transfer is refused when its amount is not positive or exceeds the source balance.
POST /portfolio/subaccounts/transferanswers 400transfer_amount_must_be_positivefor a negative amount and 400insufficient_balancefor more thanfrom_subaccountholds, as Kalshi does; neither moves a balance. A bot that relied on the twin accepting those transfers now sees Kalshi’s refusal. (RYS-60) -
Replay keeps pace with more markets per twin. The terminal event feed no longer wakes for every replayed market-data frame it does not print, so one twin applies up to a fifth more replayed events per second before it falls behind the recording. With the default event scope, a burst of market data can no longer push one of your fills out of the terminal feed. (RYS-2111)
-
Orders no longer wait behind replay catch-up. When playback or the live feed falls behind, an order request that arrives while events are applying runs after the event in flight, not after the whole batch: a cancel waited about 100 ms on average while replay caught up, and now waits about 4 ms. A pause or seek still takes effect between batches. The action log now stamps each request line with
received, when the request arrived, and writes afillline for each of your fills with itsorder_id,trade_idand the recorded time it happened. (RYS-2086) -
Replay no longer scans the whole book per event to track your orders. Each replayed event checks only the orders you have resting, not every level of the book, so one twin applies about a fifth more replayed events per second on the busiest BTC market before it falls behind the recording. (RYS-2137)
-
An open state UI no longer slows replay.
/_rystic/eventssends what queued in one write at most every 5 ms instead of waking for every event, so with/_rystic/uiopen a busy replay applies about 17% more events per second. Each event keeps its own frame and id, and resuming withLast-Event-IDor?cursor=works as before. (RYS-2140) -
Replayed trades no longer copy the whole book. A replayed trade renders its book changes from the levels it reached, so one twin applies about 40% more replayed events per second on the busiest BTC market before it falls behind the recording. WebSocket frames are byte-for-byte unchanged. (RYS-2138)
-
Replay refreshes market records once per step, not per event. A market’s record is rebuilt after its first event in a step, after each trade, and at step end, so one twin applies about 14% more replayed events per second on the busiest BTC market before it falls behind the recording. A market read between steps, and every WebSocket frame, is unchanged. (RYS-2139)
-
A pause or seek no longer waits out replay catch-up. When playback falls behind and catches up, a pause or seek takes effect at the end of the batch applying when it arrived; before, it could wait until the whole backlog had applied. (RYS-2163)
v0.0.36 — 2026-10-03
-
Query parsing errors match the demo on sixteen GET routes. Declared query fields return the measured flat
400message for malformed integers and booleans, empty values, integer overflow, and duplicate values, preserving which field fails first. Coverage is limited to the documented field prefixes; application range checks and the positions endpoint’s separate integer narrowing remain known gaps. -
Clearer market details and Economics browsing. The standalone coded frontend shows holdings with fee-inclusive average prices in outcome rows, uses series-based market URLs and labels, and adds fixed-scale charts with line/bar views. Economics gains subcategory filters and a featured-event pager, while the shared header shows open-event counts and compact balances with consistent page margins. (RYS-1695, RYS-1696, RYS-1697, RYS-1702)
-
Keyboard controls and shared trading state in the coded frontend. Use arrow keys across trading controls, inspect seeded YES and NO order intent, and keep UI interaction logs after closing the page. The coded frontend defaults to the recorded frontend’s
ui-traderaccount; setRYSTIC_ACTORto use another account. (RYS-1642) -
A set of cut games can be stitched into one tape offline.
tapecut stitch -dst <set> <cut>…writes the games already back to back on one session clock, with the replay sequence’s own rule, as one cut archive per game plus asession.jsonwhosereplay_startbody plays them with every offset at 0. The set reads as one continuous tape without the twin.examples/kalshi-sidefeedbuilds its play-by-play feed: each line runs a lead ahead of the book’s reaction on the tape, either one value or drawn per line from a seeded range (the MLB sets draw from 100 ms behind to 1 s ahead), and the plain feed is one{ts_ms, event}line per play. (RYS-1708, RYS-1711) -
Private account settings stay out of recorded UI fixtures. The original-frontend HAR importer and fixture loader replace captured API tiers, grants and limits with fixed synthetic display values, including when loading older fixtures. Unknown fields in those account responses are discarded. (RYS-1622)
-
MLB set builds refuse linked output directories. The set/game driver rejects symlinks at selected output paths, including dangling links, and stops if old output cannot be removed. Partial builds preserve sibling sets instead of writing through a link. (RYS-1708)
v0.0.35 — 2026-10-02
-
Replay stops at an exact tape time.
POST /_replay/starttakescheckpointsandPOST /_replay/playtakesuntil_ms, both in absolute tape milliseconds (the axis ofstatus.start_ms). Playback applies every event through the stop, pauses there, andstatus.at_checkpoint_msreports the stop, so the same actions at the same stops give the same fills and balances after a reset. (RYS-1628) -
A windowed capture archive replays its whole window, quiet markets included. An archive cut to a
[from, to)window carries amanifest.jsonthat declares the window, an opening book per market and a SHA-256 per file, and holds no frame at or afterto. Replaying it, alone or as a group, covers the declared window even when a market has no event near either end, so one quiet outcome no longer ends a grouped replay early. (RYS-1627) -
A replayed window’s trade history and candlesticks follow the tape. Every recorded print is stamped at its own tape time, even when an accelerated replay applies many events in one batch, so
GET /markets/tradesand the candlestick reads bucket it into the minute it traded. Restarting a replay, or jumping the play head back, removes the recorded prints the head has not reached yet; your own fills stay. (RYS-1626) -
Replay can use a zero-fee series seed. Before starting playback, seed the market’s series in
state_serieswith itsticker,fee_type: "quadratic"andfee_multiplier: 0. Crossing fills and resting orders filled by recorded prints then charge zero fees; cash changes only by quantity times price. Event fee overrides and series fee changes still take precedence over that series setting. (RYS-1629) -
Replay steps by a fixed amount of tape or to the next scheduled event.
POST /_replay/playtakesadvance_msto play that many tape milliseconds from the head and pause, oruntil: "next_event"to play to the next injected event and pause right after it. Every injected event inside the step fires at its own time first, andstatus.next_event_msshows the next stop, so a harness can drive a session one step at a time with identical results on every run. (RYS-1669) -
Independent local Kalshi interface for source checkouts. Run the standalone Kalshi Accurate app against a local twin: browse markets, open price history and the order book, buy and sell with an inline review, and manage positions, resting orders, amendments, cancellations and fill history as the
memberaccount. It reaches only the Trade API routes it uses; the existing state UI remains separate. (RYS-1632) -
A market can settle on schedule. A scripted-event schedule now takes a
callevent that runs one of the twin’s own routes, such asPOST /_settle, at its sim time, so a replayed game’s markets close and pay the real winner at the final out with no admin request. A windowed archive’smanifest.jsonrecords each market’s outcome, read from its market records, while itsmeta/stays free of it, so nothing the agent reads shows the winner early. (RYS-1667) -
Liquidity you take during replay stays taken. When your order lifts a replayed level, the next recorded update at that price no longer puts the contracts back: the level shows the recorded size minus what you took, and recorded decreases there pay that down until the book is back on the recording. The same holds for reconnect snapshots and for live-fed markets, and a restart, seek or reset starts clean. (RYS-1660)
-
The control plane can be locked away from an agent, and every action logged to disk. With
RYSTIC_ADMIN_TOKENset,/_rystic/*,/_replay/*and/_settleneed that value inX-Rystic-Admin-Token(or therystic_admin_tokencookie).GET /_rystic/versionstays open as the readiness probe, and so doesGET /_feed.RYSTIC_ACTION_LOG=<path>appends one JSON line per state-changing request and control op, with the sim clock and the replay head on each line. (RYS-1639) -
Replayed markets can share one cash pool. A replayed market keeps the exchange shard its own record names, so a seeded account needs presence and cash on that shard; the HOWTO’s “Seeding state” section now shows the seed. To fund every replayed market from one balance instead, start the twin with
KALSHI_REPLAY_SHARD=0: each market minted from a tape or the live feed is placed on shard 0, at the cost of per-shard fidelity. (RYS-1679) -
A grader can read a whole account, the action log and the run’s configuration over HTTP.
GET /_account/snapshotreturns one account’s cash per shard, positions with average entry price and a mark, portfolio value at those marks, open orders, every fill with its fee and the replay time it happened at, and settlements. It agrees with the Trade API’s own reads.GET /_rystic/actions?since=<seq>pages through this run’s action log, andGET /_rystic/confignames the run: input digests, RNG seed, and the loaded replay session with its archive and cut manifest. All three need the admin token when one is set. (RYS-1680) -
Replay can play games back to back in any order.
POST /_replay/starttakes asequenceof games, each a market or a list of markets, withgap_msbetween them. Games recorded on different days play one after another on one session clock, each shifted whole (book, trades and market times), so a game plays the same wherever it sits in the order.KALSHI_CAPTURE_DIRcan hold one cut archive per game.GET /_replay/statusandGET /_rystic/configreport each game’s recorded window, session window and offset, so a session time maps back to its recorded time. (RYS-1668) -
The Enterprise image runs a replayed session with the network off. The HOWTO’s new “Running offline” section shows the
docker create --network nonerecipe for a cut library: license,RYSTIC_REPLAY_METADATA=off, the admin token, the action log and a single cash pool. It also namesGET /_rystic/versionas the readiness probe. A nightly check runs a two-game session, its feed, an order and both settlements through the release image with no network. (RYS-1689)
v0.0.34 — 2026-10-02
-
check.shfails fast on a port that never answers. Its port check and boot wait give each/_rystic/versionprobe two seconds, so an address where something accepts connections but never replies fails the check with a message instead of hanging. (RYS-1444) -
Order ids from the matching engine come from their own range. An order the engine places gets an
order_idsuch asa7cade2e-8c1a-43f4-8000-000000000001rather than00000001-0000-4000-8001-000000000001, the id other records minted first share. Ids still count up in their last digits and restart on reset; a test that pinned an old order id needs the new one. (RYS-1593) -
market_unopenedseeds its market asinitialized. The market reportsstatus: "initialized", Kalshi’s status for a market before its open time, where it reported"unopened", which is aGET /marketsfilter value and never a market’s status. Orders are still refused with 409market_closed, andGET /markets?status=unopenedstill lists the market. (RYS-1600) -
Scripted events fire on the sim clock. Load a schedule of timed messages and seeds with
POST /_rystic/inject, or--inject <file>/RYSTIC_INJECTat boot. Each event fires once when the clock reaches itsat, between the same two tape events at any replay speed. Fired messages are readable atGET /_feed?since=<seq>, andGET /_rystic/injectshows what fired and when. (RYS-1638)
v0.0.33 — 2026-09-30
-
The batched cancel is measured against live on the order it places. The sealed probe for
DELETE /portfolio/events/orders/batchednow cancels the resting order its own chain creates, so every round compares the simulator’s cancel with live Kalshi’s instead of stopping before the call, and the order no longer outlives the run. (RYS-1294) -
The state UI takes the rystic.ai look.
/_rystic/ui— the market list, order book, order entry and bot panels included — now reads in the site’s paper, ink and Space Grotesk instead of the dark theme. (RYS-1332)
v0.0.32 — 2026-09-27
-
Taker fees follow the fee schedule. A taker fill now pays ceil(0.07 × the series’
fee_multiplier× C × P × (1−P)) per fill, at the micro-dollar, as live Kalshi does; the simulator used the multiplier as the rate, so every taker fill on a series that carries one — thebtc15m_tapeworld’s KXBTC15M among them — paid about 14 times the schedule. A backtest’s fees and P&L on the tape world are correct again, and two sealed probes hold the simulator to the schedule. (RYS-1255) -
A replayed market keeps its ending to itself, and stays settled. While a tape market is active its record no longer shows the recorded
expiration_value(look-ahead data in a backtest); itsexpected_expiration_time,latest_expiration_time,expiration_timeandoccurrence_datetimefollow the market’s own geometry on the replayed clock (5 minutes and 7 days past close on a 15-minute market) instead of all readingclose_time. AfterPOST /_settlethe market quotes bid 0.0000 / ask 1.0000 on an empty book, publishes the recorded settlement value, and the tape’s later frames leave it alone. (RYS-1256) -
The V1 order writes answer 410, as live Kalshi has since June 2026.
POST /portfolio/orders,POST /portfolio/orders/{id}/amend,DELETE /portfolio/orders/{id}andDELETE /portfolio/orders/batchednow answer410 deprecated_v1_order_endpointwith live’s envelope, so a bot that still calls them fails on the simulator the way it fails in production; trade through/portfolio/events/orders(V2). A v1-era bot you cannot change yet keeps its old wire by starting the simulator withRYSTIC_KALSHI_LEGACY_ORDERS=on. Four sealed probes hold the refusal. (RYS-1257) -
The portfolio reads publish what live publishes.
GET /portfolio/orderslists newest first by the fullcreated_timeinstant, including fractional seconds, with order ID breaking ties so cursors resume deterministically; an order canceled throughDELETE /portfolio/events/orders/batchedkeeps an RFC 3339last_update_timeinstead of a unix integer; every resolved trade from a replayed tape reachesGET /markets/tradeswith the same ID, price and quantity as its WebSocket trade and any member fill; and balance, fills, positions and settlements no longer carry the simulator’s internalownerfield. (RYS-1258) -
Error envelopes match the exchange. A path the simulator does not route answers Go’s plain-text
404 page not foundlike live;GET /markets?status=<anything outside unopened|open|paused|closed|settled>is a400 bad_requestwith detailsinvalid status filterinstead of an empty page;GET /markets?limit=1001answers the parameter binder’s{"msg":"Parameter validation failed for GetMarkets: …"}; a routed 404 carries{"error":{"code","message"}}and noservicekey; and therate_limitedworld’s 429 body is the documented{"error":"too many requests"}. (RYS-1261) -
A repeated WebSocket subscribe merges its market scope into the same subscription. Subscribing again to a market-filterable channel answers
{"type":"ok","sid":…,"seq":…,"msg":{"market_tickers":[…]}}on the samesid, followed by the requested markets’ snapshots. Subscribing to all markets keeps or expands the subscription to all markets; a later scoped subscribe cannot narrow it. Channels that require a market still require one, and keyed channels or channels configured to ignore market filters retain error 6Already subscribed. (RYS-1259)
v0.0.31 — 2026-09-26
- The login offer starts Kalshi in
btc15m_tape, and HOWTO.md and the smoke scripts start from an API key.rystic loginoffers Kalshi booted intobtc15m_tape, the world every plan can run, instead oftight_book. HOWTO.md leads withrystic login rys_…; HOWTO.md and SCENARIOS.md count all 47 named worlds and say which worlds, control-plane and/_replay/routes Free, Pro and Enterprise reach. HOWTO.md gives Compose onRYSTIC_TOKENwith the Enterprise license second, sends/_settlewithContent-Type: application/json, lists the lease check-in among outbound calls, and says theDELETE /portfolio/events/orderskill switch answers404, as demo does.check.shruns on your API key as well as a license and, on Free, waits forbtc15m_tape’s market and reads it back instead of seeding;trade-check.shsays it needs Pro or Enterprise when the plan refuses its seed. (RYS-1204)
v0.0.30 — 2026-09-25
-
The free edition is replaced by self-serve on the metered edition. A Rystic account’s token runs the metered build:
rystic login rys_…once, thenrystic runordocker run -e RYSTIC_TOKEN=…. It carries every scenario and control-plane route your plan includes, and bills active 30-second blocks against the plan’s allowance. The fixed-world free download and its usage report are gone. (RYS-1097) -
Live mode carries markets by event.
--live-events E1,E2,RYSTIC_LIVE_EVENTS, or a shared--live-events-file/RYSTIC_LIVE_EVENTS_FILE(stack keyslive_events/live_events_file) carry every open market of each named event, on top of any--live-markets. Each event is looked up again on every rescan, so a strike the exchange adds later is carried and a closed market drops. (RYS-1162)
v0.0.29 — 2026-09-23
- Live market selections survive rollover. The feeder reapplies
--live-marketsandRYSTIC_LIVE_MARKETS_FILEon every discovery scan, removes closed selections, and picks up newly open named markets. When none of the selected markets is open, it waits instead of falling back to the busiest markets. This fixes gold-market rollover losing gold data while subscribing to hundreds of unrelated markets. A named market removed through the UI returns on the next scan. - WebSocket lifecycle and price-feed channels work.
multivariate_market_lifecycle,pyth_value,cfbenchmarks_value,communicationsandorder_group_updatesnow accept subscriptions. Markets minted by a tape or seed emit lifecycle creation events, and/_settleemits determined and settled events. Live mode forwards exchange-wide lifecycle and price-feed frames, even with no market selected. Price feeds remain silent on recorded tapes; accepting communications and order-group subscriptions does not generate events for those channels. Scheduled market close remains time-based and sends no lifecycle frame. Forwarded live settlement frames do not settle the twin’s own market; exchange-wide frames may be dropped under feeder backpressure to preserve order-book updates.
v0.0.28 — 2026-09-22
- Orders on
btc15m_tapenow fill. In v0.0.27 every order placed on the recorded KXBTC15M market answered404 user_not_found. The market trades on exchange shard 2, as it does on Kalshi, but the world put the $10,000 balance on shard 0 only. The balance now sits on shard 2:GET /portfolio/balanceshows abalance_breakdownof $0.00 on shard 0 and $10,000.00 on shard 2, and an order on the market fills against the replayed book. The free edition, which boots this world, has the same fix.
v0.0.27 — 2026-09-22
- A real recorded session as a named world:
btc15m_tape../kalshi-twin --scenario btc15m_tapeboots into the KXBTC15M market that closed 2026-09-03 10:30 UTC, exactly as the exchange streamed it. It replays all 62,629 WebSocket messages recorded live off the feed, including 2,818 prints, event for event on the exchange clock from the moment the world starts, with no/_replaycall. Your bot fills against the real book, level for level, in a session that runs 0.52 → 0.95 and settles yes. The market is servedactiveon a clock rebased to now, and the 14-minute session loops, so a bot left running never reaches the end. The world seeds the market’s own event and series and a $10,000 balance. It is the 47th named scenario (SCENARIOS.mdlists it under Recorded) and the one world the free edition serves. /_rystic/versionreports more. The control-plane contract moves from 7 to 9.editionis always present: empty on a licensed build andfreeon the free edition, so a caller can tell them apart without probing.--versionprints the same value as anedition:line.initial_clockis the epoch a reset returns the clock to.api_base_paths,service_hosts,preserve_service_urlsandcredential_requiredreport settings other twins declare. Kalshi declares none, so they read empty,falseand0, present so one client reads every twin the same way.- The EULA in the archive gains a Free Edition section.
LICENSE.txtnow defines the Free Edition and sets out its terms as Section 13; General moves to Section 14. The terms of your license are unchanged, and a licensed build still sends nothing over the network.
v0.0.26 — 2026-09-17
- Drop a WebSocket connection on demand.
GET /_rystic/wslists every open socket with its subscriptions;POST /_rystic/ws-dropcuts them bytickers(any socket whose subscription names one), byconnections(ids from the listing), orall. The default is a bare close with no close frame, what a crashed server or a severed path looks like;graceful: truesends1001 going awayfirst. A reconnect bug in a one-to-many exchange client is fleet-wide, and sockets are split by ticker, so a test drops one stream and asserts the others survive. The drop is recorded on the event stream as a control op. - One tape as many markets.
/_replay/prepareand/_replay/starttakecopies: N, playing one tape as N distinct markets on one clock, named<tape>-1…<tape>-N, each on its own stream;{"tape", "as"}members inmarketspick the names yourself, mixed freely with plain tapes. REST and WS agree on the new ticker, the real record (strike, title, grid) carries over from the tape’s own ticker, andstatusshows which tape each member plays. Each copy is folded separately, so memory scales with copies × tape size.rystic run kalshi-twin --tape <id> --copies Ndoes the same from the CLI, and the copies survive a restart. - A load rig for the reconnect path.
examples/kalshi-loadtestin the rystic-sim repo drives N streams against N copies with a drop on a timer and reports frames/s,pace(tape time advanced per wall second;1.00means the twin kept up) and reconnect latency. Its README records what one twin sustained; run it on your own hardware before trusting a number. - Every live setting from the environment.
--livereadsRYSTIC_LIVE=1and every--live-x-yflag readsRYSTIC_LIVE_X_Y, flag over env over default, so a container starts fed with no argv. New--live-markets a,b(RYSTIC_LIVE_MARKETS) carries exactly those markets before the state UI picks;KALSHI_PRIVATE_KEY_B64carries your own key inline besideKALSHI_PRIVATE_KEY_FILE(the id and one key form); a stack file’slive: trueservice takeslive_env,live_series,live_max_markets,live_shards,live_carryandlive_markets; andrystic run --live --runner dockerruns the feeder on the host beside the container, ended first bystop. - A scenario swap under a running tape is one step. Moving the twin to another scenario while a tape played could carry the previous world’s records into the new one, because a frame already in flight landed after the reset. The swap now holds the tape off for its whole duration, so a retired tape cannot seed the new world.
- A market always has its event. Seeding a
state_market_catalogrecord (or replaying over one you seeded first, the strike-metadata workflow) now mints thestate_eventit names when none exists, soGET /events/{event_ticker}andGET /events?series_ticker=answer for every market the twin serves — on the exchange a market cannot exist without its event, and the twin used to 404 where Kalshi returns 200. A minted event carries its market’sexchange_indexinstead of a fixed 0, and no longer claims acollateral_return_typethe exchange never serves. An event you seed yourself is left as you seeded it.
v0.0.25 — 2026-09-12
- Pausing a replay inside a quiet stretch of tape no longer loses your
place. Between two recorded events the play head paces the gap on the
clock.
POST /_replay/pausenow keeps that paced position,play(and a speed change mid-flight) resumes from it instead of rewinding to the last applied event, andGET /_replay/statusreports it while paused as well as while playing. Before this, chunked playback — pause, play a little, pause again, the shape an agent-driven clock takes — could never cross a gap longer than one chunk, and a status read taken while paused mid-gap jumped a whole gap forward. An uninterruptedplayis unchanged.
v0.0.24 — 2026-09-06
-
The twin can be fed a live stream, not only a recorded tape.
rystic run kalshi-twin --livestarts the twin and a feeder together: the feeder holds the authenticated exchange socket and pushes every frame to the twin, so your bot trades against a book that is tracking the real market right now.rystic feed kalshi-twin --url <base>attaches a feeder to a twin that is already running — any runner, any machine, which is also how you feed a containerised twin. Pick the exchange with--live-env prod|demo, choose what is carried with--live-seriesand--live-carry, and spread the carried markets over more sockets with--live-shards. Keys come fromKALSHI_KEY_IDandKALSHI_PRIVATE_KEY_FILE, checked before the twin starts so a missing key cannot leave a half-started instance.The twin still dials nothing. The socket lives in the CLI process, never in the twin — that is what keeps the twin’s offline licence and hermetic guarantees intact. Frames arrive at
POST /_replay/feedand go through the same fold and the same seam a tape frame takes, so a fed twin ends on the same book a tape replay of the same bytes ends on.What is not claimed: paper-trading fidelity. Your orders fill against the fed book and prints but never move them, and queue position is exactly what apply-mode replay already claims. Delivery is best-effort — a slow twin costs frames (counted and reported), never the recorder a byte, and a dropped delta leaves the book behind until that market’s next snapshot.
-
The state UI carries a watch list. The Markets card lists what the feeder offers — hundreds of open markets, busiest first — and you add, remove and watch several at once; viewing a market implies carrying it. The Replay card reports the live session: frames applied, frames dropped, how many markets are carried, and
lag, which is the age of the last frame that arrived. A climbinglagmeans nothing is feeding the twin. -
The clock steps; it no longer runs. Simulated time used to advance in real time from wherever you pinned it, so two runs of the same request sequence rendered different timestamps, and an order with an
expiration_timeexpired on wall time. The clock now moves one millisecond per request that reads it and stands still between requests: the same sequence from the same reset renders byte-identical stamps,created_timeandtsincluded. What changes for a bot: nothing expires while the twin is idle — advance the clock past the deadline withPOST /_rystic/clock {"advance_seconds": N}(or pin it with{"time": ...}) and the first request after that cancels the order. Two orders placed seconds apart now carrycreated_times one tick apart, not seconds apart, so assert on ordering, never on elapsed time. The HOWTO’s clock section is rewritten to match.
v0.0.23 — 2026-09-02
- The boot banner prints a URL you can paste. 0.0.21 and 0.0.22 announced
http://[::]:8080on all four boot lines — the wildcard bind the listener reports, not a host — while the CLI’s own summary line saidlocalhost:8080for the same port. The banner now rewrites the unspecified address in all three forms ("",::,0.0.0.0) tolocalhost; an explicit host survives untouched.--healthstill answers127.0.0.1, because a distroless image has no resolver. - Two order-entry rejections now come in live’s order and live’s words.
A reused
client_order_idanswers409 order_already_existsbefore the balance check, and afill_or_killthe book cannot cover answers409 fill_or_kill_insufficient_resting_volumebefore it too; the twin used to answer400 insufficient_balanceto both, and the duplicate’s envelope (duplicate_client_order_id) was one live has never sent.good_till_canceledandpost_onlykeep answeringinsufficient_balancepre-match, as live does. One walk of the book per order; nothing is written on a rejection. - The backtest half of the API answers.
/historical/*read registers no scenario ever seeded, so every world booted with a dead archive: cutoff at epoch, 404 onhistorical/markets/{ticker}and on candlesticks at all three intervals, empty fills, orders and trades.--scenario has_historynow boots a world holding both halves —KXDEMO-26-Aopen and tradeable,KXDEMO-25-SETTLEDin the archive behind a 2026-07-20 cutoff — each 404ing on the other’s half, which is the propertyhistorical/cutoffexists to express. - Four reads answer the question asked. A candlestick’s
end_period_tsis the inclusive end of its window, so a trade landing exactly on the boundary files in the period it closes rather than one period late.?event_ticker=on both positions reads narrows through the market’s catalog record instead of returning every event.forecast_percentile_historyis one series per event, with the percentile taken across that event’s markets, not one duplicate-addressed item per market.total_cost_shares_fpcounts seeded contracts once and traded fills as they land, instead of rolling traded shares up against the cost of both.
v0.0.22 — 2026-08-30
- Every world in a
batchgets its own arguments. The bot contract was four environment variables; the argv after--was exec’d byte-identical in every slot, sorystic batch -j 4 -- ./bot --market KXBTC…ran four worlds against one ticker. The same values now substitute into your bot’s own argv as{{world}},{{world_kind}},{{market}},{{addr}}and{{result}}, for a bot that takes them as flags rather than reading the environment. The environment variables are unchanged — this is additive. An unrecognised placeholder is refused before anything boots,-dry-runprints the resolved command per world, and{{market}}is refused ongridandbatch -scenarios, where a world has no market and--market ""is an argument your bot cannot tell from a real one.{{market}}is the market ticker (KXBTC15M-26AUG202330-30); a bot that subscribes by event or series derives those from it — the derivation is in the HOWTO. - Correlated tapes play as one exchange. BTC, gold and silver over the same
session used to mean three processes on three ports, each rebasing its own
first event to now — which erased the offset between the markets, the one
thing a correlated group exists to preserve. Pass
markets: [...]to prepare and start and the group is merge-sorted into a single time-ordered stream: one twin, one port, N tickers, one play head, one rebase offset. The window is trimmed to the overlap; tapes that never overlap are refused with both spans named. - Fills and orders carry the
side/actionfields live still serves. The WebSocket fills channel servesaction_side; REST servesbook_sideandoutcome_side. The deprecated field was absent from the twin, so a bot reading it saw a key error — and stopping one bot and starting another on the same tape surfaced it as apparent state corruption. - 17 documented operations the twin never registered now route, and the read surface answers for what it is served beside: the catalog answers for its book, and the account answers for the trades that moved it. Order entry enforces the bounds it declares, and list endpoints page.
- A miss answers as the exchange. The error contract returns Kalshi’s own shape rather than a generic one, and the parser no longer accepts infinities.
- Smaller surface corrections. Block-trade accept requires the caller to be
a named party.
/exchange/statusomitsexchange_estimated_resume_timerather than sending it null./events/{ticker}/milestonesis served.expiration_timeis enforced as a deadline rather than recorded as a note; an amend keeps what the order was created with; the mutation path sees only resting orders. - Replay accuracy. Tape removals are attributed to trades versus cancels and the queue advances behind-first; a play-head jump re-syncs the applier’s tally. The network profile is re-measured from us-east-2, the region production is served from.
v0.0.21 — 2026-08-27
- The exchange is sharded, the way the real one is. Buying power,
presence and routing are now per exchange shard: an order routes to its
market’s shard and records it (
exchange_indexon the order record), the cash on that shard is what admits it, and the threeportfolio/intra_exchange_instance_transfersroutes move cash between shards. Cancels route too, and the default is shard 0: aDELETE .../events/orders/{id}with no?exchange_indexcannot reach an order resting on any other shard and answers the same 404 as an unknown id. Pass?exchange_index=<n>, or?exchange_index=-1&market_ticker=…to auto-route by market — wire it now, before your first non-zero-shard market teaches it to you as “my cancels stopped working”. - Post-only meets your own quotes the way live does. A
post_onlyorder underself_trade_prevention_type: makerwhose crossing region — every opposite level at or through its price — holds only your resting orders is now accepted: those orders are canceled in full and the new order rests at its full count, exactly live’s behavior (pinned by an 8-case demo matrix, every order read back to its terminal state). Any other participant’s order in the region, whatever its price priority — or any crossing at all undertaker_at_cross— still answers the exact 400invalid order/post only cross, byte-identical, so a pull-all-quotes trigger keyed on that string keeps firing. An MM re-quoting ask-first over its own resting bid no longer takes a spurious reject. - One trade, one id, five surfaces. A match reports itself under a single id everywhere it appears — each party’s REST fill record, each party’s private WS fill frame, and the public WS trade print — live-verified with three subscriptions open across one crossing fill. Correlate the feeds by id; the heuristics can go.
- Position money at live’s scale.
GET /portfolio/positionsrenders its money fields at six decimal places (“0.114600”), the scale live uses — a client comparing them as strings no longer diverges on every read — and each fill leg is recorded under its own party. - A replay reseed no longer swallows WS frames in flight. On a replayed market, an orderbook delta rendered inside the reseed’s poll window could be dropped as “already reflected” — a resting order’s own delta sometimes never reached the subscriber. The gateway now drains pending frames before and inside the reseed, so the stream is complete across tape snapshots.
- Batched creates are yours alone. The batched order path stamps every order it creates with the caller’s identity exactly as the single-order path does, held in place by its own regression test.
- A fresh install has somewhere to go. The bundled
random-walkerand the “no markets” screen no longer point exclusively at replay — which needs a tape archive a fresh install does not have. Scenarios are compiled into the binary and--list-scenariosnames them, so the quickstart works thirty seconds after the install, tape or no tape.
v0.0.20 — 2026-08-21
- A replayed market knows what it is. Replayed markets now carry the
exchange’s own record for that ticker —
floor_strike,cap_strike,strike_type,custom_strike,occurrence_datetime, the realyes_sub_title/no_sub_title(“Target Price: $64,968.77” rather than “Yes”) and the market’s own declared price grid. On a threshold market the strike is what the market means, and a bot that read it off a replay used to find nothing there. The record comes from your capture’smeta/log when it has one; otherwise the twin fetches it once per tape, unauthenticated, and caches it into the archive so it never fetches again. - That fetch is yours to refuse, and bounded when you keep it.
RYSTIC_REPLAY_METADATA=offdisables it outright — replay is unaffected either way, since a failed or disabled fetch never blocks playback.RYSTIC_REPLAY_METADATA_HOSTS=<host>pins the host it may dial. The twin only ever speakshttps, never follows a redirect off the host it started on, and logs the host it used on the first fetch. Stated plainly because it matters to a trading desk: with the fetch enabled, replaying a tape tells the exchange which historical window you are studying. - The market is replayed as it was, not as it ended. The fetched record is the market at settlement — finalized, with a result. Status, result, settlement values and every time field stay derived from your tape, so you still get an active market with a book that moves.
- The price grid is per market, not per series. Kalshi migrated
KXGOLD15Mfrom whole-cent to tapered deci-cent midway through 2026-08-20: the 06:45Z window and the 09:45Z window of the same series have different tick sizes. The twin serves each market’s own declared grid, and the per-series table is now only a fallback for a tape whose record is unknown. Readprice_rangesper market rather than memorising which series is which. - A twin that dies says so at once.
rystic run -d,upandcheckused to wait out the full readiness timeout when a twin refused to boot — against a bad license, say — and then blame the timeout. They now notice the exit and name it:the twin exited before serving: exit status 1. - The shipped scenario packs are complete against the current model. Every
loadable pack is now checked field by field as it loads, and the packs that
were short of the widened market and event records were completed
(
35_self_trade_preventionamong them).
v0.0.19 — 2026-08-20
- Backtest results you can read.
rystic batchnow ends in a per-market P&L table —mtm,net,fees,mark,traded,fills,position,balance— with exact totals, and each finished market’s line carries itsmtmandfees.rystic report <out-dir>re-renders the table from a finished run any time, with-csvfor spreadsheets,-sort <column>, and--json. - Honest mark-to-market. The twin values every open position at the
market’s last traded price (shorts at the dual), updated on every trade —
yours or the tape’s — and zeroed at settlement. Markets that were never
settled are called out loudly: realized columns exclude their outcomes,
the mark columns carry the last-price valuation, and the
-settlementsfile realizes them exactly. - Live progress while tapes play. A heartbeat per slot — events played, percent, time left — and a run-wide estimate on every completion, so a 15-minute tape no longer looks like a hang.
rystic doctorand the replay screen speak the CLI. The tapes hint now says-tapes <dir>instead of naming an internal environment variable, and names where tapes come from.
v0.0.18 — 2026-08-20
- Money folds in exact micro-dollars. Balances, positions and fees accumulate in an exact internal register instead of re-reading their own printed 4-dp values, so a long session’s balance is the exact figure rendered once — sub-cent fee debits no longer drift, and the register never leaks onto the wire.
- A minted market declares the grid the exchange declares. Replayed
markets now carry the complete live record — the declared price grid
(
price_level_structure+price_ranges), the full quote/volume block derived from the tape, and a complete parent event — so a bot that indexes the catalog before pricing no longer dies on a missing field. - An unbuilt fleet image names the build.
rystic batch --runner docker --imagewith an image that was never built now says to build it (with the exact command) instead of surfacing Docker Hub’s “may require ‘docker login’” dead end.
v0.0.17 — 2026-08-19
Same simulator as v0.0.16. First release cut, published and verified end to end by the automated pipeline — every archive, checksum, image digest and download endpoint checked per licensee before publication.
v0.0.16 — 2026-08-19
- Ctrl-C stops a docker fleet. Cancelling
rystic batch --runner dockernow kills the whole container process group instead of leaving workers running. - Fleet failures name their cause. A world whose twin never served reports the twin’s last log line in the ledger row; an image whose bundled
rysticpredates-tapesays exactly that instead of “exited 2”. - Sharper docker errors. A missing image is “no such image” (exit 5), never mistaken for an auth failure; a container-name collision names the project holding the name.
v0.0.15 — 2026-08-19
- Fleets from your own image.
rystic batch --runner docker --image <yours> -- <bot>runs one container per tape or world from an image you buildFROM registry.rystic.ai/kalshi-twin:<ver>, with a ledger row per world.-tape <id>names one tape exactly. - Stack files. Declare a hand-picked set of twins once in
rystic.yaml;rystic up/down/ps --projectmanage them as a group. - Export.
rystic export composeemits a digest-pinned compose file (license via.env, never in the file);rystic export antithesisscaffolds the config image and launch steps. - Secrets passed to bots ride
-e NAME— values never appear in docker’s argv.
v0.0.14 — 2026-08-18
- Images pull from
registry.rystic.ai.--runner dockerpulls the published per-licensee image through the registry front door — your license file is the docker credential, andrystic loginruns thedocker loginfor you. - The shipped Dockerfile is retired. The container path is the published image; the HOWTO opens with
rystic login/rystic run.
v0.0.13 — 2026-08-18
- Instances.
rystic run -d --name --port,ps,stop,restart,rm,logs,status, a--jsonenvelope on every command, and a stable exit-code contract (8 = runtime unavailable, 9 = conflict). rystic listis the released catalog — latest, installed and running per product your license covers.- A twin says which build it is.
--version,--health, and product/version/watermark onGET /_rystic/version. rystic gridruns the bundled bots across named scenario worlds; the bots take aRYSTIC_TICKSbudget and exit instead of running forever.- The quickstart’s
check.shresolves your license before booting the twin.
Cut for a subset of licensees and superseded the next day — rystic pull may resolve past it; nothing was lost by skipping it.
v0.0.12 — 2026-08-17
- Shipped scenario packs carry complete market records, held by the same completeness gate as the built-in scenarios.
- Order creation validates required parameters as the outermost gate, so a missing field 400s the way production Kalshi does.
v0.0.11 — 2026-08-17
- Complete market records in scenarios. Every scenario market now carries the full Kalshi field set (
close_time,price_level_structure,price_ranges,yes_sub_title, …), so clients that read the whole record no longer crash mid-stream. A seed-completeness gate keeps future scenarios honest. - Per-customer archives. Release assets are watermarked per licensee;
rystic pullresolves the build your license names.
v0.0.7 — 2026-08-12
- Named scenarios ship with the twin.
rystic run kalshi-twin --scenario thin_book;--list-scenariosandSCENARIOS.mdfor the menu. - Evidence-backed fees and P&L. Taker fees micro-ceil per fill, maker fees only on series that declare them, offsetting fills net on the spot, positions settle at expiry.
- Replay runs on the exchange clock, not capture-arrival time. Every tape starts from a flat portfolio.
- State UI. Effective configuration in the header, an Avg cost tile per bot, a Network panel.
rystic checkaccepts204 No Contentfrom/_rystic/reset.
v0.0.6 — 2026-08-11
First build shipped alongside the rystic CLI. Feature set is v0.0.5’s; existing licenses work unchanged.
v0.0.5 — 2026-08-10
- Replay is 9.4× faster, byte-for-byte identically — a 446k-event tape plays in 41s instead of 384s.
- Member fills print to the public tape, sharing one
trade_idsequence with private fills. - The egress log is bounded at 100k frames (
RYSTIC_EGRESS_RETAINto widen) and reportsdropped. - Network shaping, inert by default —
POST /_rystic/network. - Network panel in the state UI.
v0.0.4 — 2026-08-03
First licensed build: the license gate, LICENSE.txt in every archive, a rebased tape that survives restarts, and one actor identity across HTTP, WS, and replay.
v0.0.2 — first limited release
Trade API v2 (88 routes), the matching engine, the WS feed at /trade-api/ws/v2, captured-day replay, and the state UI at /_rystic/ui.
PayPal
v0.0.8 — 2026-10-12
-
A world can carry its own timeline. A
--seedfile, or a whole one posted toPOST /_rystic/seed, may addcredentials, binding presented credentials to actors, andevents: calls the twin makes on its own API at set sim times as one of those actors, through the same handlers and egress as your requests. A top-levelcredentialsoreventskey the twin used to ignore now takes effect.RYSTIC_PACE=<speed>plays the clock, and the timeline, in real time, and an event’satmay be+<duration>./_rystic/inject,--injectandRYSTIC_INJECTnow needstate_setinstead ofscenarios. (RYS-2422) -
A timeline call can hand what it minted to later events. A
callevent may addbind,{"<name>": "<JSON Pointer>"}, saving fields of its answer, such as an ID the twin minted; a later event uses one as${name}in a call’s path, headers or body or in a seed event’s record keys and values (a register name cannot be bound). A schedule naming a${name}no earlier call binds is refused at load, and an event whose binding call failed records an error instead of firing. (RYS-2505) -
design_studiocarries a timeline. As the clock moves past2026-03-01T00:00:00Z, the world opens an order for Wildflower Daycare’s open invoice, charges Oakridge Bakery’s saved wallet for its hosting renewal, refunds 500.00 of Saltmarsh Brewing’s deposit, and authorizes then captures Quill and Ink Stationery’s March care plan, each a call on the twin’s own API under the world’s bound tokens. Those calls take the first order, capture and authorization ids, so a test that advances the clock before creating its own sees its ids minted later in the sequence. (RYS-2459) -
A clock jump fires each timeline event at its own time. When one
POST /_rystic/clockor paced tick passes several events, each fires with the clock at itsat, so the records it creates carry that time, and the clock then lands where the move asked. An event already due when the clock moves fires at the current time. (RYS-2520) -
A clock jump stamps each timeline event at its exact instant. When a clock move passes timeline events, the records each one makes carry exactly its
at, to the millisecond, whatever traffic came before it, so one jump leaves the same records as stepping the clock to each event in turn. (RYS-2542) -
A timeline call can carry raw bytes, XML or a JSON array. A
callevent may sendbody_textorbody_base64instead ofbody; the twin sends it as written, under the route’s own content type (XML, bytes, JSON or a form), so a scheduled call can upload an object’s bytes or post an XML document, and${name}fills inbody_text. Abodymay be a JSON array where the route reads one. A call that answers XML binds from its elements by JSON Pointer, such as/InitiateMultipartUploadResult/UploadId. (RYS-2523)
v0.0.7 — 2026-10-11
-
check.shfails fast on a port that never answers. Its port check and boot wait give each/_rystic/versionprobe two seconds, so an address where something accepts connections but never replies fails the check with a message instead of hanging. (RYS-1444) -
Every link the twin hands out leads back to the twin. An order’s, authorization’s, capture’s or refund’s
links[].hrefand an error’sinformation_linknow carry the origin you reached the twin on, with PayPal’s path kept, so a client that followsself,captureorrefundcalls the twin rather than PayPal with its token. A buyerapprovelink and a documentation link now answer 404 from the twin; a twin started withrystic run --interceptkeeps PayPal’s hosts in its links, whichrystic execroutes to the twin. (RYS-1910) -
The order read accepts your client id and secret.
GET /v2/checkout/orders/{id}now answers HTTP Basicclient_id:secretas the sandbox does, beside a bearer token; the other payment routes still answer Basic with 401. (RYS-1920) -
The guide lists what the twin serves and what it does not, from the model.
HOWTO.mdand the docs page now carry the served operations and the known gaps generated from the twin’s model and its open issues: the spec operations not served, the branches the fidelity suite does not measure, what the model leaves out, and the known differences from the sandbox. (RYS-1918) -
The scripts in this archive are MIT-licensed.
check.shand the archive’s other scripts are published under the MIT License at https://github.com/Rystic-Inc/rystic-scripts , so you can copy, change and share them.LICENSE.txtno longer covers them; it still governs the twin itself.
v0.0.6 — 2026-09-30
-
PayPal-Debug-Idkeeps its spelling. A 404 on an authorization, capture or refund read now sendsPayPal-Debug-Idinstead ofPaypal-Debug-Id; the value still repeats the body’sdebug_id. (RYS-1336) -
The state UI shows the account as PayPal Business. On Pro and Enterprise,
/_rystic/uiopens a live PayPal Business-style view of the twin — Home with a derived balance, Activity with gross, fee and net, and each transaction’s details — where you can refund captures, capture or void authorizations and capture approved orders through the API, approve a waiting order as a chosen buyer, and watch your integration’s calls, the egress events, stored request ids and OAuth tokens. (RYS-1375)
v0.0.5 — 2026-09-26
- The guide starts from an API key, says what each plan reaches, and documents the payment events.
HOWTO.mdnow opens withrystic login rys_…, says what Free, Pro and Enterprise boot and which control-plane routes each answers, states which routes check a bearer token (and which seven refuse a minted token marked expired), and corrects the claim that the twin emits no webhook events: authorization capture and capture refund writePAYMENT.CAPTURE.COMPLETEDandPAYMENT.CAPTURE.REFUNDEDtoGET /_rystic/egress, and authorization void writes the twin’s ownauthorization_voidedrecord.SCENARIOS.mdsays what each plan boots.check.shfinds the API keyrystic loginstored as well as a license file, looked up in the CLI’s order, andrystic featuresmarks the features and commands that need Pro or Enterprise. (RYS-1204)
v0.0.4 — 2026-09-26
- Only
design_studioships as a named scenario. The older scenarios are now test-only fixtures and are no longer accepted by--scenario,RYSTIC_SCENARIO, orPOST /_rystic/scenario. Tests and evaluations can load their unchanged register maps and clocks fromsim/testdata/scenarios/through the control plane. (RYS-1177)
v0.0.3 — 2026-09-25
-
A new world,
design_studio, now the default.--scenario design_studioboots Website Design Contractors’ business account: 57 completed orders, one per invoice its eight PayPal clients paid, each with its capture,invoice_idthe studio’s invoice number andcustom_idthe client, the same invoices the studio’s Slack, Resend and Stripe worlds name. A lease that grants no scenarios boots it. (RYS-1171) -
The HOWTO now names what the twin does not do. Its scope section lists the six measured-refusal routes with their status codes, that there are no list routes, and that webhooks, bearer verification, asynchronous settlement and token expiry are not modeled; the worlds section documents
GET /_rystic/scenariosandPOST /_rystic/scenariofor switching worlds without a restart. (RYS-1020)
v0.0.2 — 2026-09-24
-
More accurate authentication errors. Requests without credentials now return PayPal-compatible 401 responses before resource lookup across checkout and payment endpoints. OAuth token requests with a missing or unsupported grant type return the corresponding
unsupported_grant_typeerror.Order validation and replay. Multiple purchase units require reference IDs. PATCH rejects removal of required amounts, empty purchase units, unsupported moves, invalid currencies, and missing reference selectors without changing state. Order reads reject XML-only response requests. Replayed order creation returns the current order after a successful PATCH.
-
Complete PATCH validation errors. Invalid order updates now report structural errors in request order, including repeated errors, before applying changes or validating amount values. Replacing the entire purchase-unit array is rejected. Failed compound updates preserve the original order and payment state.
-
USD refunds retain the original capture processing fee instead of returning a prorated amount. Ordinary USD refunds report zero PayPal fee and net equal to gross, including partial refunds and the final remainder. Replays preserve the stored refund and do not move money again.
Direct-card creation and order capture use the existing synthetic USD capture policy already used by authorization capture and scenario seeds: 3.49% plus USD 0.49, rounded to cents. This is deterministic simulator pricing, not a claim about a merchant’s current PayPal rate. Non-USD capture pricing and platform-fee return rules are unchanged and are not newly validated.
v0.0.1 — 2026-09-24
- Exercise PayPal checkout, authorizations, captures and refunds locally with twelve payment-history worlds, deterministic reset and clock control, validated retries and error contracts, and a packaged installation check. (RYS-1056)
Lob
v0.0.8 — 2026-10-12
-
The scripts in this archive are MIT-licensed.
check.shand the archive’s other scripts are published under the MIT License at https://github.com/Rystic-Inc/rystic-scripts , so you can copy, change and share them.LICENSE.txtno longer covers them; it still governs the twin itself. -
A world can carry its own timeline. A
--seedfile, or a whole one posted toPOST /_rystic/seed, may addcredentials, binding presented credentials to actors, andevents: calls the twin makes on its own API at set sim times as one of those actors, through the same handlers and egress as your requests. A top-levelcredentialsoreventskey the twin used to ignore now takes effect.RYSTIC_PACE=<speed>plays the clock, and the timeline, in real time, and an event’satmay be+<duration>./_rystic/inject,--injectandRYSTIC_INJECTnow needstate_setinstead ofscenarios. (RYS-2422) -
A timeline call can hand what it minted to later events. A
callevent may addbind,{"<name>": "<JSON Pointer>"}, saving fields of its answer, such as an ID the twin minted; a later event uses one as${name}in a call’s path, headers or body or in a seed event’s record keys and values (a register name cannot be bound). A schedule naming a${name}no earlier call binds is refused at load, and an event whose binding call failed records an error instead of firing. (RYS-2505) -
design_studiocarries a timeline. As the clock moves past2026-03-01T00:00:00Z, the world saves Harborview Dental’s second office, sends a spring-checkin postcard to Redwood Realty under an idempotency key, schedules one to Atlas Moving and cancels it a day later, then lists the campaign, each a call on the twin’s own API under the world’s bound test key. A test that advances the clock past+1hnow sees these records. (RYS-2459) -
A world can move a postcard through the mail.
POST /_tracking_eventrecords a USPS scan (In Transit,Delivered,Returned to Senderand Lob’s other documented names) on a postcard’stracking_events, so a world’s timeline can carry a piece from mailing to delivery. (RYS-2461) -
A clock jump fires each timeline event at its own time. When one
POST /_rystic/clockor paced tick passes several events, each fires with the clock at itsat, so the records it creates carry that time, and the clock then lands where the move asked. An event already due when the clock moves fires at the current time. (RYS-2520) -
A clock jump stamps each timeline event at its exact instant. When a clock move passes timeline events, the records each one makes carry exactly its
at, to the millisecond, whatever traffic came before it, so one jump leaves the same records as stepping the clock to each event in turn. (RYS-2542) -
A timeline call can carry raw bytes, XML or a JSON array. A
callevent may sendbody_textorbody_base64instead ofbody; the twin sends it as written, under the route’s own content type (XML, bytes, JSON or a form), so a scheduled call can upload an object’s bytes or post an XML document, and${name}fills inbody_text. Abodymay be a JSON array where the route reads one. A call that answers XML binds from its elements by JSON Pointer, such as/InitiateMultipartUploadResult/UploadId. (RYS-2523)
v0.0.7 — 2026-10-11
-
check.shfails fast on a port that never answers. Its port check and boot wait give each/_rystic/versionprobe two seconds, so an address where something accepts connections but never replies fails the check with a message instead of hanging. (RYS-1444) -
The test fixtures seed Lob’s render status on postcards. In
lob_mailroom,lob_returned_mailandlob_campaign_blastundersim/testdata/scenarios/, a mailed postcard’sstatuswas a mail state (delivered,in_transit,returned_to_sender, …) orcanceled. It is nowrendered, a value of Lob’s PDF render status; the mail’s progress stays intracking_events, and the canceled card is still markeddeleted: true. (RYS-1600) -
Deleting an address marks the postcards sent with it, as Lob does. After
DELETE /addresses/{adr_id}, a postcard sent from or to that address is still served, and its embeddedfromortoaddress answersdeleted: true. Measured against Lob’s test environment. (RYS-1858) -
A list’s
next_urlandprevious_urllead back to the twin, and/v1paths answer. Both list cursors name the host your client reached the twin on, under/v1, where they namedapi.lob.com, so a client that follows one stays on the twin. Every Lob path now answers with and without the/v1prefix, so an SDK’s host override may keep Lob’shttps://api.lob.com/v1form with the twin’s host. A twin started withrystic run --interceptkeepsapi.lob.comin those URLs, which its gateway routes to the twin. (RYS-1910) -
A request without credentials answers Lob’s
Missing authentication. Every route answers a request that presents no Basic credential, with noAuthorizationheader or one under another scheme such asBearer, with Lob’s401{"error":{"message":"Missing authentication","status_code":401,"code":"unauthorized"}}before it reads anything else, so a refused create writes nothing; it answered “Your API key is not valid…” or “The request isn’t authorized.” before. A Basic header with an empty username, or with a payload that is not base64, still answers the route’s own401. (RYS-1920) -
A seeded postcard renders as a created one. In
design_studioand any world you seed, a postcard’stoandfromanswer the address objects a created postcard embeds, where they answered bare address ids, and a record missingobject(or a postcard missingcarrier) takes Lob’s value.design_studio’s postcards also carryraw_url,print_speed,to_be_expunged_date,qr_code,completed_at,is_creative_proof,is_dashboard,usps_campaign_idandlob_credits_funding_status, and its addressesrecipient_moved, as created ones do. (RYS-1911) -
The guide’s Coverage and Limitations are generated from the twin. Coverage lists every route the twin serves at its
/v1path, and Limitations end with the twin’s open issues and the spec operations, unmeasured branches and unmodeled behaviors it leaves out, regenerated whenever the twin changes. The postcard list’s total count is requested asinclude[]=total_count, the form Lob takes. (RYS-1918) -
lob-assets.comrender URLs are declared Lob’s asset host. A postcard’surl,raw_urland thumbnail URLs onlob-assets.com, seeded or answered, are handed out as written and never rebased onto the twin, by declaration (pipeline_policy.jsonhost_roles) rather than by omission, so a seed carrying Lob’s own render URLs passesRYSTIC_SEED_GATE=reject. The twin serves no render behind them. (RYS-1910)
v0.0.6 — 2026-09-30
-
Rate-limit headers keep their lowercase spelling.
ratelimit-limit,ratelimit-remainingandratelimit-reseton address and postcard creation now reach the wire lowercase instead of asRatelimit-Limitand friends, so a client that keys on the received header name reads the names as written. (RYS-1336) -
The state UI shows the account as the Lob dashboard. On Pro and Enterprise,
/_rystic/uiopens a live Lob-style view of the twin — postcards with their proof, addresses and tracking, and the address book — where you can send and cancel postcards and add or delete addresses through the API, beside Mail stream and Calls cards. (RYS-1377)
v0.0.5 — 2026-09-26
- The guide starts from an API key and says what each plan reaches.
HOWTO.mdnow opens withrystic login rys_…, says what Free, Pro and Enterprise boot and which control-plane routes each answers, and states thatDELETE /addresses/{adr_id}is the one route that checks no credential;SCENARIOS.mdsays what each plan boots. Therystic featuresexample that filtered on a status no postcard carries now filtersdesign_studioby client, the idempotent-send example mails between two ofdesign_studio’s own addresses, and the features and commands that need Pro or Enterprise say so.check.shfinds the API keyrystic loginstored as well as a license file, looked up in the CLI’s order. (RYS-1204)
v0.0.4 — 2026-09-26
- Only
design_studioships as a named scenario. The older scenarios are now test-only fixtures and are no longer accepted by--scenario,RYSTIC_SCENARIO, orPOST /_rystic/scenario. Tests and evaluations can load their unchanged register maps and clocks fromsim/testdata/scenarios/through the control plane. (RYS-1177)
v0.0.3 — 2026-09-25
-
The free edition is replaced by self-serve on the metered edition. A Rystic account’s token runs the metered build:
rystic login rys_…once, thenrystic runordocker run -e RYSTIC_TOKEN=…. It carries every scenario and control-plane route your plan includes, and bills active 30-second blocks against the plan’s allowance. The fixed-world free download and its usage report are gone. (RYS-1097) -
A new world,
design_studio, now the default.--scenario design_studioboots Website Design Contractors’ account: the studio’s return address, 38 client addresses and 63 first-class postcards, a holiday campaign and a thank-you card after each site launch, each taggedmetadata[campaign]andmetadata[client], with one holiday card returned to sender. The clients are the same ones the studio’s Slack, Stripe and Resend worlds hold. A lease that grants no scenarios boots it instead oflob_mailroom. (RYS-1171) -
Every probe matches live Lob. The last full-suite round scored 21 of 21 probes on 2026-09-24 against real
api.lob.com, and the four differences the first release listed are repaired: a last page answersnext_url: null, an inlinetoorfromcarriesemailandphoneas null and a postcard read answersdeleted: false, a canceled postcard reads back as the canceled piece withdeleted: true, and the captured 210 King St address reads back with Lob’s ZIP+4. One known difference remains, disclosed under Limitations: a failed postcard can still be canceled inside its send window where Lob answers422 not_deletable. (RYS-1090)
v0.0.2 — 2026-09-24
-
Postcard cancellation and response consistency. Canceled postcards remain retrievable with
deleted: trueand disappear from collection lists; created postcards retain front/back thumbnail links. Scheduled filtering compares send and creation dates, and omitted send dates use the documented default cancellation window. Address creation stores its standardized response, while reads preserve stored address values. Lob API keys resolve through the shared HTTP Basic authentication layer, including regenerated testbooks and shape checks. Collection probes isolate their seeded postcards with a fresh metadata filter and exercise pagination with two records. (RYS-1067) -
Updated coverage guidance. The Lob guides now describe repaired cancellation, pagination, thumbnail and address responses, clarify HTTP Basic authentication, and distinguish seeded mail stages from unverified asynchronous rendering and failure transitions. (RYS-1085) The guides also disclose the failed-postcard cancellation mismatch, with repair tracked in RYS-1090. (RYS-1085)
v0.0.1 — 2026-09-24
-
A local Lob for your mail-sending code’s tests. The first release of the Lob twin: 8 routes over addresses (create, retrieve, delete, list) and postcards (create, retrieve, cancel, list), served from a model validated against real
api.lob.comwith a test key. Point your Lob SDK’s host at the twin —http://localhost:8080, no/v1— and your existing calls run against it unchanged, with form-encoded or JSON bodies. (RYS-1023) -
A retried send is the same postcard.
POST /postcardshonours anIdempotency-Keyheader, or anidempotency_keyquery parameter, as Lob does: a replay under the same key answers the original postcard instead of sending a second one, so the retry loop around your send is testable without mailing anything twice. (RYS-1023) -
Four named worlds, baked in.
--scenario lob_mailroomboots five postcards standing at every healthy stage from rendered through delivered, with their tracking histories;lob_returned_mailthe returned, failed, re-routed and canceled pieces with theirfailure_reason;lob_campaign_blastsix sends under onecampaign_idfor paging and filters;lob_quiet_deskone of everything.SCENARIOS.mdsays what each is for. (RYS-1023) -
Seed the mailroom your test needs, and get the same one twice.
POST /_rystic/seedlands addresses, postcards and idempotency keys directly;GET /_rystic/registerslists every field it accepts.POST /_rystic/resetrewinds the id sequence and the clock, so two identical runs give byte-identical postcards down toid,send_dateandexpected_delivery_date. (RYS-1023) -
It never reaches the network, and it needs no key. Any key is accepted as the Basic username and the twin dials nothing: nothing you send renders a PDF, counts against a rate limit, or could ever print or mail a piece. Not in this release: letters, checks, self-mailers, templates, campaigns as a resource, address verification and webhooks; a postcard does not move through the mail stream on its own — boot a world or seed the stage you need. (RYS-1023)
-
What is measured, and what is not. Fidelity is scored differentially against real
api.lob.comwith a test key, per response field, not by eyeball: the last full-suite round was 13 of 21 probes on 2026-09-24. That is an early number and this release ships it as-is. The misses are four known differences, listed inHOWTO.mdunder Limitations:next_urlis a link on the last page where Lob sends null; an inlinetoorfromaddress omitsemailandphone, and a postcard read answersdeleted: false, where Lob sends null; a canceled postcard reads back as 404 where Lob answers the canceled piece; and an address reads back with the ZIP you sent, not Lob’s ZIP+4. Rounds are re-run daily and each release states its own number. (RYS-1023)
Okta
v0.0.3 — 2026-10-12
-
A world can carry its own timeline. A
--seedfile, or a whole one posted toPOST /_rystic/seed, may addcredentials, binding presented credentials to actors, andevents: calls the twin makes on its own API at set sim times as one of those actors, through the same handlers and egress as your requests. A top-levelcredentialsoreventskey the twin used to ignore now takes effect.RYSTIC_PACE=<speed>plays the clock, and the timeline, in real time, and an event’satmay be+<duration>./_rystic/inject,--injectandRYSTIC_INJECTnow needstate_setinstead ofscenarios. (RYS-2422) -
A timeline call can hand what it minted to later events. A
callevent may addbind,{"<name>": "<JSON Pointer>"}, saving fields of its answer, such as an ID the twin minted; a later event uses one as${name}in a call’s path, headers or body or in a seed event’s record keys and values (a register name cannot be bound). A schedule naming a${name}no earlier call binds is refused at load, and an event whose binding call failed records an error instead of firing. (RYS-2505) -
design_studiocarries a timeline. As the clock moves past2026-03-01T00:00:00Z, the admin Noor Haddad adds Hannah Kim to Content and retitles her, suspends the freelancer Ava Chen and removes her from Freelancers, then creates and activates Lena Okafor and adds her to Freelancers, each a call on the twin’s own API under Noor’s bound token. A test that advances the clock past+1hnow sees these changes, and Lena takes the first user id the twin mints. (RYS-2459) -
A clock jump fires each timeline event at its own time. When one
POST /_rystic/clockor paced tick passes several events, each fires with the clock at itsat, so the records it creates carry that time, and the clock then lands where the move asked. An event already due when the clock moves fires at the current time. (RYS-2520) -
A clock jump stamps each timeline event at its exact instant. When a clock move passes timeline events, the records each one makes carry exactly its
at, to the millisecond, whatever traffic came before it, so one jump leaves the same records as stepping the clock to each event in turn. (RYS-2542) -
A timeline call can carry raw bytes, XML or a JSON array. A
callevent may sendbody_textorbody_base64instead ofbody; the twin sends it as written, under the route’s own content type (XML, bytes, JSON or a form), so a scheduled call can upload an object’s bytes or post an XML document, and${name}fills inbody_text. Abodymay be a JSON array where the route reads one. A call that answers XML binds from its elements by JSON Pointer, such as/InitiateMultipartUploadResult/UploadId. (RYS-2523)
v0.0.2 — 2026-10-11
-
A group lookup’s error id comes from its own range. An error from
GET /api/v1/groups/{groupId}carries anerrorIdsuch asoae6f3c83c1-0474-49d0-8000-000000000001rather thanoae00000001-0000-4000-8001-000000000001. Ids still count up in their last digits and restart on reset; a test that pinned an olderrorIdneeds the new one. (RYS-1593) -
The test fixtures seed
transitioningToStatus: null. The fourokta_*fixtures undersim/testdata/scenarios/seeded an empty string for a user with no transition in progress, whichPUT /api/v1/users/{id}andGET /api/v1/groups/{groupId}/usersechoed back. They now seednull, as the twin’s own lifecycle operations write it. (RYS-1600) -
Offboarding a user removes their app access, as Okta does. Deactivating a user, by
POST /api/v1/users/{id}/lifecycle/deactivateor a firstDELETE, and purging one remove their app assignments, direct and group-derived:GET /api/v1/apps/{appId}/users/{userId}answers 404E0000007and the app’s user list leaves them out. A group assigned to an app afterwards gives a deactivated member no access, adding a deactivated user to a group is refused 403E0000038, and activating the user again restores their group-derived access but not a direct assignment. Each was measured against a live Okta org. (RYS-1847) -
Deleting a group, an app or a user removes what names it, as Okta does. After
DELETE /api/v1/groups/{groupId},GET /api/v1/apps/{appId}/groups/{groupId}answers 404E0000007, and the app access members held only through that group goes with it. A direct assignment, or access held through another assigned group, stays. Deleting an app removes its user and group assignments, and purging a user removes their enrolled factors. The group behavior was measured against a live Okta org. (RYS-1857) -
A JSON Patch on an app’s group assignment is applied.
PATCH /api/v1/apps/{appId}/groups/{groupId}with an array such as[{"op":"replace","path":"/priority","value":9}]now changes the assignment: the answer and a laterGETcarry the replacedpriority,profileorprofileattribute, and an empty array changes nothing. Before, the twin answered 200 and left the assignment as it was. (RYS-1850) -
A user write must carry the profile properties Okta requires.
POST /api/v1/usersandPUT /api/v1/users/{id}refuse a profile that omits, nulls or blankslogin,firstName,lastNameoremailwith 400E0000001and one"<property>: The field cannot be left blank"cause per such property, plus Okta’s"login: Username must be in the form of an email address"when a create has nologin.POST /api/v1/users/{id}may still leave them out, but refuses nulling or blanking one. Before, the twin created a user withoutfirstName,lastNameoremail, replaced one withoutfirstNameorlastName, and let a partial update null either name. (RYS-1850) -
Every link in
design_studioleads back to the twin. Seeded users’ and groups’_links, on every route that answers them, are on the origin you reached the twin on, as the links the twin builds already were. They named a host on Okta’s own domain, so following one left the twin; a test that resolved a link’s path against its org URL can now follow the link as it is. The fourokta_*test fixtures undersim/testdata/scenarios/moved the same way. (RYS-1851) -
Listed users no longer carry
realmId.GET /api/v1/usersanswers each user without it, as Okta does and asGET /api/v1/users/{id}already did. Before, a user given arealmIdby a write or a seed showed it in the list and not in its own read. Measured against Okta’s reference org, whose lists and reads carry norealmId. (RYS-1850) -
The scripts in this archive are MIT-licensed.
check.shand the archive’s other scripts are published under the MIT License at https://github.com/Rystic-Inc/rystic-scripts , so you can copy, change and share them.LICENSE.txtno longer covers them; it still governs the twin itself.
v0.0.1 — 2026-10-01
-
A local Okta Management API.
okta-twinserves 49 routes of Okta’s Management API — users and their lifecycle, groups and memberships, bookmark applications with their user and group assignments, password operations and OKTA software-TOTP factors — plus the/_rystic/control plane for reset, seeding and the clock. It accepts any non-emptyAuthorizationheader and never calls Okta, so no activation email is sent and no real directory changes. (RYS-1384) -
Okta’s user lifecycle, two-step delete included. Users move through
STAGED,ACTIVE,SUSPENDEDandDEPROVISIONEDby the lifecycle routes. A firstDELETEdeactivates a user and a second purges it; a plainGET /api/v1/usersleavesDEPROVISIONEDusers out whilesearchandfilterinclude them; and a profile update on aDEPROVISIONEDuser answers 403E0000038, as an org with deactivated-profile edits turned off does. (RYS-1065) -
Password operations and software-TOTP factors. Reset, change and expire a password, reactivate a user and reset their factors; list a user’s factor catalog, and enroll, read, activate, verify and delete an OKTA
token:software:totpfactor with codes computed from its shared secret. Other factor types enroll but do not activate, and OAuth/OIDC and browser sign-in are not served. (RYS-1135) -
One shipped world,
design_studio. Website Design Contractors’ Okta org: 36 users (33ACTIVE, threeDEPROVISIONEDex-staff) in ten groups, Everyone included, each profile carrying title, department and manager. Free boots it andPOST /_rystic/resetreturns to it; Pro and Enterprise boot an empty org unless--scenario design_studionames it. (RYS-1177) -
Measured against a live Okta org. The 2026-10-01 full-suite round matched 143 of 143 probes against an Okta Integrator Free Plan org.
HOWTO.mdstates what the suite leaves out and the differences this release ships with. (RYS-1384)
Linear
v0.0.5 — 2026-10-12
-
A world can carry its own timeline. A
--seedfile, or a whole one posted toPOST /_rystic/seed, may addcredentials, binding presented credentials to actors, andevents: calls the twin makes on its own API at set sim times as one of those actors, through the same handlers and egress as your requests. A top-levelcredentialsoreventskey the twin used to ignore now takes effect.RYSTIC_PACE=<speed>plays the clock, and the timeline, in real time, and an event’satmay be+<duration>./_rystic/inject,--injectandRYSTIC_INJECTnow needstate_setinstead ofscenarios. (RYS-2422) -
A timeline call can hand what it minted to later events. A
callevent may addbind,{"<name>": "<JSON Pointer>"}, saving fields of its answer, such as an ID the twin minted; a later event uses one as${name}in a call’s path, headers or body or in a seed event’s record keys and values (a register name cannot be bound). A schedule naming a${name}no earlier call binds is refused at load, and an event whose binding call failed records an error instead of firing. (RYS-2505) -
linear_sprint_boardplays a timeline. Over the four hours after the pinned clock, Hana replies on ENG-103 and moves it to Done, Deploy Bot attaches a production deploy to it, Tomas starts ENG-102 and Yuki files a Backlog follow-up, each throughPOST /graphqlunder an API key the world binds to that person; advance the clock to watch the sprint move. (RYS-2459) -
A clock jump fires each timeline event at its own time. When one
POST /_rystic/clockor paced tick passes several events, each fires with the clock at itsat, so the records it creates carry that time, and the clock then lands where the move asked. An event already due when the clock moves fires at the current time. (RYS-2520) -
A clock jump stamps each timeline event at its exact instant. When a clock move passes timeline events, the records each one makes carry exactly its
at, to the millisecond, whatever traffic came before it, so one jump leaves the same records as stepping the clock to each event in turn. (RYS-2542) -
A timeline call can carry raw bytes, XML or a JSON array. A
callevent may sendbody_textorbody_base64instead ofbody; the twin sends it as written, under the route’s own content type (XML, bytes, JSON or a form), so a scheduled call can upload an object’s bytes or post an XML document, and${name}fills inbody_text. Abodymay be a JSON array where the route reads one. A call that answers XML binds from its elements by JSON Pointer, such as/InitiateMultipartUploadResult/UploadId. (RYS-2523)
v0.0.4 — 2026-10-11
-
The scripts in this archive are MIT-licensed.
check.shand the archive’s other scripts are published under the MIT License at https://github.com/Rystic-Inc/rystic-scripts , so you can copy, change and share them.LICENSE.txtno longer covers them; it still governs the twin itself. -
The docs say what an empty workspace’s
vieweranswers. Booted without--scenario, the twin answersviewerwith its placeholder user until you seed a person; the plans section said it was refused. (RYS-2401)
v0.0.3 — 2026-10-11
- Free runs every control route but latency and fault injection. On Free, scenarios, seeding, the clock, inspect, the state UI and replay now work as on Pro, and the twin boots empty without
--scenario./_rystic/networkand/_rystic/ws-dropanswer 403 with"feature":"faults"and stay Pro and Enterprise. (RYS-2400)
v0.0.2 — 2026-10-11
-
An issue and a comment never share an id. Each kind of record the twin creates draws its ids from its own range: the first issue you file is
554dd3a1-3e25-42f1-8000-000000000001and the first comment925e28d1-dad3-4fd4-8000-000000000001, where both were00000001-0000-4000-8001-000000000001. Ids still count up in their last digits and restart on reset; a test that pinned an old id needs the new one. (RYS-1593) -
Introspection answers each argument’s type.
__type’sfields { args }now carrytypeas the specification nests it (kind,name,ofType), so a client that reads argument signatures, such asargs { name type { kind name ofType { kind name } } }, gets them instead of a validation error. (RYS-1604) -
Nested lists answer the records that name them. An issue’s
parent,children,cycle,project,labels,relationsandinverseRelations, a cycle’s, label’s and project’sissues, a project’steams, a team’sprojects, a user’sassignedIssues, and the organization’steamsandusersnow list what the workspace holds, where they answered empty or null. An issue filed with aparentIdorlabelIds, or updated to one, shows up under its parent’schildrenand each label’sissues, and a new issue’screatorreads the caller.issueCreatetakes the parent by UUID or by identifier such asENG-102;issueUpdatestill drops an identifier, so name the parent by its UUID there. (RYS-1909) -
vieweranswers in a world with no scenario, and a seed names its caller undermember. A twin started without a scenario resolves its caller, a placeholder user with an empty name, sovieweranswers a user instead of an error. A world or a seed names the personvieweranswers by keying thatstate_userrecordmember, itsidits own; every shipped world does. A seed that keys none gets the placeholder, whereviewerused to answer the user markedisMe. (RYS-1911) -
The guide lists what the twin serves and what it does not, from the model.
HOWTO.mdand the docs page carry the served operations and the known gaps generated from the twin’s model and its open issues: the schema operations not served, the non-null fields the model does not declare, the branches the fidelity suite does not measure, what the model leaves out, and the known differences from Linear. They also say how to seed the personvieweranswers: astate_userkeyedmember. (RYS-1918) -
A JSON field or a small payload object the twin does not model no longer fails the response.
reactionData,Team.securitySettingsandsearchIssues’archivePayloadanswer an empty value where they answeredCannot return null for non-nullable field …, so@linear/sdk’sissues(),issue(id),createIssue,updateIssue,createCommentandsearchIssuesreturn data.teams(),projects()andcreateProjectstill fail onTeam.visibilityandProject.status. (RYS-1873) -
Malformed requests are refused before they run, as Linear refuses them. A missing required variable now answers 400
BAD_USER_INPUT, a variable whose type does not fit its argument ($id: ID!whereString!is expected) 400GRAPHQL_VALIDATION_FAILED, a document with several operations and nooperationName400INPUT_ERROR, a subscription 400 with “Cannot query field … on type “Subscription"", and a JSON-array batch body 400BAD_REQUEST“Operation batching disabled.”. Before, each one executed or answered a different error. (RYS-1881) -
attachment(id:)answers Linear’s not-found error. Asking for an attachment that does not exist now answersINPUT_ERRORwithstatusCode400, the messageEntity not found: Attachment: could not find by url.andCould not find referenced attachment with this url., as Linear does, instead of anINVALID_INPUT“Argument Validation Error”. (RYS-1878) -
A request without
Authorizationis refused before it runs. A GraphQL request with noAuthorizationheader now answers401with Linear’sAUTHENTICATION_ERRORerror before any field resolves, so a refused mutation writes nothing; it used to run as the world’s caller. An emptyAuthorizationheader gets the same refusal, which is the twin’s own: Linear’s answer to an empty header is not measured. Any non-empty value, a bare key or aBearertoken, is still accepted, so a test that sends no header now needs a placeholder key. (RYS-1920) -
Introspection answers each field’s type and an input object’s fields.
__type’sfieldsnow carrytype(kind,name,ofType), and__type(name: "IssueFilter") { inputFields { name type { kind name } } }lists an input object’s fields, instead of a validation error. (RYS-1882)
v0.0.1 — 2026-10-01
-
A local Linear for your agent’s tests. The first release of the Linear twin: 58 GraphQL operations on Linear’s one endpoint,
POST /graphql— issues (create, update, archive, unarchive, delete, search), comments, labels, relations, projects with milestones and status updates, documents and attachments, and read-only teams, users, workflow states, cycles and the organization — served from a model validated against the real Linear API. Point your client’s API URL athttp://localhost:8080/graphql(apiUrlin@linear/sdk) and your existing queries and mutations run against it unchanged. (RYS-1619) -
Five named workspaces, and the one your test needs.
--scenario linear_sprint_boardboots an engineering team on the day cycle 7 closes — six people, six workflow states, three labels, eight issues with comments, relations and attachments — and is the world Free boots.linear_triage_inboxholds five untriaged support issues,linear_roadmap_quarterthree projects with milestones and status updates,linear_solo_workspaceone person and one team, andlinear_archive_vaultarchived and trashed records;SCENARIOS.mdsays what each is for. On Pro and Enterprise,POST /_rystic/seedlands teams, people, workflow states, cycles and any issue, comment, label, project or document directly.POST /_rystic/resetrewinds the id sequence and the clock, so two identical runs give byte-identical issues down toid,identifier,numberandcreatedAt. (RYS-1619) -
It never reaches the network, and it needs no key. Any
Authorizationvalue is accepted, as a bare API key or aBearertoken, and the twin dials nothing: no test issue lands in a real workspace and no webhook fires. Not in this release, on purpose: workspace administration (creating teams, inviting users, workflow and cycle settings), integrations, OAuth applications, webhooks, billing and initiatives. A query for any of them answersGRAPHQL_VALIDATION_FAILEDrather than empty data. (RYS-1619) -
What is measured, and what is not. Fidelity is scored differentially against the real Linear API, per response field, not by eyeball: the last full-suite round matched 64 of 71 probes on 2026-10-01, with 2 more inconclusive, on a 73-probe suite. That is an early number and this release ships it as-is. An issue read resolves its team, workflow state, assignee and comments, but driving the twin as an agent would found differences the score does not show, listed in
HOWTO.mdunder Limitations: some non-null fields (Issue.priorityLabel,Issue.branchName,Project.state) fail the whole response;issue.labelsandissue.attachmentsanswer empty; a new issue without astateIdhas no state; a new issue in a world is numbered from 1;issuesignoresfilter; and__schemaintrospection is refused. Each release states its own number. (RYS-1619)
Stripe
v0.0.11 — 2026-10-12
-
A clock jump stamps each timeline event at its exact instant. When a clock move passes timeline events, the records each one makes carry exactly its
at, to the millisecond, whatever traffic came before it, so one jump leaves the same records as stepping the clock to each event in turn. (RYS-2542) -
Hosted Checkout completes subscription sessions. Subscribing on a
subscription-mode session’s page creates the customer and a subscription with the session’s prices, quantities andsubscription_data[metadata], completes the session and redirects tosuccess_url, sendingcustomer.created,customer.subscription.createdandcheckout.session.completedto your registered endpoints in that order. Payment and setup sessions still answer 501. (RYS-2545) -
A subscription ends at its
cancel_at. Once the twin’s clock reaches a subscription’scancel_at(set through the API or the customer portal), the subscription readscanceledwithended_atset andcustomer.subscription.deletedgoes to your registered endpoints, so aPOST /_rystic/clockadvance past it plays out the cancellation. (RYS-2545)
v0.0.10 — 2026-10-12
-
The scripts in this archive are MIT-licensed.
check.shand the archive’s other scripts are published under the MIT License at https://github.com/Rystic-Inc/rystic-scripts , so you can copy, change and share them.LICENSE.txtno longer covers them; it still governs the twin itself. -
Webhook endpoints can receive their events. Boot with
RYSTIC_WEBHOOK_DELIVERY=onand every event your requests trigger is POSTed to each enabled endpoint registered throughPOST /v1/webhook_endpointswhoseenabled_eventsnames it or*, signed with that endpoint’ssecretinStripe-Signatureat wall-clock time sostripe.webhooks.constructEventaccepts it however the twin’s clock is pinned; without the variable nothing is POSTed. (RYS-2421, RYS-1463) -
A world can carry its own timeline. A
--seedfile, or a whole one posted toPOST /_rystic/seed, may addcredentials, binding presented credentials to actors, andevents: calls the twin makes on its own API at set sim times as one of those actors, through the same handlers and egress as your requests. A top-levelcredentialsoreventskey the twin used to ignore now takes effect.RYSTIC_PACE=<speed>plays the clock, and the timeline, in real time, and an event’satmay be+<duration>./_rystic/inject,--injectandRYSTIC_INJECTnow needstate_setinstead ofscenarios. (RYS-2422) -
A timeline call can hand what it minted to later events. A
callevent may addbind,{"<name>": "<JSON Pointer>"}, saving fields of its answer, such as an ID the twin minted; a later event uses one as${name}in a call’s path, headers or body or in a seed event’s record keys and values (a register name cannot be bound). A schedule naming a${name}no earlier call binds is refused at load, and an event whose binding call failed records an error instead of firing. (RYS-2505) -
A Checkout Session’s url opens the twin’s hosted Checkout page.
urlnow leads back to the twin instead ofcheckout.stripe.com, and the page shows the session’s line items, total and email with a link back tocancel_url. Submitting it is not simulated yet: it answers 501 and the session stays open. (RYS-1463) -
GET /v1/eventslists the events your requests trigger. Creating a customer, a charge or any other object that triggers an event now also writes that event to/v1/events, under the same id its webhook delivery carries, soGET /v1/eventsandGET /v1/events/{id}return it beside any seeded events. (RYS-2460) -
A world can open a dispute.
POST /_disputeopens a dispute on a captured charge, the way a cardholder’s bank does: the charge readsdisputed, a refund is refused withcharge_disputed, andcharge.dispute.createdreaches the egress log and every endpoint registered for it. A world’s timeline schedules it like any other call. (RYS-2461) -
Products, prices, subscriptions, customer portal sessions and meter events. The twin serves 14 more Stripe routes: create and retrieve a product; create, retrieve and list prices; create, retrieve, update, cancel, list and search subscriptions; update a subscription item; create a customer portal session and a billing meter event. A subscription’s create, update and cancel emit
customer.subscription.created,.updatedand.deleted. A portal session’s url leads back to the twin, which serves no portal page yet. (RYS-1463) -
A clock jump fires each timeline event at its own time. When one
POST /_rystic/clockor paced tick passes several events, each fires with the clock at itsat, so the records it creates carry that time, and the clock then lands where the move asked. An event already due when the clock moves fires at the current time. (RYS-2520) -
Webhook signatures can follow a shifted receiver clock.
POST /_rystic/clock {"wall_offset_seconds": N}signs every webhook delivery’sStripe-Signaturet=N seconds past the wall clock, so a receiver whose own clock you moved forward (assandbox clock -advancedoes) still accepts it;0or/_rystic/resetputs it back. (RYS-2538) -
A timeline call can carry raw bytes, XML or a JSON array. A
callevent may sendbody_textorbody_base64instead ofbody; the twin sends it as written, under the route’s own content type (XML, bytes, JSON or a form), so a scheduled call can upload an object’s bytes or post an XML document, and${name}fills inbody_text. Abodymay be a JSON array where the route reads one. A call that answers XML binds from its elements by JSON Pointer, such as/InitiateMultipartUploadResult/UploadId. (RYS-2523) -
A customer portal session’s url opens the twin’s customer portal.
urlleads back to the twin at/p/session?secret=…, which lists the customer’s subscriptions with their seats and renewal date. Changing seats, cancelling at the end of the billing period (cancel_atset,cancel_at_period_endleftfalse, as Stripe’s portal does) and taking a cancellation back each update the subscription and sendcustomer.subscription.updatedto your registered webhook endpoints. (RYS-2544)
v0.0.9 — 2026-10-11
-
check.shfails fast on a port that never answers. Its port check and boot wait give each/_rystic/versionprobe two seconds, so an address where something accepts connections but never replies fails the check with a message instead of hanging. (RYS-1444) -
Answers carry the fields Stripe’s API reference fixes. Where a response would leave out, or send null for, a field the reference fixes for that route and status (a required constant such as
object, a required field’s declared default, a variant’s tag), the twin now fills it in. (RYS-1905) -
Line items are built from the request’s
line_items.GET /v1/checkout/sessions/{session}/line_itemsandGET /v1/payment_links/{payment_link}/line_itemslist one line item perline_itemselement of the create, in request order, each with its ownli_id, the element’s quantity, the product name as its description, a Price (minted fromprice_data, inactive and one-time), andamount_subtotalequal to the unit amount times the quantity. On either list, a line naming an existing Price carries that Price with every field a Stripe Price requires. The twin holds no Prices, so the Price’s product and creation time come from its id alone, the same on every session and link that names it, and the rest are defaults: active, one-time, per-unit, in the session’s or link’s currency (usdwhen the create gives none). Its unit amount stays unknown, so the line’s amounts are 0. A session no longer collapses its lines into one, or answers a null currency when every line names a Price. A payment link no longer answers the idli_unknownor amounts of 0 on a line built fromprice_data, and neither list answers a Price that is only an id. (RYS-483) -
check.shgives each run its own twin log. The twin’s log is no longer the shared/tmp/stripe-twin.log: each run writes it to a path of its own, so two checks running at once on one host no longer overwrite each other’s log or report a failure from the other’s. When the check leaves the twin running, its last line prints the log’s path; when it stops the twin, it removes the log. (RYS-1951)
v0.0.8 — 2026-09-30
-
Charge lists include the full charge details.
GET /v1/chargesnow includes the 24 fields previously available only when retrieving an individual charge, including billing details, payment-method details, outcome, and nullable references. Stored details are preserved, so callers can inspect charges directly from list results. -
Retries reuse the original response. Repeating a POST with the same
Idempotency-Keyand parameters returns the original response without creating another object. Reusing the key with different parameters returns400 idempotency_error. (RYS-1241) -
Simulator limitations are easier to find. The HOWTO now explains unsupported behavior, incomplete object shapes, missing error fields, and scenario-clock differences, and the release notes point to it for current limitations. (RYS-1239)
-
Stripe-compatible errors and response headers. Unknown URLs and trailing slashes now return Stripe’s invalid-request envelope. Supported routes include
Request-IdandStripe-Version, and echo a pinnedStripe-Versionrequest header. (RYS-1245) -
Full list objects and successful-card seeds. PaymentIntent, refund and customer list items retain their full object fields. Seeded PaymentIntent reads include nullable fields present on created records, and customer list projections preserve explicit nulls. Successful card charges in the scenario pack include synthetic payment-method, outcome and balance-transaction details. PaymentIntent confirmation records card details and an authorized outcome on its charge. Oracle key-set probes check list items, member reads, and seeded records. (RYS-1242)
-
Expand related objects on reads. Charge and PaymentIntent retrieve/list routes and PaymentMethod retrieve accept
expand[]for their modeled links. Nested paths, multiple paths and list expansions return canonical related objects. Invalid paths return 400 before the handler runs. Null references stay null. Expansion on other routes is not supported. (RYS-1240) -
Reference
Immediate test-card declines
Charge creation and PaymentIntent creation/confirmation recognize the measured immediate-decline test inputs and return HTTP 402 card errors with the matching code, decline code, message, parameter, and input-specific advice/network codes. The error includes synthetic request/charge identifiers and the measured PaymentMethod shape where applicable. A failed intent retains its requires_payment_method status and last_payment_error for subsequent reads.
The probe generator supports service-declared negative body values and request headers. It measures refusal responses separately from optional state readbacks and keeps cleanup for prerequisites. A valid confirmation retry verifies that the persisted error clears before successful state is stored. Conditional decline-after-attachment inputs, failed-charge projection, and retry-limit cancellation remain outside this closure. (RYS-1243)
-
Reference
Parameter validation
Invalid amount syntax, malformed customer email and webhook settings, oversized metadata, missing token/customer references, and conflicting payment settings return measured Stripe errors before successful writes. Charge currency is normalized to lowercase. Missing-source errors precede missing-amount errors, and missing parameters and error links use the measured response fields.
Schema-derived probes independently exercise type, format, bounds, enum, map-key/value, unknown-parameter and missing-reference cases. Bounds that the oracle accepts remain success witnesses: list limits outside 1–100 are clamped, while non-integer limits and created-time bounds are rejected. Complete error key sets distinguish absent fields from null fields. These witnesses do not establish validation of every Stripe parameter or every currency’s minimum. Repeated scalar query values use the last supplied value consistently for validation, pagination and created-time filtering. A scoped live receipt and seeded timestamp regression tests cover both orderings of duplicate inputs. (RYS-1244)
-
The state UI shows the account as the Stripe Dashboard. On Pro and Enterprise,
/_rystic/uiopens a live Dashboard view of the twin — Home charts, payments, refunds, customers, payment links, Checkout Sessions, webhooks and events — where you can refund, capture, cancel, edit customers, manage payment links and webhook endpoints and expire sessions through the twin’s own API, beside Your integration, Webhook egress and Clock cards. (RYS-1374)
v0.0.7 — 2026-09-26
- The guide starts from an API key and says what each plan reaches.
HOWTO.mdnow opens withrystic login rys_…, says what Free, Pro and Enterprise boot and which control-plane routes each answers, and lists PaymentIntent capture among the 50 routes.check.shfinds the API keyrystic loginstored as well as a license file, looked up in the CLI’s order, andrystic featuresmarks the features and commands that need Pro or Enterprise. (RYS-1204)
v0.0.6 — 2026-09-25
-
A new world,
design_studio, now the default.--scenario design_studioboots Website Design Contractors’ own account: 30 clients as customers, 269 charges since November 2024 each naming its invoice, a declined card paid on a replacement, a refund, a dispute, a bank debit still pending, and seven unpaid invoices as open intents, for the same clients, invoice numbers and dates the studio’s Slack, Resend, PayPal and Lob worlds hold. A lease that grants no scenarios boots it instead ofcustomer_with_card. (RYS-1171) -
Only
design_studioships as a named scenario. The older scenarios are now test-only fixtures and are no longer accepted by--scenario,RYSTIC_SCENARIO, orPOST /_rystic/scenario. Tests and evaluations can load their unchanged register maps and clocks fromsim/testdata/scenarios/through the control plane. (RYS-1177) -
The free edition is replaced by self-serve on the metered edition. A Rystic account’s token runs the metered build:
rystic login rys_…once, thenrystic runordocker run -e RYSTIC_TOKEN=…. It carries every scenario and control-plane route your plan includes, and bills active 30-second blocks against the plan’s allowance. The fixed-world free download and its usage report are gone. (RYS-1097)
v0.0.5 — 2026-09-23
- A free edition. stripe-twin now has a free download: no license, one
fixed world (
customer_with_card) with reads and writes, and no control plane. Get it from https://www.rystic.ai/dl/free/stripe-twin ; the licensing docs’ free edition section says exactly what is in and out. (RYS-787)
v0.0.4 — 2026-09-21
- Protected runtime contract 9. A trusted environment harness can require a private gateway credential on every request, including administrative routes. This lets the released Stripe HTTP twin serve as the protected auxiliary backend in MCP-backed Slack environments without exposing direct twin controls to the agent.
The sealed Stripe model and scenario pack are unchanged from v0.0.3. This release updates the shared runtime; it makes no new Stripe MCP compatibility or API-fidelity claim. Released multi-episode qualification is recorded separately from package availability.
v0.0.3 — 2026-09-14
-
A fourteenth named world,
busy_account_xxl.busy_account_xlproves a client follows a cursor. This one asks the next question: whether an answer still comes out right when it has to be assembled from many pages and many objects at once. Same account shape at 2011 live customers — 2305 payment intents, 2274 charges, 361 refunds, 535 checkout sessions, 158 payment links, 123 setup intents, 505 saved cards, 14 endpoints — with four things the smaller worlds do not hold: 81 charged-back charges whose owners carry the refunded money on their other orders, so the account’s net refunded figure appears on no single page; six customers holding a run of declines, with a unique worst; 240 live subscribers across four plans, sold as completedmode=subscriptionCheckout Sessions, beside one-time sessions and expired subscription checkouts at the same prices as decoys; and billing addresses, so “every customer in country X” is a read of all 2011 customers, which no Stripe list route filters on. It boots in about 0.3 seconds and holds about 75 MiB../stripe-twin --scenario busy_account_xxl, orcurl -X POST localhost:8080/_rystic/scenario -d '{"name":"busy_account_xxl"}'. -
busy_account_xlis now big enough to need paging. Stripe serves at most 100 records per list request, and the world shipped in v0.0.2 held fewer than that in every collection but customers — so a client could read the whole account with one?limit=100call per collection and never touchhas_moreorstarting_after. It is now 1011 live customers, 1083 payment intents, 1047 charges, 168 refunds, 158 checkout sessions, 134 payment links and 131 setup intents, so every one of those answershas_more: trueatlimit=100and the rest of the account is only reachable by cursor. (Webhook endpoints stay at 14, because Stripe caps an account at 16.) Everything the smaller world was authored for survives at the new size, with the same ids:busy_account’s whole cast, saved cards whose last payment sits at 45, 20 or 7 days so an overdue report moves with its window, unpaid intents either side of 1-hour, 6-hour and 48-hour cutoffs, authorized holds that are cancelable but not unpaid, duplicate-shaped charge groups straddling 7-, 14- and 30-day reporting windows, open carts either side of 10, 20 and 60 minutes, and live payment links either side of 100, 400, 2000 and 5000 cents. Point a client at it when you want to find out whether your pagination, cursors and date windows are right:./stripe-twin --scenario busy_account_xl. -
A charged-back charge can no longer be refunded.
POST /v1/refundsagainst a charge whosedisputedis true minted a refund and returned 200, where Stripe answers 400charge_disputed— “Charge<id>has been charged back; cannot issue a refund.” The twin now refuses it the same way, for a full refund, a partial one and apayment_intent-targeted one alike, and in the same order live does: an unknown parameter or a non-positiveamountis still rejected first, and the refusal precedes the amount-exceeds-remaining check.busy_account’sch_busy_disputedis the charge to try it on.
v0.0.2 — 2026-09-12
-
Separate authorization and capture works end to end.
POST /v1/payment_intents/{intent}/captureis routed, and confirming an intent created withcapture_method=manualnow holds the authorization atrequires_captureinstead of settling it. Partial captures release the remainder, over-captures are refused withamount_too_large, and the charge goes from uncaptured to captured in place — the flow acapture-laterintegration actually tests. -
confirm=trueon create does what the docs show. Creating a PaymentIntent with a payment method andconfirm=true— the one-call form the Stripe docs use for off-session charges — now succeeds and mints the charge, instead of answeringrequires_payment_methodas though the card had been declined. Creating with a payment method and noconfirmcorrectly rests atrequires_confirmation. -
A saved card belongs to its customer. Confirming a PaymentIntent with a PaymentMethod attached to a different customer is refused with Stripe’s own 400, rather than quietly charging one customer’s card against another’s intent.
-
disabled=truedisables a webhook endpoint.POST /v1/webhook_endpoints/{id}withdisabled=trueflipsstatustodisabled; it was accepted and ignored. -
A thirteenth named world,
busy_account_xl.busy_accountis a month of traffic over eleven customers, and every question you can ask of it is answered by the first page of a list.busy_account_xlis the same account at 201 customers: the wholebusy_accountcast is still there, with the same ids, plus 190 more customers with mixed histories — 43 saved cards, payments spread over the last 45 days, repeat charges that straddle a 7-, 14- or 30-day reporting window, thirteen open checkout sessions and fourteen webhook endpoints over overlapping event sets. Every collection answershas_more: trueon its default page, so it is the world to point a client at when you want to find out whether your pagination, your cursors and your date windows are right. Boot it like any other:./stripe-twin --scenario busy_account_xl, orcurl -X POST localhost:8080/_rystic/scenario -d '{"name":"busy_account_xl"}'.
v0.0.1 — 2026-09-11
The first release of the Stripe twin.
-
A local Stripe for your integration tests. 49 routes across 11 resources: customers, payment intents, setup intents, payment methods, charges, refunds, checkout sessions, payment links, webhook endpoints and events. Point your Stripe client’s
api_baseat the twin and your existing suite runs against it unchanged — the Stripe SDKs need no patching. Nothing you send it is billed, rate-limited, visible in a dashboard, or shared with whoever else on your team is using the same test-mode account. -
It never reaches the network, and it needs no key. Any
Authorizationheader is accepted, and the twin dials nothing — which is the point: a suite that hitsapi.stripe.comfails when Stripe is slow, and a suite on a shared test-mode account fails when a colleague clears it. -
Seed the world your test needs.
POST /_rystic/seedtakes a register map and lands it directly, so a test starts from the records it needs instead of creating them through the API one call at a time.GET /_rystic/registerslists what this twin accepts a seed for; a register the model does not carry is refused with a status rather than silently dropped. -
The same seed twice is the same world twice.
POST /_rystic/resetempties the twin andPOST /_rystic/seed-rngpins the generator, so ids repeat run to run;POST /_rystic/clockfixes the twin’s notion of now, socreatedon every record is a value your test chose. Two identical seed flows leave byte-identical/_rystic/inspectdumps — which is what makes a failing test reproducible rather than merely re-runnable. -
Read back the webhooks you would have sent. Event and webhook egress is rendered into an ordered in-memory log (
GET /_rystic/egress) rather than delivered, so you can assert on what would have been sent without standing up a receiver or runningstripe listen. -
See what the twin holds.
/_rystic/uiserves a live view of the registers and the requests hitting them, so a test that fails on a missing record shows you the world it actually ran against.
What is measured, and what is not. Fidelity is scored differentially
against real api.stripe.com in test mode, per response field, not by
eyeball: the last full-suite round was 131 of 161 probes on 2026-09-04,
and the suite has since grown to 175 probes. Shortfalls recorded for this
release included missing doc_url and request_log_url error fields,
customer deletion acknowledging without performing, and objects serializing
differently across endpoints. Customer deletion has since been fixed; the
other two remain. Current known limitations are listed in HOWTO.md under
Limitations. Rounds are re-run daily and each release states its own number.
Not in this release. Subscriptions, products and prices are modelled in separate twins and are not part of this one; Connect, Tax and Billing are not modelled at all.
Resend
v0.0.8 — 2026-10-12
-
A clock jump fires each timeline event at its own time. When one
POST /_rystic/clockor paced tick passes several events, each fires with the clock at itsat, so the records it creates carry that time, and the clock then lands where the move asked. An event already due when the clock moves fires at the current time. (RYS-2520) -
Webhook signatures can follow a shifted receiver clock.
POST /_rystic/clock {"wall_offset_seconds": N}signs every webhook delivery N seconds past the wall clock, so a receiver whose own clock you moved forward (assandbox clock -advancedoes) still accepts thesvix-timestamp;0or/_rystic/resetputs it back. (RYS-2538) -
A timeline call can carry raw bytes, XML or a JSON array. A
callevent may sendbody_textorbody_base64instead ofbody; the twin sends it as written, under the route’s own content type (XML, bytes, JSON or a form), so a scheduled call can upload an object’s bytes or post an XML document, and${name}fills inbody_text. Abodymay be a JSON array where the route reads one. A call that answers XML binds from its elements by JSON Pointer, such as/InitiateMultipartUploadResult/UploadId. (RYS-2523)
v0.0.7 — 2026-10-12
-
Registered webhooks can receive their events. Boot with
RYSTIC_WEBHOOK_DELIVERY=onand each webhook event a send triggers is POSTed to every enabled webhook registered throughPOST /webhookswhoseeventsnames it, signed with itssigning_secretin Svix headers at wall-clock time so svix’sWebhook.verifyaccepts it however the simulator clock is pinned; without the variable nothing is POSTed. (RYS-2421, RYS-1463) -
A world can carry its own timeline. A
--seedfile, or a whole one posted toPOST /_rystic/seed, may addcredentials, binding presented credentials to actors, andevents: calls the twin makes on its own API at set sim times as one of those actors, through the same handlers and egress as your requests. A top-levelcredentialsoreventskey the twin used to ignore now takes effect.RYSTIC_PACE=<speed>plays the clock, and the timeline, in real time, and an event’satmay be+<duration>./_rystic/inject,--injectandRYSTIC_INJECTnow needstate_setinstead ofscenarios. (RYS-2422) -
Sandbox recipients bounce and complain. An immediate send whose first
torecipient isbounced@resend.devorcomplained@resend.dev, with or without a+label, now emitsemail.bounced, oremail.deliveredandemail.complained, afteremail.sent, and the email readslast_eventbouncedorcomplained; underRYSTIC_WEBHOOK_DELIVERY=oneach is delivered Svix-signed to the webhooks subscribed to it. A bounced email’semail.bouncedandGET /emails/{email_id}carry thebounceobject live Resend sends. (RYS-2419) -
A timeline call can hand what it minted to later events. A
callevent may addbind,{"<name>": "<JSON Pointer>"}, saving fields of its answer, such as an ID the twin minted; a later event uses one as${name}in a call’s path, headers or body or in a seed event’s record keys and values (a register name cannot be bound). A schedule naming a${name}no earlier call binds is refused at load, and an event whose binding call failed records an error instead of firing. (RYS-2505) -
design_studiocarries a timeline. As the clock moves past2026-03-01T00:00:00Z, the world registers a billing webhook, sends Copperline Coffee’s March plan invoice, and schedules an overdue reminder to Pickford Law, moves it off a Sunday and cancels it, each a call on the twin’s own API under the world’s bound key. Those calls take the first email and webhook ids, so a test that advances the clock before sending sees its own email minted later in the sequence. (RYS-2459) -
A world can bounce an email.
POST /_bouncebounces an email that went out to any address: it readsbouncedwith itsbounce, andemail.bouncedreaches the egress log and every webhook subscribed to it. A world’s timeline schedules it like any other call. (RYS-2461)
v0.0.6 — 2026-10-11
-
An email and a webhook never share an id. Each kind of record draws its ids from its own range: the first email you send is
28c9b732-cf71-466d-8000-000000000001, where it and the first webhook were both00000001-0000-4000-8001-000000000001. Ids still count up in their last digits and restart on reset, and webhook signing secrets change the same way; a test that pinned an old id needs the new one. (RYS-1593) -
Every route refuses a missing or malformed key the way Resend does. All ten routes check
Authorizationbefore anything else: no header answers401 missing_api_keywith “Missing API Key”, and an empty Bearer key or another scheme such asBasic abcanswers400 validation_errorwith “API key is invalid”, where the twin used to answer 401 with “Missing API key in the authorization header.”.GET /emails,POST /emails/{email_id}/cancel,POST /webhooks,GET /webhooks/{webhook_id}andDELETE /webhooks/{webhook_id}used to answer without a key, so a test that calls them without one now needs a placeholder key. (RYS-1285) -
A scheduled send no longer shows an SMTP handoff.
POST /emailswithscheduled_atwrites only itsemail.scheduledevent toGET /_rystic/egress, since Resend hands the mail to SMTP only at the scheduled time; an immediate send still writes the handoff beforeemail.sent. (RYS-1378) -
A send or update with a form Content-Type is refused as Resend refuses it.
POST /emailsandPATCH /emails/{id}with a JSON body labelledapplication/x-www-form-urlencodedanswer 422 with Resend’s message (Missing `to` field.andThe `scheduled_at` field is required.), where the simulator answered 400 to a send and applied the update. Label JSON bodiesapplication/json. (RYS-1190) -
A scheduled email’s
scheduled_atreads back as Resend renders it.GET /emails/{id}andGET /emailsreturnscheduled_atin the same form ascreated_at, such as2026-01-03 00:00:00.439000+00, where the simulator echoed an ISO 8601 string such as2026-01-03T00:00:00.439Z. A test that compares the string should compare the instant. (RYS-1190) -
The scripts in this archive are MIT-licensed.
check.shand the archive’s other scripts are published under the MIT License at https://github.com/Rystic-Inc/rystic-scripts , so you can copy, change and share them.LICENSE.txtno longer covers them; it still governs the twin itself.
v0.0.5 — 2026-09-30
-
Scheduled email reads preserve null values. Text-only emails return
html: null, and newly scheduled emails returnmessage_id: nulluntil their state indicates a completed send. Explicit HTML, including an empty string, and existing message identifiers are preserved. (RYS-1293) -
Resend fidelity limitations are explicit. The HOWTO and website docs disclose missing throttling and sender-domain checks, distinguish local auth errors from measured Resend responses, and explain how to choose scheduled dates without treating
initial_clockas the scenario clock. The dated fidelity result and absence of a standing daily converge round remain explicit. (RYS-1283) -
Sent-email events include ISO timestamps, message IDs and tags. The
email.sentevents inGET /_rystic/egressuse UTC ISO 8601 timestamps and includedata.message_idand supplied tags as a name/value map, so tests can inspect these fields in the outbound event log. (RYS-1288) -
Request admission shares a ten-per-second window across credentials, emits rate-limit headers on API responses, and returns 429 with retry-after when exhausted. The window uses real time; control-plane requests are exempt and reset restores the allowance. Daily/monthly quotas remain unmodeled. The full 39-probe live round matched and exercised 36/39, including the header and actual 429 burst probes; the three known POST/PATCH differences remain.
Burst comparison requires an observed oracle 429 in both two-way and ThreeWay runs; an oracle that never refuses leaves the result inconclusive. Wire recording emits each burst request once, without duplicating the selected response as a synthetic exchange.
The 36/39 rate-limit acceptance round predates the merge of RYS-1288 webhook fields. Both features and their separate live records are retained; the combined simulator is covered by local regression tests, without claiming a new combined live round.
-
Rate-limit headers arrive spelled as Resend spells them.
ratelimit-limit,ratelimit-remaining,ratelimit-reset,ratelimit-policyandretry-afternow reach the wire lowercase instead of asRatelimit-Limitand friends, so a client that keys on the received header name sees the same keys it sees from Resend. (RYS-1336) -
The state UI shows the outbox as Resend’s dashboard. On Pro and Enterprise,
/_rystic/uiopens a live Resend-style view of the twin — emails with their status, events and rendered body, and webhooks with the events they would receive — where you can reschedule or cancel a scheduled email and add, edit, disable or delete webhooks through the API, beside Your program and Egress cards. (RYS-1376)
v0.0.4 — 2026-09-26
- The guide starts from an API key, says what each plan reaches, and documents the outbound mail log.
HOWTO.mdnow opens withrystic login rys_…, says what Free, Pro and Enterprise boot and which control-plane routes each answers, and names the five routes that answer401 missing_api_keywithout a bearer key and the five that check none;SCENARIOS.mdsays what each plan boots. EachPOST /emailswrites the SMTP message and itsemail.sentoremail.scheduledevent toGET /_rystic/egress, and the guide now says so.check.shfinds the API keyrystic loginstored as well as a license file, looked up in the CLI’s order, andrystic featuresmarks the features and commands that need Pro or Enterprise. (RYS-1204)
v0.0.3 — 2026-09-26
-
The fidelity number is current again. The first full-suite comparison on the 38-probe suite scored 35 of 38 against api.resend.com on 2026-09-25, and the HOWTO and site page name the three misses (content-type mismatch on sending and updating an email, and the non-object send body) with the repair that closes them. (RYS-1190)
-
Only
design_studioships as a named scenario. The older scenarios are now test-only fixtures and are no longer accepted by--scenario,RYSTIC_SCENARIO, orPOST /_rystic/scenario. Tests and evaluations can load their unchanged register maps and clocks fromsim/testdata/scenarios/through the control plane. (RYS-1177)
v0.0.2 — 2026-09-25
-
The free edition is replaced by self-serve on the metered edition. A Rystic account’s token runs the metered build:
rystic login rys_…once, thenrystic runordocker run -e RYSTIC_TOKEN=…. It carries every scenario and control-plane route your plan includes, and bills active 30-second blocks against the plan’s allowance. The fixed-world free download and its usage report are gone. (RYS-1097) -
A new world,
design_studio, now the default.--scenario design_studioboots Website Design Contractors’ outbox: 595 emails since November 2024 (invoices, receipts, overdue reminders, a declined-card notice, a refund notice, welcome and launch notes, and one receipt bounced off a departed contact, then resent), for the same clients, invoice numbers and dates the studio’s Slack, Stripe, PayPal and Lob worlds hold. A lease that grants no scenarios boots it instead ofbusy_outbox. (RYS-1171) -
The limitations now date the fidelity evidence. The HOWTO and the site page say which comparison rounds against api.resend.com stand behind the release: the 25/25 full-suite round of 2026-09-02 and the scoped rounds of 2026-09-24 that verified the 13 probes added since, with no full-suite round yet on the current 38-probe suite. The package manifest also describes the metered edition instead of the retired free edition. (RYS-1028)
v0.0.1 — 2026-09-24
-
First Resend simulator release: email lifecycle and webhook APIs, three deterministic scenarios, licensed and free archives, and customer quickstart. Includes measured validation and idempotency behavior; asynchronous delivery and documented fidelity limitations remain outside the release claim. (RYS-1028)
-
Resend email and webhook workflows, locally. The first package provides ten stateful API routes, three embedded scenarios, a smoke check, and a quickstart. Forward email paging now traverses all records. No email or webhook is delivered; scheduling, validation, collection and fixture limitations are documented explicitly. (RYS-1028)
Clerk
v0.0.2 — 2026-10-12
-
clerk_acme_tenantplays a timeline. Its backend, bound tosk_test_acme_backend, updates Sam’s metadata two hours after the pinned clock, creates Priya Raman a day in, promotes Yuki to admin, moves the organization to thescaleplan on day three and removes Iris on day five, all through the Backend API; advance the clock to watch the tenant change. (RYS-2459) -
A seeded user, organization or membership sends its created webhook. A record a world’s timeline
seedevent or a barePOST /_rystic/seedcreates now sendsuser.created,organization.createdororganizationMembership.created, as the create operation does, to the egress log and any configured webhook endpoint. A world’s starting snapshot sends none. (RYS-2508) -
Session tokens and webhooks can follow a shifted app clock.
POST /_rystic/clock {"wall_offset_seconds": N}stamps session JWTiat,nbfandexpand the webhooksvix-timestampN seconds past the wall clock, so an app whose own clock you moved forward (assandbox clock -advancedoes) still accepts them;0or/_rystic/resetputs it back. (RYS-2538) -
Every Backend API call without a secret key is refused as Clerk refuses it. A request with no
Authorizationheader now answers401witherrors[0].code = authorization_header_format_invalidon every operation, ahead of the operation’s own checks.DELETE /v1/organizations/{id}andPATCH /v1/organizations/{id}/memberships/{user_id}used to succeed without a key, and other operations answered with differingauthentication_invalidbodies. (RYS-2532)
v0.0.1 — 2026-10-11
-
A local Clerk for your app’s users, organizations and sign-in. The first release of the Clerk twin: the 20 Backend API operations on users and their metadata, organizations with their metadata and memberships, at
Clerk-API-Version: 2026-05-12, served from a model validated against a live Clerk development instance, and a browser companion that serves a lookalikeclerk.jsso a@clerk/nextjsapp signs users in against the same state. It runs on Pro and Enterprise. Point@clerk/backendat it with anysk_key (HOWTO.mdshows how); no email is sent and no real instance changes. (RYS-1315, RYS-1234, RYS-2412) -
Users and their metadata, as Clerk keeps them.
POST /v1/usersstores each email address in lowercase,GET /v1/users?email_address=finds a user by any letter case of their address, and an address that differs from a taken one only in case answers 422form_identifier_exists; a malformed one answers 422form_param_format_invalid.GET /v1/usersrefuses anorder_bythat names no sortable field with 422form_param_value_invalid.PATCHandPUT /v1/users/{user_id}/metadatamerge and replace metadata, andDELETE /v1/users/{user_id}removes the user’s organization memberships. Everyimage_urlandprofile_image_urlleads to a PNG the twin serves under/preview/. (RYS-1858, RYS-1899, RYS-1900) -
Organizations, their metadata and their members. An organization created with
created_bylists its creator as anorg:adminmember right after the create, andinclude_members_count=truecounts them.PATCH /v1/organizations/{organization_id}/metadatadeep-mergespublic_metadataandprivate_metadata, removing a key set tonull, andPUTreplaces each field it names whole. Writes and lists answer with the keys Clerk’s answers carry. (RYS-1895, RYS-1899, RYS-1909) -
Clerk-API-Versionis checked on every route and echoed.2021-02-05,2024-10-01,2025-04-10,2025-11-10and2026-05-12(the default) are accepted and echoed inclerk-api-version, and any other value answers 400api_version_invalid. At2026-05-12, metadata sent toPATCH /v1/users/{user_id}orPATCH /v1/organizations/{organization_id}answers 422form_param_deprecated, as Clerk does: write it through the/metadataroutes. (RYS-1914) -
A browser sign-in for
@clerk/nextjs. PointNEXT_PUBLIC_CLERK_JS_URLandNEXT_PUBLIC_CLERK_UI_URLat the twin’s/clerk.jsand/clerk-ui.jsand your app gets email sign-in and sign-up with consent, Continue with GitHub and Continue with Google buttons that sign in local test identities, organization creation and selection, and member invitation, role changes and removal; signing in as an invited address joins its organization with no email sent. Sessions are RS256 tokens stamped on the wall clock your site verifies them against, and the SDK middleware’s handshake is answered by the twin, so a sandboxed site never leaves it. Boot the twin with your own RSA key, your site’s origin and its own origin;GET /_twin/configgives the publishable key, the public PEM and the proxy URL. (RYS-1234, RYS-1475, RYS-2413, RYS-2414) -
Webhooks Clerk would send, signed with Svix headers. Every user write, organization create, update and delete, and membership write, from the Backend API or the browser, records
user.*,organization.*ororganizationMembership.*inGET /_rystic/egress; the organization/metadataroutes record none. Boot withCLERK_TWIN_WEBHOOK_URLand awhsec_CLERK_TWIN_WEBHOOK_SECRETand each is also delivered, itssvix-timestampon the wall clock so your receiver’s five-minute window holds however long the twin sat idle. A failed delivery never undoes the write. Event shapes follow Clerk’s documentation and are not measured against live deliveries. (RYS-1317, RYS-2413) -
Five shipped worlds.
--scenario clerk_acme_tenantboots Acme Robotics: five users in one organization, two pending invitations and three live sessions.clerk_holdco_orgsholds one identity across three organizations,clerk_invite_backlogan organization at its ten-seat cap with eight invitations pending,clerk_session_sweepsix sessions from lapsed to live, andclerk_solo_founderone user and her organization;SCENARIOS.mdsays what each is for. Without--scenariothe twin boots an empty instance,POST /_rystic/seedlands users, organizations, memberships, sessions and invitations directly, andPOST /_rystic/resetrewinds the id sequence, so two identical runs from a pinned clock give byte-identical users and organizations. (RYS-1315, RYS-2412) -
Measured against a live Clerk development instance. The 2026-10-07 full-suite round matched 100 of 100 Backend API probes, with 0 inconclusive.
HOWTO.mdstates what the suite leaves out and the differences this release ships with, each with its ticket: typed query parameters are not validated (RYS-1906), responses carry nox-clerk-trace-idheader (RYS-1912), an unserved route answers a plain 404, not Clerk’sresource_not_found(RYS-1913), and deleting an organization or changing a membership’s role checks no secret key. (RYS-2412)
Notion
v0.0.8 — 2026-10-12
-
A world can carry its own timeline. A
--seedfile, or a whole one posted toPOST /_rystic/seed, may addcredentials, binding presented credentials to actors, andevents: calls the twin makes on its own API at set sim times as one of those actors, through the same handlers and egress as your requests. A top-levelcredentialsoreventskey the twin used to ignore now takes effect.RYSTIC_PACE=<speed>plays the clock, and the timeline, in real time, and an event’satmay be+<duration>./_rystic/inject,--injectandRYSTIC_INJECTnow needstate_setinstead ofscenarios. (RYS-2422) -
A timeline call can hand what it minted to later events. A
callevent may addbind,{"<name>": "<JSON Pointer>"}, saving fields of its answer, such as an ID the twin minted; a later event uses one as${name}in a call’s path, headers or body or in a seed event’s record keys and values (a register name cannot be bound). A schedule naming a${name}no earlier call binds is refused at load, and an event whose binding call failed records an error instead of firing. (RYS-2505) -
notion_task_trackerplays a timeline. Over the three hours after the pinned clock, Rae ticks a to-do and moves a row to In review, Theo comments and files a new row, and Nour marks the retention policy Done, each through the API under a token the world binds to them; advance the clock to watch the sprint move. (RYS-2459) -
A clock jump fires each timeline event at its own time. When one
POST /_rystic/clockor paced tick passes several events, each fires with the clock at itsat, so the records it creates carry that time, and the clock then lands where the move asked. An event already due when the clock moves fires at the current time. (RYS-2520) -
A clock jump stamps each timeline event at its exact instant. When a clock move passes timeline events, the records each one makes carry exactly its
at, to the millisecond, whatever traffic came before it, so one jump leaves the same records as stepping the clock to each event in turn. (RYS-2542) -
A timeline call can carry raw bytes, XML or a JSON array. A
callevent may sendbody_textorbody_base64instead ofbody; the twin sends it as written, under the route’s own content type (XML, bytes, JSON or a form), so a scheduled call can upload an object’s bytes or post an XML document, and${name}fills inbody_text. Abodymay be a JSON array where the route reads one. A call that answers XML binds from its elements by JSON Pointer, such as/InitiateMultipartUploadResult/UploadId. (RYS-2523)
v0.0.7 — 2026-10-11
-
The scripts in this archive are MIT-licensed.
check.shand the archive’s other scripts are published under the MIT License at https://github.com/Rystic-Inc/rystic-scripts , so you can copy, change and share them.LICENSE.txtno longer covers them; it still governs the twin itself. -
A file upload’s filename may be up to 900 characters.
POST /v1/file_uploadscounts a filename’s length in characters, as Notion measures it, so an accented name of 900 characters or fewer is accepted even when it runs past 900 bytes. (RYS-2099) -
Requests Notion refuses are refused, in Notion’s order. The twin answers 400
validation_errorwhere Notion does, before looking anything up: a malformed id in the path ofGET /v1/blocks/{id}/children,GET /v1/data_sources/{id}/templatesandPATCH /v1/pages/{id}/markdown; a non-numericpage_size; an unknown filter member, mixed timestamp and property filter, unknown operator or mistyped operand in a data source query; a malformed parent id, or atemplate_idtemplate with no id, onPOST /v1/pages; an executable file type, or a filename over the limit, onPOST /v1/file_uploads; and a move to a destination the integration can’t reach (tool_error_code: destination_unavailable).GET /v1/data_sources/{id}accepts a dashless id, and its 404, like a database create’s, carriesadditional_data.integration_idandrequest_id. (RYS-2099)
v0.0.6 — 2026-10-11
- A seeded
app.notion.compage URL passes the seed gate.app.notion.comis declared Notion’s web host (pipeline_policy.jsonhost_roles): the twin hands such a URL out as written, never rebased, and a seed carrying one lands underRYSTIC_SEED_GATE=rejectwhere it was refused as an undeclared host of the service. Page, database and data-sourceurlvalues the twin builds still sit onwww.notion.so, which Notion no longer uses; that is RYS-2386. (RYS-1910)
v0.0.5 — 2026-10-10
-
A sent file upload drops its upload URL, and completing a single-part upload is refused. After a
single_partsend leaves an uploaduploaded, the send’s answer and every read of the upload leave outupload_urlandcomplete_url, as Notion’s do. Completing asingle_partupload answers 400validation_error, “One or more parts must be uploaded before completing the file upload”, where the twin answered 200. (RYS-2099) -
Three more worlds for the Tier 2 surface.
notion_page_treeboots a page tree with page and data source parents to move pages between.notion_template_boardboots a Tickets data source with five templates, one of them the default, beside a data source with none.notion_attachmentsboots a page with image and file blocks and an asset library whose files property holds files, ready for an upload to attach. (RYS-2102) -
A moved page leaves its old parent and joins the new one. After
POST /v1/pages/{page_id}/move, the old parent’sGET /v1/blocks/{id}/childrenno longer lists the page, and itshas_childrenupdates. The new parent page lists the page as itschild_pageblock. A page moved into a data source drops out of its page parent’s children and appears in the data source’s query. (RYS-2349) -
Templates refuse what Notion refuses and apply their content.
POST /v1/pageswith atemplatebesidechildren,contentormarkdown, or with{"type": "default"}under a data source that has no default template, now answers 400validation_errorwith Notion’s message. A page created from a template, or given a template byPATCH /v1/pages/{page_id}, reads back the template’s content as its children. A PATCH adds that content after the page’s existing children. (RYS-2350, RYS-2355) -
Attached file uploads read back as files the twin serves. An upload attached as a file, image, PDF, audio or video block, a page icon or cover, or a files property now reads back as
{"type": "file", "file": {"url", "expiry_time"}}. The URL serves the uploaded bytes from the twin untilexpiry_time, one hour after each read. Attaching an upload clears its ownexpiry_time.GET /v1/file_uploadslists only the calling connection’s uploads, newest first. An upload never attached readsexpiredonce its hour passes on the twin’s clock. (RYS-2351, RYS-2356, RYS-2360) -
External imports and async tasks complete. An
external_urlfile upload answerspending, as Notion’s does, and readsuploadedwith a successfulfile_import_resulton its firstGET /v1/file_uploads/{id}. Its file is the twin’s placeholder; the twin never fetches the URL. APOST /v1/pagesorPATCH /v1/pages/{page_id}/markdownsent withallow_async: trueanswers aqueuedtask withpoll_after_seconds: 2. Its firstGET /v1/async_tasks/{task_id}readssucceededwith the result, where it used to answer 404. (RYS-2352, RYS-2347) -
An import’s time reads as Notion writes it. A completed
external_urlfile upload’sfile_import_result.imported_timecarries a+00:00offset, as Notion’s does, where it used to end inZ. A client that compares or parses the stamp as text now sees Notion’s form. (RYS-2370) -
A seeded comment reads back without
attachments. A comment a shipped world seeds, such asnotion_review_thread’s, now answersGET /v1/commentsandGET /v1/comments/{comment_id}with noattachmentskey, as Notion’s comments and the onesPOST /v1/commentscreates do. It used to carryattachments: []. (RYS-2228) -
A seeded comment’s user mention carries the user. The mention in
notion_review_thread’s reply now reads back with the mentioned user’sname,avatar_urlandtype, as Notion answers a user mention in a comment, besideplain_text@Review Bot. It used to carry only the user’sobjectandid. (RYS-2286) -
A shipped world’s pages carry Notion’s url. Every page a scenario seeds now reads back with a
urlonhttps://app.notion.com/p/, its title’s words and its bare id, as Notion writes it, and a seeded page mention’shrefis the page’s barehttps://app.notion.com/p/url. They used to point atwww.notion.so. (RYS-2294) -
A user mentioned in a comment reads back as the user. A
rich_textuser mention written throughPOST /v1/commentsorPATCH /v1/comments/{comment_id}answers and reads back with the mentioned user’sobject,name,avatar_urlandtype(bot: {}orperson), andplain_text@<name>, as Notion does. It used to echo the request’s{id}with emptyplain_text. (RYS-2286) -
A comment’s markdown turns a mention-user tag into a mention.
<mention-user url="<user id>">…</mention-user>in a comment’smarkdown, its url a bare id oruser://<id>, becomes a user mention of that user, the tag’s text dropped: a person resolves to the user and@<name>, a bot to{object, id}and@Anonymous, as Notion answers them. It used to stay literal text. (RYS-2287) -
Every comment 404 names the integration.
POST /v1/commentswith an unknowndiscussion_id,parent.page_idorparent.block_id, andGET /v1/commentswith an unknownblock_id, answer 404object_not_foundwithadditional_data.integration_id, the calling bot’s id, as Notion does; a comment on an unknown block is refused, where it used to be created. (RYS-2288) -
Page markdown refuses a database or block id as Notion does.
PATCH /v1/pages/{id}/markdownthrough a database’s id answers 400validation_error“Provided IDis a database, not a page. Use the retrieve database API instead.”, and through a block that is not a page, 400 naming the page that contains it, both with additional_data.tool_error_codeinvalid_input.GETthrough a database’s id answers the same 400, while a block id still renders its subtree. They used to answer 404, or render the database. (RYS-2290) -
A seeded child database renders like a created one in page markdown.
GET /v1/pages/{page_id}/markdownrenders every child database withinlineanddata-source-urlread off the database, sonotion_team_wiki’s Meeting Notes readsinline="true" data-source-url="collection://…"as onePOST /v1/databasescreates does. (RYS-2292) -
Blocks carry every content field Notion returns. A block written as markdown or as children reads back with a heading’s
is_toggleable, a paragraph’sicon, a code block’scaption(and nocolor), a markdown numbered item’slist_formatand a markdown file block’sname, as Notion’s do. (RYS-2293) -
A created page’s url is on app.notion.com. A page
POST /v1/pagescreates, or a markdown<page>tag adds, has itsurlonhttps://app.notion.com/p/, its title’s words then its bare id, as Notion’s are. It used to point atwww.notion.so. (RYS-2294) -
Moving a page under itself is refused.
POST /v1/pages/{page_id}/movewith the page itself asparent.page_idanswers 400validation_error, “Parent ID must be different from the child ID:”, with additional_data.tool_error_codesource_is_destination, as Notion does. It used to answer 200. (RYS-2345) -
The template board’s templates hold content. In
notion_template_board, each of the Tickets data source’s five templates holds a heading, a paragraph and a to-do or bulleted item of its own, from Bug Report’s steps to reproduce to Customer Call Notes’ attendees. A page created from the default template or from atemplate_idnow reads back that template’s blocks as its children, where it used to read back none. (RYS-2385) -
Eight more routes: page moves, data source templates, async tasks and file uploads. The twin now serves
POST /v1/pages/{page_id}/move,GET /v1/data_sources/{data_source_id}/templates,GET /v1/async_tasks/{task_id}and the five file upload routes, 35 operations in all, so the official JavaScript SDK’spages.move,dataSources.listTemplates,asyncTasks.retrieveandfileUploadsmethods run against it. (RYS-2103) -
A
multi_partupload’s parts and/completeare unmeasured. The measuring workspace is on Notion’s Free plan, which caps a file at 5 MiB and offers no multi-part upload, so creating amulti_partupload is measured but sending its parts andPOST /v1/file_uploads/{file_upload_id}/completeare built from Notion’s published spec alone. (RYS-2103)
v0.0.4 — 2026-10-10
-
User and property-item reads carry
request_id.GET /v1/users/{user_id}and the single-value answer ofGET /v1/pages/{page_id}/properties/{property_id}now include Notion’s top-levelrequest_id, as every other answer does. (RYS-2289) -
Every property a data source is created or updated with gets its own id.
POST /v1/data_sources,POST /v1/databasesandPATCH /v1/data_sources/{data_source_id}mint a distinct 4-character id for each new property other than the title (whose id staystitle), and for the synced property adual_propertyrelation adds to its target, soGET /v1/pages/{page_id}/properties/{property_id}, a properties update,filter_propertiesand a query’s filter or sort by id reach the property the id names. Before,POST /v1/data_sourcesgave every property it created the same id, and the other two minted uuids. The twin’s ids are letters only; Notion’s may also hold URL-encoded punctuation. (RYS-2296)
v0.0.3 — 2026-10-09
-
A markdown
update_contentkeeps every block’s id.PATCH /v1/pages/{page_id}/markdownwithupdate_contentedits the page’s blocks in place: every block keeps its id at any depth, the edited block’s included, so an id read before the edit still names the same block after it. Before, every block of the page got a new id. (RYS-2255) -
A markdown
replace_content_rangekeeps every block’s id.PATCH /v1/pages/{page_id}/markdownwithreplace_content_rangerewrites the matched range in place: every block keeps its id at any depth, the replaced block’s included, so an id read before the edit still names the same block after it. Before, every block but a child page or database got a new id. (RYS-2259) -
A markdown edit that would delete a child page or database is refused in Notion’s words.
PATCH /v1/pages/{page_id}/markdownwithoutallow_deleting_contentanswers 400validation_errorwith Notion’s message, which lists each child page or database the edit would delete by title and id and says how to proceed, and with Notion’sadditional_data(tool_error_codeinvalid_input). Before, the refusal carried a generic message and noadditional_data. (RYS-2270) -
A markdown
<page>tag with no url creates a subpage.<page>Title</page>ininsert_content, in anupdate_content,replace_content_rangeorreplace_contentedit, or inPOST /v1/pagesmarkdown creates a child page with that title where the tag stands: the page lists itschild_pageblock, andGET /v1/pages/{id}answers it. A child page whose<page url>tag an edit moves under another block now answers that block as its parent. Before, the tag was dropped, and a moved child page kept the page as its parent. (RYS-2271) -
A connection updates or deletes only a comment it created.
PATCHandDELETE /v1/comments/{comment_id}on a comment another user or another connection created answer 404object_not_found, as Notion’s do, and leave the comment as it was. Before, the twin rewrote or deleted any comment, a person’s included. (RYS-2285) -
A property item list with more pages links to the next one.
GET /v1/pages/{page_id}/properties/{property_id}on atitle,rich_text,peopleorrelationproperty with more values thanpage_sizenow carriesproperty_item.next_url: the same request withstart_cursorset to the answer’snext_cursor, on the twin’s own host. The last page’snext_urlstaysnull. Before,next_urlwasnullon every page. (RYS-2281) -
A two-way relation is kept in sync on both sides. Writing a
dual_propertyrelation on a row, throughPOST /v1/pagesorPATCH /v1/pages/{page_id}, updates the synced property on every page the value names or no longer names, so the related pages,GET /v1/pages/{page_id}/properties/{property_id}and a data source query’srelationfilter all agree with the write. Creating such a relation, throughPOST /v1/data_sourcesorPATCH /v1/data_sources/{data_source_id}withdual_property: {}, adds the matching relation property to the target data source and answers itssynced_property_idandsynced_property_name, as Notion does. (RYS-2282, RYS-2283) -
A markdown edit with
allow_deleting_contentmoves each child page or database it drops to the trash. AfterPATCH /v1/pages/{page_id}/markdownwithallow_deleting_content: truedrops a child page’s<page url>tag or a child database’s<database url>tag, throughreplace_contentorupdate_content,GET /v1/pages/{id}andGET /v1/databases/{id}answer the child within_trash: trueunder its parent, andPOST /v1/searchno longer lists it as live. Before, the child stayed live and its block was gone. (RYS-2291)
v0.0.2 — 2026-10-09
-
An empty workspace answers as the twin’s own bot. On Pro and Enterprise, a workspace booted without
--scenarioor cleared byPOST /_rystic/resetstill has a bot user:GET /v1/users/meanswers it where it answered 401, and the pages and blocks you create name it increated_by. A world that seeds its own bot understate_user.member, as every shipped world does, replaces it. (RYS-1911) -
Moving a data source stamps the database it leaves. A
PATCH /v1/data_sources/{data_source_id}that moves the data source to another database now refreshes the previous database’slast_edited_timeto the time of the move, as Notion does. The database it joins keeps itslast_edited_time. (RYS-1909) -
A page created under a database is a row of it. POST /v1/pages with a
database_idparent now lands the page in that database’s data source: itsparentreads{type: "data_source_id", data_source_id, database_id}as Notion answers it, and a query of the data source lists it. A page created under a trashed page is refused with 400validation_error, as Notion refuses it. (RYS-1596) -
A renamed page shows its new title on its block. After
PATCH /v1/pages/{id}changes a page’s title,GET /v1/blocks/{id}and the parent’sGET /v1/blocks/{parent}/childrenanswerchild_page.titleas the new title, as Notion does. (RYS-1980) -
Rich text over 2000 characters is refused, as in Notion. A rich text item whose
text.contentis longer than 2000 characters is refused with 400validation_errorwhen you append children (PATCH /v1/blocks/{block_id}/children), update a block or a page title, or set a database or data source title, asPOST /v1/pagesalready refused it in a page title. Measured against a live Notion workspace. (RYS-1983) -
A page trashed through
PATCH /v1/pagesis trashed as a block too. AfterPATCH /v1/pages/{page_id}with"in_trash": true,GET /v1/blocks/{page_id}readsin_trash: true, as on Notion, where it readfalsebefore. (RYS-1981) -
A data source query sorts by a property and refuses an unknown one, as in Notion.
POST /v1/data_sources/{data_source_id}/querynow orders rows by asortsentry naming a property (a number numerically, a title or rich_text by its text), in array order with timestamp sorts. A filter or sort naming a property the data source does not have answers 400validation_error“Could not find property with name or id: …”, and a condition of another type than its property answers 400validation_error, as Notion answers both. Before, a property sort was ignored and both answered 200. (RYS-2058) -
A row’s properties follow its data source’s schema. A page under a data source, created with a
data_source_idor adatabase_idparent, now carries every property of the schema, an unset one with its type’s empty value (date: null,checkbox: false,number: null,select: null,title: []). A property can be named by its id, sotitlerenames theNametitle property. A select value reads{id, name, color}from its schema option, and naming an option the schema lacks adds it to the data source with colorgray. A property name the schema does not have is refused with 400validation_error“<name>is not a property that exists.”, as Notion refuses it, with the id spelled with or without dashes.filter_propertieson retrieving or updating a page and on a data source query returns only the properties it names. (RYS-1885) -
A child block of an unknown type is refused, as in Notion. Appending children (
PATCH /v1/blocks/{block_id}/children) or creating a page withchildrenrefuses a block whosetypeis not one Notion creates through the API, such as"nonsense", with 400validation_error, and stores nothing. Before, the twin answered 200 and stored the block. (RYS-1982) -
Trashing a page leaves
is_archivedalone, as in Notion.PATCH /v1/pages/{page_id}with{"in_trash": true}now answers and reads backin_trash: truewithis_archived: false;in_trashandis_archivedare independent members. Before, trashing a page also setis_archived: true. (RYS-2013) -
A page created with an over-long rich text block is refused, as in Notion.
POST /v1/pagesrefuses achildrenorcontentblock whose rich text item is over 2000 characters with 400validation_error“body failed validation: body.children[0].paragraph.rich_text[0].text.content.length should be ≤2000, instead was2001.”, and stores nothing. Before, the page was created. (RYS-2016) -
A status property starts with Notion’s defaults, and a row shows its unset status and relation. A status property created without options gets Notion’s three options (Not started, In progress, Done) and groups (To-do, In progress, Complete); a row that doesn’t set it reads
Not started. An unset relation reads{"relation": [], "has_more": false}, and a relation property’s schema carriesdatabase_idbesidedata_source_id. Before, the status schema was empty and both were left out of the row. (RYS-2108) -
Comments, users, page markdown, property items and data source creation. The twin serves 11 more Notion routes, 27 in all. Agents can post, list, read, edit and delete comments; list the workspace’s users and read one; read a page as Notion’s enhanced markdown and replace or edit it; page through one property’s values; and create a data source in an existing database. Every route at
Notion-Version: 2026-03-11was measured against a live Notion workspace.@notionhq/notion-mcp-servernow reaches 22 of its 24 tools. (RYS-2097) -
Comment deletes are soft, and the user list is the workspace’s roster. Deleting a comment keeps it readable by id with
original_content_deleted: trueand emptyrich_text, drops it from its page’s comment list, and refuses a further edit or delete with 404object_not_found, as Notion does; a comment’s timestamps are at minute precision.GET /v1/userslists persons before bots, each user once. (RYS-2097) -
Page markdown renders each block’s text.
GET /v1/pages/{page_id}/markdownand the answer toPATCH /v1/pages/{page_id}/markdownrender each block’s text, a to-do’s checked state and a code block’s language. Before, every block rendered empty ("# \n<empty-block/>"). As a result,update_contentandreplace_content_rangematched nothing, and they wiped the text of every block outside the edit. A block written through markdown also reads back from the blocks API withplain_textandhrefon its rich text andcoloron its payload, as Notion’s blocks do. (RYS-2222) -
A block’s comments list by its id.
GET /v1/comments?block_id=…takes a block id as well as a page id, as Notion does: a comment created withparent.block_idis listed under that block, apart from its page’s comments, instead of answering 404object_not_found. (RYS-2223) -
A page created with markdown holds the blocks the markdown describes.
POST /v1/pageswith amarkdownbody now stores the blocks Notion’s enhanced markdown describes, converted by the same companion that reads and updates page markdown: a leading#heading is lifted off (it is the title whenproperties.titleis omitted, and is never a block), every other element becomes a block in document order, and a tab-indented line nests under the block above it.GET /v1/blocks/{page_id}/childrenlists those blocks andGET /v1/pages/{page_id}/markdownrenders them back, where before both answered empty. Two readback flows measure this against the live workspace. (RYS-2235) -
Three more worlds for the Tier 1 surface.
notion_review_threadboots a task page with two open comment discussions to read, reply in, edit and delete.notion_team_directoryboots six people and two bots to page through withGET /v1/users.notion_linked_sourcesboots one database with two data sources, Projects and Tasks, and a project whose 30-task relation pages through property items. Every shipped world’s own bot now also answers users/me with its workspace id and workspace limits. (RYS-2098) -
A page answer holds at most 25 relation references and says when there are more.
GET /v1/pages/{page_id}, and the pagePOST /v1/pagesandPATCH /v1/pages/{page_id}answer, now carry a relation property’s first 25 references withhas_more: truewhen more exist, as Notion does, so a caller learns it must page throughGET /v1/pages/{page_id}/properties/{property_id}, which still returns every reference. The same answer holds at most 25 people in apeoplevalue and at most 25 inline mentions in atitleorrich_textvalue. Before, the answer carried every reference withhas_more: false. (RYS-2161) -
A markdown edit keeps the child pages and databases it leaves in place.
PATCH /v1/pages/{page_id}/markdownwithupdate_content,replace_content_rangeorreplace_contentkeeps every child page and child database whose<page url>or<database url>tag the edited markdown still carries, under its id and with its own content, and answers 200 withoutallow_deleting_content. Before, any such edit on a page holding a child page or database answered 400validation_error, and withallow_deleting_content: trueit deleted the untouched children. An edit that removes a child’s tag still answers 400 unlessallow_deleting_contentis true. (RYS-2258)
v0.0.1 — 2026-10-07
-
A local Notion API for your integration and the agents that drive it. The first release of the Notion twin: 16 routes of the Notion API at
Notion-Version: 2026-03-11— pages, blocks, databases with their data sources, search and the connection’s own bot user — served from a model validated against a live Notion workspace. Point@notionhq/client,notion-clientor@notionhq/notion-mcp-serverat it with any bearer token and that version (HOWTO.mdshows all three); no page lands in a real workspace. (RYS-1947) -
Pages and block trees, written and read back.
POST /v1/pagescreates a page under a page or a data source, with children if you send them;GETandPATCH /v1/pages/{page_id}read and update it;GET,PATCHandDELETE /v1/blocks/{block_id}andGETandPATCH /v1/blocks/{block_id}/childrenread, edit and append nested blocks.DELETEtrashes rather than deletes, as on Notion, andPATCHwith"in_trash": falserestores. A missing id answers Notion’s 404object_not_found. (RYS-1597, RYS-1598, RYS-1884) -
Databases, data sources and search.
POST /v1/databasescreates a database with its first data source;GETandPATCHread and update a database or a data source’s schema;POST /v1/data_sources/{data_source_id}/queryreturns its rows with property and timestamp filters applied andpage_sizepaging;POST /v1/searchfinds pages and data sources by title. A property sort is ignored in this release. (RYS-1595, RYS-1596, RYS-1886, RYS-1892) -
One API version, enforced on every route. The twin speaks
Notion-Version: 2026-03-11: a request with noNotion-Versionor any other version answers 400missing_version, on every route, as Notion does. The official clients default to2025-09-03, so set the version in the client. (RYS-1914) -
Five shipped workspaces.
--scenario notion_team_wikiboots a wiki with a root page, three child pages, a nested block tree and a Meeting Notes database, and is the world Free boots andPOST /_rystic/resetreturns to.notion_task_trackerholds a Sprint Tasks database with six rows across every status,notion_blank_slateone page and one empty database,notion_trash_bina trashed page tree beside a live restore log, andnotion_locked_vaultlocked pages beside an unlocked one;SCENARIOS.mdsays what each is for. On Pro and Enterprise,POST /_rystic/seedlands pages, blocks, databases, data sources and users directly, andPOST /_rystic/resetrewinds the id sequence and the clock, so two identical runs give byte-identical pages and blocks. (RYS-1947) -
Measured against a live Notion workspace. The 2026-10-07 full-suite round matched 79 of 79 probes against a live Notion workspace, with 0 inconclusive.
HOWTO.mdstates the differences this release ships with, each with its ticket: unserved routes answer a plain 404, not Notion’s envelope (RYS-1913); a data source query ignores property sorts, has nopage_sizecap and misses a page created with adatabase_idparent (RYS-1596); and a page’s properties are not resolved against its data source’s schema (RYS-1885). (RYS-1947)
S3
v0.0.4 — 2026-10-12
-
A world can carry its own timeline. A
--seedfile, or a whole one posted toPOST /_rystic/seed, may addcredentials, binding presented credentials to actors, andevents: calls the twin makes on its own API at set sim times as one of those actors, through the same handlers and egress as your requests. A top-levelcredentialsoreventskey the twin used to ignore now takes effect.RYSTIC_PACE=<speed>plays the clock, and the timeline, in real time, and an event’satmay be+<duration>./_rystic/inject,--injectandRYSTIC_INJECTnow needstate_setinstead ofscenarios. (RYS-2422) -
A timeline call can hand what it minted to later events. A
callevent may addbind,{"<name>": "<JSON Pointer>"}, saving fields of its answer, such as an ID the twin minted; a later event uses one as${name}in a call’s path, headers or body or in a seed event’s record keys and values (a register name cannot be bound). A schedule naming a${name}no earlier call binds is refused at load, and an event whose binding call failed records an error instead of firing. (RYS-2505) -
s3_static_siteplays a timeline. Over the two hours after the pinned clock, the site’s deploy bot, bound to its ownAWS4-HMAC-SHA256authorization header, snapshotsindex.htmlintoreleases/2026-02-01/, puts a newassets/app.js, deletes thelegacy.htmlredirect stub, rolls an access log and uploads the release bundle as a one-part multipart upload, all through the S3 API; advance the clock to watch the release land. (RYS-2459) -
A clock jump fires each timeline event at its own time. When one
POST /_rystic/clockor paced tick passes several events, each fires with the clock at itsat, so the records it creates carry that time, and the clock then lands where the move asked. An event already due when the clock moves fires at the current time. (RYS-2520) -
A clock jump stamps each timeline event at its exact instant. When a clock move passes timeline events, the records each one makes carry exactly its
at, to the millisecond, whatever traffic came before it, so one jump leaves the same records as stepping the clock to each event in turn. (RYS-2542) -
A timeline call can carry raw bytes, XML or a JSON array. A
callevent may sendbody_textorbody_base64instead ofbody; the twin sends it as written, under the route’s own content type (XML, bytes, JSON or a form), so a scheduled call can upload an object’s bytes or post an XML document, and${name}fills inbody_text. Abodymay be a JSON array where the route reads one. A call that answers XML binds from its elements by JSON Pointer, such as/InitiateMultipartUploadResult/UploadId. (RYS-2523)
v0.0.3 — 2026-10-11
-
DeleteObjects in quiet mode answers only the keys that failed. A
<Quiet>true</Quiet>delete now answers S3’s quietDeleteResult: noDeletedentries, only anErrorper key that failed, so a fully successful quiet delete answers an empty result. (RYS-1836) -
GetObjectAttributes answers every attribute you ask for.
x-amz-object-attributesnamingChecksum,ObjectPartsorStorageClassnow returns them besideETagandObjectSize: the stored checksum and its type, a completed multipart object’s parts, andSTANDARD. TheETagelement is unquoted, as S3 answers it. (RYS-1837) -
A multipart upload keeps the checksum algorithm it was created with.
CreateMultipartUploadwithx-amz-checksum-algorithm(CRC32, CRC32C, SHA1, SHA256) now completes with S3’s composite checksum for it (<base64>-<part count>, typeCOMPOSITE) instead of the default CRC64NVME, andCompleteMultipartUploadanswersx-amz-server-side-encryption. The completed object reads back throughHeadObjectandGetObjectwith that checksum andx-amz-server-side-encryption, as a single-PUT object does, andGetObjectAttributeslists its parts. (RYS-1838) -
ListObjectVersions lists the objects you write. In a bucket without versioning every object is listed as a
VersionwithVersionIdnullandIsLatesttrue, so emptying a bucket through the version listing (boto3’sbucket.object_versions.delete(), Terraform’sforce_destroy) now removes your objects. (RYS-1839) -
DeleteObject in a versioning-enabled bucket hides the object. A delete marker becomes the key’s current version and the object stays listed in
ListObjectVersionsas a noncurrent version, so a laterGetObjectorHeadObjectanswers 404NoSuchKeyas S3 does, where the twin used to keep serving the object. (RYS-1845) -
ACL grantee group URIs are declared identifiers.
http://acs.amazonaws.com/groups/global/AllUsersand its siblings in a bucket’s or an object’s ACL are handed out byte for byte and never rebased onto the twin, by declaration (pipeline_policy.jsonhost_roles), so a seeded ACL naming them passesRYSTIC_SEED_GATE=rejectand a client comparing a grantee URI against the AWS constant matches. (RYS-1910) -
The scripts in this archive are MIT-licensed.
check.shand the archive’s other scripts are published under the MIT License at https://github.com/Rystic-Inc/rystic-scripts , so you can copy, change and share them.LICENSE.txtno longer covers them; it still governs the twin itself.
v0.0.2 — 2026-10-07
-
Streaming uploads in the
aws-chunkedcoding. A body sentaws-chunked(Content-Encoding: aws-chunkedorx-amz-content-sha256: STREAMING-…), as SDKs stream uploads with a trailing checksum, is decoded: the object stores the payload alone, a trailingx-amz-checksum-*is verified, stored and echoed like the header form, and framing that does not parse answers400 IncompleteBodywithout storing anything. (RYS-1372) -
Presigned URLs. A SigV4 or SigV2 presigned URL, as
aws s3 presignand boto3’sgenerate_presigned_urlmake, stands in for theAuthorizationheader. The twin checks its shape, and its expiry against the real clock, never its signature: a misshapen SigV4 URL answers400 AuthorizationQueryParametersError, a misshapen SigV2 one403 AccessDenied, an expired one403 AccessDenied, and one sent beside a second credential400 InvalidArgument, as S3 answers them. (RYS-1372) -
Breaking: a request’s SigV4 signature must be well formed and fresh. The twin still holds no key and never verifies a signature, but it now checks the signing time against your machine’s real clock and then the
Authorizationheader’s shape, in S3’s order: a signed request with no parseableX-Amz-DateorDateanswers403 AccessDenied(“AWS authentication requires a valid Date or x-amz-date header”), one signed more than 15 minutes from now, either way, answers403 RequestTimeTooSkewedwhatever its shape, one with a fresh date but without exactly one well-formedCredential,SignedHeadersand 64-hexSignatureanswers400 AuthorizationHeaderMalformed, and one with no credential answers403 AccessDenied. A staticAuthorizationheader, such as the placeholder v0.0.1’sHOWTO.mdandcheck.shsent, is refused: sign each request instead, as the AWS SDKs and CLI already do, or withcurl --aws-sigv4 'aws:amz:us-east-1:s3' --user <access key>:<secret>(curl 7.75 or later; any key pair works)./_rystic/clocknever makes a fresh signature stale. (RYS-1372) -
Virtual-hosted addressing. A request to
<bucket>.s3.us-east-1.amazonaws.comor<bucket>.s3.amazonaws.comis served as the path-style request with that bucket first in the path, so a client keeping S3’s own hostnames and default addressing reaches the twin throughrystic run s3-twin --interceptandrystic exec, when it honors the gateway’s proxy and CA settings. Path-style on an endpoint override works as before. (RYS-1372)
v0.0.1 — 2026-10-07
-
A local Amazon S3 for your storage code and the agents that drive it. The first release of the S3 twin: 32 operations of S3’s REST API, path-style on
/,/{Bucket}and/{Bucket}/{Key+}— buckets, objects under any key, listings, multipart uploads, copies, tagging, batch deletes, conditional and Range requests and request checksums — served from a model validated against S3 itself. Point boto3 or the AWS CLI at it with an endpoint override, path-style addressing and any access key pair (HOWTO.mdshows both), and your code runs against it unchanged; nothing it stores reaches AWS. (RYS-1548) -
Objects under any key, read back as S3 answers them. PutObject, GetObject, HeadObject and DeleteObject on
/{Bucket}/{Key+}, where a key such aslogs/2026/09/report.csvis one object and%2Fin a path is the/it stands for; ListObjectsV2, ListObjects and ListObjectVersions list byprefixanddelimiter; GetObjectTagging, PutObjectTagging, DeleteObjectTagging, GetObjectAttributes and GetObjectAcl read and write an object’s settings. Bodies run to 64 MiB; a larger one answers400 EntityTooLarge. Every routed answer is S3’s own XML or headers and carriesx-amz-request-idandx-amz-id-2, every error a routed S3 request gets is S3’s<Error>document (only a path no route matches answers the twin’s own JSON 404), and an operation on a modelled path that the twin does not model (ACL writes, legal hold, retention, restore, select, and every bucket setting but versioning and tags) answers501 NotImplementedand changes nothing. (RYS-1368, RYS-1369, RYS-1370, RYS-1373) -
Multipart uploads, copies and batch deletes. CreateMultipartUpload, UploadPart, ListParts, CompleteMultipartUpload, AbortMultipartUpload and ListMultipartUploads let an SDK’s upload manager — boto3’s
upload_file,aws s3 cp— start an upload, send its parts and complete it with the multipart ETag S3 gives it, up to twelve parts at S3’s 5 MiB minimum; a part whose ETag matches none uploaded answersInvalidPart, an unknown upload idNoSuchUpload, and the completed object’sLocationleads back to the twin. CopyObject and UploadPartCopy copy within a bucket or from another byx-amz-copy-source, and DeleteObjects deletes every key its<Delete>document names. (RYS-1369, RYS-1370, RYS-1910) -
Request checksums, conditional writes and Range reads. A PutObject or UploadPart carrying
x-amz-checksum-crc32,-crc32c,-crc64nvme,-sha1or-sha256, as the AWS SDKs send by default, is checked against the body — a mismatch answers400 BadDigestand stores nothing — and the stored checksum is echoed underx-amz-checksum-mode: ENABLED, so SDK integrity validation passes.If-None-Match: *or a staleIf-Matchon PutObject answers412 PreconditionFailedand leaves the object alone;If-None-MatchandIf-Modified-Sinceon GetObject and HeadObject answer304;If-Matchnaming another ETag answers412; and aRangeanswers206 Partial Contentwith itsContent-Range. (RYS-1368, RYS-1369, RYS-1835, RYS-1840) -
Buckets: create, list, read, configure and delete. CreateBucket makes a bucket by the name you choose, ListBuckets lists the account’s buckets with each one’s
CreationDate(andBucketRegionwhen asked), HeadBucket, GetBucketLocation, GetBucketAcl, GetBucketVersioning and GetBucketTagging read one, PutBucketVersioning turns versioning on or suspends it, PutBucketTagging and DeleteBucketTagging write its tags, and DeleteBucket removes an empty one — a bucket still holding objects answersBucketNotEmpty.x-amz-expected-bucket-ownerpasses for the owning account’s 12-digit id (123456789012unless a seed says otherwise) and answers403 AccessDeniedfor any other. (RYS-1370, RYS-1856, RYS-1865) -
Five shipped worlds, and the one your test needs.
--scenario s3_static_sitebootsacme-demo-site, an unversioned website bucket with seven keys under real prefixes beside its access-log bucket, and is the world Free boots andPOST /_rystic/resetreturns to.s3_versioned_archiveholds six version rows with a delete marker among them,s3_encrypted_vaultfour objects pinning different encryption, checksum and storage-class combinations,s3_bucket_farmsix buckets over five regions and every versioning state, ands3_fresh_bucketone empty tagged bucket;SCENARIOS.mdsays what each is for. On Pro and Enterprise,POST /_rystic/seedlands buckets, objects, versions and multipart uploads directly, andPOST /_rystic/resetrewinds the id sequence and the clock, so two identical runs give byte-identical objects and listings down toETag,Last-ModifiedandUploadId. (RYS-1368, RYS-1548) -
Measured against S3 itself. The 2026-10-06 full-suite round matched 117 of 117 probes against
s3.us-east-1.amazonaws.com, with 0 inconclusive.HOWTO.mdstates what the suite leaves out and the differences this release ships with, each with its ticket: a versioning-enabled bucket keeps no versions of what you write (RYS-1845), ListObjectVersions lists nonullversion for an object you write (RYS-1839), DeleteObjects in quiet mode lists every deleted key (RYS-1836), GetObjectAttributes answersETagandObjectSizeonly (RYS-1837), and a multipart upload completes with CRC64NVME whatever algorithm it was created with and reads back without its checksum or encryption headers (RYS-1838). (RYS-1548)