Skip to content

Issues & Board Decisions ​

Each entry is a real fork in the road. Implementation details that aren't a tradeoff are NOT here.


Creator filter — client-side only, no backend changes ​

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

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

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

Task type tint — Blue, not Purple or Gray ​

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

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

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

Bulk select — Backlog only, not Board ​

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

Bulk select affordance — highlight only, no checkboxes ​

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

Bulk move — drag selected together, not context menu ​

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

Bulk-select range behaviour — shift+click selects range ​

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

Selection clears when filters change ​

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

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

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

BulkActionBar — generic segmented control ​

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

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

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

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

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

Sprint selector — frontend filter only, backend stays permissive ​

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

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

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

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

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

Backlog assignee display — text name, not avatar ​

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

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

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

No "Deleted user" fallback for assignee ​

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

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

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

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

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

Bypass adapter store for scoped reads ​

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

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

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

Tab endpoints — three dedicated routes, leave /search alone ​

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

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

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

Per-tab ResourceData classes, not shared IssueResourceData ​

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

Broadcast keeps IssueResourceData; frontend projects to slim shapes ​

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

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

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

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

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

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

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

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

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

Raise GlobalSearchIssuesAction::MAX_LIMIT from 100 to 500 ​

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

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

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

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

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

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

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

Translate descriptions only, leave comments Dutch ​

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

Translation tooling — kendo CLI, not MCP loops ​

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Comment pagination — client-side slice, no backend envelope ​

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Label exposure on issues — label_ids, not nested label resources ​

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

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

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

Label delete — silent detach, no destination, no count ​

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

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

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

Rank compaction — full project respread, not LexoRank buckets ​

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

Respread runs synchronously, not as a queued job ​

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

Respread broadcasts nothing — other clients self-heal ​

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

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

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

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

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

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

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

Responsive threshold — UnoCSS breakpoint, not a JS isNarrow ref ​

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

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

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

Multi-label filter — OR semantics, not AND ​

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

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

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

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

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

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

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

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

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

Hydrator exposes two named verbs, not one polymorphic entry point ​

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

One hydrator per resource profile, not one per model ​

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

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

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

Whoever serializes, hydrates — broadcaster and controllers, never Actions ​

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

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

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

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

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

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

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

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

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

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

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

Cache-hash bumps track the roster, not the pivot ​

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

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

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

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

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

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

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

Semantic mismatch on a shared component beats parameterizing it ​

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

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

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

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

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

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

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

No broadcast channel for single-viewer state ​

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

Stale pins are left in place; only deletion cleans up ​

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

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

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

Ungate the actions column, gate the individual action ​

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

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

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

Don't re-implement authorization the query already enforces ​

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

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

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

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

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

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

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

A mutation's return value must report what actually happened ​

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

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

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

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

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

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

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

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


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