Appearance
Issues & Board Decisions
Distilled from 35 DECISIONS.md files (12 plans had no DECISIONS.md or no real tradeoffs). Each entry is a real fork in the road. Implementation details that aren't a tradeoff are NOT here.
Creator filter — client-side only, no backend changes
- Chose: Add a
matchesCreator()predicate alongside existing client-side matchers. - Rejected: Add
user_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