# DESIGN-0.37.15 — Workflow Ownership & Lifecycle Fixes the two structural problems in the workflow system: (1) workflows are admin-only with no way to clean up stale instances, and (2) the FE surfaces don't expose the team-admin capabilities the BE already has. 90%+ of workflows are team-admin generated. Assignments happen to team members on their stage turn. This design makes that the first-class path. Depends on: v0.37.14 (FE Rewrite), v0.35.0 (Workflow Product). --- ## Use Cases ### UC1 — Pure Data Gather (no LLM, no chat) **Example:** Employee onboarding form. HR team admin creates a 3-stage workflow: (1) employee fills a form, (2) HR reviews and approves, (3) IT provisions accounts. - Stage 0: `form_only`, `entry_mode: public_link` — visitor sees a form - Stage 1: `review`, `assignment_team_id: hr-team` — HR member claims, reviews data, advances - Stage 2: `form_only`, `assignment_team_id: it-team` — IT fills provisioning fields, completes **No LLM involved at any stage.** No chat pane. The visitor never sees stages 1-2. They get a terminal "submitted" screen after stage 0. ### UC2 — Data Gather + Live Chat with Team Member (no LLM) **Example:** Customer support intake. Customer fills a form describing their issue, then gets a live chat channel with a support agent. - Stage 0: `form_only`, `entry_mode: public_link` — customer fills issue form - Stage 1: `form_chat`, `assignment_team_id: support-team` — agent claims, chats with customer - Stage 2: `review`, `assignment_team_id: support-leads` — lead reviews transcript, closes **No LLM.** The chat is human-to-human. The customer sees stages 0-1 (their form and the chat). Stage 2 is internal. ### UC3 — LLM-Driven Intake Triggering Team Member **Example:** IT help desk. An AI persona does first-level triage via chat, gathers structured data, then routes to the right team. - Stage 0: `chat_only`, `persona_id: helpdesk-bot`, `entry_mode: public_link` — LLM chats with user, extracts issue type - Stage 1: `review`, `assignment_team_id` resolved by routing rules — team member reviews AI summary + transcript - Stage 2: `form_chat`, same team — agent works with user if needed, fills resolution form **LLM is stage 0 only.** Stages 1-2 are human. Conditional routing (`transition_rules.conditions`) determines which team gets stage 1 based on data the LLM extracted. ### Cross-Cutting: Stage Visibility Stages have an audience: | Audience | Who sees it | Examples | |----------|-------------|---------| | `visitor` | The person who entered the workflow | Stage 0 form, stage 1 chat with agent | | `team` | Members of the assigned team only | Internal review, triage, approval | | `system` | No human UI — automated transition | Webhook fire, data enrichment hook | **Rule:** Once a stage's audience is `team`, the visitor's view is frozen. They see a "your request is being processed" terminal. They never see the internal stages, the assignment queue, or downstream team handoffs. This is NOT a new DB column. It's implied by the stage configuration: - `form_only` or `chat_only` with no `assignment_team_id` → visitor-facing - Any stage with `assignment_team_id` → team-facing - `auto_transition: true` with no persona and no form → system The FE reads the stage config and renders accordingly. ### Cross-Cutting: Multi-Team Handoffs Stage 1 is Team A (triage). Stage 2 is Team B (specialist). Stage 3 is Team A again (close-out). Each team sees ONLY their assignments. `ListAssignmentsForTeam` already filters by `team_id`. The FE must scope the queue view to the selected team context. ### Cross-Cutting: Instance Lifecycle Current status enum: `active | completed`. Add: `cancelled`. Who can cancel: - The instance owner (the user who started it) - A team admin of the owning team - A global admin Cancelling an instance sets `workflow_status = 'cancelled'` and cancels all open assignments (`status IN ('unassigned','claimed')`). ### Cross-Cutting: Assignment Lifecycle Current status enum: `unassigned | claimed | completed`. Add: `cancelled`. New operations: - **Unclaim** — returns `claimed` → `unassigned` (claimer or team admin) - **Reassign** — changes `assigned_to` on a claimed assignment (team admin) - **Cancel** — sets `cancelled` (team admin, fires when instance is cancelled) --- ## JSX Exemplars These are specification-grade components. Implementation translates to the project's `htm` tagged template syntax. Props match the API response shapes. State management uses the SDK (`sw.api.*`). ### E1 — Team Workflow Queue (the "inbox") This is the primary surface a team member sees. It replaces the current bare list of workflows with an actionable assignment queue. ```jsx /** * TeamWorkflowQueue — assignment inbox for a team member * * Shows: my claimed assignments, then unassigned assignments I can claim. * Actions: claim, unclaim, open (navigate to workflow channel), complete. * * Mount: team-admin surface, "Assignments" tab * Data: GET /api/v1/teams/:teamId/assignments?status=unassigned * GET /api/v1/teams/:teamId/assignments?status=claimed */ function TeamWorkflowQueue({ teamId }) { const [claimed, setClaimed] = useState([]); const [unassigned, setUnassigned] = useState([]); const [loading, setLoading] = useState(true); async function load() { const [c, u] = await Promise.all([ sw.api.teams.assignments(teamId, { status: 'claimed' }), sw.api.teams.assignments(teamId, { status: 'unassigned' }), ]); setClaimed(c || []); setUnassigned(u || []); setLoading(false); } useEffect(() => { load(); }, [teamId]); // WS live update: listen for workflow.assigned / workflow.claimed useEffect(() => { const off1 = sw.on('workflow.assigned', load); const off2 = sw.on('workflow.claimed', load); return () => { off1(); off2(); }; }, [teamId]); async function claim(id) { await sw.api.workflowAssignments.claim(id); load(); } async function unclaim(id) { await sw.api.workflowAssignments.unclaim(id); load(); } return (
{/* ── My Claimed ── */}

My Active ({claimed.length})

{claimed.map(a => (
{a.workflow_name || 'Workflow'} {a.stage_name || `Stage ${a.stage}`} {a.sla_breached && SLA}
))} {/* ── Unassigned ── */}

Available ({unassigned.length})

{unassigned.map(a => (
{a.workflow_name || 'Workflow'} {a.stage_name || `Stage ${a.stage}`} {timeAgo(a.created_at)}
))} {claimed.length === 0 && unassigned.length === 0 && (
No assignments — queue is clear
)}
); } ``` ### E2 — Stage Editor (team-admin inline) The current team-admin workflows surface shows stages read-only. This replaces it with a full inline editor. ```jsx /** * StageEditor — inline stage list with add/edit/delete/reorder * * Mount: team-admin workflows surface, inside the workflow edit view. * Data: GET /api/v1/teams/:teamId/workflows/:wfId/stages * POST /api/v1/teams/:teamId/workflows/:wfId/stages * PUT /api/v1/teams/:teamId/workflows/:wfId/stages/:sid * DELETE /api/v1/teams/:teamId/workflows/:wfId/stages/:sid * PATCH /api/v1/teams/:teamId/workflows/:wfId/stages/reorder */ function StageEditor({ teamId, workflowId }) { const [stages, setStages] = useState([]); const [editing, setEditing] = useState(null); // stage id or 'new' const [teams, setTeams] = useState([]); const [personas, setPersonas] = useState([]); async function load() { const [s, t, p] = await Promise.all([ sw.api.teams.workflowStages(teamId, workflowId), sw.api.teams.members(teamId), // for assignment_team picker sw.api.teams.personas(teamId), // for persona picker ]); setStages(s || []); setTeams(t || []); setPersonas(p || []); } useEffect(() => { load(); }, [workflowId]); async function addStage(data) { await sw.api.teams.createWorkflowStage(teamId, workflowId, data); setEditing(null); load(); } async function updateStage(stageId, data) { await sw.api.teams.updateWorkflowStage(teamId, workflowId, stageId, data); setEditing(null); load(); } async function deleteStage(stageId) { const ok = await sw.confirm('Delete this stage?', true); if (!ok) return; await sw.api.teams.deleteWorkflowStage(teamId, workflowId, stageId); load(); } return (
Stages ({stages.length})
{/* ── Stage list (drag handle placeholder for reorder) ── */} {stages.map((s, i) => (
#{i + 1}
{s.name} {s.stage_mode} {s.persona_id && persona} {s.assignment_team_id && team assign}
))} {/* ── Inline edit/create form ── */} {editing && ( s.id === editing)} personas={personas} teams={teams} onSave={(data) => editing === 'new' ? addStage(data) : updateStage(editing, data) } onCancel={() => setEditing(null)} /> )}
); } /** * StageForm — create/edit a single stage * * Fields: name, stage_mode, persona_id, assignment_team_id, * history_mode, auto_transition, sla_seconds. * Form template editing is a separate concern (CS6+). */ function StageForm({ stage, personas, teams, onSave, onCancel }) { const [name, setName] = useState(stage?.name || ''); const [mode, setMode] = useState(stage?.stage_mode || 'chat_only'); const [personaId, setPersonaId] = useState(stage?.persona_id || ''); const [assignTeam, setAssignTeam] = useState(stage?.assignment_team_id || ''); const [historyMode, setHistoryMode] = useState(stage?.history_mode || 'full'); const [autoTransition, setAutoTransition] = useState(stage?.auto_transition || false); const [sla, setSla] = useState(stage?.sla_seconds || ''); function submit() { onSave({ name, stage_mode: mode, persona_id: personaId || null, assignment_team_id: assignTeam || null, history_mode: historyMode, auto_transition: autoTransition, sla_seconds: sla ? parseInt(sla, 10) : null, }); } return (
setName(e.target.value)} />
setSla(e.target.value)} placeholder="e.g. 3600" />
); } ``` ### E3 — Instance Monitor (team-admin) Shows active instances for the team's workflows with cancel capability. ```jsx /** * TeamInstanceMonitor — active workflow instances for a team * * Mount: team-admin workflows surface, "Monitor" tab * Data: GET /api/v1/teams/:teamId/workflows/monitor/instances * Actions: cancel instance */ function TeamInstanceMonitor({ teamId }) { const [instances, setInstances] = useState([]); const [loading, setLoading] = useState(true); async function load() { const data = await sw.api.teams.workflowInstances(teamId); setInstances(data || []); setLoading(false); } useEffect(() => { load(); }, [teamId]); async function cancelInstance(channelId) { const ok = await sw.confirm( 'Cancel this workflow instance? All open assignments will be cancelled.', true ); if (!ok) return; await sw.api.teams.cancelWorkflowInstance(teamId, channelId); sw.toast('Instance cancelled', 'success'); load(); } return (

Active Instances ({instances.length})

{instances.map(inst => (
{inst.workflow_name} {inst.stage_name || `Stage ${inst.current_stage}`} {inst.sla_breached && SLA breached} Age: {formatDuration(inst.age_seconds)}
))} {instances.length === 0 &&
No active instances
}
); } ``` ### E4 — My Assignments (user settings) Every authenticated user sees their personal assignment queue across all teams they belong to. This is the "what's on my plate" view. ```jsx /** * MyAssignments — user's personal assignment queue * * Mount: settings surface, "Assignments" section * Data: GET /api/v1/workflow-assignments/mine * Actions: claim, unclaim, open channel, complete */ function MyAssignments() { const [assignments, setAssignments] = useState([]); const [loading, setLoading] = useState(true); async function load() { const data = await sw.api.workflowAssignments.mine(); setAssignments(data || []); setLoading(false); } useEffect(() => { load(); }, []); // Group: my claimed first, then available const mine = assignments.filter(a => a.status === 'claimed' && a.assigned_to === sw.auth.user?.id); const available = assignments.filter(a => a.status === 'unassigned'); return (
{mine.length > 0 && ( <>

Claimed ({mine.length})

{mine.map(a => ( ))} )} {available.length > 0 && ( <>

Available ({available.length})

{available.map(a => ( ))} )} {mine.length === 0 && available.length === 0 && (
No assignments
)}
); } /** * AssignmentRow — reusable row for any assignment context * * Shared between TeamWorkflowQueue (E1) and MyAssignments (E4). * The showTeam prop controls whether the team badge is visible * (needed in MyAssignments where assignments span multiple teams). */ function AssignmentRow({ assignment: a, onAction, showTeam }) { const isMine = a.assigned_to === sw.auth.user?.id; async function claim() { await sw.api.workflowAssignments.claim(a.id); onAction(); } async function unclaim() { await sw.api.workflowAssignments.unclaim(a.id); onAction(); } async function complete() { await sw.api.workflowAssignments.complete(a.id); sw.toast('Assignment completed', 'success'); onAction(); } return (
{a.workflow_name || 'Workflow'} {showTeam && a.team_name && {a.team_name}} {a.stage_name || `Stage ${a.stage}`} {a.sla_breached && SLA} {timeAgo(a.created_at)}
{a.status === 'unassigned' && ( )} {isMine && ( <> )}
); } ``` ### E5 — Visitor Terminal Screen When a visitor completes their last visible stage, they see this instead of the internal team stages. ```jsx /** * VisitorTerminal — "thank you" screen after visitor stages complete * * The workflow surface (Go template + JS) detects when the current * stage is team-facing and the visitor is not a team member. * Instead of rendering the stage, it renders this terminal. * * Mount: workflow surface (replaces stage content) * Data: channel workflow_status + stage config */ function VisitorTerminal({ workflowName, branding, status }) { // status: 'active' (still being processed) | 'completed' | 'cancelled' const messages = { active: { title: 'Request Submitted', body: 'Your request is being reviewed. You\'ll be contacted if we need more information.' }, completed: { title: 'Complete', body: 'Your request has been processed. Thank you.' }, cancelled: { title: 'Cancelled', body: 'This request has been cancelled.' }, }; const msg = messages[status] || messages.active; return (
{branding?.logo_url && ( )}

{msg.title}

{msg.body}

{branding?.tagline && (

{branding.tagline}

)}
); } ``` --- ## BE Additions ### New Store Methods ```go // ── ChannelStore additions ── // CancelWorkflow sets workflow_status = 'cancelled' on a workflow channel. CancelWorkflow(ctx context.Context, channelID string) error // ── WorkflowStore additions ── // CancelAssignmentsForChannel sets status = 'cancelled' on all // unassigned/claimed assignments for a channel. CancelAssignmentsForChannel(ctx context.Context, channelID string) (int64, error) // UnclaimAssignment returns a claimed assignment to unassigned. // Returns rows affected (0 = not claimed or not found). UnclaimAssignment(ctx context.Context, assignmentID string) (int64, error) // ReassignAssignment changes assigned_to on a claimed assignment. // Returns rows affected. ReassignAssignment(ctx context.Context, assignmentID, newUserID string) (int64, error) // CancelAssignment sets a single assignment to cancelled. CancelAssignment(ctx context.Context, assignmentID string) (int64, error) ``` ### New Handler Endpoints ``` POST /api/v1/channels/:id/workflow/cancel — owner or team admin POST /api/v1/workflow-assignments/:id/unclaim — claimer or team admin POST /api/v1/workflow-assignments/:id/reassign — team admin POST /api/v1/workflow-assignments/:id/cancel — team admin # Team-scoped variants (for team-admin surface): POST /api/v1/teams/:teamId/workflows/monitor/instances/:channelId/cancel ``` ### New FE API Domains ```js // teams domain additions: assignments: (id, opts) => rc.get(`/api/v1/teams/${id}/assignments` + _qs(opts)), cancelWorkflowInstance: (id, chId) => rc.post(`/api/v1/teams/${id}/workflows/monitor/instances/${chId}/cancel`, {}), workflowInstances: (id) => rc.get(`/api/v1/teams/${id}/workflows/monitor/instances`), // stage CRUD (routes exist, FE bindings missing): workflowStages: (id, wfId) => rc.get(`/api/v1/teams/${id}/workflows/${wfId}/stages`), createWorkflowStage: (id, wfId, data) => rc.post(`/api/v1/teams/${id}/workflows/${wfId}/stages`, data), updateWorkflowStage: (id, wfId, sid, data) => rc.put(`/api/v1/teams/${id}/workflows/${wfId}/stages/${sid}`, data), deleteWorkflowStage: (id, wfId, sid) => rc.del(`/api/v1/teams/${id}/workflows/${wfId}/stages/${sid}`), reorderWorkflowStages: (id, wfId, ids) => rc.patch(`/api/v1/teams/${id}/workflows/${wfId}/stages/reorder`, { ordered_ids: ids }), // workflowAssignments domain (new top-level): workflowAssignments: { mine: () => rc.get('/api/v1/workflow-assignments/mine'), get: (id) => rc.get(`/api/v1/workflow-assignments/${id}`), claim: (id) => rc.post(`/api/v1/workflow-assignments/${id}/claim`, {}), unclaim: (id) => rc.post(`/api/v1/workflow-assignments/${id}/unclaim`, {}), complete: (id) => rc.post(`/api/v1/workflow-assignments/${id}/complete`, {}), reassign: (id, userId) => rc.post(`/api/v1/workflow-assignments/${id}/reassign`, { user_id: userId }), cancel: (id) => rc.post(`/api/v1/workflow-assignments/${id}/cancel`, {}), comment: (id, text) => rc.post(`/api/v1/workflow-assignments/${id}/comment`, { text }), }, ``` ### Schema Notes No new migration files needed IF `workflow_status` and assignment `status` are TEXT columns (they are). The new `cancelled` value is just a string. Verify with: ```sql -- Should return TEXT, not an enum: SELECT column_name, data_type FROM information_schema.columns WHERE table_name = 'channels' AND column_name = 'workflow_status'; ``` --- ## Changeset Plan | CS | Scope | Files | Notes | |----|-------|-------|-------| | CS1 | BE: instance cancel | store iface + pg/sqlite impl + handler + routes | CancelWorkflow, CancelAssignmentsForChannel | | CS2 | BE: assignment lifecycle | store iface + pg/sqlite impl + handler + routes | Unclaim, Reassign, CancelAssignment | | CS3 | FE: api-domains wiring | `api-domains.js` | Wire all missing team stage CRUD + assignment operations | | CS4 | FE: team-admin workflows rewrite | `team-admin/workflows.js` (split to sub-files if needed) | Stage editor (E2), instance monitor (E3), queue (E1) | | CS5 | FE: settings assignments | `settings/workflows.js` → rename to `settings/assignments.js` | MyAssignments (E4), replaces read-only workflow list | CS1-CS2 are BE-only, testable via integration tests. CS3 is a single file, no visual changes. CS4-CS5 are FE-only, depend on CS3. --- ## What We're NOT Doing in .15 - Form template visual editor (stage form_template is still raw JSON) - Workflow template sharing between teams - Bulk operations (batch cancel, batch reassign) - SLA alerting/notification automation (monitor is read-only) - Visitor terminal customization beyond branding (E5 is static) - Drag-and-drop stage reorder (button-based reorder only) - Workflow analytics/reporting beyond the existing funnel endpoint