Skip to Content

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:

ActorIsStanding on tb-owner/tb-repo
tb-ownerthe repository ownerADMIN
tb-maintainera collaboratorWRITE
tb-reviewer-logina collaboratorWRITE
tb-contributora user, not a collaboratorREAD
memberan alias for tb-owner, and the default callerADMIN
actor_ban alias for tb-reviewer-loginWRITE

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:

  1. An author approving their own pull request gets 422 Unprocessable Entity with errors ["Review Can not approve your own pull request"], as GitHub answers.
  2. A seeded user without write access gets 403 Must have write access to repository. from merge, review dismissal, and requesting or removing review requests.
  3. A login the world seeds no user for gets 401 Bad credentials from 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.

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

  1. Qualifiers. repo:, user:, org: and owner: 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: and language:. People: author:, assignee:, review-requested:, reviewed-by:, commenter:, mentions: and involves:, with @me as the caller. Content: label:, milestone:, no:label|assignee|milestone, head: and base: (a branch-name prefix), comments: with a count or range, and review:none|approved|changes_requested. Dates: created:, updated:, closed: and merged:. A date is YYYY-MM-DD, covering its whole UTC day, or an ISO 8601 timestamp; a..b is inclusive, * leaves an end open, and >=, >, <= and < bound one side.
  2. Combining. Terms AND, and - negates any of them. Repeating author: or another person qualifier ORs; repeating label: ANDs, and label:a,b ORs. AND, OR and parentheses are operators only under the advanced syntax, which gh sends as type: ISSUE_ADVANCED and REST takes with advanced_search=true; there every repeat ANDs and -( … ) matches nothing. In the default syntax a bare parenthesis matches nothing, and AND / OR join only free text.
  3. Free text. Words match title, body and comments, case-insensitively, or the fields in:title, in:body and in:comments name. A word in the record also matches the query word one suffix shorter, so fix finds “fixes” and “fixed”, but fixes does not find “fix” and there is no prefix match. A quoted phrase matches its words in order. A key the grammar does not know, like foo:bar, is text.
  4. 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 answers 501 with X-Rystic-Marker: unmodeled_guard; iv=iv_op_get_search_issues; guard=g_qualifiers_modelled and GitHub’s error envelope (message, documentation_url, status), GraphQL an error naming that guard. The body does not yet name the qualifier.
  5. Results. total_count and issueCount count every match; per_page (at most 100) and page window the REST items with GitHub’s Link header, and sort/order or an in-query sort:created-asc order them, newest first by default. A pull request’s item carries draft and its own node_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 and comments: 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
  1. git push to an open pull request’s head branch moves the pull request — head sha, commits, files, a synchronize notification. 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.
  2. A merge through the API moves the base branch: git fetch afterwards finds the commit the merge response named.
  3. refs/pull/{n}/merge is 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’s merge_commit_sha names. A head that conflicts with its base has none, reads mergeable: false, mergeable_state: "dirty", and its merge answers 405. 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, and rebase still replays each commit’s files whole.
  4. files[].patch on a pull request is the diff of its commits.
  5. github-pulls_garrytan_gbrain serves 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.

  1. 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.
  2. 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-Actor set to that user. The twin applies its permission rules, GET /_rystic/calls attributes the write, and a refused write shows the twin’s own status and message; writes are never retried.
  3. 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.
  4. Webhook events lists what the twin rendered into GET /_rystic/egress, newest first, with each payload: the deliveries GitHub would have made.
  5. 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.
  6. 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.
  7. 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.

ClientVersionKindWorks withFails withCheckedKnown gap
cli/cliv2.102.0cliproxy, default host—2026-10-29—
octokitv10.0.0sdkbase URL, default host—2026-10-29—
@octokit/restv22.0.1sdkbase URL, default host—2026-10-29—
integrations/githubv6.13.0terraform-provider—base URL, default host2026-10-29RYS-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.

ScenarioWhat it holds
github-pulls_open_queuefour 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_teamsfour 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_queuethe same merged bot, then five open pull requests (#11–#15) each breaking it one way
github-pulls_issue_trackerfive issues and one pull request (#3) sharing a number sequence, with conversation comments
github-pulls_garrytan_gbraina 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:

  1. GET .../hooks/{hook_id}/deliveries lists the hook’s deliveries newest first, with per_page (30, at most 100), a cursor the Link header’s next and prev name, and status=success|failure (a 2xx–3xx or a 4xx–5xx status_code).
  2. GET .../deliveries/{delivery_id} adds the url, the request headers and payload as sent, and the receiver’s response headers and payload.
  3. POST .../deliveries/{delivery_id}/attempts answers 202 and 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 with redelivery: 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

  1. Webhook delivery is one attempt, JSON only. Webhooks are POSTed once, under RYSTIC_WEBHOOK_DELIVERY=on, with no automatic retry and no form encoding, 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.
  2. 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. mergeable and mergeable_state are settled values, never null while 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 reads false / "dirty"), while a merge or update-branch asks git itself at request time. A commit status, check run, review or branch protection write re-settles mergeable_state from the base’s protection and the head’s results: blocked while a required context or approval is unmet, else unstable while a result is unfinished or red, else clean.
  3. 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.
  4. Actors are a header. GitHub derives the caller from the token; here it is X-Rystic-Actor, and gh cannot send it.
  5. URLs. Response URLs on api.github.com, github.com and its subdomains, and avatars.githubusercontent.com are rewritten to the twin’s origin, html_url and _links.html included (RYSTIC_PRESERVE_SERVICE_URLS=1 keeps GitHub’s hosts); the twin serves no web page, so following an html_url answers GitHub’s 404 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’s 404 Not Found: GET /users/{login}, which every user.url names, is one. The imported github-pulls_garrytan_gbrain world still carries https://example.invalid/gh/… in its URL fields, and a client that follows them fails there.
  6. Headers. A read carries a weak ETag where GitHub’s does (GET /search/issues and GET /rate_limit carry none, as on GitHub) and a matching If-None-Match answers 304, but If-Modified-Since is not answered. The headers GitHub sends that the twin does not are under Known issues below.
  7. List query values. Past the last page, the pull-request list’s Link points prev one page back and carries no last, where GitHub, and the twin’s other page-numbered lists, point both at the last page. The pull-request list answers page=0, per_page=0, page=abc, state=bogus and sort=bogus with GitHub’s defaults and ignores a head value without user: or organization:, as measured against GitHub; it answers an unknown direction and a non-numeric per_page with the defaults too, which no round has measured.
  8. 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.
  9. Malformed bodies. Bytes that are not JSON answer 400 Problems parsing JSON on PATCH pulls 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 with 422 on the issues route, where the twin answers 400 to both.
  10. Representations. application/vnd.github.diff and .patch render from the twin’s own git objects in GitHub’s form, except that a commit made through the API carries a Date: in UTC where GitHub stamps the committing account’s time zone; body_html is CommonMark with GitHub’s extensions rather than GitHub’s renderer (Coverage), so mentions and issue references stay text.
  11. 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.
  12. One process is one GitHub. Run several on different ports for isolated worlds.
  13. 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, GraphQL viewerSubscription and updateSubscription) are not in this twin and will not be: REST answers GitHub’s 404 Not Found, GraphQL undefinedField. GraphQL search with a type other than ISSUE or ISSUE_ADVANCED (DISCUSSION, REPOSITORY, USER) is refused with an error naming g_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-Id or X-GitHub-Media-Type, X-RateLimit-* only on GET /rate_limit, and a JSON answer’s Content-Type lacks 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_at and last_used_at are written in UTC with a Z, where GitHub writes them at the account’s UTC offset, so compare them as RFC 3339 times, not as strings
  • A pull request’s comments count 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 (clean until the new head’s results report); status, check-run, review and protection writes do re-settle it
  • Repository rulesets are not served: GET .../rulesets and GET .../rules/branches/{branch} answer 404, and only classic branch protection gates a merge
  • A git push to a branch protected through PUT .../protection goes through; only a branch its world seeds protected refuses pushes, and it refuses every one
  • GraphQL branchProtectionRule answers null and a rollup context’s isRequired is 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 more
  • PATCH /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 more
  • PATCH /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:

OperationStatusWhy
DELETE /repos/{owner}/{repo}/issues/{issue_number}/labels301no request a client sends provokes it
DELETE /user/codespaces/{codespace_name}304no request a client sends provokes it
DELETE /user/codespaces/{codespace_name}500no request a client sends provokes it
GET /app/installations/{installation_id}401nothing the twin serves creates the record it needs
GET /installation/repositories304no request a client sends provokes it
GET /orgs/{org}/installation401nothing the twin serves creates the record it needs
GET /rate_limit304no request a client sends provokes it
GET /rate_limit404no request a client sends provokes it
GET /repos/{owner}/{repo}301no request a client sends provokes it
GET /repos/{owner}/{repo}/check-runs/{check_run_id}200nothing the twin serves creates the record it needs
GET /repos/{owner}/{repo}/check-runs/{check_run_id}/annotations200nothing the twin serves creates the record it needs
GET /repos/{owner}/{repo}/commits/{ref}/check-runs200nothing the twin serves creates the record it needs
GET /repos/{owner}/{repo}/commits/{ref}/status200nothing the twin serves creates the record it needs
GET /repos/{owner}/{repo}/commits/{ref}/statuses200nothing the twin serves creates the record it needs
GET /repos/{owner}/{repo}/commits/{ref}/statuses301no request a client sends provokes it
GET /repos/{owner}/{repo}/installation301no request a client sends provokes it
GET /repos/{owner}/{repo}/issues/comments/{comment_id}/reactions422nothing the twin serves creates the record it needs
GET /repos/{owner}/{repo}/issues/events422the model describes no input that reaches it
GET /repos/{owner}/{repo}/issues/events/{event_id}410nothing the twin serves creates the record it needs
GET /repos/{owner}/{repo}/issues/{issue_number}/reactions422nothing 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 unlabeled state_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

Last updated on