feat(t207): audit local procurement results

This commit is contained in:
QiuSW
2026-07-27 12:23:05 +08:00
parent a00c1e3e17
commit 4533df7abc
37 changed files with 4004 additions and 115 deletions
+13 -2
View File
@@ -180,6 +180,15 @@ func buildRouter(
if err != nil {
return nil, err
}
results, err := usecase.NewExecutionResultService(
store,
files,
clock,
ids,
)
if err != nil {
return nil, err
}
passwords, err := password.NewBcrypt(12)
if err != nil {
return nil, err
@@ -188,6 +197,7 @@ func buildRouter(
httpapi.DeviceServices{
Lifecycle: lifecycle,
Assets: assets,
Results: results,
},
)
if err != nil {
@@ -245,8 +255,9 @@ func buildRouter(
}
registerAdminRoutes, err := httpapi.NewAdminRouteRegistrar(
httpapi.AdminServices{
Assets: assets,
Tasks: tasks,
Assets: assets,
Tasks: tasks,
Results: results,
},
webHandler,
)
+64
View File
@@ -84,11 +84,75 @@ type TaskExecution struct {
FinishedAt *time.Time
}
type ExecutionEvent struct {
ID string
TaskID string
ExecutionID string
Step string
Type string
Message string
OccurredAt time.Time
ReceivedAt time.Time
ReceivedAfterExecutionExpiry bool
}
type ExecutionEvidenceAsset struct {
ID string
TaskID string
ExecutionID string
MediaType string
SizeBytes int64
SHA256 string
StorageKey string
CreatedAt time.Time
ReceivedAfterExecutionExpiry bool
}
type ExecutionCandidateBatch struct {
TaskID string
ExecutionID string
TaskContentSHA256 string
ExecutionMode string
SearchQuery string
ProvenanceJSON *string
CandidatesJSON string
RecommendationJSON *string
ReceivedAt time.Time
ReceivedAfterExecutionExpiry bool
}
type ExecutionOutcome struct {
TaskID string
ExecutionID string
ResultType string
ExecutionMode *string
TaskContentSHA256 *string
Outcome *string
OperatorReason *string
SelectedCandidateJSON *string
EvidenceAssetIDsJSON *string
ErrorCode *string
ErrorMessage *string
ErrorStep *string
Retryable *bool
OrderSubmitted bool
ReceivedAt time.Time
ReceivedAfterExecutionExpiry bool
}
type ExecutionReport struct {
Events []ExecutionEvent
EvidenceAssets []ExecutionEvidenceAsset
CandidateBatch *ExecutionCandidateBatch
Outcome *ExecutionOutcome
}
type TaskDetail struct {
Task PurchaseTask
Asset Asset
Execution *TaskExecution
Events []TaskEvent
Report *ExecutionReport
}
type TaskValidationError struct {
@@ -34,8 +34,8 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) {
if applied, err := runner.Up(ctx); err != nil {
t.Fatalf("initial Up() error = %v", err)
} else if applied != 4 {
t.Fatalf("initial Up() applied = %d, want 4", applied)
} else if applied != 5 {
t.Fatalf("initial Up() applied = %d, want 5", applied)
}
if err := runner.Down(ctx); err != nil {
t.Fatalf("initial Down(v4) error = %v", err)
@@ -50,6 +50,11 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) {
}
assertClaimsHistory(t, db, true)
if err := runner.Down(ctx); err != nil {
t.Fatalf("Down(v5) with compatible history error = %v", err)
}
assertClaimsHistory(t, db, true)
if err := runner.Down(ctx); err != nil {
t.Fatalf("Down(v4) with compatible history error = %v", err)
}
@@ -57,8 +62,8 @@ func TestClaimsMigrationPreservesHistoryAcrossUpDownUp(t *testing.T) {
if applied, err := runner.Up(ctx); err != nil {
t.Fatalf("final Up(v4) error = %v", err)
} else if applied != 1 {
t.Fatalf("final Up(v4) applied = %d, want 1", applied)
} else if applied != 2 {
t.Fatalf("final Up(v4-v5) applied = %d, want 2", applied)
}
assertClaimsHistory(t, db, true)
}
@@ -294,6 +299,9 @@ func TestClaimsMigrationDownFailsClosedForNewAuditData(t *testing.T) {
t.Fatalf("insert v4 audit event: %v", err)
}
if err := runner.Down(ctx); err != nil {
t.Fatalf("Down(v5) error = %v", err)
}
if err := runner.Down(ctx); err == nil {
t.Fatal("Down(v4) succeeded with non-representable audit event")
}
@@ -27,14 +27,15 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
if err != nil {
t.Fatalf("Up() error = %v", err)
}
if applied != 4 {
t.Fatalf("Up() applied = %d, want 4", applied)
if applied != 5 {
t.Fatalf("Up() applied = %d, want 5", applied)
}
assertStatuses(t, runner, map[int64]bool{
1: true,
2: true,
3: true,
4: true,
5: true,
})
applied, err = runner.Up(context.Background())
@@ -52,7 +53,8 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
1: true,
2: true,
3: true,
4: false,
4: true,
5: false,
})
applied, err = runner.Up(context.Background())
@@ -67,6 +69,7 @@ func TestRunnerSupportsUpStatusDownAndIdempotentUp(t *testing.T) {
2: true,
3: true,
4: true,
5: true,
})
}
@@ -383,6 +383,9 @@ func TestAuthMigrationCanRollbackWithoutRebuildingPurchaseTasks(
if err != nil {
t.Fatalf("migration.New() error = %v", err)
}
if err := runner.Down(context.Background()); err != nil {
t.Fatalf("Down(v5) error = %v", err)
}
if err := runner.Down(context.Background()); err != nil {
t.Fatalf("Down(v4) error = %v", err)
}
@@ -403,8 +406,8 @@ func TestAuthMigrationCanRollbackWithoutRebuildingPurchaseTasks(
}
if applied, err := runner.Up(context.Background()); err != nil {
t.Fatalf("Up(v3-v4) error = %v", err)
} else if applied != 2 {
t.Fatalf("Up(v3-v4) applied = %d, want 2", applied)
} else if applied != 3 {
t.Fatalf("Up(v3-v5) applied = %d, want 3", applied)
}
}
@@ -0,0 +1,891 @@
package sqlite
import (
"context"
"database/sql"
"encoding/json"
"errors"
"strings"
"cmroubao/backend-api/internal/domain"
"cmroubao/backend-api/internal/usecase"
)
type executionResultRequestRecord struct {
RequestHash string
ClaimTokenHash string
TaskID string
ExecutionID string
ResourceID *string
}
func (s *Store) GetExecutionEvidence(
ctx context.Context,
taskID string,
evidenceID string,
) (domain.ExecutionEvidenceAsset, error) {
return getExecutionEvidenceForTask(ctx, s.db, taskID, evidenceID)
}
func (s *Store) AppendExecutionEvents(
ctx context.Context,
write usecase.ExecutionResultWrite,
events []domain.ExecutionEvent,
) (bool, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return false, repositoryFailure(err)
}
defer func() { _ = tx.Rollback() }()
if replayed, err := replayExecutionResultRequest(
ctx, tx, write,
); err != nil || replayed {
return replayed, err
}
_, _, expired, err := authorizeExecutionResult(ctx, tx, write)
if err != nil {
return false, err
}
for _, event := range events {
_, err = tx.ExecContext(
ctx,
`INSERT INTO execution_events (
id, task_id, execution_id, step, event_type, message,
occurred_at, received_at, received_after_execution_expiry
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
event.ID,
write.TaskID,
write.ExecutionID,
event.Step,
event.Type,
event.Message,
formatTimestamp(event.OccurredAt),
formatTimestamp(write.Now),
expired,
)
if err != nil {
return false, repositoryFailure(err)
}
}
if err := insertExecutionResultRequest(ctx, tx, write, nil); err != nil {
return false, err
}
if err := tx.Commit(); err != nil {
return false, repositoryFailure(err)
}
return false, nil
}
func (s *Store) CreateExecutionEvidence(
ctx context.Context,
write usecase.ExecutionResultWrite,
candidate domain.ExecutionEvidenceAsset,
) (domain.ExecutionEvidenceAsset, bool, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return domain.ExecutionEvidenceAsset{}, false, repositoryFailure(err)
}
defer func() { _ = tx.Rollback() }()
record, found, err := lookupExecutionResultRequest(ctx, tx, write)
if err != nil {
return domain.ExecutionEvidenceAsset{}, false, err
}
if found {
if err := validateExecutionResultReplay(record, write); err != nil {
return domain.ExecutionEvidenceAsset{}, false, err
}
if record.ResourceID == nil {
return domain.ExecutionEvidenceAsset{}, false, usecase.ErrRepositoryInvariant
}
evidence, err := getExecutionEvidence(ctx, tx, *record.ResourceID)
if err != nil {
return domain.ExecutionEvidenceAsset{}, false, err
}
if err := tx.Commit(); err != nil {
return domain.ExecutionEvidenceAsset{}, false, repositoryFailure(err)
}
return evidence, true, nil
}
_, _, expired, err := authorizeExecutionResult(ctx, tx, write)
if err != nil {
return domain.ExecutionEvidenceAsset{}, false, err
}
candidate.ReceivedAfterExecutionExpiry = expired
_, err = tx.ExecContext(
ctx,
`INSERT INTO execution_evidence_assets (
id, task_id, execution_id, media_type, size_bytes, sha256,
storage_key, created_at, received_after_execution_expiry
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
candidate.ID,
write.TaskID,
write.ExecutionID,
candidate.MediaType,
candidate.SizeBytes,
candidate.SHA256,
candidate.StorageKey,
formatTimestamp(candidate.CreatedAt),
expired,
)
if err != nil {
return domain.ExecutionEvidenceAsset{}, false, repositoryFailure(err)
}
if err := insertExecutionResultRequest(ctx, tx, write, &candidate.ID); err != nil {
return domain.ExecutionEvidenceAsset{}, false, err
}
if err := tx.Commit(); err != nil {
return domain.ExecutionEvidenceAsset{}, false, repositoryFailure(err)
}
return candidate, false, nil
}
func (s *Store) StoreExecutionCandidates(
ctx context.Context,
write usecase.ExecutionResultWrite,
candidate domain.ExecutionCandidateBatch,
) (bool, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return false, repositoryFailure(err)
}
defer func() { _ = tx.Rollback() }()
if replayed, err := replayExecutionResultRequest(ctx, tx, write); err != nil || replayed {
return replayed, err
}
task, _, expired, err := authorizeExecutionResult(ctx, tx, write)
if err != nil {
return false, err
}
if usecase.TaskContentSHA256(task) != candidate.TaskContentSHA256 {
return false, usecase.ErrTaskVersionConflict
}
if err := validateCandidateEvidence(ctx, tx, write, candidate.CandidatesJSON); err != nil {
return false, err
}
_, err = tx.ExecContext(
ctx,
`INSERT INTO execution_candidate_batches (
execution_id, task_id, task_content_sha256, execution_mode,
search_query, provenance_json, candidates_json, recommendation_json,
received_at, received_after_execution_expiry
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
write.ExecutionID,
write.TaskID,
candidate.TaskContentSHA256,
candidate.ExecutionMode,
candidate.SearchQuery,
nullableString(candidate.ProvenanceJSON),
candidate.CandidatesJSON,
nullableString(candidate.RecommendationJSON),
formatTimestamp(write.Now),
expired,
)
if err != nil {
return false, repositoryFailure(err)
}
if err := insertExecutionResultRequest(ctx, tx, write, nil); err != nil {
return false, err
}
if err := tx.Commit(); err != nil {
return false, repositoryFailure(err)
}
return false, nil
}
func (s *Store) CompleteExecution(
ctx context.Context,
write usecase.ExecutionResultWrite,
outcome domain.ExecutionOutcome,
) (domain.PurchaseTask, bool, error) {
return s.finishExecution(ctx, write, outcome, domain.TaskStatusSucceeded)
}
func (s *Store) FailExecution(
ctx context.Context,
write usecase.ExecutionResultWrite,
outcome domain.ExecutionOutcome,
) (domain.PurchaseTask, bool, error) {
return s.finishExecution(ctx, write, outcome, domain.TaskStatusFailed)
}
func (s *Store) finishExecution(
ctx context.Context,
write usecase.ExecutionResultWrite,
outcome domain.ExecutionOutcome,
terminalStatus domain.TaskStatus,
) (domain.PurchaseTask, bool, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return domain.PurchaseTask{}, false, repositoryFailure(err)
}
defer func() { _ = tx.Rollback() }()
record, found, err := lookupExecutionResultRequest(ctx, tx, write)
if err != nil {
return domain.PurchaseTask{}, false, err
}
if found {
if err := validateExecutionResultReplay(record, write); err != nil {
return domain.PurchaseTask{}, false, err
}
task, err := getLifecycleTask(ctx, tx, write.TaskID)
if err != nil {
return domain.PurchaseTask{}, false, err
}
if task.Status != terminalStatus {
return domain.PurchaseTask{}, false, usecase.ErrTaskStateConflict
}
if err := tx.Commit(); err != nil {
return domain.PurchaseTask{}, false, repositoryFailure(err)
}
return task, true, nil
}
task, execution, expired, err := authorizeExecutionResult(ctx, tx, write)
if err != nil {
return domain.PurchaseTask{}, false, err
}
if task.CancelRequestedAt != nil {
return domain.PurchaseTask{}, false, usecase.ErrTaskStateConflict
}
if outcome.ResultType == "COMPLETE" {
if outcome.TaskContentSHA256 == nil ||
*outcome.TaskContentSHA256 != usecase.TaskContentSHA256(task) {
return domain.PurchaseTask{}, false, usecase.ErrTaskVersionConflict
}
if err := validateCompleteCandidate(ctx, tx, write, outcome); err != nil {
return domain.PurchaseTask{}, false, err
}
if err := validateSelectedEvidence(ctx, tx, write, outcome.SelectedCandidateJSON); err != nil {
return domain.PurchaseTask{}, false, err
}
} else if err := validateEvidenceIDsFromOutcome(
ctx, tx, write, outcome.EvidenceAssetIDsJSON,
); err != nil {
return domain.PurchaseTask{}, false, err
}
_ = execution
outcome.ReceivedAfterExecutionExpiry = expired
_, err = tx.ExecContext(
ctx,
`INSERT INTO execution_outcomes (
execution_id, task_id, result_type, execution_mode, task_content_sha256,
outcome, operator_reason, selected_candidate_json, evidence_asset_ids_json,
error_code, error_message, error_step, retryable, order_submitted, received_at,
received_after_execution_expiry
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`,
write.ExecutionID,
write.TaskID,
outcome.ResultType,
nullableString(outcome.ExecutionMode),
nullableString(outcome.TaskContentSHA256),
nullableString(outcome.Outcome),
nullableString(outcome.OperatorReason),
nullableString(outcome.SelectedCandidateJSON),
nullableString(outcome.EvidenceAssetIDsJSON),
nullableString(outcome.ErrorCode),
nullableString(outcome.ErrorMessage),
nullableString(outcome.ErrorStep),
nullableBool(outcome.Retryable),
formatTimestamp(write.Now),
expired,
)
if err != nil {
return domain.PurchaseTask{}, false, repositoryFailure(err)
}
step := "COMPLETED"
if terminalStatus == domain.TaskStatusFailed {
step = "FAILED"
}
result, err := tx.ExecContext(
ctx,
`UPDATE task_executions
SET current_step = ?,
last_heartbeat_at = ?,
finished_at = ?
WHERE id = ? AND finished_at IS NULL`,
step,
formatTimestamp(write.Now),
formatTimestamp(write.Now),
write.ExecutionID,
)
if err != nil {
return domain.PurchaseTask{}, false, repositoryFailure(err)
}
if affected, err := result.RowsAffected(); err != nil || affected != 1 {
if err != nil {
return domain.PurchaseTask{}, false, repositoryFailure(err)
}
return domain.PurchaseTask{}, false, usecase.ErrExecutionMismatch
}
result, err = tx.ExecContext(
ctx,
`UPDATE purchase_tasks
SET status = ?,
version = version + 1,
claimed_by_user_id = NULL,
claimed_by_device_id = NULL,
claim_token_hash = NULL,
claim_issued_at = NULL,
claim_expires_at = NULL,
updated_at = ?
WHERE id = ?
AND status IN ('RUNNING', 'WAITING_CONFIRMATION')
AND claim_generation = ?`,
terminalStatus,
formatTimestamp(write.Now),
write.TaskID,
write.ClaimGeneration,
)
if err != nil {
return domain.PurchaseTask{}, false, repositoryFailure(err)
}
if affected, err := result.RowsAffected(); err != nil || affected != 1 {
if err != nil {
return domain.PurchaseTask{}, false, repositoryFailure(err)
}
return domain.PurchaseTask{}, false, usecase.ErrTaskStateConflict
}
if err := insertExecutionResultRequest(ctx, tx, write, nil); err != nil {
return domain.PurchaseTask{}, false, err
}
task, err = getLifecycleTask(ctx, tx, write.TaskID)
if err != nil {
return domain.PurchaseTask{}, false, err
}
if err := tx.Commit(); err != nil {
return domain.PurchaseTask{}, false, repositoryFailure(err)
}
return task, false, nil
}
func authorizeExecutionResult(
ctx context.Context,
tx *sql.Tx,
write usecase.ExecutionResultWrite,
) (domain.PurchaseTask, domain.TaskExecution, bool, error) {
task, err := getClaimProtectedTask(ctx, tx, write.TaskID)
if err != nil {
return domain.PurchaseTask{}, domain.TaskExecution{}, false, err
}
if err := validateClaimOwner(
task,
write.UserID,
write.DeviceID,
write.ClaimGeneration,
write.ClaimTokenHash,
); err != nil {
return domain.PurchaseTask{}, domain.TaskExecution{}, false, err
}
if !domain.CanHeartbeat(task.Status) {
return domain.PurchaseTask{}, domain.TaskExecution{}, false, usecase.ErrTaskStateConflict
}
execution, err := getExecutionByID(ctx, tx, write.ExecutionID)
if err != nil {
return domain.PurchaseTask{}, domain.TaskExecution{}, false, err
}
if execution.TaskID != write.TaskID || execution.UserID != write.UserID ||
execution.DeviceID != write.DeviceID ||
execution.ClaimGeneration != write.ClaimGeneration || execution.FinishedAt != nil {
return domain.PurchaseTask{}, domain.TaskExecution{}, false, usecase.ErrExecutionMismatch
}
expired := task.ClaimExpiresAt == nil || !task.ClaimExpiresAt.After(write.Now)
return task, execution, expired, nil
}
func replayExecutionResultRequest(
ctx context.Context,
tx *sql.Tx,
write usecase.ExecutionResultWrite,
) (bool, error) {
record, found, err := lookupExecutionResultRequest(ctx, tx, write)
if err != nil || !found {
return false, err
}
if err := validateExecutionResultReplay(record, write); err != nil {
return false, err
}
if err := tx.Commit(); err != nil {
return false, repositoryFailure(err)
}
return true, nil
}
func lookupExecutionResultRequest(
ctx context.Context,
tx *sql.Tx,
write usecase.ExecutionResultWrite,
) (executionResultRequestRecord, bool, error) {
var record executionResultRequestRecord
var resourceID sql.NullString
err := tx.QueryRowContext(
ctx,
`SELECT request_sha256, claim_token_sha256, task_id, execution_id, resource_id
FROM execution_result_requests
WHERE user_id = ? AND device_id = ? AND operation = ? AND idempotency_key = ?`,
write.UserID,
write.DeviceID,
write.Operation,
write.IdempotencyKey,
).Scan(
&record.RequestHash,
&record.ClaimTokenHash,
&record.TaskID,
&record.ExecutionID,
&resourceID,
)
if errors.Is(err, sql.ErrNoRows) {
return executionResultRequestRecord{}, false, nil
}
if err != nil {
return executionResultRequestRecord{}, false, repositoryFailure(err)
}
if resourceID.Valid {
record.ResourceID = &resourceID.String
}
return record, true, nil
}
func validateExecutionResultReplay(
record executionResultRequestRecord,
write usecase.ExecutionResultWrite,
) error {
if record.RequestHash != write.RequestHash ||
record.ClaimTokenHash != write.ClaimTokenHash ||
record.TaskID != write.TaskID || record.ExecutionID != write.ExecutionID {
return usecase.ErrIdempotencyConflict
}
return nil
}
func insertExecutionResultRequest(
ctx context.Context,
tx *sql.Tx,
write usecase.ExecutionResultWrite,
resourceID *string,
) error {
_, err := tx.ExecContext(
ctx,
`INSERT INTO execution_result_requests (
user_id, device_id, operation, idempotency_key, request_sha256,
claim_token_sha256, task_id, execution_id, resource_id, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
write.UserID,
write.DeviceID,
write.Operation,
write.IdempotencyKey,
write.RequestHash,
write.ClaimTokenHash,
write.TaskID,
write.ExecutionID,
nullableString(resourceID),
formatTimestamp(write.Now),
)
if err != nil {
return repositoryFailure(err)
}
return nil
}
func getExecutionEvidence(
ctx context.Context,
queryer queryRower,
evidenceID string,
) (domain.ExecutionEvidenceAsset, error) {
var evidence domain.ExecutionEvidenceAsset
var createdAt string
var receivedAfter bool
err := queryer.QueryRowContext(
ctx,
`SELECT id, task_id, execution_id, media_type, size_bytes, sha256,
storage_key, created_at, received_after_execution_expiry
FROM execution_evidence_assets WHERE id = ?`, evidenceID,
).Scan(
&evidence.ID,
&evidence.TaskID,
&evidence.ExecutionID,
&evidence.MediaType,
&evidence.SizeBytes,
&evidence.SHA256,
&evidence.StorageKey,
&createdAt,
&receivedAfter,
)
if errors.Is(err, sql.ErrNoRows) {
return domain.ExecutionEvidenceAsset{}, usecase.ErrRepositoryInvariant
}
if err != nil {
return domain.ExecutionEvidenceAsset{}, repositoryFailure(err)
}
evidence.CreatedAt, err = parseTimestamp(createdAt)
if err != nil {
return domain.ExecutionEvidenceAsset{}, repositoryFailure(err)
}
evidence.ReceivedAfterExecutionExpiry = receivedAfter
return evidence, nil
}
func getExecutionEvidenceForTask(
ctx context.Context,
queryer queryRower,
taskID string,
evidenceID string,
) (domain.ExecutionEvidenceAsset, error) {
evidence, err := getExecutionEvidence(ctx, queryer, evidenceID)
if err != nil {
if errors.Is(err, usecase.ErrRepositoryInvariant) {
return domain.ExecutionEvidenceAsset{}, usecase.ErrRepositoryNotFound
}
return domain.ExecutionEvidenceAsset{}, err
}
if evidence.TaskID != taskID {
return domain.ExecutionEvidenceAsset{}, usecase.ErrRepositoryNotFound
}
return evidence, nil
}
func validateCandidateEvidence(
ctx context.Context,
tx *sql.Tx,
write usecase.ExecutionResultWrite,
candidatesJSON string,
) error {
var candidates []struct {
EvidenceAssetIDs []string `json:"evidence_asset_ids"`
}
if err := json.Unmarshal([]byte(candidatesJSON), &candidates); err != nil {
return usecase.ErrRepositoryInvariant
}
for _, candidate := range candidates {
if err := verifyExecutionEvidenceIDs(
ctx, tx, write.TaskID, write.ExecutionID, candidate.EvidenceAssetIDs,
); err != nil {
return err
}
}
return nil
}
func validateSelectedEvidence(
ctx context.Context,
tx *sql.Tx,
write usecase.ExecutionResultWrite,
selectedJSON *string,
) error {
if selectedJSON == nil {
return nil
}
var selected struct {
EvidenceAssetIDs []string `json:"evidence_asset_ids"`
}
if err := json.Unmarshal([]byte(*selectedJSON), &selected); err != nil {
return usecase.ErrRepositoryInvariant
}
return verifyExecutionEvidenceIDs(
ctx, tx, write.TaskID, write.ExecutionID, selected.EvidenceAssetIDs,
)
}
func validateCompleteCandidate(
ctx context.Context,
tx *sql.Tx,
write usecase.ExecutionResultWrite,
outcome domain.ExecutionOutcome,
) error {
if outcome.Outcome == nil || *outcome.Outcome != "CANDIDATE_ACCEPTED" {
return nil
}
if outcome.ExecutionMode == nil || outcome.SelectedCandidateJSON == nil {
return usecase.ErrRepositoryInvariant
}
var mode string
var candidatesJSON string
err := tx.QueryRowContext(
ctx,
`SELECT execution_mode, candidates_json
FROM execution_candidate_batches
WHERE execution_id = ? AND task_id = ?`,
write.ExecutionID,
write.TaskID,
).Scan(&mode, &candidatesJSON)
if errors.Is(err, sql.ErrNoRows) {
return usecase.ErrTaskStateConflict
}
if err != nil {
return repositoryFailure(err)
}
if mode != *outcome.ExecutionMode {
return usecase.ErrTaskStateConflict
}
var selected usecase.ExecutionCandidate
var candidates []usecase.ExecutionCandidate
if err := json.Unmarshal([]byte(*outcome.SelectedCandidateJSON), &selected); err != nil {
return usecase.ErrRepositoryInvariant
}
if err := json.Unmarshal([]byte(candidatesJSON), &candidates); err != nil {
return usecase.ErrRepositoryInvariant
}
for _, candidate := range candidates {
if candidate.Ordinal != selected.Ordinal {
continue
}
stored, storedErr := json.Marshal(candidate)
selectedJSON, selectedErr := json.Marshal(selected)
if storedErr != nil || selectedErr != nil {
return usecase.ErrRepositoryInvariant
}
if string(stored) == string(selectedJSON) {
return nil
}
return usecase.ErrTaskStateConflict
}
return usecase.ErrTaskStateConflict
}
func validateEvidenceIDsFromOutcome(
ctx context.Context,
tx *sql.Tx,
write usecase.ExecutionResultWrite,
evidenceJSON *string,
) error {
if evidenceJSON == nil {
return nil
}
var evidenceIDs []string
if err := json.Unmarshal([]byte(*evidenceJSON), &evidenceIDs); err != nil {
return usecase.ErrRepositoryInvariant
}
return verifyExecutionEvidenceIDs(
ctx, tx, write.TaskID, write.ExecutionID, evidenceIDs,
)
}
func verifyExecutionEvidenceIDs(
ctx context.Context,
tx *sql.Tx,
taskID string,
executionID string,
ids []string,
) error {
seen := map[string]struct{}{}
for _, evidenceID := range ids {
evidenceID = strings.TrimSpace(evidenceID)
if _, duplicate := seen[evidenceID]; duplicate {
return usecase.ErrTaskStateConflict
}
seen[evidenceID] = struct{}{}
var exists int
err := tx.QueryRowContext(
ctx,
`SELECT EXISTS (
SELECT 1 FROM execution_evidence_assets
WHERE id = ? AND task_id = ? AND execution_id = ?
)`,
evidenceID,
taskID,
executionID,
).Scan(&exists)
if err != nil {
return repositoryFailure(err)
}
if exists != 1 {
return usecase.ErrTaskStateConflict
}
}
return nil
}
func getExecutionReport(
ctx context.Context,
queryer queryer,
taskID string,
executionID string,
) (*domain.ExecutionReport, error) {
report := &domain.ExecutionReport{
Events: make([]domain.ExecutionEvent, 0),
EvidenceAssets: make([]domain.ExecutionEvidenceAsset, 0),
}
events, err := queryer.QueryContext(
ctx,
`SELECT id, task_id, execution_id, step, event_type, message,
occurred_at, received_at, received_after_execution_expiry
FROM execution_events
WHERE task_id = ? AND execution_id = ?
ORDER BY occurred_at ASC, id ASC`,
taskID,
executionID,
)
if err != nil {
return nil, repositoryFailure(err)
}
defer events.Close()
for events.Next() {
var event domain.ExecutionEvent
var occurredAt, receivedAt string
if err := events.Scan(
&event.ID,
&event.TaskID,
&event.ExecutionID,
&event.Step,
&event.Type,
&event.Message,
&occurredAt,
&receivedAt,
&event.ReceivedAfterExecutionExpiry,
); err != nil {
return nil, repositoryFailure(err)
}
var parseErr error
event.OccurredAt, parseErr = parseTimestamp(occurredAt)
if parseErr == nil {
event.ReceivedAt, parseErr = parseTimestamp(receivedAt)
}
if parseErr != nil {
return nil, repositoryFailure(parseErr)
}
report.Events = append(report.Events, event)
}
if err := events.Err(); err != nil {
return nil, repositoryFailure(err)
}
evidenceRows, err := queryer.QueryContext(
ctx,
`SELECT id, task_id, execution_id, media_type, size_bytes, sha256,
storage_key, created_at, received_after_execution_expiry
FROM execution_evidence_assets
WHERE task_id = ? AND execution_id = ?
ORDER BY created_at ASC, id ASC`,
taskID,
executionID,
)
if err != nil {
return nil, repositoryFailure(err)
}
defer evidenceRows.Close()
for evidenceRows.Next() {
var evidence domain.ExecutionEvidenceAsset
var createdAt string
if err := evidenceRows.Scan(
&evidence.ID,
&evidence.TaskID,
&evidence.ExecutionID,
&evidence.MediaType,
&evidence.SizeBytes,
&evidence.SHA256,
&evidence.StorageKey,
&createdAt,
&evidence.ReceivedAfterExecutionExpiry,
); err != nil {
return nil, repositoryFailure(err)
}
parsed, err := parseTimestamp(createdAt)
if err != nil {
return nil, repositoryFailure(err)
}
evidence.CreatedAt = parsed
report.EvidenceAssets = append(report.EvidenceAssets, evidence)
}
if err := evidenceRows.Err(); err != nil {
return nil, repositoryFailure(err)
}
var batch domain.ExecutionCandidateBatch
var provenance, recommendation sql.NullString
var receivedAt string
err = queryer.QueryRowContext(
ctx,
`SELECT task_id, execution_id, task_content_sha256, execution_mode,
search_query, provenance_json, candidates_json, recommendation_json,
received_at, received_after_execution_expiry
FROM execution_candidate_batches
WHERE task_id = ? AND execution_id = ?`,
taskID,
executionID,
).Scan(
&batch.TaskID,
&batch.ExecutionID,
&batch.TaskContentSHA256,
&batch.ExecutionMode,
&batch.SearchQuery,
&provenance,
&batch.CandidatesJSON,
&recommendation,
&receivedAt,
&batch.ReceivedAfterExecutionExpiry,
)
if err == nil {
if provenance.Valid {
batch.ProvenanceJSON = &provenance.String
}
if recommendation.Valid {
batch.RecommendationJSON = &recommendation.String
}
batch.ReceivedAt, err = parseTimestamp(receivedAt)
if err != nil {
return nil, repositoryFailure(err)
}
report.CandidateBatch = &batch
} else if !errors.Is(err, sql.ErrNoRows) {
return nil, repositoryFailure(err)
}
var outcome domain.ExecutionOutcome
var mode, hash, result, reason, selected, evidenceIDs, code, message, step sql.NullString
var retryable sql.NullBool
var outcomeReceivedAt string
err = queryer.QueryRowContext(
ctx,
`SELECT task_id, execution_id, result_type, execution_mode,
task_content_sha256, outcome, operator_reason, selected_candidate_json,
evidence_asset_ids_json, error_code, error_message, error_step, retryable, order_submitted,
received_at, received_after_execution_expiry
FROM execution_outcomes
WHERE task_id = ? AND execution_id = ?`,
taskID,
executionID,
).Scan(
&outcome.TaskID,
&outcome.ExecutionID,
&outcome.ResultType,
&mode,
&hash,
&result,
&reason,
&selected,
&evidenceIDs,
&code,
&message,
&step,
&retryable,
&outcome.OrderSubmitted,
&outcomeReceivedAt,
&outcome.ReceivedAfterExecutionExpiry,
)
if err == nil {
outcome.ExecutionMode = nullableStringFromSQL(mode)
outcome.TaskContentSHA256 = nullableStringFromSQL(hash)
outcome.Outcome = nullableStringFromSQL(result)
outcome.OperatorReason = nullableStringFromSQL(reason)
outcome.SelectedCandidateJSON = nullableStringFromSQL(selected)
outcome.EvidenceAssetIDsJSON = nullableStringFromSQL(evidenceIDs)
outcome.ErrorCode = nullableStringFromSQL(code)
outcome.ErrorMessage = nullableStringFromSQL(message)
outcome.ErrorStep = nullableStringFromSQL(step)
if retryable.Valid {
outcome.Retryable = &retryable.Bool
}
outcome.ReceivedAt, err = parseTimestamp(outcomeReceivedAt)
if err != nil {
return nil, repositoryFailure(err)
}
report.Outcome = &outcome
} else if !errors.Is(err, sql.ErrNoRows) {
return nil, repositoryFailure(err)
}
return report, nil
}
func nullableStringFromSQL(value sql.NullString) *string {
if !value.Valid {
return nil
}
return &value.String
}
var _ usecase.ExecutionResultRepository = (*Store)(nil)
@@ -20,6 +20,11 @@ type queryRower interface {
QueryRowContext(context.Context, string, ...any) *sql.Row
}
type queryer interface {
queryRower
QueryContext(context.Context, string, ...any) (*sql.Rows, error)
}
type rowScanner interface {
Scan(...any) error
}
@@ -332,6 +337,13 @@ func nullableInt64(value *int64) any {
return *value
}
func nullableBool(value *bool) any {
if value == nil {
return nil
}
return *value
}
func repositoryFailure(err error) error {
if err == nil {
return nil
@@ -313,11 +313,19 @@ func (s *Store) GetTaskDetail(
} else {
executionPointer = &execution
}
var report *domain.ExecutionReport
if executionPointer != nil {
report, err = getExecutionReport(ctx, tx, taskID, executionPointer.ID)
if err != nil {
return domain.TaskDetail{}, err
}
}
detail := domain.TaskDetail{
Task: task,
Asset: asset,
Execution: executionPointer,
Events: events,
Report: report,
}
if err := tx.Commit(); err != nil {
return domain.TaskDetail{}, repositoryFailure(err)
@@ -24,12 +24,13 @@ const (
)
type AdminServices struct {
Assets *usecase.AssetService
Tasks *usecase.TaskService
Assets *usecase.AssetService
Tasks *usecase.TaskService
Results *usecase.ExecutionResultService
}
func (s AdminServices) validate() error {
if s.Assets == nil || s.Tasks == nil {
if s.Assets == nil || s.Tasks == nil || s.Results == nil {
return errors.New("admin services are required")
}
return nil
@@ -49,10 +50,38 @@ func registerAdminAPI(routes gin.IRoutes, services AdminServices) error {
routes.POST("/api/v1/tasks", handler.createTask)
routes.GET("/api/v1/tasks", handler.listTasks)
routes.GET("/api/v1/tasks/:id", handler.taskDetail)
routes.GET(
"/api/v1/tasks/:id/evidence/:evidence_id/content",
handler.evidenceContent,
)
routes.POST("/api/v1/tasks/:id/cancel", handler.cancelTask)
return nil
}
func (h *adminHandlers) evidenceContent(ctx *gin.Context) {
result, err := h.services.Results.OpenEvidence(
ctx.Request.Context(),
ctx.Param("id"),
ctx.Param("evidence_id"),
)
if err != nil {
writeUsecaseError(ctx, err)
return
}
defer result.Content.Close()
ctx.Header("Cache-Control", "private, no-store")
ctx.Header("Content-Type", result.Evidence.MediaType)
ctx.Header("Content-Length", strconv.FormatInt(result.Evidence.SizeBytes, 10))
ctx.Header("ETag", `"`+result.Evidence.SHA256+`"`)
ctx.Header("X-Content-Type-Options", "nosniff")
ctx.Header(
"Content-Disposition",
`inline; filename="`+result.Evidence.ID+`.jpg"`,
)
ctx.Status(http.StatusOK)
_, _ = io.Copy(ctx.Writer, result.Content)
}
func (h *adminHandlers) uploadAsset(ctx *gin.Context) {
if !hasMediaType(ctx, "multipart/form-data") {
writePublicError(
@@ -317,6 +346,10 @@ func (h *adminHandlers) taskDetail(ctx *gin.Context) {
if detail.Execution != nil {
execution = executionResponse(*detail.Execution)
}
var executionReport any
if detail.Report != nil {
executionReport = executionReportResponse(detail.Report)
}
ctx.Header("Cache-Control", "no-store")
ctx.JSON(http.StatusOK, gin.H{
"id": detail.Task.ID,
@@ -337,6 +370,7 @@ func (h *adminHandlers) taskDetail(ctx *gin.Context) {
"derived_requirement": nil,
"claim": claim,
"execution": execution,
"execution_report": executionReport,
"events": events,
"assets": []gin.H{
assetResponse(detail.Asset),
@@ -344,6 +378,78 @@ func (h *adminHandlers) taskDetail(ctx *gin.Context) {
})
}
func executionReportResponse(report *domain.ExecutionReport) gin.H {
events := make([]gin.H, 0, len(report.Events))
for _, event := range report.Events {
events = append(events, gin.H{
"id": event.ID,
"step": event.Step,
"type": event.Type,
"message": event.Message,
"occurred_at": formatTime(event.OccurredAt),
"received_at": formatTime(event.ReceivedAt),
"received_after_execution_expiry": event.ReceivedAfterExecutionExpiry,
})
}
evidence := make([]gin.H, 0, len(report.EvidenceAssets))
for _, asset := range report.EvidenceAssets {
evidence = append(evidence, gin.H{
"id": asset.ID,
"media_type": asset.MediaType,
"size_bytes": asset.SizeBytes,
"sha256": asset.SHA256,
"created_at": formatTime(asset.CreatedAt),
"received_after_execution_expiry": asset.ReceivedAfterExecutionExpiry,
})
}
response := gin.H{
"events": events,
"evidence": evidence,
}
if batch := report.CandidateBatch; batch != nil {
response["candidate_batch"] = gin.H{
"task_content_sha256": batch.TaskContentSHA256,
"execution_mode": batch.ExecutionMode,
"search_query": batch.SearchQuery,
"provenance": decodedAuditJSON(batch.ProvenanceJSON),
"candidates": decodedAuditJSON(&batch.CandidatesJSON),
"recommendation": decodedAuditJSON(batch.RecommendationJSON),
"received_at": formatTime(batch.ReceivedAt),
"received_after_execution_expiry": batch.ReceivedAfterExecutionExpiry,
}
}
if outcome := report.Outcome; outcome != nil {
response["outcome"] = gin.H{
"result_type": outcome.ResultType,
"execution_mode": outcome.ExecutionMode,
"task_content_sha256": outcome.TaskContentSHA256,
"outcome": outcome.Outcome,
"operator_reason": outcome.OperatorReason,
"selected_candidate": decodedAuditJSON(outcome.SelectedCandidateJSON),
"evidence_asset_ids": decodedAuditJSON(outcome.EvidenceAssetIDsJSON),
"error_code": outcome.ErrorCode,
"error_message": outcome.ErrorMessage,
"error_step": outcome.ErrorStep,
"retryable": outcome.Retryable,
"order_submitted": outcome.OrderSubmitted,
"received_at": formatTime(outcome.ReceivedAt),
"received_after_execution_expiry": outcome.ReceivedAfterExecutionExpiry,
}
}
return response
}
func decodedAuditJSON(value *string) any {
if value == nil {
return nil
}
var decoded any
if err := json.Unmarshal([]byte(*value), &decoded); err != nil {
return nil
}
return decoded
}
func (h *adminHandlers) cancelTask(ctx *gin.Context) {
if !hasMediaType(ctx, "application/json") {
writePublicError(
@@ -349,8 +349,12 @@ func newAdminIntegrationRouter(t *testing.T) http.Handler {
if err != nil {
t.Fatalf("usecase.NewTaskService() error = %v", err)
}
results, err := usecase.NewExecutionResultService(repositories, files, clock, ids)
if err != nil {
t.Fatalf("usecase.NewExecutionResultService() error = %v", err)
}
registrar, err := NewAdminRouteRegistrar(
AdminServices{Assets: assets, Tasks: tasks},
AdminServices{Assets: assets, Tasks: tasks, Results: results},
emptyAdminWeb{},
)
if err != nil {
@@ -19,10 +19,11 @@ const claimTokenHeader = "X-Claim-Token"
type DeviceServices struct {
Lifecycle *usecase.LifecycleService
Assets *usecase.AssetService
Results *usecase.ExecutionResultService
}
func (services DeviceServices) validate() error {
if services.Lifecycle == nil || services.Assets == nil {
if services.Lifecycle == nil || services.Assets == nil || services.Results == nil {
return errors.New("device services are required")
}
return nil
@@ -68,6 +69,11 @@ func NewDeviceRouteRegistrar(
"/api/v1/tasks/:id/cancel-ack",
handler.acknowledgeCancellation,
)
routes.POST("/api/v1/tasks/:id/events", handler.appendEvents)
routes.POST("/api/v1/tasks/:id/evidence", handler.uploadEvidence)
routes.POST("/api/v1/tasks/:id/candidates", handler.storeCandidates)
routes.POST("/api/v1/tasks/:id/complete", handler.completeTask)
routes.POST("/api/v1/tasks/:id/fail", handler.failTask)
return nil
}, nil
}
@@ -367,6 +373,257 @@ func (handler *deviceHandlers) acknowledgeCancellation(
})
}
func (handler *deviceHandlers) appendEvents(ctx *gin.Context) {
principal, ok := devicePrincipal(ctx)
if !ok {
return
}
var request struct {
ExecutionID string `json:"execution_id"`
ClaimGeneration int64 `json:"claim_generation"`
Events []usecase.ClientExecutionEvent `json:"events"`
}
if !decodeDeviceJSON(ctx, &request) {
return
}
replayed, err := handler.services.Results.AppendEvents(
ctx.Request.Context(),
usecase.AppendExecutionEventsCommand{
Identity: handler.executionResultIdentity(
ctx, principal, request.ExecutionID, request.ClaimGeneration,
),
Events: request.Events,
},
)
if err != nil {
writeUsecaseError(ctx, err)
return
}
ctx.Header("Cache-Control", "no-store")
ctx.JSON(http.StatusOK, gin.H{"replayed": replayed})
}
func (handler *deviceHandlers) uploadEvidence(ctx *gin.Context) {
principal, ok := devicePrincipal(ctx)
if !ok {
return
}
if !isEvidenceMediaType(ctx.GetHeader("Content-Type")) {
writePublicError(
ctx,
http.StatusUnsupportedMediaType,
"ASSET_MEDIA_TYPE_UNSUPPORTED",
"JPEG, PNG, or WebP evidence is required",
false,
gin.H{},
)
return
}
generation, err := strconv.ParseInt(
strings.TrimSpace(ctx.GetHeader("X-Claim-Generation")),
10,
64,
)
if err != nil {
writePublicError(
ctx,
http.StatusUnprocessableEntity,
"EXECUTION_RESULT_INVALID",
"execution result request is invalid",
false,
fieldDetails("claim_generation", "must be a positive integer"),
)
return
}
ctx.Request.Body = http.MaxBytesReader(ctx.Writer, ctx.Request.Body, maxMultipartBytes)
result, err := handler.services.Results.UploadEvidence(
ctx.Request.Context(),
usecase.UploadExecutionEvidenceCommand{
Identity: handler.executionResultIdentity(
ctx,
principal,
ctx.GetHeader("X-Execution-ID"),
generation,
),
DeclaredMediaType: ctx.GetHeader("Content-Type"),
Content: ctx.Request.Body,
},
)
if err != nil {
writeUsecaseError(ctx, err)
return
}
ctx.Header("Cache-Control", "no-store")
ctx.JSON(http.StatusCreated, gin.H{
"evidence": deviceEvidenceResponse(result.Evidence),
"replayed": result.Replayed,
})
}
func (handler *deviceHandlers) storeCandidates(ctx *gin.Context) {
principal, ok := devicePrincipal(ctx)
if !ok {
return
}
var request struct {
ExecutionID string `json:"execution_id"`
ClaimGeneration int64 `json:"claim_generation"`
TaskContentSHA256 string `json:"task_content_sha256"`
ExecutionMode string `json:"execution_mode"`
SearchQuery string `json:"search_query"`
Provenance *usecase.ExecutionProvenance `json:"provenance"`
Candidates []usecase.ExecutionCandidate `json:"candidates"`
Recommendation *usecase.CandidateRecommendation `json:"recommendation"`
}
if !decodeDeviceJSON(ctx, &request) {
return
}
replayed, err := handler.services.Results.StoreCandidates(
ctx.Request.Context(),
usecase.StoreExecutionCandidatesCommand{
Identity: handler.executionResultIdentity(
ctx, principal, request.ExecutionID, request.ClaimGeneration,
),
TaskContentSHA256: request.TaskContentSHA256,
ExecutionMode: request.ExecutionMode,
SearchQuery: request.SearchQuery,
Provenance: request.Provenance,
Candidates: request.Candidates,
Recommendation: request.Recommendation,
},
)
if err != nil {
writeUsecaseError(ctx, err)
return
}
ctx.Header("Cache-Control", "no-store")
ctx.JSON(http.StatusOK, gin.H{"replayed": replayed})
}
func (handler *deviceHandlers) completeTask(ctx *gin.Context) {
principal, ok := devicePrincipal(ctx)
if !ok {
return
}
var request struct {
ExecutionID string `json:"execution_id"`
ClaimGeneration int64 `json:"claim_generation"`
TaskContentSHA256 string `json:"task_content_sha256"`
ExecutionMode string `json:"execution_mode"`
Outcome string `json:"outcome"`
OperatorReason string `json:"operator_reason"`
Candidate *usecase.ExecutionCandidate `json:"candidate"`
OrderSubmitted bool `json:"order_submitted"`
}
if !decodeDeviceJSON(ctx, &request) {
return
}
result, replayed, err := handler.services.Results.Complete(
ctx.Request.Context(),
usecase.CompleteExecutionCommand{
Identity: handler.executionResultIdentity(
ctx, principal, request.ExecutionID, request.ClaimGeneration,
),
TaskContentSHA256: request.TaskContentSHA256,
ExecutionMode: request.ExecutionMode,
Outcome: request.Outcome,
OperatorReason: request.OperatorReason,
Candidate: request.Candidate,
OrderSubmitted: request.OrderSubmitted,
},
)
if err != nil {
writeUsecaseError(ctx, err)
return
}
ctx.Header("Cache-Control", "no-store")
ctx.JSON(http.StatusOK, gin.H{
"task": deviceTaskResponse(result),
"replayed": replayed,
})
}
func (handler *deviceHandlers) failTask(ctx *gin.Context) {
principal, ok := devicePrincipal(ctx)
if !ok {
return
}
var request struct {
ExecutionID string `json:"execution_id"`
ClaimGeneration int64 `json:"claim_generation"`
Error struct {
Code string `json:"code"`
Message string `json:"message"`
Step string `json:"step"`
Retryable bool `json:"retryable"`
} `json:"error"`
EvidenceAssetIDs []string `json:"evidence_asset_ids"`
}
if !decodeDeviceJSON(ctx, &request) {
return
}
result, replayed, err := handler.services.Results.Fail(
ctx.Request.Context(),
usecase.FailExecutionCommand{
Identity: handler.executionResultIdentity(
ctx, principal, request.ExecutionID, request.ClaimGeneration,
),
ErrorCode: request.Error.Code,
ErrorMessage: request.Error.Message,
ErrorStep: request.Error.Step,
Retryable: request.Error.Retryable,
EvidenceAssetIDs: request.EvidenceAssetIDs,
},
)
if err != nil {
writeUsecaseError(ctx, err)
return
}
ctx.Header("Cache-Control", "no-store")
ctx.JSON(http.StatusOK, gin.H{
"task": deviceTaskResponse(result),
"replayed": replayed,
})
}
func (handler *deviceHandlers) executionResultIdentity(
ctx *gin.Context,
principal domain.AuthPrincipal,
executionID string,
claimGeneration int64,
) usecase.ExecutionResultIdentity {
return usecase.ExecutionResultIdentity{
UserID: principal.UserID,
DeviceID: principal.DeviceID,
TaskID: ctx.Param("id"),
ExecutionID: executionID,
ClaimGeneration: claimGeneration,
ClaimToken: ctx.GetHeader(claimTokenHeader),
IdempotencyKey: ctx.GetHeader("Idempotency-Key"),
}
}
func isEvidenceMediaType(value string) bool {
return hasEvidenceMediaType(value, "image/jpeg") ||
hasEvidenceMediaType(value, "image/png") ||
hasEvidenceMediaType(value, "image/webp")
}
func hasEvidenceMediaType(value string, expected string) bool {
return strings.EqualFold(strings.TrimSpace(strings.Split(value, ";")[0]), expected)
}
func deviceEvidenceResponse(evidence domain.ExecutionEvidenceAsset) gin.H {
return gin.H{
"id": evidence.ID,
"media_type": evidence.MediaType,
"size_bytes": evidence.SizeBytes,
"sha256": evidence.SHA256,
"created_at": formatTime(evidence.CreatedAt),
"received_after_execution_expiry": evidence.ReceivedAfterExecutionExpiry,
}
}
type lifecycleTransitionRequest struct {
DeviceID string `json:"device_id"`
ClaimGeneration int64 `json:"claim_generation"`
@@ -15,6 +15,7 @@ import (
"net/http"
"net/http/httptest"
"path/filepath"
"strconv"
"strings"
"sync"
"testing"
@@ -478,6 +479,141 @@ func TestDeviceStartAndTaskHeartbeatUseClaimContract(t *testing.T) {
assertNoClaimSecret(t, taskHeartbeat, testOpaqueToken)
}
func TestDeviceExecutionResultsAreIdempotentAndAuditable(t *testing.T) {
fixture := newDeviceHTTPFixture(t)
requireDeviceStatus(t, fixture.readyHeartbeat(t), http.StatusOK)
taskID := fixture.createPendingTask(t)
claimedResponse := fixture.claimNext(t, "claim-for-results", testOpaqueToken)
requireDeviceStatus(t, claimedResponse, http.StatusOK)
var claimed deviceLifecycleResponse
decodeResponse(t, claimedResponse, &claimed)
startResponse := performDeviceRequest(t, fixture.router, deviceRequest{
method: http.MethodPost,
target: "/api/v1/tasks/" + taskID + "/start",
contentType: "application/json",
body: strings.NewReader(fmt.Sprintf(
`{"claim_generation":%d,"expected_version":%d}`,
claimed.Task.ClaimGeneration,
claimed.Task.Version,
)),
bearerToken: testOpaqueToken,
claimToken: testOpaqueToken,
idempotencyKey: "start-for-results",
})
requireDeviceStatus(t, startResponse, http.StatusOK)
var started deviceLifecycleResponse
decodeResponse(t, startResponse, &started)
occurredAt := time.Now().UTC().Format(time.RFC3339Nano)
events := performDeviceRequest(t, fixture.router, deviceRequest{
method: http.MethodPost,
target: "/api/v1/tasks/" + taskID + "/events",
contentType: "application/json",
body: strings.NewReader(fmt.Sprintf(
`{"execution_id":%q,"claim_generation":%d,"events":[{"event_id":"00000000-0000-4000-8000-000000000701","step":"SEARCH","type":"SEARCH_STARTED","message":"开始采集候选","occurred_at":%q}]}`,
started.Execution.ID,
started.Task.ClaimGeneration,
occurredAt,
)),
bearerToken: testOpaqueToken,
claimToken: testOpaqueToken,
idempotencyKey: "result-events-1",
})
requireDeviceStatus(t, events, http.StatusOK)
evidence := performDeviceRequest(t, fixture.router, deviceRequest{
method: http.MethodPost,
target: "/api/v1/tasks/" + taskID + "/evidence",
contentType: "image/jpeg",
body: deviceReferenceImage(t, 701),
bearerToken: testOpaqueToken,
claimToken: testOpaqueToken,
idempotencyKey: "result-evidence-1",
executionID: started.Execution.ID,
claimGeneration: started.Task.ClaimGeneration,
})
requireDeviceStatus(t, evidence, http.StatusCreated)
var evidenceResponse struct {
Evidence struct {
ID string `json:"id"`
} `json:"evidence"`
}
decodeResponse(t, evidence, &evidenceResponse)
if evidenceResponse.Evidence.ID == "" {
t.Fatalf("evidence response = %s", evidence.Body.String())
}
detail, err := fixture.tasks.Get(context.Background(), "local-admin", taskID)
if err != nil {
t.Fatalf("get task for content hash: %v", err)
}
taskHash := usecase.TaskContentSHA256(detail.Task)
candidatePayload := fmt.Sprintf(
`{"execution_id":%q,"claim_generation":%d,"task_content_sha256":%q,"execution_mode":"MANUAL_FIRST","search_query":"TEST-SKU","candidates":[{"ordinal":1,"title":"手动候选","sku_text":"TEST-SKU","price":"12.00","product_url":"https://example.test/product/1","image_url":"https://example.test/image/1.jpg","evidence_asset_ids":[%q],"evaluation":null}]}`,
started.Execution.ID,
started.Task.ClaimGeneration,
taskHash,
evidenceResponse.Evidence.ID,
)
candidates := performDeviceRequest(t, fixture.router, deviceRequest{
method: http.MethodPost,
target: "/api/v1/tasks/" + taskID + "/candidates",
contentType: "application/json",
body: strings.NewReader(candidatePayload),
bearerToken: testOpaqueToken,
claimToken: testOpaqueToken,
idempotencyKey: "result-candidates-1",
})
requireDeviceStatus(t, candidates, http.StatusOK)
completePayload := fmt.Sprintf(
`{"execution_id":%q,"claim_generation":%d,"task_content_sha256":%q,"execution_mode":"MANUAL_FIRST","outcome":"CANDIDATE_ACCEPTED","operator_reason":"人工核对标题、SKU和截图后接受","candidate":{"ordinal":1,"title":"手动候选","sku_text":"TEST-SKU","price":"12.00","product_url":"https://example.test/product/1","image_url":"https://example.test/image/1.jpg","evidence_asset_ids":[%q],"evaluation":null},"order_submitted":false}`,
started.Execution.ID,
started.Task.ClaimGeneration,
taskHash,
evidenceResponse.Evidence.ID,
)
complete := performDeviceRequest(t, fixture.router, deviceRequest{
method: http.MethodPost,
target: "/api/v1/tasks/" + taskID + "/complete",
contentType: "application/json",
body: strings.NewReader(completePayload),
bearerToken: testOpaqueToken,
claimToken: testOpaqueToken,
idempotencyKey: "result-complete-1",
})
requireDeviceStatus(t, complete, http.StatusOK)
assertNoClaimSecret(t, complete, testOpaqueToken)
replayed := performDeviceRequest(t, fixture.router, deviceRequest{
method: http.MethodPost,
target: "/api/v1/tasks/" + taskID + "/complete",
contentType: "application/json",
body: strings.NewReader(completePayload),
bearerToken: testOpaqueToken,
claimToken: testOpaqueToken,
idempotencyKey: "result-complete-1",
})
requireDeviceStatus(t, replayed, http.StatusOK)
if !strings.Contains(replayed.Body.String(), `"replayed":true`) {
t.Fatalf("terminal replay response = %s", replayed.Body.String())
}
detail, err = fixture.tasks.Get(context.Background(), "local-admin", taskID)
if err != nil {
t.Fatalf("get task result detail: %v", err)
}
if detail.Task.Status != domain.TaskStatusSucceeded ||
detail.Report == nil ||
detail.Report.Outcome == nil ||
detail.Report.Outcome.OrderSubmitted ||
len(detail.Report.Events) != 1 ||
len(detail.Report.EvidenceAssets) != 1 ||
detail.Report.CandidateBatch == nil {
t.Fatalf("execution report = %+v", detail.Report)
}
}
func TestDeviceReleaseReturnsClaimedTaskToPending(t *testing.T) {
fixture := newDeviceHTTPFixture(t)
requireDeviceStatus(t, fixture.readyHeartbeat(t), http.StatusOK)
@@ -710,10 +846,15 @@ func newDeviceHTTPFixture(t *testing.T) *deviceHTTPFixture {
if err != nil {
t.Fatalf("usecase.NewLifecycleService() error = %v", err)
}
results, err := usecase.NewExecutionResultService(store, files, clock, ids)
if err != nil {
t.Fatalf("usecase.NewExecutionResultService() error = %v", err)
}
deviceRoutes, err := NewDeviceRouteRegistrar(
DeviceServices{
Lifecycle: lifecycle,
Assets: assets,
Results: results,
},
)
if err != nil {
@@ -880,13 +1021,15 @@ func deviceReferenceImage(t *testing.T, index int) io.Reader {
}
type deviceRequest struct {
method string
target string
contentType string
body io.Reader
bearerToken string
claimToken string
idempotencyKey string
method string
target string
contentType string
body io.Reader
bearerToken string
claimToken string
idempotencyKey string
executionID string
claimGeneration int64
}
func performDeviceRequest(
@@ -911,6 +1054,13 @@ func performDeviceRequest(
if spec.idempotencyKey != "" {
request.Header.Set("Idempotency-Key", spec.idempotencyKey)
}
if spec.executionID != "" {
request.Header.Set("X-Execution-ID", spec.executionID)
request.Header.Set(
"X-Claim-Generation",
strconv.FormatInt(spec.claimGeneration, 10),
)
}
response := httptest.NewRecorder()
router.ServeHTTP(response, request)
return response
@@ -756,6 +756,7 @@ type taskDetailView struct {
UpdatedAt time.Time
CanCancel bool
CancelRequiresAck bool
ExecutionReport *ExecutionReport
}
type taskDetailPage struct {
@@ -789,6 +790,7 @@ func taskDetailViewFrom(task Task) taskDetailView {
CanCancel: canCancelTaskStatus(task.Status),
CancelRequiresAck: task.Status == "RUNNING" ||
task.Status == "WAITING_CONFIRMATION",
ExecutionReport: task.ExecutionReport,
}
}
@@ -45,6 +45,62 @@ textarea {
font: inherit;
}
.execution-audit {
margin-top: 20px;
}
.audit-summary {
margin-bottom: 18px;
}
.audit-list {
margin: 8px 0 0;
padding-left: 20px;
}
.audit-list li {
margin: 6px 0;
overflow-wrap: anywhere;
}
.audit-evidence-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
}
.audit-evidence {
margin: 8px 0;
}
.audit-evidence img {
display: block;
width: 100%;
max-height: 240px;
object-fit: contain;
border: 1px solid var(--line);
background: var(--surface-soft);
}
.audit-evidence figcaption {
margin-top: 6px;
color: var(--muted);
font-size: 12px;
overflow-wrap: anywhere;
}
.audit-json {
max-height: 360px;
margin: 8px 0 16px;
overflow: auto;
padding: 12px;
border: 1px solid var(--line);
background: var(--surface-soft);
font: 12px/1.45 ui-monospace, SFMono-Regular, Consolas, monospace;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
button,
input,
select {
@@ -73,6 +73,39 @@
</aside>
</div>
{{with .Task.ExecutionReport}}
<section class="content-section execution-audit" aria-labelledby="execution-audit-heading">
<h2 id="execution-audit-heading">执行审计</h2>
{{if .Outcome}}
<dl class="definition-list audit-summary">
<dt>结果类型</dt><dd>{{.Outcome.ResultType}}</dd>
<dt>人工理由</dt><dd>{{if .Outcome.OperatorReason}}{{.Outcome.OperatorReason}}{{else}}未提供{{end}}</dd>
<dt>订单提交</dt><dd>{{if .Outcome.OrderSubmitted}}是{{else}}否{{end}}</dd>
{{if .Outcome.ErrorCode}}<dt>失败信息</dt><dd>{{.Outcome.ErrorCode}}:{{.Outcome.ErrorMessage}}</dd>{{end}}
</dl>
{{end}}
{{if .Mode}}<p class="section-note">模式:{{.Mode}} · 搜索词:{{.SearchQuery}}</p>{{end}}
{{if .Provenance}}<h3>本地模型出处</h3><pre class="audit-json">{{.Provenance}}</pre>{{end}}
{{if .Candidates}}<h3>候选与评估</h3><pre class="audit-json">{{.Candidates}}</pre>{{end}}
{{if .Recommendation}}<h3>本地推荐</h3><pre class="audit-json">{{.Recommendation}}</pre>{{end}}
{{if .Evidence}}
<h3>证据截图</h3>
<div class="audit-evidence-grid">
{{range .Evidence}}<figure class="audit-evidence">
<img src="{{.ContentURL}}" alt="执行证据截图">
<figcaption>SHA-256 {{.SHA256}}({{.SizeBytes}} bytes){{if .ReceivedAfterExecutionExpiry}},授权到期后补报{{end}}</figcaption>
</figure>{{end}}
</div>
{{end}}
{{if .Events}}
<h3>执行事件</h3>
<ul class="audit-list">
{{range .Events}}<li><time datetime="{{machineTime .OccurredAt}}">{{displayTime .OccurredAt}}</time> · {{.Step}} · {{.Message}}{{if .ReceivedAfterExecutionExpiry}}(授权到期后补报){{end}}</li>{{end}}
</ul>
{{end}}
</section>
{{end}}
{{if .Task.CanCancel}}
<noscript>
<section class="noscript-cancel" aria-labelledby="noscript-cancel-heading">
@@ -58,6 +58,47 @@ type Task struct {
ReferenceAssetID string
CreatedAt time.Time
UpdatedAt time.Time
ExecutionReport *ExecutionReport
}
type ExecutionReport struct {
Events []ExecutionReportEvent
Evidence []ExecutionReportEvidence
Mode string
SearchQuery string
Provenance string
Candidates string
Recommendation string
Outcome *ExecutionReportOutcome
}
type ExecutionReportEvent struct {
Step string
Type string
Message string
OccurredAt time.Time
ReceivedAfterExecutionExpiry bool
}
type ExecutionReportEvidence struct {
ID string
ContentURL string
SHA256 string
SizeBytes int64
CreatedAt time.Time
ReceivedAfterExecutionExpiry bool
}
type ExecutionReportOutcome struct {
ResultType string
Outcome string
OperatorReason string
ErrorCode string
ErrorMessage string
ErrorStep string
OrderSubmitted bool
ReceivedAt time.Time
ReceivedAfterExecutionExpiry bool
}
type UploadReferenceInput struct {
@@ -2,7 +2,9 @@ package webui
import (
"context"
"encoding/json"
"errors"
"strings"
"cmroubao/backend-api/internal/domain"
"cmroubao/backend-api/internal/transport/authcommon"
@@ -176,9 +178,82 @@ func taskFromPurchase(task domain.PurchaseTask) Task {
func taskFromDetail(detail domain.TaskDetail) Task {
task := taskFromPurchase(detail.Task)
task.ReferenceAssetID = detail.Asset.ID
task.ExecutionReport = executionReportFrom(detail.Report)
return task
}
func executionReportFrom(report *domain.ExecutionReport) *ExecutionReport {
if report == nil {
return nil
}
result := &ExecutionReport{
Events: make([]ExecutionReportEvent, 0, len(report.Events)),
Evidence: make([]ExecutionReportEvidence, 0, len(report.EvidenceAssets)),
}
for _, event := range report.Events {
result.Events = append(result.Events, ExecutionReportEvent{
Step: event.Step,
Type: event.Type,
Message: event.Message,
OccurredAt: event.OccurredAt,
ReceivedAfterExecutionExpiry: event.ReceivedAfterExecutionExpiry,
})
}
for _, evidence := range report.EvidenceAssets {
result.Evidence = append(result.Evidence, ExecutionReportEvidence{
ID: evidence.ID,
ContentURL: "/api/v1/tasks/" + evidence.TaskID + "/evidence/" + evidence.ID + "/content",
SHA256: evidence.SHA256,
SizeBytes: evidence.SizeBytes,
CreatedAt: evidence.CreatedAt,
ReceivedAfterExecutionExpiry: evidence.ReceivedAfterExecutionExpiry,
})
}
if batch := report.CandidateBatch; batch != nil {
result.Mode = batch.ExecutionMode
result.SearchQuery = batch.SearchQuery
result.Provenance = prettyAuditJSON(batch.ProvenanceJSON)
result.Candidates = prettyAuditJSON(&batch.CandidatesJSON)
result.Recommendation = prettyAuditJSON(batch.RecommendationJSON)
}
if outcome := report.Outcome; outcome != nil {
result.Outcome = &ExecutionReportOutcome{
ResultType: outcome.ResultType,
Outcome: stringValue(outcome.Outcome),
OperatorReason: stringValue(outcome.OperatorReason),
ErrorCode: stringValue(outcome.ErrorCode),
ErrorMessage: stringValue(outcome.ErrorMessage),
ErrorStep: stringValue(outcome.ErrorStep),
OrderSubmitted: outcome.OrderSubmitted,
ReceivedAt: outcome.ReceivedAt,
ReceivedAfterExecutionExpiry: outcome.ReceivedAfterExecutionExpiry,
}
}
return result
}
func prettyAuditJSON(value *string) string {
if value == nil || strings.TrimSpace(*value) == "" {
return ""
}
var decoded any
if err := json.Unmarshal([]byte(*value), &decoded); err != nil {
return ""
}
formatted, err := json.MarshalIndent(decoded, "", " ")
if err != nil {
return ""
}
return string(formatted)
}
func stringValue(value *string) string {
if value == nil {
return ""
}
return *value
}
func mapUsecaseError(err error) error {
var typed *usecase.Error
if !errors.As(err, &typed) {
@@ -0,0 +1,58 @@
package usecase
import (
"context"
"time"
"cmroubao/backend-api/internal/domain"
)
type ExecutionResultAuthorization struct {
UserID string
DeviceID string
TaskID string
ExecutionID string
ClaimGeneration int64
ClaimTokenHash string
}
type ExecutionResultWrite struct {
ExecutionResultAuthorization
Operation string
IdempotencyKey string
RequestHash string
Now time.Time
}
type ExecutionResultRepository interface {
AppendExecutionEvents(
context.Context,
ExecutionResultWrite,
[]domain.ExecutionEvent,
) (bool, error)
CreateExecutionEvidence(
context.Context,
ExecutionResultWrite,
domain.ExecutionEvidenceAsset,
) (domain.ExecutionEvidenceAsset, bool, error)
StoreExecutionCandidates(
context.Context,
ExecutionResultWrite,
domain.ExecutionCandidateBatch,
) (bool, error)
CompleteExecution(
context.Context,
ExecutionResultWrite,
domain.ExecutionOutcome,
) (domain.PurchaseTask, bool, error)
FailExecution(
context.Context,
ExecutionResultWrite,
domain.ExecutionOutcome,
) (domain.PurchaseTask, bool, error)
GetExecutionEvidence(
context.Context,
string,
string,
) (domain.ExecutionEvidenceAsset, error)
}
@@ -0,0 +1,761 @@
package usecase
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"io"
"net/url"
"regexp"
"strconv"
"strings"
"time"
"unicode/utf8"
"cmroubao/backend-api/internal/domain"
)
const (
executionResultEventsOperation = "EVENTS"
executionResultEvidenceOperation = "EVIDENCE"
executionResultCandidatesOperation = "CANDIDATES"
executionResultCompleteOperation = "COMPLETE"
executionResultFailOperation = "FAIL"
manualFirstMode = "MANUAL_FIRST"
aiAssistedMode = "AI_ASSISTED"
)
type ExecutionResultService struct {
repository ExecutionResultRepository
store ReferenceImageStore
clock Clock
ids IDGenerator
}
type ExecutionResultIdentity struct {
UserID string
DeviceID string
TaskID string
ExecutionID string
ClaimGeneration int64
ClaimToken string
IdempotencyKey string
}
type ClientExecutionEvent struct {
ID string `json:"event_id"`
Step string `json:"step"`
Type string `json:"type"`
Message string `json:"message"`
OccurredAt string `json:"occurred_at"`
}
type AppendExecutionEventsCommand struct {
Identity ExecutionResultIdentity
Events []ClientExecutionEvent
}
type UploadExecutionEvidenceCommand struct {
Identity ExecutionResultIdentity
DeclaredMediaType string
Content io.Reader
}
type ExecutionProvenance struct {
ProviderID string `json:"provider_id"`
Model string `json:"model"`
PromptVersion string `json:"prompt_version"`
SchemaVersion int `json:"schema_version"`
}
type CandidateEvaluation struct {
Decision string `json:"decision"`
Score float64 `json:"score"`
Matched []string `json:"matched"`
MissingOrUncertain []string `json:"missing_or_uncertain"`
RejectionReasons []string `json:"rejection_reasons"`
Confidence float64 `json:"confidence"`
}
type ExecutionCandidate struct {
Ordinal int `json:"ordinal"`
Title string `json:"title"`
SKUText string `json:"sku_text"`
Price string `json:"price"`
ProductURL string `json:"product_url"`
ImageURL string `json:"image_url"`
EvidenceAssetIDs []string `json:"evidence_asset_ids"`
Evaluation *CandidateEvaluation `json:"evaluation"`
}
type CandidateRecommendation struct {
CandidateOrdinal int `json:"candidate_ordinal"`
PolicyVersion string `json:"policy_version"`
Reasons []string `json:"reasons"`
}
type StoreExecutionCandidatesCommand struct {
Identity ExecutionResultIdentity
TaskContentSHA256 string
ExecutionMode string
SearchQuery string
Provenance *ExecutionProvenance
Candidates []ExecutionCandidate
Recommendation *CandidateRecommendation
}
type CompleteExecutionCommand struct {
Identity ExecutionResultIdentity
TaskContentSHA256 string
ExecutionMode string
Outcome string
OperatorReason string
Candidate *ExecutionCandidate
OrderSubmitted bool
}
type FailExecutionCommand struct {
Identity ExecutionResultIdentity
ErrorCode string
ErrorMessage string
ErrorStep string
Retryable bool
EvidenceAssetIDs []string
}
type UploadExecutionEvidenceResult struct {
Evidence domain.ExecutionEvidenceAsset
Replayed bool
}
type ExecutionEvidenceContent struct {
Evidence domain.ExecutionEvidenceAsset
Content io.ReadCloser
}
func NewExecutionResultService(
repository ExecutionResultRepository,
store ReferenceImageStore,
clock Clock,
ids IDGenerator,
) (*ExecutionResultService, error) {
if repository == nil || store == nil || clock == nil || ids == nil {
return nil, errors.New("execution result service dependencies are required")
}
return &ExecutionResultService{
repository: repository,
store: store,
clock: clock,
ids: ids,
}, nil
}
func (service *ExecutionResultService) AppendEvents(
ctx context.Context,
command AppendExecutionEventsCommand,
) (bool, error) {
identity, err := normalizeExecutionIdentity(command.Identity)
if err != nil {
return false, err
}
if len(command.Events) < 1 || len(command.Events) > 32 {
return false, executionResultInvalid("events", "must contain 1 to 32 events")
}
now := service.clock.Now().UTC()
events := make([]domain.ExecutionEvent, 0, len(command.Events))
seen := make(map[string]struct{}, len(command.Events))
for _, event := range command.Events {
parsed, eventErr := normalizeClientEvent(event, identity.TaskID, identity.ExecutionID, now)
if eventErr != nil {
return false, eventErr
}
if _, found := seen[parsed.ID]; found {
return false, executionResultInvalid("events", "event_id must be unique")
}
seen[parsed.ID] = struct{}{}
events = append(events, parsed)
}
requestHash, err := executionResultHash(command.Events)
if err != nil {
return false, internalExecutionResultFailure(err)
}
replayed, err := service.repository.AppendExecutionEvents(
ctx,
service.write(identity, executionResultEventsOperation, requestHash, now),
events,
)
if err != nil {
return false, wrapLifecycleRepositoryError(err)
}
return replayed, nil
}
func (service *ExecutionResultService) UploadEvidence(
ctx context.Context,
command UploadExecutionEvidenceCommand,
) (UploadExecutionEvidenceResult, error) {
identity, err := normalizeExecutionIdentity(command.Identity)
if err != nil {
return UploadExecutionEvidenceResult{}, err
}
if command.Content == nil {
return UploadExecutionEvidenceResult{}, executionResultInvalid("file", "required")
}
evidenceID, err := service.ids.NewID()
if err != nil {
return UploadExecutionEvidenceResult{}, internalExecutionResultFailure(err)
}
normalized, err := service.store.Put(
ctx,
evidenceID,
command.DeclaredMediaType,
command.Content,
)
if err != nil {
return UploadExecutionEvidenceResult{}, mapImageStoreError(err)
}
cleanup := func() {
_ = service.store.Delete(context.Background(), normalized.StorageKey)
}
now := service.clock.Now().UTC()
requestHash, err := executionResultHash(struct {
InputSHA256 string `json:"input_sha256"`
}{InputSHA256: normalized.InputSHA256})
if err != nil {
cleanup()
return UploadExecutionEvidenceResult{}, internalExecutionResultFailure(err)
}
evidence, replayed, err := service.repository.CreateExecutionEvidence(
ctx,
service.write(identity, executionResultEvidenceOperation, requestHash, now),
domain.ExecutionEvidenceAsset{
ID: evidenceID,
TaskID: identity.TaskID,
ExecutionID: identity.ExecutionID,
MediaType: normalized.MediaType,
SizeBytes: normalized.SizeBytes,
SHA256: normalized.SHA256,
StorageKey: normalized.StorageKey,
CreatedAt: now,
},
)
if err != nil {
cleanup()
return UploadExecutionEvidenceResult{}, wrapLifecycleRepositoryError(err)
}
if replayed {
cleanup()
}
return UploadExecutionEvidenceResult{Evidence: evidence, Replayed: replayed}, nil
}
func (service *ExecutionResultService) StoreCandidates(
ctx context.Context,
command StoreExecutionCandidatesCommand,
) (bool, error) {
identity, err := normalizeExecutionIdentity(command.Identity)
if err != nil {
return false, err
}
if err := validateCandidateCommand(command); err != nil {
return false, err
}
provenanceJSON, candidatesJSON, recommendationJSON, err := candidateJSON(command)
if err != nil {
return false, internalExecutionResultFailure(err)
}
now := service.clock.Now().UTC()
requestHash, err := executionResultHash(command)
if err != nil {
return false, internalExecutionResultFailure(err)
}
replayed, err := service.repository.StoreExecutionCandidates(
ctx,
service.write(identity, executionResultCandidatesOperation, requestHash, now),
domain.ExecutionCandidateBatch{
TaskID: identity.TaskID,
ExecutionID: identity.ExecutionID,
TaskContentSHA256: command.TaskContentSHA256,
ExecutionMode: command.ExecutionMode,
SearchQuery: strings.TrimSpace(command.SearchQuery),
ProvenanceJSON: provenanceJSON,
CandidatesJSON: candidatesJSON,
RecommendationJSON: recommendationJSON,
ReceivedAt: now,
},
)
if err != nil {
return false, wrapLifecycleRepositoryError(err)
}
return replayed, nil
}
func (service *ExecutionResultService) Complete(
ctx context.Context,
command CompleteExecutionCommand,
) (domain.PurchaseTask, bool, error) {
identity, err := normalizeExecutionIdentity(command.Identity)
if err != nil {
return domain.PurchaseTask{}, false, err
}
if err := validateCompleteCommand(command); err != nil {
return domain.PurchaseTask{}, false, err
}
selectedJSON, err := optionalJSON(command.Candidate)
if err != nil {
return domain.PurchaseTask{}, false, internalExecutionResultFailure(err)
}
now := service.clock.Now().UTC()
requestHash, err := executionResultHash(command)
if err != nil {
return domain.PurchaseTask{}, false, internalExecutionResultFailure(err)
}
mode := command.ExecutionMode
taskHash := command.TaskContentSHA256
outcome := command.Outcome
reason := strings.TrimSpace(command.OperatorReason)
task, replayed, err := service.repository.CompleteExecution(
ctx,
service.write(identity, executionResultCompleteOperation, requestHash, now),
domain.ExecutionOutcome{
TaskID: identity.TaskID,
ExecutionID: identity.ExecutionID,
ResultType: "COMPLETE",
ExecutionMode: &mode,
TaskContentSHA256: &taskHash,
Outcome: &outcome,
OperatorReason: &reason,
SelectedCandidateJSON: selectedJSON,
OrderSubmitted: false,
ReceivedAt: now,
},
)
if err != nil {
return domain.PurchaseTask{}, false, wrapLifecycleRepositoryError(err)
}
return task, replayed, nil
}
func (service *ExecutionResultService) Fail(
ctx context.Context,
command FailExecutionCommand,
) (domain.PurchaseTask, bool, error) {
identity, err := normalizeExecutionIdentity(command.Identity)
if err != nil {
return domain.PurchaseTask{}, false, err
}
if err := validateFailCommand(command); err != nil {
return domain.PurchaseTask{}, false, err
}
evidenceJSON, err := optionalJSON(command.EvidenceAssetIDs)
if err != nil {
return domain.PurchaseTask{}, false, internalExecutionResultFailure(err)
}
now := service.clock.Now().UTC()
requestHash, err := executionResultHash(command)
if err != nil {
return domain.PurchaseTask{}, false, internalExecutionResultFailure(err)
}
code := strings.TrimSpace(command.ErrorCode)
message := strings.TrimSpace(command.ErrorMessage)
step := strings.TrimSpace(command.ErrorStep)
retryable := command.Retryable
task, replayed, err := service.repository.FailExecution(
ctx,
service.write(identity, executionResultFailOperation, requestHash, now),
domain.ExecutionOutcome{
TaskID: identity.TaskID,
ExecutionID: identity.ExecutionID,
ResultType: "FAIL",
EvidenceAssetIDsJSON: evidenceJSON,
ErrorCode: &code,
ErrorMessage: &message,
ErrorStep: &step,
Retryable: &retryable,
OrderSubmitted: false,
ReceivedAt: now,
},
)
if err != nil {
return domain.PurchaseTask{}, false, wrapLifecycleRepositoryError(err)
}
return task, replayed, nil
}
func (service *ExecutionResultService) OpenEvidence(
ctx context.Context,
taskID string,
evidenceID string,
) (ExecutionEvidenceContent, error) {
evidence, err := service.repository.GetExecutionEvidence(ctx, taskID, evidenceID)
if err != nil {
return ExecutionEvidenceContent{}, wrapLifecycleRepositoryError(err)
}
content, err := service.store.Open(ctx, evidence.StorageKey)
if err != nil {
return ExecutionEvidenceContent{}, mapImageStoreError(err)
}
return ExecutionEvidenceContent{Evidence: evidence, Content: content}, nil
}
func (service *ExecutionResultService) write(
identity ExecutionResultIdentity,
operation string,
requestHash string,
now time.Time,
) ExecutionResultWrite {
return ExecutionResultWrite{
ExecutionResultAuthorization: ExecutionResultAuthorization{
UserID: identity.UserID,
DeviceID: identity.DeviceID,
TaskID: identity.TaskID,
ExecutionID: identity.ExecutionID,
ClaimGeneration: identity.ClaimGeneration,
ClaimTokenHash: hashSecret(identity.ClaimToken),
},
Operation: operation,
IdempotencyKey: identity.IdempotencyKey,
RequestHash: requestHash,
Now: now,
}
}
func normalizeExecutionIdentity(
identity ExecutionResultIdentity,
) (ExecutionResultIdentity, error) {
identity.UserID = strings.TrimSpace(identity.UserID)
identity.DeviceID = strings.TrimSpace(identity.DeviceID)
identity.TaskID = strings.TrimSpace(identity.TaskID)
identity.ExecutionID = strings.TrimSpace(identity.ExecutionID)
identity.IdempotencyKey = strings.TrimSpace(identity.IdempotencyKey)
fields := map[string]string{}
if !isUUID(identity.UserID) {
fields["user_id"] = "must be a UUID"
}
if !isUUID(identity.DeviceID) {
fields["device_id"] = "must be a UUID"
}
if !isUUID(identity.TaskID) {
fields["task_id"] = "must be a UUID"
}
if !isUUID(identity.ExecutionID) {
fields["execution_id"] = "must be a UUID"
}
if identity.ClaimGeneration < 1 {
fields["claim_generation"] = "must be a positive integer"
}
if strings.TrimSpace(identity.ClaimToken) == "" {
fields["claim_token"] = "required"
}
if len([]byte(identity.IdempotencyKey)) == 0 ||
len([]byte(identity.IdempotencyKey)) > maxIdempotencyKeyBytes ||
!isPrintableASCII(identity.IdempotencyKey) {
fields["idempotency_key"] = "must be printable ASCII up to 128 bytes"
}
if len(fields) > 0 {
return ExecutionResultIdentity{}, invalidError(
"EXECUTION_RESULT_INVALID",
"execution result request is invalid",
fields,
)
}
return identity, nil
}
func normalizeClientEvent(
event ClientExecutionEvent,
taskID string,
executionID string,
now time.Time,
) (domain.ExecutionEvent, error) {
if !isUUID(strings.TrimSpace(event.ID)) {
return domain.ExecutionEvent{}, executionResultInvalid("event_id", "must be a UUID")
}
step := strings.TrimSpace(event.Step)
typeValue := strings.TrimSpace(event.Type)
message := strings.TrimSpace(event.Message)
if !validLifecycleStep(step) {
return domain.ExecutionEvent{}, executionResultInvalid("step", "must be uppercase ASCII")
}
if !validLifecycleStep(typeValue) {
return domain.ExecutionEvent{}, executionResultInvalid("type", "must be uppercase ASCII")
}
if !validAuditText(message, 1000) {
return domain.ExecutionEvent{}, executionResultInvalid("message", "is invalid or contains sensitive material")
}
occurredAt, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(event.OccurredAt))
if err != nil || occurredAt.After(now.Add(5*time.Minute)) ||
occurredAt.Before(now.Add(-7*24*time.Hour)) {
return domain.ExecutionEvent{}, executionResultInvalid("occurred_at", "must be a recent RFC3339 timestamp")
}
return domain.ExecutionEvent{
ID: strings.TrimSpace(event.ID),
TaskID: taskID,
ExecutionID: executionID,
Step: step,
Type: typeValue,
Message: message,
OccurredAt: occurredAt.UTC(),
ReceivedAt: now,
}, nil
}
func validateCandidateCommand(command StoreExecutionCandidatesCommand) error {
if !sha256Pattern.MatchString(command.TaskContentSHA256) {
return executionResultInvalid("task_content_sha256", "must be lowercase SHA-256")
}
if !validExecutionMode(command.ExecutionMode) {
return executionResultInvalid("execution_mode", "must be MANUAL_FIRST or AI_ASSISTED")
}
if !validAuditText(command.SearchQuery, 512) {
return executionResultInvalid("search_query", "is invalid")
}
if len(command.Candidates) > 5 {
return executionResultInvalid("candidates", "must contain at most 5 candidates")
}
if command.ExecutionMode == aiAssistedMode {
if !validProvenance(command.Provenance) {
return executionResultInvalid("provenance", "is required for AI_ASSISTED")
}
} else if command.Provenance != nil {
return executionResultInvalid("provenance", "must be omitted for MANUAL_FIRST")
}
for index, candidate := range command.Candidates {
if candidate.Ordinal != index+1 || !validCandidate(candidate, command.ExecutionMode) {
return executionResultInvalid("candidates", "must be continuous, bounded observations")
}
}
if command.Recommendation != nil {
recommendation := command.Recommendation
if recommendation.CandidateOrdinal < 1 ||
recommendation.CandidateOrdinal > len(command.Candidates) ||
!validAuditText(recommendation.PolicyVersion, 128) ||
!validStringList(recommendation.Reasons, 8, 160) {
return executionResultInvalid("recommendation", "is invalid")
}
}
return nil
}
func validateCompleteCommand(command CompleteExecutionCommand) error {
if !sha256Pattern.MatchString(command.TaskContentSHA256) ||
!validExecutionMode(command.ExecutionMode) ||
!validOutcome(command.Outcome) ||
!validAuditText(command.OperatorReason, 1000) ||
command.OrderSubmitted {
return executionResultInvalid("complete", "contains an invalid outcome or order state")
}
if command.Outcome == "CANDIDATE_ACCEPTED" {
if command.Candidate == nil || !validCandidate(*command.Candidate, command.ExecutionMode) {
return executionResultInvalid("candidate", "is required for CANDIDATE_ACCEPTED")
}
} else if command.Candidate != nil {
return executionResultInvalid("candidate", "must be omitted for this outcome")
}
return nil
}
func validateFailCommand(command FailExecutionCommand) error {
if !validLifecycleStep(strings.TrimSpace(command.ErrorCode)) ||
!validLifecycleStep(strings.TrimSpace(command.ErrorStep)) ||
!validAuditText(command.ErrorMessage, 1000) {
return executionResultInvalid("error", "is invalid")
}
if len(command.EvidenceAssetIDs) > 5 {
return executionResultInvalid("evidence_asset_ids", "must contain at most 5 items")
}
for _, id := range command.EvidenceAssetIDs {
if !isUUID(strings.TrimSpace(id)) {
return executionResultInvalid("evidence_asset_ids", "must contain UUIDs")
}
}
return nil
}
func validCandidate(candidate ExecutionCandidate, mode string) bool {
if candidate.Ordinal < 1 ||
!validAuditText(candidate.Title, 512) ||
!validOptionalAuditText(candidate.SKUText, 512) ||
!validOptionalAuditText(candidate.Price, 64) ||
!validObservationURL(candidate.ProductURL) ||
!validObservationURL(candidate.ImageURL) ||
len(candidate.EvidenceAssetIDs) > 5 {
return false
}
for _, id := range candidate.EvidenceAssetIDs {
if !isUUID(strings.TrimSpace(id)) {
return false
}
}
if mode == manualFirstMode {
return candidate.Evaluation == nil
}
return validEvaluation(candidate.Evaluation)
}
func validEvaluation(value *CandidateEvaluation) bool {
if value == nil || value.Score < 0 || value.Score > 1 ||
value.Confidence < 0 || value.Confidence > 1 {
return false
}
if value.Decision != "REVIEW" && value.Decision != "REJECT" &&
value.Decision != "MANUAL_REQUIRED" {
return false
}
return validStringList(value.Matched, 12, 160) &&
validStringList(value.MissingOrUncertain, 12, 160) &&
validStringList(value.RejectionReasons, 12, 160)
}
func validProvenance(value *ExecutionProvenance) bool {
return value != nil &&
validAuditText(value.ProviderID, 64) &&
validAuditText(value.Model, 256) &&
validAuditText(value.PromptVersion, 128) &&
value.SchemaVersion >= 1 && value.SchemaVersion <= 32
}
func validExecutionMode(value string) bool {
return value == manualFirstMode || value == aiAssistedMode
}
func validOutcome(value string) bool {
switch value {
case "CANDIDATE_ACCEPTED", "CANDIDATE_REJECTED", "NO_MATCH", "MANUAL_REQUIRED":
return true
default:
return false
}
}
func validObservationURL(value string) bool {
if strings.TrimSpace(value) == "" {
return true
}
parsed, err := url.Parse(strings.TrimSpace(value))
return err == nil &&
(parsed.Scheme == "https" || parsed.Scheme == "http") &&
parsed.Host != "" &&
parsed.User == nil &&
len(value) <= 2048
}
func validAuditText(value string, maximum int) bool {
value = strings.TrimSpace(value)
if value == "" || !utf8.ValidString(value) || len([]byte(value)) > maximum {
return false
}
lower := strings.ToLower(value)
return !strings.Contains(lower, "authorization:") &&
!strings.Contains(lower, "api_key") &&
!strings.Contains(lower, "bearer ")
}
func validOptionalAuditText(value string, maximum int) bool {
return strings.TrimSpace(value) == "" || validAuditText(value, maximum)
}
func validStringList(values []string, maximumItems int, maximumText int) bool {
if len(values) > maximumItems {
return false
}
seen := map[string]struct{}{}
for _, value := range values {
if !validAuditText(value, maximumText) {
return false
}
key := strings.ToLower(strings.TrimSpace(value))
if _, duplicate := seen[key]; duplicate {
return false
}
seen[key] = struct{}{}
}
return true
}
func candidateJSON(command StoreExecutionCandidatesCommand) (*string, string, *string, error) {
var provenance *string
if command.Provenance != nil {
encoded, err := json.Marshal(command.Provenance)
if err != nil {
return nil, "", nil, err
}
value := string(encoded)
provenance = &value
}
candidates, err := json.Marshal(command.Candidates)
if err != nil {
return nil, "", nil, err
}
var recommendation *string
if command.Recommendation != nil {
encoded, err := json.Marshal(command.Recommendation)
if err != nil {
return nil, "", nil, err
}
value := string(encoded)
recommendation = &value
}
return provenance, string(candidates), recommendation, nil
}
func optionalJSON(value any) (*string, error) {
if value == nil {
return nil, nil
}
encoded, err := json.Marshal(value)
if err != nil {
return nil, err
}
result := string(encoded)
return &result, nil
}
func executionResultHash(value any) (string, error) {
encoded, err := json.Marshal(value)
if err != nil {
return "", err
}
sum := sha256.Sum256(encoded)
return hex.EncodeToString(sum[:]), nil
}
func TaskContentSHA256(task domain.PurchaseTask) string {
budget := ""
if task.MaxBudgetCents != nil {
budget = strconv.FormatInt(*task.MaxBudgetCents, 10)
}
payload := strings.Join([]string{
task.Title,
task.Description,
task.SKU,
task.ImageAssetID,
strconv.Itoa(task.Quantity),
budget,
task.Currency,
}, "\x00")
sum := sha256.Sum256([]byte(payload))
return hex.EncodeToString(sum[:])
}
func executionResultInvalid(field string, message string) error {
return invalidError(
"EXECUTION_RESULT_INVALID",
"execution result request is invalid",
map[string]string{field: message},
)
}
func internalExecutionResultFailure(err error) error {
return newError(
ErrorKindInternal,
"INTERNAL_ERROR",
"internal server error",
err,
)
}
var sha256Pattern = regexp.MustCompile("^[0-9a-f]{64}$")
@@ -0,0 +1,204 @@
-- +goose Up
CREATE TABLE execution_events (
id TEXT PRIMARY KEY NOT NULL
CHECK (length(id) = 36),
task_id TEXT NOT NULL
REFERENCES purchase_tasks(id) ON UPDATE RESTRICT ON DELETE CASCADE,
execution_id TEXT NOT NULL
REFERENCES task_executions(id) ON UPDATE RESTRICT ON DELETE CASCADE,
step TEXT NOT NULL
CHECK (
length(trim(step)) > 0
AND length(CAST(step AS BLOB)) <= 64
),
event_type TEXT NOT NULL
CHECK (
length(trim(event_type)) > 0
AND length(CAST(event_type AS BLOB)) <= 64
),
message TEXT NOT NULL
CHECK (length(CAST(message AS BLOB)) <= 1000),
occurred_at TEXT NOT NULL,
received_at TEXT NOT NULL,
received_after_execution_expiry INTEGER NOT NULL DEFAULT 0
CHECK (received_after_execution_expiry IN (0, 1))
);
CREATE INDEX execution_events_execution_occurred_idx
ON execution_events (execution_id, occurred_at ASC, id ASC);
CREATE TABLE execution_evidence_assets (
id TEXT PRIMARY KEY NOT NULL
CHECK (length(id) = 36),
task_id TEXT NOT NULL
REFERENCES purchase_tasks(id) ON UPDATE RESTRICT ON DELETE CASCADE,
execution_id TEXT NOT NULL
REFERENCES task_executions(id) ON UPDATE RESTRICT ON DELETE CASCADE,
media_type TEXT NOT NULL
CHECK (media_type = 'image/jpeg'),
size_bytes INTEGER NOT NULL
CHECK (size_bytes > 0),
sha256 TEXT NOT NULL
CHECK (
length(sha256) = 64
AND sha256 NOT GLOB '*[^0-9a-f]*'
),
storage_key TEXT NOT NULL UNIQUE
CHECK (
length(storage_key) > 0
AND substr(storage_key, 1, 1) <> '/'
AND instr(storage_key, '\') = 0
AND instr(storage_key, '..') = 0
),
created_at TEXT NOT NULL,
received_after_execution_expiry INTEGER NOT NULL DEFAULT 0
CHECK (received_after_execution_expiry IN (0, 1))
);
CREATE INDEX execution_evidence_execution_created_idx
ON execution_evidence_assets (execution_id, created_at ASC, id ASC);
CREATE TABLE execution_candidate_batches (
execution_id TEXT PRIMARY KEY NOT NULL
REFERENCES task_executions(id) ON UPDATE RESTRICT ON DELETE CASCADE,
task_id TEXT NOT NULL
REFERENCES purchase_tasks(id) ON UPDATE RESTRICT ON DELETE CASCADE,
task_content_sha256 TEXT NOT NULL
CHECK (
length(task_content_sha256) = 64
AND task_content_sha256 NOT GLOB '*[^0-9a-f]*'
),
execution_mode TEXT NOT NULL
CHECK (execution_mode IN ('MANUAL_FIRST', 'AI_ASSISTED')),
search_query TEXT NOT NULL
CHECK (
length(trim(search_query)) > 0
AND length(CAST(search_query AS BLOB)) <= 512
),
provenance_json TEXT,
candidates_json TEXT NOT NULL
CHECK (length(CAST(candidates_json AS BLOB)) <= 65536),
recommendation_json TEXT,
received_at TEXT NOT NULL,
received_after_execution_expiry INTEGER NOT NULL DEFAULT 0
CHECK (received_after_execution_expiry IN (0, 1))
);
CREATE TABLE execution_outcomes (
execution_id TEXT PRIMARY KEY NOT NULL
REFERENCES task_executions(id) ON UPDATE RESTRICT ON DELETE CASCADE,
task_id TEXT NOT NULL
REFERENCES purchase_tasks(id) ON UPDATE RESTRICT ON DELETE CASCADE,
result_type TEXT NOT NULL
CHECK (result_type IN ('COMPLETE', 'FAIL')),
execution_mode TEXT
CHECK (execution_mode IS NULL OR execution_mode IN ('MANUAL_FIRST', 'AI_ASSISTED')),
task_content_sha256 TEXT
CHECK (
task_content_sha256 IS NULL
OR (
length(task_content_sha256) = 64
AND task_content_sha256 NOT GLOB '*[^0-9a-f]*'
)
),
outcome TEXT
CHECK (
outcome IS NULL
OR outcome IN (
'CANDIDATE_ACCEPTED',
'CANDIDATE_REJECTED',
'NO_MATCH',
'MANUAL_REQUIRED'
)
),
operator_reason TEXT
CHECK (
operator_reason IS NULL
OR length(CAST(operator_reason AS BLOB)) <= 1000
),
selected_candidate_json TEXT,
evidence_asset_ids_json TEXT,
error_code TEXT
CHECK (error_code IS NULL OR length(CAST(error_code AS BLOB)) <= 64),
error_message TEXT
CHECK (error_message IS NULL OR length(CAST(error_message AS BLOB)) <= 1000),
error_step TEXT
CHECK (error_step IS NULL OR length(CAST(error_step AS BLOB)) <= 64),
retryable INTEGER
CHECK (retryable IS NULL OR retryable IN (0, 1)),
order_submitted INTEGER NOT NULL DEFAULT 0
CHECK (order_submitted = 0),
received_at TEXT NOT NULL,
received_after_execution_expiry INTEGER NOT NULL DEFAULT 0
CHECK (received_after_execution_expiry IN (0, 1)),
CHECK (
(result_type = 'COMPLETE'
AND execution_mode IS NOT NULL
AND task_content_sha256 IS NOT NULL
AND outcome IS NOT NULL
AND operator_reason IS NOT NULL
AND error_code IS NULL)
OR
(result_type = 'FAIL'
AND error_code IS NOT NULL
AND error_message IS NOT NULL
AND error_step IS NOT NULL)
)
);
CREATE TABLE execution_result_requests (
user_id TEXT NOT NULL
REFERENCES users(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
device_id TEXT NOT NULL
REFERENCES devices(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
operation TEXT NOT NULL
CHECK (operation IN ('EVENTS', 'EVIDENCE', 'CANDIDATES', 'COMPLETE', 'FAIL')),
idempotency_key TEXT NOT NULL
CHECK (
length(trim(idempotency_key)) > 0
AND length(CAST(idempotency_key AS BLOB)) <= 128
),
request_sha256 TEXT NOT NULL
CHECK (
length(request_sha256) = 64
AND request_sha256 NOT GLOB '*[^0-9a-f]*'
),
claim_token_sha256 TEXT NOT NULL
CHECK (
length(claim_token_sha256) = 64
AND claim_token_sha256 NOT GLOB '*[^0-9a-f]*'
),
task_id TEXT NOT NULL
REFERENCES purchase_tasks(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
execution_id TEXT NOT NULL
REFERENCES task_executions(id) ON UPDATE RESTRICT ON DELETE RESTRICT,
resource_id TEXT,
created_at TEXT NOT NULL,
PRIMARY KEY (user_id, device_id, operation, idempotency_key)
);
-- +goose Down
CREATE TEMP TABLE execution_results_v5_down_guard (
allowed INTEGER NOT NULL
CHECK (allowed = 1)
);
INSERT INTO execution_results_v5_down_guard (allowed)
SELECT CASE
WHEN EXISTS (SELECT 1 FROM execution_events)
OR EXISTS (SELECT 1 FROM execution_evidence_assets)
OR EXISTS (SELECT 1 FROM execution_candidate_batches)
OR EXISTS (SELECT 1 FROM execution_outcomes)
OR EXISTS (SELECT 1 FROM execution_result_requests)
THEN 0
ELSE 1
END;
DROP TABLE execution_results_v5_down_guard;
DROP TABLE execution_result_requests;
DROP TABLE execution_outcomes;
DROP TABLE execution_candidate_batches;
DROP INDEX execution_evidence_execution_created_idx;
DROP TABLE execution_evidence_assets;
DROP INDEX execution_events_execution_occurred_idx;
DROP TABLE execution_events;