Skip to content

Issues & Board Decisions

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

Serializer loading — remove the loadMissing self-heal, let the validator fail loudly

  • Chose: Delete loadMissing(EAGER_LOAD) from the resource factories so validateRelationsLoaded() throws MissingRelationException on an under-loaded model.
  • Rejected: Keep the self-heal as the safety net it was deliberately introduced to be.
  • Why: The self-heal and the validator are mutually exclusive in effect — loadMissing guarantees the gate can never fire, so it reduces the N+1 guard to theatre and silently rescues callers who forget, producing per-row queries inside collections with no failure signal. Only one of the two can have teeth.
  • Source: KD-0647

Eager-load orchestration gets its own layer, not the Resource and not the Model

  • Chose: A dedicated app/Hydrators/ layer that reads the resource's EAGER_LOAD* spec, with deptrac edges {Actions, Broadcasting, Controllers} → Hydrators → {Models, Resources}.
  • Rejected: A static applyEagerLoad() on the resource; OR moving the constants onto the Issue model; OR a general-purpose EagerLoader for all Issue loading; OR $issue->hydrate() as model behaviour.
  • Why: Deptrac forbids Actions → Resources, so a resource-side helper is unreachable from the ten Action call sites that need it — which is exactly why the profile was being laundered through an IssueBroadcaster::EAGER_LOAD re-export. The model can't own it either: EAGER_LOAD is "what this output shape needs", a consumer judgment rather than model self-description, and eager loading serves five unrelated purposes (serialization, authz, audit, business logic, MCP) that share no profile, so a god-loader would be wrong by construction.
  • Source: KD-0647

Hydrator exposes two named verbs, not one polymorphic entry point

  • Chose: Separate builder-side applyTo(Builder) (with/withCount/withSum) and model-side hydrate(Model) (load*), mirroring Laravel's own with/load split.
  • Rejected: A single hydrate(Builder|Model|Collection) front door dispatching on type.
  • Why: The whole refactor exists because hidden hydration cost is an N+1 trap the caller can't see; a polymorphic door reintroduces that opacity one level up, since the call site can't tell whether it is getting cheap aggregate subqueries or expensive per-instance loads unless the cost distinction lives in the method name.
  • Source: KD-0647

One hydrator per resource profile, not one per model

  • Chose: IssueHydrator / IssueListHydrator / IssueSearchHydrator, each bonded to exactly one resource's spec.
  • Rejected: A single IssueHydrator covering all three Issue serialization profiles.
  • Why: The profiles are not nested. The list profile is a strict subset, so a shared hydrator over-loads four heavy relations on the highest-cardinality board and bulk-broadcast paths — re-introducing the over-hydration the work exists to kill. The search profile is disjoint (project/lane/epic appear in no other profile), so a shared hydrator cannot serve it at all and that path would throw the moment the self-heal was removed.
  • Source: KD-0647

Abstract base owns the load sequence — resource declares demand, hydrator supplies it

  • Chose: One abstract Hydrator writing the load sequence behind three final verbs; subclasses declare only resource() (the profile bond) plus an optional forcedNestedLoads() hook.
  • Rejected: Hand-rolled per-profile hydrators, each carrying only the verbs its own callers needed.
  • Why: No two hand-rolled hydrators looked alike, so "it's a Hydrator" told a reader nothing about which verb existed, and each duplicated a loadSumArgs() helper that unpacked only the first entry of the EAGER_LOAD_SUM map while the validator iterated the whole map — a second sum entry would be demanded by the gate and loaded by no verb. Iterating the map once on the base deletes the footgun and the duplication together; moving branchLinksCountEagerLoad() (pure load mechanism) off the resource leaves it holding only the declarative constants.
  • Source: KD-0647

Whoever serializes, hydrates — broadcaster and controllers, never Actions

  • Chose: Hydration lives wherever from() is called: the broadcaster hydrates its own profile per method, controllers hydrate before the HTTP response, and all ~16 Issue Actions hold zero hydrators.
  • Rejected: The earlier "caller hydrates, broadcaster trusts" contract, where each Action injected the matching hydrator and hydrated before broadcasting.
  • Why: An Action's job is to mutate state, not to prepare a serialization payload — and an Action driving two disjoint broadcast profiles ended up injecting two hydrators and hydrating one model twice for a single save. Making hydration a precondition of from() puts it in exactly one place per serialize, with an array_key_exists (not isset) aggregate guard so a second hydrate of the same instance is free: isset would re-fire on a legitimately-null sum over zero rows.
  • Source: KD-0647

Enforcement flips are atomic; the rollout that precedes them is not

  • Chose: Add the hydrate calls incrementally across as many PRs as needed, then remove all three loadMissing calls and the broadcaster's defensive guards in one delete-only commit.
  • Rejected: One PR that adds hydration and removes the self-heal together; OR flipping the three from() methods in separate sweeps.
  • Why: Every intermediate tree stays green because the not-yet-removed self-heal still covers un-migrated callers, so the atomic surface shrinks from "ten coupled behavioural changes" to "one small mechanical deletion after ten safe additions". Splitting the flip is worse than it sounds: a path left un-flipped keeps self-healing while its siblings fail loudly — an inconsistency easy to miss precisely because that path still works.
  • Source: KD-0647

Reply depth cap — enforced in the FormRequest exists rule, not in the Action

  • Chose: Rule::exists('comments','id')->where('issue_id', $issue->id)->whereNull('parent_id') — a parent must itself be a root comment.
  • Rejected: Load the parent inside CreateCommentAction and check its parent_id there.
  • Why: One rule enforces the depth cap and blocks cross-issue parent injection at the layer that already owns the request contract, making "parent_id always points to a root comment" a validated invariant rather than an application-level convention.
  • Source: KD-0846

Replying to a reply — flat sibling with an @mention pre-fill, not a hidden reply button

  • Chose: Every comment keeps a Reply button; on a reply it opens the thread's form pre-filled with @AuthorName and posts against the root comment's id.
  • Rejected: Hide the reply button on replies so only root comments are replyable.
  • Why: The conversational flow inside a thread survives without deep nesting — the @mention names the target and fires the existing mention notification — so the flat data model stays an implementation detail instead of a visible restriction.
  • Source: KD-0846

Reply notification — new enum case, reuse the existing email preference column

  • Chose: NotificationTypeEnum::CommentReply mapped to the existing notify_watched_activity_email column.
  • Rejected: A dedicated notify_comment_reply_email column on users; OR reusing the existing Comment type with no new case.
  • Why: The distinct case is what makes the in-app message meaningful ("replied to your comment" vs "commented on KD-0846"), while reusing the email column avoids a migration on the multi-tenant users table for a preference granularity nobody asked for.
  • Source: KD-0846

Pivot sync on create — inline in the Action, don't call the sibling sync Action

  • Chose: $issue->labels()->sync(...) inline inside CreateIssueAction's transaction, mirroring the blockedBy()->sync() already there.
  • Rejected: Delegate to the existing SyncIssueLabelsAction.
  • Why: SyncIssueLabelsAction opens its own db->transaction(), so calling it from inside another Action nests savepoints and fires a second broadcast before the create broadcast — a two-line duplication is cheaper than an abstraction that can't survive a transaction boundary.
  • Source: KD-0925

Cache-hash bumps track the roster, not the pivot

  • Chose: No BumpProjectLabelsHashAction call when an issue's labels change.
  • Rejected: Bump the labels hash on any label-related write.
  • Why: The projects/{id}/labels cache covers the project's label roster, so only the three Label-state mutations (create/update/delete) invalidate it; an issue↔label pivot write changes nothing the cached list returns.
  • Source: KD-0925

Optional-array DTO fields — null means "not provided", [] means "clear"

  • Chose: ?array $labelIds = null across the update and promote DTO chains, with an if ($data->labelIds !== null) guard before sync().
  • Rejected: A non-nullable array where omitting the field detaches every label.
  • Why: API clients that predate the field would silently wipe labels on every update; nullable keeps "didn't mention it" and "explicitly cleared it" distinguishable, matching how blockedByIds/blocksIds already behave.
  • Source: KD-0925 (follow-on)

Two store-access patterns coexist — each file follows the one already in that file

  • Chose: Edit.vue primes labels via makeLabelStoreForProject(projectId); Overview.vue primes via project.makeLabelStore() and threads the result to its child as a prop.
  • Rejected: Pick one pattern and apply it to both files.
  • Why: Both resolve the same memoized store, so the only real cost is local inconsistency — matching each file's own siblings keeps the child purely prop-driven like the lanes/epics/sprints already threaded into it, whereas a global rewrite would inject a store dependency into a presentational component to satisfy a consistency nobody reads for.
  • Source: KD-0925 (follow-on)

Optional props are migration scaffolding — make them required once every caller is wired

  • Chose: Drop labels?: LabelBase[] and its = [] default from IssueForm.vue once all three callers pass it.
  • Rejected: Keep the optional prop as a defensive default.
  • Why: The default's failure mode is a silently absent picker for any future caller who forgets the prop; required turns that into a vue-tsc build error, and the guard had already served its only purpose — letting callers migrate one at a time.
  • Source: KD-0925 (follow-on)

Semantic mismatch on a shared component beats parameterizing it

  • Chose: Reuse CopyLinkButton as-is to copy an issue key — :url="issue.key", link-chain icon and all.
  • Rejected: Add an icon prop/slot (and a text alias for url) so the shared component reads correctly for non-URL payloads.
  • Why: The component writes any string to the clipboard, so the mismatch is cosmetic and local, while the fix would touch the shared component, its tests, and its other call site — blast radius buying nothing a reader of the call site couldn't already infer.
  • Source: KD-1011

Hover-reveal group belongs on the card root, not on the row you want to hover

  • Chose: Put the unnamed UnoCSS group on the whole card so hovering anywhere — including the issue key — reveals the copy icon.
  • Rejected: Put group on the key row; OR lift the key text above the overlay with relative so the row receives :hover.
  • Why: A stretched-link card paints an ::after overlay across the entire card, so a pointer over the key text hits the overlay (a card descendant) and the row-scoped group never fires. Lifting the key above the overlay fixes hover but steals the click that opens the issue, so the card root is the only shape that satisfies both.
  • Source: KD-1011

Pin state is exposed on the pinning user's own profile, not on the project resource

  • Chose: pinned_project_ids on ProfileResourceData via the existing per-user resolver, backed by a project_pins pivot with no Eloquent model (no domain behaviour to host).
  • Rejected: Mirror report_watchers, which exposes its pivot on ProjectResourceData for every viewer of the project.
  • Why: The closest precedent is the wrong precedent here — report-watching is visible on purpose (teammates need to know who gets report pings), whereas who pinned what has no product consumer, so copying the shape would leak personal state for structural symmetry alone.
  • Source: KD-0996

A pinned project still renders in its team group — personal shortcuts don't rewrite org structure

  • Chose: Render pinned projects in both the PINNED section and their normal team/Direct-Membership group.
  • Rejected: Dedup, so a project appears exactly once — under PINNED if pinned (built and shipped first, then reversed).
  • Why: Deduping conflates "this is my personal shortcut" with "this is the team's project list" and makes the latter lie by omission — one user's bookmark silently removed a project from every team section that owned it. The cost of the fix is a project rendering twice, which reads as "also reachable from PINNED", not as a bug.
  • Source: KD-0996

No broadcast channel for single-viewer state

  • Chose: Patch the local auth user from the toggle response; cross-session persistence comes from /auth/user on the next load.
  • Rejected: A realtime broadcast for pin/unpin, matching lanes/labels/sprints.
  • Why: Broadcast doctrine exists for multi-viewer resources and pin state has exactly one viewer; the acceptance criterion asked for persistence across sessions, not live cross-tab sync — a second tab lagging until its next profile fetch is the entire cost.
  • Source: KD-0996

Stale pins are left in place; only deletion cleans up

  • Chose: No cleanup when a user loses access to a pinned project — the pivot row survives; DeleteProjectAction detaches explicitly.
  • Rejected: Detach pins whenever project membership sync drops a user.
  • Why: The sidebar renders from an already access-scoped store, so a stale row simply never renders — invisible latent state, exactly what report_watchers already carries. Project deletion is different only because the FK is restrictOnDelete() per ADR-0002 and would otherwise throw.
  • Source: KD-0996

Check the source reports before treating a plausible theory as a requirement

  • Chose: Keep the sidebar pin affordance membership-gated, after pulling the two originating reports and finding neither asked to pin outside their own membership.
  • Rejected: Extend pinning to any project an admin can technically open, on the theory that access_all_projects holders would want it.
  • Why: The theory came from a paraphrase, not the source text — both reporters were asking to narrow their existing membership set, not to bypass it. The decision is recorded precisely so the next reader who notices the admin behaviour doesn't re-litigate it from the same speculation; widening the surface was later done deliberately, on its own evidence, in KD-1054.
  • Source: KD-0996

Ungate the actions column, gate the individual action

  • Chose: The Actions header and TableActionCell always render; the permission v-if moves down onto the Delete button itself.
  • Rejected: Add a second, always-visible Pin column beside the admin-only Actions column.
  • Why: Once a cell holds actions with different permission requirements the column is no longer the right gate; a fifth column for a single icon is more layout churn than rewriting the two tests that asserted the old column-level hiding.
  • Source: KD-1054

Pinned-state indicator — icon swap, not a persistent colour

  • Chose: PinIcon when unpinned (muted, group-hover:c-kendo-red), PinOffIcon when pinned — the same swap the sidebar and the comment pin toggle already use.
  • Rejected: A single PinIcon coloured c-kendo-red while pinned (built and shipped first, then reversed).
  • Why: Persistent red read as a danger signal rather than "saved", despite matching the pinned-comment badge precedent — the right precedent was the pin toggle sitting next to it, and a third convention for the same affordance was never worth defending. The hover colour has to ride group/group-hover: at the call site because IconButton's own hyphenated hover-color-* variants don't compile, a pre-existing app-wide gap.
  • Source: KD-1054

Don't re-implement authorization the query already enforces

  • Chose: Render the pin button unconditionally for every row in the All Projects table.
  • Rejected: A frontend permission check to satisfy the "no pin affordance for projects they can't open" criterion.
  • Why: The table is fed by a query already scoped with accessibleBy($user), so a row's presence means the viewer can open that project; a second gate restates the invariant in a weaker place, and if the scoping ever regressed the wrongly-visible row would be a strictly bigger problem than its pin button.
  • Source: KD-1054

Pending-guard granularity follows the shared state, not the UI row

  • Chose: One module-level usePendingGuard() singleton wrapping togglePin in the auth state, read by every call site.
  • Rejected: Per-row usePendingIdGuard<number>() inside the table (the original design).
  • Why: togglePin replaces the whole logged-in-user object from each response, so two toggles in flight on different rows — or one row plus the unguarded sidebar button — race, and the last response silently drops the other project's pinned state. The blunter guard briefly disables unrelated rows, which is the correct trade for a cross-surface data race.
  • Source: KD-1054

Icon-only buttons show pending as disabled, not with the built-in loading spinner

  • Chose: :disabled="isPending(...)" on both Pin and Delete, matching Teams/Users/Roles.
  • Rejected: IconButton's built-in :is-loading spinner.
  • Why: The spinner is prepended to the slot rather than replacing it, so an icon-only button briefly holds two icons, widens, and shoves its neighbour in a justify-end action cell. A grep of every existing consumer showed isLoading is only ever used on labelled buttons — the established convention was disabled-only, which is why nobody had hit the bug before.
  • Source: KD-1054

Read auth state through the non-throwing computed, not the assert-logged-in getter

  • Chose: authService.loggedInUser.value?.pinnedProjectIds.includes(id) ?? false — the tolerant shape can() already uses.
  • Rejected: authService.getLoggedInUser(), copied from the sidebar's identical call site.
  • Why: The getter throws by design for call sites guaranteed to be post-auth, and page-integration tests deliberately mount without auth state to exercise permission hiding. The fully-mocked unit suite never caught it because the getter was a bare vi.fn() there, so the throw surfaced only in CI's integration job.
  • Source: KD-1054

A mutation's return value must report what actually happened

  • Chose: togglePin returns true only inside the if (data) branch and false otherwise.
  • Rejected: The unconditional return true that shipped, whose test had been written to assert the buggy value.
  • Why: The caller's success toast reads off that boolean, so a 2xx with an empty body left the profile untouched while still claiming "Project pinned" — and a test written from the implementation rather than the intent pins the bug in place instead of catching it.
  • Source: KD-1054

No HasMany for a relation that doesn't own its children

  • Chose: Query Report::query()->where('promoted_issue_id', $issue->id) directly in the controller rather than adding an Issue::reports() relation.
  • Rejected: A HasMany on Issue and the usual relation-based read.
  • Why: Declaring the HasMany forces an entry in cascadeRelations() per ADR-0002, which asserts that reports cascade-delete with the issue — but reports outlive the issues they were promoted into, so the relation would encode ownership the domain doesn't have.
  • Source: KD-0990

Nested routes stack the parent-scoped permission, they don't swap it

  • Chose: ->can('viewAny', [Issue::class, 'project'])->can('viewAny', [Report::class, 'project']) on the new issues/{issue}/reports route.
  • Rejected: Carry only the Report-scoped check that the full-list endpoint it replaces used.
  • Why: Every {project}/issues/* route must carry an Issue-scoped permission (arch-enforced), and keeping the Report check alongside it means direct API callers still need report-read — so the narrower endpoint ends up strictly stronger than the path it replaced, not merely equivalent.
  • Source: KD-0990

Verify the issue's stated root cause before implementing its prescribed fix

  • Chose: Fetch only the reports the page actually renders, via a scoped nested endpoint.
  • Rejected: The issue's own prescription — "scope the trigger to the Reports route" — which assumed the fetch came from project-level hydration firing on every project route.
  • Why: The hypothesis was verifiable and false: the store factory is inert and the real trigger was a single retrieveAll() on the issue-detail page, which genuinely renders report data in its Issue Source cards. Following the literal scope line would have deleted a working feature to fix a performance bug.
  • Source: KD-0990