Appearance
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_idfiltering toSearchIssuesActionandSearchIssuesRequest. - 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
userIdvalues in the currently loaded issues. - Rejected: Reuse
buildAssigneeOptionsand 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), ORtint-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.5size used on issue cards; matches Jira's task convention. - Source: KD-0284
Bulk select — Backlog only, not Board
- Chose: Multi-select scoped to
Backlog.vueand 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
selectedIssueIdsSet 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
epicIdin the existingBoardUpdatepayload. - Rejected: New
POST /issues/bulk-assign-epicaction+request+DTO. - Why:
IssuePositionDataalready has?int $epicIdandUpdateIssueBoardActionalready handlesepic_idin 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
CardAssignmentfor 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.SaveIssueRequestvalidates onlyRule::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?)inrelations/sprints/helpers.ts. - Rejected:
useSelectableSprints()composable; OR method on the adapter sprint store. - Why: Mirrors the sibling
epics/filter-epic-issues.tspattern; 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(mirrorsIssueForm.vue:65). - Why:
IssueSidebaris 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 lastNameinline in the metadata strip. - Rejected:
ProfilePictureavatar (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 === nullas 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:
DeleteUserActionrunsupdate(['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!== 0convention. - Why:
$safe->integer()silently casts null to 0 — the same bug class will recur if the frontend ever sends null. The!== 0pattern 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/searchendpoint; Board/Backlog/Overview call it directly. - Rejected: Generic
PaginatedResourceData<T>envelope,usePaginatedListForcomposable, URL-synced state, new/issues/paginatedroute. - 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/searchcalls; CRUD still routes through the store. - Rejected: Bump
fs-adapter-storeto accept aparamsarg onretrieveAll(). - 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|recentendpoints with their own Actions and ResourceData. - Rejected: Keep one
/searchendpoint with aview=backlog|board|recentparameter; OR strip the now-unused tab-specific filters from/searchin the same PR. - Why:
/searchis 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:
BoardIssuesActionqueries$project->sprints()->where('status', Active)./boardtakes 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.vueandBacklog.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,RecentIssueResourceDatawith tab-specificEAGER_LOAD. - Rejected: Share
IssueResourceDataand varyEAGER_LOADper Action. - Why:
IssueResourceData::from()doesloadMissing(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}IssueResourceprojection 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.valueagainst a freshly derived layout; early-return before incrementingupdateCount. - Rejected: Keep optional
retrieveAllparameter; OR snapshot before drag and compare after. - Why: Both pages already pass
retrieveAll: () => Promise.resolve()— the call site is dead code. Pre-bumpingupdateCounton 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: boolonGlobalSearchIssuesData; Action applies a MAX(order)-per-project subquery only when set. - Rejected: Add an
is_finalcolumn tolaneswith migration + backfill + settings UI; OR client-side filter via a new cross-project lanes endpoint. - Why: Keeps the refactor tight. Mirrors the existing
SearchIssuesData.includeBacklogper-view-flag pattern. Can be superseded later by a semanticis_finalcolumn without breaking the flag contract. - Source: KD-0468
Slim resource shape shared by /issues/search and /issues/my
- Chose: Rename
UserIssueResourceData→IssueSearchResourceData; both endpoints return the same shape. - Rejected: Keep separate
UserIssueResourceDataand add a separateMyIssuesResourceData; OR inflateIssueResourceDatawith denormalizedproject_name,lane_title,lane_color. - Why:
IssueResourceDatalacks 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,UserIssuesDataand 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
IndexUserIssuesActionis uncapped; capping at 100 silently truncates for heavy users — a regression. Server-side pagination is declared out of scope;Backlog.vuealready requestslimit: 500so the precedent exists. - Source: KD-0468
Empty-string regression — enforce min:1 in validator, don't restore toDto() normalisation
- Chose: Add
min:1to nullable string fields in the FormRequest'srules(). - Rejected: Restore
$x !== '' ? $x : nullintoDto(); OR add a sharedcoerceNullablehelper. - 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
onUpdatehandler to emiteditor.value?.getMarkdown() || null. - Rejected: Coerce in
AiStoryPrompt.vuebetweenRichTextAreaanddefineModel; 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-tooland 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,keyonly — 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 updateper issue with progress tracking in-worktree. - Rejected:
mcp__kendo__update-issue-toolper-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:
DragElementemitsself-assign;Board.vuecalls an exportedupdateIssueAssigneehelper fromstore.ts, then reconciles via the existingapplyIssueResourcepath. - Rejected: Inline
putRequestdirectly inBoard.vue'sonSelfAssignhandler (YAGNI — the mutation is single-use today). - Why: The deciding factor is test-mocking boundaries, not future reuse — the
__mocks__/store.tsconvention 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
assignToUserflow 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: nullregardless 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 sharedBaseToast.vue; introduceundoableSuccessToast(message, handler)helpers in both tenant + central apps, and bringBaseToast.vueinto the coverage gate in the same PR. - Rejected: A separate
UndoableToast.vuecomponent, OR a 5thactionvariant 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
canInProjectwas 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}/assigneewith its own Action, FormRequest, Input DTO, and controller method; broadcast/audit/notification parity achieved by injecting the same collaborators asUpdateIssueAction. - 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 firstRoute::patchdeclaration to keep the original PATCH-style frontend. - Why: Broad PUT doubles latency on the cheapest-possible affordance and is fragile against
IssueResourcevsSaveIssueRequestdrift; softeningSaveIssueRequestwithsometimesrules 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-codedexpect([...])->toUse(IssueAuditLogger::class)block intests/Arch/AuditTest.phpas 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
UpdateIssueActionname; 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 inBacklog.vue; pass plain numeric props toSprintRow.vue. - Rejected: Export a
getSprintTotals(issues)helper fromrelations/sprints/helpers.tsfor future reuse. - Why: One call site and one render site means a helper is a shallow module with no second consumer — the kind
simplicity-reviewerflags. 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; nov-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_pagewith a newPaginatedListResourceData<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
statuscolumn entirely and derive it from issue lane positions (binary Open/Closed) in each layer that needs it. - Rejected: Keep
statusstored 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_closedtoEpicResourceDataplus anEpicBroadcastercascade fanning out from ~7 Action touchpoints. - Why: No consumer of a backend
is_closedexists 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
useTitlewhen 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 solutionsection 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
orderwith a sparse fractional-rankrankvarchar sorted lexicographically; a move writes one row (the dragged issue's midpoint between neighbours). - Rejected: A
(lane_id, issue_id, position)pivot table, OR keepingorderint and diffing only changed rows on the frontend. - Why: The CASE-WHEN bulk rewrite bumped
updated_aton 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_aton 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 UPDATElock 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 secondsprint_rankcoordinate. - 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
orderfromAUDITABLE_FIELDSand never addrank; lane/sprint/epic changes stay audited. - Rejected: Add
rankto 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
orderanyway; auditingrankjust moves theupdated_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::movepasses$user->idas the owner-bypass arg (preserving "any project member with Update can drag any issue"), with the bound$issuearg decorative. - Rejected: Pass
$issue->user_idto match the canonical 3-argupdateshape. - 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 keepsorderpopulated 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:
IssueResourceDataexposeslabel_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-loadedlabelsride the existing Issue channel); defer a dedicated Labels-CRUD broadcast channel to a follow-up. - Rejected: Add
DomainEnum::Labels+ Label events +channels.phpwiring + 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:
DeleteLabelActiondetaches the pivot from all issues and deletes; the delete UX is confirm-only. - Rejected: Throw a
LabelInUseExceptionrequiring 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
LabelResourceDatato carryissues_countfor no real gain. - Source: KD-0804 / KD-0805
BelongsToMany never goes in cascadeRelations() — tear down pivots imperatively
- Chose:
Label::cascadeRelations()returns[];issue_labelpivot rows are torn down explicitly insideDeleteProjectAction/DeleteLabelAction. - Rejected: Declare
['issues']incascadeRelations()to mark the ownership. - Why: The arch-test invariant restricts
cascadeRelations()entries toHasMany/HasOne/MorphManyand requires a matchingDelete{Model}Actionper entry — aBelongsToManylikeissuesis 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
RespreadProjectRanksActionthat rewrites all of a project's ranks to short evenly-spaced values, reusing the migration's base-26 spread math. - Rejected: A LexoRank
bucketprefix 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_hashbump 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 alanes_hashbump. - 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_hashbump 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_pinnedboolean column, single-pin invariant enforced in the action's transaction. - Rejected: A
pinned_comment_idnullable FK onissuesthat 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
Issuesresource 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
DeleteIssueActionis 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.breakpointsentry and drive layout withlt-narrow:utilities. - Rejected: A JS-driven
isNarrowref 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 andoverflow-x-autoactivates 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 (
whereInon 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
whereInpattern; 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-setprojectId). - 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
fetchBlockingIssuesForIssueheld in a localref, joining the existing scoped-fetcher family. - Rejected: Upserting the fetched issues into
issueStoreviaextend/retrieveInto. - Why: Blocking issues use the lean
IssueListResourceshape, so upserting them into the full-Issuestore would makegetByIdreturn a partial issue missingdescription/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 sovalidateRelationsLoaded()throwsMissingRelationExceptionon 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 —
loadMissingguarantees 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'sEAGER_LOAD*spec, with deptrac edges{Actions, Broadcasting, Controllers} → Hydrators → {Models, Resources}. - Rejected: A static
applyEagerLoad()on the resource; OR moving the constants onto theIssuemodel; OR a general-purposeEagerLoaderfor 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 anIssueBroadcaster::EAGER_LOADre-export. The model can't own it either:EAGER_LOADis "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-sidehydrate(Model)(load*), mirroring Laravel's ownwith/loadsplit. - 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
IssueHydratorcovering 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
Hydratorwriting the load sequence behind threefinalverbs; subclasses declare onlyresource()(the profile bond) plus an optionalforcedNestedLoads()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 theEAGER_LOAD_SUMmap 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; movingbranchLinksCountEagerLoad()(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 anarray_key_exists(notisset) aggregate guard so a second hydrate of the same instance is free:issetwould 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
loadMissingcalls 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
CreateCommentActionand check itsparent_idthere. - Why: One rule enforces the depth cap and blocks cross-issue parent injection at the layer that already owns the request contract, making "
parent_idalways 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
@AuthorNameand 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::CommentReplymapped to the existingnotify_watched_activity_emailcolumn. - Rejected: A dedicated
notify_comment_reply_emailcolumn onusers; OR reusing the existingCommenttype 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
userstable 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 insideCreateIssueAction's transaction, mirroring theblockedBy()->sync()already there. - Rejected: Delegate to the existing
SyncIssueLabelsAction. - Why:
SyncIssueLabelsActionopens its owndb->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
BumpProjectLabelsHashActioncall when an issue's labels change. - Rejected: Bump the labels hash on any label-related write.
- Why: The
projects/{id}/labelscache 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 = nullacross the update and promote DTO chains, with anif ($data->labelIds !== null)guard beforesync(). - 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/blocksIdsalready behave. - Source: KD-0925 (follow-on)
Two store-access patterns coexist — each file follows the one already in that file
- Chose:
Edit.vueprimes labels viamakeLabelStoreForProject(projectId);Overview.vueprimes viaproject.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 fromIssueForm.vueonce 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-tscbuild 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
CopyLinkButtonas-is to copy an issue key —:url="issue.key", link-chain icon and all. - Rejected: Add an icon prop/slot (and a
textalias forurl) 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
groupon the whole card so hovering anywhere — including the issue key — reveals the copy icon. - Rejected: Put
groupon the key row; OR lift the key text above the overlay withrelativeso the row receives:hover. - Why: A
stretched-linkcard paints an::afteroverlay 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_idsonProfileResourceDatavia the existing per-user resolver, backed by aproject_pinspivot with no Eloquent model (no domain behaviour to host). - Rejected: Mirror
report_watchers, which exposes its pivot onProjectResourceDatafor 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/useron 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;
DeleteProjectActiondetaches 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_watchersalready carries. Project deletion is different only because the FK isrestrictOnDelete()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_projectsholders 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
TableActionCellalways render; the permissionv-ifmoves 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:
PinIconwhen unpinned (muted,group-hover:c-kendo-red),PinOffIconwhen pinned — the same swap the sidebar and the comment pin toggle already use. - Rejected: A single
PinIconcolouredc-kendo-redwhile 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 becauseIconButton's own hyphenatedhover-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 wrappingtogglePinin the auth state, read by every call site. - Rejected: Per-row
usePendingIdGuard<number>()inside the table (the original design). - Why:
togglePinreplaces 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-loadingspinner. - 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-endaction cell. A grep of every existing consumer showedisLoadingis 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 shapecan()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:
togglePinreturnstrueonly inside theif (data)branch andfalseotherwise. - Rejected: The unconditional
return truethat 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 anIssue::reports()relation. - Rejected: A
HasManyonIssueand the usual relation-based read. - Why: Declaring the
HasManyforces an entry incascadeRelations()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 newissues/{issue}/reportsroute. - 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