# GitHub simulator

A stateful GitHub simulator — pull requests, reviews, issues, checks, git and gh, with real permission rules.

`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:

```sh
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

```bash
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](https://www.rystic.ai/docs/licensing.md#enterprise-license-files)). Plain `docker pull` and Compose: [Docker](https://www.rystic.ai/docs/docker.md).

The examples on this page use `B` for the URL `rystic run` printed:

```bash
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:

```python
from github import Github, Auth
gh = Github(auth=Auth.Token("ghp_anything"), base_url="http://localhost:8080")
```

```js
const { Octokit } = require("@octokit/rest");
const octokit = new Octokit({ auth: "ghp_anything", baseUrl: "http://localhost:8080" });
```

`rystic stop default` stops it. [git and gh](#git-and-gh) covers `git clone` and the `gh` CLI.

## Plans

[Plans and API keys](https://www.rystic.ai/docs/licensing.md#plans) 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](https://www.rystic.ai/docs/control-plane.md#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:

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.

```bash
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

<!-- BEGIN generated: served-operations (go run ./ingest/cmd/twindocs github-pulls; do not edit) -->

<!-- coverage-claim -->
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}`.

<!-- END generated: served-operations -->

`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](#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.

```bash
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](#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:

```bash
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](#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

<!-- BEGIN generated: known-clients (go run ./ingest/cmd/twindocs github-pulls; do not edit) -->

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 |

<!-- END generated: known-clients -->

`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](https://www.rystic.ai/docs/control-plane.md#routes-by-plan). `GET /_rystic/egress` holds the webhook events your requests triggered ([Egress log](#egress-log)), `/_rystic/ui` adds the GitHub pull request view ([The pull request UI](#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](#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](https://www.rystic.ai/docs/concepts/worlds.md) 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.

```bash
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](https://www.rystic.ai/docs/concepts/worlds.md) 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](https://www.rystic.ai/docs/concepts/reset-and-determinism.md) 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](#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`.

```bash
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.

```bash
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](https://www.rystic.ai/docs/concepts/observing.md)). Approving and merging #41 in `github-pulls_open_queue` produces two:

```json
{"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](#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](#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](#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.

<!-- BEGIN generated: known-gaps (go run ./ingest/cmd/twindocs github-pulls; do not edit) -->

**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:

| 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 `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

<!-- END generated: known-gaps -->

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

```bash
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:

```bash
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:

```bash
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

```bash
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

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

---

Source: https://www.rystic.ai/docs/simulators/github-pulls · Markdown: https://www.rystic.ai/docs/simulators/github-pulls.md
