A WorkflowTask is human work attached to an Execution. Eligibility comes from direct assignee or materialized candidate rows. Business-object authorization remains the business module’s responsibility; todo visibility never bypasses that boundary.
1. Task creation
Default resolution retains explicit users and expands directory rules through IParticipantProvider. A custom resolver runs first and explicit users are merged.
2. Todo query
Platform QueryWorkflowTodo takes actor and tenant from CurrentUser, clamps PageSize to 1..200, and always sets ApplyDataScope=true.
var query = new TodoQuery{ // User and tenant come from CurrentUser, never client substitution. UserId = currentUserId, TenantId = currentTenantId, BusinessType = "MatterIntake", OfficeId = officeId, Keyword = keyword, ApplyDataScope = true, Page = page <= 0 ? 1 : page, // Clamp at the boundary even though the fallback can still full-scan. PageSize = pageSize is <= 0 or > 200 ? 20 : pageSize};
TodoPage pageResult = await taskService.QueryTodoAsync(query, cancellationToken);Without advanced filters, IWorkflowQueryStore performs assigned/candidate union, count, and paging in the database. ApplyDataScope, Keyword, OfficeId, or FlowStates triggers the compatibility path: both task sets are loaded with int.MaxValue, then instances, candidates, permissions, filters, and paging run in memory.
3. Data permission
For each task, TaskService invokes IFlowDataPermissionChecker(View). The platform implementation parses userId as long, creates workflow/task/{taskId}, and requires an explicit allow. Parse failure denies.
FlowDataScopeConfig from DSL is not passed to this checker. Business detail endpoints must authorize the business record separately.
4. Todo count
GetTodoCount uses IWorkflowCache and QueryStore when present. Its contract intentionally omits data scope, allowing over-inclusion so badges do not omit work. A count larger than the visible page can be expected.
Cache keys include tenant, user, and business type. Handoff and candidate changes must invalidate every affected user.
5. Done query
QueryStore provides database paging when available. The fallback loads a page and then loads all done tasks with int.MaxValue to calculate TotalCount. Production standalone hosts need a QueryStore.
Done means the user’s task is complete, not that the process is terminal.
6. Current task-detail defect
GetTaskAsync calls permission with an empty user ID. Platform FlowDataPermissionChecker cannot parse it and denies, so a normally composed detail read returns null.
bool allowed = await permissionChecker.HasPermissionAsync( string.Empty, // No caller parameter is available. taskId, task.TenantId, FlowDataScopeCheck.View, cancellationToken);Add trusted userId to the port or inject a trusted current-user abstraction, then add IDOR tests.
7. Mark read
MarkRead permits assignee or candidate. Repeated calls return success without advancing Version. The first update uses task.Version and maps DBConcurrencyException to Task.ConcurrencyConflict, then invalidates assignee and candidate caches.
// A repeat call succeeds without advancing Version again.Result result = await taskService.MarkReadAsync( taskId, currentUserId, cancellationToken);Provider-specific concurrency exception parity still needs testing.
8. Candidate refresh
RefreshCandidates loads task, instance, and participant rule, deletes old candidates, then expands new rows. Delete and rebuild are not explicitly transactional. Rule resolution reads versions[0] instead of the instance’s DefinitionId, so it can apply the wrong version.
RefreshCandidatesByRole runs tasks sequentially, ignores per-task Result, and returns success. It has no checkpoint, failure list, or rate control.
9. Return-model limits
TodoItem includes task, instance, business, definition, node, state, assignee, priority, and due fields. DefinitionName currently receives DefinitionKey rather than the actual definition display name.
10. Performance target
Compile DataScope, keyword, office, and flow-state filters into the QueryStore provider so tenant filter, assigned/candidate union, authorization scope, stable sort, count, and page happen together. Never page before filtering and never fetch all rows.
Benchmark 10K, 100K, and 1M pending tasks, role churn, hotspot users, and tenant concurrency. Record P50/P95, rows scanned, allocations, and cache hit rate.
11. Operational acceptance
- Confirm a direct assignee and a role candidate see the same task only once.
- Verify every query is tenant-scoped before candidate union and paging.
- Compare badge count with the visible list and document the intentional difference.
- Record database rows scanned for both the fast path and every advanced filter.
- Rotate a role while queries are active and reconcile candidate refresh failures.
- Transfer work and prove old and new assignee cache keys are both invalidated.
- Exercise detail reads with owner, candidate, unrelated user, and cross-tenant user.
- Race MarkRead, Complete, Transfer, and candidate refresh under both ORM adapters.
- Reject deployment when the tenant’s expected task volume exceeds the measured budget.
12. Source checks
rg -n "HasAdvancedFilters|int.MaxValue|GetTaskAsync|string.Empty" src/Framework/BitzOrcas.Workflow/BitzOrcas.Workflow.Engine/Services/TaskService.cs
rg -n "RefreshCandidates|ResolveParticipantRule|versions\[0\]" src/Framework/BitzOrcas.Workflow -g '*.cs'