Attribute execution-history rows to the applying user (#2978) - #3022
Conversation
ExecutionAuditRecorder stamped proposal.RequestedByUserId on every row, so a proposal authored by A and applied by B produced two rows for the same board change with contradictory actors: the handler's own mutation row named B and the generic execution-history row named A. RecordAsync now takes the authenticated applier, which AutomationExecutorService already holds as callerUserId, and falls back to the requester when no authenticated caller exists (internal lanes) or the id is empty. The requester is preserved in the row's provenance text so "who asked" stays readable next to "who applied".
A requests the assignment proposal, B approves and applies it: both the authoritative assignment-replace row and the generic execution-history row must name B, and the execution-history provenance must still name A.
The vocabulary invariant and the assignment audit section now say that ExecutionAuditRecorder rows name the applying user and carry the requester as provenance text.
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
Fresh-context independent review at exact head Verified by the reviewer from source (read-only):
Non-blocking notes, recorded (law 2c):
Author-run proof at this head: full |
What
ExecutionAuditRecorderstampedproposal.RequestedByUserIdon every row it wrote. When editor Bapplies a proposal authored by A, the handler's own mutation row names B (the authoritative
assignment-replaceaudit fromCardAssignmentService.StageReplaceAsyncalready got this right),but the generic execution-history row written immediately afterwards named A. One board change
therefore produced two rows with contradictory actors.
RecordAsyncnow takes the authenticated applying user.AutomationExecutorServicealready holdsit as
callerUserId— both HTTP entry points carry it (AutomationProposalsController.ExecuteProposaland
BatchProposalExecutionService) — so the fix is one extra argument at the existing call site.The requester is preserved in the row's provenance text, which now reads
Automation proposal <id> requested by user <requesterId>, sequence N: ....Why this shape
actorUserIdis optional and resolves to the requester whenit is absent. The internal
ExecuteProposalAsync(proposalId, idempotencyKey, ct)projection passescallerUserId: null, and that lane has no authenticated caller to name — the requester stays thebest available actor there. Same-user applies are byte-identical to before on the actor field.
Guid.Emptydegrades instead of throwing.AuditLog's constructor rejects an empty user id with aDomainException, andRecordAsyncruns inside the execution transaction, so an empty actor istreated as "no authenticated caller" rather than being allowed to roll a legitimate apply back.
AuditLoghas one actor field and one free-textChangesfield; no schema or EF migration is involved. Nothing in the backend or the frontend parsesthat string — only tests assert on it.
Behavior notes
archive-lifecycle/restore-lifecyclereceipts from#3001included. Those are the single audit row for a proposal-applied archive/restore, so if theynamed the requester the applier would be invisible entirely.
GET /api/audit/users/meis own-history only. After this change a cross-user apply appears in B'shistory rather than A's. That is the intended correction: A's request is recorded on the proposal
itself; B is who changed the board.
ProposalDto.RequestedByUserIdis already visible to anyone who can read the proposal, and theassignment audit rows already carry user GUIDs in
Changes.Scope and lane
Lane
alpha-product-trust, claimed on the issue before writing.AutomationExecutorService.csgets asingle hunk — the
_auditRecorder.RecordAsync(...)call inside the operation loop, non-adjacent tothe transaction bridge and notification ordering that
#2934may touch. Nothing inProposalOperationContractValidator(#2926),CardService,CardAssignmentService,ProposalAssignmentContractorOperationHandlerRegistrychanged.Lease amendment vs. the claim comment: the API regression landed in
backend/tests/Taskdeck.Api.Tests/CardAssignmentApiTests.cs(the assignment fixture already livesthere) instead of
AutomationProposalsApiTests.cs. That also keeps this PR off the file whoselifecycle cases
#2934is most likely to touch. No existing test method was edited in either file.Checks run
dotnet test backend/tests/Taskdeck.Application.Tests/... --filter "FullyQualifiedName~ExecutionAuditRecorder"dotnet test backend/tests/Taskdeck.Api.Tests/... --filter "FullyQualifiedName~CardAssignmentApiTests"dotnet test backend/tests/Taskdeck.Api.Tests/... --filter "FullyQualifiedName~ProposalAppliedByACollaboratorAttributesEveryAuditRowToTheApplier"dotnet test backend/Taskdeck.sln -c Release -m:1node scripts/check-docs-governance.mjsnode scripts/check-doc-links.mjsNew tests (6):
RecordAsync_ShouldAttributeActorToApplyingUser_AndKeepRequesterInProvenance,RecordAsync_ShouldAttributeLifecycleReceiptToApplyingUser(2 cases),RecordAsync_ShouldFallBackToRequester_WhenNoAuthenticatedApplier,RecordAsync_ShouldFallBackToRequester_WhenApplierIdIsEmpty,BuildAuditChanges_ShouldNameTheRequester, and the API regressionProposalAppliedByACollaboratorAttributesEveryAuditRowToTheApplier.The existing same-user cases were left untouched and still pass, including
CardLifecycleAudit_ProposalApplyAndDirectApi_EachPersistExactlyOneTypedReceipt, whoseUserId.Should().Be(user.UserId)assertion holds because requester and applier are the same person there.NOT verified
at the unit level only (
RecordAsync_ShouldAttributeLifecycleReceiptToApplyingUser); no API testapplies a lifecycle proposal as a different user.
docs/STATUS.mdanddocs/IMPLEMENTATION_MASTERPLAN.mdare untouched: the vocabulary autodocrecords post-merge STATUS sync as the batch coordinator's.
Closes #2978