Skip to main content

Approvals API

/api/approvals is Supabase-backed and is the one queue every proposed write or draft in SABLE passes through — there is no side channel for an agent-proposed action to bypass a human.

GET /api/approvals​

Lists approvals scoped to the caller. Supports filtering and paging (parseApprovalFilters, parseApprovalPaging).

GET /api/approvals?summary=types&as=<scope> (20 Oct 2026) is the one count behind the Approvals list's chips, the menu number and Home's line, by type. It counts what the list's Current view holds for the signed-in viewer:

{ "by_type": { "weekly_update": 2, "meeting": 3, "calendar": 1, "needs_project": 2 }, "total": 8,
"access": { "approvals": true, "filing": true, "client": true, "calendar": true },
"client": 5, "calendar": 1, "filing": 2,
"external_writes": { "asana": null, "calendar": true } }

by_type holds only types with something waiting. The list's types count for leads; needs_project (the current Needs a project count from _inbound_waiting.js) for admins. asana_task_create and prompt proposals are in no count. client, calendar and filing are the 19 Oct tab numbers, kept with total and access so a cached nav script still reads a true number; ?summary=tabs answers the same body. ?summary=1 is unchanged. Since 23 Oct 2026 the body also carries mine: { by_type, total, client, calendar, filing }, the same count for My approvals (rows whose owner is the viewer, or the View-as person; an admin's also the unassigned ones). The menu number and Home's line read mine; the top level stays the All count, so an older caller is unchanged.

Owners (23 Oct 2026): on the Approvals list's reads (surface=approvals, ?waiting=1, ?needs_project=1) every row carries owner: { item_kind, item_id, person_id, name, assigned, assigned_by_name, assigned_at, default_person_id, default_name, lead_only, can_reassign } — person_id null is Unassigned — and mine=1 keeps only My approvals. ?assignees=1 lists the active staff a row may be reassigned to (lead_only_ok marks leads and admins).

GET /api/approvals?waiting=1&as=<scope> returns the list's rows that are not the meeting queue: drafts (each with the approval_id the list approves; a draft marked Internal is excluded), portal (meetings and emails for a portal-active client, see Manage approvals), proposals and kickoff. Leads only; empty for anyone else.

GET /api/approvals?needs_project=1&view=active|expired (admins) returns the Inbound queue's unfiled rows, both types, shaped as /api/inbound-emails shapes them, with the assignment roster.

POST /api/approvals​

{ "action": "approve", "id": "approval-uuid" }
{ "action": "approve_and_execute", "id": "approval-uuid" }
{ "action": "reject", "id": "approval-uuid", "reason": "not needed" }
{ "action": "edit", "id": "approval-uuid", "payload": { "text": "Edited" } }
{ "action": "batch_approve", "ids": ["approval-uuid"] }
{ "action": "execute", "id": "approval-uuid" }
{ "action": "assign", "item_kind": "approval", "item_id": "approval-uuid", "assignee_person_id": "person-uuid", "note": "optional" }
{ "action": "assign", "item_kind": "source", "item_id": "source-uuid", "assignee_person_id": null }

assign (23 Oct 2026) reassigns one Approvals row (approval_assignments), or with assignee_person_id: null resets it to its project manager. An admin, the row's current owner or its project manager (for Needs a project, of a suggested project) may; anyone else gets 403 not_owner. The item must be open and in the caller's organization (404 / 409 decided); the assignee must be active staff there (422 assignee_not_staff); a calendar, Asana, project-change or kickoff row goes only to a lead or admin (422 lead_only_kind). The assignee may then decide that one item.

Who may decide (WATT 8, 20 Oct 2026): an admin; the manager of the approval's project (projects.lead_person_id), whatever their role; the person the item is reassigned to (that item only); or a lead, except when the request says "surface": "approvals" (the Approvals list), where a lead must manage the project too. A consultant manager is refused calendar kinds (calendar_requires_lead); a batch with any row the person may not decide is refused whole (not_project_manager). Reads with surface=approvals, and ?waiting=1, ?summary=types and ?needs_project=1, are scoped the same way. canExecuteApprovalKind and approvalExecutionFunction decide, per approval kind, whether an execute/approve_and_execute call actually reaches an external system (today, only asana_task_create and the Calendar write kinds do) or just changes the approval's own status.

Prompt-owned approval kinds get an extra gate​

PROMPT_OWNED_KINDS = { prompt_update, prompt_base_update, skill_update }

These change what SABLE itself runs — including skills, since guidance held outside the base prompt is still prompt content, and leaving it ungated would make the skills table a way around the single-owner control rather than an extension of it. isPromptOwner (from _prompt_owner.js) gates all three, independent of the general admin/lead check every other approval kind uses.

What each approval kind actually does on execute​

KindEffect
asana_task_createExecutes an external write via asana-act (or calendar-act for calendar kinds)
weekly_update, scope_change, sow_draft, client_readoutMutates only SABLE-owned draft or update records
deliverable_releaseAtomically consumes the approval and creates the immutable portal release after source-draft QA checks
prompt_updateFolds an approved learned rule into the live agent prompt
email_draft, scope_flag, sow_estimateRemain approval/audit records until their delivery paths are implemented

Approving a deliverable-shaped approval (sow_draft, client_readout, scope_summary) also triggers evaluateDraftReadiness and reconcileDraftEvidence — the readiness and citation checks that back the Draft Workbench.

Approval kinds that create client-facing records (weekly_update and scope_change publish a client update; sow_draft, client_readout and scope_summary create a deliverable for later release) run an eligibility preflight when the approval names a client, before it is marked approved. It rechecks the approval and every piece of client-output evidence against the same client-eligibility rules the publication step uses, and, for a weekly wrap, that each meeting listed in the update is still scheduled, current, unchanged and starts when the draft says. An ineligible item stays pending and the request returns 409 with a plain message, for example "Source material is now Internal or unavailable. Regenerate this client update before publishing." or, when a listed meeting has passed or changed, "A meeting listed in this update has passed or changed. Regenerate the update." Regenerate the draft and approve the new one.

Approving deliverable_release requires an exact deliverable_id in payload or edited_payload. Only admin and lead callers can trigger release_deliverable_approval_to_portal. The RPC uses deliverable-approval:<approval id> as its stable idempotency key, requires an approved linked source draft with a ready quality gate and no QA issues or missing inputs, and returns 409 without consuming the approval when readiness fails. Success creates one immutable version, review state, release receipt, approval-feedback row, and the release.published and review.requested outbox events. batch_approve refuses deliverable_release rows with HTTP 422 and individual_release_required, including the affected approval_ids. Release approvals must be handled one at a time so the caller receives the exact QA conflict or durable publication receipt.

Where the code lives​

  • web/api/approvals.js, _approvals.js
  • web/api/_prompt_owner.js
  • web/api/_deliverable_authoring.js, _approval_evidence.js
  • web/api/_kickoff.js, _board.js, _propose_skill.js
  • trigger/approvals.ts — sable.approval.execute, the durable execution path