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