---
title: "Create Operator Eval"
method: POST
path: "/eval/operator/{event_id}"
tags: ["eval"]
---

# Create Operator Eval

`POST /eval/operator/{event_id}`

## Request

- Base URL: `https://api.verdun.ai` (the document declares no server; this is the origin it was published from)
- URL: `https://api.verdun.ai/eval/operator/{event_id}`
- Auth: HTTP bearer

## Path parameters

- `event_id` string, required

## Request body

- CreateOperatorEvalRequest
  - `trace_context` object, nullable: Optional client close provenance. close_surface and entry_source are optional; invalid containers or values are ignored, never a reason to reject the close.
    - `close_surface` 'list' | 'by_site' | 'story' | 'review_from_story' | 'next_alarm' | 'focus' | 'deep_link' | 'history' | 'unknown' | 'null', nullable
    - `entry_source` 'list' | 'by_site' | 'story' | 'next_alarm' | 'focus' | 'deep_link' | 'review_rail' | 'history' | 'notification' | 'unknown' | 'null', nullable
  - `operator_disposition` 'False alarm - no human activity' | 'Escalate Dispatch' | 'Escalate Voice Down' | 'Non-threatening human activity' | 'Not monitored on this camera', required
  - `notes` string, required: A note about this operator
  - `closeout` CloseoutEntry: Structured operator close-out: what was observed, what was done, how it ended. Stored as one JSONB blob on operator_evaluations.closeout and snapshotted into training_samples.operator_closeout. TWO INDEPENDENT GATES, and conflating them is the mistake to avoid: * SHAPE (this model) is validated on EVERY request carrying a closeout, in every deployment, flag or no flag. * REQUIRED-NESS (which fields a given disposition must fill) lives in EvalService, gated on CLOSEOUT_REQUIRED, because it depends on the disposition AND on deployment state — neither of which belongs in a schema that gets published as openapi.json. That split is why every field except schema_version is optional HERE. The false-alarm fast path may legitimately post a closeout carrying only an outcome, and a model-level `observed: str` would 422 it before the matrix got a say. Optional-here, required-there is what lets ONE schema serve both the flag-off rollout window and the flag-on steady state. `observed` and `outcome_note` are CUSTOMER-VISIBLE (brief §5 emails them in site reports): no operator names, no internal jargon. The copy rule is shown inline in the ops-view form; nothing here can enforce it. UNKNOWN KEYS ARE REJECTED (extra="forbid"), unlike most request models in this repo. This record is versioned and persisted verbatim: with the default extra="ignore" a client typo such as `outcome_notes` would return 200 and be silently discarded before persistence, which is exactly the data loss a "shape is always validated" contract promises cannot happen. A future shape change bumps schema_version rather than adding keys the old server would have to tolerate. (PR #173 review, P2.)
    - `schema_version` 1: Closeout record schema version. Pinned to 1 — a client announcing a shape this server does not implement is rejected rather than silently downgraded into version-1 storage.
    - `observed` string, nullable: Free text: who/what, where, doing what. Customer-visible in site reports. Surrounding whitespace is stripped and a blank value is normalized to null.
    - `actions` string[]: What the operator did, from a closed vocabulary. At most 32 entries on the wire; de-duplicated server-side, first occurrence wins, order preserved.
    - `outcome` 'no_activity' | 'benign_explained' | 'subject_left' | 'subject_remained' | 'dispatched' | 'owner_notified' | 'handed_off', nullable: How the event ended. Defaults to no_activity on a false-alarm disposition that submits a closeout without one.
    - `outcome_note` string, nullable: Optional one-line qualifier on the outcome. Customer-visible. Blank is normalized to null.
    - `ai_feedback` AiFeedbackBlock: Internal operator judgment on the AI's own call (operator-feedback- loop round-0/1 design; owner GO, dispatch D-20260929-27). NEVER customer-visible — see customer_projection_service. STRIP_WHEN_NESTED_UNDER["closeout"] and the SITE_MANAGER-specific strip beside it. Optional everywhere: the API never requires this block, on any disposition, sampled or not — EvalService._validate_closeout's required-ness matrix never references it, and that absence IS the enforcement (see that function's docstring). Accepted from EVERY role that can submit a close-out — no role gate here or in EvalService (research/spec-owner call, round-1-review.md "Carried to the owner" #2): provenance is handled by the sibling operator_evaluations.submitter_role snapshot, not by restricting who may answer. Nested inside CloseoutEntry, itself additive and optional — adding this field needed no schema_version bump (see CloseoutEntry's own docstring on why schema_version stays a hard Literal[1] wall): a v1 row without this key is unaffected, and `ai_feedback` simply reads as None/absent.
      - `agree` boolean, nullable: Operator's tap on whether the AI's call was correct. None = no opinion (skipped or never asked); never defaulted to True. Strict bool (G3, round-5 review, the #314 lesson again): a lax bool coerces "true"/1/"false"/0, which would let a client bug silently turn a string or an int into a real tap.
      - `reason` 'wrong_class' | 'benign_routine' | 'camera_fault' | 'environmental' | 'person_shaped_static' | 'detection_phantom' | 'context_needed' | 'missed_person' | 'other', nullable: Present if and only if agree is False.
      - `should_have_been` 'INTRUSION' | 'TAILGATING' | 'FIGHTING' | 'ARMED_ROBBERY' | 'VEHICLE_THEFT' | 'LOITERING' | 'MAINTENANCE' | 'DELIVERY' | 'NORMAL' | 'HEIGHTENED_ACTIVITY', nullable: Optional even when agree is False; forbidden unless agree is False. Restricted to the security profile's incident types plus NORMAL and HEIGHTENED_ACTIVITY — see AI_FEEDBACK_SHOULD_HAVE_BEEN_TYPES.
      - `frame_ms` integer, nullable: Optional clip offset in milliseconds the operator tapped ('the frame that shows it'). Strict int, no float coercion (the #314 lesson: a stub once accepted a float where the API requires a strict int). Bounded to a generous 1-hour sanity ceiling here; the real clip-length bound is enforced by the client's video element, not this schema.

## Response `200`

Successful Response

- object

## Other responses

- `422`: Validation Error

---

[API](https://skmtc.dev/verdun/apis/verdun-api.md) - [All operations](https://skmtc.dev/verdun/apis/verdun-api/llms.txt) - [OpenAPI document](https://skmtc.dev/verdun/apis/verdun-api/revisions/35a2a6ebedda?raw)
