Skip to content

Reports Decisions

Distilled from 8 DECISIONS.md files. 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