Skip to main content

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_create
  • weekly_update
  • email_draft
  • scope_flag
  • sow_estimate
  • sow_draft
  • client_readout
  • scope_change
  • scope_summary
  • deliverable_release
  • prompt_update
  • prompt_base_update
  • skill_update
  • board_update
  • kickoff_brief
  • calendar_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.

TypeWhoWhat the row does
Drafts (weekly_update, scope_change, sow_draft, client_readout, scope_summary, deliverable_release)Project managersClient 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, EmailProject managersNew. 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 projectAdmins; the suggested project's managerThe 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 estimateProject managers (calendar: leads and admins)Approved, edited or rejected in place, as before.
Project change, Kickoff briefProject managersOpen 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/whoami managesProjects). 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 with surface: "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-emails filing, and /api/portal-publications publish/withdraw (the manager of the resource's project, for that project's client). Migration 20261020210000_approvals_project_managers.sql lets mutate_portal_resource_publication and set_item_internal/get_item_internal accept 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, because edited_payload steers 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}; the approval_assignments table, migration 20261023120000_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/whoami hasApprovalAssignments).
  • One count. GET /api/approvals?summary=types returns { 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 offers execute again.
  • 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_approve is 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.