Skip to content

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 IssueResourceData itself, 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 UpdateIssueBoardAction returning Collection<int, Issue>.
  • Rejected: Return slim shape so the controller is thinner and the SQL can be column-narrowed.
  • Why: notifyLaneChanges depends on full Issue models for NotifyLaneChangeAction; switching would force two reads or a bigger refactor. The actual win came from skipping EAGER_LOAD queries and shrinking the wire payload, not from narrowing the SELECT.
  • Source: KD-0452

Switch broadcasts from ShouldBroadcastNow to ShouldBroadcast

  • Chose: Queue PrivateAnnouncement and ProjectDomainUpdateEvent; keep AgentProgressEvent synchronous.
  • Rejected: Keep all three synchronous.
  • Why: ShouldBroadcastNow blocks 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 deepCamelKeys inside 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.dev in 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.php reads app.env to branch.
  • Source: KD-0426

Reverb VM lifecycle

  • Chose: auto_stop_machines = 'off' (always running) for the dedicated reverb process group.
  • Rejected: 'suspend' (pauses memory state, fast resume) or 'stop' with min_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 revert is simple enough.
  • Source: KD-0447

Reverb redundancy posture

  • Chose: min_machines_running = 1 in both prod and staging; accept ~3-5s reconnect on deploys.
  • Rejected: min_machines_running = 2 for 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, delete REVERB_SERVER_HOST env 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=[::]:2047 pattern.
  • Source: KD-0447

nginx proxy URI rewrite

  • Chose: Add explicit rewrite ^/ws/(.*) /$1 break; and drop the trailing slash on proxy_pass.
  • Rejected: Set REVERB_SERVER_PATH so Reverb matches the full /ws/app/KEY path.
  • Why: When proxy_pass targets 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 IssuePositionBatchEvent with broadcastAs: 'positions' on the existing channel.
  • Rejected: N ProjectDomainUpdateEvents with IssuePositionResourceData payloads, or single event with array resource.
  • Why: Reusing the event class would force a runtime shape discriminator on the existing 'updates' listener, which expects full IssueResource. 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 UpdateIssueBoardAction via injected BroadcastFactory or the broadcast() helper.
  • Why: Deptrac forbids Actions → Resources; building IssuePositionResourceData::collection(...) inside the Action is a layer violation. Controllers already depend on Resources and have access to the request's X-Socket-ID header 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 (applyIssuePosition early-returns).
  • Rejected: Refetch the missing issue via GET /issues/{id}, or look up in the global issueStore cache.
  • 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 syncIssueFromServer entirely; 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 ProjectDomainDeleteEvent class with broadcastAs: 'deleted'.
  • Rejected: Reuse ProjectDomainUpdateEvent with nullable resource + 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 plain int $id directly 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 bare public int $id on broadcast events.
  • Source: KD-0462, KD-0464, KD-0465

Created broadcast: partial relations vs touching CreateIssueAction

  • Chose: Accept partial relation data on created broadcasts (empty blocked_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 call retrieveAll() 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}Broadcaster classes injected into Actions.
  • Rejected: Inject Illuminate\Contracts\Broadcasting\Factory directly into Actions and build ResourceData inline; pass the Model through the Event and construct the Resource inside the Event; relax Actions → Resources globally.
  • Why: Inline builds would import App\Http\Resources\… into Actions, violating Deptrac's Actions → Resources ban. 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 ShouldDispatchAfterCommit so 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::class to the comment / sprint / epic / lane channel names in routes/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 → ProjectAccessChannel then.
  • Source: KD-0463, KD-0464

Comment store live updates

  • Chose: Wire AdapterStoreBroadcast<CommentBase> inside makeCommentStoreForIssue, using the library's built-in subscribe({onUpdate, onDelete}) contract.
  • Rejected: Convert CommentSection to a local ref<CommentResource[]> and call HTTP directly, or fork the lib to expose public applyUpdate/applyRemoval.
  • Why: Local refs would kill adapter ergonomics (comment.update(), .delete(), makeAttachmentStore()) — a much bigger refactor. The lib's broadcast contract already routes events into setById/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, LaneObserver per 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 deleted event fires.
  • Rejected: Load each affected issue, build resources, dispatch N events.
  • Why: Bulk UPDATE statements (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 leaveProjectChannel on unmount; secondary consumers let subscriptions linger.
  • Rejected: Every page that creates a store calls leaveProjectChannel on 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 ShouldBroadcast event may have exactly one int public property named id.
  • Rejected: Literal "≥1 JsonSerializable property" rule per AC, or "≥2 public properties" rule.
  • Why: Literal rule fails AgentProgressEvent and PrivateAnnouncement which 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 projectEchoService AND calls getRequest for 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

  • 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-links channel.
  • Why: branch_link_statuses lives on IssueResourceData as 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

  • Chose: DeletedIssueBranchLinkResourceData carrying {id, issue_id}.
  • Rejected: {id} only to match DeletedCommentResourceData/DeletedIssueResourceData.
  • Why: The richer tombstone lets the sidebar listener short-circuit on deleted.issue_id !== currentIssue.id without 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 whereIn deletes 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-deletes event names; ProjectDomain{Update,Delete}BatchEvent class names.
  • Rejected: bulk-updates / bulk-deletes, or updates-bulk / deleted-bulk.
  • Why: "Batch" already exists in IssuePositionBatchEvent, keeping class-name and event-name conventions consistent. Broadcaster method names stay bulkUpdated/bulkDeleted because they reflect caller intent (the wire format is what batch- reflects).
  • Source: KD-0474

Bulk broadcaster contract

  • Chose: Caller must preload EAGER_LOAD and attachments_count before calling bulkUpdated.
  • 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 UpdateIssueBoardAction with IssueBroadcaster::positionsUpdated.
  • Rejected: Create a new ReorderIssuesAction that follows ReorderEpicsAction shape.
  • Why: UpdateIssueBoardAction already 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 matches UpdateIssueAction already.
  • Source: KD-0522

Action-side broadcast vs Controller-side

  • Chose: Move broadcast dispatch from ProjectIssueController::updateBoard() into IssueBroadcaster::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 in Broadcasting which IS allowed to depend on Resources. Action passes raw Collection<int, Issue>; broadcaster wraps it. Cleaner Action signature, consistent with updated().
  • Source: KD-0522

Toast/announcement gate scope

  • Chose: Gate PrivateAnnouncement on changes to 6 scalar fields (title, description, assignee_id, lane_id, sprint_id, epic_id).
  • Rejected: Include blocked_by_ids / blocks_ids in the gate.
  • Why: $oldValues from IssueAuditLogger::snapshotIssue doesn'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 GuardsBroadcastPayloadSize applied to every broadcaster in app/Broadcasting/.
  • Rejected: Inline in IssueBroadcaster only and replicate in follow-ups, or trait applied only to IssueBroadcaster.
  • 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_jobs which 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 TabIssueResourceDataIssueListResourceData; drop description entirely.
  • 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 matchesSearch which 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 via applyResourceUpdate.
  • Rejected: Doorbell (content-free event) + frontend retrieveAll(), or hybrid with unreadCount only.
  • 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 + UserDomainDeleteEvent mirroring the project-scoped pair.
  • Rejected: Widen PrivateAnnouncement with an optional payload, or one-off NotificationCreatedEvent siblings.
  • Why: Widening conflates two distinct semantics (notice vs resource update) and risks breaking the existing alerts listener. 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*BatchEvent shape, 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.vue retrieveAll on mount; Navbar's initial fetch + WS feed covers everything.
  • Rejected: Keep as belt-and-suspenders against silent WS drift, or add refetchOnReconnect hook.
  • 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/deleted for 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 updates event with the full resource for create / promote / dismiss / reorder — the adapter-store's onUpdate upserts by id.
  • Rejected: A dedicated created (or promoted/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's broadcast config; pages only call leaveProjectChannel on 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/onDelete contract already routes broadcasts into setById/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-tracking overview 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 CreateAttachmentAction with one broadcast per upload.
  • Rejected: Queue a ProcessAttachmentImageJob that 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/DeleteAttachmentAction also fire IssueBroadcaster/ReportBroadcaster::updated so attachments_count on 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 loadCount as 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 by if ($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_type on the broadcast payload as the DomainEnum value ('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_id to the existing AttachmentResourceData.
  • Rejected: Introduce a separate AttachmentBroadcastResourceData carrying 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 ValidImageUpload rule on StoreAttachmentRequest that runs getimagesize() only for image-mime uploads.
  • Rejected: A getimagesize guard inside CreateAttachmentAction, or running the full Intervention::read() pipeline in the FormRequest.
  • Why: Validation belongs in the FormRequest per the layering convention (native 422, no new exception class), and getimagesize is 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_TYPES inline at each of the three call sites (two Actions + the Rule).
  • Rejected: Keep a shared AttachmentMimeTypes helper and widen the Rules deptrac layer to permit Rules → Helpers.
  • Why: deptrac forbids FormRequests → Helpers so 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 UpdateProjectAction and pass them to ProjectBroadcaster::updated(Project, bool, array), which calls fromWithAccess.
  • Rejected: Inject the resolver Actions into the broadcaster, or have the Action build the finished ProjectResourceData and 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 applyBroadcastUpdate adapter method (which closes over the private setById) plus a subscribeToProjectMetadata(projectId) helper called from the layout.
  • Rejected: Refactor the global store into makeProjectStoreForProject(projectId), capture the broadcast hook into module-level mutable state, or expose setById publicly.
  • 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 setById private, and gets teardown for free.
  • Source: KD-0529

Broadcast after the transaction + unsetRelation, not inside the closure

  • Chose: Resolve access and call broadcaster->updated() after UpdateProjectAction's transaction returns and after the existing unsetRelation calls.
  • 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-closure fromWithAccess would serialise stale ids and re-run the resolve on every one of the 3 retries — unsetRelation after 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 deleted event; chips disappear because issue payloads carry normalized labelIds and the frontend resolves chips through the labels store.
  • Rejected: Re-broadcast every detached issue via IssueBroadcaster so 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-labelIds design 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 myIssuesChanged callers 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 myIssuesChanged callers against a registry classifying each as refresh or safe:<reason>, failing CI on any unregistered caller.
  • Rejected: Centralize the guard in IssueBroadcaster::myIssuesChanged by refreshing denormalized relations before serializing.
  • Why: A centralized refresh re-introduces ~3N relation queries on the bulk Actions that eager-load project/lane/epic specifically so per-issue loadMissing is a no-op, and loadMissing can'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 EpicResourceData bytes, 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 StampCacheHashesMiddleware on 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 EpicResourceData bytes) 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