Skip to content

Broadcasting Decisions ​

Each entry is a real fork in the road — what we chose, what we passed on, and why. Implementation details that aren't a tradeoff are NOT here (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 TabIssueResourceData → IssueListResourceData; 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

Sentinel wired only to the payload shapes that can realistically overflow the guard ​

  • Chose: Emit the refresh-required sentinel on guard-false only from broadcast paths carrying free text or relation collections (issue updated / bulkUpdated, and the sibling domains' updated); scalar-bounded paths — deletes, position updates, My Issues — drop silently.
  • Rejected: Wiring a sentinel onto every guard-false branch, to satisfy "no broadcast is ever silently dropped" literally.
  • Why: A payload of fixed scalars plus short single-line titles is structurally incapable of approaching the 10 KB cap, so a sentinel there is dead defensive code — and a dropped scalar broadcast self-heals on the next updated() for the same entity or on navigate-away. The one-line opt-in stays available if any of those payload shapes ever grows.
  • Source: KD-0982

Stale-data recovery: full page reload over per-page refresh callbacks ​

  • Chose: window.location.reload() as the stale-data toast's "Refresh" action.
  • Rejected: Registering per-page refresh callbacks so each view re-fetches only its own data.
  • Why: Board holds its issue list in a component-local ref with no external write path, so nudging the cached stores would still leave the board stale; a reload is the only thing that guarantees fresh data across board, backlog, epics, and issue detail without coupling the sentinel helper to every consuming page. The user explicitly asked for a refresh, so a brief loading state is an acceptable price.
  • Source: KD-0982

One generic stale-data message over per-domain messages ​

  • Chose: A single "Some data may be out of date" toast regardless of which domain fired the sentinel.
  • Rejected: Per-domain wording ("Lane data may be out of date", etc.).
  • Why: All four sentinels resolve through the same action — reload everything — so per-domain wording buys extra dedup state without changing anything the user does.
  • Source: KD-0982

Hold the frontend wiring back so development never sees the auto-fetch intermediate ​

  • Chose: Trim the in-flight PR to the backend size guard only, and land all four stores' refresh-required handlers together in the follow-up feature branch.
  • Rejected: Merge the PR with its auto-fetch store handlers, then remove them in the follow-up.
  • Why: Even a brief window with auto-fetch handlers on development gives every project a thundering-herd response to a large broadcast; splitting on the backend/frontend seam keeps the mainline free of a state that is wrong on its own.
  • Source: KD-0982

One project channel instead of eleven domain channels ​

  • Chose: Replace every Tenant.{t}.Project.{p}.{domain} registration with one Tenant.{t}.Project.{p} channel.
  • Rejected: A custom pusher-js authorizer that batch-auths several channels in one POST.
  • Why: All eleven registrations already delegated to the same IssueChannel doorman, so the domain suffix was never an auth boundary; pusher-js returns the cached channel on repeated subscribe(), giving one auth POST with no custom authorizer to maintain.
  • Source: KD-1149

Domain lives in the event name, not the payload ​

  • Chose: broadcastAs() returns {domain}.{verb} (e.g. issues.updates).
  • Rejected: Put domain inside broadcastWith() and have each store filter received events.
  • Why: A payload discriminator lets a store accidentally process another domain's events; prefixing the event keeps the public subscribeProjectChannel(projectId, domain, handlers) API unchanged.
  • Source: KD-1149

WebSocket delivery is membership-scoped, not REST-permission-scoped ​

  • Chose: Every project member on the one channel receives every domain event; REST policies still gate HTTP only.
  • Rejected: Per-recipient filtering, or a per-user channel set that mirrors REST abilities.
  • Why: broadcastWhen is all-or-nothing on one Pusher/Reverb channel, and a per-user channel set undoes the ≤2 auth-POST target.
  • Source: KD-1149

No dual-registration window when renaming channels ​

  • Chose: Remove the eleven old channel names in the same deploy that the frontend listens on the new name.
  • Rejected: Dual-register old and new names during a transition.
  • Why: An open tab on the old frontend already goes silent when event names change, so keeping the old registrations cannot keep those tabs live and would reintroduce eleven auth POSTs.
  • Source: KD-1149

Oversize patches demote the largest changed value first ​

  • Chose: While over the byte limit, move the biggest remaining changed value into stale and remeasure; never drop id or always-keys.
  • Rejected: A per-value threshold that marks any single oversize field stale in one pass.
  • Why: Several mid-size values can still fail the guard after a per-value cut, so refresh-required would keep firing on the case patches exist to absorb.
  • Source: KD-1297

stale stays on the wire even though the client cannot refetch ​

  • Chose: Keep stale: list<string> in the patch contract, always present (empty when nothing was dropped).
  • Rejected: Drop stale and fall back to refresh-required whenever a field will not fit.
  • Why: A field-level signal replaces a full-page reload for every viewer; what the client does with it is a later issue, not a reason to keep today's worst case.
  • Source: KD-1297

Patch shaping is an injected class, not a trait waiting for adopters ​

  • Chose: A BroadcastPatchShaper class that uses the size-guard trait itself.
  • Rejected: A sibling trait no broadcaster can adopt until the frontend ships, silenced with a PHPStan ignoreErrors.
  • Why: PHPStan does not analyse an unused trait at all, so the shaping logic would have merged unchecked.
  • Source: KD-1297

A broadcast never issues an HTTP request ​

  • Chose: A patch merges into a held row, removes it when it stops matching, and is a no-op for an id the list does not hold.
  • Rejected: Fetch the row when a patch's scope keys match a list that does not yet hold it.
  • Why: The cost scales with viewers × rows on a project channel (twelve people × a 30-issue move = 360 GETs), and the only one-issue route returns the full ~6 KB resource the sparse path exists to keep off the wire.
  • Source: KD-1298

The client reads stale and ignores it ​

  • Chose: Carry stale on the wire and do nothing with it.
  • Rejected: Refetch the row when the store already holds that id.
  • Why: A broadcast causing a request is a rule, not a default, and a detail-page carve-out is the kind of exception that grows; the dirty editor is already warned by the existing remote-update watcher.
  • Source: KD-1298

onPatch runs even when changes is empty ​

  • Chose: Call onPatch whenever a patches event arrives for a held row, including {changes: {}, stale: [...]}.
  • Rejected: Guard against calling onPatch with an empty object.
  • Why: The package's empty merge still reassigns store identity, which is what wakes the dirty-editor warning; guarding it would make the dangerous case silent.
  • Source: KD-1298

Drag buffer: a second map, full row supersedes patch ​

  • Chose: A parallel patch map beside the full-row map; a full row clears the patch, a patch folds into a buffered full row, a delete clears both.
  • Rejected: An ordered event log replayed in arrival order, or dropping patches for the duration of a drag.
  • Why: An event log rewrites a buffer already tuned for per-id dedup; dropping patches loses a title or assignee edit that nothing will re-send, unlike positions which the drop itself authorises.
  • Source: KD-1298

A stale field does not fire the project-wide refresh toast ​

  • Chose: Keep stale unread rather than mapping it onto refresh-required.
  • Rejected: if (stale.length) notifyRefreshRequired(projectId).
  • Why: That sentinel is project-scoped and its action is window.location.reload(), so one oversize description would ask every viewer to reload the SPA — the blast radius sparse broadcasting exists to remove.
  • Source: KD-1298

A scope-key change sends the patch plus one lean row on batch-updates ​

  • Chose: Always patch; when a scope key changed, also fire one batch-updates carrying a single lean issue row.
  • Rejected: Put the lean row on issues.updates, send a full updates on scope change, or patch only.
  • Why: A lean row on updates would replace an open detail page and blank description/prompt; lists already add rows through their batch-updates handler, so the companion event needs no frontend change.
  • Source: KD-1299

bulkUpdated() stays on batch-updates ​

  • Chose: Keep bulk issue updates on batch-updates and chunk them by measured size.
  • Rejected: Move bulkUpdated() to batch-patches.
  • Why: The five callers include creates and assignee-nulling, which lists must be able to add as full lean rows; batch-patches cannot insert a row the list does not hold.
  • Source: KD-1299

Always-keys cover derived scalars getChanges() names differently ​

  • Chose: Keep the four list-membership keys plus remaining_minutes as always-keys.
  • Rejected: Ship only remaining_minutes, or ship none.
  • Why: getChanges() reports an estimate edit under estimated_minutes, so without an always-key the remaining figure goes stale on the wire.
  • Source: KD-1299

The broadcaster reads getChanges(); callers pass relation keys only ​

  • Chose: patched($model, array $relationKeys = []) merges array_keys($model->getChanges()) with the relation keys the caller names.
  • Rejected: Callers enumerate every changed resource key.
  • Why: A column-only site would otherwise list ~15 optional fields the model already knows it saved.
  • Source: KD-1299

Report always-keys are the status-membership triple ​

  • Chose: dismissed_at, promoted_at, promoted_issue_id as always-keys.
  • Rejected: The two timestamps only, or an empty set.
  • Why: Overview tab-splits through those fields, so a patch that moved a report without both timestamps would leave the other viewer's counts wrong; empty would miss an attachment add on an already-dismissed report.
  • Source: KD-1300

Report reorder stays a per-row patched() loop ​

  • Chose: Keep the reorder action's per-report save loop calling patched().
  • Rejected: One batched reports.batch-patches event.
  • Why: An unmoved row's save() is a no-op, so the loop already emits only the rows that moved — a batch event would add a handler and a wire shape on a domain that has neither a bulk broadcaster nor a lean list resource.
  • Source: KD-1300

Reports get no scope-change companion row ​

  • Chose: Status changes ride on the patch's always-keys alone.
  • Rejected: Pair a status patch with a lean full row on batch-updates, as issues do.
  • Why: The reports store already holds every report in the project and filters client-side, so a status change never has to insert a row it does not hold.
  • Source: KD-1300

Comment always-keys are empty ​

  • Chose: No always-keys on comment patches.
  • Rejected: issue_id to mirror the updates filter, or is_pinned as a reorder key.
  • Why: onPatch no-ops for an id the per-issue store does not hold, so membership is structural; issue_id never changes and is_pinned already appears in getChanges().
  • Source: KD-1301

Comments do not subscribe to refresh-required ​

  • Chose: No sentinel handler on the comments store.
  • Rejected: Wire it defensively "in case".
  • Why: With no always-keys the shaper can demote until the floor is ~100 B, so the patch guard cannot fail; a handler no broadcast can reach is dead code the coverage gate would then have to fake.
  • Source: KD-1301

Epic membership is a different seam than epic field patches ​

  • Chose: Ship field patches only; move membership to its own issue.
  • Rejected: Keep a shared membership Action injected into seven issue-writing callers, each capturing the affected epic ids.
  • Why: Eight of twelve review findings were one hazard class — an epic id read from a row that was stale by the time it mattered — appearing at a new caller each round, including a leg an audit had explicitly reasoned was safe.
  • Source: KD-1302

Epic always-keys stay empty ​

  • Chose: Empty always-keys; issue_ids must never join them.
  • Rejected: Treat issue_ids / issues_count / is_closed as always-keys so every field edit also refreshes membership.
  • Why: An unbounded issue-id list riding on every title edit is the payload this ticket exists to remove.
  • Source: KD-1302

Field-patch PRs serialise no membership values at all ​

  • Chose: Epic patched() ships getChanges() and nothing else.
  • Rejected: Keep membership keys on the edited epic and drop the issues relation before serialising.
  • Why: Dropping the relation patches the stale-preload hazard; omitting the keys removes it, and a loadMissing hydrator is a no-op on a relation the MCP update path already loaded.
  • Source: KD-1302

Watcher toggles start broadcasting ​

  • Chose: Both report-watch and error-watch toggles call patched() with their watcher-id list as the relation key.
  • Rejected: Leave them silent and record the AC as a deviation.
  • Why: Every other viewer otherwise keeps the pre-toggle set until they navigate; the resolver cost is bounded to a one-element collection.
  • Source: KD-1303

Project always-keys stay empty ​

  • Chose: Empty always-keys on project patches.
  • Rejected: archived and has_ai_key as derived always-keys.
  • Why: Neither can change on any patched() path — archive and AI-key writes do not broadcast — so both would be dead bytes on every patch.
  • Source: KD-1303

One Action owns the project-patch resolver trio ​

  • Chose: A sibling Action takes the project and relation keys, resolves access plus both watcher lists, then calls the broadcaster.
  • Rejected: Copy the five lines into each Action, or put a helper on the broadcaster.
  • Why: Three copies are how that trio drifts, and deptrac forbids Broadcasting → Actions so the broadcaster cannot run the resolvers itself.
  • Source: KD-1303

Relation keys ride only when the write actually changed them ​

  • Chose: Push membership and lane-id keys only when sync() attached or detached, or the reorder branch fired.
  • Rejected: Push all three on every settings edit.
  • Why: Unconditional keys re-send ~1 KB of membership lists nobody touched, which is the waste sparse broadcasting removes.
  • Source: KD-1303

Once patches read getChanges(), a retry must start from a fresh row ​

  • Chose: Refresh the project as the first statement of the 3-attempt transaction closure.
  • Rejected: Lock the row and overwrite attributes, or leave the existing retry-safety opt-out marker.
  • Why: A deadlock retry was re-assigning attributes onto originals from the rolled-back attempt, so save() skipped and getChanges() still reported the first attempt — invisible when updated() sent the whole resource, data loss now that the change-set is the wire.
  • Source: KD-1303

The size guard measures Reverb's request limit, not the client-frame limit ​

  • Chose: Inject reverb.servers.reverb.max_request_size at every broadcaster.
  • Rejected: Keep reverb.apps.apps.0.max_message_size.
  • Why: max_message_size bounds incoming client frames; the queue worker POSTs to /events and Reverb 413s on max_request_size.
  • Source: KD-1309

Measure the escaped encoding plus a fixed envelope ​

  • Chose: Compare double-encoded JSON length against ceiling − ENVELOPE_ALLOWANCE.
  • Rejected: Rebuild the exact HTTP body at every call site, or subtract a flat margin from raw JSON.
  • Why: Rebuilding couples every broadcaster to its channel and event names; a flat margin is wrong by an amount that grows with quotes and newlines, which is the term that actually overflows.
  • Source: KD-1309

The trait derives the budget; call sites inject the raw ceiling ​

  • Chose: The size-guard trait subtracts the allowance from the injected max_request_size.
  • Rejected: A precomputed broadcasting.payload_budget config key.
  • Why: A second key can drift from the Reverb setting it is derived from, and the config attribute would stop naming the limit it enforces.
  • Source: KD-1309

The envelope allowance is measured, not guessed ​

  • Chose: Render a real Pusher trigger request for the longest channel, event, and host kendo uses, then round 571 B up to 600.
  • Rejected: A flat 1 000 B margin.
  • Why: 10% of every frame given away, and nothing fails when the real envelope grows past a guess.
  • Source: KD-1309

One measurement for degrade-then-guard ​

  • Chose: The story-generation broadcaster folds its private fit/truncate checks into the trait's escaped measurement.
  • Rejected: Leave the private raw-byte check and widen its ceiling.
  • Why: After the guard started measuring escaped bytes, the broadcaster could shed pipeline and reasoning, declare the result fitting, and then have the guard drop the terminal event — the silence it exists to prevent.
  • Source: KD-1309

Truncation binary-searches a prefix that fits, rather than reserving slack ​

  • Chose: Binary-search the longest description prefix whose escaped size fits the budget.
  • Rejected: Raise the encoding-slack constant to a quote-proof multiple.
  • Why: A bigger reserve is the same guess; a 20 000-quote description still dropped the terminal event at 256 B of slack, and no test can say when the next guess is wrong.
  • Source: KD-1309

Stale-field state lives in kendo, not on the row and not in the store package ​

  • Chose: A module-level per-project map of issue id to stale fields, read through the store's extend seam.
  • Rejected: Widen the adapter-store's onPatch to carry stale, or merge a synthetic __stale field onto the row.
  • Why: A package release was deferred, and issue.update() PUTs issue.mutable wholesale so a field on the row would be written back.
  • Source: KD-1314

Name the stale field; do not auto-reload and do not stay silent ​

  • Chose: A persistent warning naming the stale fields with a deliberate reload action; the generic "updated elsewhere" toast only when something visible actually changed.
  • Rejected: Reload the page from the broadcast, or suppress the toast when nothing visible moved.
  • Why: An automatic reload throws away the rest of the page for one field; saying nothing leaves the reader looking at a description the server has moved on from.
  • Source: KD-1314

The notice watches the registry, so a repeat of the same field re-raises ​

  • Chose: The composable watches the registry and raises itself.
  • Rejected: Compare against the last announced field set and let the page call show().
  • Why: Same-field-again after a hand-dismissal is the common case, and the toast library never tells the composable the reader closed the last notice, so a page-driven latch swallowed every later patch.
  • Source: KD-1314

Apply the resource update before recording stale fields ​

  • Chose: Apply the patch to the store first, then record stale, on both single and batch handlers.
  • Rejected: Record stale first so the notice can raise against the pre-merge row.
  • Why: A payload missing stale would throw before the store moved, so the reader would miss the whole remote update to protect a marker.
  • Source: KD-1314

The stale-field reload is a page reload, not an in-place fetch ​

  • Chose: window.location.reload(), the dirty-editor path's shape.
  • Rejected: Keep fetching the row in place and take another round of interleaving fixes.
  • Why: A broadcast and an HTTP response cannot be ordered from the client, and four review rounds produced a new race each time; the AC asks for a deliberate action, not an in-place GET.
  • Source: KD-1314

A malformed patch in a batch does not strand the rest ​

  • Chose: Isolate each iteration, apply the rest, rethrow the first failure.
  • Rejected: Validate the payload in kendo before calling onPatch.
  • Why: Duplicating the package's contract drifts from it; a silent catch would leave the error wrong. The rest of the batch still lands.
  • Source: KD-1314

The plain reload action re-checks dirty state when pressed ​

  • Chose: On press, if the form has since become dirty, raise the discard variant instead of reloading.
  • Rejected: Watch isDirty and swap the toast when it flips.
  • Why: The toast library reports no hand-dismissal, so swapping would resurrect a notice the reader already closed.
  • Source: KD-1314

Epic membership broadcasts from the hash-bump, not from seven callers ​

  • Chose: The epics-hash bump is the producer; it takes nothing from the caller but the project.
  • Rejected: A sibling Action the bump calls, or per-caller id capture as in the KD-1302 attempt.
  • Why: There is then no id to capture and no stale read to have; twelve of fourteen callers stay untouched.
  • Source: KD-1358

After-commit via the transactions manager, with the missing container alias added ​

  • Chose: Inject DatabaseTransactionsManager and alias 'db.transactions' to that class.
  • Rejected: Inject ConnectionInterface and call afterCommit() (needs an arch-test exemption), or inject the connection resolver so the forbidden type name is absent.
  • Why: Laravel binds the manager only under the string key, so constructing the class builds a second empty manager whose callback fires inside the open transaction; the alias is the honest fix, not a spelling dodge.
  • Source: KD-1358

Last bump in a transaction wins, by captured hash ​

  • Chose: Each bump captures the hash it wrote and its callback broadcasts only while the project's epics_hash still equals that value.
  • Rejected: A request-scoped pending-project set, or accept duplicate frames.
  • Why: Two identical frames for one write look like a second change to the client; a pending set puts state back into a design whose point is to have none.
  • Source: KD-1358

Membership patches carry the trio only ​

  • Chose: issue_ids, issues_count, is_closed; order does not ride; always-keys stay empty.
  • Rejected: Trio plus order, so a reorder bump would also refresh membership.
  • Why: order moves independently of membership, and putting it on every membership change sends a field the change did not touch.
  • Source: KD-1358

The membership trio degrades as a whole, never to refresh-required ​

  • Chose: When issue_ids lands in stale, drop issues_count and is_closed from changes too.
  • Rejected: Let the shaper demote only issue_ids, or let the batch fall back to refresh-required.
  • Why: A count that contradicts the id list still on screen is worse than a stale pair that agrees; a reload toast for one large epic is the failure sparse broadcasting exists to remove.
  • Source: KD-1358

Bulk create registers one membership frame, by threading the existing $broadcast flag ​

  • Chose: The bump gains bool $broadcast = true; bulk-create skips per-row callbacks and calls the bump once after the loop.
  • Rejected: Accept 25 frames, or introduce the request-scoped pending set last-hash-wins already refused.
  • Why: Bulk-create opens no outer transaction (nested transaction() would kill deadlock-retry), so last-hash-wins cannot collapse those 25 commits; the flag already exists for board-sync fan-out.
  • Source: KD-1358

The callback loads every epic on the project, fresh, after commit ​

  • Chose: Query $project->epics() inside the after-commit callback, then hydrate once.
  • Rejected: Load only the epics the write touched, or serialise whatever the caller is holding.
  • Why: Knowing which epics changed is the capture problem the choke point removed, and loadMissing is a no-op on a relation the MCP update path already preloaded — so a caller-held model would serialise pre-write membership.
  • Source: KD-1358

Tenant-wide cache hashes live in a dedicated cache_hashes singleton, not on settings or in Redis ​

  • Chose: A per-tenant one-row cache_hashes table with typed columns (users_hash, teams_hash), bumped by a raw UPDATE inside the mutating Action's transaction.
  • Rejected: Columns on AppSetting, a Redis/cache key, or a stringly-typed (cache_key, hash) bag.
  • Why: Settings is the wrong semantic home, a cache write is not in the DB transaction so a rolled-back mutation would still advertise a new hash, and project hashes are already named columns — a bag would be the one untyped key in the protocol.
  • Source: KD-1088

One cache-hash stamper, two registries — do not split tenant keys onto a second middleware ​

  • Chose: StampCacheHashesMiddleware carries a tenant-key map beside the project-relation map and stamps both namespaces in one header write; tenant keys stay subscribe-only.
  • Rejected: A separate StampTenantCacheHashesMiddleware with merge semantics and per-route co-mounts on the four /projects writes.
  • Why: The split paid for a protocol helper, header merge, and two arch tests to paper over a collision that typed namespaces never had, and the extra singleton read was too cheap to keep off the hot path.
  • Source: KD-1088

Over-bump the users/teams hash on any write in the transitive set ​

  • Chose: Unconditionally bump users_hash / teams_hash on every Action in the enumerated two-hop dependency set (user, three pivots, roles/permissions).
  • Rejected: Surgical conditions like the epics precedent, core-CRUD-only bumps, or a derived hash computed on read.
  • Why: A missed bump serves stale forever (no staleAfterMs); over-bump is one cheap admin refetch, and a read-time aggregate would miss pivot writes that never touch user.updated_at.
  • Source: KD-1088

Session-end rebuilds tenant-wide cached stores through a shallowRef facade ​

  • Chose: Export userStore / teamStore as stable facades over a shallowRef holder and reassign the holder on onSessionEnd.
  • Rejected: Hard-reload on logout, convert 31 call sites from singleton to factory, or skip the reset.
  • Why: ref() would unwrap the store's nested computed refs; a hard-reload changes logout UX across a shared service; a factory conversion breaks the app-wide singleton existing code leans on — the facade re-tracks every consumer with no call-site churn.
  • Source: KD-1088