{
  "slug": "projects-module",
  "spec_file": "docs/specs/2026-05-30-projects-module.md",
  "findings": [
    {
      "id": "projects-module-001",
      "severity": "P0",
      "type": "security",
      "summary": "Member-scoped project visibility never applies because fullVisibility equals projects:read permission",
      "spec_ref": "Permissions — Project members only see projects they're assigned to; Plan Task 3 fullVisibility from role config",
      "code_ref": "apps/zync-api/src/routes/projects/crud.ts:56",
      "evidence": "fullVisibility = session.permissions.includes('projects:read'). MEMBER role seeds projects:read at packages/db/src/seed/permission-keys.ts:71, so listProjects member EXISTS filter at packages/db/src/queries/projects.ts:89 never runs for typical members.",
      "repro": "MEMBER user not in project_members → GET /api/projects still returns all tenant projects."
    },
    {
      "id": "projects-module-002",
      "severity": "P0",
      "type": "security",
      "summary": "Hours and retainer-ledger endpoints skip project member access checks",
      "spec_ref": "Permissions — member visibility; API GET /api/projects/:id/hours and /retainer-months",
      "code_ref": "apps/zync-api/src/routes/projects/reports.ts:24",
      "evidence": "getProjectHours and listRetainerMonths called with tenantId+projectId only. getProjectHours at packages/db/src/queries/projects.ts:665 checks projects row exists but has no ProjectAccess/member gate unlike getProjectById at :220.",
      "repro": "User with projects:read but not a project member → GET /api/projects/:foreignId/hours returns 200 {this_month:0,all_time:0} instead of 404."
    },
    {
      "id": "projects-module-003",
      "severity": "P0",
      "type": "broken",
      "summary": "Retainer hour-bank depletion webhook and invoice enqueue never fire on real time-stop path",
      "spec_ref": "Retainer Hour Bank — hours_used increment; webhook retainer.depleted; auto_invoice enqueue",
      "code_ref": "packages/db/src/queries/time.ts:155",
      "evidence": "Production path maybeUpdateRetainerHourBank upserts hours_used only (:178-191); no invoice_triggered_at, no QUEUE, no webhook.deliver. incrementRetainerHours at packages/db/src/queries/projects.ts:712 has depletion logic but grep finds zero callers outside definition/export.",
      "repro": "Stop billable time on retainer project past included hours → retainer_months.invoice_triggered_at stays NULL; no queue message."
    },
    {
      "id": "projects-module-004",
      "severity": "P1",
      "type": "missing",
      "summary": "incrementRetainerHours exported but never invoked by time-management or routes",
      "spec_ref": "Plan Task 5 — time entries increment retainer_months via incrementRetainerHours",
      "code_ref": "packages/db/src/queries/projects.ts:712",
      "evidence": "grep incrementRetainerHours across repo (excl. worktrees) finds only packages/db/src/queries/projects.ts and packages/db/src/queries/index.ts export.",
      "repro": "No code path calls incrementRetainerHours(db, tenantId, projectId, month, hours, queue)."
    },
    {
      "id": "projects-module-005",
      "severity": "P1",
      "type": "missing",
      "summary": "getProjectWithStats returns hardcoded zero stats despite tasks/invoices/time tables existing",
      "spec_ref": "API GET /api/projects/:id detail+stats; Project detail Summary bar",
      "code_ref": "packages/db/src/queries/projects.ts:265",
      "evidence": "stats object literals all 0 with comment 'tasks/time/invoices tables are not yet present'. tasks schema at packages/db/src/schema/tasks.ts, invoices at packages/db/src/schema/invoices.ts, time_entries at packages/db/src/schema/time.ts all exist.",
      "repro": "Project with tasks/time/invoices → GET /api/projects/:id returns stats.total_tasks:0, hours_all_time:0, invoices_outstanding:0."
    },
    {
      "id": "projects-module-006",
      "severity": "P1",
      "type": "missing",
      "summary": "getProjectHours always returns zero despite time_entries table and listEntries aggregation",
      "spec_ref": "API GET /api/projects/:id/hours → {this_month, all_time}; Time tab totals",
      "code_ref": "packages/db/src/queries/projects.ts:673",
      "evidence": "Hardcoded return { this_month: 0, all_time: 0 } with comment 'time-management module lands in a later wave'. listEntries at packages/db/src/queries/time.ts:379 already joins time_entries by projectId.",
      "repro": "Project with logged time → GET /api/projects/:id/hours returns {this_month:0,all_time:0}."
    },
    {
      "id": "projects-module-007",
      "severity": "P1",
      "type": "missing",
      "summary": "Tasks tab does not embed task board when tasks module is enabled",
      "spec_ref": "Project detail [Tab] Tasks — embedded task board pre-filtered to project",
      "code_ref": "apps/zync-app/src/modules/projects/detail/TasksTab.tsx:24",
      "evidence": "useModuleEnabled('tasks') passes but component renders EmptyState 'Tasks board coming soon'. TasksBoardPage exists at apps/zync-app/src/features/tasks/TasksBoardPage.tsx with project filter via useTaskFilters.",
      "repro": "Enable tasks module → open /projects/:id Tasks tab → placeholder EmptyState, not board."
    },
    {
      "id": "projects-module-008",
      "severity": "P1",
      "type": "missing",
      "summary": "Time tab shows stub totals and placeholder instead of project time-entries table",
      "spec_ref": "Project detail [Tab] Time — entries table (user, date, duration, task, description)",
      "code_ref": "apps/zync-app/src/modules/projects/detail/TimeTab.tsx:44",
      "evidence": "When time_management enabled, renders hour summary from useProjectHours (stubbed API) then EmptyState 'Time entries table coming soon'. GET /api/time supports projectId filter at apps/zync-api/src/routes/time.ts:256.",
      "repro": "Enable time_management with entries on project → Time tab has no table rows."
    },
    {
      "id": "projects-module-009",
      "severity": "P1",
      "type": "missing",
      "summary": "Invoices tab does not list project invoices or offer Create invoice action",
      "spec_ref": "Project detail [Tab] Invoices — invoices for project; Create invoice (fixed/hourly)",
      "code_ref": "apps/zync-app/src/modules/projects/detail/InvoicesTab.tsx:24",
      "evidence": "useModuleEnabled('invoices') passes but renders EmptyState 'Invoices list coming soon'. listInvoices supports projectId at apps/zync-api/src/routes/invoices/index.ts:119.",
      "repro": "Enable invoices module → Invoices tab shows placeholder; no list or create action."
    },
    {
      "id": "projects-module-010",
      "severity": "P1",
      "type": "missing",
      "summary": "Project list UI missing customer filter required by spec",
      "spec_ref": "Project list — Filter by status, billing type, customer",
      "code_ref": "apps/zync-app/src/modules/projects/list/ProjectFilters.tsx:34",
      "evidence": "ProjectFilters renders status, billing_type, and sort Selects only; no customer_id control. useProjectList supports customer_id query param at apps/zync-app/src/modules/projects/api.ts:68.",
      "repro": "Open /projects → no customer filter control; cannot filter list by customer in UI."
    },
    {
      "id": "projects-module-011",
      "severity": "P1",
      "type": "missing",
      "summary": "createProject does not auto-add creator as project member/owner",
      "spec_ref": "Permissions — member visibility; Create project flow",
      "code_ref": "packages/db/src/queries/projects.ts:303",
      "evidence": "Only inserts project_members when input.members provided; createdBy stored on projects.created_by but never added to project_members.",
      "repro": "If member visibility enforced, creator without explicit members[] cannot list or access own project."
    },
    {
      "id": "projects-module-012",
      "severity": "P1",
      "type": "missing",
      "summary": "Overview recent tasks always placeholder despite tasks module and API",
      "spec_ref": "Project detail Overview — Recent tasks (last 5)",
      "code_ref": "apps/zync-app/src/modules/projects/detail/OverviewTab.tsx:172",
      "evidence": "Hardcoded EmptyState 'Tasks will appear here once the tasks module is installed' with no useQuery to /api/tasks?project=.",
      "repro": "Project with tasks → Overview Recent tasks section stays empty placeholder."
    },
    {
      "id": "projects-module-013",
      "severity": "P1",
      "type": "missing",
      "summary": "Overtime billing logic absent from invoice generation for hourly projects",
      "spec_ref": "Overtime Billing — threshold hours at base rate, above at multiplier",
      "code_ref": "packages/db/src/queries/invoice-generation.ts:1",
      "evidence": "grep overtime in packages/db/src/queries/invoice-generation.ts returns no matches. billing_config overtime fields only stored/validated, never consumed for invoicing.",
      "repro": "Hourly project overtime_enabled=true with excess daily hours → invoice generation has no overtime rate split."
    },
    {
      "id": "projects-module-014",
      "severity": "P1",
      "type": "missing",
      "summary": "rollOverRetainerHours never invoked (no cron or caller)",
      "spec_ref": "Plan Task 5 — rollOverRetainerHours for carry_over overflow action",
      "code_ref": "packages/db/src/queries/projects.ts:875",
      "evidence": "grep rollOverRetainerHours finds only definition and index export; no apps/ or cron caller.",
      "repro": "Retainer with hour_bank_overflow_action=carry_over → unused hours never rolled to next month."
    },
    {
      "id": "projects-module-015",
      "severity": "P1",
      "type": "missing",
      "summary": "tenantQuery factory does not register project domain helpers",
      "spec_ref": "Plan Architecture — tenantQuery(db,tenantId).project domain",
      "code_ref": "packages/db/src/queries/tenant-query.ts:19",
      "evidence": "tenantQuery returns only _db and _tenantId with comment 'Populated by downstream specs'; no project key. Routes import listProjects etc. directly from @zync/db/queries.",
      "repro": "grep 'tenantQuery(db' in projects routes → not used; plan factory pattern absent."
    },
    {
      "id": "projects-module-016",
      "severity": "P1",
      "type": "missing",
      "summary": "Settings tab documents permission gating but does not enforce projects:write/delete",
      "spec_ref": "Project detail Settings — gated on projects:write / projects:delete; Danger zone archive/delete",
      "code_ref": "apps/zync-app/src/modules/projects/detail/SettingsTab.tsx:46",
      "evidence": "Edit billing config and Archive buttons render unconditionally; no useSession permissions check unlike ActivityTimeline canWrite at ProjectDetailPage.tsx:195.",
      "repro": "Viewer with only projects:read → Settings tab still shows Edit billing config and Archive project buttons."
    },
    {
      "id": "projects-module-017",
      "severity": "P2",
      "type": "divergent",
      "summary": "time.ts retainer upsert diverges from projects retainer engine (no hours_included seed, no depletion)",
      "spec_ref": "Plan Task 5 — incrementRetainerHours seeds hours_included from billing_config + rollover",
      "code_ref": "packages/db/src/queries/time.ts:178",
      "evidence": "maybeUpdateRetainerHourBank insert omits hours_included; onConflict only increments hours_used. incrementRetainerHours at projects.ts:738-763 computes baseIncluded + rolledOver.",
      "repro": "First billable time stop on retainer → retainer_months.hours_included NULL; HourBankGauge shows used/— h."
    },
    {
      "id": "projects-module-018",
      "severity": "P2",
      "type": "divergent",
      "summary": "List endpoint returns raw page object instead of buildPaginated wrapper per plan",
      "spec_ref": "Plan Task 6 — return buildPaginated(items, nextCursor, total)",
      "code_ref": "apps/zync-api/src/routes/projects/crud.ts:70",
      "evidence": "return c.json(page, 200) where page is {items,nextCursor,total}. grep buildPaginated in apps/zync-api finds only kb/versions.ts local helper, not used by projects.",
      "repro": "GET /api/projects response shape is inline page object, not shared buildPaginated envelope."
    },
    {
      "id": "projects-module-019",
      "severity": "P2",
      "type": "divergent",
      "summary": "archiveProject sets status archived but does not populate archivedAt lifecycle column",
      "spec_ref": "Plan schema — archivedAt set on status transitions",
      "code_ref": "packages/db/src/queries/projects.ts:422",
      "evidence": "update sets { status: 'archived', updatedAt: new Date() } only. projects.archivedAt column defined at packages/db/src/schema/projects.ts:56.",
      "repro": "DELETE /api/projects/:id → project.status=archived but archived_at column remains NULL."
    },
    {
      "id": "projects-module-020",
      "severity": "P2",
      "type": "divergent",
      "summary": "Project form uses raw UUID Input for customer instead of customer Select",
      "spec_ref": "Create/edit project — customer (select)",
      "code_ref": "apps/zync-app/src/modules/projects/form/ProjectFormSheet.tsx:169",
      "evidence": "Customer field is <Input placeholder='Customer UUID' {...register('customer_id')} /> not a Select populated from customers API.",
      "repro": "Open + New Project sheet → must paste customer UUID manually."
    },
    {
      "id": "projects-module-021",
      "severity": "P2",
      "type": "divergent",
      "summary": "Client form validation does not enforce discriminated billing_config shapes",
      "spec_ref": "Plan Task 11 — client validation mirrors createProjectSchema; retainer without monthly hours rejected",
      "code_ref": "apps/zync-app/src/modules/projects/form/ProjectFormSheet.tsx:31",
      "evidence": "formSchema uses billing_config: z.record(z.unknown()).optional() instead of billingConfigByTypeSchema from packages/db/src/validation/projects.ts:32.",
      "repro": "Submit retainer project with empty billing_config from UI → client passes; server returns 400."
    },
    {
      "id": "projects-module-022",
      "severity": "P2",
      "type": "divergent",
      "summary": "listRetainerMonths does not verify project exists (returns [] for unknown id)",
      "spec_ref": "Plan Task 8 — 404 when project not found on retainer-months route",
      "code_ref": "packages/db/src/queries/projects.ts:684",
      "evidence": "Selects retainer_months by tenant+project only; no projects table lookup. reports.ts:47 catches 'Project not found' but listRetainerMonths never throws that error.",
      "repro": "GET /api/projects/random-uuid/retainer-months → 200 [] instead of 404."
    },
    {
      "id": "projects-module-023",
      "severity": "P2",
      "type": "divergent",
      "summary": "incrementRetainerHours enqueues retainer.depleted on QUEUE instead of webhook.deliver",
      "spec_ref": "Plan Task 5 — fire retainer.depleted webhook via webhook.deliver",
      "code_ref": "packages/db/src/queries/projects.ts:811",
      "evidence": "queue.send({ type: 'retainer.depleted', ... }) at :811. Other modules use type: 'webhook.deliver' wrapper (e.g. apps/zync-api/src/lib/time-webhooks.ts:55).",
      "repro": "Dead code path would enqueue wrong message shape vs notifications pipeline."
    },
    {
      "id": "projects-module-024",
      "severity": "P2",
      "type": "divergent",
      "summary": "List/card views show generic Customer link text not customer name",
      "spec_ref": "Project list card view — customer name (link to /customers/:id)",
      "code_ref": "apps/zync-app/src/modules/projects/list/ProjectCard.tsx:52",
      "evidence": "Link text is literal 'Customer' not resolved name. API ProjectObject has customer_id only, no customer_name field.",
      "repro": "Card view always displays 'Customer' link regardless of actual customer name."
    },
    {
      "id": "projects-module-025",
      "severity": "P2",
      "type": "divergent",
      "summary": "Team members list displays raw user_id UUIDs not user names",
      "spec_ref": "Overview — Team members list",
      "code_ref": "apps/zync-app/src/modules/projects/detail/OverviewTab.tsx:186",
      "evidence": "Renders m.user_id string; ProjectMemberObject has no user name/email field from API.",
      "repro": "Overview team section shows UUIDs instead of display names."
    },
    {
      "id": "projects-module-026",
      "severity": "P2",
      "type": "divergent",
      "summary": "Detail header edit/archive actions not gated on projects:write or projects:delete",
      "spec_ref": "Permissions — Create/edit projects:write; Archive/delete projects:delete",
      "code_ref": "apps/zync-app/src/modules/projects/detail/ProjectDetailPage.tsx:132",
      "evidence": "Edit and Archive DropdownMenu.Item render without permissions.includes checks.",
      "repro": "User without projects:write can open edit sheet from header (API PATCH will 403)."
    },
    {
      "id": "projects-module-027",
      "severity": "P2",
      "type": "divergent",
      "summary": "Danger zone offers archive only; spec mentions delete project",
      "spec_ref": "Settings danger zone — archive / delete project",
      "code_ref": "apps/zync-app/src/modules/projects/detail/SettingsTab.tsx:58",
      "evidence": "Only Archive project action via useArchiveProject (soft DELETE /api/projects/:id). No hard-delete endpoint or UI.",
      "repro": "Settings danger zone has no delete-project control."
    },
    {
      "id": "projects-module-028",
      "severity": "P3",
      "type": "divergent",
      "summary": "projects schema adds completedAt/archivedAt columns not in base spec DDL",
      "spec_ref": "Data Model — projects table columns",
      "code_ref": "packages/db/src/schema/projects.ts:55",
      "evidence": "completedAt and archivedAt timestamp columns present; spec SQL lists id through updated_at only. serializeProject omits them from API response at packages/db/src/serialize/projects.ts:23.",
      "repro": "Schema/migration include lifecycle columns absent from spec; not exposed in API."
    },
    {
      "id": "projects-module-029",
      "severity": "P3",
      "type": "divergent",
      "summary": "List page uses danger text for errors instead of design-system ErrorState",
      "spec_ref": "Plan Task 10 — error → ErrorState",
      "code_ref": "apps/zync-app/src/modules/projects/list/ProjectsListPage.tsx:75",
      "evidence": "error branch renders <p className='text-body-1 text-danger'>Failed to load projects</p>. grep ErrorState in apps/zync-app/src/modules/projects returns no matches.",
      "repro": "Simulate API failure on /projects → plain error text, not ErrorState component."
    },
    {
      "id": "projects-module-030",
      "severity": "P3",
      "type": "divergent",
      "summary": "POST /api/projects/:id/members returns 200 instead of 201 on create",
      "spec_ref": "Plan Task 7 — POST members returns ProjectMemberObject (typical 201 for create)",
      "code_ref": "apps/zync-api/src/routes/projects/members.ts:63",
      "evidence": "return c.json(member, 200) for upsert including new member insert.",
      "repro": "POST new member → HTTP 200."
    }
  ],
  "summary": {
    "P0": 3,
    "P1": 13,
    "P2": 11,
    "P3": 3,
    "total": 30
  }
}
