wulf-pulse/.planning/phases/06-analyzer-feed-new/06-01-PLAN.md

28 KiB
Raw Blame History

phase plan type wave depends_on files_modified autonomous requirements must_haves
06-analyzer-feed-new 01 execute 1
app/api/mobile/analyzer/feed/route.ts
true
ANL-01
ANL-02
ANL-06
truths artifacts key_links
GET /api/mobile/analyzer/feed returns the latest completed analyzer_analyses rows ordered by completed_at DESC, id DESC
Response shape is {analyses: AnalyzerFeedRow[], nextCursor: string | null, hasMore: boolean}
Each row contains the columns AnalyzerFeedRow consumers (Plan 06-02) need: id, ticketNumber, title, companyName, summary, confidenceScore, haikuUsed, sonnetUsed, opusUsed, needsHumanReview, completedAt, analysisVersion
Pagination is cursor-based with server-capped limit ≤ 25 (D-05)
Out-of-scope companies are filtered out by kiosk_settings scoping (D-04)
Latest analysis per ticket only — re-analyzed tickets do not appear multiple times (D-02)
Unauthenticated requests return 401 via requireAuth() (security)
path provides exports min_lines
app/api/mobile/analyzer/feed/route.ts GET handler + exported AnalyzerFeedRow + AnalyzerFeedResponse types
GET
AnalyzerFeedRow
AnalyzerFeedResponse
120
from to via pattern
app/api/mobile/analyzer/feed/route.ts analyzer_analyses, tickets, companies tables postgresClient.query() with parameterized SQL FROM analyzer_analyses.*INNER JOIN tickets.*INNER JOIN companies
from to via pattern
app/api/mobile/analyzer/feed/route.ts kiosk_settings getMobileCompanyFilter() helper duplicated inline kiosk_settings
from to via pattern
app/api/mobile/analyzer/feed/route.ts lib/auth-utils.ts requireAuth() session gate requireAuth
Build `GET /api/mobile/analyzer/feed` — the new mobile-only endpoint that returns the most-recent-first stream of completed AI ticket analyses (latest analysis per ticket) with cursor pagination and `kiosk_settings` company scoping. Export `AnalyzerFeedRow` and `AnalyzerFeedResponse` types from the route file so the Wave 2 feed page can `import type` them.

Purpose: This is the data spine for Phase 6. Plan 06-02 (feed page UI) cannot consume real data without it. The endpoint must mirror the patterns from app/api/mobile/tickets/route.ts exactly so the manager's mental model from Tickets carries over with zero learning cost — same envelope shape ({...List, nextCursor, hasMore}), same cursor encoding (base64 JSON), same kiosk_settings scoping helper, same camelCase response transform.

Output:

  • app/api/mobile/analyzer/feed/route.ts — GET handler, requireAuth-gated, cursor-paginated, scoped, with exported TS interfaces.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/REQUIREMENTS.md @.planning/phases/06-analyzer-feed-new/06-CONTEXT.md @.planning/phases/06-analyzer-feed-new/06-UI-SPEC.md @CLAUDE.md @app/api/mobile/tickets/route.ts @app/api/analyzer/tickets/route.ts @migrations/069_create_analyzer_tables.sql

From app/api/mobile/tickets/route.ts (pattern source — the new feed route mirrors this exactly):

// Exported response interfaces (mirror this shape)
export interface MobileTicket { /* fields */ }
export interface MobileTicketListResponse {
  tickets: MobileTicket[];
  nextCursor: string | null;
  hasMore: boolean;
}

// Cursor helpers (inline in the route file — D-06)
interface CursorPayload { last_activity_date: string; id: number; }
function encodeCursor(p: CursorPayload): string { return Buffer.from(JSON.stringify(p),'utf8').toString('base64'); }
function decodeCursor(raw: string | null): CursorPayload | null {
  if (!raw) return null;
  try {
    const parsed = JSON.parse(Buffer.from(raw,'base64').toString('utf8'));
    if (typeof parsed?.last_activity_date === 'string' && typeof parsed?.id === 'number') return parsed as CursorPayload;
    return null;
  } catch { return null; }
}

// Auth + scoping pattern
const { session, error: authError } = await requireAuth();
if (authError) return authError;
const { condition: companyCondition } = await getMobileCompanyFilter();
// companyCondition is "c.company_category_id IN (...) AND c.id NOT IN (...)" (or a fallback)

// LIMIT n+1 trick to detect hasMore without a COUNT query
LIMIT ${limit + 1}
const hasMore = rows.length > limit;
const sliced = hasMore ? rows.slice(0, limit) : rows;

From app/api/analyzer/tickets/route.ts (latest-version-per-ticket pattern reference, lines 319328):

LEFT JOIN LATERAL (
  SELECT aa.id, aa.triggered_at, aa.completed_at,
         aa.needs_human_review, aa.confidence_score,
         aa.aggregate_fingerprint, aa.analysis_version
    FROM analyzer_analyses aa
   WHERE aa.ticket_number = f.ticket_number
     AND aa.status = 'complete'
   ORDER BY aa.analysis_version DESC
   LIMIT 1
) latest ON TRUE

NOTE: this endpoint joins tickets→latest analysis. Phase 6's feed reverses the direction — it joins analyses→tickets (one row per latest-completed analysis) so the same physical ticket re-analyzed N times appears once. The LEFT JOIN LATERAL ... ORDER BY analysis_version DESC LIMIT 1 idiom is the same; only the FROM table changes.

From migrations/069_create_analyzer_tables.sqlanalyzer_analyses columns this plan reads:

id UUID PRIMARY KEY
ticket_number TEXT NOT NULL
autotask_ticket_id BIGINT NOT NULL
analysis_version INT NOT NULL
status TEXT (must equal 'complete')
completed_at TIMESTAMPTZ (nullable on pending; non-null when complete)
summary TEXT (nullable)
confidence_score NUMERIC(3,2) (nullable)
needs_human_review BOOLEAN NOT NULL
haiku_used BOOLEAN NOT NULL
sonnet_used BOOLEAN NOT NULL
opus_used BOOLEAN NOT NULL

UNIQUE constraint exists on (ticket_number, analysis_version), and an index idx_analyzer_analyses_ticket_version on (ticket_number, analysis_version DESC) — the LATERAL select is index-supported.

Task 1: Create route file shell with exported types and auth gate app/api/mobile/analyzer/feed/route.ts - .planning/phases/06-analyzer-feed-new/06-CONTEXT.md (D-04, D-05, D-06, D-07, D-24, D-26, D-39 — locked decisions) - .planning/phases/06-analyzer-feed-new/06-UI-SPEC.md (§"API Shape Contract" — exact field list and types for AnalyzerFeedRow + AnalyzerFeedResponse) - app/api/mobile/tickets/route.ts (PATTERN SOURCE — copy structure: imports, getMobileCompanyFilter helper, exported interfaces, encodeCursor/decodeCursor, requireAuth flow, NextResponse.json with `satisfies`, error catch shape) - CLAUDE.md (no Zod in API routes; use NextResponse.json; auth via requireAuth()) Create the new file `app/api/mobile/analyzer/feed/route.ts`. This task scaffolds the file with everything EXCEPT the SQL query and result transform (Task 2 fills those in).
  1. Add the file header imports:
import { NextRequest, NextResponse } from 'next/server';
import { requireAuth } from '@/lib/auth-utils';
import postgresClient from '@/lib/services/postgres-client';
  1. Duplicate the getMobileCompanyFilter() helper from app/api/mobile/tickets/route.ts lines 729 verbatim (D-04 says "duplicate inline; keep this phase's diff small"). The helper signature is async function getMobileCompanyFilter(): Promise<{ join: string; condition: string }>. Do NOT import it — duplicate inline. Add a // ─── Company filter helper (duplicated from /api/mobile/tickets/route.ts per D-04) ─── comment.

  2. Export the response types EXACTLY as specified in 06-UI-SPEC.md §"API Shape Contract" (D-26):

// ─── Exported response interfaces (D-26) ─────────────────────────────────────

export interface AnalyzerFeedRow {
  id: string;                    // analyzer_analyses UUID
  ticketNumber: string;
  title: string;
  companyName: string;
  summary: string | null;
  confidenceScore: number | null;
  haikuUsed: boolean;
  sonnetUsed: boolean;
  opusUsed: boolean;
  needsHumanReview: boolean;
  completedAt: string;           // ISO string
  analysisVersion: number;
}

export interface AnalyzerFeedResponse {
  analyses: AnalyzerFeedRow[];
  nextCursor: string | null;
  hasMore: boolean;
}
  1. Add cursor encode/decode helpers inline (D-06). The cursor payload shape is { completed_at: ISO string, id: uuid string } — DIFFERENT from tickets route (which uses { last_activity_date, id: number }). Use base64 of JSON:
// ─── Cursor encode/decode (inline per D-06) ──────────────────────────────────

interface CursorPayload { completed_at: string; id: string; }

function encodeCursor(p: CursorPayload): string {
  return Buffer.from(JSON.stringify(p), 'utf8').toString('base64');
}

function decodeCursor(raw: string | null): CursorPayload | null {
  if (!raw) return null;
  try {
    const parsed = JSON.parse(Buffer.from(raw, 'base64').toString('utf8'));
    if (typeof parsed?.completed_at === 'string' && typeof parsed?.id === 'string') {
      return parsed as CursorPayload;
    }
    return null;
  } catch { return null; }
}

The try/catch around JSON.parse is the cursor-injection mitigation: malformed input returns null (treated as "no cursor → first page"), never throws.

  1. Add the GET handler skeleton (Task 2 fills in the SQL):
// ─── GET handler ─────────────────────────────────────────────────────────────

export async function GET(request: NextRequest): Promise<NextResponse> {
  const { error: authError } = await requireAuth();
  if (authError) return authError;

  try {
    const { searchParams } = request.nextUrl;
    const cursorParam = searchParams.get('cursor');

    // Server-side limit cap — D-05 (page size 25, cap at 25)
    const limit = Math.min(25, Math.max(1, parseInt(searchParams.get('limit') ?? '25')));

    const cursor = decodeCursor(cursorParam);

    // TODO Task 2: build SQL, execute, transform, return.
    return NextResponse.json({ analyses: [], nextCursor: null, hasMore: false } satisfies AnalyzerFeedResponse);
  } catch (error) {
    console.error('GET /api/mobile/analyzer/feed failed:', error);
    return NextResponse.json(
      { error: 'Failed to fetch analyses', message: error instanceof Error ? error.message : 'unknown' },
      { status: 500 },
    );
  }
}
  1. NO Zod (D-39, CLAUDE.md). NO ORM (CLAUDE.md). NO new state libraries (D-38). NO request validation library — direct searchParams.get() reads.

After this task the file compiles, returns an empty list, and the types are exported for Task 2 (and Plan 06-02) to consume. npx tsc --noEmit --pretty 2>&1 | grep -E "app/api/mobile/analyzer/feed" || echo "TypeScript clean for new route file" <acceptance_criteria> - File exists: test -f app/api/mobile/analyzer/feed/route.ts - Exports the correct types: grep -E '^export interface AnalyzerFeedRow' app/api/mobile/analyzer/feed/route.ts returns one match - Exports the response envelope type: grep -E '^export interface AnalyzerFeedResponse' app/api/mobile/analyzer/feed/route.ts returns one match - Exports GET: grep -E '^export async function GET' app/api/mobile/analyzer/feed/route.ts returns one match - Auth gate present: grep -F 'requireAuth()' app/api/mobile/analyzer/feed/route.ts returns at least one match - Cursor payload shape matches D-06: grep -F 'completed_at: string' app/api/mobile/analyzer/feed/route.ts returns at least one match (NOT last_activity_date) - Cursor cap enforced: grep -E 'Math\.min\(25,' app/api/mobile/analyzer/feed/route.ts returns at least one match - getMobileCompanyFilter helper duplicated inline: grep -F 'kiosk_settings' app/api/mobile/analyzer/feed/route.ts returns at least one match - No Zod imports: grep -E "from\s+['\"]zod['\"]" app/api/mobile/analyzer/feed/route.ts returns zero matches - All response field names match camelCase per UI-SPEC: grep -E '\\b(ticketNumber|companyName|confidenceScore|haikuUsed|sonnetUsed|opusUsed|needsHumanReview|completedAt|analysisVersion):' app/api/mobile/analyzer/feed/route.ts | wc -l returns at least 9 - npx tsc --noEmit --pretty exits 0 (no type errors introduced) </acceptance_criteria> File compiles, exports the two interfaces and the GET handler, returns an empty envelope on every call. Wave 2 plans can import type { AnalyzerFeedRow, AnalyzerFeedResponse } from '@/app/api/mobile/analyzer/feed/route'.

Task 2: Implement cursor-paginated query, joins, transform, and security scoping app/api/mobile/analyzer/feed/route.ts - app/api/mobile/analyzer/feed/route.ts (current state from Task 1) - .planning/phases/06-analyzer-feed-new/06-CONTEXT.md (D-01 status='complete', D-02 latest version per ticket, D-03 ordering, D-04 scoping, D-05 cap, D-07 envelope) - app/api/mobile/tickets/route.ts (cursor seek predicate pattern, LIMIT n+1 trick, snake_case→camelCase mapping) - app/api/analyzer/tickets/route.ts (lines 319-328 — LEFT JOIN LATERAL pattern for latest version per ticket) - migrations/069_create_analyzer_tables.sql (lines 11-65 — column types and indexes; `idx_analyzer_analyses_ticket_version` on (ticket_number, analysis_version DESC) supports the LATERAL) - Test: cursor=null + no rows → returns `{analyses: [], nextCursor: null, hasMore: false}` - Test: more than 25 latest-completed analyses exist → returns 25 rows + non-null nextCursor + hasMore=true - Test: passing the returned nextCursor → returns the next 25 (older) rows + correct hasMore - Test: malformed cursor (random string) → returns first page (decodeCursor returns null, no exception) - Test: a ticket re-analyzed 3 times → appears once in the feed (the highest analysis_version among status='complete' rows) - Test: kiosk_settings excludes a company → analyses for tickets in that company do NOT appear - Test: requireAuth fails → 401 (existing behavior from Task 1 gate) - Test: rows with completed_at IS NULL appear AT THE END (NULLS LAST), tied rows broken by id DESC Replace the `// TODO Task 2` block in `app/api/mobile/analyzer/feed/route.ts` with the complete query implementation. This task does NOT add any new exports or change the file structure — only fills in the GET handler body.
  1. Apply the kiosk_settings scope. Just before building the SQL, call:
const { condition: companyCondition } = await getMobileCompanyFilter();

The helper returns condition like c.company_category_id IN (1) AND c.id NOT IN (42, 99) (or c.company_category_id = 1 fallback). The helper aliases the companies table as c — your SQL must alias companies as c to match (D-04). This is the security boundary for ANL-01: a manager must not see analyses for tickets in companies outside their kiosk scope.

  1. Build the predicate list. Mirror the conditions: string[] + params: unknown[] pattern from app/api/mobile/tickets/route.ts lines 125166:
const conditions: string[] = [
  "aa.status = 'complete'",          // D-01
  't.is_deleted = false',            // hide soft-deleted tickets
  companyCondition,                  // D-04 (kiosk_settings scoping)
];
const params: unknown[] = [];
  1. Cursor seek predicate (D-03, D-06). When cursor is non-null, append the keyset predicate (completed_at, id) < (cursor.completed_at, cursor.id):
if (cursor) {
  params.push(cursor.completed_at);
  params.push(cursor.id);
  conditions.push(`(aa.completed_at, aa.id) < ($${params.length - 1}::timestamptz, $${params.length}::uuid)`);
}

The cast $N::uuid is critical because analyzer_analyses.id is UUID (not int like tickets.id).

  1. The query — analyses-first, with LATERAL latest-per-ticket guard (D-02, D-03). The shape: from analyzer_analyses rows, only include the row if it IS the latest analysis_version for that ticket_number among status='complete' rows. This naturally produces "one row per ticket, latest first":
WITH latest_per_ticket AS (
  SELECT DISTINCT ON (ticket_number) id
    FROM analyzer_analyses
   WHERE status = 'complete'
   ORDER BY ticket_number, analysis_version DESC
)
SELECT aa.id, aa.ticket_number, aa.completed_at, aa.analysis_version,
       aa.summary, aa.confidence_score, aa.needs_human_review,
       aa.haiku_used, aa.sonnet_used, aa.opus_used,
       t.title,
       c.company_name
  FROM analyzer_analyses aa
  INNER JOIN latest_per_ticket l ON l.id = aa.id
  INNER JOIN tickets t ON t.ticket_number = aa.ticket_number AND t.is_deleted = false
  INNER JOIN companies c ON c.id = t.company_id
 WHERE ${conditions.filter excluding the t.is_deleted and aa.status which are now in the CTE/inner join  keep only companyCondition + cursor predicate}
 ORDER BY aa.completed_at DESC NULLS LAST, aa.id DESC
 LIMIT ${limit + 1}

Practical implementation: keep companyCondition and the cursor predicate in the WHERE; absorb aa.status='complete' into the CTE and t.is_deleted=false into the JOIN. Final WHERE has 12 predicates. Use LIMIT ${limit + 1} so you can detect hasMore without a COUNT (mirrors tickets route line 183).

Build the final SQL string by interpolating ${conditions.join(' AND ')} and ${limit + 1}. Pass params to postgresClient.query(sql, params).

  1. Transform rows snake_case → camelCase (CLAUDE.md: manual transform, no ORM). Map each pg row to AnalyzerFeedRow:
const rows = result.rows;
const hasMore = rows.length > limit;
const sliced = hasMore ? rows.slice(0, limit) : rows;

const analyses: AnalyzerFeedRow[] = sliced.map(row => ({
  id: String(row.id),
  ticketNumber: row.ticket_number,
  title: row.title ?? '',
  companyName: row.company_name ?? '',
  summary: row.summary ?? null,
  confidenceScore: row.confidence_score === null ? null : Number(row.confidence_score),
  haikuUsed: row.haiku_used,
  sonnetUsed: row.sonnet_used,
  opusUsed: row.opus_used,
  needsHumanReview: row.needs_human_review,
  completedAt: row.completed_at instanceof Date ? row.completed_at.toISOString() : String(row.completed_at),
  analysisVersion: row.analysis_version,
}));

confidence_score comes back from pg as a string (NUMERIC type) → coerce with Number(). completed_at is a Date from pg → .toISOString(). (Compare to app/api/analyzer/tickets/route.ts:362 for the same .toISOString() pattern.)

  1. Compute nextCursor from the LAST row of sliced (D-06):
const nextCursor = hasMore && analyses.length > 0
  ? encodeCursor({
      completed_at: analyses[analyses.length - 1].completedAt,
      id: analyses[analyses.length - 1].id,
    })
  : null;

Note the cursor's completed_at is the ISO string already in analyses[N].completedAt — consistent with the WHERE predicate's ::timestamptz cast.

  1. Return with satisfies AnalyzerFeedResponse:
return NextResponse.json({ analyses, nextCursor, hasMore } satisfies AnalyzerFeedResponse);
  1. Security review (per <security_threat_model>):
    • cursor injection → mitigated by decodeCursor try/catch + shape validation (returns null on malformed input)
    • kiosk scoping → mitigated by getMobileCompanyFilter() companyCondition (T-06-02 in threat model)
    • payload leakage → only the columns the row card needs are returned; NOT model_traces, NOT human_review_reasons, NOT itglue_docs_referenced, NOT IT Glue doc bodies (T-06-05)
    • rate limiting → server-side Math.min(25, ...) cap (T-06-04) npx tsc --noEmit --pretty 2>&1 | (! grep -E "app/api/mobile/analyzer/feed") <acceptance_criteria>
    • npx tsc --noEmit --pretty exits 0 (no errors)
    • SQL contains the latest-per-ticket CTE: grep -E 'DISTINCT ON \(ticket_number\)' app/api/mobile/analyzer/feed/route.ts returns at least one match
    • SQL filters status complete only: grep -E "status\s*=\s*'complete'" app/api/mobile/analyzer/feed/route.ts returns at least one match
    • Ordering matches D-03: grep -F "ORDER BY aa.completed_at DESC NULLS LAST, aa.id DESC" app/api/mobile/analyzer/feed/route.ts returns at least one match
    • Cursor seek predicate uses correct types: grep -E '\\(aa\\.completed_at, aa\\.id\\) < \\(\\$' app/api/mobile/analyzer/feed/route.ts returns at least one match
    • kiosk scoping wired: grep -F 'getMobileCompanyFilter()' app/api/mobile/analyzer/feed/route.ts returns at least one match
    • LIMIT n+1 trick used: grep -E 'LIMIT \\$\\{limit \\+ 1\\}' app/api/mobile/analyzer/feed/route.ts returns at least one match (or equivalent pattern)
    • hasMore detection: grep -E 'rows\\.length > limit' app/api/mobile/analyzer/feed/route.ts returns at least one match
    • camelCase transform present: grep -E 'ticketNumber: row\\.ticket_number' app/api/mobile/analyzer/feed/route.ts returns at least one match
    • Companies table aliased as c: grep -E 'INNER JOIN companies c\\b' app/api/mobile/analyzer/feed/route.ts returns at least one match (matches helper's expectation)
    • Tickets joined: grep -E 'INNER JOIN tickets t\\b' app/api/mobile/analyzer/feed/route.ts returns at least one match
    • Sensitive columns NOT selected: grep -E "(model_traces|itglue_docs_referenced|human_review_reasons)" app/api/mobile/analyzer/feed/route.ts returns ZERO matches (security: payload minimization)
    • Manual smoke test: curl -s 'http://localhost:3100/api/mobile/analyzer/feed' -H "Cookie: <auth>" returns JSON with analyses array (or 401 if not authed) — NOT a 500. (Optional; auth-gated, dev-only.) </acceptance_criteria> Endpoint returns the latest-completed analysis per ticket, ordered by completed_at DESC, id DESC, scoped by kiosk_settings, paginated with cursor (≤ 25/page). The response envelope matches AnalyzerFeedResponse. Plan 06-02 can fetch and render real data from this route.

<threat_model>

Trust Boundaries

Boundary Description
client → API (/api/mobile/analyzer/feed) Untrusted query string (cursor, limit) crosses into server; session cookie verified
API → Postgres Parameterized queries; no string interpolation of user input
API → response payload Server controls which columns leave the trust boundary; potentially-sensitive analyzer fields must NOT cross

STRIDE Threat Register

Threat ID Category Component Disposition Mitigation Plan
T-06-01 Spoofing / Auth Bypass GET /api/mobile/analyzer/feed mitigate Call requireAuth() from lib/auth-utils.ts BEFORE any DB query (Task 1, line const { error: authError } = await requireAuth(); if (authError) return authError;). Better Auth session cookie is the gate; no bypass path. ASVS L1 §V2.1.
T-06-02 Information Disclosure (IDOR / cross-tenant read) feed route SQL mitigate Apply getMobileCompanyFilter() companyCondition to the WHERE clause (Task 2). Without it a manager could list analyses for tickets in companies outside their kiosk scope. The helper reads kiosk_settings.mobile_company_category_ids and mobile_excluded_company_ids and emits a SQL fragment scoped to c.*. The companies table alias c MUST be used in JOIN to match the helper.
T-06-03 Tampering (cursor injection) decodeCursor() mitigate Wrap JSON.parse(Buffer.from(raw,'base64').toString('utf8')) in try/catch. Shape-validate decoded object: only return non-null when completed_at is a string AND id is a string. Any malformed input returns null → handler treats as "first page". Failure mode is fail-closed (no SQL injection vector — params are still parameterized; worst case is a cursor that doesn't match any row, returning empty).
T-06-04 Denial of Service (large pagination) feed route limit param mitigate Server-side Math.min(25, Math.max(1, ...)) cap on limit query param (Task 1). Even if a client sends ?limit=10000, the server reads at most 26 rows (limit + 1 for hasMore detection). LIMIT in SQL is integer-interpolated AFTER the cap.
T-06-05 Information Disclosure (sensitive analyzer payload leakage) feed route SELECT list mitigate Whitelist columns in the SELECT — only the 12 fields AnalyzerFeedRow declares. Do NOT select model_traces, itglue_docs_referenced, human_review_reasons, or error_message. These can contain client data, IT Glue references, and IT Glue doc bodies (per migration 069 comments). The detail page (Plan 06-03) reuses the existing /api/analyzer/analyses/[id] endpoint which is already auth-gated, but its IDOR posture is OUT OF SCOPE for this plan and is flagged in Plan 06-03's threat model.
T-06-06 Repudiation feed route logging accept Auth gate logs are produced by Better Auth middleware; per-request access audit logging is NOT implemented for /api/mobile/* today (Phase 4 didn't add it either). Risk is low: read-only endpoint, no state change. Future phase can add structured access logs if compliance requires.
T-06-07 Information Disclosure (SQL error messages) error catch block mitigate The catch block returns error instanceof Error ? error.message : 'unknown'. Postgres error messages can include schema details. For a read-only endpoint with parameterized SQL the leakage surface is small (no user-controlled SQL fragments); the team's existing /api/mobile/tickets route uses the same pattern, so this matches established convention. Error is also logged to console.error for server-side observability.
</threat_model>
- `npx tsc --noEmit --pretty` exits 0 - `grep -RE "from\s+['\"]@/app/api/mobile/analyzer/feed/route['\"]" app/ components/ 2>/dev/null` returns nothing yet (Wave 2 will create the consumer) - Manual smoke (developer): `curl -s 'http://localhost:3100/api/mobile/analyzer/feed' -H "Cookie: better-auth.session_token="` returns `{analyses: [...], nextCursor, hasMore}` JSON - Pagination smoke: capture `nextCursor` from response 1, pass as `?cursor=`, verify response 2 returns OLDER rows (or empty if total < 25)

<success_criteria>

  1. app/api/mobile/analyzer/feed/route.ts exists and exports GET, AnalyzerFeedRow, AnalyzerFeedResponse
  2. The endpoint returns ONE row per ticket (latest analysis_version among status='complete' rows) — NOT multiple rows for re-analyzed tickets
  3. Ordering is completed_at DESC NULLS LAST, id DESC
  4. Out-of-scope companies (per kiosk_settings) are excluded
  5. Cursor pagination works: passing the returned nextCursor returns the next page; null nextCursor means exhausted
  6. Server-side limit cap of 25 is enforced regardless of ?limit= value
  7. Unauthenticated requests return 401 (via requireAuth())
  8. Response payload contains ONLY the 12 fields declared by AnalyzerFeedRow (no model_traces, no IT Glue bodies, no human_review_reasons array)
  9. npx tsc --noEmit --pretty passes </success_criteria>
After completion, create `.planning/phases/06-analyzer-feed-new/06-01-SUMMARY.md` documenting: - The exported types (with their final field list) - The SQL approach (DISTINCT ON CTE + JOIN, ordering, cursor predicate) - How `kiosk_settings` scoping is applied (companies aliased as `c`) - Notes for Plan 06-02 executors: import path is `@/app/api/mobile/analyzer/feed/route`; sample request URL is `/api/mobile/analyzer/feed?limit=25`