Skip to content

Make proposal preview match apply for reorder, create-card ids, and structure limits - #1374

Merged
Chris0Jeky merged 7 commits into
mainfrom
issue-1370/preview-apply-parity
Jul 17, 2026
Merged

Make proposal preview match apply for reorder, create-card ids, and structure limits#1374
Chris0Jeky merged 7 commits into
mainfrom
issue-1370/preview-apply-parity

Conversation

@Chris0Jeky

Copy link
Copy Markdown
Owner

Closes #1370

Restores the review-first preview == apply guarantee for three post-merge defects found on PR #1339 (confirmed on main @ d46adf48). Each fix makes what a reviewer approves in a proposal preview equal what Apply executes; three focused commits, one per defect.

Defect A — reorder clamp divergence

ColumnService.ReorderColumnAsync clamps an overshooting target to the end (Math.Min(position, columnCount - 1)), but the proposal diff surfaced the raw requested position — a reorder-to-99 on a 3-column board previewed "to position 99" and applied "move to end".

Per the coordinator decision, apply-side clamping is unchanged (manual UI callers rely on it); instead the diff now computes the same clamp against the current board columns. AutomationProposalService.DescribeOperationReadable (reorder branch) now renders Math.Min(requestedPosition, columnCount - 1) (falling back to the raw value only when the best-effort board lookup returns no columns).

Updated test (locked in the divergence): GetProposalDiffAsync_ShouldSurfaceDestinationPosition_ForColumnReorderOperations previously used a single-column board with an out-of-range position = 2 and asserted the raw "to position 2" — encoding preview ≠ apply. It now uses a 3-column board so the destination is in range (preview == apply). Added GetProposalDiffAsync_ShouldSurfaceClampedEffectivePosition_WhenColumnReorderOvershoots (position 99 → preview "to position 2") and ColumnServiceTests.ReorderColumnAsync_ShouldClampOvershootingTargetToEnd (position 99 → applied position 2); together they assert preview == apply == 2.

Defect B — create-card id collision

A create-card op whose cardId parameter equalled its targetId took the existing-card validation branch (ValidateCardBoardAsync), previewed OK, was registered as planned, then Apply failed on the duplicate id.

ProposalOperationContractValidator.ValidateEntityScopeAsync now routes every create-card op through ValidateNewCardIdAsync (never the existing-card branch), so a collision with an existing card fails at preview with a stable 409 Conflict — consistent with the neighbouring duplicate-within-proposal / empty-id validators.

Added ValidateAsync_ShouldRejectCreateCardWhoseIdCollidesWithExistingCard_EvenWhenCardIdParameterMatchesTargetId.

Defect C — structure checks after preview

The policy structure checks (op count ≤ 50, unique/non-negative sequences, params ≤ 10000 chars — PR #1288) ran before revised-payload diffs and at Apply (ValidatePolicy), but not before original-proposal diffs, so Apply could fail structure validation after a clean preview.

Extracted the checks into a shared ProposalOperationStructureValidator (AutomationPolicyEngine.ValidateOperationStructure now delegates to it — one source of truth), and GetProposalDiffAsync runs it before building an original-proposal diff, returning the same ValidationError Apply returns.

Added GetProposalDiffAsync_ShouldRejectOriginalProposalViolatingStructureLimits.

Scope / constraints

No new endpoints, no EF/model changes, no migrations. Clean Architecture boundaries preserved (Application-only, delegates to Domain Result/ErrorCodes). Stable HTTP codes preserved (409 for id collisions, 400 for structure/validation).

Verification (local, Release)

  • dotnet build backend/Taskdeck.sln -c Release -m:10 errors (pre-existing warnings only).
  • Targeted Taskdeck.Application.Tests (ColumnService | AutomationProposalService | ProposalOperationContractValidator | AutomationPolicyEngine | AutomationExecutorService | ProposalRevision) — 168 passed, 0 failed.
  • Full Taskdeck.Application.Tests3459 passed, 0 failed.
  • Taskdeck.Api.Tests (Proposal | Mcp | Automation) — 359 passed, 0 failed.
  • CI runs the full matrix.

A create-card operation carrying a cardId parameter equal to its targetId took
the existing-card validation branch, previewed OK, and was registered as planned,
then Apply failed on the duplicate id. Route every create-card op through the
new-card-id validation so a collision with an existing card fails at preview with
a stable 409, keeping preview == apply (#1370).
ColumnService.ReorderColumnAsync clamps an overshooting target to the end
(Math.Min(position, columnCount - 1)), but the proposal diff surfaced the raw
requested position, so a reorder-to-99 on a 3-column board previewed 'to position
99' and applied 'move to end'. Compute the same clamp against the current board
columns when rendering the reorder diff so preview == apply. Apply-side clamping is
unchanged (manual UI callers rely on it).

Updates GetProposalDiffAsync_ShouldSurfaceDestinationPosition_ForColumnReorderOperations,
which previously used a single-column board with an out-of-range position=2 and
asserted the raw value, locking in the divergence; it now uses an in-range
destination. Adds a clamp-specific preview test and a ColumnService apply test that
both land on the same effective position (#1370).
The policy structure checks (op count <= 50, unique/non-negative sequences,
parameters <= 10000 chars) ran before revised-payload diffs and at Apply, but not
before original-proposal diffs, so a proposal could preview cleanly and then fail
structure validation at Apply. Extract the checks into a shared
ProposalOperationStructureValidator (AutomationPolicyEngine now delegates to it,
keeping one source of truth) and run it before building an original-proposal diff,
returning the same ValidationError Apply returns (#1370 preview == apply).
Copilot AI review requested due to automatic review settings July 17, 2026 00:20

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@gemini-code-assist gemini-code-assist 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.

Code Review

This pull request aligns the automation proposal preview and execution phases by extracting structural validation into a shared ProposalOperationStructureValidator and improving validation for card creation and column reordering. The reviewer identified two important issues: first, the column reorder preview clamping uses a point-in-time snapshot of the column count, which can diverge from the actual applied position if preceding operations in the proposal create or delete columns; second, a potential NullReferenceException could occur in ProposalOperationStructureValidator if operation.Parameters is null.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

@Chris0Jeky

Copy link
Copy Markdown
Owner Author

Adversarial Code Review

Self-review of the full diff, focused on the preview == apply invariant, Clean Architecture boundaries, HTTP semantics, and test rigor.

CRITICAL

  • None.

HIGH

  • None. Verified the reorder clamp mirrors Apply exactly: Apply's effective landing index is Math.Min(position, boardColumns.Count - 1) (ordered = boardColumns.Where(c => c.Id != moved), so ordered.Count == boardColumns.Count - 1); the diff computes Math.Min(position, columnNames.Count - 1) where columnNames is the same live board column set. The diff builder only runs after ProposalOperationContractValidator has confirmed the target column is on the proposal board, so columnNames always includes it and the counts agree. Column ops are reorder-only (no create/delete-column proposal op), so column count is invariant within a proposal — the clamp is stable even for multi-reorder proposals.

MEDIUM

  • None requiring change. Two intentional scope decisions, called out so they are not silently assumed:
    1. Structure validation added to the original-proposal diff path only, not the revision diff path. The revision path is already structure-validated at save time (ProposalRevisionService, Validate revised operation structure when saving a proposal revision (dup sequences / op-count / param-size) #1281), and the executor re-runs ValidatePolicy on the effective (revised) proposal at Apply. Adding a redundant check to the revision diff path would be scope creep. Original path was the actual gap.
    2. Apply-side reorder clamping is deliberately unchanged (per the issue's coordinator decision — manual UI callers depend on it). Only the preview was made to match Apply.

LOW

  • Reorder diff falls back to the raw requested position when the best-effort board-column lookup returns nothing (columnNames.Count == 0 — a DB error caught in BuildReadableDiffAsync, or a board-less proposal). In that degraded path the preview could still show the pre-clamp value. This is strictly no worse than the prior behavior (which always showed raw), only occurs on a transient lookup failure that is already handled by falling back to IDs, and such reorders are rejected by contract validation before the diff builds in the normal path. Kept minimal rather than suppressing the position entirely.

Bot Comments Addressed

  • None present (no existing human/bot comments on the PR at review time).

Summary

0 CRITICAL / 0 HIGH / 0 MEDIUM / 1 LOW (intentional degraded-path trade-off, not merge-blocking). No code changes required. All three fixes ship with regression tests that fail without the fix (B and C previewed cleanly before; A previewed the raw overshoot). Verification: build 0 errors; targeted Application.Tests 168 passed; full Application.Tests 3459 passed; Api.Tests (Proposal|Mcp|Automation) 359 passed. CI security/architecture/migration/docs/SAST green; Backend Unit + API Integration running.

@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: 00d5644a6f

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

@Chris0Jeky

Copy link
Copy Markdown
Owner Author

Adversarial Review — Fixes Applied

No code changes were required — the review found no CRITICAL/HIGH/MEDIUM defects. The single LOW item is an intentional, documented trade-off, not a dropped finding.

Finding Severity Disposition Verified
Reorder diff clamp mirrors Apply (Math.Min(position, columnCount-1) against the live board column set) HIGH-checked Correct as written; column ops are reorder-only so count is invariant within a proposal ReorderColumnAsync_ShouldClampOvershootingTargetToEnd + GetProposalDiffAsync_ShouldSurfaceClampedEffectivePosition... (preview == apply == 2)
Structure validation scoped to original-proposal diff path only MEDIUM-checked Intentional — revision path is structure-validated at save (#1281) and re-validated at Apply GetProposalDiffAsync_ShouldRejectOriginalProposalViolatingStructureLimits
create-card id collision routed away from existing-card branch HIGH-checked Fixed in commit 1b12f67 ValidateAsync_ShouldRejectCreateCardWhoseIdCollidesWithExistingCard_EvenWhenCardIdParameterMatchesTargetId (409)
Reorder diff falls back to raw position when board-column lookup returns empty (transient DB error / board-less) LOW Retained by design — no worse than prior behavior, only on an already-handled degraded path; suppressing the position would reduce reviewer information n/a (degraded path)

All findings addressed (fixed, or assessed and explicitly retained with justification). Nothing silently dropped; no out-of-scope issues to seed.

CI status: required gate GREEN — Backend Unit (ubuntu+windows), API Integration (ubuntu+windows), Frontend Unit, Backend Architecture, Migration Validation, Docs Governance, CodeQL, SAST, secret scans all pass. E2E Smoke pending (backend-only change; no frontend runtime surface).

@Chris0Jeky

Copy link
Copy Markdown
Owner Author

Consolidated Adversarial Review — two independent lenses + bot findings

Two independent adversarial reviews (correctness lens, test-design lens) are complete and adjudicated. All three fixes are CONFIRMED correct. Refuted candidates from the correctness lens, recorded for the trail:

  • Suspected create-card divergence at apply time — REFUTED: apply re-runs the same ProposalOperationContractValidator via AutomationPolicyEngine.ValidatePermissionsAsync (executor line ~138), so the preview-side collision rejection is enforced identically at execution.
  • Suspected clamp mismatch at boundaries — REFUTED: clamp parity proven exact, including N==1 (single column), position==N (one past end), and position==0.

Surviving findings (all being fixed in this batch, plus one tracked issue):

LOW — test design

  1. Parity asserted via twin hardcoded literals. Preview==apply is currently proven by two independently hardcoded 2s ("to position 2" in AutomationProposalServiceTests and Position.Should().Be(2) in ColumnServiceTests). Fix: one true parity contract test — build the proposal, parse the rendered destination out of the diff text, execute the reorder via ColumnService on the same board, assert applied position == parsed position. No shared literal; the Proposal reorder: preview shows requested position, apply silently clamps to end (preview≠apply, from #1339) #1370 regression class becomes structurally un-reintroducible.
  2. Degenerate in-range test. The rewritten in-range test uses position 2 on a 3-column board — exactly the clamp ceiling, so it cannot distinguish "renders requested" from "always renders the ceiling". Fix: strictly interior position (1 on 3 columns).
  3. Coverage gaps (all cheap): (a) single-column board preview (any requested position renders "to position 0"); (b) boundary-PASS — an exactly-50-op proposal previews cleanly through the new structure gate; (c) the contract validator's negative-reorder-position guard is uncovered; (d) pin the documented degraded path — when the board-column lookup fails, the preview renders the RAW requested position (behavior intentionally unchanged; test pins it).

LOW — wording

  1. ValidateNewCardIdAsync messages say "Create card targetId …" even when the validated id came from the cardId parameter fallback. Fix: neutral "Create card id …" (and see bot P2 below, which removes the fallback entirely).

Bot findings (both bot reviews inspected in full)

  • gemini-code-assist HIGH (clamp uses point-in-time columnNames.Count; preceding create/delete-column ops could change the count): REFUTED with evidence. Neither side of the contract admits a column-count-changing operation: ProposalOperationContractValidator.ValidateColumnFields rejects any column action other than reorder, and Apply's OperationHandlerRegistry.ExecuteColumnOperationAsync has only case "reorder" (anything else: "Unsupported column action"). Column count is invariant across a proposal's operation sequence, so the snapshot cannot diverge from apply's count due to preceding ops. Concurrent out-of-band changes are inherent to previewing live state and are re-validated at apply. The new parity contract test (finding 1) additionally locks preview to apply end-to-end.
  • gemini-code-assist MEDIUM (null Parameters would NRE in the structure validator): ACCEPTED — fixing. Choosing an explicit null rejection over the suggested ?. so null parameters fail closed with a ValidationError identically on preview and apply (both paths share this validator), instead of silently passing the size check.
  • chatgpt-codex-connector P2 (create-card id validation should come from targetId only — Apply's CreateCardAsync consumes operation.TargetId exclusively and ignores a cardId parameter): ACCEPTED — fixing. Verified: OperationHandlerRegistry line 76 passes only operation.TargetId into CreateCardAsync. Dropping the ?? cardId fallback so preview validates exactly the id source Apply consumes; when TargetId is absent Apply generates a fresh id, so there is nothing to collision-check. This also supersedes the wording concern's premise (finding 4) — rewording applied regardless.
  • chatgpt-codex-connector P2 (zero-op proposal with cached DiffPreview returns 200 while apply rejects with 400): VALID, PRE-EXISTING, OUT OF SCOPE for this PR (the early-return branch predates it). Tracked — see the seeded issue below, folded with its sibling asymmetry (proposal expiry never checked at diff time). Zero-skip: nothing dropped.

Disposition

Fixes 1–4 + the two accepted bot findings land in small commits on this branch; the out-of-scope pair gets one tracked issue referenced from this PR. Fix-evidence comment (finding → commit → verification) follows the push.

…view

Apply sources the created card's id exclusively from operation.TargetId
(OperationHandlerRegistry.CreateCardAsync ignores a cardId parameter), so the
preview-side collision check must validate exactly that id. Drop the cardId
parameter fallback: a create op with a cardId parameter and no targetId is
executed with a generated id, so preview no longer rejects it on a collision
Apply would never hit. Also reword the ValidateNewCardIdAsync messages to the
neutral 'Create card id ...' (chatgpt-codex-connector P2 + coordinator FIX 4
on PR #1374).
… validation

A null Parameters (legacy rows / nullable DB data) would throw
NullReferenceException in the shared structure validator on both preview and
apply. Fail closed with the same ValidationError on both paths instead; chose
an explicit rejection over a null-conditional so null cannot silently pass the
size check (gemini-code-assist MEDIUM on PR #1374). Adds unit tests for the
null rejection and the exactly-MaxOperationCount boundary pass.
… coverage

- True parity contract test: parses the destination position out of the
  rendered diff text and asserts ColumnService applies the reorder to exactly
  that position on the same board - no shared hardcoded literal, so the #1370
  regression class cannot be reintroduced without this test failing.
- De-degenerate the in-range preview test: strictly interior position 1 on a
  3-column board (position 2 equalled the clamp ceiling, so a degenerate
  always-render-the-ceiling bug would have passed).
- Coverage gaps: single-column board previews 'to position 0' for any request;
  exactly-50-op proposal previews cleanly through the structure gate; pin the
  documented degraded path (board-column lookup failure renders the RAW
  requested position - behavior intentionally unchanged).
(Coordinator FIX 1/2/3a/3b/3d on PR #1374.)
The contract validator's negative-position guard for column reorders was
uncovered; assert it fails preview with the same 'Invalid position: must be
non-negative' error the apply-side handlers produce (coordinator FIX 3c on
PR #1374).
@Chris0Jeky

Copy link
Copy Markdown
Owner Author

Consolidated Review — Fixes Applied

All in-scope findings from the two adjudicated lenses and both bot reviews are fixed; the out-of-scope pre-existing pair is tracked in #1376. Head is now d2dcc888.

# Finding Severity Fix commit Verified
1 Parity asserted via twin hardcoded literals LOW (test design) ea83a908 ProposalReorderPreview_ShouldMatchApplyOutcome_ForOvershootingPosition parses the destination out of the diff text and asserts ColumnService applies to exactly that position — no shared literal
2 In-range test degenerate at the clamp ceiling LOW (test design) ea83a908 test now uses strictly interior position 1 on a 3-column board and asserts NotContain("to position 2")
3a Single-column board preview uncovered LOW (coverage) ea83a908 GetProposalDiffAsync_ShouldPreviewPositionZero_ForReorderOnSingleColumnBoard (requested 5 renders "to position 0")
3b Structure-gate boundary-PASS uncovered LOW (coverage) ea83a908 GetProposalDiffAsync_ShouldPreviewProposalAtMaxOperationCount (50 ops preview cleanly, one line per op) + unit-level Validate_ShouldAcceptExactlyMaxOperationCount in 559f4568
3c Negative reorder position guard uncovered LOW (coverage) d2dcc888 ValidateAsync_ShouldRejectNegativePositionForColumnReorder (same error text as the apply-side handlers)
3d Degraded fallback (raw position on failed column lookup) unpinned LOW (coverage) ea83a908 GetProposalDiffAsync_ShouldFallBackToRawPosition_WhenBoardColumnLookupFails pins raw rendering; behavior intentionally unchanged
4 "Create card targetId" wording inaccurate for the fallback source LOW (wording) 730a362b messages now read "Create card id …"; existing substring assertions unaffected
B1 gemini HIGH: clamp uses point-in-time column count vs preceding create/delete-column ops REFUTED n/a evidence: ProposalOperationContractValidator.ValidateColumnFields and OperationHandlerRegistry.ExecuteColumnOperationAsync both accept ONLY reorder for columns — no proposal op can change column count; reply posted on the thread
B2 gemini MEDIUM: null Parameters NREs in structure validator FIXED 559f4568 explicit fail-closed ValidationError shared by preview and apply; Validate_ShouldRejectNullOperationParameters
B3 codex P2: create-card id validation must use targetId only (Apply ignores cardId param) FIXED 730a362b fallback dropped; verified OperationHandlerRegistry line 76 passes only operation.TargetId into CreateCardAsync; existing collision tests still green
B4 codex P2: zero-op proposal with cached DiffPreview returns 200 while apply 400s TRACKED #1376 pre-existing branch, out of scope here; folded with the expiry-at-diff-time asymmetry into one issue

Verification (local, Release): build 0 errors; AutomationProposalServiceTests 53 passed / 0 failed; ProposalOperationContractValidatorTests 24 passed / 0 failed; ProposalOperationStructureValidatorTests 2 passed / 0 failed; full Taskdeck.Application.Tests 3466 passed / 0 failed (+7 new tests over the previous push).

Zero-skip accounting: 8 fixed, 1 refuted with evidence, 1 tracked (#1376). Nothing dropped.

@Chris0Jeky
Chris0Jeky merged commit 7f3158b into main Jul 17, 2026
35 checks passed
@github-project-automation github-project-automation Bot moved this from Pending to Done in Taskdeck Execution Jul 17, 2026
Chris0Jeky added a commit that referenced this pull request Jul 17, 2026
)

* Gate proposal diff on expiry and structure to match apply

GetProposalDiffAsync never checked ExpiresAt and short-circuited zero-op proposals to a cached DiffPreview (200) or a 404, while apply's AutomationPolicyEngine.ValidatePolicy rejects both (expiry: ValidationError 'Proposal has expired'; empty: ValidationError 'Proposal must contain at least one operation'). Both let a reviewer preview a proposal apply always rejects (#1376 preview == apply, same class as #1370/#1374).

Run the executor's structure gate then expiry gate on every diff path (revised, original, and the cached-DiffPreview fast paths) in ValidatePolicy's order, returning the identical ValidationError shape. A non-expired, well-formed proposal's diff is unchanged.

* Cover preview==apply parity for expired and zero-op proposal diffs

Adds expiry-parity tests (original path with a true ValidatePolicy parity assertion, cached-preview fast path, and revised path) and zero-op rejection tests (with and without a stored preview). Retargets the two tests that locked in the old divergence: the cached-preview happy path now uses a non-empty proposal (byte-identical back-compat), and the former 404 zero-op test now asserts the apply-shaped 400.

* fix(automation): MEDIUM reuse domain IsExpired in diff expiry gate

The diff expiry helper duplicated the AutomationProposal.IsExpired expression (DateTime.UtcNow > ExpiresAt). Reuse the domain property so there is one source of truth for expiry; semantics and the ValidationError shape are identical to apply's ValidatePolicy (gemini-code-assist MEDIUM on PR #1395).

* fix(review): LOW update PaperReviewView diff-contract comments for #1376

The no-op guard and error-handler comments still described the pre-#1376 contract (404 when no stored DiffPreview and no operations). After PR #1395 the diff endpoint runs Apply's gates and returns 400 ValidationError for zero-op ('Proposal must contain at least one operation') and expired ('Proposal has expired') proposals; 404 now only means proposal-not-found. Comment-only, no behavior change (round-2 consumer-lens F1 on PR #1395).
Chris0Jeky added a commit that referenced this pull request Jul 17, 2026
Chris0Jeky added a commit that referenced this pull request Jul 17, 2026
…#1408)

* Docs: narrow #1395 parity claim and credit presence fixes in ledger row (post-merge #1405 Codex P2s)

* Docs: qualify the #1374 parity headlines to match the narrowed #1395 claim

* Docs: front-load ledger-row credits in jsonl and regenerate md via renderer
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.

Proposal reorder: preview shows requested position, apply silently clamps to end (preview≠apply, from #1339)

2 participants