Skip to content

feat(dashboard): rebuild to V3 mockup, Vite → Next.js 16#25

Merged
Gradata merged 12 commits into
mainfrom
worktree-dashboard-nextjs-rebuild
Apr 13, 2026
Merged

feat(dashboard): rebuild to V3 mockup, Vite → Next.js 16#25
Gradata merged 12 commits into
mainfrom
worktree-dashboard-nextjs-rebuild

Conversation

@Gradata

@Gradata Gradata commented Apr 13, 2026

Copy link
Copy Markdown
Owner

Summary

Complete rebuild of the Gradata cloud platform surface — dashboard migration, 17 routes, 11 sim-validated widgets, 3 new backend endpoints, Stripe SKU wiring, Cloudflare Pages config, end-to-end observability. Zero deferrals.

Built against:

  • Approved V3 Dark Cinematic mockup (03-dashboard-v3-complete.html)
  • 2026-04-09 design spec
  • 1,760 lines of newer sim synthesis in brain/stress_test_results/ (S101-103, WAVE2, SIM_A/B) — applied as sim-validated revisions

Dashboard migration (cloud/dashboard/)

  • Stack: Vite + React Router → Next.js 16.2 App Router (static export for Cloudflare Pages, no Node runtime at edge)
  • Theme: V3 Dark Cinematic tokens, Space Grotesk + Inter + JetBrains Mono fonts, fractal noise overlay, glass cards with gradTop + scan-line
  • Preserved verbatim: all 13 shadcn primitives, useAuth/useApi hooks, lib/{api,supabase,utils}.ts, types/api.ts
  • 17 routes shipped: dashboard, brain (query-param detail), corrections, rules, meta-rules, self-healing, observability, privacy, setup, team, team/members, operator, billing, settings, api-keys, notifications, + auth flow

Sim-validated widgets (11)

Widget Sim source Purpose
KpiStrip S103, S101 Correction Rate Δ%, Sessions to Graduation w/ 95% CI, 0 Misfires, Brain Footprint
CorrectionDecayCurve S103_STAT_REPLICATION Hero viz — Wozniak exp-decay fit + CI band
GraduationProgressBar SIM_A consensus 3-tier INSTINCT 0.40 → PATTERN 0.60 → RULE 0.90 (the moat)
ActiveRulesPanel SIM16, SIM103 Confidence as metadata, not primary text (80% don't audit)
CategoriesChart WAVE2, SIM102 6-dim taxonomy (Goal/Tone/Clarity/Factual/Domain/Actionability)
MetaRulesGrid SIM101 2-layer (Objective/Subjective) w/ Goal Alignment governing
PrivacyPosturePanel SIM_A §5A "Raw corrections never leave your device" trust surface
ABProofPanel SIM_B §3, S103 Principles-vs-baseline with 95% CIs (differentiator)
MethodologyLink SIM_DESIGN_RESEARCH Cited baselines (Duolingo HLR, Wozniak, Copilot RCT) + limitations
ActivityFeed design spec 6 event kinds with convergence signal
PlanBadge / PlanGate S104 pricing Stripe tier badges + "upgrade to unlock" affordances

All widgets fetch real data from backend endpoints and fall back to fixtures gracefully when brain has no events (cold start).

Backend additions (cloud/app/)

  • 3 new routes + 10 new tests:

    • GET /brains/{id}/meta-rules — synthesized principles, no raw correction content
    • GET /brains/{id}/activity — learning events feed filtered to visible kinds
    • GET /brains/{id}/rule-patches + POST .../rollback — self-healing audit trail with append-only history
  • Stripe SKU wiring (+14 tests):

    • CheckoutRequest.plan is now a PlanTier enum (cloud/team/enterprise)
    • Price IDs read from GRADATA_STRIPE_PRICE_ID_CLOUD / _TEAM settings (was inline os.environ)
    • POST /billing/portal endpoint for Stripe customer portal
    • Webhooks handle customer.subscription.{created,updated,deleted}, invoice.payment_failed, checkout.session.completed
    • Plan extraction resolves from metadata first, then price IDs, then nickname
    • Enterprise checkout rejected with 400 + "contact sales"
  • Backend tests: 72/72 passing (was 58 + 14 new billing tests)

Deploy + ops

  • Cloudflare Pages: wrangler.toml, public/_headers (HSTS, CSP allowing Supabase + Sentry + Google Fonts, immutable cache for _next/static/*), public/_redirects (legacy /brains/*/brain?id=...), .nvmrc (Node 20)
  • Documentation:
    • cloud/OPS.md — single-page operations index
    • cloud/SUPABASE-SETUP.md — Google OAuth provider + email templates + RLS
    • cloud/RAILWAY-ENV.md — complete backend env var list with new Stripe vars
    • cloud/SENTRY-SETUP.md — error tracking setup (landed earlier)
    • cloud/dashboard/CLOUDFLARE-PAGES.md — updated with new build command + env var list
    • cloud/dashboard/.env.example — documented env var template

Build + test status

  • Backend: 72/72 tests passing (48 baseline + 10 new-routes + 14 billing)
  • Dashboard: 22 static routes, 0 build errors
  • Dev server smoke: /, /login, /dashboard all respond 200 with correct content
  • Sentry: disabled no-op path verified (prod-safe without DSN)

Sim deltas applied (vs stale design spec)

  • 6-dim taxonomy replaces legacy 5 buckets (TONE/DRAFTING/FORMAT/PROCESS/ACCURACY) — spec reflected stale categories
  • Wozniak decay hero viz replaces generic trend bar
  • 3-tier graduation pipeline surfaced as its own widget (sim called it "the moat")
  • "Observability lens over local-first system" framing — privacy posture panel explicit
  • Weekly digest default (SIM16 rejected daily)
  • "93% after 3 sessions" hero claim (not "100%" — defensible cohort)
  • $29 Cloud / $99 Team pricing (stale spec said $1K/$7.5K which were Sprites whitelabel numbers)
  • Legal vertical as Review: Full codebase audit via Greptile #1 ICP (JTBD 30/30)

Known runbooks to execute (human action required)

  1. Create Supabase Google OAuth app per SUPABASE-SETUP.md
  2. Create Stripe products + webhook per RAILWAY-ENV.md
  3. Set env vars on Railway + Cloudflare Pages
  4. Add custom domain app.gradata.ai in Cloudflare Pages dashboard

What's still deferred

Truly nothing from the S105 dashboard scope. Marketing site SEO migration (gradata.ai → Next.js) is its own separate project in .tmp/website/ and requires its own PR.

Generated with Gradata

Oliver Le and others added 3 commits April 12, 2026 15:25
Pre-launch hardening. No-op when DSN is unset (prod-safe on merge).

Backend (FastAPI on Railway):
- sentry-sdk[fastapi] integration with FastApi + Starlette
- send_default_pii=False, max_request_body_size="never" (Stripe PII safety)
- include_local_variables=False (no secret leaks in stack frames)
- before_send scrubs auth headers, cookies, X-API-Key, X-Stripe-Signature,
  and known secret keys in extras/contexts
- Release tag from RAILWAY_GIT_COMMIT_SHA with "dev" fallback
- Logs init status INFO so misconfig is visible in Railway logs

Frontend (Vite+React on Cloudflare Pages):
- @sentry/react with browserTracing + replay (masked + block media)
- replaysSessionSampleRate=0, replaysOnErrorSampleRate=0.5 (cost guard)
- beforeSend scrubs Supabase tokens from URLs + breadcrumbs
- @sentry/vite-plugin uploads source maps when SENTRY_AUTH_TOKEN set
  (source maps deleted after upload, not served to browsers)

Tests: 11 new (48 total passing). Docs: cloud/SENTRY-SETUP.md.

Co-Authored-By: Gradata <noreply@gradata.ai>
Phase 1-3 of the dashboard rebuild (Phase 4 widgets to come). Static
export for Cloudflare Pages, Sentry rewired, V3 Dark Cinematic tokens +
sidebar matching approved mockup.

Migration:
- Next.js 16.2 App Router (static export, no Node runtime at edge)
- Removed: Vite, React Router, @sentry/vite-plugin, @sentry/react,
  @fontsource-variable/geist, vite.config.ts, index.html, main.tsx,
  App.tsx, old src/pages/*.tsx, old Sidebar.tsx + Header.tsx
- Added: next, @sentry/nextjs, eslint-config-next, @tailwindcss/postcss
- Auth pages ported: login, signup, forgot-password, protected
  (dashboard) group with auth-guarded layout
- useAuth.ts kept unchanged (no router coupling)
- lib/supabase.ts + lib/api.ts: VITE_* → NEXT_PUBLIC_* env vars

V3 Dark Cinematic theme:
- globals.css: 10 color tokens, Space Grotesk + Inter + JetBrains Mono
  fonts via CSS @import (turbopack font loader path issue workaround),
  glass utility, scan-line keyframes, text-gradient-brand
- NoiseOverlay: fractal noise SVG overlay (opacity 0.035)
- GlassCard: reusable primitive with gradTop + scanLine options

Shell UI matching mockup:
- Sidebar: 3 sections (Brain/Team/Settings), active-route highlight,
  brand mark with gradient + glow, collapsible scroll
- Header: workspace email + sign-out button
- DashboardLayout: max-w-1280 content area, 32px padding

Sentry:
- sentry.client.config.ts + stub server/edge configs for static export
- withSentryConfig wraps next.config.ts with conditional source-map
  upload (auth token optional)
- All PII scrubbing from S105 kept: auth headers, tokens, URL params

Build: 8 static routes, 0 errors.

Known Phase 4 TODOs (per APPENDIX A of plan, sim-validated):
- KpiStrip with 6-dim taxonomy (Goal Alignment, Tone & Register, Clarity
  & Structure, Factual Integrity, Domain Fit, Actionability)
- CorrectionDecayCurve (hero viz, Wozniak decay + 95% CI bands)
- GraduationProgressBar (INSTINCT 0.40 → PATTERN 0.60 → RULE 0.90)
- ActiveRulesPanel (Bayesian conf, decay, zombie/suppression flags)
- ABProofPanel, PrivacyPosturePanel, MethodologyLink
- Principle lineage, convergence signal, contradiction state

Co-Authored-By: Gradata <noreply@gradata.ai>
Implements APPENDIX A of the plan (sim-revised widget set from S101-103,
WAVE2, SIM_A/B, 1760 lines of synthesis). Dashboard reframed as an
observability lens over a local-first system; raw corrections never
surface, synthesized principles only.

New widgets (all in src/components/brain/):
- KpiStrip: 4 sim-validated metrics
    · Correction Rate % (the only universally respected metric — S101)
    · Sessions to Graduation with 95% CI (differentiator vs Mem0/Letta)
    · Misfires absolute (trust signal, S103 "0 across 900+ applications")
    · Brain Footprint (observability, not "cloud owns your data")
- CorrectionDecayCurve: hero viz, Wozniak-style exp-decay fit + CI band
    (replaces trend bar from design spec per S103_STAT_REPLICATION)
- GraduationProgressBar: 3-tier INSTINCT 0.40 → PATTERN 0.60 → RULE 0.90
    (SIM_A: "the graduation pipeline IS the product")
- ActiveRulesPanel: confidence as metadata not primary, recurrence
    indicator (SIM16: 80% don't audit raw confidence scores)
- CategoriesChart: 6-dimension taxonomy (WAVE2 §2-3, SIM102 R5) —
    Goal Alignment, Tone & Register, Clarity & Structure, Factual
    Integrity, Domain Fit, Actionability — folds legacy 5-bucket
    categories into the new dims until backend migrates
- MetaRulesGrid: 2-layer grouping (Objective / Subjective) with Goal
    Alignment as governing node, tier badges Universal/Strong/Minority
    (SIM101 consensus)
- ActivityFeed: graduation/self-healing/recurrence/meta-rule/convergence/
    alert color coding per mockup (mock data, backend TODO)
- PrivacyPosturePanel: "raw corrections never leave your device" +
    injection cap + footprint (SIM_A §5A trust surface)
- ABProofPanel: principles-vs-baseline with 95% CIs + 70% win rate
    headline (S103 validated claims)
- MethodologyLink: modal with cited baselines (Duolingo HLR, Wozniak,
    Copilot RCT) + named limitations (truth protocol)

Supporting infra:
- src/lib/analytics-client.ts: client-side KPI computation + decay-curve
    fitting (no backend changes required for MVP)
- src/lib/fixtures/mock-{meta-rules,activity,proof}.ts: TODO(backend)
    placeholders with sim-validated data shapes
- src/hooks/useApi.ts: accepts null URL for conditional fetching

Old src/components/brain/*Tab.tsx removed (replaced by widget set).

Empty state copy (no brains): "AI that learns the corrections you keep
making" + "Mem0 remembers. Gradata learns." + pip install gradata
(JTBD-validated positioning from S101-103).

Docs: cloud/dashboard/CLOUDFLARE-PAGES.md — deploy settings, env vars,
static-export gotchas.

Build: 8 static routes, 0 errors. Sentry disabled no-op path verified.

Co-Authored-By: Gradata <noreply@gradata.ai>

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gradata has reached the 50-review limit for trial accounts. To continue receiving code reviews, upgrade your plan.

@coderabbitai

coderabbitai Bot commented Apr 13, 2026

Copy link
Copy Markdown

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough
  • Migration: dashboard rebuilt from a Vite SPA to Next.js 16 App Router with static export for Cloudflare Pages; React Router removed and dashboard build/runtime scripts/configs updated (next.config.ts, postcss, vitest, tsconfig changes).
  • Breaking changes: dashboard env vars renamed VITE_* → NEXT_PUBLIC_* (inlined at build time — rebuild required when changed); removed Vite config/tsconfig.app.json and several legacy entrypoints; routing and client bootstrap replaced by Next.js app router.
  • UI & theme: full V3 Dark Cinematic redesign — new globals.css, fonts, glass/gradient utilities, NoiseOverlay, scan-line, rebuilt layout primitives (GlassCard, sidebar/header inline), and many Next pages/components to match mockup.
  • New/ported pages & widgets: 17 routes shipped (dashboard, brain, corrections, rules, meta-rules, self-healing, observability, privacy, setup, team/members, operator, billing, settings, api-keys, notifications, auth flows) and 11 sim-validated widgets/primitives (KpiStrip, CorrectionDecayCurve, GraduationProgressBar, ActiveRulesPanel, CategoriesChart, MetaRulesGrid, PrivacyPosturePanel, ABProofPanel, MethodologyLink, ActivityFeed, PlanBadge/PlanGate).
  • New public JS APIs: analytics helpers exported — computeKpis(), computeGraduationCounts(), buildDecayCurve() (cloud/dashboard/src/lib/analytics-client.ts).
  • Backend changes / new routes: added GET /brains/{id}/meta-rules, GET /brains/{id}/activity, GET /brains/{id}/rule-patches and POST /brains/{id}/rule-patches/{patch_id}/rollback; billing refactor: PlanTier enum, CheckoutRequest.plan → PlanTier, enterprise checkout rejected (400), free/enterprise gating and 503 on missing Stripe price IDs, plus new /billing/portal endpoint.
  • Sentry & telemetry: backend Sentry init with event scrubbing and release resolution (cloud/app/sentry_init.py); dashboard wired to @sentry/nextjs with client/server/edge stubs and docs (cloud/SENTRY-SETUP.md, CLOUDFLARE-PAGES.md); Sentry disabled unless DSNs set and source-map uploads guarded.
  • Tests & ops: backend tests pass (72/72 including new billing/new-routes tests); dashboard has Vitest unit tests (41 widget tests) and 22 static routes with 0 build errors in sim; added ops/runbooks and env docs (cloud/OPS.md, RAILWAY-ENV.md, SUPABASE-SETUP.md) and Cloudflare Pages config (wrangler.toml, public/_headers, _redirects).

Walkthrough

Initializes backend Sentry (with scrubbing and release resolution) and client Sentry stubs; migrates dashboard from Vite to Next.js; restructures dashboard routing/layout/auth; adds many Next.js pages, UI components, analytics helpers, fixtures, billing endpoints/models, new backend routes (activity/meta-rules/rule-patches), tests, and operational docs.

Changes

Cohort / File(s) Summary
Backend Sentry & Billing
cloud/app/sentry_init.py, cloud/app/config.py, cloud/app/main.py, cloud/app/models.py, cloud/app/routes/billing.py, cloud/pyproject.toml, cloud/tests/test_sentry.py, cloud/tests/test_billing.py
Add init_sentry(settings) with recursive event scrubbing and release resolution; add Sentry/Stripe settings to Settings; invoke Sentry early in app startup; add PlanTier/PortalResponse models; refactor billing endpoints (checkout, portal, webhook) to use Settings/PlanTier; add pytest coverage for Sentry and billing.
New Backend Routes & Routing Registration
cloud/app/routes/activity.py, cloud/app/routes/meta_rules.py, cloud/app/routes/rule_patches.py, cloud/app/routes/__init__.py, cloud/tests/test_new_routes.py
Introduce authenticated endpoints: list brain activity, list meta-rules, list/rollback rule-patches; register routers in routes/init; add tests covering behavior, pagination, filtering and rollback semantics.
Dashboard Framework Migration
cloud/dashboard/package.json, cloud/dashboard/next.config.ts, cloud/dashboard/tsconfig.json, cloud/dashboard/postcss.config.mjs, cloud/dashboard/.nvmrc, removed cloud/dashboard/vite.config.ts, removed cloud/dashboard/tsconfig.app.json, removed cloud/dashboard/tsconfig.node.json, removed cloud/dashboard/src/main.tsx, removed cloud/dashboard/src/App.tsx, removed cloud/dashboard/index.html
Migrate dashboard from Vite to Next.js app-dir: update deps/scripts/configs, add Next config wrapped with Sentry, remove Vite-specific configs/bootstrap and old app entrypoints.
Frontend Sentry & Next.js config
cloud/dashboard/sentry.client.config.ts, cloud/dashboard/sentry.server.config.ts, cloud/dashboard/sentry.edge.config.ts, cloud/dashboard/instrumentation.ts, cloud/dashboard/.env.example
Add client Sentry init with replay and beforeSend scrubbing; add server/edge stub configs and instrumentation no-op for static export; provide env example entries and Next.js Sentry wrapper settings.
Root Layout, Auth & Providers
cloud/dashboard/app/layout.tsx, cloud/dashboard/app/globals.css, cloud/dashboard/app/page.tsx, cloud/dashboard/app/(dashboard)/layout.tsx, cloud/dashboard/src/components/providers/AuthProvider.tsx, cloud/dashboard/src/components/layout/DashboardLayout.tsx
Add Next.js root layout and global CSS; AuthProvider; protected dashboard layout that redirects unauthenticated users; convert DashboardLayout to accept children and inline sidebar/header/mobile drawer.
Pages: auth, root & dashboard pages
cloud/dashboard/app/login/page.tsx, cloud/dashboard/app/signup/page.tsx, cloud/dashboard/app/forgot-password/page.tsx, cloud/dashboard/app/(dashboard)/dashboard/page.tsx, cloud/dashboard/app/(dashboard)/brain/page.tsx, cloud/dashboard/app/(dashboard)/* (corrections, rules, meta-rules, settings, api-keys, billing, observability, notifications, operator, self-healing, setup, privacy, team/*)
Convert auth pages to Next client components; add root redirect and a comprehensive set of Next.js app-directory dashboard pages implementing brain detail, metrics, corrections, rules, meta-rules, billing, team, operator, observability, self-healing, setup, notifications, and privacy.
UI Components & Layout Primitives
cloud/dashboard/src/components/layout/GlassCard.tsx, cloud/dashboard/src/components/layout/NoiseOverlay.tsx, cloud/dashboard/src/components/brain/* (KpiStrip, CorrectionDecayCurve, GraduationProgressBar, CategoriesChart, ActiveRulesPanel, ABProofPanel, PrivacyPosturePanel, MetaRulesGrid, MethodologyLink, ActivityFeed, PlanBadge, etc.)
Add GlassCard, NoiseOverlay and numerous brain-focused UI components and primitives used across new pages; many components are client-rendered and include accessibility attributes.
Analytics utilities & fixtures
cloud/dashboard/src/lib/analytics-client.ts, cloud/dashboard/src/lib/api.ts, cloud/dashboard/src/lib/supabase.ts, cloud/dashboard/src/lib/fixtures/*
Add computeKpis, computeGraduationCounts, buildDecayCurve; change env reads to NEXT_PUBLIC_* for client; update Supabase client env sourcing; add multiple mock fixtures for demo fallback data.
Hooks, tests, assets & docs
cloud/dashboard/src/hooks/useApi.ts, cloud/dashboard/tests/*, cloud/dashboard/vitest.config.ts, cloud/dashboard/tests/setup.ts, cloud/dashboard/.gitignore, cloud/dashboard/public/* (_headers, _redirects, robots.txt), cloud/dashboard/wrangler.toml, cloud/SENTRY-SETUP.md, cloud/OPS.md, cloud/RAILWAY-ENV.md, cloud/SUPABASE-SETUP.md, cloud/dashboard/CLOUDFLARE-PAGES.md
Allow null URL in useApi to opt-out of requests; add extensive Vitest tests for UI components and analytics helpers; add Cloudflare Pages config (headers/redirects/robots), Wrangler, .gitignore updates, and multiple operational/runbook documentation files.
Removed legacy frontend CSS/components
cloud/dashboard/src/index.css, cloud/dashboard/src/components/layout/{Header,Sidebar}.tsx, cloud/dashboard/src/components/brain/{AnalyticsTab,CorrectionsTab,LessonsTab,OverviewTab}.tsx, cloud/dashboard/src/pages/*
Delete Vite-era global CSS, legacy Header/Sidebar and several tab/page components replaced by new Next.js pages and components.

Sequence Diagram(s)

sequenceDiagram
    actor Browser
    participant NextJS as "Next.js App (client)"
    participant Auth as "AuthProvider / ProtectedLayout"
    participant API as "FastAPI Backend"
    participant SentryClient as "Sentry (browser)"
    Browser->>NextJS: Request /dashboard
    NextJS->>Auth: render ProtectedLayout
    Auth->>Auth: useAuth() (client)
    alt unauthenticated
        Auth-->>Browser: redirect /login
    else authenticated
        NextJS->>API: fetch /brains, /analytics, /corrections...
        API-->>NextJS: responses
        NextJS->>NextJS: computeKpis(), computeGraduationCounts()
        NextJS->>Browser: render dashboard UI
        Browser->>SentryClient: capture error/event (if DSN set)
        SentryClient->>SentryClient: beforeSend scrub -> send
    end
Loading
sequenceDiagram
    participant FastAPI as "FastAPI Server"
    participant SentrySDK as "sentry_sdk (server)"
    participant Scrubber as "before_send scrubber"
    participant SentryCloud as "Sentry Cloud"

    FastAPI->>SentrySDK: init_sentry(settings)
    alt DSN configured
        SentrySDK->>SentrySDK: resolve_release()
        SentrySDK->>SentrySDK: configure integrations (FastAPI/Starlette)
        SentrySDK->>Scrubber: register before_send
        FastAPI->>SentrySDK: capture exception/event
        SentrySDK->>Scrubber: before_send(event)
        Scrubber->>Scrubber: _scrub_event(event)
        Scrubber-->>SentrySDK: sanitized event
        SentrySDK->>SentryCloud: POST event
        SentryCloud-->>SentrySDK: ack
    else DSN empty
        SentrySDK-->>FastAPI: disabled / no-op
    end
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch worktree-dashboard-nextjs-rebuild

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 23

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@cloud/app/sentry_init.py`:
- Around line 72-79: _scrub_dict currently only recurses into dict values so
sensitive data nested inside lists (e.g., {"extra": {"tokens": [{"access_token":
"..."}]}}) is not filtered; update _scrub_dict to also descend into iterable
containers: when a value is a list or tuple, iterate its elements and
recursively call _scrub_dict on any element that is a dict (and recursively
handle nested lists/tuples), and likewise handle dict-like mappings; keep the
existing behavior of replacing d[key] with "[Filtered]" when key.lower() is in
_SENSITIVE_KEYS. Target the _scrub_dict function and ensure recursion covers
dicts, lists, and tuples so secrets nested inside arrays are filtered.

In `@cloud/dashboard/app/`(dashboard)/dashboard/page.tsx:
- Around line 29-44: The dashboard is unwrapping only the first page from useApi
(correctionsResp, lessonsResp) and computing KPIs from that single .data slice;
update the data-loading to aggregate all pages for a brain when primaryBrainId
is set: either extend useApi to accept and auto-fetch all pages or add a
pagination loop that uses the paginated response (PaginatedResponse) to request
subsequent pages until exhausted and then set corrections and lessons to the
full concatenated arrays (referencing correctionsResp, lessonsResp, corrections,
lessons and useApi/primaryBrainId to locate the logic). Ensure the final
corrections and lessons arrays contain all items across pages before any
KPI/chart calculations run.
- Around line 26-34: The widgets are rendering a transient empty state because
useApi hooks flip from null to loading after primaryBrainId appears; fix by
gating the widget rendering on the actual dependent responses instead of just
primaryBrainId: keep the existing useApi calls (useApi, analytics,
correctionsResp, lessonsResp, primaryBrainId) but wrap/chart-render blocks so
they only render when the corresponding response is present (e.g. analytics !==
undefined / null before rendering analytics chart, correctionsResp exists before
rendering corrections panel, lessonsResp exists before rendering lessons panel);
apply the same guarding for all dependent widgets referenced in this file (lines
~52-125).

In `@cloud/dashboard/app/forgot-password/page.tsx`:
- Around line 39-41: The current JSX nests the Button component inside Link,
creating nested interactive elements (Link and Button) which is inaccessible;
update the markup to use one interactive element only by either rendering the
Button as the Link using the Button's asChild prop (e.g., pass asChild to Button
and place Link as its child) or by styling the Link to look like the Button and
remove the Button wrapper; change the <Link> / <Button> usage around the "Back
to login" text accordingly (refer to the Link and Button components in page.tsx)
and ensure keyboard focus and semantics remain a single interactive element.

In `@cloud/dashboard/app/globals.css`:
- Around line 3-23: The SCSS linter is flagging Tailwind's custom at-rule
`@theme`; update your Stylelint configuration to teach the scss/at-rule-no-unknown
rule to ignore Tailwind at-rules by adding an ignoreAtRules array that includes
"theme" (and common Tailwind at-rules such as "tailwind", "apply", "screen",
"variants", "responsive", "layer" as needed). Modify the scss/at-rule-no-unknown
rule entry in your Stylelint config (e.g., .stylelintrc.js or package.json
stylelint key) to include ignoreAtRules with "theme" so the `@theme` declaration
in globals.css no longer triggers the error.

In `@cloud/dashboard/CLOUDFLARE-PAGES.md`:
- Around line 38-41: The Sentry smoke-test instruction that asks operators to
trigger a client-side React error on the live site should be removed or
relocated: update the checklist that mentions the browser console message
"[sentry] initialized env=production release=..." and the step to "trigger a
React error" so it either (a) moves into preview/staging guidance, or (b)
replaces the manual error with a non-invasive synthetic verification such as
invoking a controlled Sentry API call (e.g., a documented synthetic event via
Sentry.captureMessage/captureException or server-side test event using the DSN)
so production operators do not create real user-facing errors; ensure references
to DSN and the console log remain but clarify that generation of events on
production is forbidden and point to the staging verification alternative.

In `@cloud/dashboard/package.json`:
- Around line 13-40: Run local compatibility tests for the specified dependency
set: install and build the Next.js app using next@16.0.0, typescript@~6.0.2, and
`@sentry/nextjs`@10.48.0, and resolve any peer dependency conflicts reported by
npm/yarn; if build or tsc fails, either pin `@sentry/nextjs` or typescript to a
compatible minor version or add a package manager resolution/override to force
compatible peer deps, then re-run next build and tsc --noEmit to confirm success
(check package names `@sentry/nextjs`, next, and typescript in package.json and
adjust versions/overrides accordingly).

In `@cloud/dashboard/src/components/brain/ABProofPanel.tsx`:
- Around line 42-57: The progress bar inner divs for baseline and
with-principles (using baselinePct and withPct) are currently plain divs with
aria-labels that won't be announced; update those elements to include
role="progressbar" and the ARIA attributes aria-valuenow={baselinePct} (or
{withPct}), aria-valuemin="0", aria-valuemax="100" while keeping the aria-label
text so assistive tech correctly announces the percentage; locate the two inner
divs in ABProofPanel (the ones with className "h-full bg-[var(--color-body)]"
and "h-full bg-gradient-brand") and add those attributes.

In `@cloud/dashboard/src/components/brain/ActivityFeed.tsx`:
- Around line 20-26: The ago function needs to validate the parsed date before
computing differences: inside ago(iso) check that iso is a non-empty string and
that new Date(iso).getTime() yields a valid number (use Date.parse or
Number.isFinite/Number.isNaN checks); if invalid, return a safe fallback such as
'just now' or 'unknown'; otherwise compute diffMs and proceed as before. Update
the ago helper to perform this validation and early-return to avoid producing
"NaNd ago".

In `@cloud/dashboard/src/components/brain/CategoriesChart.tsx`:
- Around line 45-47: The loop that folds analytics.corrections_by_category
currently defaults unknown keys to 'Factual Integrity' (see the iteration over
analytics.corrections_by_category, LEGACY_MAP lookup, and folded accumulator),
which can misattribute new/misspelled backend keys; change the logic to skip
keys not present in LEGACY_MAP (or aggregate them under an explicit
'Unknown'/'Other' bucket) instead of using the 'Factual Integrity' default, and
optionally emit a console.warn or telemetry for unmapped keys so they are
visible during debugging.

In `@cloud/dashboard/src/components/brain/CorrectionDecayCurve.tsx`:
- Around line 26-28: The headline drop uses the zero-filled edges from
buildDecayCurve (variables first/last/dropPct) which makes sparse ranges report
0%; instead locate the first and last observed empirical values (scan data for
the first element where item.empirical is not null/undefined/0 and the last
element likewise), assign those to firstObserved/lastObserved, and compute
dropPct from those (falling back to 0 if no observed points); update the
references where first/last are used so dropPct reflects actual observed
start/end values rather than zero-filled entries.
- Around line 73-91: The CI band is stacked incorrectly because both Area
components use stackId="ci"; compute a new derived data field ciBand = ciHigh -
ciLow for each datum and update the chart to render ciLow first as a transparent
base Area (e.g., dataKey="ciLow", fillOpacity=0, stroke="none") and then render
ciBand on top (dataKey="ciBand", stackId="ci", fill="#3A82FF", fillOpacity=0.08)
so the shaded region covers ciLow → ciHigh; update any data mapping logic that
builds the series to include the ciBand field before rendering.

In `@cloud/dashboard/src/components/brain/KpiStrip.tsx`:
- Around line 12-30: The delta is being formatted with toFixed(0) for display
but the changeTone decision still uses the original unrounded
metrics.correctionRateDeltaPct, causing mismatched signs; compute a roundedPct =
Math.round(metrics.correctionRateDeltaPct) (or similar) once inside the items
entry and use that roundedPct both to decide value (use fmtDelta(roundedPct) or
inline formatting) and to set changeTone (roundedPct < 0 ? 'pos' : roundedPct >
0 ? 'neg' : 'neu'), keeping the existing zero-display of '—' when roundedPct ===
0 and updating the Correction Rate item's value and changeTone to reference that
rounded value instead of the raw metric.

In `@cloud/dashboard/src/components/brain/MetaRulesGrid.tsx`:
- Around line 27-49: The inline Card component inside MetaRulesGrid causes a new
function on every render; move Card out to module scope (above or below
MetaRulesGrid) and give it a typed props interface (e.g., props: { m: typeof
mockMetaRules[number] }) so it no longer gets recreated each render, then update
MetaRulesGrid to use the top-level Card; optionally wrap the top-level Card with
React.memo to further prevent unnecessary re-renders and keep existing
identifiers like Card, MetaRulesGrid, mockMetaRules and TIER_STYLE unchanged so
keys and styling continue to work.

In `@cloud/dashboard/src/components/brain/MethodologyLink.tsx`:
- Around line 29-94: The modal rendered when open (role="dialog" in
MethodologyLink component) lacks keyboard and focus accessibility: add
Escape-to-close handling, explicit aria-labelledby/aria-modal attributes, and
proper focus management. Implement a ref for the dialog container and focus the
first interactive element (the close button) on open using useEffect, restore
focus to the element that opened the modal on close, and add an onKeyDown
handler that listens for Escape to call setOpen(false); additionally ensure the
heading has an id and the dialog uses aria-labelledby pointing to it, and
consider trapping focus within the modal while open (or use an existing
focus-trap utility) to prevent keyboard users from tabbing out.

In `@cloud/dashboard/src/components/layout/DashboardLayout.tsx`:
- Around line 43-77: The sidebar in DashboardLayout permanently reserves 240px
(the aside using SECTIONS and Link) which breaks small viewports; change it to a
responsive collapsible/drawer: hide the aside on small screens (e.g., use
utility classes like hidden on sm and show on md) and add a toggle state
(isSidebarOpen) and a hamburger button in the top/header to open a drawer; when
open render the same section list (use SECTIONS, pathname, Link) inside a
full-screen/overlay drawer with appropriate aria attributes and a close control,
and ensure the aside keeps its current layout on larger screens so desktop
behavior is unchanged.

In `@cloud/dashboard/src/hooks/useApi.ts`:
- Around line 31-35: The fetchData callback returns early when url === null but
doesn't clear previously loaded state, so update fetchData (in useApi.ts) to
explicitly clear stored results when url becomes null by calling the appropriate
state setters (e.g., setData and setError) and setLoading(false) before
returning; locate fetchData and the state hooks (setData, setError, setLoading)
and ensure you reset data to an empty/initial value (or null per existing state
shape) so stale analytics/corrections are not shown when url transitions to
null.

In `@cloud/dashboard/src/lib/analytics-client.ts`:
- Around line 132-160: The code builds 24h rolling buckets from `now`
(Date.now()) so bucket boundaries float with the current clock; change the
bucket anchor to a day boundary instead: compute a local "endOfToday" (a Date
set to local midnight for the current day, e.g., new Date(); setHours(24,0,0,0)
or setHours(0,0,0,0) then add dayMs as appropriate) and use that as the
reference instead of `now` when constructing `buckets`, computing `idx`, and
filtering corrections (use the correction timestamp `c.created_at` as you
already do). This ensures buckets align to calendar days and labels from
toLocaleDateString reflect the actual calendar day for each bucket; update uses
of `now` in the loop that builds `buckets` and the index calc (`const idx =
Math.floor((t - (now - rangeDays * dayMs)) / dayMs)`) to use the new
`endOfToday` reference.

In `@cloud/dashboard/src/lib/api.ts`:
- Line 5: The code currently sets baseURL: process.env.NEXT_PUBLIC_API_URL ||
'https://gradata-production.up.railway.app/api/v1', which silently falls back to
production; remove the implicit fallback and instead fail fast when
NEXT_PUBLIC_API_URL is missing by checking process.env.NEXT_PUBLIC_API_URL
during initialization (the baseURL assignment used to configure the API client)
and throwing or logging a clear error if it's undefined so the app does not
accidentally send traffic to prod.

In `@cloud/dashboard/tsconfig.json`:
- Around line 38-45: The tsconfig currently includes ".next/types/**/*.ts" and
".next/dev/types/**/*.ts" but also excludes the entire ".next" directory, which
prevents those included paths from being used; remove ".next" from the "exclude"
array so the explicit includes take effect (update the tsconfig.json exclude
list to only contain "node_modules" and "out"), ensuring Next.js generated
route/type files are available to the TypeScript compiler.

In `@cloud/SENTRY-SETUP.md`:
- Around line 20-24: The fenced code blocks containing environment variable
examples (e.g., the block with GRADATA_SENTRY_DSN,
GRADATA_SENTRY_TRACES_SAMPLE_RATE, GRADATA_ENVIRONMENT) need explicit language
tags to satisfy markdownlint; update each triple-backtick fence (including the
other blocks at ranges referenced) to use an appropriate tag such as ```bash or
```env so the blocks are recognized as shell/environment snippets and the linter
warnings are resolved.
- Around line 3-4: The frontend Sentry runbook still references Vite-era setup
(VITE_* env vars, vite.config.ts, src/main.tsx) but the dashboard now uses
Next.js with public vars renamed to NEXT_PUBLIC_*; update the doc to instruct
operators to set NEXT_PUBLIC_SENTRY_DSN (and any NEXT_PUBLIC_SENTRY_ENV,
NEXT_PUBLIC_SENTRY_RELEASE), redeploy the Next.js dashboard on Cloudflare Pages,
and replace instructions about modifying vite.config.ts/src/main.tsx with
guidance to initialize Sentry in the Next.js entrypoint (e.g., app/layout.tsx or
pages/_app.tsx) and any next.config.js/runtime config changes so client-side
Sentry is actually enabled. Ensure the same corrections are applied to the other
affected sections (lines ~34-50 and ~91-100).

In `@cloud/tests/test_sentry.py`:
- Around line 35-45: The test test_init_sentry_enabled_when_dsn_set calls
init_sentry which invokes the real sentry_sdk.init and leaves a global client
active, polluting other tests; update this test to mock sentry_sdk.init (or
patch it) so init_sentry is exercised without creating a real global client,
assert the mock was called with the expected DSN and environment, and remove any
assertions that rely on sentry_sdk.get_client() being active; reference the test
function name test_init_sentry_enabled_when_dsn_set, the init_sentry function
under test, and the sentry_sdk.init call to locate where to apply the
mock/patch.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: e623deb7-5935-47ad-9176-2c046b35d2f4

📥 Commits

Reviewing files that changed from the base of the PR and between 1a987b2 and 3d31f8e.

⛔ Files ignored due to path filters (1)
  • cloud/dashboard/pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (64)
  • cloud/SENTRY-SETUP.md
  • cloud/app/config.py
  • cloud/app/main.py
  • cloud/app/sentry_init.py
  • cloud/dashboard/.gitignore
  • cloud/dashboard/CLOUDFLARE-PAGES.md
  • cloud/dashboard/app/(dashboard)/api-keys/page.tsx
  • cloud/dashboard/app/(dashboard)/dashboard/page.tsx
  • cloud/dashboard/app/(dashboard)/layout.tsx
  • cloud/dashboard/app/(dashboard)/settings/page.tsx
  • cloud/dashboard/app/forgot-password/page.tsx
  • cloud/dashboard/app/globals.css
  • cloud/dashboard/app/layout.tsx
  • cloud/dashboard/app/login/page.tsx
  • cloud/dashboard/app/page.tsx
  • cloud/dashboard/app/signup/page.tsx
  • cloud/dashboard/index.html
  • cloud/dashboard/instrumentation.ts
  • cloud/dashboard/next.config.ts
  • cloud/dashboard/package.json
  • cloud/dashboard/postcss.config.mjs
  • cloud/dashboard/sentry.client.config.ts
  • cloud/dashboard/sentry.edge.config.ts
  • cloud/dashboard/sentry.server.config.ts
  • cloud/dashboard/src/App.tsx
  • cloud/dashboard/src/components/brain/ABProofPanel.tsx
  • cloud/dashboard/src/components/brain/ActiveRulesPanel.tsx
  • cloud/dashboard/src/components/brain/ActivityFeed.tsx
  • cloud/dashboard/src/components/brain/AnalyticsTab.tsx
  • cloud/dashboard/src/components/brain/CategoriesChart.tsx
  • cloud/dashboard/src/components/brain/CorrectionDecayCurve.tsx
  • cloud/dashboard/src/components/brain/CorrectionsTab.tsx
  • cloud/dashboard/src/components/brain/GraduationProgressBar.tsx
  • cloud/dashboard/src/components/brain/KpiStrip.tsx
  • cloud/dashboard/src/components/brain/LessonsTab.tsx
  • cloud/dashboard/src/components/brain/MetaRulesGrid.tsx
  • cloud/dashboard/src/components/brain/MethodologyLink.tsx
  • cloud/dashboard/src/components/brain/OverviewTab.tsx
  • cloud/dashboard/src/components/brain/PrivacyPosturePanel.tsx
  • cloud/dashboard/src/components/layout/DashboardLayout.tsx
  • cloud/dashboard/src/components/layout/GlassCard.tsx
  • cloud/dashboard/src/components/layout/Header.tsx
  • cloud/dashboard/src/components/layout/NoiseOverlay.tsx
  • cloud/dashboard/src/components/layout/Sidebar.tsx
  • cloud/dashboard/src/components/providers/AuthProvider.tsx
  • cloud/dashboard/src/hooks/useApi.ts
  • cloud/dashboard/src/index.css
  • cloud/dashboard/src/lib/analytics-client.ts
  • cloud/dashboard/src/lib/api.ts
  • cloud/dashboard/src/lib/fixtures/mock-activity.ts
  • cloud/dashboard/src/lib/fixtures/mock-meta-rules.ts
  • cloud/dashboard/src/lib/fixtures/mock-proof.ts
  • cloud/dashboard/src/lib/supabase.ts
  • cloud/dashboard/src/main.tsx
  • cloud/dashboard/src/pages/ApiKeys.tsx
  • cloud/dashboard/src/pages/BrainDetail.tsx
  • cloud/dashboard/src/pages/Dashboard.tsx
  • cloud/dashboard/src/pages/Settings.tsx
  • cloud/dashboard/tsconfig.app.json
  • cloud/dashboard/tsconfig.json
  • cloud/dashboard/tsconfig.node.json
  • cloud/dashboard/vite.config.ts
  • cloud/pyproject.toml
  • cloud/tests/test_sentry.py
💤 Files with no reviewable changes (17)
  • cloud/dashboard/src/App.tsx
  • cloud/dashboard/src/main.tsx
  • cloud/dashboard/tsconfig.app.json
  • cloud/dashboard/index.html
  • cloud/dashboard/tsconfig.node.json
  • cloud/dashboard/src/pages/BrainDetail.tsx
  • cloud/dashboard/src/components/brain/AnalyticsTab.tsx
  • cloud/dashboard/src/index.css
  • cloud/dashboard/src/pages/Dashboard.tsx
  • cloud/dashboard/src/components/layout/Sidebar.tsx
  • cloud/dashboard/src/pages/Settings.tsx
  • cloud/dashboard/src/components/brain/LessonsTab.tsx
  • cloud/dashboard/src/components/layout/Header.tsx
  • cloud/dashboard/vite.config.ts
  • cloud/dashboard/src/components/brain/CorrectionsTab.tsx
  • cloud/dashboard/src/pages/ApiKeys.tsx
  • cloud/dashboard/src/components/brain/OverviewTab.tsx
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Cloudflare Pages
🧰 Additional context used
🪛 markdownlint-cli2 (0.22.0)
cloud/SENTRY-SETUP.md

[warning] 20-20: Fenced code blocks should have a language specified

(MD040, fenced-code-language)


[warning] 28-28: Fenced code blocks should have a language specified

(MD040, fenced-code-language)


[warning] 38-38: Fenced code blocks should have a language specified

(MD040, fenced-code-language)


[warning] 53-53: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🪛 Stylelint (17.6.0)
cloud/dashboard/app/globals.css

[error] 3-3: Unexpected unknown at-rule "@theme" (scss/at-rule-no-unknown)

(scss/at-rule-no-unknown)

🔇 Additional comments (29)
cloud/dashboard/src/lib/supabase.ts (1)

3-4: Env var migration looks correct.

Using process.env.NEXT_PUBLIC_SUPABASE_URL and process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY aligns with the Next.js migration and keeps client config explicit.

cloud/dashboard/src/components/providers/AuthProvider.tsx (1)

5-7: Provider wiring is clean and correct.

This correctly bridges useAuthProvider() into AuthContext.Provider for app-wide auth state.

cloud/dashboard/src/components/layout/NoiseOverlay.tsx (1)

3-12: Overlay implementation is solid.

aria-hidden + pointer-events-none is the right combination for a purely decorative noise layer.

cloud/dashboard/.gitignore (1)

1-31: Ignore rules are aligned with the Next.js/Sentry migration.

This is a clean, targeted set for build outputs, env files, and generated artifacts.

cloud/dashboard/src/lib/fixtures/mock-activity.ts (1)

2-20: Fixture typing is strong and UI-friendly.

ActivityKind + MockActivity with __mocked: true gives clear, safe mock-data boundaries.

cloud/dashboard/src/lib/fixtures/mock-meta-rules.ts (1)

5-70: Meta-rule fixture structure is well designed.

The typed layer/tier model and source metadata make this dataset robust for grid rendering and future backend swap.

cloud/pyproject.toml (1)

14-14: Sentry dependency addition matches backend instrumentation changes.

This dependency update is consistent with the FastAPI Sentry integration introduced in this PR.

cloud/dashboard/sentry.edge.config.ts (1)

1-2: Sentry edge stub is correct for static export.

This minimal module is appropriate and avoids unnecessary runtime init for this target.

cloud/dashboard/app/(dashboard)/api-keys/page.tsx (1)

1-11: Placeholder route implementation is clear and safe.

The page renders a deterministic fallback state and keeps scope explicit for Phase 4.

cloud/dashboard/instrumentation.ts (1)

1-5: No-op instrumentation hook is appropriate here.

This keeps Next/Sentry integration expectations satisfied without introducing runtime work.

cloud/dashboard/sentry.server.config.ts (1)

1-2: Server Sentry stub is correctly minimal.

This is a valid static-export placeholder and does not introduce server runtime coupling.

cloud/dashboard/postcss.config.mjs (1)

1-7: PostCSS configuration looks good.

The plugin wiring is concise and aligned with the new tooling setup.

cloud/app/main.py (1)

26-30: Sentry init placement is correct.

Initializing before FastAPI app construction is the right ordering for integration coverage.

cloud/dashboard/app/(dashboard)/settings/page.tsx (1)

1-11: Settings placeholder page is acceptable for phased delivery.

The route is explicit, stable, and communicates implementation status clearly.

cloud/app/config.py (1)

23-26: LGTM!

The Sentry configuration fields are well-designed with sensible defaults. Empty DSN as the disable mechanism is a standard pattern, and 0.1 trace sample rate is reasonable for production use. The inline comment documenting the release fallback behavior is helpful.

cloud/dashboard/app/page.tsx (1)

7-17: LGTM!

The redirect logic is correct: router.replace() prevents back-navigation to the empty page, and the effect dependencies are complete. The pattern aligns with the protected layout's auth gating.

Note: On static export, there may be a brief moment where the page mounts before the redirect fires. This is acceptable since null is rendered, but consider adding a loading indicator if users report a flash of blank screen.

cloud/dashboard/app/(dashboard)/layout.tsx (1)

8-17: LGTM!

The protected layout correctly guards dashboard routes: it returns null while auth is loading or when unauthenticated (preventing flash of protected content), and the effect handles the redirect. The pattern is consistent with the root page's auth handling.

cloud/dashboard/app/layout.tsx (2)

11-28: LGTM!

The root layout correctly sets up the app structure with AuthProvider for global auth state and NoiseOverlay for the V3 theme. The Google Fonts approach with display=swap is appropriate given the noted turbopack limitation. Consider revisiting next/font when the path issue is resolved for automatic subsetting benefits.


14-21: 🧹 Nitpick | 🔵 Trivial

Font loading via stylesheet links bypasses next/font optimizations.

The comment notes a turbopack path issue necessitating this approach. Be aware this skips Next.js font optimizations (automatic subsetting, self-hosting, font-display control). Consider revisiting once the turbopack issue is resolved.

For now, adding font-display=swap to the URL prevents FOIT:

🎨 Add font-display parameter
         <link
           rel="stylesheet"
-          href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Space+Grotesk:wght@500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"
+          href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Space+Grotesk:wght@500;600;700&family=JetBrains+Mono:wght@400;500&display=swap&font-display=swap"
         />

Note: The display=swap parameter is already present, which handles this. LGTM on the current implementation.

			> Likely an incorrect or invalid review comment.
cloud/dashboard/src/components/brain/MetaRulesGrid.tsx (1)

51-85: LGTM!

The conditional rendering logic for the three layers is clean, and the UI structure follows the documented SIM101/SIM102 consensus design. The TODO comment for backend integration is noted.

cloud/dashboard/src/components/layout/GlassCard.tsx (1)

1-27: LGTM!

Clean, reusable component with well-defined props interface. The optional gradTop and scanLine effects integrate properly with the global CSS classes defined in globals.css. Good use of cn utility for className merging and proper prop spreading.

cloud/dashboard/src/lib/fixtures/mock-proof.ts (1)

1-19: LGTM!

Well-structured fixture with clear interface definition. The TODO comment appropriately flags this for backend replacement, and the data provenance is documented in comments.

cloud/dashboard/src/components/brain/ABProofPanel.tsx (1)

12-41: LGTM!

The component logic for computing deltas and displaying confidence intervals is correct. Good documentation of the differentiator rationale in the JSDoc comment.

Also applies to: 58-69

cloud/dashboard/next.config.ts (1)

1-22: LGTM!

Well-configured for static export to Cloudflare Pages. Good practices include:

  • hideSourceMaps: true prevents exposing source maps in production
  • silent: !process.env.CI keeps local dev quiet while ensuring CI visibility
  • Graceful degradation when SENTRY_AUTH_TOKEN is not set (dev-safe)
  • TypeScript/ESLint checks enforced during builds

The stub files for server/edge configs properly handle the static export case where no runtime exists.

cloud/dashboard/app/login/page.tsx (2)

20-33: LGTM!

The session redirect pattern is correctly implemented. The useEffect waits for !loading && session which ensures the redirect only fires after the initial auth state check completes (per the useAuth hook's initialization sequence). The handleSubmit properly relies on the auth state change subscription to trigger the redirect rather than navigating manually, avoiding race conditions.


1-5: LGTM!

Clean migration from React Router to Next.js App Router. Imports correctly updated to next/link and next/navigation, and the component properly uses 'use client' directive for client-side interactivity.

Also applies to: 12-14, 66-71, 82-83

cloud/dashboard/sentry.client.config.ts (1)

1-40: LGTM!

Solid PII scrubbing implementation with comprehensive coverage:

  • Sensitive headers filtered (authorization, cookie, x-api-key)
  • Token parameters redacted from URLs (query params and hash fragments)
  • sendDefaultPii: false as baseline protection
  • Replay integration configured with maskAllText and blockAllMedia for privacy
  • Graceful no-op when DSN is unset (appropriate for local dev)

The beforeSend hook correctly handles edge cases with try-catch for URL parsing.

cloud/dashboard/src/components/brain/GraduationProgressBar.tsx (1)

50-52: No defensive fallback is needed; avgConfidenceByState is always fully populated.

The avg() function explicitly returns 0 for empty arrays, and the object literal in computeGraduationCounts creates all three state keys (INSTINCT, PATTERN, RULE) with numeric values every time. Accessing counts.avgConfidenceByState[t.key] will always return a number, so calling .toFixed(2) is safe.

			> Likely an incorrect or invalid review comment.
cloud/dashboard/app/signup/page.tsx (1)

33-37: > Likely an incorrect or invalid review comment.

Comment thread cloud/app/sentry_init.py
Comment on lines +72 to +79
def _scrub_dict(d: dict[str, Any]) -> None:
"""Recursively replace values whose keys look sensitive."""
for key, value in list(d.items()):
if isinstance(key, str) and key.lower() in _SENSITIVE_KEYS:
d[key] = "[Filtered]"
elif isinstance(value, dict):
_scrub_dict(value)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Recursion stops at lists, so nested secrets still leak.

_scrub_dict() only descends into dict values. Payloads like {"extra": {"tokens": [{"access_token": "..."}]}} pass through untouched, which breaks the “anywhere in extras/contexts” guarantee and can leak credentials.

Suggested fix
+def _scrub_value(value: Any) -> None:
+    if isinstance(value, dict):
+        _scrub_dict(value)
+    elif isinstance(value, list):
+        for item in value:
+            _scrub_value(item)
+
+
 def _scrub_dict(d: dict[str, Any]) -> None:
     """Recursively replace values whose keys look sensitive."""
     for key, value in list(d.items()):
         if isinstance(key, str) and key.lower() in _SENSITIVE_KEYS:
             d[key] = "[Filtered]"
-        elif isinstance(value, dict):
-            _scrub_dict(value)
+        else:
+            _scrub_value(value)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cloud/app/sentry_init.py` around lines 72 - 79, _scrub_dict currently only
recurses into dict values so sensitive data nested inside lists (e.g., {"extra":
{"tokens": [{"access_token": "..."}]}}) is not filtered; update _scrub_dict to
also descend into iterable containers: when a value is a list or tuple, iterate
its elements and recursively call _scrub_dict on any element that is a dict (and
recursively handle nested lists/tuples), and likewise handle dict-like mappings;
keep the existing behavior of replacing d[key] with "[Filtered]" when
key.lower() is in _SENSITIVE_KEYS. Target the _scrub_dict function and ensure
recursion covers dicts, lists, and tuples so secrets nested inside arrays are
filtered.

Comment on lines +26 to +34
const { data: analytics } = useApi<BrainAnalytics>(
primaryBrainId ? `/brains/${primaryBrainId}/analytics` : null,
)
const { data: correctionsResp } = useApi<PaginatedResponse<Correction> | Correction[]>(
primaryBrainId ? `/brains/${primaryBrainId}/corrections` : null,
)
const { data: lessonsResp } = useApi<PaginatedResponse<Lesson> | Lesson[]>(
primaryBrainId ? `/brains/${primaryBrainId}/lessons` : null,
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Gate the widgets on the dependent requests, not just /brains.

Because useApi() starts these hooks with url = null and only flips them to loading in the follow-up effect, this path renders zeroed charts/panels for one frame after primaryBrainId appears. That produces a visible false-empty state before analytics, corrections, and lessons resolve.

Suggested fix
-  const { data: analytics } = useApi<BrainAnalytics>(
+  const { data: analytics, loading: loadingAnalytics } = useApi<BrainAnalytics>(
     primaryBrainId ? `/brains/${primaryBrainId}/analytics` : null,
   )
-  const { data: correctionsResp } = useApi<PaginatedResponse<Correction> | Correction[]>(
+  const { data: correctionsResp, loading: loadingCorrections } = useApi<PaginatedResponse<Correction> | Correction[]>(
     primaryBrainId ? `/brains/${primaryBrainId}/corrections` : null,
   )
-  const { data: lessonsResp } = useApi<PaginatedResponse<Lesson> | Lesson[]>(
+  const { data: lessonsResp, loading: loadingLessons } = useApi<PaginatedResponse<Lesson> | Lesson[]>(
     primaryBrainId ? `/brains/${primaryBrainId}/lessons` : null,
   )
@@
-  if (loadingBrains) return <LoadingSpinner className="py-20" />
+  if (
+    loadingBrains ||
+    (primaryBrainId && (loadingAnalytics || loadingCorrections || loadingLessons))
+  ) {
+    return <LoadingSpinner className="py-20" />
+  }

Also applies to: 52-125

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cloud/dashboard/app/`(dashboard)/dashboard/page.tsx around lines 26 - 34, The
widgets are rendering a transient empty state because useApi hooks flip from
null to loading after primaryBrainId appears; fix by gating the widget rendering
on the actual dependent responses instead of just primaryBrainId: keep the
existing useApi calls (useApi, analytics, correctionsResp, lessonsResp,
primaryBrainId) but wrap/chart-render blocks so they only render when the
corresponding response is present (e.g. analytics !== undefined / null before
rendering analytics chart, correctionsResp exists before rendering corrections
panel, lessonsResp exists before rendering lessons panel); apply the same
guarding for all dependent widgets referenced in this file (lines ~52-125).

Comment on lines +29 to +44
const { data: correctionsResp } = useApi<PaginatedResponse<Correction> | Correction[]>(
primaryBrainId ? `/brains/${primaryBrainId}/corrections` : null,
)
const { data: lessonsResp } = useApi<PaginatedResponse<Lesson> | Lesson[]>(
primaryBrainId ? `/brains/${primaryBrainId}/lessons` : null,
)

const corrections = useMemo<Correction[]>(() => {
if (!correctionsResp) return []
return Array.isArray(correctionsResp) ? correctionsResp : correctionsResp.data
}, [correctionsResp])

const lessons = useMemo<Lesson[]>(() => {
if (!lessonsResp) return []
return Array.isArray(lessonsResp) ? lessonsResp : lessonsResp.data
}, [lessonsResp])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Paginated responses are being treated as complete datasets.

When these endpoints return PaginatedResponse, this page unwraps a single .data page and computes every KPI/chart from that slice only. Once a brain has more than one page of corrections or lessons, the dashboard becomes silently wrong.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cloud/dashboard/app/`(dashboard)/dashboard/page.tsx around lines 29 - 44, The
dashboard is unwrapping only the first page from useApi (correctionsResp,
lessonsResp) and computing KPIs from that single .data slice; update the
data-loading to aggregate all pages for a brain when primaryBrainId is set:
either extend useApi to accept and auto-fetch all pages or add a pagination loop
that uses the paginated response (PaginatedResponse) to request subsequent pages
until exhausted and then set corrections and lessons to the full concatenated
arrays (referencing correctionsResp, lessonsResp, corrections, lessons and
useApi/primaryBrainId to locate the logic). Ensure the final corrections and
lessons arrays contain all items across pages before any KPI/chart calculations
run.

Comment on lines +39 to 41
<Link href="/login">
<Button variant="outline" className="w-full">Back to login</Button>
</Link>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Nested interactive elements create accessibility issues.

Wrapping a <Button> inside a <Link> creates nested interactive elements, which can cause screen reader confusion and keyboard navigation issues. Use either a styled link or a button with onClick navigation.

♿ Proposed fix using Button's asChild pattern (if supported) or direct styling
-            <Link href="/login">
-              <Button variant="outline" className="w-full">Back to login</Button>
-            </Link>
+            <Link href="/login" className="inline-flex w-full items-center justify-center rounded-md border border-input bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground">
+              Back to login
+            </Link>

Or if the Button component supports asChild:

<Button variant="outline" className="w-full" asChild>
  <Link href="/login">Back to login</Link>
</Button>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cloud/dashboard/app/forgot-password/page.tsx` around lines 39 - 41, The
current JSX nests the Button component inside Link, creating nested interactive
elements (Link and Button) which is inaccessible; update the markup to use one
interactive element only by either rendering the Button as the Link using the
Button's asChild prop (e.g., pass asChild to Button and place Link as its child)
or by styling the Link to look like the Button and remove the Button wrapper;
change the <Link> / <Button> usage around the "Back to login" text accordingly
(refer to the Link and Button components in page.tsx) and ensure keyboard focus
and semantics remain a single interactive element.

Comment on lines +3 to +23
@theme {
--color-bg: #0C1120;
--color-surface: #151D30;
--color-text: #F8FAFC;
--color-body: #8895A7;
--color-accent-blue: #3A82FF;
--color-accent-violet: #7C3AED;
--color-success: #22C55E;
--color-warning: #EAB308;
--color-destructive: #EF4444;
--color-border: rgba(255, 255, 255, 0.08);
--color-border-hover: rgba(58, 130, 255, 0.25);

--font-sans: 'Inter', system-ui, sans-serif;
--font-heading: 'Space Grotesk', sans-serif;
--font-mono: 'JetBrains Mono', monospace;

--radius-card: 0.625rem;
--radius-item: 0.5rem;
--radius-badge: 0.25rem;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

git ls-files | grep -E "(globals\.css|\.stylelintrc)" | head -20

Repository: Gradata/gradata

Length of output: 90


🏁 Script executed:

fd -t f "globals.css" --max-results 5

Repository: Gradata/gradata

Length of output: 90


🏁 Script executed:

fd -t f "\.stylelintrc" -o "stylelint" --max-results 10

Repository: Gradata/gradata

Length of output: 196


🏁 Script executed:

fd ".stylelintrc"

Repository: Gradata/gradata

Length of output: 41


🏁 Script executed:

fd "stylelint" -t f

Repository: Gradata/gradata

Length of output: 41


🏁 Script executed:

cat -n cloud/dashboard/app/globals.css

Repository: Gradata/gradata

Length of output: 2594


🏁 Script executed:

find . -type f \( -name "package.json" -o -name "stylelint.config.*" -o -name ".stylelintrc.*" \) | grep -v node_modules | head -10

Repository: Gradata/gradata

Length of output: 109


🏁 Script executed:

rg "stylelint" --type json --max-count 20

Repository: Gradata/gradata

Length of output: 222


🏁 Script executed:

cat -n package.json | head -100

Repository: Gradata/gradata

Length of output: 103


🏁 Script executed:

cat -n .stylelintrc.json

Repository: Gradata/gradata

Length of output: 1643


🏁 Script executed:

cat -n cloud/dashboard/package.json | head -150

Repository: Gradata/gradata

Length of output: 1419


🏁 Script executed:

rg "tailwind" cloud/dashboard/package.json -A 2 -B 2

Repository: Gradata/gradata

Length of output: 368


🏁 Script executed:

rg "postcss" --type json

Repository: Gradata/gradata

Length of output: 150


🏁 Script executed:

find . -type f \( -name "postcss.config.*" -o -name "tailwind.config.*" \) | grep -v node_modules

Repository: Gradata/gradata

Length of output: 95


🏁 Script executed:

fd -t f "postcss.config" -o "tailwind.config"

Repository: Gradata/gradata

Length of output: 208


🏁 Script executed:

cat -n cloud/dashboard/postcss.config.mjs

Repository: Gradata/gradata

Length of output: 199


🏁 Script executed:

find cloud/dashboard -type f -name "*tailwind*"

Repository: Gradata/gradata

Length of output: 41


Teach Stylelint about Tailwind's @theme at-rule.

The @theme at-rule in globals.css (line 3) will trigger scss/at-rule-no-unknown from the inherited Stylelint config until explicitly ignored. Add the ignoreAtRules option to allow Tailwind v4's custom at-rules.

Suggested config change
// .stylelintrc.json
{
  "extends": [
    "stylelint-config-standard-scss"
  ],
  "rules": {
+    "scss/at-rule-no-unknown": [true, { "ignoreAtRules": ["theme"] }],
    "selector-id-pattern": null,
🧰 Tools
🪛 Stylelint (17.6.0)

[error] 3-3: Unexpected unknown at-rule "@theme" (scss/at-rule-no-unknown)

(scss/at-rule-no-unknown)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cloud/dashboard/app/globals.css` around lines 3 - 23, The SCSS linter is
flagging Tailwind's custom at-rule `@theme`; update your Stylelint configuration
to teach the scss/at-rule-no-unknown rule to ignore Tailwind at-rules by adding
an ignoreAtRules array that includes "theme" (and common Tailwind at-rules such
as "tailwind", "apply", "screen", "variants", "responsive", "layer" as needed).
Modify the scss/at-rule-no-unknown rule entry in your Stylelint config (e.g.,
.stylelintrc.js or package.json stylelint key) to include ignoreAtRules with
"theme" so the `@theme` declaration in globals.css no longer triggers the error.


const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'https://gradata-production.up.railway.app/api/v1',
baseURL: process.env.NEXT_PUBLIC_API_URL || 'https://gradata-production.up.railway.app/api/v1',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Remove implicit production fallback for required API URL.

On Line 5, defaulting to the production Railway URL can silently send non-prod traffic/data to prod when NEXT_PUBLIC_API_URL is misconfigured. Since this env is required, fail fast instead of auto-fallback.

Proposed fix
-const api = axios.create({
-  baseURL: process.env.NEXT_PUBLIC_API_URL || 'https://gradata-production.up.railway.app/api/v1',
-})
+const apiBaseUrl = process.env.NEXT_PUBLIC_API_URL
+if (!apiBaseUrl) {
+  throw new Error('Missing required env: NEXT_PUBLIC_API_URL')
+}
+
+const api = axios.create({
+  baseURL: apiBaseUrl,
+})
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cloud/dashboard/src/lib/api.ts` at line 5, The code currently sets baseURL:
process.env.NEXT_PUBLIC_API_URL ||
'https://gradata-production.up.railway.app/api/v1', which silently falls back to
production; remove the implicit fallback and instead fail fast when
NEXT_PUBLIC_API_URL is missing by checking process.env.NEXT_PUBLIC_API_URL
during initialization (the baseURL assignment used to configure the API client)
and throwing or logging a clear error if it's undefined so the app does not
accidentally send traffic to prod.

Comment on lines +38 to +45
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules",
"out",
".next"
]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

Next.js 16 tsconfig.json recommended exclude settings

💡 Result:

The recommended "exclude" settings in tsconfig.json for Next.js 16 are ["node_modules"]. This is the default that Next.js automatically adds or expects when running next dev or next build if not present, as TypeScript excludes it by default but Next.js explicitly enforces it. In practice, users often extend this for testing files or generated directories, e.g.: "exclude": [ "node_modules", "src//.spec.ts", "src//.test.ts", ".next/types//*.ts" ] No specific changes for Next.js 16 were found; it follows the standard Next.js TypeScript template from create-next-app, which uses TypeScript 5+ and modern defaults like target ES2017 (no es5).

Citations:


Conflicting include/exclude for .next types directory.

Lines 38-39 include .next/types/**/*.ts and .next/dev/types/**/*.ts, but line 44 excludes the entire .next directory. TypeScript's exclude takes precedence over include, preventing the generated Next.js route types from being included. This defeats the purpose of explicitly including .next/types and may cause missing type definitions for route parameters.

The Next.js 16 standard recommends excluding only node_modules, not .next. Remove the .next exclusion:

Proposed fix
   "exclude": [
     "node_modules",
-    "out",
-    ".next"
+    "out"
   ]
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules",
"out",
".next"
]
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules",
"out"
]
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cloud/dashboard/tsconfig.json` around lines 38 - 45, The tsconfig currently
includes ".next/types/**/*.ts" and ".next/dev/types/**/*.ts" but also excludes
the entire ".next" directory, which prevents those included paths from being
used; remove ".next" from the "exclude" array so the explicit includes take
effect (update the tsconfig.json exclude list to only contain "node_modules" and
"out"), ensuring Next.js generated route/type files are available to the
TypeScript compiler.

Comment thread cloud/SENTRY-SETUP.md
Comment on lines +3 to +4
Sentry is wired into the backend (FastAPI on Railway) and the dashboard (Vite+React on Cloudflare Pages). Both are **disabled by default** — they stay no-op until you set the DSN env vars.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

The frontend Sentry runbook is still Vite-era.

This doc tells operators to set VITE_* vars, rebuild a Vite app, and update vite.config.ts / src/main.tsx, but this PR migrated the dashboard to Next.js and renamed the public env vars to NEXT_PUBLIC_*. Following these steps will leave client-side Sentry disabled on Cloudflare Pages.

Suggested fix
-Sentry is wired into the backend (FastAPI on Railway) and the dashboard (Vite+React on Cloudflare Pages).
+Sentry is wired into the backend (FastAPI on Railway) and the dashboard (Next.js on Cloudflare Pages).
-VITE_SENTRY_DSN=https://...@o0.ingest.sentry.io/<project-id>
-VITE_SENTRY_ENVIRONMENT=production           # optional, defaults to MODE
-VITE_SENTRY_RELEASE=gradata-dashboard@<ver>  # optional
+NEXT_PUBLIC_SENTRY_DSN=https://...@o0.ingest.sentry.io/<project-id>
+NEXT_PUBLIC_SENTRY_ENVIRONMENT=production
+NEXT_PUBLIC_SENTRY_RELEASE=gradata-dashboard@<ver>
-- `cloud/dashboard/package.json` — added `@sentry/react`, `@sentry/vite-plugin`
-- `cloud/dashboard/src/lib/sentry.ts` — init + PII scrubber
-- `cloud/dashboard/src/main.tsx` — calls `initSentry()` before `createRoot`
-- `cloud/dashboard/vite.config.ts` — conditional source-map upload plugin
+- `cloud/dashboard/package.json` — added `@sentry/nextjs`
+- `cloud/dashboard/sentry.client.config.ts` — browser init
+- `cloud/dashboard/sentry.server.config.ts` — server init placeholder for static export
+- `cloud/dashboard/sentry.edge.config.ts` — edge init placeholder for static export
+- `cloud/dashboard/instrumentation.ts` — Next.js instrumentation entrypoint

Also applies to: 34-50, 91-100

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cloud/SENTRY-SETUP.md` around lines 3 - 4, The frontend Sentry runbook still
references Vite-era setup (VITE_* env vars, vite.config.ts, src/main.tsx) but
the dashboard now uses Next.js with public vars renamed to NEXT_PUBLIC_*; update
the doc to instruct operators to set NEXT_PUBLIC_SENTRY_DSN (and any
NEXT_PUBLIC_SENTRY_ENV, NEXT_PUBLIC_SENTRY_RELEASE), redeploy the Next.js
dashboard on Cloudflare Pages, and replace instructions about modifying
vite.config.ts/src/main.tsx with guidance to initialize Sentry in the Next.js
entrypoint (e.g., app/layout.tsx or pages/_app.tsx) and any
next.config.js/runtime config changes so client-side Sentry is actually enabled.
Ensure the same corrections are applied to the other affected sections (lines
~34-50 and ~91-100).

Comment thread cloud/SENTRY-SETUP.md
Comment on lines +20 to +24
```
GRADATA_SENTRY_DSN=https://...@o0.ingest.sentry.io/<project-id>
GRADATA_SENTRY_TRACES_SAMPLE_RATE=0.1 # optional, defaults to 0.1
GRADATA_ENVIRONMENT=production # already set
```

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add language tags to the fenced blocks.

markdownlint is already warning on these fences, so this file will keep failing doc lint until they are marked as bash, text, etc.

Also applies to: 28-30, 38-47, 53-55

🧰 Tools
🪛 markdownlint-cli2 (0.22.0)

[warning] 20-20: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cloud/SENTRY-SETUP.md` around lines 20 - 24, The fenced code blocks
containing environment variable examples (e.g., the block with
GRADATA_SENTRY_DSN, GRADATA_SENTRY_TRACES_SAMPLE_RATE, GRADATA_ENVIRONMENT) need
explicit language tags to satisfy markdownlint; update each triple-backtick
fence (including the other blocks at ranges referenced) to use an appropriate
tag such as ```bash or ```env so the blocks are recognized as shell/environment
snippets and the linter warnings are resolved.

Comment on lines +35 to +45
def test_init_sentry_enabled_when_dsn_set(caplog):
"""Valid DSN must init Sentry and log success (visible misconfig)."""
# Use an obviously-fake DSN that Sentry SDK accepts syntactically
fake_dsn = "https://publickey@o0.ingest.sentry.io/0"
settings = _make_settings(sentry_dsn=fake_dsn, environment="test")
with caplog.at_level(logging.INFO, logger="app.sentry_init"):
init_sentry(settings)
# Client should exist after init (use 2.x API, not deprecated Hub)
client = sentry_sdk.get_client()
assert client is not None and client.is_active(), "Sentry SDK client not active"
assert any("initialized" in rec.message.lower() for rec in caplog.records)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Find the test file and examine its contents
find . -name "test_sentry.py" -type f | head -5

Repository: Gradata/gradata

Length of output: 87


🏁 Script executed:

# Look for conftest or fixtures related to sentry
find . -name "conftest.py" -o -name "*sentry*" | grep -E "(conftest|sentry)" | head -20

Repository: Gradata/gradata

Length of output: 284


🏁 Script executed:

# Search for the init_sentry function
rg "def init_sentry" --type py -A 15

Repository: Gradata/gradata

Length of output: 1233


🏁 Script executed:

cat -n ./cloud/tests/test_sentry.py

Repository: Gradata/gradata

Length of output: 5508


🏁 Script executed:

cat -n ./cloud/tests/conftest.py | head -100

Repository: Gradata/gradata

Length of output: 3256


🏁 Script executed:

cat -n ./cloud/app/sentry_init.py

Repository: Gradata/gradata

Length of output: 4938


🏁 Script executed:

rg "sentry" ./cloud/tests/conftest.py -i

Repository: Gradata/gradata

Length of output: 41


🏁 Script executed:

rg "def.*sentry" ./tests/conftest.py -i

Repository: Gradata/gradata

Length of output: 41


🏁 Script executed:

# Check if there are any fixtures that might reset sentry state
rg "@pytest.fixture" ./cloud/tests/conftest.py -A 5

Repository: Gradata/gradata

Length of output: 721


🏁 Script executed:

# Search for any sentry_sdk cleanup/close calls in tests
rg "sentry_sdk\." ./cloud/tests/test_sentry.py

Repository: Gradata/gradata

Length of output: 95


🏁 Script executed:

# Check Sentry SDK documentation behavior on multiple init calls
rg "close|reset|shutdown" ./cloud/tests/ -i

Repository: Gradata/gradata

Length of output: 167


🏁 Script executed:

# Check if sentry_sdk.init is called during app creation
rg "create_app|init_sentry" ./cloud/app/main.py -B 2 -A 8

Repository: Gradata/gradata

Length of output: 617


🏁 Script executed:

# Look for any test dependencies or test ordering issues
rg "test_init_sentry|test_app_creates" ./cloud/tests/test_sentry.py -B 1 -A 10

Repository: Gradata/gradata

Length of output: 1513


🏁 Script executed:

# Search for any pytest markers or test configuration that might affect test isolation
find ./cloud/tests -name "pytest.ini" -o -name "pyproject.toml" | xargs grep -l "pytest" 2>/dev/null

Repository: Gradata/gradata

Length of output: 43


This test initializes a global Sentry SDK client that is never cleaned up between test runs.

init_sentry() calls the real sentry_sdk.init(), which sets up a process-wide client. The test asserts this client is active, but never resets it. This pollutes subsequent tests—a test designed to check the disabled path will still find an active client if this test ran first, making the suite dependent on test execution order.

Consider mocking sentry_sdk.init() instead of initializing the actual SDK in the test, so the test verifies your code passes the correct parameters to Sentry without persisting global state.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cloud/tests/test_sentry.py` around lines 35 - 45, The test
test_init_sentry_enabled_when_dsn_set calls init_sentry which invokes the real
sentry_sdk.init and leaves a global client active, polluting other tests; update
this test to mock sentry_sdk.init (or patch it) so init_sentry is exercised
without creating a real global client, assert the mock was called with the
expected DSN and environment, and remove any assertions that rely on
sentry_sdk.get_client() being active; reference the test function name
test_init_sentry_enabled_when_dsn_set, the init_sentry function under test, and
the sentry_sdk.init call to locate where to apply the mock/patch.

Fills every deferred item from S105 plan. No more fixtures-only pages —
everything wires to a backend endpoint with graceful fallback to demo
data when the brain has no real events yet.

Backend (cloud/app/routes/):
- meta_rules.py: GET /brains/{id}/meta-rules — synthesized principles
  from meta_rules table, newest first, no raw correction content
- activity.py: GET /brains/{id}/activity — learning events feed filtered
  to visible kinds (graduation, self-healing, recurrence, meta-rule-
  emerged, convergence, alert)
- rule_patches.py: GET /brains/{id}/rule-patches — self-healing audit
  trail + POST /brains/{id}/rule-patches/{id}/rollback creates inverse
  patch and restores lesson description (append-only audit)
- 10 new tests (test_new_routes.py), 58/58 backend tests green

Dashboard pages (cloud/dashboard/app/(dashboard)/):
- settings: Stripe tier pricing cards ($29 Cloud, $99 Team, Enterprise)
  with upgrade CTAs routing to /billing/checkout
- api-keys: V3-themed port with dialog flow for key generation + display
- billing: Current plan + Stripe customer portal link + period end
- brain?id=...: Brain detail (query-param, not [id] — simpler under
  static export) reusing KpiStrip + GraduationProgressBar + RulesPanel
- corrections: severity-filtered correction list, newest first
- rules: state-filtered lesson list (INSTINCT/PATTERN/RULE), confidence-
  sorted
- meta-rules: standalone MetaRulesGrid page
- observability: A/B proof + convergence signal + methodology link
  (differentiator page)
- privacy: PrivacyPosturePanel + explicit "what reaches cloud" list
- self-healing: PatchKind diffs with rollback button wired to API
- operator: god-mode for gradata.ai/sprites.ai domains — MRR/ARR/churn/
  NRR KPIs, customer list with health + plan + MRR, alerts panel
- setup: SDK install wizard (pip install → API key → init snippet →
  verify), progress bar tracks completion
- notifications: alert toggles (correction spike, rule regression, meta
  emerged) + digest cadence (weekly default per SIM16) + Slack webhook
- team: KPI strip + leaderboard ("whose AI learned fastest" by Δ %) +
  members list with invite dialog. All gated behind PlanGate(requires=team)
- team/members: invite flow + role management (owner/admin/member)

New components + fixtures:
- PlanBadge + PlanGate: tier badge + "upgrade to unlock" blur overlay
  for Cloud/Team/Enterprise features
- mock-team.ts, mock-operator.ts, mock-self-healing.ts: fixtures for
  surfaces that fall back when backend returns empty
- Sidebar now includes Observability, Privacy, Setup, Operator sections

Data flow:
- MetaRulesGrid + ActivityFeed + SelfHealing now fetch real data and
  gracefully fall back to fixtures when brain has no events yet
  (avoids empty states during cold start / pre-launch demo)

Build: 21 static routes, 0 errors.

Co-Authored-By: Gradata <noreply@gradata.ai>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Apr 13, 2026

Copy link
Copy Markdown

Deploying gradata-dashboard with  Cloudflare Pages  Cloudflare Pages

Latest commit: d908a9d
Status: ✅  Deploy successful!
Preview URL: https://a920b58b.gradata-dashboard.pages.dev
Branch Preview URL: https://worktree-dashboard-nextjs-re.gradata-dashboard.pages.dev

View logs

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gradata has reached the 50-review limit for trial accounts. To continue receiving code reviews, upgrade your plan.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 24

♻️ Duplicate comments (3)
cloud/dashboard/src/components/brain/ActivityFeed.tsx (1)

70-75: ⚠️ Potential issue | 🟡 Minor

Guard ago() against invalid timestamps.

Date parsing can return NaN, which leads to invalid relative-time text. Add a parse guard and safe fallback.

Proposed fix
 const ago = (iso: string): string => {
-  const diffMs = Date.now() - new Date(iso).getTime()
+  const ts = Date.parse(iso)
+  if (!Number.isFinite(ts)) return 'just now'
+  const diffMs = Math.max(0, Date.now() - ts)
   const h = Math.floor(diffMs / 3600_000)
   if (h < 1) return 'just now'
   if (h < 24) return `${h}h ago`
   return `${Math.floor(h / 24)}d ago`
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cloud/dashboard/src/components/brain/ActivityFeed.tsx` around lines 70 - 75,
The ago(iso: string) helper can produce NaN-based results when new
Date(iso).getTime() fails; add a parse guard in the ago function to detect
invalid dates (use Number.isFinite or isNaN on the timestamp) and return a safe
fallback like 'unknown' or 'just now' when the timestamp is invalid, otherwise
continue computing h and the existing relative strings; update the ago function
signature and body (the ago function) accordingly so it handles null/invalid iso
inputs safely.
cloud/dashboard/src/components/layout/DashboardLayout.tsx (1)

49-83: ⚠️ Potential issue | 🟠 Major

Restore a mobile collapse/drawer path for the sidebar.

The layout still hard-reserves 240px for navigation on every viewport and always renders the sidebar, so small screens lose too much content width with no way to dismiss nav. This remains a release blocker for phone/narrow-tablet usability.

Also applies to: 109-114

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cloud/dashboard/src/components/layout/DashboardLayout.tsx` around lines 49 -
83, The sidebar currently always reserves 240px via the aside element (class
includes w-[240px] min-w-[240px]) and is always rendered, which breaks
small-screen layouts; change the layout to hide the aside on small viewports and
provide a toggleable drawer: add a boolean state (e.g., isSidebarOpen) in the
DashboardLayout component, replace the static aside rendering with responsive
classes (e.g., hidden on sm/md breakpoints and visible on larger screens) and
render the aside as a slide-over panel when isSidebarOpen is true; add a header
toggle button that flips isSidebarOpen, render an overlay/backdrop that closes
the drawer on click/Escape, and reuse the same SECTIONS map/Link items inside
the drawer so behavior (active detection via pathname and cn usage) remains
identical.
cloud/dashboard/src/components/brain/MetaRulesGrid.tsx (1)

98-122: 🧹 Nitpick | 🔵 Trivial

Move Card to module scope to avoid per-render component recreation.

Line 98 defines Card inside MetaRulesGrid, which recreates the component function on every parent render. Extracting it to top-level (optionally memoized) keeps identity stable.

♻️ Proposed refactor
+function Card({ m }: { m: DisplayMetaRule }) {
+  return (
+    <li className="rounded-[0.5rem] border border-[var(--color-border)] bg-white/[0.02] p-4 transition-all hover:border-[var(--color-border-hover)]">
+      <div className="mb-2 flex items-start justify-between gap-3">
+        <div className="text-[13px] leading-snug">{m.title}</div>
+        <span className={`shrink-0 rounded-[0.25rem] px-2 py-0.5 text-[9px] font-semibold uppercase tracking-wider ${TIER_STYLE[m.tier]}`}>
+          {m.tier}
+        </span>
+      </div>
+      <div className="mb-2 text-[11px] font-mono text-[var(--color-body)]">
+        {m.source_description}
+      </div>
+      {m.source_categories.length > 0 && (
+        <div className="flex flex-wrap gap-1.5">
+          {m.source_categories.map((cat) => (
+            <span
+              key={cat}
+              className="rounded-[0.25rem] bg-[rgba(58,130,255,0.08)] px-2 py-0.5 text-[10px] font-medium text-[var(--color-accent-blue)]"
+            >
+              {cat}
+            </span>
+          ))}
+        </div>
+      )}
+    </li>
+  )
+}
+
 export function MetaRulesGrid() {
@@
-  const Card = ({ m }: { m: DisplayMetaRule }) => (
-    ...
-  )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cloud/dashboard/src/components/brain/MetaRulesGrid.tsx` around lines 98 -
122, The Card component is defined inside MetaRulesGrid causing it to be
recreated on every render; move Card to module scope as a top-level React
component (export or file-local) that accepts props like ({ m }: { m:
DisplayMetaRule }) and uses TIER_STYLE, then import/consume it from
MetaRulesGrid (optionally wrap with React.memo for stable identity). Ensure you
preserve the JSX structure and classNames, keep the same prop type
DisplayMetaRule, and update any references inside MetaRulesGrid to pass the m
prop into the relocated Card.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@cloud/dashboard/app/`(dashboard)/api-keys/page.tsx:
- Around line 34-39: The create flow sets newKey on success but never closes the
create dialog, so showCreate remains true and the create dialog reopens after
the one‑time key is dismissed; update the success path in the POST handler (the
block that calls api.post and then setNewKey, setKeyName, refetch) to also call
setShowCreate(false) (and mirror the same fix in the similar create flow around
the 126-156 block) so the create dialog is explicitly closed when a key is
successfully created.

In `@cloud/dashboard/app/`(dashboard)/billing/page.tsx:
- Around line 21-29: The page currently falls back to a fake "free" plan when
API responses fail; instead, detect API errors from the useApi hook (e.g., the
error fields returned alongside data from useApi for profile and subscription)
and render the existing ErrorState component used in settings rather than
computing currentPlan when either request errored. Specifically, update the
logic around useApi/profile/sub (the variables useApi, profile, sub,
loadingProfile, loadingSub, currentPlan, planMeta) to: if either API reports an
error (profileError || subError) return <ErrorState ...> (matching the pattern
in cloud/dashboard/app/(dashboard)/settings/page.tsx) and only compute
currentPlan/planMeta when no error and loading is false.

In `@cloud/dashboard/app/`(dashboard)/brain/page.tsx:
- Around line 29-52: The page currently treats failed/errored secondary fetches
as empty data; update the useApi result handling so analytics, correctionsResp
and lessonsResp are only considered settled when their respective loading is
false and error is null — i.e., keep their original loading/error flags from
useApi and do not coerce undefined into empty arrays inside the
corrections/lessons useMemo; change the kpis and gradCounts computations
(computeKpis and computeGraduationCounts) to run only when the corresponding
data is present and error-free, and surface or forward the loading/error states
to the KPI/graduation widgets rather than rendering zero-state values.

In `@cloud/dashboard/app/`(dashboard)/corrections/page.tsx:
- Around line 20-34: The code hides API failures by treating missing data as
empty state; update the two useApi calls (the one that returns { data: brains,
loading: loadingBrains } and the one that returns { data: resp, loading }) to
also read their error values (e.g., { error: brainsError } and { error:
respError }) and render an error UI when present instead of the EmptyState/empty
corrections: if brainsError show an ErrorState (or similar) with the brainsError
message instead of "No brain yet", and if respError show an ErrorState with
respError text instead of rendering an empty corrections list, preserving
LoadingSpinner while loading and keeping existing primaryId/corrections logic
otherwise.
- Around line 31-45: The displayed "newest first" promise is wrong because
filtered is never sorted; update the logic that builds filtered (based on
corrections and filter) to produce a new array sorted by created_at descending
(most recent first) — e.g., take corrections (or corrections.filter(...)), copy
it (slice or spread) and sort by comparing Date.parse(a.created_at) vs
Date.parse(b.created_at) or numeric timestamps so that the variable filtered
reflects newest-first order; ensure you do not mutate the original corrections
array and keep the existing filter condition using filter and the severities
list.

In `@cloud/dashboard/app/`(dashboard)/meta-rules/page.tsx:
- Line 1: Remove the top-level 'use client' directive from the page component so
the page stays a Server Component; specifically edit page.tsx and delete the
"'use client'" line, keep the page exporting the component that renders
<MetaRulesGrid /> (MetaRulesGrid should remain a Client Component) and ensure
you do not move or remove any client-only hooks or APIs from MetaRulesGrid
itself.

In `@cloud/dashboard/app/`(dashboard)/notifications/page.tsx:
- Around line 39-44: Add a "saving" state and prevent concurrent saves in
handleSave by returning early if saving is true, set saving true at start and
false when complete; store the timeout id returned by setTimeout (used to clear
the saved indicator) in a ref or module-scope variable and call clearTimeout
before setting a new timeout; also clear that timeout in a useEffect cleanup to
avoid state updates after unmount; update the Save button to be disabled when
the saving state is true and show a saving label. Refer to handleSave, setSaved,
(new) setSaving/saving, and the setTimeout call that clears the saved state for
locating where to apply changes.

In `@cloud/dashboard/app/`(dashboard)/observability/page.tsx:
- Around line 71-82: In ConvergenceRow, clamp and sanitize the incoming cosine
before using it for display and the progress width: ensure cosine is numeric and
finite, clamp it to the 0..1 range (e.g., replace NaN/Infinity with 0 or nearest
bound), store the sanitized value in a local variable (e.g., clampedCosine) and
use that for both the toFixed(2) output and the style width calculation so you
never render invalid percentages or NaN.
- Around line 38-40: The paragraph element rendering "TODO(backend): wire to
real cross-user cosine similarity when endpoint ships." is internal debug text
visible to users; update the user-facing copy in the paragraph (the <p
className="mt-6 font-mono text-[11px] text-[var(--color-body)]"> element in
page.tsx) to a neutral user-facing message or remove it entirely—for example
replace with "Cross-user cosine similarity coming soon" or hide it behind a
dev-only flag so internal TODOs are not rendered to end users.

In `@cloud/dashboard/app/`(dashboard)/operator/page.tsx:
- Around line 11-15: The operator dataset is being imported at module scope
(OPERATOR_EMAIL_DOMAINS and the mock operator data) inside the client component
page.tsx which bakes sensitive data into the bundle; instead, remove the mock
dataset and OPERATOR_EMAIL_DOMAINS from the client module, implement a
server-side protected API endpoint (e.g., /api/admin/operators) that validates
the authenticated user/role and returns operator data, and update the client
component to fetch operator data from that endpoint at runtime (or convert the
page to a server component and call the server API directly); ensure all
references to OPERATOR_EMAIL_DOMAINS and the in-module mock operators are
deleted so the data is no longer bundled to the client.

In `@cloud/dashboard/app/`(dashboard)/privacy/page.tsx:
- Around line 12-23: The component shows "0 KB" while analytics is still loading
because only brains loading (loadingBrains) is gated; update the analytics
useApi call to also return its loading flag (e.g., const { data: analytics,
loading: loadingAnalytics } = useApi<BrainAnalytics>(primaryId ?
`/brains/${primaryId}/analytics` : null)) and then include loadingAnalytics in
the render gate (e.g., if (loadingBrains || loadingAnalytics) return
<LoadingSpinner ... />) or otherwise avoid computing/printing footprintKb until
analytics is defined; adjust footprintKb (useMemo) to only run when analytics
exists and ensure the UI renders a loader or placeholder until analytics
resolves.
- Around line 79-86: The nested ternary assignments for dot and prefix based on
tone are hard to maintain; replace them with a typed lookup map (e.g., a const
TONE_META: Record<ToneType, { dot: string; prefix: string }>) keyed by the
existing tone variable, then derive dot and prefix from TONE_META[tone] with a
safe fallback for unknown tones; update references where dot and prefix are
defined (the dot and prefix constants) so the logic uses the map and remove the
nested ternaries.

In `@cloud/dashboard/app/`(dashboard)/rules/page.tsx:
- Around line 18-33: The page currently treats failed API calls as empty data
because it only checks loading and the derived primaryId/lessons; update the
handling to surface API errors from useApi instead: read the error fields
returned by useApi for both the brains call (const { data: brains, loading:
loadingBrains, error: brainsError } = useApi<Brain[]>('/brains')) and the
lessons call (const { data: resp, loading, error: lessonsError } =
useApi<...>(primaryId ? `/brains/${primaryId}/lessons` : null)), and if either
brainsError or lessonsError is truthy render the shared ErrorState component
with a helpful message instead of falling through to EmptyState; keep the
existing LoadingSpinner logic (loadingBrains || loading) and only compute
primaryId/lessons after checking brainsError so that backend failures are shown
as ErrorState rather than "No brain yet" or "No rules."
- Around line 24-30: The current computed variable filtered sorts the lessons
array in place, mutating the original lessons reference derived from resp; to
fix, ensure you sort a copy instead of mutating lessons by creating a new array
before sorting (e.g., use lessons.slice() or spread [...lessons] when filter ===
'all', or always build a new array from the filter result) so that the original
lessons/resp data is not mutated; update the logic that computes filtered
(referencing lessons, filtered, and resp) to operate on a copied array prior to
calling .sort().

In `@cloud/dashboard/app/`(dashboard)/self-healing/page.tsx:
- Around line 93-107: The handleRollback function currently treats api.post as
always succeeding; wrap the network call in a try/catch so failures are handled
explicitly: call
api.post(`/brains/${primaryId}/rule-patches/${patchId}/rollback`) inside try, on
success update setRolledBack(patchId) and call refetch(), but in catch set a
failure indicator (e.g., call a toast/notification helper or setRollbackError
state keyed by patchId) so the user sees the rollback failed and do NOT mark the
patch as rolled back; keep the finally block to clear
setRollbackInFlight(patchId). Reference symbols: handleRollback, api.post,
setRolledBack, refetch, setRollbackInFlight and add/use a toast or
setRollbackError state for visible failure feedback.

In `@cloud/dashboard/app/`(dashboard)/settings/page.tsx:
- Around line 70-116: The plan grid is hardcoded to (['free','cloud','team'] as
const) so the enterprise tier from PLANS/PlanTier is never rendered; change the
iteration to derive tiers from PLANS (e.g. Object.keys(PLANS) as PlanTier[] or a
sorted array that includes 'enterprise') so the enterprise card is rendered and
the isCurrent/canUpgrade logic (using currentPlan, PLANS, upgrading,
handleUpgrade) works correctly; ensure the new iteration preserves the existing
key={tier} usage and button behavior.
- Line 26: currentPlan is cast directly from profile.plan to PlanTier and can be
an unexpected string; instead normalize and validate the plan against the PLANS
keys before using it: create a safePlan (e.g., let safePlan = typeof
profile.plan === 'string' ? profile.plan.toLowerCase() : 'free'), check that
PLANS hasOwnProperty(safePlan) (or use Object.keys(PLANS).includes(safePlan))
and if not fallback to 'free', then cast safePlan to PlanTier for subsequent
use; update references to currentPlan and the access of PLANS[currentPlan].rank
(and any other PLANS[currentPlan] usages) to use the validated safePlan so you
never index PLANS with an invalid key.

In `@cloud/dashboard/app/`(dashboard)/setup/page.tsx:
- Around line 22-54: The current onboarding treats fetch failures as empty
state; update the use of useApi for both '/api-keys' and '/brains' to surface
and handle fetch errors explicitly by extracting the error (e.g., keysError,
brainsError) alongside data and loading, then short-circuit normal wizard
rendering when an error exists (render an error message/UI and avoid showing
onboarding steps or assume no keys/brains). Specifically modify the
destructuring around useApi in this file (symbols: useApi, keys, brains,
loadingKeys, loadingBrains, hasKey, hasBrain) to check for error values and
return a clear error component or message when keysError or brainsError is
truthy, and ensure any side effects (like hasKey/hasBrain logic) only run when
there is no error.
- Around line 28-46: The snippet mistakenly interpolates the persisted prefix
(firstKeyPrefix) into the runnable api_key example, which is misleading since
the prefix is not the secret; update the initSnippet generation so
Brain.init(api_key=...) never contains a real-looking key by replacing
interpolation of firstKeyPrefix with a non-authentic placeholder (e.g.,
"gd_YOUR_API_KEY_HERE" or a rendered "gd_{PREFIX}…" string that clearly includes
an ellipsis) and ensure the variables firstKeyPrefix, initSnippet and the
Brain.init call reflect this change so the snippet cannot be mistaken for a
usable secret.

In `@cloud/dashboard/app/`(dashboard)/team/members/page.tsx:
- Around line 47-57: The header "Invite member" Button and the invite modal
(state setter setInviteOpen and any InviteModal/InviteDialog component) must be
gated the same way as the member list: check currentPlan for the "team"
entitlement and either wrap the Button and the modal render in the existing
PlanGate (featureName="Team member management") or disable the Button and avoid
rendering/opening the modal when currentPlan lacks "team"; specifically update
the Button onClick usage (setInviteOpen(true)) and the modal render (the
component that reads inviteOpen) so they are only reachable when PlanGate
permits (or short-circuit with a disabled Button and no modal mount when
currentPlan doesn't include the "team" feature).
- Around line 79-86: The Remove button (rendered where m.role !== 'owner' in
page.tsx) currently has no onClick or disabled state and thus wrongly advertises
a destructive action; either remove the JSX entirely until the backend exists or
render the Button disabled and add explanatory copy/tooltip (e.g., "Coming soon"
or "Not available") and set the disabled prop so it’s inert; if you prefer a
future-enabled stub, keep the element but add a noop onClick that opens a
modal/tooltip explaining the feature is unavailable and ensure className and
aria-disabled reflect the disabled state.

In `@cloud/dashboard/src/components/brain/ActivityFeed.tsx`:
- Around line 85-100: The mapped feed items in the useMemo for items (using
real, normalizeKind, KIND_TITLE, extractDetail and created_at) must be sorted by
created_at (newest first) before applying slice(0, 8); modify the pipeline so
after mapping and filtering you sort by numeric timestamp (e.g., compare new
Date(a.created_at).getTime()) descending and then truncate to the first 8
entries — apply the same sorting+truncation change to the other similar feed
calculation later in the file.
- Around line 86-111: The mapping currently returns mockActivity only when real
is falsy or empty, but if real contains entries whose types all map to null via
normalizeKind the mapped result becomes empty while showingDemo remains false;
update the logic in the memo that builds the display list (the block using
real.map, normalizeKind, KIND_TITLE, extractDetail and filtering to
DisplayActivity) to capture the mapped-and-filtered array into a const (e.g.,
mapped = real.map(...).filter(...)) and if mapped.length === 0 fall back to
returning mockActivity; also ensure showingDemo uses the same condition (i.e.,
showingDemo = !real || mapped.length === 0) so the UI correctly treats “all
events filtered out” as demo.

In `@cloud/dashboard/src/components/layout/DashboardLayout.tsx`:
- Around line 66-78: The nav links currently rely only on visual styling; update
the Link element (the one rendering item.href/item.label in DashboardLayout) to
set aria-current="page" when active (e.g., aria-current={active ? 'page' :
undefined}) so assistive tech can expose the active nav state, and mark the icon
span (the <span> wrapping item.icon) as decorative to screen readers by adding
aria-hidden="true" (and remove it from tab order if needed) so Unicode glyphs
aren’t announced.

---

Duplicate comments:
In `@cloud/dashboard/src/components/brain/ActivityFeed.tsx`:
- Around line 70-75: The ago(iso: string) helper can produce NaN-based results
when new Date(iso).getTime() fails; add a parse guard in the ago function to
detect invalid dates (use Number.isFinite or isNaN on the timestamp) and return
a safe fallback like 'unknown' or 'just now' when the timestamp is invalid,
otherwise continue computing h and the existing relative strings; update the ago
function signature and body (the ago function) accordingly so it handles
null/invalid iso inputs safely.

In `@cloud/dashboard/src/components/brain/MetaRulesGrid.tsx`:
- Around line 98-122: The Card component is defined inside MetaRulesGrid causing
it to be recreated on every render; move Card to module scope as a top-level
React component (export or file-local) that accepts props like ({ m }: { m:
DisplayMetaRule }) and uses TIER_STYLE, then import/consume it from
MetaRulesGrid (optionally wrap with React.memo for stable identity). Ensure you
preserve the JSX structure and classNames, keep the same prop type
DisplayMetaRule, and update any references inside MetaRulesGrid to pass the m
prop into the relocated Card.

In `@cloud/dashboard/src/components/layout/DashboardLayout.tsx`:
- Around line 49-83: The sidebar currently always reserves 240px via the aside
element (class includes w-[240px] min-w-[240px]) and is always rendered, which
breaks small-screen layouts; change the layout to hide the aside on small
viewports and provide a toggleable drawer: add a boolean state (e.g.,
isSidebarOpen) in the DashboardLayout component, replace the static aside
rendering with responsive classes (e.g., hidden on sm/md breakpoints and visible
on larger screens) and render the aside as a slide-over panel when isSidebarOpen
is true; add a header toggle button that flips isSidebarOpen, render an
overlay/backdrop that closes the drawer on click/Escape, and reuse the same
SECTIONS map/Link items inside the drawer so behavior (active detection via
pathname and cn usage) remains identical.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 5b03cf8d-436d-488e-b31f-a98673af5f6a

📥 Commits

Reviewing files that changed from the base of the PR and between 3d31f8e and 71251f1.

📒 Files selected for processing (22)
  • cloud/dashboard/app/(dashboard)/api-keys/page.tsx
  • cloud/dashboard/app/(dashboard)/billing/page.tsx
  • cloud/dashboard/app/(dashboard)/brain/page.tsx
  • cloud/dashboard/app/(dashboard)/corrections/page.tsx
  • cloud/dashboard/app/(dashboard)/meta-rules/page.tsx
  • cloud/dashboard/app/(dashboard)/notifications/page.tsx
  • cloud/dashboard/app/(dashboard)/observability/page.tsx
  • cloud/dashboard/app/(dashboard)/operator/page.tsx
  • cloud/dashboard/app/(dashboard)/privacy/page.tsx
  • cloud/dashboard/app/(dashboard)/rules/page.tsx
  • cloud/dashboard/app/(dashboard)/self-healing/page.tsx
  • cloud/dashboard/app/(dashboard)/settings/page.tsx
  • cloud/dashboard/app/(dashboard)/setup/page.tsx
  • cloud/dashboard/app/(dashboard)/team/members/page.tsx
  • cloud/dashboard/app/(dashboard)/team/page.tsx
  • cloud/dashboard/src/components/brain/ActivityFeed.tsx
  • cloud/dashboard/src/components/brain/MetaRulesGrid.tsx
  • cloud/dashboard/src/components/brain/PlanBadge.tsx
  • cloud/dashboard/src/components/layout/DashboardLayout.tsx
  • cloud/dashboard/src/lib/fixtures/mock-operator.ts
  • cloud/dashboard/src/lib/fixtures/mock-self-healing.ts
  • cloud/dashboard/src/lib/fixtures/mock-team.ts
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Cloudflare Pages
🔇 Additional comments (4)
cloud/dashboard/app/(dashboard)/observability/page.tsx (1)

12-67: Clean composition and reusable page structure

Nice decomposition with ABProofPanel, GlassCard, and MethodologyLink; this keeps the page readable and easy to evolve.

cloud/dashboard/app/(dashboard)/notifications/page.tsx (2)

46-132: LGTM!

The JSX structure is well-organized with clear separation of notification preference sections. The use of GlassCard containers and consistent styling aligns with the V3 theme.


135-152: LGTM!

ToggleRow is appropriately defined at module scope, avoiding unnecessary re-creation. The label-wrapped checkbox pattern ensures good accessibility, and the hover state provides clear visual feedback.

cloud/dashboard/app/(dashboard)/privacy/page.tsx (1)

37-71: Good clarity in user-facing privacy contract content.

The categories are explicit, and the “never sent” items are clearly distinguished from metadata/counters.

Comment on lines 34 to 39
const res = await api.post<ApiKeyCreateResponse>('/api-keys', {
name: keyName || 'Default',
})
setNewKey(res.data)
setKeyName('')
refetch()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Reset showCreate on success or the create dialog reopens after dismissing the key.

After a successful POST, newKey is populated but showCreate stays true. That closes the first dialog only because of !newKey; once the user dismisses the one-time key dialog, newKey becomes null and the create dialog opens again immediately.

🐛 Suggested fix
     try {
       const res = await api.post<ApiKeyCreateResponse>('/api-keys', {
         name: keyName || 'Default',
       })
       setNewKey(res.data)
+      setShowCreate(false)
       setKeyName('')
       refetch()

Also applies to: 126-156

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cloud/dashboard/app/`(dashboard)/api-keys/page.tsx around lines 34 - 39, The
create flow sets newKey on success but never closes the create dialog, so
showCreate remains true and the create dialog reopens after the one‑time key is
dismissed; update the success path in the POST handler (the block that calls
api.post and then setNewKey, setKeyName, refetch) to also call
setShowCreate(false) (and mirror the same fix in the similar create flow around
the 126-156 block) so the create dialog is explicitly closed when a key is
successfully created.

Comment on lines +21 to +29
const { data: profile, loading: loadingProfile } = useApi<UserProfile>('/users/me')
const { data: sub, loading: loadingSub } = useApi<SubscriptionResponse>('/billing/subscription')
const [loadingPortal, setLoadingPortal] = useState(false)
const [portalError, setPortalError] = useState<string | null>(null)

if (loadingProfile || loadingSub) return <LoadingSpinner className="py-20" />

const currentPlan = (sub?.plan ?? (profile?.plan as PlanTier) ?? 'free') as PlanTier
const planMeta = PLANS[currentPlan] ?? PLANS.free

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Do not default failed billing/profile fetches into a fake “free plan” view.

If either request errors, currentPlan falls back and the page renders billing controls against incomplete data. That is especially risky here because it can misstate plan status and send users into the wrong Stripe flow. This page should surface ErrorState, like cloud/dashboard/app/(dashboard)/settings/page.tsx Lines 18-20.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cloud/dashboard/app/`(dashboard)/billing/page.tsx around lines 21 - 29, The
page currently falls back to a fake "free" plan when API responses fail;
instead, detect API errors from the useApi hook (e.g., the error fields returned
alongside data from useApi for profile and subscription) and render the existing
ErrorState component used in settings rather than computing currentPlan when
either request errored. Specifically, update the logic around useApi/profile/sub
(the variables useApi, profile, sub, loadingProfile, loadingSub, currentPlan,
planMeta) to: if either API reports an error (profileError || subError) return
<ErrorState ...> (matching the pattern in
cloud/dashboard/app/(dashboard)/settings/page.tsx) and only compute
currentPlan/planMeta when no error and loading is false.

Comment on lines +29 to +52
const { data: brain, loading, error, refetch } = useApi<Brain>(id ? `/brains/${id}` : null)
const { data: analytics } = useApi<BrainAnalytics>(id ? `/brains/${id}/analytics` : null)
const { data: correctionsResp } = useApi<PaginatedResponse<Correction> | Correction[]>(
id ? `/brains/${id}/corrections` : null,
)
const { data: lessonsResp } = useApi<PaginatedResponse<Lesson> | Lesson[]>(
id ? `/brains/${id}/lessons` : null,
)

const corrections = useMemo<Correction[]>(() => {
if (!correctionsResp) return []
return Array.isArray(correctionsResp) ? correctionsResp : correctionsResp.data
}, [correctionsResp])

const lessons = useMemo<Lesson[]>(() => {
if (!lessonsResp) return []
return Array.isArray(lessonsResp) ? lessonsResp : lessonsResp.data
}, [lessonsResp])

const kpis = useMemo(
() => (analytics ? computeKpis(analytics, corrections, lessons) : null),
[analytics, corrections, lessons],
)
const gradCounts = useMemo(() => computeGraduationCounts(lessons), [lessons])

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't treat failed secondary fetches as real zero-state data.

analytics, correctionsResp, and lessonsResp drive the KPIs and graduation panels, but their loading/error states are ignored. If /brains/:id succeeds while one of those requests fails, this page renders missing KPIs or zero-count widgets as if the brain actually had no data. Surface a partial-error state, or keep the dependent widgets in loading/error until those requests settle successfully.

Possible direction
-  const { data: analytics } = useApi<BrainAnalytics>(id ? `/brains/${id}/analytics` : null)
-  const { data: correctionsResp } = useApi<PaginatedResponse<Correction> | Correction[]>(
+  const { data: analytics, loading: loadingAnalytics, error: analyticsError } =
+    useApi<BrainAnalytics>(id ? `/brains/${id}/analytics` : null)
+  const {
+    data: correctionsResp,
+    loading: loadingCorrections,
+    error: correctionsError,
+  } = useApi<PaginatedResponse<Correction> | Correction[]>(
     id ? `/brains/${id}/corrections` : null,
   )
-  const { data: lessonsResp } = useApi<PaginatedResponse<Lesson> | Lesson[]>(
+  const {
+    data: lessonsResp,
+    loading: loadingLessons,
+    error: lessonsError,
+  } = useApi<PaginatedResponse<Lesson> | Lesson[]>(
     id ? `/brains/${id}/lessons` : null,
   )
...
-  if (loading) return <LoadingSpinner className="py-20" />
-  if (error) return <ErrorState message={error} onRetry={refetch} />
+  if (loading || loadingAnalytics || loadingCorrections || loadingLessons) {
+    return <LoadingSpinner className="py-20" />
+  }
+  if (error || analyticsError || correctionsError || lessonsError) {
+    return <ErrorState message={error || analyticsError || correctionsError || lessonsError} onRetry={refetch} />
+  }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cloud/dashboard/app/`(dashboard)/brain/page.tsx around lines 29 - 52, The
page currently treats failed/errored secondary fetches as empty data; update the
useApi result handling so analytics, correctionsResp and lessonsResp are only
considered settled when their respective loading is false and error is null —
i.e., keep their original loading/error flags from useApi and do not coerce
undefined into empty arrays inside the corrections/lessons useMemo; change the
kpis and gradCounts computations (computeKpis and computeGraduationCounts) to
run only when the corresponding data is present and error-free, and surface or
forward the loading/error states to the KPI/graduation widgets rather than
rendering zero-state values.

Comment on lines +20 to +34
const { data: brains, loading: loadingBrains } = useApi<Brain[]>('/brains')
const primaryId = brains?.[0]?.id ?? null
const { data: resp, loading } = useApi<PaginatedResponse<Correction> | Correction[]>(
primaryId ? `/brains/${primaryId}/corrections` : null,
)

const corrections = useMemo<Correction[]>(() => {
if (!resp) return []
return Array.isArray(resp) ? resp : resp.data
}, [resp])

const filtered = filter === 'all' ? corrections : corrections.filter((c) => c.severity === filter)

if (loadingBrains || loading) return <LoadingSpinner className="py-20" />
if (!primaryId) return <EmptyState title="No brain yet" description="Install the SDK to start logging corrections." />

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

This page also collapses fetch failures into empty/setup states.

A failed /brains request renders “No brain yet”, and a failed /corrections request renders “No corrections”. Please surface the useApi error path here as well, consistent with cloud/dashboard/app/(dashboard)/api-keys/page.tsx Lines 64-65 and cloud/dashboard/app/(dashboard)/settings/page.tsx Lines 18-20.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cloud/dashboard/app/`(dashboard)/corrections/page.tsx around lines 20 - 34,
The code hides API failures by treating missing data as empty state; update the
two useApi calls (the one that returns { data: brains, loading: loadingBrains }
and the one that returns { data: resp, loading }) to also read their error
values (e.g., { error: brainsError } and { error: respError }) and render an
error UI when present instead of the EmptyState/empty corrections: if
brainsError show an ErrorState (or similar) with the brainsError message instead
of "No brain yet", and if respError show an ErrorState with respError text
instead of rendering an empty corrections list, preserving LoadingSpinner while
loading and keeping existing primaryId/corrections logic otherwise.

Comment on lines +31 to +45
const filtered = filter === 'all' ? corrections : corrections.filter((c) => c.severity === filter)

if (loadingBrains || loading) return <LoadingSpinner className="py-20" />
if (!primaryId) return <EmptyState title="No brain yet" description="Install the SDK to start logging corrections." />

const severities: Array<'all' | Correction['severity']> = ['all', 'trivial', 'minor', 'moderate', 'major', 'rewrite']

return (
<>
<header className="mb-7 flex items-baseline justify-between">
<div>
<h1 className="text-[22px]">Corrections</h1>
<p className="mt-1 text-[13px] text-[var(--color-body)]">
Every correction, newest first · <span className="font-mono">{filtered.length}</span> shown
</p>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

“Newest first” is not actually enforced.

The subtitle promises newest-first ordering, but filtered is never sorted by created_at. Right now the list order depends on whatever the backend happens to return.

🕒 Suggested fix
-  const filtered = filter === 'all' ? corrections : corrections.filter((c) => c.severity === filter)
+  const filtered = [...(filter === 'all' ? corrections : corrections.filter((c) => c.severity === filter))]
+    .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const filtered = filter === 'all' ? corrections : corrections.filter((c) => c.severity === filter)
if (loadingBrains || loading) return <LoadingSpinner className="py-20" />
if (!primaryId) return <EmptyState title="No brain yet" description="Install the SDK to start logging corrections." />
const severities: Array<'all' | Correction['severity']> = ['all', 'trivial', 'minor', 'moderate', 'major', 'rewrite']
return (
<>
<header className="mb-7 flex items-baseline justify-between">
<div>
<h1 className="text-[22px]">Corrections</h1>
<p className="mt-1 text-[13px] text-[var(--color-body)]">
Every correction, newest first · <span className="font-mono">{filtered.length}</span> shown
</p>
const filtered = [...(filter === 'all' ? corrections : corrections.filter((c) => c.severity === filter))]
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
if (loadingBrains || loading) return <LoadingSpinner className="py-20" />
if (!primaryId) return <EmptyState title="No brain yet" description="Install the SDK to start logging corrections." />
const severities: Array<'all' | Correction['severity']> = ['all', 'trivial', 'minor', 'moderate', 'major', 'rewrite']
return (
<>
<header className="mb-7 flex items-center justify-between">
<div>
<h1 className="text-[22px]">Corrections</h1>
<p className="mt-1 text-[13px] text-[var(--color-body)]">
Every correction, newest first · <span className="font-mono">{filtered.length}</span> shown
</p>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cloud/dashboard/app/`(dashboard)/corrections/page.tsx around lines 31 - 45,
The displayed "newest first" promise is wrong because filtered is never sorted;
update the logic that builds filtered (based on corrections and filter) to
produce a new array sorted by created_at descending (most recent first) — e.g.,
take corrections (or corrections.filter(...)), copy it (slice or spread) and
sort by comparing Date.parse(a.created_at) vs Date.parse(b.created_at) or
numeric timestamps so that the variable filtered reflects newest-first order;
ensure you do not mutate the original corrections array and keep the existing
filter condition using filter and the severities list.

Comment on lines +47 to +57
<header className="mb-7 flex items-baseline justify-between">
<div>
<h1 className="text-[22px]">Members</h1>
<p className="mt-1 text-[13px] text-[var(--color-body)]">
Invite teammates and manage roles
</p>
</div>
<Button onClick={() => setInviteOpen(true)}>Invite member</Button>
</header>

<PlanGate current={currentPlan} requires="team" featureName="Team member management">

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Gate the invite flow too.

The member list is plan-gated, but the header CTA and modal are always rendered. A user without the team entitlement can still open this dialog and complete the invite workflow, which breaks the feature boundary for team management. Wrap the button/dialog in the same gate or disable them when currentPlan is below team.

Also applies to: 94-134

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cloud/dashboard/app/`(dashboard)/team/members/page.tsx around lines 47 - 57,
The header "Invite member" Button and the invite modal (state setter
setInviteOpen and any InviteModal/InviteDialog component) must be gated the same
way as the member list: check currentPlan for the "team" entitlement and either
wrap the Button and the modal render in the existing PlanGate (featureName="Team
member management") or disable the Button and avoid rendering/opening the modal
when currentPlan lacks "team"; specifically update the Button onClick usage
(setInviteOpen(true)) and the modal render (the component that reads inviteOpen)
so they are only reachable when PlanGate permits (or short-circuit with a
disabled Button and no modal mount when currentPlan doesn't include the "team"
feature).

Comment on lines +79 to +86
{m.role !== 'owner' && (
<Button
variant="ghost"
size="sm"
className="text-[var(--color-destructive)] hover:text-[var(--color-destructive)]"
>
Remove
</Button>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Remove or disable the inert Remove action.

This button has no onClick and no disabled/coming-soon treatment, so it advertises a destructive action that currently does nothing. Hide it until the backend exists, or render it disabled with explanatory copy.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cloud/dashboard/app/`(dashboard)/team/members/page.tsx around lines 79 - 86,
The Remove button (rendered where m.role !== 'owner' in page.tsx) currently has
no onClick or disabled state and thus wrongly advertises a destructive action;
either remove the JSX entirely until the backend exists or render the Button
disabled and add explanatory copy/tooltip (e.g., "Coming soon" or "Not
available") and set the disabled prop so it’s inert; if you prefer a
future-enabled stub, keep the element but add a noop onClick that opens a
modal/tooltip explaining the feature is unavailable and ensure className and
aria-disabled reflect the disabled state.

Comment on lines +85 to +100
const items = useMemo<DisplayActivity[]>(() => {
if (real && real.length > 0) {
return real
.map((e) => {
const kind = normalizeKind(e.type)
if (!kind) return null
return {
id: e.id,
kind,
title: KIND_TITLE[kind],
detail: extractDetail(e),
created_at: e.created_at,
}
})
.filter((x): x is DisplayActivity => x !== null)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Sort activity by created_at before truncating.

The feed is described as chronological, but current rendering depends on API order. Without explicit sorting, slice(0, 8) can drop recent events.

Proposed fix
   const items = useMemo<DisplayActivity[]>(() => {
     if (real && real.length > 0) {
-      return real
+      return real
         .map((e) => {
           const kind = normalizeKind(e.type)
           if (!kind) return null
           return {
             id: e.id,
             kind,
             title: KIND_TITLE[kind],
             detail: extractDetail(e),
             created_at: e.created_at,
           }
         })
         .filter((x): x is DisplayActivity => x !== null)
+        .sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at))
     }

Also applies to: 122-122

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cloud/dashboard/src/components/brain/ActivityFeed.tsx` around lines 85 - 100,
The mapped feed items in the useMemo for items (using real, normalizeKind,
KIND_TITLE, extractDetail and created_at) must be sorted by created_at (newest
first) before applying slice(0, 8); modify the pipeline so after mapping and
filtering you sort by numeric timestamp (e.g., compare new
Date(a.created_at).getTime()) descending and then truncate to the first 8
entries — apply the same sorting+truncation change to the other similar feed
calculation later in the file.

Comment on lines +86 to +111
if (real && real.length > 0) {
return real
.map((e) => {
const kind = normalizeKind(e.type)
if (!kind) return null
return {
id: e.id,
kind,
title: KIND_TITLE[kind],
detail: extractDetail(e),
created_at: e.created_at,
}
})
.filter((x): x is DisplayActivity => x !== null)
}
// Fallback: demo data so the surface isn't empty
return mockActivity.map((a) => ({
id: a.id,
kind: a.kind,
title: a.title,
detail: a.detail,
created_at: a.created_at,
}))
}, [real])

const showingDemo = !real || real.length === 0

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Handle “all events filtered out” as a fallback case.

If real.length > 0 but all type values are unmapped, the UI renders an empty list and still shows the non-demo label. Treat empty mapped results as fallback.

Proposed fix
-  const items = useMemo<DisplayActivity[]>(() => {
+  const { items, showingDemo } = useMemo(() => {
     if (real && real.length > 0) {
-      return real
+      const mapped = real
         .map((e) => {
           const kind = normalizeKind(e.type)
           if (!kind) return null
           return {
             id: e.id,
             kind,
             title: KIND_TITLE[kind],
             detail: extractDetail(e),
             created_at: e.created_at,
           }
         })
         .filter((x): x is DisplayActivity => x !== null)
+      if (mapped.length > 0) return { items: mapped, showingDemo: false }
     }
-    // Fallback: demo data so the surface isn't empty
-    return mockActivity.map((a) => ({
+    return {
+      items: mockActivity.map((a) => ({
       id: a.id,
       kind: a.kind,
       title: a.title,
       detail: a.detail,
       created_at: a.created_at,
-    }))
+      })),
+      showingDemo: true,
+    }
   }, [real])
-
-  const showingDemo = !real || real.length === 0
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (real && real.length > 0) {
return real
.map((e) => {
const kind = normalizeKind(e.type)
if (!kind) return null
return {
id: e.id,
kind,
title: KIND_TITLE[kind],
detail: extractDetail(e),
created_at: e.created_at,
}
})
.filter((x): x is DisplayActivity => x !== null)
}
// Fallback: demo data so the surface isn't empty
return mockActivity.map((a) => ({
id: a.id,
kind: a.kind,
title: a.title,
detail: a.detail,
created_at: a.created_at,
}))
}, [real])
const showingDemo = !real || real.length === 0
if (real && real.length > 0) {
const mapped = real
.map((e) => {
const kind = normalizeKind(e.type)
if (!kind) return null
return {
id: e.id,
kind,
title: KIND_TITLE[kind],
detail: extractDetail(e),
created_at: e.created_at,
}
})
.filter((x): x is DisplayActivity => x !== null)
if (mapped.length > 0) return { items: mapped, showingDemo: false }
}
return {
items: mockActivity.map((a) => ({
id: a.id,
kind: a.kind,
title: a.title,
detail: a.detail,
created_at: a.created_at,
})),
showingDemo: true,
}
}, [real])
const { items, showingDemo } = useMemo<DisplayActivity[]>(() => {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cloud/dashboard/src/components/brain/ActivityFeed.tsx` around lines 86 - 111,
The mapping currently returns mockActivity only when real is falsy or empty, but
if real contains entries whose types all map to null via normalizeKind the
mapped result becomes empty while showingDemo remains false; update the logic in
the memo that builds the display list (the block using real.map, normalizeKind,
KIND_TITLE, extractDetail and filtering to DisplayActivity) to capture the
mapped-and-filtered array into a const (e.g., mapped =
real.map(...).filter(...)) and if mapped.length === 0 fall back to returning
mockActivity; also ensure showingDemo uses the same condition (i.e., showingDemo
= !real || mapped.length === 0) so the UI correctly treats “all events filtered
out” as demo.

Comment on lines +66 to +78
<Link
key={item.href}
href={item.href}
className={cn(
'flex items-center gap-2.5 rounded-[0.5rem] px-3 py-2 text-[13px] transition-all',
active
? 'border border-[rgba(58,130,255,0.15)] bg-[rgba(58,130,255,0.12)] text-[var(--color-text)]'
: 'text-[var(--color-body)] hover:bg-[rgba(58,130,255,0.08)] hover:text-[var(--color-text)]',
)}
>
<span className="w-4 text-center text-sm opacity-50">{item.icon}</span>
{item.label}
</Link>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Expose the active nav state and hide decorative glyphs from assistive tech.

These links rely on visual styling only. Add aria-current for the active item and mark the icon span as decorative so screen readers don’t announce the Unicode symbols.

♿ Suggested fix
               <Link
                 key={item.href}
                 href={item.href}
+                aria-current={active ? 'page' : undefined}
                 className={cn(
                   'flex items-center gap-2.5 rounded-[0.5rem] px-3 py-2 text-[13px] transition-all',
                   active
                     ? 'border border-[rgba(58,130,255,0.15)] bg-[rgba(58,130,255,0.12)] text-[var(--color-text)]'
                     : 'text-[var(--color-body)] hover:bg-[rgba(58,130,255,0.08)] hover:text-[var(--color-text)]',
                 )}
               >
-                <span className="w-4 text-center text-sm opacity-50">{item.icon}</span>
+                <span aria-hidden="true" className="w-4 text-center text-sm opacity-50">
+                  {item.icon}
+                </span>
                 {item.label}
               </Link>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cloud/dashboard/src/components/layout/DashboardLayout.tsx` around lines 66 -
78, The nav links currently rely only on visual styling; update the Link element
(the one rendering item.href/item.label in DashboardLayout) to set
aria-current="page" when active (e.g., aria-current={active ? 'page' :
undefined}) so assistive tech can expose the active nav state, and mark the icon
span (the <span> wrapping item.icon) as decorative to screen readers by adding
aria-hidden="true" (and remove it from tab order if needed) so Unicode glyphs
aren’t announced.

Oliver Le and others added 2 commits April 13, 2026 00:34
SUPABASE-SETUP.md — Google OAuth provider config, custom email
templates, RLS policies for meta_rules/rule_patches/events, auth URL
config, auto-workspace-creation trigger verification.

RAILWAY-ENV.md — complete env var list including new Stripe price ID
vars (GRADATA_STRIPE_PRICE_ID_CLOUD / _TEAM) that the Stripe SKU
wiring expects. Includes Stripe product + webhook registration steps.

Co-Authored-By: Gradata <noreply@gradata.ai>

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gradata has reached the 50-review limit for trial accounts. To continue receiving code reviews, upgrade your plan.

Oliver Le and others added 3 commits April 13, 2026 00:42
…t redirect

- public/robots.txt: block crawlers from the authed dashboard; marketing
  content lives at gradata.ai (separate project)
- app/page.tsx: show pulsing brand mark while the auth state resolves
  instead of a white screen on cold load

Co-Authored-By: Gradata <noreply@gradata.ai>
…nterprise)

Replaces stale `pro`/`team` checkout request with a typed `PlanTier` enum
(`free`/`cloud`/`team`/`enterprise`) matching S104 pricing ($29 Cloud / $99 Team).
Enterprise is rejected with a contact-sales 400; free is rejected as a non-checkout tier.

Stripe price IDs and credentials now live on the typed `Settings` object via
`GRADATA_STRIPE_PRICE_ID_CLOUD` / `GRADATA_STRIPE_PRICE_ID_TEAM` /
`GRADATA_STRIPE_SECRET_KEY` / `GRADATA_STRIPE_WEBHOOK_SECRET` rather than being
read inline at module import. When a price ID for a requested plan is unset,
checkout returns 503 "not configured yet".

Adds POST /billing/portal which mints a Stripe customer-portal session for the
caller's stripe_customer_id, returning 404 "no active subscription" when none
is on file.

Webhook handler now also processes `customer.subscription.created`, downgrades
to `free` on `customer.subscription.deleted`, marks `past_due` on
`invoice.payment_failed`, and resolves plan from metadata, price metadata, then
matched price IDs (canonical names instead of legacy nicknames).

Tests: 14 new in cloud/tests/test_billing.py — invalid plan rejection, 400 for
enterprise, 503 when price IDs unset, happy paths for cloud and team using the
correct configured price_id, portal 404 paths and success, and three webhook
event handlers including signature failure. Stripe SDK is fully mocked via
sys.modules; Supabase via the existing MockSupabaseClient. Total backend tests
go from 58 to 72 passing.

Co-Authored-By: Gradata <noreply@gradata.ai>
- public/_headers: HSTS, CSP (Supabase + api.gradata.ai + Sentry + Google Fonts),
  X-Frame-Options DENY, Permissions-Policy, immutable cache for /_next/static/*
- public/_redirects: legacy /brains/* and /app/* compat
- wrangler.toml: pages_build_output_dir=out, name=gradata-dashboard
- .env.example: documents required + optional vars (Supabase, API, Sentry)
- .nvmrc: pin Node 20 for Cloudflare build env
- CLOUDFLARE-PAGES.md: full project settings, custom domain, verify steps

Build verified: pnpm build emits out/_headers + out/_redirects.

Co-Authored-By: Gradata <noreply@gradata.ai>

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gradata has reached the 50-review limit for trial accounts. To continue receiving code reviews, upgrade your plan.

Covers KPI logic, graduation pipeline, categories folding, plan tier
gating, active rules sort/filter, activity feed normalization, and
meta-rule layer grouping. useApi mocked for widget tests.

Co-Authored-By: Gradata <noreply@gradata.ai>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@cloud/OPS.md`:
- Line 61: Replace the hardcoded string "All 58 tests should pass" with a
generic, future-proof phrase such as "All tests should pass" or "All tests
should complete" (retain the performance target "<20s" if desired); locate the
exact text "All 58 tests should pass in <20s" in OPS.md and update it to
something like "All tests should pass in <20s (no real Supabase/Stripe needed —
fully mocked)" so the doc doesn’t require updates when the test count changes.

In `@cloud/RAILWAY-ENV.md`:
- Around line 43-49: Add documentation for the two optional Stripe redirect env
vars by inserting a new "Optional — Stripe redirects" table after the "Optional
— App config" section that documents GRADATA_STRIPE_SUCCESS_URL (default
https://app.gradata.ai/billing?success=1) and GRADATA_STRIPE_CANCEL_URL (default
https://app.gradata.ai/billing?cancel=1) and their purposes (redirect after
successful checkout and redirect after cancelled checkout), ensuring the
variable names and defaults match the billing implementation so operators can
override them for staging or custom domains.

In `@cloud/SUPABASE-SETUP.md`:
- Line 110: Update the sentence that references the internal project name
'project_s104_handoff' in the documentation: either remove that internal
identifier entirely or replace it with a neutral description (e.g., "an internal
project" or "our onboarding project") and add a brief note explaining what that
project is/does (it installs a trigger that creates a workspace and brain row on
user signup) so future maintainers understand the context; locate the sentence
containing 'project_s104_handoff' and edit it to use the clarified phrasing or
remove the reference.
- Line 22: Update the "Authorized redirect URIs:
`https://<supabase-project>.supabase.co/auth/v1/callback`" line to clarify that
`<supabase-project>` is the Supabase project ref (Reference ID) and tell the
reader where to find it (Supabase → Settings → General → Reference ID), and give
a brief example phrasing such as "Replace `<supabase-project>` with your project
ref (e.g., your-project-ref.supabase.co)".
- Around line 69-85: The doc's RLS SQL examples are out-of-sync with the
migration: replace the old SELECT-only examples with the real policies used in
migrations by updating the SQL block to match the migration logic and names: use
meta_rules_via_brain, events_via_brain, and rule_patches_via_lesson, change FOR
SELECT to FOR ALL where appropriate, and include the workspace_members join
conditions for team access (brain_id and workspace_id checks) so the
documentation mirrors cloud/migrations/001_initial_schema.sql exactly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: bec587d1-1e79-447d-a42b-e01982c6bf70

📥 Commits

Reviewing files that changed from the base of the PR and between 71251f1 and fcd68e8.

📒 Files selected for processing (3)
  • cloud/OPS.md
  • cloud/RAILWAY-ENV.md
  • cloud/SUPABASE-SETUP.md
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Cloudflare Pages
🔇 Additional comments (3)
cloud/RAILWAY-ENV.md (2)

1-91: LGTM: Comprehensive environment variable documentation.

The structure is clear and actionable, with good separation between required/optional variables and helpful source attributions. The verification steps and Railway-specific guidance are valuable for operators.


20-21: The documentation in RAILWAY-ENV.md is correct and matches the actual code implementation. There is no variable name mismatch.

The code in cloud/app/config.py defines:

  • stripe_price_id_cloud (becomes GRADATA_STRIPE_PRICE_ID_CLOUD with the GRADATA_ prefix)
  • stripe_price_id_team (becomes GRADATA_STRIPE_PRICE_ID_TEAM)

The tests in cloud/tests/test_billing.py confirm these exact variable names. The environment variables documented in RAILWAY-ENV.md lines 20–21 and the example on lines 69–70 are correct and will function as expected.

There is no cloud/app/routes/billing.py file using GRADATA_STRIPE_PRICE_PRO in the codebase. The referenced "billing.py line 21" does not exist.

			> Likely an incorrect or invalid review comment.
cloud/OPS.md (1)

1-109: LGTM: Excellent operations playbook.

This document provides clear, actionable guidance for operators across the entire deployment lifecycle. The structure is logical (setup → day-to-day → emergencies), cross-references are appropriate, and the troubleshooting guidance aligns well with the actual error handling in the codebase (e.g., webhook signature verification, Sentry DSN-based initialization).

The mapping of services to URLs and the emergency contact information make this genuinely useful for on-call scenarios.

Comment thread cloud/OPS.md
cd cloud
python -m pytest tests/ -q
```
All 58 tests should pass in <20s (no real Supabase/Stripe needed — fully mocked).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Avoid hardcoding test count in documentation.

The phrase "All 58 tests should pass" will become outdated as tests are added or removed. Consider using a more generic statement.

♻️ Suggested wording
-All 58 tests should pass in <20s (no real Supabase/Stripe needed — fully mocked).
+All tests should pass in <20s (no real Supabase/Stripe needed — fully mocked).
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
All 58 tests should pass in <20s (no real Supabase/Stripe needed — fully mocked).
All tests should pass in <20s (no real Supabase/Stripe needed — fully mocked).
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cloud/OPS.md` at line 61, Replace the hardcoded string "All 58 tests should
pass" with a generic, future-proof phrase such as "All tests should pass" or
"All tests should complete" (retain the performance target "<20s" if desired);
locate the exact text "All 58 tests should pass in <20s" in OPS.md and update it
to something like "All tests should pass in <20s (no real Supabase/Stripe needed
— fully mocked)" so the doc doesn’t require updates when the test count changes.

Comment thread cloud/RAILWAY-ENV.md
Comment on lines +43 to +49
## Optional — App config

| Var | Default | Purpose |
|-----|---------|---------|
| `GRADATA_ENVIRONMENT` | `development` | Set to `production` on Railway |
| `GRADATA_LOG_LEVEL` | `INFO` | Use `DEBUG` only when actively diagnosing |
| `GRADATA_CORS_ORIGINS` | `http://localhost:3000,https://app.gradata.ai` | Comma-separated allow-list |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Document optional Stripe redirect URL overrides.

The billing code supports two additional optional Stripe environment variables that aren't documented:

  • GRADATA_STRIPE_SUCCESS_URL (default: https://app.gradata.ai/billing?success=1)
  • GRADATA_STRIPE_CANCEL_URL (default: https://app.gradata.ai/billing?cancel=1)

These control the redirect URLs after Stripe checkout completion/cancellation. While they have sensible defaults, operators may want to customize them (e.g., for staging environments).

📋 Proposed addition to Optional section

Add after the "Optional — App config" section:

## Optional — Stripe redirects

| Var | Default | Purpose |
|-----|---------|---------|
| `GRADATA_STRIPE_SUCCESS_URL` | `https://app.gradata.ai/billing?success=1` | Where to redirect after successful checkout |
| `GRADATA_STRIPE_CANCEL_URL` | `https://app.gradata.ai/billing?cancel=1` | Where to redirect if user cancels checkout |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cloud/RAILWAY-ENV.md` around lines 43 - 49, Add documentation for the two
optional Stripe redirect env vars by inserting a new "Optional — Stripe
redirects" table after the "Optional — App config" section that documents
GRADATA_STRIPE_SUCCESS_URL (default https://app.gradata.ai/billing?success=1)
and GRADATA_STRIPE_CANCEL_URL (default https://app.gradata.ai/billing?cancel=1)
and their purposes (redirect after successful checkout and redirect after
cancelled checkout), ensuring the variable names and defaults match the billing
implementation so operators can override them for staging or custom domains.

Comment thread cloud/SUPABASE-SETUP.md
- Type: Web application
- Name: `Gradata Supabase`
- Authorized JS origins: `https://app.gradata.ai`, `http://localhost:3000`
- Authorized redirect URIs: `https://<supabase-project>.supabase.co/auth/v1/callback`

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Clarify the Supabase project reference placeholder.

The redirect URI contains <supabase-project> as a placeholder. Consider adding guidance on where to find this value (e.g., "Replace <supabase-project> with your project ref from Supabase → Settings → General → Reference ID").

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cloud/SUPABASE-SETUP.md` at line 22, Update the "Authorized redirect URIs:
`https://<supabase-project>.supabase.co/auth/v1/callback`" line to clarify that
`<supabase-project>` is the Supabase project ref (Reference ID) and tell the
reader where to find it (Supabase → Settings → General → Reference ID), and give
a brief example phrasing such as "Replace `<supabase-project>` with your project
ref (e.g., your-project-ref.supabase.co)".

Comment thread cloud/SUPABASE-SETUP.md
Comment on lines +69 to +85
```sql
-- meta_rules: user can SELECT their own brain's meta-rules
CREATE POLICY "meta_rules_read_own" ON meta_rules FOR SELECT
USING (brain_id IN (SELECT id FROM brains WHERE user_id = auth.uid()));

-- rule_patches: user can SELECT patches for their own lessons
CREATE POLICY "rule_patches_read_own" ON rule_patches FOR SELECT
USING (lesson_id IN (
SELECT l.id FROM lessons l
JOIN brains b ON b.id = l.brain_id
WHERE b.user_id = auth.uid()
));

-- events: user can SELECT events for their own brain
CREATE POLICY "events_read_own" ON events FOR SELECT
USING (brain_id IN (SELECT id FROM brains WHERE user_id = auth.uid()));
```

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Extract RLS policy definitions from the migration file

rg -A 10 "CREATE POLICY.*meta_rules" cloud/migrations/001_initial_schema.sql
rg -A 10 "CREATE POLICY.*events" cloud/migrations/001_initial_schema.sql  
rg -A 10 "CREATE POLICY.*rule_patches" cloud/migrations/001_initial_schema.sql

Repository: Gradata/gradata

Length of output: 1165


Critical: RLS policy examples don't match the actual migration SQL.

The SQL examples in this section differ significantly from the actual policies in cloud/migrations/001_initial_schema.sql:

Policy names mismatch:

  • Documentation: "meta_rules_read_own", "events_read_own", "rule_patches_read_own"
  • Migration: meta_rules_via_brain, events_via_brain, rule_patches_via_lesson

Permission scope mismatch:

  • Documentation: FOR SELECT (read-only)
  • Migration: FOR ALL (read + write via brain/workspace ownership)

The migration policies also include workspace membership logic for team access that's completely missing from the documentation examples.

📝 Proposed fix: Update examples to match migration SQL
-```sql
--- meta_rules: user can SELECT their own brain's meta-rules
-CREATE POLICY "meta_rules_read_own" ON meta_rules FOR SELECT
-  USING (brain_id IN (SELECT id FROM brains WHERE user_id = auth.uid()));
-
--- rule_patches: user can SELECT patches for their own lessons
-CREATE POLICY "rule_patches_read_own" ON rule_patches FOR SELECT
-  USING (lesson_id IN (
-    SELECT l.id FROM lessons l
-    JOIN brains b ON b.id = l.brain_id
-    WHERE b.user_id = auth.uid()
-  ));
-
--- events: user can SELECT events for their own brain
-CREATE POLICY "events_read_own" ON events FOR SELECT
-  USING (brain_id IN (SELECT id FROM brains WHERE user_id = auth.uid()));
-```
+```sql
+-- meta_rules: user can read/write their own brain's meta-rules (and team members' via workspace)
+CREATE POLICY meta_rules_via_brain ON meta_rules
+    FOR ALL USING (
+        brain_id IN (SELECT id FROM brains WHERE user_id = auth.uid())
+        OR brain_id IN (
+            SELECT b.id FROM brains b
+            JOIN workspace_members wm ON wm.workspace_id = b.workspace_id
+            WHERE wm.user_id = auth.uid()
+        )
+    );
+
+-- events: user can read/write events for their own brain (and team members' via workspace)
+CREATE POLICY events_via_brain ON events
+    FOR ALL USING (
+        brain_id IN (SELECT id FROM brains WHERE user_id = auth.uid())
+        OR brain_id IN (
+            SELECT b.id FROM brains b
+            JOIN workspace_members wm ON wm.workspace_id = b.workspace_id
+            WHERE wm.user_id = auth.uid()
+        )
+    );
+
+-- rule_patches: user can read/write patches for their own lessons
+CREATE POLICY rule_patches_via_lesson ON rule_patches
+    FOR ALL USING (
+        lesson_id IN (
+            SELECT l.id FROM lessons l
+            JOIN brains b ON b.id = l.brain_id
+            WHERE b.user_id = auth.uid()
+        )
+    );
+```
</details>

<details>
<summary>🤖 Prompt for AI Agents</summary>

Verify each finding against the current code and only fix it if needed.

In @cloud/SUPABASE-SETUP.md around lines 69 - 85, The doc's RLS SQL examples are
out-of-sync with the migration: replace the old SELECT-only examples with the
real policies used in migrations by updating the SQL block to match the
migration logic and names: use meta_rules_via_brain, events_via_brain, and
rule_patches_via_lesson, change FOR SELECT to FOR ALL where appropriate, and
include the workspace_members join conditions for team access (brain_id and
workspace_id checks) so the documentation mirrors
cloud/migrations/001_initial_schema.sql exactly.


</details>

<!-- fingerprinting:phantom:triton:puma -->

<!-- This is an auto-generated comment by CodeRabbit -->

Comment thread cloud/SUPABASE-SETUP.md

## 6. Auto-create workspace on signup

The existing trigger (per `project_s104_handoff`) creates a workspace + brain row on user signup. Verify it's installed:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Remove or clarify the internal project reference.

The phrase per 'project_s104_handoff' appears to be an internal project reference. Consider either removing it or adding context about what it refers to for future maintainers.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cloud/SUPABASE-SETUP.md` at line 110, Update the sentence that references the
internal project name 'project_s104_handoff' in the documentation: either remove
that internal identifier entirely or replace it with a neutral description
(e.g., "an internal project" or "our onboarding project") and add a brief note
explaining what that project is/does (it installs a trigger that creates a
workspace and brain row on user signup) so future maintainers understand the
context; locate the sentence containing 'project_s104_handoff' and edit it to
use the clarified phrasing or remove the reference.

Sidebar collapses to hamburger drawer below md (768px). Wide row layouts
(team leaderboard, members, operator customers/alerts) now wrap on mobile
instead of cramming fixed-width columns. KPI value text scales down on
small screens to prevent wrapping at <400px. Methodology modal close
button is now 44x44 for tap-friendliness. Page headers wrap their title
+ action pair so filter pills don't get squeezed off-screen.

Pure additive className changes — no component restructuring, no behavior
changes, no backend touches. Build verified (21 routes), dev server
returns 200 on all touched pages.

Co-Authored-By: Gradata <noreply@gradata.ai>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 12

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@cloud/app/config.py`:
- Around line 28-37: The config exposes stripe_secret_key but the code calls
Stripe APIs without verifying it; update the _stripe() helper to check that
config.stripe_secret_key is non-empty and raise or return an HTTP
503/appropriate error when missing, and ensure callers (create_checkout and
portal routes) use this validation path instead of calling stripe.* directly so
they fail fast with a clear 503 message; reference the _stripe() function,
create_checkout route, and portal route when making the change.

In `@cloud/app/routes/billing.py`:
- Around line 107-113: The 404 error message is misleading when
db.select("workspaces", filters={"owner_id": user_id}) returns no rows; update
the handling in the route so it distinguishes between "no workspace/billing
account" and "no active subscription": if ws_rows is empty, raise HTTPException
with a message like "no billing account found" (or similar) referencing the
ws_rows result from db.select, otherwise keep the existing check of customer_id
= ws_rows[0].get("stripe_customer_id") and raise "no active subscription" when
customer_id is missing; adjust the HTTPException messages in this function
accordingly.
- Around line 98-102: The create_portal_session endpoint has an unused request:
Request parameter—remove the Request parameter from the function signature in
create_portal_session and any corresponding type imports so the function becomes
async def create_portal_session(user_id: str = Depends(get_current_user_id)) ->
PortalResponse; update any internal references (none expected) and remove the
unused Request import from the module to keep imports accurate.
- Around line 176-185: Replace the hardcoded "free" plan string with the enum
value to keep config consistent: in the stripe event handler for etype
"customer.subscription.deleted" (the block that calls db.update on
"workspaces"), change the data payload to use PlanTier.free.value instead of the
literal "free" and import or reference PlanTier as needed so PlanTier.free.value
is available where db.update is called.

In `@cloud/dashboard/.env.example`:
- Around line 12-13: Update the example env value for NEXT_PUBLIC_API_URL so it
matches the runtime fallback used by the API client in
cloud/dashboard/src/lib/api.ts (which appends /api/v1); change the example from
https://api.gradata.ai to include the same base path (e.g.,
https://api.gradata.ai/api/v1) so local/production examples and the
NEXT_PUBLIC_API_URL value used by the application are consistent with the
fallback path referenced in the Api client.

In `@cloud/dashboard/CLOUDFLARE-PAGES.md`:
- Around line 89-90: The documentation currently asserts a console message
"[sentry] initialized env=production release=..." which the client does not
emit; update CLOUDFLARE-PAGES.md to remove that incorrect "initialized"
expectation and either keep only the verifiable disabled-case message "[sentry]
disabled: ..." or replace the check with a verifiable alternative (for example,
instructions to verify event delivery in non-production or to look for a real
emitted log/event), referencing the exact strings "[sentry] initialized
env=production release=..." and "[sentry] disabled: ..." so reviewers can find
and update the sentence accordingly.
- Line 73: The documentation currently claims `'unsafe-eval'` is required for
Next.js hydration; update the CLOUDFLARE-PAGES.md text that mentions
`'unsafe-eval'` so it no longer asserts this for production—either remove the
`'unsafe-eval'` directive entirely from the production CSP example or explicitly
state that `'unsafe-eval'` is only a possible development-time addition and that
production Next.js hydration typically requires only `'unsafe-inline'` for
inline scripts; adjust the sentence referencing Next.js hydration to cite that
`'unsafe-eval'` is not required in production and should be applied
conditionally for development builds.

In `@cloud/dashboard/public/_headers`:
- Line 10: The Content-Security-Policy header's connect-src currently includes a
staging origin (https://gradata-production.up.railway.app) which should be
removed; edit the Content-Security-Policy line to delete that origin from
connect-src so only canonical production origins (e.g., https://api.gradata.ai
and other approved domains like https://*.supabase.co, wss://*.supabase.co,
https://*.ingest.sentry.io) remain, ensuring the CSP in the file's
Content-Security-Policy header no longer permits the staging backend.
- Line 10: Update the Content-Security-Policy entry in the _headers file by
removing 'unsafe-eval' from the script-src directive (the CSP line starting with
"Content-Security-Policy: default-src 'self'; script-src ..."); keep
'unsafe-inline' if required for styles/inline scripts but ensure the script-src
no longer contains 'unsafe-eval' for production builds (or conditionally include
it only in your development config).

In `@cloud/dashboard/public/robots.txt`:
- Around line 3-8: The robots.txt has two separate "User-agent: *" blocks which
violates the spec; merge them into a single "User-agent: *" group and place both
directives (Disallow: / and Allow: /favicon.ico) under that single header so
crawlers apply both rules correctly; update the file by removing the duplicate
"User-agent: *" line and moving the Allow directive into the same group that
contains the Disallow directive.

In `@cloud/tests/test_billing.py`:
- Around line 33-54: Add a test that verifies behavior when
GRADATA_STRIPE_SECRET_KEY is missing while price IDs are configured: create a
new fixture (e.g., stripe_missing_secret) that calls get_settings.cache_clear(),
sets GRADATA_STRIPE_PRICE_ID_CLOUD and GRADATA_STRIPE_PRICE_ID_TEAM to test
values, unsets GRADATA_STRIPE_SECRET_KEY (monkeypatch.delenv with
raising=False), sets GRADATA_STRIPE_WEBHOOK_SECRET, yields, then clears cache;
add a test function (e.g., test_checkout_missing_stripe_secret) that uses this
fixture and invokes the same checkout endpoint exercised by existing tests and
asserts the response is a 503 (and optionally contains the expected
service-unavailable/error message) instead of raising a Stripe auth error.
- Around line 245-257: Remove the redundant "if True:" blocks wrapping test
event setup and posting; locate the three occurrences inside the test methods
that build the "event" dict and call self._post_event (the shown block using
event = {...} and resp = self._post_event(client, fake_stripe, event)), as well
as the similar blocks in test_subscription_deleted_downgrades_to_free and
test_invoice_payment_failed_marks_past_due, and simply delete the "if True:"
line and unindent the inner lines so the event construction and resp =
self._post_event(...) run directly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 94ffd29b-de78-47ed-b3aa-87fcfbb6057e

📥 Commits

Reviewing files that changed from the base of the PR and between fcd68e8 and 74ada40.

📒 Files selected for processing (12)
  • cloud/app/config.py
  • cloud/app/models.py
  • cloud/app/routes/billing.py
  • cloud/dashboard/.env.example
  • cloud/dashboard/.nvmrc
  • cloud/dashboard/CLOUDFLARE-PAGES.md
  • cloud/dashboard/app/page.tsx
  • cloud/dashboard/public/_headers
  • cloud/dashboard/public/_redirects
  • cloud/dashboard/public/robots.txt
  • cloud/dashboard/wrangler.toml
  • cloud/tests/test_billing.py
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Cloudflare Pages
🔇 Additional comments (15)
cloud/dashboard/app/page.tsx (3)

1-6: Client-page setup is correctly wired.

'use client' and hook imports are appropriate for this auth-driven redirect page.


11-14: Redirect flow is correctly sequenced after auth hydration.

The loading guard before router.replace(...) is correct and prevents premature navigation.


16-23: Loading placeholder implementation looks good.

This avoids a blank initial paint and matches the intended branded auth-resolve state.

cloud/dashboard/.env.example (1)

1-11: Clean and well-structured env template.

Required vs optional separation is clear, and the Sentry runtime/build-time split matches actual usage in cloud/dashboard/sentry.client.config.ts and cloud/dashboard/next.config.ts.

Also applies to: 15-32

cloud/dashboard/CLOUDFLARE-PAGES.md (1)

91-91: Keep production verification non-invasive.

The instruction at Line 91 to trigger a React error on live production is the same concern raised earlier.

cloud/dashboard/.nvmrc (1)

1-1: Node version pin looks good.

.nvmrc aligns with the documented Cloudflare Pages Node 20 requirement.

cloud/dashboard/wrangler.toml (1)

1-3: Wrangler Pages config is coherent.

The project name, compatibility date, and output directory are consistent with the static-export deployment flow.

cloud/dashboard/public/_redirects (1)

6-9: Legacy redirect rules look correct.

The mappings are clear and align with the current /brain?id=... route model and /app/* prefix cleanup.

cloud/app/models.py (2)

186-197: LGTM!

The PlanTier enum is well-documented with clear tier semantics. The docstring accurately describes the business logic (free = no Stripe sub, cloud/team = paid, enterprise = sales-only), which aligns with the route-layer validation in billing.py.


200-211: LGTM!

Typing CheckoutRequest.plan as PlanTier enforces enum validation at the Pydantic layer, and PortalResponse correctly models the portal session URL response.

cloud/app/config.py (1)

23-26: LGTM!

Sentry settings default safely — empty DSN disables initialization gracefully (confirmed in sentry_init.py), and the trace sample rate default of 0.1 is reasonable for production.

cloud/tests/test_billing.py (2)

57-80: LGTM!

The fake_stripe fixture is well-designed — it properly stubs the lazy-imported module structure, mocks checkout.Session.create, billing_portal.Session.create, Webhook.construct_event, and provides a custom SignatureVerificationError exception class for testing the error path.


93-141: LGTM!

Comprehensive validation tests covering invalid enum values (422), enterprise/free rejection (400), and missing price ID configuration (503). The assertions correctly verify both status codes and error message content.

cloud/app/routes/billing.py (2)

27-34: LGTM on checkout validation flow.

The checkout endpoint properly validates enterprise (400, sales-only), free (400, no checkout needed), and missing price IDs (503). The _price_id_for helper cleanly maps PlanTier to configured Stripe price IDs.

Also applies to: 54-95


198-227: LGTM on plan extraction logic.

The _extract_plan function has a robust 4-level fallback: subscription metadata → price metadata → configured price ID matching → legacy nickname. This handles various Stripe configurations gracefully.

Comment thread cloud/app/config.py
Comment on lines +28 to +37
# Stripe (all optional — empty values mean "not configured")
# Env vars: GRADATA_STRIPE_SECRET_KEY, GRADATA_STRIPE_WEBHOOK_SECRET,
# GRADATA_STRIPE_PRICE_ID_CLOUD, GRADATA_STRIPE_PRICE_ID_TEAM
stripe_secret_key: str = ""
stripe_webhook_secret: str = ""
stripe_price_id_cloud: str = ""
stripe_price_id_team: str = ""
stripe_success_url: str = "https://app.gradata.ai/billing?success=1"
stripe_cancel_url: str = "https://app.gradata.ai/billing?cancel=1"
stripe_portal_return_url: str = "https://app.gradata.ai/billing"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if stripe_secret_key is validated before Stripe API calls
rg -n "stripe_secret_key" cloud/app/routes/billing.py

Repository: Gradata/gradata

Length of output: 105


🏁 Script executed:

#!/bin/bash
# Get context around line 39 where stripe_secret_key is accessed
sed -n '30,60p' cloud/app/routes/billing.py

Repository: Gradata/gradata

Length of output: 1052


🏁 Script executed:

#!/bin/bash
# Check the webhook validation pattern mentioned (lines 132-133)
sed -n '125,145p' cloud/app/routes/billing.py

Repository: Gradata/gradata

Length of output: 876


🏁 Script executed:

#!/bin/bash
# Find the _stripe() function definition and understand its structure
rg -n "def _stripe" cloud/app/routes/billing.py -A 15

Repository: Gradata/gradata

Length of output: 622


🏁 Script executed:

#!/bin/bash
# Look for stripe API calls in the checkout/portal routes
rg -n "stripe\." cloud/app/routes/billing.py | head -20

Repository: Gradata/gradata

Length of output: 308


🏁 Script executed:

#!/bin/bash
# Get the full create_checkout function to see if there's price_id validation
sed -n '54,95p' cloud/app/routes/billing.py

Repository: Gradata/gradata

Length of output: 1494


🏁 Script executed:

#!/bin/bash
# Get the full portal function to see if there's any validation
sed -n '100,125p' cloud/app/routes/billing.py

Repository: Gradata/gradata

Length of output: 936


🏁 Script executed:

#!/bin/bash
# Check if price_id is validated before the Stripe API call
rg -n "stripe_price_id" cloud/app/routes/billing.py -B 2 -A 2

Repository: Gradata/gradata

Length of output: 545


Missing validation for stripe_secret_key before Stripe API calls.

When stripe_secret_key is empty (the default) but a client attempts checkout or portal actions, the calls to stripe.checkout.Session.create() and stripe.billing_portal.Session.create() will proceed with an empty API key, resulting in cryptic authentication errors from Stripe rather than a clean 503 response.

The webhook endpoint properly validates stripe_webhook_secret before calling Stripe (lines 132-135), but similar validation is missing for stripe_secret_key in the _stripe() function. The create_checkout route validates the price_id and returns 503 if missing, but bypasses stripe_secret_key validation. The portal route has no such safety checks.

Add validation in _stripe() to fail fast:

Proposed fix in `cloud/app/routes/billing.py`
 def _stripe():
     """Lazy-import stripe so the module loads even when stripe is not installed."""
+    if not get_settings().stripe_secret_key:
+        raise HTTPException(status_code=503, detail="Stripe not configured")
     try:
         import stripe as _s

         _configure_stripe(_s)
         return _s
     except ImportError as exc:  # pragma: no cover
         raise HTTPException(status_code=503, detail="Stripe not configured") from exc
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cloud/app/config.py` around lines 28 - 37, The config exposes
stripe_secret_key but the code calls Stripe APIs without verifying it; update
the _stripe() helper to check that config.stripe_secret_key is non-empty and
raise or return an HTTP 503/appropriate error when missing, and ensure callers
(create_checkout and portal routes) use this validation path instead of calling
stripe.* directly so they fail fast with a clear 503 message; reference the
_stripe() function, create_checkout route, and portal route when making the
change.

Comment on lines +98 to +102
@router.post("/billing/portal", response_model=PortalResponse)
async def create_portal_session(
request: Request,
user_id: str = Depends(get_current_user_id),
) -> PortalResponse:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Unused request: Request parameter.

The request parameter is not used in create_portal_session. Consider removing it to clean up the function signature.

♻️ Proposed fix
 `@router.post`("/billing/portal", response_model=PortalResponse)
 async def create_portal_session(
-    request: Request,
     user_id: str = Depends(get_current_user_id),
 ) -> PortalResponse:
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cloud/app/routes/billing.py` around lines 98 - 102, The create_portal_session
endpoint has an unused request: Request parameter—remove the Request parameter
from the function signature in create_portal_session and any corresponding type
imports so the function becomes async def create_portal_session(user_id: str =
Depends(get_current_user_id)) -> PortalResponse; update any internal references
(none expected) and remove the unused Request import from the module to keep
imports accurate.

Comment on lines +107 to +113
ws_rows = await db.select("workspaces", filters={"owner_id": user_id})
if not ws_rows:
raise HTTPException(status_code=404, detail="no active subscription")

customer_id = ws_rows[0].get("stripe_customer_id")
if not customer_id:
raise HTTPException(status_code=404, detail="no active subscription")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Error message is misleading when user has no workspace.

The 404 response at line 109 says "no active subscription" when the user might simply have no workspace record at all. Consider differentiating these cases or using a more accurate message like "no billing account found."

✏️ Proposed fix
     ws_rows = await db.select("workspaces", filters={"owner_id": user_id})
     if not ws_rows:
-        raise HTTPException(status_code=404, detail="no active subscription")
+        raise HTTPException(status_code=404, detail="No billing account found")

     customer_id = ws_rows[0].get("stripe_customer_id")
     if not customer_id:
-        raise HTTPException(status_code=404, detail="no active subscription")
+        raise HTTPException(status_code=404, detail="No active subscription")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
ws_rows = await db.select("workspaces", filters={"owner_id": user_id})
if not ws_rows:
raise HTTPException(status_code=404, detail="no active subscription")
customer_id = ws_rows[0].get("stripe_customer_id")
if not customer_id:
raise HTTPException(status_code=404, detail="no active subscription")
ws_rows = await db.select("workspaces", filters={"owner_id": user_id})
if not ws_rows:
raise HTTPException(status_code=404, detail="No billing account found")
customer_id = ws_rows[0].get("stripe_customer_id")
if not customer_id:
raise HTTPException(status_code=404, detail="No active subscription")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cloud/app/routes/billing.py` around lines 107 - 113, The 404 error message is
misleading when db.select("workspaces", filters={"owner_id": user_id}) returns
no rows; update the handling in the route so it distinguishes between "no
workspace/billing account" and "no active subscription": if ws_rows is empty,
raise HTTPException with a message like "no billing account found" (or similar)
referencing the ws_rows result from db.select, otherwise keep the existing check
of customer_id = ws_rows[0].get("stripe_customer_id") and raise "no active
subscription" when customer_id is missing; adjust the HTTPException messages in
this function accordingly.

Comment on lines +176 to +185
elif etype == "customer.subscription.deleted":
customer_id = data.get("customer")
status = data.get("status", "canceled")
if customer_id:
await db.update(
"workspaces",
data={"plan": "free", "subscription_status": status},
filters={"stripe_customer_id": customer_id},
)
_log.info("Subscription deleted customer=%s -> free", customer_id)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Use PlanTier.free.value instead of hardcoded "free" string.

For consistency with the rest of the codebase that uses the PlanTier enum, consider using PlanTier.free.value here. This ensures the value stays in sync if the enum value ever changes.

♻️ Proposed fix
     elif etype == "customer.subscription.deleted":
         customer_id = data.get("customer")
         status = data.get("status", "canceled")
         if customer_id:
             await db.update(
                 "workspaces",
-                data={"plan": "free", "subscription_status": status},
+                data={"plan": PlanTier.free.value, "subscription_status": status},
                 filters={"stripe_customer_id": customer_id},
             )
             _log.info("Subscription deleted customer=%s -> free", customer_id)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cloud/app/routes/billing.py` around lines 176 - 185, Replace the hardcoded
"free" plan string with the enum value to keep config consistent: in the stripe
event handler for etype "customer.subscription.deleted" (the block that calls
db.update on "workspaces"), change the data payload to use PlanTier.free.value
instead of the literal "free" and import or reference PlanTier as needed so
PlanTier.free.value is available where db.update is called.

Comment on lines +12 to +13
# Backend API base URL (Railway: https://gradata-production.up.railway.app, prod: https://api.gradata.ai)
NEXT_PUBLIC_API_URL=https://api.gradata.ai

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Align API base URL example with runtime fallback path.

Line 13 sets NEXT_PUBLIC_API_URL=https://api.gradata.ai, but cloud/dashboard/src/lib/api.ts falls back to a base URL that includes /api/v1. This can create environment-specific path drift.

Suggested fix
-# Backend API base URL (Railway: https://gradata-production.up.railway.app, prod: https://api.gradata.ai)
-NEXT_PUBLIC_API_URL=https://api.gradata.ai
+# Backend API base URL (Railway: https://gradata-production.up.railway.app/api/v1, prod: https://api.gradata.ai/api/v1)
+NEXT_PUBLIC_API_URL=https://api.gradata.ai/api/v1
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# Backend API base URL (Railway: https://gradata-production.up.railway.app, prod: https://api.gradata.ai)
NEXT_PUBLIC_API_URL=https://api.gradata.ai
# Backend API base URL (Railway: https://gradata-production.up.railway.app/api/v1, prod: https://api.gradata.ai/api/v1)
NEXT_PUBLIC_API_URL=https://api.gradata.ai/api/v1
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cloud/dashboard/.env.example` around lines 12 - 13, Update the example env
value for NEXT_PUBLIC_API_URL so it matches the runtime fallback used by the API
client in cloud/dashboard/src/lib/api.ts (which appends /api/v1); change the
example from https://api.gradata.ai to include the same base path (e.g.,
https://api.gradata.ai/api/v1) so local/production examples and the
NEXT_PUBLIC_API_URL value used by the application are consistent with the
fallback path referenced in the Api client.

Comment on lines +89 to +90
4. Browser console shows `[sentry] initialized env=production release=...`
(or `[sentry] disabled: ...` if DSN not set — prod-safe)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Sentry verification text does not match runtime behavior.

At Line 89, the doc expects an initialization console message that the client config does not emit. Keep only the disabled-case log expectation or replace with a verifiable signal (e.g., test event delivery in non-prod).

Suggested edit
-4. Browser console shows `[sentry] initialized env=production release=...`
-   (or `[sentry] disabled: ...` if DSN not set — prod-safe)
+4. If `NEXT_PUBLIC_SENTRY_DSN` is unset, browser console shows
+   `[sentry] disabled: NEXT_PUBLIC_SENTRY_DSN not set`.
+   If DSN is set, verify via Sentry event ingestion instead of console text.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
4. Browser console shows `[sentry] initialized env=production release=...`
(or `[sentry] disabled: ...` if DSN not set — prod-safe)
4. If `NEXT_PUBLIC_SENTRY_DSN` is unset, browser console shows
`[sentry] disabled: NEXT_PUBLIC_SENTRY_DSN not set`.
If DSN is set, verify via Sentry event ingestion instead of console text.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cloud/dashboard/CLOUDFLARE-PAGES.md` around lines 89 - 90, The documentation
currently asserts a console message "[sentry] initialized env=production
release=..." which the client does not emit; update CLOUDFLARE-PAGES.md to
remove that incorrect "initialized" expectation and either keep only the
verifiable disabled-case message "[sentry] disabled: ..." or replace the check
with a verifiable alternative (for example, instructions to verify event
delivery in non-production or to look for a real emitted log/event), referencing
the exact strings "[sentry] initialized env=production release=..." and
"[sentry] disabled: ..." so reviewers can find and update the sentence
accordingly.

X-Frame-Options: DENY
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: interest-cohort=(), geolocation=(), camera=(), microphone=(), payment=(), usb=(), accelerometer=(), gyroscope=(), magnetometer=()
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; img-src 'self' data: blob: https:; connect-src 'self' https://*.supabase.co wss://*.supabase.co https://api.gradata.ai https://gradata-production.up.railway.app https://*.ingest.sentry.io https://ingest.sentry.io; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'; upgrade-insecure-requests

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Remove staging backend origin from production connect-src.

At Line 10, permitting https://gradata-production.up.railway.app in prod unnecessarily expands data exfiltration surface. Prefer only canonical production API origin(s).

Suggested edit
-  Content-Security-Policy: ... connect-src 'self' https://*.supabase.co wss://*.supabase.co https://api.gradata.ai https://gradata-production.up.railway.app https://*.ingest.sentry.io https://ingest.sentry.io; ...
+  Content-Security-Policy: ... connect-src 'self' https://*.supabase.co wss://*.supabase.co https://api.gradata.ai https://*.ingest.sentry.io https://ingest.sentry.io; ...
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; img-src 'self' data: blob: https:; connect-src 'self' https://*.supabase.co wss://*.supabase.co https://api.gradata.ai https://gradata-production.up.railway.app https://*.ingest.sentry.io https://ingest.sentry.io; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'; upgrade-insecure-requests
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; img-src 'self' data: blob: https:; connect-src 'self' https://*.supabase.co wss://*.supabase.co https://api.gradata.ai https://*.ingest.sentry.io https://ingest.sentry.io; frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'; upgrade-insecure-requests
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cloud/dashboard/public/_headers` at line 10, The Content-Security-Policy
header's connect-src currently includes a staging origin
(https://gradata-production.up.railway.app) which should be removed; edit the
Content-Security-Policy line to delete that origin from connect-src so only
canonical production origins (e.g., https://api.gradata.ai and other approved
domains like https://*.supabase.co, wss://*.supabase.co,
https://*.ingest.sentry.io) remain, ensuring the CSP in the file's
Content-Security-Policy header no longer permits the staging backend.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

For Next.js 16 App Router static export production builds, is script-src 'unsafe-eval' required at runtime, or can CSP omit it safely?

💡 Result:

No, script-src 'unsafe-eval' is not required at runtime for Next.js 16 App Router static export production builds. CSP can safely omit it. In production static exports (using output: 'export' in next.config.js), the build generates static HTML, CSS, and JS bundles in the out directory, served without a Node.js server. The client-side JS bundles (e.g., webpack chunks) execute in browsers and do not require eval for core functionality like hydration or routing. Inline scripts such as self.next_f.push(...) are present for router state initialization and client-side navigation but are plain JavaScript without eval. unsafe-eval is only needed in development for features like React Fast Refresh (source maps) or certain debugging tools, as noted in CSP examples distinguishing dev (${isDev ? "'unsafe-eval'" : ''}) from production. Strict CSP using 'self', nonces, hashes, or 'strict-dynamic' works for production static builds, though inline scripts require 'unsafe-inline', nonces, or hashes—not 'unsafe-eval'. Sources confirm static exports support App Router with prerendered Server Components to HTML/JS payloads, Client Components hydrated client-side without server dependencies, and no mentions of production eval needs.

Citations:


Remove unsafe-eval from production CSP—it is not required.

At Line 10, 'unsafe-eval' significantly weakens XSS protections without necessity. Next.js 16 static exports generate pre-built JS bundles that execute without eval; inline scripts use plain JavaScript without eval (e.g., self.next_f.push(...)). unsafe-eval is only needed in development for React Fast Refresh debugging. Remove it from the production CSP header.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cloud/dashboard/public/_headers` at line 10, Update the
Content-Security-Policy entry in the _headers file by removing 'unsafe-eval'
from the script-src directive (the CSP line starting with
"Content-Security-Policy: default-src 'self'; script-src ..."); keep
'unsafe-inline' if required for styles/inline scripts but ensure the script-src
no longer contains 'unsafe-eval' for production builds (or conditionally include
it only in your development config).

Comment on lines +3 to +8
User-agent: *
Disallow: /

# Allow health checks
User-agent: *
Allow: /favicon.ico

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

🧩 Analysis chain

🌐 Web query:

In robots.txt, when there are multiple "User-agent: *" groups, do major crawlers merge them or treat them separately? Is a single combined group recommended for compatibility?

💡 Result:

Major crawlers treat multiple "User-agent: " groups separately rather than merging them. They select only one matching group based on specificity and position according to their parsing rules, typically the first or most specific "' group for non-specific agents, ignoring subsequent "" groups. Google merges multiple groups only if they match the same specific user-agent (e.g., multiple "Googlebot" groups), but explicitly does not combine specific user-agent groups with "" groups. For "", multiple groups are treated as separate, with only one applied per the order of precedence. Bingbot honors only one set of directives: the specific "bingbot" group (if present), or "msnbot", or the first/default "" group, discarding others. The original spec and Yandex indicate only one "*" record is allowed; multiples are invalid, and crawlers pick the first matching one. A single combined "User-agent: *" group is recommended for compatibility across all major crawlers to avoid unpredictable behavior from multiple groups.

Citations:


Consolidate duplicate wildcard groups for spec compliance.

The robots.txt specification allows only one User-agent: * group. Using two separate blocks (lines 3 and 7) violates the spec and causes crawlers to apply only the first matching group, ignoring subsequent directives. Merge both Disallow and Allow directives under a single User-agent: * block.

Suggested edit
-User-agent: *
-Disallow: /
-
-# Allow health checks
-User-agent: *
-Allow: /favicon.ico
+User-agent: *
+Disallow: /
+Allow: /favicon.ico
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
User-agent: *
Disallow: /
# Allow health checks
User-agent: *
Allow: /favicon.ico
User-agent: *
Disallow: /
Allow: /favicon.ico
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cloud/dashboard/public/robots.txt` around lines 3 - 8, The robots.txt has two
separate "User-agent: *" blocks which violates the spec; merge them into a
single "User-agent: *" group and place both directives (Disallow: / and Allow:
/favicon.ico) under that single header so crawlers apply both rules correctly;
update the file by removing the duplicate "User-agent: *" line and moving the
Allow directive into the same group that contains the Disallow directive.

Comment on lines +33 to +54
@pytest.fixture
def configured_prices(monkeypatch):
"""Set Stripe price IDs in settings (use cache_clear so values stick)."""
get_settings.cache_clear()
monkeypatch.setenv("GRADATA_STRIPE_PRICE_ID_CLOUD", "price_cloud_test")
monkeypatch.setenv("GRADATA_STRIPE_PRICE_ID_TEAM", "price_team_test")
monkeypatch.setenv("GRADATA_STRIPE_SECRET_KEY", "sk_test_dummy")
monkeypatch.setenv("GRADATA_STRIPE_WEBHOOK_SECRET", "whsec_test_dummy")
yield
get_settings.cache_clear()


@pytest.fixture
def unconfigured_prices(monkeypatch):
"""Explicitly clear price IDs so checkout returns 503."""
get_settings.cache_clear()
monkeypatch.delenv("GRADATA_STRIPE_PRICE_ID_CLOUD", raising=False)
monkeypatch.delenv("GRADATA_STRIPE_PRICE_ID_TEAM", raising=False)
monkeypatch.setenv("GRADATA_STRIPE_SECRET_KEY", "sk_test_dummy")
monkeypatch.setenv("GRADATA_STRIPE_WEBHOOK_SECRET", "whsec_test_dummy")
yield
get_settings.cache_clear()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Consider adding a test for missing stripe_secret_key.

The configured_prices and unconfigured_prices fixtures test price ID presence, but there's no test verifying behavior when stripe_secret_key is empty while price IDs are configured. This scenario would currently result in a Stripe authentication error rather than a clean 503.

Would you like me to generate a test case for this scenario?

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cloud/tests/test_billing.py` around lines 33 - 54, Add a test that verifies
behavior when GRADATA_STRIPE_SECRET_KEY is missing while price IDs are
configured: create a new fixture (e.g., stripe_missing_secret) that calls
get_settings.cache_clear(), sets GRADATA_STRIPE_PRICE_ID_CLOUD and
GRADATA_STRIPE_PRICE_ID_TEAM to test values, unsets GRADATA_STRIPE_SECRET_KEY
(monkeypatch.delenv with raising=False), sets GRADATA_STRIPE_WEBHOOK_SECRET,
yields, then clears cache; add a test function (e.g.,
test_checkout_missing_stripe_secret) that uses this fixture and invokes the same
checkout endpoint exercised by existing tests and asserts the response is a 503
(and optionally contains the expected service-unavailable/error message) instead
of raising a Stripe auth error.

Comment on lines +245 to +257
if True:
event = {
"type": "customer.subscription.updated",
"data": {
"object": {
"customer": "cus_test_123",
"status": "active",
"current_period_end": 1700000000,
"metadata": {"plan": "team"},
}
},
}
resp = self._post_event(client, fake_stripe, event)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Remove unnecessary if True: blocks.

The if True: blocks at lines 245, 276, and 299 serve no purpose and reduce readability. They appear to be debug artifacts or copy-paste remnants.

♻️ Proposed fix
         mock_supabase.update = _capture_update  # type: ignore[method-assign]
-        if True:
-            event = {
-                "type": "customer.subscription.updated",
-                ...
-            }
-            resp = self._post_event(client, fake_stripe, event)
+        event = {
+            "type": "customer.subscription.updated",
+            ...
+        }
+        resp = self._post_event(client, fake_stripe, event)

Apply the same cleanup to test_subscription_deleted_downgrades_to_free (line 276) and test_invoice_payment_failed_marks_past_due (line 299).

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if True:
event = {
"type": "customer.subscription.updated",
"data": {
"object": {
"customer": "cus_test_123",
"status": "active",
"current_period_end": 1700000000,
"metadata": {"plan": "team"},
}
},
}
resp = self._post_event(client, fake_stripe, event)
event = {
"type": "customer.subscription.updated",
"data": {
"object": {
"customer": "cus_test_123",
"status": "active",
"current_period_end": 1700000000,
"metadata": {"plan": "team"},
}
},
}
resp = self._post_event(client, fake_stripe, event)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cloud/tests/test_billing.py` around lines 245 - 257, Remove the redundant "if
True:" blocks wrapping test event setup and posting; locate the three
occurrences inside the test methods that build the "event" dict and call
self._post_event (the shown block using event = {...} and resp =
self._post_event(client, fake_stripe, event)), as well as the similar blocks in
test_subscription_deleted_downgrades_to_free and
test_invoice_payment_failed_marks_past_due, and simply delete the "if True:"
line and unindent the inner lines so the event construction and resp =
self._post_event(...) run directly.

…rktree

The 3 backend route files + test_new_routes.py (10 tests) existed in
the main checkout but were never committed — they got written to the
wrong working tree earlier in the session.

Now on the PR branch where they belong. All 72 backend tests pass
(48 baseline + 10 new-routes + 14 billing).

Co-Authored-By: Gradata <noreply@gradata.ai>
@greptile-apps

greptile-apps Bot commented Apr 13, 2026

Copy link
Copy Markdown

Too many files changed for review. (108 files found, 100 file limit)

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 12

♻️ Duplicate comments (6)
cloud/dashboard/app/(dashboard)/corrections/page.tsx (2)

31-45: ⚠️ Potential issue | 🟡 Minor

“Newest first” is promised but not enforced.

Line 44 says newest-first, but Line 31 only filters and never sorts by created_at.

Suggested fix
-  const filtered = filter === 'all' ? corrections : corrections.filter((c) => c.severity === filter)
+  const filtered = [...(filter === 'all' ? corrections : corrections.filter((c) => c.severity === filter))]
+    .sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at))
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cloud/dashboard/app/`(dashboard)/corrections/page.tsx around lines 31 - 45,
The UI claims "Newest first" but the list is never sorted; update the filtering
logic around the filtered variable (currently defined as const filtered = ...)
to sort by Correction.created_at descending after applying the severity filter
(e.g., produce a sorted array or replace filtered with a sorted result) so that
the corrections shown are in newest-first order; ensure you preserve
immutability by creating a new array (use array.slice()/spread before sort) and
reference Correction['created_at'] or the created_at field on each correction
when sorting.

20-35: ⚠️ Potential issue | 🟠 Major

Handle API failures explicitly instead of falling back to empty states.

On Line 20 and Line 22, useApi errors are ignored; failed requests currently render “No brain yet” / “No corrections,” which is misleading.

Suggested fix
-  const { data: brains, loading: loadingBrains } = useApi<Brain[]>('/brains')
+  const { data: brains, loading: loadingBrains, error: brainsError } = useApi<Brain[]>('/brains')
   const primaryId = brains?.[0]?.id ?? null
-  const { data: resp, loading } = useApi<PaginatedResponse<Correction> | Correction[]>(
+  const { data: resp, loading, error: correctionsError } = useApi<PaginatedResponse<Correction> | Correction[]>(
     primaryId ? `/brains/${primaryId}/corrections` : null,
   )

   if (loadingBrains || loading) return <LoadingSpinner className="py-20" />
+  if (brainsError) return <EmptyState title="Failed to load brains" description={brainsError} />
   if (!primaryId) return <EmptyState title="No brain yet" description="Install the SDK to start logging corrections." />
+  if (correctionsError) return <EmptyState title="Failed to load corrections" description={correctionsError} />
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cloud/dashboard/app/`(dashboard)/corrections/page.tsx around lines 20 - 35,
The component currently ignores API errors from useApi and treats failed
requests as empty data (rendering LoadingSpinner or EmptyState); update the
logic to read and handle the error fields returned by useApi for both the brains
and corrections calls (e.g., the error properties alongside data/loading), and
render an explicit error UI/message or ErrorState when either useApi for brains
or for corrections reports an error instead of falling back to the "No brain
yet" / "No corrections" empty states; locate the useApi calls and the variables
primaryId, loadingBrains, loading, resp, corrections, filtered, LoadingSpinner
and EmptyState to add conditional branches that surface errors and avoid
conflating network failures with legitimately empty data.
cloud/dashboard/app/(dashboard)/api-keys/page.tsx (1)

37-39: ⚠️ Potential issue | 🟠 Major

Close the create dialog on successful key creation.

At Line 37, newKey is set but showCreate is left true. Because Line 126 gates the dialog with showCreate && !newKey, closing the one-time key dialog at Line 156 (setNewKey(null)) reopens the create dialog immediately.

Proposed fix
     try {
       const res = await api.post<ApiKeyCreateResponse>('/api-keys', {
         name: keyName || 'Default',
       })
       setNewKey(res.data)
+      setShowCreate(false)
       setKeyName('')
       refetch()

Also applies to: 126-127, 156-157

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cloud/dashboard/app/`(dashboard)/api-keys/page.tsx around lines 37 - 39, The
create dialog is left open after successful key creation because
setNewKey(res.data) is called without closing it; update the success path (where
setNewKey(res.data), setKeyName(''), refetch() are called) to also call
setShowCreate(false) so the dialog closes on success, and ensure any code that
clears the one-time key (setNewKey(null)) does not set showCreate back to true
(remove or avoid any setShowCreate(true) around the setNewKey(null) usage) so
the dialog doesn't immediately reopen.
cloud/dashboard/app/(dashboard)/operator/page.tsx (1)

1-8: ⚠️ Potential issue | 🔴 Critical

Sensitive mock data bundled in client component — previously flagged.

The 'use client' directive causes the mock operator data (customer names, MRR, health status, alerts) imported at line 8 to be bundled and shipped to every browser, regardless of whether the user passes the domain check. The client-side domain check at lines 32-34 can be trivially bypassed by modifying the response or inspecting the bundle.

As noted in the prior review, move operator data retrieval to a server-side protected API endpoint that validates operator role after authentication. The mock fixture should not be imported in client code.
,

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cloud/dashboard/app/`(dashboard)/operator/page.tsx around lines 1 - 8, The
page currently uses the 'use client' directive and directly imports mock
operator fixtures (mockGlobalKpis, mockCustomers, mockAlerts, AdminCustomer)
which bundles sensitive data into the browser; remove those imports from the
client component and instead implement a server-side protected API endpoint that
validates the operator role post-authentication and returns the operator data.
Update the client component (operator/page.tsx) to call that endpoint via the
existing useApi hook (or a fetch wrapper) to retrieve kpis/customers/alerts at
runtime, and ensure mock fixtures remain only on the server (or in test/dev-only
server code) so no mock data is shipped to the client and the client-side domain
check is not relied upon for security.
cloud/dashboard/src/components/brain/MethodologyLink.tsx (1)

21-27: ⚠️ Potential issue | 🟠 Major

Modal still lacks keyboard/focus accessibility essentials.

Line 31 introduces the dialog, but it still has no Escape-to-close, no initial focus/restore focus flow, and no aria-labelledby association to the heading. This is the same unresolved issue from earlier review.

Suggested fix
-'use client'
+ 'use client'

-import { useState } from 'react'
+import { useEffect, useRef, useState } from 'react'

 export function MethodologyLink() {
   const [open, setOpen] = useState(false)
+  const triggerRef = useRef<HTMLButtonElement>(null)
+  const closeRef = useRef<HTMLButtonElement>(null)
+
+  useEffect(() => {
+    if (!open) return
+    const prev = document.activeElement as HTMLElement | null
+    closeRef.current?.focus()
+    const onKeyDown = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') setOpen(false)
+    }
+    window.addEventListener('keydown', onKeyDown)
+    return () => {
+      window.removeEventListener('keydown', onKeyDown)
+      prev?.focus?.()
+    }
+  }, [open])

   return (
     <>
       <button
+        ref={triggerRef}
         type="button"
+        aria-haspopup="dialog"
+        aria-expanded={open}
         onClick={() => setOpen(true)}
         className="mt-6 text-[11px] font-mono uppercase tracking-wider text-[var(--color-body)] underline-offset-4 hover:text-[var(--color-accent-blue)] hover:underline"
       >
         Methodology &amp; limitations →
@@
         <div
           role="dialog"
           aria-modal="true"
+          aria-labelledby="methodology-title"
           className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm"
           onClick={() => setOpen(false)}
         >
@@
-              <h3 className="text-[18px] font-semibold">Methodology &amp; limitations</h3>
+              <h3 id="methodology-title" className="text-[18px] font-semibold">Methodology &amp; limitations</h3>
               <button
+                type="button"
+                ref={closeRef}
                 onClick={() => setOpen(false)}
                 className="-mr-2 flex h-11 w-11 shrink-0 items-center justify-center rounded-[0.5rem] text-[24px] leading-none text-[var(--color-body)] hover:bg-white/[0.04] hover:text-[var(--color-text)]"
                 aria-label="Close"

Also applies to: 31-48

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cloud/dashboard/src/components/brain/MethodologyLink.tsx` around lines 21 -
27, The modal launched by the MethodologyLink component (button onClick calls
setOpen(true)) lacks keyboard and focus semantics; update the dialog
implementation to: add an aria-labelledby that points to the modal heading id
(ensure the heading element has that id), implement Escape-to-close by handling
keydown/Escape to call setOpen(false), set initial focus into a meaningful
focusable element inside the dialog when opened and restore focus back to the
MethodologyLink button when closed, and ensure focus is trapped inside the
dialog while open (use the existing dialog component or a focus-trap utility to
manage focus).
cloud/dashboard/src/components/brain/KpiStrip.tsx (1)

12-13: ⚠️ Potential issue | 🟡 Minor

Use the same rounded delta for both label and tone.

Line 13 rounds only for display while Lines 28–30 classify tone from the raw value. Small deltas can render as -0%/0% but still use non-neutral tone.

Suggested fix
 export function KpiStrip({ metrics }: { metrics: KpiMetrics }) {
-  const fmtDelta = (pct: number) =>
-    pct === 0 ? '—' : `${pct > 0 ? '+' : ''}${pct.toFixed(0)}%`
+  const roundedCorrectionDelta = Math.round(metrics.correctionRateDeltaPct)
+  const fmtDelta = (pct: number) =>
+    pct === 0 ? '—' : `${pct > 0 ? '+' : ''}${pct}%`
@@
     {
       label: 'Correction Rate',
-      value: metrics.correctionRateDeltaPct === 0
+      value: roundedCorrectionDelta === 0
         ? '—'
-        : `${fmtDelta(metrics.correctionRateDeltaPct)}`,
+        : fmtDelta(roundedCorrectionDelta),
       change: `${metrics.correctionsThisWeek} this week · ${metrics.correctionsPriorWeek} prior`,
       changeTone:
-        metrics.correctionRateDeltaPct < 0 ? 'pos'
-          : metrics.correctionRateDeltaPct > 0 ? 'neg'
+        roundedCorrectionDelta < 0 ? 'pos'
+          : roundedCorrectionDelta > 0 ? 'neg'
             : 'neu',
     },

Also applies to: 23-30

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cloud/dashboard/src/components/brain/KpiStrip.tsx` around lines 12 - 13,
Compute a single rounded delta and use it for both display and tone: in fmtDelta
(or the surrounding component) derive const roundedPct = Math.round(pct) (or
parsed Number(pct.toFixed(0))) and use roundedPct for the string output
(producing '—' when roundedPct === 0 and avoiding '-0%') and also use roundedPct
for the tone classification logic currently using the raw pct (the code around
the tone classification lines 28–30) so that small values that round to 0 get
the neutral tone.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@cloud/app/routes/activity.py`:
- Around line 32-38: The function list_activity declares an unused parameter
request: Request; remove this parameter from the function signature in
cloud/app/routes/activity.py (keep the other parameters: brain_id, credentials:
HTTPAuthorizationCredentials = Depends(_bearer), limit, offset) or, if the
Request is intentionally reserved for future middleware/logging, rename it to
_request to silence linters; update any imports or type hints accordingly so the
signature no longer contains the unused request variable.

In `@cloud/app/routes/meta_rules.py`:
- Around line 20-26: The list_meta_rules route declares an unused parameter
request: Request; remove the unused parameter from the function signature in
list_meta_rules and also remove the corresponding Request import from this
module (or mark it unused/underscore if intentionally kept), ensuring the
function signature becomes async def list_meta_rules(brain_id: str, credentials:
HTTPAuthorizationCredentials = Depends(_bearer), limit: int = Query(50, ge=1,
le=200), offset: int = Query(0, ge=0)) -> list[dict]: and adjust any callers if
necessary.

In `@cloud/app/routes/rule_patches.py`:
- Around line 20-26: Remove the unused Request parameter from both endpoint
signatures: update list_rule_patches(...) and rollback_patch(...) to drop the
request: Request argument and any references to it, and then remove the
now-unused Request import from the module; keep the credentials dependency and
Query params unchanged so endpoint behavior remains the same.
- Around line 86-101: The rollback currently inserts an inverse patch via
db.insert(...) before updating the lesson, risking an inconsistent audit trail
if the update fails; change the order in rule_patches.py to perform the lesson
update (await db.update("lessons", {"description": patch["old_description"]},
filters={"id": patch["lesson_id"]})) first and only after it succeeds insert the
inverse audit record (await db.insert("rule_patches", {...})); also catch and
log any error from the subsequent insert of the audit record (including patch_id
and lesson_id) so a failed audit write is visible for follow-up.

In `@cloud/dashboard/app/`(dashboard)/api-keys/page.tsx:
- Around line 126-127: Reset the createError state whenever the create dialog is
dismissed by clearing createError inside the Dialog open-change handler: update
the onOpenChange (where Dialog currently uses setShowCreate) to call
setCreateError(null) when the dialog is closed (and keep existing setShowCreate
behavior), and apply the same change to the other Dialog instance referenced
around the 135-139 area so any stale error is cleared each time the create
dialog is closed.

In `@cloud/dashboard/app/`(dashboard)/corrections/page.tsx:
- Around line 44-45: The displayed count uses filtered.length while the list
actually renders filtered.slice(0, 50), causing mismatch when >50 items exist;
update the UI text to reflect the number shown versus total (e.g., "showing
{Math.min(filtered.length, 50)} of {filtered.length}") or change the rendered
list to show all items — adjust the JSX where filtered.length is used and where
filtered.slice(0, 50) is rendered (look for the variables/expressions filtered
and filtered.slice(0, 50) in the page component) so the count and displayed
items stay consistent.

In `@cloud/dashboard/app/`(dashboard)/dashboard/page.tsx:
- Around line 83-94: The time-range buttons in dashboard/page.tsx rendering
inside the map lack an accessibility state; update the <button> that sets range
(the one using onClick={() => setRange(r)} and comparing r === range) to include
aria-pressed with a boolean value reflecting selection (e.g., aria-pressed={r
=== range}) so screen readers can detect the active state.

In `@cloud/dashboard/app/`(dashboard)/operator/page.tsx:
- Around line 164-170: The formatAgo(iso: string) function must validate the
parsed date and handle future timestamps: first construct the Date from iso and
check isNaN(date.getTime()) — if invalid return a sensible fallback like
'invalid date' or 'unknown'; then compute diffMs and if diffMs < 0 handle future
times (e.g., return 'in Xh' or 'just now' depending on desired UX) instead of
treating them as recent; ensure you use the same math for hours/days (h =
Math.floor(Math.abs(diffMs) / 3600_000)) when formatting future intervals and
clamp/format appropriately so NaN never propagates to the returned string.
- Around line 29-33: The current logic uses useApi<UserProfile>('/users/me') and
treats profile being undefined the same as a non-operator; instead detect and
handle the API error state returned by useApi (e.g., an error or status flag)
rather than only checking loading, and surface an error UI or retry option
before computing domain/isOperator; update the component to branch: if (loading)
show LoadingSpinner, else if (error) show an error message and retry control (or
a dismissible toast), else compute const domain = profile.email.split('@')[1]
and const isOperator = OPERATOR_EMAIL_DOMAINS.includes(domain) so transient
failures don’t incorrectly render the "restricted" view.

In `@cloud/dashboard/app/`(dashboard)/team/page.tsx:
- Around line 11-15: The component TeamOverviewPage currently defaults
currentPlan to 'free' when profile is null; instead destructure error from
useApi (e.g., const { data: profile, loading, error } =
useApi<UserProfile>('/users/me')) and, when error is truthy, render the
ErrorState component (import ErrorState from '@/components/shared/ErrorState')
before computing currentPlan so API failures are surfaced rather than silently
falling back to 'free'; keep the existing loading path for loading and only
compute currentPlan after confirming profile exists.

In `@cloud/dashboard/src/components/layout/DashboardLayout.tsx`:
- Around line 152-157: The Sign out button element should explicitly declare
type to avoid accidental form submissions; update the JSX for the button that
calls signOut (the onClick={() => signOut()} button in DashboardLayout
component) to include type="button" so it remains a non-submitting control even
if later wrapped in a form.

In `@cloud/dashboard/tests/MetaRulesGrid.test.tsx`:
- Around line 19-22: The useApi mock in the tests currently returns fallback
data for any non-'/brains' URL which masks incorrect endpoint usage; update the
mockImplementation for useApiMock to explicitly branch on each expected URL
(e.g., '/brains', other endpoints used in this test) and return withData(...)
for each expected path, and throw an error (or fail) for any unexpected url to
ensure tests assert the endpoint contract; apply the same strict URL-branching
pattern to the other useApiMock occurrences in this file (the blocks around
lines that currently return fallback []) so each test verifies exact API paths.

---

Duplicate comments:
In `@cloud/dashboard/app/`(dashboard)/api-keys/page.tsx:
- Around line 37-39: The create dialog is left open after successful key
creation because setNewKey(res.data) is called without closing it; update the
success path (where setNewKey(res.data), setKeyName(''), refetch() are called)
to also call setShowCreate(false) so the dialog closes on success, and ensure
any code that clears the one-time key (setNewKey(null)) does not set showCreate
back to true (remove or avoid any setShowCreate(true) around the setNewKey(null)
usage) so the dialog doesn't immediately reopen.

In `@cloud/dashboard/app/`(dashboard)/corrections/page.tsx:
- Around line 31-45: The UI claims "Newest first" but the list is never sorted;
update the filtering logic around the filtered variable (currently defined as
const filtered = ...) to sort by Correction.created_at descending after applying
the severity filter (e.g., produce a sorted array or replace filtered with a
sorted result) so that the corrections shown are in newest-first order; ensure
you preserve immutability by creating a new array (use array.slice()/spread
before sort) and reference Correction['created_at'] or the created_at field on
each correction when sorting.
- Around line 20-35: The component currently ignores API errors from useApi and
treats failed requests as empty data (rendering LoadingSpinner or EmptyState);
update the logic to read and handle the error fields returned by useApi for both
the brains and corrections calls (e.g., the error properties alongside
data/loading), and render an explicit error UI/message or ErrorState when either
useApi for brains or for corrections reports an error instead of falling back to
the "No brain yet" / "No corrections" empty states; locate the useApi calls and
the variables primaryId, loadingBrains, loading, resp, corrections, filtered,
LoadingSpinner and EmptyState to add conditional branches that surface errors
and avoid conflating network failures with legitimately empty data.

In `@cloud/dashboard/app/`(dashboard)/operator/page.tsx:
- Around line 1-8: The page currently uses the 'use client' directive and
directly imports mock operator fixtures (mockGlobalKpis, mockCustomers,
mockAlerts, AdminCustomer) which bundles sensitive data into the browser; remove
those imports from the client component and instead implement a server-side
protected API endpoint that validates the operator role post-authentication and
returns the operator data. Update the client component (operator/page.tsx) to
call that endpoint via the existing useApi hook (or a fetch wrapper) to retrieve
kpis/customers/alerts at runtime, and ensure mock fixtures remain only on the
server (or in test/dev-only server code) so no mock data is shipped to the
client and the client-side domain check is not relied upon for security.

In `@cloud/dashboard/src/components/brain/KpiStrip.tsx`:
- Around line 12-13: Compute a single rounded delta and use it for both display
and tone: in fmtDelta (or the surrounding component) derive const roundedPct =
Math.round(pct) (or parsed Number(pct.toFixed(0))) and use roundedPct for the
string output (producing '—' when roundedPct === 0 and avoiding '-0%') and also
use roundedPct for the tone classification logic currently using the raw pct
(the code around the tone classification lines 28–30) so that small values that
round to 0 get the neutral tone.

In `@cloud/dashboard/src/components/brain/MethodologyLink.tsx`:
- Around line 21-27: The modal launched by the MethodologyLink component (button
onClick calls setOpen(true)) lacks keyboard and focus semantics; update the
dialog implementation to: add an aria-labelledby that points to the modal
heading id (ensure the heading element has that id), implement Escape-to-close
by handling keydown/Escape to call setOpen(false), set initial focus into a
meaningful focusable element inside the dialog when opened and restore focus
back to the MethodologyLink button when closed, and ensure focus is trapped
inside the dialog while open (use the existing dialog component or a focus-trap
utility to manage focus).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 37ab0561-91e8-4bef-8087-d4666fbc2ca3

📥 Commits

Reviewing files that changed from the base of the PR and between 74ada40 and d908a9d.

⛔ Files ignored due to path filters (1)
  • cloud/dashboard/pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (26)
  • cloud/app/routes/__init__.py
  • cloud/app/routes/activity.py
  • cloud/app/routes/meta_rules.py
  • cloud/app/routes/rule_patches.py
  • cloud/dashboard/app/(dashboard)/api-keys/page.tsx
  • cloud/dashboard/app/(dashboard)/corrections/page.tsx
  • cloud/dashboard/app/(dashboard)/dashboard/page.tsx
  • cloud/dashboard/app/(dashboard)/operator/page.tsx
  • cloud/dashboard/app/(dashboard)/rules/page.tsx
  • cloud/dashboard/app/(dashboard)/team/members/page.tsx
  • cloud/dashboard/app/(dashboard)/team/page.tsx
  • cloud/dashboard/package.json
  • cloud/dashboard/src/components/brain/KpiStrip.tsx
  • cloud/dashboard/src/components/brain/MethodologyLink.tsx
  • cloud/dashboard/src/components/layout/DashboardLayout.tsx
  • cloud/dashboard/tests/ActiveRulesPanel.test.tsx
  • cloud/dashboard/tests/ActivityFeed.test.tsx
  • cloud/dashboard/tests/CategoriesChart.test.tsx
  • cloud/dashboard/tests/GraduationProgressBar.test.tsx
  • cloud/dashboard/tests/KpiStrip.test.tsx
  • cloud/dashboard/tests/MetaRulesGrid.test.tsx
  • cloud/dashboard/tests/PlanBadge.test.tsx
  • cloud/dashboard/tests/analytics-client.test.ts
  • cloud/dashboard/tests/setup.ts
  • cloud/dashboard/vitest.config.ts
  • cloud/tests/test_new_routes.py
📜 Review details
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Cloudflare Pages
🔇 Additional comments (34)
cloud/dashboard/app/(dashboard)/corrections/page.tsx (1)

22-24: Conditional fetch gating with null URL is correctly applied.

Deferring corrections fetch until primaryId is available avoids premature requests and matches the hook contract.

cloud/dashboard/app/(dashboard)/api-keys/page.tsx (1)

90-102: Good change: only key prefix is rendered in the list.

Showing gd_{k.key_prefix}… instead of full key material is a solid safety improvement for accidental secret exposure in UI.

cloud/dashboard/app/(dashboard)/operator/page.tsx (3)

16-26: LGTM!

The style constants are well-typed using Record and as const, providing type safety and covering all expected health/alert states.


144-162: LGTM!

The Kpi helper component is clean and properly handles all tone variants with appropriate color styling.


115-115: The type assertion c.plan as PlanTier is redundant. Both AdminCustomer.plan and PlanTier are identically defined as 'free' | 'cloud' | 'team' | 'enterprise', so the assertion is unnecessary. Consider removing it to let TypeScript infer the correct type automatically.

			> Likely an incorrect or invalid review comment.
cloud/dashboard/src/components/layout/DashboardLayout.tsx (3)

67-80: Add aria-current and aria-hidden for assistive technology support.

The past review comment about accessibility has not been addressed. The active nav link should expose its state to screen readers, and the decorative icon glyphs should be hidden from assistive tech.

♿ Suggested fix
               <Link
                 key={item.href}
                 href={item.href}
                 onClick={onNavigate}
+                aria-current={active ? 'page' : undefined}
                 className={cn(
                   'flex items-center gap-2.5 rounded-[0.5rem] px-3 py-2 text-[13px] transition-all',
                   active
                     ? 'border border-[rgba(58,130,255,0.15)] bg-[rgba(58,130,255,0.12)] text-[var(--color-text)]'
                     : 'text-[var(--color-body)] hover:bg-[rgba(58,130,255,0.08)] hover:text-[var(--color-text)]',
                 )}
               >
-                <span className="w-4 text-center text-sm opacity-50">{item.icon}</span>
+                <span aria-hidden="true" className="w-4 text-center text-sm opacity-50">
+                  {item.icon}
+                </span>
                 {item.label}
               </Link>

97-123: LGTM!

The mobile sidebar drawer is well-implemented with proper body scroll lock (storing and restoring previous overflow value), accessible overlay button, and auto-close on navigation.


164-178: LGTM!

Clean Next.js layout composition with proper children rendering pattern. The local state for mobile drawer toggle is appropriate, and the integration with the parent ProtectedLayout (which handles auth redirect and renders <DashboardLayout>) is correct.

cloud/dashboard/tests/setup.ts (1)

1-1: Good shared matcher setup.

Keeps test files clean and ensures consistent DOM matcher availability.

cloud/dashboard/vitest.config.ts (1)

4-10: Vitest configuration looks solid.

Environment, setup bootstrap, and alias mapping are aligned with the added RTL test suite.

cloud/dashboard/tests/ActiveRulesPanel.test.tsx (1)

23-63: Strong behavioral coverage for panel rules.

The suite validates the key rendering contracts (filter/sort/empty-state/confidence suppression) well.

cloud/dashboard/tests/PlanBadge.test.tsx (1)

5-64: Plan badge/gate test coverage is good.

It exercises both presentation mapping and rank-based gating behavior in a clear way.

cloud/dashboard/tests/GraduationProgressBar.test.tsx (1)

14-60: Comprehensive progress-bar test set.

Covers both arithmetic correctness and UI output for populated and empty states.

cloud/dashboard/package.json (1)

5-48: Version compatibility already flagged in prior review.

The dependency versions (next@16.0.0, typescript@6.0.2, vitest@4.1.4, jsdom@29.0.2) are beyond my knowledge cutoff. A prior review comment already requested verification of these package versions and their compatibility.

cloud/dashboard/app/(dashboard)/rules/page.tsx (1)

18-33: API error handling and array mutation issues already flagged.

Prior review comments correctly identified:

  1. API failures silently fall through to empty states instead of showing ErrorState
  2. The .sort() call mutates the original lessons array when filter === 'all'

These issues remain unaddressed.

cloud/dashboard/app/(dashboard)/team/members/page.tsx (1)

47-57: Invite flow gating and inert Remove button already flagged.

Prior review comments correctly identified:

  1. The "Invite member" button and dialog (lines 54, 94-134) are outside the PlanGate, allowing users without the team entitlement to access the invite workflow
  2. The "Remove" button (lines 79-87) has no onClick handler, making it a non-functional UI element

These issues remain unaddressed.

Also applies to: 79-87, 94-134

cloud/dashboard/tests/ActivityFeed.test.tsx (1)

1-79: LGTM! Well-structured test suite for ActivityFeed.

The tests properly mock useApi before importing the component and cover the key scenarios: fixture fallback, real data rendering, type normalization, and graceful handling of missing data.

cloud/dashboard/tests/CategoriesChart.test.tsx (1)

1-70: LGTM! Thorough coverage of the category folding taxonomy.

The tests correctly validate the fixed 6-dimension rendering and legacy category mappings defined in LEGACY_MAP. The use of closest('li')?.textContent to verify counts is a pragmatic approach.

cloud/dashboard/tests/KpiStrip.test.tsx (1)

1-52: LGTM! Test assertions align correctly with the KpiStrip implementation.

The tests properly verify the conditional CSS classes for tone-based styling, and the placeholder behavior for zero values matches the component logic shown in the context snippets.

cloud/dashboard/tests/analytics-client.test.ts (3)

117-135: LGTM! buildDecayCurve tests verify bucket structure correctly.

The parameterized test for bucket sizing and the field presence assertions provide good coverage of the decay curve output shape.


92-115: LGTM! computeGraduationCounts tests are well-structured.

The tests correctly verify per-state counting, totalActive calculation as pattern + rule (excluding instinct), and the edge case of empty lessons returning 0 for all confidence averages. The implementation and comment are consistent and accurate.

			> Likely an incorrect or invalid review comment.

46-89: No issues found. All test expectations align with the computeKpis implementation: empty inputs correctly return zero values, the negative delta formula matches the test assertion, footprint calculation is 11 KB × correction count, and totalFires correctly sums only RULE and PATTERN lesson fire counts. The tests are well-written and correct.

cloud/dashboard/app/(dashboard)/team/page.tsx (1)

74-124: LGTM! Clean helper components for KPI cards and leaderboard rows.

The Kpi and LeaderRow components are well-structured with appropriate prop types and conditional styling logic.

cloud/dashboard/app/(dashboard)/dashboard/page.tsx (3)

26-34: Dependent requests' loading states are not tracked, causing a transient empty-state flash.

The loading state from analytics, correctionsResp, and lessonsResp hooks is not destructured. After primaryBrainId becomes available, the widgets render with null/empty data for one frame before the effect triggers and data loads. This was previously flagged.


36-44: Only the first page of paginated data is used for KPI and chart calculations.

When endpoints return PaginatedResponse, lines 38 and 43 extract only the .data array from the first page. Once a brain accumulates enough corrections or lessons to span multiple pages, all dashboard metrics become silently incomplete. This was previously flagged.


99-114: LGTM on conditional widget rendering.

KpiStrip and CategoriesChart are correctly gated on their data being present. The remaining widgets that receive potentially-empty arrays will benefit from the loading-state gating fix suggested in the earlier comment.

cloud/app/routes/activity.py (1)

47-55: LGTM — correct filtering and pagination.

The in-memory filtering against _VISIBLE_EVENT_TYPES and newest-first sorting is correct. The comment in rule_patches.py acknowledges this is acceptable for MVP volume; consider adding server-side type filtering via PostgREST in operator when event volume grows.

cloud/app/routes/rule_patches.py (1)

35-51: LGTM — correct lesson-scoped filtering.

The two-step query (fetch brain's lessons, then filter patches by lesson_id set) correctly enforces brain-level isolation. The MVP caveat about fetching all patches is documented.

cloud/app/routes/__init__.py (1)

7-7: LGTM!

The three new routers are correctly imported and registered with descriptive tags matching their endpoint groups.

Also applies to: 14-15, 27-29

cloud/app/routes/meta_rules.py (1)

36-44: LGTM!

Direct filtering by brain_id at the DB level is efficient. Sorting and pagination are correctly applied.

cloud/tests/test_new_routes.py (4)

7-18: LGTM — clean fixture for bypassing auth.

The valid_bearer_patches fixture correctly stubs get_brain_for_request across all three route modules, allowing tests to focus on DB query behavior.


21-80: LGTM!

TestMetaRulesEndpoint and TestActivityEndpoint provide good coverage for empty results, ordering, pagination, and type filtering. The visible-types test (lines 57-74) effectively validates that only whitelisted event types are returned.


137-159: LGTM — good 404 coverage.

Both edge cases (patch belongs to different brain, patch doesn't exist) correctly verify the 404 response, ensuring the endpoint doesn't leak information about patches from other brains.


113-135: Test doesn't verify lesson update.

The rollback test asserts that an inverse patch was inserted (lines 130-135) but doesn't verify that the lesson's description was updated to old_description. The endpoint does call db.update("lessons", ...) on line 97-101, but if that call were accidentally removed, this test would still pass.

However, the suggested fix in the original comment references mock_supabase._updates, which doesn't exist in the MockSupabaseClient — the mock only tracks inserts. Consider either enhancing the mock to track updates, or adding a different verification approach for this behavior.

			> Likely an incorrect or invalid review comment.

Comment on lines +32 to +38
async def list_activity(
brain_id: str,
request: Request,
credentials: HTTPAuthorizationCredentials = Depends(_bearer),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
) -> list[dict]:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Unused request parameter.

The request: Request parameter is declared but never used in the function body. Remove it unless it's needed for middleware or future logging.

🧹 Proposed fix
 `@router.get`("/brains/{brain_id}/activity")
 async def list_activity(
     brain_id: str,
-    request: Request,
     credentials: HTTPAuthorizationCredentials = Depends(_bearer),
     limit: int = Query(50, ge=1, le=200),
     offset: int = Query(0, ge=0),
 ) -> list[dict]:
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async def list_activity(
brain_id: str,
request: Request,
credentials: HTTPAuthorizationCredentials = Depends(_bearer),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
) -> list[dict]:
async def list_activity(
brain_id: str,
credentials: HTTPAuthorizationCredentials = Depends(_bearer),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
) -> list[dict]:
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cloud/app/routes/activity.py` around lines 32 - 38, The function
list_activity declares an unused parameter request: Request; remove this
parameter from the function signature in cloud/app/routes/activity.py (keep the
other parameters: brain_id, credentials: HTTPAuthorizationCredentials =
Depends(_bearer), limit, offset) or, if the Request is intentionally reserved
for future middleware/logging, rename it to _request to silence linters; update
any imports or type hints accordingly so the signature no longer contains the
unused request variable.

Comment on lines +20 to +26
async def list_meta_rules(
brain_id: str,
request: Request,
credentials: HTTPAuthorizationCredentials = Depends(_bearer),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
) -> list[dict]:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Unused request parameter.

Same as the other route files — request: Request is declared but unused.

🧹 Proposed fix
 `@router.get`("/brains/{brain_id}/meta-rules")
 async def list_meta_rules(
     brain_id: str,
-    request: Request,
     credentials: HTTPAuthorizationCredentials = Depends(_bearer),
     limit: int = Query(50, ge=1, le=200),
     offset: int = Query(0, ge=0),
 ) -> list[dict]:
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async def list_meta_rules(
brain_id: str,
request: Request,
credentials: HTTPAuthorizationCredentials = Depends(_bearer),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
) -> list[dict]:
async def list_meta_rules(
brain_id: str,
credentials: HTTPAuthorizationCredentials = Depends(_bearer),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
) -> list[dict]:
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cloud/app/routes/meta_rules.py` around lines 20 - 26, The list_meta_rules
route declares an unused parameter request: Request; remove the unused parameter
from the function signature in list_meta_rules and also remove the corresponding
Request import from this module (or mark it unused/underscore if intentionally
kept), ensuring the function signature becomes async def
list_meta_rules(brain_id: str, credentials: HTTPAuthorizationCredentials =
Depends(_bearer), limit: int = Query(50, ge=1, le=200), offset: int = Query(0,
ge=0)) -> list[dict]: and adjust any callers if necessary.

Comment on lines +20 to +26
async def list_rule_patches(
brain_id: str,
request: Request,
credentials: HTTPAuthorizationCredentials = Depends(_bearer),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
) -> list[dict]:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Unused request parameter in both endpoints.

Both list_rule_patches and rollback_patch declare request: Request but never use it.

🧹 Proposed fix for list_rule_patches
 `@router.get`("/brains/{brain_id}/rule-patches")
 async def list_rule_patches(
     brain_id: str,
-    request: Request,
     credentials: HTTPAuthorizationCredentials = Depends(_bearer),
     limit: int = Query(50, ge=1, le=200),
     offset: int = Query(0, ge=0),
 ) -> list[dict]:
🧹 Proposed fix for rollback_patch
 `@router.post`("/brains/{brain_id}/rule-patches/{patch_id}/rollback", status_code=204)
 async def rollback_patch(
     brain_id: str,
     patch_id: str,
-    request: Request,
     credentials: HTTPAuthorizationCredentials = Depends(_bearer),
 ) -> None:

Also applies to: 55-60

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cloud/app/routes/rule_patches.py` around lines 20 - 26, Remove the unused
Request parameter from both endpoint signatures: update list_rule_patches(...)
and rollback_patch(...) to drop the request: Request argument and any references
to it, and then remove the now-unused Request import from the module; keep the
credentials dependency and Query params unchanged so endpoint behavior remains
the same.

Comment on lines +86 to +101
# Flip the patch by creating the inverse
await db.insert(
"rule_patches",
{
"lesson_id": patch["lesson_id"],
"old_description": patch["new_description"],
"new_description": patch["old_description"],
"reason": f"rollback of patch {patch_id}",
},
)
# Apply the restored text to the lesson
await db.update(
"lessons",
{"description": patch["old_description"]},
filters={"id": patch["lesson_id"]},
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Non-atomic rollback risks partial failure.

The rollback performs two sequential operations: inserting an inverse patch (line 87-95) and updating the lesson (line 97-100). Per cloud/app/db.py, there's no transaction wrapper. If the insert succeeds but the update fails (network issue, constraint violation), the audit trail will show a rollback that never actually applied to the lesson.

Consider one of:

  1. Reversing the order: update lesson first, then insert audit record (failed audit insert is less harmful than inconsistent lesson state)
  2. Adding a compensation mechanism if the second operation fails
  3. Documenting this as a known limitation for MVP
🛡️ Suggested reorder (lower-risk approach)
-    # Flip the patch by creating the inverse
-    await db.insert(
-        "rule_patches",
-        {
-            "lesson_id": patch["lesson_id"],
-            "old_description": patch["new_description"],
-            "new_description": patch["old_description"],
-            "reason": f"rollback of patch {patch_id}",
-        },
-    )
     # Apply the restored text to the lesson
     await db.update(
         "lessons",
         {"description": patch["old_description"]},
         filters={"id": patch["lesson_id"]},
     )
+    # Record the inverse patch for audit (after lesson is successfully updated)
+    await db.insert(
+        "rule_patches",
+        {
+            "lesson_id": patch["lesson_id"],
+            "old_description": patch["new_description"],
+            "new_description": patch["old_description"],
+            "reason": f"rollback of patch {patch_id}",
+        },
+    )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# Flip the patch by creating the inverse
await db.insert(
"rule_patches",
{
"lesson_id": patch["lesson_id"],
"old_description": patch["new_description"],
"new_description": patch["old_description"],
"reason": f"rollback of patch {patch_id}",
},
)
# Apply the restored text to the lesson
await db.update(
"lessons",
{"description": patch["old_description"]},
filters={"id": patch["lesson_id"]},
)
# Apply the restored text to the lesson
await db.update(
"lessons",
{"description": patch["old_description"]},
filters={"id": patch["lesson_id"]},
)
# Record the inverse patch for audit (after lesson is successfully updated)
await db.insert(
"rule_patches",
{
"lesson_id": patch["lesson_id"],
"old_description": patch["new_description"],
"new_description": patch["old_description"],
"reason": f"rollback of patch {patch_id}",
},
)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cloud/app/routes/rule_patches.py` around lines 86 - 101, The rollback
currently inserts an inverse patch via db.insert(...) before updating the
lesson, risking an inconsistent audit trail if the update fails; change the
order in rule_patches.py to perform the lesson update (await
db.update("lessons", {"description": patch["old_description"]}, filters={"id":
patch["lesson_id"]})) first and only after it succeeds insert the inverse audit
record (await db.insert("rule_patches", {...})); also catch and log any error
from the subsequent insert of the audit record (including patch_id and
lesson_id) so a failed audit write is visible for follow-up.

Comment on lines 126 to 127
<Dialog open={showCreate && !newKey} onOpenChange={setShowCreate}>
<DialogContent>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Reset createError when the dialog is dismissed.

If a create attempt fails once, the old error message persists and is shown again on next open before any new submission.

Proposed fix
-      <Dialog open={showCreate && !newKey} onOpenChange={setShowCreate}>
+      <Dialog
+        open={showCreate && !newKey}
+        onOpenChange={(open) => {
+          setShowCreate(open)
+          if (!open) setCreateError(null)
+        }}
+      >

Also applies to: 135-139

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cloud/dashboard/app/`(dashboard)/api-keys/page.tsx around lines 126 - 127,
Reset the createError state whenever the create dialog is dismissed by clearing
createError inside the Dialog open-change handler: update the onOpenChange
(where Dialog currently uses setShowCreate) to call setCreateError(null) when
the dialog is closed (and keep existing setShowCreate behavior), and apply the
same change to the other Dialog instance referenced around the 135-139 area so
any stale error is cleared each time the create dialog is closed.

Comment on lines +29 to +33
const { data: profile, loading } = useApi<UserProfile>('/users/me')
if (loading) return <LoadingSpinner className="py-20" />

const domain = profile?.email?.split('@')[1] ?? ''
const isOperator = OPERATOR_EMAIL_DOMAINS.includes(domain)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Handle API error state.

If the /users/me call fails, loading becomes false but profile remains undefined. This causes domain to be an empty string, showing the "restricted" message even for legitimate operators experiencing transient network issues.

Consider handling the error state explicitly to show an appropriate error message or retry option.

🛡️ Proposed fix
 export default function OperatorPage() {
-  const { data: profile, loading } = useApi<UserProfile>('/users/me')
+  const { data: profile, loading, error } = useApi<UserProfile>('/users/me')
   if (loading) return <LoadingSpinner className="py-20" />
+  if (error) {
+    return (
+      <div className="py-12 text-center">
+        <h1 className="text-[22px]">Operator</h1>
+        <p className="mt-3 text-[13px] text-[var(--color-destructive)]">
+          Failed to load profile. Please refresh to try again.
+        </p>
+      </div>
+    )
+  }
 
   const domain = profile?.email?.split('@')[1] ?? ''
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const { data: profile, loading } = useApi<UserProfile>('/users/me')
if (loading) return <LoadingSpinner className="py-20" />
const domain = profile?.email?.split('@')[1] ?? ''
const isOperator = OPERATOR_EMAIL_DOMAINS.includes(domain)
export default function OperatorPage() {
const { data: profile, loading, error } = useApi<UserProfile>('/users/me')
if (loading) return <LoadingSpinner className="py-20" />
if (error) {
return (
<div className="py-12 text-center">
<h1 className="text-[22px]">Operator</h1>
<p className="mt-3 text-[13px] text-[var(--color-destructive)]">
Failed to load profile. Please refresh to try again.
</p>
</div>
)
}
const domain = profile?.email?.split('@')[1] ?? ''
const isOperator = OPERATOR_EMAIL_DOMAINS.includes(domain)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cloud/dashboard/app/`(dashboard)/operator/page.tsx around lines 29 - 33, The
current logic uses useApi<UserProfile>('/users/me') and treats profile being
undefined the same as a non-operator; instead detect and handle the API error
state returned by useApi (e.g., an error or status flag) rather than only
checking loading, and surface an error UI or retry option before computing
domain/isOperator; update the component to branch: if (loading) show
LoadingSpinner, else if (error) show an error message and retry control (or a
dismissible toast), else compute const domain = profile.email.split('@')[1] and
const isOperator = OPERATOR_EMAIL_DOMAINS.includes(domain) so transient failures
don’t incorrectly render the "restricted" view.

Comment on lines +164 to +170
function formatAgo(iso: string): string {
const diffMs = Date.now() - new Date(iso).getTime()
const h = Math.floor(diffMs / 3600_000)
if (h < 1) return 'just now'
if (h < 24) return `${h}h ago`
return `${Math.floor(h / 24)}d ago`
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Handle invalid or future dates gracefully.

If iso is malformed, new Date(iso) returns an invalid date, causing diffMs to be NaN. This propagates through the math operations, resulting in "NaNd ago" being displayed. Additionally, future dates produce negative diffMs, which would incorrectly show "just now".

Consider adding validation:

🛡️ Proposed fix
 function formatAgo(iso: string): string {
+  const date = new Date(iso)
+  if (isNaN(date.getTime())) return '—'
-  const diffMs = Date.now() - new Date(iso).getTime()
+  const diffMs = Date.now() - date.getTime()
+  if (diffMs < 0) return 'just now'
   const h = Math.floor(diffMs / 3600_000)
   if (h < 1) return 'just now'
   if (h < 24) return `${h}h ago`
   return `${Math.floor(h / 24)}d ago`
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function formatAgo(iso: string): string {
const diffMs = Date.now() - new Date(iso).getTime()
const h = Math.floor(diffMs / 3600_000)
if (h < 1) return 'just now'
if (h < 24) return `${h}h ago`
return `${Math.floor(h / 24)}d ago`
}
function formatAgo(iso: string): string {
const date = new Date(iso)
if (isNaN(date.getTime())) return '—'
const diffMs = Date.now() - date.getTime()
if (diffMs < 0) return 'just now'
const h = Math.floor(diffMs / 3600_000)
if (h < 1) return 'just now'
if (h < 24) return `${h}h ago`
return `${Math.floor(h / 24)}d ago`
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cloud/dashboard/app/`(dashboard)/operator/page.tsx around lines 164 - 170,
The formatAgo(iso: string) function must validate the parsed date and handle
future timestamps: first construct the Date from iso and check
isNaN(date.getTime()) — if invalid return a sensible fallback like 'invalid
date' or 'unknown'; then compute diffMs and if diffMs < 0 handle future times
(e.g., return 'in Xh' or 'just now' depending on desired UX) instead of treating
them as recent; ensure you use the same math for hours/days (h =
Math.floor(Math.abs(diffMs) / 3600_000)) when formatting future intervals and
clamp/format appropriately so NaN never propagates to the returned string.

Comment on lines +11 to +15
export default function TeamOverviewPage() {
const { data: profile, loading } = useApi<UserProfile>('/users/me')
if (loading) return <LoadingSpinner className="py-20" />

const currentPlan = (profile?.plan?.toLowerCase() ?? 'free') as PlanTier

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Surface API failure instead of defaulting to 'free' plan.

If the /users/me call fails, profile will be null and currentPlan silently defaults to 'free'. This could incorrectly gate features for users who have a higher plan but experienced a transient API error. Consider destructuring error from useApi and rendering an ErrorState when the profile fetch fails.

🛡️ Proposed fix
 export default function TeamOverviewPage() {
-  const { data: profile, loading } = useApi<UserProfile>('/users/me')
+  const { data: profile, loading, error } = useApi<UserProfile>('/users/me')
   if (loading) return <LoadingSpinner className="py-20" />
+  if (error) return <ErrorState title="Failed to load profile" description="Please try again." />

   const currentPlan = (profile?.plan?.toLowerCase() ?? 'free') as PlanTier

You'll also need to import ErrorState:

import { ErrorState } from '@/components/shared/ErrorState'
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cloud/dashboard/app/`(dashboard)/team/page.tsx around lines 11 - 15, The
component TeamOverviewPage currently defaults currentPlan to 'free' when profile
is null; instead destructure error from useApi (e.g., const { data: profile,
loading, error } = useApi<UserProfile>('/users/me')) and, when error is truthy,
render the ErrorState component (import ErrorState from
'@/components/shared/ErrorState') before computing currentPlan so API failures
are surfaced rather than silently falling back to 'free'; keep the existing
loading path for loading and only compute currentPlan after confirming profile
exists.

Comment on lines +152 to +157
<button
onClick={() => signOut()}
className="rounded-[0.5rem] border border-[var(--color-border)] px-3 py-1.5 text-[12px] text-[var(--color-body)] transition-all hover:border-[var(--color-border-hover)] hover:text-[var(--color-text)]"
>
Sign out
</button>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Add type="button" to the sign-out button.

Although not currently inside a form, adding type="button" is a defensive best practice to prevent unintended form submission behavior if the component structure changes.

Suggested fix
           <button
+            type="button"
             onClick={() => signOut()}
             className="rounded-[0.5rem] border border-[var(--color-border)] px-3 py-1.5 text-[12px] text-[var(--color-body)] transition-all hover:border-[var(--color-border-hover)] hover:text-[var(--color-text)]"
           >
             Sign out
           </button>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<button
onClick={() => signOut()}
className="rounded-[0.5rem] border border-[var(--color-border)] px-3 py-1.5 text-[12px] text-[var(--color-body)] transition-all hover:border-[var(--color-border-hover)] hover:text-[var(--color-text)]"
>
Sign out
</button>
<button
type="button"
onClick={() => signOut()}
className="rounded-[0.5rem] border border-[var(--color-border)] px-3 py-1.5 text-[12px] text-[var(--color-body)] transition-all hover:border-[var(--color-border-hover)] hover:text-[var(--color-text)]"
>
Sign out
</button>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cloud/dashboard/src/components/layout/DashboardLayout.tsx` around lines 152 -
157, The Sign out button element should explicitly declare type to avoid
accidental form submissions; update the JSX for the button that calls signOut
(the onClick={() => signOut()} button in DashboardLayout component) to include
type="button" so it remains a non-submitting control even if later wrapped in a
form.

Comment on lines +19 to +22
useApiMock.mockImplementation((url: string | null) => {
if (url === '/brains') return withData([{ id: 'b1' }])
return withData([])
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Tighten useApi mocks to assert expected endpoint contracts.

Current mocks return fallback data for any non-/brains URL, so a wrong API path can still pass tests. Make the mock explicit per URL and fail on unexpected inputs.

Suggested hardening pattern
-    useApiMock.mockImplementation((url: string | null) => {
-      if (url === '/brains') return withData([{ id: 'b1' }])
-      return withData(real)
-    })
+    useApiMock.mockImplementation((url: string | null) => {
+      if (url === '/brains') return withData([{ id: 'b1' }])
+      if (url === '/brains/b1/meta-rules') return withData(real)
+      throw new Error(`Unexpected useApi url: ${url}`)
+    })

Apply the same strict URL branching in the other tests in this file.

Also applies to: 30-33, 39-42, 65-68

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cloud/dashboard/tests/MetaRulesGrid.test.tsx` around lines 19 - 22, The
useApi mock in the tests currently returns fallback data for any non-'/brains'
URL which masks incorrect endpoint usage; update the mockImplementation for
useApiMock to explicitly branch on each expected URL (e.g., '/brains', other
endpoints used in this test) and return withData(...) for each expected path,
and throw an error (or fail) for any unexpected url to ensure tests assert the
endpoint contract; apply the same strict URL-branching pattern to the other
useApiMock occurrences in this file (the blocks around lines that currently
return fallback []) so each test verifies exact API paths.

@Gradata Gradata merged commit e08b558 into main Apr 13, 2026
3 of 6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant