# Changelog

What changed in each simulator release, newest first.

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](https://github.com/Rystic-Inc/twin-envs/blob/main/docs/slack-mcp-support.md)
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:

   ```bash
   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_time`s 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](https://www.rystic.ai/docs/cli.md). 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. # 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. # 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 <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: <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)

---

Source: https://www.rystic.ai/docs/changelog · Markdown: https://www.rystic.ai/docs/changelog.md
