Skip to content

Reject archived parent targets in Board JSON hierarchy imports - #2994

Merged
Chris0Jeky merged 1 commit into
mainfrom
issue-2966/reject-archived-import-parents
Sep 11, 2026
Merged

Chris0Jeky merged 1 commit into
mainfrom
issue-2966/reject-archived-import-parents

Conversation

@Chris0Jeky

Copy link
Copy Markdown
Owner

What

BoardJsonExportImportService.ImportBoardAsync now rejects a version-3 Board JSON payload that names an archived card as another card's parent. The check sits in the existing whole-payload validation pass, immediately after the source-ID loop and before the board, columns or cards are constructed.

Why

Follow-up from PR #2965 review comment 3984069198 (source-confirmed MEDIUM contract gap).

Hand-crafted version-3 Board JSON could set IsArchived=true on a card and still name it as another card's ParentCardId. Import called SetParent before Archive, and CardHierarchy.Validate accepted the graph because that validator only checks existence, board scope, cycles and depth. The create (CardService.ValidateActiveParent), update, and proposal (ProposalHierarchyValidator) lanes all refuse an archived parent, and ordinary archival detaches every direct child, so no clean export can produce this graph. Restoring the imported parent then unexpectedly retained those links.

How

if (card.ParentCardId is Guid parentSourceId && archivedSourceIds.Contains(parentSourceId))
    throw new DomainException(ErrorCodes.ValidationError,
        $"Card '{card.Title}' references an archived parent. Restore the parent card before assigning it.");
  • No new error code. Reuses ErrorCodes.ValidationError, the same code the import already throws for missing-parent, duplicate-source-ID, cycle and depth failures, so the HTTP surface stays 400 and the existing catch (DomainException) -> RollbackTransactionAsync path is unchanged.
  • Wording matches the other lanes. The sentence "Restore the parent card before assigning it." is lifted verbatim from CardService.ValidateActiveParent and ProposalHierarchyValidator, prefixed with the offending card title the way the import's other messages are.
  • Atomic by construction, not only by rollback. Placing the check before new Board(...) means a rejected payload never reaches board/column/card creation at all; the transaction rollback is a second line of defence rather than the only one.
  • Validating the payload's IsArchived flags is equivalent to validating the completed graph, because card.Archive() in the import loop is driven by exactly that flag and nothing else.

Scope

  • Archived children whose parent stays active still import, with the parent remapped to its fresh ID.
  • Plain and version-2 payloads carry no hierarchy fields, so the check is inert for them.
  • Restore behaviour, account/GDPR export and the database import path are untouched. CardHierarchy, CardService and the proposal lanes are untouched.

Checks run

  • dotnet test backend/tests/Taskdeck.Application.Tests/Taskdeck.Application.Tests.csproj -c Release -m:1 --filter "FullyQualifiedName~ExportImport" — 78/78 passed.
  • dotnet test backend/tests/Taskdeck.Api.Tests/Taskdeck.Api.Tests.csproj -c Release -m:1 --filter "FullyQualifiedName~CardHierarchyContractTests" — 11/11 passed.
  • dotnet test backend/Taskdeck.sln -c Release -m:1 (the backend/AGENTS.md required check) — green, exit 0: Domain 1674/1674, Application 4328/4328, Api 3143 passed / 4 skipped, Cli 243/243, Architecture 28 passed / 1 skipped, Integration 7 passed / 29 skipped. 9423 passed, 34 skipped, 0 failed.
  • node scripts/check-docs-governance.mjs — passed.
  • node scripts/check-doc-links.mjs — passed (693 files, 0 broken links).
  • Negative controls (both confirmed red without the fix, green with it):
    • ImportBoardAsync_RejectsArchivedParentTarget_BeforeCreatingAnything — failed with the source change reverted.
    • ImportRemapsChildBeforeParentAndRejectsBadGraphsAtomically(shape: "archivedParent") — failed with the source change reverted.

Tests added

Test Proves
ImportBoardAsync_RejectsArchivedParentTarget_BeforeCreatingAnything Rejected with ValidationError and the shared wording; Boards.AddAsync, Columns.AddAsync, Cards.AddAsync, SaveChangesAsync and CommitTransactionAsync all never called; RollbackTransactionAsync called once. No partial board.
ImportBoardAsync_ImportsArchivedChildWhenItsParentStaysActive Archived child imports, stays archived, and points at the remapped active parent's fresh ID.
ImportBoardFromJsonAsync_StillImportsVersion2PayloadWithoutHierarchy A taskdeck-board version-2 envelope still imports its column and card and commits.
CardHierarchyContractTests...(shape: "archivedParent") End-to-end through POST /api/import/boards: 400 and the persisted board count is unchanged.

The Api case is one [InlineData] plus one named argument on the existing atomicity theory, which already asserted "400 and no board persisted" for the missing/duplicate/cycle shapes.

Docs

docs/product/CARD_HIERARCHY.md — one sentence added to the existing Board JSON import paragraph stating the new rule and the preserved archived-child case. docs/STATUS.md deliberately untouched: it has no current hierarchy block to append to and PR #2947 owns STATUS reconciliation right now.

NOT verified

  • No frontend, E2E or Playwright run — this slice is backend-only and changes no API shape or response contract beyond an additional 400 case on an already-400-capable endpoint.
  • No live/manual probe of the HTTP response; the 400 is asserted by the Api integration test against the in-process host, not against a running stack.
  • The wording match with the create/update/proposal lanes is by inspection of CardService.ValidateActiveParent and ProposalHierarchyValidator; there is no shared constant tying them together, so the three strings can still drift independently. Not fixed here to keep the change localized.
  • A payload where an archived card is named as parent and the graph is also cyclic or too deep now reports the archived-parent message rather than the cycle/depth one, because the new check runs earlier. Both are 400 ValidationError; no test pins which message wins.
  • Other lanes are concurrently editing BoardJsonExportImportService.cs (PR Add multiple card assignments and explicit import mapping #2977, card assignments). This branch only inserts a block and rewrites no existing line, but the merge order has not been exercised.

Closes #2966

Hand-crafted version-3 Board JSON could name a card with IsArchived=true as
another card's ParentCardId. Import set the relationship and CardHierarchy.Validate
accepted it, because that validator only checks existence, board scope, cycles and
depth. The create, update and proposal lanes all refuse an archived parent, and
ordinary archival detaches every direct child, so no clean export can produce that
graph; restoring the imported parent then unexpectedly retained those links.

ImportBoardAsync now rejects such a payload in the existing whole-payload validation
pass, before the board, columns or cards are constructed, so a rejected graph can
never leave a partial board. It reuses the ValidationError code the import already
uses for missing-parent, cycle and depth failures (HTTP 400) and the wording of
CardService.ValidateActiveParent. Archived children whose parent stays active still
import, and plain/version-2 payloads are unaffected.

Tests: three Application-level cases (rejected archived parent with nothing created
and the transaction rolled back, archived child under an active parent accepted,
version-2 envelope still imported) and an archivedParent case on the existing Api
atomicity theory. Both rejection tests were confirmed red without the fix.
@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-11T17:17:15.060718Z 72ca920 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 72ca9200ba105dc83672a9c9423c77e8838397b2 against base 48865cd90da95b81ab0f60764dfa126bf284be92: CLEAN, no CRITICAL/HIGH findings.

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

  • Placement: the new check sits after BeginTransactionAsync and the source-ID dictionary build, before the first AddAsync (new Board(...)); the only SaveChangesAsync is much later and the DomainException catch rolls the transaction back. The Api contract test asserts the board count against the live TaskdeckDbContext, not just the exception.
  • ID space: archivedSourceIds and ParentCardId are both source-space (pre-remap), matching how the existing missing-parent resolution reads cardIds; the fixtures use the same space, so the guard is live, not dead. An archived card with a null SourceId can never be a parent target either.
  • Version-2 envelope: TryDeserializeImportDto accepts version 2 or 3 on the format branch and the new test asserts ColumnsImported/CardsImported plus one commit.
  • Archived child of an archived parent: docs/product/CARD_HIERARCHY.md is unconditional ("restore an archived card before assigning it as a parent"), ordinary archive detaches all children (ReadDetachChildrenAsync does not filter archived), and the proposal lane already rejects with the identical message, so rejecting is correct and cannot false-reject a real round-trip.
  • No change to export, restore, or account export paths.

Author-run proof at this head: full dotnet test backend/Taskdeck.sln -c Release -m:1 exit 0 (9423 passed / 34 skipped / 0 failed), plus negative controls showing the two rejection tests go red with the source change reverted.

Non-blocking notes, recorded here rather than as commits (law 2c):

  • LOW: the inserted comment attributes atomicity to placement; the transaction rollback is the real mechanism. Wording only.
  • LOW: a self-parenting archived card now reports the archived-parent message instead of the self-parent message; both remain 400 ValidationError.
  • LOW: no test drives the rejection through the version-3 envelope path (ImportBoardFromJsonAsync); it funnels into the same method.

Merge gate: hosted checks at this head plus the 3-minute aging floor; will merge with a merge commit once green.

@Chris0Jeky
Chris0Jeky merged commit aabc0a7 into main Sep 11, 2026
36 checks passed
@Chris0Jeky
Chris0Jeky deleted the issue-2966/reject-archived-import-parents branch September 11, 2026 17:53
@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.

Reject archived parent targets in Board JSON hierarchy imports

1 participant