# AICOE Governance API — Complete Guide

Version 1.1 — 2026-09-28

The AICOE Governance API is a facade over one governance pipeline and exposes
its configured estate through authenticated HTTP endpoints. Consumers can register connection
metadata for models, classify their own issues with JEV, submit problems into
the governance flow, and inspect API receipts — without touching the pipeline's
internals, credentials or database. Registration stores metadata and a one-way
key hash only; it does not call, attach, or execute the registered provider.

One design rule governs everything: **the API is a facade, never a side
door.** GitHub-backed submissions and ask answers enter the governance gates;
local metadata/account writes remain tenant-scoped API state. No endpoint
authors or merges code. Signed-action HTTP routes are disabled until a separate
direct STOP-aware authority broker exists. Merge authority stays with the
repository owner.

---

## 1. What you can do with it

| Capability | Endpoint | What it gives you |
|---|---|---|
| Landing page | GET / | Self-contained product and boundary overview with live public health |
| Browser guide | GET /API_GUIDE.html | This guide on the application origin |
| Health check | GET /v1/health | Service liveness, STOP state |
| Queue overview | GET /v1/queue | Live per-queue counts of the whole estate |
| Issue status | GET /v1/issues/{owner}/{repo}/{n}/status | Where any issue stands and why |
| Issue evidence | GET /v1/issues/{owner}/{repo}/{n}/evidence | Recorded receipts and stored SHA-256 digests |
| Policies | GET /v1/policies | The published governance policy set |
| Register model metadata | POST /v1/models | Store provider/base URL and a one-way key hash; no provider call |
| List models | GET /v1/models | Registered models (never the keys) |
| JEV raw ask | POST /v1/jev | The typed advisory classifier, raw form |
| JEV classify | POST /v1/jev/classify | work_kind / risk / impact / spec_ready |
| Quality | GET /v1/quality | Per-model output-quality aggregates (your tenant's) |
| Abuse log | GET /v1/ops/abuse | Last 200 quota-breach events (your tenant's) |
| Your usage | GET /v1/usage | The calling token's own request metering |
| Tenants | GET /v1/tenants | Every tenant (operator only) |
| Create a tenant | POST /v1/tenants | Provision an org or individual (operator only) |
| Issue a token | POST /v1/tenants/{id}/tokens | A scoped token for that tenant |
| Revoke a token | DELETE /v1/tenants/{id}/tokens/{name} | Revoke that tenant's token |
| Submit an issue | POST /v1/submissions | Creates a real issue in the governance flow |
| Answer an ask | POST /v1/asks/{owner}/{repo}/{n} | Reply to a governance clarification |


---

## 2. Authentication

Every token is personal, scoped, and revocable, and belongs to exactly one
**tenant**. Three scopes exist:

- **read** — all GET endpoints plus the JEV endpoints
- **submit** — everything above plus POST /v1/submissions, /v1/asks and
  POST /v1/models (registration is a write)
- **admin** — everything above plus tenant administration (issue/revoke that
  tenant's tokens). API tokens cannot invoke signed actions. Scopes are ranked:
  admin implies submit implies read; the reverse never holds.

Send the token as a header:

```
Authorization: Bearer agk_YOUR_TOKEN_HERE
```

### Tenants and isolation (#10)

Every tenant-owned store — `models.db`, `quality.db`, `usage.db`, `abuse.db`
and the `token_days` quota counters — carries a `tenant_id`. Every read and
write is scoped to the **calling token's tenant**: the server resolves that id
once per request from the authenticated token row (`server.tenant_of`, fed by
`auth.verify`'s `who["tenant_id"]`), and nothing in a request — path, query,
body or header — can name a tenant. Consequences:

- Two tenants may register the same model name; neither overwrites the other.
- A cross-tenant lookup returns an empty result (or 404 for a token that is not
  yours), never another tenant's data.
- A token whose tenant is suspended fails authentication (401), fail-closed.

The loop-state reads (`/v1/health`, `/v1/queue`, `/v1/issues/.../status`,
`/v1/issues/.../evidence`, `/v1/policies`) expose the **shared** governance
estate — one pipeline, one queue — and are deliberately not tenant-partitioned.

**The operator** is any admin-scoped token of the default tenant (id 1, name
`default`). The conventional token is named `operator`, but that name carries no
authority by itself and may be reused safely in another tenant. A **tenant
admin** (admin scope in any other tenant) may issue and revoke only its own
tenant's tokens — never another tenant's, and never a tenant.

### Migrating an existing deployment

The migration runs automatically on every `tokens.db` connect and on first touch
of each other store, and it is idempotent. To run it explicitly over the whole
state directory:

```bash
sudo -u aicoeapi python3 -c "
import sys; sys.path.insert(0, '/opt/aicoe-governance-api')
import server
print(server.migrate_state('/var/lib/aicoe-governance-api'))
"
```

Every pre-#10 token and every pre-#10 row lands in the default tenant (id 1,
name `default`), so nothing a running deployment relies on stops working; the
migration never deletes a row and never rebuilds a table twice.

### Rate limits

- Reads (including JEV): 60 per minute per token
- Writes (submissions, answers, model registrations): 5 per hour per token
- Tenant administration (POST /v1/tenants, tenant token issue/revoke): 30 per
  hour per token (`TENANT_ADMIN_LIMIT_PER_HOUR`) — a privileged, rare operation
  with its own budget, plus the daily write quota below
- Signup (`POST /v1/signup`, the one PUBLIC write): 3 per hour **per IP address**
  (`SIGNUP_LIMIT_PER_HOUR`), so a stranger can never spend a tenant's budget
- The public pages and the public status route (`GET /signup`,
  `GET /signup/status/{id}`, `GET /account`, `GET /v1/signup/{id}`): 60 per
  minute per IP address — the same budget a read token gets, applied by IP
  because there is no token to key on

Exceeding a limit returns HTTP 429 with a JSON error body.

### Daily quotas (#9)

On top of the per-minute/per-hour limits above, every token has a daily budget:

- Reads: **5,000 per UTC day** (`DEFAULT_DAILY_READ_QUOTA`)
- Writes: **100 per UTC day** (`DEFAULT_DAILY_WRITE_QUOTA`)

Counters persist in `api-state/tokens.db` (`token_days`) and reset at UTC
midnight. Operators can override the defaults by writing
`api-state/quotas.json`:

```json
{"daily_reads": 5000, "daily_writes": 100}
```

Only positive integers are honoured; anything malformed (or missing) keeps the
defaults — the override path can tighten or widen, but never break, a quota.

Exceeding a daily quota returns 429 with the seconds until UTC midnight:

```json
{"error": "daily reads quota exceeded (5000/day)",
 "retry_after_seconds": 34701}
```

Every 429 quota breach is appended to `api-state/abuse.db` and visible at
GET /v1/ops/abuse (see below).

### Getting a token (operator)

Tokens are issued on the server by an operator with access to the state
directory:

```bash
sudo -u aicoeapi python3 -c "
import sys; sys.path.insert(0, '/opt/aicoe-governance-api')
import auth
out = auth.issue('/var/lib/aicoe-governance-api', 'name@example.com', 'read')
print(out['token'])   # shown ONCE; only the hash is stored
"
```

That issues into the default tenant. For a tenant (#10), create the tenant and
issue its tokens through the API instead — the operator token is the one named
`operator` (or any admin token of the default tenant):

```bash
sudo -u aicoeapi python3 -c "
import sys; sys.path.insert(0, '/opt/aicoe-governance-api')
import auth
print(auth.issue('/var/lib/aicoe-governance-api', 'operator', 'admin')['token'])
"
# then, with that token:
#   POST /v1/tenants                        {"name": "acme", "kind": "org"}
#   POST /v1/tenants/2/tokens               {"name": "acme-ci", "scope": "submit"}
```

Revoking:

```bash
sudo -u aicoeapi python3 -c "
import sys; sys.path.insert(0, '/opt/aicoe-governance-api')
import auth
print(auth.revoke('/var/lib/aicoe-governance-api', 'name@example.com', tenant_id=1))
"
```

Tokens are stored ONLY as SHA-256 hashes. A lost token cannot be recovered —
revoke and reissue.

### Self-serve signup (beta, #14)

Anyone may ask for a tenant without shell access:

```bash
curl -s -X POST https://governance-api.preview.aicoe.io/v1/signup -H 'Content-Type: application/json' \
  -d '{"email":"you@example.com","org_name":"Your Org","kind":"org"}'
```

```json
{"request_id": 7, "status": "pending", "verify_code": "K7QM3XPD"}
```

**Beta honesty: this deployment has NO mail connector, so no email is ever
sent.** The `verify_code` is returned to the requester exactly once here. Only
a salted PBKDF2 hash is stored; `GET /v1/signup/pending` never returns verifier
material, and the operator cannot recover the code. Status checks send it in a
POST body, never in a URL.

- `email` is validated against a strict pattern; `org_name` must already be a
  legal tenant name (1-64 chars, starting with a letter or digit). Both are
  trimmed. A bad value is 400 and stores nothing.
- A second request for an **email that is still pending** is 409 (case
  insensitive); once a request is decided, that email may sign up again.
- The public budget is **3 signup requests per hour per IP address**
  (`SIGNUP_LIMIT_PER_HOUR`), keyed on the real client IP — behind the nginx edge
  that is `X-Real-IP`, and a header from a non-loopback peer is ignored.

Approval is **one operator call** and is the whole provisioning step:

```bash
curl -s -H "Authorization: Bearer ***" https://governance-api.preview.aicoe.io/v1/signup/pending
curl -s -X POST -H "Authorization: Bearer ***" \
  https://governance-api.preview.aicoe.io/v1/signup/7/approve
```

```json
{"tenant_id": 5, "token": "agk_...", "token_name": "admin"}
```

The first token is an **admin** token named `admin`, shown once, and it belongs
to the new tenant. Tenant insertion, first-token hash storage, signup approval
and tenant binding commit in one SQLite transaction. Any failure rolls all four
changes back, so the same pending request can be retried safely; a retry after a
successful approval is 409 and creates no duplicate tenant or token.

`POST /v1/signup/7/reject` answers `{"rejected": true}` and creates no tenant
and no token. Approving or rejecting a request that is not pending is 409;
an unknown request id is 404. Both decisions need the operator token (403
otherwise).

### The signup and account pages (beta UI, #14)

The public frontend is self-contained. Signup, status, and account forms need
**no JavaScript** and no external asset — they work with scripting disabled:

| Page | What it does |
| --- | --- |
| `GET /` | Production landing page with honest authority/tenant boundaries and an optional live `GET /v1/health` enhancement. |
| `GET /API_GUIDE.html` | Self-contained browser guide served by the application. |
| `GET /signup` | The signup form. It posts to `POST /signup` (same validation as the API) and renders the request id + verify code once. |
| `GET /signup/status/{id}` | A status form that reveals no request data and asks for the code. |
| `POST /signup/status/{id}` | The requester's pending / approved / rejected view. The code is sent in the form body (403 if wrong, 404 for an unknown id). |
| `GET /account` | The token dashboard: the caller's tenant, token **names and scopes** (never values), usage, quality, exact issue inspection, and admin-gated token controls. |
| `POST /account/inspect` | Authenticated exact status + evidence view using the existing shared-estate readers. The token is submitted in the form body and never retained or rendered. |

`GET /account` accepts the token in the `Authorization` header or through the
token field of the forms (`POST /account`, `POST /account/issue`,
`POST /account/revoke`). Tokens are deliberately not accepted in URL query
strings. Every interpolated value is HTML-escaped, and no page
ever renders a token value — the single exception is the page that has just
issued one, which shows the new value once so it can actually reach its owner.
Issuing and revoking through the UI obey the same authority rule as the API:
the operator, or an admin-scoped token of that same tenant (403 otherwise).
Read-scoped tokens can use the exact issue inspector because issue status and
evidence are part of the documented shared governance estate. Operator-only
tenant and signup approval content is not added to that page.

Every application response carries `Cache-Control: no-store`,
`X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, a no-referrer policy,
a restrictive permissions policy, and a `Content-Security-Policy` header. Server-rendered
signup/account pages allow no scripts. The landing page's one inline health
enhancement is admitted only by a response-computed SHA-256 CSP hash. Essential
signup and account functions work with JavaScript disabled.

---

## 3. Endpoint reference

### GET /v1/health — no auth

Production reads the root-owned sanitized
`/var/lib/aicoe-governance-export/governance-health.json` snapshot rather than
granting `aicoeapi` broad access to canonical loop files.
Healthy responses are HTTP 200. Missing, unreadable, malformed or stale exports
are HTTP 503 with `status: "unknown"`; configured STOP, stale worker progress or
consecutive errors are HTTP 503 with `status: "unhealthy"`. Stable reason codes
and stage names are returned, never filesystem exception text or credentials.

```bash
curl -s http://127.0.0.1:18090/v1/health
```

```json
{"ok": true, "status": "healthy", "stopped": false,
 "stages": {"implementation": {"consecutive_errors": 0}}, "reasons": []}
```

### GET /v1/queue

```bash
curl -s -H "Authorization: Bearer $TOKEN" http://127.0.0.1:18090/v1/queue
```

```json
{"queues": {"implement": 47, "needs-spec": 34, "blocked-policy": 514},
 "held_for_authors": 43, "generated_at": 1790137187.8}
```

### GET /v1/issues/{owner}/{repo}/{number}/status

```bash
curl -s -H "Authorization: Bearer $TOKEN" \
  http://127.0.0.1:18090/v1/issues/AI-Centre-of-Excellence/AICOE-FOLIO/1150/status
```

Returns the live row: queue, state, attempts, work_kind, risk, impact,
spec_at (spec approved or not), pr number if one exists, and the last outcome
with its reason.

### GET /v1/issues/{owner}/{repo}/{number}/evidence — recorded evidence

Recorded receipt fields: per-attempt model identity, stored prompt/response
SHA-256 digests, HTTP statuses, durations, and outcome reasons. Nothing is
inferred, but this endpoint does not recompute digests against source bytes.

### POST /v1/models — register connection metadata (scope: submit)

```bash
curl -s -X POST http://127.0.0.1:18090/v1/models \
  -H "Authorization: Bearer $SUBMIT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "glm-5.3",
    "provider": "zai",
    "base_url": "https://api.z.ai/v1",
    "api_key": "sk-your-key"
  }'
```

Response 201:

```json
{"name": "glm-5.3", "provider": "zai", "base_url": "https://api.z.ai/v1"}
```

Rules:
- All four fields required; a missing field returns 400 naming it.
- The api_key is stored ONLY as a SHA-256 hash. No endpoint ever returns it,
  and the raw database file never contains it.
- Re-registering the same name updates the metadata (upsert).
- Registration performs no provider request and does not connect the model to
  governance execution. JEV uses the deployment's separate `TYPESAFE_API_KEY`
  and endpoint; registration receipts are not model-output quality evidence.

### GET /v1/models

Lists registrations as connection metadata — name, provider, base_url. Never
key material.

### POST /v1/jev/classify — the JEV classification (scope: read)

```bash
curl -s -X POST http://127.0.0.1:18090/v1/jev/classify \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "API rejects valid model registrations: fields silently dropped",
    "body": "## Problem\nPOST /v1/models ignores provider and base_url fields.\n\n## Expected behavior\nThe fields are stored and listed."
  }'
```

Response 200:

```json
{"work_kind": "BACKEND", "kind_confidence": 1.0,
 "risk": 2.0, "impact": "MAJOR", "spec_ready": 0.48}
```

This is the SAME advisory classifier the governance loop itself uses, with
the same payload shapes. JEV is read-only advisory — it classifies, it never
gates anything.

### POST /v1/jev — raw advisory ask (scope: read)

```bash
curl -s -X POST http://127.0.0.1:18090/v1/jev \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "state": "ISSUE myorg/myrepo#42\nDATA: {\"title\":\"Crash on sort\",\"body\":\"Sorting raises ValueError.\"}",
    "questions": {
      "spec_ready": {
        "type": "noul",
        "instructions": "Could an engineer implement and another verify it without asking a question?",
        "criteria": {"true": "implementable without asking", "false": "ambiguity remains"}
      }
    }
  }'
```

Answers are relayed verbatim with the model identity and usage.

### GET /v1/quality — per-model output quality (scope: read)

```bash
curl -s -H "Authorization: Bearer $TOKEN" http://127.0.0.1:18090/v1/quality
curl -s -H "Authorization: Bearer $TOKEN" "http://127.0.0.1:18090/v1/quality?model=glm-5.3"
```

```json
{"models": [{
   "model": "glm-5.3", "count": 1, "ok_count": 1,
   "refusal_count": 0, "error_count": 0,
   "median_latency_ms": 19.0,
   "receipts_sha256": "adcf95ab..."
}]}
```

The aggregate is API receipt telemetry. Registration rows prove metadata storage
only; they do not measure registered-provider output quality or execution.

### GET /v1/ops/abuse — tenant-scoped abuse log (scope: read)

This authenticated endpoint returns only the calling tenant's quota-breach
records. It is useful to tenant admins and operators, but the route does not
perform an operator-role check; it must not be documented as operator-only.

```bash
curl -s -H "Authorization: Bearer ***" http://127.0.0.1:18090/v1/ops/abuse
```

```json
{"events": [{
   "at": 1789452301.7, "token_id": 3, "token_name": "ci-runner@example",
   "endpoint": "GET /v1/queue",
   "reason": "daily reads quota exceeded (5000/day)",
   "token_abuse_events": 4}],
 "count": 1, "limit": 200}
```

The last 200 quota-breach events of the CALLING TENANT, newest first, with the
token's NAME (never its value) and that token's total abuse-event count within
the tenant. Use it to spot a runaway client; revoke the token if the pattern
looks hostile. Another tenant's events are never shown, and an operator's token
sees its own tenant's log (the default tenant) like any other token.
### GET /v1/usage — your own request metering (scope: read)

```bash
curl -s -H "Authorization: Bearer ***" http://127.0.0.1:18090/v1/usage
```

```json
{"period_start": 1787548861.2, "period_end": 1790140861.2,
 "totals": {"requests": 3, "by_status": {"200": 2, "201": 1}},
 "by_endpoint": [
   {"endpoint": "GET /v1/models", "count": 1},
   {"endpoint": "GET /v1/policies", "count": 1},
   {"endpoint": "POST /v1/models", "count": 1}],
 "daily": [{"day": "2026-08-25", "count": 0}, "...", {"day": "2026-09-23", "count": 3}]}
```

Every authorized request is metered into `api-state/usage.db`
(at, token_id, endpoint, method, status) — including 4xx/5xx outcomes. The
view is recomputed from those rows on every call and scoped to the **calling
token only**: a token sees its own usage, never another tenant's. `daily` is
a zero-filled series covering the last 30 days. Rows are SQLite on disk, so
counts survive a restart.

### GET /v1/tenants — every tenant (scope: read; OPERATOR ONLY)

**Operations endpoint.** Lists the deployment's tenants, oldest first:

```bash
curl -s -H "Authorization: Bearer ***" http://127.0.0.1:18090/v1/tenants
```

```json
{"tenants": [{"id": 1, "name": "default", "kind": "org", "created": 1790...,
              "status": "active", "token_count": 3}],
 "count": 1}
```

Any token authenticates, but only the **operator** is answered: any other token
gets 403. Tenant names, kinds, statuses and live token counts only — never a
token value.

### POST /v1/tenants — provision a tenant (scope: read; OPERATOR ONLY)

```bash
curl -s -X POST http://127.0.0.1:18090/v1/tenants \
  -H "Authorization: Bearer ***" -H "Content-Type: application/json" \
  -d '{"name": "acme", "kind": "org"}'
```

`kind` is `org` or `individual` (default `org`). Response 201:

```json
{"id": 2, "name": "acme", "kind": "org", "created": 1790..., "status": "active"}
```

A duplicate name is 409; a bad name or kind is 400. Tenant administration has
its own per-token budget — `TENANT_ADMIN_LIMIT_PER_HOUR` (30) — so provisioning
a fleet is not throttled by the 5/hour client write limit; the shared daily
write quota still applies.

### POST /v1/tenants/{id}/tokens — issue a token (operator, or that tenant's admin)

```bash
curl -s -X POST http://127.0.0.1:18090/v1/tenants/2/tokens \
  -H "Authorization: Bearer ***" -H "Content-Type: application/json" \
  -d '{"name": "acme-ci", "scope": "submit"}'
```

`scope` is `read`, `submit` or `admin` (default `read`). Response 201:

```json
{"name": "acme-ci", "scope": "submit", "token": "agk_...", "tenant_id": 2}
```

The token value is shown **once**; only its SHA-256 hash is stored. The caller
must be the operator or an admin-scoped token **of that same tenant** — anyone
else gets 403, and no token is created. A name already used inside that tenant
is 409 (two tenants may use the same token name independently). An unknown
tenant id is 404.

### DELETE /v1/tenants/{id}/tokens/{name} — revoke a token

```bash
curl -s -X DELETE http://127.0.0.1:18090/v1/tenants/2/tokens/acme-ci \
  -H "Authorization: Bearer ***"
```

```json
{"revoked": true, "tenant_id": 2, "name": "acme-ci"}
```

Same authority rule as issuing. The name is resolved **within that tenant**: a
token belonging to another tenant (or an unknown name) is 404, and the revoked
token is 401 on its next request.

### POST /v1/signup — request an account (PUBLIC, no auth)

```bash
curl -s -X POST http://127.0.0.1:18090/v1/signup \
  -H 'Content-Type: application/json' \
  -d '{"email": "you@example.com", "org_name": "Your Org", "kind": "org"}'
```

`kind` is `org` or `individual` (default `org`). Response **202**:

```json
{"request_id": 7, "status": "pending", "verify_code": "K7QM3XPD"}
```

A new row in `api-state/tokens.db` (`signups`: id, email, org_name, kind,
verify_code_hash, verify_code_salt, created, status
`pending|approved|rejected`, tenant_id NULL until approved, decided_at). The
plaintext code is never stored. No tenant exists yet. A duplicate **pending** email is
409, a bad email / org name / kind is 400, and the public budget is 3/hour/IP
(429). See §2 for the beta mail-connector caveat.

### POST /v1/signup/{id}/status — your request's status (PUBLIC)

```bash
curl -s -X POST http://127.0.0.1:18090/v1/signup/7/status \
  -H 'Content-Type: application/json' -d '{"verify_code":"K7QM3XPD"}'
```

```json
{"request_id": 7, "status": "pending", "created": 1790..., "decided_at": null,
 "tenant_id": null}
```

The JSON-body `verify_code` must match the request's verifier: a wrong or
missing code is 403, an unknown id is 404. URL credentials are not accepted,
and the response never repeats the code.

### GET /v1/signup/pending — the approval queue (scope: read; OPERATOR ONLY)

```bash
curl -s -H "Authorization: Bearer ***" http://127.0.0.1:18090/v1/signup/pending
```

```json
{"requests": [{"id": 7, "email": "you@example.com", "org_name": "Your Org",
               "kind": "org", "created": 1790...,
               "status": "pending", "tenant_id": null, "decided_at": null}],
 "count": 1,
 "note": "verification codes are shown once to requesters and stored only as salted hashes; operators cannot recover them"}
```

Verifier material is never returned by this endpoint. Any token authenticates,
but only the operator is answered (403 otherwise).

### POST /v1/signup/{id}/approve — one-click provisioning (scope: read; OPERATOR ONLY)

```bash
curl -s -X POST -H "Authorization: Bearer ***" \
  http://127.0.0.1:18090/v1/signup/7/approve
```

```json
{"tenant_id": 5, "token": "agk_...", "token_name": "admin"}
```

One `BEGIN IMMEDIATE` transaction re-reads and claims the pending request,
creates the tenant with the same validation and defaults as `POST /v1/tenants`,
stores the first **admin** token hash under the name `admin`, marks the request
approved, and binds it to the tenant. The token value is returned **once** only
after commit. Any failure rolls back the claim, tenant, token, and binding, so a
retry is safe. A request that is not pending is 409; an unknown id is 404; if
the org name already exists, the answer is 409 and the request remains pending.

### POST /v1/signup/{id}/reject — decline (scope: read; OPERATOR ONLY)

```bash
curl -s -X POST -H "Authorization: Bearer ***" \
  http://127.0.0.1:18090/v1/signup/7/reject
```

```json
{"rejected": true}
```

No tenant and no token are created, and the request leaves the pending queue.
A request that is not pending is 409.

### GET /signup, GET /signup/status/{id}, GET /account — the beta HTML UI

Server-rendered pages, plain forms, no JavaScript (see §2 for the details and
the token-handling rules). `GET /signup` and `GET /signup/status/{id}` are
public; `GET /account` needs a token in the Authorization header or a form body.

### POST /account/inspect — exact server-rendered issue inspection

Posts `token`, `owner`, `repo`, and positive integer `issue` as a URL-encoded
form. Authentication and normal read quotas apply. The response renders the
existing `readers.issue_status` and `readers.issue_evidence` outputs after HTML
escaping them; it does not infer missing state and does not store the token.

### Signed actions are not exposed by this HTTP service

The non-root API cannot directly and atomically inspect `/root/fleet/STOP` at an
action append boundary. All signed-action HTTP routes are therefore disabled and
the production service loads no action roster or HMAC credential. Reusable
internal action modules are reserved for a future separate, direct STOP-aware
authority broker.

### POST /v1/submissions — submit a problem (scope: submit)

```bash
curl -s -X POST http://127.0.0.1:18090/v1/submissions \
  -H "Authorization: Bearer $SUBMIT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "repo": "AI-Centre-of-Excellence/your-repo",
    "title": "A real problem in the billing flow (10-200 chars)",
    "sections": {
      "Problem": "What currently fails, concretely.",
      "Expected behavior": "What should happen instead."
    }
  }'
```

Response 201: `{"created": true, "repo": ..., "issue": 42, "note": "issue
created for a repository present in the exported governed repository inventory;
normal intake policy and future sweeps determine subsequent processing"}`.

Requirements: title 10–200 characters; sections must include at least
**Problem** and **Expected behavior**. Optional sections: Scope, Acceptance
criteria, Steps to reproduce, Environment. Before GitHub is called, the repo
must be present in the exported governed inventory; an absent repository is 409
and creates no issue. Success creates a REAL attributed GitHub issue, but normal
intake policy and future sweeps determine what happens next. The API neither
promises pickup nor skips a gate.

### POST /v1/asks/{owner}/{repo}/{number} — answer a governance ask (scope: submit)

When the governance loop holds an issue and posts a precise question on it,
this posts your answer as an attributed comment. The loop re-reads the issue
on its next sweep (every few minutes).

---

## 4. Hosting it on your own server

The API is stdlib-only Python 3 (3.11+; developed and deployed on 3.14). The
committed production topology is one non-socket service bound to
`127.0.0.1:18090`, two root-run exporter services and timers, and the dedicated
TLS origin **https://governance-api.preview.aicoe.io**. Installation and every upgrade
use the same transactional revision installer; do not manually populate
`/opt/aicoe-governance-api` or install an individual unit.

### Exact prerequisites

Before the first transaction, the target needs Linux with systemd, Python 3.11+
and nginx; a local reviewed repository containing the exact 40-lowercase-hex
commit to deploy; and these root-created inputs. Normal healthy deployment
requires an absent `/root/fleet/STOP`; the explicit stopped-runtime procedure
below leaves it present and uses a separate deployment sentinel.

```text
user/group: aicoeapi (system account, nologin)
/etc/aicoe-governance-api/                         root:root 0700
/etc/aicoe-governance-api/environment              root:root 0600 (optional)
/etc/nginx/ssl/aicoe-api/fullchain.pem              certificate for governance-api.preview.aicoe.io
/etc/nginx/ssl/aicoe-api/privkey.pem                matching private key
```

`/etc/aicoe-governance-api/environment` is optional. systemd reads this
root-owned file before dropping to `aicoeapi`; use it only for host-required
`GH_TOKEN`, `GOV_API_GH_TOKEN`, or `TYPESAFE_API_KEY` values. The server never
accepts those credentials from a request and the deployment checks their
presence without printing values. DNS for `governance-api.preview.aicoe.io` must point
at the intended edge before public activation.

Signed-action HTTP routes are disabled. The production unit loads no action
roster, HMAC credential, or pinned signed-actions implementation; those internal
modules remain reserved for a separate direct STOP-aware authority broker.

### Transactional install or upgrade

Run the same command for the first install and every later upgrade:

```bash
sudo /path/to/reviewed-repo/deploy/install.py --revision \
  <40-lowercase-hex-commit> --repo /path/to/reviewed-repo
```

When governance is intentionally stopped, leave `/root/fleet/STOP` present and
use this exact command with the dedicated deployment sentinel absent:

```bash
sudo test -e /root/fleet/STOP
sudo test ! -e /root/fleet/API_DEPLOY_STOP
sudo /path/to/reviewed-repo/deploy/install.py --revision \
  <40-lowercase-hex-commit> --repo /path/to/reviewed-repo \
  --expect-governance-stopped \
  --stop-sentinel /root/fleet/API_DEPLOY_STOP
```

The stopped mode is fail-closed. Both local HTTP and loopback HTTPS health
checks must return exactly HTTP 503 with parseable governance health JSON where
`ok` is `false`, `status` is either `unhealthy` or `unknown`, and `stopped` is
`true`. `unknown` is accepted only because an intentionally stopped runtime may
also have missing or stale worker progress; the explicit `stopped: true` remains
mandatory. A false or
null `stopped` value, malformed body, unreachable service, or unrelated status
rolls back. The separate `/root/fleet/API_DEPLOY_STOP` remains the transaction's
mandatory mutation guard; landing, anonymous-auth denial, installed-byte,
service-state, nginx, rollback, lock, journal, and STOP checks are unchanged.

The transaction stages only committed allow-listed bytes, verifies their
manifest and Python syntax, validates all five systemd units and nginx, checks
the exact credential prerequisites, snapshots the existing application and API
state, migrates state in isolation, and atomically installs the release. It
installs and runs `governance-health-export.service` and
`governance-state-export.service`, enables
`governance-health-export.timer` and `governance-state-export.timer`, verifies
the root-owned `/var/lib/aicoe-governance-export/governance-health.json` and
`/var/lib/aicoe-governance-export/loop-snapshot` handoff, and starts the
non-root API only after both initial exports complete. The API reads those
exported snapshots; it never opens canonical governance state directly.

The installer then performs local and dedicated-origin health checks, anonymous
denial checks, exact installed-byte/mode/link **readback**, and active/enabled
service-state readback. Success writes a mode-0600 `report.json` and terminal
journal under `/var/lib/aicoe-governance-deploy/transactions/`. Inspect that
report plus the installed manifest before treating a deployment as committed.

If any post-mutation check fails, the prepared journal drives **rollback** of
the prior application, API state, units, nginx bytes and exact service states.
Rollback is certified only after readback matches the pre-transaction inventory;
a failed restoration never produces a false `rolled_back` report. The next
installer invocation reconciles an interrupted prepared journal before starting
a new transaction.

Operational logs remain in
`journalctl -u aicoe-governance-api.service`; path-only nginx logging omits query
strings and Referer, and application logs never include authorization headers.

---

## 5. Guarantees and non-guarantees

Guaranteed:
- No endpoint can author or merge code, or append a signed action.
- Model keys and tokens are hashed at rest. Newly issued token values are shown
  once because that is how they reach their owner.
- Every quality claim is recomputed from recorded receipts, not asserted.
- Failures fail closed (502/503 with a reason), never silently.
- Tenant isolation: every tenant-owned store is read and written under the
  calling token's tenant only. A cross-tenant lookup returns empty or 404 —
  never another tenant's models, receipts, usage, abuse events or quota
  counters. No request field can name a tenant.

Not claimed:
- Internet-grade hardening by itself — use the proxy layer described above.
- Tenant isolation of the LOOP STATE (health/queue/issues/policies): that is one
  shared governance estate per deployment, not a per-tenant view.
- JEV verdicts are advisory classifications, not gates, by design.

---

## 6. Quick reference card

```
BASE=http://127.0.0.1:18090
H='Authorization: Bearer agk_...'

curl -s $BASE/v1/health                                     # no auth
curl -s -H "$H" $BASE/v1/queue
curl -s -H "$H" $BASE/v1/issues/OWNER/REPO/123/status
curl -s -H "$H" $BASE/v1/issues/OWNER/REPO/123/evidence
curl -s -H "$H" $BASE/v1/policies
curl -s -H "$H" $BASE/v1/models
curl -s -H "$H" $BASE/v1/quality
curl -s -H "$H" $BASE/v1/quality?model=NAME
curl -s -H "$H" $BASE/v1/usage
curl -s -X POST -H "$H" -H 'Content-Type: application/json' \
     -d '{"title":"...","body":"..."}' $BASE/v1/jev/classify
curl -s -X POST -H "$H" -H 'Content-Type: application/json' \
     -d '{"state":"...","questions":{...}}' $BASE/v1/jev
curl -s -X POST -H "$H_SUBMIT" -H 'Content-Type: application/json' \
     -d '{"name":"m","provider":"p","base_url":"https://...","api_key":"sk-..."}' \
     $BASE/v1/models
curl -s -X POST -H "$H_SUBMIT" -H 'Content-Type: application/json' \
     -d '{"repo":"o/r","title":"...","sections":{"Problem":"...","Expected behavior":"..."}}' \
     $BASE/v1/submissions

# tenants (#10) — H_OPERATOR is the operator token (name 'operator', admin scope)
curl -s -H "$H_OPERATOR" $BASE/v1/tenants
curl -s -X POST -H "$H_OPERATOR" -H 'Content-Type: application/json' \
     -d '{"name":"acme","kind":"org"}' $BASE/v1/tenants
curl -s -X POST -H "$H_OPERATOR" -H 'Content-Type: application/json' \
     -d '{"name":"acme-ci","scope":"submit"}' $BASE/v1/tenants/2/tokens
curl -s -X DELETE -H "$H_OPERATOR" $BASE/v1/tenants/2/tokens/acme-ci

# signup (#14) — the first two lines are PUBLIC; the rest is the operator's
curl -s -X POST -H 'Content-Type: application/json' \
     -d '{"email":"you@example.com","org_name":"Your Org","kind":"org"}' $BASE/v1/signup
curl -s -X POST -H 'Content-Type: application/json' \
     -d '{"verify_code":"K7QM3XPD"}' $BASE/v1/signup/7/status
curl -s -H "$H_OPERATOR" $BASE/v1/signup/pending            # metadata only; no verifier
curl -s -X POST -H "$H_OPERATOR" $BASE/v1/signup/7/approve   # one click: tenant + first admin token
curl -s -X POST -H "$H_OPERATOR" $BASE/v1/signup/7/reject

# the beta HTML UI (no JS): signup form, status page, token dashboard
open $BASE/signup                 # or curl -s $BASE/signup
open "$BASE/signup/status/7"      # enter the code in the POST form
open $BASE/account                # paste your token into the form
```
