Appearance
MCP 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).
Feature flags: MCP tools respect Pennant gates that protect HTTP routes
- Chose: Each feature-gated MCP tool early-returns via
Feature::for($tenant)->active('<flag>')at the top ofhandle(), mirroringEnsureFeatureActivemiddleware on the equivalent HTTP route. - Rejected: MCP tools bypass feature flags (consistent with "MCP tools skip HTTP middleware") so all flagged operations are reachable via MCP regardless of tenant entitlement.
- Why: A flagged feature is a tenant entitlement, not an HTTP-layer concern. Bypassing means a tenant without the feature can still drive it via Claude/CLI — confusing UX and potentially a billing/compliance issue. Cost is one early-return per tool with
TenantContextalready injected. - Source: KD-XXXX (MCP & CLI report tools parity)
Issue resolution by ID or key — covered in scoped + non-scoped contexts
- Chose: Override both
Issue::resolveRouteBinding()andProject::resolveChildRouteBinding()plus a sharedResolvesIssuetrait on MCP tools. - Rejected: Only override
Issue::resolveRouteBinding()(works in non-scoped contexts only). - Why: Laravel's
scopeBindings()calls the parent'sresolveChildRouteBinding, not the child'sresolveRouteBinding, so single-side override would have left key-based URLs broken on scoped API routes. - Source: KD-0119
Merging MoveIssueTool into UpdateIssueTool
- Chose: Route lane changes through
UpdateIssueActionviaSaveIssueData.laneId. - Rejected: Branch on
lane_idand callUpdateIssueBoardActionto mirror MoveIssueTool's exact path. - Why:
UpdateIssueActionalready handles lane-change detection and notifications; using the bulk board action for a single-issue MCP move would have meant two action paths for one tool. - Source: KD-0224
Marketing tool count after consolidation
- Chose: Keep "20+ MCP tools" in landing/config copy, only update the docs pages with literal counts.
- Rejected: Update all ~12 marketing surfaces to "15 tools".
- Why: "20+" remains accurate when counting tools + resources together (15 + 10 = 25), so touching all marketing surfaces buys honesty we already have at the cost of a less impressive number.
- Source: KD-0224
Removed tools — delete vs deprecate
- Chose: Hard-delete the unused tools.
- Rejected: Keep them with a deprecation marker.
- Why: They were never invoked via MCP; keeping dead code costs maintenance and counts against the model's tool cap.
- Source: KD-0224
Epic linking authorization
- Chose: Allow any user with issue update permission to set
epic_id; only validate that the epic belongs to the same project. - Rejected: Require Team Lead+ permission on the epic itself.
- Why: Setting epic_id is conceptually an issue update, not an epic update; all project members can already see epics, so gating on epic-level permission would over-restrict normal users without protecting anything.
- Source: KD-0320
Signed S3 URLs for MCP attachments
- Chose: Build a Resources-layer
AttachmentMcpDataDTO that wrapsStorage::temporaryUrl(). - Rejected: Add
Storageto the MCP arch-test allow-list (currently onlyGateis permitted). - Why: The arch rule keeps MCP handlers thin; relaxing it for one tool would erode the constraint and invite future facade leakage. The DTO mirrors the existing
ProfilePictureUrlsDatapattern. - Source: KD-0368
CLI download default destination
- Chose: Default
kendo issue downloadoutput to~/Downloads/<filename>. - Rejected: Default to current working directory (curl
-Ostyle). - Why: Developers run the CLI inside git repositories; CWD-default would silently drop files into version control. Requiring
--outputwas rejected as too verbose for the common case. - Source: KD-0368
CLI streaming download
- Chose: Use
client.Do()+io.Copyto stream attachment bytes directly to disk. - Rejected: Reuse the existing
client.Get()which reads the whole body into memory. - Why: Recordings and design files can blow the heap; streaming costs a few extra lines and removes an OOM risk on large attachments.
- Source: KD-0368
Attachment fetch tool size limits
- Chose: Two tiers — 5 MB binary (image), 500 KB text.
- Rejected: Three tiers (image, PDF, text) or a single 5 MB cap.
- Why: PDF is deferred (D7 below), so a separate PDF tier is dead config. A single 5 MB cap would let a 4.9 MB CSV blow the LLM's context budget.
- Source: KD-0453
PDF support in fetch-attachment v1
- Chose: Defer PDF — return
Response::error('MIME … not supported')and document a follow-up. - Rejected: Return PDF as
Response::image(mime=application/pdf), base64-encode in text, or transcode viasmalot/pdfparser. - Why: Laravel MCP's
Blob::toTool()blocks blob-in-tools at the SDK level. Faking it as an image violates the MCP spec; base64 consumes ~1.3× file size in tokens; PDF→text transcoding was explicitly out of scope. - Source: KD-0453
Orphan attachments in MCP
- Chose: Reject orphan attachments (
attachable_type = null) before the Gate check, fail-fast. - Rejected: Let the existing
AttachmentPolicy::downloadaccept them (it does today). - Why: Orphan IDs aren't discoverable via MCP resources, so supporting them only helps attacker-style ID enumeration with no real use case.
- Source: KD-0453
Send-feedback delivery path
- Chose: Have the MCP tool call the existing
SendFeedbackActionwhich POSTs externally to the kendo.dev feedback URL. - Rejected: Mirror
CreateReportTooland write directly to project 1 locally. - Why: Local writes only work when the MCP user's tenant is Kendo.dev itself; the whole point is letting any tenant submit feedback, so the cross-tenant Gate gymnastics make local-write a non-starter.
- Source: KD-0457
Send-feedback title contract
- Chose: Make
titlerequired end-to-end; the in-app bubble synthesises"Feedback van {firstName} {lastName}"on the frontend before POSTing. - Rejected: Keep the existing backend fallback that synthesises the title inside
SendFeedbackAction. - Why: A single contract at every layer beats backend fallback logic that mirrors itself across two callers (bubble + MCP). One title-generation site, no dual-mode action.
- Source: KD-0457
Send-feedback CLI auth path
- Chose: Have the CLI POST to the user's own backend
/api/feedback, which forwards externally. - Rejected: Bake the central
FEEDBACK_REPORT_TOKENinto the CLI binary and call the external URL directly. - Why: Embedding a privileged system token in every user's laptop is a credential-leak risk; routing through the user's authenticated backend reuses their personal Bearer token.
- Source: KD-0457
Report deep-link URL shape
- Chose: Query parameter
?report={id}on the existing reports overview page, written viarouter.replace. - Rejected: Nested child route
/reports/:reportIdwith a router-view in the split-pane. - Why: Nested routes would require restructuring the inline split-pane and risked breaking drag/reorder/selection UX.
router.replacealso avoids history-stack pollution from per-row selection. - Source: KD-0459
MCP report resource URI shape
- Chose: Flat
kendo://reports/{reportId}and derive project fromreport.project_id. - Rejected: Nested
kendo://projects/{projectId}/reports/{reportId}. - Why: Report ID is globally unique and LLMs sharing just
#112would fail to construct the nested URI without first discovering the project. - Source: KD-0459
Resource-layer feature flag and audit logging
- Chose: Apply the
ChecksReportFeaturetrait andAiMcpLoggertoReportResource(first MCP resource to do so). - Rejected: Follow the
IssueResourceconvention (access gate only, no flag, no logger). - Why: AC required tool/resource parity for feature gating and observability; without it, a tenant could disable the feature on tools but still fetch reports via the resource path.
- Source: KD-0459
Kendo Today widget data fetch shape
- Chose: Single aggregator
KendoTodayTool+BuildTodaySnapshotActionreturning all three data sources atomically. - Rejected: Have the iframe fire three existing tools in
Promise.all. - Why: One audit-log row per widget load instead of three; atomic snapshot semantics; simpler error recovery.
- Source: KD-0481
Sharing query logic between Actions
- Chose: Add a query scope
scopeAssignedOpenForto theIssuemodel. - Rejected: Duplicate the ~10 LOC query builder in both Actions, or inject a
Serviceto share it. - Why: Deptrac forbids
Actions → Actions, so one Action can't inject another. A query scope is the canonical Laravel mechanism and avoids both duplication and a single-purpose Service abstraction. - Source: KD-0481
MCP error handling discipline
- Chose: Catch
Throwable, log structured context viaLogManager, return a generic user-safeResponse::error. - Rejected: Rethrow the raw
Throwableas several existing tools do. - Why: With
APP_DEBUG=truethe MCP layer can leak stack traces back to the LLM. New tools must set the migration target away from the legacy rethrow pattern, not cement it; widgets render in user-visible iframes which makes the leak more dangerous. - Source: KD-0481, KD-0550, KD-0551
MCP Apps content shape
- Chose:
Response::text(html)->withMeta(['ui' => …])withmimeType = 'text/html;profile=mcp-app', validated by a 30-min spike. - Rejected: Build a local
HtmlContent+HtmlResponsemirroring the existingImageContentworkaround. - Why: Laravel MCP's
Text::toResource()already emits the exact wire shape the spec needs; the spike confirmed it works, saving ~100 LOC of local extension code. Fallback to local classes was kept as a contingency. - Source: KD-0481
Tenant host injection into widget HTML
- Chose: Server-side
template replacement at resource read time. - Rejected: Pass via
_meta.ui.initialDataor hardcodekendo.dev. - Why: Hardcoding breaks multi-tenancy.
initialDatarequires widget-side glue to read and inject; a one-line string replacement usingconfig('app.url')is cheaper and keeps the widget free of host detection. - Source: KD-0481
Widget bridge generic shape
- Chose: Generic
createBridge<TSnapshot>taking anapplySnapshot(state, snapshot)callback. - Rejected: Bridge exposes
Ref<TSnapshot | null>for widgets to read viacomputed. - Why: Nullable refs force every consumer chain to pay the null-check cost; a callback gives widgets full control over how snapshot fields land in reactive state and keeps the SDK plumbing separate from domain shape.
- Source: KD-0486
MCP widget shared-layer location
- Chose: Place under
frontend/src/apps/mcp/shared/as a sub-app-shared layer. - Rejected: Place under
frontend/src/shared/mcp/(the global shared layer). - Why: The ext-apps SDK is a widget-only concern; putting it under global
src/shared/would transitively pull it into tenant + central build/test graphs, violating the spirit ofsrc/shared/as truly cross-app reusable code. - Source: KD-0486
Bulk-assign-to-sprint authorization
- Chose: Reuse
IssuePolicy::updateBoard(the same gate the drag-drop batch path uses). - Rejected: Add a new
bulkAssignToSprintpolicy method. - Why: Identical permission semantics already exist; a synonym method dilutes the policy surface and adds arch-test wiring for no behavioral gain.
- Source: KD-0549
Bulk-assign cap
- Chose: Cap
issue_idsat 100 per call. - Rejected: No cap, relying on the thin-payload broadcast to fit any N under Reverb's 10 KB limit.
- Why: Defensive bound on audit-log volume, SQL bind-list size, and unexpected callers. Matches kendo's existing 100-row default search limit.
- Source: KD-0549
Bulk-assign broadcast strategy
- Chose: Single thin-payload
positionsUpdated(~70 B/issue) broadcast — accept the "newly-in-scope" UI gap on Board. - Rejected: Full
IssueResourceDataper issue (bulkUpdated) or hybrid full+positions broadcasts. - Why: Full payloads (~1–2 KB each, up to 65 KB with long descriptions) blow the 10 KB Reverb cap at ~6 issues. The Board edge case (issues moving INTO active sprint don't appear locally until refresh) is a known limitation tracked separately.
- Source: KD-0549
Tool description framing for overlapping surfaces
- Chose: Explicit "Prefer this tool over looping update-issue-tool whenever the only change is sprint membership" framing.
- Rejected: Soft hint "More efficient than looping update-issue-tool".
- Why: Anthropic's MCP guidance warns against overlapping tool surfaces; without retiring
sprint_idfromupdate-issue-tool, prescriptive wording is the only way to prevent the LLM picking the slow path even at N=1. - Source: KD-0549
Single-purpose vs generic bulk Action
- Chose: Build a single-purpose
BulkAssignIssuesToSprintAction. - Rejected: Build a generic
BulkUpdateIssuesActiontaking partial-fields DTOs. - Why: Each field family has different broadcast semantics — sprint changes go through thin positions, assignee changes need My Issues fanout, lane changes need notifications. A generic abstraction would prematurely couple three distinct downstream surfaces.
- Source: KD-0549
start-work-on-issue transaction owner
- Chose: New composing
StartWorkOnIssueActionopens one outer transaction; inner Actions become Laravel savepoints. - Rejected: Have the MCP tool inject
ConnectionInterfaceand own the transaction. - Why: ADR-0011 mandates Actions own transactions; pushing transaction control into the protocol layer violates the architecture and would force arch-test exclusions later. Mirrors the
PromoteReportsActionprecedent. - Source: KD-0550
start-work lane resolution
- Chose: Require agent-provided
lane_id(validated against the issue's project). - Rejected: Server-side title match
Lane::where('title', 'In Progress')per the published spec. - Why: Lane titles are user-editable per project; title-matching is fragile. The companion
prepare-issue-contexttool returns lanes, so lane choice belongs in the gather step. - Source: KD-0550
Idempotent re-run of start-work
- Chose: Pre-flight equality checks — skip
UpdateIssueActionandCreateIssueBranchLinkActionwhen desired state already matches. - Rejected: Always run all steps and let inner Actions handle duplicates.
- Why:
CreateIssueBranchLinkActionthrowsBranchAlreadyLinkedExceptionon duplicates and the exception doesn't structurally distinguish "same issue" from "different issue". Pre-flight checks also avoid no-op audit-log noise fromUpdateIssueAction. - Source: KD-0550
start-work response shape
- Chose: Hand-build a structured response with nested
{id, title}blocks for lane/sprint, mirroringUpdateIssueTool. - Rejected: Return
IssueResourceData::from(...)->toArray()(flat IDs, no nested objects). - Why:
IssueResourceData::EAGER_LOADdeliberately omits lane/assignee/sprint to keep its shape lean; the agent calling start-work needs to render a confirmation card without firing a follow-up read. - Source: KD-0550
prepare-issue-context payload composition
- Chose: Mirror existing MCP resource shapes (hand-rolled per resource), do NOT introduce HTTP
ResourceDataDTOs into the MCP layer. - Rejected: Reuse
IssueResourceData::from()etc. as the issue spec suggested. - Why: No existing MCP resource uses HTTP ResourceData DTOs; doing so would set a new convention and risk drift if HTTP shape evolves and silently breaks MCP contracts. Layer unification is its own epic.
- Source: KD-0551
prepare-issue vs prepare-project split
- Chose: Two sibling tools (
prepare-issue-context+prepare-project-context), each with one entity in scope. - Rejected: One fat tool with conditional fields via
include_project_metaflag. - Why: Conditional response shapes are an
outputSchema()lie that strict MCP clients reject; the agent can fire both tools in parallel for the same wall-clock cost when both gathers are needed. - Source: KD-0627
prepare-project-context field shape
- Chose: Mirror existing
kendo://resource fields exactly; addmembers_countcompanion field; keepmembers[]unbounded. - Rejected: Trim to a "minimal context" shape, add
all_sprints[]andepics[], or paginate members. - Why: Trimming defeats the consolidation goal (callers would still need fan-out for trimmed fields). Pagination is premature — worst case ~50 members × 80 bytes ≈ 4 KB.
- Source: KD-0627
prepare-project-context migration strategy
- Chose: Single PR with breaking shape change; no
KendoServer::$versionbump. - Rejected: Two PRs (add new tool first, slim old tool in follow-up) with a deprecation period.
- Why: Atomic change avoids a window of overlapping responsibilities between the two tools. We don't currently version-pin anywhere; bumping for one breaking change sets a maintenance precedent we'd have to honor on every future schema change.
- Source: KD-0627
Resolving an existing link vs finding a target for a new one
- Chose: Inline resolution in
UnlinkBranchTool(walkissue->branchLinks()filtered bybranch_name) rather than reusing the create-path's repo resolver. - Rejected: Generalise
ResolveBranchTargetRepoActionto also locate an existing link byrepo_full_name. - Why: "Find the target repo for a new branch" and "find the existing link to delete" are different problems with different error semantics (the resolver raises "no primary repo" where the unlinker wants "no link found"), so bending one helper to do both muddies it.
- Source: KD-0777
Hint-mismatch behaviour on a destructive tool
- Chose: Strict — when a supplied
repo_full_namedoesn't match the single candidate link, return an error listing the actual repo and refuse to delete. - Rejected: Lenient — treat the hint as advisory and unlink the one match anyway.
- Why: A destructive op should refuse on stale or wrong assumptions; lenient matching would let an agent that thinks it's unlinking
acme/foosucceed againstacme/barand never notice. - Source: KD-0777
"Matches the create path" means parity, not exceeding it
- Chose: Cover unlink audit via the standard
AiMcpLogrow every MCP tool emits, since the create path has no per-entity audit logger either. - Rejected: Introduce a first-class
IssueBranchLinkAuditLoggerto make unlinks directly queryable. - Why: The AC said "matches the create path," and a per-entity logger would force a symmetric retrofit on the create path plus a migration and arch-test wiring — scope creep beyond literal parity.
- Source: KD-0777
Agent-facing response shape vs reusing the SPA delete DTO
- Chose: Emit a rich inline payload (
{id, branch_name, repository.name, issue.id}) snapshotted before the Action deletes the row. - Rejected: Reuse the web UI's
DeletedIssueBranchLinkResourceData({id, issue_id}). - Why: The agent needs to confirm exactly what was deleted in one round-trip; the lean SPA shape would force the LLM to re-read the issue to name the branch — the agent surface has different requirements than the SPA surface.
- Source: KD-0777
Label attach at create-issue — separate sync call vs extending the issue Action
- Chose:
create-issuerunsCreateIssueAction, then makes a separateSyncIssueLabelsActioncall whenlabel_idsis supplied. - Rejected: Extend
CreateIssueData/UpdateIssueDataand the issue Actions to sync labels inside the issue transaction (likeblockedBy/blocks). - Why: The web paths don't pass labels, so folding sync into the shared issue Action adds an unused parameter to production web code and modifies its unit tests; the separate call has zero blast radius and mirrors the web's deliberate split, accepting a narrow two-transaction partial-failure window that's retryable via
sync-issue-labels. - Source: KD-0829
Changing labels on an existing issue — dedicated tool vs folding into update-issue
- Chose: A dedicated
sync-issue-labelstool; keeplabel_idsoncreate-issueonly and drop it fromupdate-issue. - Rejected: Fold label changes into
update-issueso attach happens via create/update only (matching the issue's literal scope). - Why: A label-only
update-issuecall would re-invokeUpdateIssueActionwith all-current values — a no-op re-save emitting a spurious "Issue Updated" audit row and broadcast — unlessupdate-issuespecial-cases label-only changes; the dedicated tool mirrors the web's separate label endpoint 1:1 and gives one canonical way to change labels. - Source: KD-0829
Label output shape on issue-returning tools
- Chose: Single-issue tools emit full
labels: [{id, name, color}]; list tools (search-issues≤100,get-my-issues≤500) emitlabel_ids: [int]only. - Rejected: Return full label objects uniformly across single-issue and list tools.
- Why: Hydrating full label rows × up to 500 results is an efficiency risk, and the id→name/color map is already available from
prepare-project-context, so list tools only need ids (consistent withget-my-issuesalready being id-only for lane/sprint). - Source: KD-0829
Label read gating — bundle vs dedicated tool
- Chose:
prepare-project-contextincludes labels on project access alone, while the standaloneget-labelstool additionally gates on theviewAnyLabel permission. - Rejected: Apply one uniform gate to both surfaces.
- Why: The aggregate bundle follows the lanes/sprints/members precedent (project access, no per-resource gate) and
label_idsalready leak to project-access holders via issue tools, whereas the dedicated read tool faithfully mirrors the web label-index route'sviewAnygate. - Source: KD-0829
@mention pre-processing — skill guidance vs server-side retry DTO
- Chose: Ship the skill-guidance slice only (markdown edit to
kendo-mcp/SKILL.md), with no backend changes. - Rejected: Build the server-side retry DTO that signals unresolved mentions so the agent can retry.
- Why: The skill slice delivers the ambiguous→resolved win at zero backend/test cost, and observed unresolved-mention rates should decide whether the backend DTO earns its cost rather than paying it speculatively.
- Source: KD-0860
Where @mention pre-processing guidance lives
- Chose: Put the guidance in
kendo-mcp/SKILL.mdonly, as the canonical MCP-usage reference other skills defer to. - Rejected: Duplicate the guidance across all content-writing skills (newbranch, plan-feature, triage-reports).
- Why: A single source of truth avoids drift when the guidance evolves; the risk that another skill runs without loading kendo-mcp in time is acceptable because server-side resolution remains the fallback.
- Source: KD-0860
update-comment-tool excluded from @mention pre-processing
- Chose: Exclude
update-comment-toolfrom the pre-processing guidance and document why. - Rejected: Include it for symmetry with add-comment (it also accepts a
contentfield). - Why:
UpdateCommentActioninjects neither the mention resolver nor the notifier, so the server never resolves mentions on comment updates — pre-processing a field the server ignores is pointless. - Source: KD-0860
Agent behaviour when member[] is missing from context
- Chose: Skip @mention pre-processing and fall back to server-side resolution when
prepare-project-contexthasn't run (somember[]is unavailable). - Rejected: Call
prepare-project-contexton demand to guarantee pre-processing always runs. - Why: The server-side resolver is the reliable fallback and the gather phase already requires the bundle for most write flows, so an extra round-trip just to force pre-processing isn't worth it.
- Source: KD-0860
Sprint listing — discoverable tool vs existing resource
- Chose: Add a
GetSprintsToolTool class mirroringGetEpicsTool, alongside the existingSprintsResource. - Rejected: Just promote
SprintsResourcediscoverability via more explicitKendoServerinstructions. - Why: Resources aren't returned by
ToolSearch— onlyToolclasses are — so agents miss the resource in practice; the tool is the discoverable agent-facing surface while the resource stays as a lightweight direct-URI read path. - Source: KD-0891
Sprints in prepare-project-context — Planned+Active vs all
- Chose: Include only Planned + Active sprints in the bundle; Completed sprints are fetched via
GetSprintsTool. - Rejected: Include all sprints (Planned + Active + Completed) in
prepare-project-context. - Why: The query cost is identical, but a year-old project can accumulate 20+ completed sprints that grow the payload unbounded and aren't planning context — the bundle is for planning/action, so historical sprints belong behind a separate call.
- Source: KD-0891
GetSprintsTool return shape — sprint fields only vs embedded issues
- Chose: Return sprint fields only (id, title, status, dates, issues_count), mirroring
SprintsResource. - Rejected: Embed the full issue list per sprint, mirroring
GetEpicsTool's deeper shape. - Why: Agents needing a sprint's issues call
search-issues-toolfiltered bysprint_id, so embedding issues would add N+1 hydration for a need that's already served elsewhere. - Source: KD-0891
Typing mixed tool inputs — assert the int|string union, not the single type
- Chose: Assert the widened
int|stringunion at the seam where validated tool input is consumed and keep the coercing(int)cast; array inputs get alist<int|string>docblock for the same reason. - Rejected: The sibling precedent's shape — bare
assert(is_int($id))with the cast dropped, andlist<int>docblocks on the array inputs. - Why: Laravel's
integerrule validates without coercing, so a numeric string survives intovalidated()— the single-type assert would throw on it (a real behaviour change) andlist<int>would be a lie about the runtime type; the union clears themixed-cast finding without touching runtime behaviour. - Source: KD-0962
Bulk-create: one transaction per row, not one per batch
- Chose: Each issue commits in its own transaction and keeps CreateIssueAction's retry budget; failures return as values, not throws.
- Rejected: One outer transaction wrapping the batch (the PromoteReportsAction shape), or chunked transactions of 5.
- Why: Nested
transaction()throws DeadlockException immediately and kills the retry loop, one bad row would roll back the audit trail for all 25, and per-row outcome reporting needs committed rows. - Source: KD-1075
Bulk-create: no idempotency key
- Chose: No client-supplied or content-derived idempotency key; a lost response can duplicate at most 25 issues.
- Rejected: A per-row
idempotency_keyunique per project, a batch-level Stripe-style key, or a title+description fingerprint. - Why: The cap of 25 makes the blast radius a human-deletable duplicate set, which does not earn a migration on the busiest tenant table; fingerprinting would make two legitimately identical issues impossible.
- Source: KD-1075
Bulk-create response: thin and failure-indexed
- Chose: Return
created_count,failed_count,complete, createdkeys[], andfailed[{index, error}]— no titles, descriptions, or URLs. - Rejected: Mirror CreateIssueTool's 15-field issue object per row, or counts-only.
- Why: Full objects would echo ~40–60k tokens back at the model that just wrote them; counts-only cannot tell the caller which rows to resend when there is no idempotency key.
- Source: KD-1075
Bulk-create cap of 25 — below every sibling bulk cap
- Chose: Cap
rowsat 25 per call. - Rejected: Match sibling caps of 100 (sprint-assign), 200 (bulk-delete), or 500 (epic-assign / bulk-move).
- Why: Siblings are one
whereInUPDATE or DELETE; each bulk-create row is its own transaction, issue_number lock, audit insert, and watcher attach, so the blast radius of one request is categorically larger. - Source: KD-1075
Bulk-create reuses CreateIssueAction per row rather than reimplementing
- Chose: BulkCreateIssuesAction delegates to CreateIssueAction per row.
- Rejected: Reimplement the create path inline, matching BulkDeleteIssuesAction.
- Why: Delete is set-expressible (one
whereIncascade), so reimplementation pays; create is not — each row still needs its own transaction, number, key, and audit chain, so reimplementation would duplicate ~120 lines for no batching gain. - Source: KD-1075
Bulk-create suppresses per-row broadcasts via a defaulted flag
- Chose: Add a defaulted
$broadcast = trueon CreateIssueAction; the bulk path passes false and firesbulkUpdated+myIssuesChangedManyonce after the loop. - Rejected: Leave the 50 per-row events in place, extract broadcasting into the six callers, or fire nothing and emit a project-wide refresh-required toast.
- Why: Per-row re-introduces the N+1 KD-0647 removed; extracting puts callers outside the Actions-only freshness arch-test; a project-wide toast would nag every subscribed page about rows only Backlog can show.
- Source: KD-1075
Bulk-create row-rate cap lives inside the Action
- Chose: A per-tenant, per-minute row-rate cap inside the Action (250 subscribed / 100 unsubscribed), charged by row count before the loop.
- Rejected: Rely on
throttle:mcp(60 requests/min), a dedicated route-level limiter, or lowering the row cap. - Why: One MCP route serves every tool so per-tool route limits are inexpressible, and
issue_audit_logsis unprunable — a 25× write amplification is permanent storage kendo pays for. - Source: KD-1075
Bulk-create keeps per-row mention notifications
- Chose: Leave CreateIssueAction's mention fan-out on; a member mentioned in all 25 descriptions gets 25 notifications.
- Rejected: Suppress notifications on the bulk path, or aggregate into one summary per person per batch.
- Why: Create materialises work that was not previously visible, so the notification is the recipient's only channel (unlike bulk-move, which mutates already-visible issues); aggregation is a new notification type with no digest infrastructure in this codebase.
- Source: KD-1075
Post-commit side-effect failure must not report committed work as failed
- Chose: Contain notification and post-loop broadcast failures inside the Actions — log and return success for work that already committed.
- Rejected: Leave the window (the tool reports
failedfor a created issue) or rethrow a distinguishable exception carrying the Issue. - Why: ADR-0011 classifies notifications as safe-to-fail, and a received
failedindex would make the agent resend a row that already committed — duplicating under the no-idempotency-key choice. - Source: KD-1075
Type the bulk rows array with ->items(), breaking the prose-only schema convention
- Chose: Advertise
rowsas an array of objects via->items(), the first use of that API in the codebase. - Rejected: Follow the prose-only
$schema->array()convention used by every existing tool, or reshape ValidationException into machine-readable row errors instead. - Why: A row is an object whose field names the model cannot infer from one English sentence, and a shape error is batch-fatal — wasting 25 generated descriptions, which is the expensive part of the feature.
- Source: KD-1075
Project-token ability gates live in parameterized middleware, not FormRequests
- Chose: Parameterized route middleware
project-token:<scope>as the single declaration point. - Rejected: An
EnforcesProjectTokenAbilitytrait shared by the six FormRequests. - Why: Token entitlement is authentication-layer, not input validation; the trait would keep two no-body gate-requests plus their arch exemptions alive and stay undiscoverable from
routes/api.php. - Source: KD-1157
Project-token linkage miss stays a ValidationException 422
- Chose: Throw
ValidationException::withMessages(['token' => …])from the middleware. - Rejected: A custom 422 JsonResponse, or folding linkage into the 403.
- Why: ValidationException renders the same wire shape consumers already handle, and linkage-miss (config) vs ability-miss (permission) are distinct conditions.
- Source: KD-1157
Project-token middleware wraps Passport primitives rather than using stock scope:
- Chose: Custom middleware calling
$token->cant(), keeping authorization data 100% Passport. - Rejected: Passport's
CheckToken(scope:) for the ability half. - Why: Stock middleware cannot express the
project_tokenslinkage check, so using it would split the entitlement story across two middlewares — the disease being cured. - Source: KD-1157
Project-token middleware: route-declaration errors fail loud
- Chose:
RuntimeExceptionon an unknown ability param or an unbound{project}. - Rejected: Pass the raw string to
$token->cant(); pass the request through; deny with 403/422. - Why: A typo would silently deny every token and pass every session, and pass-through is the KD-1155 bug (any project's token of the right ability reaches the route) — both are config errors, not client errors.
- Source: KD-1157