# services.yaml — contract for downstream consumers

`services.yaml` in this directory is the **canonical, versioned**
description of the inference services this fleet exposes. It is a
first-class artifact: external tools (the asset-engine UI is the
first; CLIs, monitoring dashboards, and other internal services may
follow) consume it programmatically, and changes to its shape or
contents have downstream effect.

This document is the consumer contract.

## Where the data lives

- **Canonical source**: `docs/asset-engine/services.yaml` in the
  `eshpfi-management` repo (this file's directory).
- **Schema**: `docs/asset-engine/services.schema.json` (JSON Schema
  draft 2020-12). Generated from the Pydantic model in
  `~/development/asset_engine/src/asset_engine/catalog.py` —
  regenerate via `cd ~/development/asset_engine && uv run scripts/dump_schema.py`
  whenever the model changes.
- **Change history**: git log on this directory.

## Data model — high level

```yaml
catalog_version: 1                 # bump on SCHEMA changes
services: [Service]                # the entries; closed list
reproducibility_audit: [Audit]     # one entry per service
```

### Service entry

| field             | type                                      | required | meaning |
|-------------------|-------------------------------------------|----------|---------|
| `id`              | string                                    | yes      | stable URL-safe identifier; never reused |
| `name`            | string                                    | yes      | display name |
| `description`     | string                                    | no       | one-paragraph summary |
| `category`        | enum: tts, asr, sfx, music, image         | yes      | groups in pickers |
| `version`         | int                                       | yes      | per-service schema version; bump on field changes |
| `status`          | enum: ready, catalog-deferred, experimental, down | no | default: ready |

**Status semantics:**

| value | meaning | picker behavior |
|---|---|---|
| `ready` | Deployed, exercised, current default | clickable, primary surface |
| `experimental` | Deployed, may break under unusual params or while a custom renderer is being built (e.g. kokoro-captioned pre-renderer) | clickable, surfaced with a "experimental" tag |
| `catalog-deferred` | By-design out of scope this catalog version (e.g. ComfyUI requires per-asset workflow templates the catalog can't express today) | greyed, non-clickable, "deferred" tooltip |
| `down` | *Temporarily* unreachable on the host fleet — deployment gap, scheduled maintenance, container reaped, etc. Distinct from `catalog-deferred` because the intent is to flip back to `ready` once redeployed; the catalog row still describes a real intended service. | greyed, non-clickable, "service down" tooltip |
| `host`            | string (host alias)                       | yes      | which fleet host serves this |
| `endpoint`        | URL                                       | yes      | absolute URL of the inference endpoint |
| `method`          | string                                    | yes      | HTTP method (POST common) |
| `content_type`    | MIME type                                 | yes      | request body type |
| `model.id`        | string                                    | yes      | upstream model id (HF or other) |
| `model.revision`  | string \| null                            | no       | SHA when known |
| `model.image`     | string                                    | yes      | container image ref this is hosted from |
| `fields`          | list[Field]                               | no       | request parameters; empty for catalog-deferred |
| `section_groups`  | list[SectionGroup]                        | no       | named groups for progressive disclosure; consumers render fields under tabs/accordions per group. SectionGroup = `{id, label, hint?}`. Field.section refs must resolve here. |
| `response.type`   | enum: audio, image, video, text, json, file | yes    | renderer dispatch (closed vocabulary) |
| `response.mime`   | string                                    | no       | static response MIME (raw-bytes wire) |
| `response.mime_from_field` | string                          | no       | name of a field whose value determines the MIME (raw-bytes wire) |
| `response.output_field`    | string                          | no       | for text/json responses, JSON key holding the asset |
| `response.audio_field`     | string                          | no       | for `type: audio` with JSON-envelope wire, JSON key holding base64-encoded audio bytes |
| `response.audio_format_field` | string                       | no       | for `type: audio` with JSON-envelope wire, JSON key holding the decoded audio MIME (e.g. "audio/wav") |
| `response.timestamps_field` | string                         | no       | JSON key holding a structured timestamps array — independent of type, declared by any service that emits time-aligned markers alongside its primary output |
| `reproducibility.seedable`    | bool                          | yes      | does the endpoint accept a seed? |
| `reproducibility.deterministic` | bool                        | yes      | same params → same bytes? |
| `reproducibility.notes`       | string                        | no       | gotchas |
| `estimated_latency.cold_start_s` | int \| float               | no       | informational only |
| `estimated_latency.warm_per_unit` | string                    | no       | informational only |
| `license`         | string                                    | yes      | upstream license |
| `license_warning` | string                                    | no       | non-empty → consumer MUST surface a warning before use |
| `notes`           | string                                    | no       | freeform |

### Field entry

The field-type vocabulary is **closed**. Adding a new type requires:
(a) updating the Pydantic model in `asset_engine/catalog.py`,
(b) bumping `catalog_version`, (c) coordinating with all consumers.

| `type`     | meaning                                              |
|------------|------------------------------------------------------|
| `text`     | single-line string                                   |
| `textarea` | multi-line string                                    |
| `number`   | int or float, no bounded range                       |
| `slider`   | bounded numeric — requires `min`, `max`, optional `step` |
| `select`   | enum — `options:` list OR remote `source_url:` + `source_jsonpath:` |
| `bool`     | true/false                                           |
| `file`     | file upload — requires `accepted_types:` list of MIME |
| `json`     | freeform JSON value (arrays of floats, ref objects, etc) |

Optional/required: a field is required UNLESS it has either
`required: false` or `optional: true`. (Both ways are accepted; new
entries should prefer `required: false`.)

### Response renderer vocabulary

Closed set: `audio`, `image`, `video`, `text`, `json`, `file`.
Same change-management as field types.

## Versioning policy

| Change                                       | Bump                  |
|----------------------------------------------|-----------------------|
| Add a new service                            | nothing               |
| Add a non-required field to an existing service | service `version:` |
| Add an optional key to the `response:` schema (e.g. audio_field, timestamps_field) | nothing — additive, backward-compatible |
| Add an optional key to a Field (e.g. section) or to Service (e.g. section_groups) | nothing — additive, backward-compatible |
| Extend an existing enum (StatusT, FieldTypeT, ResponseTypeT, CategoryT) with a non-conflicting new value, with the consumer updated in the same coordinated change | nothing — older consumers don't encounter the new value; updated consumers parse it correctly |
| Change a field's type, range, or default     | service `version:`    |
| Remove a service                             | service `version:` (sentinel: removed=true), then drop in next catalog_version bump |
| Add a new entry to the field-type vocabulary | `catalog_version:`    |
| Add a new entry to the response-type vocabulary | `catalog_version:` |
| Drop a field-type or response-type           | `catalog_version:` (BREAKING) |
| Pure prose edit (description, notes)         | nothing               |

Consumers should pin their tested `catalog_version`. When this repo
bumps it, downstreams should re-test before consuming the new shape.

## Sync workflow for downstream consumers

The recommended pattern is **vendor + drift-check**, not
fetch-at-runtime. Reasons: deployments stay reproducible; airgapped
or network-flaky deploys still work; schema changes surface in PRs
instead of breaking production silently.

A consumer should:

1. Vendor a copy of `services.yaml` in their repo (e.g. at
   `data/services.yaml`).
2. Record the source SHA they synced from in a `services.yaml.lock`
   alongside (path, source SHA, sync timestamp).
3. Provide a `sync_catalog` script that pulls the latest from the
   canonical path and updates both files.
4. Validate the vendored file against `services.schema.json` on
   load — refuse to start if validation fails.
5. CI: re-validate on every commit.

The asset-engine repo at `~/development/asset_engine/` is the
reference implementation of this pattern; copy the script shape from
`scripts/sync_catalog.py` over there.

## Known consumers

| Consumer                                    | Vendored at                          | Pin policy        |
|---------------------------------------------|--------------------------------------|-------------------|
| `asset_engine` (FastAPI/HTMX UI; ~/development/asset_engine/) | `data/services.yaml` + `data/services.yaml.lock` | catalog_version=2 |

When you add a consumer, add a row here in the same PR that lands
the consumer. This list is what we audit when planning a
`catalog_version` bump.

## Service authoring notes

When **adding** a service to the catalog:

- **Source-of-truth precedence.** OpenAPI is convenient but often lies
  by omission (bare `string` types where the dispatch chain enforces
  a specific enum, missing required-only-in-the-handler fields, etc).
  The actual ground truth is, in order:
    1. The Pydantic input model — for field shape, types, required-ness.
    2. The handler / pipeline code — for enum dispatches and runtime
       validation.
    3. The Gradio UI / client code (when present) — for blessed
       defaults and slider ranges, since the model authors picked
       these for human-facing UX.
    4. The README — least reliable; rots fastest.
  Every catalog change should cite which of these was read. "OpenAPI
  said X" is not enough on its own — past audits have caught three
  bugs in a single service (ace-step) that all looked correct in
  OpenAPI.
- The reproducibility audit at the bottom of `services.yaml` MUST get
  a matching entry for any new service.
- If a service can't produce reproducible output (no seed AND
  non-deterministic model), that's a **bug in the service** — fix it
  there before adding to the catalog. The asset-engine relies on
  reproducibility to power "regenerate" / "fork" affordances.
- If the service has a non-permissive license, populate
  `license_warning:` with the user-facing language. The UI is
  required to surface it before a generation; missing it is a
  contract violation.
- Image refs SHOULD be digest-pinned (`@sha256:...`) for true
  reproducibility. Tag-pinned (`:v1.2.3`) is acceptable but
  vulnerable to upstream re-pushes.

When **removing** a service: see the versioning policy table.
Removal is a two-step process; surprise removal breaks consumers'
asset libraries (regenerate-from-history breaks).
