Feat v0.3.4 team roles + multi-party validation (#18)

Custom team roles: removed CHECK constraint on team_members.role,
roles stored in teams.settings, roles API, stage_config.required_role
enforced on claim. Multi-party signoff: workflow_signoffs table,
validation gate in advanceInternal, SubmitSignoff engine method,
signoff HTTP API. Frontend: dynamic role management, stage config
validation UI, signoff panel. Design docs for extension lifecycle
and trigger composition. 20 store tests passing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-28 00:22:04 +00:00
parent dba718b914
commit 748da6c2b4
22 changed files with 952 additions and 19 deletions

View File

@@ -139,6 +139,44 @@ func (e *Engine) advanceInternal(ctx context.Context, instanceID string, stageDa
}
currentStage := stages[currentOrdinal]
// ── Validation gate (v0.3.4) ──────────────
sc := ParseStageConfig(currentStage.StageConfig)
if sc.Validation != nil && sc.Validation.RequiredApprovals > 0 {
// Check for rejections first
rejectCount, _ := e.stores.Workflows.CountSignoffs(ctx, instanceID, currentStage.Name, models.SignoffReject)
if rejectCount > 0 {
action := sc.Validation.RejectAction
if action == "" || action == "cancel" {
_ = e.Cancel(ctx, instanceID, userID)
e.emit(ctx, "workflow.rejected", instanceID, map[string]any{
"instance_id": instanceID,
"stage": currentStage.Name,
})
return e.stores.Workflows.GetInstance(ctx, instanceID)
}
// Reroute to named stage
target, rerr := ResolveStageByName(stages, action)
if rerr != nil {
return nil, fmt.Errorf("reject_action stage %q not found: %w", action, rerr)
}
merged := mergeJSON(inst.StageData, stageData)
if err := e.stores.Workflows.AdvanceStage(ctx, instanceID, stages[target].Name, merged); err != nil {
return nil, fmt.Errorf("reject reroute: %w", err)
}
e.emit(ctx, "workflow.rejected", instanceID, map[string]any{
"instance_id": instanceID,
"stage": currentStage.Name,
"rerouted_to": stages[target].Name,
})
return e.stores.Workflows.GetInstance(ctx, instanceID)
}
approveCount, _ := e.stores.Workflows.CountSignoffs(ctx, instanceID, currentStage.Name, models.SignoffApprove)
if approveCount < sc.Validation.RequiredApprovals {
return nil, fmt.Errorf("insufficient approvals (%d of %d required)", approveCount, sc.Validation.RequiredApprovals)
}
}
// Merge stage data
merged := mergeJSON(inst.StageData, stageData)
@@ -301,6 +339,112 @@ func (e *Engine) AdvancePublic(ctx context.Context, entryToken string, stageData
return e.advanceInternal(ctx, inst.ID, stageData, inst.StartedBy, 0)
}
// ── Signoffs (v0.3.4) ─────────────────────────
// SubmitSignoff records a user's approval or rejection at the current stage boundary.
func (e *Engine) SubmitSignoff(ctx context.Context, instanceID, userID, decision, comment string) (*models.WorkflowSignoff, error) {
inst, err := e.stores.Workflows.GetInstance(ctx, instanceID)
if err != nil {
return nil, fmt.Errorf("instance not found: %w", err)
}
if inst.Status != models.InstanceStatusActive {
return nil, fmt.Errorf("instance is %s, not active", inst.Status)
}
// Load version snapshot to get current stage config
ver, err := e.stores.Workflows.GetVersion(ctx, inst.WorkflowID, inst.WorkflowVersion)
if err != nil {
return nil, fmt.Errorf("version not found: %w", err)
}
var stages []models.WorkflowStage
if err := json.Unmarshal(ver.Snapshot, &stages); err != nil {
return nil, fmt.Errorf("corrupt snapshot: %w", err)
}
// Find current stage
var currentStage *models.WorkflowStage
for i := range stages {
if stages[i].Name == inst.CurrentStage {
currentStage = &stages[i]
break
}
}
if currentStage == nil {
return nil, fmt.Errorf("current stage %q not found", inst.CurrentStage)
}
sc := ParseStageConfig(currentStage.StageConfig)
if sc.Validation == nil || sc.Validation.RequiredApprovals == 0 {
return nil, fmt.Errorf("stage %q does not require signoffs", currentStage.Name)
}
// Role check: if validation has a required_role, verify the user has it
if sc.Validation.RequiredRole != "" && currentStage.AssignmentTeamID != nil {
member, merr := e.stores.Teams.GetMember(ctx, *currentStage.AssignmentTeamID, userID)
if merr != nil {
return nil, fmt.Errorf("user is not a member of the assignment team")
}
if member.Role != sc.Validation.RequiredRole {
return nil, fmt.Errorf("role %q required to sign off (you have %q)", sc.Validation.RequiredRole, member.Role)
}
}
so := &models.WorkflowSignoff{
InstanceID: instanceID,
Stage: inst.CurrentStage,
UserID: userID,
Decision: decision,
Comment: comment,
}
if err := e.stores.Workflows.CreateSignoff(ctx, so); err != nil {
return nil, fmt.Errorf("create signoff: %w", err)
}
e.emit(ctx, "workflow.signoff", instanceID, map[string]any{
"instance_id": instanceID,
"stage": inst.CurrentStage,
"user_id": userID,
"decision": decision,
})
return so, nil
}
// CheckClaimRole verifies that a user has the required_role (from stage_config)
// to claim an assignment. Returns nil if allowed, error if forbidden.
func CheckClaimRole(ctx context.Context, stores store.Stores, assignment *models.WorkflowAssignment, userID string) error {
inst, err := stores.Workflows.GetInstance(ctx, assignment.InstanceID)
if err != nil {
return nil // can't verify — allow claim
}
ver, err := stores.Workflows.GetVersion(ctx, inst.WorkflowID, inst.WorkflowVersion)
if err != nil {
return nil
}
var stages []models.WorkflowStage
if err := json.Unmarshal(ver.Snapshot, &stages); err != nil {
return nil
}
for _, s := range stages {
if s.Name == assignment.Stage {
sc := ParseStageConfig(s.StageConfig)
if sc.RequiredRole == "" {
return nil // no role restriction
}
member, merr := stores.Teams.GetMember(ctx, assignment.TeamID, userID)
if merr != nil {
return fmt.Errorf("not a member of team")
}
if member.Role != sc.RequiredRole {
return fmt.Errorf("role %q required for this stage (you have %q)", sc.RequiredRole, member.Role)
}
return nil
}
}
return nil // stage not found in snapshot — allow
}
// emit publishes an event on the bus.
func (e *Engine) emit(_ context.Context, label, room string, payload map[string]any) {
if e.bus == nil {