Skip to Content
Changelog

Reference

Changelog

What changed in each simulator release, newest first.

Slack

v0.0.19 — 2026-10-12

  1. A world can carry its own timeline. A --seed file, or a whole one posted to POST /_rystic/seed, may add credentials, binding presented credentials to actors, and events: 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-level credentials or events key the twin used to ignore now takes effect. RYSTIC_PACE=<speed> plays the clock, and the timeline, in real time, and an event’s at may be +<duration>. /_rystic/inject, --inject and RYSTIC_INJECT now need state_set instead of scenarios. (RYS-2422)

  2. A timeline call can hand what it minted to later events. A call event may add bind, {"<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)

  3. A clock jump fires each timeline event at its own time. When one POST /_rystic/clock or paced tick passes several events, each fires with the clock at its at, 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)

  4. 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)

  5. A timeline call can carry raw bytes, XML or a JSON array. A call event may send body_text or body_base64 instead of body; 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 in body_text. A body may 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

  1. 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 image registry.rystic.ai/slack-twin-ui:<ver> beside the twin’s, pulled with the same docker login, and rystic ui runs it against a running instance (or, without Docker, the UI’s archive on your Node 22+). A Free key is refused with feature_not_in_plan, naming the verified_ui entitlement; the Web API and the embedded state UI stay on every plan. (RYS-2403)

  2. The scripts in this archive are MIT-licensed. check.sh and 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.txt no longer covers them; it still governs the twin itself.

v0.0.17 — 2026-10-10

  1. Scheduled messages post when the twin’s clock reaches them. A chat.scheduleMessage posts once, as its author and in its thread, when simulator time reaches post_at, and then leaves chat.scheduledMessages.list; a cancelled message never posts. (RYS-1572)

  2. Ephemeral posts reach the activity stream, and stream starts keep their chunks. A successful chat.postEphemeral emits one slack.message.ephemeral event on /_rystic/events naming its recipient, and never enters history. A chat.startStream with chunks and no markdown_text stores the chunks’ text as the message text. (RYS-1575)

  3. Closing a DM closes it only for you. conversations.close closes the DM for the caller alone and leaves the other participants’ view untouched; conversations.open on it reopens it for the caller with their read position kept. (RYS-1570)

  4. Deleting a message removes its pin and its reactions, as Slack does. After chat.delete, pins.list on the channel no longer lists the message, and reactions.get on it answers message_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)

  5. Conversation answers name your workspace’s team, and list and info agree. conversations.create, invite, join, rename, setTopic and setPurpose answer shared_team_ids with your workspace’s team and leave out the latest and unread counts Slack omits there; conversations.list and conversations.info give one conversation the same creator and updated, whoever asks, and a new group DM is named mpdm-… from its members’ handles. setPurpose, rename and join leave their system message in channel history, and a streamed message carries your bot’s bot_id, app_id and bot_profile, reads back as in_progress with the text sent so far until chat.stopStream, and finishes as completed. (RYS-885)

  6. 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, and files.info answers the uploader’s team in user_team instead of a placeholder. (RYS-891)

  7. Refusals carry Slack’s diagnostics, and a published view names your app. Every invalid_arguments refusal carries response_metadata.messages, conversations.invite refusals carry the per-user errors array, and views.publish answers your app’s app_id and bot_id rather than fixed placeholders. A post_at that is not a timestamp on chat.scheduleMessage, a presence other than auto or away on users.setPresence, and markdown_text beside chunks on chat.stopStream answer invalid_arguments, as Slack does. conversations.invite with force=true passes over an unknown user only while another named user can still be added, so beside members alone it answers user_not_found. (RYS-889)

  8. 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 ok tag the method’s documented response fixes for that status, error envelopes included; a value the handler wrote is never replaced. (RYS-1905)

  9. Tokens are read and refused the way Slack reads them. A token that is not a Slack token (Bearer x, Bearer token) now answers invalid_auth instead of being accepted. A malformed Authorization header — Bearer alone, or a second one — answers not_authed, as live Slack answers Bearer alone on every method but api.test, which it answers ok, though live Slack refuses a second header with invalid_auth; a header under another scheme, such as Basic, presents no token and answers not_authed too. A form-encoded token now wins over the header, and of two form tokens the last, as live Slack answers; api.test still answers without a token but refuses an invalid one. Rotated xoxe.xoxb-/xoxe.xoxp-, app-level xapp- and workflow xwfp- tokens are recognised kinds, so an app-level token on users.identity answers not_allowed_token_type. (RYS-865)

  10. A write moves the records it relates to, as Slack does. A stream started in a thread (chat.startStream with thread_ts), or a file shared into one (files.completeUploadExternal with thread_ts, naming its one conversation in channel_id or channels), moves its parent’s reply_count, reply_users, reply_users_count and latest_reply, as a posted reply does, and the file’s share message is a reply in that thread. pins.remove takes the channel off the message’s pinned_to, so conversations.history counts one pin fewer. conversations.info answers your last_read, which conversations.mark moves. (RYS-1909)

  11. An empty workspace holds the bot every token acts as. A twin started without a scenario, and the workspace POST /_rystic/reset wipes to, now hold one bot user, tb-member-user-id of team tb-member-team-id, and every token acts as it. A method that names it now finds it: users.list lists it, and users.info and users.profile.get, among others, answer it instead of user_not_found. A user token’s users.identity answers it instead of invalid_auth, and what you create is authored by it: a channel’s creator, and a message’s user, team, bot_id and app_id, where a minted U00000001 of team T00000002 authored them before. The workspace still holds no team record, so auth.test answers invalid_auth as before. A seed that keys its own caller under member replaces this bot; one that keys every user by its id keeps it beside them, and every token still acts as it. (RYS-1911)

  12. 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 GET and POST on both /<method> and /api/<method>, as GET /_rystic/routes lists them. The twin answers what it did before. (RYS-1918)

  13. A file is shared only once its bytes are uploaded, and a /me message reads back as Slack’s. files.completeUploadExternal on a file whose bytes never reached its upload_url still answers ok, 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. A chat.meMessage message reads back with your app’s app_id, a rich_text block and no team, and reactions.get answers a message’s pinned_to while it is pinned. (RYS-1909)

  14. 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.close answers the first close of an open DM with no_op: false and already_closed: false beside ok. reactions.get on a stream still in flight answers an empty text with streaming_state: in_progress, the bot’s bot_id and app_id, and parent_user_id on a reply. A thread’s parent answers thread_ts equal to its own ts once any reply joins it, a streamed reply included. conversations.history lists a broadcast reply (reply_broadcast) with no bot_profile or team, and chat.update of a /me message answers neither. An IM’s conversations.info carries is_archived, and its latest bot message’s bot_profile carries a name. (RYS-885)

v0.0.16 — 2026-10-02

  1. Socket Mode envelope ids come from their own range. The first envelope the twin sends carries envelope_id cdf8b689-d7dc-46e6-8000-000000000001 rather than 00000001-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)

  2. Post as a person with X-Rystic-Surface: client. A chat.postMessage made as a seeded non-bot user with the header X-Rystic-Surface: client stores and emits the message that user would type in the Slack client: user, team, text, blocks and a client_msg_id, with no bot_id, app_id or bot_profile, so an app that ignores bot messages starts a turn on its message and app_mention events. Posts without the header keep their app attribution, as on Slack. (RYS-1663)

v0.0.15 — 2026-10-01

  1. check.sh fails fast on a port that never answers. Its port check and boot wait give each /_rystic/version probe two seconds, so an address where something accepts connections but never replies fails the check with a message instead of hanging. (RYS-1444)

  2. Your bot receives Events API events, over Socket Mode or at a request URL. Run the twin with SLACK_TWIN_SOCKET_MODE=1 and point Bolt at it with any xapp- token, or set SLACK_TWIN_EVENTS_URL and SLACK_TWIN_SIGNING_SECRET to 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

  1. Inspect offers an identity view, and an injected 429 can carry Retry-After. /_rystic/inspect?view=identities folds identical actor-label aliases for display; the default export retains all records so a reset followed by seeding restores caller identity. A network profile’s reject takes validated headers, so an injected 429 can send the Retry-After a client’s backoff reads. The docs now say what file downloads answer, how state_reaction is keyed, and that a deleted file’s share message keeps its full file. (RYS-1262)

  2. File URLs serve the bytes. A GET of a file’s url_private or url_private_download with a Bearer token returns the bytes you uploaded, under the file’s mimetype (the download form as an attachment); without a token it answers 401, and a file whose bytes never reached the twin answers 404. files.completeUploadExternal now returns the file’s mimetype, mode, pretty_type, is_public and url_private_download, as Slack does. (RYS-1265)

  3. 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 limit values. (RYS-1263, RYS-1264)

  4. The state UI shows the workspace as a Slack client. On Pro and Enterprise, /_rystic/ui opens 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)

  5. The state UI takes the rystic.ai look. /_rystic/ui and 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)

  6. Unmodified clients reach the twin at Slack’s hostnames. rystic run slack-twin --intercept boots the twin behind the rystic CLI’s HTTPS gateway, and rystic exec -- <command> routes that command’s calls to slack.com and slack-files.com to it, for supported SDKs using their default transports. (RYS-1398)

  7. Missing and invalid arguments answer Slack’s errors. A call missing a required argument answers invalid_arguments with Slack’s response_metadata.messages ([ERROR] missing required field: channel), except users.info without user, which answers user_not_found as Slack does. chat.postMessage with no text, blocks or attachments answers no_text, reactions.add with an emoji name the workspace doesn’t know answers invalid_name, and users.getPresence without user reports the caller’s own presence. (RYS-1267)

  8. Thread and message reads reflect earlier writes. A reply_broadcast reply appears in channel history as a thread_broadcast carrying its parent as root. Deleting a reply updates its parent’s reply_count, reply_users and latest_reply; deleting a parent that has replies leaves a tombstone (“This message was deleted.”, from USLACKBOT) that keeps its thread, and deleting it again answers message_not_found. An updated message carries no parse or link_names, and a post answers no username unless you sent one. (RYS-1264, RYS-1266, RYS-1268, RYS-900)

  9. Pins list newest first, and Slackbot is a listed user. pins.list lists the most recently pinned item first, pins made in the same second included, and each pinned message carries pinned_info. users.list lists the built-in Slackbot first, as the same object users.info returns for USLACKBOT (is_bot false, 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)

  10. History windows and reply reads follow Slack. conversations.history with oldest returns 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.replies with a reply’s ts returns that reply alone, not its whole thread. (RYS-1266)

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

  1. Several agents can share one workspace, each under its own token. POST /_rystic/credentials binds a token to a user, and every call with that token acts as that user, with no Rystic header. GET /_rystic/calls lists every request since reset with the identity that made it, for grading. See Identities in the Slack docs. (RYS-1202)

  2. 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)

  3. Private conversations stay hidden from non-members everywhere. conversations.list shows a private channel, DM or group DM only to its members, as on Slack, and a non-member posting into one gets channel_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

  1. HOWTO.md and check.sh start from an API key. HOWTO.md leads with rystic login rys_…, says which control-plane routes Free, Pro and Enterprise reach and what POST /_rystic/reset does on each, documents seeding (twelve registers, state_trigger included, and an inspect dump posted back as it is) and the token rules as served (xoxb-/xoxp- kinds, not_allowed_token_type, the form-body token= fallback), and states that the bundled MCP package, rebound to design_studio, is not yet re-qualified. SCENARIOS.md names design_studio as the world Free boots. check.sh runs the Free and Pro build on your API key (RYSTIC_TOKEN, or the key rystic login stored) 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

  1. Only design_studio ships as a named scenario. The older scenarios are now test-only fixtures and are no longer accepted by --scenario, RYSTIC_SCENARIO, or POST /_rystic/scenario. Tests and evaluations can load their unchanged register maps and clocks from sim/testdata/scenarios/ through the control plane. (RYS-1177)

v0.0.10 — 2026-09-25

  1. 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, then rystic run or docker 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)

  2. design_studio is 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 #finance names 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

  1. 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_studio world 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 a schema_version.

v0.0.8 — 2026-09-22

  1. A fourth world, design_studio: somebody else’s whole Slack. ./slack-twin --scenario design_studio boots 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 three Rystic Shop worlds are unchanged, message for message, so a suite pinned to their ts values keeps passing. SCENARIOS.md has the workspace’s channels, people and the six questions.

v0.0.7 — 2026-09-21

  1. No change to the licensed twin’s behaviour. Same control plane, scenario pack, seeds, worlds and license gate as v0.0.6. /_rystic/version gains an edition: 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

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

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

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

  1. A third world, refund_desk_xl. ./slack-twin --scenario refund_desk_xl boots 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’s busy_account — the nine refund_desk names 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_desk and ops_channel are unchanged, message for message, so a suite pinned to their ts values keeps passing. SCENARIOS.md lists the new world’s requests and what each resolves to on the Stripe twin.

v0.0.2 — 2026-09-18

  1. Two named worlds, baked in. ./slack-twin --scenario refund_desk boots 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’s busy_account world — with the refund handbook pinned and one request already answered in its thread. ops_channel is #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. Your xoxb- token is the workspace’s ops bot (auth.test → UBOTOPS0001) and already a member, so conversations.history answers 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-scenarios names them; RYSTIC_SCENARIO=<name> does the same in a container.

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

  3. conversations.history lists top-level messages only. Thread replies no longer appear in a channel’s history; they live under conversations.replies, as on Slack, with thread_broadcast replies the one exception. A bot’s own posts now carry the bot_id and app_id that auth.test reports for its token, and each user’s tz_label / tz_offset follow their tz.

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.

  1. 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 with files.info/list/delete, views.open/publish/update, auth.test, api.test, team.info, team.profile.get, dnd.info and dnd.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.

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

  3. 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 hits slack.com/api fails 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.

  4. Seed the workspace your test needs. POST /_rystic/seed takes a register map — channels, memberships, messages, users, the team, reactions, pins, bookmarks, files, scheduled messages, views — and lands it directly. GET /_rystic/registers lists what this twin accepts a seed for; a register the model does not carry is refused with a status rather than silently dropped.

  5. The same seed twice is the same workspace twice. POST /_rystic/reset empties the twin, POST /_rystic/seed-rng pins the generator so channel ids repeat run to run, and POST /_rystic/clock fixes the twin’s notion of now — which matters more here than elsewhere, because a message’s ts is both its id and its clock reading. Two identical seed flows leave byte-identical /_rystic/inspect dumps.

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

  1. The scripts in this archive are MIT-licensed. check.sh and 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.txt no longer covers them; it still governs the twin itself.

  2. bots/test_bot.py boots your installed Kalshi twin. With neither KALSHI_BASE_URL nor KALSHI_TWIN_BIN set, it runs the kalshi-twin version rystic pull kalshi-twin installed, with the API key or license check.sh would find, instead of trying to build the twin from source. When that twin fails to start, the test prints its log.

  3. A world can carry its own timeline. A --seed file, or a whole one posted to POST /_rystic/seed, may add credentials, binding presented credentials to actors, and events: 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-level credentials or events key the twin used to ignore now takes effect. RYSTIC_PACE=<speed> plays the clock, and the timeline, in real time, and an event’s at may be +<duration>. /_rystic/inject, --inject and RYSTIC_INJECT now need state_set instead of scenarios. (RYS-2422)

  4. A timeline call can hand what it minted to later events. A call event may add bind, {"<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)

  5. github-pulls_open_queue plays 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 into tb-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)

  6. A clock jump fires each timeline event at its own time. When one POST /_rystic/clock or paced tick passes several events, each fires with the clock at its at, 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)

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

  8. A timeline call can carry raw bytes, XML or a JSON array. A call event may send body_text or body_base64 instead of body; 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 in body_text. A body may 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)

  9. A read-only collaborator cannot remove review requests. DELETE /repos/{owner}/{repo}/pulls/{pull_number}/requested_reviewers refuses a caller who can only read the repository with 403 Must have write access to repository., as requesting reviewers already did. (RYS-2133)

  10. 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 invalid event on review create Unprocessable Entity and on submit Validation Failed with GitHub’s errors — each measured against GitHub. Issue search applies reason:completed, reason:"not planned" and owner:, which it used to read as text. A search qualifier the twin does not evaluate still answers 501, now in GitHub’s error envelope with a message. (RYS-2134)

  11. GET /user/codespaces lists your codespaces. It answers {total_count, codespaces} with every codespace the caller created from a pull request, newest first, paged by per_page and page and narrowed by repository_id; without a credential it answers 401 Requires authentication. (RYS-2135)

  12. Other search types are refused, and what is out of scope is listed. GraphQL search with a type other than ISSUE or ISSUE_ADVANCED is refused with an error naming g_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

  1. 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 on example.invalid. A pull request’s head.repo and base.repo are the whole repository object (fork, clone_url, ssh_url, pushed_at and the rest), its user and every owner the whole simple-user, and comments and review_comments count exactly the rows the comment routes list. The imported github-pulls_garrytan_gbrain world keeps its example.invalid URLs. (RYS-1248)

  2. A body that is not JSON is refused, not applied as an empty edit. PATCH /repos/{owner}/{repo}/issues/{issue_number} and PATCH /repos/{owner}/{repo}/pulls/{pull_number} answer bytes their JSON decoder rejects with GitHub’s 400 {"message":"Problems parsing JSON"} before looking anything up, and write nothing — both used to answer 200 and bump updated_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. A PATCH on a seeded pull request answers the projection GET renders, with all eight _links and its derived merge_commit_sha, not the record as seeded. (RYS-1251)

  3. A pull request serves its diff and patch. GET /repos/{owner}/{repo}/pulls/{pull_number} with Accept: application/vnd.github.diff (or .v3.diff) answers the unified diff as text under Content-Type: application/vnd.github.diff; charset=utf-8, .patch the format-patch mbox of its commits, and application/vnd.github.full+json the JSON body with body_html and body_text — including calls made by Octokit’s pulls.get({mediaType:{format:"diff"}}). (RYS-1247)

  4. 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 answers 404), the headers it sends unlike GitHub (no ETag or 304, no X-GitHub-Request-Id, X-RateLimit-* only on GET /rate_limit, no charset on JSON), the past-the-last-page Link, the list query values and the requested-reviewers readback no round has measured, the truncated JSON bodies GitHub reads leniently, the Date: of a commit made through the API, and what body_html renders from. (RYS-1246)

  5. The diff and patch representations read as GitHub writes them. GET /repos/{owner}/{repo}/pulls/{pull_number} under Accept: application/vnd.github.diff names each index line’s blobs by their first seven characters, and under .patch numbers a lone commit [PATCH], carries the create mode line 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’s format-patch output parses the twin’s. The one remaining difference is the Date: header of a commit created through the API: the twin stamps it in UTC, GitHub in the committing account’s time zone. (RYS-1247)

  6. A seeded draft pull request is mergeable. In github-pulls_open_queue, the draft #43 answers mergeable: true, mergeable_state: "clean" — the settled values GitHub computes for a draft, measured against the real service on a full-suite round — where it answered mergeable: null, mergeable_state: "unknown" on every read, so a merge bot that polls until mergeable resolves now stops. A draft opened through the API already answered the settled values. (RYS-1252)

  7. The pull-request list falls back to GitHub’s defaults. page=0, per_page=0, a non-numeric page and an unknown state or sort on GET /repos/{owner}/{repo}/pulls answer 200 with the defaults, as GitHub does, where the twin answered 422 Validation Failed; and a head value without user: or organization: is ignored rather than matched against the branch name, so head=<ref> lists every open pull request as it does on GitHub. Each value is a sealed probe confirmed against the real service. An unknown state on the issues list stays 422, which is what GitHub answers there. (RYS-1249)

  8. A review comment’s review exists. A top-level comment through POST /repos/{owner}/{repo}/pulls/{pull_number}/comments creates the COMMENTED review its pull_request_review_id names, so GET …/pulls/{pull_number}/reviews/{review_id} answers that review and the reviews list includes it, as GitHub does. (RYS-1250)

  9. 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_reviewers and the pull request’s requested_reviewers no longer list them, as GitHub documents, so gh pr view drops 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)

  10. What a pull request’s conversation page reads now answers. GET .../issues/{n}/events and GET .../issues/{n}/timeline list a pull request’s or issue’s events and its derived timeline (comments, reviews, review comments, commits); Accept: application/vnd.github.html+json, .text+json and .full+json answer body_html and body_text; GET /repos/{owner}/{repo} answers permissions for the caller; and every avatar_url leads to an image the twin serves itself. github-pulls_garrytan_gbrain now 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)

  11. 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/repos error responses include status. (RYS-1717)

  12. 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)

  13. A body’s media type renders the same under its v3 spelling. Accept: application/vnd.github.v3.full+json, .v3.html+json, .v3.text+json and .v3.raw+json answer an issue’s, pull request’s, comment’s or review’s body exactly as the unversioned media types do: body_html and body_text are one GitHub-flavored Markdown rendering of body, so a client that names the API version in its media type gets the rendered fields. (RYS-1247)

  14. 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’s events and timeline), each Link target carries the per_page the request sent, as GitHub does, even above the 100 items a page holds. A per_page that is not a positive integer names the size served. (RYS-1253)

  15. Pull request mutations answer the pull request’s reviews. mergePullRequest, convertPullRequestToDraft, markPullRequestReadyForReview and createPullRequest answer reviews, latestReviews, latestOpinionatedReviews and reviewDecision from the reviews submitted on the pull request, as repository.pullRequest does, instead of always empty. (RYS-1304)

  16. X-GitHub-Api-Version is checked on every route, as GitHub does. 2022-11-28 (the default when the header is absent) and 2026-03-10 are accepted; any other value answers 400 with GitHub’s error body before the operation runs. (RYS-1914)

  17. A read answers If-None-Match with 304. A GET carries a weak ETag where GitHub’s does; send it back in If-None-Match and an unchanged read answers 304 Not Modified with no body, so a client’s cache revalidates against the twin as it does against GitHub. GET /search/issues and GET /rate_limit carry no ETag, as on GitHub, so a conditional request there reads the answer again. If-Modified-Since is not answered. (RYS-1253)

  18. Web URLs lead back to the twin. html_url, _links.html, clone_url and every other URL on github.com or one of its subdomains (docs.github.com, codeload.github.com) now names the twin’s origin, as api.github.com URLs already did; RYSTIC_PRESERVE_SERVICE_URLS=1 keeps GitHub’s hosts. Through rystic run --intercept, github.com and its subdomains route to the twin. (RYS-1910)

  19. Past the last page, Link points back at it. On the page-numbered reads (GET /search/issues, GET /user/repos, GET /user/orgs, GET /user/memberships/orgs, and an issue’s events and timeline), a page past the end names the last page as both prev and last, and page 1 as first, as GitHub does. (RYS-1249)

  20. Repositories and commit authors read as GitHub embeds them. GET /user/repos lists each repository as GitHub’s full repository object with the caller’s permissions; a pull request’s base.repo and head.repo, on its reads and on PATCH …/pulls/{pull_number}, carry neither permissions nor collaborators; and GET …/pulls/{pull_number}/commits names each commit’s author and committer by GitHub’s 19-field user. (RYS-1248)

  21. A pull request’s 404 carries only GitHub’s keys. A 404 on a pull request, or on its commits, files, comments, reviews, review comments, reactions or check-run annotations, answers exactly message, documentation_url and status, as GitHub does, without the url the twin added. GET …/issues/{issue_number}/events answers its 404 with the same three keys. (RYS-1253)

  22. GET /user and GET /rate_limit answer what GitHub does. The authenticated caller reads its private-user view, with plan, disk_usage, collaborators, the private repository and gist counts, two_factor_authentication and notification_email, and GET /rate_limit lists every resource category GitHub answers, audit_log, audit_log_streaming and enterprise_token_inventory included. (RYS-1905)

  23. The Limitations name the codespace timestamps. The docs page and the HOWTO say that a codespace’s created_at, updated_at and last_used_at are 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)

  24. 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’s 404 Not Found (message, documentation_url and status) where the twin served the route without the slash; drop the slash to reach it. (RYS-1253)

  25. 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 *_url templates among them, and names its owner by GitHub’s 19-field user. Its URLs on GitHub’s hosts lead back to the twin, except git_url and ssh_url, which keep GitHub’s git:// and SSH addresses. (RYS-1253)

  26. 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 as github-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 by rystic evaluator) evaluates, and anything else shows neutral. A push also renders GitHub’s push event into GET /_rystic/egress. (RYS-2061)

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

  1. Search applies the rest of GitHub’s grammar, and refuses what it cannot evaluate. GET /search/issues and gh pr list --search now apply negation (-author:, -label:, -is:draft), repeated qualifiers as GitHub combines them, the advanced AND / OR / parentheses syntax gh sends, free text with in:title|body|comments, head:, base:, comments:, no:label, review:, involves:, mentions:, commenter: and in-query sort:, each measured against api.github.com; an unknown login or repository answers GitHub’s 422 over REST and matches nothing over GraphQL. A qualifier the twin cannot evaluate answers 501 with an X-Rystic-Marker instead of quietly broadening the result, REST search pages with GitHub’s Link header, so a client that follows it gets every page, and a pull request found by search or read through the Issues routes carries draft and its own node_id. (RYS-1620)

  2. A codespace’s environment_id comes from its own range. The first codespace you create gets 81cebaf1-e762-4d0c-8000-000000000001 rather than 00000001-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)

  3. Seeded teams report type: "organization". The teams github-pulls_org_teams seeds, which GET /repos/{owner}/{repo}/pulls/{pull_number}/requested_reviewers returns, carried type: "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)

  4. The authenticated user’s repositories and organizations answer. GET /user/repos lists the repositories the caller owns or collaborates on, with GitHub’s visibility, affiliation, type, sort, direction, since, before and paging parameters, and GET /user/orgs lists the caller’s organizations. Both answer 401 without a credential, as GitHub does, so gh api user/repos and gh api user/orgs no longer 404. (RYS-1654)

  5. Organizations have members. GET /user/orgs lists the organizations the caller holds an active membership in, GET /user/memberships/orgs answers those memberships with their role and state, and GET /user/repos?affiliation=organization_member reads the same membership. github-pulls_org_teams seeds the organization its teams belong to, tb-org, with tb-owner, tb-reviewer-login and tb-maintainer as 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

  1. check.sh fails fast on a port that never answers. Its port check and boot wait give each /_rystic/version probe two seconds, so an address where something accepts connections but never replies fails the check with a message instead of hanging. (RYS-1444)

  2. The repository, the rate limit, the API root and the authenticated user answer. GET /repos/{owner}/{repo}, GET /rate_limit, GET / and GET /user are served as GitHub serves them, so an agent’s first probes no longer 404 and gh auth status reports 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)

  3. gh pr list and gh issue list run with their filter flags. With --author, --assignee, --label, --draft or --search, gh first asks for SearchType’s values, and the twin answered Field '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)

  4. Issue search over REST, and both searches apply date qualifiers. GET /search/issues answers the world’s issues and pull requests with total_count, paging and sort. It and the GraphQL search that gh pr list --search sends now apply created:, updated:, closed: and merged: ranges, draft: and is: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

  1. The state UI shows the repository as GitHub. On Pro and Enterprise, /_rystic/ui opens 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)

  2. Unmodified clients reach the twin at GitHub’s hostnames. rystic run github-pulls-twin --intercept boots the twin behind the rystic CLI’s HTTPS gateway, and rystic exec -- <command> routes that command’s calls to api.github.com and github.com to it: gh, git clone https://github.com/..., curl and SDKs with their default base URL, when their transport honors the proxy and CA settings. (RYS-1398)

  3. Free boots the imported garrytan/gbrain repository. --scenario github-pulls_garrytan_gbrain (or RYSTIC_SCENARIO) now boots it on Free, and POST /_rystic/reset puts 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

  1. HOWTO.md and check.sh start from an API key. HOWTO.md leads with rystic login rys_…, says which worlds and control-plane routes Free, Pro and Enterprise reach, and corrects the auth rules: Codespaces need a Bearer or token header, 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 marks github-pulls_open_queue as the one Free world and keys a pull request file <number>#<path>, and both give the id bands new records get. check.sh runs the Free and Pro build on your API key (RYSTIC_TOKEN, or the key rystic login stored) 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

  1. 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 Bearer or token authorization label. (RYS-1068)

  2. 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, then rystic run or docker 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

  1. gh repo clone works with gh 2.101.0 and later. That release’s clone query selects Repository.sshUrl on 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 answers sshUrl as GitHub spells it, git@<host>:<owner>/<repo>.git, on every repository.

v0.0.4 — 2026-09-23

  1. 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, with 422 {"message": "Invalid request.\n\n<lines>"} and no errors key: "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 with errors[] 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 unknown state_reason answers GitHub’s own 422 {"message": "Please specify a valid state reason."}.

v0.0.3 — 2026-09-22

  1. 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.git and git fetch origin refs/pull/{n}/head walk git’s dumb HTTP protocol, git push speaks 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, a synchronize notification — exactly as GitHub does; pushing to a protected branch is refused. A pull request’s files[].patch and its commits’ diffs now come from the same commits, so the two never disagree.

  2. The gh CLI works against the twin. POST /graphql answers the queries and mutations the gh porcelain sends, so gh 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 list and gh issue comment run unchanged, and every mutation writes through the same handler its REST route uses, so what gh did shows on the REST surface. Point it at the twin with environment alone — gh speaks plain HTTP to github.localhost and 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-repo
    

    The GraphQL surface is the subset gh speaks, not GitHub’s schema; a field outside it answers GitHub’s undefinedField error. gh issue view --comments lists the comments the store holds.

  3. 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 fetch after any of them finds what the API described: a merge is a commit on origin/<base> carrying the head’s changes — with merge_method merge the base and the head are its two parents, while squash and rebase land single-parent commits — and a pull request opened on a branch you pushed carries that branch’s commits and files. gh pr merge followed by git pull behaves as it does against GitHub. Fetching still changes nothing — refs/pull/{n}/merge is derived when a client asks for it and never written — so a clone leaves the world exactly as it found it.

  4. Issues and their comments over REST. /repos/{owner}/{repo}/issues lists and creates them, /issues/{n} gets and updates one — state closes and reopens it, with a state_reason — and /issues/{n}/lock locks and unlocks its conversation. Comments are /issues/{n}/comments to list and create, /issues/comments for 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 a pull_request key, and /issues/{n}/comments is where a pull request’s conversation comments live — the route gh pr comment writes 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.

  5. A real repository to review: github-pulls_garrytan_gbrain. The first imported world — the 300 most recently updated pull requests of garrytan/gbrain as 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.git downloads one 85 MB pack in about two seconds, git fetch origin refs/pull/N/head reaches any imported pull request, and gh pr checkout lands on the API’s head sha. Ids, logins and shas are GitHub’s. import_repo.py --git --gzip is how another repository becomes a world.

  6. 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 credentials and a caller without write access with 403, where before they accepted both; the writes that record an author no longer create issues, pulls, reviews or comments with user: null. Standing is asked of the resolved user, never the X-Rystic-Actor label, so a review can no longer be requested from the pull request’s own author. Every world seeds all four documented logins, and tb-contributor is 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

  1. Two worlds whose diffs carry a real program. github-pulls_bot_pr_broken seeds a merged pull request (#7) whose single file is a working Kalshi market-maker bot — files[].patch is 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 red bot-smoke check run whose failure annotation points at that line. github-pulls_bot_pr_queue opens 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-scenarios names them.

  2. The bot ships as plain files too. bots/bot.py, the five bots/variants/*.py and bots/test_bot.py ride the archive beside the binary, and rystic features github-pulls-twin describes them under reference bot. The test runs the bot for 30 s against a kalshi-twin in its tight_book world and requires a bid and an ask resting at once, no crossed quote and nothing left resting; every variant is held to failing that.

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

  4. 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 a GET on a seeded pull request resolved but PATCH, merge, review dismiss, reply and reviewer requests on it answered 404. The worlds are re-keyed the way the handlers read them, and GET /_rystic/registers states 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.

  1. 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 and gh all 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.

  2. Five worlds baked in. --list-scenarios names 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.

  3. 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-Actor header and applies the seeded permissions, so the permission branches of your integration are testable without a second real account.

  4. It never reaches the network, and it needs no token. Any Authorization header is accepted, and the twin dials nothing — which is the point: a suite that hits api.github.com fails when the hourly limit is spent, and one that creates throwaway repositories leaves them behind.

  5. Seed the world your test needs. POST /_rystic/seed takes a register map — repositories, branches, commits, users, teams, pull requests, reviews, comments, reactions, check runs — and lands it directly. GET /_rystic/registers lists what this twin accepts a seed for; a register the model does not carry is refused with a status rather than silently dropped.

  6. The same seed twice is the same world twice. POST /_rystic/reset empties the twin, POST /_rystic/seed-rng pins the generator so ids, node_ids and merge shas repeat run to run, and POST /_rystic/clock fixes the twin’s notion of now so created_at and updated_at are values your test chose. Two identical seed flows leave byte-identical /_rystic/inspect dumps.

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

  1. Scheduled calls reach the Trade API, and a world can carry its own timeline. A call event in POST /_rystic/inject or --inject may name a Trade API route and act as the actor its as names, through the same handlers and WebSocket frames as your orders, and an event’s at may be +<duration>. A --seed file, or a whole one posted to POST /_rystic/seed, may carry its own credentials and events, 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, --inject and RYSTIC_INJECT now need state_set instead of scenarios. (RYS-2422)

  2. A timeline call can hand what it minted to later events. A call event may add bind, {"<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)

  3. A clock jump fires each timeline event at its own time. When one POST /_rystic/clock or paced tick passes several events, each fires with the clock at its at, 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)

  4. 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)

  5. A timeline call can carry raw bytes, XML or a JSON array. A call event may send body_text or body_base64 instead of body; 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 in body_text. A body may 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)

  6. GET /account/limits shows the advanced API usage level once you upgrade. After POST /account/api_usage_level/upgrade, the limits read answers usage_tier: "advanced", Kalshi’s advanced read and write buckets, and the advanced grant in grants, as Kalshi does. A client can now confirm its upgrade through the public API. (RYS-2249)

  7. A triggered order group refuses new orders until it is reset. After PUT /portfolio/order_groups/{id}/trigger, an order naming that group in order_group_id is refused and does not rest, as Kalshi documents; PUT /portfolio/order_groups/{id}/reset admits 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

  1. 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 image registry.rystic.ai/kalshi-twin-ui:<ver> beside the twin’s, pulled with the same docker login, and rystic ui runs 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 the verified_ui entitlement is refused with feature_not_in_plan. (RYS-2403)

  2. The scripts in this archive are MIT-licensed. check.sh and 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.txt no longer covers them; it still governs the twin itself.

v0.0.38 — 2026-10-11

  1. Kalshi is on Pro and Enterprise. A Free API key is refused product_not_in_plan for rystic pull kalshi-twin and a start. On Pro and Enterprise every world, control route and the verified Kalshi UI are open, and without --scenario the twin boots empty. (RYS-2400)

v0.0.37 — 2026-10-09

  1. The live feeder separates the exchange’s delay from its own. The feeder’s [status] line now reports upstream_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)

  2. 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/feed batch no longer holds order requests until it finishes: they run between chunks of at most 1,000 frames. (RYS-1805)

  3. 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-events or RYSTIC_LIVE_EVENTS_FILE is now named in the feeder log with the flag that carries it, instead of being reported as an event not yet open. Setting --live-carry beside named markets or events logs that the carry is ignored. (RYS-1937)

  4. A subaccount transfer is refused when its amount is not positive or exceeds the source balance. POST /portfolio/subaccounts/transfer answers 400 transfer_amount_must_be_positive for a negative amount and 400 insufficient_balance for more than from_subaccount holds, as Kalshi does; neither moves a balance. A bot that relied on the twin accepting those transfers now sees Kalshi’s refusal. (RYS-60)

  5. 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)

  6. 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 a fill line for each of your fills with its order_id, trade_id and the recorded time it happened. (RYS-2086)

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

  8. An open state UI no longer slows replay. /_rystic/events sends what queued in one write at most every 5 ms instead of waking for every event, so with /_rystic/ui open a busy replay applies about 17% more events per second. Each event keeps its own frame and id, and resuming with Last-Event-ID or ?cursor= works as before. (RYS-2140)

  9. 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)

  10. 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)

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

  1. Query parsing errors match the demo on sixteen GET routes. Declared query fields return the measured flat 400 message 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.

  2. 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)

  3. 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-trader account; set RYSTIC_ACTOR to use another account. (RYS-1642)

  4. 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 a session.json whose replay_start body plays them with every offset at 0. The set reads as one continuous tape without the twin. examples/kalshi-sidefeed builds 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)

  5. 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)

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

  1. Replay stops at an exact tape time. POST /_replay/start takes checkpoints and POST /_replay/play takes until_ms, both in absolute tape milliseconds (the axis of status.start_ms). Playback applies every event through the stop, pauses there, and status.at_checkpoint_ms reports the stop, so the same actions at the same stops give the same fills and balances after a reset. (RYS-1628)

  2. A windowed capture archive replays its whole window, quiet markets included. An archive cut to a [from, to) window carries a manifest.json that declares the window, an opening book per market and a SHA-256 per file, and holds no frame at or after to. 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)

  3. 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/trades and 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)

  4. Replay can use a zero-fee series seed. Before starting playback, seed the market’s series in state_series with its ticker, fee_type: "quadratic" and fee_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)

  5. Replay steps by a fixed amount of tape or to the next scheduled event. POST /_replay/play takes advance_ms to play that many tape milliseconds from the head and pause, or until: "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, and status.next_event_ms shows the next stop, so a harness can drive a session one step at a time with identical results on every run. (RYS-1669)

  6. 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 member account. It reaches only the Trade API routes it uses; the existing state UI remains separate. (RYS-1632)

  7. A market can settle on schedule. A scripted-event schedule now takes a call event that runs one of the twin’s own routes, such as POST /_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’s manifest.json records each market’s outcome, read from its market records, while its meta/ stays free of it, so nothing the agent reads shows the winner early. (RYS-1667)

  8. 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)

  9. The control plane can be locked away from an agent, and every action logged to disk. With RYSTIC_ADMIN_TOKEN set, /_rystic/*, /_replay/* and /_settle need that value in X-Rystic-Admin-Token (or the rystic_admin_token cookie). GET /_rystic/version stays open as the readiness probe, and so does GET /_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)

  10. 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)

  11. A grader can read a whole account, the action log and the run’s configuration over HTTP. GET /_account/snapshot returns 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, and GET /_rystic/config names 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)

  12. Replay can play games back to back in any order. POST /_replay/start takes a sequence of games, each a market or a list of markets, with gap_ms between 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_DIR can hold one cut archive per game. GET /_replay/status and GET /_rystic/config report each game’s recorded window, session window and offset, so a session time maps back to its recorded time. (RYS-1668)

  13. The Enterprise image runs a replayed session with the network off. The HOWTO’s new “Running offline” section shows the docker create --network none recipe for a cut library: license, RYSTIC_REPLAY_METADATA=off, the admin token, the action log and a single cash pool. It also names GET /_rystic/version as 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

  1. check.sh fails fast on a port that never answers. Its port check and boot wait give each /_rystic/version probe two seconds, so an address where something accepts connections but never replies fails the check with a message instead of hanging. (RYS-1444)

  2. Order ids from the matching engine come from their own range. An order the engine places gets an order_id such as a7cade2e-8c1a-43f4-8000-000000000001 rather than 00000001-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)

  3. market_unopened seeds its market as initialized. The market reports status: "initialized", Kalshi’s status for a market before its open time, where it reported "unopened", which is a GET /markets filter value and never a market’s status. Orders are still refused with 409 market_closed, and GET /markets?status=unopened still lists the market. (RYS-1600)

  4. Scripted events fire on the sim clock. Load a schedule of timed messages and seeds with POST /_rystic/inject, or --inject <file> / RYSTIC_INJECT at boot. Each event fires once when the clock reaches its at, between the same two tape events at any replay speed. Fired messages are readable at GET /_feed?since=<seq>, and GET /_rystic/inject shows what fired and when. (RYS-1638)

v0.0.33 — 2026-09-30

  1. The batched cancel is measured against live on the order it places. The sealed probe for DELETE /portfolio/events/orders/batched now 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)

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

  1. 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 — the btc15m_tape world’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)

  2. 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); its expected_expiration_time, latest_expiration_time, expiration_time and occurrence_datetime follow the market’s own geometry on the replayed clock (5 minutes and 7 days past close on a 15-minute market) instead of all reading close_time. After POST /_settle the 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)

  3. 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} and DELETE /portfolio/orders/batched now answer 410 deprecated_v1_order_endpoint with 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 with RYSTIC_KALSHI_LEGACY_ORDERS=on. Four sealed probes hold the refusal. (RYS-1257)

  4. The portfolio reads publish what live publishes. GET /portfolio/orders lists newest first by the full created_time instant, including fractional seconds, with order ID breaking ties so cursors resume deterministically; an order canceled through DELETE /portfolio/events/orders/batched keeps an RFC 3339 last_update_time instead of a unix integer; every resolved trade from a replayed tape reaches GET /markets/trades with 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 internal owner field. (RYS-1258)

  5. Error envelopes match the exchange. A path the simulator does not route answers Go’s plain-text 404 page not found like live; GET /markets?status=<anything outside unopened|open|paused|closed|settled> is a 400 bad_request with details invalid status filter instead of an empty page; GET /markets?limit=1001 answers the parameter binder’s {"msg":"Parameter validation failed for GetMarkets: …"}; a routed 404 carries {"error":{"code","message"}} and no service key; and the rate_limited world’s 429 body is the documented {"error":"too many requests"}. (RYS-1261)

  6. 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 same sid, 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 6 Already subscribed. (RYS-1259)

v0.0.31 — 2026-09-26

  1. The login offer starts Kalshi in btc15m_tape, and HOWTO.md and the smoke scripts start from an API key. rystic login offers Kalshi booted into btc15m_tape, the world every plan can run, instead of tight_book. HOWTO.md leads with rystic 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 on RYSTIC_TOKEN with the Enterprise license second, sends /_settle with Content-Type: application/json, lists the lease check-in among outbound calls, and says the DELETE /portfolio/events/orders kill switch answers 404, as demo does. check.sh runs on your API key as well as a license and, on Free, waits for btc15m_tape’s market and reads it back instead of seeding; trade-check.sh says it needs Pro or Enterprise when the plan refuses its seed. (RYS-1204)

v0.0.30 — 2026-09-25

  1. 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, then rystic run or docker 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)

  2. Live mode carries markets by event. --live-events E1,E2, RYSTIC_LIVE_EVENTS, or a shared --live-events-file / RYSTIC_LIVE_EVENTS_FILE (stack keys live_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

  1. Live market selections survive rollover. The feeder reapplies --live-markets and RYSTIC_LIVE_MARKETS_FILE on 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.
  2. WebSocket lifecycle and price-feed channels work. multivariate_market_lifecycle, pyth_value, cfbenchmarks_value, communications and order_group_updates now accept subscriptions. Markets minted by a tape or seed emit lifecycle creation events, and /_settle emits 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

  1. Orders on btc15m_tape now fill. In v0.0.27 every order placed on the recorded KXBTC15M market answered 404 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/balance shows a balance_breakdown of $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

  1. A real recorded session as a named world: btc15m_tape. ./kalshi-twin --scenario btc15m_tape boots 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 /_replay call. 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 served active on 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.md lists it under Recorded) and the one world the free edition serves.
  2. /_rystic/version reports more. The control-plane contract moves from 7 to 9. edition is always present: empty on a licensed build and free on the free edition, so a caller can tell them apart without probing. --version prints the same value as an edition: line. initial_clock is the epoch a reset returns the clock to. api_base_paths, service_hosts, preserve_service_urls and credential_required report settings other twins declare. Kalshi declares none, so they read empty, false and 0, present so one client reads every twin the same way.
  3. The EULA in the archive gains a Free Edition section. LICENSE.txt now 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

  1. Drop a WebSocket connection on demand. GET /_rystic/ws lists every open socket with its subscriptions; POST /_rystic/ws-drop cuts them by tickers (any socket whose subscription names one), by connections (ids from the listing), or all. The default is a bare close with no close frame, what a crashed server or a severed path looks like; graceful: true sends 1001 going away first. 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.
  2. One tape as many markets. /_replay/prepare and /_replay/start take copies: N, playing one tape as N distinct markets on one clock, named <tape>-1 … <tape>-N, each on its own stream; {"tape", "as"} members in markets pick 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, and status shows which tape each member plays. Each copy is folded separately, so memory scales with copies × tape size. rystic run kalshi-twin --tape <id> --copies N does the same from the CLI, and the copies survive a restart.
  3. A load rig for the reconnect path. examples/kalshi-loadtest in 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.00 means the twin kept up) and reconnect latency. Its README records what one twin sustained; run it on your own hardware before trusting a number.
  4. Every live setting from the environment. --live reads RYSTIC_LIVE=1 and every --live-x-y flag reads RYSTIC_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_B64 carries your own key inline beside KALSHI_PRIVATE_KEY_FILE (the id and one key form); a stack file’s live: true service takes live_env, live_series, live_max_markets, live_shards, live_carry and live_markets; and rystic run --live --runner docker runs the feeder on the host beside the container, ended first by stop.
  5. 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.
  6. A market always has its event. Seeding a state_market_catalog record (or replaying over one you seeded first, the strike-metadata workflow) now mints the state_event it names when none exists, so GET /events/{event_ticker} and GET /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’s exchange_index instead of a fixed 0, and no longer claims a collateral_return_type the exchange never serves. An event you seed yourself is left as you seeded it.

v0.0.25 — 2026-09-12

  1. 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/pause now keeps that paced position, play (and a speed change mid-flight) resumes from it instead of rewinding to the last applied event, and GET /_replay/status reports 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 uninterrupted play is unchanged.

v0.0.24 — 2026-09-06

  1. The twin can be fed a live stream, not only a recorded tape. rystic run kalshi-twin --live starts 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-series and --live-carry, and spread the carried markets over more sockets with --live-shards. Keys come from KALSHI_KEY_ID and KALSHI_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/feed and 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.

  2. 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 climbing lag means nothing is feeding the twin.

  3. 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_time expired 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_time and ts included. What changes for a bot: nothing expires while the twin is idle — advance the clock past the deadline with POST /_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 carry created_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

  1. The boot banner prints a URL you can paste. 0.0.21 and 0.0.22 announced http://[::]:8080 on all four boot lines — the wildcard bind the listener reports, not a host — while the CLI’s own summary line said localhost:8080 for the same port. The banner now rewrites the unspecified address in all three forms ("", ::, 0.0.0.0) to localhost; an explicit host survives untouched. --health still answers 127.0.0.1, because a distroless image has no resolver.
  2. Two order-entry rejections now come in live’s order and live’s words. A reused client_order_id answers 409 order_already_exists before the balance check, and a fill_or_kill the book cannot cover answers 409 fill_or_kill_insufficient_resting_volume before it too; the twin used to answer 400 insufficient_balance to both, and the duplicate’s envelope (duplicate_client_order_id) was one live has never sent. good_till_canceled and post_only keep answering insufficient_balance pre-match, as live does. One walk of the book per order; nothing is written on a rejection.
  3. 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 on historical/markets/{ticker} and on candlesticks at all three intervals, empty fills, orders and trades. --scenario has_history now boots a world holding both halves — KXDEMO-26-A open and tradeable, KXDEMO-25-SETTLED in the archive behind a 2026-07-20 cutoff — each 404ing on the other’s half, which is the property historical/cutoff exists to express.
  4. Four reads answer the question asked. A candlestick’s end_period_ts is 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_history is one series per event, with the percentile taken across that event’s markets, not one duplicate-addressed item per market. total_cost_shares_fp counts 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

  1. Every world in a batch gets its own arguments. The bot contract was four environment variables; the argv after -- was exec’d byte-identical in every slot, so rystic 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-run prints the resolved command per world, and {{market}} is refused on grid and batch -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.
  2. 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.
  3. Fills and orders carry the side/action fields live still serves. The WebSocket fills channel serves action_side; REST serves book_side and outcome_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.
  4. 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.
  5. 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.
  6. Smaller surface corrections. Block-trade accept requires the caller to be a named party. /exchange/status omits exchange_estimated_resume_time rather than sending it null. /events/{ticker}/milestones is served. expiration_time is 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.
  7. 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

  1. 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_index on the order record), the cash on that shard is what admits it, and the three portfolio/intra_exchange_instance_transfers routes move cash between shards. Cancels route too, and the default is shard 0: a DELETE .../events/orders/{id} with no ?exchange_index cannot 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”.
  2. Post-only meets your own quotes the way live does. A post_only order under self_trade_prevention_type: maker whose 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 under taker_at_cross — still answers the exact 400 invalid 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.
  3. 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.
  4. Position money at live’s scale. GET /portfolio/positions renders 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.
  5. 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.
  6. 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.
  7. A fresh install has somewhere to go. The bundled random-walker and 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-scenarios names them, so the quickstart works thirty seconds after the install, tape or no tape.

v0.0.20 — 2026-08-21

  1. 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 real yes_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’s meta/ 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.
  2. That fetch is yours to refuse, and bounded when you keep it. RYSTIC_REPLAY_METADATA=off disables 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 speaks https, 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.
  3. 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.
  4. The price grid is per market, not per series. Kalshi migrated KXGOLD15M from 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. Read price_ranges per market rather than memorising which series is which.
  5. A twin that dies says so at once. rystic run -d, up and check used 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.
  6. 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_prevention among them).

v0.0.19 — 2026-08-20

  1. Backtest results you can read. rystic batch now 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 its mtm and fees. rystic report <out-dir> re-renders the table from a finished run any time, with -csv for spreadsheets, -sort <column>, and --json.
  2. 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 -settlements file realizes them exactly.
  3. 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.
  4. rystic doctor and 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

  1. 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.
  2. 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.
  3. An unbuilt fleet image names the build. rystic batch --runner docker --image with 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

  1. Ctrl-C stops a docker fleet. Cancelling rystic batch --runner docker now kills the whole container process group instead of leaving workers running.
  2. 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 rystic predates -tape says exactly that instead of “exited 2”.
  3. 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

  1. Fleets from your own image. rystic batch --runner docker --image <yours> -- <bot> runs one container per tape or world from an image you build FROM registry.rystic.ai/kalshi-twin:<ver>, with a ledger row per world. -tape <id> names one tape exactly.
  2. Stack files. Declare a hand-picked set of twins once in rystic.yaml; rystic up / down / ps --project manage them as a group.
  3. Export. rystic export compose emits a digest-pinned compose file (license via .env, never in the file); rystic export antithesis scaffolds the config image and launch steps.
  4. Secrets passed to bots ride -e NAME — values never appear in docker’s argv.

v0.0.14 — 2026-08-18

  1. Images pull from registry.rystic.ai. --runner docker pulls the published per-licensee image through the registry front door — your license file is the docker credential, and rystic login runs the docker login for you.
  2. 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

  1. Instances. rystic run -d --name --port, ps, stop, restart, rm, logs, status, a --json envelope on every command, and a stable exit-code contract (8 = runtime unavailable, 9 = conflict).
  2. rystic list is the released catalog — latest, installed and running per product your license covers.
  3. A twin says which build it is. --version, --health, and product/version/watermark on GET /_rystic/version.
  4. rystic grid runs the bundled bots across named scenario worlds; the bots take a RYSTIC_TICKS budget and exit instead of running forever.
  5. The quickstart’s check.sh resolves 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

  1. Shipped scenario packs carry complete market records, held by the same completeness gate as the built-in scenarios.
  2. 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

  1. 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.
  2. Per-customer archives. Release assets are watermarked per licensee; rystic pull resolves the build your license names.

v0.0.7 — 2026-08-12

  1. Named scenarios ship with the twin. rystic run kalshi-twin --scenario thin_book; --list-scenarios and SCENARIOS.md for the menu.
  2. 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.
  3. Replay runs on the exchange clock, not capture-arrival time. Every tape starts from a flat portfolio.
  4. State UI. Effective configuration in the header, an Avg cost tile per bot, a Network panel.
  5. rystic check accepts 204 No Content from /_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

  1. Replay is 9.4× faster, byte-for-byte identically — a 446k-event tape plays in 41s instead of 384s.
  2. Member fills print to the public tape, sharing one trade_id sequence with private fills.
  3. The egress log is bounded at 100k frames (RYSTIC_EGRESS_RETAIN to widen) and reports dropped.
  4. Network shaping, inert by default — POST /_rystic/network.
  5. 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

  1. A world can carry its own timeline. A --seed file, or a whole one posted to POST /_rystic/seed, may add credentials, binding presented credentials to actors, and events: 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-level credentials or events key the twin used to ignore now takes effect. RYSTIC_PACE=<speed> plays the clock, and the timeline, in real time, and an event’s at may be +<duration>. /_rystic/inject, --inject and RYSTIC_INJECT now need state_set instead of scenarios. (RYS-2422)

  2. A timeline call can hand what it minted to later events. A call event may add bind, {"<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)

  3. design_studio carries a timeline. As the clock moves past 2026-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)

  4. A clock jump fires each timeline event at its own time. When one POST /_rystic/clock or paced tick passes several events, each fires with the clock at its at, 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)

  5. 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)

  6. A timeline call can carry raw bytes, XML or a JSON array. A call event may send body_text or body_base64 instead of body; 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 in body_text. A body may 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

  1. check.sh fails fast on a port that never answers. Its port check and boot wait give each /_rystic/version probe two seconds, so an address where something accepts connections but never replies fails the check with a message instead of hanging. (RYS-1444)

  2. Every link the twin hands out leads back to the twin. An order’s, authorization’s, capture’s or refund’s links[].href and an error’s information_link now carry the origin you reached the twin on, with PayPal’s path kept, so a client that follows self, capture or refund calls the twin rather than PayPal with its token. A buyer approve link and a documentation link now answer 404 from the twin; a twin started with rystic run --intercept keeps PayPal’s hosts in its links, which rystic exec routes to the twin. (RYS-1910)

  3. The order read accepts your client id and secret. GET /v2/checkout/orders/{id} now answers HTTP Basic client_id:secret as the sandbox does, beside a bearer token; the other payment routes still answer Basic with 401. (RYS-1920)

  4. The guide lists what the twin serves and what it does not, from the model. HOWTO.md and 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)

  5. The scripts in this archive are MIT-licensed. check.sh and 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.txt no longer covers them; it still governs the twin itself.

v0.0.6 — 2026-09-30

  1. PayPal-Debug-Id keeps its spelling. A 404 on an authorization, capture or refund read now sends PayPal-Debug-Id instead of Paypal-Debug-Id; the value still repeats the body’s debug_id. (RYS-1336)

  2. The state UI shows the account as PayPal Business. On Pro and Enterprise, /_rystic/ui opens 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

  1. The guide starts from an API key, says what each plan reaches, and documents the payment events. HOWTO.md now opens with rystic 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 write PAYMENT.CAPTURE.COMPLETED and PAYMENT.CAPTURE.REFUNDED to GET /_rystic/egress, and authorization void writes the twin’s own authorization_voided record. SCENARIOS.md says what each plan boots. check.sh finds the API key rystic login stored as well as a license file, looked up in the CLI’s order, and rystic features marks the features and commands that need Pro or Enterprise. (RYS-1204)

v0.0.4 — 2026-09-26

  1. Only design_studio ships as a named scenario. The older scenarios are now test-only fixtures and are no longer accepted by --scenario, RYSTIC_SCENARIO, or POST /_rystic/scenario. Tests and evaluations can load their unchanged register maps and clocks from sim/testdata/scenarios/ through the control plane. (RYS-1177)

v0.0.3 — 2026-09-25

  1. A new world, design_studio, now the default. --scenario design_studio boots Website Design Contractors’ business account: 57 completed orders, one per invoice its eight PayPal clients paid, each with its capture, invoice_id the studio’s invoice number and custom_id the client, the same invoices the studio’s Slack, Resend and Stripe worlds name. A lease that grants no scenarios boots it. (RYS-1171)

  2. 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/scenarios and POST /_rystic/scenario for switching worlds without a restart. (RYS-1020)

v0.0.2 — 2026-09-24

  1. 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_type error.

    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.

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

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

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

  1. The scripts in this archive are MIT-licensed. check.sh and 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.txt no longer covers them; it still governs the twin itself.

  2. A world can carry its own timeline. A --seed file, or a whole one posted to POST /_rystic/seed, may add credentials, binding presented credentials to actors, and events: 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-level credentials or events key the twin used to ignore now takes effect. RYSTIC_PACE=<speed> plays the clock, and the timeline, in real time, and an event’s at may be +<duration>. /_rystic/inject, --inject and RYSTIC_INJECT now need state_set instead of scenarios. (RYS-2422)

  3. A timeline call can hand what it minted to later events. A call event may add bind, {"<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)

  4. design_studio carries a timeline. As the clock moves past 2026-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 +1h now sees these records. (RYS-2459)

  5. A world can move a postcard through the mail. POST /_tracking_event records a USPS scan (In Transit, Delivered, Returned to Sender and Lob’s other documented names) on a postcard’s tracking_events, so a world’s timeline can carry a piece from mailing to delivery. (RYS-2461)

  6. A clock jump fires each timeline event at its own time. When one POST /_rystic/clock or paced tick passes several events, each fires with the clock at its at, 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)

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

  8. A timeline call can carry raw bytes, XML or a JSON array. A call event may send body_text or body_base64 instead of body; 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 in body_text. A body may 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

  1. check.sh fails fast on a port that never answers. Its port check and boot wait give each /_rystic/version probe two seconds, so an address where something accepts connections but never replies fails the check with a message instead of hanging. (RYS-1444)

  2. The test fixtures seed Lob’s render status on postcards. In lob_mailroom, lob_returned_mail and lob_campaign_blast under sim/testdata/scenarios/, a mailed postcard’s status was a mail state (delivered, in_transit, returned_to_sender, …) or canceled. It is now rendered, a value of Lob’s PDF render status; the mail’s progress stays in tracking_events, and the canceled card is still marked deleted: true. (RYS-1600)

  3. 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 embedded from or to address answers deleted: true. Measured against Lob’s test environment. (RYS-1858)

  4. A list’s next_url and previous_url lead back to the twin, and /v1 paths answer. Both list cursors name the host your client reached the twin on, under /v1, where they named api.lob.com, so a client that follows one stays on the twin. Every Lob path now answers with and without the /v1 prefix, so an SDK’s host override may keep Lob’s https://api.lob.com/v1 form with the twin’s host. A twin started with rystic run --intercept keeps api.lob.com in those URLs, which its gateway routes to the twin. (RYS-1910)

  5. A request without credentials answers Lob’s Missing authentication. Every route answers a request that presents no Basic credential, with no Authorization header or one under another scheme such as Bearer, with Lob’s 401 {"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 own 401. (RYS-1920)

  6. A seeded postcard renders as a created one. In design_studio and any world you seed, a postcard’s to and from answer the address objects a created postcard embeds, where they answered bare address ids, and a record missing object (or a postcard missing carrier) takes Lob’s value. design_studio’s postcards also carry raw_url, print_speed, to_be_expunged_date, qr_code, completed_at, is_creative_proof, is_dashboard, usps_campaign_id and lob_credits_funding_status, and its addresses recipient_moved, as created ones do. (RYS-1911)

  7. The guide’s Coverage and Limitations are generated from the twin. Coverage lists every route the twin serves at its /v1 path, 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 as include[]=total_count, the form Lob takes. (RYS-1918)

  8. lob-assets.com render URLs are declared Lob’s asset host. A postcard’s url, raw_url and thumbnail URLs on lob-assets.com, seeded or answered, are handed out as written and never rebased onto the twin, by declaration (pipeline_policy.json host_roles) rather than by omission, so a seed carrying Lob’s own render URLs passes RYSTIC_SEED_GATE=reject. The twin serves no render behind them. (RYS-1910)

v0.0.6 — 2026-09-30

  1. Rate-limit headers keep their lowercase spelling. ratelimit-limit, ratelimit-remaining and ratelimit-reset on address and postcard creation now reach the wire lowercase instead of as Ratelimit-Limit and friends, so a client that keys on the received header name reads the names as written. (RYS-1336)

  2. The state UI shows the account as the Lob dashboard. On Pro and Enterprise, /_rystic/ui opens 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

  1. The guide starts from an API key and says what each plan reaches. HOWTO.md now opens with rystic login rys_…, says what Free, Pro and Enterprise boot and which control-plane routes each answers, and states that DELETE /addresses/{adr_id} is the one route that checks no credential; SCENARIOS.md says what each plan boots. The rystic features example that filtered on a status no postcard carries now filters design_studio by client, the idempotent-send example mails between two of design_studio’s own addresses, and the features and commands that need Pro or Enterprise say so. check.sh finds the API key rystic login stored as well as a license file, looked up in the CLI’s order. (RYS-1204)

v0.0.4 — 2026-09-26

  1. Only design_studio ships as a named scenario. The older scenarios are now test-only fixtures and are no longer accepted by --scenario, RYSTIC_SCENARIO, or POST /_rystic/scenario. Tests and evaluations can load their unchanged register maps and clocks from sim/testdata/scenarios/ through the control plane. (RYS-1177)

v0.0.3 — 2026-09-25

  1. 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, then rystic run or docker 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)

  2. A new world, design_studio, now the default. --scenario design_studio boots 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 tagged metadata[campaign] and metadata[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 of lob_mailroom. (RYS-1171)

  3. 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 answers next_url: null, an inline to or from carries email and phone as null and a postcard read answers deleted: false, a canceled postcard reads back as the canceled piece with deleted: 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 answers 422 not_deletable. (RYS-1090)

v0.0.2 — 2026-09-24

  1. Postcard cancellation and response consistency. Canceled postcards remain retrievable with deleted: true and 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)

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

  1. 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.com with 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)

  2. A retried send is the same postcard. POST /postcards honours an Idempotency-Key header, or an idempotency_key query 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)

  3. Four named worlds, baked in. --scenario lob_mailroom boots five postcards standing at every healthy stage from rendered through delivered, with their tracking histories; lob_returned_mail the returned, failed, re-routed and canceled pieces with their failure_reason; lob_campaign_blast six sends under one campaign_id for paging and filters; lob_quiet_desk one of everything. SCENARIOS.md says what each is for. (RYS-1023)

  4. Seed the mailroom your test needs, and get the same one twice. POST /_rystic/seed lands addresses, postcards and idempotency keys directly; GET /_rystic/registers lists every field it accepts. POST /_rystic/reset rewinds the id sequence and the clock, so two identical runs give byte-identical postcards down to id, send_date and expected_delivery_date. (RYS-1023)

  5. 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)

  6. What is measured, and what is not. Fidelity is scored differentially against real api.lob.com with 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 in HOWTO.md under Limitations: next_url is a link on the last page where Lob sends null; an inline to or from address omits email and phone, and a postcard read answers deleted: 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

  1. A world can carry its own timeline. A --seed file, or a whole one posted to POST /_rystic/seed, may add credentials, binding presented credentials to actors, and events: 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-level credentials or events key the twin used to ignore now takes effect. RYSTIC_PACE=<speed> plays the clock, and the timeline, in real time, and an event’s at may be +<duration>. /_rystic/inject, --inject and RYSTIC_INJECT now need state_set instead of scenarios. (RYS-2422)

  2. A timeline call can hand what it minted to later events. A call event may add bind, {"<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)

  3. design_studio carries a timeline. As the clock moves past 2026-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 +1h now sees these changes, and Lena takes the first user id the twin mints. (RYS-2459)

  4. A clock jump fires each timeline event at its own time. When one POST /_rystic/clock or paced tick passes several events, each fires with the clock at its at, 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)

  5. 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)

  6. A timeline call can carry raw bytes, XML or a JSON array. A call event may send body_text or body_base64 instead of body; 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 in body_text. A body may 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

  1. A group lookup’s error id comes from its own range. An error from GET /api/v1/groups/{groupId} carries an errorId such as oae6f3c83c1-0474-49d0-8000-000000000001 rather than oae00000001-0000-4000-8001-000000000001. Ids still count up in their last digits and restart on reset; a test that pinned an old errorId needs the new one. (RYS-1593)

  2. The test fixtures seed transitioningToStatus: null. The four okta_* fixtures under sim/testdata/scenarios/ seeded an empty string for a user with no transition in progress, which PUT /api/v1/users/{id} and GET /api/v1/groups/{groupId}/users echoed back. They now seed null, as the twin’s own lifecycle operations write it. (RYS-1600)

  3. Offboarding a user removes their app access, as Okta does. Deactivating a user, by POST /api/v1/users/{id}/lifecycle/deactivate or a first DELETE, and purging one remove their app assignments, direct and group-derived: GET /api/v1/apps/{appId}/users/{userId} answers 404 E0000007 and 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 403 E0000038, 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)

  4. 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 404 E0000007, 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)

  5. 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 later GET carry the replaced priority, profile or profile attribute, and an empty array changes nothing. Before, the twin answered 200 and left the assignment as it was. (RYS-1850)

  6. A user write must carry the profile properties Okta requires. POST /api/v1/users and PUT /api/v1/users/{id} refuse a profile that omits, nulls or blanks login, firstName, lastName or email with 400 E0000001 and 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 no login. POST /api/v1/users/{id} may still leave them out, but refuses nulling or blanking one. Before, the twin created a user without firstName, lastName or email, replaced one without firstName or lastName, and let a partial update null either name. (RYS-1850)

  7. Every link in design_studio leads 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 four okta_* test fixtures under sim/testdata/scenarios/ moved the same way. (RYS-1851)

  8. Listed users no longer carry realmId. GET /api/v1/users answers each user without it, as Okta does and as GET /api/v1/users/{id} already did. Before, a user given a realmId by 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 no realmId. (RYS-1850)

  9. The scripts in this archive are MIT-licensed. check.sh and 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.txt no longer covers them; it still governs the twin itself.

v0.0.1 — 2026-10-01

  1. A local Okta Management API. okta-twin serves 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-empty Authorization header and never calls Okta, so no activation email is sent and no real directory changes. (RYS-1384)

  2. Okta’s user lifecycle, two-step delete included. Users move through STAGED, ACTIVE, SUSPENDED and DEPROVISIONED by the lifecycle routes. A first DELETE deactivates a user and a second purges it; a plain GET /api/v1/users leaves DEPROVISIONED users out while search and filter include them; and a profile update on a DEPROVISIONED user answers 403 E0000038, as an org with deactivated-profile edits turned off does. (RYS-1065)

  3. 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:totp factor 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)

  4. One shipped world, design_studio. Website Design Contractors’ Okta org: 36 users (33 ACTIVE, three DEPROVISIONED ex-staff) in ten groups, Everyone included, each profile carrying title, department and manager. Free boots it and POST /_rystic/reset returns to it; Pro and Enterprise boot an empty org unless --scenario design_studio names it. (RYS-1177)

  5. 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.md states what the suite leaves out and the differences this release ships with. (RYS-1384)

Linear

v0.0.5 — 2026-10-12

  1. A world can carry its own timeline. A --seed file, or a whole one posted to POST /_rystic/seed, may add credentials, binding presented credentials to actors, and events: 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-level credentials or events key the twin used to ignore now takes effect. RYSTIC_PACE=<speed> plays the clock, and the timeline, in real time, and an event’s at may be +<duration>. /_rystic/inject, --inject and RYSTIC_INJECT now need state_set instead of scenarios. (RYS-2422)

  2. A timeline call can hand what it minted to later events. A call event may add bind, {"<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)

  3. linear_sprint_board plays 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 through POST /graphql under an API key the world binds to that person; advance the clock to watch the sprint move. (RYS-2459)

  4. A clock jump fires each timeline event at its own time. When one POST /_rystic/clock or paced tick passes several events, each fires with the clock at its at, 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)

  5. 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)

  6. A timeline call can carry raw bytes, XML or a JSON array. A call event may send body_text or body_base64 instead of body; 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 in body_text. A body may 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

  1. The scripts in this archive are MIT-licensed. check.sh and 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.txt no longer covers them; it still governs the twin itself.

  2. The docs say what an empty workspace’s viewer answers. Booted without --scenario, the twin answers viewer with its placeholder user until you seed a person; the plans section said it was refused. (RYS-2401)

v0.0.3 — 2026-10-11

  1. 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/network and /_rystic/ws-drop answer 403 with "feature":"faults" and stay Pro and Enterprise. (RYS-2400)

v0.0.2 — 2026-10-11

  1. 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-000000000001 and the first comment 925e28d1-dad3-4fd4-8000-000000000001, where both were 00000001-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)

  2. Introspection answers each argument’s type. __type’s fields { args } now carry type as the specification nests it (kind, name, ofType), so a client that reads argument signatures, such as args { name type { kind name ofType { kind name } } }, gets them instead of a validation error. (RYS-1604)

  3. Nested lists answer the records that name them. An issue’s parent, children, cycle, project, labels, relations and inverseRelations, a cycle’s, label’s and project’s issues, a project’s teams, a team’s projects, a user’s assignedIssues, and the organization’s teams and users now list what the workspace holds, where they answered empty or null. An issue filed with a parentId or labelIds, or updated to one, shows up under its parent’s children and each label’s issues, and a new issue’s creator reads the caller. issueCreate takes the parent by UUID or by identifier such as ENG-102; issueUpdate still drops an identifier, so name the parent by its UUID there. (RYS-1909)

  4. viewer answers in a world with no scenario, and a seed names its caller under member. A twin started without a scenario resolves its caller, a placeholder user with an empty name, so viewer answers a user instead of an error. A world or a seed names the person viewer answers by keying that state_user record member, its id its own; every shipped world does. A seed that keys none gets the placeholder, where viewer used to answer the user marked isMe. (RYS-1911)

  5. The guide lists what the twin serves and what it does not, from the model. HOWTO.md and 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 person viewer answers: a state_user keyed member. (RYS-1918)

  6. A JSON field or a small payload object the twin does not model no longer fails the response. reactionData, Team.securitySettings and searchIssues’ archivePayload answer an empty value where they answered Cannot return null for non-nullable field …, so @linear/sdk’s issues(), issue(id), createIssue, updateIssue, createComment and searchIssues return data. teams(), projects() and createProject still fail on Team.visibility and Project.status. (RYS-1873)

  7. 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! where String! is expected) 400 GRAPHQL_VALIDATION_FAILED, a document with several operations and no operationName 400 INPUT_ERROR, a subscription 400 with “Cannot query field … on type “Subscription"", and a JSON-array batch body 400 BAD_REQUEST “Operation batching disabled.”. Before, each one executed or answered a different error. (RYS-1881)

  8. attachment(id:) answers Linear’s not-found error. Asking for an attachment that does not exist now answers INPUT_ERROR with statusCode 400, the message Entity not found: Attachment: could not find by url. and Could not find referenced attachment with this url., as Linear does, instead of an INVALID_INPUT “Argument Validation Error”. (RYS-1878)

  9. A request without Authorization is refused before it runs. A GraphQL request with no Authorization header now answers 401 with Linear’s AUTHENTICATION_ERROR error before any field resolves, so a refused mutation writes nothing; it used to run as the world’s caller. An empty Authorization header 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 a Bearer token, is still accepted, so a test that sends no header now needs a placeholder key. (RYS-1920)

  10. Introspection answers each field’s type and an input object’s fields. __type’s fields now carry type (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

  1. 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 at http://localhost:8080/graphql (apiUrl in @linear/sdk) and your existing queries and mutations run against it unchanged. (RYS-1619)

  2. Five named workspaces, and the one your test needs. --scenario linear_sprint_board boots 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_inbox holds five untriaged support issues, linear_roadmap_quarter three projects with milestones and status updates, linear_solo_workspace one person and one team, and linear_archive_vault archived and trashed records; SCENARIOS.md says what each is for. On Pro and Enterprise, POST /_rystic/seed lands teams, people, workflow states, cycles and any issue, comment, label, project or document directly. POST /_rystic/reset rewinds the id sequence and the clock, so two identical runs give byte-identical issues down to id, identifier, number and createdAt. (RYS-1619)

  3. It never reaches the network, and it needs no key. Any Authorization value is accepted, as a bare API key or a Bearer token, 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 answers GRAPHQL_VALIDATION_FAILED rather than empty data. (RYS-1619)

  4. 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.md under Limitations: some non-null fields (Issue.priorityLabel, Issue.branchName, Project.state) fail the whole response; issue.labels and issue.attachments answer empty; a new issue without a stateId has no state; a new issue in a world is numbered from 1; issues ignores filter; and __schema introspection is refused. Each release states its own number. (RYS-1619)

Stripe

v0.0.11 — 2026-10-12

  1. 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)

  2. 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 and subscription_data[metadata], completes the session and redirects to success_url, sending customer.created, customer.subscription.created and checkout.session.completed to your registered endpoints in that order. Payment and setup sessions still answer 501. (RYS-2545)

  3. A subscription ends at its cancel_at. Once the twin’s clock reaches a subscription’s cancel_at (set through the API or the customer portal), the subscription reads canceled with ended_at set and customer.subscription.deleted goes to your registered endpoints, so a POST /_rystic/clock advance past it plays out the cancellation. (RYS-2545)

v0.0.10 — 2026-10-12

  1. The scripts in this archive are MIT-licensed. check.sh and 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.txt no longer covers them; it still governs the twin itself.

  2. Webhook endpoints can receive their events. Boot with RYSTIC_WEBHOOK_DELIVERY=on and every event your requests trigger is POSTed to each enabled endpoint registered through POST /v1/webhook_endpoints whose enabled_events names it or *, signed with that endpoint’s secret in Stripe-Signature at wall-clock time so stripe.webhooks.constructEvent accepts it however the twin’s clock is pinned; without the variable nothing is POSTed. (RYS-2421, RYS-1463)

  3. A world can carry its own timeline. A --seed file, or a whole one posted to POST /_rystic/seed, may add credentials, binding presented credentials to actors, and events: 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-level credentials or events key the twin used to ignore now takes effect. RYSTIC_PACE=<speed> plays the clock, and the timeline, in real time, and an event’s at may be +<duration>. /_rystic/inject, --inject and RYSTIC_INJECT now need state_set instead of scenarios. (RYS-2422)

  4. A timeline call can hand what it minted to later events. A call event may add bind, {"<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)

  5. A Checkout Session’s url opens the twin’s hosted Checkout page. url now leads back to the twin instead of checkout.stripe.com, and the page shows the session’s line items, total and email with a link back to cancel_url. Submitting it is not simulated yet: it answers 501 and the session stays open. (RYS-1463)

  6. GET /v1/events lists 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, so GET /v1/events and GET /v1/events/{id} return it beside any seeded events. (RYS-2460)

  7. A world can open a dispute. POST /_dispute opens a dispute on a captured charge, the way a cardholder’s bank does: the charge reads disputed, a refund is refused with charge_disputed, and charge.dispute.created reaches the egress log and every endpoint registered for it. A world’s timeline schedules it like any other call. (RYS-2461)

  8. 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, .updated and .deleted. A portal session’s url leads back to the twin, which serves no portal page yet. (RYS-1463)

  9. A clock jump fires each timeline event at its own time. When one POST /_rystic/clock or paced tick passes several events, each fires with the clock at its at, 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)

  10. Webhook signatures can follow a shifted receiver clock. POST /_rystic/clock {"wall_offset_seconds": N} signs every webhook delivery’s Stripe-Signature t= N seconds past the wall clock, so a receiver whose own clock you moved forward (as sandbox clock -advance does) still accepts it; 0 or /_rystic/reset puts it back. (RYS-2538)

  11. A timeline call can carry raw bytes, XML or a JSON array. A call event may send body_text or body_base64 instead of body; 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 in body_text. A body may 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)

  12. A customer portal session’s url opens the twin’s customer portal. url leads 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_at set, cancel_at_period_end left false, as Stripe’s portal does) and taking a cancellation back each update the subscription and send customer.subscription.updated to your registered webhook endpoints. (RYS-2544)

v0.0.9 — 2026-10-11

  1. check.sh fails fast on a port that never answers. Its port check and boot wait give each /_rystic/version probe two seconds, so an address where something accepts connections but never replies fails the check with a message instead of hanging. (RYS-1444)

  2. 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)

  3. Line items are built from the request’s line_items. GET /v1/checkout/sessions/{session}/line_items and GET /v1/payment_links/{payment_link}/line_items list one line item per line_items element of the create, in request order, each with its own li_ id, the element’s quantity, the product name as its description, a Price (minted from price_data, inactive and one-time), and amount_subtotal equal 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 (usd when 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 id li_unknown or amounts of 0 on a line built from price_data, and neither list answers a Price that is only an id. (RYS-483)

  4. check.sh gives 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

  1. Charge lists include the full charge details. GET /v1/charges now 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.

  2. Retries reuse the original response. Repeating a POST with the same Idempotency-Key and parameters returns the original response without creating another object. Reusing the key with different parameters returns 400 idempotency_error. (RYS-1241)

  3. 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)

  4. Stripe-compatible errors and response headers. Unknown URLs and trailing slashes now return Stripe’s invalid-request envelope. Supported routes include Request-Id and Stripe-Version, and echo a pinned Stripe-Version request header. (RYS-1245)

  5. 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)

  6. 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)

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

  8. 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)

  9. The state UI shows the account as the Stripe Dashboard. On Pro and Enterprise, /_rystic/ui opens 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

  1. The guide starts from an API key and says what each plan reaches. HOWTO.md now opens with rystic 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.sh finds the API key rystic login stored as well as a license file, looked up in the CLI’s order, and rystic features marks the features and commands that need Pro or Enterprise. (RYS-1204)

v0.0.6 — 2026-09-25

  1. A new world, design_studio, now the default. --scenario design_studio boots 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 of customer_with_card. (RYS-1171)

  2. Only design_studio ships as a named scenario. The older scenarios are now test-only fixtures and are no longer accepted by --scenario, RYSTIC_SCENARIO, or POST /_rystic/scenario. Tests and evaluations can load their unchanged register maps and clocks from sim/testdata/scenarios/ through the control plane. (RYS-1177)

  3. 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, then rystic run or docker 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

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

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

  1. A fourteenth named world, busy_account_xxl. busy_account_xl proves 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 completed mode=subscription Checkout 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, or curl -X POST localhost:8080/_rystic/scenario -d '{"name":"busy_account_xxl"}'.

  2. busy_account_xl is 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=100 call per collection and never touch has_more or starting_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 answers has_more: true at limit=100 and 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.

  3. A charged-back charge can no longer be refunded. POST /v1/refunds against a charge whose disputed is true minted a refund and returned 200, where Stripe answers 400 charge_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 a payment_intent-targeted one alike, and in the same order live does: an unknown parameter or a non-positive amount is still rejected first, and the refusal precedes the amount-exceeds-remaining check. busy_account’s ch_busy_disputed is the charge to try it on.

v0.0.2 — 2026-09-12

  1. Separate authorization and capture works end to end. POST /v1/payment_intents/{intent}/capture is routed, and confirming an intent created with capture_method=manual now holds the authorization at requires_capture instead of settling it. Partial captures release the remainder, over-captures are refused with amount_too_large, and the charge goes from uncaptured to captured in place — the flow a capture-later integration actually tests.

  2. confirm=true on create does what the docs show. Creating a PaymentIntent with a payment method and confirm=true — the one-call form the Stripe docs use for off-session charges — now succeeds and mints the charge, instead of answering requires_payment_method as though the card had been declined. Creating with a payment method and no confirm correctly rests at requires_confirmation.

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

  4. disabled=true disables a webhook endpoint. POST /v1/webhook_endpoints/{id} with disabled=true flips status to disabled; it was accepted and ignored.

  5. A thirteenth named world, busy_account_xl. busy_account is 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_xl is the same account at 201 customers: the whole busy_account cast 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 answers has_more: true on 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, or curl -X POST localhost:8080/_rystic/scenario -d '{"name":"busy_account_xl"}'.

v0.0.1 — 2026-09-11

The first release of the Stripe twin.

  1. 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_base at 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.

  2. It never reaches the network, and it needs no key. Any Authorization header is accepted, and the twin dials nothing — which is the point: a suite that hits api.stripe.com fails when Stripe is slow, and a suite on a shared test-mode account fails when a colleague clears it.

  3. Seed the world your test needs. POST /_rystic/seed takes 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/registers lists what this twin accepts a seed for; a register the model does not carry is refused with a status rather than silently dropped.

  4. The same seed twice is the same world twice. POST /_rystic/reset empties the twin and POST /_rystic/seed-rng pins the generator, so ids repeat run to run; POST /_rystic/clock fixes the twin’s notion of now, so created on every record is a value your test chose. Two identical seed flows leave byte-identical /_rystic/inspect dumps — which is what makes a failing test reproducible rather than merely re-runnable.

  5. 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 running stripe listen.

  6. See what the twin holds. /_rystic/ui serves 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

  1. A clock jump fires each timeline event at its own time. When one POST /_rystic/clock or paced tick passes several events, each fires with the clock at its at, 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)

  2. 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 (as sandbox clock -advance does) still accepts the svix-timestamp; 0 or /_rystic/reset puts it back. (RYS-2538)

  3. A timeline call can carry raw bytes, XML or a JSON array. A call event may send body_text or body_base64 instead of body; 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 in body_text. A body may 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

  1. Registered webhooks can receive their events. Boot with RYSTIC_WEBHOOK_DELIVERY=on and each webhook event a send triggers is POSTed to every enabled webhook registered through POST /webhooks whose events names it, signed with its signing_secret in Svix headers at wall-clock time so svix’s Webhook.verify accepts it however the simulator clock is pinned; without the variable nothing is POSTed. (RYS-2421, RYS-1463)

  2. A world can carry its own timeline. A --seed file, or a whole one posted to POST /_rystic/seed, may add credentials, binding presented credentials to actors, and events: 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-level credentials or events key the twin used to ignore now takes effect. RYSTIC_PACE=<speed> plays the clock, and the timeline, in real time, and an event’s at may be +<duration>. /_rystic/inject, --inject and RYSTIC_INJECT now need state_set instead of scenarios. (RYS-2422)

  3. Sandbox recipients bounce and complain. An immediate send whose first to recipient is bounced@resend.dev or complained@resend.dev, with or without a +label, now emits email.bounced, or email.delivered and email.complained, after email.sent, and the email reads last_event bounced or complained; under RYSTIC_WEBHOOK_DELIVERY=on each is delivered Svix-signed to the webhooks subscribed to it. A bounced email’s email.bounced and GET /emails/{email_id} carry the bounce object live Resend sends. (RYS-2419)

  4. A timeline call can hand what it minted to later events. A call event may add bind, {"<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)

  5. design_studio carries a timeline. As the clock moves past 2026-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)

  6. A world can bounce an email. POST /_bounce bounces an email that went out to any address: it reads bounced with its bounce, and email.bounced reaches 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

  1. 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 both 00000001-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)

  2. Every route refuses a missing or malformed key the way Resend does. All ten routes check Authorization before anything else: no header answers 401 missing_api_key with “Missing API Key”, and an empty Bearer key or another scheme such as Basic abc answers 400 validation_error with “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} and DELETE /webhooks/{webhook_id} used to answer without a key, so a test that calls them without one now needs a placeholder key. (RYS-1285)

  3. A scheduled send no longer shows an SMTP handoff. POST /emails with scheduled_at writes only its email.scheduled event to GET /_rystic/egress, since Resend hands the mail to SMTP only at the scheduled time; an immediate send still writes the handoff before email.sent. (RYS-1378)

  4. A send or update with a form Content-Type is refused as Resend refuses it. POST /emails and PATCH /emails/{id} with a JSON body labelled application/x-www-form-urlencoded answer 422 with Resend’s message (Missing `to` field. and The `scheduled_at` field is required.), where the simulator answered 400 to a send and applied the update. Label JSON bodies application/json. (RYS-1190)

  5. A scheduled email’s scheduled_at reads back as Resend renders it. GET /emails/{id} and GET /emails return scheduled_at in the same form as created_at, such as 2026-01-03 00:00:00.439000+00, where the simulator echoed an ISO 8601 string such as 2026-01-03T00:00:00.439Z. A test that compares the string should compare the instant. (RYS-1190)

  6. The scripts in this archive are MIT-licensed. check.sh and 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.txt no longer covers them; it still governs the twin itself.

v0.0.5 — 2026-09-30

  1. Scheduled email reads preserve null values. Text-only emails return html: null, and newly scheduled emails return message_id: null until their state indicates a completed send. Explicit HTML, including an empty string, and existing message identifiers are preserved. (RYS-1293)

  2. 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_clock as the scenario clock. The dated fidelity result and absence of a standing daily converge round remain explicit. (RYS-1283)

  3. Sent-email events include ISO timestamps, message IDs and tags. The email.sent events in GET /_rystic/egress use UTC ISO 8601 timestamps and include data.message_id and supplied tags as a name/value map, so tests can inspect these fields in the outbound event log. (RYS-1288)

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

  5. Rate-limit headers arrive spelled as Resend spells them. ratelimit-limit, ratelimit-remaining, ratelimit-reset, ratelimit-policy and retry-after now reach the wire lowercase instead of as Ratelimit-Limit and friends, so a client that keys on the received header name sees the same keys it sees from Resend. (RYS-1336)

  6. The state UI shows the outbox as Resend’s dashboard. On Pro and Enterprise, /_rystic/ui opens 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

  1. The guide starts from an API key, says what each plan reaches, and documents the outbound mail log. HOWTO.md now opens with rystic login rys_…, says what Free, Pro and Enterprise boot and which control-plane routes each answers, and names the five routes that answer 401 missing_api_key without a bearer key and the five that check none; SCENARIOS.md says what each plan boots. Each POST /emails writes the SMTP message and its email.sent or email.scheduled event to GET /_rystic/egress, and the guide now says so. check.sh finds the API key rystic login stored as well as a license file, looked up in the CLI’s order, and rystic features marks the features and commands that need Pro or Enterprise. (RYS-1204)

v0.0.3 — 2026-09-26

  1. 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)

  2. Only design_studio ships as a named scenario. The older scenarios are now test-only fixtures and are no longer accepted by --scenario, RYSTIC_SCENARIO, or POST /_rystic/scenario. Tests and evaluations can load their unchanged register maps and clocks from sim/testdata/scenarios/ through the control plane. (RYS-1177)

v0.0.2 — 2026-09-25

  1. 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, then rystic run or docker 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)

  2. A new world, design_studio, now the default. --scenario design_studio boots 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 of busy_outbox. (RYS-1171)

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

  1. 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)

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

  1. clerk_acme_tenant plays a timeline. Its backend, bound to sk_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 the scale plan on day three and removes Iris on day five, all through the Backend API; advance the clock to watch the tenant change. (RYS-2459)

  2. A seeded user, organization or membership sends its created webhook. A record a world’s timeline seed event or a bare POST /_rystic/seed creates now sends user.created, organization.created or organizationMembership.created, as the create operation does, to the egress log and any configured webhook endpoint. A world’s starting snapshot sends none. (RYS-2508)

  3. Session tokens and webhooks can follow a shifted app clock. POST /_rystic/clock {"wall_offset_seconds": N} stamps session JWT iat, nbf and exp and the webhook svix-timestamp N seconds past the wall clock, so an app whose own clock you moved forward (as sandbox clock -advance does) still accepts them; 0 or /_rystic/reset puts it back. (RYS-2538)

  4. Every Backend API call without a secret key is refused as Clerk refuses it. A request with no Authorization header now answers 401 with errors[0].code = authorization_header_format_invalid on every operation, ahead of the operation’s own checks. DELETE /v1/organizations/{id} and PATCH /v1/organizations/{id}/memberships/{user_id} used to succeed without a key, and other operations answered with differing authentication_invalid bodies. (RYS-2532)

v0.0.1 — 2026-10-11

  1. 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 lookalike clerk.js so a @clerk/nextjs app signs users in against the same state. It runs on Pro and Enterprise. Point @clerk/backend at it with any sk_ key (HOWTO.md shows how); no email is sent and no real instance changes. (RYS-1315, RYS-1234, RYS-2412)

  2. Users and their metadata, as Clerk keeps them. POST /v1/users stores 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 422 form_identifier_exists; a malformed one answers 422 form_param_format_invalid. GET /v1/users refuses an order_by that names no sortable field with 422 form_param_value_invalid. PATCH and PUT /v1/users/{user_id}/metadata merge and replace metadata, and DELETE /v1/users/{user_id} removes the user’s organization memberships. Every image_url and profile_image_url leads to a PNG the twin serves under /preview/. (RYS-1858, RYS-1899, RYS-1900)

  3. Organizations, their metadata and their members. An organization created with created_by lists its creator as an org:admin member right after the create, and include_members_count=true counts them. PATCH /v1/organizations/{organization_id}/metadata deep-merges public_metadata and private_metadata, removing a key set to null, and PUT replaces each field it names whole. Writes and lists answer with the keys Clerk’s answers carry. (RYS-1895, RYS-1899, RYS-1909)

  4. Clerk-API-Version is checked on every route and echoed. 2021-02-05, 2024-10-01, 2025-04-10, 2025-11-10 and 2026-05-12 (the default) are accepted and echoed in clerk-api-version, and any other value answers 400 api_version_invalid. At 2026-05-12, metadata sent to PATCH /v1/users/{user_id} or PATCH /v1/organizations/{organization_id} answers 422 form_param_deprecated, as Clerk does: write it through the /metadata routes. (RYS-1914)

  5. A browser sign-in for @clerk/nextjs. Point NEXT_PUBLIC_CLERK_JS_URL and NEXT_PUBLIC_CLERK_UI_URL at the twin’s /clerk.js and /clerk-ui.js and 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/config gives the publishable key, the public PEM and the proxy URL. (RYS-1234, RYS-1475, RYS-2413, RYS-2414)

  6. 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.* or organizationMembership.* in GET /_rystic/egress; the organization /metadata routes record none. Boot with CLERK_TWIN_WEBHOOK_URL and a whsec_ CLERK_TWIN_WEBHOOK_SECRET and each is also delivered, its svix-timestamp on 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)

  7. Five shipped worlds. --scenario clerk_acme_tenant boots Acme Robotics: five users in one organization, two pending invitations and three live sessions. clerk_holdco_orgs holds one identity across three organizations, clerk_invite_backlog an organization at its ten-seat cap with eight invitations pending, clerk_session_sweep six sessions from lapsed to live, and clerk_solo_founder one user and her organization; SCENARIOS.md says what each is for. Without --scenario the twin boots an empty instance, POST /_rystic/seed lands users, organizations, memberships, sessions and invitations directly, and POST /_rystic/reset rewinds the id sequence, so two identical runs from a pinned clock give byte-identical users and organizations. (RYS-1315, RYS-2412)

  8. 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.md states 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 no x-clerk-trace-id header (RYS-1912), an unserved route answers a plain 404, not Clerk’s resource_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

  1. A world can carry its own timeline. A --seed file, or a whole one posted to POST /_rystic/seed, may add credentials, binding presented credentials to actors, and events: 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-level credentials or events key the twin used to ignore now takes effect. RYSTIC_PACE=<speed> plays the clock, and the timeline, in real time, and an event’s at may be +<duration>. /_rystic/inject, --inject and RYSTIC_INJECT now need state_set instead of scenarios. (RYS-2422)

  2. A timeline call can hand what it minted to later events. A call event may add bind, {"<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)

  3. notion_task_tracker plays 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)

  4. A clock jump fires each timeline event at its own time. When one POST /_rystic/clock or paced tick passes several events, each fires with the clock at its at, 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)

  5. 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)

  6. A timeline call can carry raw bytes, XML or a JSON array. A call event may send body_text or body_base64 instead of body; 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 in body_text. A body may 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

  1. The scripts in this archive are MIT-licensed. check.sh and 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.txt no longer covers them; it still governs the twin itself.

  2. A file upload’s filename may be up to 900 characters. POST /v1/file_uploads counts 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)

  3. Requests Notion refuses are refused, in Notion’s order. The twin answers 400 validation_error where Notion does, before looking anything up: a malformed id in the path of GET /v1/blocks/{id}/children, GET /v1/data_sources/{id}/templates and PATCH /v1/pages/{id}/markdown; a non-numeric page_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 a template_id template with no id, on POST /v1/pages; an executable file type, or a filename over the limit, on POST /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, carries additional_data.integration_id and request_id. (RYS-2099)

v0.0.6 — 2026-10-11

  1. A seeded app.notion.com page URL passes the seed gate. app.notion.com is declared Notion’s web host (pipeline_policy.json host_roles): the twin hands such a URL out as written, never rebased, and a seed carrying one lands under RYSTIC_SEED_GATE=reject where it was refused as an undeclared host of the service. Page, database and data-source url values the twin builds still sit on www.notion.so, which Notion no longer uses; that is RYS-2386. (RYS-1910)

v0.0.5 — 2026-10-10

  1. A sent file upload drops its upload URL, and completing a single-part upload is refused. After a single_part send leaves an upload uploaded, the send’s answer and every read of the upload leave out upload_url and complete_url, as Notion’s do. Completing a single_part upload answers 400 validation_error, “One or more parts must be uploaded before completing the file upload”, where the twin answered 200. (RYS-2099)

  2. Three more worlds for the Tier 2 surface. notion_page_tree boots a page tree with page and data source parents to move pages between. notion_template_board boots a Tickets data source with five templates, one of them the default, beside a data source with none. notion_attachments boots a page with image and file blocks and an asset library whose files property holds files, ready for an upload to attach. (RYS-2102)

  3. A moved page leaves its old parent and joins the new one. After POST /v1/pages/{page_id}/move, the old parent’s GET /v1/blocks/{id}/children no longer lists the page, and its has_children updates. The new parent page lists the page as its child_page block. 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)

  4. Templates refuse what Notion refuses and apply their content. POST /v1/pages with a template beside children, content or markdown, or with {"type": "default"} under a data source that has no default template, now answers 400 validation_error with Notion’s message. A page created from a template, or given a template by PATCH /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)

  5. 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 until expiry_time, one hour after each read. Attaching an upload clears its own expiry_time. GET /v1/file_uploads lists only the calling connection’s uploads, newest first. An upload never attached reads expired once its hour passes on the twin’s clock. (RYS-2351, RYS-2356, RYS-2360)

  6. External imports and async tasks complete. An external_url file upload answers pending, as Notion’s does, and reads uploaded with a successful file_import_result on its first GET /v1/file_uploads/{id}. Its file is the twin’s placeholder; the twin never fetches the URL. A POST /v1/pages or PATCH /v1/pages/{page_id}/markdown sent with allow_async: true answers a queued task with poll_after_seconds: 2. Its first GET /v1/async_tasks/{task_id} reads succeeded with the result, where it used to answer 404. (RYS-2352, RYS-2347)

  7. An import’s time reads as Notion writes it. A completed external_url file upload’s file_import_result.imported_time carries a +00:00 offset, as Notion’s does, where it used to end in Z. A client that compares or parses the stamp as text now sees Notion’s form. (RYS-2370)

  8. A seeded comment reads back without attachments. A comment a shipped world seeds, such as notion_review_thread’s, now answers GET /v1/comments and GET /v1/comments/{comment_id} with no attachments key, as Notion’s comments and the ones POST /v1/comments creates do. It used to carry attachments: []. (RYS-2228)

  9. A seeded comment’s user mention carries the user. The mention in notion_review_thread’s reply now reads back with the mentioned user’s name, avatar_url and type, as Notion answers a user mention in a comment, beside plain_text @Review Bot. It used to carry only the user’s object and id. (RYS-2286)

  10. A shipped world’s pages carry Notion’s url. Every page a scenario seeds now reads back with a url on https://app.notion.com/p/, its title’s words and its bare id, as Notion writes it, and a seeded page mention’s href is the page’s bare https://app.notion.com/p/ url. They used to point at www.notion.so. (RYS-2294)

  11. A user mentioned in a comment reads back as the user. A rich_text user mention written through POST /v1/comments or PATCH /v1/comments/{comment_id} answers and reads back with the mentioned user’s object, name, avatar_url and type (bot: {} or person), and plain_text @<name>, as Notion does. It used to echo the request’s {id} with empty plain_text. (RYS-2286)

  12. A comment’s markdown turns a mention-user tag into a mention. <mention-user url="<user id>">…</mention-user> in a comment’s markdown, its url a bare id or user://<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)

  13. Every comment 404 names the integration. POST /v1/comments with an unknown discussion_id, parent.page_id or parent.block_id, and GET /v1/comments with an unknown block_id, answer 404 object_not_found with additional_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)

  14. Page markdown refuses a database or block id as Notion does. PATCH /v1/pages/{id}/markdown through a database’s id answers 400 validation_error “Provided ID is 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_code invalid_input. GET through 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)

  15. A seeded child database renders like a created one in page markdown. GET /v1/pages/{page_id}/markdown renders every child database with inline and data-source-url read off the database, so notion_team_wiki’s Meeting Notes reads inline="true" data-source-url="collection://…" as one POST /v1/databases creates does. (RYS-2292)

  16. 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’s icon, a code block’s caption (and no color), a markdown numbered item’s list_format and a markdown file block’s name, as Notion’s do. (RYS-2293)

  17. A created page’s url is on app.notion.com. A page POST /v1/pages creates, or a markdown <page> tag adds, has its url on https://app.notion.com/p/, its title’s words then its bare id, as Notion’s are. It used to point at www.notion.so. (RYS-2294)

  18. Moving a page under itself is refused. POST /v1/pages/{page_id}/move with the page itself as parent.page_id answers 400 validation_error, “Parent ID must be different from the child ID: ”, with additional_data.tool_error_code source_is_destination, as Notion does. It used to answer 200. (RYS-2345)

  19. 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 a template_id now reads back that template’s blocks as its children, where it used to read back none. (RYS-2385)

  20. 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’s pages.move, dataSources.listTemplates, asyncTasks.retrieve and fileUploads methods run against it. (RYS-2103)

  21. A multi_part upload’s parts and /complete are 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 a multi_part upload is measured but sending its parts and POST /v1/file_uploads/{file_upload_id}/complete are built from Notion’s published spec alone. (RYS-2103)

v0.0.4 — 2026-10-10

  1. User and property-item reads carry request_id. GET /v1/users/{user_id} and the single-value answer of GET /v1/pages/{page_id}/properties/{property_id} now include Notion’s top-level request_id, as every other answer does. (RYS-2289)

  2. Every property a data source is created or updated with gets its own id. POST /v1/data_sources, POST /v1/databases and PATCH /v1/data_sources/{data_source_id} mint a distinct 4-character id for each new property other than the title (whose id stays title), and for the synced property a dual_property relation adds to its target, so GET /v1/pages/{page_id}/properties/{property_id}, a properties update, filter_properties and a query’s filter or sort by id reach the property the id names. Before, POST /v1/data_sources gave 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

  1. A markdown update_content keeps every block’s id. PATCH /v1/pages/{page_id}/markdown with update_content edits 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)

  2. A markdown replace_content_range keeps every block’s id. PATCH /v1/pages/{page_id}/markdown with replace_content_range rewrites 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)

  3. A markdown edit that would delete a child page or database is refused in Notion’s words. PATCH /v1/pages/{page_id}/markdown without allow_deleting_content answers 400 validation_error with 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’s additional_data (tool_error_code invalid_input). Before, the refusal carried a generic message and no additional_data. (RYS-2270)

  4. A markdown <page> tag with no url creates a subpage. <page>Title</page> in insert_content, in an update_content, replace_content_range or replace_content edit, or in POST /v1/pages markdown creates a child page with that title where the tag stands: the page lists its child_page block, and GET /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)

  5. A connection updates or deletes only a comment it created. PATCH and DELETE /v1/comments/{comment_id} on a comment another user or another connection created answer 404 object_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)

  6. A property item list with more pages links to the next one. GET /v1/pages/{page_id}/properties/{property_id} on a title, rich_text, people or relation property with more values than page_size now carries property_item.next_url: the same request with start_cursor set to the answer’s next_cursor, on the twin’s own host. The last page’s next_url stays null. Before, next_url was null on every page. (RYS-2281)

  7. A two-way relation is kept in sync on both sides. Writing a dual_property relation on a row, through POST /v1/pages or PATCH /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’s relation filter all agree with the write. Creating such a relation, through POST /v1/data_sources or PATCH /v1/data_sources/{data_source_id} with dual_property: {}, adds the matching relation property to the target data source and answers its synced_property_id and synced_property_name, as Notion does. (RYS-2282, RYS-2283)

  8. A markdown edit with allow_deleting_content moves each child page or database it drops to the trash. After PATCH /v1/pages/{page_id}/markdown with allow_deleting_content: true drops a child page’s <page url> tag or a child database’s <database url> tag, through replace_content or update_content, GET /v1/pages/{id} and GET /v1/databases/{id} answer the child with in_trash: true under its parent, and POST /v1/search no longer lists it as live. Before, the child stayed live and its block was gone. (RYS-2291)

v0.0.2 — 2026-10-09

  1. An empty workspace answers as the twin’s own bot. On Pro and Enterprise, a workspace booted without --scenario or cleared by POST /_rystic/reset still has a bot user: GET /v1/users/me answers it where it answered 401, and the pages and blocks you create name it in created_by. A world that seeds its own bot under state_user.member, as every shipped world does, replaces it. (RYS-1911)

  2. 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’s last_edited_time to the time of the move, as Notion does. The database it joins keeps its last_edited_time. (RYS-1909)

  3. A page created under a database is a row of it. POST /v1/pages with a database_id parent now lands the page in that database’s data source: its parent reads {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 400 validation_error, as Notion refuses it. (RYS-1596)

  4. 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’s GET /v1/blocks/{parent}/children answer child_page.title as the new title, as Notion does. (RYS-1980)

  5. Rich text over 2000 characters is refused, as in Notion. A rich text item whose text.content is longer than 2000 characters is refused with 400 validation_error when you append children (PATCH /v1/blocks/{block_id}/children), update a block or a page title, or set a database or data source title, as POST /v1/pages already refused it in a page title. Measured against a live Notion workspace. (RYS-1983)

  6. A page trashed through PATCH /v1/pages is trashed as a block too. After PATCH /v1/pages/{page_id} with "in_trash": true, GET /v1/blocks/{page_id} reads in_trash: true, as on Notion, where it read false before. (RYS-1981)

  7. A data source query sorts by a property and refuses an unknown one, as in Notion. POST /v1/data_sources/{data_source_id}/query now orders rows by a sorts entry 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 400 validation_error “Could not find property with name or id: …”, and a condition of another type than its property answers 400 validation_error, as Notion answers both. Before, a property sort was ignored and both answered 200. (RYS-2058)

  8. A row’s properties follow its data source’s schema. A page under a data source, created with a data_source_id or a database_id parent, 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, so title renames the Name title 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 color gray. A property name the schema does not have is refused with 400 validation_error “<name> is not a property that exists.”, as Notion refuses it, with the id spelled with or without dashes. filter_properties on retrieving or updating a page and on a data source query returns only the properties it names. (RYS-1885)

  9. A child block of an unknown type is refused, as in Notion. Appending children (PATCH /v1/blocks/{block_id}/children) or creating a page with children refuses a block whose type is not one Notion creates through the API, such as "nonsense", with 400 validation_error, and stores nothing. Before, the twin answered 200 and stored the block. (RYS-1982)

  10. Trashing a page leaves is_archived alone, as in Notion. PATCH /v1/pages/{page_id} with {"in_trash": true} now answers and reads back in_trash: true with is_archived: false; in_trash and is_archived are independent members. Before, trashing a page also set is_archived: true. (RYS-2013)

  11. A page created with an over-long rich text block is refused, as in Notion. POST /v1/pages refuses a children or content block whose rich text item is over 2000 characters with 400 validation_error “body failed validation: body.children[0].paragraph.rich_text[0].text.content.length should be ≤ 2000, instead was 2001.”, and stores nothing. Before, the page was created. (RYS-2016)

  12. 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 carries database_id beside data_source_id. Before, the status schema was empty and both were left out of the row. (RYS-2108)

  13. 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-11 was measured against a live Notion workspace. @notionhq/notion-mcp-server now reaches 22 of its 24 tools. (RYS-2097)

  14. 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: true and empty rich_text, drops it from its page’s comment list, and refuses a further edit or delete with 404 object_not_found, as Notion does; a comment’s timestamps are at minute precision. GET /v1/users lists persons before bots, each user once. (RYS-2097)

  15. Page markdown renders each block’s text. GET /v1/pages/{page_id}/markdown and the answer to PATCH /v1/pages/{page_id}/markdown render 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_content and replace_content_range matched nothing, and they wiped the text of every block outside the edit. A block written through markdown also reads back from the blocks API with plain_text and href on its rich text and color on its payload, as Notion’s blocks do. (RYS-2222)

  16. 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 with parent.block_id is listed under that block, apart from its page’s comments, instead of answering 404 object_not_found. (RYS-2223)

  17. A page created with markdown holds the blocks the markdown describes. POST /v1/pages with a markdown body 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 when properties.title is 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}/children lists those blocks and GET /v1/pages/{page_id}/markdown renders them back, where before both answered empty. Two readback flows measure this against the live workspace. (RYS-2235)

  18. Three more worlds for the Tier 1 surface. notion_review_thread boots a task page with two open comment discussions to read, reply in, edit and delete. notion_team_directory boots six people and two bots to page through with GET /v1/users. notion_linked_sources boots 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)

  19. A page answer holds at most 25 relation references and says when there are more. GET /v1/pages/{page_id}, and the page POST /v1/pages and PATCH /v1/pages/{page_id} answer, now carry a relation property’s first 25 references with has_more: true when more exist, as Notion does, so a caller learns it must page through GET /v1/pages/{page_id}/properties/{property_id}, which still returns every reference. The same answer holds at most 25 people in a people value and at most 25 inline mentions in a title or rich_text value. Before, the answer carried every reference with has_more: false. (RYS-2161)

  20. A markdown edit keeps the child pages and databases it leaves in place. PATCH /v1/pages/{page_id}/markdown with update_content, replace_content_range or replace_content keeps 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 without allow_deleting_content. Before, any such edit on a page holding a child page or database answered 400 validation_error, and with allow_deleting_content: true it deleted the untouched children. An edit that removes a child’s tag still answers 400 unless allow_deleting_content is true. (RYS-2258)

v0.0.1 — 2026-10-07

  1. 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-client or @notionhq/notion-mcp-server at it with any bearer token and that version (HOWTO.md shows all three); no page lands in a real workspace. (RYS-1947)

  2. Pages and block trees, written and read back. POST /v1/pages creates a page under a page or a data source, with children if you send them; GET and PATCH /v1/pages/{page_id} read and update it; GET, PATCH and DELETE /v1/blocks/{block_id} and GET and PATCH /v1/blocks/{block_id}/children read, edit and append nested blocks. DELETE trashes rather than deletes, as on Notion, and PATCH with "in_trash": false restores. A missing id answers Notion’s 404 object_not_found. (RYS-1597, RYS-1598, RYS-1884)

  3. Databases, data sources and search. POST /v1/databases creates a database with its first data source; GET and PATCH read and update a database or a data source’s schema; POST /v1/data_sources/{data_source_id}/query returns its rows with property and timestamp filters applied and page_size paging; POST /v1/search finds pages and data sources by title. A property sort is ignored in this release. (RYS-1595, RYS-1596, RYS-1886, RYS-1892)

  4. One API version, enforced on every route. The twin speaks Notion-Version: 2026-03-11: a request with no Notion-Version or any other version answers 400 missing_version, on every route, as Notion does. The official clients default to 2025-09-03, so set the version in the client. (RYS-1914)

  5. Five shipped workspaces. --scenario notion_team_wiki boots a wiki with a root page, three child pages, a nested block tree and a Meeting Notes database, and is the world Free boots and POST /_rystic/reset returns to. notion_task_tracker holds a Sprint Tasks database with six rows across every status, notion_blank_slate one page and one empty database, notion_trash_bin a trashed page tree beside a live restore log, and notion_locked_vault locked pages beside an unlocked one; SCENARIOS.md says what each is for. On Pro and Enterprise, POST /_rystic/seed lands pages, blocks, databases, data sources and users directly, and POST /_rystic/reset rewinds the id sequence and the clock, so two identical runs give byte-identical pages and blocks. (RYS-1947)

  6. 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.md states 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 no page_size cap and misses a page created with a database_id parent (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

  1. A world can carry its own timeline. A --seed file, or a whole one posted to POST /_rystic/seed, may add credentials, binding presented credentials to actors, and events: 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-level credentials or events key the twin used to ignore now takes effect. RYSTIC_PACE=<speed> plays the clock, and the timeline, in real time, and an event’s at may be +<duration>. /_rystic/inject, --inject and RYSTIC_INJECT now need state_set instead of scenarios. (RYS-2422)

  2. A timeline call can hand what it minted to later events. A call event may add bind, {"<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)

  3. s3_static_site plays a timeline. Over the two hours after the pinned clock, the site’s deploy bot, bound to its own AWS4-HMAC-SHA256 authorization header, snapshots index.html into releases/2026-02-01/, puts a new assets/app.js, deletes the legacy.html redirect 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)

  4. A clock jump fires each timeline event at its own time. When one POST /_rystic/clock or paced tick passes several events, each fires with the clock at its at, 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)

  5. 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)

  6. A timeline call can carry raw bytes, XML or a JSON array. A call event may send body_text or body_base64 instead of body; 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 in body_text. A body may 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

  1. DeleteObjects in quiet mode answers only the keys that failed. A <Quiet>true</Quiet> delete now answers S3’s quiet DeleteResult: no Deleted entries, only an Error per key that failed, so a fully successful quiet delete answers an empty result. (RYS-1836)

  2. GetObjectAttributes answers every attribute you ask for. x-amz-object-attributes naming Checksum, ObjectParts or StorageClass now returns them beside ETag and ObjectSize: the stored checksum and its type, a completed multipart object’s parts, and STANDARD. The ETag element is unquoted, as S3 answers it. (RYS-1837)

  3. A multipart upload keeps the checksum algorithm it was created with. CreateMultipartUpload with x-amz-checksum-algorithm (CRC32, CRC32C, SHA1, SHA256) now completes with S3’s composite checksum for it (<base64>-<part count>, type COMPOSITE) instead of the default CRC64NVME, and CompleteMultipartUpload answers x-amz-server-side-encryption. The completed object reads back through HeadObject and GetObject with that checksum and x-amz-server-side-encryption, as a single-PUT object does, and GetObjectAttributes lists its parts. (RYS-1838)

  4. ListObjectVersions lists the objects you write. In a bucket without versioning every object is listed as a Version with VersionId null and IsLatest true, so emptying a bucket through the version listing (boto3’s bucket.object_versions.delete(), Terraform’s force_destroy) now removes your objects. (RYS-1839)

  5. DeleteObject in a versioning-enabled bucket hides the object. A delete marker becomes the key’s current version and the object stays listed in ListObjectVersions as a noncurrent version, so a later GetObject or HeadObject answers 404 NoSuchKey as S3 does, where the twin used to keep serving the object. (RYS-1845)

  6. ACL grantee group URIs are declared identifiers. http://acs.amazonaws.com/groups/global/AllUsers and 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.json host_roles), so a seeded ACL naming them passes RYSTIC_SEED_GATE=reject and a client comparing a grantee URI against the AWS constant matches. (RYS-1910)

  7. The scripts in this archive are MIT-licensed. check.sh and 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.txt no longer covers them; it still governs the twin itself.

v0.0.2 — 2026-10-07

  1. Streaming uploads in the aws-chunked coding. A body sent aws-chunked (Content-Encoding: aws-chunked or x-amz-content-sha256: STREAMING-…), as SDKs stream uploads with a trailing checksum, is decoded: the object stores the payload alone, a trailing x-amz-checksum-* is verified, stored and echoed like the header form, and framing that does not parse answers 400 IncompleteBody without storing anything. (RYS-1372)

  2. Presigned URLs. A SigV4 or SigV2 presigned URL, as aws s3 presign and boto3’s generate_presigned_url make, stands in for the Authorization header. The twin checks its shape, and its expiry against the real clock, never its signature: a misshapen SigV4 URL answers 400 AuthorizationQueryParametersError, a misshapen SigV2 one 403 AccessDenied, an expired one 403 AccessDenied, and one sent beside a second credential 400 InvalidArgument, as S3 answers them. (RYS-1372)

  3. 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 Authorization header’s shape, in S3’s order: a signed request with no parseable X-Amz-Date or Date answers 403 AccessDenied (“AWS authentication requires a valid Date or x-amz-date header”), one signed more than 15 minutes from now, either way, answers 403 RequestTimeTooSkewed whatever its shape, one with a fresh date but without exactly one well-formed Credential, SignedHeaders and 64-hex Signature answers 400 AuthorizationHeaderMalformed, and one with no credential answers 403 AccessDenied. A static Authorization header, such as the placeholder v0.0.1’s HOWTO.md and check.sh sent, is refused: sign each request instead, as the AWS SDKs and CLI already do, or with curl --aws-sigv4 'aws:amz:us-east-1:s3' --user <access key>:<secret> (curl 7.75 or later; any key pair works). /_rystic/clock never makes a fresh signature stale. (RYS-1372)

  4. Virtual-hosted addressing. A request to <bucket>.s3.us-east-1.amazonaws.com or <bucket>.s3.amazonaws.com is 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 through rystic run s3-twin --intercept and rystic 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

  1. 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.md shows both), and your code runs against it unchanged; nothing it stores reaches AWS. (RYS-1548)

  2. Objects under any key, read back as S3 answers them. PutObject, GetObject, HeadObject and DeleteObject on /{Bucket}/{Key+}, where a key such as logs/2026/09/report.csv is one object and %2F in a path is the / it stands for; ListObjectsV2, ListObjects and ListObjectVersions list by prefix and delimiter; GetObjectTagging, PutObjectTagging, DeleteObjectTagging, GetObjectAttributes and GetObjectAcl read and write an object’s settings. Bodies run to 64 MiB; a larger one answers 400 EntityTooLarge. Every routed answer is S3’s own XML or headers and carries x-amz-request-id and x-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) answers 501 NotImplemented and changes nothing. (RYS-1368, RYS-1369, RYS-1370, RYS-1373)

  3. 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 answers InvalidPart, an unknown upload id NoSuchUpload, and the completed object’s Location leads back to the twin. CopyObject and UploadPartCopy copy within a bucket or from another by x-amz-copy-source, and DeleteObjects deletes every key its <Delete> document names. (RYS-1369, RYS-1370, RYS-1910)

  4. Request checksums, conditional writes and Range reads. A PutObject or UploadPart carrying x-amz-checksum-crc32, -crc32c, -crc64nvme, -sha1 or -sha256, as the AWS SDKs send by default, is checked against the body — a mismatch answers 400 BadDigest and stores nothing — and the stored checksum is echoed under x-amz-checksum-mode: ENABLED, so SDK integrity validation passes. If-None-Match: * or a stale If-Match on PutObject answers 412 PreconditionFailed and leaves the object alone; If-None-Match and If-Modified-Since on GetObject and HeadObject answer 304; If-Match naming another ETag answers 412; and a Range answers 206 Partial Content with its Content-Range. (RYS-1368, RYS-1369, RYS-1835, RYS-1840)

  5. 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 (and BucketRegion when 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 answers BucketNotEmpty. x-amz-expected-bucket-owner passes for the owning account’s 12-digit id (123456789012 unless a seed says otherwise) and answers 403 AccessDenied for any other. (RYS-1370, RYS-1856, RYS-1865)

  6. Five shipped worlds, and the one your test needs. --scenario s3_static_site boots acme-demo-site, an unversioned website bucket with seven keys under real prefixes beside its access-log bucket, and is the world Free boots and POST /_rystic/reset returns to. s3_versioned_archive holds six version rows with a delete marker among them, s3_encrypted_vault four objects pinning different encryption, checksum and storage-class combinations, s3_bucket_farm six buckets over five regions and every versioning state, and s3_fresh_bucket one empty tagged bucket; SCENARIOS.md says what each is for. On Pro and Enterprise, POST /_rystic/seed lands buckets, objects, versions and multipart uploads directly, and POST /_rystic/reset rewinds the id sequence and the clock, so two identical runs give byte-identical objects and listings down to ETag, Last-Modified and UploadId. (RYS-1368, RYS-1548)

  7. 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.md states 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 no null version for an object you write (RYS-1839), DeleteObjects in quiet mode lists every deleted key (RYS-1836), GetObjectAttributes answers ETag and ObjectSize only (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)

Last updated on