From 4fade1a9d3c378303b91236ed434160635b7d6cb Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Tue, 8 Sep 2026 12:23:04 -0400 Subject: [PATCH] Reject the semantic inverse of an existing ticket dependency (#51) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit addDependency()'s "already exists" check only matched the exact (ticket_id, depends_on_id, dependency_type) tuple. A user could add "A blocks B" from ticket A's page, then separately add "B blocked_by A" from ticket B's page — wouldCreateCycle() correctly found no cycle (both normalize to the same precedence edge), so the insert was allowed, creating two DB rows describing one real relationship (shown twice on ticket B's page: once under Dependencies, once under Dependents). Added an inverse-relationship check before the insert: blocks/ blocked_by are inverses of each other, relates_to is its own inverse (symmetric). duplicates has no defined inverse type in the schema, so both directions remain independently insertable, which is correct — "A duplicates B" and "B duplicates A" are distinct claims. Verified against a local MariaDB instance: the exact repro from the issue (A blocks B, then B blocked_by A) is now rejected, relates_to's symmetric case is rejected in both directions, and duplicates in either direction is unaffected. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01MGDKHiU5RJdo3dqQUDow3X --- models/DependencyModel.php | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/models/DependencyModel.php b/models/DependencyModel.php index a7caf31..8f37899 100644 --- a/models/DependencyModel.php +++ b/models/DependencyModel.php @@ -172,6 +172,27 @@ class DependencyModel } $checkStmt->close(); + // Also check the semantic inverse: "A blocks B" and "B blocked_by A" + // describe the same relationship, so adding one from either ticket's + // page must be rejected as a duplicate of the other. relates_to is + // its own inverse (symmetric); duplicates has no defined inverse type. + $inverseTypes = ['blocks' => 'blocked_by', 'blocked_by' => 'blocks', 'relates_to' => 'relates_to']; + if (isset($inverseTypes[$type])) { + $inverseType = $inverseTypes[$type]; + $checkInverseSql = "SELECT dependency_id FROM ticket_dependencies + WHERE ticket_id = ? AND depends_on_id = ? AND dependency_type = ?"; + $checkInverseStmt = $this->conn->prepare($checkInverseSql); + $checkInverseStmt->bind_param("sss", $dependsOnId, $ticketId, $inverseType); + $checkInverseStmt->execute(); + $inverseResult = $checkInverseStmt->get_result(); + + if ($inverseResult->num_rows > 0) { + $checkInverseStmt->close(); + return ['success' => false, 'error' => 'This relationship already exists']; + } + $checkInverseStmt->close(); + } + // Check for circular dependency if ($this->wouldCreateCycle($ticketId, $dependsOnId, $type)) { return ['success' => false, 'error' => 'This would create a circular dependency'];