# API guide — controlled human video

Phase 182, `182.C.39.03`. The endpoint surface, the examples, and what the
examples are allowed to claim.

## Three things to know before the first request

**This is mounted now, and most of it still answers 503.** That sentence
replaced "nothing here is mounted" on 2026-09-12: `server.ts` registers all
thirty-two endpoints, and a deployment with no database binds exactly one of
them. Which is which is in the boot line, and the boot line from a real run is
archived at `evidence/2026-09-12-live-surface-capture/boot-line.txt`.

**Four responses in this guide were captured from a running server**, and they
are the four that say what the surface does when it is not fully configured:

| capture                                                     | what it shows                                                                                 |
| ----------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
| `evidence/2026-09-12-live-surface-capture/presets-200.json` | `GET /presets` with an `hv:*` token — the catalogue, served with no database at all           |
| `.../identity-packs-503.json`                               | `GET /identity-packs` — `hv.internal.store_unavailable`, with the detail naming the operation |
| `.../presets-403-domain.json`                               | the same request with `domain:*` — refused, because that scope must not reach this surface    |
| `.../boot-line.txt`                                         | what a 503 will mean on that deployment, port by port                                         |

Every OTHER payload below is derived from the schema and labelled as such. A
hand-written 200 presented as a capture is exactly the misleading mocked success
claim this item exists to prevent, and the way you tell the difference is that a
captured response carries a dated evidence path under
`docs/domains/isis/human-video/evidence/` (`182.C.01.04`).

**A complete example needs an input, and the input is a person's face.** A
runnable quickstart therefore has to ship either biometric data or something
that is not the real input. It ships neither: the only publishable subject is
the `182.C.33.10` smoke corpus, which is a test pattern and a tone with **no
face in it**. So a quickstart against the smoke corpus exercises the transport —
auth, idempotency, preconditions, polling, error shapes — and proves nothing
about identity or sync. That limit is the honest one, and it is stated rather
than worked around.

**Credentials never appear in an example.** Every snippet reads its token from
the environment. A key written into documentation is a leaked key from the
moment it is written, and rotating it later does not unpublish it.

## Two versions, and conflating them is the bug

The URL version pins the shape of the **route**. `schemaVersion` on the payload
pins the shape of the **document**. They move independently, and the case that
proves it is a stored one: a candidate written under `schemaVersion` 1 and read
back tomorrow through a later route **is still a v1 document** — the route
cannot change what was written last week. A response carries the schema version
of the document it contains, never the version of the path it arrived on
(`human-video-api-surface.ts`, `assertDocumentVersionHonest`).

## The surface

Base path `/v1/human-video`. Forty operations, of which twenty-three accept an
`Idempotency-Key` and seven require an `If-Match`, because they decide something
somebody else may be deciding at the same time. One of the forty —
`POST /jobs/estimate` — is a POST that accepts no key at all, because it writes
nothing: it is a question whose argument is a plan, and a plan of eight shots in
a query string is a URL a proxy truncates.

The table below is not the surface; `human-video-api-surface.ts` is, and the
generated SDK is emitted from it. Rows here have gone missing before.

| Operation                                          | Idempotency-Key | If-Match | What it does                                                                                                                                    |
| -------------------------------------------------- | --------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| `POST /upload-sessions`                            | yes             | —        | open a resumable upload for a reference image or an audio track                                                                                 |
| `GET /upload-sessions/:sessionId`                  | —               | —        | where a resumable upload got to, so a client can continue it                                                                                    |
| `POST /upload-sessions/:sessionId/chunks/:offset`  | yes             | —        | the bytes at that offset, sent as `application/octet-stream` — the body is the chunk, not a description of it                                   |
| `POST /upload-sessions/:sessionId/completion`      | yes             | —        | stop the upload and digest what was stored; the digest is what an authorization later binds to                                                  |
| `POST /upload-sessions/:sessionId/abandonment`     | yes             | —        | give up on an upload, release its quota, and destroy the bytes it staged                                                                        |
| `POST /identity-packs`                             | yes             | —        | declare a reference set with its roles and authorizations                                                                                       |
| `GET /identity-packs`                              | —               | —        | the reference sets in reach                                                                                                                     |
| `GET /identity-packs/:packId`                      | —               | —        | one reference set, with its per-reference quality findings                                                                                      |
| `POST /audio`                                      | yes             | —        | register a speech track and its word alignment                                                                                                  |
| `GET /audio/:audioId`                              | —               | —        | one speech track with its alignment, exactness and approval state — and the ETag the approval demands                                           |
| `POST /audio/:audioId/approval`                    | yes             | yes      | approve the exact audio a render will be measured against; deciding, because every sync verdict downstream is a fact about this take            |
| `POST /jobs/estimate`                              | no              | —        | what a plan would cost on each route that could serve it, before anything is submitted — a floor from a live price list, and no route is proven |
| `POST /jobs`                                       | yes             | —        | submit a directed human-video request                                                                                                           |
| `GET /jobs`                                        | —               | —        | jobs in reach, newest first                                                                                                                     |
| `GET /jobs/:jobId`                                 | —               | —        | one job with its stage and honest progress                                                                                                      |
| `POST /jobs/:jobId/cancellation`                   | yes             | —        | stop waiting for a job; its own verb rather than a DELETE, because 182.C.15.05 established that cancelling a submitted render recovers nothing  |
| `GET /jobs/:jobId/candidates`                      | —               | —        | the takes a job produced                                                                                                                        |
| `GET /candidates/:candidateId`                     | —               | —        | one take with its lineage                                                                                                                       |
| `GET /candidates/:candidateId/measurements`        | —               | —        | what was measured, and what could not be                                                                                                        |
| `GET /reviews`                                     | —               | —        | candidates waiting for a human decision, each with what THIS reviewer may hold                                                                  |
| `POST /candidates/:candidateId/approval`           | yes             | yes      | accept a take against the measurements currently on it                                                                                          |
| `POST /candidates/:candidateId/rejection`          | yes             | yes      | set a take aside, keeping its measurements on the record                                                                                        |
| `POST /candidates/:candidateId/corrections`        | yes             | yes      | send a take back to be corrected; deciding, because 182.C.21.09 invalidates eight gates and most of what was just reviewed stops being true     |
| `POST /jobs/:jobId/shots/:shotId/regenerations`    | yes             | yes      | rebuild a shot, having been shown what else it invalidates                                                                                      |
| `POST /candidates/:candidateId/release`            | yes             | yes      | publish a cleared take                                                                                                                          |
| `POST /candidates/:candidateId/release/withdrawal` | yes             | —        | pull a published take back                                                                                                                      |
| `DELETE /assets/:assetId`                          | yes             | —        | destroy an asset and every derived copy, returning what was destroyed rather than a bare 204                                                    |
| `POST /authorizations/:authorizationId/withdrawal` | yes             | —        | withdraw an authorization and destroy what it permitted, including the embeddings that outlive the images                                       |
| `GET /projects`                                    | —               | —        | projects in reach                                                                                                                               |
| `POST /projects`                                   | yes             | —        | start a project: the row the authoring work waits in while the subject enrols                                                                   |
| `GET /projects/:projectId`                         | —               | —        | the authoring state — cast, references, script, audio refs, shots, preset                                                                       |
| `PATCH /projects/:projectId`                       | yes             | —        | replace the authoring content, against the version it was edited from                                                                           |
| `GET /presets`                                     | —               | —        | the presets and advanced settings a creator may choose from, and the three settings deliberately not offered                                    |

## A session was not an upload, and for most of the phase that is all there was

`POST /upload-sessions` opened one and `GET /upload-sessions/:id` answered a
`nextOffset` — "send the next chunk here" — and **there was nowhere to send
it**. Two operations that start a resumable upload and report its progress, and
none that moves a byte. The three above are the rest of the protocol
`human-video-upload-session.ts` has implemented since the phase began.

**The chunk's body is the chunk.** `application/octet-stream`, parsed inside the
human-video routes' own encapsulated Fastify scope so the parser is not imposed
on the rest of the BFF, and declared on the endpoint (`bodyMedia`) so the
generated SDK sets the same header it will be parsed under. Base64 in a JSON
field costs a third again on every biometric byte sent over a phone connection.

**The offset is in the path, not a query parameter.** It is part of what is
being addressed — the chunk at this position — and a client that omits a query
parameter gets a refusal it could have been given a URL for instead.

**The digest is computed at completion by reading the stored object back.** It
used to be accumulated in a process-local `Map`, with
`createHash('sha256').digest('hex')` — **the digest of an empty file** — as the
fallback whenever that `Map` had no entry: a session resumed after a restart, or
completed on a second worker. `182.C.26` binds an AUTHORIZATION to that value,
so every such record would have bound to the same constant and any two subjects
would have appeared to consent to the same bytes. Computing it from storage is
process-independent and has a property the running hash never had: it hashes
what a later reader will actually get, so a sink that lost a chunk produces a
mismatch rather than a confident wrong answer.

**The resume offset comes from the bytes, not from the row.** `receivedBytes` is
a projection and the two can disagree for one transaction's worth of time. A row
that is AHEAD of the object — it committed and the byte write did not — makes a
client resume past the end and leave a hole in the middle of a file whose length
still comes out right, which nothing downstream can detect. So the sink is asked
on every append and the row is repaired from it.

**Abandoning destroys the bytes and returns a manifest.** Not a 204: a bare
success cannot be told apart from a success that removed nothing. An abandoned
upload left staged is a copy of somebody's face that no record points at, and is
therefore outside every retention rule and every erasure sweep.

## Why `GET /presets` is not `GET /presets` plural rows

It is the one endpoint here that reads nothing a tenant wrote. The presets are
the product's own vocabulary — the same five for everybody — so the operation is
`get-preset-catalogue` rather than `list-presets`: naming it a list would put it
under `assertListsArePaginated`, correctly for rows, and paginating a fixed
vocabulary tells a client the set grows with use, so it pages for a sixth preset
that will never arrive.

It is still gated, on `hv:projects:read`. Having no store does not make it
public: the catalogue names the capability classes this deployment intends to
serve.

**It carries no availability count, and that absence is the design.** How many
routes can serve a preset is a question about one request against the lane
registry at one moment — it is what the pre-submission summary answers
immediately before submitting, from lanes this endpoint never reads. A catalogue
that stated it would be stating it from a registry it did not consult, and the
plausible filler is the dangerous one: `eligibleRoutes: 0` is a claim that no
route can serve the preset, and it disables the control. The field is null on
the wire and the surface renders null as NOT CHECKED HERE.

**And three settings are served as deliberately absent, with reasons.** Mood,
gestures and whether an action finishes are not offered, because nothing can
score a delivered take against them — `JUDGEMENT_REASONS` in
`human-video-direction-adherence.ts` carries the measured reason for each (52
blendshapes come back from a face; mapping them onto "warm" rather than "amused"
is not a measurement). They are listed rather than omitted because an absent
control reads as an oversight, and the fix somebody reaches for is a free-text
box — which is the one control shape `182.C.23.05` forbids. The rule generalises
the item's prohibition: raw provider JSON is forbidden not because it is JSON
but because nothing downstream knows what it asked for, and a named dropdown
nothing can score is the same failure in nicer clothing.

## Scopes, and why a real session is still refused

Every operation is gated on an `hv:` scope — `hv:assets:read`,
`hv:projects:write`, and so on — and a held scope now satisfies a required one
**exactly or by a wildcard inside the namespace**: `hv:assets:*` and `hv:*`
cover `hv:assets:read`. That matches what every other gate in this BFF already
does (`oneroster-route.ts`, `sso-route.ts`, `lms-route.ts` and
`output-catalog.ts` all accept `admin:*` beside the specific scope). Until
2026-09-12 the comparison here was exact, so `hv:*` — the obvious thing for an
entitlement layer to mint, and the scope this document has named since it was
written — was a string that granted nothing.

**The wildcard stops at `hv:` on purpose.** `domain:*` is issued to every
signed-in customer by `customer-auth-store.ts`. Letting it cover
`hv:assets:read` would hand all of them the tenant's identity packs, which is
listing the faces a project was built from — the exact thing `REQUIRED_SCOPE`
separates from project access, and a bare `*` would be every scope on the
platform.

**So a real customer session is still refused by all thirty-two endpoints**, and
that is the honest state rather than an oversight: nothing mints an `hv:` scope
yet. Who may author directed human video is an entitlement decision, and
`182.C.24.05` already says it belongs in the entitlement layer rather than in a
directory name. This change only makes the answer expressible; measured in the
web e2e on 2026-09-11, the Studio's regions render the 403 as a failed read with
its reason, which is what a creator should see until that decision is made.

## What is actually mounted, and what a 503 means

The surface is **registered unconditionally** in `apps/oshun/bff/src/server.ts`
and is fail-closed: every port behind it is nullable, and an operation whose
store is unbound answers `503 hv.internal.store_unavailable` rather than an
empty list — an empty list would tell a tenant they have no work.

Three adapters are real. `human_video_project_draft`, so `POST /projects`,
`GET /projects/:id` and `PATCH /projects/:id` serve where the table exists; the
idempotency record is bound with it. And `human_video_upload_session` together
with a byte sink — **both**, because a session table with nowhere to put the
bytes would accept an open, answer a `nextOffset`, and drop every chunk into
nothing, which a client cannot tell from an upload that worked. The sink is a
staging directory named by `OSHUN_HUMAN_VIDEO_UPLOAD_ROOT`; there is
deliberately no default, because an implicit one takes biometric bytes into a
location nobody chose and nobody backs up. The boot line says which half is
missing.

A resumable upload straight to object storage is a multipart upload — part
numbers, per-part ETags, a 5 MiB floor on every part but the last — and browser
chunk sizes do not obey that floor. Staging where an append at an offset is a
real operation and handing the object over once is the shape that works;
`finalize` is that seam and this deployment stops there.

And `human_video_identity_pack` together with a face detector — **both**, for
the same shape of reason. `evaluateIdentityPack` REJECTS a reference with no
face in it, correctly; so a detector that was absent and answered "no faces"
would tell a creator their photograph has nobody in it. The detector is YuNet
running on CPU through `tools/human-video/face-geometry.py`, configured by
`OSHUN_HUMAN_VIDEO_PYTHON`, `_FACE_GEOMETRY_SCRIPT` and `_YUNET_MODEL`; every
failure of the child process throws, and nothing degrades to an empty face list.

**A face id has to survive a second run, and the type does not say so.**
`DetectedFace.faceId` is documented as "stable id within one detection run",
which is not enough for the flow it serves: a two-face reference comes back
`awaiting-mapping`, the creator answers which face is the cast member, the pack
is POSTed again — **and the detector runs again**. An id that is the array index
is stable only within a run, and YuNet orders by confidence, which can flip
between two faces of near-equal score; the creator's answer would then name a
face "the detector did not find". So the id is derived from WHERE the face is —
the box, quantised to whole percent of the frame — and two faces that quantise
to one id are refused rather than deduplicated, because dropping one loses a
person from the image. `GET /presets` serves on every deployment, because it has
no store to be unbound — so a 503 from THAT one means the service itself is
failing rather than an adapter being absent, and the web client says so in
different words from the ones it uses everywhere else. Everything else — upload
sessions, identity packs, audio takes, jobs, candidates, reviews, releases,
erasure and the project listing — has no adapter yet and says so at boot, in a
line naming each one, so an operator meeting a 503 can tell a misconfigured
database from an operation that was never meant to serve yet.

A deployment whose generated Prisma client predates those tables is treated
**exactly like no database**: the delegate is checked for the methods it must
have, and a missing one leaves the port null. Binding it anyway would produce
`Cannot read properties of undefined (reading 'findUnique')` on the first
request — a 500 that tells nobody anything.

## Why there is a `GET /reviews`

`182.C.29.02` built the review queue and `182.C.29.04` built the rule for what a
reviewer may hold, and neither was reachable: an operator could record a
decision through `POST /candidates/:id/approval` and could not see what was
waiting for one.

The listing carries **the access decision per artifact**, taken here rather than
by the client: the take and the approved audio come with a URL, the biometric
original comes back with the sentence explaining why not, and the URL never
travels beside a refusal. The reviewer role is read from the caller's SCOPES —
`hv:reviews:senior`, `hv:reviews:trust-and-safety` — because a role a client
could name is a role a client could claim, and role is half of what unlocks the
most sensitive artifact in the system. The other half is an open case about that
subject, which the deployment supplies; a senior reviewer with no case still
gets the refusal.

## Why there is a `GET /audio/:audioId`

`approve-audio` is a deciding operation: it requires an `If-Match` carrying the
version being approved. Until this read existed, the only place that tag ever
appeared was the response to `POST /audio` — so a creator who closed the tab
between submitting and approving could not approve at all, and a surface
reopened days later could not show what it was about to approve. In a flow whose
normal state is waiting days on somebody else, that is not an edge case: a read
is what makes the deciding operation reachable a second time.

## The project draft, and the two things it deliberately does not do

A project holds the creator's work between sessions. It has to: the flow waits
for the subject to enrol and `182.C.23.01` establishes that may take days, which
is longer than a browser tab lives.

**It carries no consent state, no quality, and no approval.** Those belong to
resources the creator does not own, and a draft that stored them would let an
autosave from an old tab write back a consent that has since been withdrawn —
undoing a withdrawal through the one path nobody audits, because it is not a
decision, it is typing. The draft holds identifiers and the creator's own
choices (role, crop, which face, the words, the shots); everything else is read
from the resource that owns it, every time.

**And its concurrency control is a version in the body, not an `If-Match`.** The
precondition path is for deciding operations, where the ETag also carries the
digest of the evidence being decided against; saving a draft decides nothing,
and sending an autosave through that machinery would make it look like an
approval. A save states the `expectedVersion` it was edited from. Omitting the
field is refused rather than defaulted, because a client that forgets it would
otherwise win every race silently.

### What a withdrawal does to a draft

A draft holds no image. It holds the subject's **name**, the identifiers of
their references and the creator's crop over them — and a project that was never
submitted has **no job**, so the job-based enumeration a withdrawal runs finds
nothing for exactly the most sympathetic case there is: somebody withdrew before
anything was generated, and the surface goes on offering them as cast.

So `authoring-drafts` is a copy location, and
`POST /authorizations/:id/withdrawal` clears it:

- the cast entry and their references go;
- **the recorded voice goes too**, because a recorded or cloned voice is the
  subject's likeness in its own right (`182.C.26.01`);
- **the words stay**, with the speaker cleared. They are the creator's, written
  by them and reusable with a different performer, and destroying them is a
  second wrong committed while righting the first;
- the draft's **version does not move**, so the creator's next save is not
  reported as somebody else's conflict.

Two things this deliberately does not do. It never **blocks** the withdrawal: a
deployment with no draft store bound, or a store that cannot answer, reports
`authoring-drafts` as **not reached** in the manifest and the retraction goes
ahead — refusing a retraction until an operator binds a store is the sentence
the whole retraction path exists to avoid saying. And an unbound store is never
read as "no drafts": absent is not none, and the manifest says whether any
unsubmitted project still names the person is **not known here**.

A version mismatch answers `409 hv.validation.draft_conflict` **with the other
side's draft in the body**. A bare 409 leaves a client choosing between
discarding the creator's typing and refetching; with both documents in hand it
can keep the unsent edits and say which version it did not apply.

Two properties hold across the whole set rather than per handler, which is why
the surface is a table and not thirty hand-written routes: **every deciding
operation requires a precondition**, and **every mutation accepts an idempotency
key**. `assertSurfaceCoherent()` checks both, and a new endpoint that forgets
either fails that test rather than review.

There is no `DELETE /jobs`. Cancellation is its own verb, because `182.C.15.05`
established that cancelling a submitted render recovers nothing — the money is
spent — and a DELETE would suggest otherwise.

## The shape of a call

Schema-derived, not captured. Read these for the headers and the sequence, not
as evidence that a server answered.

```http
POST /v1/human-video/jobs
Authorization: Bearer $OSHUN_API_TOKEN
Idempotency-Key: 8f1c…            # any mutation; replaying it returns the first result
Content-Type: application/json
```

The sequence is fixed by the workflow rather than by taste
(`creator-workflow.md`): upload sessions produce assets, an identity pack
declares what they are, an authorization binds a subject to their **digests**,
audio is registered and then approved, and only then can a job be submitted.
Submitting before the authorization exists is not an ordering preference — it is
`hv.consent.missing`, and it is terminal.

## Lead with the refusals, because they are what you will hit first

An API guide made of 200s teaches an integrator to write a client that only
handles success. Every failure carries a stable code, an HTTP status, and a
**retry disposition** that says whether retrying can ever help
(`libs/contracts/src/human-video/failure-codes.ts`). The disposition is the
contract; the message is not.

| Code                                | Status | Retry        | What it means for your client                                                                      |
| ----------------------------------- | ------ | ------------ | -------------------------------------------------------------------------------------------------- |
| `hv.consent.missing`                | 403    | after change | No authorization binds this subject to these reference digests. Terminal; nothing to wait for.     |
| `hv.capability.no_eligible_lane`    | 409    | other lane   | No route can serve this request as written. Another may, and waiting will not change that.         |
| `hv.validation.unsupported_version` | 400    | after change | The contract version is not served. **If your client did not change, we rolled back** — see below. |
| `hv.budget.rate_limited`            | 429    | later        | A window has to reset. `Retry-After` is present and honest.                                        |
| `hv.transport.submission_ambiguous` | 500    | never        | The provider may have accepted it. **Do not retry**; reconcile with the idempotency key.           |

The statuses are worth reading twice. `consent.missing` is a **403**, not a 400:
the request is well-formed and you are not permitted to make it.
`no_eligible_lane` is a **409**, because nothing about the request is invalid —
the platform has no route for it today, which is why the catalog blames
`platform` rather than the caller. And `submission_ambiguous` is a **500** whose
disposition is `never`: a retry could pay twice for a render the provider
already accepted.

Two rules follow from that table and both are enforced rather than advised:

- **A `Retry-After` never appears on a failure waiting will not fix.** A
  well-behaved client obeys one, sleeps, and sends the identical request again;
  attaching one to a settled refusal turns an answer into a polling loop
  (`assertRetryAfterHonest`).
- **`unsupported_version` blames the request, and after a rollback that is our
  fault, not yours.** The catalog marks it `blame: request`, which is right when
  a client is behind and wrong when the server moved. A rollback-caused refusal
  discloses the rollback (`182.C.38.06`); if you see this code and your client
  has not changed, that is what happened.

## What a measurements response is and is not

`GET /candidates/:candidateId/measurements` returns **what was measured, and
what could not be**. Those are different fields for a reason a client has to
respect: a window nobody could score is not a window that passed. A client that
treats "no findings" as "no problems" reproduces the exact failure the evaluator
stack was built to prevent, and a shot of the back of somebody's head passes
every check there is.

Read `limitations.md` before writing the code that consumes this endpoint.

## What this guide will not do

1. Show a captured success. There is no server to capture one from, and a
   fabricated one would be worse than the gap.
2. Ship a face. The smoke corpus has none, and a quickstart that needs one is a
   quickstart that ships biometric data.
3. Quote a pass rate. Those are `182.C.30.03`'s and they wait on a corpus.
