# 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.