Usage & Billing panel 21
T2-1
HIGH OPEN
worktree · Usage Panel Overhaul (THIS SESSION)
Allocation TOCTOU: concurrent PUTs over-subscribe org AI budget pool
Issue The allocation cap check (existingSum + monthly_limit <= orgLimit) is read-then-write with no atomicity, and the app's own handleAutoBalance fires N parallel PUTs, making over-subscription of the AI budget pool trivially reproducible.
Recommended Fix: move the sum-check + upsert into one SECURITY DEFINER RPC under a row lock on the org (option a) — it is the only option that closes the race without weakening the auto-balance UX.
T2-2
HIGH OPEN
worktree · Usage Panel Overhaul (THIS SESSION)
Admin org meter renders two contradictory overage lines at >=100%
Issue The admin OrgBudgetMeter mounts UsageBudgetMeter without threading overageActive/overageCreditsUsedPct, so the meter's internal hard-stop OverageLine and the admin's own correct credits-drawing block both render, producing two contradictory messages at 100%.
Recommended Fix: thread the overage props in and delete the admin's duplicate block for a single source (option a), and extend the render test past 96% to the 100%/overage-on path — cleanest de-dup with one canonical line.
T2-3
HIGH OPEN
worktree · Usage Panel Overhaul (THIS SESSION)
'Top AI spenders' % computed over only the fetched top-10 page
Issue orgTotalCents sums only the <=10 paginated rows, so every '% of org' is inflated and visible shares always sum to ~100% regardless of true org size, contradicting copy that reads as a true share-of-organization.
Recommended Fix: add org_total_ai_cost_cents to the breakdown response computed server-side over the full roster and divide by that (option a) — preserves the intended 'share of org' meaning rather than relabeling.
T2-6
HIGH OPEN
worktree · Usage Panel Overhaul (THIS SESSION)
Threshold divergence across the four usage surfaces
Issue The same budget % drives four 'approaching the limit' signals at different breakpoints (cron 80/90/100, meter warn80/danger95, MessageChip >=90, platform >=80), leaving the meter's 95-99% red danger band with no matching cron alert by design.
Recommended Fix: single-source the threshold set in one shared constant consumed by all four surfaces (option a) — eliminates the unalertable danger band and keeps signals coherent.
T2-7
HIGH OPEN
worktree · Usage Panel Overhaul (THIS SESSION)
Pagination uses count='estimated' causing silent truncation / empty pages
Issue Whole-org hasMore is computed against profiles count:'estimated' (stale reltuples) while the RPC returns actual rows, so a low estimate makes trailing users unreachable and a high estimate yields empty trailing pages.
Recommended Fix: derive hasMore from data.length === pageSize (option b) — cheapest, adds no extra query, and is exact for the actual returned window.
T2-8
HIGH OPEN
worktree · Usage Panel Overhaul (THIS SESSION)
Mid-cycle plan change skews run-rate projection denominator
Issue On a mid-cycle upgrade, effective_budget_cents jumps so percentage drops while periodStart is unchanged, making dailyRate=percentage/daysElapsed swing the 'on pace by' projection wildly even though spend did not change.
Recommended Fix: compute run-rate off raw spend (cents/day) and project against the current budget rather than off the recomputed % (option a) — keeps the projection stable across plan changes.
T2-9
HIGH OPEN
worktree · Usage Panel Overhaul (THIS SESSION)
Org pool can shrink below summed allocations with no reconciliation
Issue A plan downgrade, budget-override reduction, or power-seat loss shrinks the pool with nothing re-checking existing user_usage_allocations, producing an over-allocated state, incoherent per-user denominators, and a confusing 'would exceed' error even when reducing an allocation.
Recommended Fix: show a read-time 'over-allocated — reconcile' banner (option a) — least destructive; it surfaces the inconsistency without silently rescaling or hard-blocking admins.
T2-10
HIGH OPEN
worktree · Usage Panel Overhaul (THIS SESSION)
Admin $-viewer sees '$0.00 of $0.00 used' on a free/$0-budget org
Issue The clean 'no budget' empty state is gated noBudget && !canSeeDollars, so a billing.view admin on a free tier falls through to a 0% bar and '$0.00 of $0.00' instead of the empty state the platform-operator twin shows everyone.
Recommended Fix: show the 'no monthly AI budget' empty state to $-viewers too and treat budget<=0 as empty-state rather than computing % (option a) — matches the correct platform twin behavior.
EDGE-T2-L
MEDIUM OPEN
main · May 23 (GDPR / data-retention / admin-export)
people-export stream errors close silently; truncation undetectable
Issue A network error mid-stream calls controller.close() yielding a partial CSV (header + N rows) with no way for the consumer to know it was truncated.
Recommended Take option (a): emit a trailer row like '# ERROR: export truncated due to upstream failure' on stream error; it is parseable, harmless to normal CSV consumers, and far cheaper than HTTP/2 trailers or moving to background jobs. Coordinate with EDGE-T2-M's threshold decision.
T2-14
MEDIUM OPEN
worktree · Usage Panel Overhaul (THIS SESSION)
overage-credits-used-pct reverse-derived from balance not ledger
Issue totalLoadedCredits is inferred from balance+overspend rather than the credit ledger, so the non-$ 'X% of loaded credits used' shown to non-admins is wrong whenever over-budget spend is not credit-funded.
Recommended Fix: compute the denominator from the real credit ledger (option to be exact) — the inferred value misleads non-admins on the one number they can see, so accuracy here is worth the ledger query.
T2-16
MEDIUM OPEN
worktree · Usage Panel Overhaul (THIS SESSION)
Personal panel renders 0% on unchecked RPC error
Issue settings/usage/personal/route.ts never checks the .error from get_user_ai_usage_cents_bulk, so a transient RPC failure renders an allocated user a confident '0% used' with a 200 status.
Recommended Fix: check the RPC .error and surface an unavailable/error state instead of a silent 0% — a confident wrong number is worse than an honest 'temporarily unavailable'.
T2-18
MEDIUM OPEN
worktree · Usage Panel Overhaul (THIS SESSION)
Allocation cache invalidation can throw after a successful write
Issue Promise.all of the two cache invalidators in allocations/route.ts can reject after a successful DB write, returning a misleading 500 on a saved allocation and leaving stale org-budget % until TTL.
Recommended Fix: use Promise.allSettled or per-invalidator .catch so invalidation failure does not mask a successful write — the write succeeded, so the response should reflect success.
LP-005
LOW OPEN
main · May (AV-scan, Stripe-CSRF, gmail-push, Microsoft email)
trial_ends_at cleared on trialing->active loses org-row conversion signal
Issue On trial-to-paid conversion Stripe sends trial_end=null and the org row's trial_ends_at is cleared, so the org row alone cannot answer "did this org convert from trial" (signal lives in subscription_events / Stripe Dashboard); this is pre-existing.
Recommended Option (a): accept current — conversion analytics already live durably in subscription_events and Stripe, and adding a trial_converted_at column is only worth it if a specific product surface needs a self-contained org-row signal; defer until that need is concrete.
T2-11
LOW OPEN
worktree · Usage Panel Overhaul (THIS SESSION)
Percentage rounding flips 99.95% to 100% hard-stop early
Issue Math.round flips 99.95%->100.0% (and 94.95%->95.0 danger) in cost-budget/route.ts, firing hard-stop/danger copy half a percent early, though this is display-only since enforcement uses the separate correct microcent reservation.
Recommended Fix: floor instead of round at the threshold so copy never claims a limit reached before it is — trivial change, and floor is the honest choice for a 'used' percentage.
T2-17
LOW OPEN
worktree · Usage Panel Overhaul (THIS SESSION)
Shared-pool rep at 100% gets no hard-stop explanation
Issue In settings/usage/personal/route.ts plus its UI, a shared-pool rep at org 100% sees a bare '100%' with no overage/hard-stop/why chrome explaining why AI stopped.
Recommended Fix: add an explanatory affordance (why AI is blocked + who to contact) at the shared-pool 100% state — small UX add that prevents confused support tickets from blocked reps.
T2-19
LOW OPEN
worktree · Usage Panel Overhaul (THIS SESSION)
Empty-roster team manager copy indistinguishable from idle team
Issue In the breakdown route + admin UsageClient, a zero-team manager sees 'No usage data' that is indistinguishable from 'team exists but idle', with no 'you're not assigned to a team — ask an admin' affordance.
Recommended Fix: differentiate the no-team-assigned empty state from the idle-team state with a distinct message and next step — small copy/branch change that resolves a genuinely ambiguous state for managers.
NF-1
LOW OPEN
worktree · Usage Panel Overhaul (THIS SESSION)
No measurement-lag disclaimer on cron-snapshot %-meter
Issue The %-meter is fed by a cron snapshot with no measurement-lag disclaimer, so it can show 98% when true usage is already 105% (Vercel-style 'checks every few minutes; usage keeps accruing').
Recommended Fix: add a short 'updated every few minutes; usage keeps accruing' disclaimer near the meter — display-only, sets correct expectations and matches industry practice; accept-as-is is also defensible if lag is small.
NF-2
LOW OPEN
worktree · Usage Panel Overhaul (THIS SESSION)
Projection thin-data floor MIN_DAYS_FOR_PROJECTION=0.5 below industry
Issue The run-rate projection floor MIN_DAYS_FOR_PROJECTION=0.5 is far below industry norms (Oracle >=1 full period, Tableau >=5 points), so projections render off very thin early-cycle data.
Recommended Fix: raise the floor (e.g. >=1 day) or add a low-confidence label when below it — prevents wildly swinging early-cycle 'on pace' projections that erode trust in the meter.
EDGE-T2-M
MEDIUM VERIFY FIRST
main · May 23 (GDPR / data-retention / admin-export)
people-export MAX_ROWS=10000 silent truncation
Issue Orgs with more than 10K contacts get only the first 10K rows in the people export with no indication of truncation.
Recommended Adopt option (b): route people exports above 10K to the existing background-export pattern used by other entities; this is consistent with the rest of the app and avoids silent data loss. Until then, the EDGE-T2-L trailer at least signals truncation.
needs-verification: confirm whether people export now falls back to background export above a threshold
T2-13
MEDIUM VERIFY FIRST
worktree · Usage Panel Overhaul (THIS SESSION)
effective_budget_cents not floored at zero on negative override
Issue effective_budget_cents in lib/billing/costBudget.ts is never floored at >=0, so a large negative override_delta inflates '% of credits used' while every >0 gate treats it as $0, with reachability depending on whether the override UI allows a delta below -(base+bonus).
Recommended Fix: clamp with Math.max(0, ...) (needs-verification of UI delta bounds first) — cheap defensive guard that costs nothing if the UI already prevents the negative case.
needs-verification:whether the override UI allows a delta below -(base+bonus), which determines reachability
T2-15
MEDIUM VERIFY FIRST
worktree · Usage Panel Overhaul (THIS SESSION)
AI-integrations settings still shows token-denominated usage bar
Issue The AI-integrations settings widget at app/api/settings/ai/usage/route.ts still shows a TOKEN-denominated AI usage % bar, contradicting the plan's claim that the token model was fully retired everywhere.
Recommended Fix: re-point the widget to the cost-budget % to match the retired-token-model intent — or, if intentional, document it as a deliberate raw-token observability surface; the contradiction must be resolved one way.
needs-verification:whether this surface was intentionally kept as raw-token observability or simply missed in the token retirement
Billing & Stripe 20
T2-4
HIGH OPEN
worktree · Usage Panel Overhaul (THIS SESSION)
Seat add-on cross-minute double-charge from first UI caller
Issue The add-seat idempotency key is a per-minute bucket and each call re-reads live Stripe quantity, so two '+1' clicks straddling a minute boundary get distinct keys and apply +2 seats with two prorated invoices.
Recommended Fix: client-generated stable per-click UUID idempotency key plus a server-authoritative absolute target quantity (option a) — the Stripe-recommended pattern that is immune to the minute boundary and webhook lag.
T2-5
HIGH OPEN
worktree · Usage Panel Overhaul (THIS SESSION)
BuyCreditsModal arms overage + auto-reload before Stripe Checkout
Issue handleBuy PATCHes overage_enabled and overage_auto_reload to true before the Checkout session is created, so abandoning Checkout leaves auto-recharge armed with no credits and possibly no payment method on file.
Recommended Fix: only flip auto_reload_enabled on the confirmed webhook (checkout.session.completed / setup_intent.succeeded) (option a) — the safe post-confirmation pattern that prevents arming on abandonment.
SC-004
MEDIUM OPEN
main · May (AV-scan, Stripe-CSRF, gmail-push, Microsoft email)
pending_org_signups row not cleaned up on Stripe error blocks retry 1hr
Issue When Stripe throws during create-checkout, the inserted 'draft' pending_org_signups row persists with expires_at +1hr, so the user hits 409 SIGNUP_IN_FLIGHT and cannot retry signup until the cron sweep or expiry.
Recommended Option (a): DELETE the row immediately in the Stripe-error catch path — directly unblocks the user, is the least surprising behavior, and the row carries no value once checkout failed; cheap and high user-impact.
SC-005
MEDIUM OPEN
main · May (AV-scan, Stripe-CSRF, gmail-push, Microsoft email)
Mobile UX: Continue button below 4 stacked plan cards requires long scroll
Issue The consolidated Continue button sits at the bottom of the plan grid, so mobile users must scroll past ~1000-1600px of stacked cards to reach it, a regression from the previous always-visible per-card buttons.
Recommended Option (a): make the Continue button sticky-bottom on mobile (md:relative for desktop) — removes the scroll friction on a conversion-critical step while keeping the clean desktop layout; standard mobile pattern, low risk.
LP-002
MEDIUM OPEN
main · May (AV-scan, Stripe-CSRF, gmail-push, Microsoft email)
syncCustomerDefaultPaymentMethod card-only filter + auto-promote-on-attach
Issue Path 3 filters type:'card' only (non-card payers see "No payment method on file") and an attached PM auto-promotes to default even with no active subscription, producing a "visible but not billable" hero for churned customers.
Recommended Option (c): add an active-subscription check before promoting an attached PM to default — fixes the misleading churned-customer hero state with a targeted change; the non-card widening (option b) is low value since signup only accepts cards, so defer that part.
LP-003
MEDIUM OPEN
main · May (AV-scan, Stripe-CSRF, gmail-push, Microsoft email)
Incomplete subscription's declined card propagates to invoice_settings default
Issue ACTIVE_STATUS includes 'incomplete', so a subscription whose first invoice was declined still propagates its declined default_payment_method, and a user who fixes it by attaching a new card via the portal still sees the old declined card in the hero.
Recommended Option (a): drop 'incomplete' from ACTIVE_STATUS so declined-first-invoice subs don't propagate the rejected card, letting Path 3's listPaymentMethods surface the real current card — directly corrects the wrong-card display; verify no flow legitimately needs 'incomplete' treated as active.
LE3-D2-accept-and-pay-no-idempotency-key
MEDIUM OPEN
main · May 22-23 (adversarial passes)
Public accept-and-pay has no client-side Idempotency-Key header support
Issue The route relies on a server-side deterministic Stripe key plus the atomic UPDATE for double-click protection, so a buyer who double-clicks gets a 409 loser-of-race error rather than a replayable same-response, diverging from Stripe's public-mutation idempotency convention.
Recommended Fix the LE3-F2 retry path first, then adopt option (c) — Redis SETNX short-TTL keyed on idempotencyKey+publicId so a second click within ~60s returns the cached response; lightweight, no new table, and complements rather than duplicates the F2 fix.
SC-002
LOW OPEN
main · May (AV-scan, Stripe-CSRF, gmail-push, Microsoft email)
Double-click race in ConfigureSeatsClient submitting guard
Issue The submitting guard reads from React state closure, so two clicks in the same tick both pass and can insert two 'draft' pending_org_signups rows before server-side dedup fires, creating an orphaned Stripe checkout session.
Recommended Option (a): add a useRef synchronous guard alongside useState — a 2-line client-side fix that closes the same-tick race cheaply; server dedup plus cron sweep already make the orphan self-healing, so no server change is warranted.
SC-006
LOW OPEN
main · May (AV-scan, Stripe-CSRF, gmail-push, Microsoft email)
ConfigureSeatsClient handleSubmit lacks fallback setSubmitting(false)
Issue If the API returns { ok: true } with neither redirect_url nor organization_id, the submit button stays in submitting state forever with no UI safety net.
Recommended Option (b): add an explicit fallthrough setError('Unexpected response') at the bottom of the handler — both re-enables the button and tells the user something went wrong, which is more helpful than a silent finally-reset; trivial defensive guard.
SC-007
LOW OPEN
main · May (AV-scan, Stripe-CSRF, gmail-push, Microsoft email)
customer_update.name='auto' overwrites Stripe customer name with checkout-typed value
Issue The customer_update.name='auto' flag writes the checkout billing-form name back to the Stripe customer, which can then diverge from the CRM org's organization_name and complicate billing reconciliation.
Recommended Option (a): keep 'auto' — Stripe's customer record should reflect the legally accurate billing name the payer entered, which is authoritative for invoicing/tax; reconciliation can key on customer ID rather than name.
SC-008
LOW OPEN
main · May (AV-scan, Stripe-CSRF, gmail-push, Microsoft email)
pendingRecent 409 check ignores rows with NULL stripe_session_id
Issue The SIGNUP_IN_FLIGHT 409 branch only fires when pendingRecent.stripe_session_id is truthy, so a 'draft' row with NULL session_id does not block a second insert, risking duplicate pending_org_signups rows.
Recommended Option (b): add a partial unique constraint on requesting_user_id WHERE status IN ('draft','checkout_created') so duplicate inserts fail at the DB layer — the durable defense that also covers the SC-002 race; pair with tightening the app check.
MF-021
HIGH VERIFY FIRST
main · Apr 22 (auth/Stripe/quotes/pipelines) + calendar-booking
cleanup-pending-orgs cron deletes paid-org-racing-webhook or legit free-tier org
Issue The hourly cron deletes orgs with NULL subscription_status AND NULL stripe_customer_id AND created_at<24h, which can wrongly delete a paid org racing the Stripe webhook, a webhook-delayed org, or a legitimate free-tier org.
Recommended Combine (a) extend the quarantine threshold to 7 days and require NOT EXISTS profiles with (b) a Stripe customer.search cross-reference before deletion; the dual guard is cheap relative to deleting a paying customer's org.
needs-verification:check app/api/cron/cleanup-pending-orgs/route.ts for current threshold and Stripe cross-check
MF-045
HIGH VERIFY FIRST
main · Apr 22 (auth/Stripe/quotes/pipelines) + calendar-booking
Stripe checkout session insert failure leaves customer paid with no CRM record
Issue A payment_sessions insert failure does not abort the checkout flow, so a customer can pay with no corresponding CRM record.
Recommended Adopt option (c) transactional outbox (insert + enqueue Stripe call in one SQL transaction, process outbox async) — it preserves conversion (no fail-closed) while guaranteeing the CRM record exists for every paid session.
needs-verification:check app/api/payments/checkout/route.ts for fail-closed or outbox handling
MF-071
HIGH VERIFY FIRST
main · Apr 22 (auth/Stripe/quotes/pipelines) + calendar-booking
Floating-point quote line math drifts pennies
Issue Quote line totals computed as quantity * unit_price * (1+tax) using JS floats accumulate penny rounding drift.
Recommended Adopt option (a) integer-cents (BigInt) with a toMoney() helper for all monetary math — exact and dependency-free; enforce it server-side and persist as NUMERIC(12,2) so the client can never reintroduce float drift.
needs-verification:check app/api/quotes/** for integer-cents or decimal money handling
LP-001
HIGH VERIFY FIRST
main · May (AV-scan, Stripe-CSRF, gmail-push, Microsoft email)
Stripe webhook endpoint may not subscribe to customer.updated + payment_method.attached
Issue The new event handlers are no-ops in production if the Stripe Dashboard webhook endpoint is not subscribed to customer.updated and payment_method.attached, silently regressing the card-preview cache-bust fix (users see stale card for up to the 5-min TTL).
Recommended Option (c): boot-time idempotent verify that calls stripe.webhookEndpoints.update to ensure both events are subscribed — self-healing across environments so the fix can't silently regress on a fresh deploy; cheaper to maintain than a runbook step humans forget.
needs-verification:check whether the Stripe endpoint subscription verification was added (runbook, diagnostic route, or boot-time) since 2026-05-17
LE3-F2-accept-and-pay-502-retry-contradiction
HIGH VERIFY FIRST
main · May 22-23 (adversarial passes)
accept-and-pay 502 retry message contradicts the 409 buyer gets on retry
Issue When Stripe fails after the quote is flipped to accepted, the route returns 502 telling the buyer to retry, but retry hits the atomic UPDATE guard and returns 409, creating a buyer-facing dead-end recoverable only by operator support.
Recommended Fix with option (a) — on a no-row atomic UPDATE because status is already accepted, fall through to invoice-lookup + Stripe-Checkout re-mint via the existing idempotency key; safely re-enters the flow without weakening the two-buyers-racing defense (original signer is preserved).
needs-verification:check accept-and-pay/route.ts ~181-200/423-433 for whether the already-accepted branch now re-enters the payment flow
LE3-D7-accept-flow-not-transactional
HIGH VERIFY FIRST
main · May 22-23 (adversarial passes)
Accept-then-invoice-then-Stripe is not transactional; mid-flight crash leaves partial ledger
Issue The accept-and-pay flow runs ~8 sequential awaits with no transaction wrapper, so a runtime kill mid-flow leaves a quote accepted with a partial/absent invoice ledger that buyers cannot recover from.
Recommended Fix with option (c) + LE3-F2 — wrap the DB side (quote UPDATE + invoice INSERT + ledger INSERTs) in a single Postgres RPC keeping the Stripe call outside the TX, eliminating partial-DB-ledger states; pair with the LE3-F2 retry path to cover the DB-committed-Stripe-failed case. A cleanup cron (b) is a good lighter add-on for stragglers.
needs-verification:check accept-and-pay/route.ts:202-339 for whether the DB writes were since wrapped in an atomic RPC
MF-033
MEDIUM VERIFY FIRST
main · Apr 22 (auth/Stripe/quotes/pipelines) + calendar-booking
reconcile-seat-counters races invitation accepts; 500-org cap skips orgs
Issue The seat reconciler bulk-reads counts while accept_invitation is mid-transaction (stale count overwrites fresh) and its 500-org cap silently skips higher-id orgs.
Recommended Adopt option (b) trigger-maintained counters so no reconciler/global-cap is needed; if a reconciler must remain, partition by org_id range to remove the cap and add pg_advisory_xact_lock shared with accept_invitation.
needs-verification:check app/api/cron/reconcile-seat-counters/route.ts for cap removal and locking
SC-001
MEDIUM VERIFY FIRST
main · May (AV-scan, Stripe-CSRF, gmail-push, Microsoft email)
Stripe err.message and err.param echoed verbatim to client
Issue On StripeInvalidRequestError the signup/billing checkout handlers send Stripe's raw err.message and err.param to the client, which can contain user-supplied data and violates the app/api CLAUDE.md rule against exposing internal details.
Recommended Option (b): map known Stripe error codes to curated user-facing strings and fall back to a generic message for unknowns — preserves the actionable-feedback goal that motivated the verbatim echo while respecting the info-hiding rule; cleaner long-term than raw passthrough.
needs-verification:check first-subscribe and create-checkout catch handlers for whether error mapping was added later
D1-cross-currency-aggregates-no-ui
MEDIUM VERIFY FIRST
main · May 22-23 (adversarial passes)
Cross-currency aggregate fields returned by RPCs but no UI consumes them
Issue 4 RPCs now return base_currency + other_currency_count and the pipeline-summary route forwards them, but zero components read the keys, so mixed-currency orgs still see truncated dashboard totals with no excluded-count indication.
Recommended Fix with option (a) inline footer text ('Excluding N opportunities in other currencies') on each affected widget — minimal change that completes the data path the migration intended and matches Stripe's reporting-tab pattern; defer richer per-currency breakdown (c) to a later cycle.
needs-verification:grep components for other_currency_count to confirm no widget has since started rendering it
Auth & security 19
PW-T2-001
HIGH OPEN
main · DP7 + auth-shell-sync + password/OAuth + My Work
JIT-elevation grants are method-agnostic, scope-blind, and not single-use
Issue A cheap 5-min oauth_reauth elevation grant minted on the user Security page satisfies requireElevation on ADMIN routes (impersonation, force-password-reset), authorizes many distinct sensitive actions (no consumed_at), records no scope/intent, and filters only user_id not organization_id.
Recommended Fix: at minimum exclude oauth_reauth from admin-route acceptance via an allowed-method set (option b) so admin power requires an admin-scoped step-up, not a user provider bounce; ideally also add scope/min_assurance/organization_id to admin_elevation_grants and consumed_at on the most destructive actions.
LE-009
MEDIUM OPEN
main · May (AV-scan, Stripe-CSRF, gmail-push, Microsoft email)
forward + bulk-actions + send routes lack messaging.send_email permission gate
Issue All three email outbound routes use withAuth only with no role permission gate, so a view_only-role user with a connected mailbox can send, forward, and bulk-trash (bounded to their own org-isolated mailbox).
Recommended Option (a): add a single messaging.send_email permission key and gate all 3 routes — enforces the role hierarchy consistently with minimal surface; per-action split keys (option b) are over-granular for the current product, so one key is the right altitude.
EDGE-T2-P
MEDIUM OPEN
main · May 23 (GDPR / data-retention / admin-export)
Export download endpoint has no per-user rate limit
Issue A user can repeatedly hit GET /api/admin/export/download/[id] to grind out signed URLs, and the phantom audit_logs means there is no abuse trace.
Recommended Adopt option (c): combine a per-user limit (~10/min via the existing limiter) with a per-export-id fetch cap (~5); together they bound both broad abuse and single-export hammering at low cost using infrastructure already present.
MW-SEC-001
MEDIUM OPEN
main · DP7 + auth-shell-sync + password/OAuth + My Work
PATCH /api/tasks/[id] has no tasks.edit gate or ownership clamp
Issue Any org member with a task id can complete/snooze/edit ANY task in the org because status/due_date edits are gated only by the org filter (checkTaskAssignPermission fires solely on assigned_to changes), despite a code comment asserting a tasks.edit gate that does not exist.
Recommended Fix as a focused separate change (pre-existing on main, within-org authz gap not cross-tenant): wrap the route with withPermission('tasks.edit') plus a creator/assignee/manager ownership clamp scoped own/team/all (mirror the activities PATCH), preserving manager team-completion.
EDGE-T2-Q
LOW OPEN
main · May 23 (GDPR / data-retention / admin-export)
GET /api/admin/data-retention does not require MFA (POST does)
Issue Reading which retention policies are active does not require MFA even though writes do, a minor compliance-posture information leak.
Recommended Accept option (b): leave reads non-MFA since writes already require MFA and this matches normal settings-page UX; the leak is minor and forcing MFA on every read would hurt usability. Revisit only if compliance explicitly requires read-side MFA.
PW-T2-002
LOW OPEN
main · DP7 + auth-shell-sync + password/OAuth + My Work
password_history reuse check truncates at bcrypt's 72-byte limit
Issue bcryptjs silently truncates at 72 bytes, so the no-reuse-of-last-5 check operates on a truncated value for long/multibyte passwords (weakens history check only, not a login risk).
Recommended Fix: SHA-256 pre-hash before bcrypt at both write and compare sites (option a), accepting that existing history hashes silently pass the reuse check for one rotation cycle until they age out; low cost, closes the truncation gap for power users.
PW-T2-003
LOW OPEN
main · DP7 + auth-shell-sync + password/OAuth + My Work
currentPassword === newPassword not explicitly blocked
Issue A grandfathered email user whose current password was never written to password_history can rotate to the same password as a no-op.
Recommended Fix: add an explicit current!=new check (option a) in the change-password handler; trivial guard that does not depend on Supabase behavior and gives a clear validation error.
PW-T2-004
LOW OPEN
main · DP7 + auth-shell-sync + password/OAuth + My Work
Password validation allows control chars / null bytes
Issue validatePassword checks length and character classes but not control chars, so a NUL byte reaches GoTrue with provider-defined handling.
Recommended Fix: reject control chars / null bytes in validatePassword (option a); cheap input hardening that avoids undefined downstream provider behavior.
MW-SEC-002
LOW OPEN
main · DP7 + auth-shell-sync + password/OAuth + My Work
Tasks PATCH applies status/priority/position with no enum validation
Issue body.status (out-of-CHECK yields 500 not 400), body.priority and body.position (no CHECK constraint) are assigned unvalidated, so free-form values can persist on own-org rows (no cross-tenant exposure).
Recommended Fix on main: validate status against the task-status enum and priority against its enum, coerce position with Number()/Number.isFinite, and return 400 on mismatch; cheap input hardening mirroring the existing estimated_hours/actual_hours guards.
MF-001
HIGH VERIFY FIRST
main · Apr 22 (auth/Stripe/quotes/pipelines) + calendar-booking
Auth cache HMAC cookie survives user deletion/revocation up to 10 min
Issue After admin signOut+deleteUser, the target user's HMAC-signed auth_cache cookie still verifies and serves org-scoped GET routes as the deleted user for the full 10-minute TTL.
Recommended Adopt option (b) per-user auth_version embedded in the HMAC payload (bump column on any revocation event), since it closes the window on the next request without adding a Redis round-trip to every authenticated GET; accept the migration cost as worth it for revocation correctness.
needs-verification:check lib/authCache.ts and lib/authHandler.ts for an auth_version/blocklist mechanism added since April
MF-112
HIGH VERIFY FIRST
main · Apr 22 (auth/Stripe/quotes/pipelines) + calendar-booking
Invitation email-mismatch handling divergent across callback vs accept
Issue /api/auth/callback and /api/invitations/accept handle an invite/session email mismatch differently, creating inconsistent and potentially hijackable behavior.
Recommended Adopt option (c) clear the invite cookie on any session-user change AND unify on a single hard-reject-with-confirm flow — clearing the cookie closes the hijack vector and a single shared handler removes the divergence.
needs-verification:check auth/callback and invitations/accept for shared mismatch handling
EC-L-004
MEDIUM VERIFY FIRST
main · May (AV-scan, Stripe-CSRF, gmail-push, Microsoft email)
AV scope: proxy upload to Drive/OneDrive and import routes unscanned
Issue Cloudmersive AV runs only on the three Supabase-Storage upload routes; files proxied to Google Drive/OneDrive and CSV/XLSX imports are not scanned by us, and the CASA SAQ #10 memo scopes coverage to "Supabase Storage" only.
Recommended Option (a): document the AV scope as Supabase-Storage-only in the SAQ memo and /docs/admin/security, citing Drive/OneDrive's own upstream malware scanning — lowest cost and the proxy/import paths are genuinely covered by provider AV or are parse-not-store, so extending our own scan adds latency and cost for no real risk reduction.
needs-verification:confirm SAQ memo and /docs/admin/security still describe scope as Supabase-Storage-only and that proxy-upload path is unchanged
EDGE-T2-20260530-001
MEDIUM LIKELY DONE
main · DP7 + auth-shell-sync + password/OAuth + My Work
Self-inflicted session-revoked banner in peer tabs
Issue A self-initiated global signOut() (e.g. own password reset) broadcasts SIGNED_OUT to the user's other tabs, which lack the per-tab intentional flag and hard-navigate to /login?reason=session_revoked, showing an alarming false 'session revoked' banner.
Recommended Already RESOLVED (option b, 2026-05-30, commit c9e2d06d): cross-tab SIGNED_OUT now redirects to /login?reason=signed_out_elsewhere with neutral copy; no further action needed.
likely-superseded:in-file RESOLUTION note at lines 1289 marks 001 RESOLVED via option b on branch auth-shell-tier2-hardening
EDGE-T2-20260530-003
MEDIUM LIKELY DONE
main · DP7 + auth-shell-sync + password/OAuth + My Work
Spurious SIGNED_OUT from Supabase multi-tab token-reuse race causes false forced logout
Issue Concurrent multi-tab refreshes of the same Supabase token can make the loser emit SIGNED_OUT (broadcast to all tabs), firing both chrome-hide and hard-redirect to ?reason=session_revoked as a false positive on an actively-working user.
Recommended Already RESOLVED (option a, 2026-05-30): verified @supabase/auth-js 2.103.3 ships navigator.locks refresh serialization; keep option b (single getUser() re-check before redirect) in reserve only if the race recurs.
likely-superseded:in-file RESOLUTION note at line 1291 marks 003 RESOLVED via option a (version verified)
EDGE-T2-20260530-004
MEDIUM LIKELY DONE
main · DP7 + auth-shell-sync + password/OAuth + My Work
Forced cross-tab logout destroys unsaved work (no autosave on this path)
Issue On cross-tab SIGNED_OUT the listener immediately clearAuthData() + hard-navigates with no beforeunload or autosave guard, so a half-written email/edit in a background tab is silently lost.
Recommended Accept as deferred (already dispositioned option c, 2026-05-30): clearAuthData()+sessionStorage.clear() wipe any autosave blob on every logout and there is no restore consumer, so a parity write would be dead code; revisit only as a scoped restore-on-next-login feature (option b) if forced-logout data loss becomes a real reported pain.
likely-superseded:in-file RESOLUTION at line 1294 marks 004 ACCEPTED/DEFERRED via option c with revisit-only-as-feature guidance
MF-XXX-quote-public-ratelimit
LOW LIKELY DONE
main · May 22-23 (adversarial passes)
Public quote accept/decline routes lacked per-IP rate limit
Issue Public accept/decline/accept-and-pay quote routes had no per-IP throttle, allowing junk quote_activities rows and Stripe rate-limit budget burn.
Recommended No action needed — already RESOLVED in /deep-harden iter 1 (option-a: 5/10min on cost-heavy routes, 60/min on view routes, all 429s carry RATE_LIMITED + Retry-After). Close as done.
likely-superseded:marked RESOLVED in /deep-harden iteration 1 with Decision [x] option-a
EDGE-T2-20260530-002
LOW LIKELY DONE
main · DP7 + auth-shell-sync + password/OAuth + My Work
AppShellGate un-hide keyed on any session-carrying event, not SIGNED_IN
Issue AppShellGate reset signedOut=false on ANY event carrying a session (TOKEN_REFRESHED, USER_UPDATED, INITIAL_SESSION, PASSWORD_RECOVERY), so a benign refresh could flip the shell back to visible after a sign-out, weakening fail-closed.
Recommended Already RESOLVED (option b, 2026-05-30): signedOut made a one-way latch (removed the else-if-session un-hide); login always hard-navs so the latch resets on reload; no further action needed.
likely-superseded:in-file RESOLUTION note at line 1290 marks 002 RESOLVED via option b
EDGE-T2-20260530-005
LOW LIKELY DONE
main · DP7 + auth-shell-sync + password/OAuth + My Work
Chromeless flash of own protected content during forced-logout gap
Issue When signedOut flips true on a protected route, the gate renders publicLayout which still renders the protected page's server-rendered children (own dashboard content without sidebar/providers) for a beat before the hard-redirect lands.
Recommended Already RESOLVED (option a, 2026-05-30): a <SignedOutPlaceholder/> now renders during the logout gap on protected routes instead of publicLayout-with-children; no further action needed.
likely-superseded:in-file RESOLUTION note at line 1292 marks 005 RESOLVED via option a
EDGE-T2-20260530-006
LOW LIKELY DONE
main · DP7 + auth-shell-sync + password/OAuth + My Work
AuthStateListener mounted inside the shell subtree it helps unmount
Issue AuthStateListener (which performs the non-intentional redirect) was mounted inside the shell that AppShellGate unmounts on SIGNED_OUT, so the redirect survived only by Supabase's synchronous-dispatch timing luck.
Recommended Already RESOLVED (option a, 2026-05-30): <AuthStateListener/> moved out of the shell prop in app/layout.tsx to an always-mounted layout position, making the guaranteed redirect structurally guaranteed; no further action needed.
likely-superseded:in-file RESOLUTION note at line 1293 marks 006 RESOLVED via option a
Data, RLS & tenancy 14
LE-010
MEDIUM OPEN
main · May (AV-scan, Stripe-CSRF, gmail-push, Microsoft email)
linked_thread_group_id SHA-256 hash not salted with organization_id
Issue The linked_thread_group_id SHA-256 hash is computed from the message-id root without an org salt, so two orgs receiving mail from the same sender produce the same group id, a defense-in-depth violation if any future query omits the org filter.
Recommended Option (a): salt the hash with organization_id now and backfill existing rows during off-hours — the feature is new and small, so the backfill cost is low and salting closes the cross-tenant collision before broad rollout makes migration painful; accept the cutover (option b) only if backfill proves too risky.
LE-014
MEDIUM OPEN
main · May (AV-scan, Stripe-CSRF, gmail-push, Microsoft email)
getStoredTokenRecord does not filter by organization_id
Issue getStoredTokenRecord uses the service-role admin client (bypasses RLS) and queries user_provider_tokens by user_id+provider only with no org filter, violating the CLAUDE.md defense-in-depth rule even though the combination is schema-unique.
Recommended Option (b): add an optional organization_id? param applied as a defense-in-depth filter when callers provide it, enabling gradual rollout across the 50+ call sites without a big-bang change — better than a required param (option a) that forces all callers at once or post-fetch validation (option c) that's easy to forget. Route to /refactor or /deep-harden as noted.
D6-quote-status-migration-lint-bypass
MEDIUM OPEN
main · May 22-23 (adversarial passes)
Quote status migration bypasses freeze lint via multi-line SQL formatting
Issue The quotes_status_allow_rejected migration splits ADD CONSTRAINT and CHECK across lines to slip a superset CHECK past the per-line freeze lint, which is safe here but sets a copyable precedent for non-superset CHECKs.
Recommended Do option (a) — document the multi-line workaround as an explicit carve-out for strict-superset CHECKs in lint-migrations.sh/CLAUDE.md, stating the superset condition; cheaper than rewriting the linter to multi-line scanning (b) and removes the silent-precedent risk.
INV-1-workflow-executions-orgid
HIGH VERIFY FIRST
main · May 22-23 (adversarial passes)
workflow_executions lacks denormalized organization_id column
Issue workflow_executions enforces tenant isolation via a join to workflows instead of a denormalized organization_id, violating the CLAUDE.md child-of-parent rule and risking cross-tenant leak under RLS policy drift.
Recommended Fix with option (b) — add NULLABLE organization_id plus a BEFORE INSERT trigger that fills it from the workflow row, then index it and switch reads to direct filter; freeze-safe, closes the defense-in-depth gap and the ~50ms join cost, lazy-backfill old rows.
needs-verification:grep workflow_executions migrations/lib for an organization_id column — may already be backfilled by later shipped work
MF-022
MEDIUM VERIFY FIRST
main · Apr 22 (auth/Stripe/quotes/pipelines) + calendar-booking
Onboarding double-submit creates orphan orgs
Issue A rapid double-click or React StrictMode double-fire both pass the null check and create two orgs while the profile points at only one, leaving an orphan org.
Recommended Adopt option (a) unique partial index on profiles plus an advisory lock / UPSERT with affected-row verification — the DB-level uniqueness is the only race-proof guarantee and client disable-on-submit alone cannot be trusted.
needs-verification:check for a unique partial index on profiles and locking in app/api/onboarding/route.ts
MF-050
MEDIUM VERIFY FIRST
main · Apr 22 (auth/Stripe/quotes/pipelines) + calendar-booking
Pipeline last-of-kind deactivation orphans opportunities
Issue An admin can deactivate the only pipeline, leaving all opportunities orphaned with no active pipeline.
Recommended Adopt option (a) block deactivation of the last active pipeline with a clear error — simplest correct guard; auto-promote/cascade (b/c) add surprising side effects an admin did not request.
needs-verification:check app/api/pipelines/** for a last-pipeline guard
MF-051
MEDIUM VERIFY FIRST
main · Apr 22 (auth/Stripe/quotes/pipelines) + calendar-booking
api_keys.created_by FK blocks self-delete and breaks org-delete cascade
Issue Because api_keys.created_by has a restricting FK, a super_admin with active api_keys cannot self-delete and delete_organization_fully breaks mid-cascade.
Recommended Adopt option (a) ON DELETE SET NULL on created_by — keys keep working (no service disruption from CASCADE deleting live keys) at the cost of losing attribution, which is acceptable since the key itself records scope.
needs-verification:check api_keys migration for the created_by FK on-delete action
MF-070
MEDIUM VERIFY FIRST
main · Apr 22 (auth/Stripe/quotes/pipelines) + calendar-booking
Invoice due date off-by-one in non-UTC orgs
Issue 'Due in 30 days' is computed in UTC, so an org in UTC-12 sees the due date as 31 days out (off-by-one).
Recommended Adopt option (a) store due_date as a DATE computed in the org's configured timezone — this is the canonical fix and also resolves the related MF-164/goalProgress timezone bug from one timezone source.
needs-verification:check app/api/invoices/** and lib/businessDays.ts for org-timezone date computation
MF-164
MEDIUM VERIFY FIRST
main · Apr 22 (auth/Stripe/quotes/pipelines) + calendar-booking
goalProgress setHours breaks period boundaries in non-UTC orgs
Issue Goal-progress period boundaries are computed with setHours(0,0,0,0) which breaks for non-UTC orgs (wrong period bucketing).
Recommended Resolve jointly with MF-070: compute boundaries in the org's configured timezone from a single shared timezone source rather than local server time — bundling avoids two divergent timezone implementations.
needs-verification:check lib/goalProgress.ts for org-timezone boundary computation
EDGE-T2-R
MEDIUM VERIFY FIRST
main · May 23 (GDPR / data-retention / admin-export)
Phantom activities.status reference in bookings detail endpoint
Issue app/api/bookings/[id]/route.ts:54 selects a non-existent activities.status column, so the query errors and the booking detail silently omits linked activity data.
Recommended Take option (a): drop status from the select list since grep found no caller reading activity.status from this endpoint; one-line fix matching the same class of bug already corrected by migration 20260523300000. Verify no caller needs cancellation state first.
needs-verification: confirm the select at app/api/bookings/[id]/route.ts:54 no longer references the phantom status column
EDGE-T2-S
MEDIUM VERIFY FIRST
main · May 23 (GDPR / data-retention / admin-export)
companies.is_archived filter inconsistent across action-items paths
Issue The stale-leads branch surfaces archived lead-type companies via the RPC and lib fallback (both missing the is_archived filter) while only the route fallback excludes them, a 3-way divergence.
Recommended Adopt option (a): add .eq('is_archived', false) to the migration RPC body and lib/homeQueries.ts:829 to match the route fallback; a small follow-up migration plus a one-line lib edit that aligns all three paths with UX expectations (archived leads should not resurface).
needs-verification: confirm the RPC body and lib/homeQueries.ts:829 now include the is_archived=false filter
EDGE-T2-DP7-001
MEDIUM VERIFY FIRST
main · DP7 + auth-shell-sync + password/OAuth + My Work
opportunity_contacts + opportunity_team_members lack denormalized organization_id
Issue The opportunity_contacts and opportunity_team_members tables have no organization_id column and rely solely on RLS EXISTS-joins to opportunities for tenant isolation, violating the documented child-entity denorm rule and failing open if RLS is ever weakened.
Recommended Fix: denormalize organization_id onto both tables (additive ADD COLUMN, backfill from opportunities.organization_id, NOT NULL, composite indexes, filter every query) to match the documented tasks/quote_items/invoice_items pattern; defense-in-depth, no active leak today but cheap to align.
needs-verification:confirm the two tables still lack an organization_id column on current main and that no later migration already added it
MF-159
LOW VERIFY FIRST
main · Apr 22 (auth/Stripe/quotes/pipelines) + calendar-booking
Public quote sendBeacon 405s against GET-only endpoint
Issue The public quote page's sendBeacon hits a GET-only tracking endpoint and 405s, so view analytics are lost.
Recommended Adopt option (a) accept POST on the tracking endpoint — sendBeacon sends POST, so this is the one-line fix that preserves the unload-safe beacon semantics rather than replacing with a less-reliable fetch.
needs-verification:check app/q/[id]/* tracking endpoint for a POST handler
F6-inline-check-oauth-freeze
LOW VERIFY FIRST
main · May 22-23 (adversarial passes)
Inline CHECK on ADD COLUMN during Google OAuth verification freeze
Issue payment_sessions.source was added with an inline CHECK that structurally mirrors the ADD CONSTRAINT CHECK the freeze linter blocks, even though the DEFAULT makes it practically safe for main's old code path.
Recommended Keep as-is (option a) — the column has a DEFAULT and the CHECK is permissive, so main-version inserts pass; just add a one-line note in the migration/lint policy that inline CHECK with a DEFAULT is the sanctioned freeze-safe form to avoid future confusion.
needs-verification:confirm freeze is still active as of 2026-06; if Google verification completed, the freeze concern is moot and item is simply close-as-keep
Workflows, jobs & cron 11
MW-T2-001
HIGH OPEN
main · DP7 + auth-shell-sync + password/OAuth + My Work
TOCTOU: concurrent task-complete double-fires done-transition side effects
Issue app/api/tasks/[id]/route.ts gates the system-activity insert, task_completed notification, and outbound task.completed webhook on a read-then-check (status !== 'done') not an atomic transition, so two concurrent PATCHes both pass and produce duplicate timeline rows, duplicate notifications, and a double outbound webhook (customer Zap runs twice).
Recommended Fix as a dedicated separately-tested change (pre-existing, not in this diff): when body.status==='done' add .neq('status','done') to the UPDATE and gate side effects on rows-affected (0-row non-If-Match = already-done, return success + skip side effects), being careful about the If-Match/PGRST116->409 interaction; mirror on the activities PATCH.
T2-12
MEDIUM OPEN
worktree · Usage Panel Overhaul (THIS SESSION)
AI alert dedup uses calendar-month bucket for cycle-window spend
Issue The AI budget alert measures subscription-cycle spend via getOrgCostBudget but stamps it into a calendar-month usage_alerts bucket, so the dedup window does not align with the cycle it measures.
Recommended Fix: key the dedup bucket on the billing cycle period rather than the calendar month so alerts reset with the cycle — the comment acknowledges the mismatch, making it a latent reconciliation gap worth closing.
MW-T2-002
LOW OPEN
main · DP7 + auth-shell-sync + password/OAuth + My Work
Deleted-parent task dead-ends on the entity not-found card
Issue A follow-up whose company/person/project was deleted after the queue was fetched drills to the parent route and renders the not-found card before the ?task= panel effect mounts (no crash or leak).
Recommended Fix (option b): have the not-found branch redirect a ?task= param to /tasks?task=<id> so the task panel still opens; small UX win, or accept the floor since it is rare and harmless.
MW-T2-003
LOW OPEN
main · DP7 + auth-shell-sync + password/OAuth + My Work
Panel-over-stale-board on the by-id fallback
Issue A task linked to both a person and a company drills to the company board by precedence; if that board lacks the row, the correct org-scoped panel floats over a board that doesn't list it.
Recommended Fix (option b): route board/row mismatches to /tasks?task=<id> for a consistent surface; cosmetic, accept if board-fallback churn is not worth it.
MW-T2-004
LOW OPEN
main · DP7 + auth-shell-sync + password/OAuth + My Work
Add-outcome then Undo leaves an outcome activity linked to a re-opened task
Issue After Add-outcome then Undo, the outcome activity remains valid but is linked to a now-reopened task, raising whether Add-outcome (a terminal intent) should dismiss the completion toast.
Recommended Fix lightly: make Add-outcome dismiss the completion toast as a terminal intent by having addToast return its id so the action can removeToast; benign either way, low priority.
MW-T2-005
LOW OPEN
main · DP7 + auth-shell-sync + password/OAuth + My Work
Completion toast Undo/Add-outcome remain actionable after navigating away
Issue Toast closures resolve correctly surface-independently, but acting on an Undo/Add-outcome toast for a row you have navigated away from is odd.
Recommended Fix: auto-dismiss action toasts on route change for cleaner UX, or leave them since they expire in 7s; low priority, mild polish.
MW-T2-006
LOW OPEN
main · DP7 + auth-shell-sync + password/OAuth + My Work
ProjectDetailClient ?task= effect lacks the isLoading guard EntityTasksSection has
Issue ProjectDetailClient fires a redundant by-id GET when the board is still loading because its ?task= effect lacks the isLoading guard its sibling EntityTasksSection has (parity nit, no defect).
Recommended Fix: add the matching isLoading guard for parity to avoid the redundant fetch; trivial, no correctness impact.
MF-025/MF-026
HIGH VERIFY FIRST
main · Apr 22 (auth/Stripe/quotes/pipelines) + calendar-booking
Email queue at-least-once semantics + send-immediate claim order double-send
Issue Email queue workers select pending rows, process, then update status with no atomic claim, so concurrent cron ticks double-send emails.
Recommended Adopt option (a) UPDATE ... FOR UPDATE SKIP LOCKED atomic claim — it is the standard single-store queue pattern and avoids the split-state problems of a Redis claim.
needs-verification:check emailQueueWorker.ts/campaignEmailWorker.ts for SKIP LOCKED or claim semantics
MF-027
HIGH VERIFY FIRST
main · Apr 22 (auth/Stripe/quotes/pipelines) + calendar-booking
process-delayed-actions double-executes workflow side effects
Issue The delayed-actions cron fetches status='pending' rows with no claim and fires side effects before marking them completed, so a redelivery double-executes workflow side effects.
Recommended Adopt option (a) atomic claim plus entity-existence check and transient/permanent error distinction — claim-before-execute is required for at-most-once side effects, and the error classification prevents poison-row retry storms.
needs-verification:check app/api/cron/process-delayed-actions/route.ts for claim-then-execute
MF-090
HIGH VERIFY FIRST
main · Apr 22 (auth/Stripe/quotes/pipelines) + calendar-booking
Workflow engine swallows action errors with no partial_failed state
Issue Workflow action failures are logged but the run continues to completion with no partial_failed state, so failures are invisible and unrecoverable.
Recommended Adopt option (a) add a partial_failed run state plus a retry UI — surfaces failures and allows recovery without aborting already-succeeded actions (fail-fast option c would lose completed work).
needs-verification:check lib/workflowEngine.ts for a partial_failed state
F5-bulk-action-double-execute
MEDIUM VERIFY FIRST
main · May 22-23 (adversarial passes)
Bulk-action worker can double-execute on QStash redelivery
Issue Two QStash redeliveries of the same jobId can both pass the status gate and fire provider-side actions (Gmail/Outlook trash/star) twice while progress writes race last-writer-wins.
Recommended Fix with option (a) atomic claim — change the gate to .update({status:'processing'}).eq('id',jobId).eq('status','pending').select() so only the winner proceeds; cheapest single-worker guarantee, no new table, defends provider-side double-actions which DB idempotency cannot.
needs-verification:check worker/route.ts:124-128 still uses .in('status',['pending','processing']) gate rather than an atomic claim
Email & integrations 19
LE-004
HIGH OPEN
main · May (AV-scan, Stripe-CSRF, gmail-push, Microsoft email)
bulk-actions 1000-id fanout may exceed 30s function timeout
Issue A 1000-id bulk action at ~500ms/call over 10 concurrency runs ~50s, exceeding the 30s default Vercel timeout, so half the provider fanout can be killed mid-request leaving DB/notification state mutated but the provider only partially synced while the response reports full success.
Recommended Option (b): raise the route's maxDuration to 60 (and register it in vercel.json) so the full fanout completes within the window — least disruptive correctness fix; combine with LE-007's provider_skipped/failed reporting so truncation is never silent. Option (c) (QStash) is the right long-term path if batch sizes grow.
GP-001
MEDIUM OPEN
main · May (AV-scan, Stripe-CSRF, gmail-push, Microsoft email)
Orphan NULL-org Google tokens persist indefinitely with live Pub/Sub
Issue When a user OAuth-connects Google before onboarding completes, storeProviderTokens writes a token with organization_id=NULL that persists indefinitely while Pub/Sub keeps pushing for ~7 days, wasting compute and accumulating latent orphans with no observability.
Recommended Option (c): time-based soft-delete (flip is_active=false after ~7d and call stopGmailWatch) — stops the wasted pushes and Pub/Sub stream while preserving the row for analytics on how many users abandon mid-onboarding; balances cleanup with signal retention.
GP-003
MEDIUM OPEN
main · May (AV-scan, Stripe-CSRF, gmail-push, Microsoft email)
gmail-push webhook can't filter token lookup by org_id (ambiguity 500)
Issue The gmail-push token lookup keys on (account_email, provider, is_active) with no org filter, so if two orgs both have the same active Gmail account, maybeSingle() throws "more than one row" and the webhook returns 500.
Recommended Option (b): add a partial unique index on (account_email, provider) WHERE is_active=true (after checking for existing duplicates) — prevents the ambiguity at the data layer and is the right invariant; combine with option (c)'s 200-ACK-plus-alert as a safety net.
LE-001
MEDIUM OPEN
main · May (AV-scan, Stripe-CSRF, gmail-push, Microsoft email)
forward route 5MB bodyHtml cap vs send route 500KB (10x divergence)
Issue The email forward route caps bodyHtml at 5MB with an uncapped subject while the send route caps body at 500KB and subject at 1000 chars, giving two outbound mail paths 10x-different ceilings and letting /forward burn 10x function memory.
Recommended Option (a): align forward to send (500KB body, 1000-char subject) — eliminates the memory-amplification vector and the confusing inconsistency; 500KB is ample for forwarded HTML and matches the established outbound contract.
LE-002
MEDIUM OPEN
main · May (AV-scan, Stripe-CSRF, gmail-push, Microsoft email)
forward route does not log activity or increment emails_sent usage
Issue Unlike send, the forward route neither increments the emails_sent usage counter nor inserts an activities row, so per-tenant send quota can be bypassed via /forward and the CRM activity log shows no record of forwards.
Recommended Option (a): mirror send — increment emails_sent and insert an 'email' activity row for forwards — closes the quota-bypass and audit gap and keeps the two outbound paths behaviorally consistent; forwards are real outbound mail and should count.
LE-005
MEDIUM OPEN
main · May (AV-scan, Stripe-CSRF, gmail-push, Microsoft email)
forward route recipient validation accepts arbitrary non-empty strings
Issue The forward route validates to/cc/bcc only as non-empty strings with no per-address length cap, unlike send's 320-char RFC 5321 cap, so garbage and 10K-char addresses propagate to the provider causing confusing 500s and DoS-amplified Graph calls.
Recommended Option (a): match send — cap each address at 320 chars — closes the amplification vector and aligns the two paths cheaply; full RFC 5321 regex (option b) is brittle and the provider already rejects malformed addresses, so a length cap is the pragmatic guard.
LE-007
MEDIUM OPEN
main · May (AV-scan, Stripe-CSRF, gmail-push, Microsoft email)
bulk-actions silently drops messages with broken/missing tokens
Issue Rows whose token lookup failed are silently dropped from the provider fanout so provider_synced + provider_failed < ids.length with no signal, leaving the UI unable to surface a "reconnect required" state.
Recommended Option (a): surface skipped — return provider_synced, provider_failed, provider_skipped and a reconnect_required boolean — lets the UI tell the user why some messages didn't sync and distinguishes a connection problem from a provider error; pairs naturally with LE-004's truncation reporting.
LE-013
MEDIUM OPEN
main · May (AV-scan, Stripe-CSRF, gmail-push, Microsoft email)
Gmail forward fallback drops RFC 5322 References/In-Reply-To headers
Issue The Gmail forward fallback sends a brand-new message without inReplyTo/references headers (unlike Outlook's createForward), defeating the cross-provider thread linking feature this batch just added when the forwarder is a Gmail user.
Recommended Option (a): preserve inReplyTo/references from the source email when calling sendGmailMessage — keeps the just-shipped thread-linking feature working for Gmail forwarders, which is the point of the feature; the RFC "forwards start new threads" argument (option b) undercuts the product goal here.
GP-002
LOW OPEN
main · May (AV-scan, Stripe-CSRF, gmail-push, Microsoft email)
Token.org_id can drift from profile.org_id on non-NULL disagreement
Issue The sync trigger only fills organization_id when NEW.organization_id IS NULL, so a token row written with a non-NULL org_id that disagrees with profiles.organization_id is not corrected, though no current code path causes this.
Recommended Option (a): leave as-is — no current caller produces the disagreement (all copy from profile), and always-overwriting in the trigger could mask legitimate future multi-org scenarios; revisit only if a drift case is actually observed.
GP-004
LOW OPEN
main · May (AV-scan, Stripe-CSRF, gmail-push, Microsoft email)
Pub/Sub keeps pushing for orphan tokens until Gmail watch expires
Issue NULL-org tokens' Gmail watches stay alive on Google's side until 7-day expiry and the 6h renewal cron re-registers them indefinitely, so every push hits the webhook for an ACK-and-skip, wasting compute.
Recommended Option (c): make the renewal cron skip NULL org_id rows (.not('organization_id','is',null)) so orphan watches expire naturally within 7 days — minimal change that stops perpetual renewal; pairs well with GP-001 soft-delete which also calls stopGmailWatch.
LE-003
LOW OPEN
main · May (AV-scan, Stripe-CSRF, gmail-push, Microsoft email)
bulk-actions omits broadcastChannel after notification UPDATE (stale cross-tab badge)
Issue bulk-actions updates notifications.is_read/archived_at but never calls broadcast(), violating lib/CLAUDE.md Notification State rule #4, so peer tabs' bell badge stays stale until the next 60-300s poll.
Recommended Option (a): add the broadcast call (notification_archived / notification_marked_read with ids, userId, organizationId, unread_count) — restores the documented ~10ms cross-tab consistency and fixes a clear CLAUDE.md rule violation; small, well-patterned change.
LE-006
LOW OPEN
main · May (AV-scan, Stripe-CSRF, gmail-push, Microsoft email)
bulk-actions affected count fallback swallows ID mismatches
Issue The Supabase update without count:'estimated' can return null, and the fallback to ids.length reports affected:1000 even when only 500 rows belonged to the user, lying about how many rows actually flipped.
Recommended Option (a): pass { count: 'estimated' } to the update and report count ?? 0 — gives an honest affected count and closes the audit/UX-misreport gap; trivial change with no downside.
LE-008
LOW OPEN
main · May (AV-scan, Stripe-CSRF, gmail-push, Microsoft email)
folders route returns empty array for 3 distinct states
Issue The email folders route returns { folders: [] } for Gmail-only, Outlook-broken-token, and Outlook-no-folders alike, so the UI cannot render the correct empty state (Connect vs Reconnect vs No folders).
Recommended Option (a): add a status field — return { folders, provider, status: 'ok'|'reconnect_required'|'no_folders' } — lets the UI render the right CTA without overloading HTTP status semantics that clients may mis-handle; clean and explicit.
LE-011
LOW OPEN
main · May (AV-scan, Stripe-CSRF, gmail-push, Microsoft email)
inlineCidImages has per-attachment 5MB cap but no per-message cumulative cap
Issue inlineCidImages caps each inline image at 5MB but has no cumulative cap, so a message with several large inline images could inline ~20MB into one returned HTML body; the function is new with no callers yet.
Recommended Option (a): add MAX_TOTAL_INLINE_BYTES (~10MB) tracking cumulative bytes and skip once the next image would exceed it — fixes the brittle contract before any caller depends on it, which is far cheaper than retrofitting after callers exist.
LE-012
LOW OPEN
main · May (AV-scan, Stripe-CSRF, gmail-push, Microsoft email)
forward route message-id validation diverges from sibling [id] route
Issue The forward route accepts any UTF-8 string <=512 chars for the message id while the sibling [id] route uses isUUID || isValidProviderMessageId (256-char, restricted charset); SSRF/RCE is blocked by encodeURIComponent but the validation convention is inconsistent.
Recommended Option (a): extract isUUID and isValidProviderMessageId to a shared lib/email/idValidation.ts and use it on both routes — removes the divergence and prevents future drift, which inlining (option b) would not; small refactor with lasting consistency benefit.
LE-015
LOW OPEN
main · May (AV-scan, Stripe-CSRF, gmail-push, Microsoft email)
bulk-actions notifications UPDATE not idempotency-filtered
Issue The bulk-actions notifications UPDATE lacks .is('is_read', false) / .is('archived_at', null) filters, so re-running mark_read or archive flips already-flipped rows, wasting DB work and inflating any reported marked_count.
Recommended Option (a): add the idempotency filters (.is('is_read', false) for mark_read, .is('archived_at', null) for archive) — avoids redundant writes and yields the real flipped count per CLAUDE.md Notification State rule #1; cheap and correct.
LE-016
LOW OPEN
main · May (AV-scan, Stripe-CSRF, gmail-push, Microsoft email)
bulk-actions provider fanout switch is non-exhaustive
Issue The Google and Azure provider fanout switch branches lack a default case, so adding a new action to validActions without updating the switch would silently no-op on fanout (DB updates but providerSyncedCount stays 0).
Recommended Option (b): use a TypeScript exhaustive switch (const _exhaustive: never = action) so an unhandled new action fails at compile time — prevents the drift footgun at build rather than relying on a runtime warn (option a) that ships the bug.
LE-017
LOW OPEN
main · May (AV-scan, Stripe-CSRF, gmail-push, Microsoft email)
signatureExtraction return value is not HTML-escaped
Issue signatureExtraction relies on a docstring caller-contract that input is plaintext; if a future caller passes bodyHtml, returned suggestions could include <script> tags and a UI rendering them unescaped would XSS.
Recommended Option (a): add a defensive HTML strip (.replace(/[<>]/g,'')) as a last pass before returning — a cheap belt-and-suspenders guard against contract misuse, since XSS severity outweighs the tiny cost of stripping angle brackets from a plaintext signature.
MF-066
HIGH VERIFY FIRST
main · Apr 22 (auth/Stripe/quotes/pipelines) + calendar-booking
Gmail 429s trigger infinite retries with no backoff
Issue A Gmail 429 response triggers infinite retries with no backoff, risking a retry storm and quota lockout.
Recommended Adopt option (b) exponential backoff with a max retry count (and honor Retry-After when present) — bounded backoff is the standard rate-limit response and prevents the unbounded loop; defer-to-QStash (c) only works if all Gmail calls route through QStash.
needs-verification:check lib/integrations/gmail/* for backoff/max-retry handling
Calendar & scheduling 13
LE3-D1-org-timezone-no-write-surface
HIGH VERIFY FIRST
main · May 22-23 (adversarial passes)
No admin UI/API writes organization_timezone — feature locked at UTC
Issue The organization_timezone column and getOrgTimezone reader exist but no call site writes the value, so every org is permanently locked at UTC and the MF-070/MF-164 timezone feature is dead in production.
Recommended Fix with option (d) hybrid — add the Admin Settings → Organization → Timezone selector (a) as the authoritative write path AND default it from the signer's browser tz at signup (b); the admin selector is the must-have, the signup default reduces zero-touch UTC pain immediately.
needs-verification:grep app/ and lib/ for any write to organization_timezone (PATCH /api/admin/organization/settings) added since this run
CB-001
HIGH LIKELY DONE
main · Apr 22 (auth/Stripe/quotes/pipelines) + calendar-booking
Recurring series partial-rollback failures silently swallowed
Issue When a recurring occurrence INSERT failed, the unchecked delete-based rollback could leave a partial series holding slots indefinitely while still returning 409.
Recommended Keep the applied decision (c): the rollback_recurring_series_partial RPC makes rollback atomic Postgres-side; no further action beyond confirming the RPC is invoked on every failure path.
likely-superseded:marked APPLIED 2026-04-27 via rollback_recurring_series_partial RPC
CB-007
HIGH LIKELY DONE
main · Apr 22 (auth/Stripe/quotes/pipelines) + calendar-booking
Routing form prefill_token leaks to third parties via Referer
Issue The booker page received the prefill token in the URL, so any third-party resource it loaded could capture the token (resolving to submitter responses + person id) via the Referer header.
Recommended Keep the applied decision (a): no-referrer meta on /b and /r layouts; consider follow-up to also move prefill off the URL (option b) for defense-in-depth if third-party embeds grow, but the referrer header is the immediate leak and is closed.
likely-superseded:marked APPLIED 2026-04-27 via no-referrer metadata in /b and /r layouts
CB-002
MEDIUM LIKELY DONE
main · Apr 22 (auth/Stripe/quotes/pipelines) + calendar-booking
Routing-form numeric operators match on null/undefined responses
Issue greater_than/less_than used Number(null)=0, so a missing answer could satisfy a numeric inequality and route bookers to unintended meeting types.
Recommended Keep the applied decision (a): null/undefined/empty short-circuits to no-match before Number coercion; no further action.
likely-superseded:marked APPLIED 2026-04-27 in lib/routingForms/evaluateRules.ts
CB-003
MEDIUM LIKELY DONE
main · Apr 22 (auth/Stripe/quotes/pipelines) + calendar-booking
Recurring series DST wall-clock drift
Issue Recurrence advanced in pure UTC so a weekly 9am-PT slot shifted +/-1 hour at each DST change.
Recommended Keep the applied decision (a): re-anchor each occurrence in the meeting type's timezone (Calendly/Cal.com behavior); no further action.
likely-superseded:marked APPLIED 2026-04-27 in lib/scheduler/recurrence.ts (TZ-anchored expansion)
CB-004
MEDIUM LIKELY DONE
main · Apr 22 (auth/Stripe/quotes/pipelines) + calendar-booking
MONTHLY recurrence clamps Jan-31 once and drifts to 28th forever
Issue A Jan-31 monthly series clamped to Feb-28 and then used the clamped day every subsequent month instead of re-extending to the original day.
Recommended Keep the applied decision (a): re-extend to the original anchor day each month (Jan 31 -> Feb 28 -> Mar 31); no further action.
likely-superseded:marked APPLIED 2026-04-27 in lib/scheduler/recurrence.ts (anchorDay re-extends)
CB-006
MEDIUM LIKELY DONE
main · Apr 22 (auth/Stripe/quotes/pipelines) + calendar-booking
Pre-meeting briefing cron fire-then-stamp race on QStash redelivery
Issue The briefing cron fired the notification before stamping briefing_sent_at, so a QStash redelivery could double-notify hosts.
Recommended Keep the applied decision (a): claim-then-fire via UPDATE ... WHERE briefing_sent_at IS NULL RETURNING, skipping when 0 rows; no further action.
likely-superseded:marked APPLIED 2026-04-27 in send-pre-meeting-briefings cron
CB-008
MEDIUM LIKELY DONE
main · Apr 22 (auth/Stripe/quotes/pipelines) + calendar-booking
reschedule_booking_atomic RPC granted to authenticated (defense-in-depth gap)
Issue The reschedule RPC was granted to authenticated and did not verify auth.uid() had access to the passed org, allowing a direct PostgREST call by any logged-in user who knew the org_id pairing.
Recommended Keep the applied decision (a): REVOKE from PUBLIC/anon/authenticated so only service_role (used by CRM routes) can call it; no further action.
likely-superseded:marked APPLIED 2026-04-27 in 20260427070000_scheduler_tier2_decisions.sql
CB-010
MEDIUM LIKELY DONE
main · Apr 22 (auth/Stripe/quotes/pipelines) + calendar-booking
Routing form route_to_host doesn't validate host membership
Issue route_to_host only checked string presence on host_user_id without verifying the host belongs to the routed meeting type or the form's org, allowing misrouting to a non-host UUID.
Recommended Keep the applied decision (d): validate host membership at form-save, at routing-form POST evaluation, and at booking-time host validation (defense in depth); no further action.
likely-superseded:marked APPLIED 2026-04-27 across routing-forms and booking routes
CB-011
MEDIUM LIKELY DONE
main · Apr 22 (auth/Stripe/quotes/pipelines) + calendar-booking
cancel-series partial failure marks series cancelled while occurrences remain confirmed
Issue The series-cancel loop always set series.status='cancelled' even on partial failure, so the series looked cancelled while some bookings still occupied slots.
Recommended Keep the applied decision (b): set status to partially_cancelled when failed>0 so status reflects reality; consider pairing with a cron retry (option c) if partial failures recur in ops data.
likely-superseded:marked APPLIED 2026-04-27 in series cancel route
CB-005
LOW LIKELY DONE
main · Apr 22 (auth/Stripe/quotes/pipelines) + calendar-booking
Management/series/attendee tokens used in plaintext as rate-limit keys
Issue Raw 256-bit management tokens were used directly as Redis rate-limit keys, putting raw tokens in a logging-adjacent path (CLAUDE.md no-secrets-in-logs gray zone).
Recommended Keep the applied decision (a): hash tokens via rateLimitKeyForToken before keying the limiter across all 6 manage routes; no further action.
likely-superseded:marked APPLIED 2026-04-27 via rateLimitKeyForToken helper
CB-009
LOW LIKELY DONE
main · Apr 22 (auth/Stripe/quotes/pipelines) + calendar-booking
Cancel pipeline TOCTOU on booking status sends spurious cancel email
Issue If booking status flipped between SELECT and the status-guarded UPDATE, 0 rows updated but the pipeline continued and sent a cancellation email for an already-cancelled booking.
Recommended Keep the applied decision (a): detect 0-rows-updated, re-fetch status, and return alreadyCancelled to short-circuit the email; no further action.
likely-superseded:marked APPLIED 2026-04-27 in lib/scheduler/cancelBookingPipeline.ts
CB-012
LOW LIKELY DONE
main · Apr 22 (auth/Stripe/quotes/pipelines) + calendar-booking
prefill_token 30-minute TTL loses prefill for slow bookers
Issue The prefill token expired 30 minutes after issue, so a distracted booker who returned later lost their prefilled name/email.
Recommended Keep the applied decision (a): extend TTL to 24 hours for single-day re-engagement; no further action.
likely-superseded:marked APPLIED 2026-04-27 in app/api/public/routing-form/[slug]/route.ts
GDPR & data retention 13
EDGE-T2-E
MEDIUM OPEN
main · May 23 (GDPR / data-retention / admin-export)
Erasure not idempotent; concurrent calls run the chain twice
Issue Two simultaneous erase calls on the same personId both run the full chain, causing storage 404s on the second and duplicated audit-trail entries with no idempotency key.
Recommended Adopt option (a): a gdpr_erasure_runs(person_id, run_id, status, timestamps) table guarded by a Postgres advisory lock so concurrent calls serialize and the second short-circuits; it also yields an erasure-attempt audit trail as a free side benefit.
EDGE-T2-G
MEDIUM OPEN
main · May 23 (GDPR / data-retention / admin-export)
Export history retains user_id + filter PII on GDPR erasure
Issue data_exports rows hold user_id and a filters JSON that can contain the erased person's identifying values (name, companyId, search terms) and are currently untouched by erasePerson.
Recommended Adopt option (a): on erasure, UPDATE data_exports.filters to a redacted sentinel for rows referencing the erased person, since GDPR Art. 4(1) treats indirectly-identifying data as personal data; low cost and closes a strict-reading gap. Wire it into the EDGE-T2-A RPC.
EDGE-T2-H
MEDIUM OPEN
main · May 23 (GDPR / data-retention / admin-export)
Security event logged AFTER partial erasure; no record of attempt on throw
Issue logSecurityEvent fires only after the erasure chain, so an uncaught throw mid-chain leaves no record that an erasure was ever attempted.
Recommended Implement option (a): emit data_erasure_started before the chain, data_erasure_completed after, and data_erasure_failed in the catch, so security-events queries cleanly partition erasures by lifecycle and every attempt is traceable. Trivial and complements EDGE-T2-E.
EDGE-T2-K
LOW OPEN
main · May 23 (GDPR / data-retention / admin-export)
Two-tab race on retention policy upsert (no version field)
Issue Concurrent retention-policy upserts have no version/ETag check, so last-writer-wins can silently clobber another admin's change.
Recommended Accept option (c) leave as-is for now (admin-only, low concurrency, last-writer-wins acceptable) and revisit with optimistic-concurrency WHERE updated_at=$prev only if a user actually reports a lost update; not worth the engineering cost today.
EDGE-T2-N
LOW OPEN
main · May 23 (GDPR / data-retention / admin-export)
Retention-policy input doesn't show per-entity minimum in UI
Issue The retention number input does not surface the per-entity minimum (e.g. 365-day SOC 2 floor), so admins can attempt invalid values that only the server rejects.
Recommended Implement option (a)+(b): set a per-policy min attribute on the input based on entity_type and add inline per-row help text stating the minimum; cheap client-side affordance that prevents avoidable server rejections while the server stays authoritative.
EDGE-T2-A
CRITICAL VERIFY FIRST
main · May 23 (GDPR / data-retention / admin-export)
GDPR Article 17 erasure incomplete across ~15+ FK tables
Issue erasePerson leaves PII in ~16 other tables (campaign_follow_ups, synced_calendar_events, invoices/quotes denormalized names, lead_capture_submissions, meeting bookings, email_drafts, tasks, etc.) after returning a 'successful' GDPR erasure.
Recommended Adopt option (b): build a lib/gdpr/erasureRegistry.ts mapping each table to delete | redact | retain-with-marker, with a legal-hold carve-out for financial records (invoices/quotes/payments for 7-yr tax retention) and full redaction elsewhere, executed in a SECURITY DEFINER gdpr_erase_person_v2() RPC; this matches HubSpot/Stripe industry norms and bounds regulator/SOC2 liability. Pair with the gdpr_erased_at marker from EDGE-T2-F.
needs-verification: confirm current erasePerson.ts table coverage hasn't already been expanded since 2026-05-23 and whether erasureRegistry/v2 RPC now exists
EDGE-T2-B
HIGH VERIFY FIRST
main · May 23 (GDPR / data-retention / admin-export)
erasePerson has no transaction; partial erasure on mid-chain failure
Issue The 8 sequential supabase calls have no transaction or rollback, so if step 5 fails after step 4 succeeds the function still returns success:true leaving the person half-erased.
Recommended Take option (c): fold the chain into the same SECURITY DEFINER gdpr_erase_person RPC as EDGE-T2-A so it runs in one DB transaction with storage ops sequenced LAST (storage can't be transactional), giving atomic in-DB rollback; cheap once the RPC exists and eliminates half-erased state.
needs-verification: check whether erasePerson now delegates to a transactional RPC rather than a JS call chain
EDGE-T2-C
HIGH VERIFY FIRST
main · May 23 (GDPR / data-retention / admin-export)
Reducing retention is destructive; no confirmation modal or preview
Issue One admin click can drop retention from 730 to 1 day and the next daily cron deletes nearly all historical rows, with no row-count preview, no double-confirmation, and no audit.
Recommended Implement option (a) type-DELETE confirmation modal showing estimated affected row count PLUS option (c) a dry_run=1 flag on the enforce-data-retention cron for verification; high user-facing urgency, low engineering cost, and the dry-run doubles as ops tooling. This also unblocks EDGE-T2-J.
needs-verification: check data-retention page for a confirmation modal and the cron route for a dry_run flag
EDGE-T2-D
HIGH VERIFY FIRST
main · May 23 (GDPR / data-retention / admin-export)
Retention cron has no per-run budget; can timeout silently at scale
Issue N orgs x 5 entities x up to 50K rows with no declared maxDuration means the cron can hit Vercel's runtime limit and die mid-run leaving partial state with no truncation signal.
Recommended Ship option (a) now: add a MAX_RUNTIME_MS soft budget plus declare maxDuration and return partial:true when cut off; plan option (b) QStash per-org fan-out as the scale path. Immediate safety is cheap; fan-out is the durable fix as org count grows.
needs-verification: check the cron route for maxDuration export and a soft runtime budget
EDGE-T2-F
HIGH VERIFY FIRST
main · May 23 (GDPR / data-retention / admin-export)
No gdpr_erased_at marker; re-imports may re-populate redacted PII
Issue After erasure a later merge/import/sync that upserts on email could overwrite the redacted people row because there is no schema-level guard preventing re-population.
Recommended Take option (a): add people.gdpr_erased_at TIMESTAMPTZ plus a BEFORE UPDATE trigger that rejects writes re-setting PII columns once the marker is non-null; DB-level enforcement is RLS/admin-client-bypass-safe, unlike app-layer checks. Implement alongside EDGE-T2-A.
needs-verification: check the people table schema for a gdpr_erased_at column and a re-population guard trigger
EDGE-T2-I
MEDIUM VERIFY FIRST
main · May 23 (GDPR / data-retention / admin-export)
Retention cron uses created_at uniformly; wrong semantics for some entities
Issue The cron deletes by created_at for all entities, but activity-inactivity rules should delete by staleness (updated_at/last_active_at), so activities may be retained or purged on the wrong basis.
Recommended Take option (a): generalize the existing ENTITY_CONFIG (already maps email_events to occurred_at) to allow a per-entity dateColumn override, then do a short policy review of which entity uses created_at vs updated_at; architecture already supports it so it is mainly a policy decision.
needs-verification: check ENTITY_CONFIG for a per-entity dateColumn override and which column each entity uses
EDGE-T2-J
MEDIUM VERIFY FIRST
main · May 23 (GDPR / data-retention / admin-export)
Retention policy edits are not audited; SOC 2 change-management gap
Issue An admin lowering retention (e.g. 365 to 7 days) leaves no immutable trail, which a SOC 2 Type II change-management review would flag.
Recommended Write retention-policy-change events to whatever durable audit destination EDGE-T2-C selects (the phantom audit_logs must be replaced first); this is a dependent follow-up, so decide EDGE-T2-C/the audit sink, then wire policy-change events into it.
needs-verification: depends on whether the phantom audit_logs destination has since been resolved
DP5-EXPORT-AUDIT-DESTINATION
HIGH LIKELY DONE
main · May 22-23 (adversarial passes)
SOC 2/GDPR export-audit trail targeted phantom audit_logs table — never recorded
Issue Admin-export INSERTs and the GDPR-erasure UPDATE all targeted a nonexistent audit_logs (plural) table and silently no-opped, so SOC 2 export audit rows were lost and GDPR Article 17 redaction of audit-trail PII never ran.
Recommended No action needed — already APPLIED 2026-05-23 via option (a): routes now call logSecurityEvent to security_events, GDPR redaction targets security_events.metadata + audit_log singular, retention allowlist cleaned. Close as done.
likely-superseded:marked APPLIED 2026-05-23 with Decision [x] (a); SOC 2 trail live and GDPR step executes
Support & service 8
LE3-D3-quote-signature-typed-name-only
MEDIUM OPEN
main · May 22-23 (adversarial passes)
Quote signature is plain typed-name; no signature-image/intent artifact stored
Issue accept-and-pay stores only a typed signature_name plus IP/UA/timestamp with no explicit-intent artifact, which is defensible for low-stakes B2B but may be unenforceable under E-SIGN/eIDAS for high-stakes or EU/CA buyers.
Recommended Accept current shape for now (option c) and document the legal scope in product/security review, since the target market is low-stakes B2B; revisit with option (a) intent-checkbox + acceptance certificate only when product validates a regulated/high-value use case (d).
EC-L-005
LOW OPEN
main · May (AV-scan, Stripe-CSRF, gmail-push, Microsoft email)
file_quarantined notification dedup suppresses repeated rejections in 5-min window
Issue file_quarantined notifications are created without a relatedEntityId, so multiple malware rejections within a 5-minute window collapse to a single user-facing notification even though security_events logs each one.
Recommended Option (c): keep the 5-min single-notification-per-burst behavior — security_events already has the complete audit trail, and one "you uploaded malware" warning per burst is sufficient user signal while avoiding a notification flood; cheapest and matches user intuition.
EC-L-007
LOW OPEN
main · May (AV-scan, Stripe-CSRF, gmail-push, Microsoft email)
file_quarantined notification priority 'urgent' bypasses quiet hours
Issue file_quarantined notifications use priority 'urgent', which overrides quiet hours and can emit web-push at any hour (e.g. 2am malware upload).
Recommended Option (a): keep 'urgent' — a malware-upload event is security-relevant and may indicate a compromised auto-uploading device, so immediate notification regardless of quiet hours is the correct safety posture; the volume is naturally low.
5
LOW OPEN
worktree · Tickets/KB/Surveys redesign
Help-center deflection-stats GET uses count:'exact'
Issue The service/help-center/deflection-stats endpoint uses count:'exact' on four windowed HEAD counts, deviating from the literal CLAUDE.md "never count:'exact'" rule.
Recommended Accept the exact count here (admin-only, bounded small windows where estimated counts would be noisy and misleading) and add a one-line code comment noting the deliberate exception — or, if the owner prefers strict literal compliance, let /audit auto-switch to count:'estimated'; either is fine given the tiny blast radius.
2
HIGH VERIFY FIRST
worktree · Tickets/KB/Surveys redesign
Ticket merge circular guard is single-hop only
Issue merge/route.ts guards self-merge, survivor==source, and source-already-merged, but not the case where the SURVIVOR is itself already merged into another ticket, so merging into an already-merged survivor re-points children onto a closed/redirecting ticket.
Recommended Fix: reject with 409 when survivor.merged_into_id IS NOT NULL — it is the simplest, safest guard and prevents orphaned/looping merge chains; resolving to the terminal chain head is acceptable but adds complexity for little benefit.
needs-verification:confirm merge/route.ts:92-98 still lacks a survivor.merged_into_id check in current code
MF-049
MEDIUM VERIFY FIRST
main · Apr 22 (auth/Stripe/quotes/pipelines) + calendar-booking
Quote rejection silent, no rejected state or audit trail
Issue A user can reject a quote without a reason and it silently reverts to draft with no rejected state or audit trail.
Recommended Adopt option (a) add an explicit rejected state with a required comment and timestamp — minimal schema change that gives the audit trail; defer the heavier version-history resubmit flow (option c) unless sales requests it.
needs-verification:check app/api/quotes/** for a rejected status enum value
3
MEDIUM VERIFY FIRST
worktree · Tickets/KB/Surveys redesign
Survey re-send dedup is dead plumbing
Issue send/route.ts advertises a per-recipient send_token-UNIQUE dedup, but send_token is a random UUID with a unique index on itself only (no UNIQUE on survey_id+person_id), so re-sends insert a second queued row and the skippedDuplicate branch is unreachable.
Recommended Decide intended semantics, then make code match: if re-send should be idempotent per recipient, add a partial UNIQUE on (org, survey_id, person_id) WHERE status IN ('queued','responded') + ON CONFLICT DO NOTHING to activate the skip path — recommended, since duplicate survey invites to the same person are a real UX/spam risk; otherwise delete the misleading dedup comments.
needs-verification:confirm send/route.ts and the surveys schema still lack a UNIQUE(survey_id, person_id) index
EC-L-006
LOW VERIFY FIRST
main · May (AV-scan, Stripe-CSRF, gmail-push, Microsoft email)
EventTypeFilter on /admin/security omits AV and several other event types
Issue The /admin/security filter dropdown only lists a curated subset of SecurityEventType values, excluding the new AV types (malware_upload_blocked, av_scan_unavailable) and several pre-existing types, so operators must use the "all" view to find them.
Recommended Option (b): add only the critical/security-relevant types (malware_upload_blocked, suspicious_export, csp_violation) to the filter — restores filterability for events operators actually triage without overcrowding the dropdown with noise types.
needs-verification:check app/admin/security/page.tsx EventTypeFilter for whether AV types were added in a later run
Accessibility & i18n 4
SC-003
LOW OPEN
main · May (AV-scan, Stripe-CSRF, gmail-push, Microsoft email)
ARIA radiogroup tabIndex on every plan card vs strict spec
Issue All 4 tier cards have tabIndex={0}, which contradicts the WAI-ARIA radiogroup convention where only the selected radio is in tab order and arrow keys move focus within the group.
Recommended Option (b): adopt strict ARIA (tabIndex={-1} on unselected, arrow-key roving focus) — correct screen-reader semantics matter on a conversion-critical signup page, and the discoverability concern is addressed by standard radiogroup arrow-key behavior most SR users expect.
PW-T2-005
LOW OPEN
main · DP7 + auth-shell-sync + password/OAuth + My Work
Minor step-up/oauth_reauth UX gaps (non-blocking bundle)
Issue A bundle of non-blocking UX gaps: two-tab oauth_reauth cookie clobber, otherSessionsRevoked:false ignored by the modal, ?stepup= notice possibly not SR-announced, startOauthReauth never-resolving promise causing a stuck spinner, autoOpenSetPassword lost on overview fetch error, and StepUpModal size=md vs claimed mobile full-screen.
Recommended Fix incrementally as polish: prioritize the stuck-spinner (never-resolving startOauthReauth promise) and the aria-live SR announcement for ?stepup= since those are real user-facing defects; treat the cookie-clobber and otherSessionsRevoced copy mismatch as accept-or-tidy; verify the 320px StepUpModal overflow.
MF-119/122/125/126/128/129/130/135
MEDIUM VERIFY FIRST
main · Apr 22 (auth/Stripe/quotes/pipelines) + calendar-booking
A11y + i18n architecture (focus traps, ARIA, keyboard nav, i18n/RTL)
Issue A cluster of accessibility and internationalization gaps (modal/cookie-banner focus traps, autoComplete attrs, calendar keyboard nav, dropdown ARIA menu pattern, FileUpload semantics, hardcoded en-US, missing html lang/RTL) lacks an agreed architectural approach.
Recommended Adopt a headless a11y primitives library (react-aria or @headlessui/react) for the interaction patterns plus a next-intl i18n project for localization/RTL — one consistent foundation is far cheaper to maintain than per-finding custom hooks and prevents regressions.
needs-verification:check package.json for react-aria/headlessui/next-intl and html lang attribute
EDGE-T2-O
LOW VERIFY FIRST
main · May 23 (GDPR / data-retention / admin-export)
Retention error feedback uses native alert(); not SR- or test-friendly
Issue The data-retention admin page reports errors via native alert(), which is not screen-reader-friendly and not testable.
Recommended Adopt option (a): render inline errors within the PolicyRow component (aria-live region) instead of alert(); minimal new infrastructure, accessible, and assertable in tests. Use a toast only if a shared toast component already exists.
needs-verification: check if alert() in the data-retention page has already been replaced with inline/toast feedback