This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/docs/Workflow/DESIGN-0.37.15.md
gobha b7746c3004 Changeset 0.37.14 (#226)
Co-authored-by: gobha <jasafpro@gmail.com>
Co-committed-by: gobha <jasafpro@gmail.com>
2026-03-23 16:47:48 +00:00

26 KiB
Raw Blame History

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 claimedunassigned (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.

/**
 * 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 (
    <div className="wf-queue">
      {/* ── My Claimed ── */}
      <h4>My Active ({claimed.length})</h4>
      {claimed.map(a => (
        <div key={a.id} className="wf-queue-item wf-queue-item--claimed">
          <div className="wf-queue-item__info">
            <strong>{a.workflow_name || 'Workflow'}</strong>
            <span className="badge">{a.stage_name || `Stage ${a.stage}`}</span>
            {a.sla_breached && <span className="badge badge-danger">SLA</span>}
          </div>
          <div className="wf-queue-item__actions">
            <button className="btn-small" onClick={() => unclaim(a.id)}>Release</button>
            <button className="btn-small btn-primary"
                    onClick={() => location.href = `${BASE}/w/${a.channel_id}`}>
              Open
            </button>
          </div>
        </div>
      ))}

      {/* ── Unassigned ── */}
      <h4>Available ({unassigned.length})</h4>
      {unassigned.map(a => (
        <div key={a.id} className="wf-queue-item">
          <div className="wf-queue-item__info">
            <strong>{a.workflow_name || 'Workflow'}</strong>
            <span className="badge">{a.stage_name || `Stage ${a.stage}`}</span>
            <span className="text-muted">{timeAgo(a.created_at)}</span>
          </div>
          <div className="wf-queue-item__actions">
            <button className="btn-small btn-primary" onClick={() => claim(a.id)}>
              Claim
            </button>
          </div>
        </div>
      ))}

      {claimed.length === 0 && unassigned.length === 0 && (
        <div className="empty-hint">No assignments  queue is clear</div>
      )}
    </div>
  );
}

E2 — Stage Editor (team-admin inline)

The current team-admin workflows surface shows stages read-only. This replaces it with a full inline editor.

/**
 * 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 (
    <div className="stage-editor">
      <div className="stage-editor__header">
        <h5>Stages ({stages.length})</h5>
        <button className="btn-small" onClick={() => setEditing('new')}>+ Add Stage</button>
      </div>

      {/* ── Stage list (drag handle placeholder for reorder) ── */}
      {stages.map((s, i) => (
        <div key={s.id} className="stage-editor__row">
          <span className="stage-editor__ordinal">#{i + 1}</span>
          <div className="stage-editor__info">
            <strong>{s.name}</strong>
            <span className="badge">{s.stage_mode}</span>
            {s.persona_id && <span className="badge badge-active">persona</span>}
            {s.assignment_team_id && <span className="badge">team assign</span>}
          </div>
          <div className="stage-editor__actions">
            <button className="btn-small" onClick={() => setEditing(s.id)}>Edit</button>
            <button className="btn-small btn-danger" onClick={() => deleteStage(s.id)}>×</button>
          </div>
        </div>
      ))}

      {/* ── Inline edit/create form ── */}
      {editing && (
        <StageForm
          stage={editing === 'new' ? null : stages.find(s => s.id === editing)}
          personas={personas}
          teams={teams}
          onSave={(data) => editing === 'new'
            ? addStage(data)
            : updateStage(editing, data)
          }
          onCancel={() => setEditing(null)}
        />
      )}
    </div>
  );
}

/**
 * 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 (
    <div className="stage-form">
      <div className="form-row">
        <div className="form-group">
          <label>Name</label>
          <input value={name} onChange={e => setName(e.target.value)} />
        </div>
        <div className="form-group">
          <label>Mode</label>
          <select value={mode} onChange={e => setMode(e.target.value)}>
            <option value="chat_only">Chat Only</option>
            <option value="form_only">Form Only</option>
            <option value="form_chat">Form + Chat</option>
            <option value="review">Review</option>
          </select>
        </div>
      </div>
      <div className="form-row">
        <div className="form-group">
          <label>Persona (LLM)</label>
          <select value={personaId} onChange={e => setPersonaId(e.target.value)}>
            <option value=""> none </option>
            {personas.map(p => <option key={p.id} value={p.id}>{p.name}</option>)}
          </select>
        </div>
        <div className="form-group">
          <label>Assign to Team</label>
          <select value={assignTeam} onChange={e => setAssignTeam(e.target.value)}>
            <option value=""> none (visitor stage) </option>
            {teams.map(t => <option key={t.id} value={t.id}>{t.name}</option>)}
          </select>
        </div>
      </div>
      <div className="form-row">
        <div className="form-group">
          <label>History Mode</label>
          <select value={historyMode} onChange={e => setHistoryMode(e.target.value)}>
            <option value="full">Full</option>
            <option value="summary">Summary</option>
            <option value="fresh">Fresh</option>
          </select>
        </div>
        <div className="form-group">
          <label>SLA (seconds, optional)</label>
          <input type="number" value={sla} onChange={e => setSla(e.target.value)} placeholder="e.g. 3600" />
        </div>
      </div>
      <label className="toggle-label">
        <input type="checkbox" checked={autoTransition} onChange={e => setAutoTransition(e.target.checked)} />
        Auto-advance when complete
      </label>
      <div className="form-row" style={{ marginTop: 12, gap: 8 }}>
        <button className="btn-small btn-primary" onClick={submit}>
          {stage ? 'Update' : 'Add Stage'}
        </button>
        <button className="btn-small" onClick={onCancel}>Cancel</button>
      </div>
    </div>
  );
}

E3 — Instance Monitor (team-admin)

Shows active instances for the team's workflows with cancel capability.

/**
 * 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 (
    <div className="wf-monitor">
      <h4>Active Instances ({instances.length})</h4>
      {instances.map(inst => (
        <div key={inst.channel_id} className="wf-monitor__row">
          <div className="wf-monitor__info">
            <strong>{inst.workflow_name}</strong>
            <span className="badge">{inst.stage_name || `Stage ${inst.current_stage}`}</span>
            {inst.sla_breached && <span className="badge badge-danger">SLA breached</span>}
            <span className="text-muted">Age: {formatDuration(inst.age_seconds)}</span>
          </div>
          <div className="wf-monitor__actions">
            <button className="btn-small"
                    onClick={() => location.href = `${BASE}/w/${inst.channel_id}`}>
              Open
            </button>
            <button className="btn-small btn-danger" onClick={() => cancelInstance(inst.channel_id)}>
              Cancel
            </button>
          </div>
        </div>
      ))}
      {instances.length === 0 && <div className="empty-hint">No active instances</div>}
    </div>
  );
}

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.

/**
 * 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 (
    <div>
      {mine.length > 0 && (
        <>
          <h4>Claimed ({mine.length})</h4>
          {mine.map(a => (
            <AssignmentRow key={a.id} assignment={a} onAction={load} showTeam />
          ))}
        </>
      )}

      {available.length > 0 && (
        <>
          <h4>Available ({available.length})</h4>
          {available.map(a => (
            <AssignmentRow key={a.id} assignment={a} onAction={load} showTeam />
          ))}
        </>
      )}

      {mine.length === 0 && available.length === 0 && (
        <div className="empty-hint">No assignments</div>
      )}
    </div>
  );
}

/**
 * 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 (
    <div className={`wf-queue-item ${isMine ? 'wf-queue-item--claimed' : ''}`}>
      <div className="wf-queue-item__info">
        <strong>{a.workflow_name || 'Workflow'}</strong>
        {showTeam && a.team_name && <span className="badge">{a.team_name}</span>}
        <span className="badge">{a.stage_name || `Stage ${a.stage}`}</span>
        {a.sla_breached && <span className="badge badge-danger">SLA</span>}
        <span className="text-muted">{timeAgo(a.created_at)}</span>
      </div>
      <div className="wf-queue-item__actions">
        {a.status === 'unassigned' && (
          <button className="btn-small btn-primary" onClick={claim}>Claim</button>
        )}
        {isMine && (
          <>
            <button className="btn-small" onClick={unclaim}>Release</button>
            <button className="btn-small btn-primary"
                    onClick={() => location.href = `${BASE}/w/${a.channel_id}`}>
              Open
            </button>
            <button className="btn-small btn-success" onClick={complete}>Complete</button>
          </>
        )}
      </div>
    </div>
  );
}

E5 — Visitor Terminal Screen

When a visitor completes their last visible stage, they see this instead of the internal team stages.

/**
 * 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 (
    <div className="visitor-terminal">
      {branding?.logo_url && (
        <img src={branding.logo_url} className="visitor-terminal__logo" alt="" />
      )}
      <h2>{msg.title}</h2>
      <p>{msg.body}</p>
      {branding?.tagline && (
        <p className="visitor-terminal__tagline">{branding.tagline}</p>
      )}
    </div>
  );
}

BE Additions

New Store Methods

// ── 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

// 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:

-- 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