Skip to content

Align card lifecycle proposal side effects and audit receipts - #3001

Merged
Chris0Jeky merged 5 commits into
mainfrom
issue-2939/lifecycle-audit-receipts
Sep 11, 2026
Merged

Chris0Jeky merged 5 commits into
mainfrom
issue-2939/lifecycle-audit-receipts

Conversation

@Chris0Jeky

Copy link
Copy Markdown
Owner

What

Aligns the two card lifecycle proposal actions (archive-lifecycle, restore-lifecycle) with the
rest of the proposal vocabulary in the two places PR #2932's review found them missing.

  1. Side-effect disclosure. SideEffectAnalyzer.CardMutatingActions is an exact-value set that
    omitted both lifecycle names, so /side-effects reported No board mutations in the Cards row
    for a proposal that archives or restores a card on Apply. Both names are now in the set.
  2. Audit receipts. Applying a lifecycle proposal persisted two contradictory rows:
    CardService.SetArchivedAsync staged its own typed Archived/Unarchived receipt, and
    ExecutionAuditRecorder — whose action map did not know the lifecycle action names — added a
    second row classified as Updated. The apply lane now emits exactly one correctly typed row:
    the recorder maps both names, prefixes the legacy CardService wording onto its provenance
    string so the row keeps its existing meaning, and the lifecycle handler passes
    recordLifecycleAudit: false so the service does not stage a duplicate.

The direct API lane (POST /api/boards/{b}/cards/{c}/archive|restore) is unchanged: it keeps its
single actor-stamped receipt with the exact legacy wording. Legacy archive (Block semantics) is
untouched, and explicit approve + explicit apply are unchanged.

Why

Closes #2939. Follow-up to the two review comments on PR #2932
(3982485234, 3982485243) at cd5ff22.

Both are trust-surface defects: the review disclosure understated the write being approved, and the
applied history contradicted itself about what happened.

Overlap note (PR #2977)

#2977 (card assignments) also edits CardService.cs and OperationHandlerRegistry.cs. This PR keeps
each to one minimal, non-adjacent hunk, as declared in the lane claim on #2939:

No other file from #2977's set (AutomationExecutorService.cs, AutomationProposalService.cs,
ProposalOperationContractValidator.cs) is touched here.

Tests

  • SideEffectAnalyzerTests — both lifecycle actions (and mixed casing) make the Cards row active at
    both entry points, plus a guard that the action only counts when the target really is a card.
  • ExecutionAuditRecorderTests — both names map to Archived/Unarchived, the single row carries
    the proposal id, sequence and RequestedByUserId, and BuildAuditChanges keeps the legacy
    lifecycle wording ahead of the provenance (and does not add it for non-lifecycle actions).
  • CardServiceTestsrecordLifecycleAudit: false stages no service-level receipt for either
    direction; the existing test still pins the default actor-stamped receipt.
  • AutomationProposalsApiTests.CardLifecycleAudit_ProposalApplyAndDirectApi_EachPersistExactlyOneTypedReceipt
    — end-to-end persisted-audit regression: proposal-applied archive and restore each leave
    exactly one typed row with proposal provenance and no Updated fallback row, while the same
    two transitions through the direct API leave exactly one actor-stamped row each with the unchanged
    legacy wording.

Regression proof (run against this branch's tests with the production files reverted to
origin/main):
the API test fails on the No board mutations assertion with the analyzer
reverted, and fails on the proposalAudits duplicate assertion with only the recorder/handler
reverted. Both assertions are real guards, not tautologies.

Checks run

Check Result
dotnet test backend/tests/Taskdeck.Application.Tests/... --filter "FullyQualifiedName~SideEffectAnalyzerTests|ExecutionAuditRecorderTests|CardServiceTests" Passed 116 / Failed 0
dotnet test backend/tests/Taskdeck.Api.Tests/... --filter "FullyQualifiedName~CardLifecycleAudit_ProposalApplyAndDirectApi" Passed 1 / Failed 0
dotnet test backend/Taskdeck.sln -c Release -m:1 (required backend gate) exit 0 — 9453 passed, 0 failed, 34 skipped (Domain 1674 / Application 4358 / Api 3143+4 skipped / Cli 243 / Architecture 28+1 / Integration 7+29)
node scripts/check-docs-governance.mjs Passed
node scripts/check-doc-links.mjs Passed (694 files, 0 broken links)

Docs

One sentence added to autodoc/interfaces/proposal-operation-vocabulary.md on the
archive-lifecycle / restore-lifecycle bullet, recording the side-effect classification and the
single-receipt contract. docs/product/ documents neither audit receipts nor the side-effect rows,
so nothing there needed a change; docs/STATUS.md is untouched (no shipped-capability change — this
corrects disclosure and history for an already-shipped operation).

NOT verified

  • No frontend run. The Cards-row string is unchanged, so the Review side-effects panel renders the
    same text; only the branch it takes for lifecycle-only proposals changes. Not exercised in a
    browser or Playwright.
  • No MCP end-to-end run of archive_card_lifecycle / restore_archived_card; those tools only
    create proposals and reach the same apply path covered by the API test.
  • No migration or data backfill: pre-existing duplicate Updated rows written by earlier applies
    remain in the audit log. Deliberate — the issue scopes this to forward behavior and forbids a
    broad audit redesign.
  • No load or concurrency testing of the apply transaction.

Closes #2939

…ysis

archive-lifecycle and restore-lifecycle flip a card's archived state on apply,
but SideEffectAnalyzer.CardMutatingActions omitted both names, so the review
side-effects Cards row reported "No board mutations" for the write being
approved. Add both to the action set and cover them at both entry points.
…estore

Applying an archive-lifecycle or restore-lifecycle proposal persisted two
contradictory rows: CardService.SetArchivedAsync staged its own typed
Archived/Unarchived receipt, and ExecutionAuditRecorder - whose action map did
not know the lifecycle action names - added a second row classified as Updated.

Map both names in the recorder, prefix the legacy CardService wording onto the
provenance string so the single row keeps its existing meaning, and have the
proposal lane ask SetArchivedAsync not to stage its own receipt. The direct API
archive/restore lane keeps its actor-stamped single receipt unchanged.
@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-11T18:06:49.449035Z 84e983e New commits
ℹ️ 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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3773ae9dfe

ℹ️ 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".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

{
"create", "move", "archive", "update", "delete", "bulk_move"
"create", "move", "archive", "update", "delete", "bulk_move",
"archive-lifecycle", "restore-lifecycle"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include restores in the Cards side-effect copy

When a proposal contains only restore-lifecycle, adding it to this generic mutation set routes the Cards row to Creates, moves, or archives cards on the board, even though Apply restores the card. The frontend renders this API value verbatim, so the review trust gate describes the wrong lifecycle direction; use neutral mutation wording or distinguish restore operations, and assert the resulting user-facing value rather than only checking that it differs from No board mutations.

AGENTS.md reference: AGENTS.md:L96-L97

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Triaged once: real, non-blocking (review-time copy; the proposal diff already shows Archived true -> false). Tracked as #3008.

@Chris0Jeky

Copy link
Copy Markdown
Owner Author

Fresh-context independent review (round 1) at exact head 3773ae9dfe93179e7b8e30abfcb50959dcbb1fcf against base 5c3a81e6e3e71abd071f8cd44cbbd2493210f8b2: one fix-now finding, three LOW notes. A fix round is in progress on this branch.

Verified clean by the reviewer from source:

  • The optional recordLifecycleAudit parameter inserted before cancellationToken: every call site (CardsController, OperationHandlerRegistry, CardServiceTests, CardHierarchyConcurrencyTests, McpToolsTests) passes positionally up to actorUserId or by name; none can slide a token into the new slot.
  • No orphaned receipt: ExecuteOperationAsync has one production caller, AutomationExecutorService, which calls RecordAsync unconditionally inside the same transaction; batch execution shares that path; a throw in RecordAsync rolls the card mutation back.
  • Audit semantics: right enum value, Changes within the 4000-char cap, actor improves from null to the requester; no downstream consumer parses Changes for lifecycle rows; EntityType lookups are case-normalized.
  • CardMutatingActions feeds only the Cards row; risk posture and batch eligibility do not read action types.
  • Child-detach audit rows are unaffected by the guard. The autodoc sentence matches the implementation.

Fix-now (classified HIGH for this PR because the row it removes was the always-correct receipt): ExecutionAuditRecorder keys the sole surviving lifecycle receipt on operation.TargetId, while the handler archives parameters.cardId, and nothing validates that they agree. A board writer can craft a proposal with an unrelated targetId and a real parameters.cardId; after approve and execute the real card is archived with no receipt in its history while the unrelated id gets the row. Fix requested: reject the mismatch in the contract validator (Preview == Apply), with validator unit tests and an Api regression.

LOW, recorded (law 2c), no commits requested:

  • Lifecycle is now the only card action with a single receipt; update/move proposals still write a service row plus a recorder row. Vocabulary-wide inconsistency, worth a tracked issue after this lands.
  • The receipt stamps RequestedByUserId, so a collaborator applying someone else's lifecycle proposal is not named; same shape as open Attribute generic assignment execution history to the applying user #2978.
  • ActionMap (ordinal, lowercased lookups) and LifecycleAuditSummaries (OrdinalIgnoreCase) use different comparers; style only.

Informational: PR #2977 merged after this head and touches CardService.cs and OperationHandlerRegistry.cs; the fix round integrates main first.

…epends on

Review of #3001 raised that ExecutionAuditRecorder keys the surviving lifecycle
receipt on operation.TargetId while the handler mutates parameters.cardId, so a
crafted proposal could archive one card and stamp the receipt on another now that
the CardService row is gone.

Measured: the attack is not reachable. ProposalOperationContractValidator already
rejects any card-targeted operation whose targetId disagrees with
parameters.cardId, and AutomationPolicyEngine.ValidatePermissionsAsync runs that
validator on BOTH the approve path and the execute path - the latter against the
revision-materialized effective operations, so a post-approval revision is caught
too. End to end the crafted proposal is created, then refused at approve with 400
and can never be applied.

No production change. Add the regression tests that guard is now load-bearing for:
a validator unit test (mismatch rejected, match accepted, absent targetId
accepted, both lifecycle actions) and an API test walking the full crafted-proposal
pipeline and asserting nothing is archived and no receipt lands on the decoy card.
@Chris0Jeky

Copy link
Copy Markdown
Owner Author

Review round 2 (final round)

New head: 84e983e01cbb2d29aa4dec8e2e582150926d7abe
Fix commit: 84e983e01Pin the targetId/cardId identity guard the single lifecycle receipt depends on
Also in this push: origin/main merged in (PR #2977 landed and touched CardService.cs and
OperationHandlerRegistry.cs). The merge was clean — no conflicts — and both PRs' changes are present:
#2977's assignment wiring in the registry constructor / ExecuteOperationAsync and its MapToDto
change, alongside this PR's recordLifecycleAudit hunks in SetArchivedAsync and
SetCardArchivedAsync. Rebuilt and retested after the merge.


HIGH finding — targetId / cardId divergence: measured, not reachable. Declining the code change.

The concern is correct in shape and worth raising: ExecutionAuditRecorder.ResolveAuditEntity keys the
receipt on operation.TargetId when it parses as a GUID, while the lifecycle handler mutates
parameters.cardId. Now that the CardService row is suppressed on the apply lane, a divergence would
archive one card and stamp the receipt on another, with nothing masking it.

It cannot diverge. ProposalOperationContractValidator has a shared, action-agnostic
identity-agreement guard that runs before any action-specific rule:

if (cardId.HasValue && targetId.HasValue &&
    operation.TargetType.Equals("card", StringComparison.OrdinalIgnoreCase) &&
    cardId != targetId)
{
    return Result.Failure(ErrorCodes.ValidationError, "Operation targetId must match parameter 'cardId'");
}

The review comment looked at the action-specific lifecycle branch (which indeed only requires
parameters.cardId) and missed this earlier block in the same method. It covers archive-lifecycle and
restore-lifecycle exactly as it covers move/archive/delete.

AutomationPolicyEngine.ValidatePermissionsAsync runs that validator on two gates — and
AutomationExecutorService calls it against effectiveProposal.Operations, i.e. the
revision-materialized payload, so a mismatch introduced by a post-approval revision is caught too.

I ran the described attack end to end rather than reasoning about it. Board writer, two real cards,
targetType:"card", targetId:<decoy card>, parameters.cardId:<real card>, for both lifecycle
actions:

Step Result
POST /api/automation/proposals 201 Created (creation is deliberately permissive — a proposal may be created in a transient shape and revised into validity, #1423)
POST .../approve 400 BadRequestOperation targetId must match parameter 'cardId'
POST .../execute 409 ConflictCannot execute proposal in status PendingReview
Real card unchanged
Decoy card unchanged, no Archived/Unarchived audit row

So Apply is unreachable and no misattributed receipt is ever written. Adding a second rejection in the
lifecycle branch or the handler would duplicate an existing guard rather than close a hole, so per law
2a I am not making the code change.

What I did add, because this guard is now load-bearing for the single-receipt design and was not
directly pinned anywhere:

  • ProposalOperationContractValidatorTests.Lifecycle_RejectsTargetIdThatDisagreesWithParameterCardId
    (theory over both actions) — mismatch rejected with the exact message, matching targetId accepted,
    absent targetId accepted (the recorder's fallback to parameters.cardId is then the only source).
  • AutomationProposalsApiTests.CardLifecycleProposal_WithTargetIdMismatchingParameterCardId_IsRejectedAndMutatesNothing
    (theory over both actions) — the full crafted-proposal pipeline above, asserting rejection before Apply,
    neither card mutated, and no lifecycle row on the decoy id.

Both fail if the guard is loosened, which is the protection the finding was actually asking for.


LOW — receipt attribution (recorded, not fixed here)

The single lifecycle receipt stamps proposal.RequestedByUserId, so when a collaborator approves and
applies someone else's lifecycle proposal the history names the requester, not the applier. This is
pre-existing ExecutionAuditRecorder behaviour for every proposal action, not something this PR
introduces — before this PR the CardService row on the apply lane was stamped null, which was no
better. It is the same class of gap as #2978 (Attribute generic assignment execution history to the
applying user
). Out of scope here and left to that issue rather than opening a duplicate; worth folding
the proposal-apply lane into #2978's fix when it is picked up.


Checks run at this head

Check Result
dotnet test backend/tests/Taskdeck.Application.Tests/... --filter "FullyQualifiedName~ProposalOperationContractValidatorTests" Passed 44 / Failed 0
dotnet test backend/tests/Taskdeck.Api.Tests/... --filter "…CardLifecycleProposal_WithTargetIdMismatching…|…CardLifecycleAudit_ProposalApplyAndDirectApi…" Passed 3 / Failed 0
dotnet test backend/Taskdeck.sln -c Release -m:1 (required backend gate, post-merge) exit 0 — 9491 passed, 0 failed, 34 skipped (Domain 1676 / Application 4363 / Api 3174+4 / Cli 243 / Architecture 28+1 / Integration 7+29)

Not verified this round: no frontend run (this round is backend tests only and adds no production
change); the pre-existing preview-surface wart where a created but unapprovable mismatched proposal
lists the decoy card under presentation.affectedEntities is untouched — it is unreachable past approve
and predates this PR.

@Chris0Jeky

Copy link
Copy Markdown
Owner Author

Round 2 (fix round) disposition at exact head 84e983e01cbb2d29aa4dec8e2e582150926d7abe (round-1 head 3773ae9df plus a clean merge of main after PR #2977 and a tests-only commit): no merge blocker. Round ceiling reached (law 2d); shipping once the checks at this head are green and the aging floor is met.

The round-1 fix-now item (receipt keyed on operation.TargetId while the handler mutates parameters.cardId) was refuted at runtime rather than patched: ProposalOperationContractValidator already carries a shared, action-agnostic guard that rejects any card operation whose targetId disagrees with parameters.cardId, and it runs on approve and again against the revision-materialized operations at execute. The crafted attack was executed for both lifecycle actions: create 201, approve 400 "Operation targetId must match parameter 'cardId'", execute 409, no mutation and no receipt on the decoy. The independent reviewer's read stopped at the lifecycle-specific branch and missed the shared block earlier in the same method; a read-only reviewer's assertion is a claim to check, and it was checked. The guard is now pinned by two new tests (validator unit, both actions; Api pipeline, both actions), since it is load-bearing for the single-receipt design. No production code changed in round 2, so no further fresh-context pass is owed.

Triage of everything raised since round 1 (law 2c):

  • Codex P2 (comment 3991964949): the generic Cards row copy omits "restores" for a restore-only proposal. Real, non-blocking, tracked as a separate issue linked from the thread.
  • LOW, recorded: the receipt stamps RequestedByUserId, so a collaborator applying another's lifecycle proposal is not named; same class as open Attribute generic assignment execution history to the applying user #2978, not duplicated.
  • LOW, recorded: lifecycle is now the only card action with a single receipt (update/move still double-write); ActionMap still falls back to Updated for unmapped actions. Tracked-issue material after this lands.
  • Informational: a created-but-unapprovable mismatched proposal lists the decoy under presentation.affectedEntities; pre-existing and unreachable past approve.

Author-run proof at this head: full dotnet test backend/Taskdeck.sln -c Release -m:1 after the main merge, exit 0, 9491 passed / 0 failed / 34 skipped; validator class 44/44; both Api lifecycle classes green. Not independently re-executed by the coordinator; the hosted rollup at this head is the executable proof.

@Chris0Jeky
Chris0Jeky merged commit 508c4a9 into main Sep 11, 2026
36 checks passed
@Chris0Jeky
Chris0Jeky deleted the issue-2939/lifecycle-audit-receipts branch September 11, 2026 18:30
@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.

Align card lifecycle proposal side effects and audit receipts

1 participant