---
title: "Declare the Snowflake role the shared dispatcher task runs as for this org (DVT-4797)."
method: PUT
path: "/v1/email-dispatcher"
tags: ["dashboards"]
---

# Declare the Snowflake role the shared dispatcher task runs as for this org (DVT-4797).

`PUT /v1/email-dispatcher`

## Request

- Base URL: `http://localhost:8080`
- URL: `http://localhost:8080/v1/email-dispatcher`
- Auth: one of:
  - API key in cookie `dvt_session`
  - HTTP bearer

Records an admin's own choice of role so dvt can render it back into
the admin setup SQL and compare it against what a run actually
reports (`roleMismatch` on `GET /v1/email-dispatcher`). dvt never
chooses this role itself (ADR-0069) — this endpoint only RECORDS the
admin's declaration; the admin still runs the setup SQL by hand to
actually create/own the role in Snowflake.

Requires `schedule:activate` (D8, admin-ranked) — the same capability
`POST .../activate` needs — checked explicitly. Same
`404`-feature-disabled gate as the rest of the email-schedule group.

Every validation (role shape, this install's
application name, `?warehouse=`) runs BEFORE the declared role is
written (DVT-4797 r1, M-2) — none of the failure responses below can
fire anymore after the write has already happened. The 200 response
is rendered from the write's own result, with no separate read
after it.

## Query parameters

- `warehouse` string

## Request body

- object
  - `declaredRole` string, required: The Snowflake role the dispatcher task should run as. Stored verbatim (case-sensitive, never uppercased). A Snowflake system role is accepted; a dedicated least-privilege role such as DVT_SCHEDULE_OWNER is recommended (advice, not an enforced check).

## Response `200`

The declared role was recorded. The updated dispatcher state and admin setup statements.

- object
  - `data` EmailDispatcher, required: GET/PUT `/v1/email-dispatcher`'s response body (DVT-4797): the one, install-wide dispatcher task's observed heartbeat, declared role, and the identical admin setup SQL `GET .../email-schedules/{sid}/setup` returns per schedule.
    - `dispatcherTaskName` string, required: The one, install-wide task name every consumer's account holds at most one of (`DVT_SCHEDULES.PUBLIC.DVT_REPORT_DISPATCHER`) — the same value `EmailScheduleSetup.dispatcherTaskName` carries per schedule.
    - `declaredRole` string, required: This org's declared dispatcher role: an admin's own `PUT` if one has been made, else the same default the admin setup SQL itself falls back to.
    - `firstSeenAt` string, date-time: When the dispatcher first called in for this org. ABSENT (the key itself omitted) when it has never been seen.
    - `lastSeenAt` string, date-time: When the dispatcher most recently called in. ABSENT when it has never been seen.
    - `lastInvokerRole` string: The Snowflake role the most recent heartbeat observed. ABSENT when unknown (no heartbeat yet). Always absent in the native app: `INVOKER_ROLE()` reports the app name there, which is treated as unknown (DVT-4641). Never the invoker USER — that identifies a person, and this response never discloses one.
    - `roleMismatch` boolean, required: True only once a role has actually been observed AND it disagrees with `declaredRole` (exact, case-sensitive compare) — never true while `lastInvokerRole` is absent. A mismatch here means the last successful check-in ran as another role — that check-in carried no dispatcher task name (no role compare ran) or the declared role changed since. Refusals are reported via `lastRefusedAt`/`lastRefusedReason`. In the native app this is never true: the observed role is always unknown (`INVOKER_ROLE()` reports the app name, DVT-4641), so no role compare can run.
    - `anchorEnforced` boolean, required: Whether a call that does NOT carry the dispatcher task's own `TASK_NAME()` at all is refused outright (`true`) or let through with no role compare (`false`, shipped default). This does NOT gate `roleMismatch`: a role mismatch IS refused on the hop today regardless of this flag's value, but only for a call that DOES carry the dispatcher's own `TASK_NAME()` — a call without it never runs the role compare at all, see `roleMismatch`'s own description. False as shipped — pending the patch-10 rig re-drill that measures `TASK_NAME()` the way DVT-4640's drill measured `INVOKER_ROLE()`.
    - `stale` boolean, required: True when `lastSeenAt` is more than three times the dispatcher's own ~5-minute poll interval in the past (15 minutes), or the dispatcher has never been seen at all.
    - `lastRefusedAt` string, date-time: When this org's dispatcher path last REFUSED a call (migration 0112). ABSENT when no refusal has ever happened. Recorded BEFORE the hop answers the refusal to its caller, never load-bearing on that refusal itself.
    - `lastRefusedRole` string: The Snowflake role that refusal observed. ABSENT alongside `lastRefusedAt` when no refusal has ever happened, and also absent for an `anchor-missing` refusal whose own call carried no role to observe.
    - `lastRefusedReason` 'role-mismatch' | 'anchor-missing': Which half of the invoker gate fired: `role-mismatch` (the call carried the dispatcher's own task name but the wrong role) or `anchor-missing` (the call did not carry the dispatcher's task name at all, refused because `anchorEnforced` is true). The wire refusal a caller of the hop itself receives is unaffected by which of these it was — both answer the same published `not-the-dispatcher` shape; this field exists purely so an admin can tell the two apart after the fact. ABSENT when no refusal has ever happened.
    - `adminSetupSql` string, required: The identical text `EmailScheduleSetup.adminSetupSql` returns per schedule — the one-time, install-wide "Admin setup" block.
  - `meta` Meta, required: Response metadata.
    - `requestId` string, required: Correlates with the X-Request-Id header.
    - `total` integer: Item count, present on collection responses.
    - `nextCursor` string, nullable: Opaque keyset-pagination cursor, present on paginated responses (e.g. GET /v1/dashboards) when more rows exist beyond this page. Pass it back as the `cursor` query param to fetch the next page; absent or null on the last page.
    - `roleResolveDegraded` boolean: Snowflake mode only (DVT-2260), on GET /v1/dashboards: present and true when the caller's live Snowflake role resolution failed or was unavailable — role-shared dashboards may be missing from this page. Omitted (not merely false) whenever resolution succeeded or the role-share arm does not apply (off snowflake mode, an org admin/owner, or off the ingress listener).

## Other responses

- `400`: Request body is not a JSON object with exactly one field, `declaredRole` (a string) — malformed JSON or an unknown field (`DisallowUnknownFields`).
- `401`: Missing, invalid, or expired session.
- `403`: The caller's role does not permit this action.
- `404`: Automatic sending is disabled on this deployment (`feature-disabled`) — see `GET .../setup`'s description.
- `409`: This install is not a packaged Snowflake native app, so there is no application to qualify the generated statements with (`email-not-configured`) — the same check `GET /v1/email-dispatcher` runs, applied here BEFORE the write.
- `413`: Request body exceeds the 4 KiB cap for this route.
- `422`: One of two reasons, distinguished by the `reason` extension: - `invalid-declared-role` — `declaredRole` is empty (after trimming whitespace), not safe to render inside a double-quoted Snowflake identifier (a control character, `"$$"`, or a Unicode bidi/zero-width/format character), or would be altered by the hop's own normalization (a line break, doubled internal whitespace, or more than 255 characters) — DVT-4797 r1: a role in this last shape would never compare equal to itself once the dispatcher's hop observes it. - the `InvalidWarehouse` shape — `?warehouse=` was supplied and is not a valid Snowflake identifier. Problem `type` is `https://docs.dvt.dev/errors/validation-failed` for the first; see `InvalidWarehouse` for the second.

---

[API](https://skmtc.dev/dvt/apis/dvt-rest-api.md) · [All operations](https://skmtc.dev/dvt/apis/dvt-rest-api/llms.txt) · [OpenAPI document](https://skmtc.dev/dvt/apis/dvt-rest-api/revisions/00016f504165?raw)
