Appearance
Postmortems
Production-affecting bugs and edge cases that taught us something. Newest first.
The fix lives in code; what's preserved here is the root cause and the generalizable lesson — the part that disappears if you only read commits.
KD-1050 — ReportCard clamp gated on a flag only the clamp itself could set
- Severity: low
- Symptom: The "Show more"/"Show less" truncation on report descriptions never activated, no matter how long the description. The clamp, the fade, and the toggle button silently never appeared — long descriptions rendered at full height with no affordance.
- Root cause: The clamp class (
max-h-40 overflow-hidden) was applied only oncedescriptionOverflowswas alreadytrue, butdescriptionOverflowswas computed asscrollHeight > clientHeightmeasured on the unclamped div — and an unconstrained block-level div always hasscrollHeight === clientHeightbecause it grows to fit. Circular and dead on arrival: the flag needed the clamp, the clamp needed the flag. - Fix: Dropped
&& descriptionOverflowsfrom the:classternary so the div clamps unconditionally while collapsed; the first measurement then sees a real cappedclientHeight.checkOverflow()and the fade/togglev-ifs were already correct — they just never received atrue. - Lesson: Every existing spec set
wrapper.vm.descriptionOverflows = trueby hand, which pinned the render branches while skipping the only measurement that could ever producetrue— a test that forces the state under test proves the branch renders, not that anything can reach it. Any feature whose visibility depends on measuring itself needs a test driving realscrollHeight/clientHeight.
KD-1049 — Reverb launched with no preflight for the PHP redis extension
- Severity: low
- Symptom:
/startupreported the dev servers running, but Reverb had crashed instantly withClass "Redis" not foundon any machine whose PHP lacked the phpredis extension. Frontend and backend were fine, so realtime features silently didn't work with no message naming the cause. - Root cause: The startup skill's final step launched
php artisan reverb:startunconditionally as a background command. An extension check did exist (backend.mdStep 1) but ran inside a different, earlier background agent as advisory prose — its result never reached the step that starts Reverb, so it had no gating effect and Laravel'sPhpRedisConnector::createClient()threw at boot instead. - Fix: Inline
php -m | grep -qi '^redis$'preflight immediately before the Reverb launch. On a miss, Reverb is skipped and the step reports it with per-OS install instructions and the exact re-run command; frontend and backend start unconditionally either way. - Lesson: A check whose result can't reach the step that would act on it is documentation, not a gate. Preflight belongs at the exact point of failure and in the same execution context as the command it guards — and a background command that dies on boot needs its failure surfaced, not just its launch reported.
KD-1048 — notifications.message VARCHAR(255) too short for an interpolated issue title
- Severity: medium
- Symptom:
php artisan dev:resetintermittently died mid-seed withSQLSTATE[22001] … Data too long for column 'message', leaving the tenant database partially seeded —NotificationSeederfailed before the Claude eligibility and session seeders ever ran. Intermittent because the seeder picks its issue at random. - Root cause:
notifications.messagewas declared$table->string('message')— implicitVARCHAR(255).NotificationFactory::forIssue()interpolates the fullissue->title(itself up to 255 chars) plus template text and an actor name into that column viasprintf, so a realistic worst case comfortably exceeds 255, and MySQL strict mode rejects rather than truncates. - Fix: Append-only migration widening
messagetoVARCHAR(512), mirroring theincrease_avatar_url_lengthprecedent. Kept asVARCHARrather thantext— it's a bounded, system-generated display string with a computable worst case, not arbitrary user content. - Lesson: A column that interpolates another
VARCHAR(255)column needs a length derived from the worst case, not the framework default —string()with no length is a silent 255 cap. And the arithmetic only bites under MySQL strict mode: the test suite runs on SQLite, which ignoresVARCHARlength entirely, so no runtime test can reproduce error 1406. The regression test pins the declared width statically instead, which is the only thing that can hold for MySQL-backed environments.
KD-1047 — Pipeline stepper keeps spinning after story generation fails
- Severity: low
- Symptom: When AI story generation failed — terminal
agent-story-failedevent, or the client guard timing out with no terminal event at all — the step that wasSTARTEDat that moment kept its spinner indefinitely. The Generate button correctly reverted to idle, so the UI showed a finished run with a step still in progress, until the nextgenerate()call. - Root cause:
useStoryGeneration.ts'sonFailedhandler and the guard-timeout callback both calledsurfaceError()+settle(null)but never resetsteps.settle()doesn't touchstepsby design — the success path already overwritessteps.valuewith the authoritativemergePipeline(data.pipeline)before settling. The failure paths had no equivalent, so whatever partial statehandleProgressEventlast wrote survived forever. - Fix:
steps.value = buildInitialSteps()in both failure exits, right aftersurfaceError()and beforesettle(null). Success path untouched, so a genuine completion still shows the authoritative pipeline. - Lesson: When the happy path cleans up as a side effect of doing its real work, the error paths inherit no cleanup at all —
settle()looked like shared teardown, but only the success branch happened to reset state before calling it. Every terminal exit of a stateful async flow needs its own reset, and the tests have to assert the post-failure state, not just that the error was surfaced.
KD-1045 — .env.example promised a TENANT_ADMIN_DB_* fallback the config deliberately refuses
- Severity: low
- Symptom: Provisioning a brand-new tenant on a local setup (a secondary worktree, or any fresh
/startup) failed the MySQL auth handshake with an opaque[2054] The server requested authentication method unknown to the client. Invisible to anyone working against the already-provisioned seeded tenant, since only a freshCREATE DATABASEexercises the path. - Root cause: Documentation drift.
config/database.php'stenant_adminconnection has no fallback fromTENANT_ADMIN_DB_*toDB_*/CENTRAL_DB_*— deliberately, per KD-0793, because such a fallback caused a real production bug where signup'sCREATE DATABASEran under the steady-state credential. But.env.example's comment asserted the opposite ("defaults fall through to the same MySQL root credential"), and the/startupenv reference never set the vars, soTENANT_ADMIN_DB_PASSWORDresolved to''against Docker'sroot. - Fix: Rewrote the
.env.examplecomment to describe the real no-fallback design, and added explicitTENANT_ADMIN_DB_USERNAME=root/TENANT_ADMIN_DB_PASSWORD=rootsteps to/startup's env reference alongside the existingDB_*local defaults.config/database.phpuntouched, per scope. - Lesson: When a config deliberately omits a convenience fallback for a safety reason, the comment describing it is load-bearing and drifts silently — nothing tests prose, and the next reader trusts it. The gap only bites the first-run path (provisioning a new tenant) that the everyday seeded setup never touches, so the local-setup docs have to set explicitly whatever the code refuses to infer.
KD-1028 — Tiptap serializes nested lists at 2 spaces; marked needs 3+
- Severity: medium
- Symptom: Editing an epic description containing a nested numbered list and saving flattened it — sub-items lost their indentation and the whole list renumbered as one continuous 1..N sequence on render. The same editor and renderer power issue descriptions and comments, so all three surfaces were affected identically.
- Root cause: Two independently-configured markdown implementations disagreeing.
RichTextArea.vueregistered the TiptapMarkdownextension with noindentationoption, defaulting to 2 spaces per level on serialize. The render side (markdown.ts) usesmarked(CommonMark), which requires a sublist under anN.marker to be indented to the parent marker's content column — ≥3 spaces for1., ≥4 for two-digit markers. At 2 spacesmarkeddoesn't recognise a sublist at all and flattens it into a single<ol>. - Fix:
Markdown.configure({indentation: {style: 'space', size: 4}})— one line, covering the epic, issue, and comment editors since they share the component. - Lesson: Tiptap's own tokenizer round-trips its 2-space output fine, so every editor-side test passed; the defect exists only in the seam between the serializer and a different renderer. Round-trip coverage has to run the real save→render pipeline, not the editor's own parse. The deeper smell is structural: two markdown implementations with no shared dialect contract will diverge again on nested blockquotes, code blocks inside list items, and anything else where CommonMark has an indent rule.
KD-1025 — Promote Actions check "already done?" outside the transaction (TOCTOU)
- Severity: high
- Symptom: Concurrent promotes — a double-click, a retry, two users — both passed the guard and both did the work.
PromoteErrorGroupToIssueActioncreated two Bug issues, one orphaned, with last-write-wins onerror_groups.linked_issue_id;PromoteReportsActioncreated duplicate issues, one carrying copied attachments that never got linked. - Root cause: Check-then-act with no atomicity between check and persist. The guard read an in-memory property outside any transaction, then the slow side-effecting work (
CreateIssueAction,CopyAttachmentsAction) ran, and only afterwards was the flag the guard checked written. Nothing at the DB level made the claim atomic, so both racers observed "not done" and both acted. Windows were:35→:54and:41→:79. - Fix: Lock-and-hold, reusing
LinkProjectGithubRepoAction's existing pattern — open the transaction first,lockForUpdate()the guarded rows as the first statement, re-assert the guard column under the lock, and relocate the side-effecting work inside the lock window. The reports re-fetch addsorderBy('id')to fix lock order so overlapping sets can't deadlock. Losers throw the pre-existing 422 exceptions, so the HTTP response is unchanged. - Lesson: A guard is only a guard if the read and the write that satisfies it are atomic — an in-memory property check before slow work is a suggestion. Note what the shipped tests still don't prove: they mock the locked re-fetch to return the winner's committed value, which pins the code path but not real lock contention, so this class stays unproven under load until a MySQL-backed race harness exists. Site #3 (
StartNewClaudeSessionAction) was split out because claiming before the paid Anthropic call needs a schema migration — the unique index prevents a duplicate row but not a wasted paid session.
KD-1015 — Full-payload preference PUT lets two sessions clobber each other
- Severity: medium
- Symptom: Two independent sessions (two tabs, two devices) each changing a different notification preference around the same time: whichever request committed last silently reverted the other session's already-committed change.
- Root cause: The wire contract had no way to express "this field is unchanged." All 10 fields were
requiredin the FormRequest and non-nullableboolon the DTO, and the Action unconditionally assigned all 10 from$data, so every request was a full-state overwrite built from the sending session's snapshot.$user->refresh()made each individual write retry-safe and read current DB state, but didn't help — the Action then blindly re-applied all 10 values from the request anyway. - Fix: Partial-update contract — fields become
?bool, rules gorequired→sometimes(extracted via the canonicalextractNullableBoolean()helper), and each assignment is guarded by!== null. The frontendtoggle()sends{[key]: value}instead of spreading the full snapshot. - Lesson: KD-1001 fixed the same clobber within one session by serializing writes client-side, and that fix could never reach across sessions — because the real defect was the full-state wire contract, not the client's queueing. When a payload asserts fields the user didn't touch, last-write-wins is guaranteed the moment a second writer exists; silence about a field is the only safe way to say "leave it alone." Incidentally exposed that
notify_error_activity_emailhad beenrequiredon the backend since KD-0894 while the frontend never sent it — every preferences save ondevelopmentwas 422-ing.
KD-1005 — EpicBoard scroll-anchor test rides the real clock, red for one day a year
- Severity: low
- Symptom:
EpicBoard.spec.ts→ "should re-anchor today at 25% from left edge on granularity change" went red on 2026-07-02 withAssertionError: expected 900 not to be 900, blocking an otherwise-green unrelated PR, then self-healed overnight. - Root cause: The component anchors "today" via
new Date()and the test never froze the clock. Both grids for the clamped full-2026 range are odd-length and symmetric about the range midpoint (DAY: 365 cols, centre 183; WEEK: 53 cols, centre 27), so on the midpoint day both granularities resolvetodayFractionto 0.5 and produce the identicalscrollLeftof 900 — making the test's.not.toBe(scrollAfterMount)assertion false. Production code was correct. - Fix:
vi.useFakeTimers({toFake: ['Date']})+vi.setSystemTime(new Date('2026-03-15'))in the two clock-reading scroll tests, restored withvi.useRealTimers(). 2026-03-15 sits well off the midpoint so the WEEK-mount and DAY anchors genuinely differ and the assertion still tests real re-computation. Faking onlyDateleaves the synchronousrequestAnimationFramestub intact. - Lesson: Any test reaching production code that calls
new Date()is a calendar-dependent flake waiting for its date — and the window can be a single day per year, so "it's been green for months" is no evidence at all. Freeze the clock at a date chosen to make the assertion discriminating, not merely deterministic: pinning the midpoint date would have been just as broken, only permanently.
KD-1002 — Markdown links open in the same tab, unloading the SPA
- Severity: medium
- Symptom: Clicking a link inside a rendered issue description or comment navigated the current tab to the target instead of opening a new one. Returning via the browser back button showed the user as logged out.
- Root cause:
createMarkdownRendererregisters a custom mention extension but never overrode the default link renderer, andmarkedv18 emits bare<a href="…">with notargetorrel. The apparent logout is a downstream consequence: same-tab navigation unloads the SPA, and on back-navigation an API call fires before the session re-validates, 401s, andhandleSessionExpiredredirects to login. - Fix: A
renderer.linkoverride appendingtarget="_blank" rel="noopener noreferrer"to every anchor (link text rendered throughthis.parser.parseInline(token.tokens)so nested inline formatting survives), plus{ADD_ATTR: ['target']}onDOMPurify.sanitize()— DOMPurify stripstargetby default as an open-redirect precaution. - Lesson: The sanitizer happily preserves an attribute the renderer never emitted, so "DOMPurify keeps
target" was true and useless — in a two-stage HTML pipeline the assertion has to be written against the final output, not either stage. And a symptom that reads like an auth bug ("the back button logs me out") can be the second-order effect of a rendering default; investigating the session layer would have found nothing wrong there.
KD-1001 — Rapid notification toggles silently revert each other
- Severity: medium
- Symptom: On Account Settings, flipping two notification toggles in quick succession made the first one visibly bounce back to its old value, and persisted the wrong preference.
- Root cause:
toggle()spreadpreferences.value— a computed derived from live auth state — into a full-payload PUT. Auth state only updates when a request responds, so a second toggle fired before the first response landed built its payload from the first key's stale value. The two PUTs raced and each response overwroteauthService.userwholesale, last-write-wins.ToggleButtonusesdefineModeland flips optimistically, which is why the revert was visible as a bounce when the stale response landed. - Fix: Optimistic local state plus serialized writes — bind the toggles to a
localreactive mirror seeded once from the logged-in user (instant UI, cumulative payloads), and chain each PUT through a component-scoped promise queue so the backend's last write is always the final cumulative state. - Lesson: Deriving a request payload from state that only advances on response makes every in-flight request a stale-read window. The regression tests are the model for this class: hold the first request open with a never-resolving promise and fire the second — a test that awaits each call in turn cannot express the race at all. And this only closed the same-session case; the cross-session version was still wide open and became KD-1015.
KD-0999 — Raw "Unauthenticated." toast shown to guests on the login page
- Severity: low
- Symptom: Visiting the app while logged out — a bookmarked board URL, or the login page directly — showed a toast with the raw backend string "Unauthenticated." on top of the login screen. The redirect itself was expected; the toast was noise on the first screen a logged-out user sees.
- Root cause:
toastMiddleware's 401 carve-out keyed offwindow.location.pathname === '/', but the login route is mounted at/auth/login, so the carve-out never fired for the guest flow it was written to cover. The middleware never consultedauthService.isUserLoggedIn— unlikeregisterUnauthorizedMiddleware, which already used exactly that signal to distinguish an anonymous 401 from a real session expiry. - Fix: Both the tenant and central
toastMiddlewarenow suppress on!authService.isUserLoggedIn.valueinstead of the pathname. A mid-session 401 still toasts, sinceisUserLoggedInonly flips after the session-expiry middleware clearsuser, which runs after the toast middleware for the same response. - Lesson: A URL string used as a proxy for auth state is a guess that stops matching the moment a route moves, and does so silently — the guard kept "working," it just never matched anything. The correct signal already existed one file over, used by a sibling guard to answer the same question. When two middlewares both decide "is this 401 expected?", they must read one source, not two proxies for it.
KD-0998 — Issue-create form loses typed input on an empty-title submit
- Severity: medium
- Symptom: Submitting the issue-create page without a title replaced the form with a fatal "Could not load page" screen; clicking "Go back" landed the user on the board with everything they had typed gone.
- Root cause:
submitIssueawaitednewIssue.create()and let the rejection propagate. The 422 rejection escaped the handler and reached the async error boundary, which unmounted the form — and the user's input with it. Feedback was never the problem:errorServiceMiddlewareandtoastMiddlewarefire synchronously in the HTTP response pipeline and had already populated the inline errors before the promise settled. The rethrow was what destroyed the form. - Fix:
newIssue.create().catch(() => null)with an earlyif (!created) returnguard, so the handler stops rethrowing and the form stays mounted with inline errors visible. - Lesson: An unhandled rejection from a routine validation failure hands a recoverable form to the fatal error boundary, which cannot distinguish "this page failed to load" from "the user forgot a field." A boundary that renders instead of the form has to be reserved for failures that genuinely make the page unusable — and the user-visible cost of getting that wrong isn't the error screen, it's the lost input.
KD-0997 — Token CTA gated on the wrong permission, three times running
- Severity: medium
- Symptom: On the project error-tracking empty state, "Create a project token" was shown to every project member. For a member without settings access, clicking it did nothing — the router silently redirected with no toast and no visible state change, so the button read as broken.
- Root cause: The CTA rendered unconditionally and leaned on the destination route's
PROJECTS.UPDATEgate, whichauthMiddlewareenforces by silent redirect. The first fix gated it onPROJECT_TOKENS.CREATE— the ability the backend policy actually requires to mint a token — but those two permissions are independent, so a principal holding one without the other still hit the same silent no-op. The second fix ANDed incanInProject(PROJECTS, UPDATE, projectOwnerId), which carries an owner-bypass the router's plaincan(PROJECTS, UPDATE)does not, so a Member-role project owner still saw the CTA and was still bounced. - Fix:
canReachTokenSettingsnow calls the identicalcan(PROJECTS, UPDATE)the router calls, ANDed with the token-creation ability. Chosen over widening the route'smeta.permissions, which would have admitted token creators to the entire settings page (teams, lanes, labels, AI keys) since no child section gates itself. - Lesson: A visibility gate that predicts a route guard must call the same function with the same arguments — two post-merge review rounds were spent on near-miss variants of the right permission. The spec was complicit:
canInProjectwas mocked with a single blanketmockReturnValue, which made "has one permission but not the other" literally inexpressible, and that is exactly the gap that let both broken versions ship. Mock each permission check independently or the tests cannot see this class of bug.
KD-0994 — splice(index) with no deleteCount wipes the whole modal stack
- Severity: high
- Symptom: Two-factor authentication could not be enabled and recovery codes could not be regenerated at all — after confirming the password prompt, the
TwoFactorSetup/RecoveryCodesmodal never appeared. - Root cause: The modal service's
onClosehandler calledmodals.value.splice(index)with nodeleteCount.Array.prototype.splice(start)removes every element fromstartto the end of the array.PasswordConfirmModalsat at index 0 and pushed the child modal at index 1; when the parent closed,splice(0)wiped both entries, destroying the child immediately after it was added. - Fix:
modals.value.splice(index, 1)— a single-character change. - Lesson: The bug shipped with the modal service in March 2025 and was harmless for eleven months because every caller opened exactly one modal. It went live in February 2026 when the password-confirmation requirement introduced the codebase's first nested
createModal, then sat unreported for four more months. A latent API misuse becomes a defect when a new usage pattern arrives, not when the code changes — so a single test for "closing one modal in a stack leaves the others mounted" would have caught it at any point across those fifteen months, and no amount of reviewing the February diff would have.
KD-0993 — Promote broadcast excludes the promoting user's own connection
- Severity: medium
- Symptom: After promoting a report to an issue, the promoter's own browser kept showing it as Pending until a manual refresh. Every other user watching the project saw it leave Pending immediately.
- Root cause:
ReportBroadcaster::updated()always called->toOthers(), which uses the requester's socket ID to exclude their own Echo/Reverb connection.PromoteReportsActionrelied solely on that broadcast to propagate promoted state into the frontend store — a deliberate KD-0526 decision, pinned by a frontend spec assertingpromote()does not refetch — and never applied the POST response locally.DismissReportActionshares the sametoOthers()-scoped broadcast and has no bug only because the frontenddismiss()applies the response viasetByIdas a backstop. - Fix:
updated()gained an opt-outbool $toOthers = true;PromoteReportsActionpassestoOthers: falseso the initiator's own connection receives the event. No frontend change — the store's existingonUpdatehandler applies it. - Lesson: "The broadcast handles state sync" and "the broadcast excludes the initiator" are each reasonable and jointly a bug — and both were pinned by passing tests, one on each side of the stack, so the contradiction was invisible from either side. When a mutation's only state-sync path is a broadcast, the initiator needs either a local apply or an inclusive broadcast. The sibling action that happened to get this right (dismiss) is precisely what kept the asymmetry from being noticed.
KD-0991 — items-start shrink-to-fits the Settings card below lg, clipping controls off-screen
- Severity: medium
- Symptom: Below 1024px the Settings/Profile page overflowed horizontally with the right-hand portion clipped and unreachable — the copy button on a revealed API token, the Cancel button on the create-token form, and "Disable 2FA" were all sliced off with no way to scroll to them.
- Root cause: The page wrapper carried unconditional
items-startalongsideflex-col-reverse lg:flex-row. Belowlgthe container is in column mode, where the cross axis is horizontal — soitems-startstoppedmain-cardfrom stretching to full width and made it shrink-to-fit instead.min-w-0 flex-1on the card governs only the main axis (height, in column mode) and did nothing. The token table's intrinsic minimum width then propagated up the auto-width chain and pushed the card past the viewport, where the app shell'soverflow-x-hiddenclipped it silently rather than offering scroll. At ≥1024px the cross axis becomes vertical anditems-startmerely top-aligns, so desktop was unaffected. - Fix: Moved
items-startbehind the breakpoint (lg:items-start), so the stacked layout falls back to the flex defaultstretchand the table's ownoverflow-x-autocontains the overflow. - Lesson:
align-itemsswaps which axis it governs whenflex-directionchanges, so an unconditional alignment class on a responsive container means two different things above and below the breakpoint — and the sizing helpers that look like they should save you (min-w-0,flex-1) are main-axis only. JSDOM computes no layout, so the 204-test auth suite could neither have caught this nor can it catch a re-break; a browser click-through is the only evidence that counts. The same pattern still sits unfixed inWorkspace.vue,teams/Show.vue, andprojects/Edit.vue.
KD-0985 — Pagination bar prints the computed-ref variable name as the entity label
- Severity: low
- Symptom: The comments pagination bar on an issue read "Showing 1 - 10 of 12 paginatedComments" — the camelCase identifier leaked verbatim into user-facing copy.
- Root cause:
CommentSection.vuepassedentity-name="paginatedComments"(the computed ref's variable name) andPaginationBarrendersdirectly. The string was never updated when the variable was named. - Fix:
entity-name="comments", with the spec assertion updated to match. - Lesson: The spec asserted
'paginatedComments'and therefore locked the bug in — a test that pins whatever the code currently does converts a typo into a specification, and every subsequent run confirms it.PaginationBaralso has no constraint thatentityNamebe human-readable, so any string passes through; a prop whose value lands in a rendered sentence needs either a typed set of allowed values or a review habit of reading the sentence out loud.
KD-0977 — Arch test re-resolve()s an already-normalized path, false-positiving on Windows
- Severity: medium
- Symptom: The full frontend suite failed locally for Windows developers while staying green on Linux CI, with the import-boundaries arch test flagging a legitimate intra-
projectsimport. It was filed as a seed-dependent inter-file mock-pollution flake with a rotating victim spec. - Root cause:
getDomainFromFilePathcomputedfilePath.replace(\${resolve(TENANT_DOMAINS_DIR)}/`, '').TENANT_DOMAINS_DIRwas alreadynormalizePath(resolve(…))— forward slashes — and re-wrapping it inresolve()reintroduced platform separators, so on Windows the replace matched nothing, the full path survived, andsplit('/')[0]returned"C:". That garbage domain never equalled the imported domain, which disabled the same-top-level-domain skip and flagged the import. On Linuxresolve()` returns forward slashes, so CI stayed green. - Fix: Strip the already-normalized constant directly (drop the redundant
resolve()), sweeping sibling arch specs for the same pattern. Separately pinnedisolate: trueexplicitly in the root Vitest config to make the protective invariant regression-proof. - Lesson: The filed diagnosis was wrong and had to be disproven by experiment: two crafted specs polluting and then reading a shared
__mocks__singleton showed no leak under the defaultisolate: true, so the mock-pollution class is architecturally prevented — and the blanketmockResetthe report implied would have been a no-op for the symptom and would have wiped the module-load defaults many specs depend on. "A different victim each run" reads like a flake and was in fact one deterministic, platform-specific failure. Separately: normalize a path once and pass the constant around; re-normalizing is exactly where the separators come back.
KD-0953 — Avatar URL never changes, so the browser never refetches after upload
- Severity: medium
- Symptom: After uploading a new profile picture, the old avatar persisted on every surface — issue sidebar, activity log, member lists — until a hard browser reload.
- Root cause: Two compounding layers.
ProfilePictureController::show()served avatars withCache-Control: max-age=604800, public, so browsers held them for a week without revalidating. AndProfilePictureUrlsData::forUser()built URLs keyed only on user id, so the freshProfileResourcereturned after upload contained byte-identical URL strings — Vue saw an unchangedsrcand never triggered a fetch. Even underno-cachethe browser never asks, because the<img>src never changes. - Fix: Append
?v=<user->profile_picture>(the baseName that rotates on every upload) to both URLs, and switch the route middleware tocache.headers:no_cache;publicso the browser revalidates; the pre-existing weak ETag makes that a cheap 304 when unchanged. No frontend change — the version token fixes every in-session client, not just the uploader. - Lesson: Cache headers cannot help when the client never asks — for mutable content behind a stable URL, the
srcbinding is the cache key, and content-addressable or version-tokened URLs are the only reliable invalidation under a reactive framework. Note the version token had to reach three separate URL-building sites, two of them inlined insideGetIssueActivitiesActionrather than going through the resource — duplicated URL construction is what makes this kind of fix leaky.
KD-0951 — Attachment grid scrolls horizontally behind an auto-hiding scrollbar
- Severity: low
- Symptom: On the issue page the attachment grid rendered as a single horizontal strip; files past the container width sat behind a macOS auto-hiding scrollbar, so users had no signal that more attachments existed. Separately, the "Attachments" section header showed no count, unlike the Comments and Activity tabs.
- Root cause:
AttachmentGrid.vuecarriedflex overflow-x-autoas unconditional static UnoCSS attributes, so the compact context's horizontal strip was also applied in the non-compact issue-page context where wrapping was wanted. The count was already computed inAttachmentSection.vuebut never rendered into theSectionHeaderslot. - Fix: Moved
overflow-x-autoout of the static attributes into the dynamic:classbehindcompact, and gave the non-compact pathflex-wrap; added a count badge span reusing the tab strip's styling. - Lesson: A component with a
compactprop that applies its compact-only layout statically has one variant that is silently wrong at every call site — the prop implies a branch the class list never actually makes. And overflow hidden behind an auto-hiding scrollbar is invisible by construction on macOS, so this class of defect can't produce a report that describes what's missing; the user doesn't know anything is.
KD-0931 — AI-key-missing failure collapses into a generic "try again"
- Severity: medium
- Symptom: A tenant with no Anthropic API key configured triggered story generation and saw only "Story generation failed. Please try again." Neither the cause (no key configured) nor the remedy (where to add one) ever reached the user.
- Root cause: Two independent gaps composing.
GenerateStoryJob::failed()logged the exception message but passed only$requestIdand$userIdtoBroadcastStoryGenerationFailureAction, which always dispatched a hardcoded generic string — the exception type was discarded before the broadcast. And on the frontend,onFailedreceivedevent.messagefrom the broadcast and ignored it, callingsurfaceError()with no argument and always rendering the same hardcoded string. - Fix: Threaded the message through the stack — optional
$messageon the broadcast action defaulting to the generic string, a ternary infailed()selecting "No AI key configured for this project. Add one in Project Settings → AI Keys." forAiKeyNotConfiguredException, andsurfaceError(event.message || undefined)on the client so an empty string still falls back. - Lesson: Two layers each defaulting to a generic message means the specific one can never arrive, and neither layer looks broken in isolation — the backend "handles" the failure, the frontend "shows" it. Any error channel carrying a message field needs at least one test asserting that a specific message survives the whole trip; a test that the generic fallback renders proves only that the fallback works.
KD-0928 — Lane reorder invisible until refresh: the cache-hash protocol never reached the client
- Severity: medium
- Symptom: Reordering lanes in Project Settings didn't change the board until a full page refresh.
- Root cause: Two backend deficiencies in the cached-store invalidation protocol (ADR-0032), each already diagnosed separately.
config/cors.phphadexposed_headers => [], so the browser strippedx-fs-cache-hasheson every cross-origin request — which is every local dev install (SPA on :3000, API on :8000) — and the store wrapper sawnullon every response (KD-0920). AndStampCacheHashesMiddlewarewas mounted on only five routes, none of them the navigation requests an SPA actually fires, making steady-state invalidation circular (KD-0919). - Fix: This ticket shipped the missing HTTP-level regression test —
PUT /api/projects/{id}with a lane reorder must stamp the freshlanes_hashinx-fs-cache-hashes, and that hash must differ from the pre-mutation value. The two defects themselves were fixed by KD-0920 and KD-0919. - Lesson: The existing protocol test covered the Action bumping
lanes_hashin the DB and the resource endpoint stamping the header — but never the mutation endpoint the settings page actually calls, which is the single link the whole invalidation chain hangs from. Testing each end of a protocol independently leaves the seam untested, and both defects lived precisely in that seam. A protocol needs at least one test that walks it end to end at the HTTP layer.
KD-0911 — Interrupted navigation renders a stale ProjectLayout, throwing on a missing route param
- Severity: medium
- Symptom: Navigating from a list view (Inbox, My Issues, Error tracking, All projects, Roles) into a detail page and away again before it finished loading produced an unhandled
Missing required param "parentId"— a brief flash of the error screen, or a full "Could not load page" on the wrong route. - Root cause:
ProjectLayout.vueis an async component and Vue's Suspense does not cancel a pending setup. When the setup resolved after the user had navigated away, Vue rendered the stale instance against the new reactive route.MenuTabs' tabtoobjects carry only{name}, so the customRouterLinkfell through toresolveParentId, which readscurrentRoute.params.parentId— undefined on a non-project route — androuter.resolvethrew.MenuTabssits above theAsyncErrorBoundaryin the template and nothing wrapsProjectLayoutin the routing tree, so it surfaced as an uncaught promise rejection. - Fix: Optional
parentIdprop onMenuTabs;ProjectLayoutpassesprojectIdcaptured at mount time, before anyawait, so it is immune to reactive route changes. Tab links never consultcurrentRouteagain. - Lesson: A route-derived implicit fallback —
resolveParentIdsilently readingcurrentRoutewhen no param is passed — is a hidden dependency on "the route hasn't changed since this component mounted," an assumption async setup breaks by construction. Anytoobject omitting a required param for a project-scoped route will throw the moment it renders on a non-project route, so the whole call-site class is suspect, not just this one. Also worth noting the error boundary was positioned below the component that throws, so it caught nothing.
KD-0886 — Branch unlink hidden behind a GitHub OAuth check it doesn't need
- Severity: low
- Symptom: On the issue Git panel, the per-branch "Remove" button was hidden for any user without personal GitHub OAuth connected — so a user whose branch was auto-linked by pushing a branch name containing the issue key could not unlink it from the UI without connecting GitHub first.
- Root cause:
BranchLinkList.vuegated the Remove button withv-if="isConnected", reflecting personal OAuth status fromGET /github/status. But unlink calls a kendo-local endpoint that never touches GitHub, and the backend gates it only onIssueBranchLinkPolicy::delete()(project membership). The frontend gate was strictly broader than what the backend enforced — create-branch and link-branch genuinely need OAuth, remove does not. - Fix: Dropped the
v-if, the now-unusedisConnectedprop, and its call-site binding. The create and link controls stay gated. - Lesson: One connection flag gating a whole panel is the easy default and it over-gates every operation in the panel that doesn't need the connection — the right granularity is per-operation, matched to what the backend actually enforces. The existing spec asserted the button was absent when disconnected, so the over-gate was pinned as intended behaviour until someone flipped the polarity.
KD-0859 — Resend-verification keyed on tenant.database as a proxy for the domain
- Severity: low
- Symptom: None in production — the gate is structurally wrong but currently unreachable, since operator-created tenants receive a
WelcomeNewTenantemail rather than entering the verification flow. Were it reachable, the action would silently no-send: the endpoint returns a uniform 204 either way for anti-enumeration, with no log line. - Root cause: Two independent sites used
tenant->databaseas a proxy for the user-facing subdomain.SignupActionsetsdatabaseanddomainfrom the same$data->subdomain, so the invariant holds for signup tenants;CreateTenantActionsets them from separate DTO fields an operator can diverge. Once they diverge, the tenant lookup misses and the domain-readiness gate resolves to null. - Fix: Step 1 matches the inbound subdomain against
domain.domainviawhereHas('domains', …); Step 2 re-keys the readinessexists()off the same$data->subdomain. The domain filter is retained deliberately — dropping it would gate on any Active domain the tenant owns, which opens the gate prematurely for a multi-domain tenant whose target is Pending and whose sibling is Active. - Lesson: An invariant enforced by one creation path and not another is a proxy waiting to break, and an anti-enumeration uniform response guarantees the break is silent — no user signal, no operator signal, so a log line on the held-send path is the only observability that exists. Worth recording that the first shape of this fix over-corrected by dropping the domain filter entirely; the multi-domain regression test that caught it only appeared in a second review round.
KD-0835 — Cached SPA shell references chunk hashes the deploy already replaced
- Severity: high
- Symptom: On the first visit each morning after an overnight deploy, users got a 404 or a blank page. A manual refresh resolved it every time.
- Root cause: The Laravel SPA fallback route returned a bare
view('app')with no explicitCache-Control. Symfony's defaultno-cache, privatepermits storage with conditional revalidation, and Cloudflare in front of Fly.io may treat the response as cacheable too. A cached shell referencestenant-<oldhash>.js, which the deploy replaced and nginx correctly 404s. Because the entry module 404s before JavaScript starts, the existingvite:preloadError/router.onErrorrecovery never runs. - Fix:
Cache-Control: no-storeon the SPA HTML response. Hashed assets in/assets/keepexpires 7d— they're content-addressable and safe to cache indefinitely. - Lesson: A recovery handler that lives inside the bundle cannot recover a failure to load the bundle — the stale-build machinery had been in place for months and was structurally incapable of covering the entry-chunk case, which is the one users actually hit. The shell that names hashed assets must never be cached; only the content-addressable assets may be. Tradeoff worth remembering:
no-storealso evicts the page from the bfcache in Chrome and Firefox, so every back-navigation becomes a full document reload;no-cachecloses the same defect more cheaply if that turns out to be perceptible.
KD-0794 — composer test OOMs at 512M because the whole suite runs in one process
- Severity: low
- Symptom:
composer testfatal-OOMed mid-Feature withAllowed memory size of 536870912 bytes exhausted, crashing inside a vendor MIME-type detector that was merely where cumulative allocation tipped over the cap. Some developers reproduced it, others couldn't. - Root cause: The
testscript runs ~686 files (Unit + Feature + Performance + Concurrency + PrivilegeSeparation) serially in one PHP process with no--parallel, unliketest:unit, so Zend memory accumulates across the whole run. Measured peak is ~715 MB — already ~190 MB over the cap. The intuitive suspect was measured and ruled out: the peak is mode-independent within ~16 MB acrossxdebug.modeoff/coverage/develop, because xdebug's coverage data lives in its ownmallocoutside the Zend allocator and doesn't count againstmemory_limitat all. - Fix: Raised the
testscript'smemory_limitto 1024M (~310 MB headroom over the measured peak), with the rationale recorded incomposer.json'sscripts-descriptionsso it's visible fromcomposer run-script --list.test:archleft at 512M. - Lesson:
memory_get_peak_usage(true)measures whatmemory_limitactually gates — OS RSS and extension-owned allocations don't — which is why the xdebug hypothesis had to be disproven by measuring atmemory_limit=-1rather than argued about. The reason nobody caught this earlier is structural: CI never invokescomposer test(it runs per-suite jobs), so this is a DX-only failure with no CI signal, and the same blind spot is currently hiding a Performance-suite query-count regression (18/16 queries against guards of 14/12). The real fix — parallelising Unit+Feature, since Concurrency and PrivilegeSeparation aren't parallel-safe — is still parked.
KD-0924 — Subdomain availability check ignores the domains table
- Severity: medium
- Symptom:
CheckSubdomainAvailabilityActionreported a subdomain as available when it existed indomains.domainbut not intenants.database. The subsequent signup insert then 500'd on thedomains_domain_uniqueconstraint. Reproduced in prod. - Root cause: The action only queried
tenants.database, butDomainis the canonical owner of the subdomain (the unique index lives ondomains.domain). Whentenants.databasediverged fromdomains.domain— possible after a rolled-back/diverged provisioning run or operator edit — the check passed falsely while the insert collided. - Fix: Injected
DomainalongsideTenant;execute()short-circuits toavailable: falseon atenants.databasehit, then also checksdomains.domain. - Lesson: An availability check must consult every table that owns the uniqueness invariant it's predicting — checking one of two tables that can diverge guarantees a false "available" the moment they drift. The DB unique index is the real contract; the pre-check has to query the same column(s) the index covers.
KD-0920 — x-fs-cache-hashes header not CORS-exposed, killing cross-origin cache invalidation
- Severity: medium
- Symptom: On any cross-origin setup (every local dev install:
script.localhost:3000→:8000), the browser never handed thex-fs-cache-hashesresponse header to JS, so the cached-store wrapper saw no invalidation signal and lanes/labels/sprints stayed stale until a full refresh. Invisible failure — the wrapper degrades tonullsilently. - Root cause:
config/cors.phpset'exposed_headers' => []. Browsers expose only the seven CORS-safelisted response headers to cross-origin JS; a custom header is readable only if the server lists it inAccess-Control-Expose-Headers, which Laravel'sHandleCorsemits only whenexposed_headersis non-empty. Backend stamping and the SPA wrapper were each correct in isolation — the bug was the missing exposure entry between them. - Fix: Added
'x-fs-cache-hashes'toexposed_headers(a config constant, not an env knob — the header is a fixed non-sensitive protocol value). - Lesson: Stamping a custom response header does nothing for cross-origin JS unless the server also CORS-exposes it — the two are separate steps and a header present on the wire is still invisible to
headers.get()without the expose list. Same-origin prod hid it; the first witness was every dev install. When a protocol depends on a custom header, the CORS expose entry is load-bearing, not optional.
KD-0919 — Cache-hash header stamped on too few routes to ever reach the SPA
- Severity: medium
- Symptom: The cached-store protocol's steady-state invalidation never fired during normal navigation. Client A mutated a sprint/epic/lane/label, the backend bumped the project's
*_hash, but an open tab on client B kept serving the stale list — the refetch signal never arrived. - Root cause: Registration coverage, not logic.
StampCacheHashesMiddlewarewas mounted on only five narrow routes (project show + the four cached-resource groups). The requests an SPA actually fires while navigating (board, backlog, issue show, comments) were in none of those groups, so the response carried no header. The signal was circular: the only responses announcing "sprints changed" were the sprint requests the wrapper had already decided to suppress. - Fix: Hoisted the middleware to the
Route::prefix('projects')group (one registration covers every project-scoped route, current and future) and removed the five redundant inline mounts. The middleware already self-guardsindex/storeto header-free. - Lesson: A change-notification header is only useful on the responses the client actually requests in steady state — stamping it solely on the resource's own endpoints is circular, because those are exactly the requests the cache suppresses. Mount the signal across the whole navigation surface (group-level), and lean on the middleware's self-guards rather than narrow per-route registration that drifts as routes are added.
KD-0918 — Memoized cached stores go deaf to broadcasts after the first page unmount
- Severity: high
- Symptom: Sprints/epics/lanes/labels created or changed by another client stopped appearing live after the user's first in-app navigation — they surfaced only after a full refresh. Per-page live data (board, comments, time entries) kept updating fine; only the four project-scoped cached stores went deaf.
- Root cause:
subscribeWithAutoCleanupunconditionally calledonScopeDispose(stop)(correct for per-page subscriptions). But the four cached stores are memoized per project and subscribe exactly once, at store-creation time — which runs inside thesetup()of whichever component first calls themake…Storefactory. When that component unmounted, the scope disposed,stop()fired, and the listener was removed; the memoized store stayed cached but never re-subscribed (fs-adapter-storesubscribes once, at construction). Introduced by KD-0680'sonScopeDisposeauto-cleanup, which optimised per-page teardown without accounting for the memoized-singleton lifetime. - Fix: Persistent subscribe + evict-on-leave: a scope-free
subscribeProjectChannelPersistentplus a per-projectonLeaveProjectregistry. The four stores subscribe persistently and register an eviction callback that drops their memoized instance onleaveAllProjectChannels/resetEcho, so a revisit rebuilds and re-subscribes. Per-page subscriptions keep scope-bound teardown. - Lesson: A subscription's lifetime must match the lifetime of the thing it feeds — scope-bound (
onScopeDispose) cleanup is correct for per-component data but wrong for a memoized singleton whose listener should live as long as the cache entry. When you add an auto-cleanup optimisation, audit every consumer whose lifetime is not the mounting component's, or the optimisation silently kills long-lived subscriptions on the first unmount.
KD-0889 — FilterBar search input has no inline clear (✕) button
- Severity: low
- Symptom: The issues-tab filter-bar search input had no inline ✕ to clear the term — users had to select-and-delete or use the separate global "Clear all." The reports page's search already had one, so the two bars felt inconsistent.
- Root cause:
FilterBar.vue's search<input>was bound to the model with no per-input clear control. The inline-clear pattern existed only in the siblingSearchFilter.vueand was never carried intoFilterBar. - Fix: Added a
v-if="searchTerm"✕ button mirroringSearchFilter's clear control; clicking empties the model, which both hides the button and clears the filter (the search term is the filter). Lands across all 9 pages that mount the shared bar. - Lesson: When two sibling components present the same affordance, a pattern added to one but not the other reads as a regression — shared UI affordances should be lifted to the shared component or mirrored deliberately, not implemented per-page.
KD-0882 — ProfileSidebar spec leaks a post-teardown dynamic import, flaking CI
- Severity: low
- Symptom: The
Test tenant-corefrontend CI job intermittently exited code 1 even though all assertions passed, blocking PRs until a manual rerun. The failure was an unhandledEnvironmentTeardownError. - Root cause: The spec called the
defineAsyncComponentfactory (() => import('ProfilePictureForm.vue'), which transitively pulls the browser-onlybrowser-image-compressionmodule) withoutawaiting the returned promise. The dynamic import raced Vitest's environment teardown; when teardown won, the late-resolving import was recorded as an unhandled rejection. - Fix:
awaitthe factory call so the import resolves within the test's lifetime. - Lesson: An un-awaited promise in a test — especially a dynamic
import()— races the runner's environment teardown and surfaces as an intermittent, assertion-passing CI failure. Any async work a test triggers must complete before the test ends, or it leaks into teardown as a flake.
KD-0878 — Epic Board badge stops updating on remote drags (stale positions payload shape)
- Severity: medium
- Symptom: The Epic Board's per-epic open/closed badge stopped updating live when other users moved issues; it stayed stale until a full reload. The three issue tabs handled the same broadcast correctly.
- Root cause: Since KD-0789 the backend
IssuePositionEventbroadcasts a single{position}payload on thepositionsevent. The Epic Board's handler still destructured the pre-KD-0789 array shape{positions}and looped it, sopositionswasundefinedandfor (const position of positions)threw before any state applied. The issue tabs had been migrated to the single-{position}shape (centralised inuseIssueLiveSync); this call site was missed because the Epic Board hand-rolled the same five-event protocol inline instead of consuming the composable. The spec hid it by pinning the old array shape. - Fix: Adopted
useIssueLiveSyncon the Epic Board (widened generic over item type), replacing the five inline Echo registrations with one composable call so the broadcast contract lives in exactly one place. - Lesson: A wire-shape change ripples into every consumer that re-implements the protocol by hand — the call site that hand-rolls what a shared composable already does is the one that gets missed when the shape changes. Centralising the contract in one composable is the fix and the prevention. And a test that pins the old shape keeps a broken consumer green while prod throws.
KD-0872 — Bearer-token auth failures return a 302 redirect instead of 401 JSON
- Severity: medium
- Symptom: API/MCP token clients (CLI, VS Code extension, feedback button) carrying a revoked/expired/unknown Bearer token got a
302redirect to the login page instead of a machine-readable401. The client had no actionable signal to re-authenticate — failures were silent. This was the delivery path for the 2026-05-29 feedback-loss incident (the balloon followed the redirect into a fake 201). - Root cause: Passport's
TokenGuardcatches theOAuthServerExceptioninternally and returnsnull, soauth:sanctumthrowsAuthenticationException. Laravel's default renderer checks$request->expectsJson(); token clients don't sendAccept: application/json, so the check fails and the request is redirected — correct for browser navigation, wrong for machine clients on/api/*and/mcp/*. (The issue's premise thatOAuthServerExceptionreaches the handler was wrong; the guard swallows it.) - Fix: Added a
render()forAuthenticationExceptionreturning{code: 'TOKEN_INVALID', ...}401 JSON forapi/*/mcp/*paths that don't already expect JSON; passes through (returnsnull) otherwise so SPA/JSON-client behaviour is unchanged. Mirrors KD-0739'sAccessDeniedHttpExceptionexpectsJson()scoping. - Lesson: The framework default of "redirect non-JSON auth failures to login" is correct only for browsers — machine clients on API/MCP paths need a structured 401, and they don't send
Accept: application/json. Path-scope the override (api/*/mcp/*+!expectsJson()) so browser flows keep redirecting. Diagnose where the exception is actually thrown, not where the issue claims (the guard caught the OAuth exception two layers up).
KD-0870 — project_tokens.active not synced when the backing PAT is revoked
- Severity: medium
- Symptom: Bulk-revoking a user's personal access tokens (e.g. on 2FA enablement) flipped
oauth_access_tokens.revoked = 1but left the matchingproject_tokens.active = true. The Project Tokens UI readsactive, so it kept showing dead tokens as live; operators couldn't tell a working token from a revoked one without querying the DB. Confirmed in prod after the 2026-05-29 sweep. - Root cause:
RevokeUserTokensActionhad no dependency onProjectToken— it knew onlyoauth_access_tokens. The other revocation path,DeleteProjectTokenAction, kept both tables in sync; only this path desynced, because the two paths were written independently and only the delete path was built for cross-table consistency. - Fix: Injected
ProjectToken; after the revocation loop, setactive = falseon anyproject_tokensrows whosetoken_idis in the revoked set, inside the same transaction. - Lesson: When two code paths both invalidate the same entity, both must maintain every derived/mirrored column the entity owns — one path keeping a denormalised flag in sync while the sibling forgets it guarantees the UI shows a state that contradicts the source of truth. New invalidation paths must be checked against the full set of side-effects the canonical path performs.
KD-0858 — Board card can't be moved: deterministic rank collision dead-ends MoveIssueAction
- Severity: high
- Symptom: Certain board cards refused to move — every drag snapped back, no matter how many retries. The backend returned 409 (
RankCollisionException) and the FE reverted. Surfaced in prod (Nightwatch #168). Two coupled defects: the 409 toast showed an empty body ({"message":""}), and the user-facing copy was hardcoded per-HTTP-status in the FE rather than coming from the backend exception. - Root cause:
Rank::betweenis fully deterministic (base-26 midpoint, no randomness). When a project's rank space is degraded (zero-width gap, stale neighbour ids) so the midpoint lands on an existing(project_id, rank)UNIQUE value, the 3-attempt retry loop recomputes the same value every time and collides identically — the loop only resolves transient concurrent collisions, never deterministic ones. KD-0808's respread recovery was wired inexecute()forRankTooLongExceptiononly, so overflow self-healed but a stuck gap dead-ended. The empty body:CustomExceptionsubclasses declare copy via aprotected $messagedefault, but PHP'sException::__constructoverwrites that default with''the instant it runs with any argument — includingprevious:only — and the leak-safenew X(previous: $cause)throw (mandated to avoid leaking the MySQL duplicate-key string) was therefore silently incompatible with the property-default-message pattern. - Fix: (1) Widened the reactive-recovery catch in
MoveIssueActionandBulkMoveActiontoRankTooLongException | RankCollisionException→ respread + one bounded retry. (2) GaveCustomExceptiona constructor falling back to$this->messagewhen no explicit message is passed. (3)dontReportWhenfilter dropping handled<500CustomExceptions from the monitor. (4) FE renders the backend exception'sdata.messageinstead of a hardcoded status→string map. - Lesson: A retry loop only helps when the inputs change between attempts — retrying a deterministic computation reproduces the same collision forever; deterministic exhaustion needs a state-changing recovery (respread), not a retry. Separately: PHP's
Exception::__constructclobbers a subclass'sprotected $messagedefault to''whenever called with any named/positional argument, so aprevious:-only throw silently ships an empty message — override the constructor to restore the declared default. And the backend that owns what failed should own the words the user sees; duplicating copy in the SPA per status code drifts.
KD-0852 — My Issues badge shows a stale lane after an agent (MCP) lane change
- Severity: medium
- Symptom: When an agent moved an issue to a different lane via the MCP path (
UpdateIssueTool, orstart-work-on-issue), the My Issues page kept showing the old lane in its Status badge until a manual refresh. The broadcast fired and the row otherwise updated — onlylane_title/lane_colorwere stale. The same move through the web UI updated correctly. - Root cause:
UpdateIssueActionassigns the newlane_id, saves, then broadcasts via resources that readlane_title/lane_coloroff the in-memorylanerelation, hydrating withloadMissing.UpdateIssueToolresolves the issue withlanealready eager-loaded. Changing thelane_idFK does not refresh an already-loadedbelongsTorelation, andloadMissingis a no-op when the (now stale) relation is present — so the payload carried the newlane_idbut the oldlane_title/lane_color. The web path route-model-binds without preloadinglane, soloadMissingfetched it fresh — hence agent-specific. - Fix: Added a single
$issue->refresh()after the audit-log write and before the two broadcast calls, dropping stale relation caches so the resources re-read the new lane. Rejected per-relationunsetRelation()(mock churn, only covers listed relations) and tool-level eager-load removal (leaves the root cause for other callers). - Lesson: Mutating a foreign key does not refresh an already-loaded
belongsTorelation, andloadMissingwon't re-fetch what's already (stalely) present — so a serializer that reads through the relation emits stale nested data whenever a caller preloaded it. A broadcasting Action that mutates FKs mustrefresh()(or unset the affected relations) before serializing. The bug is caller-dependent: it only appears for the path that preloads, which is why the web UI looked fine while the agent path broke.
KD-0848 — Filter bar hijacks Cmd/Ctrl+F, blocking the browser's native find
- Severity: low
- Symptom: On any page rendering the shared
FilterBar(9 call sites), pressing Cmd/Ctrl+F opened the Kendo filter popover instead of the browser's native find-in-page. The handler calledpreventDefault(), so users lost the universal browser find shortcut everywhere the bar appeared. - Root cause:
FilterBar.vue'sisFilterShortcutmatched(metaKey || ctrlKey) && key === 'f'and the keydown handlerpreventDefault()'d before opening the popover. Cmd/Ctrl+F is the browser's universal find accelerator. - Fix: Rebound the shortcut to a bare
/(the web-standard search key), freeing Cmd/Ctrl+F to fall through untouched. The existing form-control guard already suppresses it while typing. - Lesson: Don't bind app shortcuts to the browser's reserved accelerators (Cmd/Ctrl+F/N/T/W…) —
preventDefault'ing them strips a universal capability on every page the component mounts. The bare/is the conventional in-app find/search key; reserved-modifier combos belong to the browser.
KD-0845 — Comment-editor links have no underline and navigate on click
- Severity: low
- Symptom: In the
RichTextAreacomment/description editor, links rendered with no underline and clicking one navigated away instead of letting the user select/edit it. Read-only rendered prose was correct. - Root cause: Two config/styling gaps. TipTap v3
StarterKitbundlesextension-link, whose Link mark defaults toopenOnClick: true—RichTextArearegistered StarterKit with no config. Separately, no.tiptap aCSS rule existed (editor content styles live inmarkdown.css), while read-only prose was underlined via.description-prose a. - Fix:
StarterKit.configure({link: {openOnClick: false}})so editor clicks edit rather than navigate; added a.tiptap arule mirroring.description-prose a. CSS-only, not a linkHTMLAttributesclass, so the rendered<a>markup is unchanged and the existing real-render assertion stayed green. - Lesson: A bundled extension's defaults are inherited silently when you register the kit with no config — TipTap StarterKit's Link defaults
openOnClick: true. And the editor surface (.tiptap) and read-only surface (.description-prose) are separate style scopes; a prose rule does not cover the editor. Check both the behaviour defaults and the per-surface CSS coverage.
KD-0841 — Logging time for a past date takes too many interactions
- Severity: low
- Symptom: The "Log Time" modal's "Started At" field opened empty (a bare native
<input type="datetime-local">, defaultstartedAt: null), so logging against a recent past day forced the user to hand-type every segment or click backward through the native calendar — described as "absurdly long." - Root cause: The field had no shortcut affordance and opened empty because new entries default
startedAt: null. The friction was purely the date-selection affordance — nothing about calculation or storage. - Fix: Prefill "Started At" with
now()when the create modal opens (logging against today is now zero interactions; a past date is reached by editing a populated field). The independent "auto-calculate start time" checkbox (back-dates start tonow() − duration) was briefly removed as redundant, then restored once that misread its distinct purpose, and renamed for clarity. Day-preset buttons from an earlier iteration were dropped. - Lesson: An empty input is the worst default for an "edit a near value" task — prefilling the common case (now) removes the build-from-empty friction more simply than adding preset buttons. Watch for two controls with overlapping-but-distinct purpose (prefill vs back-date-on-duration): removing one as "redundant" can quietly delete a different behaviour.
KD-0839 — PDF attachment preview renders blank (iframe sandbox blocks JS)
- Severity: medium
- Symptom: Clicking a PDF attachment opened the preview modal but the PDF never rendered — the iframe loaded the blob URL yet stayed blank. Images previewed fine (they use
<img>, not an iframe). - Root cause: The PDF
<iframe>hadsandbox="allow-same-origin". Asandboxattribute withoutallow-scriptsblocks all JavaScript inside the iframe, and both Chrome's native PDF viewer and Firefox's PDF.js need JS to initialise — so the blob loaded but rendered nothing. - Fix: Removed the
sandboxattribute. Blob URLs fromURL.createObjectURL()are ephemeral and tab-local and the content is our own server's — no cross-origin surface to sandbox. - Lesson:
sandboxwithoutallow-scriptssilently disables the JS that built-in PDF viewers depend on — a sandbox tight enough to block scripts blocks the very feature you're embedding. Don't sandbox an iframe whose source is a same-origin/tab-local blob you produced; there's nothing to isolate.
KD-0838 — AsyncErrorBoundary shows a fatal "Could not load page" for transient/user-action failures
- Severity: medium
- Symptom: The shared
AsyncErrorBoundaryrendered a full-page fatal "Could not load page." for any captured error exceptEntryNotFoundError. Two everyday events tripped it: a failed comment submission, and clicking an issue then hitting Back before it loaded (the fatal screen then persisted on the/boardroute that had loaded fine). - Root cause: Two defects. (1) Sticky boundary state —
onErrorCapturedsethasError = trueand never cleared it, andProjectLayoutkeeps one boundary instance alive across in-project tab switches (the RouterView child swaps, the boundary doesn't), so an error raised loading one route latched and poisoned the next. (2)submitCommentawaitedcreate()with notry/catch, so a rejected POST propagated throughonErrorCapturedinto the boundary. There is no request-cancellation infra in the FE, so the "aborted load" was the reporter's mental model, not a literalCanceledError. - Fix: Initially: reset
hasErroron navigation via aresetKeyprop (the boundary can't read the route — the app skipsapp.use(router)) plus a localtry/catchonsubmitComment. Per PR review, replaced the per-handler guard with a systemic discrimination:onErrorCapturednow inspects Vue'sinfoarg —'native event handler'/'component event handler'errors propagate untouched (toast middleware surfaces them, user stays on the page), while async-setup/render/lifecycle/watcher errors still latch the fatal screen.resetKeymade required so a future consumer can't forget it. - Lesson: An error boundary must distinguish a page-load failure (fatal screen is right) from a user-action failure (stay on the page, toast it) —
onErrorCapturedfires for every descendant error including event handlers, so a boundary that treats all errors as fatal turns any failed submit into a full-page crash. Discriminate by Vue'sinfosource. And latched boundary state must clear on navigation, or one route's error poisons its siblings. Prefer a required prop over an optional one for a guard that prevents a known bug (a future consumer can't silently forget it).
KD-0837 — Bar Color picker dims unselected swatches, distorting their hue
- Severity: low
- Symptom: In the epic "Bar Color" picker, selecting a colour appeared to change the colour of the other swatches (olive→yellow, orange→brown), as if the picker applied the wrong colour. Both report screenshots were of the picker itself, differing only in which swatch was at full opacity.
- Root cause: Unselected swatches were dimmed with
op-50(whole-element opacity) over a saturated fill. CSSopacitycomposites the fill against whatever is behind it — the page background, which differs per theme — so a saturated colour at 50% over a dark page mixes toward black and shifts perceived hue (not just brightness). The stored value was always correct; the defect was purely the picker's render. - Fix: Replaced the bespoke opacity-dimmed swatch grid with the
SingleSelectcolour dropdown already used for lanes/labels, which renders colour names (text) and never a dimmed swatch — sidestepping the root cause entirely. - Lesson: Whole-element
opacityon a coloured fill is theme-dependent by construction — it blends with the page background, so a "dimmed" selection indicator shifts the perceived hue differently in light vs dark mode. Indicate selection without touching the fill's alpha (a border, a check, or text-based selection), or the colour the user sees is a lie.
KD-0836 — Time-log summary cards bucket by logging date, not work date
- Severity: medium
- Symptom: On the Time Entries page the Today/Yesterday/Avg-per-day summary cards didn't reconcile with the filtered table — the cards counted hours on days that had no visible rows. Same dataset, different date axis.
- Root cause: The summary helpers (
filterByPeriod,getUniqueDaysCount) bucketed each entry oncreatedAt(when the entry was recorded) while the table presented each entry understartedAt ?? createdAt(when the work happened). When time is logged after the fact the two axes diverge, so the per-day cards counted hours on days the table never showed. The reporter's "summary uses a different dataset" hypothesis was wrong — same filtered dataset, wrong field. - Fix: Bucket the summary helpers on
startedAt ?? createdAt, matching the table's date column. Frontend-only; the backend range filter still usescreated_at(a broader product question parked). - Lesson: Two views over the same dataset must bucket on the same field, or they'll disagree without either being "wrong" — a summary and its table reconciling depends on a shared date axis (work date vs logging date), not just a shared filter. When numbers don't add up, suspect the axis before the dataset.
KD-0817 — Issue deletion fails on FK constraint for Hand-to-Claude tables
- Severity: high
- Symptom: Deleting an issue threw a 1451 FK-constraint violation whenever any Hand-to-Claude row referenced it (
claude_issue_eligibilities, its criteria children, orclaude_sessions). The same gap hitissue_label, andDeleteProjectActionwas additionally missingissue_watcherscleanup and thereports.promoted_issue_idnullify the single/bulk paths already did. - Root cause: KD-0658 shipped three tenant-DB tables with
restrictOnDelete()FKs toissuesbut didn't updateDeleteIssueAction,BulkDeleteIssuesAction, orDeleteProjectAction. The arch gate that would catch this (CascadeRelationsTest) only walksHasMany/HasOne/MorphManyrelations declared on the model — andIssuehad noclaudeSessions()/eligibility()relation, so the gate had nothing to enforce against. KD-0803 addedissue_label16 days later with the same omission; KD-0709 seeding the tables made it reproduce in dev. - Fix: Added
Issue::claudeSessions()HasMany +Issue::eligibility()HasOne and listed both incascadeRelations()so the existing arch gate now enforces future regressions. The three delete Actions guard on in-flight sessions (409), archive terminal-uncleaned sessions via a queued job capturing scalar IDs, delegate eligibility cleanup, and detach labels.DeleteProjectActiongained the missingissue_watchers+ report-nullify steps. - Lesson: An arch gate that enforces "every relation is cleaned on delete" is blind to relations the model never declares — a new table with a
restrictOnDeleteFK is invisible to the audit until someone adds the corresponding relation. The structural fix is to declare the relation and list it incascadeRelations()so the gate has teeth. The gate still doesn't cover BelongsToMany pivots (labels), so pivot omissions remain a known blind spot. (Same hand-maintained-cascade-list drift class as KD-0738.)
KD-0757 — Mention menu opens at page far-left on the first @-keystroke
- Severity: medium
- Symptom: Two positioning defects in the @-mention menu. (1) Typing
@flashed the menu at the far-left (~0,0) for one frame, then it snapped to the caret on the next character. (2) Once open, the menu didn't follow the caret when the page/editor scrolled. - Root cause: (1)
mountMentionListappended the element before the asyncupdatePositionapplied coordinates — for one frame it hadposition: staticand flowed to its container's top-left; on later keystrokes it already carriedposition: absolute, hence first-keystroke-only. (2) floating-ui'scomputePositionis one-shot; position was computed on open/update only, so the body-appended menu drifted from the caret inside its scroll container. - Fix: (1) Mount hidden+positioned: set
position: absolute+visibility: hiddenbeforeappendChild, reveal once the first computed coords are applied (visibility: hidden, notdisplay: none, keeps it measurable). (2) Position via floating-uiautoUpdate(runs immediately and on every scroll/resize) and return its cleanup, run on close/Escape so no listeners leak. - Lesson: An element positioned by an async callback flashes at its static-flow origin for the frames before coordinates land — mount it hidden-but-measurable and reveal only after the first compute. And one-shot
computePositiondoesn't track scroll; useautoUpdate(with a cleanup wired to close) when a floating element must stay glued to a moving anchor.
KD-0752 — Markdown (.md) attachments have no in-app preview
- Severity: low
- Symptom: Clicking a
.mdattachment thumbnail did nothing — it rendered as a generic file icon with only a download button. Upload and MCP fetch already worked; only the web preview failed. - Root cause: Markdown was absent from the frontend previewability path.
isPreviewableMimeTypereturned true only for images + PDFs, so the thumbnail click never emittedpreviewfor a.md, and the preview modal had no markdown branch (it would fall through to a raw<iframe>). Stored MIME for.mdis unreliable (text/plainfrom finfo), so detection has to key off the.md/.markdownfilename extension, not MIME. - Fix: Added
isMarkdownFilename(extension-based), made the thumbnail previewable for markdown, and added a preview-modal branch that fetches the bytes through the auth'd download endpoint, reads the blob as text, and renders via the app's existingrenderMarkdown→DescriptionProsestack. Frontend-only. - Lesson: A previewability check keyed on MIME alone misses file types whose stored MIME is generic (
.md→text/plain) — detect by extension when the MIME is unreliable. And when an app already owns a renderer for a content type, wire the preview path into it rather than falling through to a raw iframe.
KD-0644 — Empty toast container stays rendered on every page after toasts dismiss
- Severity: low
- Symptom: After the
fs-toast0.2.0 migration, the<div popover="manual">toast container never disappeared once all toasts dismissed — it lingered as an emptyfixedbox on every route (pointer-events-none, so it didn't block clicks, but always present). - Root cause:
fs-toasthides the closed container by callingel.hidePopover()and relying on the UA rule[popover]:not(:popover-open){display:none}. ButApp.vueapplied a bareflexutility (→display: flex) directly to that element. Author-origindisplay: flexalways beats the UA-origindisplay: nonein the cascade regardless of specificity, so the container never collapsed when closed. Theflex/fixed/z-1050attrs predated the migration and were harmless until the popover-based hide began depending ondisplay. - Fix: Gated the display to the open state: replaced the unconditional
flexwithclass="popover-open:flex", sodisplay: flexapplies only while:popover-open, and the UA rule hides the empty container. - Lesson: An author-origin
displaydeclaration unconditionally beats a user-agentdisplay: nonerule — so any styling that hard-setsdisplayon a[popover]element defeats the Popover API's own hide. Scope display utilities to:popover-openwhen the hide is delegated to the UA rule. A previously-inert utility can become load-bearing the moment a dependency starts relying on the property it sets.
KD-0624 — CascadeRelationsTest skips Tenant and misses trait-provided relations
- Severity: medium
- Symptom: Audit-coverage defect, not a runtime crash:
CascadeRelationsTest(the ADR-0002 gate ensuring every tenant model enumerates its cascade relations) was green precisely because two blind spots let it skip the cases it should catch. Un-blinding it surfaced four previously-invisible relations (Tenant::githubInstallations+ three Passport relations onUser). - Root cause: Two intentional skips. (1)
Tenantwas hardcoded into a$centralModelsexclusion list justified as "never deleted via application logic" — false, sinceDeleteTenantActioncascades real relations. (2) The "all relations listed" test filtered out any relation contributed by a trait; but PHP flattens trait methods onto the using class (reflection reportsgetDeclaringClass() === Tenant/User), so those relations are reachable and were being thrown away — for every model, not justTenant. - Fix: Removed the
$centralModelsexclusion and the trait-method filter, and replaced them with an explicit$nonCascadeRelationsallowlist (each entry justified inline) so every discovered relation must be either incascadeRelations()or acknowledged here. Verified by transiently dropping an entry and confirming the test then fails. - Lesson: A test exclusion justified by a comment ("never deleted", "trait-provided, skip") is a place bugs hide — the green suite was an artifact of the audit skipping its hardest cases. An audit should never silently drop a category; require every discovered item to be explicitly handled or explicitly acknowledged, so the skip list itself is reviewable. (Same exclusion-comment-rot theme as KD-0786.)
KD-0479 — Tooltip layout regressions and silent failures
- Severity: medium
- Symptom: Four regressions from the "bundle tooltips into components" pivot, caught in PR review.
Tooltip.vue's wrapper<div>participated in layout, breaking call sites relying on flex/absolute positioning (ml-autowatch button, report-card copy button).IconButton/ReportListItemsniffed$attrs['aria-label'], producing empty tooltips and inaccessible buttons when callers usedtitle=or omitted the label.DragElementre-introduced a trailing-space assignee label for single-name users. - Root cause:
Tooltip.vuewrapped its slot in aninline-block<div>that the parent's flex/grid algorithm laid out as a real item, so layout-affecting attrs (ml-auto,absolute) landed on the inner<button>, not the layout participant. The$attrs['aria-label']sniff was brittle — any caller usingtitle=or omittingaria-labelsilently got an empty tooltip and a button with no accessible name. - Fix:
Tooltip.vuewrapper switched todisplay: contents(anchoring floating-ui tofirstElementChild) so it no longer participates in layout;IconButtongot an explicit requiredlabel: stringprop (sweeping ~25 call sites off the$attrssniff);DragElementlabel changed to[firstName, lastName].filter(Boolean).join(' '). - Lesson: A wrapper element silently changes its children's layout context — a tooltip/HOC wrapper that isn't
display: contentsbecomes a real flex/grid item and misplaces positioning utilities meant for the wrapped element. And sniffing a value off$attrsis a brittle implicit contract: make it an explicit, required prop so a missing label is a type error at the call site, not an empty tooltip + inaccessible button at runtime.
KD-0807 — Multi-select drag in Backlog persists only one issue's move
- Severity: medium
- Symptom: Multi-selecting N issues in the Backlog and dragging one across sprint sections moved all N cards visually, but only ONE issue's change persisted. The other N−1 silently reverted on the next sync/refresh. The BulkActionBar "Move to" dropdown hit the same bug.
- Root cause: Regression from KD-0789's fractional-rank drag rewrite. The legacy bulk-update shape posted N updates in one request; the new single-issue
moveIssueForProject(issueId, payload)posts one move at a time, and the drag store's diff helper (findLaneChangedItem) returned on the first lane-changed item. So exactly one move request fired regardless of how many cards the user moved. The single-issue endpoint cannot carry N issues atomically. - Fix: Dedicated
BulkMoveAction+POST /api/projects/{project}/issues/bulk-move(mirroring the precedentBulkAssignEpicAction), with a sprint-only{issue_ids, target_sprint_id, position}payload — each issue keeps its lane + epic, onlysprint_id+rankchange. N ranks spread logarithmically viaRank::spread. FE drag store gained abulkUpdate()path that collects every lane-changed item. - Lesson: When you replace a bulk endpoint with a single-item one, audit every multi-select code path that fed the old shape — a diff helper that "returns the first changed item" silently drops the rest. And bulk operations need a dedicated atomic endpoint; you can't synthesize N-item atomicity by looping a single-item call. Sequentially
Rank::between-ing N cards also degrades rank length ~1 char per 4 cards, so spread balanced midpoints instead of chaining.
KD-0798 — VS Code extension shows no issues after API shape change
- Severity: high
- Symptom: Opening a project in the VS Code extension showed no issues and fired a "API error" notification. Assignee avatars rendered
[object Object]as the imagesrc. - Root cause: KD-0774 changed
IssueResourceDatato returnbranch_linksas full nested objects ({id, branch_name, branch_url, status}) instead of a flat status array. The extension was only partially updated — it kept the field name but typed each link as{status}, so it still fired N secondaryGET .../branch-linkscalls to fetch data already present in the initial response. Separately,profile_picturewas typedstring | nullbut the API now returns{avif, webp} | null, so the raw object was passed through as an image URL. - Fix: Widened the extension's
branch_linkstype to the full shape and derived branch names/URLs directly from the initial response (dropping the N secondary calls). Fixedprofile_picturetype and extractedavif ?? webp ?? null. - Lesson: A backend resource shape change ripples into every client that wasn't updated in lockstep — the extension is a separate consumer with no shared type contract, so a partial update left it making redundant calls AND mis-rendering. When a response field changes from scalar to object, every client's type and every place it's interpolated (especially as a URL
src) must be revisited.
KD-0788 — Central-binding arch test over-detects: only 5 of 14 flagged Actions were true gaps
- Severity: medium
- Symptom: The KD-0783 broader arch test listed 14 Actions in
centralBindingKnownGaps(), framed in the issue as "13 quick-wins, just add the binding." Latent/low-volume — the binding gaps would surface as audit-transaction crashes only when the affected central paths ran in prod. - Root cause: The arch test is a structural detector ("Action injects a central model AND has
->transaction(") but the canonical bug is behavioural ("the outer transaction opens on the wrong connection"). Of the 14: 5 were true gaps ($this->db->transactionwrapping central writes); 5 were already correct (transaction opened via$model->getConnection()->transaction(), which the test couldn't distinguish); and 4 were tenant-primary Actions where binding$this->dbto central would invert the bug — opening a central transaction while tenant writes committed unsynchronised. - Fix: Bound the 5 true gaps in
AppServiceProvider. Refined the arch test to require bothConnectionInterfaceinjection AND->transaction((dropping the 5 model-getConnection Actions naturally). Added inline@central-binding-exempt:markers to the 4 tenant-primary Actions and taught the test to honour them. EmptiedcentralBindingKnownGaps()to[]. - Lesson: A structural arch heuristic and the behavioural bug it targets coincide for the obvious cases and diverge for the rest — a "known gaps" list taken at face value will misclassify. Reading each flagged site beats trusting the count: blindly "adding the binding" to all 14 would have broken 4 working Actions. When a heuristic over-detects, the fix is to tighten the heuristic AND provide an auditable escape-hatch marker, not to suppress with a grandfather list.
KD-0787 — RollbackProvisioningAction unbound + inline DROP DATABASE on the bound connection
- Severity: medium
- Symptom: Same latent shape as KD-0783. Dormant in prod (
DOMAIN_PROVISIONING_ENABLED=false). The moment the rollback path ran with provisioning enabled, the central audit-write would throwAuditLogWriter must be called within a database transactionbecause the outer$this->db->transaction(...)opened on the default connection, not central. - Root cause: Two coupled defects. (1) The Action injected a generic
ConnectionInterfaceand wasn't contextually bound to central, so the audit-log model's hardcodedcentralconnection sawtransactionLevel() === 0. (2) An inline$this->db->statement('DROP DATABASE...')would route through whatever connection got injected — once bound to central, central's MySQL user may lack DROP privileges. (1) couldn't land without (2): binding to central while the inline DDL remained would make rollback DDL fail in any locked-down environment. - Fix: Injected the already-tenant-bound
DropTenantDatabaseActionfor the DDL, addedRollbackProvisioningActionto the centralConnectionInterfacebinding, and promoted its arch-test entry fromcentralBindingKnownGaps()tocentralActionsRequiringBinding()(turning the gate into a regression test). - Lesson: Binding an Action's connection and extracting its cross-connection DDL are coupled changes — you can't safely flip the binding while inline statements still inherit it. DDL that needs different privileges (DROP DATABASE on tenant) must be delegated to a connection-explicit sibling Action before the parent's connection is rebound.
KD-0786 — ProvisionDomainAction unbound from central despite being central-only
- Severity: medium
- Symptom: Same crash shape as KD-0783, dormant behind
DOMAIN_PROVISIONING_ENABLED=false. Would fireLogicExceptionon every provisioning state transition that emits an audit row the moment the flag flipped on. - Root cause: The Action's outer transaction resolved
ConnectionInterfaceto the default connection because it wasn't contextually bound to central. It was excluded from the binding with a stale comment claiming it "also does tenant-DB work" — but a post-KD-0580 state-machine refactor had made the Action central-only (Domain model is central, audit is central, theadvance()branches call external providers not the DB, noTenantSwitcher). The exclusion rationale had outlived its truth. - Fix: Added
ProvisionDomainActionto the centralConnectionInterfacebinding and moved its arch-test entry from gaps to required. No change to the Action — only container wiring was missing. - Lesson: Exclusion comments rot. A "we can't bind this because it does X" note must be re-validated against the current source before it's trusted — a later refactor can remove X and leave the stale exclusion silently masking a bug-in-waiting. The arch test's sentinel ("an audit-writing Action must be in exactly one of bound-list or gaps-list") is what forced the decision instead of letting it sit undecided.
KD-0785 — CreateTenantAction crashes on first prod central-admin invite
- Severity: high
- Symptom: Same latent KD-0783 shape. The central-admin "invite a tenant" flow (
POST /api/central/tenants) worked in dev/test (whereDB_CONNECTIONfalls through tocentral) but the first invitation in prod would throw the audit-transaction assertion because the outer transaction opened on the defaultmysqlconnection while writes hit central models. - Root cause: The Action wasn't in the central
ConnectionInterfacebinding because its privatecreateAdminUseropened an inner transaction on the same$this->dbafter aTenantSwitcher::switchTo()— and those inner writes target the tenant DB (admin User row + role pivot). Binding the whole Action to central would route the inner tenant transaction to central too. Same shapeSignupActionhad pre-KD-0783. - Fix: Mirrored KD-0783's extraction exactly — pulled the tenant-DB work into a new sibling
CreateInvitedTenantAdminUserAction(injecting the default tenantConnectionInterfaceand owning theswitchTo/resetlifecycle), then boundCreateTenantActionto central and promoted its arch-test entry to required. - Lesson: An Action that opens transactions on two different connections cannot be contextually bound to either — the only clean fix is to split the second-connection work into its own Action with its own binding. The "extract the tenant-DB half into a sibling Action" pattern is now the canonical resolution for this entire class (KD-0783 → KD-0785 → KD-0787).
KD-0783 — Public signup 500s in prod on audit-log transaction assertion
- Severity: high
- Symptom: Every public signup at
central.kendo.dev/signupreturned HTTP 500 withRuntimeException: AuditLogWriter must be called within a database transaction for hash chain integrity. Production-only — the exact path passed every CI test and worked locally. A partial central row (Tenant insert) could commit before the audit assertion fired. - Root cause:
SignupActionandCreateDomainActioninjectedConnectionInterfacewith no connection name, so the container resolved the default connection. Prod's.fly/config/prod.tomlsetsDB_CONNECTION=mysql, so$this->db->transaction(...)opened onmysql. But the audit-log models hardcode$connection = 'central', andassertWithinTransactioncheckscentral's transaction level — found 0 (the open transaction was onmysql) and threw. Tests didn't catch it becauseDB_CONNECTIONis unset in tests, so both connections resolved to the same instance. The same defect was latent in every central audit-writing Action. - Fix: Contextually bound
ConnectionInterfacetocentralinAppServiceProviderfor all 10 central audit-writing Actions. ExtractedSignupAction's tenant-DBcreateAdminUserintoCreateTenantAdminUserAction(default connection) and its inlineDROP DATABASEintoDropTenantDatabaseAction(tenant connection). Added an arch test forcing every newly-discovered central audit-writing Action into either the bound list or a known-gaps list. - Lesson: Injecting an unqualified
ConnectionInterfacesilently binds to whateverDB_CONNECTIONresolves to — fine until a model hardcodes a different connection, at which point transaction-scoped invariants check the wrong connection. Tests that leaveDB_CONNECTIONunset collapse distinct connections into one instance and hide the entire class; an arch test that asserts the binding decision is the only reliable gate. (Also surfaced two side findings:DB_CONNECTION=mysqlreferences a connection that doesn't exist in config — it only resolves via Laravel's framework-default merge — andDB_PASSWORDwas visible in plaintext viafly ssh console env.)
KD-0761 — Avatar initials low-contrast, undersized, off-center
- Severity: low
- Symptom: Fallback avatar initials were hard to read — in dark mode, white text on bright tint backgrounds (e.g.
#4ade80) hit contrast ratios as low as 1.3:1, far below WCAG AA's 4.5:1. Initials also rendered too small and sat slightly high. - Root cause:
ProfilePicture.vuehardcodedc-whitefor initials regardless of theme; the dark-mode tint palette uses high-luminance backgrounds where white text fails contrast. Font sizes were ~35-40% of the container, weight was too light, and no optical baseline compensation was applied for uppercase glyphs. - Fix: Added a theme-aware
--avatar-initialsCSS variable (near-black in dark mode, white in light), bumped font sizes and weight to 700, addeditems-center+ a 0.5pxtranslateYoptical nudge. - Lesson: Hardcoding
c-whitefor text-on-color assumes dark backgrounds — a bright tint palette inverts that assumption. Contrast-sensitive colors must be theme-aware tokens, not literals. (The fix also caught a recurring trap: stale test assertions pinning the pre-fix font sizes blocked the first verification.)
KD-0760 — Delete confirmation dialog shows "Submit" instead of "Delete"
- Severity: low
- Symptom: Destructive confirmation modals (delete attachment, delete issue, delete tenant, etc.) showed a generic "Submit" confirm button instead of a destructive verb — ambiguous, and visually identical to a benign save, raising accidental-confirmation risk on irreversible actions.
- Root cause:
confirmModaldefaultsconfirmButtonTextto'Submit'. Nine destructive call sites omitted the third positional argument and inherited the generic label. (~23 other call sites already passed an explicit verb, so the misbehaviour was purely the default kicking in on incomplete calls.) - Fix: Passed an explicit
'Delete'(or equivalent verb) at all 9 destructive call sites. Left the helper's'Submit'default in place but now unreachable from any destructive flow. - Lesson: A permissive default on a shared destructive helper is a latent footgun — every incomplete call site silently inherits the wrong label. For destructive actions the safer design is no default (force the caller to name the verb), but at minimum every call site must be audited when a default is too generic to be safe.
KD-0759 — File-upload drop zones too short to hit reliably
- Severity: low
- Symptom: Drop zones (attachment uploaders, profile picture modal) rendered short — standard ~100px, compact ~40px, profile ~80px. Files released slightly above/below the dashed border missed the drop and landed on the page.
- Root cause: None of the three dropzone surfaces set a
min-h-*, so the affordance collapsed to its icon + text height. The@drophandler fires on the same<div>that draws the border, so the visible affordance IS the hit area — a short visual yields a short hit target. The compact variant fell below the WCAG 2.5.5 44px floor. - Fix: Added
min-h-32(128px) to standard + profile dropzones,min-h-14(56px) to compact, plus flex centering. No padding/border/copy changes. - Lesson: When the visual affordance and the event target are the same element, the visual size directly determines the hit area — sizing it for "looks fine" isn't the same as sizing it for "easy to hit." Set an explicit minimum height against a real interaction target (WCAG 2.5.5's 44px floor as the baseline).
KD-0756 — Invite form not reset after a successful invite
- Severity: low
- Symptom: After inviting a user, re-opening the invite modal showed the previous person's data still populated in every field. Users had to refresh to get a clean form, risking re-submitting the same details under a different email.
- Root cause:
newInviteis a module-scopedrefpassed by reference to the modal each time it opens; the form mutates that object in place viav-model. TheonSubmitsuccess path posted, refreshed, toasted, and closed the modal — but never resetnewInvite.value, so stale data persisted across openings. - Fix: One line — reassign
newInvite.valueto empty defaults aftercloseModal()inonSubmit. The toast captures the invitee's name before the reset; a failed invite throws before the reset, keeping the form populated for retry. - Lesson: A reused, mutated-in-place form model needs an explicit reset on the success path — close-and-reopen does not clear it because the same object reference is handed back. Reset after success, but only after success, so failures keep the user's input for retry.
KD-0754 — Tab walks through every WYSIWYG toolbar button
- Severity: low
- Symptom: Tabbing from a form's title field into the RichTextArea description forced keyboard users through ~8 formatting toolbar buttons first (H1/H2/H3/Bold/Italic/UL/OL/Raw). Reproduced everywhere
RichTextAreais consumed (comments, issue templates, AI story prompt, epic form). - Root cause:
FormatButton.vuewas a plain<button>with defaulttabindex="0", and the toolbar<div>precedes the editor content in DOM order — so every button became a tab stop before the editor. Norole="toolbar"or roving-tabindex consolidated them into one stop. - Fix: Applied the WAI-ARIA toolbar pattern (roving tabindex):
role="toolbar"+aria-label, exactly one button attabindex="0"and the rest at-1, arrow keys for internal navigation. Format actions remain reachable via Tiptap shortcuts; the raw-mode toggle stays keyboard-reachable (which a flattabindex="-1"strategy would have lost). - Lesson: A group of related controls should be a single tab stop with internal arrow-key navigation (the WAI-ARIA toolbar pattern), not N sequential tab stops. A shared component is the right fix point — wiring roving tabindex once protects every consumer.
KD-0753 — LinkBranchTool rethrows raw exceptions as JSON-RPC -32603
- Severity: medium
- Symptom: Any failure in the MCP
LinkBranchToolrethrew the rawThrowable, which the MCP framework mapped to a generic-32603internal error. Callers couldn't distinguish a duplicate branch link from a cross-project mismatch, a deadlock, or a broadcast failure. Under parallel agent fan-out the error was consistent and unactionable. - Root cause: The top-level catch captured the exception for the audit log but then
throw $throwable'd the raw exception — violating the documented Exception-Leak Discipline ("MCP tools must never rethrow raw Throwables from their top-level catch"). Concurrent calls could throw deadlock/unique-constraint exceptions from InnoDB gap locks, all flattened to-32603. - Fix: Replaced the rethrow with three ordered catch blocks:
BranchAlreadyLinkedExceptionandCrossProjectExceptionreturn specific structured messages; remainingThrowableis logged with scoped context and returned as a generic structured error instead of rethrown. - Lesson: At a protocol boundary (MCP/JSON-RPC), a raw rethrow collapses every distinct failure into one opaque code — the caller (often another agent) loses all ability to act. Top-level catches at such boundaries must map known exceptions to structured errors and log-then-wrap the unknown, never rethrow raw.
KD-0738 — Project deletion 500s on unhandled RESTRICT foreign keys
- Severity: high
- Symptom:
DELETE /api/projects/{project}returned 500 for any project that had been used (triggered a Claude session, been watched, linked to a tenant AI key, etc.) withSQLSTATE[23000]: Integrity constraint violation. - Root cause:
DeleteProjectActionwalks a hand-maintained list of descendant tables to delete before the parents. That list was last extended at a 2026-02-18 cascade-to-restrict audit. Six tables withrestrictOnDelete()FKs into the project subtree have landed since (claude_sessions,issue_watchers,attachment_extracted_contexts,claude_issue_eligibilities,claude_issue_eligibility_criteria,tenant_ai_key_project) and none were cleaned up, so MySQL blocked the parent delete. - Fix: Added six raw
db->table(...)->whereIn(...)->delete()calls inside the existing transaction, in FK dependency order (criteria before eligibility, contexts before attachments, pivot before project). - Lesson: A hand-maintained cascade-delete list is guaranteed to drift — every new table with a RESTRICT FK into the subtree must be manually added, and nothing fails until a populated project is deleted in prod. This bug class has no automated check today; an arch test that diffs schema FKs into project/issues against the Actions that delete them would close it. (Related drift:
BulkDeleteIssuesActionalready deletedissue_watchersbutDeleteProjectActiondidn't — the two tear-down paths had diverged.)
KD-0734 — Complete Sprint modal prompts for incomplete issues when none exist
- Severity: medium
- Symptom: Two defects. (1) The Complete Sprint modal always showed "What should we do with incomplete issues?" even when every issue was already Done. (2) After completing a sprint, the board kept showing the completed sprint until a manual reload.
- Root cause: (1)
hasNoIssuescheckedissuesCount === 0(total issues) instead of incomplete-issues count — and the backend never exposed an incomplete count, so there was nothing else to check. (2)CompleteSprintActionwas the only mutating sprint Action that never calledSprintBroadcaster->updated(), so the reactive store was never notified. A follow-up surfaced a third issue:makeSprintStoreForProjectwasn't memoized, so the modal'sretrieveAll()refreshed a different store instance than Backlog used — and since the broadcast ships with->toOthers(), the originator's UI had no refresh path at all. - Fix: Added lazily-computed
incomplete_issues_counttoSprintResourceData; switched the modal to check it. InjectedSprintBroadcasterintoCompleteSprintAction. Memoized the sprint store byprojectIdand derivedhasNoIssuesfrom the freshly-retrieved store value rather than the stale prop snapshot. - Lesson: "No issues to move" is a count of incomplete issues, not total — modeling a domain question with the nearest-available field produces noise. And a mutating Action that skips the broadcast its siblings all fire is a silent realtime gap. The deeper trap:
->toOthers()excludes the originator, so the person who triggered the action depends entirely on the HTTP response refreshing the same store instance — an unmemoized store factory quietly breaks that for the one user who most expects to see the result.
KD-0733 — Markdown tables render as unstyled plain text
- Severity: low
- Symptom: GFM tables in issue descriptions showed header and cell text with no borders, no row separation, no padding — indistinguishable from two lines of plain text.
- Root cause:
markedcorrectly emitted<table>/<thead>/<tr>/<th>and DOMPurify preserved them, butmarkdown.cssdefined.description-prosestyles for every other prose element and had no rules for table elements. The browser's default table rendering has zero borders. - Fix: Added
.description-prosetable styles (border-collapse, borders viavar(--border), header background, alternating row background, padding) matching the file's existing visual language. - Lesson: A prose stylesheet is only complete for the elements it explicitly targets — when a markdown renderer can emit an element type (tables) that the prose CSS never styled, it falls back to unstyled UA defaults. Cross-check the renderer's full output tag set against the prose stylesheet's coverage.
KD-0725 — Modal dialogs overflow viewport on narrow screens
- Severity: medium
- Symptom:
largeshared modals (1360px design width) overflowed the viewport across the entire 1024–1359px range — the half-screen-of-a-1920px-monitor band up through small desktops. Right edge clipped, close button hidden, horizontal scroll appeared. - Root cause:
BaseFormModal/BaseShowModalswitched to fixed pixel widths (lg:w-100/220/340) at thelgbreakpoint with no viewport guard. Belowlgthew-90vwfallback was already viewport-capped, so the bug only triggered in thelg+band where the fixed width exceeded the viewport. - Fix: Added
max-w-95vwto every entry in both size maps, so resolved width becamemin(design-width, 95vw). - Lesson: A fixed pixel width above a breakpoint assumes the viewport is always wider than the design width — false for half-screen and mid-desktop widths. Any fixed-width element needs a viewport-relative
max-widthcap. An arch test scanning forlg:w-<n>on a<dialog>child without a matchingmax-w-<n>vwwould prevent the regression class.
KD-0700 — Hand-to-Claude grader verdict read from a key Anthropic never sends
- Severity: high
- Symptom: Every graded Hand-to-Claude session was recorded as
Failedregardless of the actual grader verdict — the UI said "Claude could not finish the issue" even when the grader explicitly satisfied the rubric. Confirmed in prod on a session whose Anthropic events API showedresult: "satisfied"but kendo storedstatus = Failed. - Root cause:
handleOutcomereadrawPayload['outcome']['result']from the webhook, but Anthropic'soutcome_evaluation_endedwebhook is a notification only — it carries nooutcomekey at any depth. The real verdict lives on the session'soutcomeEvaluations[]list, already returned by thegetSessionretrieve call — butaggregateEventswalked the event stream for tokens/iterations only and never surfaced it. A secondary defect:triggerCleanuparchived the session unconditionally on the firststatus_idledwebhook (fired between implementer end_turn and grader start, while Anthropic had flipped back torunning), 400ing on every run. - Fix: Added
outcomeResulttoSessionResultData, populated from the latestoutcomeEvaluationsentry ingetSession, and read the verdict from there instead of the webhook payload. GatedtriggerCleanupon the session reportingidle/terminatedrather thanrunning. - Lesson: Reading state from a webhook payload that the provider documents as a notification-only event guarantees a wrong answer — the authoritative state must come from the retrieve call. The test suite hid it because the test helper fabricated the
outcomekey the provider never sends: a fixture builder that synthesizes a shape no real API produces will keep a bug green forever.
KD-0699 — PR-evidence parser rejects every verbatim MCP response
- Severity: high
- Symptom: Every Hand-to-Claude implementer session that successfully opened a PR was terminated as
Failed/missing_pr_evidencebefore the grader ran — burning the full token spend with no verdict and permanently marking the issue Failed. The implementer pasted the MCP tool response verbatim as instructed. - Root cause:
extractPullRequestUrlFromMessageread$decoded['html_url'], but the GitHub MCPcreate_pull_requesttool returns{id, url}with nohtml_url. Both the kendo parser AND the implementer system prompt's "good output looks like this" example encoded the GitHub REST API shape rather than the MCP tool's actual shape — so the prompt-mandated verbatim quoting was structurally guaranteed to fail the parse. The test suite passed because the test helper generated the same wrong shape the prompt documented. - Fix: Made the parser accept
url ?? html_url(MCP shape first, REST shape as forward-compatible fallback), both still validated through the github.com PR-URL regex. Updated the prompt example and test helper to the real MCP shape. DownstreamverifyPullRequestOpenstill confirms the PR exists, so the looser key set didn't weaken fabrication defence. - Lesson: When a prompt instructs the model to quote a tool's output verbatim, the parser must match what the tool actually emits, not what an API doc says — and the prompt example, the parser, and the test fixture must all agree on the real shape. Three places encoded the same imagined shape; prod was the first witness. Audit fixture builders for synthesized-vs-real payloads. (Side note: the diagnosed session cost ~$31 on Opus for well-trodden work — flagged a model-tier question.)
KD-0693 — Anthropic session cleanup 400s archiving the primary thread
- Severity: high
- Symptom: Every terminal Hand-to-Claude session hit
400 invalid_request_error: "The primary thread cannot be archived; archive the session instead."Because the exception threw beforecleaned_up_atwas stamped, the webhook job retried forever and the hourly prune re-hit the same failure every tick (9 occurrences in two hours post-deploy). - Root cause:
ArchiveAnthropicSessionResourcesActionwalked every thread viastreamThreadsForSessionand calledarchiveThreadon each — including the primary thread, which Anthropic rejects. The streamer yielded the primary thread (parentThreadID === null) despite its contract implying only archivable threads. Compounding it, the Action never calledsessions->archive($sessionId)at all, so sessions were never archived on the Anthropic side even before the 400 surfaced. - Fix: Filtered the primary thread (
parentThreadID === null) out ofstreamThreadsForSession, and added anarchiveSessionprimitive called once after the child-thread and vault archives, before stampingcleaned_up_at. - Lesson: When a cleanup step throws before its idempotency marker is set, it retries forever and turns a single failure into a recurring incident — cleanup loops must either tolerate the rejecting case or stamp progress before the fragile call. And a streamer whose contract says "things you can archive" must actually filter to that set, or every caller inherits the exception.
KD-0691 — session.status_idled webhook events silently dropped
- Severity: high
- Symptom: When a Hand-to-Claude session completed naturally, Anthropic emitted
session.status_idled— the dedup row was written (soprocessed_atlooked healthy) but no status update, no completion comment, no audit row, no broadcast, and no cleanup ran. From kendo's POV the session was permanently in flight; Anthropic-side resources sat until the 30-day TTL. - Root cause: All three
match ($data->eventType)blocks inHandleSessionWebhookActiononly enumeratedoutcome_evaluation_endedandstatus_terminated—session.status_idledfell through todefault => null. The dedup row was written before the inner match, which is exactly what made the failure silent: the webhook-events table looked processed while the session stayed Pending. - Fix: Added
session.status_idledto all three match blocks and ahandleIdledmethod branching onstopReason(end_turn→ Completed, anything else → Failed), mirroringhandleTerminated. Defensive early-return-with-warning if the aggregated result is null. - Lesson: A
matchwith a silentdefault => nullarm is a trap for event-type handling — a new (or unhandled) event type produces no error, just missing work. And writing a dedup/processed marker before the work means the marker lies when the work is skipped: record "processed" only after the handler actually runs, or unhandled events masquerade as healthy.
KD-0634 — Filter state leaks across projects, blanking Backlog/Board
- Severity: medium
- Symptom: Project-scoped issue filters (selected lanes/epics/creators/sprints) persisted across navigation between projects and across reloads. Because lane/epic/sprint IDs are per-project auto-increment keys, a filter from Project A matched nothing in Project B — the middle pane rendered empty with a stale filter chip showing. Affected Backlog, Board, and Overview.
- Root cause:
filters.tsdeclared module-level singleton refs persisted under global localStorage keys. Four held project-scoped IDs; the matchers did strict ID equality with no project-membership check, so a previous project's IDs filtered out every issue in the current one. - Fix: Per-slot storage keys — each project gets its own slot (
issue-filters.{projectId}.selectedLanes), hydrated onsetFilterProject(projectId). Cross-project MyIssues routes through a fixedmyissuesslot. Rejected the simpler "single global key + reset on project change" because localStorage is shared across tabs, so a Ctrl+Click into Project B would silently wipe Project A's filter in another tab. - Lesson: State persisted under a global key but holding scope-specific identifiers will leak across scopes — and the simpler "reset on change" fix breaks under multi-tab because localStorage is origin-shared. Per-scope storage slots sidestep both the leak and the cross-tab race. (Also:
selectedSprintswas dormant — declared and cleared but wired into no page; clearing it for hygiene future-proofs whoever wires it up.)
KD-0631 — Blank page when async component setup fails
- Severity: medium
- Symptom: When an HTTP request failed during a page's async
<script setup>(observed with 429s in prod during rapid navigation), the page content rendered blank — no error state, no retry. Error toasts ("Too Many Attempts.") did appear, but the content never rendered. - Root cause: Three layers combined. (1) Pages fired unguarded
await Promise.all([...])at the top of async setup — one rejection killed the whole batch. (2) Layouts wrapped<RouterView>in<Suspense>, which has no native error slot — an async child rejection puts Suspense into an unrecoverable blank state. (3) App-levelonErrorCapturedonly handledEntryNotFoundErrorand re-threw everything else. - Fix: A shared
AsyncErrorBoundarycomponent (usingonErrorCaptured) placed at the two Suspense boundaries (ProjectLayout,SharedDomainLayout), rendering "Could not load page" + a "Go back" button instead of blanking. Explicitly passesEntryNotFoundErrorthrough to the existing App.vue handler. No per-page changes. - Lesson: Vue's
<Suspense>has no error slot — an async setup rejection blanks the subtree unrecoverably unless an error boundary wraps it. Toasts surfacing the error don't help; the failure is a separate code path. One boundary at the Suspense seam covers every page beneath it. (Investigation also spun off KD-0679/0680/0635 on the underlying rate-limit pressure from unconditional refetches and orphaned broadcast subscriptions.)
KD-0512 — Reports detail pane cramped at tablet / mid-desktop widths
- Severity: low
- Symptom: The Reports page right-hand detail pane became unusable between ~768px and ~1200px — at 960px the report title wrapped character-by-character and the AI stepper labels overlapped into mush; at 768px the pane collapsed to a ~50px sliver.
- Root cause: The two-pane layout had only one breakpoint guard (
lt-md:flex-colat 768px). A fixed 400px left pane + persistent sidebar + padding left the detail pane only ~300-500px across the entire 768-1200px band — below the AI stepper's ~480px minimum. The epic documented a "narrow" breakpoint at <1100px that the Reports page never honoured. - Fix: Added a shared
isNarrowref (NARROW_BREAKPOINT = 1100) to the breakpoint service and extended the existing master-detail pattern to fire below 1100px — list OR detail, not both, with a back button. - Lesson: A layout built two-pane-first for wide monitors needs a breakpoint between "wide desktop" and "mobile stack" — the half-screen / mid-desktop band gets the worst of both otherwise. When an epic already defines a "narrow" threshold, page-level layouts must honour it via a shared signal rather than inventing per-page breakpoints.
KD-0687 — Implementer agent silently reverts to always_ask on every re-run
- Severity: high
- Symptom: The Implementer agent's GitHub MCP calls (
create_branch,push_files,create_pull_request, …) parked the session waiting on a human after any re-run of the provisioner. Production currently worked only because the policy was patched out-of-band via manual curl. - Root cause:
backend/scripts/provision-hand-to-claude.phpbuilt the Implementer'smcp_toolsetwithout apermission_policyfield. Anthropic's Managed Agents API defaults an absentpermission_policytoalways_ask, so the script — the supposed source of truth — disagreed with the live agent state, and any re-run silently overrode the manual fix. - Fix: Extracted the toolset block into a
require-returns-array companion (backend/scripts/lib/hand-to-claude-implementer-tools.php) that declares$alwaysAllow = ['type' => 'always_allow']once and applies it to both the toolset'sdefault_configand every entry inconfigs. Added a unit test asserting the shape. - Lesson: Provisioning scripts that PATCH external systems must declare every policy field explicitly — relying on API defaults means the script and the live state can diverge silently, and any "fix" applied out-of-band is one re-run away from being clobbered.
KD-0663 — Issue show page does not update from broadcasts
- Severity: medium
- Symptom: Editing an issue's title in tab B didn't re-render tab A's
<h1>until manual reload. The bell-watch toggle had its own GET endpoint, optimistic-rollback try/catch, and a manual race counter — none of which updated when other tabs watched/unwatched. - Root cause: Show.vue had no project-channel broadcast subscription at all. A mid-fix attempt introduced a per-issue channel (
Tenant.{t}.Project.{p}.Issue.{id}) + page-scopeduseLiveIssueDetailcomposable +applyResource/setByIdleaks on the issue store — re-implementing whatlanes/sprints/commentsalready did via the project-wideProjectDomainUpdateEventchannel. The fs-adapter-store package docs explicitly call out exposingsetByIdas an anti-pattern. - Fix: Reverted the per-issue channel; broadcast full
IssueResourceDataon the existing project-wide channel viaProjectDomainUpdateEvent. Addedwatcher_idstoIssueResourceData, madeToggleIssueWatchActionreturn the issue and fan out viaIssueBroadcaster::updated(), and replaced the watch GET/optimistic plumbing with a one-linecomputed+ anissue.watch()adapter method that uses the package's sanctionedstoreModule.setById. - Lesson: Before inventing a new realtime channel or store mutator, check whether sister relations (lanes/sprints/comments) already solve it on the project-wide channel — payload-size arguments rarely justify the architectural cost once you measure (typical issue ~2.5 KB compact vs Reverb's 10 KB ceiling). And when an adapter package documents
setByIdas an anti-pattern, exposing it on the store wrapper is a code smell, not a workaround.
KD-0654 — IssueForm submit button not disabled during in-flight save
- Severity: medium
- Symptom: Rapid double-click on Update/Create/Promote fired the handler twice in parallel before the first round-trip resolved. Edit popped history twice; Create produced a duplicate issue with orphan attachments associated to only one; ReportDetail promoted the report twice.
- Root cause:
IssueForm.vue's submit button had no:disabledbinding, and none of the three call sites (Edit.vue,Create.vue,ReportDetail.vue) wrapped theirawaitin anisSubmittingguard ortry/finally. The browser fired duplicate submit events freely; the async operations were independent network requests, so both succeeded. - Fix: Two-layer guard. Added optional
isSubmitting?: booleanprop toIssueFormbound to the button's:disabled. Each call site got a localisSubmittingref, an early-return guard at the top of the handler (covers syntheticrequestSubmit()paths), and atry/finallyaround the await so the flag resets on throw. - Lesson: A shared form component is the leverage point for double-submit prevention — every consumer is one optional prop away from being protected. And the guard needs both layers:
:disabledblocks the click, the early-return covers synthetic submits, andtry/finallyguarantees the flag resets even when the network call throws (otherwise a failed submit locks the form forever).
KD-0653 — UpdateIssueAction silently drops attachmentIds from PUT requests
- Severity: medium
- Symptom:
PUT /api/projects/{id}/issues/{slug}acceptedattachment_idsin the body, validated it, populated the DTO, and returned 200 OK — butUpdateIssueActionnever read the field. The API advertised behaviour it didn't implement. - Root cause:
SaveIssueRequestandSaveIssueDatawere shared across Create and Update because both controller actions used the same FormRequest. The Create path needsattachmentIdsfor the orphan-claim pattern (uploads happen before the issue has an ID); on Update, attachment edits go through dedicatedmakeAttachmentStore()endpoints, soUpdateIssueActioncorrectly didn't act on the field — but the shared DTO kept advertising it. - Fix: Per ADR-0020, split
SaveIssueDataintoCreateIssueData(withattachmentIds) andUpdateIssueData(without), andSaveIssueRequestintoCreateIssueRequest/UpdateIssueRequest. Update path's validation rule removed entirely. FrontendIssueBaselostattachmentIds; aNewIssueMutabletype carries it as a Create-only payload. - Lesson: Sharing a FormRequest/DTO across Create and Update sounds DRY but encodes a lie when the two paths have different field surfaces — the silent-drop is the symptom, the type signature is the bug. Direction-specific DTOs make the contract honest and let the type system reject the misuse.
KD-0583 — Dead unsaved-content warning on Create Issue page
- Severity: low
- Symptom: The Create Issue page had a "you have unsaved files, leave anyway?" warning that had never fired in this codebase. No user had reported the missing dialog.
- Root cause:
onBeforeRouteLeavefromvue-routerrequires the router to be installed viaapp.use(router). This app uses a customcreateRouterView()shell (shared/services/router/components.ts) and never callsapp.use()with the router, so the guard registered against an absent router and silently did nothing. The companionbeforeunloadlistener only fired on tab close, not the in-app navigation case the warning was meant to cover. The author shipped without verifying the guard fired. - Fix: Deleted the dead block —
onBeforeRouteLeave, thebeforeunloadlistener, theonUnmountedcleanup, and theclearOrphanAttachmentshelper (no other callers). Orphan attachments are pruned server-side byPruneOrphanedAttachmentsActionafter 24h, so no hygiene gap. - Lesson: Vue Router composition-API guards (
onBeforeRouteLeave,onBeforeRouteUpdate) silently no-op when the router isn't installed as a plugin — apps using a custom router-view shell must verify any router-guard hook actually fires before shipping it, because the failure mode is invisible.
KD-0626 — lint-staged glob never matches, ESLint skipped on commit
- Severity: low
- Symptom: Pre-commit hook printed "No staged files match any configured task" and ESLint never ran locally — errors only surfaced on CI.
- Root cause:
lint-stagedconfig infrontend/package.jsonused globs anchored at repo root (frontend/src/**/*) but the hook ran lint-staged with cwdfrontend/, where staged paths resolve tosrc/.... Off-by-one prefix. - Fix: Stripped the
frontend/prefix from both glob keys. - Lesson: Glob patterns must be relative to the cwd of the tool that evaluates them — when a tool is launched from a subdirectory, every config path inside it is anchored there.
KD-0606 — AI generate-story keys collide with snake_case wire format
- Severity: high
- Symptom: "Generate" button on Reports/Issues AI panel returned 422 "The source description field is required" even though the report had a description.
- Root cause: The frontend HTTP middleware runs
deepSnakeKeys()on every outbound payload, butAgentGenerateStoryRequest::rules()keys were camelCase (sourceDescription). Wire body shippedsource_description; rule never matched. The earlier KD-0511 rename intended snake_case but wrote camelCase. Feature tests posted camelCase directly, bypassing the middleware, so CI stayed green while production was broken. - Fix: Renamed rule keys to snake_case; added arch test rejecting any camelCase top-level rule key; updated tests to post the real wire format.
- Lesson: Feature tests that bypass the global request middleware can hide wire-format mismatches indefinitely — when middleware mutates payload shape, tests must post the post-middleware shape, not the pre-middleware shape.
KD-0605 — Stale checkedReportIds selection promotes the wrong report
- Severity: high
- Symptom: After dismissing a checked report and clicking a different one, pressing Promote generated an issue from the previously checked report. UI showed report B; API received report A's id.
- Root cause:
selectedReportId(detail pane) andcheckedReportIds(multi-select for promote) were two independent pieces of state. The set was never pruned when a report transitioned out of pending — dismissed reports stayed checked andReportDetail.promoteReportspreferred the non-empty stale set over the visible report. - Fix: Self-heal in the
checkedReportscomputed by filtering ongetReportStatus(report) === pending, so non-pending reports drop out of the multi-select reactively. - Lesson: Two pieces of selection state that model the same intent will drift — prefer derived/filtered state over manually synchronised mirrors, or self-heal in the computed by gating on the source-of-truth status.
KD-0604 — parseDuration silently drops decimals
- Severity: medium
- Symptom: Users entering
"2.5h"saw it round-trip to"5h"(300 minutes) instead of 150 minutes. No error — silent corruption. - Root cause:
DURATION_PATTERN = /(\d+)\s*(w|d|h|m)/giwas non-anchored and integer-only, used withmatchAll. It silently skipped any character that didn't fit the pattern — decimals, commas, junk after a fragment."2.5h"matched only5h. - Fix: Split into
VALIDATION_PATTERN(anchored, validates whole input) andEXTRACTION_PATTERN(extracts each chunk). Reject inputs that don't fully match instead of partial-summing. - Lesson: Non-anchored
matchAllover user input is a silent-corruption pattern — when parsing structured input, validate the whole string against an anchored pattern before extracting parts.
KD-0601 — Dragging issue to sprint shows "unauthorized"
- Severity: medium
- Symptom: Users with "Own" update scope could change an issue's sprint via the edit modal but got 403 when dragging the same issue on the backlog.
- Root cause:
IssuePolicy::updateBoard()calledCheckPermission::check()with no$ownerId, so the "Own" scope check evaluatednull !== null→ alwaysfalse. The siblingupdate()method correctly passed$issue->user_id. - Fix: Pass
$user->idas$ownerIdinupdateBoard(); additionally add per-issueGate::authorizeinUpdateIssueBoardActionfor issues that actually moved (sprint/lane/epic changed). - Lesson: Policies that share a permission scope must share a calling convention — when scope semantics depend on a parameter (like
$ownerId), every policy method that checks that scope must pass it the same way, or "Own" silently means "deny everyone".
KD-0600 — GitHub App install fails on webhook/redirect race
- Severity: high
- Symptom: Users completing GitHub App install were told "Installation failed — please close this tab and try again" while the recovery URL silently still worked. Reproduced on production for the emmie tenant.
- Root cause: GitHub fires the
installationwebhook and the browser redirect concurrently with no ordering guarantee. The webhook controller queuedProcessInstallationWebhookJoband returned 200 immediately; the redirect's one-shot DB lookup hit before the job ran. Worse, the error copy told users to close the tab — abandoning the working recovery URL. - Fix: Process installation events inline in the webhook controller (200 only after row committed); add bounded retry (
[200, 500, 1000, 1000]ms) in the lookup; rewrite Blade view to auto-reload oninstallation_missinginstead of telling user to close the tab. - Lesson: When two external systems fire concurrent events about the same state, "process inline" + "bounded retry on read" beats "queue async + hope" — and error copy must direct users toward recovery, never away from it.
KD-0596 — Reserved subdomain blocklist not enforced on admin CRUD
- Severity: high
- Symptom: A central operator could create a tenant with a reserved subdomain (e.g.
central.kendo.dev— the central app's own host) via admin paths, bypassing the public signup blocklist. - Root cause: Three admin FormRequests (
StoreTenantRequest,StoreDomainRequest,UpdateDomainRequest) used a weaker regex thanStoreSignupRequestand lackedRule::notIn(Tenant::RESERVED_SUBDOMAINS). Validation logic was duplicated across requests with no shared source of truth, so the drift was invisible. - Fix: Extracted shared
SubdomainRuleValidationRule class; applied to all four FormRequests including signup. - Lesson: Validation logic that exists in more than one place will drift — extract shared rules into reusable
ValidationRuleclasses the moment a second copy is needed.
KD-0591 — Validation errors silently fail on 11 forms
- Severity: medium
- Symptom: Users submitted forms; backend returned 422 with field-level errors; nothing rendered. Forms sat there with no feedback.
- Root cause: 11 templates lacked
<FormError name="…" />next to inputs whose backend rules validated those fields. The response middleware populated the globalerrorBagcorrectly — there was just no liveFormErrorinstance subscribed to render it. - Fix: Inserted 27 missing
<FormError>bindings. Two server-determined fields (laneId,order) intentionally skipped. - Lesson: Hand-authored form-error placement guarantees drift — forms should structurally couple inputs with their error display (a
FormFieldwrapper, or an arch test that cross-references templates against backend rules).
KD-0589 — Duplicate inserts return 500 instead of 422
- Severity: medium
- Symptom: Three Store endpoints (tenant AI key, project AI key, project GitHub repo) returned HTTP 500 when a user submitted a duplicate — typically a double-clicked Save button.
- Root cause: Migrations declared unique indexes but the corresponding FormRequests had no
Rule::unique(...)and the Actions had no pre-check. Duplicates reachedsave(), surfacedSQLSTATE 23000, and Laravel rendered that as 500. - Fix: Added
Rule::uniquewith the migration-matchingwhere()scope to each FormRequest. - Lesson: Every DB-level unique index needs a matching
Rule::unique(or Action-level guard) — the DB invariant is correct, but without a validation surface the user gets 500 instead of a polite 422. An arch test that cross-references migration unique indexes against FormRequest rules would catch this class.
KD-0588 — PasswordConfirmModal silent failure
- Severity: high
- Symptom: Wrong password (or any error) in the password-confirm modal closed the modal silently. User believed the destructive action they were confirming had succeeded.
- Root cause:
handleSubmitorderedemit('close')beforeawait onConfirm. Parent unmounted the modal during the synchronous emit, destroying the inline<FormError name="password" />before the response middleware could populateerrorBag.password. Thecatchblock intentionally swallowed the error, relying on aFormErrorthat no longer existed. - Fix: Reorder so
emit('close')runs only after a successfulawait onConfirm. Distinguish 422 (FormError surfaces inline) from non-422 (dangerToast) in the catch. - Lesson: Never close a modal until the awaited action it gates has resolved — and never rely on a global error bag if the component subscribed to it might already be unmounted.
KD-0587 — Cross-project attachment leak via unscoped attachment_ids
- Severity: medium
- Symptom: Reported as a cross-project leak: a user could attach attachments from another project. Investigation showed the leak didn't actually happen at runtime (Action layer scoped the query) but the FormRequest validation gap was real defense-in-depth.
- Root cause:
SaveIssueRequestvalidatedattachment_ids.*as['integer']only — noRule::exists('attachments', 'id')->where('project_id', $projectId). Inconsistent withlane_id,sprint_id,epic_idetc. on the same request. The arch test that should have caught this matched only the wrong-pattern ('exists:attachments,id') and missed omission entirely. - Fix: Added scoped
Rule::exists. Added'attachments'to the arch-test whitelist. - Lesson: Defense-in-depth scoping must live at the FormRequest layer, not just the Action — and arch tests that detect misuse must also detect omission, otherwise they're a false signal.
KD-0586 — Validation errors don't reach users on 14 forms (camelCase mismatch)
- Severity: medium
- Symptom: Server-side 422s arrived but never displayed on 14 forms. Wrong-password failures, missing-team errors, etc. all silently failed.
- Root cause: The Axios response-error middleware ran
camelCase(key)on every error key before populatingerrorBag. 25<FormError name="snake_case">bindings looked up keys the middleware never populated. Existing camelCase bindings worked; new authors didn't realise the middleware was transforming. - Fix: Renamed all 25 dead bindings to camelCase. Added arch test rejecting any
<FormError name>containing_or-. - Lesson: When a middleware silently transforms data shape, the convention has to be enforced by tooling — naming conventions across template/middleware boundaries are guaranteed to drift without an arch test.
KD-0585 — projects.description column too short for validation rule
- Severity: medium
- Symptom:
POST /api/projectsreturned HTTP 500 for any description longer than 255 chars (4 occurrences in 24h on prod). - Root cause: Migration created
descriptionas$table->string()(VARCHAR(255)). FormRequest later capped atmax:5000. Validation passed, INSERT crashed withSQLSTATE 22001 Data too long. No arch test asserts thatstring|max:Nrules don't exceed the underlying column length. - Fix: Changed column to TEXT.
- Lesson: Validation rule length and column length must be cross-checked by tooling — drift between FormRequest
max:Nand schema length silently turns 422-able input into 500s.
KD-0581 — Billing seat-count mismatch between Kendo UI and Stripe
- Severity: high
- Symptom: Pro tenant displayed "Seats: 2 (€4/seat/month)" but Stripe's invoice was €4 — quantity stuck at 1. Customer silently under-billed.
- Root cause: Two sources of truth with no reconciliation.
BillingController::statuscomputed seat count live fromUser::query()->count(). Stripe's quantity changed only whenSyncSeatQuantityJobfired from three Actions (invite/delete/restore). Any membership change before the sync infrastructure existed left Stripe permanently stale. Both paths also failed silently on edge cases (no tenant context, no Cashier subscription, queue failures). Fix (proposed): Make Stripe the single source of truth — read seat count from$subscription->quantityfor active subscriptions, fall back toUser::query()->count()only without a subscription. Lesson: External systems holding billable state must be the single source of truth — recomputing the same metric in two places (DB count + external API) without a reconciliation pass guarantees drift, and silent no-ops in sync code make the drift invisible.
KD-0574 — TenantAwareQueue captures stale scoped instances, every queued broadcast dropped
- Severity: high
- Symptom: Every realtime broadcast dispatched through queue workers silently dropped in production. Users saw no live updates anywhere until manual reload.
- Root cause:
TenantAwareQueuewas constructed once at boot withTenantSwitcherandTenantContextinjected as readonly properties. Both bindings werescoped. Laravel's queue worker callsforgetScopedInstances()before every job — removing the cached instance from the container'sinstances[]map but leavingTenantAwareQueueholding an orphan reference. TheJobProcessinglistener wrote tenant onto the orphan;broadcastOn()resolved a freshTenantContextwith no tenant set. KD-0556's lazy resolution surfaced the bug; eager constructor injection was the underlying defect. Mocked unit tests passed because Mockery has no notion of container scoping. - Fix: Replaced eager constructor injection with
resolve(...)calls inside every closure registered byregister(). Replaced unit tests (which gave false-green for ~7 weeks) with feature tests using real container bindings. - Lesson: When a service's dependencies are
scoped, that service must NOT cache them in constructor properties — and tests for scoped-binding consumers must use real container bindings, because mocks bypass container scoping and ship false-green.
KD-0556 — PR-merge webhook does not broadcast issue lane change
- Severity: medium
- Symptom: Merging a PR moved the linked issue's lane server-side but no
updatesbroadcast reached connected boards in real time. Manual drag-drop broadcast correctly, so websocket pipeline was healthy. - Root cause: 10 broadcast events derived their channel from
TenantContextsnapshotted in the constructor, andbroadcastOn()returned[]silently when the snapshot was null — no log, no exception. Either tenant context wasn't bound at construction time (background job, console command) or the snapshot drifted across processes. The silent-drop was invisible to monitoring. - Fix: Shared
ResolvesTenantBroadcastChanneltrait that resolvesTenantContextlazily at broadcast time and emits a structurederror-level log when the resolved id is null. - Lesson: Code paths that drop work silently are unobservable bugs — every "return empty / skip / no-op" branch on infrastructure code must log something structured, otherwise the failure mode never surfaces.
KD-0553 — GitHub App self-serve install fails on cache-prefix mismatch
- Severity: high
- Symptom: GitHub App tenant install fails with "Invalid or expired OAuth state" 404 on the first try. Complete feature outage.
- Root cause: Install state lived in cache. The install request ran under tenant context (
IdentifyTenantmutatedcache.prefixtotenant_{id}_); thesetup-callbackran on the base domain withoutIdentifyTenant. Laravel'sCacheManagercaches resolved stores by name — each store readscache.prefixat construction. When PHP-FPM resolved a freshcache.storebetween requests, it read the default prefix and missed the tenant-prefixed key. - Fix: Moved install state to a dedicated
github_app_install_statestable on the central connection. Prefix-immune by construction. - Lesson: State that crosses a tenant-context boundary cannot live in a tenant-prefixed cache — when reads and writes happen under different prefix configs, use a connection-scoped table instead.
KD-0545 — My Issues badge does not update via realtime broadcast
- Severity: medium
- Symptom: The Navbar's My Issues badge and page didn't react to assignment changes, lane crossings into Done, or self-assignment via MCP. Users had to refresh.
- Root cause: Two-part defect. (1) Backend never fired user-scoped issue broadcasts — only project-channel events, which the Navbar can't subscribe to. (2) Frontend
updates/deletedlisteners on the user channel were domain-blind — every payload routed unconditionally tonotificationStore, even thoughUserDomainUpdateEventalready carried adomainfield. - Fix: Added
IssueBroadcaster::myIssuesChanged()for user-scoped fan-out (computingwasOnList/isOnListper affected user). Made Navbar's user-channel listeners domain-aware. - Lesson: Realtime channels must be cut along the same axis as the data they keep in sync — a "My X" view cannot rely on per-project channels, and a multiplexed user channel needs explicit domain dispatch on the frontend or stores can't share it.
KD-0537 — Activity-timeline backfill migration deadlocks production release
- Severity: high
- Symptom: Fly's
release_commandfor prod v180 failed with MySQL deadlock during a tenant-migration backfill. Production stuck on v179; every dev→main merge would keep failing the release. - Root cause: Two interacting problems. (1) Fly's
release_commandruns in an ephemeral machine while v179 app machines keep serving live traffic — the migration's per-rowSELECT ... FOR UPDATE+INSERTfought the liveIssueAuditLoggerfor the next-key lock; InnoDB picked the migration as the deadlock victim. (2) The backfill itself was wrong-shaped — it would have written synthetic "Created today" audit-log entries withnow()timestamps, polluting the append-only hash chain with fabricated history. Staging never hit it because traffic was lower at deploy time. - Fix: Deleted the migration. The activity timeline correctly returns
[]for legacy issues with no audit history; the frontend already had an empty state for that case. - Lesson:
release_commandmigrations run concurrently with the previous version's live writes — any backfill that contends with hot-path writes on the same index will deadlock. And: if the truthful response to "we have no data" is an empty array, don't fabricate data to make it look populated.
KD-0519 — Lane reorder chevrons silently no-op on Project Settings
- Severity: medium
- Symptom: Clicking up/down chevron next to a lane appeared to do nothing — the order didn't change visually. (DB was actually updated; the frontend just didn't refresh.)
- Root cause: Earlier KD-0464 split into two commits. Frontend assumed broadcasts would keep the lane store in sync and removed
laneStore.retrieveAll()fromupdateLaneOrder(). Backend explicitly excluded bulk/cascade lane mutations from broadcasting — and lane reorder happens insideUpdateProjectAction's loop, which is exactly that "bulk/cascade" bucket. Net: write happened, no broadcast, no refetch, store kept staleordervalues. - Fix: Restored the one-line
laneStore.retrieveAll()afterproject.update(). - Lesson: When two commits together replace a refetch with a broadcast, both halves must cover the same code paths — broadcaster scope decisions on the backend must be cross-checked against every refetch the frontend removed.
KD-0518 — Sprint title update wrongly requires status
- Severity: low
- Symptom: Reporter claimed sprint edit modal returned 422 "status field required". Investigation showed real UI usage round-trips
statuscorrectly viamutable.value; only hand-crafted partial-payload clients (curl, MCP, external API) hit the 422. - Root cause: No defect in the real UI flow. The contract is "send the full sprint shape on update" — the adapter-store does that; clients that craft partial payloads correctly fail validation.
- Fix: Kept
statusasrequired. Removed regression tests that had been added during investigation that encoded behaviour contradicting the chosen contract. - Lesson: Reproduce the bug from the actual UI flow before changing the contract — a report describing partial-payload behaviour might be from a hand-crafted client and reflect the contract working as designed.
KD-0514 — "Added you to project" notification fires for existing members
- Severity: medium
- Symptom: Users already on a project got an "added you to project X" notification when a new team containing them was linked.
- Root cause:
UpdateProjectActionbuilt recipients byarray_unique(array_merge($newlyAddedDirectMemberIds, $newTeamMemberIds)). The team-side list was every member of every newly-attached team with no diff against existing project membership.array_uniqueonly deduplicated between the two lists — it didn't subtract users who already had access. - Fix: Subtract
$currentDirectMemberIds∪ members-of-currently-attached-teams from$allNewMemberIdsbefore notifying. - Lesson: Notifications about "newly added" must diff against the prior state — set-union dedup is not the same as diff. When access can be granted via multiple paths (direct + team), every path must be considered when computing "what changed".
KD-0511 — AI validation error leaks into form fields
- Severity: medium
- Symptom: Clicking Generate on the AI story prompt with a short report description showed a 422 error attached to the IssueForm's description textarea — a field the user never edited.
- Root cause: Wire-level field-name collision. The AI endpoint and the IssueForm shared two field names (
description,title). The global error bag was keyed only by Laravel field name with no per-form scoping. A 422 keyeddescriptionfrom the AI endpoint rendered under any<FormError name="description">. - Fix: Renamed AI endpoint payload keys to
sourceDescription/sourceTitleso no<FormError>watches them. - Lesson: A globally-scoped error bag means wire field names are a global namespace — two forms sharing a field name will leak errors across each other. Either scope error bags per form, or use distinct wire-field names for distinct forms.
KD-0510 — Newly created report not auto-selected in detail pane
- Severity: low
- Symptom: Submitting "+ Report" appended the new report to the list but the right-hand detail pane stayed on the placeholder. User had to find and click the new entry.
- Root cause:
handleCreatecalledawait newReport.create()but discarded the return value. The adapter resolved to the persisted Report with its server-assigned id; nothing assigned that id toselectedReportId. - Fix: Capture the returned Report and assign its id to
selectedReportId. - Lesson: Async create flows must thread the persisted entity's id back through the UI — otherwise the verify-and-edit loop is broken into two disconnected steps.
KD-0508 — Modal close button not accessible
- Severity: low
- Symptom: The X close button in modals was invisible to keyboard tools (Vimium) and screen readers.
- Root cause: The X was a bare
<svg>with a@clickhandler. SVG is not in the default tab order, has no implicit ARIA role, and is invisible to browser-level focus management. - Fix: Wrapped the icon in
<button type="button" aria-label="Close">with focus-visible ring. - Lesson: Click handlers belong on semantic elements (
<button>,<a>) — never on bare icons. Accessibility regressions of this class are best caught by a structural arch rule, not visual review.
KD-0500 — Epic name overflow on board cards
- Severity: low
- Symptom: Long epic titles overflowed the colored badge horizontally past the card's right edge into adjacent space.
- Root cause: Three compounding causes. (1) Project doesn't import
@unocss/reset, so elements default tocontent-box—max-width: 100%constrained only content, padding+border were added on top. (2)min-width: autoon inline-block elements withnowrapresolves to full unwrapped text width, beatingmax-width. (3)text-nowrapprevented wrapping but didn't addoverflow:hiddenor ellipsis. - Fix: Combined
box-border+min-w-0+max-w-full+truncateonSimpleBadge. Addedmin-w-0on the parent flex container. - Lesson: Without a global
box-sizing: border-boxreset, every component that uses padding/border with percentagemax-widthis overflow-prone — andmin-width: autoon flex/inline-block items is the silent killer that makesmax-widthconstraints useless.
KD-0496 — Manual reports show 'Unknown' as author
- Severity: low
- Symptom: Manual reports created through the UI showed "Unknown" as author. API-created reports were fine.
- Root cause:
ReportForm.vuedidn't sendauthor_name;CreateReportActionstorednull; frontend templates fell back to literal'Unknown'. The single write site ($report->author_name = $data->authorName) was the bug — every read path correctly read the column, but the column was never populated for manual reports. - Fix: Write-time fallback in the Action:
$data->authorName ?? "{first_name} {last_name}"when a creator is present. - Lesson: When many read paths share a single column, fix at the write site — anything else means hunting through every consumer (HTTP Resource, MCP tools, frontend templates) to patch the same fallback.
KD-0484 — PaginationBar overflows narrow containers
- Severity: low
- Symptom: Pagination bar visibly overflowed the Reports Overview's 400px left column when there were 8+ pages.
- Root cause:
<nav>hadflexwith noflex-wrapand used 11 fixed-widthw-10buttons (~440px intrinsic). Flex items don't shrink below their content's intrinsic width without explicitmin-w-0. Parent hadflex-wrapso siblings could wrap relative to each other, but neither child could wrap internally. - Fix: Added
flex-wrapas last-resort fallback plus a CSS container query that hides the redundant«/»shortcut buttons below 440px (the edge pages remain reachable via the existing first/last page-number buttons). - Lesson: Container queries are the right tool for "compact this UI when its container is narrow" — viewport media queries can't see how big the actual parent column is.
KD-0445 — Ticket-updated toast cluttered, auto-hides
- Severity: low
- Symptom: When user A updated an issue, user B got an
infoToastreading"Alert: <title> updated by <actor> at <ISO timestamp>"that auto-hid after 5s. - Root cause: Three coupled gaps. (1)
Show.vue(issue detail page) didn't subscribe to project-channel issue-update broadcasts like Board/Backlog/Overview did. (2) Backend papered over (1) with a globalPrivateAnnouncementuser-channel toast. (3) The toast variant had no persistence escape-hatch — auto-hid before the user could act. - Fix: Removed the entire
PrivateAnnouncement → 'alerts' → infoToastplumbing. Realtime "your view is stale" UX should live on the page that goes stale, not as a global cross-page toast. - Lesson: When a global notification papers over a missing local realtime subscription, the right fix is to wire the local subscription — not to refine the global notification.
KD-0443 — Board layout glitches on phone in landscape
- Severity: low
- Symptom: On phone in landscape, the sidebar took ~30% of the width and avatars overflowed issue cards.
- Root cause: Two issues. (1) Breakpoint service used
window.innerWidth < 768for mobile detection — phone in landscape often has 800-900px width, crossing the threshold and rendering the desktop sidebar. Viewport width is not a reliable proxy for device type. (2)DragElement.vuehad nooverflow-hiddenconstraint, so children escaped at narrow column widths. - Fix: Added
isTouchDevicevia CSS media query(pointer: coarse) and (hover: none)(correctly identifies phones/tablets without external input devices). Forced collapsed sidebar on touch devices. Addedoverflow-hidden+ truncation to card. - Lesson: For "is this a phone" questions, ask CSS about input modality (
pointer: coarse,hover: none) — never use viewport width as a proxy. Phones in landscape break that proxy.
KD-0406 — Toast notifications hidden behind modal overlay
- Severity: low
- Symptom: Toasts fired while a modal was open rendered beneath the modal's backdrop and were invisible to the user.
- Root cause: Modals use native
<dialog>.showModal(), which adds the dialog to the browser's top layer — a separate rendering stack that paints above every regularz-indexcontext. The toast container was a fixed<div>atz-index: 1050in the regular stacking context. Top layer always wins. - Fix: Upstream in
@script-development/fs-toast@0.2.0— addedpopover="manual"to the container<div>andshowPopover()on mount. Re-enters the top layer on every new toast (last-in-wins ordering). - Lesson: The browser's top-layer is not a higher z-index — it's a parallel rendering stack. Anything that must paint above a native
<dialog>must also live in the top layer (via Popover API), not just have a high z-index.
Recurring themes
Silent failures are the real bug. KD-0556, KD-0588, KD-0511, KD-0586, KD-0591, KD-0581, KD-0604 — every "no error, no toast, nothing rendered" symptom traces to a code path that swallows or drops without logging. Every "return empty / skip / no-op" branch on infrastructure code needs a structured log entry, or the bug is unobservable.
Form-error binding edge cases. KD-0586 (camelCase mismatch), KD-0591 (missing bindings), KD-0511 (cross-form key collision), KD-0588 (modal unmounted before error rendered), KD-0606 (snake/camel rule mismatch). The
<FormError>+ global error bag pattern keeps producing the same shape: any drift between the wire shape, the middleware transform, the rule key, or the template binding silently breaks the user feedback loop. Arch tests catch the structural class; component-level patterns (FormField wrappers, scoped error bags) prevent it.Tenant-scoping leaks via the wrong infrastructure. KD-0553 (cache prefix crossing tenant boundary), KD-0574 (scoped binding captured at boot), KD-0556/KD-0537 (broadcast/migration assumes tenant context). State that crosses tenant boundaries must live somewhere prefix-immune (central connection table, lazy resolution at consumption time) — caching it under a tenant prefix or capturing scoped instances at boot is a guaranteed silent drop.
Validation drift between layers. KD-0585 (
max:5000vs VARCHAR(255)), KD-0589 (unique index vs noRule::unique), KD-0596 (signup blocklist vs admin paths), KD-0587 (scoped exists missing on attachments). Whenever a constraint exists at one layer (DB, migration, central rule) but isn't mirrored at the layer the user hits first (FormRequest), the error surfaces as a 500 or a silent leak. Arch tests that cross-reference layers (column length vs rule max, migration unique vs FormRequest unique, project-owned tables vs scoped exists) close the entire class.Single source of truth, or guaranteed drift. KD-0581 (UI count vs Stripe quantity), KD-0596 (4 copies of subdomain rule), KD-0586 (manual
<FormError>placement vs middleware naming), KD-0510 (server response not threaded to UI state). Anywhere the same value is computed/stored/displayed in two places without a reconciliation pass, drift is a question of when, not if.Broadcast/refetch coverage gaps. KD-0519 (frontend dropped refetch assuming broadcast covered it), KD-0545 (project channel doesn't cover My-X views), KD-0556 (silent broadcast drop), KD-0445 (page didn't subscribe at all). Realtime channels must be cut along the same axis as the views consuming them. A user-list view cannot rely on per-project channels; a per-page view cannot rely on a global toast as compensation for a missing subscription.
Misleading error UX directs users away from recovery. KD-0600 ("close this tab" while the URL silently still worked), KD-0588 (modal closed before error rendered), KD-0606 ("source description required" with no input by that name), KD-0998 (fatal error boundary eats a form full of typed input on a 422), KD-0931 (generic "try again" when the actual remedy is one settings page away). Error copy must reference the user's actual recovery path — never tell users to abandon a working state, never reference field names the UI doesn't show, and never let a recoverable validation failure destroy the thing the user was working on.
Check-then-act and last-write-wins races. KD-1025 (guard read in memory, outside the transaction, before slow side-effecting work — two promotes both win), KD-1001 (payload built from state that only advances on response, so two rapid clicks each carry the other's stale value), KD-1015 (full-state PUT from a per-session snapshot, so two sessions clobber each other), KD-0911 (async setup Suspense doesn't cancel, so a stale component renders against the new route), KD-0882 (un-awaited dynamic import races test-environment teardown). The shape is always the same: a read and the write that depends on it are separated by a window in which something else can commit. The fixes divide cleanly by layer — atomicity at the DB (
lockForUpdate()+ re-assert under the lock), serialization at the client (promise queue), and partial-update contracts at the wire so a request never asserts fields it didn't change. Note the escalation pattern: fixing the client-side race in KD-1001 could not close the cross-session case, because the real defect was the contract, not the caller. Testing this class needs deliberate suspension — hold the first request open with a never-resolving promise, or mock the locked re-fetch to return the winner's value. A test that awaits each call in turn cannot express a race at all.Caching something whose identity never changes. KD-0835 (SPA shell cached while naming hashed chunks the next deploy deletes), KD-0953 (avatar URL keyed only on user id, so a reactive
srcnever changes and the browser never asks), KD-0920/KD-0919/KD-0928 (invalidation signal stamped but stripped by CORS, then stamped on too few routes to arrive). Two rules fall out. First, a cache key must change when the content changes — content-addressable or version-tokened URLs, never a stable URL for mutable bytes; cache headers cannot help when the client never issues a request. Second, the shell that names content-addressable assets must never itself be cached, and the recovery handler for a stale bundle cannot live inside that bundle.Tests that pin the bug instead of catching it. KD-0985 (spec asserted the leaked variable name, converting a typo into a specification), KD-0886 (spec asserted the over-gated button was absent), KD-1050 (spec forced
descriptionOverflows = true, skipping the measurement that could never produce it), KD-0997 (single blanketmockReturnValueon the permission check made "has one permission but not the other" inexpressible — the exact gap that let two broken fixes ship), KD-0878 (spec pinned the pre-migration payload shape while prod threw). A test written from the implementation rather than the requirement records current behaviour and defends it. The tells are consistent: asserting a literal copied out of the source, forcing the state whose derivation is the thing under test, and mocking a decision function wholesale when the bug lives in the disagreement between two of its answers.