Skip to content

Attribute execution-history rows to the applying user (#2978) - #3022

Merged
Chris0Jeky merged 3 commits into
mainfrom
issue-2978/execution-history-applier
Sep 11, 2026
Merged

Chris0Jeky merged 3 commits into
mainfrom
issue-2978/execution-history-applier

Conversation

@Chris0Jeky

Copy link
Copy Markdown
Owner

What

ExecutionAuditRecorder stamped proposal.RequestedByUserId on every row it wrote. When editor B
applies a proposal authored by A, the handler's own mutation row names B (the authoritative
assignment-replace audit from CardAssignmentService.StageReplaceAsync already got this right),
but the generic execution-history row written immediately afterwards named A. One board change
therefore produced two rows with contradictory actors.

RecordAsync now takes the authenticated applying user. AutomationExecutorService already holds
it as callerUserId — both HTTP entry points carry it (AutomationProposalsController.ExecuteProposal
and 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

  • Fallback, not a required parameter. actorUserId is optional and resolves to the requester when
    it is absent. The internal ExecuteProposalAsync(proposalId, idempotencyKey, ct) projection passes
    callerUserId: null, and that lane has no authenticated caller to name — the requester stays the
    best available actor there. Same-user applies are byte-identical to before on the actor field.
  • Guid.Empty degrades instead of throwing. AuditLog's constructor rejects an empty user id with a
    DomainException, and RecordAsync runs inside the execution transaction, so an empty actor is
    treated as "no authenticated caller" rather than being allowed to roll a legitimate apply back.
  • Requester in the provenance text, not a new column. AuditLog has one actor field and one free-text
    Changes field; no schema or EF migration is involved. Nothing in the backend or the frontend parses
    that string — only tests assert on it.

Behavior notes

  • This applies to every recorder row, the archive-lifecycle / restore-lifecycle receipts from
    #3001 included. Those are the single audit row for a proposal-applied archive/restore, so if they
    named the requester the applier would be invisible entirely.
  • GET /api/audit/users/me is own-history only. After this change a cross-user apply appears in B's
    history rather than A's. That is the intended correction: A's request is recorded on the proposal
    itself; B is who changed the board.
  • The requester's user id now appears in board- and entity-scoped history text. No new exposure class:
    ProposalDto.RequestedByUserId is already visible to anyone who can read the proposal, and the
    assignment audit rows already carry user GUIDs in Changes.
  • Authorization is untouched. The authoritative assignment audit is untouched.

Scope and lane

Lane alpha-product-trust, claimed on the issue before writing. AutomationExecutorService.cs gets a
single hunk — the _auditRecorder.RecordAsync(...) call inside the operation loop, non-adjacent to
the transaction bridge and notification ordering that #2934 may touch. Nothing in
ProposalOperationContractValidator (#2926), CardService, CardAssignmentService,
ProposalAssignmentContract or OperationHandlerRegistry changed.

Lease amendment vs. the claim comment: the API regression landed in
backend/tests/Taskdeck.Api.Tests/CardAssignmentApiTests.cs (the assignment fixture already lives
there) instead of AutomationProposalsApiTests.cs. That also keeps this PR off the file whose
lifecycle cases #2934 is most likely to touch. No existing test method was edited in either file.

Checks run

Check Result
dotnet test backend/tests/Taskdeck.Application.Tests/... --filter "FullyQualifiedName~ExecutionAuditRecorder" 26 passed / 0 failed
dotnet test backend/tests/Taskdeck.Api.Tests/... --filter "FullyQualifiedName~CardAssignmentApiTests" 18 passed / 0 failed
dotnet test backend/tests/Taskdeck.Api.Tests/... --filter "FullyQualifiedName~ProposalAppliedByACollaboratorAttributesEveryAuditRowToTheApplier" 1 passed / 0 failed
dotnet test backend/Taskdeck.sln -c Release -m:1 8498 passed / 34 skipped / 0 failed, exit 0 (Domain 1676, Application 4369, Api 3175+4 skipped, Cli 243, Architecture 28+1 skipped, Integration 7+29 skipped)
node scripts/check-docs-governance.mjs passed
node scripts/check-doc-links.mjs passed (695 files, 0 broken links)

New tests (6): RecordAsync_ShouldAttributeActorToApplyingUser_AndKeepRequesterInProvenance,
RecordAsync_ShouldAttributeLifecycleReceiptToApplyingUser (2 cases),
RecordAsync_ShouldFallBackToRequester_WhenNoAuthenticatedApplier,
RecordAsync_ShouldFallBackToRequester_WhenApplierIdIsEmpty,
BuildAuditChanges_ShouldNameTheRequester, and the API regression
ProposalAppliedByACollaboratorAttributesEveryAuditRowToTheApplier.

The existing same-user cases were left untouched and still pass, including
CardLifecycleAudit_ProposalApplyAndDirectApi_EachPersistExactlyOneTypedReceipt, whose
UserId.Should().Be(user.UserId) assertion holds because requester and applier are the same person there.

NOT verified

  • No frontend, E2E, or browser run — no frontend file changed and no wire contract moved.
  • The API regression proves the assignment path end to end. The cross-user lifecycle path is proven
    at the unit level only (RecordAsync_ShouldAttributeLifecycleReceiptToApplyingUser); no API test
    applies a lifecycle proposal as a different user.
  • No EF migration was generated or run — no schema change.
  • docs/STATUS.md and docs/IMPLEMENTATION_MASTERPLAN.md are untouched: the vocabulary autodoc
    records post-merge STATUS sync as the batch coordinator's.

Closes #2978

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.
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 11, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-11T19:33:44.727352Z d46c050 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@Chris0Jeky

Copy link
Copy Markdown
Owner Author

Fresh-context independent review at exact head d46c050c6d2e0a3bd517433860046f09c7efe8c7 against base 4c479a7ff311912ffb96da027f6e525521bb70df: CLEAN, no CRITICAL/HIGH/MEDIUM findings.

Verified by the reviewer from source (read-only):

  • callerUserId is the authenticated applier on every production path: the single-execute controller reads it from JWT claims, the batch service takes a non-nullable Guid and forwards it verbatim, MCP has no apply lane, the CLI has no executor reference, and the null-caller overload has zero production callers. The requester and Guid.Empty fallbacks are unreachable in production.
  • Provenance text: bounded well under the 4000-char Changes cap; no consumer parses the "Automation proposal" prefix (history falls through on non-JSON, metrics/forecasting regex a target_column= string that never appears here); existing recorder assertions still hold.
  • Composition with PR Align card lifecycle proposal side effects and audit receipts #3001's lifecycle prefix yields one well-formed sentence; the direct-API lane's exact-equality row is untouched.
  • Authorization: the in-transaction board-write recheck still precedes the operation loop and RecordAsync.
  • The Api test has B (Editor, not the requester) approve and execute, asserts the history row's actor is B with the requester in provenance, and its two Single predicates are disjoint; unit tests cover both lifecycle actions, null and empty applier.
  • Both doc sentences are accurate about which row names whom.

Non-blocking notes, recorded (law 2c):

  • LOW: the autodoc line "agrees with the actor a handler's own mutation row records" is exact for assignments and vacuous for lifecycle, while other card handlers call CardService without an actor (null rows). Nothing disagrees.
  • LOW: the "internal lanes that execute without an authenticated caller" clause describes a lane that does not exist today.
  • Informational: user-scoped audit aggregates (streaks, history, GDPR export) now attribute applied proposals to the applier rather than the requester, which is the intended semantics.

Author-run proof at this head: full dotnet test backend/Taskdeck.sln -c Release -m:1 exit 0, 8498 passed / 34 skipped / 0 failed. Merging with a merge commit once the hosted checks at this head are green and the aging floor is met.

@Chris0Jeky
Chris0Jeky merged commit 715fba1 into main Sep 11, 2026
36 checks passed
@Chris0Jeky
Chris0Jeky deleted the issue-2978/execution-history-applier branch September 11, 2026 19:52
@github-project-automation github-project-automation Bot moved this from Pending to Done in Taskdeck Execution Sep 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

Attribute generic assignment execution history to the applying user

1 participant