Appearance
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_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