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
Epic closed-status — backend is_closed, not the page's loaded issues
Chose: Expose is_closed on EpicResourceData, derived from the epic's full issue set. Rejected: Reuse the client-side isEpicClosed() helper on whatever issues the current page has loaded. Why: Board/Backlog/Overview each pass a sprint-scoped subset, so an epic with issues outside that subset would be silently misclassified. Source: KD-1035
Completed epics in the filter — hint text, not a group header
Chose: Set hint: 'Completed' on closed epics and sort open-first. Rejected: Add a group-header primitive to the shared filter popover. Why: Open-first ordering already does the work; a group header would touch every filter kind for one dimension's visual nicety. Source: KD-1035
Cross-project epic filter — leave My Issues unsorted
Chose: Sort and hint only when order and isClosed are present; My Issues' extracted epics pass through unchanged. Rejected: Resolve per-project order and closed-status for the cross-project list too. Why: An epic's order and closed-status are only meaningful inside one project, and no acceptance criterion asked to invent a cross-project ranking. Source: KD-1035
Label exclusion — sibling number[] ref, not a richer option state
Chose: Add a second scoped excludedLabels ref beside selectedLabels. Rejected: One {id, state}[] or Map<id, 'in'|'out'> as the source of truth. Why: Every shared filter helper assumes Ref<number[]>; a richer type would force a parallel generic path used by one dimension. Source: KD-1036
Label exclusion UI — opt-in on the shared filter, not a forked control
Chose: Add supportsExclusion?: boolean to the shared filter descriptor and a parallel exclusion map. Rejected: A bespoke tri-state label control that bypasses the shared popover and chip. Why: Label would look like a different product from every other filter, and the other ten consumers stay byte-identical behind the opt-in. Source: KD-1036
Exclusion click cycle — unselected → include → exclude → unselected
Chose: One click target cycles through include, then exclude, then clear. Rejected: A checkbox plus a separate exclude toggle on each row. Why: The reporter drew a single-target cycle, and a second affordance widens every option row for a case the existing toggle already covers. Source: KD-1036
Exclusion chip — one chip with an "is not" segment, not two chips
Chose: A single chip renders is, is not, or both segments. Rejected: Split include and exclude into two chips of the same kind. Why: The bar surfaces one primary chip per kind; a two-chip model fights that layout for no extra information. Source: KD-1036
Issue prev/next context — in-memory singleton, not the URL or sessionStorage
Chose: Hold the ordered key list in a module-scoped ref that dies on refresh. Rejected: Serialize the list into query params, or persist it in sessionStorage. Why: A 100-item list does not belong in the URL, and surviving a refresh is a benefit nobody asked for. Source: KD-1037
Prev/next with no captured list — hide the controls, don't invent a fallback
Chose: Render nothing unless the current issue key is in the snapshot. Rejected: Fetch the issue's lane (or Overview order) so the controls are always visible. Why: Unlisted and deep-linked issues are not the job; a second fetch path is complexity for a case the developer called not worth it. Source: KD-1037
Prev/next granularity — stay inside the lane or sprint section
Chose: Capture only the clicked lane's (or backlog section's) keys; clamp at the ends. Rejected: Walk the whole board or backlog, spilling into the next lane or sprint. Why: Each column is a visual list of its own, and ending there is clearer than silently continuing into a different group. Source: KD-1037
Prev/next advance — walk the snapshot, don't re-filter on each click
Chose: Index math over the key array captured at the original click. Rejected: Re-fetch and re-apply the origin's filters on every Next/Prev. Why: Capturing filter criteria from five origins and paying a round-trip per click is the opposite of fast lane traversal. Source: KD-1037
Prev/next chrome — render outside the issue page, not in its header
Chose: Mount the controls in the project layout, outside the issue page's error boundary. Rejected: Catch a failed fetch inside the issue page and render an inline not-found state. Why: Restructuring the issue page so a missing target still mounts a header is a large blast radius for a small control. Source: KD-1037
Comment like — watch-style membership toggle, not a pin-style exclusive flag
Chose: A comment_likes pivot and a single POST toggle, mirroring issue watching. Rejected: Follow comment pinning's boolean column and POST/DELETE pair. Why: A like is per-user membership, not an exclusive single-slot flag, so pinning's two-endpoint shape exists for a rule likes do not have. Source: KD-1074
Like resource — liker_ids, not a count plus liked_by_me
Chose: Expose liker_ids: list<int> and let the client derive count and "liked by me". Rejected: Server-computed likes_count plus liked_by_me. Why: watcher_ids is the only per-user-toggle precedent, and the same includes(currentUserId) idiom already exists on the client. Source: KD-1074
Like permission — Comments:Read, not Issues:Read
Chose: A CommentPolicy::like() ability gated on Comments Read. Rejected: IssuePolicy::viewAny(), copied from the watch route's nesting. Why: Liking mutates a comment, so gating it through Issues would silently diverge the day Comments Read and Issues Read stop being equivalent. Source: KD-1074
Pin restrictions do not transfer to likes
Chose: Replies can be liked, and authors may like their own comments; no toast on toggle. Rejected: Reuse pin's root-only rule, author-bypass block, and success toast. Why: Those restrictions exist because pinning curates the section; a like is a personal high-frequency reaction with no exclusivity constraint. Source: KD-1074
Archiving is tenant-wide state, not a per-user hide
Chose: A shared projects.archived_at with archive/restore actions and permission gating. Rejected: A per-user pivot that hides projects from one person's lists. Why: The report was filed from the shared projects list as a team-clutter problem; a personal filter leaves the mess on everyone else's screen. Source: KD-1076
Archive column — nullable timestamp, not a boolean and not SoftDeletes
Chose: projects.archived_at. Rejected: is_archived, or Laravel SoftDeletes. Why: The timestamp records when without a second column, and SoftDeletes would silently filter every query — including time-entry exports — while colliding with real project deletion. Source: KD-1076
Archived projects stay writable — hidden from lists, not frozen
Chose: Hide archived projects from default lists; leave every write path open. Rejected: A read-only guard on every project-scoped route, MCP tool, webhook, and Hand-to-Claude session. Why: Every acceptance criterion is read-side, and freezing writes is orders of magnitude beyond the ticket. Source: KD-1076
List membership and access are different questions
Chose: A new active() scope on the two list-shaped project queries; leave accessibleBy alone. Rejected: Fold archive filtering into accessibleBy, or a SoftDeletes global scope. Why: accessibleBy answers "may this user reach it?" and is reused by time-entry export, global search, and notifications — hiding archived projects there would violate "history stays findable". Source: KD-1076
Archive permission — Update, not Delete and not a new enum case
Chose: Gate archive and restore on Projects:Update, via named archive/restore policy abilities. Rejected: Gate on Delete, or add an Archive action to the permission enum. Why: The persona who may tidy but not destroy would otherwise be forced to delete, which is the behaviour this ticket exists to remove; a fifth enum case ripples through the role matrix for no extra power. Source: KD-1076
No audit log for archive or restore
Chose: Record whether and when via archived_at; do not put Project on the entity-logger roster. Rejected: Full project audit, or an archived_by_user_id column. Why: Creating, updating, and deleting a project are already unaudited; logging the reversible action while the irreversible one is silent is incoherent. Source: KD-1076
Archive is hidden by default — every consumer opts in deliberately
Chose: Server-side active() by default; REST/CLI take include_archived, MCP gets a second resource, the overview toggle refetches. Rejected: Return everything and let the SPA filter, or a deleted-users-style separate endpoint. Why: Frontend-only filtering makes archive advisory for CLI, MCP, and API tokens; a second endpoint's only advantage is its own permission, which product ruled out. Source: KD-1076
MCP archived list — a second fixed URI, not a template on the primary resource
Chose: kendo://projects/archived as its own resource; keep kendo://projects untemplated. Rejected: Parameterise ProjectsResource with HasUriTemplate, or return archived projects from the primary list marked. Why: Implementing a URI template removes the resource from resources/list and leaves it only under templates, which is a discoverability regression for the default list. Source: KD-1076
Pin loses to archive; the pin row survives
Chose: Filter archived projects out of the sidebar before the pin section; leave the pinnedByUsers pivot in place. Rejected: Drop the pin on archive, or let a pinned archived project stay in the sidebar. Why: Restoring should bring the pin back, and deleting another user's preference as a side effect of archive is worse than a dormant row. Source: KD-1076
No live broadcast on archive or restore
Chose: The archiving user's store updates from the HTTP response; other tabs stay stale until they navigate. Rejected: Fan out through ProjectBroadcaster::updated, matching rename. Why: An archived project is inactive by definition, and the broadcast would pull in three extra resolvers for a mutation whose open-tab collision is rare. Source: KD-1076
Archive payload carries an archived boolean, not only archived_at
Chose: Every project payload exposes "archived": true beside the timestamp, derived from it. Rejected: Let consumers infer archived from a non-null archived_at. Why: A timestamp requires interpretation; a boolean is unmissable for agents, and the two cannot disagree if one is derived. Source: KD-1076
Board blocked state — derived at read time, not stored
Chose: A batched withCount scope plus a self-healing accessor, structurally mirroring Sprint's incomplete-count. Rejected: A stored issues.is_blocked kept in sync via observers or Actions. Why: The value is sent fresh on every board fetch and broadcast, so a stored column would add an invalidation protocol for a number nobody caches. Source: KD-1077
Blocked field is a count, not a boolean
Chose: Expose unresolved_blockers_count: int; the card badges when the count is greater than zero. Rejected: A boolean is_blocked, or both fields. Why: The board wants "how many", and a boolean that is always count > 0 is dead weight on a payload already near the Pusher ceiling. Source: KD-1077
Blocked count rides the full issue profile, not just the list
Chose: Add unresolved_blockers_count to IssueResourceData as well as the list resource. Rejected: List-profile only, matching the issue text's wording. Why: Live-sync splices the full profile over the board card wholesale, so a missing field would silently zero every other viewer's badge on the next unrelated edit. Source: KD-1077
Blocked-badge live updates — fan out beyond the relation edit
Chose: Rebroadcast dependents on relation edit, Done-boundary lane moves, and delete. Rejected: Relation-edit only, matching AC3's literal wording. Why: Dragging a blocker to Done is the common way a blocker actually resolves, and leaving that stale undermines the badge for every viewer except the one who dragged. Source: KD-1077
Backlog labels column — default visible, persisted per project
Chose: Show the column on first visit; store the toggle at backlog-labels-visible.${projectId}. Rejected: Default-hidden, or one global preference. Why: Shipping the column off defeats the issue's premise, and labels are project-scoped so a label-free project should not inherit another project's empty column. Source: KD-1143
Label overflow — cap at one chip plus a tooltip, not wrap or two chips
Chose: Show the first chip, collapse the rest into a +N badge, both wrapped in a tooltip. Rejected: Wrap onto a second line, cap at two, or measure width with a ResizeObserver. Why: Two chips truncate mid-word or shove the badge out of an overflow-hidden row; wrapping breaks the fixed-height grid; measuring is JS layout for a dense list row. Source: KD-1143
Visible label chip — prefer an active filter match, not insertion order
Chose: Stable-sort resolved labels so a filter-matching label is the one chip shown. Rejected: Thread selectedLabelIds through three parent components, or a second "N matched" badge. Why: Insertion order hides the label the user is filtering on behind +N; prop-threading three files for a value only the resolver reads is worse than one nested read of an existing singleton. Source: KD-1143
Comment overflow menu — props-driven items, not slots
Chose: The caller passes an items array; the menu hides its trigger when the array is empty. Rejected: Slot-based items mirroring Dropdown, or slots plus a :has-items prop. Why: "Zero items means no trigger" is load-bearing state, and only a first-class array makes that a testable value instead of vnode inspection or two sources of truth. Source: KD-1153
Menu items clamp to 44px on touch; the trigger stays 26px
Chose: media-touch:min-h-11 on the items; leave the trigger matching Reply and Like. Rejected: Clamp everything, or clamp nothing to match IconButton. Why: A 44px trigger beside 26px neighbours looks broken, and mis-tapping Delete is the risk that earns the floor — the selects family already solved this split. Source: KD-1153
Overflow menu positioning — floating-ui with flip, not plain absolute
Chose: @floating-ui/vue with flip() and shift(), already in the tree via Tooltip. Rejected: Copy FiltersPopover's absolute mt-2 with no flip. Why: New comments append at the bottom of the thread, so the common case is a menu that would otherwise render past the viewport. Source: KD-1153
Attach stays ungated in this PR; empty-menu is a primitive-only guarantee
Chose: Ship the overflow menu with Attach always present; the zero-items guard lives on the primitive for future callers. Rejected: Pull Attach's missing can() gate into the same branch so the issue's empty-menu AC becomes literally true. Why: Mixing an authorization fix into a presentational PR makes both harder to review and harder to revert. Source: KD-1153
Overflow panel is surface-1, not the documented surface-3
Chose: Match FiltersPopover: bg-kendo-surface-1 with border-light. Rejected: Follow the design-system doc's dropdown token, or patch hover/divider/danger as three separate bugs. Why: In dark mode surface-3 is lighter than the surface-2 hover layered on it, so every contrast symptom had one cause; the shipped popovers were right and the doc was wrong. Source: KD-1153
Sprint-completion audit — inline the sequence, don't call the sibling Action
Chose: Repeat the snapshot/log/broadcast loop inside CompleteSprintAction. Rejected: Delegate to BulkAssignIssuesToSprintAction, or extract a shared reassign Action. Why: The sibling opens its own retried transaction, so calling it nests a savepoint whose deadlock retry no longer guards the outer commit. Source: KD-1180
Sprint-completion target rules live in the Action, not only in the modal
Chose: Reject a self-target or already-Completed target inside the Action after the lane lock. Rejected: Leave the rules in the frontend picker, or duplicate them in the FormRequest and the MCP tool. Why: HTTP and MCP both bypass the modal, and a no-op self-target still churns N rows and broadcasts; one Action-level 422 covers every current and future caller. Source: KD-1180
No batch cap on sprint completion
Chose: Audit however many incomplete issues the sprint holds; document the lock-hold cost. Rejected: Cap at 500 like the other bulk issue paths. Why: A cap would make a sprint with 501 incomplete issues impossible to complete, refusing a legitimate lifecycle action to protect a write path. Source: KD-1180
Sprint-delete re-read — through the project, not the relation just emptied
Chose: Collect ids during the snapshot loop and re-read $project->issues()->whereIn(...). Rejected: Re-get $sprint->issues() after the update. Why: The update sets sprint_id to null, so the sprint relation returns an empty collection and every audit row would be built from nothing. Source: KD-1181
Sprint-delete write — scope to the snapshotted ids, not a current-read WHERE sprint_id
Chose: whereIn the snapshotted ids so a mid-flight arrival is not silently cleared. Rejected: Leave the unscoped $sprint->issues()->update(...), or loop until read and write agree. Why: An unscoped update is a current InnoDB read, so a late arrival is nulled with no audit row; scoping turns that gap into a RESTRICT abort instead. Source: KD-1181
Lost sprint-delete race is a retryable 409, not a 422 and not a 500
Chose: Catch the RESTRICT errno 1451 and throw a 409 CustomException. Rejected: Let the QueryException surface as a 500, or reuse SprintNotDeletableException. Why: The sprint is still Planned and deletable — it just gained an issue mid-flight — so a 422 "cannot delete active or completed" would state something false, and 500 would leak the constraint name. Source: KD-1181
Lane-delete audit template — BulkMoveAction, not the issue-named sprint sibling
Chose: Copy BulkMoveAction's snapshot loop plus positionsUpdated and myIssuesChangedMany. Rejected: Copy BulkAssignIssuesToSprintAction, which the issue names as the template. Why: lane_id is in the My Issues filter and sprint_id is not, so copying the sprint tail would leave My Issues stale for every migrated issue that crossed Done. Source: KD-1182
Lane-delete My Issues membership — judge "was on the list" against the pre-delete Done lane
Chose: Pass $oldFinalLaneId into the fanout so $wasInFinalLane uses the deleted lane's identity. Rejected: Rewrite $oldLaneIds so deleted-final entries carry the new final lane id, or resolve one $finalLaneId post-commit and use it for both arms. Why: Deleting Done promotes another lane, so judging both arms against the new final makes every migrated issue look like it used to be on My Issues when it was excluded from it. Source: KD-1182
Epic-membership live update — fire positionsUpdated and myIssuesChangedMany
Chose: Both events, post-commit, mirroring DeleteLaneAction. Rejected: myIssuesChangedMany only, copying BulkAssignEpicAction. Why: The board's epic badge is derived from issue.epicId merged out of the position payload, so skipping positionsUpdated leaves every other client's badge stale until reload. Source: KD-1183
Epic-delete write — scope to snapshotted ids; add no 409
Chose: whereIn the snapshotted ids; let a mid-flight arrival be cleared by ON DELETE SET NULL. Rejected: Copy KD-1181's new 409 exception for a RESTRICT blocker. Why: epic_id is nullOnDelete, so a straggler cannot block the parent delete the way sprint_id's RESTRICT can. Source: KD-1183
Audit fan-out — one tail-lock read and batched INSERTs, not a ceiling plus a job
Chose: Hash the chain in memory after one locked tail read, then multi-row INSERT in chunks of 200. Rejected: Keep the per-row loop up to 500 and defer larger sets to a queued job, or hard-cap at 500. Why: Writing later needs a batch_key, snapshot payloads, actor re-resolution, and "deleted since" semantics (~3 100 lines) and still holds the lock for 500 round trips; a cap rejects an ordinary Done-lane clear. Source: KD-1228
Audited bulk-write seam — the six server-derived sets, not the payload-capped Bulk* three
Chose: One seam for the six Actions whose issue set is derived server-side, snapshotted inside the transaction, with no skip branch. Rejected: Absorb all nine behind skip and snapshot-placement flags. Why: The Bulk* three snapshot before the transaction and are already payload-capped; two flags would let a caller ask for a combination nobody has reasoned about in audit-critical code. Source: KD-1258
Seam write scope is caller-supplied; only the re-read is project-scoped
Chose: Take the write Builder/Relation as a parameter; keep Project for the re-read. Rejected: Write through $project->issues() for both. Why: Project-scoping the write would null a concurrent mover's sprint_id/epic_id/lane_id and let a parent delete succeed that today correctly 409s on RESTRICT. Source: KD-1258
Written, snapshotted, and audited sets are one locked read
Chose: Under the caller's transaction, lock the write-scope rows and drive the update, snapshots, re-read, and fan-out maps from that result. Rejected: Preserve the pre-existing split — write through the narrow scope, re-read through the project, and snapshot the caller's pre-lock collection. Why: A concurrent leaver was not written but was still audited and broadcast under this actor, pairing somebody else's field change with this bulk write. Source: KD-1258
Issue-list cursor rides in meta.next_cursor, not a new envelope
Chose: Extend CappedListMetaData with next_cursor; an unparameterised call stays byte-identical. Rejected: Swap the endpoint to a CursorPaginatedResourceData envelope, or version the API. Why: This endpoint just caused a wire break; a second one would take btc-dash and every installed CLI binary with it. Source: KD-1268
Issue-list sort key is id ascending, not updated_at
Chose: Keyset on id; the default order becomes oldest-id-first. Rejected: Keep latest('updated_at'), or treat updated_at ascending as a changes feed. Why: An edit mid-walk moves a row past an updated_at cursor, and a mirroring consumer reads the skipped row as deleted — the hazard that nearly wiped 2268 EMMIE issues. Source: KD-1268
Malformed cursor is 422, not a silent first page
Chose: Reject a present-but-undecodable cursor with 422; an absent cursor is still page one. Rejected: Match the central audit viewer, which treats a bad cursor as "start over". Why: The consumer that motivated this issue is a nightly mirror with no human watching; a silent restart loops forever. Source: KD-1268
Other list endpoints stay bare arrays
Chose: Leave /users, /projects, /lanes, /sprints, /epics, and /time-entries unenveloped. Rejected: Roll the {data, meta} envelope out consistently. Why: All six are bounded per tenant or project, so wrapping them breaks retrieveAll and every installed CLI binary for no paging benefit. Source: KD-1268
CLI walks every page in the same PR; absence is not completion
Chose: ListIssues follows meta.next_cursor until null, and treats missing meta or truncated without a cursor as a truncation signal. Rejected: Ship the sort-key change and leave the CLI for a follow-up, or let a missing meta decode as a finished walk. Why: Without the walk, three commands silently see the wrong 500 issues; without the signal, an old server reintroduces the silent truncation this issue exists to kill. Source: KD-1268
Issue-list cursor is unsigned
Chose: A bare encoded {"id": N}; validation asserts shape, not provenance. Rejected: HMAC-sign the cursor so only this endpoint can issue one. Why: The query is still $project->issues() behind viewAny, so a forged cursor only chooses a starting offset — the same reach ?after_id= would give. Source: KD-1268
Comment pin concurrency — lock the issue row, not the pinned-comment SELECT
Chose: lockForUpdate the parent issue as the first statement on the pin path only. Rejected: Lock the is_pinned=true SELECT, or a partial unique index. Why: Two racers can both find no pinned row, so locking that SELECT does not close the gap; a partial unique index is not expressible on the SQLite test path. Source: KD-1328
Pin transaction stays single-attempt
Chose: Keep $this->db->transaction(function() …) with no retry budget. Rejected: Add , 5) because lockForUpdate usually ships with deadlock retry. Why: The issue-row lock serializes rather than deadlocks, nothing is snapshotted before it, and a retry would re-introduce Eloquent save() no-op without a reset. Source: KD-1328
Deleted-batch audit — per-logger logDeletedMany(), not a generic logMany(action)
Chose: Each logger gets its own logDeletedMany(), mirroring logUpdatedMany(). Rejected: One generic method taking the action enum. Why: Issue and session loggers snapshot different fields, so a shared method still needs a per-logger hook and the name would stop saying which row it writes. Source: KD-1341
A request cap is not a lock-hold bound
Chose: Batch BulkDeleteIssuesAction's audit writes even though max:200 already caps the payload. Rejected: Leave the per-row loop under a @fanout-bound marker that cites the request rule. Why: 200 issues still means 200 serial SELECT … FOR UPDATE plus INSERT pairs, and every other audited write in the tenant queues behind them. Source: KD-1341
Snapshot-equality skip lives in the logger, not in each caller
Chose: logUpdatedMany() compares snapshots and skips on equality, so the seam and the three Bulk* Actions share one rule. Rejected: Filter in each caller, or a $skipUnchanged flag whose only false caller would be the seam. Why: Four copies of one rule is the shape that put a per-row loop in three Actions to begin with. Source: KD-1361
Payload caps stay after the audit write is batched
Chose: Keep max:500 / MAX_ISSUES 100 and their pinning tests. Rejected: Drop the caps now that fan-out is one write. Why: The cap bounds the request's per-element exists validation — a DoS surface that has nothing to do with the hash chain. Source: KD-1361
Equality skip compares the encoded row, not the snapshot arrays
Chose: json_encode both halves, matching what writeMany() stores. Rejected: === on the snapshot arrays, or special-case handed_to_claude_at. Why: handed_to_claude_at is a CarbonImmutable, and === on arrays compares objects by identity, so two equal timestamps never match and a handed issue that did not change still takes a row. Source: KD-1361
Orphan unassign — put the assignee predicate on the locked write, not the pre-read
Chose: Add whereIn('assignee_id', $userIds) to the existing write scope; leave the pre-read of $issues in place. Rejected: Filter $issues before handing it to the seam. Why: A row reassigned to a remaining member between the finder's read and the locked write would still be picked up by an un-narrowed write scope. Source: KD-1374
Unassign-on-user-delete queries assignee_id, not User::issues()
Chose: Snapshot and write Issue::query()->where('assignee_id', $user->id) (per project). Rejected: Drive the write from User::issues(), or add a named assignedIssues() relation. Why: User::issues() is the creator FK user_id, so the audit would have logged the wrong column and missed every assigned issue whose creator was someone else. Source: KD-1184
Cross-project unassign loops the audit seam per project
Chose: Group the snapshot by project_id, call AuditedIssueColumnWriteAction once per project, merge, then one bulkUpdated(). Rejected: Teach the seam a multi-project scope. Why: Extending the seam would touch every existing caller for a shape only this Action needs; UpdateTeamAction already proves the per-project loop. Source: KD-1184
User-delete unassign broadcasts bulkUpdated only
Chose: One post-unassign bulkUpdated(); no positionsUpdated, no myIssuesChangedMany, no assignment notification. Rejected: Copy the sprint/lane/epic trio of broadcasts, or fire NotifyAssignmentChangeAction. Why: IssuePositionResourceData has no assignee_id, the My Issues audience is the user being deleted, and a notification per unassigned issue would spam an admin action. Source: KD-1184
Broadcast the unassign after the delete transaction commits
Chose: Accumulate written issues inside the closure and call bulkUpdated() after the transaction returns. Rejected: Broadcast inside the transaction the way UpdateTeamAction does. Why: A pre-commit broadcast publishes state a deadlock retry can still roll back; the newer delete siblings already wait. Source: KD-1184
A failed unassign broadcast must not skip avatar cleanup or seat sync
Chose: Catch the post-commit bulkUpdated() throw, record it through GuardedReport, and continue. Rejected: Reorder the three post-commit steps so broadcast runs last. Why: Reordering only changes which step gets skipped; the avatar-cleanup Action already documents that a rethrow would drop the seat-sync dispatch that follows. Source: KD-1184
Lock the assignee snapshot, not only the seam's re-read
Chose: lockForUpdate() on the initial assignee_id = $user->id query so the predicate holds for the whole transaction. Rejected: Re-read again immediately before commit. Why: The seam only locks ids it already has, so a concurrent assign after the snapshot would survive pointing at a deleted user; locking the predicate closes that structurally. Source: KD-1184
Re-fetch unassigned issues through the blocked-dependents Action before broadcasting
Chose: Hand the written ids to BroadcastBlockedDependentsAction so unresolved_blockers_count is batched; on failure, fall back to bulkUpdated() on the un-batched collection. Rejected: Copy UpdateTeamAction's local refetch into DeleteUserAction. Why: That Action already owns the batched-count query; a private copy would drift, and a broadcast-only failure must not drop the unassignment or its audit row. Source: KD-1184
Repo-detach accepts the snapshot-vs-live race on project_github_repo_id
Chose: Keep the seam's snapshot-then-scoped-write and document the gap: an issue linked to the repo between snapshot and write is neither cleared nor audited. Rejected: Add an ON DELETE RESTRICT FK plus a 409-style exception, mirroring sprint/epic delete. Why: There is no FK today, the window is one admin request, and a new constraint plus exception path is real scope for a race every other seam caller already accepted. Source: KD-1185
Repo-detach does not broadcast the cleared project_github_repo_id
Chose: Ship with no live update; the issue-detail git section stays stale until reload. Rejected: Extend IssueBroadcaster with a patch that carries project_github_repo_id. Why: positionsUpdated cannot convey the field, patched() needs getChanges() from a save() the seam's builder update() never does, and no sibling needed a live update for an admin-only detach. Source: KD-1185
Fix the retry-unsafe $repo->delete() inline when the audit seam adds a retry budget
Chose: Re-resolve $repo through $project->githubRepos()->findOrFail($repo->id) as the closure's first statement. Rejected: Defer to KD-1227 with an @retry-safe-delete opt-out. Why: Injecting the seam requires a retry budget, which reproduces the Eloquent exists-flag hole; KD-1183 already took the deferral detour and had to fix it inline anyway. Source: KD-1185