Appearance
Platform Decisions
Distilled from 9 DECISIONS.md files (4 plans had no DECISIONS.md or no real tradeoffs). Each entry is a real fork in the road. Implementation details that aren't a tradeoff are NOT here.
Feature flags — enrich ProfileResourceData, no separate endpoint
- Chose: Add
features: Record<string, boolean>toGET /auth/user;useFeatureActive()reads fromauthService.user. - Rejected: Dedicated
GET /api/featuresendpoint with its own controller, route, andservices/features.tswith module-levelref. - Why: Feature flags are per-tenant session context — same category as role, projectIds, teamIds (already on the profile). Extra request, controller, route, and service file added complexity without a concrete benefit; page reload re-fetches the user anyway.
- Source: KD-0286
Seeders — keep behavior on missing MinIO, don't add local-disk fallback
- Chose:
AttachmentSeederkeeps current skip-with-warning behaviour. - Rejected: Switch to
Storage::disk('local')when configured filesystem is unreachable. - Why: Masks a developer-environment problem at the seeder layer; production reads from the configured filesystem disk so local-disk attachments would be unreadable in the running app anyway. Docker is a documented hard prerequisite.
- Source: KD-0404
Bias dense seed data toward active + completed sprints
- Chose: Comments target active-sprint issues; TimeEntries target active + completed sprint issues.
- Rejected: Even random distribution across all 50 issues.
- Why: Realistic project-management usage concentrates this data on active work. Spreading thin produces backlog/Done noise disproportionate to where developers actually look.
- Source: KD-0404
Lean seed volumes, reject 8× density bumps from issue body
- Chose: Comments ~40, TimeEntries ~30, Notifications ~50, Reports ~15.
- Rejected: Issue-spec targets (Comments 100-120, TimeEntries 60-80, Notifications 50-80).
- Why: Combined with active-sprint biasing, ~40 well-placed comments produce more realistic UI than 120 randomly distributed; UIs don't pretend-paginate better at higher volumes.
- Source: KD-0404
Audit trail — read from issue_audit_logs, no new issue_activities table
- Chose: Transform existing audit-log JSON snapshots to per-field changes at read time in
GetIssueActivitiesAction. - Rejected: New purpose-built
issue_activitiestable withbatch_idUUID grouping; OR hybrid audit + materialized view. - Why: Audit log already captures every issue change. A second writer hooked into every mutation duplicates data and risks drift; a materialized view adds cache-invalidation complexity for an MVP read path.
- Source: KD-0417
Audit-trail relation resolution — server-side; enums client-side
- Chose: Server resolves
lane_id,assignee_id,sprint_id,epic_idto display objects (withwithTrashed); client resolvespriority/typeenums viaenumCollection.fromValue(). - Rejected: Full server-side resolution; OR full client-side resolution from stores.
- Why: Frontend stores don't always have deleted entities loaded — needed for historical audit entries. Enums are stable lookup tables that the client already knows about.
- Source: KD-0417
Audit-trail tab UI — inline tab strip, not MenuTabs with queryKey
- Chose: Local
activeTabref with simple flex-container styled buttons. - Rejected: Reuse
MenuTabsshared component. - Why:
MenuTabsis designed for router-link navigation with routes/queries — overkill for a same-page two-state toggle. ~20 lines of inline markup matches the design exactly. - Source: KD-0417
Audit-trail store — read-only composable, not adapter-store
- Chose: Simple
useActivityStorecomposable returning{ activities, isLoading, fetch }. - Rejected: Adapter-store factory.
- Why: Activities are read-only derived data — the adapter pattern's mandatory
NewAdapted<T>type and create/update/delete methods would be unused. - Source: KD-0417
Nullable scalar coercion — three-layer enforcement, not single string-scan rule
- Chose: Layer 1 (Pest DTO test) + Layer 2a (PHPStan rule) + Layer 2b (Pest heuristic).
- Rejected: String-scan ban on forbidden patterns (
!== 0 ?,!== '' ?,isset($validated[) inFormRequestsTest.php. - Why: String scanning can't tell nullable from non-nullable contexts; the same
!== ''is a bug for nullable fields and legitimate for non-nullable ones. False positives erode trust in arch tests. - Source: KD-0413
Inline input() !== null ? integer() : null pattern, no helper trait
- Chose: Verbose canonical pattern repeated per field.
- Rejected:
ExtractsNullabletrait withnullableInt()/nullableEnum()helpers. - Why: PHP has no generics —
nullableEnum()return type would bemixed, losing type safety. Adds scope to a bug fix; arch tests would need to recognise the helper as valid. - Source: KD-0413
Scalar guard scope — int only initially, expand to string/float/bool in follow-up
- Chose: Phase 1 covers
intonly; phase 2 (KD-0477) extends coverage. - Rejected: Cover all nullable scalars in one pass.
- Why: Layer 2b heuristic broadens substantially for strings (non-nullable strings are common); landing the rule for
intfirst builds team experience with the enforcement pattern. - Source: KD-0413
Scalar guard — require input() !== null, ban has()
- Chose: PHPStan rule strengthened to require exactly
->input(name) !== null ? ->scalar(name) : null. - Rejected: Accept any
Ternaryas a valid guard. - Why:
has('field')is semantically wrong — it returns true when the key was present even with a null value, so the bare extraction still coerces null to 0/''/false/0.0. The correct question is whether the value is non-null. - Source: KD-0477
Scalar guard — fix bool = false defaults uniformly, no allow-list
- Chose:
LoginUserData::$rememberMeandSaveRoleData::$accessAllProjectschange to?bool = null; consuming Actions use$data->field ?? false. - Rejected: Allow-list these as semantically equivalent ("absent = false").
- Why: The same "absent = default" argument applies to
GlobalSearchIssuesData::$excludeFinalLane = false, which is explicitly being fixed as a bug. Case-by-case semantic carve-outs aren't enforcement at all — they are exactly the reasoning the rule prevents. - Source: KD-0477
Default limit belongs in the Action as a constant, not in toDto()
- Chose: DTO carries
?int $limit = null; Actions defineprivate const DEFAULT_LIMIT = 25and applymin($data->limit ?? self::DEFAULT_LIMIT, MAX_LIMIT). - Rejected: Hardcode
25intoDto()via$safe->has('limit') ? $safe->integer('limit') : 25. - Why:
toDto()is the wrong layer for a business default; hardcoding it duplicates knowledge between the DTO and the Action's capping logic and reintroduces bannedhas()usage. - Source: KD-0477
HTTP timeouts — frontend-only scope; nginx fix tracked separately
- Chose: Ship client-side timeout discipline (KD-0584); nginx
fastcgi_read_timeoutstays under KD-0149. - Rejected: One combined ticket covering frontend + nginx config.
- Why: Blends Vue work with infra/nginx in one PR — cross-cutting review surface; KD-0149's scope explicitly anticipates a separate implementation ticket. Both halves are independent (kendo is still on fs-http 0.1.x with no client timeout).
- Source: KD-0584
Timeout values — named constants in shared/, not typed wrapper functions
- Chose:
shared/constants/http-timeouts.tsre-exported from per-appservices/http.ts. - Rejected: Per-app constants in each
services/http.ts(drift risk); OR typed wrappers likeuploadRequest/agentRequest(more indirection, harder to grep). - Why: Two apps need timeout discipline; re-export keeps app-local imports while preventing drift between tenant and central. Mirrors the existing
shared/constants/icons.tsprecedent. - Source: KD-0584
Timeout — 120s for both UPLOAD and AGENT
- Chose: Match PHP's
set_time_limit(120)exactly. - Rejected: UPLOAD 300s (generous margin); OR 60s on both (match nginx ceiling).
- Why: Once KD-0149 raises
fastcgi_read_timeoutto 125s, the full chain (PHP 120s → nginx 125s → client 120s) is internally consistent. 60s would leave AI calls aborting before PHP's own deadline; 300s gives 5 minutes of "is this hung?" UX with no progress feedback for 40 MB uploads. - Source: KD-0584
Route hygiene epic — five small PRs, one arch test per PR
- Chose: Split route-hygiene work into five sequential PRs (KD-0636 → KD-0640), each shipping one arch test green with no allowlist.
- Rejected: One mega-PR bundling all four anti-pattern bans + meta unification; OR two PRs (hygiene then meta).
- Why: A 600+ line auth-adjacent diff makes review fatigue and rollback granularity worse; one rule per PR means a single regression fails to a single PR and reordering stays cheap.
- Source: KD-0636 (epic 29)
Route meta — flat object with permissions: readonly PermissionMeta[], empty array = no check
- Chose: Flat
RouteMetawithpermissionsalways present;[]means "no resource check"; typed viaPermissionResourceEnumValue/PermissionActionEnumValueenum-value unions. - Rejected: Discriminated union of
{public} | {dual} | {private}with apermission: PermissionMeta | 'any'sentinel string. - Why: Unions and sentinel strings (
'any') were explicitly disliked; empty array is the standard JS "no items" representation, TypeScript still catches missing fields, and the same flat-types-over-unions preference now applies as a generalised rule. - Source: KD-0636 (epic 29)
Billing access — add BILLING to PermissionResourceEnum + backfill migration
- Chose: New
Billingcase inPermissionResourceEnumwithREAD+UPDATEactions and a migration inserting admin-role rows. - Rejected: Map billing to existing
APP_SETTINGSresource; OR keeprequiresAdmin: trueas a third meta variant. - Why: Reusing
APP_SETTINGSis a semantic mismatch (it gates other settings billing-viewers shouldn't see), and keepingrequiresAdmindefeats the meta-unification by leaving two parallel auth concepts; a dedicated resource also unlocks tenant-level granular billing roles later. - Source: KD-0640 (epic 29)
Public/auth route names stay bare; arch test exempts them via inline documentation
- Chose: Keep
login,signup,pricing,dashboard, etc. as bare names; document the exempt list in the arch test as a comment rather than a code-level skip. - Rejected: Rename every public route to
<domain>.<page>for a single strict arch rule with no exemptions. - Why: Route names are slated to seed page titles later, and
auth.loginreads worse as a title thanlogin; the "allowlist" only governs which names stay bare, not which routes can bypass the factory — so the arch test still runs zero-violation against every file. - Source: KD-0638 (epic 29)
Route feature meta stays loose string, no typed literal union
- Chose:
feature?: stringonmetaOverrides. - Rejected:
type FeatureFlag = 'reportTool' | 'featurePlanner'literal union. - Why: The source of truth for feature flags lives in GrowthBook config, not TypeScript — a TS union would drift from real config and
useFeatureActive(name: string)already accepts a plain string downstream. - Source: KD-0636 (epic 29)
Every route declares permissions: explicitly — no factory default fallback
- Chose: Force every route to declare
permissions:(using[]for "no resource check"), migrating ~20 routes that previously inherited the factory default. - Rejected: Keep the factory default
{authOnly: true, canSeeWhenLoggedIn: true}and only declarepermissionswhen there's a real gate. - Why: Silent defaults re-create the bug they're meant to prevent — forgetting the field stops being a TypeScript error, and each route's access intent should be readable on the page rather than inferred from absence.
- Source: KD-0640 (epic 29)
Tenant name uniqueness — enforce via DB unique index + form rule
- Chose: Add tenant-scoped unique indexes on
projects.nameandteams.name, plus the missinguniquerule onStoreProjectRequest. - Rejected: Drop the existing
uniquerules from update requests so duplicates are allowed everywhere. - Why: Uniqueness is a one-way door — relaxing it later is a one-line index drop, but imposing it on production data full of duplicates needs a conflict-resolving data migration; humans identify projects by name in standups and Slack, so duplicates feel like bugs.
- Source: KD-0592
Column-widening migrations — group by transformation, not by table
- Chose: One migration per schema transformation (
varchar→textfor teams;text→mediumTextfor issues + reports in a single closure). - Rejected: One migration per table (most atomic); OR one migration covering all three columns.
- Why: Per-table maximises revertability but inflates file count past the issue body's stated "two migrations"; one-big-file loses bisectability if one tenant DB rejects one column — grouping by transformation keeps each file a single schema-level intent and matches the precedent set by KD-0585.
- Source: KD-0593
Column-widening down() mirrors up() simply — no defensive data-length guard
- Chose:
->string('description')->change()/->text('description')->change()indown(), identical to KD-0585. - Rejected: Throw in
down()if any row would exceed the smaller cap; OR emptydown()as forward-only migration. - Why: A defensive row-count guard is bespoke logic no other migration does and adds query surface; empty
down()diverges from Laravel + the established precedent and breaksmigrate:rollbackflows — rollback risk is the operator's, identical to every other column-widening migration. - Source: KD-0593
Description round-trip test — single combined file, one case per validation cap
- Chose: Single
DescriptionLengthRoundTripTest.phpwith four cases (project + team at 5,000 chars; issue + report at 65,535 chars), each usingstr_repeat('🎉', cap)4-byte emoji. - Rejected: Per-domain test files mirroring KD-0585's
ProjectDescriptionLengthTest.php; OR fixed-length emoji (e.g. 1,000 chars) regardless of column. - Why: The acceptance criterion "all four columns can store the maximum" must map row-for-row to test cases from one file, and fixed-length 4,000-byte payloads would still fit
varchar(4096)— proving nothing about the actual column boundary the migration moves. - Source: KD-0593
Route-factory post-processing ban — line-scan + regex, not AST
- Chose: Hand-rolled line-scan + paren-depth tracker in a Pest/Vitest arch spec, mirroring
code-quality.spec.tsandbroadcast-listeners.spec.ts. - Rejected: AST parser (
ts-morph/typescriptcompiler API) walking the call-expression tree. - Why: No existing arch test uses AST tooling — adopting it for one rule adds a devDependency just for this check; route files are constrained enough by sibling arch tests that the regex's brittleness window stays small, and the line-scan pattern is now a reusable shape across arch specs.
- Source: KD-0636
Planner route migration — direct createStandardRouteConfig, not widen createNestedDomainChildren
- Chose: Convert
plannerChildrento a direct factory call with composed name${FEATURE_PLANNER_DOMAIN_NAME}${OVERVIEW_PAGE_NAME}, matchingissueChildren/reportChildrenshape. - Rejected: Fold a minimal
createNestedDomainChildrenmeta-override widening into PR a so the existing nested-factory call can pass{feature: 'featurePlanner'}through. - Why: Widening
createNestedDomainChildrenis explicitly PR d's scope — bundling it into PR a breaks the "one arch test per PR" boundary and leaves PR d with nothing to do for that factory; planner only has an overview component anyway, so the nested factory was filtering 3 of its 4 outputs. - Source: KD-0636
Inline pricingRoute stays in router/index.ts — no dedicated domain folder
- Chose: Keep
pricingRouteas a factory call insidecentral/router/index.ts. - Rejected: Move it to a new
pricing/route.tsdomain folder. - Why: The arch test scans aggregators too, so a factory call there already satisfies the rule; a domain folder for one public route with no co-located
pages/directory is organisational overhead with no payoff. - Source: KD-0638
Export PermissionMeta from routes factory — don't hide it behind an inferred return type
- Chose: Export
PermissionMetapublicly when introducingcreateRedirectRoute. - Rejected: Give the new factory an explicit return type that doesn't reference the private
PermissionMetainterface. - Why: TypeScript can't construct public re-exports without naming the interface; an explicit return type duplicates shape knowledge and PR e will refactor
PermissionMetato a typed enum-value union anyway — exporting it now lines up cleanly with the upcoming change. - Source: KD-0638
CI self-hosted — stop optimising at 5:12 wall-clock, leave the floor untouched
- Chose: Ship the cache layer (PHPStan/Rector/Deptrac + ESLint/vue-tsc/cspell/knip) and stop; backend-feature-tests' 3:15 critical path sets a ~3:50 floor.
- Rejected: Consolidate frontend gates into one job (10-15s); host-cache
node_modulesto skipnpm ci(25-35s, largest single lever); cache.vite/.cache/vitest; sub-shardTest projects/issues. - Why: 5:12 is well under the original 15-min regression cliff, and pushing toward the floor would cost three more cache wrappers, a multi-tool job, and
node_modulespostinstall-script risk — readability ofci.yml's "one job per concern" shape was worth more than the remaining 80s of theoretical headroom. - Source: ci-self-hosted-speedups
Cap parallel-by-default tools globally, not just in CI
- Chose: Commit Rector's
parallel().jobs(4)inrector.php; PHPStan's--memory-limit+ worker cap lives in a separatephpstan-ci.neonso localcomposer phpstankeeps defaults. - Rejected: Cap everything via CI-only config files / env vars; OR cap everything in the shared config.
- Why: Rector's default 16 workers overloads dev laptops too — committing the cap helps both environments — but PHPStan's default fan-out is fine locally, so its CI-only override (including
tmpDir: .phpstan-cache) belongs inphpstan-ci.neonto keep local invocations untouched. - Source: ci-self-hosted-speedups
ESLint cache uses --cache-strategy content, not mtime
- Chose:
eslint --cache --cache-strategy contentwith the cache file in a frontend-rooted path wrapped byactions/cache@v5. - Rejected: Default mtime-based cache strategy in
node_modules/.cache/.... - Why:
npm ciwipesnode_modulesandactions/checkoutresets mtimes on every run — mtime invalidates the cache 100% of the time, defeating the cache entirely; content-hashing survives checkout and a frontend-rooted path survivesnpm ci. - Source: ci-self-hosted-speedups
Nullable-scalar extraction — extract into a trait once the idiom hardened (reverses KD-0413)
- Chose: Build the
ExtractsNullableScalarstrait blueprinted on the existingExtractsIntArraytrait, deduping 23 inlineinput() !== null ? TYPE() : nullsites plus 3 copied privatenullableInt()methods. - Rejected: Keep the verbose inline pattern per field (the position KD-0413 took when it rejected an
ExtractsNullabletrait), or fold the methods intoExtractsIntArray, or introduce a base FormRequest subclass. - Why: KD-0413's "no helper" reasoning was about type-safety on a generic helper, but the now-arch-enforced
input() !== nullsemantics are stable enough to extract verbatim — the two PHPStan rules early-return outsidetoDto()and only match aValidatedInputreceiver, so a$this->-receiver trait method passes all gates while DRY-ing the duplication, and folding intoExtractsIntArraywould erode its single responsibility. - Source: KD-0483
Eager-load contract — omit EAGER_LOAD in a subclass rather than redeclare the inherited empty default
- Chose: Resources that load no relations declare nothing;
static::EAGER_LOADresolves to the base-class[]via late static binding, andfrom()skips the no-oploadMissing/validateRelationsLoadedcalls. - Rejected: Declare an explicit
EAGER_LOAD = []in each subclass "for consistency" and callloadMissing([])to match the shape of resources that do load relations. - Why: A subclass constant identical to the inherited value is pure noise that makes a reader wonder what was intentionally omitted — "explicit" only adds value when the content differs, so a present
EAGER_LOADshould always signal meaningful entries. - Source: KD-0641
CI health-audit tooling — bash + jq, not a TypeScript script
- Chose: Write the CI health audit as ~350 lines of bash + jq, matching the one existing repo script (
check-skill-table.sh). - Rejected: A TypeScript (tsx) script with cleaner JSON aggregation and median/log-parse code.
- Why: There is no root
package.json, so a TS script would have to introduce a tsconfig + deps +npx tsxinvocation with no offsetting test-coverage win (an ops script carries no test obligation either way), whereas bash+jq reusesghandjqthat are already present and matches the precedent the next engineer reads alongside it. - Source: KD-0810
Cache-health probe scopes to refs/heads/development only
- Chose: Count cache entries only where
ref == refs/heads/development, the shared fallback every run reads. - Rejected: Count entries under the prefix on any ref (including the many
refs/pull/N/mergeentries). - Why: A prefix can look healthy from PR-ref caches while the dev-ref fallback is empty — exactly the shape of the motivating Rector-empty bug, which an any-ref count would have masked.
- Source: KD-0810
CI surfacing of the audit — separate dispatch/scheduled workflow, never gate PRs
- Chose: Lint the script via shellcheck in the existing
skill-tableCI job, and run the audit itself only from a separateci-health.yml(workflow_dispatch+ daily schedule) that renders a job summary and fails on 🚫 only. - Rejected: Run the audit as a step in
ci.ymlon every push/PR, gating theci-passedcheck. - Why: The audit queries historical runs (~70s + ~80 API calls) for data that changes slowly, so running it per-push is recursive, rate-limit heavy, and noisy — linting the script's correctness belongs on the cheap per-push gate, but executing the audit belongs on a cadence that never blocks a PR.
- Source: KD-0810
Nullable-enum extraction guard — extend the existing scalar rule, not a sibling rule
- Chose: Add the nullable backed-enum guard to
NullableScalarExtractionRule(one new bare-enum detector, keeping the class name); enums are scalar-backed so the name stays defensible. - Rejected: A new
NullableEnumExtractionRuleregistered alongside it inphpstan.neon. - Why: A sibling rule duplicates ~40 lines of boilerplate (
toDto()scope check, FormRequest-subclass guard, DTO-constructor resolution, the type-agnosticisCorrectlyGuardedTernary()helper) — and deduping it into a shared base is more churn than the feature warrants, whereas the existing rule's already type-agnostic helper makes the enum branch ~one new detector. - Source: KD-0826 D1
Nullable-enum guard flags both from() and tryFrom() — the danger is the argument, not the method
- Chose: Match both
EnumClass::from(...)andEnumClass::tryFrom(...)when the argument is a bareValidatedInputextraction on a nullable enum param. - Rejected: Exempt
tryFrombecause it already returns?Enum. - Why: A bare
$safe->integer('field')coerces an absent value to0before the enum sees it, sotryFrom(0)resolves to whatever case is backed by0(e.g.PriorityEnum::Highest) rather than null and passescomposer phpstanclean —tryFromis arguably the worse shape because it looks safe while silently landing on the most-severe case, and the argument-side check is identical for both. - Source: KD-0826 D3b
Nullable-enum guard requires a ValidatedInput argument — precision over recall
- Chose: Flag only
Enum::from(<validated-input extraction>), reusingdetectBareExtractionType()on thefrom()argument; covers both int- and string-backed enums generically. - Rejected: Flag any bare
Enum::from(...)on a nullable enum param regardless of argument source. - Why: The rule's mandate is "extraction-from-validated-input without the canonical guard," so gating on a
ValidatedInputreceiver mirrors the scalar rule exactly and avoids false positives on a legitimately non-null computed value assigned to a nullable param — a shape that doesn't occur and wouldn't be a regression. - Source: KD-0826 D3