1Executive Summary
/api/v1/tasks and /api/v1/admin/users with no session), and the audit found 15 critical and 18 high-severity defects across security, data correctness, and UX. The product's read-path visibility design is genuinely good; everything else routes around it. A six-phase plan below fixes the live exposure today, centralizes authorization in week 1-2, and brings the product to the Linear/Asana bar by week 8.
Five independent audit passes covered: (1) backend task core, (2) roles and permissions, (3) frontend UX, (4) the contract/mapping layer between database, API, shared types, and client, and (5) the surrounding modules (projects, sprints, standup, dashboards, intake, tags, notifications). Findings were verified against the actual code with file and line references, and the two most dangerous claims were verified against the live production environment.
The pattern across all five passes is consistent: the system has exactly one strong authorization primitive (the visibilityClause read predicate in tasks.service.ts) and one strong convention (the noon-UTC due-date helper in lib/due-date.ts), and both are bypassed everywhere they were not mechanically enforced. The fix is not 85 individual patches. It is four structural moves (centralized authorization, shared contracts, server-owned date semantics, and view unification) plus a hotfix wave, and most of the individual findings fall out of those moves.
2Confirmed Live Production Exposure
Fix today. This is not theoretical.
Verified against production on June 11, 2026:
GET https://api.sga3p.com/api/v1/taskswith no authentication returns HTTP 200.GET https://api.sga3p.com/api/v1/admin/users(full user roster: emails, roles, manager chain) with no authentication returns HTTP 200.- Railway production env for the
apiservice hasNODE_ENV=staging. The auth plugin only enforces 401-on-anonymous whenNODE_ENV=production, so production runs with anonymous pass-through. DEMO_PASSWORD=sga2026is set in production. That bearer is treated as a full-HQ identity that bypasses the visibility predicate and role checks, and the same password is used as the public page gate on multiple deployed SGA decks. The demo god-token is effectively public.
Why NODE_ENV is wrong: it is not an accident you can flip. kafka.ts throws at boot when NODE_ENV=production ("Kafka stub cannot be used in production"), so production was deliberately run as staging to boot at all. The hotfix requires the small code change in Phase 0, then the env flip.
Exposure chain: anonymous pass-through + zero per-task authorization on mutations (SEC-4) + ungated admin routes (SEC-2) means an unauthenticated caller can read the roster, grant itself hq_admin, and read or rewrite any task. Mitigating factor: list endpoints called without a principal returned empty data in the live check, but detail reads by UUID, the admin surfaces, and all mutation endpoints do not have that accidental protection.
3Scorecard by Dimension
80%+ = Strong | 50-79% = Needs Work | under 50% = Critical
| Dimension | Score | Grade | One-line verdict |
|---|---|---|---|
| Authorization and roles | 20% | Critical | One good read predicate; the write path, admin surfaces, and aggregations have effectively no authorization. |
| Backend task core | 40% | Critical | Thoughtful list/stats/SSE design undermined by zero transactions, a broken rules rate limiter, and a DB constraint the code violates. |
| Test coverage (task domain) | 15% | Critical | A 3,536-line service with no direct tests; only the rules service and the due-date helper are tested. |
| Contract / mapping layer | 55% | Needs Work | Disciplined by hand but with no shared source of truth; ~30 hand-mirrored interfaces, 8 enums duplicated 3-4x, and two silent-drift criticals already shipped. |
| Frontend UX vs Linear/Asana bar | 60% | Needs Work | Feature surface is close to the bar; trust is undermined by date lies, a forked stale copy of all three alt views, dead bulk UI, and state that evaporates on navigation. |
| Surrounding modules | 65% | Needs Work | Soft-delete discipline is genuinely good; standup leaks org-wide, the public form is dead in prod, and rollup numbers disagree across surfaces. |
What is genuinely good (worth saying, because the fix plan builds on it): the two-layer visibility predicate with owner-escape; consistent deleted_at IS NULL discipline in almost every rollup; the noon-UTC due-date helper and its tests; the snake_case response discipline; additive migrations with legacy column sync; Granola correctly locked to per-user tokens with no shared-key fallback.
4Security Findings
File paths are relative to the repo root. Full per-finding detail with quoted code lives in the five audit transcripts; this table is the canonical fix list.
| ID | Severity | Finding | Where | Fix |
|---|---|---|---|---|
| SEC-1 | Critical | Production runs with anonymous pass-through auth. NODE_ENV=staging in prod because the Kafka stub throws on production; auth hook then lets sessionless requests through as anonymous. Confirmed live (200 on /tasks and /admin/users with no auth). DEMO_PASSWORD also set in prod and is a full-HQ visibility-bypass bearer using a publicly reused password. | plugins/kafka.ts:19-23, plugins/auth.ts:183-190, Railway env | Kafka stub logs and continues in prod; auth default-denies in ALL envs (explicit ALLOW_ANONYMOUS_DEV flag for local); set NODE_ENV=production; remove DEMO_PASSWORD from prod. |
| SEC-2 | Critical | users-admin routes have zero authorization. Any authenticated user can list the full roster (emails, roles), PATCH any user, hard-delete users, and grant themselves hq_admin via POST /:userId/roles. Chains to full network read and persona impersonation. | routes/users-admin.ts (entire file) | requireRole(hq_admin) on the route group; refuse hq_* grants to practice/agency user types. |
| SEC-3 | Critical | practice-members routes have zero authorization. A practice user can add themselves to any other practice and instantly see its tasks, defeating the locked hard practice-isolation rule. Docstring claims requireRole enforcement that does not exist. | routes/practice-members.ts | requireRole(hq_admin) on writes; practice-member-or-HQ on reads; self-add impossible. |
| SEC-4 | Critical | Every task mutation is an IDOR. Write path runs on ActorContext (no roles/memberships by design), so updateStatus / assign / triage / PATCH / delete / comment all load by bare task_id with no visibility or ownership check. An empty PATCH body returns the full task (title, description, custom_fields) for ANY task id including private ones, because the no-op path calls getTask without a principal. | routes/tasks.ts:620-899, services/tasks.service.ts:1955-2937 (esp. 2796-2798) | Full Principal in the write path + a mandatory loadTaskAuthorized(taskId, principal, intent) helper reusing visibilityClause; principal required on getTask. |
| SEC-5 | Critical | Watch-self privilege escalation. POST /:taskId/watchers/me inserts with no visibility check, and watchers count as owners in the visibility predicate. Watching an arbitrary UUID permanently grants full read of a private task plus ongoing notifications. | routes/tasks.ts:973-988, tasks.service.ts:3402-3411, 131-141 | Verify task visibility before inserting the watcher row. |
| SEC-6 | Critical | Dependency and watcher reads unscoped. listDependencies returns full related-task rows (title, status, assignee, due) for any task id with no visibility or deleted filter; listWatchers returns watcher names AND emails to any caller. These also leak valid task UUIDs, defeating "unguessable UUID" as a defense. | routes/tasks.ts:928-1014, tasks.service.ts:3306-3434 | Principal-gate all four endpoints; visibility-filter hydrated rows; drop email from watcher payload. |
| SEC-7 | Critical | Projects fully unscoped, read and write. Any user (including agency users granted one project) can enumerate every project, read any project's standup agenda (blockers, conversation, assignments), PATCH any project, and attach/detach arbitrary tasks. Violates the per-project/per-practice agency grant model. | routes/projects.ts:216-331, services/projects.service.ts | Thread the principal into list/get/agenda; gate mutators; agency scope = grants only. |
| SEC-8 | Critical | Standup feed leaks org-wide. No access control on the feed; task_refs hydrate to live task titles/status with no visibility predicate AND no deleted_at filter. Practice and agency users read every team's standups plus private and deleted task titles. Found independently by three audit passes. | routes/standup.ts:51-68, services/standup.service.ts:136-146 | Scope feed to caller's team subtree; apply visibilityClause + deleted filter to preview hydration; gate pin/delete (null-author posts currently deletable by anyone). |
| SEC-9 | Critical | CORS reflects attacker-hostable origins with credentials, and there is no CSRF defense. Allow-list includes wildcard *.vercel.app, *.trycloudflare.com, *.ngrok*.app, *.loca.lt with credentials:true; session cookies are SameSite=None. A free hosted page can make and READ authenticated requests on behalf of a logged-in user. | app.ts:96-126, routes/auth.ts:74-83 | Pin credentialed origins to sga3p.com domains + explicit env-listed staging hosts; add Origin/Sec-Fetch-Site checks or a CSRF token on state-changing routes. |
| SEC-10 | High | Team and sprint dashboards leak cross-tenant operations data. TeamDashboardService applies the principal to 1 of 8 buckets (workload, velocity, projects, member counts are team_id-only); SprintDashboardService takes no principal at all and aggregates the whole network for any caller. | services/team-dashboard.service.ts:158-342, services/sprint-dashboard.service.ts, routes | Apply the predicate to every bucket; verify caller scope over teamKey; HQ-gate or scope sprint dashboards. |
| SEC-11 | High | AI ask-context leaks private and deleted tasks. queryShipped/queryUpcoming feed task titles, assignees, projects into AI answers for any caller with no visibility or deleted filter. Bonus bug: queryBlockers filters status='blocked', which is not a real status, so the blockers section has been silently empty since it was written. | services/ask.service.ts:338-388 | Apply the visibility predicate + deleted filter to the AI context; fix or delete queryBlockers. |
| SEC-12 | High | Requester/assignee spoofing on create and emit. Any user can set requesterUserId/assignedToUserId to anyone, forging audit identity, planting tasks on other people's boards, and manipulating owner-escape visibility. | routes/tasks.ts:331, 76-77 | Honor overrides only for HQ-privileged roles or a service token; otherwise force requester = actor. |
| SEC-13 | High | Turnstile fails open; bulk-import ungated. Missing TURNSTILE_SECRET_KEY silently disables bot protection on public intake. /tasks/bulk-import (which invokes Claude triage = real token spend) has no role gate, relevant given the May 15 key-abuse incident. | services/turnstile.service.ts:52-58, routes/tasks.ts:1024-1183 | Hard-fail public submit in prod when secret missing; HQ-gate bulk-import + per-user daily cap. |
| SEC-14 | Medium | isFullReadHq matches any role string starting "hq_" (broader than intended, multiplies SEC-2); rules catalog + run history readable by all users; demo bearer can edit automation rules; change-password mints an orphan 30-day session per call; 500 handlers leak raw Postgres error text. | tasks.service.ts:118-122, routes/rules.ts, routes/auth.ts:359, routes/tasks.ts:1202-1207 | Enumerate privileged roles explicitly; HQ-gate rules reads; verify-only password check; generic 500 bodies. |
5Data Correctness Findings
| ID | Severity | Finding | Where | Fix |
|---|---|---|---|---|
| COR-1 | Critical | Boolean query params: false becomes true on every list endpoint. z.coerce.boolean() treats the string "false" as true (the codebase even documents this trap in config.ts, fixed there only). Live consequences verified: the "Show snoozed" toggle is a no-op in one direction, archived projects leak into the agency-grant admin and project picker, and per-practice task rollups count subtasks (includeChildren=false coerced true), inflating every practice's numbers. | lib/api/client.ts:84-88 + routes/tasks.ts:143-178, routes/projects.ts, routes/tags.ts, routes/home.ts, routes/insights.ts, routes/reporting.ts | One shared strict boolParam (z.enum(["true","false"]).transform) replacing every z.coerce.boolean() in query schemas. |
| COR-2 | Critical | getTask omits team_id/team_key/team_name; every mutation returns that shape. FE TaskDetail declares them required. The drawer's triage banner always says "Routed to team" (never the name), the team select always shows No team, and merging mutation responses into caches wipes team chips. | tasks.service.ts:1868-1931 vs lib/api/tasks.ts:85-87, symptom at TaskDrawer.tsx:629,653,739 | Add the team join + columns to getTask; then add response serialization (CON-1) so this class becomes a test failure instead of silent undefined. |
| COR-3 | Critical | Due-date convention violated on three write paths and the server never normalizes. The task wizard stores midnight-UTC (the exact PR #148 bug class); the drawer's subtask due editor is wrong on BOTH read (local-day prefill: a US-Eastern user opening a legacy row sees yesterday pre-filled, then Save persists the wrong day) and write; the TasksApp gantt drag uses local-day arithmetic while the shared GanttView does it right. Server-side, create accepts z.coerce.date (midnight anchor) while PATCH requires datetime, and no endpoint normalizes to noon UTC, so emit tools and imports write fragile values. | TaskNew.tsx:511, TaskDrawer.tsx:1269-1307,1117, TasksApp.tsx:1320-1325, routes/tasks.ts:91,714 | Route all three through lib/due-date.ts helpers; add a backend due-date helper normalizing every write to noon UTC; align create/patch wire format; ESLint ban on raw new Date(...).toISOString() in task files. |
| COR-4 | Critical | Server overdue/due-soon SQL compares the noon anchor against NOW(). Tasks due today flip to Overdue at 8:00am ET and vanish from due-soon; legacy midnight rows go overdue the prior evening. Affects Home buckets, the overdue stat, and the overdueOnly filter. The due-soon cron already does it correctly (UTC-day boundaries) and is the model. Frontend mirrors the same bug: renderSla and TaskListRow show "Overdue Nh" on cards whose due chip says Today. | tasks.service.ts:1637-1653, 3173-3181; FE lib/tasks-display.ts:289-295, TaskListRow.tsx:45-47 | UTC-day-boundary SQL fragments in a shared backend helper; FE overdue at day grain when due_date is set. |
| COR-5 | High | Rules-engine rate limiter is global per rule, not per task. The cap (default 3/day) is meant per (rule, task) but the query omits task_id, so a routing rule fires for the first 3 tasks of the day and then silently dies network-wide for 24h. Form-intake routing to market managers stops mid-morning on any busy day. | services/task-rules.service.ts:402-416 | Add task_id to the WHERE; index (rule_id, task_id, fired_at). |
| COR-6 | High | Backlog tier writes priority=0, which the DB CHECK constraint rejects. tasks.priority has CHECK BETWEEN 1 AND 4; TIER_TO_PRIORITY maps backlog to 0. Creating or patching a task to backlog 500s. Corroborating: the seed migration that inserts priority-0 rows has been silently failing on every boot because the migration runner swallows errors. Inverse half: defer_backlog sets the tier but never syncs the legacy int. | tasks.service.ts:716-722, 2187-2196 vs migrations/018:30 | Migration relaxing CHECK to 0-4; sync priority in defer_backlog. |
| COR-7 | High | Zero database transactions in the task core; emit has a check-then-insert race. createTask, dependency add/remove (two edges), triage duplicate_of, and updateStatus are all multi-step autocommit sequences. Concurrent emits of the same externalKey 500 on the unique index instead of refreshing idempotently. Crash windows leave asymmetric dependency graphs and tasks with no audit trail. | tasks.service.ts:1107-1219, 3198-3304, 1273-1341 | db.transaction around every multi-step mutator; emit becomes INSERT ... ON CONFLICT DO UPDATE (the unique index already exists). |
| COR-8 | High | Granola re-import rewrites the wrong tasks. The idempotency key is meeting + array INDEX, but the refinement loop reorders/merges/splits items; the emit refresh path overwrites title/description on key match. Re-importing a refined meeting overwrites already-triaged tasks with different items' content. The modal's "won't create duplicates" promise is the failure mode. Also: one non-Fibonacci estimatedPoints 400s the sequential import loop mid-run. | GranolaImportModal.tsx:176,81-119,192, tasks.service.ts:1281-1321 | Content-stable per-item key (or persist an item id through refinement); clamp points before emit; make the loop resilient. |
| COR-9 | High | Public anonymous form is dead in production. The public page populates its required practice dropdown from an authenticated endpoint; anonymously it 401s and falls back to three hardcoded seed UUIDs that do not exist in prod, so every submission 404s ("Practice not found"). Separately, an anonymous submitter can attribute intake to ANY valid practice id. | plugins/auth.ts:60-67, lib/api/practices.ts:67-94, PublicFormPage.tsx:86, forms.service.ts:289-309 | Minimal public practices endpoint under the whitelisted /forms/public prefix (id+name only); bind allowed practices to the form definition. |
| COR-10 | High | Rule actions bypass the state machine and fan-out. change_status writes tasks.status directly: skips transition validation, completed_at stamping (breaking velocity/rollups), watcher notifications, SSE events, and cascading rules. field_equals rules can be authored in the UI but never fire (no dispatch call site). | task-rules.service.ts:338-345, 449-468 | Route rule actions through TasksService.updateStatus; dispatch field_equals from updateTaskFields or remove it from the schema. |
| COR-11 | Medium | Soft-deleted tasks are mutable and noisy. getTask and all mutators ignore deleted_at: you can comment on, restatus, and link dependencies to deleted tasks, firing notifications and rules. Stats count archived tasks the boards hide; the due-soon cron nags about archived and snoozed tasks; governor rollup reconcile flips completed children to cancelled, destroying completion records. | tasks.service.ts:1868-1933, 3154-3161, due-soon-cron.ts:104-114, task-emit-governor.service.ts:330-355 | Reject deleted in loadTaskAuthorized (restore excepted); archived_at filters on stats/cron; status guard in reconcile. |
| COR-12 | Medium | Rollups disagree across surfaces. Three different definitions of "blocked" (project rollup overcounts: any blocked_by edge ever, even if the blocker is done or deleted); OrgDashboard fetches one capped 500-row page and slices client-side, silently undercounting at 260-practice scale; triage badge counts a different set than the triage list; sprint close wipes retro actions (closeSprint defaults to empty array), defeating the strict-close guard which itself only checks the latest sprint. | projects.service.ts:715-722, project-standup.service.ts:284-358, OrgDashboard.tsx:51, MyWork.tsx:243-250, sprints.service.ts:254-274, 603-620 | One shared blocked predicate; server-side org rollup endpoint; merge retro actions on close; check all completed sprints. |
| COR-13 | Medium | Unvalidated FK writes 500; migration runner swallows failures; project-audience visibility documented but unimplemented. PATCH writes sprintId/projectId/teamId/practiceId straight through (bad UUID = raw 500; the assert helpers exist and are unused). Boot migration runner logs all failures as warnings, making schema drift invisible. Tasks on a project with no team get visibility=team but match no one's team scope: invisible to most HQ users on project boards. | tasks.service.ts:2679-2688, plugins/database.ts:147-169, tasks.service.ts:228-276 | Use assert helpers in updateTaskFields; migration ledger + loud failure; add project-membership clause to the HQ scope gate. |
| COR-14 | Medium | Mentions exclude real users; sprint labels off by one; tag counts drift. The @mention roster filters persona_key IS NOT NULL, so email+password users can never be mentioned. Sprint start/end dates formatted local from date strings show the prior day. Tag usage_count only increments (never on detach/merge/delete) and orphan pending tags accumulate. | tasks.service.ts:2835, TasksDashboard.tsx:209-217 (+SprintsList, SprintDetail), tags.service.ts:188-311 | Filter mentionables on status; UTC-format sprint dates; periodic usage_count recompute + orphan GC. |
6Frontend / UX Findings
| ID | Severity | Finding | Where | Fix |
|---|---|---|---|---|
| UX-1 | Critical | Hiding a column misaligns the entire table. Header colgroup/thead filter by visibleColumns but TaskRow renders Practice, Status, Priority, Size, Due, and Source cells unconditionally. Hide Practice and every cell shifts under the wrong header until reset. | TaskTable.tsx:581-585, 936, 1127-1449 | Wrap every non-structural td in the same visibleKeys check the project/team cells use. |
| UX-2 | Critical | The Watching tab does not show watched tasks. It queries requestedByMe (no watchedByMe filter exists anywhere in the API client), so it duplicates "Created by me" and its empty state lies. | MyWork.tsx:183-193, lib/api/tasks.ts | Add a watching=true server filter and wire it; hide the tab until then. |
| UX-3 | High | TasksApp contains a forked, stale copy of Board/Calendar/Gantt. The flagship /tasks-app surface does not import the shared components. Already-shipped drift: rainbow domain tints violating the locked neutral-chip color contract, missing card affordances (project band, due chip, subtask/attachment counts), cancelled transitions the shared board removed, local-time gantt drag, and two different priority vocabularies (P1-P4 vs tiers) depending on entry point. | TasksApp.tsx:861-1720 vs components/tasks/* | Delete the embedded copies; render TaskViewSwitcher (or the shared views) in TasksApp. |
| UX-4 | High | TaskDetail is a stale fork of the drawer; "open full page" affordances broken. Full page still uses legacy P1-P4, has a strict status pipeline where completed/cancelled tasks cannot be reopened (they can everywhere else), is read-only for title/description/due/size/project/team/tags/visibility, lacks archive/delete/mentions. The drawer's own "Open in full page" just reopens the drawer; the table's goes to the stale page; back button hardcodes /tasks. | TaskDetail.tsx vs TaskDrawer.tsx (field-by-field table in audit) | Rebuild TaskDetail as a route wrapper around the drawer's TaskBody + TaskMetaRail. One editor, two skins. |
| UX-5 | High | SSE event storm. Every task event invalidates the entire ["tasks"] prefix; TasksApp mounts 11 task queries, MyWork up to 6. Any status flip by anyone triggers about 12 refetches per open client. No payload-driven cache patching, no debounce. | useTaskEventStream.ts:94, TasksApp.tsx:252-293 | Debounce/coalesce invalidations; patch caches from event payloads; replace 9 count queries with one stats endpoint. |
| UX-6 | High | /tasks-app silently truncates at 50 tasks; counts contradict each other. No pagination UI; rail badges show meta.total while the toolbar shows the client-filtered page count. Board/calendar/gantt render a 50-row sample as if complete: misleading planning surfaces. | TasksApp.tsx:238, 581-583 | Pagination/infinite scroll on list; higher limits + virtualization for the visual views; honest counts. |
| UX-7 | High | Bulk actions are half dead UI, half foot-gun. On MyWork/ProjectDetail/TeamSpace, checkboxes select but no BulkBar ever renders (the wiring never landed). On TasksApp, bulk runs N parallel mutations with no error handling, and selection is NOT cleared on filter change despite the comment claiming it is, so bulk-complete can mutate rows the user can no longer see. | TaskViewSwitcher.tsx:118,311-413, TasksApp.tsx:589-625, 221-222 | Render BulkBar when selection is non-empty; clear selection on filter change; try/catch + result toast; bulk endpoint later. |
| UX-8 | High | View and filter state evaporates. Scope, status, view, groupBy, chips are all useState; only taskId is in the URL. Refresh, share a link, or open a task full-page and everything resets. MyDayBuckets even links with ?scope=myday, a param MyWork never reads. | TasksApp.tsx:187-196, MyWork.tsx:92, TaskViewSwitcher.tsx:115-138 | Lift view state into searchParams (the drawer already proves the pattern). |
| UX-9 | Medium | No failure feedback on inline edits; five copies of the status machine; assorted polish. Every popover editor awaits mutateAsync with no catch (server rejection = silent revert). The status transition map exists in five files and has already diverged. Plus: board drag has no optimistic move (card snaps back then jumps), title cell single-click vs double-click rename conflict, duplicate teams caches under two query keys, one popover not portal'd (clips at card boundaries), drawer cannot clear a description, comment URL regex drops links, single-slot undo toast loses the previous undo forever. | useTasks.ts, tasks-display.ts:118-126 + 4 copies, various | Shared onError toast on mutation hooks; import transitions from one module; optimistic board patch; the audit lists each one-line fix. |
| UX-10 | Medium | Accessibility and mobile gaps. Drawer has no dialog role, no focus trap, no focus restore; Esc closes the whole drawer instead of the topmost popover; pickers lack arrow-key navigation; status is signaled by color alone in several views (submitted vs cancelled = identical gray dot); fixed-width rail/drawer never stack on mobile. | TaskDrawer.tsx:200-205,149-160, PortalPopover.tsx:96-101, TaskListRow.tsx:154-165 | Dialog semantics + focus trap; layered Esc; icons/labels alongside color; responsive breakpoints. |
| UX-11 | Medium | Performance headroom. TaskRow instantiates 6 mutation hooks + 7 refs per row, is not memoized, and re-renders on every cache invalidation; no virtualization anywhere; grouping/sorting recompute on each SSE-driven invalidation. Compounds with UX-5 into visible flicker under load. | TaskTable.tsx:1009+ | Memo TaskRow; hoist mutations to table context; virtualize the flat-list path first. |
| UX-12 | Medium | Reachability gaps on /tasks-app. Domain rail omits hr/finance/strategy; status filter omits cancelled (cancelled tasks unfindable on this surface); search box filters only the 50 fetched rows client-side even though the API supports server search; Excel import discards the AI's assignee/practice column mapping (Claude call burned, rows land unassigned); studio proofs Source tab renders blank (FE type has zero field overlap with the stored shape). | TasksApp.tsx:72-142, 375-381, 529-535, ExcelImportModal.tsx:128-133, source-loaders.ts:37-44 | Complete the rail/status lists; wire server search; surface import hints as pickers; retype StudioProof. |
7Contract Layer Findings
Root condition: the task domain has no shared contract. packages/shared-types exports zero task/project/sprint types or zod schemas through its index; the server declares request schemas privately per route file; the frontend hand-maintains about 30 mirror interfaces and 8 enum unions (each enum duplicated 3-4 times across pgEnum, route zod, FE union, and service consts, with no DB CHECK constraints on domain/visibility/tier/size at all). COR-1 and COR-2 are the shipped proof of what this costs. Additional drift already present: practice_id nullable in DB but non-nullable in FE types; dead unbound pgEnums inviting false trust; migration 095's table missing from the Drizzle schema despite the file claiming to be the single source of truth; PATCH body declared twice in the same route file with drift between the copies; the API client has no AbortSignal/timeout/retry despite its header claiming consistent retry semantics.
Hardening recommendation (adopted in Phase 3): per-domain zod contracts in packages/shared-types/src/contracts/ (enums declared once, request + response schemas, a strict boolParam helper), with Fastify response serialization turned on against them. The infrastructure is already half-installed: the zod type provider's validatorCompiler is registered and serializerCompiler is already imported; routes just never declare response schemas. Once they do, a dropped field is a thrown serialization error in dev instead of silent undefined in prod. The FE then deletes its local interfaces and re-exports from the workspace package, one domain per PR: tasks first, then projects, sprints, standup, tags, forms.
8Module Health Summary
| Module | Health | Notes |
|---|---|---|
| Task list/read path (visibility predicate, soft-delete discipline) | Strong | The design to build on. Owner-escape, two-layer scoping, consistent deleted_at filters in rollups. |
| Granola integration | Strong | Per-user encrypted tokens, no shared-key fallback: the locked privacy decision holds. One idempotency bug (COR-8) and a staging stub flag to confirm. |
| Projects + sprints | Needs Work | Solid CRUD; unscoped access (SEC-7), retro-action wipe, blocked-count overcount, sprint label off-by-one. |
| Dashboards (team/sprint/org) | Needs Work | Aggregation leak paths (SEC-10) and the 500-row client-side org rollup (COR-12). |
| Forms intake | Critical | Dead in production for anonymous users (COR-9); Turnstile fail-open (SEC-13). |
| Standup feed | Critical | Weakest module: no access control, leaks private and deleted titles, composer cannot even attach tasks (half-built feature). |
| Rules/automation engine | Weak | Rate limiter kills automations daily (COR-5); actions bypass the state machine (COR-10); run table grows unbounded with noise rows. |
| Notifications | Needs Work | In-app only (no task email path exists, so the email decision is still open); sprint fan-out has no governor or preferences; cron nags about archived/snoozed tasks. |
| Tags | Needs Work | Sound design; usage_count drift, orphan pending tags, no slug rename, seeded colors outside the palette. |
9Root Causes (why 85 findings is really 4 problems)
- Authorization is opt-in and the write path cannot authorize even in principle. ActorContext deliberately omits roles and memberships, so no mutator can run the visibility predicate; route-level requireRole appears in 3 of about 40 route files and forgetting it fails open. Every IDOR, leak, and priv-esc above is this one missing layer surfacing in different files.
- No shared contract. Types, enums, and schemas are hand-mirrored 3-4 times per concept with nothing tying them together at compile time or runtime. Silent drift (COR-1, COR-2, the studio-proof blank tab) is the inevitable output.
- Conventions live in helpers, not enforcement. The due-date convention exists, is documented, is tested, and was violated in 11 new places because nothing (server normalization, lint, response schemas) makes violation impossible.
- God-files without tests. tasks.service.ts (3,536 lines, 0 direct tests), TaskTable (2,361), TaskDrawer (2,207), TasksApp (1,746, containing a fork of three whole views). Duplication is the symptom; the forked views and five status-machine copies are the cost already paid.
10Remediation Plan
Six phases, ordered by risk. Effort is in focused engineering days (Dakota + Claude Code pairing, the current delivery model). Each phase is independently shippable; phases 0-2 should not be reordered.
Phase 0: Production lockdown | today, 1 day | fixes SEC-1, SEC-2, SEC-3, SEC-9 (partial), SEC-13 (partial)
- Kafka stub: log-and-continue under NODE_ENV=production (remove the boot throw). S
- Auth plugin: default-deny anonymous in ALL environments; local dev opts in via explicit ALLOW_ANONYMOUS_DEV=true. S
- Railway: set NODE_ENV=production on the api service; delete DEMO_PASSWORD from the production environment. S
- requireRole(hq_admin) preHandler on the users-admin and practice-members route groups. S
- CORS: remove wildcard vercel/ngrok/trycloudflare/loca.lt origins from the credentialed allow-list; pin to sga3p.com + env-listed hosts. S
- Principal required and fail-closed on getTask, listDependencies, listDependenciesForTaskIds, listWatchers; visibility check before watchTask insert. M
- Turnstile: hard-fail public submit in production when the secret is missing. S
- Verify: re-run the unauthenticated curl checks (must 401), confirm demo bearer dead, confirm app boots and logins work. S
Phase 1: One authorization layer | week 1-2, 6-8 days | fixes SEC-4 through SEC-12, SEC-14
- Replace ActorContext/ProjectActor with a full Principal (userId, userType, globalRoles, practiceMemberships) threaded through every route. M
- Add loadTaskAuthorized(taskId, principal, "read"|"write") reusing visibilityClause; call it at the top of every mutator; mutators return getTask(taskId, principal); reject soft-deleted (restore excepted). L
- Fail-closed route authorization map: /api/v1/admin/* and any state-changing method on an unlisted route denies unless it declares a required role/scope. Forgetting requireRole becomes a 403, not a Critical. M
- Scope projects (list/get/agenda/mutators) and enforce agency per-project + per-practice grants. M
- Apply the predicate to all team-dashboard buckets, sprint dashboards, standup feed + task_ref hydration, and the AI ask context. M
- Requester/assignee overrides restricted to privileged roles or a service token. S
- CSRF defense (Origin/Sec-Fetch-Site check in the auth hook, or double-submit token). M
- isFullReadHq enumerates explicit role keys; rules catalog reads HQ-gated; bulk-import HQ-gated with a daily AI-spend cap. S
- Authorization matrix test suite: hq admin / hq member / practice / agency / anonymous crossed with read, write, aggregate, admin endpoints. This is the regression net that keeps the layer closed. L
Phase 2: Data correctness | week 2-3, 6-8 days | fixes COR-1 through COR-14
- Backend due-date helper (mirror of lib/due-date.ts): noon-UTC normalization on create/emit/patch/bulk-import; UTC-day-boundary SQL for overdue, due-soon, and stats; align create/patch wire formats. M
- Frontend due-date sweep: TaskNew wizard, drawer subtask editor (read + write), TasksApp gantt drag, the 8 local-read sites, sprint date labels, day-grain SLA; ESLint rule banning raw date construction in task files. M
- Strict boolParam replacing z.coerce.boolean() across all route query schemas. S
- getTask team join + columns. S
- Priority CHECK migration (0-4) + defer_backlog legacy sync. S
- Rules engine: per-(rule, task) rate limit + index; actions routed through updateStatus; field_equals dispatched or removed; stop writing skipped_condition noise rows. M
- db.transaction on all multi-step mutators; emit becomes ON CONFLICT DO UPDATE. M
- Public form: public practices endpoint (id+name) under /forms/public; practice binding on form definitions. M
- Granola: content-stable item keys through the refinement loop; clamp points; resilient import loop. M
- Consistency sweep: one shared blocked predicate; retro-action merge on sprint close; all-sprints strict-close check; archived_at on stats/cron; governor reconcile status guard; FK assert helpers in updateTaskFields; mentionables include real users; migration ledger with loud failures. M
Phase 3: Shared contracts | week 3-4, 5-7 days | structural fix for the whole drift class
- packages/shared-types/src/contracts/tasks.ts: the 8 enums declared once, request + response zod schemas, boolParam helper, dueDate schema documented against the convention. M
- Server adopts: routes import shared schemas; response serialization (schema.response) on GET /tasks, GET /tasks/:id, and every mutation. Dropped fields become thrown errors in dev. M
- Frontend adopts: lib/api/tasks.ts deletes local interfaces, re-exports from the package. Then projects, sprints, standup, tags, forms: one PR each. L
- DB truth backfill: practice_id nullable in FE types; bind or delete the dead pgEnums; add the missing project_functional_areas model; CHECK constraints for priority_tier, estimated_size, visibility, domain; retire functional_area_id from the API surface (tags are the lens now). M
- API client: AbortSignal forwarding from react-query, timeout, shared mutation onError toast. S
Phase 4: Frontend unification and UX | week 4-6, 8-10 days | fixes UX-1 through UX-12
- Surgical fixes first: column-hide cell alignment, Watching tab (server watchedByMe filter), selection cleared on filter change, bulk error handling + toasts. M
- De-fork TasksApp: delete the embedded Board/Calendar/Gantt, render TaskViewSwitcher; one color contract, one priority vocabulary, one transitions source (import from tasks-display everywhere; delete the other four copies). L
- Unify the editor: TaskDetail rebuilt as a route wrapper around the drawer's TaskBody + TaskMetaRail; both "open full page" affordances point at it; back button respects origin. L
- BulkBar rendered on all TaskViewSwitcher surfaces. S
- URL-backed view state (view, groupBy, scope, filters as searchParams); honest counts + pagination/infinite scroll on /tasks-app; server-side search wired. M
- SSE: debounced/targeted invalidation, payload-driven cache patching, one stats endpoint replacing the 9 count queries; optimistic board drag. M
- Accessibility: dialog role + focus trap + focus restore on the drawer, layered Esc, portal the visibility popover, non-color status affordances. M
- Reachability: complete domain rail and status filters; Excel import surfaces assignee/practice hints as pickers; StudioProof retype so the Source tab renders. M
Phase 5: Decomposition, performance, tests | week 6-8, 8-10 days | pays down the god-files
- Split tasks.service.ts: task-visibility, task-triage, task-notifications, task-dependencies, task-emit modules around a thin orchestrator. L
- Decompose TaskTable (columns/grouping/row/editors modules, memoized TaskRow, shared editor popovers reused by drawer subtasks) and TaskDrawer (TaskBody, TaskMetaRail, inline primitives, panels) per the decomposition plan in the audit transcript. L
- Performance: pg_trgm index for search, team-scope CTE caching, governor batching, limit clamps, list virtualization. M
- Server-side org rollup endpoint replacing OrgDashboard's capped client-side slice. M
- Test foundation as the durable deliverable: visibility predicate suite, due-date round-trip suite (FE helper + backend helper + SQL fragments), rules engine suite, contract round-trip tests, plus the Phase 1 authorization matrix in CI. L
Sequencing notes
- Phase 0 ships today as one PR + one env change. Nothing else lands before it.
- Phases 1 and 2 can interleave (different files mostly), but the Principal refactor lands before the contract work so contracts encode the final shapes.
- Phase 4's surgical fixes (item 1) can ship any time after Phase 0; the de-fork and editor unification should wait for Phase 3's shared types to avoid re-doing prop plumbing.
- The open marketing-ingest work (PR #147) is unaffected; none of these phases touch the reporting pipeline.
11Acceptance Criteria
| Phase | Done means |
|---|---|
| Phase 0 | Unauthenticated curls to /tasks, /admin/users, and any mutation return 401. NODE_ENV=production in Railway. DEMO_PASSWORD absent from prod. App boots, logins and persona flows work. A page hosted on vercel.app cannot make credentialed requests. |
| Phase 1 | The authorization matrix suite passes in CI: practice users see only their practices, agency users only their grants, private tasks invisible to everyone but owners (per the 2026-05-25 privacy reversal: leads do NOT see member-private), empty PATCH on a foreign task returns 403/404, watch-self on an invisible task is rejected, dashboards and standup return only caller-scoped data. |
| Phase 2 | A task created with due date 6/11 shows 6/11 in every view for a US-Eastern user and is not Overdue at 9am ET. Snoozed/archived toggles actually toggle. Backlog tier saves without a 500. A routing rule fires for the 50th task of the day. Re-importing a refined Granola meeting touches only matching items. An anonymous submitter can complete the public form against a real practice. |
| Phase 3 | Task enums and shapes exist in exactly one package. Removing a field from a route response fails tests. The FE compiles against shared types with local task interfaces deleted. |
| Phase 4 | One board/calendar/gantt implementation, one editor, one status machine, one priority vocabulary. Refresh and shared links preserve view state. Counts match contents. Bulk works (with errors surfaced) on every list surface. Drawer passes a keyboard-only walkthrough. |
| Phase 5 | No task-domain file over ~800 lines. Task list interaction stays smooth at 500+ visible tasks. CI runs visibility, due-date, rules, contract, and authorization suites green. |
One decision to confirm (not blocking Phase 0): the audit found that lead-sees-member-private is not implemented. The May 25 privacy reversal says that is correct behavior (member views show non-private only). The plan treats the reversal as authoritative; the older spec memos should be updated so the next audit does not re-flag it.