Koja Specification Proposal

The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in RFC 2119.

Koja is a contribution and socialization layer on top of Git and Matrix with a core goal of preserving decentralization. Traditional forges require an account on each instance, which in practice leads to centralization for convenience. Koja instead proposes a federated workflow powered by Git patches and Matrix.

Koja client

A Koja client connects to the user’s Matrix homeserver and performs all actions on the user’s account. A Koja client can include functionality like sending patches or creating repositories Using federation, any Koja client can contribute to any project, regardless of which homeserver hosts it.

Code hosting is independent of Koja, and a repository’s code can live on any Git host.

Decentralization

The examples in this document use example homeservers such as example.org. Koja is fully decentralized - the client talks directly to your own Matrix homeserver, so there is no central instance to depend on. Anyone can use any Matrix homeserver they choose. Since all actions are performed through Matrix, a patch submitted from one Koja client can be merged by a maintainer using a completely different Koja client.

Authentication

When a user logs in interactively, they MUST be prompted with the standard Matrix login flow, and a device SHOULD be created on their account. A client MAY also support logging in with an existing access token, in which case it SHOULD validate the token against the user’s homeserver before storing it. The user’s Matrix credentials SHOULD then be stored locally on the device. That stored session is what the client uses for all subsequent actions.

The user may terminate the session on any Matrix client.

Repositories

A repository has a Matrix room on any homeserver, technically not a Git repository. This room contains core state and event timeline for the repository. The room creation MUST have a room type of koja.repository. Repository rooms MUST receive events when a patch (and in the future, issue) is submitted. A maintainer is defined as a user with a power level higher or equal to 50.

A repository room MUST contain at least one koja.repository.origin state event. They look like this:

State Event koja.repository.origin
State key: slug for the origin (e.g. "origin", "backup", "mirror")
{
  "url": "ssh://...",
  "display_name": "",
  "order": "" // how clients should order origins in UI. Same convention as https://spec.matrix.org/v1.17/client-server-api/#ordering-of-children-within-a-space
}

Repository Creation

When creating a repository, the Koja client SHOULD first confirm the user is authenticated and that their homeserver can be reached. If the user is not authenticated or their homeserver cannot be reached, an error SHOULD be returned. Once auth has been confirmed, the repository room MUST be created on the user’s account with the room name set via m.room.name.

The Koja client MUST send at least one koja.repository.origin state event, with its URL set to the Git URL the user is publishing.

The Koja client SHOULD return information regarding the created room to the user.

Patch requests (PRs)

When the Koja client is asked to submit a patch, it MUST first confirm the user is authenticated and that their homeserver can be reached. If the user is not authenticated or their homeserver cannot be reached, an error MUST be returned. Once auth has been confirmed, the PR room MUST be created on the user’s account.

When submitting a patch, the user provides a slug, this is for the user to publish revisions to their PR in the future. A slug is stored locally by the Koja client and is unique per repository per Matrix user.

State Event m.room.create
{
  "m.federate": true,
  "room_version": "12",
  "type": "koja.patch_request",
  "koja.repository": {
    "room_id": "matrix room id of the repository",
    "via": [
      "PR creator's homeserver",
      "repo owner's homeserver",
      "...up to 3 maintainer homeservers"
    ]
  }
}

The via array MUST contain the homeserver of the patch request creator and the repository owner. It MAY additionally contain up to 3 homeservers of repository maintainers. These servers serve as routing hints for federation to the repository room. Refer to the Matrix spec on routing for more information.

The Koja client MAY send the initial patch revision.

Event koja.revision
{
  "url": "mxc://",
  "body": "",
  "target": "master"
}

The patch is uploaded as a Matrix asset to the user's homeserver. The url field contains the mxc:// URI.

The room MUST have the following state events:

State Event koja.active_revision
{
  "event_id": ""
}

An active revision is the one the contributor wishes to get merged.

State Event koja.draft
{
  "is_draft": false
}

Clients SHOULD NOT allow maintainers to merge drafts.

State Event koja.merged
State key: The sender's Matrix ID (e.g. @user:homeserver.tld)
{
  "is_merged": false
}

See Status Resolution for how clients MUST interpret this state.

State Event koja.closed
State key: The sender's Matrix ID (e.g. @user:homeserver.tld)
{
  "is_closed": false
}

See Status Resolution for how clients MUST interpret this state.

Status Resolution

Patch request rooms use state_key set to the sender’s Matrix ID for koja.merged and koja.closed. This means multiple users can each have their own state event, avoiding Matrix state conflicts. Clients MUST resolve these into a single status using the algorithms below.

Merged Status

Merging is a one-way action. Once a patch is merged, it cannot be un-merged.

To determine if a PR is merged:

  1. Collect all koja.merged state events in the room.
  2. Filter to events where the sender is a maintainer in the repository room.
  3. If ANY of these events has is_merged: true, the PR is merged.
  4. Otherwise, the PR is not merged.

Closed Status

A PR may be closed (and reopened) by maintainers or the PR author. The author always has the ability to withdraw their PR, overriding maintainer decisions. Among maintainers, the most recent decision wins.

To determine if a PR is closed:

  1. If the PR is merged, it is closed. Stop.
  2. Get the koja.closed state event for the PR author (the sender of m.room.create).
  3. If the author’s state has is_closed: true, the PR is closed (author withdrawal). Stop.
  4. Collect all koja.closed state events from maintainers.
  5. If any exist, use the one with the latest origin_server_ts. That event’s is_closed value is the status.
  6. Otherwise, the PR is open.

The room MAY have the following power levels.

State Event m.room.power_levels
{
  "users_default": 0,
  "events": {
    "m.room.name": 50,
    "m.room.message": 0,
    "m.room.pinned_events": 50,
    "m.room.redaction": 0,
    "m.room.topic": 50,
    "koja.closed": 0,
    "koja.merged": 0,
    "koja.revision": 100
  },
  "events_default": 0,
  "state_default": 100,
  "ban": 100,
  "kick": 100,
  "redact": 100,
  "invite": 0,
  "historical": 100,
  "notifications": {
    "room": 50
  }
}

A good starting point for most usecases.

Event koja.patch_request
{
  "room_id": "matrix room ID of the created patch request",
  "via": [
    "PR creator's homeserver",
    "repo owner's homeserver",
    "...up to 3 maintainer homeservers"
  ]
}

Sent to the repository room after the PR room has been created.

The via array MUST contain the homeserver of the patch request creator and the repository owner. It MAY additionally contain up to 3 homeservers of repository maintainers. These servers serve as routing hints for federation to the patch request room. Refer to the Matrix spec on routing for more information.

Comments

A patch request room MAY have any standard matrix room message sent and clients SHOULD render them like any other room.

Code Review

Reviewers MAY leave inline comments on a patch and provide an overall verdict. Discussion on inline comments SHOULD use Matrix threads rooted at the comment event.

Review verdict

State Event koja.review
State key: The reviewer's Matrix ID (e.g. @user:homeserver.tld)
{
  "verdict": "approve | request_changes | comment",
  "revision_event_id": "",
  "body": ""
}

Clients MUST verify the author is a maintainer of the repository before considering the verdict.

Inline comments

Event koja.review_comment
{
  "revision_event_id": "",
  "start": 0,
  "end": 0,
  "body": ""
}

The start and end fields refer to line positions within the patch file. When commenting on a single line, start and end MUST be equal. Discussion SHOULD use Matrix threads (m.thread relation) rooted at this event.

Comment resolution

State Event koja.review_comment_resolved
State key: event ID of the review comment
{
  "resolved": true
}

Used to mark an inline comment as resolved or unresolved.

Merging

To merge a patch request, a maintainer retrieves the patch file of the active revision and applies it to their local repository (for example with git am). Once the patch is part of the repository, the maintainer SHOULD mark the patch request merged by sending the koja.merged room state event, as documented earlier.

Before sending koja.merged, the Koja client SHOULD check that the user is a maintainer in the repository. This is not done for security purposes, but simply to improve user experience and provide a spec-compliant solution. The Koja client SHOULD refuse to mark the patch merged and inform the user, if this check is not met. Please read Appendix A, Recommended power levels for more information regarding the security policies and reasoning behind this choice.

Encryption

As it stands, Koja rooms (repositories, patch requests) MUST NOT have End-to-End encryption enabled. This may change in the future if we see a legitimate workflow. Currently, this is not supported as having access to timeline history is vital for the functionality of Koja.

UPDATE (non-normative) 24.6.2026: MSC4268 has just been merged. This could allow for Koja to work over E2EE.

Appendix A: FAQ

This section is non-normative.

Matrix Room IDs

As per the v1.16 Matrix specification and Room version 12, room IDs take the form of !opaque_id, and legacy rooms !opaque_id:domain.tld.

https://spec.matrix.org/v1.17/appendices/#room-ids

The power levels for PR rooms, as recommended by us as a starting point, have koja.closed and koja.merged with a power level of 0. This is intentional, as the Patch Request room has no access to the Repository room’s power levels.

Why state_key is the sender’s user ID

The koja.merged and koja.closed events use the sender’s Matrix ID as the state_key. This avoids Matrix state resolution conflicts when multiple users send these events.

If we used an empty state_key, Matrix would resolve conflicts using its state resolution algorithm (favoring later timestamps among equal power levels). This could allow any user to override a maintainer’s decision by simply sending a newer event.

By giving each user their own state slot, we avoid protocol-level conflicts entirely. Clients then aggregate these states using the algorithms in Status Resolution, which enforce the proper authorization rules: only maintainer decisions count for merging, and author withdrawal takes priority for closing.

Additionally, Matrix enforces that state events with a state_key starting with @ can only be sent by that user (authorization rule 9: “If the event has a state_key that starts with an @ and does not match the sender, reject”). This means authenticity is guaranteed at the protocol level - a malicious user cannot write to another user’s state slot. Clients only need to aggregate and filter by maintainer status; they do not need to verify that the sender matches the state_key.

Inline comments referencing patchfile

Inline comments (koja.review_comment) intentionally reference lines in a patch file. If a review is put on a comment of the patch file that does not contain code (such as headers), clients should still render them. The whole workflow is centered around a patch, so reviews should be too. You are not reviewing changes, you are reviewing a patch submmited by a contributor. There is no risk of drift, as a comment pins to a specific revision (revision_event_id).