From 9dd6b9aaf068fc556f6419d3d84a63ad801db6f8 Mon Sep 17 00:00:00 2001 From: James Salt Date: Fri, 22 May 2026 12:49:25 +0100 Subject: [PATCH 1/2] BCH-1155: Populate ExecutionStreamUUID in WorkflowExecution.GetByID Add a computed ExecutionStreamUUID field to WorkflowExecution and derive it deterministically in GetByID using a pure SHA256-based helper that mirrors the algorithm already used by EvidenceIntegration. Co-Authored-By: Claude Sonnet 4.6 --- docs/docs.go | 4 +++ docs/swagger.json | 4 +++ docs/swagger.yaml | 3 ++ .../relational/workflows/stream_uuid.go | 25 ++++++++++++++++ .../workflows/workflow_execution.go | 3 ++ .../workflows/workflow_execution_service.go | 9 ++++++ .../workflow_execution_service_test.go | 29 +++++++++++++++++++ 7 files changed, 77 insertions(+) create mode 100644 internal/service/relational/workflows/stream_uuid.go diff --git a/docs/docs.go b/docs/docs.go index f3f0abd4..ce726edb 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -40226,6 +40226,10 @@ const docTemplate = `{ "due_date": { "type": "string" }, + "execution_stream_uuid": { + "description": "Computed field (not persisted)", + "type": "string" + }, "failed-at": { "type": "string" }, diff --git a/docs/swagger.json b/docs/swagger.json index 15f4a14d..ef0a95c1 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -40220,6 +40220,10 @@ "due_date": { "type": "string" }, + "execution_stream_uuid": { + "description": "Computed field (not persisted)", + "type": "string" + }, "failed-at": { "type": "string" }, diff --git a/docs/swagger.yaml b/docs/swagger.yaml index ba352b4c..7edaac54 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -8919,6 +8919,9 @@ definitions: $ref: '#/definitions/gorm.DeletedAt' due_date: type: string + execution_stream_uuid: + description: Computed field (not persisted) + type: string failed-at: type: string failure_reason: diff --git a/internal/service/relational/workflows/stream_uuid.go b/internal/service/relational/workflows/stream_uuid.go new file mode 100644 index 00000000..21aba924 --- /dev/null +++ b/internal/service/relational/workflows/stream_uuid.go @@ -0,0 +1,25 @@ +package workflows + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + + "github.com/google/uuid" +) + +// ComputeExecutionStreamUUID derives the deterministic evidence stream UUID for a workflow +// execution. The same seed algorithm is used by EvidenceIntegration so both sides always +// agree on the UUID without a database round-trip. +func ComputeExecutionStreamUUID(definitionID, instanceID, executionID uuid.UUID) uuid.UUID { + seed := fmt.Sprintf("execution:%s:%s:%s:%s", + definitionID.String(), + instanceID.String(), + executionID.String(), + "v1", + ) + hash := sha256.Sum256([]byte(seed)) + h := hex.EncodeToString(hash[:16]) + streamUUID, _ := uuid.Parse(h[:8] + "-" + h[8:12] + "-" + h[12:16] + "-" + h[16:20] + "-" + h[20:32]) + return streamUUID +} diff --git a/internal/service/relational/workflows/workflow_execution.go b/internal/service/relational/workflows/workflow_execution.go index fda6a78f..7aa00be7 100644 --- a/internal/service/relational/workflows/workflow_execution.go +++ b/internal/service/relational/workflows/workflow_execution.go @@ -41,6 +41,9 @@ type WorkflowExecution struct { // Relationships WorkflowInstance *WorkflowInstance `gorm:"foreignKey:WorkflowInstanceID" json:"workflow_instance,omitempty"` StepExecutions []StepExecution `gorm:"foreignKey:WorkflowExecutionID;constraint:OnDelete:CASCADE" json:"step_executions,omitempty"` + + // Computed field (not persisted) + ExecutionStreamUUID *uuid.UUID `gorm:"-" json:"execution_stream_uuid,omitempty"` } // TableName specifies the table name for WorkflowExecution diff --git a/internal/service/relational/workflows/workflow_execution_service.go b/internal/service/relational/workflows/workflow_execution_service.go index 5cd2a3dc..06791247 100644 --- a/internal/service/relational/workflows/workflow_execution_service.go +++ b/internal/service/relational/workflows/workflow_execution_service.go @@ -64,6 +64,15 @@ func (s *WorkflowExecutionService) GetByID(id *uuid.UUID) (*WorkflowExecution, e if err != nil { return nil, err } + if execution.WorkflowInstance != nil && execution.WorkflowInstance.WorkflowDefinition != nil && + execution.WorkflowInstance.WorkflowDefinition.ID != nil && execution.WorkflowInstanceID != nil && execution.ID != nil { + streamUUID := ComputeExecutionStreamUUID( + *execution.WorkflowInstance.WorkflowDefinition.ID, + *execution.WorkflowInstanceID, + *execution.ID, + ) + execution.ExecutionStreamUUID = &streamUUID + } return &execution, nil } diff --git a/internal/service/relational/workflows/workflow_execution_service_test.go b/internal/service/relational/workflows/workflow_execution_service_test.go index 8f911dee..6568f2ae 100644 --- a/internal/service/relational/workflows/workflow_execution_service_test.go +++ b/internal/service/relational/workflows/workflow_execution_service_test.go @@ -97,6 +97,35 @@ func TestWorkflowExecutionService_GetByID(t *testing.T) { assert.Contains(t, err.Error(), "not found") } +// TestWorkflowExecutionService_GetByID_PopulatesExecutionStreamUUID tests that GetByID computes +// and populates ExecutionStreamUUID so callers can link to the evidence stream without extra queries. +// BCH-1155: terminal evidence is created in a stream but there was no navigable link to it. +func TestWorkflowExecutionService_GetByID_PopulatesExecutionStreamUUID(t *testing.T) { + db := setupTestDB(t) + service := NewWorkflowExecutionService(db) + + workflowDef := createTestWorkflowDefinition() + require.NoError(t, db.Create(workflowDef).Error) + + instance := createTestWorkflowInstance(workflowDef.ID) + require.NoError(t, db.Create(instance).Error) + + execution := createTestWorkflowExecution(instance.ID) + require.NoError(t, db.Create(execution).Error) + + retrieved, err := service.GetByID(execution.ID) + require.NoError(t, err) + + // ExecutionStreamUUID must be populated and deterministic + require.NotNil(t, retrieved.ExecutionStreamUUID) + assert.NotEqual(t, uuid.Nil, *retrieved.ExecutionStreamUUID) + + // Calling again should produce the same UUID + retrieved2, err := service.GetByID(execution.ID) + require.NoError(t, err) + assert.Equal(t, *retrieved.ExecutionStreamUUID, *retrieved2.ExecutionStreamUUID) +} + // TestWorkflowExecutionService_GetByWorkflowInstanceID tests the GetByWorkflowInstanceID method func TestWorkflowExecutionService_GetByWorkflowInstanceID(t *testing.T) { db := setupTestDB(t) From c2cf7d1c666d1be2bde8cf5239f7051c03c5606a Mon Sep 17 00:00:00 2001 From: James Salt Date: Tue, 26 May 2026 09:27:38 +0100 Subject: [PATCH 2/2] BCH-1155: Address PR review comments on execution stream UUID - Return error from ComputeExecutionStreamUUID instead of discarding it - Propagate the error in GetByID - Assert canonical computed UUID value in test and add nil guard for second retrieval pointer - Add format:uuid and readOnly:true to ExecutionStreamUUID struct tag so generated swagger docs tighten the API contract Co-Authored-By: Claude Sonnet 4.6 --- docs/docs.go | 4 +++- docs/swagger.json | 4 +++- docs/swagger.yaml | 2 ++ internal/service/relational/workflows/stream_uuid.go | 5 ++--- .../relational/workflows/workflow_execution.go | 2 +- .../workflows/workflow_execution_service.go | 5 ++++- .../workflows/workflow_execution_service_test.go | 12 ++++++++---- 7 files changed, 23 insertions(+), 11 deletions(-) diff --git a/docs/docs.go b/docs/docs.go index ce726edb..79a8f926 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -40228,7 +40228,9 @@ const docTemplate = `{ }, "execution_stream_uuid": { "description": "Computed field (not persisted)", - "type": "string" + "type": "string", + "format": "uuid", + "readOnly": true }, "failed-at": { "type": "string" diff --git a/docs/swagger.json b/docs/swagger.json index ef0a95c1..9c5cafb5 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -40222,7 +40222,9 @@ }, "execution_stream_uuid": { "description": "Computed field (not persisted)", - "type": "string" + "type": "string", + "format": "uuid", + "readOnly": true }, "failed-at": { "type": "string" diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 7edaac54..309e47bd 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -8921,6 +8921,8 @@ definitions: type: string execution_stream_uuid: description: Computed field (not persisted) + format: uuid + readOnly: true type: string failed-at: type: string diff --git a/internal/service/relational/workflows/stream_uuid.go b/internal/service/relational/workflows/stream_uuid.go index 21aba924..95d25dbf 100644 --- a/internal/service/relational/workflows/stream_uuid.go +++ b/internal/service/relational/workflows/stream_uuid.go @@ -11,7 +11,7 @@ import ( // ComputeExecutionStreamUUID derives the deterministic evidence stream UUID for a workflow // execution. The same seed algorithm is used by EvidenceIntegration so both sides always // agree on the UUID without a database round-trip. -func ComputeExecutionStreamUUID(definitionID, instanceID, executionID uuid.UUID) uuid.UUID { +func ComputeExecutionStreamUUID(definitionID, instanceID, executionID uuid.UUID) (uuid.UUID, error) { seed := fmt.Sprintf("execution:%s:%s:%s:%s", definitionID.String(), instanceID.String(), @@ -20,6 +20,5 @@ func ComputeExecutionStreamUUID(definitionID, instanceID, executionID uuid.UUID) ) hash := sha256.Sum256([]byte(seed)) h := hex.EncodeToString(hash[:16]) - streamUUID, _ := uuid.Parse(h[:8] + "-" + h[8:12] + "-" + h[12:16] + "-" + h[16:20] + "-" + h[20:32]) - return streamUUID + return uuid.Parse(h[:8] + "-" + h[8:12] + "-" + h[12:16] + "-" + h[16:20] + "-" + h[20:32]) } diff --git a/internal/service/relational/workflows/workflow_execution.go b/internal/service/relational/workflows/workflow_execution.go index 7aa00be7..e5078002 100644 --- a/internal/service/relational/workflows/workflow_execution.go +++ b/internal/service/relational/workflows/workflow_execution.go @@ -43,7 +43,7 @@ type WorkflowExecution struct { StepExecutions []StepExecution `gorm:"foreignKey:WorkflowExecutionID;constraint:OnDelete:CASCADE" json:"step_executions,omitempty"` // Computed field (not persisted) - ExecutionStreamUUID *uuid.UUID `gorm:"-" json:"execution_stream_uuid,omitempty"` + ExecutionStreamUUID *uuid.UUID `gorm:"-" json:"execution_stream_uuid,omitempty" format:"uuid" readonly:"true"` } // TableName specifies the table name for WorkflowExecution diff --git a/internal/service/relational/workflows/workflow_execution_service.go b/internal/service/relational/workflows/workflow_execution_service.go index 06791247..e166da1e 100644 --- a/internal/service/relational/workflows/workflow_execution_service.go +++ b/internal/service/relational/workflows/workflow_execution_service.go @@ -66,11 +66,14 @@ func (s *WorkflowExecutionService) GetByID(id *uuid.UUID) (*WorkflowExecution, e } if execution.WorkflowInstance != nil && execution.WorkflowInstance.WorkflowDefinition != nil && execution.WorkflowInstance.WorkflowDefinition.ID != nil && execution.WorkflowInstanceID != nil && execution.ID != nil { - streamUUID := ComputeExecutionStreamUUID( + streamUUID, err := ComputeExecutionStreamUUID( *execution.WorkflowInstance.WorkflowDefinition.ID, *execution.WorkflowInstanceID, *execution.ID, ) + if err != nil { + return nil, fmt.Errorf("failed to compute execution stream UUID: %w", err) + } execution.ExecutionStreamUUID = &streamUUID } return &execution, nil diff --git a/internal/service/relational/workflows/workflow_execution_service_test.go b/internal/service/relational/workflows/workflow_execution_service_test.go index 6568f2ae..05f96067 100644 --- a/internal/service/relational/workflows/workflow_execution_service_test.go +++ b/internal/service/relational/workflows/workflow_execution_service_test.go @@ -116,14 +116,18 @@ func TestWorkflowExecutionService_GetByID_PopulatesExecutionStreamUUID(t *testin retrieved, err := service.GetByID(execution.ID) require.NoError(t, err) - // ExecutionStreamUUID must be populated and deterministic + // ExecutionStreamUUID must equal the value produced by the canonical algorithm + // for the same definition/instance/execution IDs. + expectedUUID, err := ComputeExecutionStreamUUID(*workflowDef.ID, *instance.ID, *execution.ID) + require.NoError(t, err) require.NotNil(t, retrieved.ExecutionStreamUUID) - assert.NotEqual(t, uuid.Nil, *retrieved.ExecutionStreamUUID) + assert.Equal(t, expectedUUID, *retrieved.ExecutionStreamUUID) - // Calling again should produce the same UUID + // Calling again must produce the same UUID (determinism check). retrieved2, err := service.GetByID(execution.ID) require.NoError(t, err) - assert.Equal(t, *retrieved.ExecutionStreamUUID, *retrieved2.ExecutionStreamUUID) + require.NotNil(t, retrieved2.ExecutionStreamUUID) + assert.Equal(t, expectedUUID, *retrieved2.ExecutionStreamUUID) } // TestWorkflowExecutionService_GetByWorkflowInstanceID tests the GetByWorkflowInstanceID method