Drop chat-era columns (persona_id, history_mode) from workflow_stages. Rename transition_rules → stage_config. Add audience, stage_type, starlark_hook, branch_rules columns. Update stage_mode CHECK to (form, review, delegated, automated). All Go stores, handlers, routing engine, Starlark module, and frontend editors updated. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
39 KiB
Workflow Redesign — v0.2.6
Status: Phase A complete (v0.3.0 — schema + stage CRUD modernization)
Branch: feat/workflow-redesign-v0.2.6
Date: 2026-03-27
Context: Mapping viable ideas from chat-switchboard v0.39.x onto core's extension-first architecture.
1. Guiding Principles
- Workflows are kernel. Definitions, instances, the stage graph, form validation, and assignment queues are platform primitives — not extensions.
- Execution is delegated. How a stage renders or collects data is an extension concern. The kernel says what a stage needs; a surface package provides the how.
- No chat assumptions. The kernel never references personas, channels, or AI providers. A chat extension can participate in workflows, but the workflow engine does not depend on it.
- Public access is a platform capability. Anonymous/public sessions are not workflow-specific — they are a kernel-level feature that any surface can opt into. Workflows consume public access; they don't own it. See §15.
- Edit existing migrations. Per the design-decisions log: no migration chains until the schema is in production. New tables get new files; modified tables are edited in place.
2. Disposition Index
Every item below is classified:
| Tag | Meaning |
|---|---|
| 🗑 TRASH | Remove from core. Vestigial chat-switchboard concept that doesn't fit. |
| ✏️ MOD | Exists in core today — modify in place. |
| ➕ ADD | New to core. Requires new code/tables. |
| ✅ KEEP | Already correct in core. No changes needed. |
3. Schema Changes
3a. 007_workflows.sql — edit in place
workflows table
| Column | Disposition | Action |
|---|---|---|
entry_mode |
✏️ MOD | Current values: public_link, team_only. Keep the column but change its meaning — it becomes a workflow-level default/flag that says "this workflow has at least one public stage." The real visibility control moves to per-stage audience. See note below. |
| All other columns | ✅ KEEP | Clean. No chat coupling. |
Note on entry_mode: Chat-switchboard treated this as a binary toggle — the whole
workflow was either public or not. Real workflows mix audiences: internal setup → public
form → internal review → public confirmation. The per-stage audience field (see below)
is the source of truth. entry_mode on the workflow becomes a convenience flag: if any
stage has audience = 'public', the workflow is public-entry eligible. This avoids a
full schema break — existing code that checks entry_mode still works, it just gets set
automatically when stages are saved.
workflow_stages table
| Column | Disposition | Action |
|---|---|---|
persona_id |
🗑 TRASH | Drop column. Personas are a chat-extension concept. If a stage needs an AI participant, the stage's surface_pkg_id extension handles that internally. The comment in the migration already acknowledges this ("personas are extensions now") but the column is still there. Remove it. |
stage_mode CHECK |
✏️ MOD | Current: form_only, form_chat, review, custom. Replace with: form, review, delegated, automated. See §4 for definitions. form_chat is trash (chat coupling). form_only renames to form. custom renames to delegated (clearer intent). automated is new (no UI, Starlark-only). |
history_mode |
🗑 TRASH | Drop column. This controlled how much chat history a persona saw across stages. Core has no chat history. If a delegated surface needs context management, it reads stage_data — that's the contract. |
audience |
➕ ADD | TEXT NOT NULL DEFAULT 'team' with CHECK ('team', 'public', 'system'). Controls who interacts with this stage. team = authenticated team members only (internal). public = anonymous visitors via entry token (the "public-ish" stage). system = no human interaction, automated only. A workflow can freely alternate: team→public→team→public. The kernel enforces this: public stages are accessible via token auth, team stages require JWT, system stages have no UI. |
stage_type |
➕ ADD | TEXT NOT NULL DEFAULT 'simple' with CHECK ('simple', 'dynamic', 'automated'). Drives the graph engine: simple = declarative rules only, dynamic = Starlark hook resolves next stage, automated = no human UI, fires hook on entry and auto-advances. |
starlark_hook |
➕ ADD | TEXT (nullable). Package-qualified entry point for dynamic/automated stages. Format: package_id:entry_point. NULL for simple stages. |
branch_rules |
➕ ADD | JSONB NOT NULL DEFAULT '[]'. Replaces the overloaded transition_rules for simple stage branching. Array of {field, op, value, target_stage}. Evaluated before starlark_hook, before ordinal fallback. |
transition_rules |
✏️ MOD | Rename to stage_config. This JSONB blob was doing double duty (routing rules + on_advance hooks + auto_assign). Routing moves to branch_rules. What remains is stage-specific config that the kernel or surface reads: {on_advance: {package_id, entry_point}, auto_assign: "round_robin"}. |
assignment_team_id |
✅ KEEP | Clean. Kernel concept. |
form_template |
✅ KEEP | Kernel concern — structured data schema. |
auto_transition |
✅ KEEP | Kernel concern — skip human interaction. |
surface_pkg_id |
✅ KEEP | The delegation pointer. Required for delegated mode stages. |
sla_seconds |
✅ KEEP | Kernel concern — time budget per stage. |
workflow_versions table
| Column | Disposition | Notes |
|---|---|---|
| All existing columns | ✅ KEEP | Immutable snapshots. No changes needed. |
3b. 007_workflows.sql — add new tables (same file)
➕ ADD workflow_instances
Replaces the channel-based instance model from chat-switchboard. This is the execution record.
CREATE TABLE IF NOT EXISTS workflow_instances (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
workflow_id UUID NOT NULL REFERENCES workflows(id) ON DELETE CASCADE,
workflow_version INTEGER NOT NULL,
current_stage INTEGER NOT NULL DEFAULT 0,
stage_data JSONB NOT NULL DEFAULT '{}',
status TEXT NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'completed', 'cancelled', 'stale', 'error')),
started_by UUID REFERENCES users(id) ON DELETE SET NULL,
entry_token TEXT, -- for public_link resumption
metadata JSONB NOT NULL DEFAULT '{}',
stage_entered_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_wf_instances_workflow
ON workflow_instances(workflow_id, status);
CREATE INDEX IF NOT EXISTS idx_wf_instances_status
ON workflow_instances(status) WHERE status = 'active';
CREATE UNIQUE INDEX IF NOT EXISTS idx_wf_instances_token
ON workflow_instances(entry_token) WHERE entry_token IS NOT NULL;
DROP TRIGGER IF EXISTS wf_instances_updated_at ON workflow_instances;
CREATE TRIGGER wf_instances_updated_at BEFORE UPDATE ON workflow_instances
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
Rationale: Chat-switchboard used channels as instances — that table carried message trees,
AI context windows, participant lists, and other chat concerns. Core needs a purpose-built table
that holds only workflow execution state: which version, which stage, accumulated data, lifecycle status.
➕ ADD workflow_assignments
Dropped during the fork ("channel-dependent, rebuild as needed" — see 007 header comment). Now rebuilt as a kernel primitive.
CREATE TABLE IF NOT EXISTS workflow_assignments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
instance_id UUID NOT NULL REFERENCES workflow_instances(id) ON DELETE CASCADE,
stage INTEGER NOT NULL,
team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE,
assigned_to UUID REFERENCES users(id) ON DELETE SET NULL,
status TEXT NOT NULL DEFAULT 'unassigned'
CHECK (status IN ('unassigned', 'claimed', 'completed', 'cancelled')),
review_data JSONB NOT NULL DEFAULT '{}',
claimed_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_wf_assignments_instance
ON workflow_assignments(instance_id, stage);
CREATE INDEX IF NOT EXISTS idx_wf_assignments_team_status
ON workflow_assignments(team_id, status) WHERE status IN ('unassigned', 'claimed');
CREATE INDEX IF NOT EXISTS idx_wf_assignments_user
ON workflow_assignments(assigned_to) WHERE assigned_to IS NOT NULL;
Claim lock: Same optimistic pattern from chat-switchboard:
UPDATE ... WHERE id = $1 AND status = 'unassigned' — if rows_affected == 0, already claimed.
3c. New migration file: 012_workflow_instances.sql
Wait — per the principle, new tables get new files. But we're also editing 007. Decision:
Put the new tables in 007_workflows.sql. The schema isn't in production. Keep all workflow DDL in one file. When the schema is in production (post-MVP), new additions get numbered migrations. This is consistent with the existing design-decisions log entry: "No new migrations pre-MVP."
4. Stage Mode Redesign
Current (trash/rename)
| Current Mode | Disposition | Rationale |
|---|---|---|
form_only |
✏️ MOD → form |
Rename. Drop the _only suffix — there's no form_chat to distinguish from anymore. |
form_chat |
🗑 TRASH | Chat coupling. If you want a form + AI conversation, use delegated with a chat surface package. |
review |
✅ KEEP | Team member reviews accumulated data. Pure kernel concept. |
custom |
✏️ MOD → delegated |
Rename for clarity. "Custom" is vague. "Delegated" makes the contract explicit: the kernel delegates stage execution to surface_pkg_id. |
New modes
| Mode | Disposition | Description |
|---|---|---|
form |
✏️ MOD (renamed) | Kernel renders form_template, validates submission, merges into stage_data. No extension needed. |
review |
✅ KEEP | Assignment queue stage. Team member claims, reviews stage_data, adds review_data, completes. Kernel-rendered. |
delegated |
✏️ MOD (renamed) | Kernel hands off to surface_pkg_id. The surface package receives the instance context (stage_data, form_template, metadata) and calls back to advance. This is where chat, AI, or any custom UX lives. |
automated |
➕ ADD | No UI. On stage entry, kernel fires starlark_hook, merges returned data into stage_data, and auto-advances. For enrichment, API calls, validation, routing decisions. |
Stage type vs stage mode
These are orthogonal:
stage_typecontrols how the next stage is chosen:simple(declarative branch_rules),dynamic(Starlark hook returns target),automated(hook + auto-advance).stage_modecontrols how the stage executes:form,review,delegated,automated.
An automated stage_type with automated stage_mode is the common case for system stages,
but you can have a dynamic stage_type with form stage_mode (the form collects data, then a
Starlark hook decides where to go next based on the submission).
5. Model Changes
server/models/workflow.go
WorkflowStage struct — ✏️ MOD
// TRASH: Remove these fields
// PersonaID *string `json:"persona_id,omitempty"`
// HistoryMode string `json:"history_mode"`
// MOD: Rename transition_rules → stage_config
// TransitionRules json.RawMessage `json:"transition_rules"`
StageConfig json.RawMessage `json:"stage_config"`
// ADD: New fields
Audience string `json:"audience"` // team | public | system
StageType string `json:"stage_type"` // simple | dynamic | automated
StarlarkHook *string `json:"starlark_hook,omitempty"` // package_id:entry_point
BranchRules json.RawMessage `json:"branch_rules"` // [{field, op, value, target_stage}]
Stage mode constants — ✏️ MOD
// TRASH
// StageModeFormOnly = "form_only"
// StageModeFormChat = "form_chat"
// MOD (rename)
StageModeForm = "form" // was form_only
StageModeDelegated = "delegated" // was custom
// KEEP
StageModeReview = "review"
// ADD
StageModeAutomated = "automated"
// ADD: Stage type constants
StageTypeSimple = "simple"
StageTypeDynamic = "dynamic"
StageTypeAutomated = "automated"
// ADD: Audience constants
AudienceTeam = "team"
AudiencePublic = "public"
AudienceSystem = "system"
➕ ADD: WorkflowInstance model
type WorkflowInstance struct {
ID string `json:"id"`
WorkflowID string `json:"workflow_id"`
WorkflowVersion int `json:"workflow_version"`
CurrentStage int `json:"current_stage"`
StageData json.RawMessage `json:"stage_data"`
Status string `json:"status"` // active | completed | cancelled | stale | error
StartedBy *string `json:"started_by,omitempty"`
EntryToken *string `json:"entry_token,omitempty"`
Metadata json.RawMessage `json:"metadata"`
StageEnteredAt time.Time `json:"stage_entered_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
➕ ADD: WorkflowAssignment model
type WorkflowAssignment struct {
ID string `json:"id"`
InstanceID string `json:"instance_id"`
Stage int `json:"stage"`
TeamID string `json:"team_id"`
AssignedTo *string `json:"assigned_to,omitempty"`
Status string `json:"status"` // unassigned | claimed | completed | cancelled
ReviewData json.RawMessage `json:"review_data"`
ClaimedAt *time.Time `json:"claimed_at,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
TypedFormTemplate, FormField, FormValidation — ✅ KEEP
All form types are clean kernel code. No changes.
ValidateFormData — ✅ KEEP
Pure data validation. No chat coupling.
6. Store Interface Changes
server/store/workflow_iface.go — ✏️ MOD + ➕ ADD
Current interface is definition-only (CRUD workflows + stages + versions). Add instance and assignment operations.
type WorkflowStore interface {
// ── Definition CRUD (KEEP — no changes) ──────────
Create(ctx context.Context, w *models.Workflow) error
GetByID(ctx context.Context, id string) (*models.Workflow, error)
GetBySlug(ctx context.Context, teamID *string, slug string) (*models.Workflow, error)
Update(ctx context.Context, id string, patch models.WorkflowPatch) error
Delete(ctx context.Context, id string) error
ListForTeam(ctx context.Context, teamID string) ([]models.Workflow, error)
ListGlobal(ctx context.Context) ([]models.Workflow, error)
// ── Stage CRUD (KEEP — no changes) ───────────────
CreateStage(ctx context.Context, s *models.WorkflowStage) error
ListStages(ctx context.Context, workflowID string) ([]models.WorkflowStage, error)
UpdateStage(ctx context.Context, s *models.WorkflowStage) error
DeleteStage(ctx context.Context, id string) error
ReorderStages(ctx context.Context, workflowID string, orderedIDs []string) error
// ── Versioning (KEEP — no changes) ───────────────
Publish(ctx context.Context, v *models.WorkflowVersion) error
GetVersion(ctx context.Context, workflowID string, versionNumber int) (*models.WorkflowVersion, error)
GetLatestVersion(ctx context.Context, workflowID string) (*models.WorkflowVersion, error)
// ── Instances (ADD) ──────────────────────────────
CreateInstance(ctx context.Context, inst *models.WorkflowInstance) error
GetInstance(ctx context.Context, id string) (*models.WorkflowInstance, error)
GetInstanceByToken(ctx context.Context, token string) (*models.WorkflowInstance, error)
UpdateInstance(ctx context.Context, id string, patch models.InstancePatch) error
ListInstances(ctx context.Context, workflowID string, status string) ([]models.WorkflowInstance, error)
AdvanceStage(ctx context.Context, id string, nextStage int, mergeData json.RawMessage) error
CompleteInstance(ctx context.Context, id string) error
CancelInstance(ctx context.Context, id string) error
MarkStale(ctx context.Context, olderThan time.Duration) (int, error)
// ── Assignments (ADD) ────────────────────────────
CreateAssignment(ctx context.Context, a *models.WorkflowAssignment) error
ClaimAssignment(ctx context.Context, id string, userID string) error // optimistic lock
UnclaimAssignment(ctx context.Context, id string) error
CompleteAssignment(ctx context.Context, id string, reviewData json.RawMessage) error
CancelAssignment(ctx context.Context, id string) error
ListAssignmentsByTeam(ctx context.Context, teamID string, status string) ([]models.WorkflowAssignment, error)
ListAssignmentsByUser(ctx context.Context, userID string) ([]models.WorkflowAssignment, error)
ListAssignmentsByInstance(ctx context.Context, instanceID string) ([]models.WorkflowAssignment, error)
}
7. Handler Changes
server/handlers/workflows.go — ✏️ MOD
Stage CRUD updates — reflect new field names in CreateStage/UpdateStage:
- Remove
persona_idandhistory_modefrom bind/validation. - Add
stage_type,starlark_hook,branch_rulesto bind/validation. - Rename
transition_rules→stage_configin bind/validation. - Update
stage_modeCHECK to new set:form,review,delegated,automated. - Validate:
delegatedrequiressurface_pkg_id.dynamic/automatedstage_type requiresstarlark_hook.
server/handlers/workflow_hooks.go — ✏️ MOD
Rename: channelID parameter → instanceID in FireOnAdvanceHook signature.
The hook context dict changes from {channel_id, ...} to {instance_id, ...}.
The jsonToStarlark helper referenced on line 81 lives in
server/handlers/starlark_helpers.go. Consider consolidating with goToStarlark in
server/triggers/event.go — two independent Go→Starlark converters is tech debt.
server/handlers/workflow_team.go — ✅ KEEP
Team-scoped wrappers. Clean delegation pattern. No changes.
server/handlers/workflow_packages.go — ✏️ MOD
Update workflowPkgStage struct:
- Remove
PersonaID,HistoryModefields. - Add
StageType,StarlarkHook,BranchRulesfields. - Rename
TransitionRules→StageConfig.
Update ExportWorkflowPackage and InstallWorkflowFromManifest accordingly.
➕ ADD server/handlers/workflow_instances.go
New handler file for instance lifecycle:
POST /api/v1/workflows/:id/start → Start (create instance from latest published version)
GET /api/v1/workflows/instances/:iid → GetInstance
POST /api/v1/workflows/instances/:iid/advance → Advance (submit stage data, resolve next)
POST /api/v1/workflows/instances/:iid/cancel → Cancel
GET /api/v1/workflows/:id/instances → ListInstances (by workflow, optionally by status)
Public entry (for public_link workflows):
POST /api/v1/workflows/entry/:slug → StartPublic (no auth, generates entry_token)
GET /api/v1/workflows/entry/:token → ResumePublic (retrieve instance by token)
POST /api/v1/workflows/entry/:token/advance → AdvancePublic (submit data via token)
➕ ADD server/handlers/workflow_assignments.go
New handler file for the assignment queue:
GET /api/v1/teams/:teamId/assignments → ListTeamAssignments (filterable by status)
POST /api/v1/teams/:teamId/assignments/:id/claim → Claim
POST /api/v1/teams/:teamId/assignments/:id/unclaim → Unclaim
POST /api/v1/teams/:teamId/assignments/:id/complete → Complete (with review_data)
POST /api/v1/teams/:teamId/assignments/:id/cancel → Cancel
GET /api/v1/assignments/mine → ListMyAssignments (across teams)
8. Routing Engine Changes
server/workflow/routing.go — ✏️ MOD
The existing ResolveNextStage evaluates transition_rules.conditions[]. This needs to
become a two-phase resolution that respects the new stage_type:
Phase 1: Evaluate branch_rules (declarative, for simple + dynamic types)
Phase 2: If no match AND stage_type == dynamic → fire starlark_hook
Phase 3: Fallback → currentStage + 1
For automated stage_type, the engine fires starlark_hook on entry (not for routing — for
data enrichment), then routes via branch_rules or ordinal fallback. The routing call happens
after the hook returns.
Concrete changes:
ResolveNextStagesignature: addstageType string, starlarkHook *string, runner *sandbox.Runnerparameters.- Extract branch_rules evaluation from the current
TransitionRulesWithConditions(which reads fromtransition_rulesJSON) into a dedicated function that reads from the newbranch_rulesfield. TransitionRulesWithConditionsstruct → rename toStageConfig, removeConditionsfield (moved to branch_rules), keepAutoAssignandOnAdvance.
server/workflow/ — ➕ ADD engine.go
New file: the stage execution engine. Orchestrates the advance lifecycle:
1. Load instance + version snapshot
2. Validate current stage allows advancement (status checks)
3. If current stage has form_template → validate submitted data
4. Merge submitted data into stage_data
5. Fire on_advance hook (if configured in stage_config)
6. Resolve next stage (branch_rules → starlark_hook → ordinal)
7. If next stage is past last stage → complete instance
8. If next stage is automated → fire its hook, recurse to step 6
9. If next stage has assignment_team_id → create WorkflowAssignment
10. Update instance (current_stage, stage_data, stage_entered_at)
11. Emit bus events (workflow.advanced, workflow.assigned, workflow.completed)
server/workflow/ — ➕ ADD automated.go
Handles automated stage execution: fire the Starlark hook, merge returned data, and
auto-advance. Includes a cycle guard (max 10 consecutive automated stages) to prevent
infinite loops from misconfigured workflows.
9. Event Bus Updates
server/events/types.go — ✏️ MOD
Current workflow events are fine but incomplete. Add:
| Event | Direction | Disposition | Trigger |
|---|---|---|---|
workflow.assigned |
DirToClient | ✅ KEEP | Assignment created |
workflow.claimed |
DirToClient | ✅ KEEP | Assignment claimed |
workflow.advanced |
DirToClient | ✅ KEEP | Stage transition |
workflow.completed |
DirToClient | ✅ KEEP | Instance completed |
workflow.cancelled |
DirToClient | ➕ ADD | Instance cancelled |
workflow.started |
DirToClient | ➕ ADD | Instance created |
workflow.sla.warning |
DirToClient | ➕ ADD | SLA at 80% threshold |
workflow.sla.breached |
DirToClient | ➕ ADD | SLA exceeded |
workflow.error |
DirLocal | ➕ ADD | Hook execution failure |
10. Starlark Module Updates
server/sandbox/workflow_module.go — ✏️ MOD
The BuildWorkflowModule (in workflow_module.go, not modules.go) currently exists but
targets the old channel-based model. It currently only exposes get_definition.
Rebuild to expose instance-oriented operations:
# Available to extensions with workflow.read or workflow.write permission
workflow.get_instance(instance_id) # → instance dict
workflow.get_stage_data(instance_id) # → stage_data dict
workflow.set_stage_data(instance_id, data) # → merge into stage_data
workflow.advance(instance_id) # → trigger advance (write perm)
workflow.get_assignments(instance_id) # → assignment list
The old workflow_advance() function that chat-switchboard's personas called is gone.
Extensions call workflow.advance() which delegates to the same engine as the HTTP handler.
11. Salvage Map from chat-switchboard v0.39.x
What was planned there, and what happens to each idea in core:
v0.39.0 — Stage Graph Engine + Form Builder
| Component | Disposition | Core equivalent |
|---|---|---|
stage_type enum |
➕ ADD | Adopted directly: simple, dynamic, automated |
BranchRule struct |
➕ ADD | Adopted as branch_rules JSONB on workflow_stages |
| Starlark hook integration | ➕ ADD | starlark_hook column + engine integration |
Graph engine (server/engine/graph.go) |
➕ ADD | server/workflow/engine.go in core |
| Automated stage runner | ➕ ADD | server/workflow/automated.go in core |
| Visual form builder (5 Preact components) | 🗑 TRASH for now | The form builder was tightly coupled to chat-switchboard's stage editor surface. Core doesn't ship a workflow editor surface (that's an extension). The form schema is kernel; the form builder UI is a surface package concern. Revisit when a workflow-admin surface is built. |
| Stage type badges in UI | 🗑 TRASH | Same — UI is extension territory. |
v0.39.1 — Stage Reorder + Bulk Ops
| Component | Disposition | Core equivalent |
|---|---|---|
| Drag-and-drop reorder | 🗑 TRASH | UI concern. The kernel ReorderStages endpoint already exists. |
| Bulk assignment ops | ➕ ADD (later) | Useful but not blocking. Defer to v0.2.7+. The individual claim/unclaim/cancel endpoints come first. |
v0.39.2 — SLA Alerting
| Component | Disposition | Core equivalent |
|---|---|---|
| Periodic SLA scanner | ➕ ADD | Kernel scheduled goroutine (like the existing staleness sweep). Query active instances with sla_seconds configured, compute breach from stage_entered_at. |
sla_notified_at on instances |
➕ ADD | Add to workflow_instances schema. Prevents duplicate notifications. |
| Notifications on breach | ➕ ADD | Use existing notifications table + bus event (workflow.sla.breached). |
| Webhook on breach | ➕ ADD | Fire workflow's webhook_url if configured. |
| Pulsing badge animation | 🗑 TRASH | UI concern for a surface package. |
v0.39.3 — Visitor Portal
| Component | Disposition | Core equivalent |
|---|---|---|
| Public entry endpoint | ➕ ADD | /api/v1/workflows/entry/:slug — kernel handles anonymous session creation + token issuance. |
| Session resume via token | ➕ ADD | entry_token on workflow_instances + GetInstanceByToken store method. |
| Branded entry page | 🗑 TRASH | Surface package. The kernel stores branding JSONB — a portal surface reads it. |
| Progress indicator | 🗑 TRASH | Surface package reads stage list, computes "step N of M" locally. |
| Email notifications | 🗑 TRASH for now | Requires SMTP infrastructure. Defer to post-MVP or make it a connection-based extension (use ext_connections for SMTP config, fire via Starlark hook). |
v0.39.4 — Templates + Clone
| Component | Disposition | Core equivalent |
|---|---|---|
| Clone endpoint | ➕ ADD | POST /api/v1/workflows/:id/clone — deep copy workflow + stages. Straightforward. |
| Built-in templates table | 🗑 TRASH | Core doesn't ship opinionated templates. Templates are workflow packages (.pkg archives). A "template gallery" is a surface. |
| Template picker UI | 🗑 TRASH | Surface concern. |
v0.39.5 — Analytics
| Component | Disposition | Core equivalent |
|---|---|---|
| Analytics query endpoint | ➕ ADD (later) | GET /api/v1/workflows/:id/analytics?period=7d — kernel exposes raw metrics (throughput, cycle time, stage dwell, SLA compliance) derived from instance + assignment timestamps. Defer to v0.2.8+ or v0.3.x. |
| Analytics dashboard UI | 🗑 TRASH | Surface package. |
12. Implementation Order
All items within v0.2.6. Ordered by dependency:
Phase A: Schema + Models
- ✏️ Edit
007_workflows.sql: droppersona_id,history_mode; addaudience,stage_type,starlark_hook,branch_rules; renametransition_rules→stage_config; updatestage_modeCHECK. - ✏️ Edit
007_workflows.sql(same file): addworkflow_instancesandworkflow_assignmentstables. - ✏️ Edit
007_workflows.sql(SQLite dialect): mirror changes. - ✏️ Update
server/models/workflow.go: remove trashed fields, add new structs, update constants.
Phase B: Store Layer
- ✏️ Update
server/store/workflow_iface.go: add instance + assignment methods. - ➕ Implement Postgres store:
server/store/postgres/workflow_instances.go,workflow_assignments.go. - ➕ Implement SQLite store:
server/store/sqlite/workflow_instances.go,workflow_assignments.go.
Phase C: Engine
- ✏️ Update
server/workflow/routing.go: branch_rules evaluation, starlark_hook integration. - ➕ Add
server/workflow/engine.go: advance lifecycle orchestration. - ➕ Add
server/workflow/automated.go: automated stage execution + cycle guard. - ✏️ Update
server/handlers/workflow_hooks.go: rename channel → instance references.
Phase D: Handlers + Routes
- ✏️ Update
server/handlers/workflows.go: stage CRUD field changes. - ✏️ Update
server/handlers/workflow_packages.go: package struct field changes. - ➕ Add
server/handlers/workflow_instances.go: instance lifecycle endpoints. - ➕ Add
server/handlers/workflow_assignments.go: assignment queue endpoints. - ✏️ Wire new routes in
server/main.go.
Phase E: Events + Starlark
- ✏️ Update
server/events/types.go: add new workflow events. - ✏️ Update
server/sandbox/modules.go: rebuild workflow Starlark module.
Phase F: Background Jobs
- ➕ Add SLA scanner goroutine (check active instances, fire notifications + events on breach).
- ➕ Add staleness sweep for workflow instances (reuse pattern from chat-switchboard).
Phase G: Verification
- ➕ Integration tests: instance lifecycle, stage advancement, branching, automated stages, assignments, SLA breach.
go build ./...clean.- Update ICD (OpenAPI spec) with new endpoints.
- Update ROADMAP.md + CHANGELOG.md.
13. What We're NOT Doing (Explicit Deferrals)
| Item | Why | Revisit |
|---|---|---|
| Visual form builder UI | Surface concern, no workflow-admin surface yet | When a workflow-admin surface ships |
| Drag-and-drop stage reorder UI | Surface concern | Same |
| Email notifications | Needs SMTP infrastructure or connection-based ext | Post-MVP |
| Built-in workflow templates | Core doesn't ship opinionated content | Package gallery |
| Analytics dashboard | Nice-to-have, kernel endpoint can come first | v0.3.x |
| Bulk assignment operations | Individual ops first | v0.2.7+ |
Workflow chaining (on_complete) |
Already in schema, needs engine wiring | v0.2.7 |
| Visitor portal surface | Surface package, not kernel | Extension track |
14. Migration Policy Reminder
From the design-decisions log:
No new migrations pre-MVP. Edit existing migration SQL files in place. No migration chains until schema is in production.
For this redesign:
007_workflows.sql: Edit in place for column changes AND add new tables to the same file.- No
012_*.sql: Everything goes in 007. - Both dialects: Postgres and SQLite must be kept in sync.
The only time a new numbered migration file is created is when a truly new, unrelated subsystem is added (like 011_triggers.sql was for the trigger system). Workflow instances and assignments are part of the workflow subsystem → they go in 007.
15. Public Surfaces — Kernel Capability, Not Workflow Feature
The problem with workflow-owned public access
Chat-switchboard treated public access as a binary workflow toggle: entry_mode: public_link
made the whole workflow public. But real workflows alternate audiences — internal setup →
public-facing customer form → internal review → public confirmation page. And public access
isn't even a workflow-only need: feedback forms, status pages, and surveys all need anonymous
sessions without being workflows.
Per-stage audience (the v0.2.6 solution)
The audience field on workflow_stages is the source of truth:
| Audience | Who interacts | Auth required | Example |
|---|---|---|---|
team |
Authenticated team members | JWT | Internal triage, setup, review |
public |
Anonymous visitors | Entry token | Customer intake form, confirmation page |
system |
Nobody (automated) | N/A | API enrichment, routing decision |
A single workflow freely alternates:
Stage 0: "Setup" audience=team (agent configures parameters)
Stage 1: "Customer Form" audience=public (customer fills out intake)
Stage 2: "Internal Review" audience=team (team reviews submission)
Stage 3: "Confirmation" audience=public (customer sees result)
The engine enforces boundaries:
- When the instance enters a
publicstage, the visitor's token grants access. Team members can also see it (they have elevated access). - When the instance enters a
teamstage, the visitor's view freezes. They see "being processed" or whatever the portal surface shows. The token is still valid for resumption when the nextpublicstage arrives. - When the instance enters a
systemstage, nobody interacts — the automated hook fires and the engine advances.
The entry_mode column
Stays on the workflows table but becomes a derived convenience flag. If any stage has
audience = 'public', the workflow is public-entry eligible. The kernel can auto-compute
this when stages are saved, or treat it as an explicit admin toggle that gates whether
public stages are actually reachable. Either way, it's no longer the primary access control
mechanism — audience on each stage is.
Platform-level public access (v0.2.7+ concern)
The broader vision: public access as a package-level manifest capability so any surface
(not just workflows) can serve anonymous visitors. A package declares "public": true in
its manifest, and the kernel mounts a public route namespace (/p/:package_slug/*) with
anonymous session middleware, token issuance, and rate limiting. Workflows consume this
capability — they don't own it.
For v0.2.6, the workflow-specific public entry routes are sufficient:
POST /api/v1/workflows/entry/:slug → StartPublic
GET /api/v1/workflows/entry/:token → ResumePublic
POST /api/v1/workflows/entry/:token/advance → AdvancePublic
The anonymous session management (entry_token on workflow_instances, IP rate limiting)
lives in the workflow handler for now. Generalization to a platform-level public_sessions
table happens when non-workflow surfaces need it.
Visitor view contract
When a public stage is active, the kernel API returns only what the visitor should see:
- The current stage's
form_template(if mode isform) - The stage name and ordinal (for progress indicators)
- Accumulated
stage_datakeys marked asvisitor_visible(future: field-level ACL) - The
brandingJSONB from the workflow
Internal stages, team assignments, review data, and other instances' data are never exposed through the token-authenticated endpoints. The surface package renders whatever the kernel returns — it doesn't need to filter.
16. Branch Rules — Routing, Skipping, and Convergence
How branch_rules work
Each stage has a branch_rules JSONB array. On stage completion, the engine evaluates rules
top-to-bottom against accumulated stage_data. First match wins. No match → ordinal + 1.
[
{"field": "priority", "op": "eq", "value": "urgent", "target_stage": "Emergency Review"},
{"field": "amount", "op": "gt", "value": 10000, "target_stage": "Manager Approval"},
{"field": "type", "op": "in", "value": ["a","b"], "target_stage": "Fast Track"}
]
target_stage accepts either a stage name (case-insensitive) or a numeric ordinal.
The engine tries name resolution first, falls back to numeric. This is already implemented
in routing.go → resolveTarget().
Skipping stages
Branch rules can target any stage, not just the next one. If a workflow has stages
0→1→2→3→4 and stage 1's rules say target_stage: "Stage 4", stages 2 and 3 are skipped
for that instance. The engine doesn't care about gaps — it sets current_stage to whatever
the rule resolved to.
Linear workflows (no branch_rules on any stage) advance 0→1→2→3→4 by the ordinal + 1 fallback. Adding a single branch rule to any stage introduces conditional skipping without changing the linear stages.
Convergence (diamond patterns)
Two different branches can target the same downstream stage:
Stage 0: Intake Form
branch_rules: [
{field: "region", op: "eq", value: "US", target_stage: "US Processing"},
{field: "region", op: "eq", value: "EU", target_stage: "EU Processing"}
]
Stage 1: US Processing (ordinal 1)
branch_rules: [{field: "*", op: "exists", target_stage: "Final Review"}]
(always converge after processing)
Stage 2: EU Processing (ordinal 2)
branch_rules: [{field: "*", op: "exists", target_stage: "Final Review"}]
(always converge after processing)
Stage 3: Final Review (ordinal 3)
(both branches land here)
No special syntax needed. The routing engine doesn't track "which branch am I on" — it just
resolves the target and moves the instance there. The accumulated stage_data carries
everything downstream stages need to know about what happened upstream.
Backward jumps
Branch rules can also target earlier stages (lower ordinals) for retry/correction loops.
The cycle guard (max 10 consecutive automated stages) in automated.go prevents infinite
loops for automated stages. For human-facing stages, backward jumps are intentional — the
human breaks the cycle by submitting different data.
Operators
Full set, inherited from the existing routing.go implementation:
| Op | Description | Example |
|---|---|---|
eq |
Loose equality (string comparison) | "status" eq "approved" |
neq |
Not equal | "status" neq "rejected" |
gt |
Greater than (numeric) | "amount" gt 10000 |
lt |
Less than (numeric) | "amount" lt 100 |
gte |
Greater or equal | "score" gte 80 |
lte |
Less or equal | "score" lte 20 |
in |
Value in array | "category" in ["a","b","c"] |
contains |
Substring match | "notes" contains "urgent" |
exists |
Field present in stage_data | "approval_date" exists |
not_exists |
Field absent | "rejection_reason" not_exists |