---
title: "Project lookup"
---

# Project lookup

How a workspace folder is bound to its Govern project from its repository addresses, the canonical form of a repository address, and the registration event clients refresh on.

The Editor, the Agents app and the CLI bind every workspace folder to a project by asking Govern which project its repository belongs to. The answer decides which project context, policies and rules apply in that folder.

## The lookup

```http
POST /api/v1/context/project/lookup
Authorization: Bearer <access token>
Content-Type: application/json

{"repoUrls": ["git@gitlab.example.com:group/sub/name.git", "https://gitlab.example.com/group/sub/name"]}
```

| Form | Use |
|---|---|
| `POST /api/v1/context/project/lookup` with `{"repoUrls": [...]}` or `{"projectId": "..."}` | Up to 8 candidate addresses in the client's order of preference; body at most 16 KiB, exactly one JSON object |
| `GET /api/v1/context/project?repoUrl=...&repoUrl=...` | The same lookup in a query string (up to 8 candidates) |
| `GET /api/v1/context/project?projectId=...` | A known project |
| `GET /api/v1/context/project` | The organisation-level context |
| `GET /api/v1/context/agent/project?repoUrl=...` | The endpoint agent, with its device token |

Use the POST for long candidate lists: eight long addresses in a query string can exceed a proxy's request line limit.

The answer:

```json
{
  "projectId": "6f1c...",
  "projectName": "Payments API",
  "projectKey": "payments-api",
  "spaceId": "0a9e...",
  "resolvedVia": "repo_url",
  "matchedCandidate": 1,
  "repoKey": "gitlab.example.com/group/sub/name",
  "classification": "confidential",
  "spaces": [],
  "sources": [],
  "activeSources": 3
}
```

**Client rule.** Bind a folder only when `resolvedVia` is `repo_url` (or `project_id`) and `projectId` is set. `org_fallback` means no candidate is registered to a project the caller may read; the folder is not bound. The branch is recorded by clients and not used: a repository belongs to one project whatever branch is checked out.

A GET with at most one `repoUrl` answers in its original shape, without `projectName`, `projectKey`, `matchedCandidate` and `repoKey`, for older clients.

### Errors

| Status | Code | When |
|---|---|---|
| `422` | `context.space.invalid_request` | No candidate is a repository address, more than 8 candidates, or both `projectId` and candidates |
| `409` | `context.space.repo_ambiguous` | The first matching candidate is a codebase source of two readable projects, or bound in the Directory to one and a codebase source of another |
| `429` | `context.project.rate_limited` | Over `KEYSTONE_CONTEXT_PROJECT_LOOKUPS_PER_MINUTE` (default 60 per person, GET, POST and agent route together); `Retry-After` given |
| `403` | | The matched project's space (or the organisation's, for the fallback) cannot be read |
| `404` | `context.project.not_found` | A `projectId` that is unknown, has no space yet, or names a project the caller cannot read |

## Canonical repository address

Registration and lookup use one canonical form, compared for exact equality, never by prefix or pattern:

```
host/segment/.../name
```

| Rule | Detail |
|---|---|
| Accepted forms | `https://`, `http://`, `ssh://`, `git://` URLs, scp-like `[user@]host:path`, and bare `host/path`. `file://`, local paths and other schemes are refused |
| Characters | Printable ASCII only; non-ASCII is refused everywhere, so a look-alike host can never produce another repository's key |
| Credentials | A user name is dropped only on `ssh://` and scp-like addresses. A password anywhere, and any user information on `https://`, `http://` and `git://`, is refused, and the error never repeats the address |
| Host | Lower-cased DNS name or IPv4 address; the port is dropped; IPv6 literals are refused |
| Path | 2 to 20 segments (GitLab subgroups supported), lower-cased; one trailing `/` and one trailing `.git` removed; empty, `.` and `..` segments refused; only `%20` decoded |
| Azure DevOps | `ssh.dev.azure.com:v3/{org}/{project}/{repo}` maps to `dev.azure.com/{org}/{project}/_git/{repo}` |

Examples: `https://GitLab.com/Group/Sub/Name.git/` becomes `gitlab.com/group/sub/name`; `git@ssh.dev.azure.com:v3/acme/My%20Project/payments` becomes `dev.azure.com/acme/my%20project/_git/payments`.

## How a registration is found

A repository is registered to a project in the Directory's project repositories, or as a codebase source of a project space. For each candidate in order, the first rule that applies decides:

1. The Directory binds the key to a project whose space the caller may read: that project (unless a codebase source with the key is in another readable project: `409`).
2. Codebase sources with the key are in exactly one readable project space: that project.
3. They are in two or more readable project spaces: `409`.
4. Otherwise the next candidate is tried.

Only readable projects take part. A repository registered to a project the caller cannot read gives exactly the answer of an unregistered one, so the lookup cannot be used to learn about projects one cannot see. Each successful lookup is audited as `context.project.resolve`, without the addresses.

## Registration event

When the repositories of a project change (in the Directory or as codebase sources), the feed carries `project.repositories_changed` to readers of the project's space, with ids and counts only, never an address. Clients refresh their folder bindings with the lookup when they receive it, and after reconnecting with a resume gap. See [Real-time feed](https://neuralinverse.com/docs/context/realtime/).

## Older registrations

Directory rows stored by earlier releases in other forms are rewritten to the canonical form by the job `context.repository_backfill`, every 10 minutes, per organisation, idempotently. Rows that cannot be converted, and rows that would give two projects the same key, are left unchanged and reported, as counts, in `repositoryIssues` of `GET /api/v1/context/status` (organisation administrators). The console's project page shows them and asks an administrator to fix the project's repositories.
