# Change requests The CR data model, lifecycle, diff/merge semantics, CLI surface, and REST API. Canonical page: https://kortix.com/docs/reference/change-requests Kortix's pull-request equivalent — the path session work takes to reach `main`. For the plain-language version see [Change requests](/docs/concepts/change-requests); for a review walkthrough see [Reviewing and merging](/docs/quickstart). A CR proposes merging one branch (`head_ref`) into another (`base_ref`) inside a single Kortix project. The layer is Kortix-native — it works on any git host (GitHub, GitLab, plain git) without per-host integration. The CR row is metadata; the git operations (fetch, diff, three-way merge, fast-forward) run inside the Kortix API against the project's `repo_url` backend. ## The agent mandate An agent in a session sandbox MUST open a CR to land any change on `main`. Sessions run on ephemeral branches (`session-`); the sandbox dies at end-of-session and nothing reaches `main` unless a CR merges it. Future sessions boot from `main` — without merging, the work is invisible to every other agent, trigger fire, and collaborator. The contract: 1. **Commit on the session branch** (`$KORTIX_BRANCH_NAME`). Small, working commits. Don't rewrite or force-push. 2. **Push the branch** (`git push origin HEAD`). 3. **Open the CR** (`kortix cr open --title "…" --description "…"`). From inside the sandbox `--head` and `--session` are auto-detected from `$KORTIX_BRANCH_NAME` and `$KORTIX_SESSION_ID`; `--base` defaults to the project's default branch. 4. **Surface the CR number to the user** (`kortix cr ls`) so they can review. 5. **Stop. The agent does not merge its own CR.** Merging is the user's call, from the dashboard or `kortix cr merge `. Applies to everything: code edits, new files (skills, agents, slash commands, tools, plugins), manifest edits (`kortix.yaml` or legacy `kortix.toml` — triggers, env/secrets, sandbox, agent grants), `AGENTS.md` changes, new MCP server configs — anything committed to the tree. There is no "small enough to skip the CR" exception. ### Anti-patterns - **Force-pushing to `main`.** Breaks the user's review contract even where the backend allows it. - **"I committed it on my branch, the user can pull it."** The session branch dies; they can't pull it once the sandbox shuts down unless it's merged. - **Bundling the change as a tarball / paste / gist.** A workaround for a problem the CR system already solves. ## Data model CRs live in the `change_requests` table. | Column | Type | Notes | | --- | --- | --- | | `cr_id` | uuid (PK) | Stable identifier. What the REST API uses. | | `account_id` | uuid | Tenant. | | `project_id` | uuid | Project the CR belongs to. Cascade-deleted with the project. | | `number` | integer | Short, per-project, monotonically-increasing display number. `#1`, `#2`, … Unique per project. | | `title` | text | Required. | | `description` | text | Defaults to empty string. | | `base_ref` | text | The branch being merged *into*. Usually `main`. | | `head_ref` | text | The branch being merged *from*. Usually `session-`. | | `status` | enum | `open` \| `merged` \| `closed`. | | `head_commit_sha` | text (nullable) | Refreshed against the live `head_ref` tip on every read for open CRs. Captured at merge time for merged CRs. | | `base_commit_sha` | text (nullable) | Same idea. For merged CRs this is the base SHA *before* the merge commit was created. | | `origin_session_id` | text (nullable) | The session that opened the CR. FK to `project_sessions.session_id` with `ON DELETE SET NULL`, so closing the originating session orphans the link rather than deleting the CR. | | `created_by` | uuid | User who created the CR (or sandbox token's resolved user). | | `merged_at` | timestamptz (nullable) | When the merge ran. | | `merged_by` | uuid (nullable) | Who triggered the merge. | | `merge_commit_sha` | text (nullable) | The merge commit. For fast-forwards equals `head_commit_sha` at merge time. | | `closed_at` | timestamptz (nullable) | When the CR was closed without merging. | | `closed_by` | uuid (nullable) | Who closed it. | | `metadata` | jsonb | Free-form key/value. Defaults to `{}`. Holds `requested_changes: { text, by, at }[]` — the log of human "request changes" notes appended by `POST /:crId/request-changes` (CRs have no separate comment table). | | `created_at` | timestamptz | Defaults to now. | | `updated_at` | timestamptz | Updated on every status change or SHA refresh. | Indexes: - `idx_change_requests_account` on `account_id` - `idx_change_requests_project` on `project_id` - `idx_change_requests_project_status` on `(project_id, status)` - `idx_change_requests_project_number` (**unique**) on `(project_id, number)` The unique index on `(project_id, number)` lets the CLI accept `kortix cr show 3` — `3` resolves to the row with `number = 3` for the project. Numbers don't recycle on close; the counter keeps going. ## Lifecycle ``` open │ ├── kortix cr close ──▶ closed ──┐ │ │ │ │ kortix cr reopen │ │ │ │ ◀──────────────────────┘ │ │ │ ├── kortix cr merge ──▶ merged ─┘ (terminal) ``` - `open` is the starting state. - `closed` is reversible: `kortix cr reopen ` puts it back to `open`. The branch tips are re-resolved on the next read. - `merged` is **terminal**. You cannot reopen a merged CR — open a new one against the post-merge state if you need to. - You cannot close a merged CR — already final. When a CR is merged, the row captures the SHAs that were active at merge time: - `merge_commit_sha` ← the new merge commit (or fast-forward target). - `base_commit_sha` ← the base SHA *before* the merge. - `head_commit_sha` ← the head branch's tip at merge time. (For fast-forwards this equals `merge_commit_sha`; for three-way merges it stays at the original head tip, so the diff can re-render via `base...head`.) ## SHA refresh For open CRs, the API refreshes `head_commit_sha` and `base_commit_sha` against the live branches on every read. If the repo is unreachable or a branch is missing, the refresh is skipped silently — the row still serves its existing metadata, so the UI renders title / description / status even when the repo is temporarily down. ## Diff semantics `GET /v1/projects/:projectId/change-requests/:crId/diff` returns a unified patch with `files`, `additions`, `deletions`, and per-file status (`added`, `modified`, `deleted`). - For `open` and `closed` CRs: diff is **three-dot** between live `base_ref` and live `head_ref` — i.e. `base...head`, which excludes changes already on `base_ref` that aren't on `head_ref`. - For `merged` CRs: diff is computed from the **captured SHAs** (`base_commit_sha`, `head_commit_sha`), so the patch still renders even though `head_ref` is now reachable from `base_ref` post-merge. `kortix cr diff` falls back to no color when stdout isn't a TTY (or with `--no-color`). ## Merge mechanics `POST /v1/projects/:projectId/change-requests/:crId/merge` runs through `mergeBranches`: 0. **Manifest gate.** Before touching git, the route reads `kortix.yaml` (or legacy `kortix.toml`) from the CR's `head_ref` — the version about to be merged — and validates it against the canonical manifest schema (the same validator `kortix ship` runs pre-flight). A branch with no manifest at all is fine. A branch whose manifest fails to validate is not: 422 with `code: "MANIFEST_INVALID"` and an `issues` array, and the merge never runs. 1. Fast-forward if `head_ref` is strictly ahead of `base_ref`. 2. Otherwise create a merge commit (three-way merge). Default message `Merge CR #: `; override with `--message`. Author is `Kortix <noreply@kortix.ai>`. 3. On success: update the `change_requests` row to `merged`, capture SHAs, invalidate the project's mirror cache. 4. On conflict: 409 with `error: <specific message from the merge, e.g. "Merge conflicts detected — resolve before merging">` and the conflict list available via `GET /merge-preview`. ### Merge preview `GET /v1/projects/:projectId/change-requests/:crId/merge-preview` returns: ```ts { base_sha: string, // current tip of base_ref head_sha: string, // current tip of head_ref merge_base: string | null, // common ancestor (null if histories are unrelated) is_up_to_date: boolean, // head_ref is fully merged into base can_merge: boolean, // no conflicts can_fast_forward: boolean, // head is strictly ahead of base conflicts: string[], // file paths that would conflict } ``` `kortix cr show <cr>` calls this automatically for open CRs and renders it inline. Run `show` before `merge` to see what you're about to do. ### Conflicts If the merge preview lists conflicts, the agent does not merge anyway to auto-resolve. Standard recovery: 1. On the session branch, `git pull origin <base_ref>` (or merge `main` into the branch). 2. Resolve conflicts locally with `edit`. 3. Commit + push. 4. `kortix cr show <cr>` to confirm the preview is now clean. 5. Hand back to the user to merge. ## CLI surface The full surface is in the [CLI reference](/docs/reference/cli). Summary: | Command | What it does | | --- | --- | | `kortix cr ls [--status open\|merged\|closed\|all]` | List CRs on the project. Default: `open`. | | `kortix cr show <cr>` | Metadata + merge preview. | | `kortix cr diff <cr> [--no-color]` | Unified patch. | | `kortix cr open --title "..." [--description "..."] [--head <ref>] [--base <ref>]` | Open a CR. | | `kortix cr merge <cr> [--message "..."]` | Merge it (fast-forward or three-way). | | `kortix cr close <cr>` | Close without merging. | | `kortix cr reopen <cr>` | Reopen a closed CR. | `<cr>` is the per-project number (`3` or `#3`) or the UUID `cr_id`. ### Sandbox auto-detection When `kortix cr open` runs inside a session sandbox: - `--head` defaults to `$KORTIX_BRANCH_NAME` (or `$KORTIX_HEAD_REF`). - `--session` defaults to `$KORTIX_SESSION_ID`, which back-fills `origin_session_id` on the row so the dashboard can show which session opened the CR. - `--base` defaults to the project's default branch (from `projects.default_branch`, usually `main`). - `--project` defaults to the session's project (from `$KORTIX_PROJECT_ID`). - `--title` is the only required flag. Minimal viable invocation in a sandbox: ```sh kortix cr open --title "Add release-notes skill" \ --description "Drafts release notes from merged commits. Tested against the last 5 tags." ``` ## REST API All endpoints under `/v1/projects/:projectId/change-requests`. The CLI is a thin wrapper. | Method | Path | Notes | | --- | --- | --- | | GET | `/` | `?status=open\|merged\|closed\|all` (default `all`). | | POST | `/` | Body: `{ title, description?, head_ref, base_ref?, session_id? }`. Returns 201 + serialized CR. | | GET | `/:crId` | Returns `{ change_request: ... }`. Refreshes SHAs as a side effect. | | PATCH | `/:crId` | Edit `title` / `description`. | | GET | `/:crId/diff` | Unified patch + file list. | | GET | `/:crId/merge-preview` | Conflict + fast-forward analysis. | | POST | `/:crId/merge` | Body: `{ message?: string }`. 422 `MANIFEST_INVALID` if the head branch's manifest fails schema validation. 409 on conflict or non-`open` status. | | POST | `/:crId/close` | No body. 409 if already `merged`. | | POST | `/:crId/reopen` | No body. 409 if not `closed`. | | POST | `/:crId/request-changes` | Body: `{ feedback }`. Appends `{ text, by, at }` to `metadata.requested_changes` and delivers the feedback to the originating session's agent (best-effort, background — a sleeping sandbox is woken to receive it). 409 if not `open`. | Read endpoints (`GET /`, `GET /:crId`, `GET /:crId/diff`, `GET /:crId/merge-preview`) require **read** access to the project. Mutating endpoints (`POST /`, `PATCH /:crId`, `POST /:crId/merge`, `POST /:crId/close`, `POST /:crId/reopen`, `POST /:crId/request-changes`) require **write** access; `POST /` and `POST /:crId/request-changes` additionally require the `project.gitops.push` capability, and `POST /:crId/merge` additionally requires `project.gitops.merge`. Mismatched token → 403. Validation on `POST /`: - `title` required (non-empty). - `head_ref` required. - `base_ref` defaults to the project's `default_branch`. - `head_ref === base_ref` → 400 (must differ). - `session_id` is validated against `project_sessions`; unknown session IDs are silently dropped (`origin_session_id` becomes null). - Branch tips are resolved at create time, so the CR row has anchor SHAs from the moment it's opened. - `head_ref` must have at least one commit ahead of `base_ref` — otherwise 422 with `code: "CR_HEAD_NOT_AHEAD"` (an empty CR could never be applied — it would render "No changes detected" and `merge-preview` would report it un-mergeable). This covers both a committed-but-never-pushed session branch (head tip == base tip) and a stale branch behind an advanced base. The resolver force-refetches the mirror before concluding "not ahead," so a push that just landed doesn't bounce — this is the concrete error an agent sees if it skips step 2 of [the agent mandate](#the-agent-mandate). ## Composition with the rest of the system | Surface | How the CR composes | | --- | --- | | **Sessions** | A CR's `origin_session_id` is back-filled from `$KORTIX_SESSION_ID` so the dashboard shows the session that opened it. Cascade is `ON DELETE SET NULL` — closing the session orphans the link, doesn't delete the CR. | | **Skills** | New `.kortix/opencode/skills/<name>/SKILL.md` files reach future sessions **only** after a CR that contains them merges to `main`. Until then, only the originating session sees them. | | **Agents** | Same: new `.kortix/opencode/agents/<agent>.md` files need to land via CR. | | **Triggers** | Edits to `triggers:` (v2) or `[[triggers]]` (legacy v1) only reach the scheduler after the CR merges. The scheduler reads the manifest on `main`. | | **Secrets** | Decoupled. Secrets live in the Kortix Secrets Manager, not the manifest; CRs don't move secrets. | | **Dashboard** | Renders CR list / detail / diff / merge button. Same data as the CLI sees. | | **Triggers firing inside a session** | A trigger-spawned session can itself open a CR — same flow. | ## Gotchas - **Merge is `Kortix <noreply@kortix.ai>`-authored.** If you want the user's name on the merge commit, that's a dashboard-side option, not a CLI flag today. - **You cannot close a `merged` CR.** It's the terminal state. - **You cannot reopen a `merged` CR.** Open a new one against the post-merge tip. - **Branch deletion is not automatic.** After a CR merges, the head branch still exists in the git backend. If the project policy is to clean up session branches, that's a separate sweep — not part of the CR merge. - **The session-branch tip changes after the agent commits more.** CRs that read live tips will reflect new commits on the head branch even after the CR is opened — the diff updates. There's no freeze-on-open semantic. - **The `KORTIX_*` env vars expected at `cr open` time:** `KORTIX_CLI_TOKEN` (or its alias `KORTIX_EXECUTOR_TOKEN`), `KORTIX_API_URL`, `KORTIX_PROJECT_ID`, `KORTIX_BRANCH_NAME` (or `KORTIX_HEAD_REF`), `KORTIX_SESSION_ID`. All are pre-injected by the session bootstrap. Note `KORTIX_TOKEN` is a *different* credential — the sandbox daemon's own service key, not a CLI token — and is not what the CLI authenticates with. Running `kortix cr open` outside a session needs `--head` and `--project` explicitly, or a cwd linked via `kortix projects link`.