Simulators
GitHub simulator
github-pulls-twin is a local, stateful simulator of GitHub’s pull-request and issue surface — the review state machine, the permission rules, the conflict paths, a real git repository and the GraphQL subset gh speaks. Not stubs.
Standalone UI for repository development
A source checkout includes a local GitHub web client in public-apis/github-pulls/ui/web/. Start the twin, then start the UI from the repository root:
cd public-apis/github-pulls/ui/web && npm ci
RYSTIC_TWIN_URL=http://127.0.0.1:8080 npm start
Use your twin’s loopback URL for RYSTIC_TWIN_URL, then open http://127.0.0.1:18434. The UI reads and writes through the REST and GraphQL routes as one seeded user at a time. It covers the pull request and issue lists, issue and pull request pages with their comments, lifecycle controls and merge box, commits and checks, files with inline review threads and reactions, reviews and reviewers, and search. Add ?as=<login> to start as someone else, or switch from the avatar menu. This source-run UI is separate from the embedded pull request UI and is not installed by the CLI.
Quickstart
curl -fsSL https://www.rystic.ai/install.sh | sh
rystic login rys_… # an API key from https://www.rystic.ai/account/api-keys
rystic run github-pulls-twin -d --port 0 --scenario github-pulls_open_queue # prints the URL it chose
curl -s http://localhost:<port>/_rystic/version
Expect "product":"github-pulls-twin" and "version":"0.0.13". --scenario github-pulls_open_queue boots the same world on every plan; leave it out and the twin starts empty. Enterprise signs in with its license file instead: rystic login ./rystic-license.json (Enterprise license files). Plain docker pull and Compose: Docker.
The examples on this page use B for the URL rystic run printed:
B=http://localhost:8080 # yours may differ
curl -s $B/repos/tb-owner/tb-repo/pulls -H 'Authorization: Bearer ghp_anything' | jq -r '.[] | "#\(.number) \(.title)"'
# #44 Bump vendored dependencies
# #43 WIP: split the store package
# #42 Retry transient 503s from upstream
# #41 Fix pagination cursor off-by-one
Point your client at it — base URL only, code unchanged:
from github import Github, Auth
gh = Github(auth=Auth.Token("ghp_anything"), base_url="http://localhost:8080")
const { Octokit } = require("@octokit/rest");
const octokit = new Octokit({ auth: "ghp_anything", baseUrl: "http://localhost:8080" });
rystic stop default stops it. git and gh covers git clone and the gh CLI.
Plans
Plans and API keys has the full table. Every plan serves every GitHub route, git and gh, all nine worlds and every control route but /_rystic/network and /_rystic/ws-drop, which are Pro and Enterprise (routes by plan). Without --scenario the twin boots empty, and POST /_rystic/reset wipes it to empty.
Auth
A personal token never names the caller, and no route checks its value; the twin checks only a GitHub App’s credentials, an installation token it minted and an App JWT off the App routes (below). A GitHub client needs one to build its requests, so send a placeholder — Authorization: Bearer ghp_anything or token ghp_anything, both labels work. Most routes answer without the header too. The exceptions are GET /user, GET /user/repos, GET /user/orgs, GET /user/memberships/orgs and Codespaces: reading the authenticated user, their repositories, their organizations or their memberships, creating a codespace from a pull request, listing them and deleting one by name answer 401 Requires authentication unless the request carries Authorization: Bearer <x> or token <x>; a Basic header is refused as well.
What a client asks first works. GET / answers GitHub’s URL index, so gh auth status reports the caller viewer resolves to and Token scopes: none. GET /rate_limit reports a full budget (core 5000) that resets at the next hour of the twin’s clock; the twin enforces no limit. GET /user and viewer are the resolved caller. GET /user/repos lists the repositories that caller owns or collaborates on, with GitHub’s visibility, affiliation, type, sort, direction, since, before and paging defaults; type beside visibility or affiliation is GitHub’s 422, and organization_member reaches the repositories of an organization the caller is an active admin or member of, never through a billing manager’s role or a pending invitation. GET /user/orgs lists the organizations the caller holds an active membership in, and GET /user/memberships/orgs the caller’s own memberships, invitations included, with their role and state. A repository’s permissions, there and on GET /repos/{owner}/{repo}, are the caller’s standing: admin for the owner and for an admin of the owning organization, a collaborator’s declared permission, and read for a member. Only github-pulls_org_teams seeds one: tb-org, with tb-owner as admin and tb-reviewer-login and tb-maintainer as members. Every other world answers [].
Who is calling is a header. GitHub’s review rules depend on the caller, so the twin reads it from X-Rystic-Actor: the key or login of a seeded user. Without the header every request is member. Every authored world seeds the same cast, except that in github-pulls_checks_red tb-maintainer is not a collaborator and holds READ:
| Actor | Is | Standing on tb-owner/tb-repo |
|---|---|---|
tb-owner | the repository owner | ADMIN |
tb-maintainer | a collaborator | WRITE |
tb-reviewer-login | a collaborator | WRITE |
tb-contributor | a user, not a collaborator | READ |
member | an alias for tb-owner, and the default caller | ADMIN |
actor_b | an alias for tb-reviewer-login | WRITE |
In github-pulls_garrytan_gbrain, member is garrytan, the owner; its collaborators are the logins GitHub labels OWNER, MEMBER or COLLABORATOR plus actor_b’s. GET /repos/{owner}/{repo} answers permissions for the caller from this standing: all five true for the owner, push, triage and pull for a collaborator, pull alone for anyone else. The rules apply to the resolved user:
- An author approving their own pull request gets
422Unprocessable Entitywith errors["Review Can not approve your own pull request"], as GitHub answers. - A seeded user without write access gets
403 Must have write access to repository.from merge, review dismissal, and requesting or removing review requests. - A login the world seeds no user for gets
401 Bad credentialsfrom authored writes (a pull request, issue, comment, review comment, reply or review) and from merge, dismissal and review requests. PATCH on a pull request or a review comment, check runs and reactions do not check the caller, so an unseeded login gets through there. Reads work for anyone.
curl -s -X POST $B/repos/tb-owner/tb-repo/pulls/41/reviews \
-H 'Authorization: Bearer ghp_anything' -H 'X-Rystic-Actor: tb-maintainer' \
-H 'Content-Type: application/json' -d '{"event":"APPROVE","body":"LGTM"}'
A GitHub App authenticates as itself. Every authored world installs the App tb-agent (app id 4125) on tb-owner’s account as installation 41250001, on every repository, with write on checks, contents, issues, pull_requests and statuses. Give Octokit’s createAppAuth, Probot or actions/create-github-app-token that app id, any RSA private key, and the twin as base URL. The twin reads the JWT by structure: RS256, an iss naming a seeded app by id or client id, and an exp at most eleven minutes after an integer iat. It never checks the signature, nor either time against a clock. GET /app, its installations and the installation lookups by repository, organization and user answer the JWT. POST /app/installations/{id}/access_tokens mints a ghs_ token (narrowed by repositories, repository_ids and permissions, expiring an hour later on the twin’s clock). That token acts as tb-agent[bot], type Bot: its writes are the bot’s, and its standing on a repository is its grant (none outside the repositories it reaches or under a suspended installation). Creating a pull request, issue, review, review comment or reply, and a merge, dismissal or review request, the grant does not cover answer 403 Resource not accessible by integration; the writes that check no write access (issue comments, labels, assignees, milestones) take the token whatever its grant. GET /installation/repositories lists what the token reaches, and DELETE /installation/token revokes it; once revoked or expired it answers 401 Bad credentials, while a ghs_ token the twin never minted, such as Actions’ GITHUB_TOKEN, is accepted like a personal one. On every route but the App’s routes, REST or GraphQL, the App’s JWT answers 401 Bad credentials before its handler runs. A world seeds its own app with state_github_app, state_installation and the bot’s state_user (<slug>[bot], type Bot). Of the App routes, only the refusals of a request with no credential or a malformed one are measured against GitHub (Limitations).
Coverage
163 routes across 26 resources, counted from the sim’s literal registrations.
GraphQL fields answer at POST /graphql.
| Resource | Operations |
|---|---|
| BranchProtection (6) | PUT, DELETE /repos/{owner}/{repo}/branches/{branch}/protection; PATCH, DELETE /repos/{owner}/{repo}/branches/{branch}/protection/required_pull_request_reviews; PATCH, DELETE /repos/{owner}/{repo}/branches/{branch}/protection/required_status_checks |
| CheckRun (3) | POST /repos/{owner}/{repo}/check-runs; PATCH /repos/{owner}/{repo}/check-runs/{check_run_id}; POST /repos/{owner}/{repo}/check-runs/{check_run_id}/rerequest |
| Codespace (3) | POST /repos/{owner}/{repo}/pulls/{pull_number}/codespaces; GET /user/codespaces; DELETE /user/codespaces/{codespace_name} |
| Commit (1) | PUT /repos/{owner}/{repo}/pulls/{pull_number}/update-branch |
| CommitStatus (1) | POST /repos/{owner}/{repo}/statuses/{sha} |
| GitHubApp (1) | GET /app |
| Installation (3) | GET /app/installations; GET /app/installations/{installation_id}; GET /orgs/{org}/installation |
| InstallationToken (3) | POST /app/installations/{installation_id}/access_tokens; GET /installation/repositories; DELETE /installation/token |
| Issue (18) | POST /repos/{owner}/{repo}/issues; PATCH /repos/{owner}/{repo}/issues/{issue_number}; POST, DELETE /repos/{owner}/{repo}/issues/{issue_number}/assignees; GET, POST, PUT, DELETE /repos/{owner}/{repo}/issues/{issue_number}/labels; DELETE /repos/{owner}/{repo}/issues/{issue_number}/labels/{name}; PUT, DELETE /repos/{owner}/{repo}/issues/{issue_number}/lock; Mutation.closeIssue; Mutation.createIssue; Mutation.reopenIssue; Mutation.updateIssue; Repository.issue; Repository.issueOrPullRequest; Repository.issues |
| IssueComment (4) | PATCH, DELETE /repos/{owner}/{repo}/issues/comments/{comment_id}; POST /repos/{owner}/{repo}/issues/{issue_number}/comments; Mutation.addComment |
| Label (4) | GET, POST /repos/{owner}/{repo}/labels; PATCH, DELETE /repos/{owner}/{repo}/labels/{name} |
| Milestone (4) | GET, POST /repos/{owner}/{repo}/milestones; PATCH, DELETE /repos/{owner}/{repo}/milestones/{milestone_number} |
| OrgMembership (2) | GET /user/memberships/orgs; GET /user/orgs |
| PullRequest (17) | POST /repos/{owner}/{repo}/pulls; PATCH /repos/{owner}/{repo}/pulls/{pull_number}; PUT /repos/{owner}/{repo}/pulls/{pull_number}/merge; POST, DELETE /repos/{owner}/{repo}/pulls/{pull_number}/requested_reviewers; GET /repos/{owner}/{repo}/pulls/{pull_number}/reviews/{review_id}/comments; GET /search/issues; Mutation.closePullRequest; Mutation.convertPullRequestToDraft; Mutation.createPullRequest; Mutation.markPullRequestReadyForReview; Mutation.mergePullRequest; Mutation.reopenPullRequest; Query.node; Query.search; Repository.pullRequest; Repository.pullRequests |
| PullRequestReview (6) | POST /repos/{owner}/{repo}/pulls/{pull_number}/reviews; PUT, DELETE /repos/{owner}/{repo}/pulls/{pull_number}/reviews/{review_id}; PUT /repos/{owner}/{repo}/pulls/{pull_number}/reviews/{review_id}/dismissals; POST /repos/{owner}/{repo}/pulls/{pull_number}/reviews/{review_id}/events; Mutation.addPullRequestReview |
| PullRequestReviewComment (5) | GET, PATCH, DELETE /repos/{owner}/{repo}/pulls/comments/{comment_id}; POST /repos/{owner}/{repo}/pulls/{pull_number}/comments; POST /repos/{owner}/{repo}/pulls/{pull_number}/comments/{comment_id}/replies |
| Reaction (6) | POST /repos/{owner}/{repo}/issues/comments/{comment_id}/reactions; DELETE /repos/{owner}/{repo}/issues/comments/{comment_id}/reactions/{reaction_id}; POST /repos/{owner}/{repo}/issues/{issue_number}/reactions; DELETE /repos/{owner}/{repo}/issues/{issue_number}/reactions/{reaction_id}; POST /repos/{owner}/{repo}/pulls/comments/{comment_id}/reactions; DELETE /repos/{owner}/{repo}/pulls/comments/{comment_id}/reactions/{reaction_id} |
| Repository (37) | GET /repos/{owner}/{repo}; GET /repos/{owner}/{repo}/assignees; GET /repos/{owner}/{repo}/branches/{branch}/protection; GET /repos/{owner}/{repo}/branches/{branch}/protection/required_pull_request_reviews; GET /repos/{owner}/{repo}/branches/{branch}/protection/required_status_checks; GET /repos/{owner}/{repo}/check-runs/{check_run_id}; GET /repos/{owner}/{repo}/check-runs/{check_run_id}/annotations; GET /repos/{owner}/{repo}/commits/{ref}/check-runs; GET /repos/{owner}/{repo}/commits/{ref}/status; GET /repos/{owner}/{repo}/commits/{ref}/statuses; GET /repos/{owner}/{repo}/installation; GET /repos/{owner}/{repo}/issues; GET /repos/{owner}/{repo}/issues/comments; GET /repos/{owner}/{repo}/issues/comments/{comment_id}; GET /repos/{owner}/{repo}/issues/comments/{comment_id}/reactions; GET /repos/{owner}/{repo}/issues/events; GET /repos/{owner}/{repo}/issues/events/{event_id}; GET /repos/{owner}/{repo}/issues/{issue_number}; GET /repos/{owner}/{repo}/issues/{issue_number}/comments; GET /repos/{owner}/{repo}/issues/{issue_number}/events; GET /repos/{owner}/{repo}/issues/{issue_number}/reactions; GET /repos/{owner}/{repo}/issues/{issue_number}/timeline; GET /repos/{owner}/{repo}/labels/{name}; GET /repos/{owner}/{repo}/milestones/{milestone_number}; GET /repos/{owner}/{repo}/pulls; GET /repos/{owner}/{repo}/pulls/comments; GET /repos/{owner}/{repo}/pulls/comments/{comment_id}/reactions; GET /repos/{owner}/{repo}/pulls/{pull_number}; GET /repos/{owner}/{repo}/pulls/{pull_number}/comments; GET /repos/{owner}/{repo}/pulls/{pull_number}/commits; GET /repos/{owner}/{repo}/pulls/{pull_number}/files; GET /repos/{owner}/{repo}/pulls/{pull_number}/merge; GET /repos/{owner}/{repo}/pulls/{pull_number}/requested_reviewers; GET /repos/{owner}/{repo}/pulls/{pull_number}/reviews; GET /repos/{owner}/{repo}/pulls/{pull_number}/reviews/{review_id}; GET /user/repos; Query.repository |
| RepositoryGit (5) | GET /repos/{owner}/{repo}/branches; GET /repos/{owner}/{repo}/branches/{branch}; GET /repos/{owner}/{repo}/commits/{ref}; GET /repos/{owner}/{repo}/compare/{basehead}; GET /repos/{owner}/{repo}/git/ref/{ref+} |
| User (3) | GET /user; GET /users/{username}/installation; Query.viewer |
| Other (2) | GET, HEAD /; GET /rate_limit |
Also served, outside the sealed model: GET /in/{id}, GET /orgs/{org}/hooks, POST /orgs/{org}/hooks, GET /orgs/{org}/hooks/{hook_id}, PATCH /orgs/{org}/hooks/{hook_id}, DELETE /orgs/{org}/hooks/{hook_id}, GET /orgs/{org}/hooks/{hook_id}/config, PATCH /orgs/{org}/hooks/{hook_id}/config, GET /orgs/{org}/hooks/{hook_id}/deliveries, GET /orgs/{org}/hooks/{hook_id}/deliveries/{delivery_id}, POST /orgs/{org}/hooks/{hook_id}/deliveries/{delivery_id}/attempts, POST /orgs/{org}/hooks/{hook_id}/pings, GET /repos/{owner}/{repo}/actions/jobs/{job_id}, GET /repos/{owner}/{repo}/actions/jobs/{job_id}/logs, POST /repos/{owner}/{repo}/actions/jobs/{job_id}/rerun, GET /repos/{owner}/{repo}/actions/runs, GET /repos/{owner}/{repo}/actions/runs/{run_id}, GET /repos/{owner}/{repo}/actions/runs/{run_id}/artifacts, GET /repos/{owner}/{repo}/actions/runs/{run_id}/attempts/{attempt_number}, GET /repos/{owner}/{repo}/actions/runs/{run_id}/attempts/{attempt_number}/jobs, GET /repos/{owner}/{repo}/actions/runs/{run_id}/attempts/{attempt_number}/logs, POST /repos/{owner}/{repo}/actions/runs/{run_id}/cancel, GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs, GET /repos/{owner}/{repo}/actions/runs/{run_id}/logs, POST /repos/{owner}/{repo}/actions/runs/{run_id}/rerun, POST /repos/{owner}/{repo}/actions/runs/{run_id}/rerun-failed-jobs, GET /repos/{owner}/{repo}/actions/workflows, GET /repos/{owner}/{repo}/actions/workflows/{workflow_id}, POST /repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches, GET /repos/{owner}/{repo}/actions/workflows/{workflow_id}/runs, GET /repos/{owner}/{repo}/hooks, POST /repos/{owner}/{repo}/hooks, GET /repos/{owner}/{repo}/hooks/{hook_id}, PATCH /repos/{owner}/{repo}/hooks/{hook_id}, DELETE /repos/{owner}/{repo}/hooks/{hook_id}, GET /repos/{owner}/{repo}/hooks/{hook_id}/config, PATCH /repos/{owner}/{repo}/hooks/{hook_id}/config, GET /repos/{owner}/{repo}/hooks/{hook_id}/deliveries, GET /repos/{owner}/{repo}/hooks/{hook_id}/deliveries/{delivery_id}, POST /repos/{owner}/{repo}/hooks/{hook_id}/deliveries/{delivery_id}/attempts, POST /repos/{owner}/{repo}/hooks/{hook_id}/pings, GET /u/{id}, GET /{owner}/{repo}/HEAD, POST /{owner}/{repo}/git-receive-pack, GET /{owner}/{repo}/info/refs, GET /{owner}/{repo}/objects/info/packs, GET /{owner}/{repo}/objects/pack/{name}, GET /{owner}/{repo}/objects/{dir}/{name}.
GET /_rystic/routes lists every GitHub route the build serves; the control plane is not in it. Issues and pull requests share one number sequence per repository, as on GitHub: in github-pulls_open_queue the next thing opened, issue or pull request, is #45, and GET .../issues/{n} on a pull request’s number returns the pull request rendered as an issue.
GET .../issues/{n}/events lists a subject’s issue events, oldest first: those the world seeds, which agree with its records, then those the twin’s own writes log. Closing, reopening, renaming, locking, unlocking and merging an issue or pull request, and readying or drafting a pull request, append the event GitHub logs, with GitHub’s keys. Adding or removing a label, an assignee or a milestone, through the label and assignee routes, PATCH .../issues/{n} or at creation, logs labeled/unlabeled, assigned/unassigned or milestoned/demilestoned; review requests log nothing yet. GET /repos/{owner}/{repo}/issues/events reads the repository’s whole feed, each event carrying its issue. GET .../issues/{n}/timeline merges them with the conversation comments, submitted reviews, review comments and a pull request’s commits, ordered by time.
GitHub’s body media types work on every route that answers a body: Accept: application/vnd.github.html+json answers body_html, .text+json body_text, .full+json all three, and the default the stored Markdown; reviews and review comments take the application/vnd.github-commitcomment.* spellings. The HTML is CommonMark with GitHub’s tables, strikethrough, autolinks and task lists, not GitHub’s own renderer.
Every avatar_url names avatars.githubusercontent.com, which the twin re-bases onto its own origin and serves: /u/{id} (/in/{id} for an app) answers a PNG identicon drawn from the id, the same for the same user every time, sized by ?s=. Nothing is fetched from GitHub.
Search. GET /search/issues and the GraphQL search that gh pr list --search and gh issue list --search send answer the issues and pull requests the world holds, through one grammar both read from the model.
- Qualifiers.
repo:,user:,org:andowner:scope the query, and a query naming none matches nothing. Family and state:is:pr,is:pull-request,is:issue,type:,state:,reason:completed|"not planned",is:open|closed|merged|unmerged|draft|locked|unlocked|public|private,draft:true|false,archived:andlanguage:. People:author:,assignee:,review-requested:,reviewed-by:,commenter:,mentions:andinvolves:, with@meas the caller. Content:label:,milestone:,no:label|assignee|milestone,head:andbase:(a branch-name prefix),comments:with a count or range, andreview:none|approved|changes_requested. Dates:created:,updated:,closed:andmerged:. A date isYYYY-MM-DD, covering its whole UTC day, or an ISO 8601 timestamp;a..bis inclusive,*leaves an end open, and>=,>,<=and<bound one side. - Combining. Terms AND, and
-negates any of them. Repeatingauthor:or another person qualifier ORs; repeatinglabel:ANDs, andlabel:a,bORs.AND,ORand parentheses are operators only under the advanced syntax, whichghsends astype: ISSUE_ADVANCEDand REST takes withadvanced_search=true; there every repeat ANDs and-( … )matches nothing. In the default syntax a bare parenthesis matches nothing, andAND/ORjoin only free text. - Free text. Words match title, body and comments, case-insensitively, or the fields
in:title,in:bodyandin:commentsname. A word in the record also matches the query word one suffix shorter, sofixfinds “fixes” and “fixed”, butfixesdoes not find “fix” and there is no prefix match. A quoted phrase matches its words in order. A key the grammar does not know, likefoo:bar, is text. - Refusals. Over REST, an unrecognized date, a login or repository the world does not hold, or operators with no text answer GitHub’s
422; over GraphQL each matches nothing. A qualifier GitHub accepts that the twin cannot evaluate (review:required,reactions:,project:,linked:and a few more) is refused rather than ignored: REST answers501withX-Rystic-Marker: unmodeled_guard; iv=iv_op_get_search_issues; guard=g_qualifiers_modelledand GitHub’s error envelope (message,documentation_url,status), GraphQL an error naming that guard. The body does not yet name the qualifier. - Results.
total_countandissueCountcount every match;per_page(at most 100) andpagewindow the REST items with GitHub’sLinkheader, andsort/orderor an in-querysort:created-ascorder them, newest first by default. A pull request’s item carriesdraftand its ownnode_id. Free-text matches are measured within a few pull requests of GitHub’s over the imported world. A write is searchable at once, where GitHub’s index lags by seconds, and two of its counts disagree with GitHub’s own records:closed:misses pull requests closed in bulk andcomments:undercounts. The twin follows the record.
GraphQL is gh’s subset, not the schema. Queries: node, repository, search, viewer, and on a repository issue, issueOrPullRequest, issues, pullRequest and pullRequests, with their reviews, comments and check-run rollup. Mutations: addComment, addPullRequestReview, closeIssue, closePullRequest, convertPullRequestToDraft, createIssue, createPullRequest, markPullRequestReadyForReview, mergePullRequest, reopenIssue, reopenPullRequest and updateIssue. Every mutation with a REST route writes through that route’s handler, so what gh does shows on the REST surface; convertPullRequestToDraft and markPullRequestReadyForReview have no REST route and write the pull request directly. __type answers a type’s name, kind, fields with their args, and enumValues, which is what gh’s feature probes read; __schema is not served. A field outside the subset answers GitHub’s undefinedField error.
Not in this simulator: rulesets, deleting a branch through the API (git push --delete works), Actions beyond workflow runs, and repository or organization administration. Over GraphQL, hiding a comment (minimizeComment), resolving a review thread (resolveReviewThread), adding a thread to a pending review (addPullRequestReviewThread) and a pull request’s reviewThreads answer undefinedField.
git and gh
Every world is a real repository, served on the twin’s own port. The shas match the API’s.
git clone $B/tb-owner/tb-repo.git
git -C tb-repo fetch origin refs/pull/41/head # any pull request's head
git -C tb-repo fetch origin refs/pull/41/merge # its test merge with the base
git pushto an open pull request’s head branch moves the pull request — head sha,commits,files, asynchronizenotification. Pushing a new branch creates it, so a world can get a branch no pull request uses yet (Cookbook). A protected branch refuses the push.- A merge through the API moves the base branch:
git fetchafterwards finds the commit the merge response named. refs/pull/{n}/mergeis the pull request’s test merge: its head merged into the base branch’s current tip, rebuilt as either moves, and the commit an open pull request’smerge_commit_shanames. A head that conflicts with its base has none, readsmergeable: false,mergeable_state: "dirty", and its merge answers405. A merge or squash lands that same merged tree. Merging is git’s, path by path and line by line, without rename detection: a file one side renames and the other edits conflicts, andrebasestill replays each commit’s files whole.files[].patchon a pull request is the diff of its commits.github-pulls_garrytan_gbrainserves its repository from one 85 MB pack; only the branches and pull refs the world advertises are reachable.
gh api is REST, and every gh pr and gh issue command is GraphQL; the twin serves both. With GH_HOST=github.localhost, gh speaks plain HTTP to api.github.localhost and honours HTTP_PROXY, so the twin’s URL is the proxy:
export GH_HOST=github.localhost GH_TOKEN=ghp_anything HTTP_PROXY=$B
gh pr list -R tb-owner/tb-repo
gh pr view 41 -R tb-owner/tb-repo --comments
gh pr review 41 -R tb-owner/tb-repo --approve
gh pr merge 41 -R tb-owner/tb-repo --squash
gh issue create -R tb-owner/tb-repo --title "An issue" --body "body"
gh pr list -R tb-owner/tb-repo --author tb-contributor --label bug
gh api repos/tb-owner/tb-repo/pulls/41 --jq '.state, .merged'
gh pr list and gh issue list with --author, --assignee, --label, --draft or --search read SearchType’s values by introspection, then send one GraphQL search query in the advanced syntax, so they filter by the grammar in Coverage’s Search paragraph: --draft lists drafts only, --search "merged:2026-09-01..2026-09-07" counts that window, and free text narrows the list. A qualifier the twin cannot evaluate fails the command with an error naming it.
gh acts as member; it has no way to send X-Rystic-Actor.
The pull request UI
Open http://localhost:8080/_rystic/ui in a browser: the
Repository card draws the twin’s state as GitHub’s own pull request pages —
the Pull requests and Issues lists, and a pull request’s Conversation,
Commits, Checks and Files changed tabs — and redraws as your automation calls
the API.
- View as anyone. The button at the top right picks the seeded user the view acts as, with that user’s standing on the repository beside it. The Actors & calls card lists every seeded user and alias with its standing, and the live request log.
- Act as them. Comments, reviews, line comments from the + in a diff,
replies, reactions, review requests, merges, Update branch, close and
reopen, title edits, locks and new issues go through the twin’s own REST
API with
X-Rystic-Actorset to that user. The twin applies its permission rules,GET /_rystic/callsattributes the write, and a refused write shows the twin’s own status and message; writes are never retried. - Checks. Nothing runs CI here, so the Checks tab carries a Rystic control that sets a check run’s status and conclusion, rerequests it, or creates one on the head sha. Annotations render on their lines in Files changed.
- Webhook events lists what the twin rendered into
GET /_rystic/egress, newest first, with each payload: the deliveries GitHub would have made. - Labels, assignees and milestones are drawn read-only; change them through
the REST routes (
.../labels,.../assignees,PATCH .../issues/{n}). Links never open, images load only when clicked, and the page contacts nothing but the twin. - Switching users or repositories closes open edits, menus and the review panel and discards pending responses. Resetting, loading a scenario, or detecting a gap in the event feed also clears drafts and returns to the pull request list; replies from the previous world cannot change the new one.
- The imported world reads about 90 MB per refresh. Writes and control events refresh the view, spaced by twice the last read’s time. Failed reads show an error with a Retry button and retry automatically with bounded backoff.
Clients known to work
Each client’s quickstart was run against this simulator in each way it connects, with the vendor’s own hosts blocked.
| Client | Version | Kind | Works with | Fails with | Checked | Known gap |
|---|---|---|---|---|---|---|
cli/cli | v2.102.0 | cli | proxy, default host | — | 2026-10-29 | — |
octokit | v10.0.0 | sdk | base URL, default host | — | 2026-10-29 | — |
@octokit/rest | v22.0.1 | sdk | base URL, default host | — | 2026-10-29 | — |
integrations/github | v6.13.0 | terraform-provider | — | base URL, default host | 2026-10-29 | RYS-4255 |
terraform-provider-github looks up its owner with GET /orgs/{owner} at startup. The twin does not serve that route yet, so it answers 404, and the provider takes the owner for a user account and carries on. Set a user account as the provider’s owner; organization resources such as teams and organization settings are not served yet.
Control plane
Every route under /_rystic/ is listed in routes by plan. GET /_rystic/egress holds the webhook events your requests triggered (Egress log), /_rystic/ui adds the GitHub pull request view (The pull request UI), and the simulator serves no WebSocket, so /_rystic/ws lists none.
Scenarios
Nine worlds ship in the binary. The eight authored ones share tb-owner/tb-repo, default branch tb-base-branch, and the cast in Auth; they pin the clock to 2026-02-01T00:00:00Z.
| Scenario | What it holds |
|---|---|
github-pulls_open_queue | four open pull requests (#41–#44) for listing, sorting and state/draft filtering |
github-pulls_review_thread | #77 with a finished review conversation: commented, changes-requested and approved reviews, an inline reply thread, a line comment and reactions |
github-pulls_checks_red | #58, unstable, its head sha carrying four check runs: failed, green, in progress and neutral |
github-pulls_merged_history | #12 merged with a merge commit, #13 squash-merged, #14 closed unmerged |
github-pulls_org_teams | four org teams, two of them requested for review on #91 |
github-pulls_bot_pr_broken | #7 merged a working Kalshi market-maker bot; #8 breaks it on one line, with a red bot-smoke check run annotating that line |
github-pulls_bot_pr_queue | the same merged bot, then five open pull requests (#11–#15) each breaking it one way |
github-pulls_issue_tracker | five issues and one pull request (#3) sharing a number sequence, with conversation comments |
github-pulls_garrytan_gbrain | a real repository imported from GitHub: 300 pull requests of garrytan/gbrain, 5,693 changed files, 395 conversation comments, 994 issue events, 6,266 check runs, and the repository itself to clone. About 600 MB of memory |
The archive ships the two bot worlds’ Python files as plain text in bots/ (bot.py, test_bot.py, and five variants/); the container image does not carry them. Worlds covers listing and loading worlds.
Seeding state
POST /_rystic/seed lands a register map directly, so a test starts from the records it needs. Eighteen registers:
state_branch state_check_annotation state_check_run
state_codespace state_commit state_issue
state_issue_comment state_org_membership state_organization
state_pull_request state_pull_request_file state_pull_request_review
state_pull_request_review_comment state_reaction
state_repository state_repository_git state_team
state_user
Records are GitHub’s own object shapes. A repository is keyed <owner>/<repo>, a branch <owner>/<repo>#<name>, a pull request or issue <owner>/<repo>#<number> (owner and repo lowercased), a commit by sha, an organization by its login, and a membership <user>#<organization>. The write routes look records up by exactly that key.
The shortest path to a world of your own is a shipped one: GET /_rystic/inspect returns the state in the shape seed takes, git objects included.
curl -s -X POST $B/_rystic/scenario -d '{"name":"github-pulls_open_queue"}'
curl -s $B/_rystic/inspect > world.json # edit the pull requests, keep the rest
curl -s -X POST $B/_rystic/reset
curl -s -X POST $B/_rystic/seed -d @world.json # 204
A pull request needs its repository, both branches, their commits and its author seeded with it; a record that names one the seed does not hold 404s later. Worlds covers seed files, GET /_rystic/registers and the seed gate.
Determinism & reset
Pull-request and issue numbers continue the world’s sequence; ids come from fixed bands the seeded ids stay out of — review 9100001, review comment 8200001, pull request id 2100001, issue id 4100001, issue comment 6100001 onward — and repeat run to run. A world’s clock is pinned (the authored worlds at 2026-02-01T00:00:00Z, an empty twin at 2026-01-01T00:00:00Z) and does not follow wall time, so records you create carry the world’s timestamps. Reset and determinism covers reset, the clock and the RNG.
Webhooks
Repository and organization hooks are served: GET, POST /repos/{owner}/{repo}/hooks, GET, PATCH, DELETE /repos/{owner}/{repo}/hooks/{hook_id}, GET, PATCH .../hooks/{hook_id}/config and POST .../hooks/{hook_id}/pings, and the same under /orgs/{org}/hooks. A hook defaults to events: ["push"] and active: true; "*" subscribes to every event; the secret is never read back.
The twin POSTs to hooks only when it runs with RYSTIC_WEBHOOK_DELIVERY=on; without it, every event still lands in the egress log and nothing leaves the machine. With it, a hook gets a ping when it is created or pinged, and the push, pull_request, pull_request_review, pull_request_review_comment, check_run, check_suite, workflow_run, workflow_job, issues, issue_comment, label, milestone and status events it subscribed to, from its repository or, for an organization hook, from the organization’s repositories. Each delivery carries GitHub’s headers: X-GitHub-Event, X-GitHub-Delivery (a GUID shared by every hook the event reaches), X-GitHub-Hook-ID, X-GitHub-Hook-Installation-Target-ID and -Type (the repository’s or organization’s id, and repository or organization) and User-Agent: GitHub-Hookshot/…. When the hook has a secret it also carries X-Hub-Signature: sha1=<hex HMAC-SHA1> and X-Hub-Signature-256: sha256=<hex HMAC-SHA256> of the body, which @octokit/webhooks verifies. The body is JSON whatever content_type says. A label, assignee or lock change on a pull request’s number arrives as pull_request, and so does a reopen, as action: reopened, through PATCH .../pulls/{pull_number}, PATCH .../issues/{issue_number} or reopenPullRequest; editing or closing a pull request through PATCH .../pulls/{pull_number} sends nothing yet.
A delivery is tried once, with no automatic retry, as on GitHub. Each attempt sent is recorded, a hook’s newest 500 kept, and the deliveries API reads the records:
GET .../hooks/{hook_id}/deliverieslists the hook’s deliveries newest first, withper_page(30, at most 100), acursortheLinkheader’snextandprevname, andstatus=success|failure(a 2xx–3xx or a 4xx–5xxstatus_code).GET .../deliveries/{delivery_id}adds theurl, therequestheaders and payload as sent, and the receiver’sresponseheaders and payload.POST .../deliveries/{delivery_id}/attemptsanswers202and sends the delivery again, the same payload and GUID, to the hook’s current URL and signed under its current secret, as GitHub uses the current webhook configuration. The redelivery is recorded as a new delivery withredelivery: true.
A receiver that answers non-2xx is recorded with status Invalid HTTP Response: <code>, one that refuses the connection as failed to connect (502), and one that times out as timed out. A repository hook’s last_response follows its latest delivery. The same routes serve organization hooks under /orgs/{org}/hooks. GET /_rystic/egress also reports each attempt under delivery.
curl -s -X POST $B/repos/tb-owner/tb-repo/hooks -H 'Authorization: Bearer ghp_anything' \
-d '{"events":["pull_request"],"config":{"url":"http://127.0.0.1:3000/webhook","content_type":"json","secret":"s3cret"}}'
rystic grid turns delivery on in every cell and gives each cell’s agent its own $PORT to listen on. examples/github-app-agent/agent.py registers a hook there, verifies each signature and comments on the pull request it sees merged.
Workflow runs
gh workflow run, gh workflow list, gh run list, gh run view (with --log, --log-failed, --job and --attempt), gh run rerun (with --failed and --job) and gh run cancel work against the twin. A workflow is a .github/workflows file on the default branch, or one a stored run names. Each workflow a push, a pull request or a dispatch starts is one run in a check suite of its own, with one job per check run (the same id), completed from the evaluation engine as its check run is; a job’s log is the evaluator’s output. A dispatch needs a ref the repository holds and a workflow_dispatch trigger in the file at that ref, and runs that workflow with the inputs it was sent. A run’s or a job’s logs answer 302 to an expiring download: a zip with one <n>_<job name>.txt per job, or the job’s text.
A re-run is the run’s next attempt (run_attempt): every job, the failed ones, or one job, each with the jobs that need it, while the rest carry over with their results; .../attempts/{n} serves an earlier one. Cancel ends every unfinished job. Each step fires workflow_run, workflow_job and check_suite webhooks.
A harness can also set runs, outcomes and logs through /_rystic/seed: state_workflow_run takes GitHub’s run fields plus repository.full_name, and state_workflow_job takes a job’s fields, its run_id and its log text in log. A partial record merges. A seeded run cannot be re-run.
curl -s -X POST $B/_rystic/seed -d '{
"state_workflow_run": {"9001": {"id": 9001, "repository": {"full_name": "tb-owner/tb-repo"},
"path": ".github/workflows/pipeline.yml", "name": "pipeline", "event": "workflow_dispatch",
"head_branch": "tb-base-branch", "head_sha": "<sha>", "status": "completed", "conclusion": "failure"}},
"state_workflow_job": {"9101": {"id": 9101, "run_id": 9001, "name": "converge",
"status": "completed", "conclusion": "failure", "log": "##[error]3 probes diverged\n"}}}'
gh run view 9001 --log -R tb-owner/tb-repo
# -> converge UNKNOWN STEP ##[error]3 probes diverged
Egress log
Every webhook event your requests trigger renders into one ordered log, whether or not a hook receives it, and GET /_rystic/egress returns it (Observing). Approving and merging #41 in github-pulls_open_queue produces two:
{"events":[{"seq":1,"effect":"eff_pull_request_review_notification",
"type":"github-notifications/subscribers-of-tb-owner/tb-repo/pulls/41",
"payload":{"action":"submitted","…":"…"}},
{"seq":2,"effect":"eff_merge_notification","type":"pull_request",
"payload":{"action":"closed","number":41,"…":"…"}}],
"total":2,"dropped":0}
payload is the envelope GitHub would have delivered, its URLs on GitHub’s hosts rewritten to the origin you read the log on, as a delivered hook’s are; ?raw=1 returns them on GitHub’s hosts. Opening a pull request adds a pull_request event with action: "opened".
Limitations
- Webhook delivery is one attempt, JSON only. Webhooks are POSTed once, under
RYSTIC_WEBHOOK_DELIVERY=on, with no automatic retry and noformencoding, and events other than the ten listed there are not delivered. What a delivery records and how it redelivers is measured against no live receiver. GitHub App webhooks are not modelled: an App-style agent registers a repository or organization hook. - Nothing moves on its own. No bot leaves reviews. A push, and a pull request that opens, moves or reopens, starts the repository’s workflows as one check run per job, which the twin’s evaluation engine completes without running the job; a pull request’s are evaluated on its test merge and report on its head, and a conflicting one starts none; any other check run changes only when your client PATCHes it.
mergeableandmergeable_stateare settled values, nevernullwhile computing as GitHub’s first read can be: a world seeds them, and the twin rewrites the git half whenever a write touches the pull request or moves its base (a conflicting test merge readsfalse/"dirty"), while a merge or update-branch asks git itself at request time. A commit status, check run, review or branch protection write re-settlesmergeable_statefrom the base’s protection and the head’s results:blockedwhile a required context or approval is unmet, elseunstablewhile a result is unfinished or red, elseclean. - Repositories and teams arrive with the world. There is no create-repository or create-team route; seed them. Branches come from the world, a
git push, or a seed. - Actors are a header. GitHub derives the caller from the token; here it is
X-Rystic-Actor, andghcannot send it. - URLs. Response URLs on
api.github.com,github.comand its subdomains, andavatars.githubusercontent.comare rewritten to the twin’s origin,html_urland_links.htmlincluded (RYSTIC_PRESERVE_SERVICE_URLS=1keeps GitHub’s hosts); the twin serves no web page, so following anhtml_urlanswers GitHub’s404 Not Found. The authored worlds seed the URLs of their users, repositories, commits, files, reviews, teams and check runs on those hosts, so a client that follows one reaches the twin, where a route outside Coverage answers GitHub’s404 Not Found:GET /users/{login}, which everyuser.urlnames, is one. The importedgithub-pulls_garrytan_gbrainworld still carrieshttps://example.invalid/gh/…in its URL fields, and a client that follows them fails there. - Headers. A read carries a weak
ETagwhere GitHub’s does (GET /search/issuesandGET /rate_limitcarry none, as on GitHub) and a matchingIf-None-Matchanswers304, butIf-Modified-Sinceis not answered. The headers GitHub sends that the twin does not are under Known issues below. - List query values. Past the last page, the pull-request list’s
Linkpointsprevone page back and carries nolast, where GitHub, and the twin’s other page-numbered lists, point both at the last page. The pull-request list answerspage=0,per_page=0,page=abc,state=bogusandsort=boguswith GitHub’s defaults and ignores aheadvalue withoutuser:ororganization:, as measured against GitHub; it answers an unknowndirectionand a non-numericper_pagewith the defaults too, which no round has measured. - Reviews. A submitted review removes its author from
requested_reviewers, as GitHub documents, but no converge round has read that list back against GitHub yet. - Malformed bodies. Bytes that are not JSON answer
400 Problems parsing JSONonPATCHpulls and issues and write nothing. GitHub reads a truncated object leniently: a body missing only its closing brace is accepted, and a dangling value fails its schema check with422on the issues route, where the twin answers400to both. - Representations.
application/vnd.github.diffand.patchrender from the twin’s own git objects in GitHub’s form, except that a commit made through the API carries aDate:in UTC where GitHub stamps the committing account’s time zone;body_htmlis CommonMark with GitHub’s extensions rather than GitHub’s renderer (Coverage), so mentions and issue references stay text. - GitHub Apps are served, not measured. The converge oracle holds a personal access token, not an App, so every answer only an App’s JWT or installation token reaches is the model’s, unmeasured; only a request with no credential or a malformed one is compared with GitHub. The twin never checks a JWT’s signature, and a token’s permissions grant one standing per repository rather than GitHub’s per-endpoint check. An App’s own webhook (
/app/hook) is not served. - One process is one GitHub. Run several on different ports for isolated worlds.
- Out of scope. Code browsing (
GET /repos/{owner}/{repo}/contents/{path},GET /repos/{owner}/{repo}/git/trees/{tree_sha}), code and commit search (GET /search/code,GET /search/commits) and issue subscriptions (/notifications/threads/{thread_id}/subscription, GraphQLviewerSubscriptionandupdateSubscription) are not in this twin and will not be: REST answers GitHub’s404 Not Found, GraphQLundefinedField. GraphQLsearchwith atypeother thanISSUEorISSUE_ADVANCED(DISCUSSION,REPOSITORY,USER) is refused with an error namingg_search_type_served, and package and wiki search are not served. “New changes since you last viewed” and the compare page’s “Able to merge” before a pull request exists have no public API, so there is nothing to simulate.
Known issues.
- The GitHub App routes are served but not measured: the oracle holds a personal access token, so only a request with no credential or a malformed one is compared with GitHub. The twin checks an App JWT’s structure, not its signature, and an installation token’s permissions grant one standing per repository rather than GitHub’s per-endpoint check
- Responses carry no
X-GitHub-Request-IdorX-GitHub-Media-Type,X-RateLimit-*only onGET /rate_limit, and a JSON answer’sContent-Typelacks GitHub’s; charset=utf-8 - Review-request writes log no issue events; contents, deleting a branch through the API, hiding comments and resolving review threads are not served; the twin’s merge commits are unsigned where GitHub signs them; and some write permissions are looser than GitHub’s
- A codespace’s
created_at,updated_atandlast_used_atare written in UTC with aZ, where GitHub writes them at the account’s UTC offset, so compare them as RFC 3339 times, not as strings - A pull request’s
commentscount is stored rather than counted from the issue comments addressed to it, and no round checks that the two agree - A push that moves an open pull request’s head keeps its
mergeable_state, where GitHub recomputes it for the new head (cleanuntil the new head’s results report); status, check-run, review and protection writes do re-settle it - Repository rulesets are not served:
GET .../rulesetsandGET .../rules/branches/{branch}answer 404, and only classic branch protection gates a merge - A git push to a branch protected through
PUT .../protectiongoes through; only a branch its world seedsprotectedrefuses pushes, and it refuses every one - GraphQL
branchProtectionRuleanswers null and a rollup context’sisRequiredis never true, although REST serves the branch’s protection GET /orgs/{org}is not served and answers 404, so terraform-provider-github’s startup lookup of its owner finds no organization. Workaround: Name a user account as the provider’s owner: the provider reads the 404 as a user owner and carries on, and its repository data sources plan against the twin; an organization owner is taken for a user, and organization resources are not served.
Not served. 1080 of the 1192 operations in the published spec are not served.
- apps, 9 of 37 served:
POST /app-manifests/{code}/conversions,GET /app/hook/config,PATCH /app/hook/config,GET /app/hook/deliveries,GET /app/hook/deliveries/{delivery_id},POST /app/hook/deliveries/{delivery_id}/attempts,GET /app/installation-requests,DELETE /app/installations/{installation_id}, … and 20 more - checks, 6 of 12 served:
POST /repos/{owner}/{repo}/check-suites,PATCH /repos/{owner}/{repo}/check-suites/preferences,GET /repos/{owner}/{repo}/check-suites/{check_suite_id},GET /repos/{owner}/{repo}/check-suites/{check_suite_id}/check-runs,POST /repos/{owner}/{repo}/check-suites/{check_suite_id}/rerequest,GET /repos/{owner}/{repo}/commits/{ref}/check-suites - codespaces, 3 of 48 served:
GET /orgs/{org}/codespaces,PUT /orgs/{org}/codespaces/access,POST /orgs/{org}/codespaces/access/selected_users,DELETE /orgs/{org}/codespaces/access/selected_users,GET /orgs/{org}/codespaces/secrets,GET /orgs/{org}/codespaces/secrets/public-key,GET /orgs/{org}/codespaces/secrets/{secret_name},PUT /orgs/{org}/codespaces/secrets/{secret_name}, … and 37 more - issues, 34 of 55 served:
GET /issues,GET /orgs/{org}/issues,GET /repos/{owner}/{repo}/assignees/{assignee},PUT /repos/{owner}/{repo}/issues/comments/{comment_id}/pin,DELETE /repos/{owner}/{repo}/issues/comments/{comment_id}/pin,GET /repos/{owner}/{repo}/issues/{issue_number}/assignees/{assignee},GET /repos/{owner}/{repo}/issues/{issue_number}/dependencies/blocked_by,POST /repos/{owner}/{repo}/issues/{issue_number}/dependencies/blocked_by, … and 13 more - meta, 1 of 5 served:
GET /meta,GET /octocat,GET /versions,GET /zen - orgs, 2 of 108 served:
GET /organizations,GET /orgs/{org},PATCH /orgs/{org},DELETE /orgs/{org},POST /orgs/{org}/artifacts/metadata/deployment-record,POST /orgs/{org}/artifacts/metadata/deployment-record/cluster/{cluster},POST /orgs/{org}/artifacts/metadata/storage-record,GET /orgs/{org}/artifacts/{subject_digest}/metadata/deployment-records, … and 98 more - reactions, 9 of 15 served:
GET /repos/{owner}/{repo}/comments/{comment_id}/reactions,POST /repos/{owner}/{repo}/comments/{comment_id}/reactions,DELETE /repos/{owner}/{repo}/comments/{comment_id}/reactions/{reaction_id},GET /repos/{owner}/{repo}/releases/{release_id}/reactions,POST /repos/{owner}/{repo}/releases/{release_id}/reactions,DELETE /repos/{owner}/{repo}/releases/{release_id}/reactions/{reaction_id} - repos, 18 of 203 served:
GET /orgs/{org}/repos,POST /orgs/{org}/repos,GET /orgs/{org}/rulesets,POST /orgs/{org}/rulesets,GET /orgs/{org}/rulesets/rule-suites,GET /orgs/{org}/rulesets/rule-suites/{rule_suite_id},GET /orgs/{org}/rulesets/{ruleset_id},PUT /orgs/{org}/rulesets/{ruleset_id}, … and 177 more - search, 1 of 7 served:
GET /search/code,GET /search/commits,GET /search/labels,GET /search/repositories,GET /search/topics,GET /search/users - users, 1 of 47 served:
PATCH /user,GET /user/blocks,GET /user/blocks/{username},PUT /user/blocks/{username},DELETE /user/blocks/{username},PATCH /user/email/visibility,GET /user/emails,POST /user/emails, … and 38 more - Not served at all: actions (187), activity (32), agent-tasks (5), agents (30), billing (13), campaigns (5), classroom (6), code-quality (2), code-scanning (21), code-security (20), codes-of-conduct (2), copilot (29), copilot-spaces (28), credentials (1), dependabot (25), dependency-graph (5), emojis (1), enterprise-team-memberships (6), enterprise-team-organizations (6), enterprise-teams (5), … and 15 more
Required fields the model does not declare. The spec requires these in a success response; the model’s response shape leaves them out, so the twin may not return them:
GET /installation/repositories(200):repositories/*/archive_url,repositories/*/assignees_url,repositories/*/blobs_url,repositories/*/branches_url,repositories/*/clone_url,repositories/*/collaborators_url,repositories/*/comments_url,repositories/*/commits_url, … and 54 morePATCH /repos/{owner}/{repo}/check-runs/{check_run_id}(200):app/created_at,app/description,app/events,app/external_url,app/html_url,app/owner/events_url,app/owner/followers_url,app/owner/following_url, … and 10 morePATCH /repos/{owner}/{repo}/pulls/{pull_number}(200):additions,assignee,author_association,body,changed_files,closed_at,comments,comments_url, … and 29 more
Not measured. The fidelity suite runs 540 probes, and 133 documented branches have none. 77 need a credential the measuring account does not hold (200 ×8, 201 ×2, 204 ×1, 401 ×7, 403 ×49, 404 ×7, 422 ×3). The other 56:
| Operation | Status | Why |
|---|---|---|
DELETE /repos/{owner}/{repo}/issues/{issue_number}/labels | 301 | no request a client sends provokes it |
DELETE /user/codespaces/{codespace_name} | 304 | no request a client sends provokes it |
DELETE /user/codespaces/{codespace_name} | 500 | no request a client sends provokes it |
GET /app/installations/{installation_id} | 401 | nothing the twin serves creates the record it needs |
GET /installation/repositories | 304 | no request a client sends provokes it |
GET /orgs/{org}/installation | 401 | nothing the twin serves creates the record it needs |
GET /rate_limit | 304 | no request a client sends provokes it |
GET /rate_limit | 404 | no request a client sends provokes it |
GET /repos/{owner}/{repo} | 301 | no request a client sends provokes it |
GET /repos/{owner}/{repo}/check-runs/{check_run_id} | 200 | nothing the twin serves creates the record it needs |
GET /repos/{owner}/{repo}/check-runs/{check_run_id}/annotations | 200 | nothing the twin serves creates the record it needs |
GET /repos/{owner}/{repo}/commits/{ref}/check-runs | 200 | nothing the twin serves creates the record it needs |
GET /repos/{owner}/{repo}/commits/{ref}/status | 200 | nothing the twin serves creates the record it needs |
GET /repos/{owner}/{repo}/commits/{ref}/statuses | 200 | nothing the twin serves creates the record it needs |
GET /repos/{owner}/{repo}/commits/{ref}/statuses | 301 | no request a client sends provokes it |
GET /repos/{owner}/{repo}/installation | 301 | no request a client sends provokes it |
GET /repos/{owner}/{repo}/issues/comments/{comment_id}/reactions | 422 | nothing the twin serves creates the record it needs |
GET /repos/{owner}/{repo}/issues/events | 422 | the model describes no input that reaches it |
GET /repos/{owner}/{repo}/issues/events/{event_id} | 410 | nothing the twin serves creates the record it needs |
GET /repos/{owner}/{repo}/issues/{issue_number}/reactions | 422 | nothing the twin serves creates the record it needs |
| … and 36 more |
Not modeled. What the model’s operations state they leave out:
- No executable probe recipe: the harness presents the run’s own credential and cannot be made to authenticate with a freshly minted installation access token, so the 204 branch cannot be driven against the oracle from this model. The missing precondition is a credential swap the harness does not support; the producer (iv_op_post_app_installations_installation_id_access_tokens) mints a token into a response body, not into the request’s Authorization header. (
DELETE /installation/token) - The answer to a request whose credential is not an installation access token (PAT, JWT, already-revoked or expired token) is undocumented in the bundle and left unmodelled — the credential guard’s on_false is recorded as unknown rather than pointed at an invented refusal branch. (
DELETE /installation/token) - Whether the 204 is also returned when the presented token has already been revoked (idempotent revoke) or whether that answers 401 is unverified. (
DELETE /installation/token) - Whether a 403 fires for plan reasons on a private repository without GitHub Pro (the operation’s description lists the products protected branches are available in), separately from the admin-standing refusal. (
DELETE /repos/{owner}/{repo}/branches/{branch}/protection) - Whether deleting protection while a ruleset (state_repository_ruleset) still protects the branch leaves the branch effectively protected; rulesets are a separate, read-only family in this scope. (
DELETE /repos/{owner}/{repo}/branches/{branch}/protection) - Admin/owner standing: the sibling PUT/PATCH protection routes require admin or owner permissions and document a 403, but this operation documents no 403 branch, so no permission guard is modelled here. (
DELETE /repos/{owner}/{repo}/branches/{branch}/protection/required_pull_request_reviews) - Whether a repository on a plan without protected branches (GitHub Free private repositories) answers a different status for this route. (
DELETE /repos/{owner}/{repo}/branches/{branch}/protection/required_pull_request_reviews) - Entitlement: branch protection on a private repository requires a paid GitHub plan; whether the fixture’s private repository can carry a protection record at all is unmeasured. (
DELETE /repos/{owner}/{repo}/branches/{branch}/protection/required_status_checks) - Permission refusal: the sibling protection routes document a 403 for callers without admin or owner standing (state model asm_branch_protection_requires_admin), but this operation documents only 204; no branch is modeled for it, and the run’s caller owns the fixture repository, so the condition cannot be exercised here. (
DELETE /repos/{owner}/{repo}/branches/{branch}/protection/required_status_checks) - Whether a repeated removal (no required_status_checks member present) answers 204 again or 404 is undocumented. (
DELETE /repos/{owner}/{repo}/branches/{branch}/protection/required_status_checks) - Pinned comments (the issue-comment pin surface) are out of scope. (
DELETE /repos/{owner}/{repo}/issues/comments/{comment_id}) - Rate limiting (429 / 403 secondary-rate-limit) and the 503 service-unavailable branch are not modeled. (
DELETE /repos/{owner}/{repo}/issues/comments/{comment_id},DELETE /repos/{owner}/{repo}/issues/{issue_number}/lock,GET /repos/{owner}/{repo}/issues,GET /repos/{owner}/{repo}/issues/comments,GET /repos/{owner}/{repo}/issues/comments/{comment_id},GET /repos/{owner}/{repo}/issues/{issue_number},GET /repos/{owner}/{repo}/issues/{issue_number}/comments,PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}, … and 4 more) - Reactions on an issue comment are not modeled in this scope: state_reaction is keyed by review-comment id, so deleting an issue comment cascades to nothing. (
DELETE /repos/{owner}/{repo}/issues/comments/{comment_id}) - Rate limiting (429) and secondary-rate-limit responses are not modeled for this operation. (
DELETE /repos/{owner}/{repo}/issues/comments/{comment_id}/reactions/{reaction_id},DELETE /repos/{owner}/{repo}/issues/{issue_number}/reactions/{reaction_id},DELETE /repos/{owner}/{repo}/pulls/comments/{comment_id},DELETE /repos/{owner}/{repo}/pulls/comments/{comment_id}/reactions/{reaction_id}) - The alternate route DELETE /repositories/{repository_id}/issues/comments/{comment_id}/reactions/{reaction_id} mentioned in the description (repository addressed by id instead of owner/repo) is not modeled as a separate vector. (
DELETE /repos/{owner}/{repo}/issues/comments/{comment_id}/reactions/{reaction_id}) - Whether a caller with repository admin/push access may delete another user’s reaction (softening guard_caller_owns_reaction) is undocumented; the guard models the author-only rule. (
DELETE /repos/{owner}/{repo}/issues/comments/{comment_id}/reactions/{reaction_id},DELETE /repos/{owner}/{repo}/issues/{issue_number}/reactions/{reaction_id},DELETE /repos/{owner}/{repo}/pulls/comments/{comment_id}/reactions/{reaction_id}) - Whether a reaction survives the deletion of its parent issue comment is not modeled here. (
DELETE /repos/{owner}/{repo}/issues/comments/{comment_id}/reactions/{reaction_id}) - A caller without push access, whose assignees GitHub drops, is not modeled: every harness identity holds push access. (
DELETE /repos/{owner}/{repo}/issues/{issue_number}/assignees,POST /repos/{owner}/{repo}/issues/{issue_number}/assignees) - The timeline events assigned / unassigned are not written here. (
DELETE /repos/{owner}/{repo}/issues/{issue_number}/assignees,POST /repos/{owner}/{repo}/issues/{issue_number}/assignees) - GitHub appends one
unlabeledstate_issue_event per removed label, which the events and timeline reads would then list. No evidence chunk in this bundle documents that append and the state model’s asm_issue_event_appended_by_in_scope_writes does not name the label routes, so no state_issue_event write is declared here. (DELETE /repos/{owner}/{repo}/issues/{issue_number}/labels) - … and 285 more
Cookbook
Every recipe runs in github-pulls_open_queue. Start each from a clean world with curl -s -X POST $B/_rystic/scenario -d '{"name":"github-pulls_open_queue"}'.
Review and merge a pull request
#41 is tb-contributor’s; tb-reviewer-login (actor_b) is the requested reviewer.
H='Authorization: Bearer ghp_anything'
R=$B/repos/tb-owner/tb-repo
# the author cannot approve their own work — GitHub's rule, and the twin's
curl -s -X POST $R/pulls/41/reviews -H "$H" -H 'X-Rystic-Actor: tb-contributor' \
-H 'Content-Type: application/json' -d '{"body":"lgtm","event":"APPROVE"}'
# -> 422 {"errors":["Review Can not approve your own pull request"],"message":"Unprocessable Entity",...}
# the requested reviewer can
curl -s -X POST $R/pulls/41/reviews -H "$H" -H 'X-Rystic-Actor: actor_b' \
-H 'Content-Type: application/json' -d '{"body":"Looks good.","event":"APPROVE"}'
# -> 200 {"id":9100001,"state":"APPROVED","author_association":"COLLABORATOR",...}
# a reader cannot merge; the owner can
curl -s -X PUT $R/pulls/41/merge -H "$H" -H 'X-Rystic-Actor: tb-contributor' \
-H 'Content-Type: application/json' -d '{"merge_method":"squash"}'
# -> 403 {"message":"Must have write access to repository.",...}
curl -s -X PUT $R/pulls/41/merge -H "$H" \
-H 'Content-Type: application/json' -d '{"merge_method":"squash"}'
# -> 200 {"merged":true,"message":"Pull Request successfully merged","sha":"a419dbb3…"}
curl -s $R/pulls/41 -H "$H" | jq -c '{state, merged, merged_by: .merged_by.login}'
# -> {"state":"closed","merged":true,"merged_by":"tb-owner"}
#42 stays blocked even with both requested reviewers’ approvals, and its merge answers 405 Pull Request is not mergeable — a refusal path to test against.
Open a pull request from a pushed branch
Every branch in the world already has a pull request, so push a new one:
git clone $B/tb-owner/tb-repo.git && cd tb-repo
git checkout -b tb-rate-limit
mkdir -p pkg/limit && printf 'package limit\n\nconst Burst = 10\n' > pkg/limit/limit.go
git add pkg/limit && git commit -m "Add a rate limiter" && git push origin tb-rate-limit
curl -s -X POST $R/pulls -H "$H" -H 'Content-Type: application/json' \
-d '{"title":"Add rate limiting","head":"tb-rate-limit","base":"tb-base-branch"}'
# -> 201 {"number":45,"id":2100001,"state":"open","additions":3,"changed_files":1,...}
member opened it, so member cannot approve it; actor_b can, and after a merge git fetch finds the squash commit on origin/tb-base-branch.
Drive a review-comment thread
A review comment anchors to a line of the pull request’s diff at its head sha:
SHA=$(curl -s $R/pulls/41 -H "$H" | jq -r .head.sha)
curl -s $R/pulls/41/files -H "$H" | jq -r '.[].filename' # pkg/page/cursor.go
curl -s -X POST $R/pulls/41/comments -H "$H" -H 'X-Rystic-Actor: actor_b' \
-H 'Content-Type: application/json' \
-d "{\"body\":\"Should this be <= ?\",\"path\":\"pkg/page/cursor.go\",\"line\":12,\"side\":\"RIGHT\",\"commit_id\":\"$SHA\"}"
# -> 201 {"id":8200001,"path":"pkg/page/cursor.go","line":12,...}
curl -s -X POST $R/pulls/41/comments/8200001/replies -H "$H" -H 'X-Rystic-Actor: tb-contributor' \
-H 'Content-Type: application/json' -d '{"body":"No: the cursor is exclusive."}'
# -> 201 {"id":8200002,"in_reply_to_id":8200001,...}
A path the diff does not touch answers 422 with path is invalid.
File an issue and comment on it
curl -s -X POST $R/issues -H "$H" -H 'Content-Type: application/json' \
-d '{"title":"Retries drop the last error","body":"Seen twice on CI"}'
# -> 201 {"number":45,"id":4100001,"state":"open",...} — the next number in the shared sequence
curl -s -X POST $R/issues/45/comments -H "$H" -H 'Content-Type: application/json' -d '{"body":"Reproduced"}'
# -> 201 {"id":6100001,...}
curl -s -X PATCH $R/issues/45 -H "$H" -H 'Content-Type: application/json' \
-d '{"state":"closed","state_reason":"completed"}'
Byte-identical CI runs
curl -s -X POST $B/_rystic/scenario -d '{"name":"github-pulls_open_queue"}'
curl -s -X POST $B/_rystic/seed-rng -d '{"seed":42}'
# ... run your review flow ...
curl -s $B/_rystic/inspect > state.json
curl -s $B/_rystic/egress | jq -c '.events[] | {seq, effect, type}' > events.jsonl
# diff both against the last green run
Release notes: Changelog