Simulators
GitHub Pulls simulator
github-pulls-twin is a local, stateful simulator of GitHub’s pull-request review surface — the review state machine, the permission rules and the conflict paths, not stubs.
Quickstart
No github-pulls-twin build has been cut yet. Request access for the first one.
curl -fsSL "https://www.rystic.ai/api/install?l=…" | sh # CLI + license
rystic run github-pulls-twin # pulls it, serves :8080
rystic run github-pulls-twin -d --port 9000 --seed scenario.json
rystic run github-pulls-twin --runner docker -d
Point your client at it — base URL only, code unchanged:
from github import Github, Auth
gh = Github(auth=Auth.Token("ghp_twin"), base_url="http://localhost:8080")
const { Octokit } = require("@octokit/rest");
const octokit = new Octokit({ auth: "ghp_twin", baseUrl: "http://localhost:8080" });
Coverage
36 routes, 13 resources. Everything is scoped under /repos/{owner}/{repo}/.
| Resource | Operations |
|---|---|
| Pull requests | list, create, get, update |
| Files & commits | list files, list commits |
| Reviews | list, create, get, update, delete, submit event, dismiss |
| Review comments | list on repo, list on PR, create, get, update, delete, reply |
| Reactions | list, create, delete — on review comments |
| Requested reviewers | list, request, remove |
| Merge | check merged, merge |
| Branch | update branch from base |
| Codespaces | create from a pull request |
| Check runs | create, get, update, list annotations, rerequest |
Issues and repository administration are the separate github simulator. Creating repositories, branches and teams is out of scope in both — they arrive through the seed.
Auth
Any Authorization header works, and so does none at all — the twin never validates a credential. Send a placeholder ghp_... so your client builds well-formed requests.
Identity is separate from auth, because the review rules depend on who is calling. X-Rystic-Actor names the caller; without it every request is member.
curl -s localhost:8080/repos/octo-org/Demo-Repo/pulls/1 # acts as member
curl -s -H 'X-Rystic-Actor: actor_b' localhost:8080/…/pulls/1 # acts as a second collaborator
That is what makes the review flow testable in one process: member opens the pull request, actor_b reviews it. See Cookbook.
Seeding state
| Endpoint | What it does |
|---|---|
GET /_rystic/version | health check + baked model hash |
POST /_rystic/reset | wipe all state |
POST /_rystic/seed | load a state snapshot |
POST /_rystic/clock | set or advance the clock |
POST /_rystic/seed-rng | fix the RNG |
GET /_rystic/inspect | dump the entire state |
GET /_rystic/egress | webhook events your requests triggered, in order — ?tail=N for the last N |
POST /_rystic/egress-retain | bound the egress log (0 = unbounded) |
GET /_rystic/registers | list seedable families and fields |
GET /_rystic/ui | the state browser (browser only) |
Thirteen registers, one per resource:
state_branch state_check_annotation state_check_run
state_codespace state_commit state_pull_request
state_pull_request_file state_pull_request_review
state_pull_request_review_comment state_reaction
state_repository state_team state_user
GET /_rystic/registers returns each one’s key fields and its schema. Most are keyed by id; the ones that hang off a repository are keyed by path — state_repository by owner.login + name, state_pull_request by base.repo.full_name + number.
A pull request needs a world to live in. Seeding state_repository alone gets you a repository whose pull-request list answers 200 [], but POST /pulls will answer 422 Validation Failed until the head and base branches exist and both point at seeded commits. Seed state_repository, state_user, state_branch and state_commit together.
Two seed shapes:
-
Scenario file, booted via
RYSTIC_SEED— aseedwrapper plus determinism knobs. Abbreviated; every register’s required fields are inGET /_rystic/registers.{ "seed": { "state_repository": { "octo-org/demo-repo": { "name": "Demo-Repo", "full_name": "octo-org/Demo-Repo", "default_branch": "main", "collaborators": ["member", "actor_b"] } }, "state_user": { "member": { "login": "member", "id": 1 } }, "state_branch": { "main": { "name": "main", "sha": "aaa…" }, "feature": { "name": "feature", "sha": "bbb…" } }, "state_commit": { "aaa…": { "sha": "aaa…" }, "bbb…": { "sha": "bbb…" } } }, "clock": "2026-01-01T00:00:00Z", "rng_seed": 42 } -
POST /_rystic/seed— the register map only, no wrapper.jq .seed scenario.json | curl -X POST localhost:8080/_rystic/seed -d @-
Success returns 204; rejection names every valid register. unknown register "seed" means you posted the wrapped file.
Egress log
Nothing is POSTed to an endpoint. Every webhook event your requests trigger renders into one ordered log, and GET /_rystic/egress returns it. This is the webhook surface.
Opening a pull request, approving it and merging it produces three:
{"events":[{"seq":1,"effect":"eff_pull_request_notification","type":"pull_request",
"payload":{"action":"opened","number":1,"pull_request":{"…":"…"}}},
{"seq":2,"effect":"eff_pull_request_review_notification",
"payload":{"action":"submitted","…":"…"}},
{"seq":3,"effect":"eff_merge_notification","type":"pull_request.closed",
"payload":{"action":"closed","…":"…"}}],
"total":3,"dropped":0}
payloadis the envelope GitHub would have POSTed.effectis the model’s id for the effect.totalcounts everything emitted since the last reset;eventsmay hold fewer.- The log keeps 100,000 events by default. Beyond that the oldest drop and
droppedcounts them.RYSTIC_EGRESS_RETAIN=0at boot orPOST /_rystic/egress-retain {"retain":0}removes the bound, andGET /_rystic/versionreportsegress_retain. Checkdroppedbefore trusting the log as complete. POST /_rystic/resetclears it and restartsseqat 1.
Determinism & reset
curl -X POST localhost:8080/_rystic/reset
jq .seed scenario.json | curl -X POST localhost:8080/_rystic/seed -d @-
# ... run your review flow ...
curl -s localhost:8080/repos/octo-org/Demo-Repo/pulls/1 # assert the exact record
curl -s localhost:8080/_rystic/egress # assert the exact events
Execution is serialized; pull-request numbers, review ids and comment ids are monotonic sequences that reset with the state, so the first pull request of a run is always 1. Timestamps come from the pinned clock, which runs forward from its pin — compare time-bearing fields by shape, not byte-equality. State is in-memory: restart = clean slate (plus RYSTIC_SEED).
Limitations
- The repository arrives through the seed. There is no create-repository, create-branch or create-team route on this surface —
state_branchhas no producer, so a flow that needs a branch must seed it.state_teamis seed-only for the same reason, which is why team-based reviewer requests cannot be built up from an empty world. - No webhook delivery. Events render to the ordered egress log instead of being POSTed anywhere. Don’t build a delivery-retry or signature-verification test against this twin.
- Nothing moves on its own. No background merge-conflict computation, no scheduled checks —
mergeableandmergeable_statereport what the model derives at read time, and time only advances through/_rystic/clock. - Review surface only. Issues, repository settings, Actions and the GraphQL API are out of scope; issues and repo administration are the separate
githubsimulator. - One process = one world. Run multiple ports for isolation.
Thirty-nine probes in the suite are recorded as skipped rather than silently passing — most of them status codes the modelled world cannot reach (status_unrealizable), plus a handful needing an entitlement the oracle account lacks. The card above counts only what actually ran.
Cookbook
Open a pull request, review it, merge it
The permission rules are real, which is the point of the two actors.
# member opens it
curl -s localhost:8080/repos/octo-org/Demo-Repo/pulls \
-H 'Content-Type: application/json' \
-d '{"title":"Add rate limiting","head":"feature","base":"main"}'
# -> 201 {"number":1,"state":"open","additions":3,"changed_files":1,...}
# member cannot approve their own work — GitHub's rule, and the twin's
curl -s localhost:8080/repos/octo-org/Demo-Repo/pulls/1/reviews \
-H 'Content-Type: application/json' -d '{"body":"lgtm","event":"APPROVE"}'
# -> 422 {"message":"Unprocessable Entity",
# "errors":["Can not approve your own pull request"]}
# a second collaborator can
curl -s localhost:8080/repos/octo-org/Demo-Repo/pulls/1/reviews \
-H 'X-Rystic-Actor: actor_b' -H 'Content-Type: application/json' \
-d '{"body":"Looks good.","event":"APPROVE"}'
# -> 200 {"id":1,"state":"APPROVED","author_association":"COLLABORATOR",...}
curl -s -X PUT localhost:8080/repos/octo-org/Demo-Repo/pulls/1/merge \
-H 'Content-Type: application/json' -d '{"merge_method":"squash"}'
# -> 200 {"merged":true,"message":"Pull Request successfully merged","sha":"…"}
Assert what the merge emitted
curl -s localhost:8080/repos/octo-org/Demo-Repo/pulls/1 | jq '{state, merged}'
# -> {"state":"closed","merged":true}
curl -s localhost:8080/_rystic/egress | jq '.events[] | {seq, effect}'
# -> the opened, submitted and closed events, in order
Drive a review-comment thread
A review comment is anchored to a commit, so it needs the pull request’s head sha:
SHA=$(curl -s localhost:8080/repos/octo-org/Demo-Repo/pulls/1 | jq -r .head.sha)
curl -s localhost:8080/repos/octo-org/Demo-Repo/pulls/1/comments \
-H 'Content-Type: application/json' \
-d "{\"body\":\"Why a token bucket here?\",\"path\":\"limits.go\",
\"line\":1,\"side\":\"RIGHT\",\"commit_id\":\"$SHA\"}"
# -> 201 {"id":1,"path":"limits.go","line":1,...}
curl -s localhost:8080/repos/octo-org/Demo-Repo/pulls/1/comments/1/replies \
-H 'X-Rystic-Actor: actor_b' -H 'Content-Type: application/json' \
-d '{"body":"Bursts."}'
# -> 201 {"id":2,"in_reply_to_id":1,...}
Byte-identical CI runs
curl -X POST localhost:8080/_rystic/reset
curl -X POST localhost:8080/_rystic/clock -d '{"time":"2026-01-01T00:00:00Z"}'
jq .seed scenario.json | curl -X POST localhost:8080/_rystic/seed -d @-
# ... run your review flow ...
curl -s localhost:8080/_rystic/inspect > state.json
# diff state.json against the last green run — numbers and ids are exact;
# normalize created_at/updated_at first, they advance from the pinned clock
Release notes
No numbered build yet. Cut builds get an entry here, newest first.