You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Central Dogma applies every change immediately: a push lands on the repository HEAD and watching clients
see it right away. This is a poor fit for several operational workflows:
Operators often prepare a configuration change ahead of time and want to apply it at a chosen moment
(e.g. during a maintenance window), not at the moment of writing it.
There is no built-in way to have a change reviewed before it takes effect, even though a mistake in a
configuration repository propagates to clients instantly.
Users without write permission cannot propose a change; they have to hand the content to someone with
write access out of band.
This proposal adds a GitHub-style pull request workflow: stage a set of changes against a repository as a
pull request, review and approve it, then merge it with one click (or one API call) at the time of your
choosing.
Goals
Stage a proposed change set (one or more files) against a repository without touching its HEAD.
Review workflow: approve / request changes. Merging requires at least one approval.
One-click merge that applies the staged changes as a single ordinary commit.
Detect when a pull request is outdated — a file it touches changed since the pull request was created —
and block merging until the author updates it.
Full REST API and web UI. Identical behavior in standalone and replicated deployments.
Non-goals (v1)
Scheduled or automatic merge at a specified time. The pull request workflow is the prerequisite for it;
see Future work.
Git branches. Central Dogma repositories are single-branch by design; a pull request here is a stored
change set, not a source branch.
Comment threads or line comments. A v1 review carries a state and an optional comment.
System design
Architecture
A pull request is a JSON document describing a proposed change set. It lives outside the target repository
until merged; merging turns it into an ordinary commit on the target repository.
flowchart TD
C["Web UI / clients"] -->|REST| A["Pull request API"]
A --> E["Command executor"]
E -.->|"replicated log"| R["Other replicas"]
E -->|"create, review, close"| S[("Pull request store<br/>RocksDB, per replica")]
E -->|"merge commit"| G[("Target repository<br/>Git")]
Loading
Every mutation — creating a pull request, reviewing it, merging it — is expressed as a command and executed
through the command executor, the same write path as ordinary pushes. In a replicated deployment the
command log is the only channel between replicas, so routing pull request state through it is what makes
the feature work identically on one replica or many.
Storage
Pull request documents are not stored in Git. They are staging data and inherently ephemeral: once a
pull request is merged, its content lives in the target repository, and the staged copy is dead weight.
Central Dogma's Git storage keeps every revision forever and cannot reclaim space, so storing pull requests
there would grow the data directory without bound over the lifetime of a deployment.
Instead, each replica keeps pull requests in a local RocksDB store under its data directory. RocksDB is
already embedded in the server (it backs the encryption-at-rest storage), and the replication pattern is
also established: like login sessions and encryption keys, the store itself is per-replica local state
that stays consistent because all mutations arrive as replicated commands and every replica applies
the same command log in the same order.
Layout:
{project}/{repo}/counter # next pull request number, per target repository
{project}/{repo}/{number} # one JSON document per pull request
Properties by construction:
Replication and durability — new command types (create / update / review / merge-finalize / close)
carry the full payload through the command log; every replica converges on the same store content.
Space is reclaimable — deleting a pull request actually frees storage, unlike a Git-backed store.
This makes a retention policy (e.g. expire merged and closed pull requests after a configurable period)
possible; see Future work.
Sequential numbering — the number is allocated from the per-repository counter inside the execution
of the create command. Commands are totally ordered, so every replica allocates the same number.
Atomicity — state transitions are conditional updates validated inside command execution (e.g. a
review command is rejected unless the document is still OPEN); total ordering makes the validation
race-free.
Auditability trade-off — unlike Git storage, the store keeps no independent history. Each document
therefore records its lifecycle facts (creation, reviews, merge, close — each with user and timestamp),
which serve as the audit record and are removed together with the document.
The store is exposed behind a small repository interface following the existing CrudRepository pattern,
extended with an atomic conditional-update operation for state transitions (the existing update() is a
non-atomic read-then-write), so the storage backend stays swappable.
Replication considerations
The new commands follow the contract the existing commands already obey:
A command carries all of its inputs — author, timestamps, content. Execution derives everything from the
command plus the current store state and never reads the wall clock, so replay on another replica is
deterministic.
Re-applying an already-applied command converges to a no-op with the same result: document writes are
idempotent puts, and a transition that finds its target state already reached succeeds idempotently.
If a replica's replay result diverges from the recorded result, it fences itself into read-only mode —
the same protection the replication layer applies to every existing command.
Replicas are provisioned from a data-directory snapshot, as today; the pull request store lives inside
the data directory and rides along.
Pull request lifecycle
flowchart LR
N(( )) -->|create| OPEN
OPEN -->|"review / update"| OPEN
OPEN -->|merge| MERGED
OPEN -->|close| CLOSED
CLOSED -->|reopen| OPEN
Loading
Creating a pull request requires only READ permission on the target repository: letting users without
write permission propose changes is one of the main points of the feature. Merging and reviewing require WRITE; the full mapping is in the API table below.
Updating the changes of a pull request resets its reviews and re-bases it on the current HEAD;
updating only the title or description keeps the reviews.
A review is APPROVED or CHANGES_REQUESTED, one entry per reviewer (a new review by the same reviewer
replaces the previous one). Authors cannot review their own pull requests.
MERGED is terminal. CLOSED pull requests can be reopened.
changes reuses the existing Change JSON format — the same shape the push API accepts.
status is OPEN, MERGED or CLOSED. A merged document additionally records mergedRevision and
the merger; a closed document records who closed it and when.
v1 restricts change types to UPSERT_JSON, UPSERT_TEXT and REMOVE. APPLY_JSON_PATCH, APPLY_TEXT_PATCH and RENAME are rejected with 400 for now: upserts render trivially in a diff UI and
merge deterministically. The restriction can be lifted later.
REST API
Method
Path
Required permission
POST
/api/v1/projects/{p}/repos/{r}/pulls
READ
GET
/api/v1/projects/{p}/repos/{r}/pulls?status=
READ
GET
/api/v1/projects/{p}/repos/{r}/pulls/{n}
READ
PUT
/api/v1/projects/{p}/repos/{r}/pulls/{n}
author (or ADMIN)
POST
/api/v1/projects/{p}/repos/{r}/pulls/{n}/reviews
WRITE, not the author
POST
/api/v1/projects/{p}/repos/{r}/pulls/{n}/merge
WRITE
POST
/api/v1/projects/{p}/repos/{r}/pulls/{n}/close
author or WRITE
POST
/api/v1/projects/{p}/repos/{r}/pulls/{n}/reopen
author or WRITE
The path parameters are the target repository, so the existing permission model applies unchanged. The
pull request store is internal and never exposed directly. Pull requests cannot target internal
repositories.
GET .../pulls/{n} additionally reports outdated and the current headRevision of the target
repository.
Errors: 404 for an unknown pull request; 409 for a wrong status, a missing approval, an active change
request, an outdated pull request or conflicting content; 403 for permission failures.
Merge semantics
Merging pull request #N applies its stored changes to the target repository HEAD:
Verify the status is OPEN, at least one APPROVED review exists and no CHANGES_REQUESTED review is
active; otherwise 409.
Preview the stored changes against HEAD. An empty diff means the content is already applied — for
example, a previous merge crashed between pushing and finalizing — so skip to step 5.
Outdated check: if any path touched by the pull request changed between baseRevision and HEAD,
respond 409; the author must update the pull request first.
Push the stored changes as a single commit authored by the merger, with the summary Merge pull request #N: <title>; the commit detail records the original author.
Finalize: transition the document to MERGED, recording mergedRevision, the merger and the time.
Merging an already-merged pull request is idempotent.
Conflict handling
Because step 3 compares content rather than timestamps, the workflow is honest about drift: a pull request
merges only while the files it touches are exactly as they were at its base revision — otherwise the author
re-bases explicitly. There is no implicit three-way merge. Concretely:
Pull request vs. direct push — a direct push that touches a pull request's path makes it outdated:
the detail API and UI flag it, and merging returns 409 until the author updates the pull request
(which re-bases it and resets reviews).
Pull request vs. pull request — two open pull requests touching the same path are both valid until
one merges; the merge makes the other outdated. First merge wins, the second author re-bases.
Unrelated changes never block — the check covers only the paths the pull request touches, so
ordinary traffic to other files does not invalidate it.
Identical content — if the target files already contain exactly the proposed content (a manual push
of the same change, or a retry after a crash between push and finalize), the merge succeeds by
finalizing only (step 2).
Concurrent merges of the same pull request — commands are totally ordered, so one merge wins and the
other observes MERGED and returns idempotently.
Close racing a merge — if a close lands after the merge commit was pushed but before finalization,
the pull request still ends MERGED: the applied commit is the source of truth.
Residual push conflicts — a conflict surfacing at push time despite the outdated gate (e.g. a path
type clash) returns 409 with the cause, and the pull request stays OPEN.
Web UI
A Pull Requests button on the repository page, next to History, leading to a list page with
Open / Merged / Closed filters.
A detail page showing the diff (rendered with the existing diff viewer), the description, review states
and an outdated badge, with Approve / Request changes / Merge / Close buttons gated by
repository role.
The file editor and the new-file screen gain a Commit directly / Propose as pull request choice.
Users with only READ permission get the pull request option alone — today their commit attempt would
simply fail with 403. Multi-file pull requests are API-only in v1.
Future work
Scheduled merge: merge automatically at a specified time — the original operational motivation — via a
leader-only scheduler, following the mirror scheduler pattern.
Retention policy: expire merged and closed pull requests after a configurable period. Deletion
actually reclaims space in this design.
Support for APPLY_JSON_PATCH / APPLY_TEXT_PATCH / RENAME changes.
Multi-file pull request creation in the web UI.
Notification integration (e.g. webhooks) for pull request events.
Per-repository merge policy, e.g. a configurable required-approval count.
Open questions
Should the approval requirement be configurable per repository from the start? v1 proposes a fixed rule:
at least one approval and no active change request.
Should merged and closed pull requests be kept indefinitely by default, or expire after a default period?
Is pulls the right API path segment, or should it be spelled out as pull-requests?
Proposal: Pull request workflow for repositories
Motivation
Central Dogma applies every change immediately: a push lands on the repository HEAD and watching clients
see it right away. This is a poor fit for several operational workflows:
(e.g. during a maintenance window), not at the moment of writing it.
configuration repository propagates to clients instantly.
write access out of band.
This proposal adds a GitHub-style pull request workflow: stage a set of changes against a repository as a
pull request, review and approve it, then merge it with one click (or one API call) at the time of your
choosing.
Goals
and block merging until the author updates it.
Non-goals (v1)
see Future work.
change set, not a source branch.
System design
Architecture
A pull request is a JSON document describing a proposed change set. It lives outside the target repository
until merged; merging turns it into an ordinary commit on the target repository.
flowchart TD C["Web UI / clients"] -->|REST| A["Pull request API"] A --> E["Command executor"] E -.->|"replicated log"| R["Other replicas"] E -->|"create, review, close"| S[("Pull request store<br/>RocksDB, per replica")] E -->|"merge commit"| G[("Target repository<br/>Git")]Every mutation — creating a pull request, reviewing it, merging it — is expressed as a command and executed
through the command executor, the same write path as ordinary pushes. In a replicated deployment the
command log is the only channel between replicas, so routing pull request state through it is what makes
the feature work identically on one replica or many.
Storage
Pull request documents are not stored in Git. They are staging data and inherently ephemeral: once a
pull request is merged, its content lives in the target repository, and the staged copy is dead weight.
Central Dogma's Git storage keeps every revision forever and cannot reclaim space, so storing pull requests
there would grow the data directory without bound over the lifetime of a deployment.
Instead, each replica keeps pull requests in a local RocksDB store under its data directory. RocksDB is
already embedded in the server (it backs the encryption-at-rest storage), and the replication pattern is
also established: like login sessions and encryption keys, the store itself is per-replica local state
that stays consistent because all mutations arrive as replicated commands and every replica applies
the same command log in the same order.
Layout:
Properties by construction:
carry the full payload through the command log; every replica converges on the same store content.
This makes a retention policy (e.g. expire merged and closed pull requests after a configurable period)
possible; see Future work.
of the create command. Commands are totally ordered, so every replica allocates the same number.
review command is rejected unless the document is still
OPEN); total ordering makes the validationrace-free.
therefore records its lifecycle facts (creation, reviews, merge, close — each with user and timestamp),
which serve as the audit record and are removed together with the document.
The store is exposed behind a small repository interface following the existing
CrudRepositorypattern,extended with an atomic conditional-update operation for state transitions (the existing
update()is anon-atomic read-then-write), so the storage backend stays swappable.
Replication considerations
The new commands follow the contract the existing commands already obey:
command plus the current store state and never reads the wall clock, so replay on another replica is
deterministic.
idempotent puts, and a transition that finds its target state already reached succeeds idempotently.
the same protection the replication layer applies to every existing command.
the data directory and rides along.
Pull request lifecycle
flowchart LR N(( )) -->|create| OPEN OPEN -->|"review / update"| OPEN OPEN -->|merge| MERGED OPEN -->|close| CLOSED CLOSED -->|reopen| OPENREADpermission on the target repository: letting users withoutwrite permission propose changes is one of the main points of the feature. Merging and reviewing require
WRITE; the full mapping is in the API table below.updating only the title or description keeps the reviews.
APPROVEDorCHANGES_REQUESTED, one entry per reviewer (a new review by the same reviewerreplaces the previous one). Authors cannot review their own pull requests.
MERGEDis terminal.CLOSEDpull requests can be reopened.Data model
{ "number": 42, "title": "Increase connection timeout of backend-a", "description": "To be applied during the maintenance window.", "author": { "name": "alice", "email": "alice@example.com" }, "baseRevision": 1234, "changes": [ { "path": "/backend-a/config.json", "type": "UPSERT_JSON", "content": { "timeoutMillis": 10000 } } ], "status": "OPEN", "reviews": [ { "reviewer": "bob@example.com", "state": "APPROVED", "comment": "LGTM", "timestamp": "2026-08-06T12:00:00Z" } ], "creation": { "user": "alice@example.com", "timestamp": "2026-08-05T09:00:00Z" } }changesreuses the existingChangeJSON format — the same shape the push API accepts.statusisOPEN,MERGEDorCLOSED. A merged document additionally recordsmergedRevisionandthe merger; a closed document records who closed it and when.
UPSERT_JSON,UPSERT_TEXTandREMOVE.APPLY_JSON_PATCH,APPLY_TEXT_PATCHandRENAMEare rejected with 400 for now: upserts render trivially in a diff UI andmerge deterministically. The restriction can be lifted later.
REST API
/api/v1/projects/{p}/repos/{r}/pullsREAD/api/v1/projects/{p}/repos/{r}/pulls?status=READ/api/v1/projects/{p}/repos/{r}/pulls/{n}READ/api/v1/projects/{p}/repos/{r}/pulls/{n}ADMIN)/api/v1/projects/{p}/repos/{r}/pulls/{n}/reviewsWRITE, not the author/api/v1/projects/{p}/repos/{r}/pulls/{n}/mergeWRITE/api/v1/projects/{p}/repos/{r}/pulls/{n}/closeWRITE/api/v1/projects/{p}/repos/{r}/pulls/{n}/reopenWRITEpull request store is internal and never exposed directly. Pull requests cannot target internal
repositories.
GET .../pulls/{n}additionally reportsoutdatedand the currentheadRevisionof the targetrepository.
request, an outdated pull request or conflicting content; 403 for permission failures.
Merge semantics
Merging pull request #N applies its stored changes to the target repository HEAD:
OPEN, at least oneAPPROVEDreview exists and noCHANGES_REQUESTEDreview isactive; otherwise 409.
example, a previous merge crashed between pushing and finalizing — so skip to step 5.
baseRevisionand HEAD,respond 409; the author must update the pull request first.
Merge pull request #N: <title>; the commit detail records the original author.MERGED, recordingmergedRevision, the merger and the time.Merging an already-merged pull request is idempotent.
Conflict handling
Because step 3 compares content rather than timestamps, the workflow is honest about drift: a pull request
merges only while the files it touches are exactly as they were at its base revision — otherwise the author
re-bases explicitly. There is no implicit three-way merge. Concretely:
the detail API and UI flag it, and merging returns 409 until the author updates the pull request
(which re-bases it and resets reviews).
one merges; the merge makes the other outdated. First merge wins, the second author re-bases.
ordinary traffic to other files does not invalidate it.
of the same change, or a retry after a crash between push and finalize), the merge succeeds by
finalizing only (step 2).
other observes
MERGEDand returns idempotently.the pull request still ends
MERGED: the applied commit is the source of truth.type clash) returns 409 with the cause, and the pull request stays
OPEN.Web UI
Open / Merged / Closed filters.
and an outdated badge, with Approve / Request changes / Merge / Close buttons gated by
repository role.
Users with only
READpermission get the pull request option alone — today their commit attempt wouldsimply fail with 403. Multi-file pull requests are API-only in v1.
Future work
leader-only scheduler, following the mirror scheduler pattern.
actually reclaims space in this design.
APPLY_JSON_PATCH/APPLY_TEXT_PATCH/RENAMEchanges.Open questions
at least one approval and no active change request.
pullsthe right API path segment, or should it be spelled out aspull-requests?