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/DESIGN-WORKFLOW-REDESIGN-0.2.6.md
Jeffrey Smith 1b08769f09
Some checks failed
CI/CD / detect-changes (pull_request) Successful in 4s
CI/CD / test-frontend (pull_request) Successful in 5s
CI/CD / test-go-pg (pull_request) Failing after 1m56s
CI/CD / test-sqlite (pull_request) Successful in 2m47s
CI/CD / build-and-deploy (pull_request) Has been skipped
Feat v0.3.0 workflow schema redesign + stage CRUD modernization
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>
2026-03-27 17:48:14 +00:00

39 KiB
Raw Blame History

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

  1. Workflows are kernel. Definitions, instances, the stage graph, form validation, and assignment queues are platform primitives — not extensions.
  2. 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.
  3. 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.
  4. 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.
  5. 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_type controls how the next stage is chosen: simple (declarative branch_rules), dynamic (Starlark hook returns target), automated (hook + auto-advance).
  • stage_mode controls 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_id and history_mode from bind/validation.
  • Add stage_type, starlark_hook, branch_rules to bind/validation.
  • Rename transition_rulesstage_config in bind/validation.
  • Update stage_mode CHECK to new set: form, review, delegated, automated.
  • Validate: delegated requires surface_pkg_id. dynamic/automated stage_type requires starlark_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, HistoryMode fields.
  • Add StageType, StarlarkHook, BranchRules fields.
  • Rename TransitionRulesStageConfig.

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:

  • ResolveNextStage signature: add stageType string, starlarkHook *string, runner *sandbox.Runner parameters.
  • Extract branch_rules evaluation from the current TransitionRulesWithConditions (which reads from transition_rules JSON) into a dedicated function that reads from the new branch_rules field.
  • TransitionRulesWithConditions struct → rename to StageConfig, remove Conditions field (moved to branch_rules), keep AutoAssign and OnAdvance.

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

  1. ✏️ Edit 007_workflows.sql: drop persona_id, history_mode; add audience, stage_type, starlark_hook, branch_rules; rename transition_rulesstage_config; update stage_mode CHECK.
  2. ✏️ Edit 007_workflows.sql (same file): add workflow_instances and workflow_assignments tables.
  3. ✏️ Edit 007_workflows.sql (SQLite dialect): mirror changes.
  4. ✏️ Update server/models/workflow.go: remove trashed fields, add new structs, update constants.

Phase B: Store Layer

  1. ✏️ Update server/store/workflow_iface.go: add instance + assignment methods.
  2. Implement Postgres store: server/store/postgres/workflow_instances.go, workflow_assignments.go.
  3. Implement SQLite store: server/store/sqlite/workflow_instances.go, workflow_assignments.go.

Phase C: Engine

  1. ✏️ Update server/workflow/routing.go: branch_rules evaluation, starlark_hook integration.
  2. Add server/workflow/engine.go: advance lifecycle orchestration.
  3. Add server/workflow/automated.go: automated stage execution + cycle guard.
  4. ✏️ Update server/handlers/workflow_hooks.go: rename channel → instance references.

Phase D: Handlers + Routes

  1. ✏️ Update server/handlers/workflows.go: stage CRUD field changes.
  2. ✏️ Update server/handlers/workflow_packages.go: package struct field changes.
  3. Add server/handlers/workflow_instances.go: instance lifecycle endpoints.
  4. Add server/handlers/workflow_assignments.go: assignment queue endpoints.
  5. ✏️ Wire new routes in server/main.go.

Phase E: Events + Starlark

  1. ✏️ Update server/events/types.go: add new workflow events.
  2. ✏️ Update server/sandbox/modules.go: rebuild workflow Starlark module.

Phase F: Background Jobs

  1. Add SLA scanner goroutine (check active instances, fire notifications + events on breach).
  2. Add staleness sweep for workflow instances (reuse pattern from chat-switchboard).

Phase G: Verification

  1. Integration tests: instance lifecycle, stage advancement, branching, automated stages, assignments, SLA breach.
  2. go build ./... clean.
  3. Update ICD (OpenAPI spec) with new endpoints.
  4. 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 public stage, the visitor's token grants access. Team members can also see it (they have elevated access).
  • When the instance enters a team stage, the visitor's view freezes. They see "being processed" or whatever the portal surface shows. The token is still valid for resumption when the next public stage arrives.
  • When the instance enters a system stage, 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 is form)
  • The stage name and ordinal (for progress indicators)
  • Accumulated stage_data keys marked as visitor_visible (future: field-level ACL)
  • The branding JSONB 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.goresolveTarget().

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