Spec: F40 — Performance Optimization
Status: partial — shipped repository optimizations are contract-backed; production p95, live Redis, and deployed worker/observability evidence remain open
Contract Graph boundary
F40 is owned by contracts/features/F40.cue and its implementation audit. The repository proves query-shape limits, asynchronous chat state handling, batched beat paths, gzip, and cache-backed local behavior. It does not claim production latency, live Redis operations, deployed worker capacity, Sentry tracing, or cloud scaling from those tests.
1. Problem & CEO Job
Job: Trust the signal — but the signal degrades when brief generation runs a full table scan every 15 minutes across every tenant, when task lists issue 50+ unnecessary database queries, and when the AI chat endpoint blocks a Gunicorn worker for 30 seconds.
Lumi is performance-optimistic: no indexes on core operational models or application cache despite Redis; Celery beat iterates every organization with N+1 queries; AI chat blocks synchronously; and Gunicorn has 2 workers × 4 threads. This works for dozens of orgs, then breaks silently at hundreds. Make scale mechanical, not over-engineered: index real access patterns, cache where invalidation is trivial, and move slow work out of the request-response path.
2. User Stories & Acceptance Criteria
-
As a CEO loading the Today dashboard, I want the page to load in under 2 seconds, so that I see what needs attention immediately.
- API response:
GET /api/today/returns in < 500ms (p95). - Database: ≤ 5 queries for a typical Today page load.
- API response:
-
As a CEO loading task/project lists, I want paginated lists to render without visible lag, so that I can scroll through my team’s work.
-
GET /api/tasks/returns in < 300ms at 50 items/page (p95). -
GET /api/projects/returns in < 300ms at 50 items/page (p95).
-
-
As a CEO cold-opening an authenticated P0 route on the floor device, I want its critical modules to arrive without a long queue of tiny shared chunks, so that identity and route queries can start promptly.
- Every registered P0 manifest graph contains at most 70 local JavaScript files and still meets its existing gzip budget or recorded no-regression boundary. The 70-file ceiling is above the measured 67-file maximum and below the former 112-file worst case; forcing 40 required unrelated-code overfetch or invalid execution semantics and was rejected.
- The canonical floor trace proves the selected authenticated route’s identity and first route query start earlier than the 2026-07-15 baseline; byte/request improvements alone do not authorize a CWV score increase.
- A remembered browser session may warm only the matched authenticated P0 Layout/leaf graph before
main.tsx; the hint never authorizes access, public/unknown routes are excluded, and pending logout disables speculation. - Production HTML resolves the emitted content-addressed Geist Latin 700 face and places a parser-time font hint before both module entries only for remembered-session Today and Brief paths; all other routes avoid the request.
- The critical-font hint keeps auth/router authority unchanged and fails closed when the session hint is absent, storage is unreadable, or logout is pending. A missing emitted font fails the production build instead of silently dropping the optimization.
- Production HTML resolves the emitted Layout and Today/Brief leaf chunks and starts only the matched pair with parser-time
modulepreload; missing or duplicate assets fail the build, and the executable entry suppresses duplicate Today/Brief imports while retaining all six factories for stable chunk topology.
-
As a user returning to a tab after a deployment, I want stale lazy-route chunks to recover without a permanent error screen, so that routine releases do not strand the workspace.
- The first Vite preload failure replaces the current document with the same route plus a dedicated cache-bust token, forcing a fresh HTML/chunk graph instead of reusing a stale cached document.
- A guarded repeat failure is not swallowed into an undefined lazy-module payload; the original chunk-load rejection remains observable.
- The production Error Boundary retry uses the same cache-busted document recovery rather than a cache-reusing soft reload.
- Recovery preserves the current pathname, hash, and unrelated search parameters while replacing only Lumi’s dedicated recovery token.
-
As a system operator, I want Celery beat tasks to complete within a single beat interval, so that briefs are always fresh and tasks don’t overlap.
- Brief generation per org: < 30 seconds.
- System-wide beat cycle: < 15 minutes (the beat interval).
- No overlapping beat task runs (prevented by a lock or idempotency check).
-
As a user chatting with Lumi, I want the AI to respond without blocking other users, so that the app stays responsive under load.
- AI chat requests are offloaded to Celery. (chat/views.py:
process_chat_async.delay(...)— creates pending placeholder, Celery fills in the response; P10 Fixed in STATUS.md) - Chat response is delivered via polling (202 Accepted + client polls thread endpoint until
status != "pending"). (chat/views.py line 92: returns 202 with pending assistant_message; frontend polls) - Gunicorn workers are never occupied for > 5 seconds by any request — requires production load test to verify; not a code gap.
- AI chat requests are offloaded to Celery. (chat/views.py:
-
As a developer, I want query performance to be observable, so that regressions are caught before deploy.
-
django-debug-toolbaror equivalent profiler available in dev. - CI fails if any API endpoint exceeds a query count threshold (assertNumQueries).
- Brief generation query count ≤ 20 (P2 query consolidation: select_related + bulk_create).
-
3. Scope Boundaries
- In:
- Database indexes on core operational models (Task, Project, Commitment, Risk, DecisionRequest, Brief, PulseEntry).
select_related/prefetch_relatedoptimization on all list ViewSets.- Application-level caching of brief results, org settings, and pulse questions.
- Async AI chat processing (offload to Celery).
- Beat task batching (paginated org iteration, idempotency).
- Frontend bundle size audit and optimization.
- Frontend critical-path request-graph audit and bounded code splitting.
- N+1 query elimination in beat tasks.
- Performance monitoring (Sentry tracing already configured — verify coverage).
- Out:
- Table partitioning, sharding, read replicas — premature at current scale.
- CDN caching strategy (addressed in F38).
- Server-side rendering (stays SPA).
- Database migration to PostgreSQL read replicas.
- Full APM tooling (Sentry tracing is sufficient for now).
- Worker pool scaling (ECS auto-scaling is a deploy concern).
4. Data Model
4.1 New Database Indexes
All indexes are on the organization_id FK plus the most common filter/sort columns. These follow
the scale-ready design rule:
make the future upgrade mechanical, don’t perform the upgrade now.
Task (backend/apps/work/models.py):
class Meta:
indexes = [
models.Index(fields=["organization", "status"]),
models.Index(fields=["organization", "due_date"]),
models.Index(fields=["organization", "owner"]),
models.Index(fields=["organization", "project"]),
models.Index(fields=["organization", "team"]),
models.Index(fields=["organization", "priority"]),
]
Project (backend/apps/work/models.py):
class Meta:
indexes = [
models.Index(fields=["organization", "team"]),
models.Index(fields=["organization", "owner"]),
models.Index(fields=["organization", "visibility"]),
]
Commitment (backend/apps/operations/models.py):
class Meta:
indexes = [
models.Index(fields=["organization", "status"]),
models.Index(fields=["organization", "due_date"]),
]
Risk (backend/apps/operations/models.py):
class Meta:
indexes = [
models.Index(fields=["organization", "status"]),
models.Index(fields=["organization", "severity"]),
]
DecisionRequest (backend/apps/operations/models.py):
class Meta:
indexes = [
models.Index(fields=["organization", "status"]),
]
Brief (backend/apps/briefs/models.py):
class Meta:
indexes = [
models.Index(fields=["organization", "brief_date", "brief_type"]),
]
PulseEntry (backend/apps/pulses/models.py):
class Meta:
indexes = [
models.Index(fields=["organization", "user", "date"]),
models.Index(fields=["organization", "status"]),
]
OrganizationMember (backend/apps/organizations/models.py):
class Meta:
indexes = [
models.Index(fields=["organization", "status"]),
models.Index(fields=["organization", "user"]),
]
ChatMessage (backend/apps/chat/models.py):
class Meta:
indexes = [
models.Index(fields=["thread", "created_at"]),
]
4.2 No Schema Changes
Indexes are additive. No column changes, no constraint changes, no data migration.
5. API Contract
No new endpoints. No breaking changes to existing endpoints.
5.1 ViewSet Query Optimizations
| ViewSet | File | Change |
|---|---|---|
TaskViewSet | backend/apps/work/views.py | Add select_related("owner__user", "project", "team") to get_queryset() |
ProjectViewSet | backend/apps/work/views.py | Add select_related("owner__user", "team") |
RecurringTaskViewSet | backend/apps/work/views.py | Add select_related("owner__user") |
list_threads | backend/apps/chat/views.py | Add select_related("user__user") and remove N+1 in serializer’s get_last_message() |
CommitmentViewSet | backend/apps/operations/views.py | Add select_related("owner__user") |
RiskViewSet | backend/apps/operations/views.py | Add select_related("owner__user") |
DecisionRequestViewSet | backend/apps/operations/views.py | Add select_related("owner__user") |
5.2 Chat AI Processing (Async Migration)
Current: POST /api/chat/threads/{id}/messages/ → answer_chat_message() runs synchronously (blocking Gunicorn).
Target: POST /api/chat/threads/{id}/messages/ → creates message + dispatches Celery task → returns message with status: "processing". Client polls or listens via existing SSE endpoint for the AI response.
No API contract change to the existing endpoint signature — only the response behavior changes (message starts as “processing” and updates when AI completes).
6. UI / UX
No visual changes. Chat UX may show a “Lumi is thinking…” indicator while the AI response is being generated asynchronously — existing i18n key chat.thinking or new key.
7. Test Plan
| # | Case | Type | Where |
|---|---|---|---|
| 1 | Task list API returns ≤ 10 queries | backend | backend/tests/test_performance.py::TestBriefGenerationQueryCount |
| 2 | Brief generation runs with indexes (not full table scans) | backend | verify index usage via EXPLAIN ANALYZE in test |
| 3 | AI chat offloads to Celery and returns 202 + pending placeholder | backend | backend/tests/test_performance.py::TestChatApiAsyncResponse |
| 4 | Beat task does not overlap with previous run (cache.add SETNX lock) | backend | See §11 row 4 (P13 distributed-lock skip path); no standalone test class |
| 5 | Celery task retries on transient failure with backoff | backend | Planned — no dedicated test yet (coverage gap) |
| 6 | Registered P0 route graphs enforce gzip and local-JavaScript request budgets | frontend | frontend/src/test/_shared/bundle-budget.test.ts + scripts/check-bundle-budget.mjs |
| 7 | Composite indexes exist | backend | Migrations 0006_add_composite_indexes_p3 (work) / 0005 (operations); query-count tests in test_performance.py (see §11 row 1) |
| 8 | Authenticated route waterfall is measured on the canonical floor profile | frontend / browser | scripts/measure-cwv-p0.mjs --only=<route> with CWV_TRACE=1 |
| 9 | Stale lazy-route failures cache-bust the document, preserve route context, retain the original error when guarded, and use the same recovery from Error Boundary | frontend | frontend/src/test/_shared/staleChunkReload.test.ts + frontend/src/test/_shared/ErrorBoundary.test.tsx |
| 10 | Cloudflare Pages serves known SPA paths from the current entry with HTTP 200, while never rewriting a missing hashed asset into index.html | frontend | frontend/src/test/_shared/auth-custody.test.ts + frontend/src/test/_shared/pages-spa-fallback.test.ts + frontend/functions/[[path]].ts + frontend/public/_routes.json + frontend/public/_redirects + generated dist/404.html |
8. Current Behavior
See docs/performance/PERFORMANCE_AUDIT.md for the full 30+ finding audit. Key highlights:
Fixed (Previous Rounds)
- Beat tasks iterate all orgs (P1) — fixed with
Organization.objects.iterator(chunk_size=50)and distributed lock prevention; 15-min beat tasks no longer N+1 per org. - Brief generation indexes + query consolidation (P2) — composite indexes added; queries now use
(organization, status)and(organization, due_date)indexes. All Task queries useselect_related("owner__user"). BriefItems created in bulk. Queries reduced from 39 to ≤20 per org. - Zero custom indexes (P3) — composite indexes on Task, Commitment, Risk, DecisionRequest, Brief, PulseEntry, OrganizationMember, ChatMessage all shipped.
- select_related/prefetch_related (P4, P5) — TaskViewSet, ProjectViewSet, RecurringTaskViewSet, CommitmentViewSet, RiskViewSet, DecisionRequestViewSet, and ChatThread list all have proper eager loading.
- Celery retry policies (P12) — retry backoff with jitter on all beat tasks.
- No overlapping beat tasks (P13) — Redis
SETNXdistributed lock on all beat tasks. - Frontend bundle size (P19) — lazy-loaded i18n locales, route-level splitting, and interaction-only Tasks/Deals dependencies keep registered routes below their per-route targets. Budget verdicts compare integer on-wire bytes with strict
<limits before formatting; tables retain three decimals so boundary outcomes are reviewable. The latest candidate Tasks slice measures 256.384 kB/38 files statically and 298.362 kB in a three-run seeded floor trace after removing a redundant owner-initial avatar while preserving the owner name. The full post-merge PM route set remains the scoring authority. - Pre-main matched-route warming (P20/P49) — the separate preload entry remains because focused Tasks controls proved it outperformed parser-earlier route-hint variants. P49 instead resolves Today/Brief’s emitted Latin 700 face at build time and injects one parser-time, session-gated preload before both module entries. Focused candidate evidence improves Today floor LCP from 3724 to 3532 ms while preserving Tasks at 3016 ms versus 3040 ms; merged-main browser evidence remains required before any W-SYS-01 score change.
- Route-level code splitting (P20) — React.lazy() for all route components.
Remaining
- AI chat blocks Gunicorn for up to 30 seconds (P10) — ADR-011 accepted, but code changes (ChatMessage.status field, Celery task, async send_message) have not yet landed in any PR. 8 concurrent chat requests would saturate the entire backend (2 workers × 4 threads).
- No Celery result backend (P14) — deferred; task results discarded, no monitoring.
- 100% synchronous codebase (P11) — deferred; full ASGI migration premature at current scale.
- Connection pooling (P23) — deferred; pgbouncer when connection count becomes bottleneck.
- Redis application caching gaps (P8 remaining items: org settings, pulse questions) — brief results cached; remaining targets deferred to next round.
Medium
- Redis running but zero application-level caching.
- Granian configured for only 2 processes × 4 threads = 8 concurrent requests.
ORDER BY RANDOM()used on OrganizationMember queryset in brief generation for “random pulse” — non-deterministic and expensive.- No task retry policies on any Celery task.
- No celery task result backend.
Positive findings
- Some models (Deal, Company, Message) have proper indexes.
select_related/prefetch_relatedused correctly in BriefViewSet.- Sentry tracing configured at 10% sample rate with continuous profiling.
- Vite code splitting with
vendor-react,vendor-state,vendor-httpchunks. CompressedManifestStaticFilesStoragefor WhiteNoise static serving.- Multi-stage Docker builds keep images small.
9. Error States
| Scenario | Behavior |
|---|---|
| Celery task for AI chat fails | Message status set to “error” with retryable flag; user sees error toast with retry button |
| Beat task takes longer than interval | Next run skips (idempotency check via Redis lock or DB flag). Logged to Sentry. |
| Index migration fails | Django migration is atomic — rolls back. Index creation is CONCURRENTLY in production (manual step documented in migration). |
| Query count exceeds threshold in CI | CI fails with query count report |
| N+1 regression introduced | CI fails — assertNumQueries test catches it |
10. Diagrams & Docs Touched
- New: docs/performance/PERFORMANCE_AUDIT.md — full finding-by-finding audit.
- New: docs/performance/STATUS.md — tracker with status/assignee/target.
- backend/DIAGRAMS.md: add Celery async chat flow, index strategy section.
- backend/COOKBOOK.md: add
select_related/prefetch_relatedpattern and index creation template.
11. Implementation Audit (2026-06-12, updated 2026-06-14)
| # | Claim | Status | Evidence |
|---|---|---|---|
| 1 | Composite indexes on Task, Project, Commitment, Risk, DecisionRequest, Brief, PulseEntry, OrganizationMember, ChatMessage | ✅ match | P3; migrations 0006_add_composite_indexes_p3 (work), 0005_add_composite_indexes_p3 (operations); §13 |
| 2 | select_related/prefetch_related on all list ViewSets (Tasks, Projects, Recurring, Chat, Commitments, Risks, Decisions) | ✅ match | P4/P5; §13 2026-06-12 Fleet-C R11; test_task_list_query_count, test_project_list_query_count ≤10 queries |
| 3 | Beat tasks use Organization.objects.iterator(chunk_size=50) (no N+1 per org) | ✅ match | P1; 7 beat tasks updated; §13 2026-06-12 Lead session 2 |
| 4 | Beat tasks use cache.add(SETNX) distributed lock — no overlap | ✅ match | P13; lock-skip path tested for each task |
| 5 | Brief generation ≤20 queries with select_related + bulk_create | ✅ match | P2; §13 2026-06-13 Fleet-B T3-B-r02; test_brief_generation_query_count (≤20) |
| 6 | Async AI chat: send_message → 202 + pending placeholder → process_chat_async Celery task | ✅ match | P10; §13 2026-06-13 T3-C R10; ChatMessage.status field + migration 0003_add_chatmessage_status |
| 7 | Frontend bundle and request graph stay bounded after lazy-load + chunk consolidation | ✅ match | Registered P0 graphs remain under the 70-file cap and strict raw-byte target comparisons reject equality before display rounding. Tasks passes at 256.384 kB/38 files statically and 298.362 kB in a focused three-run seeded floor trace after redundant owner decoration was removed without removing the owner name. Source Landing remains a static diagnostic hold governed by its separately enforced deployed document, and a full post-merge PM browser train is still required before any W-SYS score change. |
| 8 | GZip middleware (GZipMiddleware first in MIDDLEWARE) + DB connection pooling (CONN_MAX_AGE=60) | ✅ match | P14/P23; §13 2026-06-13 T3-F R11 |
| 9 | Redis application caching: pulse questions cached (1h TTL) | ✅ match | P8; §13 2026-06-14 T3 R12 Agent A; TestPulseQuestionsCache (3 tests) |
| 10 | GET /api/today/ < 500ms p95; GET /api/tasks/ < 300ms p95 | ⚠️ gap | §2 ACs [ ] (unstruck); no p95 load test; assertNumQueries verifies query count but not wall-clock latency under load |
Summary: 9/10 match; 1 gap (p95 latency ACs require production load test). F40 findings all resolved or formally deferred.
Edge Cases Catalog (formerly §9, 4 items): E1 change improving median but worsening p95 CEO flows (gap above — load test needed); E2 cached summaries must invalidate on tenant-scoped writes (pulse questions cache is static constants, not per-tenant — invalidation not needed; future per-tenant cache must handle this); E3 optimizations must not break output: ‘export’ or static hosting (Vite config preserved; P20 lazy loading uses React.lazy() compatible with SPA); E4 public marketing vs authenticated CEO flow budgets must be measured separately (§7 test 6 covers bundle; no separate flow budget test yet).
12. Open Questions / Decisions
- Chat async delivery mechanism: SSE (already used in messaging) vs. polling vs. WebSocket. Decision: reuse existing SSE infrastructure from messaging (F29). Chat thread messages already stream via SSE.
- Beat task idempotency approach: Redis lock (
SETNX) vs. DB flag vs. Celeryonceextension. Decision: Redis lock — Redis is already the broker, andSETNXwith TTL is trivial. - Index creation in production: Django migrations create indexes with
CREATE INDEX(blocks writes on large tables). Decision: document manualCREATE INDEX CONCURRENTLYstep for production migrations on tables with > 10K rows. - Query count threshold in CI: What’s the acceptable ceiling per endpoint? Decision: start with 20 queries for list endpoints, 10 for detail endpoints, 5 for simple GETs. Tune down over time.
13. Bug / Change Log
2026-07-31 — Current production-deploy candidate bundle baseline
- The W-SYS-04 static-manifest no-regression ceilings now match the measured current production-deploy candidate with the existing 1 kB deterministic-build allowance. The CI failure was a stale baseline mismatch across the authenticated route graph, not an executable violation of the route request caps or the 300 kB authenticated-route targets.
/store/enretains its separate 170 kB end-state static-manifest target. The current 210 kB graph remains an explicit warning and does not become a target pass through the accepted no-regression ceiling; its deployed prerendered document is measured separately.
2026-07-17 — Brief summary paint boundary
BriefPagekeeps the generated summary and metadata in the first committed data frame, then starts the interaction-heavyBriefItemsViewimport after two animation frames. The fixed-height Suspense region preserves layout while item cards and every action remain fully reachable.- A frame-controlled test prevents the item renderer from returning to the summary commit. The candidate static Brief graph remains inside its no-regression ceiling.
- Focused three-run evidence improves Brief 3604→3344 ms LCP with CLS 0.000 and essentially unchanged transfer (289.782→289.793 kB). Tasks improves 3068→3044 ms with unchanged custody. Exact merged-main PM scoring remains separate.
2026-07-16 — Brief nested state custody restores the empirical budget
Type: Frontend critical-path request custody
BriefPagealready made the Project view lazy, but the default Type renderer statically re-importedBriefProjectViewandLumiDidBriefSection. That nested import defeated the route boundary and loaded project rollups, select/dialog primitives, and their dependencies before the empirical transfer settled.BriefItemsViewnow resolves both modules only in the states that render them. The default item list, evidence, and item actions remain complete; hovering or focusing the Project toggle still warms the same project chunk before intent.- In a same-seed three-run floor sample, Brief moved from 310.2 to 292.6 kB empirical JavaScript and from 3684 to 3552 ms LCP. FCP remained below target at 1332 ms and CLS remained 0.000. This closes focused readiness only; the PM-owned complete merged-main train still governs W-SYS-04 scoring.
Tests:
frontend/src/test/_shared/bundle-budget.test.tsfrontend/src/test/intel/BriefPage.test.tsx- focused
measure-cwv-p0.mjs 3 --only=brief
2026-07-16 — Public hero media leaves authenticated SPA startup
Type: Frontend critical-path request custody
- The shared SPA document no longer gives the 43 kB public Landing hero image high-priority
preload status on every authenticated route. The prerendered Landing keeps the real hero
<img loading="eager" fetchPriority="high">, so its deployed document still discovers the actual LCP asset without a JavaScript dependency. - In exact-main, same-backend three-run floor A/B samples, Pulse moved 3596→3408 ms LCP and 1448→1340 ms FCP, while Chat moved 3472→3256 ms LCP with unchanged route JavaScript and CLS. The prerendered Landing control moved 1184→1140 ms LCP, so the authenticated gain does not trade away the public first view.
- These are focused readiness measurements, not a W-SYS-01 score promotion. All three routes retain their existing canonical score ownership and complete-train requirements.
Tests:
frontend/src/test/_shared/bundle-budget.test.ts- production
pnpm buildplus 224/224 public-route prerender - focused
measure-cwv-p0.mjs 3Pulse, Chat, and Landing A/B samples
2026-07-16 — Authenticated route chunks start before the app entry
Type: Frontend critical-path scheduling
- A separate tiny Vite entry reads only the existing non-secret remembered-session hint and pending-logout flag, then starts Layout plus the matched P0 leaf import before the full app entry graph evaluates. F01 refresh and TanStack Router remain authoritative.
- The build plugin fails closed if the early entry is missing, folded into the app graph, or cannot be inserted before the development or hashed production app script.
- In the same three-run floor harness, authenticated LCP medians moved Today 4144→4104 ms, Brief
4208→4088, Chat 3684→3456, Tasks 4192→3708, Pulse 3976→3572, and Deals 3796→3528. The accepted
shared-entry movement is 1.3–1.4 kB from exact main and 0.3–0.4 kB beyond the prior
+1 kBtolerance ceilings; all empirical authenticated routes remain below 300 kB. Exact merged-main scoring remains PM-owned and unchanged in this train.
Tests:
frontend/src/test/_shared/routePreloaders.test.ts- production
pnpm buildentry-graph assertion - full canonical
measure-cwv-p0.mjs 3candidate matrix
2026-07-16 — Deals state-conditioned startup custody
Type: Frontend route-graph performance
- Saved-view mutations, the pipeline picker, collapsed conversion/aging, Calendar-only activity data, and chart implementations now mount only from their owning state or interaction. Pipeline analytics wait for a pipeline id.
- The read-only forecast chip is independent from its editable Select, and Deals imports exact page primitives instead of the full page barrel.
- The production graph falls from the 289.1 kB / 56-file merged ceiling to 257.8 kB / 44 files. A focused three-run seeded floor trace records 291.8 kB, 4052 ms LCP, 1480 ms FCP, and 0.013 CLS. Transfer passes; LCP remains a hold.
- PM-owned merged-main evidence is deliberately unchanged in this engineering train.
Tests:
frontend/src/test/_shared/bundle-budget.test.tsfrontend/src/test/crm/DealsPipelineSelector.test.tsx- focused saved-view, analytics, forecast, URL-state, CRUD, and contact tests
2026-07-16 — Brief and Chat state-conditioned startup custody
Type: Frontend route-graph performance
- Brief now warms project grouping from view-toggle intent, loads assignment and dismissal dialogs only from item-action intent, starts the members query only inside assignment, and defers share/autonomy implementations until required.
- Empty Chat preserves its prompt, thread rail, and trust entry while the active message/composer stack, conditional banners, trust popover, and delete dialog stay outside the initial graph until their owning state or interaction exists.
F40.AC005.BRIEF_STATE_CUSTODYandCHAT_STATE_CUSTODYlock these source boundaries. Focused production graphs are 261.9 kB / 41 files for Brief and 263.1 kB / 41 files for Chat. Three-run floor medians record 290.9 kB and 299.0 kB respectively; canonical scores still require the PM-owned full train.- Lazy-entry registration adds 1.4 kB to the shared source-entry map; the post-train baseline records that bounded cost and tightens every authenticated route ceiling. The separately enforced deployed Landing document stays 0.5 kB.
Tests:
frontend/src/test/_shared/bundle-budget.test.tsfrontend/src/test/intel/BriefPage.test.tsxfrontend/src/test/intel/BriefPage.share.test.tsxfrontend/src/test/intel/ChatPage.test.tsx
2026-07-16 — Tasks confirmation-dialog custody
Type: Frontend route-graph performance
- Extracted row-delete and dirty-form discard confirmations from the cold Tasks graph into an intent-warmed lazy module. Delete pointer/focus and the first dirty-form transition preload the module before confirmation is requested.
- Extended
F40.AC005.TASKS_CUSTODYto reject eager dialog imports while the existing Tasks suite continues to prove both confirmation workflows. - The focused production graph falls 286.3→273.1 kB gzip and 51→43 files. A three-run seeded floor subset records 299.8 kB browser JS, 3888 ms LCP, 1484 ms FCP, and 0.000 CLS. This is engineering evidence, not authorization to edit the PM-owned W-SYS score.
Tests:
frontend/src/test/_shared/bundle-budget.test.tsfrontend/src/test/work/TasksPage.test.tsx
2026-07-16 — Defer the desktop CommandDock payload after initial route commit
Type: Performance behavior change
The authenticated shell now waits for the initial route content to commit, then gives the browser a
750 ms quiet window before mounting the desktop CommandDock. This keeps the desktop rail, unread
queries, panel behavior, mobile intent trigger, and composer-surface suppression unchanged while
removing the non-critical dock payload from the first route paint competition. The focused Layout
proof is registered as F40.AC005.DOCK_QUIET_WINDOW; no browser floor score is claimed by this
engineering slice.
Tests:
frontend/src/test/_shared/Layout.test.tsxfrontend/src/test/_shared/CommandDock.test.tsx
2026-07-16 — Cloudflare SPA fallback rewrote missing deploy chunks to HTML
Type: User-facing reliability regression
The Pages /* → /index.html 200 rule rewrote a missing old content-hashed route chunk
after a deployment to the HTML shell. Browsers correctly rejected that response as a module
MIME-type violation; the existing cache-busted stale-chunk reload could then still see the
same bad graph. The fallback is limited to known application route families and build:ssg
generates the top-level 404.html from the current Vite app shell. This disables Cloudflare
Pages’ otherwise implicit SPA fallback, so /assets/* is served normally or fails as an
observable module load. A scoped Pages Function now serves declared client routes from the
current / asset with HTTP 200; _routes.json excludes immutable assets and avoids invoking
the Function for static/SSG traffic. The enforcing CSP now also permits
the Cloudflare Insights script and its collection endpoint, removing a separate telemetry-only
console violation.
Tests:
frontend/src/test/_shared/auth-custody.test.tsfrontend/src/test/_shared/pages-spa-fallback.test.ts
2026-07-15 — Tasks interaction-only dependency boundary
Type: Performance behavior change
Summary:
- Split the task create form, detail panel, and drag-and-drop reorder surface from the initial
/work/tasksgraph while preserving create, inspect, complete, delete, deep-link, dirty-discard, and focus behavior. - Reorder is now an explicit icon control that loads the existing accessible DnD surface on intent; the normal task list remains fully actionable without that library.
- Added source-custody and Tasks behavior tests. Static JS fell 360.7→286.3 kB gzip (61→51 files); the seeded floor subset fell 409.3→326.1 kB browser JS and 6440→5248 ms LCP.
- W-SYS-01 and W-SYS-04 remain HOLD 6 because the empirical route still misses the unchanged targets.
Tests:
frontend/src/test/_shared/bundle-budget.test.tsfrontend/src/test/work/TasksPage.test.tsx
2026-07-15 — Stale route chunks survived the recovery reload
Type: User-facing reliability regression
The deployed dev build loaded correctly in a clean browser, but a previously used browser profile
resolved lazy imports as undefined, reached the production Error Boundary, and remained there after
pressing Reload. The existing vite:preloadError handler called preventDefault() even when its
10-second guard suppressed another reload, masking the original chunk failure as Lazy module resolved without a usable default export. Its soft location.reload() could also reuse the stale cached HTML.
Recovery must perform one cache-busted document replacement and only suppress the Vite rejection when
that replacement was actually initiated.
2026-07-15 — Deals interaction-only dependency boundaries
Type: Performance behavior change
Summary:
- Split Deals create/edit, table, calendar, forecast, timeline, detail, bulk, chart, and Kanban DnD modules behind intent-warmed lazy boundaries while preserving the default Kanban cards, CSV export, URL view state, and DnD semantics.
- Added a card-preserving fallback while the Kanban wrapper loads on board intent; the default route stays usable during the boundary fetch.
- Static Deals JS fell 394.4→297.7 kB gzip and 67→59 local files, clearing the strict static target. The browser transfer and W-SYS score remain unclaimed until a fresh floor-profile measurement exists.
Tests:
frontend/src/test/_shared/bundle-budget.test.tsfrontend/src/test/crm/DealsCrud.test.tsxfrontend/src/test/crm/ContactDealPrefill.test.tsxfrontend/src/test/crm/DealsViewUrlSync.test.tsx
2026-07-15 — Authenticated critical-path request waterfall
Type: Performance architecture / build custody
The canonical Chat floor trace showed that refresh completed at 2.25 s, while 92 local JavaScript files kept the Layout/Chat dependency graph queued until roughly 4.5 s. Identity requests began at 4.82 s and the first Chat query at 6.31 s, producing LCP 6516 ms. Deterministic Lucide buckets and a small shared route-chrome chunk reduce registered graphs to 15–67 files. The median-of-three after trace moves identity to 3.79 s, first Chat queries to 5.21 s, and LCP to 5456 ms; empirical Chat JS falls 352.0→343.7 kB. Existing product byte targets remain unchanged. A more aggressive 40-file design was rejected because its non-recursive groups rendered the stale-chunk recovery screen; the strict execution-order remedy then regressed every byte baseline.
2026-07-13 — Cursor paths for high-growth chronological collections
Type: Performance + API compatibility
Extended the additive hybrid paginator from tasks to commitments, decisions, signal inbox, and
audit logs. Each cursor path is tenant-scoped, UUIDv7 -id ordered, backed by (organization, -id),
filter-preserving, and proven to issue no SQL OFFSET on page two. Risks and deals remain page-only
because their product ordering is not chronological and needs a separate compound-keyset design.
Tests: 50 focused paginator, endpoint, envelope, index, ordering, and insert-between-pages proofs pass; 180 affected feature regressions pass.
2026-07-13 — F05 opt-in cursor pagination without a flag day
Type: Performance + API Compatibility
Summary:
- Added reusable hybrid page/cursor pagination in
core.pagination. GET /api/tasks/remains page-number paginated by default and acceptspagination=cursorfor UUIDv7-idkeyset traversal.- Added
(organization, -id)indextask_org_id_desc_idxand migrationwork.0018. - Preserved
{count,next,previous,results}for shipped KMP/iOS decoders. Exact cursor count remains a documented transitional cost.
Tests:
- 19 focused pagination/contract tests pass, including SQL proof that cursor page two contains no
OFFSET. - 121 existing F05/work/performance tests pass.
2026-07-02 — Spec drift review: 0 ACs changed, all unchecked ACs confirmed genuine gaps
Type: Documentation Change
Summary:
- Reviewed 5 unchecked ACs against §11 audit. All 5 map to §11 row 10 (⚠️ gap): p95 wall-clock latency for
GET /api/today/(<500ms), Today page query count (≤5),GET /api/tasks/(<300ms),GET /api/projects/(<300ms), and Gunicorn <5s cap are not verified by any production load test.assertNumQueriestests confirm query counts but not latency under load. - No ACs changed. All 5 remain
[ ]per §11 row 10 guidance. - No code changes; documentation only.
2026-06-14 — Restructure to TEMPLATE.md section order; expand §11 audit (SP fleet)
Type: Documentation Change
Summary:
- Absorbed §9 Edge Cases (4 items) into §11 note; §10 Error States → §9; §11 Diagrams → §10; §12 Implementation Audit (narrative + build-order list) → §11 (10-row table); §13 Open Questions → §12; §14 Bug/Change Log → §13.
- §11 audit: 10 rows, 9 ✅, 1 gap (p95 latency ACs require production load test).
2026-06-13 (T3-F R11)
P14 — Response compression — GZipMiddleware (Fixed)
- Added
django.middleware.gzip.GZipMiddlewareas the first entry inMIDDLEWAREinbackend/config/settings/base.py. - Django’s built-in GZip middleware compresses responses ≥200 bytes when the client sends
Accept-Encoding: gzip. - Tests:
TestP14GzipCompression::test_gzip_response— assertsContent-Encoding: gzipon/api/tasks/response. - Files:
backend/config/settings/base.py
P23 — DB connection pooling (Fixed)
- Updated
backend/config/settings/production.py:CONN_MAX_AGEchanged from0to60seconds. CONN_HEALTH_CHECKS=Truealready present (pre-existing). Development and test settings remain atCONN_MAX_AGE=0to avoid connection leaks between test cases.- Files:
backend/config/settings/production.py
P11 — Query count regression tests + async AI chat (Fixed)
- Created
backend/apps/ai/tasks.pywithprocess_chat_asyncCelery task — implements P10 async chat offload (fixesModuleNotFoundErrorfrom R10). - Created migration
apps/chat/migrations/0003_add_chatmessage_status.pyforChatMessage.statusfield (also from R10). - Added
TestP14GzipCompression::test_today_api_query_count— assertsGET /api/tasks/≤15 queries withselect_relatedJOINs. - Fixed
test_send_message_uses_build_system_promptpatch target:apps.ai.tasks.answer_chat_inner(notapps.ai.services.answer_chat_message— Celery task imports it at module load time). - Files:
backend/apps/ai/tasks.py,backend/apps/chat/migrations/0003_add_chatmessage_status.py,backend/tests/test_performance.py,backend/tests/test_ai_language.py
2026-06-13 (Fleet-B T3-B-r02)
P2 — Brief generation query consolidation (Fixed)
- Before:
generate_morning_brief()issued 39 queries per org: 4 separate Task SELECTs (overdue, due-today, customer-impact, cash-impact), individual BriefItem INSERTs (1 per item), and 12 N+1 queries fromtask.owner__usertraversal in_overdue_task_suggestion()/_due_today_task_suggestion()/_impact_task_suggestion(). - After:
generate_morning_brief()issues ≤20 queries (≤14 on PostgreSQL, excluding SQLite SAVEPOINT overhead):- All Task queries use
.select_related("owner__user")— eliminates 12 N+1 queries. - All BriefItem creation uses
BriefItem.objects.bulk_create(pending_items)— single INSERT instead of 1 per item. - Customer + cash/revenue impact queries kept separate to preserve ranking order (F12 §2).
brief.save(update_fields=["summary_text"])— saves 1 unnecessary column write.
- All Task queries use
- Also:
_items_prefetchinbriefs/views.pynow covers all 5 related FK types (related_task, related_commitment, related_risk, related_decision_request, related_approval_request) — prevents N+1 when BriefItems are serialized. - Tests:
test_brief_generation_query_count(≤20),test_brief_generation_empty_org(≤20),test_brief_generation_no_n_plus_one(0 standalone member/user queries). - Files:
backend/apps/ai/services.py,backend/apps/briefs/views.py,backend/tests/test_performance.py
2026-06-12 (Fleet-C R11)
P5 — ChatThreadSerializer N+1 (Fixed)
- Before:
list_threadswith 5 threads issued 17 queries (1 base + 5 N+1 forget_last_message()+ overhead). - After:
list_threadswith 5 threads issues ≤5 queries usingPrefetch("messages", ...)withto_attr="_prefetched_msgs". - Fix: Added
select_related("user__user").prefetch_related(Prefetch("messages", ..., to_attr="_prefetched_msgs"))tolist_threads()queryset. Serializer reads from_prefetched_msgsattribute first, falling back to ORM query. - Tests:
test_chat_thread_list_no_n_plus_1— asserts query count ≤5 for 5 threads. - Files:
backend/apps/chat/views.py,backend/apps/chat/serializers.py
P4 — select_related/prefetch_related gaps (Fixed)
- Added
select_related("owner__user", "project", "team")toTaskViewSet.get_queryset() - Added
select_related("owner__user", "team")toProjectViewSet.get_queryset() - Added
select_related("owner__user", "project", "team")toRecurringTaskViewSet.get_queryset() - Added
select_related("owner__user")toCommitmentViewSet.get_queryset() - Added
select_related("owner__user")toRiskViewSet.get_queryset() - Added
select_related("requested_by__user")toDecisionRequestViewSet.get_queryset() - Added
select_related("user__user")tolist_threads()queryset in chat views - Tests:
test_task_list_query_count,test_project_list_query_count— assert query counts ≤10 for 5 items each. - Remaining (future round): P8 (Redis caching), P1 (beat task batching), P2 (brief query consolidation), P3 (indexes)
2026-06-12 (Lead session 2)
P3 — Zero custom indexes (Fixed)
- Added composite indexes to Project, Task (work app) and Commitment, Risk, DecisionRequest (operations app).
- Indexes:
(organization, status),(organization, due_date),(organization, visibility),(organization, severity),(organization, priority). - Migrations:
apps/work/migrations/0006_add_composite_indexes_p3.py,apps/operations/migrations/0005_add_composite_indexes_p3.py.
P2 — Brief generation 8+ queries without indexes (Fixed)
- Added composite indexes on DecisionRequest, Risk, Commitment, Task, and Deal models.
- Brief generation queries now use the new
(organization, status)and(organization, due_date)indexes, eliminating full table scans. - Tests: brief generation query count assertions in
backend/tests/test_performance.py::TestBriefGenerationQueryCount.
P20 — Route-level code splitting (Fixed)
- All page components converted from static imports to
React.lazy()+Suspense. - Reduces initial bundle size by splitting authenticated app pages into separate chunks.
P1 — Beat tasks iterate all orgs with N+1 queries (Fixed)
- All 6 beat tasks (
generate_daily_briefs,send_hub_digest,send_mention_digest,generate_follow_up_drafts_for_all_orgs,send_quiet_hours_summary_all_orgs,generate_recurring_tasks,send_daily_pulse_reminders) updated to useOrganization.objects.iterator(chunk_size=50)instead of.all(). - This prevents loading all orgs into memory on each tick.
- Files:
apps/briefs/tasks.py,apps/hub/tasks.py,apps/messaging/tasks.py,apps/followups/tasks.py,apps/notifications/tasks.py,apps/work/tasks.py,apps/telegram/tasks.py.
P13 — No overlapping beat task prevention (Fixed)
- All beat tasks now use
cache.add(key, True, timeout)to acquire a distributed lock before running. - A task that finds the lock already held logs a warning and returns
{"skipped": True}immediately, preventing overlap when a slow run overlaps a new tick. - Lock TTL is set just under the beat interval (e.g., 840 s for 15-min tasks, 3300 s for hourly tasks).
- Tests added for each task’s lock-skip path.
2026-06-13 (T3-C R10)
P10 — Async AI chat offload to Celery (Fixed)
send_messageview now creates a placeholderassistantmessage withstatus="pending"and dispatchesprocess_chat_async.delay()— returning HTTP 202 immediately.apps/ai/tasks.py— new Celery taskprocess_chat_async: marks user messageprocessing, callsanswer_chat_message, updates the placeholder in-place tostatus="complete", handles exception path by marking both messages"failed".apps/chat/models.py— addedstatusfield toChatMessage(pending/processing/complete/failed), migration0003_add_chatmessage_status.apps/pulses/views.py— minor query consolidation.- Tests:
TestChatApiAsyncResponse::test_send_message_returns_202_with_pending_status,TestChatApiAsyncResponse::test_process_chat_async_marks_messages_failed_on_exceptioninbackend/tests/test_performance.py. Coverage: 99.03%. - Files:
backend/apps/ai/tasks.py,backend/apps/chat/models.py,backend/apps/chat/views.py,backend/apps/chat/migrations/0003_add_chatmessage_status.py
2026-06-14 (T3 R12 Agent A)
P8 — Pulse questions cache (Fixed)
- Added
PULSE_QUESTIONS_CACHE_KEY = "pulses:default_questions"andPULSE_QUESTIONS_CACHE_TTL = 3600(1 hour) constants toapps/pulses/views.py. - Added
_get_cached_pulse_questions()helper — returnsDEFAULT_PULSE_QUESTIONSfrom Redis on cache hit; populates with 1-hour TTL on miss.today_pulsenow calls this helper instead of referencingDEFAULT_PULSE_QUESTIONSdirectly, so the static list is serialised once and served from cache for all subsequent not-submitted pulse requests within the hour. - Tests:
TestPulseQuestionsCache::test_pulse_questions_cached_on_first_miss,test_pulse_questions_served_from_cache,test_pulse_questions_cache_ttladded tobackend/tests/test_performance.py. All 3 pass. Coverage: 98.70%. - Files:
backend/apps/pulses/views.py,backend/tests/test_performance.py
E2E bundle size test — §7 test 6 (Added)
- Added
test.describe("F40 — Bundle size")block tofrontend/tests/e2e/lumi.spec.ts. - Test reads
frontend/dist/assets/*.js, identifies the largestindex-*.jsentry chunk, computes its gzip level-9 size, and asserts ≤500 KB. - Skips gracefully (via
test.skip()) whendist/assets/is absent — dev-only runs with Vite dev server are not blocked. - Files:
frontend/tests/e2e/lumi.spec.ts
2026-06-14 (T5)
P25 — Stub locale JS chunks eliminated (Fixed)
import.meta.globpattern infrontend/src/i18n/index.tschanged from./locales/*/*.json(matches all 62 locale dirs = ~2728 files) to./locales/{en,en-AU,en-CA,en-GB,en-IN,en-SG,en-ZA,vi}/*.json(352 files). Eliminates ~2376 unnecessary tiny JS chunks from the production bundle. Stub locale directories (ar, ms, id, fil, lv, …) remain on disk for F69 but produce zero output chunks.- Files:
frontend/src/i18n/index.ts
P26 — Web Vitals runtime tracking (Fixed)
frontend/src/lib/web-vitals.tsobserves LCP, INP, and CLS via the nativePerformanceObserverAPI and reports each metric to Sentry as a measurement (Sentry.setMeasurement) and breadcrumb with rating (good / needs-improvement / poor). Uses native browser APIs — no external npm dependency.- Called from
frontend/src/main.tsximmediately afterSentry.init(). - Files:
frontend/src/lib/web-vitals.ts,frontend/src/main.tsx
2026-07-16 — Today chart route custody
- Moved the Today signal-count chart below decisions, risks, tasks, and commitments so the actionable queue remains the primary executive workflow.
- Extracted Recharts rendering into
TodaySignalsChart.tsxand load it only when its reserved 180 px region approaches the viewport.F40.AC005.TODAY_CHART_CUSTODYprevents the chart library from returning to Today’s cold route graph. - The three-run candidate harness measured 297.1 kB empirical route JavaScript and 3756 ms floor LCP, down from 417.2 kB and 4580 ms on the preceding merged-main train. These are focused readiness measurements; only a complete post-merge PM train may change the web audit score.
2026-07-16 — Strict raw-byte bundle boundary and Tasks margin
- Replaced rounded
<=verdicts with shared strict predicates used by both bundle gates and the CWV reporter. Exactly 300 KiB now fails a< 300 kBtarget, equality fails the registered LCP/INP/FCP<targets, missing zero timings remain failures, and route tables retain three decimals so reviewers can audit near-boundary results. - The corrected gate exposed Tasks at 300.012 kB. The row already displayed the full owner name, so its duplicate owner-initial avatar and initial Avatar chunk were subtracted without removing information or actions. A valid three-run seeded floor rerun records 298.362 kB; the static graph records 256.384 kB/38 files.
- This is candidate engineering proof only. Canonical evidence and W-SYS scoring still require a complete train on the exact merged commit with freshly reset demo data.
2026-07-16 — Authenticated critical-font discovery contract
- Canonical Today showed its LCP heading’s Latin 700 font completing near 3.6 s. The accepted P49 boundary resolves that content-addressed build asset and starts its request from parser-time HTML only for remembered-session Today and Brief paths.
- Generated external
deferandasyncroute-hint runtimes, an unconditional Tasks view hint, direct dependency expansion, and recursive closure were all rejected after focused traces. They either executed too late or saturated the floor connection. Removing the original module entry regressed Tasks from 3040 to 3168 ms, so it remains unchanged. - The accepted candidate starts the Today font at 0.19 s, completes it at 1.83 s, improves Today floor LCP from 3724 to 3532 ms, and preserves Tasks at 3016 ms versus its 3040 ms baseline. The guard emits no font request on Tasks, public/unknown routes, logout, or storage failure.
- This is candidate engineering evidence, not a CWV score claim. A separate exact-merged-main PM train remains mandatory.
2026-07-16 — Parser-time Today/Brief module discovery
- The prior entry was parser-early but its dynamic Layout/leaf imports queued behind the main
module burst. The production HTML transform now resolves the emitted Layout and matched
Today/Brief leaf chunks and creates session-gated
modulepreloadlinks before either module entry. - All six factories stay in the separate entry so Rollup preserves the measured shared graph. Runtime execution skips only Today/Brief, preventing duplicate imports; Chat, Tasks, Pulse, and Deals keep the proven executable warm-up. Auth and router checks remain authoritative.
- Three-run candidate controls move Today 3812 to 3492 ms and Tasks 3036 to 3004 ms. Today remains above target; the populated signal-card LCP uses Geist 500 and requires a separate proof slice.
Tests:
frontend/src/test/_shared/bundle-budget.test.tsfrontend/scripts/check-bundle-budget.mjs --route=/work/tasks- focused
measure-cwv-p0.mjs 3 --only=tasks
2026-07-17 — Today locale custody
- Today no longer subscribes to complete Brief and Hub namespaces for three greetings, four share
labels, and one announcement link. Those exact authored values now live in
today.jsonfor all 62 locales, and a structural proof compares every route-owned value to its localized source. - The startup namespace set now contains shell plus Today’s grouped decision/risk/commitment dictionaries. Brief and Hub remain lazy until their own surfaces need them.
- Focused three-run evidence records Today at 3480 ms LCP, 0.000 CLS, and 291.782 kB versus exact main at 3472 ms / 0.000 / 297.180 kB. Timing is neutral within noise and settled route transfer falls 5.398 kB. Tasks records 3000 ms and 298.371 kB versus 3040 ms and 298.369 kB.
- This is route-custody evidence, not a W-SYS score claim. A separate exact-merged-main PM train remains mandatory.