Skip to content

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_id retained 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:create scope.
  • 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_id non-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-story payload.
  • Rejected: New POST /reports/{id}/generate-story endpoint.
  • 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 reportContext prop.
  • Rejected: Separate IssueAiPrompt and ReportAiPrompt components.
  • 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: showDismiss prop + @dismiss emit 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 CombinedReportDetail rendered 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 reusing ReportContext { 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-promote following the bulk-delete-issues pattern.
  • 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 via GET /projects/{p}/reports/{r}/attachments.
  • Rejected: Inline AttachmentResourceData[] nested on the report response.
  • Why: AttachmentGrid expects an Adapted<AttachmentBase> wrapper with update()/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 CopyAttachmentsAction injected 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; paginatedReports slices 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-rows reflects filteredReports.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
  • Chose: Clear promoted_at and promoted_issue_id inside 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
  • Chose: Set both promoted_at and promoted_issue_id to null.
  • Rejected: Clear only the FK to preserve the historical timestamp.
  • Why: A promoted_at non-null + FK null hybrid would mis-categorize the report in status filters and show a misleading "promoted" timestamp.
  • Source: KD-0442
  • Chose: Single Report::newQuery()->where(...)->update([...]).
  • Rejected: Iterate models and save() each.
  • Why: No event listeners depend on the model save path; matches the DeleteLaneAction precedent and ADR-0019 (which bans mass-assignment via model->update(), not query-builder updates).
  • Source: KD-0442

Bulk delete fix: in same PR, not deferred

  • Chose: Patch BulkDeleteIssuesAction with 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
  • Chose: No audit log entry when reports are unlinked.
  • Rejected: Building a ReportAuditLogger infrastructure 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: int with 0=Pending, 1=Promoted, 2=Dismissed.
  • Rejected: String enum or accepting both string and int.
  • Why: Every existing *Enum in app/Enums/ is int-backed; matches SearchIssuesTool'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= to GET /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 status tinyint 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|2 mirroring 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 ReportBroadcaster into Create, Dismiss, Promote, Reorder, and Delete Actions.
  • Rejected: Follow the ticket literally — broadcast on Create/Update/Dismiss/Promote/Delete and create a new UpdateReportAction to match the AC.
  • Why: UpdateReportAction doesn't exist (reports have no inline edit) and ReorderReportsAction is 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-model foreach … 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_watchers pivot table mirroring the proven issue_watchers pattern, 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_watchers for every existing project's owner, diverging from the issue_watchers precedent.
  • 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: null and fold the free-text author_name into the notification message (e.g. "New report 'Login bug' by John Doe").
  • Rejected: A synthetic "Kendo Bot" system actor.
  • Why: A submitter's user_id is 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_id NOT NULL FK, derived server-side from the ingest token.
  • Rejected: Tenant-wide grouping keyed on a free-text territory string (the issue as written), or a nullable project_id hybrid.
  • 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 TokenAbilityEnum column to project_tokens and parametrize the create Action, backfilling existing rows to report:create.
  • Rejected: Leaving the scope only on the Passport token (a separate minting path with no column), or reusing the broad write scope.
  • 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 to sha256(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() with TenantSwitcher::switchTo() and a try/finally reset, isolating per-tenant failures.
  • Rejected: Copy the existing single-connection prune command shape.
  • Why: error_groups is 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_count and 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_at tracking; rely on error_groups.last_seen_at as 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-groups endpoint with a nullable-project policy and an Action that filters to the user's accessible projects, mirroring MyIssuesController.
  • 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 ErrorGroups permission 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_trace in 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-tracking route, 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_id with 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 EmptyStateShell wrapped by two presentational leaves (full per-project, lighter cross-project).
  • Rejected: One component whose optional projectId prop (or a variant string 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 loaded ref), 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 projectApiTokens flag.
  • Rejected: Gate the CTA on projectApiTokens so 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 EmptyStateShell under errorGroups/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_report many-to-many pivot with an editable report picker in IssueForm (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

  • Chose: Show.vue filters the already-loaded project reports for promotedIssueId === issue.id.
  • Rejected: Writing a link row at promote time (plus a backfill migration for historical promotes), or a promoted_report_ids resource 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: CreateReportAction strips control characters before the row is written.
  • Rejected: Stripping inside AssembleReportContextAction at 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: GenerateStoryJob carries list<int> $reportIds and assembly happens inside RunStoryGenerationAction.
  • 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.origin plus the anonymized path.
  • Rejected: The router's fullPath alone.
  • 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 → Open flips on ingest; Ignored survives 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 UPDATE clause behind a load-bearing driver guard.
  • Rejected: Keeping KD-0771's single INSERT … ON DUPLICATE KEY UPDATE and detecting new-vs-existing via ROW_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 SingleSelect with 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 IssueForm like 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_id throwing ErrorGroupAlreadyLinkedException (422).
  • Rejected: SELECT … FOR UPDATE to 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 PromoteReportsAction already 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, behind useFeatureActive('errorTracking') and an ErrorGroups:Read check.
  • 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: promote checks only ErrorGroups:Update, exactly like ReportPolicy::promoteReports, with the gap recorded in writing.
  • Rejected: Adding an Issues:Create check 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: NotifyNewErrorGroupJob on 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 in failed_jobs and the notification is simply lost.
  • Rejected: $tries = 3 to 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 useTenantFilterBar already 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: useIssueFilterBar is 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