Appearance
Reports Decisions
Each entry is a real fork in the road — what we chose, what we passed on, and why. Implementation details that aren't a tradeoff are NOT here.
Feedback proxy: keep server-side, not direct
- Chose: SendFeedbackAction proxies multipart from tenant to script.kendo.dev with server-side token.
- Rejected: Frontend calls Report API directly with a project token.
- Why: Direct calls leak the project token to the browser and add CORS + cross-tenant complexity; the dual upload cost is acceptable.
- Source: KD-0157
Feedback uploads: inline-only at report creation
- Chose: Files travel with
POST {project}/reports; no separate attachment routes for reports. - Rejected: Mirroring the issue/epic/comment pattern with dedicated attachment routes.
- Why: Reports are created via the feedback proxy and only read/promoted/dismissed afterward — separate routes would be YAGNI.
- Source: KD-0157
Feedback file scope: images-only at 20 MB
- Chose: PNG/JPG/GIF/WebP, 20 MB per file, 5 file cap.
- Rejected: 5 MB strict images, or images + documents matching issues/epics.
- Why: 20 MB matches the existing attachment ceiling, but the feedback use case is screenshots — not arbitrary file types.
- Source: KD-0157
Feedback API failure: error toast, no queued retry
- Chose: Surface 503 via
SendFeedbackException; user retries manually. - Rejected: Queued job with temporary tenant-side file storage and retry.
- Why: A retry queue with file lifecycle is too much infra for the value it adds at current Report API uptime.
- Source: KD-0157
Reports surface: separate tab, not a board lane
- Chose: New "Reports" tab outside the kanban board.
- Rejected: A "Draft" lane added to the existing board.
- Why: Lanes are workflow states; intake items pollute the board and mix triage with active work.
- Source: KD-0186
Report shape: lightweight title+description, not full issue
- Chose: Reports carry only title + description; fields enriched at promotion time.
- Rejected: Reports as issues with a "not on board" flag.
- Why: Quick capture with a validation gate is the whole point — full fields would replicate normal issue creation.
- Source: KD-0186
Promote model: keep + timestamp, don't delete
- Chose:
promoted_at+promoted_issue_idretained on the report. - Rejected: Deleting the report row on promotion.
- Why: Traceability and conversion-rate analytics need the historical link; deletion erases provenance.
- Source: KD-0186
Webhook auth: Passport bearer tokens, not custom HMAC
- Chose: Passport tokens with
report:createscope. - Rejected: Custom HMAC-SHA256 webhook signature scheme.
- Why: Reuses existing token infra; HMAC duplicates a token system for marginal extra rigor.
- Source: KD-0186
Webhook routing: tenant DB, not central
- Chose: ProjectWebhook/ProjectToken live in the tenant database.
- Rejected: Central-DB lookup for webhook resolution.
- Why: The subdomain in the webhook URL already drives tenant identification via
IdentifyTenant; central lookup adds a hop for nothing. - Source: KD-0186
Reports UI: master-detail, not card grid
- Chose: Left list + right detail-and-promote panel.
- Rejected: 2-column ReportCard grid with a promote modal.
- Why: Triage is a sequential review-and-act workflow; the promote form needs room for type/priority/lane/assignee/epic without a cramped modal.
- Source: KD-0265
Multi-report merge: defer, but architect for it
- Chose: Ship single-report promote; keep
promoted_issue_idnon-unique so multiple reports can later share an issue. - Rejected: Building multi-select + merge logic up front.
- Why: Significant UI complexity without real usage data; the data model already permits the future feature.
- Source: KD-0265
AI story endpoints: reuse issue endpoints, no report-specific API
- Chose: Frontend maps report fields onto the existing
generate-storypayload. - Rejected: New
POST /reports/{id}/generate-storyendpoint. - Why: Existing endpoints are tested and audit-logged; the DTO already accepts title/description/context.
- Source: KD-0280
AI story UI: extract composable + component, refactor IssueForm in same PR
- Chose: New
useStoryGeneration+AiStoryPrompt; IssueForm refactored to consume both. - Rejected: Copy-paste 220 lines of AI logic into ReportDetail and refactor later.
- Why: Two implementations would drift; the user explicitly requested the refactor in scope.
- Source: KD-0280
AI story dual-mode: one component with reportContext prop
- Chose: Single AiStoryPrompt that flips label/required/payload based on a
reportContextprop. - Rejected: Separate
IssueAiPromptandReportAiPromptcomponents. - Why: UI is 95% identical; one component guarantees consistency and halves the test surface.
- Source: KD-0280
AI prompt placement: page-level, not inside IssueForm
- Chose: Pages render
<AiStoryPrompt>above<IssueForm>and bridge state. - Rejected: Embedding AI generation inside IssueForm.
- Why: Keeps IssueForm a pure form; standard "smart parent, dumb component" pattern.
- Source: KD-0280
Promote form: shared IssueForm, drop ReportDetail's inline form
- Chose: Reports and Create/Edit use the same IssueForm with an Advanced Options toggle.
- Rejected: Keeping a separate ~130-line inline promote form on ReportDetail.
- Why: Identical fields; one form means one place to evolve and a ~330-line net reduction.
- Source: KD-0280
Dismiss button: on the report card, not in the form
- Chose: Dismiss lives next to status badges on the report details card.
- Rejected:
showDismissprop +@dismissemit on IssueForm. - Why: Dismiss is a report-level action; baking it into the form would leak report concerns into a generic form.
- Source: KD-0280
Combine multiple reports: new component, not modify ReportDetail
- Chose: New
CombinedReportDetailrendered when 2+ reports are checked. - Rejected: Generalize ReportDetail to handle one or many reports.
- Why: ReportDetail is tightly coupled to a single report (form reset watches
report.id); branching it would entangle two flows. - Source: KD-0281
Combined AI context: numbered description, reuse existing interface
- Chose: Title =
"Combined: T1, T2", Description = numbered list reusingReportContext { title, description }. - Rejected: New combined-context payload shape feeding AiStoryPrompt.
- Why: Reusing the existing interface means zero changes to AiStoryPrompt or useStoryGeneration.
- Source: KD-0281
Batch promote: atomic backend endpoint, not N sequential calls
- Chose: Single
POST /reports/batch-promotefollowing thebulk-delete-issuespattern. - Rejected: Frontend looping over the singular promote endpoint.
- Why: All-or-nothing matters when promoting many reports to one issue; sequential calls leave partial state on failure.
- Source: KD-0281
Attachment delivery: store pattern with dedicated endpoint
- Chose: ReportResourceData carries only
attachmentsCount; full data viaGET /projects/{p}/reports/{r}/attachments. - Rejected: Inline
AttachmentResourceData[]nested on the report response. - Why: AttachmentGrid expects an
Adapted<AttachmentBase>wrapper withupdate()/delete()/mutable— inline data can't satisfy the type without method stubs. - Source: KD-0349
Attachment transfer on promote: shared file, copy DB rows
- Chose: New Attachment rows for the issue pointing to the same physical file.
- Rejected: Deep file copy, or transferring nothing.
- Why: Storage cost matters for a hosted product; the shared-file edge case is bounded by orphan-cleanup, separately tracked.
- Source: KD-0349
Batch promote attachments: cap 10 with frontend warning
- Chose: Pre-promote dialog when combined attachmentsCount > MAX_ATTACHMENTS_PER_ENTITY (10).
- Rejected: No cap, or silent truncation server-side.
- Why: The 10 limit is established and the user deserves to know which attachments will drop (oldest first by creation date).
- Source: KD-0349
Attachment-copy logic: separate Action, not inline
- Chose: New
CopyAttachmentsActioninjected into PromoteReportsAction. - Rejected: Inline copy logic in PromoteReportsAction.
- Why: ADR-0011 single-responsibility; copy is independently testable and reusable for future flows.
- Source: KD-0349
Pagination: client-side slicing, no backend changes
- Chose:
retrieveAll()continues to fetch all reports;paginatedReportsslices in a computed. - Rejected: Server-side
?page=&per_page=with LengthAwarePaginator. - Why: Every other paginated list in the app is client-side; no server-side pagination infra exists.
- Source: KD-0398
Pagination total: filtered count, not grand total
- Chose:
total-rowsreflectsfilteredReports.length. - Rejected: Always showing the full report count regardless of active filters.
- Why: Matches Projects Overview convention; the count should describe what the user is browsing.
- Source: KD-0398
Issue delete with linked reports: silently unlink
- Chose: Clear
promoted_atandpromoted_issue_idinside the delete transaction; reports return to Pending. - Rejected: Confirmation modal asking unlink-vs-dismiss; always-dismiss; cascade-delete the reports.
- Why: The other options either over-engineer a UX paper-cut or destroy audit-grade source feedback.
- Source: KD-0442
Unlink scope: clear both timestamp and FK
- Chose: Set both
promoted_atandpromoted_issue_idto null. - Rejected: Clear only the FK to preserve the historical timestamp.
- Why: A
promoted_atnon-null + FK null hybrid would mis-categorize the report in status filters and show a misleading "promoted" timestamp. - Source: KD-0442
Unlink mechanics: query-builder bulk update, no per-row save
- Chose: Single
Report::newQuery()->where(...)->update([...]). - Rejected: Iterate models and
save()each. - Why: No event listeners depend on the model save path; matches the
DeleteLaneActionprecedent and ADR-0019 (which bans mass-assignment viamodel->update(), not query-builder updates). - Source: KD-0442
Bulk delete fix: in same PR, not deferred
- Chose: Patch
BulkDeleteIssuesActionwith the same unlink at the start of its transaction. - Rejected: Ship singular fix; track bulk separately.
- Why: Bulk currently throws an uncaught FK violation (HTTP 500) — a strictly worse symptom of the same bug, and the fix is mechanical.
- Source: KD-0442
Audit on unlink: none
- Chose: No audit log entry when reports are unlinked.
- Rejected: Building a
ReportAuditLoggerinfrastructure for the side effect. - Why: Reports have never been audit-tracked; constructing the rail just for this single event is overengineering.
- Source: KD-0442
Status enum representation: int-backed
- Chose:
ReportStatusEnum: intwith 0=Pending, 1=Promoted, 2=Dismissed. - Rejected: String enum or accepting both string and int.
- Why: Every existing
*Enuminapp/Enums/is int-backed; matchesSearchIssuesTool's schema convention. - Source: KD-0456
Reports list endpoint filter scope: MCP only, not REST
- Chose: Filter inside the MCP tool; CLI filters returned slice in Go.
- Rejected: Adding
?status=toGET /api/projects/{project}/reports. - Why: Issue scope was the MCP token-window pain; REST bandwidth was never the motivation, and adding the param widens validation/test surface for nothing.
- Source: KD-0456
Status storage: composed inline, no new column
- Chose: Filter via
whereNull('promoted_issue_id')->whereNull('dismissed_at')etc. - Rejected: New stored
statustinyint column with backfill + sync. - Why: Status is already derivable; a stored column duplicates state and risks drift.
- Source: KD-0456
CLI status flag: friendly strings, not enum ints
- Chose:
--status pending|promoted|dismissed. - Rejected:
--status 0|1|2mirroring the MCP schema. - Why: CLI users are humans; MCP and CLI have different audiences and can diverge.
- Source: KD-0456
Broadcast surface: all 5 real mutating Actions, including Reorder
- Chose: Inject
ReportBroadcasterinto Create, Dismiss, Promote, Reorder, and Delete Actions. - Rejected: Follow the ticket literally — broadcast on Create/Update/Dismiss/Promote/Delete and create a new
UpdateReportActionto match the AC. - Why:
UpdateReportActiondoesn't exist (reports have no inline edit) andReorderReportsActionis the most active "silent" mutation today; literal AC adherence would add dead code while leaving drag-and-drop unbroadcast. - Source: KD-0526
ReorderReportsAction: load-save loop, not bulk QueryBuilder update
- Chose: Replace
newQuery()->where(...)->update([...])with a loaded-modelforeach … save()loop so the broadcaster fires next to each save inside the transaction. - Rejected: Keep the bulk update and fetch models afterward to broadcast, or use
ProjectDomainBatchUpdateEvent. - Why: Same lesson as KD-0460/KD-0467 — QueryBuilder bulk updates bypass the model save path and split the broadcast from the write; a batch event isn't justified at sub-50-report drag scale.
- Source: KD-0526
Notification recipients: project-scoped watchers, not roles or a global preference
- Chose: A
report_watcherspivot table mirroring the provenissue_watcherspattern, letting users self-subscribe per project. - Rejected: Role-based recipients (roles are tenant-wide, not project-scoped; "maintainer" doesn't exist), or a single user-wide notification preference.
- Why: Triage is per-project — a user should be able to watch Project A but not Project B — and a global toggle is fatally all-or-nothing; roles would force a new project-scoped role concept.
- Source: KD-0503
Auto-watch on project create: owner only
- Chose: Only the project creator is auto-added to
report_watchers; everyone else opts in via the bell icon. - Rejected: Auto-watching the owner plus all direct members.
- Why: Broader auto-watch is noisy for members who don't triage; minimal default noise with deliberate opt-in keeps notification spam low.
- Source: KD-0503
Existing project owners: backfill the watcher pivot
- Chose: A migration seeds
report_watchersfor every existing project's owner, diverging from theissue_watchersprecedent. - Rejected: No backfill (the consistent choice with issue_watchers, which started empty).
- Why: Report-watching has only one organic auto-trigger (project creation) versus issue-watching's three, so without backfill the feature is invisible on day 1 for all current projects — a real discovery problem that justifies the divergence.
- Source: KD-0503
Email gate: dedicated notify_report_activity_email column
- Chose: A new per-user email preference column for report activity.
- Rejected: Reusing the existing
notify_watched_activity_email(issue-watching) toggle. - Why: Report triage is a different workflow from issue watching; one shared switch conflates the two and removes the ability to opt out of one without the other.
- Source: KD-0503
External/anonymous report author: null actor, name in message
- Chose: Store
actor_id: nulland fold the free-textauthor_nameinto the notification message (e.g. "New report 'Login bug' by John Doe"). - Rejected: A synthetic "Kendo Bot" system actor.
- Why: A submitter's
user_idis meaningful only in their home tenant, so storing it as the actor on the receiving tenant is a cross-tenant id collision; the notification model already supports nullable actors and a placeholder avatar, while a synthetic user introduces a fake-user concept. - Source: KD-0503
Error event scoping: per-project NOT NULL FK, not tenant-wide free-text
- Chose: Every error group belongs to exactly one project via a
project_idNOT NULL FK, derived server-side from the ingest token. - Rejected: Tenant-wide grouping keyed on a free-text
territorystring (the issue as written), or a nullableproject_idhybrid. - Why: A project token can't exist without a project anyway, so a real FK gives true attribution and reuses the entire
ProjectToken/project-access authz machinery instead of a parallel tenant-level token system. - Source: KD-0771
Token ability: stored int-enum column, not scope-only or a separate minting path
- Chose: Add an int-backed
TokenAbilityEnumcolumn toproject_tokensand parametrize the create Action, backfilling existing rows toreport:create. - Rejected: Leaving the scope only on the Passport token (a separate minting path with no column), or reusing the broad
writescope. - Why: A stored, least-privilege ability lets a token list cheaply show whether a row is a report or error token without joining Passport scopes.
- Source: KD-0771
Fingerprint fallback: parse top frame, fall back to class + first trace line
- Chose:
sha256(class::file::method)from a parsed top frame, falling back tosha256(class + first-non-empty-trace-line)when no frame parses. - Rejected: Class-only fallback, or hashing the whole stack-trace string.
- Why: Class-only collapses unrelated errors into one group, while whole-string hashing forks a new group on every line-number or argument change.
- Source: KD-0771
Path normalization: client strips its own base path; server normalizes only as fallback
- Chose: The client library strips its exact
base_path()before sending; the server keeps a prefix-regex normalizer only as a best-effort fallback and still owns the fingerprint formula. - Rejected: Server-only normalization that guesses each caller's deploy root with a fixed prefix regex.
- Why: Only the client knows its own root exactly; a server regex silently fails for unanticipated roots (Fly, Docker, Windows) and forks duplicate groups for the same app.
- Source: KD-0771
Unknown ingest fields: strip silently and accept 2xx, don't 422
- Chose: Only
validated()keys reach the DTO, so unknown fields are silently dropped and the request still succeeds. - Rejected: Returning 422 when the body carries extra fields.
- Why: Stripping matches every existing kendo FormRequest, and the client swallows responses so a 422 signal would go unseen anyway.
- Source: KD-0771
Retention sweep: iterate all tenants via TenantSwitcher
- Chose: The prune command loops
Tenant::all()withTenantSwitcher::switchTo()and atry/finallyreset, isolating per-tenant failures. - Rejected: Copy the existing single-connection prune command shape.
- Why:
error_groupsis a per-tenant table, so a single-connection sweep cleans only one tenant — the known KD-0873 bug. - Source: KD-0771
Ingest dedup: atomic INSERT … ON DUPLICATE KEY UPDATE
- Chose: A single raw upsert that increments
occurrence_countand refreshes the latest-event fields in one round-trip. - Rejected: Insert-then-catch the unique violation, or SELECT … FOR UPDATE before writing.
- Why: Laravel's transaction retry does not catch
UniqueConstraintViolationException, so two events racing on a brand-new fingerprint need DB-atomic dedup, not app-level catch or locking. - Source: KD-0771
Token last-used timestamp: drop to a later version
- Chose: Omit any
last_used_attracking; rely onerror_groups.last_seen_atas the token-liveness signal. - Rejected: Writing last-used on every ingest, or a coalesced periodic write.
- Why: A per-request write amplifies the hot path at the 1000/min ceiling, and token liveness is already observable from arriving errors.
- Source: KD-0771
Ingest rate limit: keyed per token, not per project
- Chose:
Limit::perMinute(1000)->by(token id ?? ip)on the ingest route. - Rejected: Keying the limiter per project.
- Why: A per-project key lets one noisy token starve every sibling token on the same project.
- Source: KD-0771
Global error view: separate My-Issues-style endpoint, not a widened per-project policy
- Chose: A dedicated
GET /error-groupsendpoint with a nullable-project policy and an Action that filters to the user's accessible projects, mirroringMyIssuesController. - Rejected: Widening the per-project route to also serve tenant-wide when no project is bound.
- Why: Route-model binding can't express an optional project, so overloading the per-project route muddies the policy and authz.
- Source: KD-0773
Error visibility: permission-gated, not admin-only
- Chose: Gate error-group reads on the
ErrorGroupspermission resource, grantable to non-admin roles. - Rejected: Restrict visibility to the tenant admin role (the stale AC), 403 for everyone else.
- Why: Triage shouldn't require admin; a per-resource permission lets any role with the grant see errors without a role-wide escalation.
- Source: KD-0773
Stack trace rendering: verbatim monospace <pre>, not a markdown renderer
- Chose: Render
latest_stack_tracein a scrollable monospace<pre>block with verbatim text. - Rejected: Reuse the markdown/prose renderer, or add syntax highlighting (highlight.js).
- Why: A markdown renderer interprets backticks/underscores/angle-brackets and mangles raw traces; highlighting adds a dependency for marginal v1 value.
- Source: KD-0773
Error pages: one relation domain holds both per-project and global views
- Chose: A single
relations/errorGroups/domain exporting both project-nested children and a top-level/error-trackingroute, mirroring My Issues. - Rejected: A separate top-level
domains/errorTracking/for the global view. - Why: A separate domain triggers ADR-0014 cross-domain sharing for the row/badge components; co-locating both pages keeps them as plain in-domain imports.
- Source: KD-0773
Global-list resource: expose project_id only, resolve name on the FE
- Chose: The resource carries
project_idwith no eager-loaded project relation; the global page resolves the name from the already-loaded FE projects store. - Rejected: Eager-load the project relation onto each error-group row.
- Why: Eager-loading per row introduces an N+1 the FE store already makes unnecessary.
- Source: KD-0773
Empty-state components: two leaves wrapping a shared shell, not one prop-driven component
- Chose: A generic
EmptyStateShellwrapped by two presentational leaves (full per-project, lighter cross-project). - Rejected: One component whose optional
projectIdprop (or avariantstring enum) drives the full-vs-lighter body. - Why: One component silently rendering two quite-different bodies behind an optional prop or string sentinel is exactly the branching/union shape kendo avoids.
- Source: KD-0881
Empty-state load: top-level await retrieveAll(), not fire-and-forget
- Chose: Await the store fetch at the top of the page so the empty state only renders after the first load resolves.
- Rejected: Fire-and-forget the fetch (gating on a local
loadedref), or accept the flash. - Why: An un-awaited fetch makes the zero-data check true on first render, flashing the rich onboarding block on projects that actually have errors.
- Source: KD-0881
Token CTA on empty state: render unconditionally, no second feature-flag read
- Chose: Always render the "create a project token" CTA without reading the
projectApiTokensflag. - Rejected: Gate the CTA on
projectApiTokensso it disappears when that flag is off. - Why: The two flags realistically ship together (project tokens exist for error ingestion), so coupling this empty state to a second flag adds cross-relation coupling for a corner case.
- Source: KD-0881
Extracted shell placement: keep in domain, defer promotion to shared
- Chose: Keep
EmptyStateShellundererrorGroups/components/with its two in-domain callers. - Rejected: Promote it to
shared/components/immediately. - Why: With only two callers it isn't yet shared; promotion is a clean follow-up once a third caller appears, and keeping it local avoids cross-domain boundary concerns now.
- Source: KD-0881
Report↔issue linking: derive from promotion, don't build a manual pivot
- Chose: No manual link at all — the issue's Related section is read-only and derived from
promoted_issue_id. - Rejected: An
issue_reportmany-to-many pivot with an editable report picker inIssueForm(specced, then cut in review). - Why: The real workflow is report→issue promotion, so a manual link adds a second issue-FK concept, pivot cleanup on both delete paths, and an edit surface for a relationship nobody creates by hand.
- Source: KD-0608
Promoted-report surfacing: derive on the frontend, don't write a second link
- Chose:
Show.vuefilters the already-loaded project reports forpromotedIssueId === issue.id. - Rejected: Writing a link row at promote time (plus a backfill migration for historical promotes), or a
promoted_report_idsresource field. - Why: The reports index already returns every report with its promote FK, so deriving costs nothing and covers history for free, while a written link duplicates the promote fact in two places that can drift.
- Source: KD-0608
Control-character stripping: at storage, not at prompt-assembly
- Chose:
CreateReportActionstrips control characters before the row is written. - Rejected: Stripping inside
AssembleReportContextActionat AI-use time. - Why: One choke point at the ingress boundary protects every current and future consumer, and the round-trip-fidelity loss is theoretical — control characters have no legitimate use in report text.
- Source: KD-0614
Prompt-injection sanitization: one authoritative gate, not defense in depth
- Chose: Context assembly does format + cap only;
PromptInjectionGuard::sanitize()stays solely at prompt-build time. - Rejected: Sanitizing again during assembly as belt-and-suspenders.
- Why: Every agent Action already sanitizes when templating the description into its prompt, so a second pass buys nothing and introduces double-escaping risk.
- Source: KD-0614
Report context assembly: on the worker, not before dispatch
- Chose:
GenerateStoryJobcarrieslist<int> $reportIdsand assembly happens insideRunStoryGenerationAction. - Rejected: Assembling in the controller and serialising the finished string into the job payload.
- Why: A primitive payload keeps up to 50 000 characters out of the queue row and reads report content at execution time rather than click time.
- Source: KD-0614
Job payload schema change: atomic swap, no version discriminator
- Chose: Ship the new constructor signature and accept that jobs in flight at deploy fail with a retryable toast.
- Rejected: A version flag so old-schema payloads still deserialise.
- Why: Exposure is bounded by the job's 150 s timeout and
$tries = 1, which doesn't justify carrying deserialisation-compatibility code forever. - Source: KD-0614
Feedback page URL: absolute, not path-only
- Chose: Pre-fill
window.location.originplus the anonymized path. - Rejected: The router's
fullPathalone. - Why: Triagers receive reports from every tenant, and a bare path forces them to reverse-engineer the originating subdomain from the author name.
- Source: KD-0976
Page-URL anonymization: mask by default, allowlist the safe query keys
- Chose: Route-param values become
{paramName}, numeric query values{id}, long values{token}, with a small allowlist of enum-like keys passing through verbatim. - Rejected: Pre-filling the raw URL and trusting the user to trim it, or stripping the query string wholesale.
- Why: An editable field makes the reporter responsible for spotting what's sensitive, and default-mask-unless-allowlisted is the safer polarity when the payload crosses a tenant boundary to the platform team — at the cost of an explicit allowlist edit whenever a new safe key appears.
- Source: KD-0976
Ignored error groups: stay ignored when new occurrences arrive
- Chose: Only
Resolved → Openflips on ingest;Ignoredsurvives new occurrences untouched. - Rejected: Treating Resolved and Ignored symmetrically so both reopen on regression.
- Why: Resolved is a claim about the past that a new event falsifies, whereas Ignored is a standing instruction that a new event shouldn't override.
- Source: KD-0892
Error ingest: SELECT FOR UPDATE + explicit branches, replacing the atomic upsert
- Chose: Two explicit INSERT/UPDATE branches inside a retried transaction, with the MySQL-only
FOR UPDATEclause behind a load-bearing driver guard. - Rejected: Keeping KD-0771's single
INSERT … ON DUPLICATE KEY UPDATEand detecting new-vs-existing viaROW_COUNT(). - Why: Regression detection and new-group notifications both need to know which path ran, and
ROW_COUNT()is DB-specific and unreliable under the SQLite test driver — the gap-lock deadlock the rewrite introduces is absorbed by the transaction's retry count. - Source: KD-0892
Error-group broadcast: user-initiated status changes only
- Chose: Resolve/ignore/reopen broadcast through a new
ErrorGroupBroadcaster; the ingest-time regression flip does not. - Rejected: Broadcasting every status change, including the automatic Resolved→Open flip.
- Why: The regression flip has no user actor and sits on a 1000-events/min hot path, where a broadcast per event is cost without an audience.
- Source: KD-0892
Status control: sidebar inline-edit idiom, not the plan's discrete buttons
- Chose: A pencil trigger revealing a
SingleSelectwith all three statuses. - Rejected: The plan's literal spec — two conditional action buttons rendered per current state.
- Why: Every other sidebar mutation uses the pencil→widget→cancel idiom and the functional outcome is identical, so implementing the plan verbatim would have made this the first sidebar to break the convention.
- Source: KD-0892
Error group → Bug issue: one click, no form and no LLM
- Chose: The button creates the Bug immediately from server-computed title/description and navigates to it.
- Rejected: A confirm-and-preview modal, a full editable
IssueFormlike the Reports promote flow, or an AI generation step. - Why: A report is free text needing interpretation, whereas an error group's exception class, message, and trace are already the finished content — authoring UI and an LLM round-trip would add friction, cost, and prompt-injection surface over a mechanical string format.
- Source: KD-0893
Double-promote guard: typed exception, no row lock
- Chose: A guard clause on
linked_issue_idthrowingErrorGroupAlreadyLinkedException(422). - Rejected:
SELECT … FOR UPDATEto close the race window completely. - Why: The button disappears once linked and the submit is pending-guarded, so the exception only backstops a narrow race — the same risk tolerance
PromoteReportsActionalready ships with. - Source: KD-0893
Issue-side error display: client-derived, gated on both flag and permission
- Chose: The issue Show page filters the error-group store for
linkedIssueId === issue.id, behinduseFeatureActive('errorTracking')and anErrorGroups:Readcheck. - Rejected: A reverse column or relation on
Issue, or fetching the store unconditionally like the Related Reports section does. - Why: The related-reports pattern already proves client-side derivation works, but error tracking is a Pennant-gated feature with its own RBAC resource, so an ungated fetch would be wasted or unauthorized for most tenants.
- Source: KD-0893
Promote permission: replicate the known gap, don't patch one endpoint
- Chose:
promotechecks onlyErrorGroups:Update, exactly likeReportPolicy::promoteReports, with the gap recorded in writing. - Rejected: Adding an
Issues:Createcheck on this endpoint alone. - Why: Both endpoints mint an Issue through the same shape of operation, so two promote flows enforcing different bars is a worse defect than one consistent, documented gap that can be closed for both at once.
- Source: KD-0893
New-error-group notification: queued job dispatched outside the transaction
- Chose:
NotifyNewErrorGroupJobon the default queue, dispatched after the INSERT transaction commits, carrying only primitive ids. - Rejected: Calling the notify Action synchronously inside ingest.
- Why: Ingest is a 1000/min hot path that must not wait on watcher fan-out, and dispatching inside the transaction risks a worker picking up a job whose group row was rolled back.
- Source: KD-0894
Notification job retries: none
- Chose:
$tries = 1— a failure lands infailed_jobsand the notification is simply lost. - Rejected:
$tries = 3to survive transient failures. - Why: The notifications table has no dedup guard, so a retry can double-notify; a missed notification is recoverable because the error is still on the errors page, a duplicate one isn't.
- Source: KD-0894
Error filtering: keep the server/client split, don't unify it
- Chose: Tenant-wide environment and project stay server-side; status and the new search stay client-side.
- Rejected: Moving every dimension client-side so one uniform matcher handles all of them.
- Why: The tenant-wide store's 500-row cap is applied after server filtering, so client-filtering the capped set would silently hide a project's groups beyond the cap — a query-behaviour regression disguised as a refactor.
- Source: KD-1041
Error filter dimensions: single-select, not board-style multi-select
- Chose: Every dimension stays a single value; toggling an option replaces the current selection.
- Rejected: Multi-select parity with the Board/Backlog filter bar.
- Why: Environment and project are server-side single-value params, so multi-select would need backend array support, and
useTenantFilterBaralready proves single-select is a first-class FilterBar usage. - Source: KD-1041
Error filter composable: one generic bindings-driven hook
- Chose: A new
useErrorGroupFilterBar(bindings)taking an array of dimension descriptors, shared by both the per-project and tenant-wide pages. - Rejected: Reusing the issues domain's
useIssueFilterBar, or writing a page-specific composable for each page. - Why:
useIssueFilterBaris multi-select and hard-wired to issue filter metadata, so reusing it would couple the errorGroups relation to the issues relation, while per-page composables would duplicate near-identical selection logic instead of letting a new dimension be data. - Source: KD-1041
Error filter state: component-local and non-persisted
- Chose: Each page owns its filter refs in
setup(); nothing is written to storage. - Rejected: Module-scoped persisted refs, matching issues, notifications, and tenants.
- Why: The per-project page mounts a separate store per project, so module-level refs would leak one project's selection into the next.
- Source: KD-1041