Skip to content

Issues & Board Decisions

Distilled from 35 DECISIONS.md files (12 plans had no DECISIONS.md or no real tradeoffs). Each entry is a real fork in the road. Implementation details that aren't a tradeoff are NOT here.


Creator filter — client-side only, no backend changes

  • Chose: Add a matchesCreator() predicate alongside existing client-side matchers.
  • Rejected: Add user_id filtering to SearchIssuesAction and SearchIssuesRequest.
  • Why: Board/Backlog/Overview already load all issues upfront and filter in-memory; existing filters except text search work this way. A backend filter would be unnecessary scope.
  • Source: KD-0276

Creator filter options — derive from loaded issues, not project members

  • Chose: Build the dropdown from unique userId values in the currently loaded issues.
  • Rejected: Reuse buildAssigneeOptions and show all project members.
  • Why: Showing members who haven't created issues produces a noisy dropdown with phantom entries. Derived options stay relevant.
  • Source: KD-0276

Task type tint — Blue, not Purple or Gray

  • Chose: tint-blue (c-blue-4).
  • Rejected: tint-purple (collides with Highest priority), OR tint-gray (looks disabled).
  • Why: Blue is universally recognised for tasks (Jira, Linear), already defined in kendo.css, and visually distinct from green (Feature) and red (Bug).
  • Source: KD-0284

Task icon — Lucide circle-check, not square-check or plain check

  • Chose: Circle-check.
  • Rejected: Square-check (confused with checkboxes), OR plain check (too small).
  • Why: Most recognisable at the small h-3.5 w-3.5 size used on issue cards; matches Jira's task convention.
  • Source: KD-0284

Bulk select — Backlog only, not Board

  • Chose: Multi-select scoped to Backlog.vue and the sprint drag store.
  • Rejected: Both Backlog and Board.
  • Why: User story focused on sprint planning efficiency (the backlog workflow). Board is drag-drop focused; adding multi-select there is a separate initiative with its own selection state, layout, and shift-click logic.
  • Source: KD-0306

Bulk select affordance — highlight only, no checkboxes

  • Chose: Background colour on selected rows.
  • Rejected: Checkboxes + highlight; OR checkboxes only.
  • Why: Modern OS selection patterns (cmd+click, shift+click) are well-known to power users doing sprint planning. Highlight alone keeps the UI minimal and avoids row-clutter.
  • Source: KD-0306

Bulk move — drag selected together, not context menu

  • Chose: Cmd+click to multi-select, then drag any selected item to move all.
  • Rejected: Right-click context menu with "Move to Sprint" action.
  • Why: Backlog already uses drag-and-drop for single items — bulk drag is a natural extension. Context menus add a click and complicate the existing drag pipeline.
  • Source: KD-0306

Bulk-select range behaviour — shift+click selects range

  • Chose: Shift+click between last selected and current = full range.
  • Rejected: Shift+click toggles deselect (like cmd+click).
  • Why: Range selection is the standard desktop file-explorer pattern; the toggle alternative is less powerful and unfamiliar.
  • Source: KD-0306

Selection clears when filters change

  • Chose: Watch filters; clear selectedIssueIds Set on any change.
  • Rejected: Persist selection (move only visible items); OR warn-and-allow.
  • Why: Off-screen selected items are confusing — users wouldn't know what would actually move. Filters change the view; selection should reset.
  • Source: KD-0306

Bulk-assign endpoint — reuse /issues/update-board, not new bulk endpoint

  • Chose: Send epicId in the existing BoardUpdate payload.
  • Rejected: New POST /issues/bulk-assign-epic action+request+DTO.
  • Why: IssuePositionData already has ?int $epicId and UpdateIssueBoardAction already handles epic_id in batch SQL with audit + broadcast. Zero backend work; a parallel endpoint would duplicate logic.
  • Source: KD-0384

BulkActionBar — generic segmented control

  • Chose: Component accepts modes: {id, label}[] array.
  • Rejected: Hardcoded Sprint/Epic boolean toggle.
  • Why: KD-0495 (bulk assignee) is already filed — a generic shape avoids near-term refactor. Auto-confirm on dropdown selection (deviation from original plan): matches drag-drop's no-button behavior and saves bar width for a third mode.
  • Source: KD-0384

Epic-form issue list — show muted at bottom, don't hide

  • Chose: Issues already assigned to other epics appear muted and sorted last; checked items mix in their natural position.
  • Rejected: Hide them entirely; OR group into separate "Available" / "Assigned to other epics" sections.
  • Why: Reassignment should stay one-step (forcing users to unassign elsewhere first is friction). GroupSelect-style grouping would require reworking CardAssignment for one screen.
  • Source: KD-0385

Epic-form noise filter — hide by completed sprint, not by Done lane

  • Chose: Filter on sprintId === null || sprint.status !== Completed.
  • Rejected: Filter on "last lane by order" heuristic.
  • Why: Lane-order heuristic is fragile and hides issues recently moved to Done in the active sprint (useful context). Sprint-based filtering reads as "is the work period closed?" — semantically cleaner.
  • Source: KD-0385

Sprint selector — frontend filter only, backend stays permissive

  • Chose: Filter completed sprints out of the dropdown in IssueSidebar.vue, Edit.vue, Create.vue. SaveIssueRequest validates only Rule::exists.
  • Rejected: Add Rule::where('status', '!=', Completed) in the FormRequest.
  • Why: AC says "accidentally link" — UX guard, not policy. Retroactive linking via MCP/CLI/scripts (timesheet bookkeeping, retrospectives) is legitimate. Mirrors the epic dropdown pattern where the backend stays permissive.
  • Source: KD-0376

Sprint filter — pure helper, not a composable or store method

  • Chose: Pure filterSelectableSprints(sprints, currentSprintId?) in relations/sprints/helpers.ts.
  • Rejected: useSelectableSprints() composable; OR method on the adapter sprint store.
  • Why: Mirrors the sibling epics/filter-epic-issues.ts pattern; pure functions test trivially without store mocking; an adapter-store method fights the generic-factory pattern by injecting per-domain rules.
  • Source: KD-0376

Sprint dropdown — show empty field with "No sprints available", don't hide

  • Chose: Conditional placeholder text when the filtered list is empty.
  • Rejected: Hide the field with v-if (mirrors IssueForm.vue:65).
  • Why: IssueSidebar is a detail page — hiding/showing fields based on store state causes layout jumps and looks like a bug.
  • Source: KD-0376

Backlog assignee display — text name, not avatar

  • Chose: firstName lastName inline in the metadata strip.
  • Rejected: ProfilePicture avatar (consistent with board card).
  • Why: Backlog row already uses text for every other metadata item (lane, creator, date); introducing an avatar just for assignee is inconsistent within the same component.
  • Source: KD-0397

Backlog assignee fallback — show "Unassigned", don't hide

  • Chose: Always render the slot; null maps to "Unassigned".
  • Rejected: Omit the element when assigneeId === null.
  • Why: Consistent row layout matters; users see at a glance whether there's an assignee.
  • Source: KD-0397

No "Deleted user" fallback for assignee

  • Chose: Treat assigneeId === null as covering both "never assigned" and "user was deleted".
  • Rejected: Show "Deleted User" when the user is missing from the store, mirroring the creator fallback.
  • Why: DeleteUserAction runs update(['assignee_id' => null]) inside the deletion transaction — by the time the user is gone, every assignee FK is already null. The "Deleted User" branch is dead code.
  • Source: KD-0397

Optional ID coercion — frontend init null, backend input() !== null check

  • Chose: Fix both frontend (teamId: null) and backend ($safe->input('team_id') !== null ? $safe->integer('team_id') : null).
  • Rejected: Frontend-only fix; OR backend-only with special-case for 0; OR adopt the codebase's existing !== 0 convention.
  • Why: $safe->integer() silently casts null to 0 — the same bug class will recur if the frontend ever sends null. The !== 0 pattern is what caused the bug in the first place; checking actual null is semantically correct.
  • Source: KD-0400

Issue listing scope — per-screen filters, not generic pagination

  • Chose: Three new filter fields (sprint_status[], include_backlog, since) on the existing /search endpoint; Board/Backlog/Overview call it directly.
  • Rejected: Generic PaginatedResourceData<T> envelope, usePaginatedListFor composable, URL-synced state, new /issues/paginated route.
  • Why: Each listing page has a natural narrow scope (active sprint, non-completed sprints, recent date range) the server isn't using. Three filter fields plus three page migrations beat 6 new backend files plus a composable.
  • Source: KD-0415

Bypass adapter store for scoped reads

  • Chose: Pages keep a local ref<Issue[]> populated from direct /search calls; CRUD still routes through the store.
  • Rejected: Bump fs-adapter-store to accept a params arg on retrieveAll().
  • Why: Keeps the external package change off the critical path; pages get narrow local state without changing retrieveAll() semantics for unrelated consumers.
  • Source: KD-0415

WS-update merge helper lives in shared, not duplicated per page

  • Chose: Single applyIssueUpdate(localRef, fetchedIssue, filterFn) helper called by all three pages.
  • Rejected: Inline merge logic in each page.
  • Why: Three duplicates invite drift. Each page passes its own filter predicate to keep scope-aware insert/replace/remove behaviour.
  • Source: KD-0415

Tab endpoints — three dedicated routes, leave /search alone

  • Chose: New /projects/{id}/issues/backlog|board|recent endpoints with their own Actions and ResourceData.
  • Rejected: Keep one /search endpoint with a view=backlog|board|recent parameter; OR strip the now-unused tab-specific filters from /search in the same PR.
  • Why: /search is legitimately used by the kendo CLI for real text search; three dedicated routes document intent and isolate CLI risk. Polymorphic responses behind one URL would just rename the abuse.
  • Source: KD-0558

Active sprint resolution — server-side on the /board endpoint

  • Chose: BoardIssuesAction queries $project->sprints()->where('status', Active). /board takes no params.
  • Rejected: Keep client-side resolution via sprintStore.retrieveAll().
  • Why: "What is the active sprint?" rule lives in one place (backend) instead of duplicated in Board.vue and Backlog.vue; eliminates the client-side race where the sprint store lands after the user already triggered a refetch.
  • Source: KD-0558

Per-tab ResourceData classes, not shared IssueResourceData

  • Chose: New BacklogIssueResourceData, BoardIssueResourceData, RecentIssueResourceData with tab-specific EAGER_LOAD.
  • Rejected: Share IssueResourceData and vary EAGER_LOAD per Action.
  • Why: IssueResourceData::from() does loadMissing(self::EAGER_LOAD) inside, forcing all four relations regardless of caller; sharing saves nothing. Each tab's resource also halves its byte payload by skipping fields it doesn't render.
  • Source: KD-0558

Broadcast keeps IssueResourceData; frontend projects to slim shapes

  • Chose: One broadcast event, three to{Backlog,Board,Recent}IssueResource projection helpers on the client.
  • Rejected: Per-tab broadcasts (ProjectBacklogIssueUpdate, ProjectBoardIssueUpdate).
  • Why: Issues cross tab scopes (a backlog issue dragged into the active sprint becomes a board issue). Per-tab events force the broadcaster to either compute diffs server-side or fan out three events per change — more bandwidth and substantial surgery on IssueBroadcaster/IssueObserver.
  • Source: KD-0558

Drag factory — push no-op detection into the factory, drop retrieveAll parameter

  • Chose: Compare current data.value against a freshly derived layout; early-return before incrementing updateCount.
  • Rejected: Keep optional retrieveAll parameter; OR snapshot before drag and compare after.
  • Why: Both pages already pass retrieveAll: () => Promise.resolve() — the call site is dead code. Pre-bumping updateCount on no-ops also invalidates in-flight genuine drags. The factory-level guard catches both drag no-ops and bulk no-ops (empty selection paths) that the consumer-side guard missed.
  • Source: KD-0450

My Issues — port "exclude final lane" as a DTO flag, no schema changes

  • Chose: excludeFinalLane: bool on GlobalSearchIssuesData; Action applies a MAX(order)-per-project subquery only when set.
  • Rejected: Add an is_final column to lanes with migration + backfill + settings UI; OR client-side filter via a new cross-project lanes endpoint.
  • Why: Keeps the refactor tight. Mirrors the existing SearchIssuesData.includeBacklog per-view-flag pattern. Can be superseded later by a semantic is_final column without breaking the flag contract.
  • Source: KD-0468

Slim resource shape shared by /issues/search and /issues/my

  • Chose: Rename UserIssueResourceDataIssueSearchResourceData; both endpoints return the same shape.
  • Rejected: Keep separate UserIssueResourceData and add a separate MyIssuesResourceData; OR inflate IssueResourceData with denormalized project_name, lane_title, lane_color.
  • Why: IssueResourceData lacks the cross-project denormalized fields needed for the My Issues table; inflating it would balloon Backlog/Board payloads. Two near-identical resource classes for two endpoints in the same cluster is duplication for no semantic win.
  • Source: KD-0468

Delete /api/my-issues route entirely, don't keep as proxy

  • Chose: Remove UserIssueController, UserIssueRequest, IndexUserIssuesAction, UserIssuesData and their tests.
  • Rejected: Keep the route as a thin proxy over GlobalSearchIssuesAction.
  • Why: No grep-visible CLI/MCP/external consumers; KD-0239 explicitly marked this migration as the intended end state. Dead-weight forever vs. real cleanup.
  • Source: KD-0468

Raise GlobalSearchIssuesAction::MAX_LIMIT from 100 to 500

  • Chose: Bump cap and update validation rule to max:500.
  • Rejected: Keep 100 cap and paginate inside MyIssuesController; OR accept silent truncation at 100 as the new behavior.
  • Why: Today's IndexUserIssuesAction is uncapped; capping at 100 silently truncates for heavy users — a regression. Server-side pagination is declared out of scope; Backlog.vue already requests limit: 500 so the precedent exists.
  • Source: KD-0468

Empty-string regression — enforce min:1 in validator, don't restore toDto() normalisation

  • Chose: Add min:1 to nullable string fields in the FormRequest's rules().
  • Rejected: Restore $x !== '' ? $x : null in toDto(); OR add a shared coerceNullable helper.
  • Why: Restoring the coercion silently masks client bugs and reverts the intentional D7 canonical guard. The validator is the right contract layer; toDto() stays a dumb pass-through.
  • Source: KD-0521

RichTextArea null coercion lives in the component, not at call sites

  • Chose: Update Tiptap's onUpdate handler to emit editor.value?.getMarkdown() || null.
  • Rejected: Coerce in AiStoryPrompt.vue between RichTextArea and defineModel; OR coerce at the issue store HTTP boundary.
  • Why: Tiptap returning '' for an empty editor is an implementation detail of the component, not a parent contract. Encapsulation belongs in the component; call-site coercion duplicates logic for every consumer.
  • Source: KD-0521

English user stories — translate the entire 517-issue board across three PRs

  • Chose: Sequenced PR-A (skills + 85 active KD), PR-B (340 Done KD), PR-C (92 IT/EIP legacy).
  • Rejected: Active KD only (85 issues); OR all KD in one bundled PR.
  • Why: Search consistency for mcp__kendo__search-issues-tool and RAG embeddings demands single-language content board-wide; one PR with ~500 mutations + skill edits + ~3000-line snapshot diff is unreviewable. Mutation of other-authored Done issues is fine because titles+descriptions were authored by Jasper or Kendo Bot via /triage-reports.
  • Source: KD-0530

Translate descriptions only, leave comments Dutch

  • Chose: Migration touches title + description; comments stay as-is.
  • Rejected: Translate descriptions + all comments.
  • Why: Comments are conversational artefacts that often quote deleted UI strings; AI translation loses nuance. Search backend targets title, description, key only — mixed-language comments have zero impact on retrieval quality.
  • Source: KD-0530

Translation tooling — kendo CLI, not MCP loops

  • Chose: Bash script wrapping kendo issue update per issue with progress tracking in-worktree.
  • Rejected: mcp__kendo__update-issue-tool per-issue loop.
  • Why: MCP tokens expired mid-batch during prep, leaving the backlog half-translated; CLI auth is stable and idempotent for long-running automation.
  • Source: KD-0530

Self-assign orchestration — event-emit-up + thin store helper, not inline HTTP in the handler

  • Chose: DragElement emits self-assign; Board.vue calls an exported updateIssueAssignee helper from store.ts, then reconciles via the existing applyIssueResource path.
  • Rejected: Inline putRequest directly in Board.vue's onSelfAssign handler (YAGNI — the mutation is single-use today).
  • Why: The deciding factor is test-mocking boundaries, not future reuse — the __mocks__/store.ts convention makes the store helper trivially mockable at the module layer, whereas an inline call forces HTTP-service-level mocking with a wider blast radius that leaks HTTP concerns into component tests.
  • Source: KD-0423

Self-assign commit timing — optimistic write, not deferred buffer

  • Chose: Fire the write on click; flip the icon immediately; reverse-write on undo within the 5 s window.
  • Rejected: Buffer the click as pending UI state and only fire the backend write if undo wasn't pressed.
  • Why: Optimistic matches the existing sidebar assignToUser flow and keeps Echo broadcast semantics clean (other clients see changes immediately, not 5 s later); the ≤ 5 s window where the DB holds a "wrong" value self-corrects on undo and is acceptable.
  • Source: KD-0423

Undo-window concurrent edit — unconditional reverse write, accept the rare race

  • Chose: Undo always writes assigneeId: null regardless of any concurrent reassignment from another client during the 5 s window.
  • Rejected: Check the current value at undo-click time and skip the write if it changed (or subscribe to Echo inside the toast and auto-dismiss on concurrent change).
  • Why: The race window is ≤ 5 s and extremely rare; defensive-check needs new copy ("Issue was reassigned — undo cancelled") and Echo-subscribed toasts couple toast lifecycle to broadcast state for almost no real-world payoff.
  • Source: KD-0423

Toast undo affordance — extend BaseToast with an optional action prop, not a new component

  • Chose: Add action?: { label, handler } to the shared BaseToast.vue; introduce undoableSuccessToast(message, handler) helpers in both tenant + central apps, and bring BaseToast.vue into the coverage gate in the same PR.
  • Rejected: A separate UndoableToast.vue component, OR a 5th action variant that diverges the visual shell (no progress bar, inline button).
  • Why: A separate component duplicates the visual shell (header, progress bar, animations) and invites style drift; extending the existing component is ~15 lines of additive prop, and any meaningful behaviour added to a previously coverage-excluded shared component must drop the exclusion now — leaving it half-tested is a worse signal than the test-debt cost of including it.
  • Source: KD-0423

Frontend permission gate — pass entityOwnerId as 4th arg, not 3-arg copy of the CREATE gate

  • Chose: canInProject(ISSUES, UPDATE, project.userId, issue.userId) for the self-assign affordance — gives the scope=Own branch the entity owner it needs to resolve.
  • Rejected: Copy the textually nearby 3-arg canInProject(ISSUES, CREATE, projectUserId) pattern (used by the "Add Issue" button above it in the same file).
  • Why: CREATE is boolean and has no scope; UPDATE is scope-gated (None/Own/All). Dropping the 4th arg silently blocks scope=Own users from acting on issues they created — making the frontend stricter than the backend. The 4-arg shape is exactly what canInProject was designed for; future scope-gated checks copying from a CREATE call site should add it.
  • Source: KD-0423

Single-field issue mutations — dedicated narrow PUT endpoint, not broad PUT or PATCH

  • Chose: New PUT /projects/{id}/issues/{id}/assignee with its own Action, FormRequest, Input DTO, and controller method; broadcast/audit/notification parity achieved by injecting the same collaborators as UpdateIssueAction.
  • Rejected: Broad PUT to the existing /{issue} endpoint with a full echoed payload (requires GET-then-PUT on each click), OR adding the codebase's first Route::patch declaration to keep the original PATCH-style frontend.
  • Why: Broad PUT doubles latency on the cheapest-possible affordance and is fragile against IssueResource vs SaveIssueRequest drift; softening SaveIssueRequest with sometimes rules weakens the create path as a side effect. A narrow PUT is idempotent at the verb level, has single-purpose validation, and matches the codebase's PUT-only convention — five files of cost in exchange for isolation from the create path.
  • Source: KD-0423

Hard-coded audit-test list must be updated when adding narrow-update Actions

  • Chose: Add new narrow-update Action classes (e.g. UpdateIssueAssigneeAction) to the hard-coded expect([...])->toUse(IssueAuditLogger::class) block in tests/Arch/AuditTest.php as part of the same PR.
  • Rejected: Rely on the auto-discovery rule that reconstructs 'Update' . 'Issue' . 'Action' and strict-equals against the action class short-name.
  • Why: Auto-discovery only matches the canonical UpdateIssueAction name; sibling narrow-update Actions slip past it, so even though the new Action declares the audit logger today, dropping that dependency in a future refactor would silently pass CI. The "Actions touching auditable models depend on the audit logger" invariant is aspirational, not literal, until the list is extended.
  • Source: KD-0423

Sprint capacity aggregation — inline reduce in the caller, not a helper module

  • Chose: Compute {totalEstimatedMinutes, unestimatedCount} per row inline in Backlog.vue; pass plain numeric props to SprintRow.vue.
  • Rejected: Export a getSprintTotals(issues) helper from relations/sprints/helpers.ts for future reuse.
  • Why: One call site and one render site means a helper is a shallow module with no second consumer — the kind simplicity-reviewer flags. Promote to a helper only when a second surface (board, sprint dropdown, modal) actually emerges; out-of-scope surfaces don't justify the abstraction up front.
  • Source: KD-0602

Capacity badges render on Backlog row too, not just Sprint rows

  • Chose: Render the time and unestimated-count badges on both real Sprint rows and the synthetic {id: null, title: 'Backlog'} row; no v-if="sprint.id" carve-out on the new badges.
  • Rejected: Restrict the badges to real sprints (strict reading of "sprint total").
  • Why: Planners ask the same capacity question of the backlog — "how much unsized work is sitting there?" — so symmetric treatment beats a carve-out, and the existing v-if="sprint.id" on edit/delete buttons stays in place because those genuinely don't apply to the Backlog.
  • Source: KD-0602

Comment pagination — client-side slice, no backend envelope

  • Chose: Paginate the already-loaded comment array client-side with the shared paginate() helper + PaginationBar.
  • Rejected: Server-side ?page/?per_page with a new PaginatedListResourceData<T> envelope.
  • Why: The user-visible cost is DOM render time, not transport — the full list already loads fast; a backend envelope doubles the surface (per-page composable, off-page broadcast semantics, permalink probe loop) for a render-cost problem no real user is pulling on. Two earlier branch reversals were spent on exactly that complexity.
  • Source: KD-0505

Epic status — derived from issue lanes, not a stored column

  • Chose: Drop the status column entirely and derive it from issue lane positions (binary Open/Closed) in each layer that needs it.
  • Rejected: Keep status stored with automatic backend transitions wired into every issue-mutation Action.
  • Why: Stored status duplicates data the frontend already computes from lanes, so automation only papers over a sync problem; deriving eliminates the redundant state and the sync-bug class. Binary beats three-state because the progress bar already shows the granular ratio — status only answers "done or not?".
  • Source: KD-0696

Filter-dropdown population — loaded-issues rule, not backend-derived is_closed

  • Chose: Build epic filter options from epics that appear in the page's currently-loaded issues (promote the existing MyIssues pattern to all four pages).
  • Rejected: Add backend-computed is_closed to EpicResourceData plus an EpicBroadcaster cascade fanning out from ~7 Action touchpoints.
  • Why: No consumer of a backend is_closed exists yet, so building derivation + a broadcast-storm-prone cascade for one frontend reader is YAGNI; the ~4-line frontend rule is reactive for free via the existing issue broadcast and converges divergent per-page logic onto one pattern.
  • Source: KD-0696

Issue-template AC — outcome framing, not a fixed example tweak

  • Chose: Add a short paragraph teaching outcome-vs-mechanism before the AC template, scoped per issue type (Feature = user-observable outcome; Task = level appropriate to the structural/infra/research subtype).
  • Rejected: Keep "concrete, testable" and just fix the example AC.
  • Why: The KD-0506 failure (a ticket prescribing useTitle when only the outcome was the contract) came from writers lacking the principle, not from a bad example — teaching by imitation leaves the rule invisible, and a universal AC rule doesn't hold because refactoring/infra tasks legitimately specify mechanism.
  • Source: KD-0731

Template safety — structure people, don't rely on "everyone knows it's optional"

  • Chose: Reject an optional ## Proposed solution section whose non-binding nature depends on every reader knowing the convention.
  • Rejected: Add the section with a "Proposed = non-binding" norm.
  • Why: A norm whose safety depends on cultural sophistication fails for new hires and tired senior devs; set people up for success with the structure itself, not with a convention that only works when everyone is calibrated.
  • Source: KD-0731

Board ordering — fractional-rank string on the existing column, not a position pivot

  • Chose: Replace integer order with a sparse fractional-rank rank varchar sorted lexicographically; a move writes one row (the dragged issue's midpoint between neighbours).
  • Rejected: A (lane_id, issue_id, position) pivot table, OR keeping order int and diffing only changed rows on the frontend.
  • Why: The CASE-WHEN bulk rewrite bumped updated_at on every row (corrupting "My Issues" sort) and was a deadlock + fat-broadcast surface; a pivot adds a read-path JOIN and splits lane membership across two tables, while diff-only still shifts multiple rows' updated_at on any cross-lane drag. Fractional rank is the closest "single drag = single write" with no read-path cost; ranks degrade gracefully (just get longer) rather than break.
  • Source: KD-0789

Rank algorithm — server resolves from neighbour IDs, client never computes

  • Chose: Frontend sends rank_before_id/rank_after_id (both nullable for top/bottom); the server reads neighbour ranks in-transaction and computes the midpoint.
  • Rejected: Client computes the rank string and POSTs it, OR both compute (FE optimistic, BE authoritative).
  • Why: Server-only keeps the base-26 algorithm in one language and makes the DB the authoritative owner; client-computed ranks duplicate the algorithm in PHP+TS, and concurrent drags can compute identical ranks unless the backend validates anyway — making the FE value untrustworthy.
  • Source: KD-0789

Rank collision — UNIQUE index + retry, not FOR UPDATE on neighbours

  • Chose: UNIQUE (project_id, rank) index; on duplicate-key, retry the whole transaction with fresh neighbour reads via a hand-rolled outer loop.
  • Rejected: FOR UPDATE lock on the two neighbour rows inside the transaction.
  • Why: FOR UPDATE serializes the writes but doesn't prevent identical midpoints — the neighbours themselves never change (the new row inserts between them), so both transactions read the same two ranks and compute the same value; only a UNIQUE index is a true collision detector. (Laravel's built-in retry budget doesn't fire on 1062 Duplicate entry — it only covers SQLSTATE 40001 deadlock/lock-wait, so unique-violation recovery needs an explicit outer loop.)
  • Source: KD-0789

Rank coordinate space — per-project, not per-lane

  • Chose: One rank coordinate per project (Atlassian LexoRank shape): UNIQUE (project_id, rank), backfill ordered (lane.order, issue.order, issue.id).
  • Rejected: Per-lane rank space (lane_id, rank) (the original design), OR a second sprint_rank coordinate.
  • Why: The Backlog groups by sprint, not lane, so a sprint section interleaves issues from multiple lanes; with per-lane ranks a cross-lane Backlog drop computes a midpoint between neighbours from unrelated coordinate spaces, producing meaningless or colliding ranks. A single project-wide space makes Board and Backlog coherent slices of one ordering; the send/receive shape is unchanged — only the space the rank lives in moves.
  • Source: KD-0789

Position writes are not audited — drop order from auditable fields, don't add rank

  • Chose: Remove order from AUDITABLE_FIELDS and never add rank; lane/sprint/epic changes stay audited.
  • Rejected: Add rank to auditable fields (every drag writes an audit row), OR conditionally skip writing for rank-only changes.
  • Why: Within-lane reorders are semantically empty and the Activity tab never displayed order anyway; auditing rank just moves the updated_at-churn problem to the audit table. Keeping lane/sprint/epic audited preserves the user-visible "moved from To Do to In Progress" entries.
  • Source: KD-0789

Drag permission gate — keep the looser owner-bypass arg, document it

  • Chose: IssuePolicy::move passes $user->id as the owner-bypass arg (preserving "any project member with Update can drag any issue"), with the bound $issue arg decorative.
  • Rejected: Pass $issue->user_id to match the canonical 3-arg update shape.
  • Why: The 3-arg shape silently tightens to "issue owner OR Update-on-others", a UX regression for roles configured without update-on-others; preserving today's looser semantics avoids the regression — but it must be documented so reviewers and the follow-up PR don't "fix" it to match update.
  • Source: KD-0789

Two-PR migration — add+backfill+switch the hot path, then drop the column

  • Chose: PR1 adds rank, backfills, switches only the drag path, and keeps order populated by all other callers; PR2 migrates the remaining ~7 callers and drops the column.
  • Rejected: One PR that adds, backfills, switches every consumer, and drops in a single migration.
  • Why: The single PR has a massive review surface and a hard rollback once the column is gone; the two-PR shape gives one deploy where both columns are consistent (easy rollback) and isolates the drop as a reviewable cleanup. The accepted cost is a brief write-side 500 spike during PR2's deploy window — bounded at kendo's scale and operator-visible via alerting, so a PR2a/PR2b split to eliminate it isn't worth the extra issue/branch/review/deploy coordination.
  • Source: KD-0789 / KD-0796

Label exposure on issues — label_ids, not nested label resources

  • Chose: IssueResourceData exposes label_ids: list<int> via pluck; the frontend joins through a project-scoped labels store loaded once.
  • Rejected: Nest full LabelResourceData[] inline on every issue payload.
  • Why: Labels are stable project-scoped reference data (like lanes/sprints/epics, which all use the IDs-only shape), not per-issue dynamic data — nesting duplicates the taxonomy on every issue payload, balloons board/backlog bytes, and re-hydrates on every issue write.
  • Source: KD-0804

Label realtime — reuse the Issue channel for assignment; defer a Labels-CRUD channel

  • Chose: Fire the existing IssueBroadcaster::updated($issue) on label sync (the eager-loaded labels ride the existing Issue channel); defer a dedicated Labels-CRUD broadcast channel to a follow-up.
  • Rejected: Add DomainEnum::Labels + Label events + channels.php wiring + arch-test coverage now, OR no broadcast at all.
  • Why: Issue-label assignment is the high-value axis and propagates with one backend touch and zero new channel surface; label CRUD (rename/recolor in settings) is rare single-admin territory that can tolerate refetch-on-focus, so building the full channel slice inside a frontend-scoped issue is scope creep.
  • Source: KD-0806

Label delete — silent detach, no destination, no count

  • Chose: DeleteLabelAction detaches the pivot from all issues and deletes; the delete UX is confirm-only.
  • Rejected: Throw a LabelInUseException requiring a force flag (mirror Lane), OR show an impact count / destination select.
  • Why: Unlike a lane (a mandatory state — every issue must have one), a label is optional taxonomy; losing it is metadata removal, not a state-machine violation, so blocking or requiring a destination is friction, and an impact count would need LabelResourceData to carry issues_count for no real gain.
  • Source: KD-0804 / KD-0805

BelongsToMany never goes in cascadeRelations() — tear down pivots imperatively

  • Chose: Label::cascadeRelations() returns []; issue_label pivot rows are torn down explicitly inside DeleteProjectAction/DeleteLabelAction.
  • Rejected: Declare ['issues'] in cascadeRelations() to mark the ownership.
  • Why: The arch-test invariant restricts cascadeRelations() entries to HasMany/HasOne/MorphMany and requires a matching Delete{Model}Action per entry — a BelongsToMany like issues is excluded by convention codebase-wide; pivot cleanup is a transaction-level concern of the parent delete, not a model-level cascade declaration.
  • Source: KD-0803

Rank compaction — full project respread, not LexoRank buckets

  • Chose: A RespreadProjectRanksAction that rewrites all of a project's ranks to short evenly-spaced values, reusing the migration's base-26 spread math.
  • Rejected: A LexoRank bucket prefix column + (project_id, bucket, rank) index with an atomic active-bucket flip.
  • Why: For kendo's project sizes (largest ~781 issues) a respread needs no schema change, no bucket-aware reads, and no FE drag-store changes — it reuses proven write code; the bucket approach is materially more arch surface for a degradation problem that telemetry hasn't shown to need it.
  • Source: KD-0808

Respread runs synchronously, not as a queued job

  • Chose: Run the respread inline inside MoveIssueAction/BulkMoveAction (which already hold tenant context) and from the nightly sweep's tenant-switched loop.
  • Rejected: A queued RespreadProjectRanksJob + 503/Retry-After response.
  • Why: kendo's queue connection is pinned to the central DB with no tenant context (deptrac forbids Jobs → Tenancy), so a job would be kendo's first tenant-scoped job — novel tenancy surface; the wall is rare enough (the mid-alphabet anchor means normal usage never hits it) that a rare slower request beats inventing a whole queue/tenancy class of complexity, and the job shape stays documented as the escalation lever if lock contention ever bites.
  • Source: KD-0808

Respread broadcasts nothing — other clients self-heal

  • Chose: No broadcast event and no lanes_hash bump after a respread; stale ranks self-correct on the next fetch, and a stale-neighbour drop is absorbed by the existing UNIQUE+retry.
  • Rejected: A consolidated IssuesRespreadEvent + FE refetch and a lanes_hash bump.
  • Why: The respread is rare and its only correctness consequence (a stale-neighbour drop) is already self-healed by the collision retry, so a new event + FE subscriber isn't worth a rare cosmetic window; the lanes_hash bump is dead weight because that hash gates per-lane resource fields (issues_count, github triggers) that a respread doesn't change — issues stay in their lanes.
  • Source: KD-0808

Pinned comment state — is_pinned boolean on comments, not pinned_comment_id FK on issues

  • Chose: Per-comment is_pinned boolean column, single-pin invariant enforced in the action's transaction.
  • Rejected: A pinned_comment_id nullable FK on issues that structurally enforces single-pin and is race-safe.
  • Why: The boolean rides the existing comment realtime path with zero cross-store coordination and clean delete semantics, whereas the FK splits pin state across two stores (issue vs comment) and needs explicit FK-clearing on delete (ADR-0002 forbids ON DELETE SET NULL) — cohesion beats the rare, self-healing double-pin race.
  • Source: KD-0513

Pin permission — gate on Issues:Update, not Comments:Update owner-bypass

  • Chose: New policy methods keyed on the Issues resource with no owner-scope arg, so authorship grants no pin right.
  • Rejected: Reuse Comments:Update, whose owner bypass lets any author pin their own comment to the top.
  • Why: Pinning curates the issue's comment section (issue-management capability), not comment ownership — so it must follow "can manage this issue", not "did I write this comment".
  • Source: KD-0513

Bulk-delete audit — per-issue loop, not one batch entry

  • Chose: Call logDeleted() per issue inside the transaction, snapshotting each issue's fields.
  • Rejected: A single summary audit row for the whole bulk operation.
  • Why: Variant parity with single DeleteIssueAction is the stronger property (a bulk delete of 3 reads the same as 3 single deletes) and per-issue snapshots keep forensics reconstructable; the extra N writes are absorbed by the hash-chain deadlock-retry budget.
  • Source: KD-0625

Responsive threshold — UnoCSS breakpoint, not a JS isNarrow ref

  • Chose: Promote the 1100px threshold to a theme.breakpoints entry and drive layout with lt-narrow: utilities.
  • Rejected: A JS-driven isNarrow ref plus a scoped <style> block in the component (the existing precedent).
  • Why: A pure-display concern belongs in display config, not JS state — one source of truth for the threshold, reusable across components, and it avoids conflating input (pointer/touch) with viewport width; the JS ref stays only where conditional rendering (not styling) is needed.
  • Source: KD-0724

Lane overflow sizing — per-lane CSS min-width, not JS-measured widths

  • Chose: Each lane declares min-w-<value>; the flex container overflows and overflow-x-auto activates natively.
  • Rejected: A ResizeObserver on the container that measures and distributes widths to lanes in JS.
  • Why: Letting the browser do the sizing eliminates reflow-timing bugs and edge cases when lanes are added/removed — no measurement code, no JS in the layout path.
  • Source: KD-0724

Multi-label filter — OR semantics, not AND

  • Chose: An issue matches if it carries ANY selected label (whereIn on the backend, Set-membership on the frontend).
  • Rejected: ALL/AND semantics (issue must carry every selected label).
  • Why: OR is consistent with every other filter dimension (assignee/epic) and the existing whereIn pattern; AND would be a surprising one-off and is not built.
  • Source: KD-0828

Create-another reset — full form reset, not partial preserve

  • Chose: Full reset() after each "Create another" submit (then re-set projectId).
  • Rejected: Partial reset that preserves sprint/assignee to speed batch creates within one sprint.
  • Why: The spec language "ready for the next issue" implies a clean slate; partial preserve adds field-by-field clearing complexity for a convenience the spec didn't ask for.
  • Source: KD-0847

Scoped-read fetcher — module-level local ref, not an adapter-store upsert

  • Chose: A plain fetchBlockingIssuesForIssue held in a local ref, joining the existing scoped-fetcher family.
  • Rejected: Upserting the fetched issues into issueStore via extend/retrieveInto.
  • Why: Blocking issues use the lean IssueListResource shape, so upserting them into the full-Issue store would make getById return a partial issue missing description/comments — store pollution; a local ref keeps the lean shape isolated from full-issue consumers.
  • Source: KD-0944