# Project registry API (#635)

Phase 1 of the [console architecture ADR](architecture/webui-control-plane-console-architecture-adr.md)
gives the project registry a versioned, read-only API. This document is the
field-by-field contract for that API and for the registry file behind it.

Everything here is **read-only**. The console never writes the registry; an
operator edits the JSON file, and an invalid file fails closed rather than
rendering a partial inventory.

## Routes

| Route | Method | Description |
|-------|--------|-------------|
| `/api/v1/projects` | GET | Versioned registry export: all projects, with provenance |
| `/api/v1/projects/{project_id}` | GET | Single project; `404` with `project_not_found` when unknown |
| `/api/projects` | GET | Unversioned MVP alias (#427), retained for all of Phase 1 |
| `/projects` | GET | HTML list — status and onboarding progress per project |
| `/projects/{project_id}` | GET | HTML detail — identity, profiles, paths, checklist |

Per ADR section 6 the unversioned alias may be retired no earlier than Phase 2,
and only after this document and `webui-local-dev.md` record the swap. The alias
returns the same payload as `/api/v1/projects`, including the legacy `version`
and `source_path` keys #427 consumers already read.

The HTML views render from the same DTO the JSON routes serialize
(`project_to_dict`), so the console and the API cannot disagree about a
project's status or onboarding progress.

## Registry file

Default location: `webui/data/projects.registry.json`. Override with the
`WEBUI_PROJECT_REGISTRY` environment variable.

Schema versions: **1** and **2** are accepted; **2** is current. A version 1
file loads unchanged and is normalized with the documented defaults, so an
existing operator registry keeps working without edits.

### Root

| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `version` | int | yes | `1` or `2`. Anything else fails closed |
| `projects` | array | yes | Must be non-empty |

### Project

| Field | Type | Required | Default | Notes |
|-------|------|----------|---------|-------|
| `id` | string | yes | — | Stable registry id used in URLs |
| `repo_name` | string | yes | — | Gitea repository name |
| `gitea_owner` | string | yes | — | Owning org or user |
| `remote_host` | string | yes | — | Instance base URL, no credentials |
| `remote_name` | string | no | `null` | Logical remote label, e.g. `prgs` (v2) |
| `default_branch` | string | yes | — | Stable branch name |
| `local_checkout_path` | string | yes | — | Control checkout path |
| `status` | string | no | `active` | `active`, `onboarding`, `paused`, `archived` (v2) |
| `profiles` | object | yes | — | Must map `author`, `reviewer`, `reconciler` |
| `workflow_paths` | object | yes | — | Non-empty; label to repo-relative path |
| `schema_paths` | object | no | `{}` | Label to repo-relative path |
| `onboarding_checklist` | array | no | `[]` | See below |
| `last_seen_health` | object | no | `null` | Redacted health only (v2) |

### Onboarding step

| Field | Type | Required | Default | Notes |
|-------|------|----------|---------|-------|
| `id` | string | yes | — | Stable step id |
| `title` | string | yes | — | Short operator-facing label |
| `description` | string | yes | — | Self-contained; assumes no chat history |
| `state` | string | no | `pending` | `complete`, `pending`, `blocked`, `not_applicable` (v2) |
| `required` | bool | no | `true` | Optional steps never block readiness (v2) |

### Last-seen health

| Field | Type | Required | Notes |
|-------|------|----------|-------|
| `status` | string | no (default `unknown`) | `healthy`, `degraded`, `unreachable`, `unknown` |
| `checked_at` | string | no | ISO-8601 UTC timestamp, e.g. `2026-01-01T00:00:00Z` |
| `detail` | string | no | Short redacted note |

Health is recorded metadata, not a live probe: Phase 1 performs no outbound
health checks. Endpoints, tokens, and keychain identifiers must never appear
here.

## Response shape

`GET /api/v1/projects`:

```json
{
  "api_version": "v1",
  "schema_version": 2,
  "version": 2,
  "source_path": "/path/to/webui/data/projects.registry.json",
  "source": {
    "kind": "file",
    "path": "/path/to/webui/data/projects.registry.json",
    "inventory_complete": true
  },
  "project_count": 1,
  "projects": [
    {
      "id": "example",
      "repo_name": "Example",
      "gitea_owner": "Org",
      "repo_full_name": "Org/Example",
      "remote_host": "https://gitea.example.invalid",
      "remote_name": "example-remote",
      "default_branch": "main",
      "local_checkout_path": ".",
      "status": "active",
      "profiles": {"author": "...", "reviewer": "...", "reconciler": "..."},
      "workflow_paths": {"skill": "skills/..."},
      "schema_paths": {},
      "onboarding_checklist": [
        {
          "id": "profiles",
          "title": "Configure execution profiles",
          "description": "...",
          "state": "complete",
          "required": true
        }
      ],
      "onboarding_summary": {
        "total": 1,
        "complete": 1,
        "pending": 0,
        "blocked": 0,
        "not_applicable": 0,
        "required_outstanding": 0,
        "onboarding_complete": true
      },
      "last_seen_health": null
    }
  ]
}
```

`GET /api/v1/projects/{project_id}` returns `api_version`, `schema_version`,
`source`, and a single `project` object with the same fields.

The `source` block satisfies the ADR section 6 provenance rule: every payload
states where the data came from and whether the inventory is complete. A
file-backed registry is always complete — there is no pagination to truncate it.

`onboarding_summary` is derived, never stored. `required_outstanding` counts
steps that are `required` **and** in state `pending` or `blocked`;
`onboarding_complete` is true when that count is zero.

## Fail-closed errors

Validation failures raise `RegistryError`, which routes render instead of a
traceback.

`404` — unknown project id on `/api/v1/projects/{project_id}`:

```json
{
  "error": "project_not_found",
  "project_id": "not-registered",
  "known_project_ids": ["example"],
  "remediation": "Request one of the known project ids, or add the project ...",
  "source": {"kind": "file", "path": "...", "inventory_complete": true}
}
```

`500` — invalid registry, on both the versioned route and the alias:

```json
{
  "error": "registry_invalid",
  "detail": "unsupported registry version: 42",
  "remediation": "Set 'version' to one of 1, 2 (current schema is 2) ...",
  "field_path": "version",
  "source_path": "/path/to/registry.json"
}
```

`field_path` points at the offending location (`projects[0].profiles.reconciler`,
`projects[0].onboarding_checklist[2].state`, and so on). The HTML routes render
the same detail, field, source, and remediation on a "Project registry
unavailable" page.

Conditions that fail closed:

* file missing or unreadable;
* invalid JSON (the remediation names line and column);
* root not an object, or `projects` missing/empty;
* unsupported `version`;
* a credential-shaped key anywhere in the file (`token`, `*_secret`, `auth_*`, and similar);
* a project missing a required field, or missing an `author`/`reviewer`/`reconciler` profile;
* an unknown `status`, onboarding `state`, or health `status`.

## Credential rule

The registry stores redacted metadata only. Credential-shaped keys are
rejected at load time, before any DTO is built, consistent with
[safety-model.md](safety-model.md) and
[credential-isolation.md](credential-isolation.md). Tokens live in the keychain
and are resolved server-side by `gitea_auth`.

## Migrating a version 1 registry

1. Set `"version": 2`.
2. Optionally add `"status"` per project (omitted means `active`).
3. Optionally add `"remote_name"` per project.
4. Optionally add `"state"` and `"required"` to each onboarding step (omitted
   means `pending` and `true`).
5. Optionally add `"last_seen_health"`.

No step is mandatory: a version 1 file keeps loading. Bumping the version only
declares that the file may use the v2 fields.
