wulf-pulse/docs/wulf-pulse-ticket-analyzer-build-notes.md
lorentz 1112a06afe feat: RMM Overshell, IT Glue audit/write-back, LogLift, link-aware bundles, dashboard overhaul
- RMM Overshell (migration 077): admin page, dispatch UI, executor/worker, target
  resolver, script registry (AD/DHCP/DNS/event-log/services/software/network/loglift)
- LogLift evidence pipeline (migration 078): upload webhook, B2 storage client,
  receiver/matcher, EventLogCollector PowerShell script
- IT Glue audit + write-back (migrations 075, 076): asset-audit runner, ticket
  xrefs, applications/configurations browse pages + apply/revert/audit endpoints
- Link-aware analyzer bundles (migration 073) + provider toggle (migration 074):
  link-discovery service, OpenRouter LLM provider, related-tickets/itglue-suggestion
  panels, analyze-bundle endpoint
- Endpoint data model + device-link reconciliation (migrations 079, 080): conflicts
  admin page, reconciler service, resolve endpoints
- Dashboard overhaul: integration-health service + alerts, overview/health endpoints
- Permissions: add itglue + rmm scopes; middleware: public /api/rmm/loglift route

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 07:13:18 -04:00

78 KiB
Raw Permalink Blame History

AI Ticket Analyzer — Build Notes

A running journal of the multi-phase build for the AI Ticket Analyzer feature. Captures what was delivered each phase, design decisions worth flagging, and what was deliberately left out. Spec lives in wulf-pulse-ticket-analyzer-prompt.md.

This file is updated after each phase ships.


Phase 1 — Migration + Zod schemas

Delivered

  • migrations/069_create_analyzer_tables.sql — three tables (analyzer_analyses, analyzer_shares, analyzer_jobs) with pgcrypto extension guard, indexes per spec, status CHECK constraints, and FK types corrected to TEXT (not UUID) to match Better Auth's user.id.
  • lib/types/analyzer.ts — Zod schemas for every LLM-stage parsed JSON (TaggedEvent, TriageResponse, DeepAnalysisResponse, OpusResponse), persisted row shapes, job status, API request bodies, and the internal PreprocessedTicket payload that flows through the pipeline.

Decisions worth flagging

  • triggered_by_user_id is nullable + ON DELETE SET NULL (not NOT NULL). An analysis should still be readable in history if the triggering user is later deleted. analyzer_shares.shared_by_user_id is NOT NULL + ON DELETE CASCADE (audit-log style — share rows go with the user).
  • evidence_timestamps typed as string().datetime({offset: true}) (ISO timestamps), not numeric indices. More robust to model hallucination and reads better in the UI.
  • TaggedEvent does NOT support a "two separate events" form for time entries with both Summary + Internal Notes — single event with both fields, per the spec's preference for a cleaner timeline.

Deliberately left out

  • Migration is committed but not applied to any running DB. Postgres only re-applies migrations on first init of a fresh volume; the existing DB needs a manual run of this migration when phase 5 is exercised.

Phase 2 — Stage 0 pre-processor + IT Glue redaction

Delivered

  • lib/services/analyzer/itglue-redact.ts — recursive walk; matches keys against /password|secret|key|token|credential|api[_-]?key/i; replaces values with [REDACTED]. Subtree redaction (key matching auth → no leaves leak), defensive copy, cycle guard ([CIRCULAR] marker).
  • lib/services/analyzer/preprocessor.ts — filters workflow noise + Service Desk Notification rows, tags ticket_create/notes/time entries with actor / actor_type / source / visibility / summary_notes / internal_notes / hours, sorts chronologically, computes sha256 content hash over canonical JSON of (events, status, priority, queue).
  • vitest@^4.1.5 added as devDep with vitest.config.ts setting up the @/ alias. Two test files at this phase: 32 redaction tests + 36 preprocessor tests including the regression run against the T20260424.0045 fixture.
  • Type-aliasing fix in lib/types/analyzer.ts: added export type X = z.infer<typeof X> for ActorType, EventSource, Visibility, Severity, ComplexityTier, TicketType — the Zod-enum const exports alone don't produce a usable TypeScript type.

Decisions worth flagging

  • actor_type is classified by email domain, not author text, per the spec. lorentz@wulfconsulting.com is wulf_tech regardless of how the message reads. Sonnet handles the role nuance at Stage 3.
  • Time entries with no narrative content (no summary, no internal notes) are dropped — a purely numeric entry adds nothing.
  • Vendor-domain allowlist is intentionally short and conservative (Vertafore, Datto, Microsoft, etc.). Misclassifying a customer domain as "vendor" is worse than the default client_contact.

Deliberately left out

  • No real protection for secrets embedded in free text (e.g. a notes field containing the literal string "the password is hunter2"). The redaction guarantee is on field keys, not values. The IT Glue search test documents this contract explicitly so it isn't "fixed" without thought.

Phase 3 — Anthropic SDK setup + Stage 1 (Haiku triage)

Delivered

  • lib/services/llm/{models,pricing,client,call}.ts — model ID constants, per-model rate table (Haiku $1/$5, Sonnet $3/$15, Opus $5/$25 per 1M tokens
    • cache read/write tiers), lazy SDK singleton, generic callLLMStage<T>({model, system, user, schema, maxTokens, client?}) helper that:
    • Marks the system prompt with cache_control: {type: "ephemeral"}
    • Sends NO temperature/top_p/top_k (Opus 4.7 would 400)
    • Strips a single ```json fence before parsing
    • On parse failure, retries once with prior-attempt + error in a follow-up user turn
    • Returns {data, usage, estimated_cost_usd, attempts, raw_response}
  • lib/services/analyzer/stages/stage1-triage.ts — Haiku caller with the spec's verbatim system prompt and a 50KB user-payload cap that drops oldest internal-only events first when oversized.
  • lib/services/analyzer/itglue-search.ts — search facade that runs every result through redact() before returning. Snippets capped at 2000 chars, doc count capped at 10. itglue-aliases.json skeleton for known fuzzy org-name mappings.
  • 32 new tests across pricing/call/Stage 1/IT Glue search.

Decisions worth flagging

  • Manual JSON.parse + Zod validate, not output_config.format / client.messages.parse(). The spec said retry-once on Zod parse failure, and Zod 4 ↔ JSON Schema conversion has edge cases I didn't want to depend on (e.g. .datetime({offset: true}) → JSON Schema format). Manual parse is what the spec asks for and is more transparent.
  • Prompt caching probably won't fire on these stages. Our system prompts are ~1-2 KB (~250-500 tokens); minimum cacheable prefix is 2048 (Sonnet) or 4096 (Haiku/Opus) tokens. The cache_control marker is a no-op below threshold and incurs no cost — left in defensively, but caching is not a meaningful lever for this workload.
  • Redaction is on KEY names, not free text — re-stated for emphasis. The IT Glue search test asserts this contract.
  • itglue-aliases.json is a skeleton with _comment / _example keys documenting the format. Real org-id entries get added when phase 5 wires this into the pipeline.

Deliberately left out

  • No live API integration test. All Stage 1 tests use a mocked Anthropic client. A real-API smoke test belongs in phase 5+ when we run end-to-end.
  • Did not wrap lib/services/itglue-client.ts in a redacting search facade for non-LLM callers. The redaction primitive is ready; non-LLM callers (sync service, data browsers) intentionally have full data — they are not the path that needs protection.

Phase 4 — Pipeline + Job worker (Stages 3, 4, 5)

Delivered

  • lib/services/analyzer/stages/stage3-deep-analysis.ts — Sonnet caller, spec's verbatim prompt, 80KB payload cap.
  • lib/services/analyzer/stages/stage4-deep-reasoning.ts — Opus caller plus pure helpers shouldRunDeepReasoning() and applyOpusUpdates().
  • lib/services/analyzer/data-access.tsloadTicketBundle(ticketNumber) joins tickets / statuses / priorities / queues / companies / contacts / resources / ticket_notes / time_entries and returns the exact RawTicketBundle shape the preprocessor expects. Throws typed TicketNotFoundError.
  • lib/services/analyzer/persistence.tsgetNextAnalysisVersion, findExistingAnalysisByContentHash, insertAnalysis, plus job claimQueuedJob / updateJobStatus / completeJob / failJob / queueJob / getJob. Job claim uses FOR UPDATE SKIP LOCKED so multiple Next.js workers can poll safely.
  • lib/services/analyzer/pipeline.ts — composes Stage 0 → idempotency check → Stage 1 → (Stage 2 if itglue_lookup_needed) → Stage 3 → (Stage 4 if trigger fires AND cost ceiling not reached) → result. Cost circuit breaker trips at $2.00 before Opus, sets needs_human_review=true with a reason. Returns full model_traces for debugging.
  • lib/services/analyzer/worker.ts — singleton with 2-second poll loop; auto-starts in production; opt-in in dev via ANALYZER_WORKER_AUTOSTART=1; skipped under vitest. runJob() exposed for tests + manual triggers. TicketNotFoundError produces a user-facing job error message that fingers the sync as the culprit.
  • 28 new tests bringing the total to 128.

Decisions worth flagging

  • Worker auto-start is more conservative than sync-scheduler.ts. That one auto-starts on any non-browser import (including tests). I gated this one because the worker hits the database AND runs LLM calls — much higher blast radius. To run locally, set ANALYZER_WORKER_AUTOSTART=1.
  • Cost circuit breaker only fires before Opus. Sonnet runs unconditionally even if it would push past $2. Spec wording matches; if the team wants stricter control, the natural place is next to COST_CEILING_USD in pipeline.ts.
  • Idempotency check matches status='complete' only. Failed runs don't poison the cache.
  • IT Glue failures are tolerated. Search throws → analysis continues without context, doesn't fail the run.
  • worker.test.ts casts mockImplementationOnce to as never because vitest's overload resolution fights us when the mocked function has multiple call signatures. Functional, just ugly.

Deliberately left out

  • No live API integration test (still). Phase 5+ is the natural place for an end-to-end smoke test.
  • Stage 3 doesn't have a fixture-driven happy-path test like Stage 1; it's exercised at the orchestration layer via pipeline.test.ts only. Worth adding direct Stage 3 tests later.
  • Migration 069 still not applied — same as phase 1.

Phase 5 — API routes

Delivered

  • Persistence read paths added to lib/services/analyzer/persistence.ts: getAnalysisById, listAnalysesByTicketNumber, listNeedsReview, createShare, plus a shared rowToPersistedAnalysis row mapper.
  • 6 routes under app/api/analyzer/:
    Route Method Returns
    tickets/[ticketNumber]/analyze POST {status, jobId?, existingAnalysisId?}
    tickets/[ticketNumber]/analyses GET {analyses: PersistedAnalysis[]}
    jobs/[jobId] GET {job}
    analyses/[id] GET {analysis}
    analyses/[id]/share POST {share}
    needs-review?limit=&offset= GET {analyses}

Decisions worth flagging

  • Analyze runs preprocess inline. The route does loadTicketBundle → preprocessTicket → findExistingAnalysisByContentHash synchronously to support the spec's existingAnalysisId? immediate response. The worker also runs this — duplicated work, but preprocess is fast (deterministic, one DB load) and the alternative (always queue, frontend polls to discover the short-circuit) is worse UX.
  • Email send deferred to phase 8 per the spec's delivery order. The share route persists the audit row and validates the recipient domain against ALLOWED_SHARE_DOMAINS. Until phase 8, share rows have viewed_at = null indefinitely.
  • requireAuth() everywhere — not requireAdmin(). Any authenticated user can analyze a ticket they have access to. If /needs-review should be admin-only later, swap that one to requirePermission('analyzer', 'review') once the permission map is decided.
  • No new permission entries added to lib/permissions.ts. Adding scoped permissions for a feature still under build risks getting them wrong.
  • Routes are NOT in middleware.ts publicRoutes — they require a session.

Deliberately left out

  • No API route tests. The repo has zero app/api/**/*.test.ts files; the routes are thin orchestration on top of already-tested persistence. Adding integration tests means setting up a test harness for the auth helpers + Postgres, which is a separate effort.

Phase 6 — Frontend pages

Delivered

  • components/analyzer/analyze-button.tsx<AnalyzeButton> with the full state machine: POSTs to the analyze endpoint, navigates straight to an existing analysis if existingAnalysisId came back, otherwise polls /api/analyzer/jobs/:jobId every 2s and renders stage labels (Queued → Fetching → Triaging → Searching IT Glue → Analyzing → Deep review → Done). 5-minute hard timeout. Failures surface as toast errors. Supports force for explicit re-run.
  • components/analyzer/share-modal.tsx<ShareModal> with a shadcn Dialog
    • email field + optional note (max 2000 chars). POSTs to the share endpoint and surfaces server-side validation errors (domain not allowed → toast).
  • components/analyzer/analysis-view.tsx — the full 10-section analysis layout per spec. Header with model-tier badges (Haiku/Sonnet/Opus pills), confidence score, total cost, Share + Re-analyze buttons. Summary, Next Step (with rationale collapsed), Timeline (vertical list with 🟢 / 🔒 / 🔄 markers, click to expand), What Was Done / Should Have Been Done side-by-side on wide screens, Gaps colored by severity with "Evidence:" links that scroll-and-expand the matching timeline event, Post-Resolution (only if present), Human Review Flags (only if needed), IT Glue References.
  • app/analyzer/ticket/[ticketNumber]/page.tsx — ticket detail with the Analyze button and a list of historical versions; latest is badged.
  • app/analyzer/analysis/[id]/page.tsx — fetches one analysis and renders it via <AnalysisView>.
  • app/analyzer/queue/page.tsx — needs-review queue, shows ticket number, version, summary preview, top reasons, confidence badge, and a high gap badge if any gap is high severity.

Decisions worth flagging

  • Imperative useState + useEffect + fetch, no SWR / react-query — matches CLAUDE.md and the rest of the repo. Don't refactor to a global cache layer for these three pages; if it becomes a real pain, that's a whole-app concern.
  • 'use client' everywhere. The pages use use(params) (the React hook) to unwrap Next.js 16's params: Promise<...> shape on the client. Server components weren't appropriate here — every page does interactive state (analyze flow, expand events, share modal).
  • Stage labels render directly from JobStatus enum values, not a separate label list, so any new statuses added to the enum auto-render with their default name.
  • Re-analyze banner not implemented yet. The spec calls for "New activity since last analysis · Re-analyze" when the live content_hash drifts from the persisted one. That requires a live-preprocess endpoint (or running preprocess on the page render). Skipped for now — the user can always click Re-analyze. Worth adding once we have real-world signal on whether activity drift is common.
  • No autocomplete on the share-recipient field. Spec says "autocomplete from existing wulf-pulse user list if available". Skipped — the existing user list is in Better Auth's user table; exposing it requires a small API endpoint. Easy follow-up.
  • No navigation entry yet. components/navigation/app-navigation.tsx doesn't yet have an "Analyzer" link. Adding that is a one-line edit; I left it for the operator to opt in once the feature is staged.
  • Printable analysis view — the spec mentions "printable" for the analysis page. The current layout is print-friendly by accident (no fixed sidebars, sectioned cards), but no explicit @media print styles yet. Add when someone asks.

Deliberately left out

  • No frontend tests. vitest is configured for lib/**/*.test.ts only; the pages and components are visually verified. Component tests with testing-library would be a separate setup decision.
  • Analysis view doesn't auto-refresh while a job is running on a different version. If a user navigates to an old version while a new one is in progress, they don't see the in-progress state. Acceptable — the queue view + ticket history give that signal.

Phase 7 — Share-via-email integration

Delivered

  • sendAnalysisShareEmail() added to lib/services/email.ts — re-uses the existing nodemailer SMTP transport that already serves magic-link and invitation mail. Subject Pulse analysis · <ticket> v<n> — <summary excerpt>, gradient-header HTML body matching the other Pulse emails, plain text fallback, replyTo set to the sharer so a recipient reply lands with the right person.
  • Share route app/api/analyzer/analyses/[id]/share/route.ts now resolves the sharer's session, builds the analysis URL from BETTER_AUTH_URL (falling back to NEXT_PUBLIC_BETTER_AUTH_URL, then localhost:3100), persists the audit row, then attempts the email send.
  • Share-modal frontend handles the new emailSent/emailError fields: success → green toast; row-saved-but-send-failed → orange toast.warning carrying the SMTP error.

Decisions worth flagging

  • Audit row persists even if email send fails. The analyzer_shares row is the audit log, not just a delivery receipt. SMTP outages should not erase the record that the user attempted a share.
  • Response is HTTP 200 on email failure. The route returns {share, emailSent: false, emailError}. A non-2xx would imply the share itself failed; surfacing emailSent: false is the more honest signal.
  • No email_sent_at column added. Adding it requires a migration and a way to retry — neither is asked for by the spec. Send state lives only in the response and the server log line [ANALYZER-SHARE] email send failed for share <id>. If retries become a real need, a dedicated analyzer_share_email_attempts table is the natural shape.
  • Used existing nodemailer SMTP, not Graph sendMail. Spec said "via M365 Graph using existing wulf-pulse mail integration if one exists; otherwise use a new module" — email.ts is the existing module. The Graph integration in this codebase is read-only (mailbox search, user reports), not send-capable.
  • HTML escaping is hand-rolled. Five-replace function for amp/lt/gt/quote/apos. The user-controlled fields (sender name, note, analysis summary, next step) all flow through it. The repo has no HTML escaper utility and the magic-link/invitation emails don't need one because their inputs are URLs and admin-set names.

Deliberately left out

  • No retries. A failed send is logged once and surfaced to the user. They can re-share if they want — that creates a fresh audit row, which is correct behavior.
  • No tests. lib/services/email.ts has no existing tests, mocking nodemailer fully would add a non-trivial test scaffold for one function, and the share route is route-handler thin. Consistent with phases 5/6.
  • No viewed_at tracking yet. The migration has the column but nothing writes to it. A /share/:id/viewed endpoint with an unguessable token would be the smallest addition; spec didn't ask, so skipped.

Phase 8 — Operator runbook

Delivered

  • docs/wulf-pulse-ticket-analyzer-runbook.md — cost monitoring queries (daily spend, top expensive analyses, multi-run tickets), the cost ceiling explanation (COST_CEILING_USD = 2.00 in pipeline.ts), the IT Glue alias workflow (where to find org IDs, how to verify via model_traces.itglue.alias_used), failure triage tables (failed jobs vs. needs_human_review flagged completes, with the actual error strings the worker emits), and a manual-ops section for queueing jobs from psql.
  • README.md "Documentation" section links the runbook so operators finding the project for the first time discover it.

Decisions worth flagging

  • Runbook lives in docs/, not the README. Per CLAUDE.md, long-form per-feature docs go in docs/ and the README points to them. Matches the existing pattern (workflow-editor-guide, sync guides, etc.).
  • Manual migration step is documented. Postgres only re-applies migrations on first init — operators applying analyzer to an existing DB need to run psql -f migrations/069_*.sql. The runbook leads with this.
  • Listed what's not implemented. Retries, viewed_at, share email_sent_at, pre-Sonnet cost gate, share-recipient autocomplete. Better to advertise the gaps than have an operator trip over them.

Deliberately left out

  • No production-monitoring dashboard (Grafana etc.) — Pulse doesn't have one for any other feature, and the SQL queries cover the same ground.
  • No README expansion for the analyzer feature itself. Pulse's README is intentionally short; the runbook + spec + build notes are the deep docs.

Phase 9 — Ticket browser + analysis-view formatting

Reactive to first-use feedback: the analysis page Summary / Next Step text was bare and dense, and there was no way to discover tickets to analyze without typing the URL.

Delivered

  • <ProseText> helper inside analysis-view.tsx — splits text on blank lines, renders each chunk as a separate <p> with leading-7 and whitespace-pre-line. Applied to Summary, Next Step, Next Step rationale, and Post-Resolution Analysis. Single-paragraph text still renders cleanly.
  • Summary + Post-Resolution headers got an uppercase tracking-wide treatment to act as section dividers, and body text bumped to text-base text-foreground so it reads as a finding rather than a caption.
  • Next Step card now has a subtle bg-primary/5 tint, an ArrowRight icon next to "Recommended Next Step", a stronger separator before the rationale collapsible, and the rationale itself renders in a bordered indented block.
  • New /analyzer/tickets browse page — pill-style period chips (Today, Yesterday, This week, Last week, Last 30/60 days, All time), a client (company) Select, an issue-type Select, and a debounced free-text search across ticket_number/title. Compact table with per-row Analyze/Re-analyze button (reusing <AnalyzeButton>) and a "View" button shortcut to the existing analysis when one is recorded. Active-filter count + clear-all in the filter card header.
  • New API GET /api/analyzer/tickets/list — filters by period (computed in Postgres against last_activity_date), companyId, issueType, search. Returns 50 rows + total via COUNT(*) OVER (), plus latestAnalysisId from a LATERAL join into analyzer_analyses.
  • New API GET /api/analyzer/tickets/filter-options — companies that have at least one non-deleted ticket (drops dormant accounts) + active issue types ordered by sort_order, label.
  • Top-level "Analyzer" nav menu added to app-navigation.tsx, with "Browse Tickets" + "Needs Review". Earlier phases left this off intentionally; this phase opts in.

Decisions worth flagging

  • Period filters on last_activity_date, not create_date. "Today" surfaces tickets that had activity today (new tickets, re-opened, status churn) — much more useful for an analyzer-driven triage flow than tickets created today. A new ticket created today also has activity today, so we don't lose those.
  • Period math runs in Postgres via date_trunc('day', NOW()) etc. Database server-clock = app-process clock for an internal Docker stack, so naive timestamps and naive NOW() agree. If users complain about edge-of-day drift, swap to NOW() AT TIME ZONE 'America/New_York' — Pulse's primary user base.
  • Default period is last_30d. "All time" pulls many thousands of rows; defaulting wide-open hurts first-page latency. 30 days hits ~7K rows in our DB, paginates cleanly.
  • Per-row analyze button reuses <AnalyzeButton> directly. Each row gets its own component instance — no shared state, the running state lives per-button. The button navigates on completion, which feels right: click Analyze, watch the stages, land on the analysis page.
  • force=true is set automatically on tickets that already have an analysis. Re-analyze should re-run, not short-circuit to the cached row. The "View" button covers the cached path.
  • No Linear/JIRA-style multi-select filters. Single-value Selects are simpler and match the rest of Pulse.

Deliberately left out

  • No saved views. A filter URL is shareable, but there's no bookmark / saved-view UX. Add when someone asks.
  • No latest_analysis_status exposure. A failed analysis doesn't show up — the LATERAL join filters by status='complete'. So a ticket whose only analysis failed looks like an un-analyzed ticket. Acceptable: re-running is the intended action there anyway.
  • Search is ILIKE '%...%'. No tsvector / trigram index. 7K-row scans are sub-100ms in this DB; if the corpus grows past low six digits, swap in pg_trgm.

Status after each phase

Phase Tests tsc Notes
1 0 clean migration + types only
2 68 clean redaction + preprocessor regression
3 100 clean + LLM scaffolding + Stage 1
4 128 clean + pipeline + worker
5 128 clean API routes (no route tests)
6 128 clean frontend (no FE tests)
7 128 clean share email via existing SMTP transport
8 128 clean operator runbook + README link
9 128 clean browse page + analysis-view formatting + nav entry

Phase 2 (cross-ticket analysis)

Spec: docs/ticket-analyzer-phase2-spec.md. Eight sub-phases delivered as one Phase 2 push.

2.1 — Schema additions

Delivered

  • Migration 070: analyzer_stage_executions table (per-stage I/O for every analyzer run, including failed attempts) + three columns on analyzer_analyses: source_snapshot, aggregate_fingerprint, fingerprint_generated_at.
  • model_traces column annotated with a LEGACY COMMENT ON COLUMN for the SQL side and a // LEGACY doc comment in TS — kept for back-compat until aggregate reports have soaked.
  • Zod schemas: AggregateFingerprint, StageName, StageExecution (read-back), StageExecutionRecord (write-time interface).

Decisions worth flagging

  • analyzer_stage_executions.analysis_id starts NOT NULL in 070. Migration 071 (Phase 2.6) relaxes it and adds the mutually-exclusive CHECK with aggregate_report_id.
  • 070 is idempotent (IF NOT EXISTS on every object) so re-running against an already-applied DB is safe.

2.2 — Pipeline writes to stage executions

Delivered

  • recordedStage(meta, fn, callbacks, outputSelector) helper in pipeline.ts wraps each stage call, emits a StageExecutionRecord on success or failure (re-throws after recording). Pipeline wires it into Stage 1 (triage), Stage 3 (analyze), Stage 4 (deep_review). Stage 0 (preprocess) and Stage 2 (itglue) are recorded inline since they're not LLM calls.
  • Pipeline result now includes triage_response, sonnet_response, opus_response for downstream stages (Stage 6 fingerprint).
  • Worker's runJob collects records via onStageRecord callback, bulk-inserts them after insertAnalysis succeeds. On pipeline throw, worker captures the preprocessed bundle via onPreprocessed callback, persists a status='failed' analyzer_analyses row with source_snapshot intact, and bulk-inserts the partial stage records linked to it.
  • New persistence functions: bulkInsertStageExecutions, insertFailedAnalysis, updateAnalysisFingerprint.

Decisions worth flagging

  • Failure-tolerant audit: spec says "Every stage that runs MUST insert a row, including stages that fail." We persist a failed analyzer_analyses row even on pipeline crash so the partial stage records have a parent. Without this the FK would be orphaned.
  • Single bulk insert: ~56 stage rows per pipeline run. One multi-VALUES INSERT is fast enough; no need for COPY.
  • model_traces double-write retained: the legacy column still receives the old payload. Drop it in a future migration once aggregate reports have soaked through prod.

2.3 — Prose formatting

Delivered

  • Stage 3 system prompt updated with the markdown formatting rules from the spec verbatim (banned filler phrases, action-verb emphasis, no headers).
  • react-markdown@10, remark-gfm@4, @tailwindcss/typography@0.5 added. Tailwind 4 plugin registered via @plugin "@tailwindcss/typography" in app/globals.css.
  • <AnalysisMarkdown> component at components/analyzer/analysis-markdown.tsx renders prose with prose prose-sm dark:prose-invert max-w-none prose-p:leading-7. Coerces stray model headers into bold paragraphs (the prompt forbids them but defense-in-depth).
  • <ProseText> removed. Summary, Recommended Next Step, next_step_rationale, and post_resolution_analysis all use <AnalysisMarkdown>.

Decisions worth flagging

  • Tailwind 4 syntax: @plugin "@tailwindcss/typography" in CSS, no JS config needed.
  • Stage 3 prompt change is back-compatible — old analyses with plain-text summaries still render fine through ReactMarkdown.

2.4 — Stage 6 fingerprint + backfill CLI

Delivered

  • lib/services/analyzer/stages/stage6-fingerprint.ts — Haiku call with the spec's verbatim system prompt. Server-overrides generated_by_model and generated_at after parse so the model's guess for those fields can't drift.
  • Worker integration: after insertAnalysis succeeds, run fingerprint with try/catch. Failure logs a warn, fingerprint stays NULL on the row, but the analysis is still complete and usable. The fingerprint stage record is added to the bulk insert whether it succeeded or failed.
  • scripts/backfill-fingerprints.ts — idempotent CLI. Reads analyzer_analyses.model_traces.{triage_response, sonnet_response, opus_response} (which Phase 1 was already storing), runs Stage 6, writes aggregate_fingerprint. Supports --dry-run and --limit=N. Skips analyses where model_traces is incomplete.

Decisions worth flagging

  • Stage 6 input is just the analysis content (triage + sonnet + optional opus). No pre payload needed — fingerprinting is about the produced analysis, not the source ticket.
  • Backfill processes oldest-first (triggered_at ASC). Lets us observe a few rounds before chewing through hundreds.
  • Stage 6 failure is non-fatal. Spec: "If fingerprinting fails, do NOT fail the overall analysis."

2.5 — Browse / filter UI rebuild

Delivered

  • New endpoint: GET /api/analyzer/tickets (replaces the simpler GET /api/analyzer/tickets/list from Phase 1.9). Multi-select CSV-style query params (clientId, issueType, queue, status, priority, assignedTo); analyzed segmented filter (any/yes/no/stale); needsReview toggle; search; sort.
  • /api/analyzer/tickets/filter-options extended with queues, statuses, priorities, resources (joined to "has at least one ticket" so the dropdowns aren't padded).
  • New <MultiSelect> component at components/ui/multi-select.tsx — Popover + checkbox list with optional search box (auto-shown above 8 options). One trigger + one popover, no shadcn Command dependency.
  • /analyzer/tickets page rebuilt:
    • Sticky filter bar with period pills, multi-selects, search, analyzed segmented, needs-review checkbox, sort
    • Active-filter chips (click to clear individual filter)
    • Bulk selection persisted via localStorage (key analyzer:ticket-selection:v1) — survives pagination
    • "Analyze N selected" — sequential job queue, forces re-analyze on stale rows
    • "Generate aggregate report" — routes to /analyzer/reports/new; only enabled when all selected are current
  • Top nav reorganized: Browse Tickets / Aggregate Reports / Needs Review under "Analyzer".

Decisions worth flagging

  • Staleness via last_activity_date > completed_at, not content-hash compare. The spec lets either; the date heuristic is good enough and avoids per-row preprocessing on 50-row paginated responses.
  • MultiSelect is a one-popover-per-instance design — multiple popovers can be open across the bar. Acceptable; matches how Linear / Vercel's table filters behave.
  • No "Select all matching filters" semantic. Selection is an explicit per-row action stored as ticket numbers in localStorage. Filter-level selection adds significant complexity (server has to resolve filter→IDs, two-modes everywhere). Skipped for V1; the spec's intent (don't lose selection on pagination) is met.
  • Bulk Analyze is sequential, not parallel. N concurrent calls would all hit claimQueuedJob and the worker would process them one at a time anyway (single in-process worker). Sequential POSTs are more honest about that.

2.6 — Aggregate reports

Delivered

  • Migration 071: analyzer_aggregate_reports table + ALTER on analyzer_stage_executions to drop NOT NULL on analysis_id, add aggregate_report_id FK, add the analyzer_stage_executions_parent_check CHECK constraint ((analysis_id IS NOT NULL) <> (aggregate_report_id IS NOT NULL)).
  • lib/services/analyzer/stages/aggregate-reduce.ts — Sonnet (Opus opt-in) reduce stage with the spec's verbatim system prompt and AggregateReduceResponse Zod schema.
  • lib/services/analyzer/aggregate-persistence.tscreateAggregateReport, getAggregateReport, listAggregateReports, runAggregateReport, bulkInsertReportStageExecutions. The runner is fire-and-forget (called via void runAggregateReport(id) from the POST endpoint); it persists distributions immediately so the UI can show partial results during the LLM call.
  • IT Glue context fetcher: per-client findOrganizationByName + getFlexibleAssets, capped at 200 doc titles total per spec. Failure tolerant — per-client errors don't fail the report.
  • API endpoints:
    • POST /api/analyzer/aggregate-reports — validates (≤100, fingerprint exists, not stale), creates pending row, fires runner
    • GET /api/analyzer/aggregate-reports/:id — full report row (UI polls this every 3s while pending/running)
    • GET /api/analyzer/aggregate-reports — paginated list
  • Pages:
    • /analyzer/reports/new?ids=T...,T... — pre-flight: shows selected tickets, options (title, IT Glue context toggle), Generate button
    • /analyzer/reports/[id] — pending → distributions → completed. Sections: header, executive summary, four distribution mini-bar cards, documentation gaps (with itglue_check color tone), process gaps (severity tone), recurrence clusters, recommended actions (sorted by priority), narrative summary.
    • /analyzer/reports — table list of past reports

Decisions worth flagging

  • Fire-and-forget runner, no separate worker module. The POST endpoint kicks void runAggregateReport(id); updates land in the row when the LLM call completes. Frontend polls. Avoids adding a second polling worker alongside analyzerWorker.
  • Stage names reused for aggregate sub-stages. The CHECK constraint on analyzer_stage_executions.stage enumerates the per-analysis stage names. Aggregate sub-stages (SQL aggregation, IT Glue context, reduce LLM) are recorded with stage='analyze' / 'itglue' plus aggregate_report_id set. A future migration could add aggregate_sql / aggregate_reduce to the enum and re-emit those rows; for now the existing names are good enough for forensics.
  • generated_by_user_id is TEXT nullable, not uuid NOT NULL per spec. Better Auth's user.id is text, and we want the report to remain readable if the generating user is later deleted — matches the pattern from analyzer_analyses.triggered_by_user_id.
  • Distributions persist before LLM call so partial-state UI doesn't have to wait the full 3090s for anything to render.

2.7 — Cost guards

Delivered

  • Migration 072: analyzer_cost_audit table.
  • lib/services/analyzer/cost-guard.ts: estimateAggregateReportCost, getUserDailySpend, evaluateCost, recordCostAuditDecision. Thresholds: REQUIRES_CONFIRMATION_USD = 5, SOFT_WARN_DAILY_USD = 20, HARD_BLOCK_DAILY_USD = 50.
  • evaluateCost produces a four-state decision (approved / requires_confirmation / blocked / overridden) plus boolean softWarn/hardBlocked/requiresConfirmation/isOverride fields the API can return for UX.
  • POST /api/analyzer/aggregate-reports enforces:
    • requires_confirmation → 400 with requiresConfirmation:true, estimatedCost, dailySpendBefore so the frontend can show confirm() and re-POST with confirmedCost:true.
    • blocked → 403 with the daily spend in the body.
    • Every decision (including approved) writes a row to analyzer_cost_audit.
  • Override env var ANALYZER_DAILY_COST_OVERRIDE_USERS (comma-separated user ids).
  • Frontend new-report page: catches requiresConfirmation, shows window.confirm() with the dollar figure, retries with confirmedCost: true.

Decisions worth flagging

  • Cost estimate is char/4 → tokens × Sonnet pricing. Crude but pessimistic in the right direction. At 100 tickets the estimate comes in under $0.50 — far below the $5 threshold — so the confirmation modal almost never fires in practice. Ceiling exists to catch payload bloat / Opus-opt-in scenarios.
  • Daily window is trailing 24h, not "today UTC". Avoids midnight-edge-of-day reset gaming; rolling window is what the spec calls "$X/day" naturally.
  • Soft warn at $20/day is informational only. Fields exposed in the cost evaluation; UI can choose to surface, but the API doesn't refuse to proceed. Hard block at $50/day is the only enforcement.

2.8 — Documentation

Delivered

  • docs/wulf-pulse-ticket-analyzer-runbook.md — added Phase 2 sections covering stage execution forensics, fingerprint backfill, aggregate report flow + SQL queries, cost guard configuration + override env var, and the rebuilt browse UI behavior.
  • This file — the per-sub-phase notes above.

Status after Phase 2

Phase Tests tsc Notes
2.1 128 clean schema (070)
2.2 128 clean stage_executions writes + failure-tolerant persistence
2.3 128 clean markdown rendering + Stage 3 prompt
2.4 128 clean Stage 6 fingerprint + backfill CLI
2.5 128 clean browse UI rebuild
2.6 128 clean aggregate reports (071, runner, 3 endpoints, 3 pages)
2.7 128 clean cost guards (072, audit log, threshold gating)
2.8 128 clean runbook + build notes

Why

Single-ticket analysis misses the bigger picture for master/problem tickets, which are explicitly aggregator records — a "Master problem ticket" with a RELATED TICKETS: block in its description naming the constituent incidents. Aggregate reports already existed (Phase 2.6) but required the user to pre-analyze every constituent and hand-pick them on /analyzer/reports/new. Phase 3 closes the gap: one click on a problem ticket fans out individual analyses for each linked ticket and chains them into an aggregate report.

Delivered

  • migrations/073_analyzer_link_aware_bundles.sql:
    • analyzer_aggregate_reports.expected_ticket_numbers TEXT[] — the full set of ticket numbers a bundle is waiting on.
    • analyzer_aggregate_reports.triggered_by_ticket_number TEXT — the master ticket the bundle was launched from.
    • Status check extended to include 'pending_analyses' (waiting for individual analyses) before transitioning to 'pending' (ready for aggregate-reduce).
    • Replaced the partial pending-status index to cover the new state; added a GIN index on expected_ticket_numbers filtered to pending_analyses for the worker chain-trigger lookup.
  • lib/services/analyzer/link-discovery.ts:
    • Explicit arm (no LLM, deterministic): regex scan over the ticket description and each retained note for T\d{8}\.\d{4} references, detection of the structured RELATED TICKETS: block (refs inside it flagged confidence: 'high'), and resolution of tickets.problem_ticket_id to a ticket number. Self-references and refs not present in the local mirror are dropped silently. Capped at MAX_EXPLICIT_LINKS = 15.
    • Suggested arm (Haiku, opt-in): one LLM pass over recent same-company tickets (±30 days, capped at 50 candidates). Returns up to 5 candidates with one-sentence reasons. Hallucination-guarded — drops any number not in the candidate list.
    • detectProblemTicket() returns boolean + signal list; UI uses signals to decide whether to highlight the bundle CTA as the primary action.
  • app/api/analyzer/tickets/[ticketNumber]/links/route.ts:
    • GET returns the explicit arm only (cheap, called on page load).
    • POST { includeSuggested: true } runs both arms.
  • app/api/analyzer/tickets/[ticketNumber]/analyze-bundle/route.ts:
    • Validates master + linked tickets exist locally (single SQL roundtrip).
    • Runs per-ticket idempotency: existing complete analyses with matching content hash short-circuit; missing tickets are queued via the existing queueJob() helper.
    • Cost guard runs against the new work only — already-complete analyses don't add cost. Per-ticket estimate is a flat $0.15 (Sonnet-tier pessimistic) plus estimateAggregateReportCost() for the reduce step.
    • Creates one analyzer_aggregate_reports row in 'pending_analyses' (or straight to 'pending' and fires runAggregateReport() if everything was already complete).
    • Bundle cap: MAX_BUNDLE_SIZE = 25.
  • lib/services/analyzer/aggregate-persistence.ts:
    • createAggregateReport accepts expectedTicketNumbers and triggeredByTicketNumber. When set, status starts as 'pending_analyses'.
    • chainTriggerForCompletedAnalysis(ticketNumber, analysisId) — called by the worker after each successful job. Atomically appends the analysis_id to every pending_analyses bundle expecting that ticket (deduped via analysis_ids @> ARRAY[…] guard) and re-checks whether the full set is now satisfied. Returns readyReportIds for the worker to fire runAggregateReport() on.
  • lib/services/analyzer/worker.ts — chain-trigger fires from both the success branch and the idempotent-short-circuit branch (the bundle endpoint's idempotency check happens at submit time, but a parallel analysis can complete between then and when the worker picks the job up). Failures here are logged but never fail the underlying job.
  • components/analyzer/related-tickets-panel.tsx — renders above the existing <AnalyzeButton> on /analyzer/ticket/[ticketNumber]:
    • Cheap GET on mount populates the panel only when refs exist or the ticket looks like a problem ticket — otherwise the component renders nothing.
    • Pre-checked checkboxes for explicit refs; Switch toggle to load AI-suggested refs (additive, suggestions show with a badge, unchecked by default).
    • Primary CTA is bundle ("Analyze with N linked tickets") highlighted when isProblemTicket=true. Single-ticket flow is preserved untouched on the existing AnalyzeButton in the parent header.
    • Polls GET /api/analyzer/aggregate-reports/:id every 3s after submit; routes to /analyzer/reports/:id on completion.
  • lib/services/analyzer/link-discovery.test.ts — 18 new tests covering the regex, RELATED TICKETS section bounds, problem-ticket signal detection, dedup/self-skip, mirror filtering, MAX_EXPLICIT_LINKS cap, and confidence-based sorting.

Decisions worth flagging

  • Bundle is opt-in via the panel, not auto. A ticket that mentions another ticket once in passing (e.g. "see T20260101.0001 for context") shouldn't quietly trigger 2× the LLM cost on every analysis. The panel is the consent surface — pre-checked when explicit refs exist, but the user explicitly picks the CTA.
  • RELATED TICKETS: is a strong signal, not a parser-required format. The regex catches T-numbers anywhere; the structured block just promotes them to high confidence and acts as a problem-ticket signal. No new format is imposed on whoever writes the master ticket.
  • Suggested arm uses Haiku, not Sonnet. ~$0.005 per call against the $5 per-request confirmation threshold — never trips the modal. We never fail the whole call if the suggestion arm throws (logged-and-suppressed via try/catch in discoverLinks).
  • Per-ticket cost estimate is flat $0.15. We could compute it from the preprocessed event count, but at the bundle's typical size (3-10 tickets) that's $0.45 $1.50 — far below the $5 confirmation threshold. Worth revisiting if we see real false-positive blocks.
  • pending_analyses is the new status, distinct from 'pending'. Explicit two-step state lets the runner stay simple — it never has to ask "are all my analyses ready?" — that gate is the chain-trigger's job. Existing manual-multi-select reports continue to start at 'pending'; their flow is untouched.
  • expected_ticket_numbers matches via array containment, not a separate join table. Postgres GIN gives us O(log n) lookup and the data lives where it's used — no new table, no foreign-key cascade decisions to make.
  • Self-references and unknown tickets are dropped silently. The user isn't asked to pick from a list; they get a clean "you have N linked tickets" panel. A ghost reference (T-number that doesn't exist in the mirror) is a sync gap, not a bundle decision.
  • Fixture migration: T20260424.0045.input.json updated to include problem_ticket_id: null so the type-strict load through the preprocessor still parses. The column is nullable in the data-access query and on the row type.

Deliberately left out

  • No retroactive linking for already-completed analyses. If a master ticket got a single-ticket analysis before this shipped, the user re-runs from the panel to bundle.
  • No editing the bundle composition after submit. Re-run with a different selection if you want a different scope.
  • No time-window auto-correlation arm (e.g. "all tickets at this client in the last 6 hours"). At Hynes Industries on 2026-05-01 we observed 84 tickets in one day — auto-grouping by time would have been useless noise. Same-company time-window ranking is what the Haiku suggested arm is for.
  • No UI for the /analyzer/reports/[id] page to flag itself as a "bundle" vs a manual report. The new fields are surfaced in the API response but the page renders the same regardless.

Status after Phase 3

Phase Tests tsc Notes
3 146 clean link discovery, bundle endpoint, chain-trigger, panel (073)

Phase 4 — IT Glue asset audit + documentation write-back

Why

Single-ticket analysis already extracts documentation_gaps_observed per ticket (in aggregate_fingerprint), but nothing acts on them. The companion direction — audit IT Glue records against ticket history — converts a passive output into an actionable backlog and offers direct write-back. Concrete proof: T20260502.0033 (Hynes — tags not printing) was resolved by identifying a stopped Windows service on MISYS-SQL processing BarTender scan-folder text files. The IT Glue record MISYS 6.3 (asset 17096940) had 12/17 fields empty — including Wulf Application Champion and Vendor Maintenance/Support — so the next tech with this issue would re-discover everything.

Delivered

  • migrations/075_itglue_audit.sql — two new tables:
    • itglue_asset_audits — one row per audit run with full LLM context snapshot (asset traits at audit time, redacted), the gaps/promotions/ contradictions output, and cost.
    • itglue_writes — one row per PATCH attempt; before/after diff, who, when, status pending → committed | failed | reverted, audit_id provenance, raw IT Glue API response, source_evidence (tickets that prompted the gap).
  • lib/services/itglue-client.ts:
    • updateFlexibleAsset(id, traits) — PATCH /flexible_assets/:id with JSON:API body.
    • refreshFlexibleAsset(id) thin wrapper around getFlexibleAsset.
    • getRawSingle(path, params) — for callers that need raw attributes (created-at/updated-at) for upserts.
    • isITGlueConfigured() helper.
    • Internal patch(path, body) mirrors the existing request pattern.
  • lib/services/itglue-sync-service.ts:
    • refreshFlexibleAssetById(id) per-record sync helper. Avoids running the full 27-entity fullSync() after every write.
  • lib/permissions.ts:
    • New itglue: ['read', 'write'] permission. Admin + super-admin get write; user gets read-only.
  • lib/services/analyzer/asset-audit/:
    • data-builder.ts — collects all six context arms: asset snapshot, field schema with hints, peer exemplars same-client (top 5 by trait fill count), peer exemplars across all clients (top 3), per-field fill-rate stats (per-client + global), recent ticket fingerprints matching the asset's name. Redacts asset traits + every peer.
    • prompt.ts — single Sonnet/V4 Pro call; system prompt categorizes findings into field_gaps / notes_promotions / contradictions; refuses to suggest credential-shaped fields. Payload cap 80KB; trims peer_global first, then oldest tickets.
    • runner.tsrunAssetAudit({assetId, generatedByUserId, provider}). Provider-aware via stageModelsFor(provider).deep_analysis. Persists itglue_asset_audits row (or failed row on throw).
    • persistence.ts — typed read/write of both tables; fieldNameToTraitKey() helper matches IT Glue's lower-hyphen-strip convention.
    • runner.test.ts — 12 unit tests covering fillCount semantics, trait-key conversion, AssetAuditResponse Zod validation, payload trimming.
  • API routes:
    • GET /api/analyzer/itglue/applications — list of all Application records joined to latest audit, ordered worst-score-first.
    • GET /api/analyzer/itglue/applications/[id] — asset detail with field schema (the renderer uses field order + populated state).
    • GET /api/analyzer/itglue/applications/[id]/audit?history=1 — latest audit + history.
    • POST /api/analyzer/itglue/applications/[id]/audit — runs a fresh audit; cost-guard via recordCostAuditDecision({action:'itglue_audit'}).
    • POST /api/analyzer/itglue/applications/[id]/apply — admin-only; inserts pending row, calls IT Glue PATCH, marks committed/failed, refreshes mirror, writes generic audit_log row.
    • POST /api/analyzer/itglue/applications/[id]/revert/[writeId] — admin-only; inserts a new write row with reversed before/after, applies, marks original status='reverted'.
    • GET /api/analyzer/itglue/applications/[id]/writes — auth-only per-asset history.
    • GET /api/analyzer/itglue/writes — admin-only cross-asset write log.
  • UI:
    • /analyzer/itglue/applications — list with score badges + filter input.
    • /analyzer/itglue/applications/[id] — asset header (with link to IT Glue), audit panel (gaps with severity-toned cards, notes promotions, contradictions), current fields rendered with hints for empty ones, write history with revert button, audit history with score timeline. <ProviderToggle/> reused from Phase 3.
    • /admin/itglue-writes — admin-only global write log with status filters.
    • Navigation: new "IT Glue audit" entry under the Analyzer dropdown.

Audit-trail design

Three layers of trail, all permanent:

  1. itglue_asset_audits — every audit run with full LLM context.
  2. itglue_writes — every write attempt. Before/after, who, when, status, audit provenance. Reverts produce a new row with reversed diff; original row → status='reverted'. The chain is always traceable.
  3. Generic audit_log (existing migration 014) — written in parallel via audit.log(). Action itglue.write / itglue.revert, resource flexible_asset, resourceId = asset_id, details = { field_name, before, after, audit_id }. Surfaces in /admin/audit-log next to every other admin action.

Decisions worth flagging

  • Two domain-specific tables, not one generic events table. Audits carry full LLM context (heavy, infrequent). Writes are atomic per-field decisions with hard-typed before/after diffs. The generic audit_log's free-form JSONB doesn't model the diff cleanly — but we still write to it so admins see a unified feed.
  • Admin-direct write, no two-step approval. Per the user's call. The audit log is the safety net. We left approval_requests as a possible v2 if mistakes start happening.
  • Per-record sync helper instead of fullSync() after every write. refreshFlexibleAssetById() does one GET + one upsert. Keeping fullSync() available for ops + scheduler; bypassing it on the write path keeps Apply latency under a second after the IT Glue PATCH lands.
  • Credential refusal at three layers. Prompt instructs the LLM not to suggest password/secret/key/token/credential fields. The Apply endpoint also regex-blocks any field name matching that pattern. The redact() utility from itglue-redact.ts strips matching keys from any payload flowing into the LLM in the first place.
  • Trait-key derivation in code, not hard-coded. IT Glue's convention is field-name lowercased, non-alphanum → single hyphen, stripped. We compute this from each field's name (verified against the live Hynes MISYS 6.3 trait map). If a future field doesn't match the rule, it'd surface as an unmatched fill-rate / value-not-applied — easy to spot.
  • Fill-rate computation in JS, not SQL. Avoids one query per field. At the example data volume (~92 active Hynes assets, ~few-thousand globally per type) this stays sub-100ms; can revisit if it grows.
  • Peer-exemplar ranking by populated-key count. Cheap proxy for "well-documented." Doesn't penalize asset types whose fields are legitimately optional. If the LLM starts producing strange suggestions we can refine to require/expected-field weighting.
  • Asset detail page renders from local mirror, not IT Glue API. Means a user could see a stale value for a few seconds between Apply and the per-record sync landing. Acceptable for a read view; the API response from Apply returns the freshly-PATCH'd asset so the UI can immediately reflect the new state.

Deliberately left out

  • v1 covers Applications only (flexible_asset_type_id = 3790). The schema generalizes (asset_type is a column), but Configurations / Procedures / Domains have different shapes and prompts. One type at a time.
  • No bulk-apply. Admin clicks each suggestion. If a single audit produces 10 gaps that's 10 clicks — fine for v1; bulk-apply is an easy follow-on.
  • No two-step approval workflow.
  • No auto-create of new asset records — Apply only updates existing.
  • No inline editor for suggested values — admin sees the LLM's suggestion verbatim and clicks Apply, then edits in IT Glue if they want to tweak.
  • Passwords / Secrets / Keys / Tokens / Credentials: never written via this surface, ever. Refused at prompt + endpoint + redaction layers.

Status after Phase 4

Phase Tests tsc Notes
4 158 clean IT Glue asset audit (075), runner + 6 endpoints + 3 pages, write-back with revert

Phase 4.1 — Ticket-first capture + Configurations + cross-reference index

Why

Phase 4 was asset-first (admin browses worst-scoring records). Phase 4.1 flips perspective: every time we analyze a ticket, learn whether this ticket taught us something documentable. Plus extends write-back to IT Glue Configurations (servers, workstations, devices) — the prior flexible-asset-only scope missed records like MISYS-SQL where the T20260502.0033 root cause actually lived. Plus a cross-reference table so both perspectives become one-query lookups, and the future RAG automation has its lookup index.

Delivered

  • migrations/076_itglue_ticket_xrefs.sql:
    • triggered_by_ticket_number + triggered_by_analysis_id on itglue_asset_audits; triggered_by_ticket_number on itglue_writes (denormalized per user's call so "every write a ticket drove" is a direct query).
    • asset_type CHECK extended to include 'configuration' on both audit + write tables.
    • New itglue_ticket_xrefs table — ticket↔asset linkage with relationship type (referenced | updated | should_have_referenced), source (analyzer_referenced | audit_write | manual), unique constraint preventing dup ingestion.
  • lib/services/itglue-client.ts:
    • updateConfiguration(id, attributes) — PATCH /configurations/:id with flat JSON:API attributes (no traits blob).
    • refreshConfiguration(id) thin wrapper.
  • lib/services/itglue-sync-service.ts:
    • refreshConfigurationById(id) per-record refresh (mirrors the bulk syncConfigurations 33-column upsert).
  • lib/services/analyzer/asset-audit/:
    • data-builder.ts generalized: dispatches on assetType, supports ticketScopeAnalysisId for ticket-first audits. Configurations get a hand-curated 16-field schema with hints (since IT Glue Configurations don't have a _fields table). Synthesizes a "traits" map from flat columns so the prompt stays type-agnostic.
    • prompt.ts — two system prompts (Application-flavored vs Configuration-flavored, the latter focused on hostname/FQDN, OS currency, named services, IP/MAC, contact ownership). Ticket-scoped suffix when an analysis is the source so the LLM frames findings as "what did this ticket teach us?"
    • runner.ts accepts assetType, ticketScopeAnalysisId; persists triggered_by_* columns.
    • persistence.tsasset_type union extended; new getLatestTicketScopedAudit helper for the analysis-page panel; createPendingWrite accepts asset_type
      • triggered_by_ticket_number.
    • xrefs.ts (new) — bulk-insert helpers: insertReferencedXrefsFromAnalysis (post-analysis hook), insertUpdatedXref (post-apply hook), with listXrefsForAsset / listXrefsForTicket queries.
    • asset-matcher.ts (new) — given an analysis_id, returns matched flexible_assets + configurations for the ticket's client based on fingerprint terms (applications_involved, device_classes, vendors_involved). Score = exact (3) > word-boundary (2) > substring (1); top 5 per kind.
  • lib/services/analyzer/worker.ts — post-analysis hook calls insertReferencedXrefsFromAnalysis for every doc the LLM cited; best-effort, never fails the job.
  • API routes:
    • GET /api/analyzer/analyses/[id]/itglue-suggestions — match assets + return any existing ticket-scoped audits keyed by (assetType, assetId).
    • POST /api/analyzer/analyses/[id]/itglue-suggestions — body { assetType, assetId, provider }, runs a ticket-scoped audit.
    • Full Configurations route tree mirroring Applications: list, detail, audit (GET/POST), apply (admin), revert (admin), writes, xrefs.
    • GET /api/analyzer/applications/[id]/xrefs (new) and GET /api/analyzer/tickets/[ticketNumber]/itglue-xrefs (new).
  • UI:
    • <ItglueSuggestionsPanel/> rendered on the analysis detail page. Opt-in trigger ("Check IT Glue documentation"); shows matched Applications + Configurations grouped, per-asset ticket-scoped audit buttons, inline gap cards with Apply (admin-only), score badges. Reuses <ProviderToggle/> from Phase 3.
    • /analyzer/itglue/configurations — list mirroring Applications.
    • /analyzer/itglue/configurations/[id] — detail mirroring Applications, plus the new "Tickets that touched this configuration" section.
    • Application detail page — added the same xref section.
    • Navigation split into "IT Glue — Applications" + "IT Glue — Configurations".

Decisions worth flagging

  • Two separate Configuration write methods + two route trees instead of one polymorphic surface. The codebase has no other [assetType]-style polymorphism; existing patterns favor parallel resource paths. Added ~80 LOC duplication on the page side, but each surface is independently testable + obvious in URL routing.
  • Configuration field schema is hand-curated, not loaded from IT Glue. IT Glue exposes flexible-asset field metadata via /flexible_asset_fields but Configurations have no equivalent endpoint. The 16 hand-written hints (in data-builder.ts) are what the LLM sees as field documentation. Versioned in code; PR review is the change control.
  • Apply on Configurations is column-allowlisted. Even with the audit pipeline picking field_name, the apply route refuses anything outside name | hostname | primary_ip | mac_address | serial_number | asset_tag | position | notes | operating_system_notes. Stops the LLM from suggesting edits to read-only/derived fields like manufacturer_id (which is an FK resolved by IT Glue, not a free-text field).
  • xref ingestion happens in the worker after a successful analysis, not as a separate batch job. Best-effort wrap means a transient DB hiccup never fails the analysis itself. The unique index on the xref table ensures retries are idempotent.
  • ticketScopeAnalysisId narrows ticket evidence to one row. This is the key prompt-shaping decision for Phase 4.1: the LLM sees just the one analysis the user clicked from, plus the asset state + schema + peer exemplars. Findings frame as "what this ticket revealed" rather than all-time history.
  • No backfill of existing analyses. Per user's call. The xref table fills forward; backfill is a future opt-in script if needed.
  • Asset matching is loose — substring + word-boundary. A ticket mentioning "MISYS" matches both MISYS 6.3 (the Application) and MISYS-SQL (the Configuration), and the user picks per-asset which to audit. Less false-negative-y than strict matching; user controls confirmation.
  • Configuration audits don't write to manufacturer/model/OS-name/contact/location — those are FK fields IT Glue resolves by id, not free-text. The audit prompt can suggest changes but Apply blocks them. Future iteration could resolve names → ids via the IT Glue manufacturers/models endpoints.

Deliberately left out

  • Datto RMM script execution (Phase 4.2 — separate plan). Ability to run PowerShell via Datto RMM Overshell on Wulf Nurse endpoints to gather fresh evidence (DHCP scopes, DNS zones, AD info, named services) and feed it into the audit pipeline. Decisions logged: generic Overshell component + Pulse-managed scripts; admin-direct with audit log; new rmm.execute permission.
  • No backfill of the xref table.
  • No bulk-apply across multiple gaps; admin clicks each one.
  • No two-step approval workflow. Audit log + role gating remain the safety net.
  • No Configuration write for FK-shaped fields (manufacturer, model, OS, contact, location) — only flat editable columns.

Status after Phase 4.1

Phase Tests tsc Notes
4.1 160 clean xref table (076), Configurations parity, ticket-first capture, analysis-page panel

Phase 4.2 — Datto RMM Overshell evidence pipeline

Why

Phase 4.1 wires ticket history + IT Glue field schemas into LLM-driven documentation suggestions. The next leverage point is fresh evidence from the live environment — service lists, AD health, DHCP scopes, DNS zones, event logs — that ticket history can't surface. Without it, audits flag "the named service that processes BarTender scan-folder text files isn't documented" but can't suggest the actual service name. With it, we suggest the literal value pulled from the running server seconds ago.

The proven test case: openclaw produced an AD health summary at Hynes around 2026-04-25 (IP conflicts, ZR006 missing trust account, DNS forwarders timing out, Hendricks site missing site-links) by orchestrating Datto RMM Overshell. Phase 4.2 lets Pulse produce the same intel directly from a button on the asset page, store it, and feed it back into audits.

Delivered

  • migrations/077_rmm_overshell.sql:
    • rmm_settings singleton — caches the discovered Overshell component_uid, component_name, variable_name (default CommandLine).
    • rmm_executions — full lifecycle row per dispatch: queued → running → complete | failed | timeout. Captures target_device_uid, target_hostname, target_company_id, optional audit/asset linkage, job_uid, raw stdout/stderr (redacted), parsed_evidence, exit code, timeout_at. 8 indexes covering all query paths the audit pipeline + UI need.
  • lib/services/rmm/scripts/:
    • 7 v1 scripts, all read-only. Each is a typed RmmScript exporting body (PowerShell), target_type, parseOutput, expected_runtime_seconds, version. Bodies end with ConvertTo-Json -Depth … -Compress so the parser is just JSON.parse. Registry validates uniqueness at load.
    • asset_self: get-services, get-installed-software, get-event-log-recent.
    • site_anchor: get-ad-health (mirrors the openclaw test case), get-dhcp-scopes, get-dns-zones, get-network-discovery (catches the IP-conflict pattern from the proven test case).
  • lib/services/rmm/target-resolver.ts:
    • resolveSiteAnchorTarget(companyId) — looks up datto_rmm_sites where autotask_company_id = $1, finds devices matching ^[A-Z]{3}[A-Z]{3}WNP\d{2}$, picks online + lowest numeric suffix.
    • resolveAssetSelfTarget(deviceUid) — direct lookup.
    • resolveDeviceByHostname(hostname) — fallback when an IT Glue Configuration's rmm_id doesn't resolve cleanly.
  • lib/services/rmm/settings.ts:
    • discoverOvershellComponent() — calls client.findOvershellComponent(/overshell/i) and persists the uid.
    • resolveOvershellComponent() — read-cache-or-discover; throws if nothing matches.
  • lib/services/rmm/executor.ts:
    • queueExecution({ scriptId, target, performedByUserId, triggeredByAuditId? }) — validates registry, resolves target, runs cost-guard rate limit (50/user/24h, decision logged to analyzer_cost_audit), inserts pending row, calls runQuickJob, captures jobUid, flips to running. Generic audit.log entry on success.
  • lib/services/rmm/worker.ts:
    • 5-second poll loop, self-init pattern matching analyzerWorker.
    • Sweeps timed-out rows first (status → timeout).
    • Polls running rows via client.getJobResults per target_device_uid. On terminal status: redacts stdout/stderr, runs script's parseOutput, persists. Parse errors are non-fatal — raw output still kept.
  • lib/services/rmm/persistence.ts:
    • Typed RmmExecutionRow + status helpers, plus the audit-pipeline queries listLatestEvidenceForCompany(companyId, days) and listLatestEvidenceForAsset(assetType, assetId).
  • lib/services/datto-rmm-client.ts — added findOvershellComponent(pattern).
  • lib/services/analyzer/asset-audit/data-builder.ts:
    • 7th LLM context arm rmm_evidence populated from listLatestEvidenceForCompany (site-anchored, last 7 days) + listLatestEvidenceForAsset (asset-self, all-time).
    • Joins via companies → itg_organizations on case-insensitive company_name match (same join the ticket-evidence loader uses).
  • lib/services/analyzer/asset-audit/prompt.ts:
    • New === LIVE RMM EVIDENCE === section emits when ctx.rmm_evidence.length > 0. Trim path drops it last (highest-value section).
    • LIVE_EVIDENCE_NOTE injected into the system prompt: "Treat parsed contents as authoritative current state … Cite execution_id alongside ticket numbers."
  • lib/permissions.ts — new rmm: ['read','execute']. Admin + super-admin get both; user gets read.
  • API routes:
    • GET/PATCH /api/admin/rmm/settings — admin-only, view + edit variable name.
    • POST /api/admin/rmm/settings/discover — admin-only, force component scan.
    • GET /api/rmm/scripts — auth, library catalog (no bodies).
    • GET/POST /api/rmm/executions — list (auth) + queue (rmm.execute).
    • GET /api/rmm/executions/[id] — auth, polls one execution.
    • GET /api/analyzer/itglue/sites/[companyId] — site-discovery summary.
  • UI:
    • <RmmScriptPicker filter='site_anchor'|'asset_self' …/> — popover listing applicable scripts, dispatches on click, disables for non-admins.
    • <RmmExecutionStream/> — polls every 3s, shows status + parsed evidence + raw stdout (collapsible).
    • /admin/rmm-overshell — settings + recent execution log.
    • /analyzer/itglue/sites/[companyId] — site-discovery view.
    • Embedded picker on Application detail (site-anchor with parent client) and Configuration detail (asset-self if rmm_id resolves to a Datto device).
    • Nav entry: Admin → "RMM Overshell".

Decisions worth flagging

  • Component discovery is automatic and cached. Pulse scans for any component matching /overshell/i on first dispatch, persists the uid, and never re-scans unless an admin clicks "Re-discover". The variable name defaults to CommandLine (Datto's "Run Command" component). If Wulf's Overshell uses a different variable, admin sets it once via /admin/rmm-overshell.
  • Script bodies live in code, not the DB. Three reasons: PR review is the change-control mechanism; nothing in the database is treated as executable PowerShell; the 7 scripts are already curated and we don't need (or want) ad-hoc paste-a-script UX.
  • WNP-only target resolution. Site-anchored scripts hit the Wulf Nurse Production endpoint (LLLCCCWNPNN); PowerShell uses native AD cmdlets to reach the DC over the network. Direct-to-DC role detection is a fast-follow.
  • 5-minute hard timeout + 50/user/24h rate limit. Both enforced server-side in the executor. The cost-guard rows in analyzer_cost_audit give admins a unified view of LLM and RMM activity per user.
  • Output is redacted before persistence. Same redact() from the IT Glue redaction module; strips any password/secret/key/token/credential keyed values from stdout/stderr before the parser sees them.
  • Live RMM evidence trims last. When the audit prompt overflows the 80KB cap, peer_global → ticket_evidence → rmm_evidence (in that order). Live evidence is the most novel signal; it's worth keeping.
  • Worker is in-process, not a separate service. Same auto-start pattern as analyzerWorker. RMM_WORKER_AUTOSTART=1 opt-in for dev. Multiple Next.js workers are safe — each row's jobUid is set once and the poll loop is idempotent.
  • getJobResults response shape is variable across Datto tenants. The worker handles both top-level and results[*] payloads, picks the per-device result when present, and falls back to the first array entry.

Deliberately left out

  • No backfill of existing Overshell jobs. Per user's call. Pulse starts capturing from the first dispatch.
  • No DC-role detection. WNP-only. Add later if AD scripts that need native DC execution become important.
  • No ad-hoc PowerShell paste-in. Only registry-listed scripts run.
  • No openclaw integration. Phase 4.2 talks directly to Datto RMM.
  • No audit-driven auto-execution. v1 is admin-clicks-button. The audit panel will gain a "Run Get-Services to fill this gap?" prompt in a fast-follow once we trust the safety layers.
  • No Overshell write operations. All scripts are read-only / discovery. Configuration changes happen via IT Glue (Phase 4) or manually.
  • No per-script per-user permissions. Anyone with rmm.execute can run any script. Per-script gating is a fast-follow if needed.
  • No credential output ever. Three-layer refusal:
    1. Script library has no credential-handling scripts; tests verify bodies don't reference $plaintext password patterns.
    2. redact() strips matching keys from output before persistence.
    3. The audit prompt's existing credential refusal applies to anything that does sneak through.

Status after Phase 4.2

Phase Tests tsc Notes
4.2 174 clean RMM Overshell pipeline (077), 7 scripts, executor + worker, audit-context arm

Phase 4.3 — LogLift event-log ingestion

Why

Overshell stdout caps around ~50KB practical — fine for service lists or installed-software dumps, too small for full Windows event logs across critical/error/warning levels. Wulf already runs a richer collector via n8n: PowerShell on each endpoint gathers logs + system context, gzips it, uploads to a Backblaze B2 bucket (wulf-audits / us-west-002), then POSTs metadata. n8n decompresses, runs an LLM analysis, and posts a Telegram summary.

Phase 4.3 makes Pulse the receiver instead of n8n so:

  • LogLift evidence lands in the same rmm_executions table 4.2 introduced.
  • The audit pipeline's rmm_evidence arm picks it up automatically.
  • Admins can dispatch a LogLift run directly from the Configuration page.
  • Successful uploads matched to a unique IT Glue Configuration auto-fire an asset-first audit so documentation suggestions surface immediately.

Shape

migrations/078_loglift_uploads.sql adds three columns to rmm_executions (transport, evidence_object_key, run_id — with a unique index on run_id), and three to rmm_settings (loglift_component_uid, loglift_component_name, loglift_discovered_at). The transport column has a CHECK constraint restricting it to overshell_stdout | b2_upload.

lib/services/b2/client.ts is a from-scratch SigV4 implementation ported from docs/LogLift Review.json: presigned GET + PUT (different expiries), 25MB hard download cap, path-traversal-safe object key regex, and a B2NotConfiguredError when env vars are missing. 8 tests cover the regex + signature stability + signing-key derivation.

lib/services/rmm/scripts/loglift-eventlogs.ts registers the script: target_type='asset_self', transport='b2_upload', empty body (the collector PowerShell lives in the Datto-registered LogLift component, not in Pulse). The registry's body-length sanity test skips b2_upload scripts.

Dispatch path

executor.ts forks on script.transport:

  • overshell_stdout (default) — unchanged 4.2 path: resolve Overshell component, dispatch with {Variable: body}, worker polls for stdout.
  • b2_upload — new fork. Resolves the Datto site uid from the device, resolves the LogLift component (discover-on-demand), generates a runId (pulse_<hex>_<ms>), inserts an rmm_executions row with transport='b2_upload', dispatches the Quick Job with variables RunId, ClientId, WebhookUrl, WebhookSecret. The persisted variables column strips WebhookSecret so admins can read the row without exposing the OPENCLAW key.

Receive path

POST /api/rmm/loglift/upload (public per middleware.ts, x-openclaw-key validated):

  1. Zod validate body + object-key regex.
  2. Resolve clientId (Datto site uid) → datto_rmm_sites.idautotask_company_id (FK or name fallback — same as 4.2 multi-site work).
  3. Resolve computerName → Datto device uid (case-insensitive).
  4. Resolve computerName + company → itg_configurations.id. Two-pass (count + fetch) sets single_match=true only when exactly one Configuration matches.
  5. Correlate to a Pulse-dispatched execution by run_id. If no match (out-of-band collector), insert a fresh running row.
  6. Download from B2 (25MB cap), gunzip with zip-bomb guard (refuse > 100MB inflated, checked via gzip ISIZE before decompression and again after).
  7. Slim: keep system_context + summary + top 100 events sorted by severity (Critical → Error → Warning → Info), then recency. Drop the raw events array; the full gzip stays in B2 forever.
  8. redact() the slim object, persist with markExecutionFromB2Upload.
  9. Auto-audit hook: if Configuration matched single, fire runAssetAudit({assetType:'configuration', assetId}) synchronously (still in the webhook handler — the LLM call is the bottleneck but the agent doesn't care about webhook latency past ~30s). On failure, log + continue — webhook still 200s.

audit_log actions: rmm.loglift.dispatched, rmm.loglift.received, rmm.loglift.matched, rmm.loglift.audit_triggered.

Worker change

worker.ts filters transport='b2_upload' rows out of the running poll list — no stdout to fetch. The 5-minute timeout sweep still applies; stuck rows get marked timeout.

Prompt update

LIVE_EVIDENCE_NOTE extended to teach the LLM about the LogLift slim shape: cite events as event:<EventId> or execution:<id>, event_count_total is the original count (top 100 only in the prompt), and system_context is authoritative for OS / hardware / disk / memory facts on the matched Configuration.

UI surfaces

  • /admin/rmm-overshell gets a second "LogLift component" block with a "Re-discover LogLift" button next to the existing Overshell discovery. discoverLogliftComponent() matches /loglift|eventlog/i.
  • Configuration page picker (filtered by target_type='asset_self') surfaces the LogLift entry automatically — Phase 4.2's executor + UI scaffolding handles it through the new dispatch fork.

Files

New: migrations/078_loglift_uploads.sql, lib/services/b2/client.ts (+ test), lib/services/rmm/scripts/loglift-eventlogs.ts, lib/services/rmm/loglift-matcher.ts, lib/services/rmm/loglift-receiver.ts, app/api/rmm/loglift/upload/route.ts, app/api/admin/rmm/settings/discover-loglift/route.ts, docs/loglift-eventlog-pipeline-spec.md.

Modified: lib/services/datto-rmm-client.ts (generalized findOvershellComponentfindComponentByName), lib/services/rmm/settings.ts (LogLift discover/resolve), lib/services/rmm/persistence.ts (transport + run_id + new findExecutionByRunId, createOutOfBandUploadExecution, markExecutionFromB2Upload), lib/services/rmm/executor.ts (b2_upload fork), lib/services/rmm/worker.ts (skip b2_upload poll), lib/services/rmm/scripts/index.ts (register), …/scripts/types.ts (transport field), …/scripts/registry.test.ts (8-script expectation + b2_upload body skip), lib/services/analyzer/asset-audit/prompt.ts (LIVE_EVIDENCE_NOTE), app/admin/rmm-overshell/page.tsx (LogLift block + button), middleware.ts (/api/rmm/loglift public).

Refusals + guards

  1. Object-key regex (^[A-Za-z0-9_-]+/[A-Za-z0-9_.-]+/eventlogs_[0-9_]+\.json\.gz$).
  2. B2 25MB download cap.
  3. Decompress 100MB cap (gzip ISIZE pre-check + post-inflate re-check).
  4. redact() on slim payload before persistence.
  5. Auto-audit only on single-match Configurations — multiple matches logged + skipped.
  6. Webhook secret stripped from persisted variables column.

Status after Phase 4.3

Phase Tests tsc Notes
4.3 TBD (target ~182) TBD LogLift pipeline (078), B2 SigV4, b2_upload transport, slim + auto-audit