---
title: "Close out several queued alarms with one disposition"
method: POST
path: "/eval/operator/bulk"
tags: ["eval"]
---

# Close out several queued alarms with one disposition

`POST /eval/operator/bulk`

## 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/bulk`
- Auth: HTTP bearer

The site story's "Close all N". Every event is disposed under the single-event route's rules (tenant scope, disposition permission, one evaluation per event) in its own transaction; ids that could not be disposed come back in `failed` with the status the single route would have answered, and the rest still land. An alarm another operator holds a live claim on is refused per item with status 423 and code `claim_held` (never the 409 "already closed"): it stays queued and the others in the batch still close; the caller uses Take over first. Only the false-alarm and non-threatening dispositions are accepted (422 otherwise): a talk-down or a dispatch is decided against one clip on the single route.

## Request body

- BulkOperatorEvalRequest
  - `event_ids` string[], required: The queued events to close out with ONE disposition - the site story's "Close all N". Duplicates are collapsed. Each event is disposed in its own transaction under the same rules as the single-event route (tenant scope, disposition permission, one evaluation per event); the response reports every id that could not be disposed instead of failing the batch.
  - `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: The note written on every event's evaluation.
  - `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.
  - `ai_feedback_event_id` string, nullable: The ONE event the site story's AI-feedback question was about (owner 2026-10-03: "selected clip only"). Must be one of event_ids, and only with SUSPICIOUS_MONITOR. ai_feedback_asked on that event is decided by the same server rule as the single-event route (capture flag on, real VLM verdict); every other event stays unasked. Valid WITHOUT ai_feedback: the question was shown and left blank.
  - `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.
  - `story_alarm_count` integer, nullable: How many alarms the WHOLE site story closed, across every batch (the client splits a story into batches of at most 500). Only with ai_feedback_event_id; stored as provenance beside that event's answer. Omitted = stored as null (unknown); never inferred from this request's own count.
  - `surface` 'site_bulk' | 'camera_bulk' | 'rail_bulk': Which console surface sent a bulk close (`POST /eval/operator/bulk`). Written, verbatim, as the claim_log `surface` of every line of that call and nothing else. `site_bulk` (By site, Close all) is the default when the client sends nothing; the single-event close is always `single` and never uses this enum.

## Response `200`

Successful Response

- BulkOperatorEvalResponse
  - `disposed` string[], required: Event ids that now carry the disposition, in request order
  - `failed` BulkOperatorEvalFailure[], required: Event ids that could not be disposed, each with the reason. A clip another operator triaged between the operator opening the site and closing it out shows here as 409 rather than silently vanishing from the batch.
    - `event_id` string, required: The event that was NOT disposed
    - `status` integer, required: The status the single-event route would have answered: 404 unknown, 403 out of scope or missing the disposition permission, 409 already disposed, 423 held: another operator holds a live claim on it (it is NOT gone and NOT closed: keep it queued with its notes and use Take over first).
    - `reason` string, required: Why, in the single-event route's words
    - `code` string, nullable: Machine code for a refusal that has one. `claim_held` on a 423 item (kept distinct from the 409 'already closed'); absent otherwise.
    - `claim` AlarmClaim
      - `operator_id` string, required: The operator holding the alarm
      - `operator_name` string, nullable: Their display name; null if the operator no longer exists
      - `claimed_at` string, required: When the claim was taken (ISO 8601, UTC)
      - `expires_at` string, required: When the claim lapses (ISO 8601, UTC)
      - `expires_in_seconds` integer, required: Seconds left on the claim, computed server-side. Time the claim from this against the local clock at receipt, not from `expires_at`, so a skewed workstation clock does not move it.
      - `generation` string, nullable: Ownership generation (a UUID): it changes whenever the claim's HOLDER changes (acquire, take-over) and never on a renewal. Echo it on `POST /events/{id}/claim/acting` and its cancel; a write carrying an old one is rejected. Null while CLAIM_ESCALATION_ENABLED is off, or on a claim stamped before generations existed (the holder's next renew fills it in).
    - `reservation` Reservation: An alarm reserved for a focuser or held by a site hold. A reservation is NOT a claim: it has no TTL and lives under the claim.
      - `origin` 'focus' | 'story': `story` for a site hold. A reservation from an older api has no `origin`: it is a Focus.
      - `focus_id` string, required: The focus generation or site hold that reserved this alarm.
      - `operator_id` string, required: The focuser or holder.
      - `operator_name` string, nullable: Their display name; null if the operator no longer exists.
      - `reserved_at` string, required: When the alarm entered the queue and was reserved (ISO 8601, UTC).
      - `elapsed_seconds` integer, required: Server-computed now - reserved_at, for the age display; the unreviewed-age cue applies to reserved alarms exactly as to free ones.
      - `engaged` boolean, nullable: Story only: the current holder has opened this alarm.
      - `open` boolean, nullable: Story only: the holder's page is open (no deadline while it is).
      - `return_at` string, nullable: Story only: when the unattended hold returns its alarms (ISO 8601, UTC); null while `open`. After a clean leave, about 30 s after it unless an earlier idle or new-alert deadline or a live call changes it.
      - `now` string, nullable: Story only: the server clock `return_at` is measured against.
      - `serious_unopened` integer, nullable: Story only: serious alarms the holder has not opened (visibility only).
      - `oldest_serious_queued_at` string, nullable: Story only: when the oldest of those entered the queue.
  - `request_id` string, nullable: This call's id: the `request=` value on every claim_log line it wrote. A console can echo it as `closes` on `POST /eval/operator/bulk/undo` so the undo's log lines name the close they reverse. Log correlation only; absent from an older api.

## 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/a19366be50a3?raw)
