Pending decisions — consolidated backlog

Every unprocessed owner-decision item across the main tree + all worktrees, gathered 2026-06-17. 146 items from 19 edge-case runs (Apr 22 → Jun 16) + 2 active worktrees. Each carries a recommended disposition — these are your calls to make, not auto-fixes.

146
total decisions
1
critical
37
high
59
medium
49
low
79
open
47
verify first
20
likely already done
21
fresh (this session)

How to read this. Items are grouped by area, then sorted open → verify-first → likely-done, then by severity. Each has my recommended disposition.

OPEN a genuine call awaiting your decision   VERIFY FIRST may already be fixed by later shipped work — confirm in current code before acting   LIKELY DONE almost certainly superseded; listed for completeness, dimmed.

Severity is the original edge-case tier / my judgment, not a deadline. Many of the older (Apr–May) items predate large shipped overhauls (Quote-to-Cash, Work Surfaces, Support Suite) and are flagged VERIFY/DONE accordingly.

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

AI 2

1 HIGH VERIFY FIRST worktree · Tickets/KB/Surveys redesign

AI verification gate evidence strength for refund_issued / task_created

Issue The AI close-verification gate maps refund_issued and task_created to the generic status_change kind that passes on merely "the bot replied or noted something this run," with no refund-record or tasks-row check, while the automation path does verify task_created against a real tasks row.

Recommended Fix: require a concrete artifact (refund/transaction record for refund_issued, a tasks row linked to the ticket for task_created) before the AI gate allows autonomous close, mirroring the automation path — financial/task promises are high-blast-radius and should not auto-close on bot chatter alone.

needs-verification:confirm executeToolService.ts:2551-2555 and resolveVerification.ts:266-277 still map these two action kinds to status_change in current code

4 HIGH VERIFY FIRST worktree · Tickets/KB/Surveys redesign

Answer-bot budget org vs session org (cross-slug token replay)

Issue answer-bot/route.ts charges budget/usage to the org_slug-resolved org while the session is resolved by session_token alone with no slug/org consistency check, so a caller can start a session against org-A then send ask turns with org_slug=B, billing spend to B and splitting the transcript across two orgs (no cross-tenant data read leak, but budget misattribution).

Recommended Fix: bind the session to its org — have answerbot_start_session return the org and reject ask/escalate when session.org != slug-resolved org; cheap consistency check that closes a billing-misattribution and split-transcript hole.

needs-verification:confirm answer-bot/route.ts:107-117 and answerbot_record_turn/loadHistory still resolve session by token without org binding

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

Other 2

MF-Tier2-bundle MEDIUM OPEN main · Apr 22 (auth/Stripe/quotes/pipelines) + calendar-booking

Tier-2 candidate backlog (77 single-hunter findings) — auth, integration DoS, money, workflow, AI, export, messaging, orphans

Issue A summarized backlog of 77 single-hunter Tier-2 findings (auth cache/session gaps, integration rate-limit/DoS caps, monetary invariants, workflow robustness, AI prompt-injection/cost fail-open, export truncation, messaging dedup, orphan-state recovery) remains undispositioned and deferred to future passes.

Recommended Triage this bundle in a dedicated /deep-harden pass rather than as one decision: prioritize the security-bearing items (prompt injection via stored activity IT2-F-21, AI cost fail-open J-23, per-org integration rate caps) first since they are exploitable, and queue the monetary/orphan items into the money/data fixes above.

LP-004 LOW OPEN main · May (AV-scan, Stripe-CSRF, gmail-push, Microsoft email)

useInstallPrompt route-scoped beforeinstallprompt capture gap

Issue beforeinstallprompt is captured only on /settings/install-app, so the event is permanently lost if it fires anywhere else, breaking any future in-app Install CTA (users can still install via Chrome's address-bar icon).

Recommended Option (b): capture globally but skip preventDefault — preserves the deferred prompt for future in-app CTAs while still letting Chrome show its mini-infobar, avoiding the suppressed-banner console noise of option (c); modest hook refactor with clear future upside.

Sources: main .pipeline/edge-cases/decisions-pending.md (1,496 lines, 19 runs) + worktrees usage-panel-overhaul (this session) and tickets-kb-surveys-redesign. Generated by the consolidation sweep. Recommendations are starting points — adjust freely.