Issue #3624430: Let a stand-in read the run behind the work they cover
A delegation widens which work a person reaches, and every door that asks "is this the viewer's?" reads it — except one.
WorkItemManager::getWorkedByAccountCondition() matched the acting account's own uid alone, while getAccountVisibilityCondition() widens the assignee it matches through AssignmentMatcher::getSelfAndDelegatorIds(), and isAssigneeOrStandIn() widens the holder the same way.
That condition is the whole of the default read access. Under ReadAccess::WORKERS, the shipped default, a run's read-only page admits whoever holds or completed one of its steps — so a stand-in handed a colleague's task could open it and complete it, and could not read the run it belongs to. The visible symptom is the pending-actions list, whose Process column links the run the listed work item is said to entitle the reader to: for a delegate the run's name rendered as plain text where the person they cover for saw a link.
The widest scope hid the gap rather than sharing it: ReadAccess::AUDIENCE ORs in getAccountVisibilityCondition(), which does read cover, so it admitted the stand-in. The default scope was the one with the hole.
What changed
getWorkedByAccountCondition()matchesassigneeandcompleteragainstgetSelfAndDelegatorIds(), the collaborator the class already injects and already uses at the two doors that do read cover. Both callers — the per-runisAccountParticipant()and the per-stepisAccountTaskParticipant()— are fixed by the one change, so the two questions cannot come to disagree.- Cover is read at the moment of asking, so what it widens lapses with the delegation. The verdicts built on this already declare that window through
AssignmentMatcher::calculateCacheableMetadata(), so nothing new has to be invalidated. - The prose that said holding is matched on the viewer's own id is swept:
ReadAccess,WorkItemManagerInterface::isAccountParticipant(),docs/delegation.mdanddocs/human-tasks.md.
Tests
DelegationVisibilityTest::testStandInReadsTheRunBehindTheCoveredWork() drives the real rule at the default scope and asserts all four sides: the holder reads the run, the stand-in reads it, somebody who neither works it nor covers anyone who does still does not, and the per-token door answers the same way. It fails against the unfixed code with "The stand-in covering the holder works it too. Failed asserting that false is true." and passes with the change.
testTheRunLapsesWithTheCover() is the guard on the other side: a lapsed cover reads no run of the delegator's, so the fix cannot be read as widening beyond the delegation window.
The second commit: declaring what the widened gate is read by
Reading the run through cover made a second verdict depend on a dated window, and one of its consumers declared only part of what it read.
The webform handler's render gate asks the resumer whether a step may be shown. For a branch that has moved on that answer is isAccountTaskParticipant(), which now reads cover; the gate's verdict is then reduced to isAllowed() and dropped, as InteractionAccessInterface says it is. The build's own declaration was the token list plus a hand-named user context, and the submit refusal that would have merged the rest returns earlier on that path, when it finds no live token. So nothing named the delegation window — and a user context partitions per account without ever expiring, so a page cached while somebody covered the holder went on being served to them after the cover lapsed.
Declared where the gateway contract says to declare it: at the consumer that renders from the verdict, by asking AssignmentMatcher::calculateCacheableMetadata() what a verdict about this account rests on. That replaces the hand-named subset rather than adding a second one, and it costs nothing when nobody covers anybody — DelegationWindow::secondsToNextChange() is Cache::PERMANENT unless the actor has a dated delegation, and NoDelegation answers with empty metadata and no query.
HandlerViewGateTest::testTheFinishedStepsBuildDeclaresWhatItsGateRead() asserts the finished step's build declares every tag and context the matcher reports for the actor, compared against the matcher rather than a list copied out of it. It fails against the unfixed code on config:orchestra_tenant_list and passes with it.
Audit
Three rounds. Round 1 found the above; round 2 audited that fix and found no behavioural or design defect; round 3 confirmed the remaining reviewer surfaces (the commit bodies, this description, the issue body, and the project's own 128 prose and invariant guards in tests/src/Unit). Two candidates were examined and dropped for having no reachable consequence: a redundant assignee IN (…) branch that the AUDIENCE scope now ORs with itself, and the slight over-declaration this adds to a bearer-handle build, whose gate reads nothing about the actor — narrowing that would mean type-sniffing the resumer.