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
| Kind | Effect |
|---|---|
asana_task_create | Executes an external write via asana-act (or calendar-act for calendar kinds) |
weekly_update, scope_change, sow_draft, client_readout | Mutates only SABLE-owned draft or update records |
deliverable_release | Atomically consumes the approval and creates the immutable portal release after source-draft QA checks |
prompt_update | Folds an approved learned rule into the live agent prompt |
email_draft, scope_flag, sow_estimate | Remain approval/audit records until their delivery paths are implemented |
Related draft/evidence machinery
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.jsweb/api/_prompt_owner.jsweb/api/_deliverable_authoring.js,_approval_evidence.jsweb/api/_kickoff.js,_board.js,_propose_skill.jstrigger/approvals.ts—sable.approval.execute, the durable execution path