Appearance
Broadcasting Decisions
Distilled from 19 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 (read the codebase for those).
Slim-payload response from /update-board
- Chose: Add a new
IssuePositionResourceData(5 fields) for the update-board response and partial-merge on the frontend. - Rejected: Trim
IssueResourceDataitself, or whole-replace from a slim response on the frontend. - Why: Trimming the shared resource breaks every other consumer that relies on the full shape. Whole-replace would wipe
title,description, blocking relations from the in-memory list. ADR-0009 (separate Resource per context) supports the new class. - Source: KD-0452
Action return type vs slim-shape return
- Chose: Keep
UpdateIssueBoardActionreturningCollection<int, Issue>. - Rejected: Return slim shape so the controller is thinner and the SQL can be column-narrowed.
- Why:
notifyLaneChangesdepends on full Issue models forNotifyLaneChangeAction; switching would force two reads or a bigger refactor. The actual win came from skippingEAGER_LOADqueries and shrinking the wire payload, not from narrowing the SELECT. - Source: KD-0452
Switch broadcasts from ShouldBroadcastNow to ShouldBroadcast
- Chose: Queue
PrivateAnnouncementandProjectDomainUpdateEvent; keepAgentProgressEventsynchronous. - Rejected: Keep all three synchronous.
- Why:
ShouldBroadcastNowblocks every HTTP response on a ~50–100ms call to Reverb; queueing scales better. Agent progress is real-time pipeline UX where any delay breaks the experience. - Source: KD-0426
WebSocket payload casing
- Chose: Wrap all listener callbacks in
deepCamelKeysinside the shared echo service. - Rejected: Per-consumer conversion in each listener.
- Why: Per-consumer conversion is repetitive and easy to forget. The wrap mirrors the Axios HTTP middleware pattern; existing already-camelCase events become a no-op so it's safe layer-wide.
- Source: KD-0426
WebSocket allowed-origins lockdown
- Chose: Hardcode
*.kendo.devin non-local environments; keep*in local. - Rejected: Make it env-configurable via
REVERB_ALLOWED_ORIGINS. - Why: Env var is over-engineering for a single deployment; domain changes are rare and would require code changes anyway.
reverb.phpreadsapp.envto branch. - Source: KD-0426
Reverb VM lifecycle
- Chose:
auto_stop_machines = 'off'(always running) for the dedicatedreverbprocess group. - Rejected:
'suspend'(pauses memory state, fast resume) or'stop'withmin_machines_running = 2. - Why: Suspend/resume causes a reconnect hiccup for long-lived WebSocket connections; aggressive idle-stop saves cost but adds complexity that isn't worth it at Kendo's scale.
- Source: KD-0447
Reverb migration approach
- Chose: Single big-bang PR (process group + nginx + env all at once).
- Rejected: Two-phase cutover with dual-running, or three-phase with a feature flag.
- Why: Three earlier PRs in the same area had already produced churn fatigue. Dual-running has subtle issues with two Reverb instances coordinating via Redis (potential duplicate broadcasts during transition); rollback via
git revertis simple enough. - Source: KD-0447
Reverb redundancy posture
- Chose:
min_machines_running = 1in both prod and staging; accept ~3-5s reconnect on deploys. - Rejected:
min_machines_running = 2for seamless rolling deploys. - Why: Echo auto-reconnects; broadcasts from queued jobs retry; the ~$2/mo savings buys cheaper iteration. Bumping to 2 is a one-liner later if UX impact matters.
- Source: KD-0447
Reverb bind config
- Chose: Specify bind via
--host=[::]flag in the process command, deleteREVERB_SERVER_HOSTenv var. - Rejected: Keep both the env var and the flag for redundancy.
- Why: Two configuration sources for one value is non-obvious — if someone later changes one but not the other, the winner is unclear. Self-documenting process command matches nightwatch's
--listen-on=[::]:2047pattern. - Source: KD-0447
nginx proxy URI rewrite
- Chose: Add explicit
rewrite ^/ws/(.*) /$1 break;and drop the trailing slash onproxy_pass. - Rejected: Set
REVERB_SERVER_PATHso Reverb matches the full/ws/app/KEYpath. - Why: When
proxy_passtargets a variable (for DNS resolver pattern), nginx silently disables URI-prefix substitution, which broke WebSocket upgrades after the process-group split. Explicit rewrite mirrors the existing/mattermost/pattern in the same file. - Source: KD-0447
Bulk board-update broadcast event class
- Chose: New
IssuePositionBatchEventwithbroadcastAs: 'positions'on the existing channel. - Rejected: N
ProjectDomainUpdateEvents withIssuePositionResourceDatapayloads, or single event with arrayresource. - Why: Reusing the event class would force a runtime shape discriminator on the existing
'updates'listener, which expects fullIssueResource. A drag of 20 issues = 20 echo packets vs 1 with the dedicated event. - Source: KD-0460
Where to dispatch the batched board-update broadcast
- Chose: Dispatch from
ProjectIssueController::updateBoard()after the Action returns. - Rejected: Dispatch from inside
UpdateIssueBoardActionvia injectedBroadcastFactoryor thebroadcast()helper. - Why: Deptrac forbids
Actions → Resources; buildingIssuePositionResourceData::collection(...)inside the Action is a layer violation. Controllers already depend on Resources and have access to the request'sX-Socket-IDheader for->toOthers(). (Note: this decision was later superseded — see KD-0522.) - Source: KD-0460
SQL shape for batch UPDATE
- Chose: Single raw-SQL UPDATE with CASE expressions, dispatched via
$this->db->update($sql, $bindings). - Rejected:
Builder::upsert()(multi-row upsert) or N parameterised UPDATEs in one transaction. - Why: Upsert requires every column NOT NULL on the insert branch and would silently insert partial rows on a missing id — wrong tool for pure update. N statements fail the AC1 single-statement requirement.
- Source: KD-0460
Listener behavior when slim payload targets unknown issue
- Chose: Skip silently (
applyIssuePositionearly-returns). - Rejected: Refetch the missing issue via
GET /issues/{id}, or look up in the globalissueStorecache. - Why: Refetch reintroduces the per-event HTTP that KD-0461 eliminated and conflicts with the KD-0465 arch rule banning listener-side refetch. Cache lookup usually misses anyway since pages keep their own local refs.
- Source: KD-0460
Direct-apply listeners (delete syncIssueFromServer)
- Chose: Delete
syncIssueFromServerentirely; listener applies the broadcast payload directly. - Rejected: Keep as a fallback path on payload-parse failure.
- Why: Fallback reintroduces the conditional refetch the architecture rule (KD-0465) bans. If the broadcast payload is ever malformed, failing loudly is the right outcome.
- Source: KD-0461
Drag buffer shape
- Chose:
Map<id, IssueResource>keyed by id (last-write-wins per id). - Rejected: Ordered
IssueResource[]queue replaying every payload, or drop buffer entirely. - Why: Last-write-wins matches today's semantics (one apply per issue regardless of how many events arrived) and the final state is identical to replaying every payload. Dropping the buffer silently loses broadcasts during drag.
- Source: KD-0461
Issue create/delete broadcast event shape
- Chose: Dedicated
ProjectDomainDeleteEventclass withbroadcastAs: 'deleted'. - Rejected: Reuse
ProjectDomainUpdateEventwith nullableresource+ id flag, or smuggle a tombstone payload onto the'updates'event. - Why: Bimodal events break the "payload is always a full resource" contract and force listener-side discriminators. The dedicated class mirrors the established class-per-payload-shape pattern.
- Source: KD-0462
Tombstone DTO for delete broadcasts
- Chose: Domain-specific
Deleted{Domain}ResourceData extends ResourceData<Model>carrying only{id}. - Rejected: Cross-domain generic
DeletedResourceData implements JsonSerializable, or plainint $iddirectly on the event. - Why: Generic DTOs can't extend
ResourceData(no model), break the convention, and offer no clean seam for domain extensions like soft-delete vs hard-delete. KD-0465 also bans barepublic int $idon broadcast events. - Source: KD-0462, KD-0464, KD-0465
Created broadcast: partial relations vs touching CreateIssueAction
- Chose: Accept partial relation data on
createdbroadcasts (emptyblocked_by_ids, etc.). - Rejected: Restructure the event to lazy-build the DTO, or move
save()after relation syncs. - Why: The issue's "Niet" scope explicitly forbade touching
CreateIssueAction. Board/Backlog/Overview cards don't render those fields anyway, and detail pages callretrieveAll()on mount, so the gap is invisible. - Source: KD-0462
Drag buffer collision: update + delete same id
- Chose: Parallel
pendingIssueDeletes: Set<number>with delete-wins on flush. - Rejected: Encode delete as a sentinel in the existing Map (union value type), or apply deletes immediately ignoring drag.
- Why: Union value types make every consumer discriminate; immediate delete causes rows to disappear mid-drag, which the buffer was specifically built to prevent.
- Source: KD-0462
Bridge from Actions to broadcasting (post-Observer removal)
- Chose: Per-domain
App\Broadcasting\{Domain}Broadcasterclasses injected into Actions. - Rejected: Inject
Illuminate\Contracts\Broadcasting\Factorydirectly into Actions and build ResourceData inline; pass the Model through the Event and construct the Resource inside the Event; relaxActions → Resourcesglobally. - Why: Inline builds would import
App\Http\Resources\…into Actions, violating Deptrac'sActions → Resourcesban. Events can depend on Resources but not Models, so Model-in-Event fails differently. Relaxing the global rule erodes "Resources are HTTP-layer concerns". - Source: KD-0470
Broadcast call placement vs transaction
- Chose: Call broadcaster inside the transaction closure, last statement before return.
- Rejected: Dispatch outside, after the transaction returns.
- Why: Both events implement
ShouldDispatchAfterCommitso wire-level dispatch always waits for commit anyway. Outside-the-transaction needs$model->refresh()and puts the broadcast call far from the mutation it describes. - Source: KD-0470
Channel reuse for new domains
- Chose: Bind
IssueChannel::classto the comment / sprint / epic / lane channel names inroutes/channels.php. - Rejected: New
CommentChannel,SprintChannel, etc. classes per domain. - Why: All these domains share the same authorization logic ("user has access to project"); separate classes would be pure duplication. If sub-entity ACLs ever land, rename
IssueChannel → ProjectAccessChannelthen. - Source: KD-0463, KD-0464
Comment store live updates
- Chose: Wire
AdapterStoreBroadcast<CommentBase>insidemakeCommentStoreForIssue, using the library's built-insubscribe({onUpdate, onDelete})contract. - Rejected: Convert
CommentSectionto a localref<CommentResource[]>and call HTTP directly, or fork the lib to expose publicapplyUpdate/applyRemoval. - Why: Local refs would kill adapter ergonomics (
comment.update(),.delete(),makeAttachmentStore()) — a much bigger refactor. The lib's broadcast contract already routes events intosetById/deleteById. - Source: KD-0463
Re-introducing observers for new domains
- Chose: Per-domain Broadcasters (Sprint, Epic, Lane) injected into mutating Actions — NOT observers.
- Rejected: Add
SprintObserver,EpicObserver,LaneObserverper the literal AC text. - Why: KD-0470 had just removed every observer because
save()-triggered broadcasts fire before relation syncs (causing partial payloads). Reviving them for three domains would re-introduce the architectural regression. - Source: KD-0464
Cascade broadcasts on delete
- Chose: Don't broadcast per-affected-issue when a sprint/epic/lane is deleted; only the entity-level
deletedevent fires. - Rejected: Load each affected issue, build resources, dispatch N events.
- Why: Bulk
UPDATEstatements (e.g.,sprint_id = null) bypass per-model save events; broadcasting per affected issue would be N+1. KD-0470 AC #4 carved out mass-updates as broadcast-loos. - Source: KD-0464
Frontend cleanup ownership for new domain channels
- Chose: Only "owner" pages (Backlog, Board, EpicOverview, ProjectLaneSettings) call
leaveProjectChannelon unmount; secondary consumers let subscriptions linger. - Rejected: Every page that creates a store calls
leaveProjectChannelon unmount. - Why:
echo.leave(channel)tears down ALL listeners on that channel. If a non-owner page (e.g., IssueSidebar) cleaned up while an owner page was still mounted, it would break the owner's live updates. - Source: KD-0464
Architecture-rule shape for broadcast payloads
- Chose: Forbid the "id-only" event shape — no
ShouldBroadcastevent may have exactly oneintpublic property namedid. - Rejected: Literal "≥1 JsonSerializable property" rule per AC, or "≥2 public properties" rule.
- Why: Literal rule fails
AgentProgressEventandPrivateAnnouncementwhich carry rich primitives but no JsonSerializable. The intent is "no event forces a refetch", which the id-only check captures cleanly without exemptions. - Source: KD-0465
Frontend arch test scan depth
- Chose: Brace-balancing extraction of Echo callback bodies, scan only those bodies for forbidden HTTP calls.
- Rejected: File-level scan with allow-list, or require named handlers (
projectEchoService(..., handleIssueUpdate)). - Why: File-level scan false-positives (Board imports
projectEchoServiceAND callsgetRequestfor unrelated reasons). Named handlers require a style refactor outside the test's scope. Brace balancing is ~40 lines and zero false positives. - Source: KD-0465
Branch-link broadcast strategy
- Chose: Each branch-link mutating Action also calls
IssueBroadcaster::updated($link->issue)after its own broadcast. - Rejected: Have Board/Backlog/Overview subscribe to a separate
branch-linkschannel. - Why:
branch_link_statuseslives onIssueResourceDataas a flat list; reconstructing it from a single branch-link event would require shadow state per issue. Two cheap broadcasts per mutation keeps the frontend domain boundaries intact. - Source: KD-0467
Branch-link tombstone shape
- Chose:
DeletedIssueBranchLinkResourceDatacarrying{id, issue_id}. - Rejected:
{id}only to matchDeletedCommentResourceData/DeletedIssueResourceData. - Why: The richer tombstone lets the sidebar listener short-circuit on
deleted.issue_id !== currentIssue.idwithout tracking "do I have this id in state?". Self-documents the relationship. - Source: KD-0467
SyncBranchLinkStatusAction refactor
- Chose: Convert the bulk
->update()into a load-save loop so the broadcaster fires per model. - Rejected: Keep the bulk update and follow it with
whereIn->get->each(broadcast). - Why: The whereIn-then-broadcast pattern needs two extra SELECTs and is more fragile (easy to forget the post-update reload). The N is almost always 1, occasionally 2 — the cost is negligible.
- Source: KD-0467
Bulk-delete shape after broadcast extraction
- Chose: Single
whereIn->delete()sweep. - Rejected: Keep
->each(->delete())for safety against future Eloquent observers. - Why: No observer exists today; cascade-child cleanup already runs explicit
whereIndeletes above the loop. Future code adding an Issue observer would need to audit this Action anyway. The DECISIONS.md entry is the audit trail (no source comments). - Source: KD-0474
Bulk broadcast naming
- Chose:
batch-updates/batch-deletesevent names;ProjectDomain{Update,Delete}BatchEventclass names. - Rejected:
bulk-updates/bulk-deletes, orupdates-bulk/deleted-bulk. - Why: "Batch" already exists in
IssuePositionBatchEvent, keeping class-name and event-name conventions consistent. Broadcaster method names staybulkUpdated/bulkDeletedbecause they reflect caller intent (the wire format is whatbatch-reflects). - Source: KD-0474
Bulk broadcaster contract
- Chose: Caller must preload
EAGER_LOADandattachments_countbefore callingbulkUpdated. - Rejected: Have the broadcaster eagerly hydrate the whole collection internally.
- Why: Hidden hydration creates an N+1 trap that the caller can't see; existing single-issue cascade callers already preload with
->with(...)->withCount(...), so the contract is already established. - Source: KD-0474
Reorder Action site
- Chose: Extend the existing
UpdateIssueBoardActionwithIssueBroadcaster::positionsUpdated. - Rejected: Create a new
ReorderIssuesActionthat followsReorderEpicsActionshape. - Why:
UpdateIssueBoardActionalready owns the DB write, audit logging, and lane-change notifications; a separate Action would either duplicate the SQL builder or force awkward delegation. The broadcast-from-Action pattern matchesUpdateIssueActionalready. - Source: KD-0522
Action-side broadcast vs Controller-side
- Chose: Move broadcast dispatch from
ProjectIssueController::updateBoard()intoIssueBroadcaster::positionsUpdated, called from the Action. - Rejected: Keep it in the Controller (per KD-0460) and have the Action build the ResourceData.
- Why: Deptrac forbids
Actions → Resources; the broadcaster lives inBroadcastingwhich IS allowed to depend on Resources. Action passes rawCollection<int, Issue>; broadcaster wraps it. Cleaner Action signature, consistent withupdated(). - Source: KD-0522
Toast/announcement gate scope
- Chose: Gate
PrivateAnnouncementon changes to 6 scalar fields (title, description, assignee_id, lane_id, sprint_id, epic_id). - Rejected: Include
blocked_by_ids/blocks_idsin the gate. - Why:
$oldValuesfromIssueAuditLogger::snapshotIssuedoesn't include pivot tables; capturing them would require an extra relation-fetch before save. Documented gap — revisit if owners complain about missing toasts on blocking-only changes. - Source: KD-0522
Payload size guard placement
- Chose: Shared trait
GuardsBroadcastPayloadSizeapplied to every broadcaster inapp/Broadcasting/. - Rejected: Inline in
IssueBroadcasteronly and replicate in follow-ups, or trait applied only toIssueBroadcaster. - Why: Defense-in-depth across all six (seven, with
NotificationBroadcaster) broadcasters in one PR; otherwise Comment/IssueBranchLink/Notification keep zero protection until they break separately. - Source: KD-0548
Behavior on payload overflow
- Chose: Predicate-style guard: log warning + drop the broadcast.
- Rejected: Throw in non-prod, log+drop in prod (originally chosen, reversed during implementation); or always throw.
- Why: Always-throw lands back in
failed_jobswhich is what the bug was. Env-aware throwing doubles the trait's surface (Application injection + env branching) for a regression case the unit tests already cover. Nightwatch surfaces warning logs in dev and prod alike. - Source: KD-0548
Slim broadcast resource for issues
- Chose: Reuse + rename
TabIssueResourceData→IssueListResourceData; dropdescriptionentirely. - Rejected: Keep description and rely on the size guard, truncate to 4 KB, or build a separate
IssueBroadcastResourceData. - Why: Tab UI doesn't display description; the only consumer was
matchesSearchwhich silently matched hidden body text (confusing UX). Truncation is a cliff-edge that doesn't fix outliers. Dropping makes the broadcast future-proof against arbitrarily long briefs. - Source: KD-0548
Notification broadcast payload shape
- Chose: Self-contained event carrying full
NotificationResourceData; listener applies viaapplyResourceUpdate. - Rejected: Doorbell (content-free event) + frontend
retrieveAll(), or hybrid withunreadCountonly. - Why: Doorbell+refetch fans out HTTP per broadcast across every open tab and conflicts with the Epic 26 principle "broadcasts carry enough state to apply locally — otherwise WebSocket is just an expensive doorbell".
- Source: KD-0405
User-scoped broadcast event shape
- Chose: New reusable
UserDomainUpdateEvent+UserDomainDeleteEventmirroring the project-scoped pair. - Rejected: Widen
PrivateAnnouncementwith an optional payload, or one-offNotificationCreatedEventsiblings. - Why: Widening conflates two distinct semantics (notice vs resource update) and risks breaking the existing
alertslistener. Per-notification classes don't scale to future user-scoped domains (invites, mentions). - Source: KD-0405
Bulk notification broadcast
- Chose: One batched event per bulk operation (revised after KD-0474 landed the project-side primitive).
- Rejected: N per-notification broadcasts.
- Why: N WebSocket frames + N frontend re-renders for what is logically one user action ("mark all read"). Once KD-0474 established the
ProjectDomain*BatchEventshape, mirroring it on the user channel was a small precedented addition rather than a bespoke construction. - Source: KD-0405
Removing redundant retrieveAll calls after WS hookup
- Chose: Delete the inbox
Overview.vueretrieveAll on mount; Navbar's initial fetch + WS feed covers everything. - Rejected: Keep as belt-and-suspenders against silent WS drift, or add
refetchOnReconnecthook. - Why: The silent-drift risk exists everywhere WS-maintained state is used today (Board, Backlog), and the codebase already accepts that risk. Reconnect-refetch is a cross-cutting Epic 26 concern, not a notification one.
- Source: KD-0405
Broadcaster API surface: updated/deleted only, not one method per server verb
- Chose: Keep per-domain broadcasters at two methods (
updated,deleted) regardless of how many server-side verbs trigger them. - Rejected: Expand to one method per user-facing verb (e.g.
created/updated/promoted/dismissed/deletedfor reports). - Why: All non-delete wire events are the same (
updates); the listener has no way to discriminate, so per-verb methods are dead surface area. Method name should describe what the listener does, not what happened server-side. - Source: KD-0526
Single updates event for create / state-change mutations
- Chose: Dispatch one
updatesevent with the full resource for create / promote / dismiss / reorder — the adapter-store'sonUpdateupserts by id. - Rejected: A dedicated
created(orpromoted/dismissed) event so listeners can react to lifecycle transitions explicitly. - Why: Upsert semantics already do the right thing for both new and existing ids; filter chips recompute from the payload's own timestamps. Extra event names would force every listener to add a discriminator for zero behavioural gain.
- Source: KD-0526
Single-domain store subscription: wire at store-config, not at the page
- Chose: For project-scoped, single-domain, single-list state (reports, epics, lanes, comments), pass a
makeXBroadcastForProject(projectId)factory into the adapter-store'sbroadcastconfig; pages only callleaveProjectChannelon unmount. - Rejected: Subscribe at the page via
projectEchoService(...)and apply payloads with the KD-0461/0462 helpers (the cross-store coordinator pattern used by Board/Backlog). - Why: The adapter-store's
onUpdate/onDeletecontract already routes broadcasts intosetById/deleteById; the page-level helpers exist for coordinators that span multiple stores (issues + sprints + lanes) and apply custom logic like position recompute, which single-domain pages don't need. - Source: KD-0526
Per-domain broadcaster class vs shared abstract base
- Chose: Keep a thin per-domain broadcaster class (
TimeEntryBroadcaster, etc.) even at 8+ near-identical classes. - Rejected: Extract an
AbstractProjectDomainBroadcaster<TModel, TResource, TDeleted>that every broadcaster extends. - Why: Each broadcaster varies just enough (which relation to load, how to compute
project_id, optional batch methods) that the base would become a sprawling generic and the apparent saving collapses on inspection. - Source: KD-0527
Overview/log pages do NOT get live broadcasts; only the sidebar you act on does
- Chose: Drop live updates from the
/time-trackingoverview entirely; keep them only on the issue sidebar. - Rejected: Subscribe the overview to every accessible project's channel (mount-time snapshot) so logged time updates live everywhere it renders.
- Why: An overview/log page that mutates while you read it is a UX hazard (it shifts under you mid-inspection) and subscribing to all accessible projects is an inelegant cost class — live updates fit surfaces where you're acting on the row, not inspecting a table.
- Source: KD-0527
Synchronous image transcode over an async Job pipeline
- Chose: Transcode attachment images inline in
CreateAttachmentActionwith one broadcast per upload. - Rejected: Queue a
ProcessAttachmentImageJobthat re-broadcasts after thumbnailing, backed by a stuck-job recovery command + hourly schedule. - Why: At the 20MB cap transcode measured ~150-300ms typical (~1.5s worst case) — below the threshold where async earns its 4 extra classes, double broadcasts, and a "stuck" failure mode; the async branch is archived at a tag for revival if upload throughput ever changes.
- Source: KD-0528
Parent broadcast ping to refresh a child count
- Chose: Have
Create/DeleteAttachmentActionalso fireIssueBroadcaster/ReportBroadcaster::updatedsoattachments_counton parent cards stays fresh. - Rejected: Have the frontend re-derive the count by walking the attachment broadcast payload and incrementing the local store.
- Why: Re-deriving duplicates count logic across stores and is brittle; a second cheap broadcast keeps the resource's own
loadCountas the single source of truth (and only Issue/Report carry the count, so Epic/Comment need no ping). - Source: KD-0528
Skip broadcasts for state other viewers can't act on
- Chose: Early-return from attachment broadcasts when
attachable_type === null(orphan paste uploads), guarded byif ($attachable instanceof Model). - Rejected: Broadcast orphans uniformly on the project channel like every other attachment row.
- Why: Orphans are per-user state whose parent doesn't exist yet, so other project viewers would receive events for things they can't see — pointless channel noise, and the frontend never needs a null-attachable branch.
- Source: KD-0528
Wire-format polymorphic type as DomainEnum value, not FQCN
- Chose: Serialize
attachable_typeon the broadcast payload as theDomainEnumvalue ('issues','comments', …) via an FQCN-to-enum map in the resource. - Rejected: Emit the raw FQCN (
App\Models\Issue) or a PascalCase basename ('Issue'). - Why: FQCN leaks backend namespaces so any model rename breaks every listener, and PascalCase introduces a third casing for one concept — the enum value already matches the channel-route naming on both ends.
- Source: KD-0528
Extend the shared resource with broadcast-needed fields vs a broadcast-only resource
- Chose: Add
attachable_type/attachable_idto the existingAttachmentResourceData. - Rejected: Introduce a separate
AttachmentBroadcastResourceDatacarrying the same fields plus attachable info. - Why: Two serializers for one model is duplication arch tests don't ask for; one resource means CLI/MCP consumers also learn the parent, at the cost of re-snapshotting a few feature tests.
- Source: KD-0528
Decode-validate image uploads in the FormRequest, with getimagesize not Intervention
- Chose: A
ValidImageUploadrule onStoreAttachmentRequestthat runsgetimagesize()only for image-mime uploads. - Rejected: A
getimagesizeguard insideCreateAttachmentAction, or running the fullIntervention::read()pipeline in the FormRequest. - Why: Validation belongs in the FormRequest per the layering convention (native 422, no new exception class), and
getimagesizeis a cheap header decode versus spinning up the whole Intervention pipeline just to confirm valid image bytes. - Source: KD-0528
Inline a duplicated const vs widen a deptrac layer for a single source of truth
- Chose: Declare
private const IMAGE_MIME_TYPESinline at each of the three call sites (two Actions + the Rule). - Rejected: Keep a shared
AttachmentMimeTypeshelper and widen theRulesdeptrac layer to permitRules → Helpers. - Why: deptrac forbids
FormRequests → Helpersso the helper couldn't serve the one site that needed it most anyway; a small, stable, grep-able list is cheaper to triplicate than to keep alive behind cross-layer dependency surgery. - Source: KD-0528
Resolve access in the Action and pass values in, because Broadcasting can't depend on Actions
- Chose: Resolve AI-key access + report-watcher ids in
UpdateProjectActionand pass them toProjectBroadcaster::updated(Project, bool, array), which callsfromWithAccess. - Rejected: Inject the resolver Actions into the broadcaster, or have the Action build the finished
ProjectResourceDataand pass the resource. - Why: deptrac forbids
Broadcasting → Actions(so injecting resolvers is dead on arrival) and passing a pre-built resource moves EAGER_LOAD ownership off the broadcaster — keeping the broadcaster as "the thing that knows how to serialise this model for broadcast" wins, even at a rare double-resolution. - Source: KD-0529
Applying per-project broadcasts into a single GLOBAL adapter store
- Chose: Add an
applyBroadcastUpdateadapter method (which closes over the privatesetById) plus asubscribeToProjectMetadata(projectId)helper called from the layout. - Rejected: Refactor the global store into
makeProjectStoreForProject(projectId), capture the broadcast hook into module-level mutable state, or exposesetByIdpublicly. - Why: The project store is globally listed (sidebar + overview) so a per-project refactor is the wrong shape and a huge blast radius, while the adapter-method route reuses an exact existing pattern, keeps
setByIdprivate, and gets teardown for free. - Source: KD-0529
Broadcast after the transaction + unsetRelation, not inside the closure
- Chose: Resolve access and call
broadcaster->updated()afterUpdateProjectAction's transaction returns and after the existingunsetRelationcalls. - Rejected: Place the broadcast inside the transaction closure like the Lane sibling does.
- Why:
teams()->sync()/directMembers()->sync()leave the in-memory relations stale so an in-closurefromWithAccesswould serialise stale ids and re-run the resolve on every one of the 3 retries —unsetRelationafter commit forces a fresh reload and a single dispatch. - Source: KD-0529
Clearing label chips by store eviction, not by re-broadcasting affected issues
- Chose: A label delete fires only the single label
deletedevent; chips disappear because issue payloads carry normalizedlabelIdsand the frontend resolves chips through the labels store. - Rejected: Re-broadcast every detached issue via
IssueBroadcasterso its chips refresh. - Why: Store eviction (
onDelete(id)) already makes every chip referencing that label vanish everywhere, so fanning out to potentially hundreds of issue events on a popular-label delete is redundant — the normalized-labelIdsdesign from KD-0806 makes this cheap. - Source: KD-0819
Scoping the FK-staleness audit: trace the denormalisation surface, not every broadcasting Action
- Chose: Find which broadcast resources denormalize a belongsTo row (title/color/name), then audit only the Actions feeding those resources.
- Rejected: Audit all ~50 broadcasting Actions against the generic "mutates an FK then broadcasts" framing.
- Why: Resources that emit FKs as raw scalars are structurally immune to rename-staleness, so the only real hazard lives in the handful that denormalize belongsTo columns — auditing the rest is unbounded false leads with no signal.
- Source: KD-0854
Prove each broadcast path safe vs defensively refresh
- Chose: Prove each of the 7
myIssuesChangedcallers safe with file:line evidence and change zero production code. - Rejected: Add a defensive
$model->refresh()before every broadcast for uniformity. - Why: Defensive refreshes add redundant queries where the Action is already safe and misrepresent the finding; honesty about incidental safety is paid for by an enforcement net that prevents silent regression rather than by blanket refetching.
- Source: KD-0854
Enforce FK-freshness via an omission-detecting registry arch test, not a centralized broadcaster guard
- Chose: A source-scan arch test that diffs actual
myIssuesChangedcallers against a registry classifying each asrefreshorsafe:<reason>, failing CI on any unregistered caller. - Rejected: Centralize the guard in
IssueBroadcaster::myIssuesChangedby refreshing denormalized relations before serializing. - Why: A centralized refresh re-introduces ~3N relation queries on the bulk Actions that eager-load
project/lane/epicspecifically so per-issueloadMissingis a no-op, andloadMissingcan't tell stale-loaded from fresh-loaded so the guard can't be made conditional — the registry catches new unguarded callers without fighting that optimization. - Source: KD-0854
Conditional cache-hash bumps gated on the FK actually changing
- Chose: Issue-side Actions bump the epics cache hash only when an epic is actually involved (e.g.
$oldEpicId !== $data->epicId); epic-side Actions bump unconditionally. - Rejected: Bump the epics hash unconditionally on every issue create/delete/move.
- Why: Unconditional bumps invalidate the epic cache for writes that don't change
EpicResourceDatabytes, defeating the suppression the cache key exists to provide. - Source: KD-0909
Cache-hash stamp middleware on the route-group root, not per-route
- Chose: Mount
StampCacheHashesMiddlewareon the whole epics route group, so nested attachment routes also stamp subscribed hashes. - Rejected: Mount it per-route on only the six epic CRUD routes for a minimal surface.
- Why: Stamping is read-only, subscribe-driven, and policy-gated so it carries no bump obligation — stamping harmless extra routes (attachments don't change
EpicResourceDatabytes) costs nothing and avoids six mount points versus one. - Source: KD-0909
Ship the stamp/bump cache protocol as a single end-to-end PR
- Chose: Ship migration + backend bumps + stamp middleware + frontend subscription conversion in one PR.
- Rejected: Backend-first, frontend-second across two PRs.
- Why: The protocol is only correct end-to-end — stamping without frontend subscription is inert dead bytes, and frontend conversion without stamping refetches forever — so a split leaves one PR in a broken state with no risk reduction.
- Source: KD-0909