{
  "slug": "tenant-portals",
  "spec_file": "docs/specs/2026-05-30-tenant-portals.md",
  "findings": [
    {
      "id": "tenant-portals-001",
      "severity": "P0",
      "type": "security",
      "summary": "portal_sessions table and stateful JWT verification absent — sessions cannot be revoked",
      "spec_ref": "Portal JWT expiry and revocation — portal_sessions schema; Permissions — stateful verify on each request",
      "code_ref": "packages/db/migrations/*.sql",
      "evidence": "grep portal_session across packages/db returns zero SQL matches; no packages/db/src/schema/portalSessions.ts; no packages/auth/src/portal/*; portal router verifyPortalCookie (apps/zync-api/src/routes/portal/index.ts:54) only verifyJwt signature with no DB lookup.",
      "repro": "Staff freeze portal user or password reset → existing portal_session cookie remains valid until JWT exp (8h).",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tenant-portals-002",
      "severity": "P0",
      "type": "security",
      "summary": "Portal login authenticates any customer_contacts email — skips customer_portal_users status=active",
      "spec_ref": "Authentication — customer_portal_users row with status=active; Permissions — portal RBAC",
      "code_ref": "apps/zync-api/src/routes/portal/index.ts:103",
      "evidence": "POST /auth/request calls findCustomerContactByEmail only (packages/db/src/queries/customer-portal.ts:81); no join/filter on customer_portal_users.status. Frozen or never-invited contacts with an email row can receive tokens.",
      "repro": "Contact exists, no active customer_portal_users row (or status=frozen) → POST /api/portal/auth/request + verify → portal_session issued.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tenant-portals-003",
      "severity": "P0",
      "type": "broken",
      "summary": "Magic-link request never sends email — token only inserted to DB",
      "spec_ref": "Authentication — magic link email to customer contact; API POST /api/portal/auth/magic",
      "code_ref": "apps/zync-api/src/routes/portal/index.ts:88",
      "evidence": "Handler calls generatePortalToken (packages/db/src/queries/portal-auth.ts:59) which inserts magic_link_tokens only; grep sendEmail in apps/zync-api/src/routes/portal returns zero matches.",
      "repro": "POST /api/portal/auth/request { email, tenantId } → 200 { ok:true } but no email queued; customer must manually paste token in UI.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tenant-portals-004",
      "severity": "P0",
      "type": "broken",
      "summary": "Portal login API requires tenantId UUID but SPA routes use tenant slug (e.g. demo)",
      "spec_ref": "Customer Portal — /portal/{tenantSlug}/login; API login body tenantSlug",
      "code_ref": "apps/zync-api/src/routes/portal/index.ts:77",
      "evidence": "requestSchema requires tenantId: z.string().uuid(); login.tsx:24 sends tenantId from useParams (main.tsx:77 path portal/:tenantId/login); Playwright test navigates /portal/demo/login (apps/zync-app/tests/portal-login.spec.ts:12).",
      "repro": "POST /api/portal/auth/request { email, tenantId: 'demo' } → 400 Invalid request; real slug-based URLs cannot authenticate.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tenant-portals-005",
      "severity": "P0",
      "type": "security",
      "summary": "No cross-tenant slug validation — JWT tid never checked against URL tenantSlug",
      "spec_ref": "Permissions — tenantId from JWT matches resource tenant_id; Plan Task 3 — portal JWT for tenant A under slugB → 403",
      "code_ref": "apps/zync-api/src/routes/portal/index.ts:157",
      "evidence": "Data handlers use ps.tid from cookie JWT only; no tenant slug resolution or assert payload.tenantId matches request path/context. grep tenantSlug in apps/zync-api/src/routes/portal returns zero.",
      "repro": "Obtain portal JWT for tenant A; browse tenant B portal SPA routes with same cookie → API returns tenant A data under tenant B branded URL.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tenant-portals-006",
      "severity": "P0",
      "type": "broken",
      "summary": "Portal KB API reads staff session (c.get('session')) not portal_session cookie",
      "spec_ref": "API GET /api/portal/kb/spaces; Permissions — portal_customer JWT required",
      "code_ref": "apps/zync-api/src/routes/portal-kb.ts:25",
      "evidence": "getPortalSession reads c.get('session') set by authMiddleware; portal-kb mounted at /api/portal-kb (routes/index.ts:244) with no portal cookie middleware. Portal router does not mount kb routes.",
      "repro": "Authenticated portal customer with portal_session cookie → GET /api/portal-kb/spaces → 401 Unauthorized.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tenant-portals-007",
      "severity": "P0",
      "type": "broken",
      "summary": "Portal KB SPA calls nonexistent /portal/:tenantSlug/api/kb/* proxy paths",
      "spec_ref": "Shared Knowledge Base — /portal/{tenantSlug}/kb; API GET /api/portal/kb/spaces",
      "code_ref": "apps/zync-app/src/features/portal/kb/PortalKbPage.tsx:19",
      "evidence": "PortalKbPage fetch `/portal/${tenantSlug}/api/kb/spaces`; grep 'portal.*api/kb' in apps/zync-api and wrangler configs returns zero worker proxy. Actual API is /api/portal-kb/spaces.",
      "repro": "Navigate /portal/acme/kb → network request to /portal/acme/api/kb/spaces → 404 from SPA host.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tenant-portals-008",
      "severity": "P0",
      "type": "security",
      "summary": "Portal invoice list exposes non-portal statuses (only excludes DRAFT)",
      "spec_ref": "My Invoices — filtered to TAX_ISSUED + PAID + PARTIALLY_PAID; DRAFT and CANCELLED not visible",
      "code_ref": "packages/db/src/queries/customer-portal.ts:132",
      "evidence": "getPortalInvoices WHERE uses ne(invoices.status,'DRAFT') only; CANCELLED/VOID/WRITTEN_OFF etc. returned to portal customer.",
      "repro": "Customer with CANCELLED invoice → GET /api/portal/invoices includes cancelled row.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tenant-portals-009",
      "severity": "P1",
      "type": "missing",
      "summary": "portalAuthMiddleware and packages/auth portal token/session helpers not implemented",
      "spec_ref": "Plan Tasks 2–3; Permissions — role=portal_customer + portal_sessions lookup",
      "code_ref": "packages/auth/src/index.ts",
      "evidence": "grep signPortalSession|verifyPortalToken|portalAuthMiddleware across repo (excluding worktrees) returns zero; no apps/zync-api/src/middleware/portalAuth.ts.",
      "repro": "N/A — components absent.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tenant-portals-010",
      "severity": "P1",
      "type": "missing",
      "summary": "Email+password portal login endpoint missing",
      "spec_ref": "Authentication — email+password via customer_portal_users; API POST /api/portal/auth/login",
      "code_ref": "apps/zync-api/src/routes/portal/index.ts",
      "evidence": "portal router defines only /auth/request and /auth/verify (lines 88–153); grep '/auth/login' under apps/zync-api/src/routes/portal returns zero.",
      "repro": "POST /api/portal/auth/login → 404.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tenant-portals-011",
      "severity": "P1",
      "type": "missing",
      "summary": "Portal password reset and session refresh/logout endpoints missing",
      "spec_ref": "Portal Password Reset; Session Refresh — POST /api/portal/auth/forgot-password, reset-password, refresh, logout",
      "code_ref": "apps/zync-api/src/routes/portal/index.ts",
      "evidence": "grep forgot-password|reset-password|/auth/refresh|/auth/logout in apps/zync-api/src/routes/portal returns zero; staff equivalents exist at apps/zync-api/src/routes/auth/password.ts.",
      "repro": "POST /api/portal/auth/forgot-password → 404.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tenant-portals-012",
      "severity": "P1",
      "type": "missing",
      "summary": "GET /api/portal/auth/magic/verify redirect flow missing (only POST /auth/verify JSON)",
      "spec_ref": "Authentication — magic link /portal/{tenantSlug}/magic?token=; GET /api/portal/auth/magic/verify",
      "code_ref": "apps/zync-api/src/routes/portal/index.ts:115",
      "evidence": "Only POST /auth/verify returns JSON + Set-Cookie; no GET handler; no SPA route portal/:slug/magic (main.tsx routes omit magic callback).",
      "repro": "Click email link /portal/acme/magic?token=… → no matching SPA route or GET verify handler.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tenant-portals-013",
      "severity": "P1",
      "type": "missing",
      "summary": "GET /api/portal/dashboard endpoint missing",
      "spec_ref": "My Dashboard widgets; API GET /api/portal/dashboard",
      "code_ref": "apps/zync-app/src/routes/portal/dashboard.tsx:54",
      "evidence": "Dashboard page aggregates via separate GET /api/portal/invoices|projects|tickets; grep '/dashboard' in apps/zync-api/src/routes/portal returns zero.",
      "repro": "GET /api/portal/dashboard → 404.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tenant-portals-014",
      "severity": "P1",
      "type": "missing",
      "summary": "Portal invoice HTML view and Pay Now initiation endpoints missing",
      "spec_ref": "My Invoices — GET /api/portal/invoices/:id/html; POST /api/portal/invoices/:id/pay",
      "code_ref": "apps/zync-api/src/routes/portal/index.ts",
      "evidence": "Portal router has GET /invoices list only (line 157); staff GET /api/invoices/:id/html exists at apps/zync-api/src/routes/invoices/index.ts:614 behind staff auth. No portal /invoices/:id/pay route.",
      "repro": "POST /api/portal/invoices/{id}/pay with portal_session → 404.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tenant-portals-015",
      "severity": "P1",
      "type": "missing",
      "summary": "Portal ticket detail and reply endpoints missing",
      "spec_ref": "My Support Tickets — thread view; GET /api/portal/tickets/:id; POST /api/portal/tickets/:id/reply",
      "code_ref": "apps/zync-api/src/routes/portal/index.ts:179",
      "evidence": "Only GET/POST collection /tickets implemented; createPortalTicket inserts tickets row only (packages/db/src/queries/customer-portal.ts:254) — no ticket_messages with author_type=customer.",
      "repro": "GET /api/portal/tickets/{id} → 404; created ticket has no message thread.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tenant-portals-016",
      "severity": "P1",
      "type": "missing",
      "summary": "GET /api/portal/proposals endpoint missing",
      "spec_ref": "Proposals — /portal/{tenantSlug}/proposals; API GET /api/portal/proposals",
      "code_ref": "apps/zync-api/src/routes/portal/index.ts",
      "evidence": "grep proposals in apps/zync-api/src/routes/portal returns zero; no SPA route portal/:id/proposals in main.tsx.",
      "repro": "GET /api/portal/proposals → 404.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tenant-portals-017",
      "severity": "P1",
      "type": "missing",
      "summary": "Portal profile and password-change endpoints missing",
      "spec_ref": "Profile — PATCH /api/portal/profile; PATCH /api/portal/profile/password",
      "code_ref": "apps/zync-api/src/routes/portal/index.ts",
      "evidence": "grep profile in apps/zync-api/src/routes/portal returns zero; no apps/zync-app/src/routes/portal/profile.tsx or PortalShell.",
      "repro": "PATCH /api/portal/profile → 404.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tenant-portals-018",
      "severity": "P1",
      "type": "missing",
      "summary": "Schema deltas customer_portal_users.locale and tenant_settings.portal_max_session_hours absent",
      "spec_ref": "Locale & RTL — portal_users.locale; Session extension — portal_max_session_hours",
      "code_ref": "packages/db/src/schema/customers.ts:99",
      "evidence": "customerPortalUsers columns end at lastLoginAt (line 122) — no locale; grep portal_max_session_hours across packages/db returns zero.",
      "repro": "Inspect DB/migrations — columns not present.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tenant-portals-019",
      "severity": "P1",
      "type": "missing",
      "summary": "Staff revoke-all-portal-sessions endpoint per plan not implemented",
      "spec_ref": "Force re-auth — POST /api/customers/:id/portal-users/:uid/revoke-sessions; Plan Task 10",
      "code_ref": "apps/zync-api/src/routes/customers/portal.ts",
      "evidence": "customers/portal.ts exposes freeze/unfreeze only; revokePortalUser (packages/db/src/queries/settings-portal.ts:186) sets status=frozen — no portal_sessions revocation. Settings uses POST /api/settings/portal/users/:contactId/revoke (different path/behavior).",
      "repro": "POST /api/customers/{id}/portal-users/{uid}/revoke-sessions → 404.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tenant-portals-020",
      "severity": "P1",
      "type": "missing",
      "summary": "Portal SPA infrastructure missing — PortalShell, usePortalSession, portalLocale, session-expiry guard",
      "spec_ref": "Plan Tasks 11–14; Session expiry detection; Locale resolution",
      "code_ref": "apps/zync-app/src/main.tsx:77",
      "evidence": "grep PortalShell|usePortalSession|portalLocale in apps/zync-app returns zero; pages mounted individually without shared shell; no JWT exp check or refresh call.",
      "repro": "Expired portal JWT → pages redirect on 401 only; no session_expired banner or portal_return_to restore.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tenant-portals-021",
      "severity": "P1",
      "type": "missing",
      "summary": "Customer portal UI pages missing — projects, proposals, profile, ticket detail, forgot/reset password, magic callback, root dashboard route",
      "spec_ref": "Pages — /portal/{tenantSlug}/, /projects, /proposals, /profile, login flows",
      "code_ref": "apps/zync-app/src/main.tsx:77",
      "evidence": "Registered portal routes: login, dashboard, invoices, tickets, files, kb only; no /portal/:slug/projects|proposals|profile|reset-password|magic; spec root /portal/{slug}/ unregistered (dashboard at /dashboard subpath).",
      "repro": "Navigate /portal/demo/projects → no Route match.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tenant-portals-022",
      "severity": "P1",
      "type": "missing",
      "summary": "Staff portal RBAC sidebar by role (STAFF/READ_ONLY) not implemented",
      "spec_ref": "Staff Portal — Visible modules by role; STAFF vs READ_ONLY sidebar",
      "code_ref": "apps/zync-app/src/shell/nav-model.ts:42",
      "evidence": "NAV_MODEL gates items by permission keys only; grep STAFF|READ_ONLY role-based nav filtering in apps/zync-app/src/shell returns zero beyond settings copy.",
      "repro": "STAFF user with broad permissions sees full sidebar per permission set, not role-limited assignment views.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tenant-portals-023",
      "severity": "P1",
      "type": "missing",
      "summary": "GET /api/portal/files/:id/download missing on portal router (UI calls wrong path)",
      "spec_ref": "Portal file download for customer-owned files",
      "code_ref": "apps/zync-app/src/routes/portal/files.tsx:59",
      "evidence": "UI fetches /api/portal/files/{id}/download; portal/files.ts API has list/upload/create only. Download exists at /api/portal-files/:id/download (apps/zync-api/src/routes/portal-files.ts:214) with customerId ownership check.",
      "repro": "Portal files page download button → GET /api/portal/files/{id}/download → 404.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tenant-portals-024",
      "severity": "P2",
      "type": "divergent",
      "summary": "Portal JWT uses type:'portal' not role:'portal_customer' and cookie name portal_session",
      "spec_ref": "Authentication — JWT role=portal_customer; cookie zync_portal_session",
      "code_ref": "apps/zync-api/src/routes/portal/index.ts:128",
      "evidence": "Payload { type:'portal', tid, customerId, sub }; Set-Cookie portal_session= (line 141); spec/plan require role portal_customer and zync_portal_session.",
      "repro": "Decode portal_session JWT — no role claim.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tenant-portals-025",
      "severity": "P2",
      "type": "divergent",
      "summary": "Portal JWT TTL 8h stateless vs spec 4h stateful with refresh rotation",
      "spec_ref": "Portal JWT expiry — 4-hour TTL; POST /api/portal/auth/refresh",
      "code_ref": "apps/zync-api/src/routes/portal/index.ts:38",
      "evidence": "PORTAL_JWT_TTL = '8h'; no refresh endpoint; no portal_max_session_hours enforcement.",
      "repro": "Issue portal_session → valid for 8h with no server-side revocation.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tenant-portals-026",
      "severity": "P2",
      "type": "divergent",
      "summary": "Portal ticket create schema diverges — title/description vs subject/body/category/attachments",
      "spec_ref": "Create ticket — subject, body, category, attachments; ticket_messages author_type=customer",
      "code_ref": "packages/db/src/queries/customer-portal.ts:20",
      "evidence": "createPortalTicketSchema uses title/description/priority; inserts tickets.description directly, no ticket_messages row, no categoryId, no R2 attachments.",
      "repro": "POST /api/portal/tickets { subject, body, categoryId } → 400 validation error.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tenant-portals-027",
      "severity": "P2",
      "type": "divergent",
      "summary": "Portal locale follows staff SPA LocaleProvider (browser/user pref) not portal resolution chain",
      "spec_ref": "Locale & RTL — portal_users.locale → tenant default → he; separate from staff SPA",
      "code_ref": "apps/zync-app/src/main.tsx:65",
      "evidence": "Portal routes render inside global LocaleProvider initialLocale={getUserLocale()} (staff preference from localStorage); login.tsx:84 uses useLocale() — not customer_portal_users.locale or tenant country_code.",
      "repro": "Portal login with zync_locale=en shows English regardless of tenant/customer portal locale setting.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tenant-portals-028",
      "severity": "P2",
      "type": "divergent",
      "summary": "White-label portal branding (logo, brand color, portal title) not injected into portal shell",
      "spec_ref": "White-Label Customization — CSS variables injected server-side",
      "code_ref": "apps/zync-app/src/routes/portal/login.tsx:100",
      "evidence": "Portal pages use generic ThemeProvider/CSS vars; grep portal_primary_color|portal_logo in apps/zync-app/src/routes/portal returns zero; settings stored via getPortalSettings (settings-portal.ts) unused in portal UI.",
      "repro": "Configure portal logo/color in /settings/portal → customer portal login unchanged.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tenant-portals-029",
      "severity": "P2",
      "type": "divergent",
      "summary": "Dashboard client-side widget logic uses wrong invoice/ticket status sets",
      "spec_ref": "My Dashboard — outstanding TAX_ISSUED/PARTIALLY_PAID; open tickets open/in_progress/pending_customer",
      "code_ref": "apps/zync-app/src/routes/portal/dashboard.tsx:87",
      "evidence": "openInvoices filter includes SENT, APPROVED, PARTIALLY_PAID (line 88); openTickets excludes resolved/closed only (line 95) — not spec status sets; no unread KB widget.",
      "repro": "Dashboard shows counts diverging from spec widget definitions.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tenant-portals-030",
      "severity": "P2",
      "type": "divergent",
      "summary": "Magic-link auth endpoints and responses diverge from spec paths and 204-always semantics",
      "spec_ref": "API POST /api/portal/auth/magic; POST /api/portal/auth/login; forgot-password always 204",
      "code_ref": "apps/zync-api/src/routes/portal/index.ts:88",
      "evidence": "Implemented as POST /auth/request and /auth/verify returning 200 JSON { ok:true }; spec uses /auth/magic, /auth/login, 204 responses for enumeration-safe flows.",
      "repro": "POST /api/portal/auth/magic → 404; POST /auth/request → 200 not 204.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tenant-portals-031",
      "severity": "P2",
      "type": "divergent",
      "summary": "Portal KB mounted at /api/portal-kb not /api/portal/kb per spec",
      "spec_ref": "API GET /api/portal/kb/spaces; GET /api/portal/kb/articles/:id",
      "code_ref": "apps/zync-api/src/routes/index.ts:244",
      "evidence": "routes.route('/portal-kb', portalKbRouter) separate from portalRouter at /portal.",
      "repro": "GET /api/portal/kb/spaces → 404; GET /api/portal-kb/spaces → 401 (no portal session middleware).",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tenant-portals-032",
      "severity": "P2",
      "type": "divergent",
      "summary": "Portal magic token TTL inconsistent (15min comment, 1h DB, manual paste UX)",
      "spec_ref": "Magic link TTL 1 hour; Plan Task 4",
      "code_ref": "apps/zync-api/src/routes/portal/index.ts:42",
      "evidence": "index.ts PORTAL_TOKEN_TTL_MS = 15*60*1000 (line 43); portal-auth.ts PORTAL_TOKEN_TTL_MS = 60*60*1000 (line 17); login copy says 1 hour.",
      "repro": "Token inserted with 1h expiry but route comment says 15 minutes.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tenant-portals-033",
      "severity": "P2",
      "type": "divergent",
      "summary": "Staff 'revoke portal user' freezes account instead of revoking portal_sessions",
      "spec_ref": "Force re-auth — portal_sessions.revoked_at; Plan Task 10",
      "code_ref": "packages/db/src/queries/settings-portal.ts:191",
      "evidence": "revokePortalUser UPDATE customer_portal_users SET status='frozen'; no portal_sessions table to update; active JWTs remain valid (see tenant-portals-001).",
      "repro": "POST /api/settings/portal/users/{contactId}/revoke → user frozen but portal_session cookie still works.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tenant-portals-034",
      "severity": "P2",
      "type": "divergent",
      "summary": "Portal list queries scope tenant+customer correctly but lack requirePortalScope helper pattern",
      "spec_ref": "Permissions — tenantId match + customerId scope on every route",
      "code_ref": "packages/db/src/queries/customer-portal.ts:111",
      "evidence": "getPortalInvoices/getPortalProjects/getPortalTickets filter eq(tenantId)+eq(customerId) — good for collection endpoints; no shared middleware helper; ID-based routes (invoice html, ticket detail) absent so ownership checks untested.",
      "repro": "Collection endpoints safe; resource-by-id endpoints not implemented to verify 403 on foreign IDs.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tenant-portals-035",
      "severity": "P3",
      "type": "cosmetic",
      "summary": "Session-expired banner and portal_return_to redirect flow missing",
      "spec_ref": "Session expiry detection — ?session_expired=1 banner; portal_return_to restore",
      "code_ref": "apps/zync-app/src/routes/portal/login.tsx:81",
      "evidence": "Login page has no searchParams session_expired handling; grep portal_return_to in apps/zync-app returns zero.",
      "repro": "Visit /portal/demo/login?session_expired=1 → no banner.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tenant-portals-036",
      "severity": "P3",
      "type": "cosmetic",
      "summary": "Invoices portal page lacks status filter dropdown and Pay Now / View HTML actions",
      "spec_ref": "My Invoices — filter All·Tax Issued·Paid·Partially Paid; View HTML; Pay Now",
      "code_ref": "apps/zync-app/src/routes/portal/invoices.tsx",
      "evidence": "Page renders list with badges only; grep pay|filter|html in file returns no action handlers; no POST /pay integration.",
      "repro": "Portal invoices page shows list without pay or view controls.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tenant-portals-037",
      "severity": "P3",
      "type": "cosmetic",
      "summary": "Customer detail Portal tab lacks Revoke all sessions button per spec",
      "spec_ref": "Force re-auth — customer detail Portal tab [Revoke all sessions]",
      "code_ref": "apps/zync-app/src/modules/customers/tabs/PortalUsersTab.tsx:33",
      "evidence": "Tab shows freeze/unfreeze only; grep 'Revoke all sessions' in PortalUsersTab returns zero.",
      "repro": "Customer detail → Portal tab → no revoke-sessions action.",
      "fix_direction": "code_to_spec"
    },
    {
      "id": "tenant-portals-038",
      "severity": "P3",
      "type": "cosmetic",
      "summary": "Dashboard missing spec Pay Now CTA on outstanding balance and unread KB widget",
      "spec_ref": "My Dashboard — Pay Now on outstanding balance; Unread KB Articles widget",
      "code_ref": "apps/zync-app/src/routes/portal/dashboard.tsx:97",
      "evidence": "Three widgets only (invoices/projects/tickets); no KB stat; no Pay Now button routing to invoices payment flow.",
      "repro": "Portal dashboard lacks fourth widget and pay CTA from spec mockup.",
      "fix_direction": "code_to_spec"
    }
  ],
  "summary": {
    "P0": 8,
    "P1": 15,
    "P2": 11,
    "P3": 4,
    "total": 38
  }
}
