Manage approvals
Approvals are SABLE's human-in-the-loop control surface. They live in the Supabase approvals table and are surfaced by Home through /api/inbox and /api/approvals.
Status Model
The API prevents terminal statuses from being regressed into approved.
Approval Kinds
Current approval kinds:
asana_task_createweekly_updateemail_draftscope_flagsow_estimatesow_draftclient_readoutscope_changescope_summarydeliverable_releaseprompt_updateprompt_base_updateskill_updateboard_updatekickoff_briefcalendar_event_create,calendar_event_update,calendar_event_cancel
asana_task_create and the three calendar_event_* kinds are the ones that execute an external
write, each behind its own operator switch. Asana proposals are currently paused at source. See
SPINE_DISABLED_PROPOSAL_KINDS.
Several kinds apply inside SABLE the moment they are approved, with no Execute step:
board_update appends a board_state row (the per-project status line get_board reads on every
later run); prompt_update folds a learned rule into the live prompt; prompt_base_update promotes a
new base prompt; skill_update rewrites a skill body; kickoff_brief attaches the discovery headline
to the project. Approving those is the change, not a record that someone agreed with it.
Drafting approval kinds mutate SABLE-owned records: weekly_update and scope_change insert
published client_updates; sow_draft, client_readout, and scope_summary create internal
deliverables.
The canonical release path is a separate admin or lead Release to client action in Draft
Workbench. It calls /api/deliverable-release directly and creates the immutable current version,
review state, release receipt, and notification event. Setting the base visibility flag alone does
not satisfy the current-version portal gate.
Approving a pending legacy deliverable_release record now uses the same release rules rather than
flipping the base flag. The route resolves the exact deliverable ID, rechecks the linked source draft's
quality gate, QA issues, and missing inputs, then atomically consumes the approval and creates the
immutable release through release_deliverable_approval_to_portal. A blocked release returns 409 and
leaves the approval pending. Exact retries use deliverable-approval:<approval id> and do not create
another version or email.
Approvals that publish to a client (weekly_update and scope_change updates, and the drafting
kinds that create deliverables) run an eligibility check before the approval is recorded. It is
the same client-eligibility check that runs when the record is published. An item that is no longer
eligible stays pending and the approval returns 409 with a plain message (usually asking you to regenerate), so nothing ends
up approved but unpublished. Typical causes are a source that has since become Internal, or a
weekly wrap whose listed meeting has passed or changed, which gets its own message ("A meeting
listed in this update has passed or changed. Regenerate the update.").
Batch approval refuses all customer-publication kinds with HTTP 422 and
individual_publication_required. This includes weekly_update, scope_change, sow_draft,
client_readout, scope_summary, and deliverable_release. They must be approved individually so
SABLE can run the kind-specific validation and return the exact publication result.
prompt_update is a lead-reviewed learned rule for Prompt Control; when approved, SABLE folds it
into the managed rules block on the live agent prompt.
Approvals Screen
web/approvals.html is the one place for SABLE's own decisions. Since 20 Oct 2026 it is one list,
newest first, replacing the three tabs of Approvals PR 2 (19 Oct). Lindsey and Kirk: "the approval
should just say what type of approval and then approve it", one place to come each morning, and for
anything that could reach a client the choice is Client portal or Internal staff only. It is on
the main menu for leads, after Portal. Each row is labelled with its type, a chip per type (with its
count) filters the list, and the filter is kept in the address (#type=weekly_update,
#type=meeting, …). Old links land on what their tab held: #tab=client on the drafts,
#tab=calendar on calendar and requests, #tab=filing on Needs a project. The list, its types and
the old-link mapping live in web/approvals-model.js.
| Type | Who | What the row does |
|---|---|---|
Drafts (weekly_update, scope_change, sow_draft, client_readout, scope_summary, deliverable_release) | Project managers | Client portal approves in place with the same POST /api/approvals {action:"approve"} Drafts sends, so the server rechecks QA, the weekly-update scope and client eligibility, and a refusal shows on the row. Weekly updates and scope change notes publish on approve; the others are released from Drafts. Internal staff only marks the draft Internal (/api/item-visibility, kind draft) and it leaves the list. Open in Drafts stays. |
| Meeting, Email | Project managers | New. A meeting or email that arrived in the last 14 days, is filed on an active, current, non-Internal project of a client with at least one invited portal contact (a client_memberships row whose person is not deactivated; an invitation writes the membership before acceptance), is not dismissed or Internal, and has no portal_resource_publications row (published, withdrawn or auto-released) or legacy release metadata. Client portal publishes through /api/portal-publications (a meeting with its title and summary editable in the row, prefilled as Prepare does; an email through portal-email-publish.js's dialog). Internal staff only marks the source Internal. Either removes it. |
| Needs a project | Admins; the suggested project's manager | The Inbound queue's unfiled rows (GET /api/approvals?needs_project=1), drawn and run by inbound-queue.js's row controls (SABLE_INBOUND_QUEUE.rows): assign, Internal, dismiss. Filed for a portal-active client, an item comes back as a Meeting or Email. |
| Calendar, Email draft, Board update, Scope flag, SOW estimate | Project managers (calendar: leads and admins) | Approved, edited or rejected in place, as before. |
| Project change, Kickoff brief | Project managers | Open their own pages. |
asana_task_create proposals are paused and in no row or count. Pending prompt_update and
prompt_base_update proposals left Approvals on 20 Oct 2026: only the prompt owners handle them, in
Proposed rules on Admin → Prompt.
- Who sees what (WATT 8, 20 Oct 2026, "for now"). Admins see and act on everything. A project
manager (
projects.lead_person_id) sees and acts on the items whose project they manage, whatever their role: a consultant who manages a project gains Approvals, a lead who manages none loses it (Approvals only; leads keep their authority on Drafts, Kickoff and elsewhere). Unfiled items are seen by admins and by the manager of a project SABLE suggests (the live candidates), who can file them only on a project they manage, or dismiss them; restoring and routing rules stay an admin's. The menu shows Approvals to admins and to anyone managing a current project (/api/whoamimanagesProjects). Every read and action is checked on the server (web/api/_approvals_scope.js): the list, its count and its queue read (surface=approvals),POST /api/approvals(a request withsurface: "approvals"must be the project's manager; without it a lead keeps the lead rule, and a non-lead must be the manager),/api/inbound-emailsfiling, and/api/portal-publicationspublish/withdraw (the manager of the resource's project, for that project's client). Migration20261020210000_approvals_project_managers.sqlletsmutate_portal_resource_publicationandset_item_internal/get_item_internalaccept a consultant only for a project they manage. A project manager who is not a lead or admin may only approve or reject (lead_action_only): edit, retire and execute stay a lead's, becauseedited_payloadsteers what calendar-act and a release do; calendar and Asana rows are neither shown to nor decidable by them. To publish to the portal they must also be a member of the project's team (portal access is not widened). View as previews the viewed person's scope within the viewer's own. Calendar changes stay with leads and admins (calendar-act writes with their access), and so does a deliverable release. - Owners, and My approvals (23 Oct 2026; Shalonda and Lindsey, after WATT emails sat in Lindsey's
list unseen by Shalonda). Every row says who owns it. The default is computed, never stored: the
project manager of the row's project (current and active, an active staff member); for Needs a
project, the manager of SABLE's top suggested project; otherwise Unassigned, which is the
admins'. A calendar change, project change or kickoff brief never defaults to a manager who is not a
lead or admin (they could not decide it), so it falls to the admins. Reassign… hands one row to
any active staff member, and Reset to project manager removes the reassignment
(
POST /api/approvals {action:"assign", item_kind:"approval"|"source", item_id, assignee_person_id|null}; theapproval_assignmentstable, migration20261023120000_approval_assignments.sql). An admin, the row's current owner or its project manager may reassign it; a lead-only row goes only to a lead or admin (lead_only_kind). The assignee sees and decides that one item even on a project they do not manage — on every route the row uses (approve/reject, Internal staff only, Client portal publishing including the email's inline editor when they are not on the client's team, and filing or dismissing a Needs a project row on a suggested project) — within their own role's limits, and nothing else. The list opens on My approvals (rows I own; an admin's also the unassigned ones), with All beside it, remembered per person in the browser. View as previews exactly that person's My approvals. Someone with an open reassigned item gets the Approvals menu (/api/whoamihasApprovalAssignments). - One count.
GET /api/approvals?summary=typesreturns{ by_type, total, access, mine }for the signed-in viewer, counted the way the list's Current view builds it. The top level is All;mine({ by_type, total, … }) is My approvals, and is what the number on the Approvals menu link and Home's line ("5 waiting in Approvals: 2 weekly updates, 3 meetings") count. The chips count whichever view the page shows. Needs a project counts as Inbound's current queue (14-day arrival window). - Calendar approvals make the change. A calendar proposal is approved with
approve_and_execute, so approving it changes Google Calendar in the same step; the page never sends a plain approve for a calendar kind. While the calendar write switch is off or unconfirmed, calendar proposals sit in Held and cannot be approved. If the write fails after the approval is recorded, the row says so and offersexecuteagain. - Window and expiry. Items older than 14 days move to Expired, where they can still be decided; drafts have no window. Nothing is deleted.
- One at a time. The per-meeting Approve all went with the per-project groups;
batch_approveis still on the server. - Home approves nothing. Home's Pulse cards link to Drafts or Approvals.
API
List approvals:
GET /api/approvals?status=pending
Approve one:
{ "action": "approve", "id": "approval-uuid" }
Approve and execute an external write:
{ "action": "approve_and_execute", "id": "approval-uuid" }
Reject one:
{ "action": "reject", "id": "approval-uuid", "reason": "not needed" }
Edit one:
{ "action": "edit", "id": "approval-uuid", "payload": { "due": "2026-07-01" } }
Batch approve ordinary internal approvals:
{ "action": "batch_approve", "ids": ["a", "b", "c"] }
Batch approval refuses every kind that creates or releases customer-visible material. The response names the affected approval IDs; approve those rows individually so SABLE records the exact update, hidden deliverable, or immutable release that was created.
Execute:
{ "action": "execute", "id": "approval-uuid" }
Execution Gate
Executing an Asana proposal calls the Supabase Edge Function asana-act. The function requires:
- Valid Supabase JWT.
- Admin/lead caller.
- Approval belongs to caller org.
- Approval status is
approved. - Approval kind is
asana_task_create. ASANA_ACT_WRITES_ENABLED=true.
Audit
Approved/rejected/edited actions log to board_feedback. Executed external writes log to audit.