wulf-pulse/docs/wulf-pulse-ticket-analyzer-runbook.md

1000 lines
34 KiB
Markdown
Raw Permalink Normal View History

# AI Ticket Analyzer — Operator Runbook
How to keep the analyzer healthy in production. Audience is whoever is on
call for Pulse. The feature spec is in
`wulf-pulse-ticket-analyzer-prompt.md`; the implementation log is in
`wulf-pulse-ticket-analyzer-build-notes.md`.
---
## At a glance
- **Pipeline**: Haiku triage → (optional) IT Glue retrieval → Sonnet deep
analysis → (optional) Opus deep reasoning → persistence.
- **Trigger**: user clicks "Analyze" on a ticket page; that POSTs to
`/api/analyzer/tickets/:ticketNumber/analyze`. The route preprocesses
inline, short-circuits to the cached row when content hasn't changed,
otherwise queues a job for the worker.
- **Worker**: `analyzerWorker` polls `analyzer_jobs` every 2 s in-process.
Auto-starts in production (`NODE_ENV=production`). Opt in for dev with
`ANALYZER_WORKER_AUTOSTART=1`.
- **Cost ceiling**: hard-coded `COST_CEILING_USD = 2.00` in
`lib/services/analyzer/pipeline.ts`. Trips before Opus only — Sonnet runs
unconditionally. When tripped, the analysis is flagged
`needs_human_review = true` with reason "cost ceiling reached".
---
## Required environment
Add to `~/projects_env/wulf-pulse.env` (do not commit values):
```
ANTHROPIC_API_KEY=
ITGLUE_API_KEY=
ITGLUE_API_BASE=https://api.itglue.com
ALLOWED_SHARE_DOMAINS=wulfconsulting.com
# Optional in dev:
ANALYZER_WORKER_AUTOSTART=1
```
`SMTP_*` and `BETTER_AUTH_URL` are already set elsewhere in Pulse — share
emails reuse the existing magic-link transport.
---
## First-time setup on an existing database
Migration `069_create_analyzer_tables.sql` creates `analyzer_analyses`,
`analyzer_shares`, and `analyzer_jobs`. Postgres only re-applies migrations
on a fresh data volume — for an existing DB, run the migration directly:
```bash
psql "$DATABASE_URL" -f migrations/069_create_analyzer_tables.sql
```
The script is idempotent (`IF NOT EXISTS` on every object).
---
## Monitoring cost
Every completed analysis stores `estimated_cost_usd`, `total_input_tokens`,
`total_output_tokens`, and per-stage tier flags
(`haiku_used` / `sonnet_used` / `opus_used`). The number is a forward
estimate from `lib/services/llm/pricing.ts` and will drift if Anthropic
changes published rates — re-check that file before believing big spend
numbers.
### Daily spend (last 14 days)
```sql
SELECT date_trunc('day', triggered_at) AS day,
count(*) FILTER (WHERE status = 'complete') AS analyses,
count(*) FILTER (WHERE opus_used) AS opus_runs,
count(*) FILTER (WHERE needs_human_review) AS needs_review,
round(sum(estimated_cost_usd)::numeric, 2) AS spend_usd
FROM analyzer_analyses
WHERE triggered_at > now() - interval '14 days'
GROUP BY 1
ORDER BY 1 DESC;
```
### Top 20 most expensive analyses (last 30 days)
```sql
SELECT id, ticket_number, analysis_version, estimated_cost_usd,
opus_used, needs_human_review, triggered_at
FROM analyzer_analyses
WHERE triggered_at > now() - interval '30 days'
ORDER BY estimated_cost_usd DESC
LIMIT 20;
```
### Heaviest tickets (multiple re-runs against the same ticket)
```sql
SELECT ticket_number,
count(*) AS analyses,
round(sum(estimated_cost_usd)::numeric, 2) AS spend_usd,
max(triggered_at) AS last_run
FROM analyzer_analyses
GROUP BY 1
HAVING count(*) > 1
ORDER BY spend_usd DESC
LIMIT 20;
```
### Cost ceiling — what to do when it trips
`needs_human_review = true` with `human_review_reasons` containing "cost
ceiling reached" means Sonnet ran but Opus was skipped. The Sonnet output
is still persisted and is usually fine. To re-run with Opus forced on,
trigger a new analysis (the route accepts `force=true`); the cost ceiling
will reapply, so check the prior `estimated_cost_usd` before committing.
To raise the ceiling, edit `COST_CEILING_USD` in
`lib/services/analyzer/pipeline.ts` and redeploy.
To reduce spend without code changes: discourage operators from clicking
Re-analyze unless content has changed (the idempotency short-circuit
already prevents charged re-runs against the same `content_hash`).
---
## Adding IT Glue org aliases
Stage 2 resolves an Autotask company name to an IT Glue organization in
two steps:
1. **Alias map**`lib/services/analyzer/itglue-aliases.json`. Keys are
normalized company names (lowercase, single-spaced); values are IT Glue
organization IDs as **strings**. Exact-match wins immediately.
2. **Live IT Glue search** — fallback when the alias map misses.
When the live search misses or returns the wrong org, add an alias.
### Finding the IT Glue organization ID
In the IT Glue UI, navigate to the organization. The numeric segment of
the URL (`/organizations/12345`) is the ID. Or via API:
```bash
curl -s -H "x-api-key: $ITGLUE_API_KEY" \
"$ITGLUE_API_BASE/organizations?filter[name]=Seubert%20and%20Associates" \
| jq '.data[] | {id, name: .attributes.name}'
```
### Updating the alias file
Edit `lib/services/analyzer/itglue-aliases.json`. Keys must be **already
normalized** — lowercase, single-spaced — because the matcher applies the
same normalization to the Autotask company name and looks up an exact key.
```json
{
"seubert": "1234567",
"seubert and associates": "1234567",
"acme insurance group": "7654321"
}
```
Strip the `_comment` / `_example` keys when adding the first real entry,
or leave them — they're ignored at match time (no real org name normalizes
to a leading underscore).
Changes require a redeploy. The file is bundled into the build by Next's
`import` — there is no runtime reload.
### Verifying an alias works
After deploy, run an analysis on a ticket from that company. Check the
`model_traces.itglue` field on the resulting row:
```sql
SELECT model_traces->'itglue'
FROM analyzer_analyses
WHERE id = '<analysis-id>';
```
`alias_used: true` confirms the alias hit. `org_id` should be the value
you put in the JSON.
---
## Triaging failed analyses
Two failure surfaces to know about:
### 1. Failed jobs (`analyzer_jobs.status = 'failed'`)
The job hit an exception before the analysis row was written. The error
message is in `analyzer_jobs.error_message`. Common causes:
| Error message | Cause | Fix |
|---|---|---|
| `Ticket T... not found in local mirror — confirm sync is current.` | Autotask sync hasn't pulled the ticket yet. | Wait for the next sync, or trigger a manual sync from `/admin`. The webhook usually fills the gap; if not, see `scripts/backfill-ticket-notes-gap.ts` for the reconciliation pattern. |
| `LLM stage on claude-... failed twice` | Two Anthropic call attempts both failed (network / parse / 5xx). | Check the API key is valid (`curl` Anthropic), and check Anthropic status. Re-queue by clicking Re-analyze. |
| `Anthropic API error: 429 ...` | Rate limit. | Wait and re-queue. |
| `IT Glue search failed: ...` should NOT appear here — the pipeline tolerates IT Glue failures and continues without context. | If you see one, the failure was outside the catch block and is a bug. |
```sql
-- Recent failures
SELECT id, ticket_number, error_message, queued_at, finished_at
FROM analyzer_jobs
WHERE status = 'failed'
AND queued_at > now() - interval '7 days'
ORDER BY queued_at DESC;
```
To retry a failed job, queue a fresh one — there is no in-place retry.
Either click Re-analyze in the UI or:
```sql
INSERT INTO analyzer_jobs (ticket_number, queued_by_user_id)
SELECT ticket_number, queued_by_user_id
FROM analyzer_jobs
WHERE id = '<failed-job-id>';
```
### 2. Completed but flagged for human review (`needs_human_review = true`)
The pipeline finished and produced an analysis, but the model itself
reported low confidence or the cost circuit breaker tripped before Opus.
`human_review_reasons` is a JSONB array of short strings — read it.
The UI exposes this queue at `/analyzer/queue`. SQL equivalent:
```sql
SELECT id, ticket_number, analysis_version,
confidence_score, estimated_cost_usd,
human_review_reasons
FROM analyzer_analyses
WHERE needs_human_review = true
AND status = 'complete'
ORDER BY triggered_at DESC
LIMIT 50;
```
Reasons you'll see:
- `"cost ceiling reached"` — Opus skipped to save money. Sonnet output is
what you have. Re-run with `force=true` to override (and accept the
spend).
- `"low confidence: <score>"` — the model itself reports uncertainty. The
Sonnet output may still be useful as a starting point; treat as
guidance, not verdict.
- A model-emitted reason from Stage 3 or Stage 4. The full string is in
the JSONB — don't truncate before reading.
### 3. Worker not picking up jobs
If `analyzer_jobs` has rows in `status='queued'` for more than ~30s, the
worker is wedged or not running.
- Check the application log for `[ANALYZER-WORKER] starting` at boot. If
missing in production, `NODE_ENV` may not be set to `production`
bring up `ANALYZER_WORKER_AUTOSTART=1` as a workaround.
- The worker is in-process; restarting the Pulse app restarts it.
- Concurrency: multiple replicas all run a worker, but `claimQueuedJob`
uses `FOR UPDATE SKIP LOCKED` so each job is processed exactly once.
---
## Manual ops
### Trigger an analysis from psql
```sql
INSERT INTO analyzer_jobs (ticket_number, queued_by_user_id)
VALUES ('T20260424.0045', NULL);
```
The worker will pick it up within 2 seconds. `queued_by_user_id` may be
null (`ON DELETE SET NULL`).
### Force a re-run despite a cached analysis
The `force=true` flag on `POST /api/analyzer/tickets/:ticketNumber/analyze`
bypasses the idempotency short-circuit. The Re-analyze button in the UI
sends this flag.
### Inspect raw stage outputs for a debugging session
```sql
SELECT model_traces
FROM analyzer_analyses
WHERE id = '<analysis-id>';
```
Per-stage payload includes input/output tokens, attempt count, full parsed
model responses, and IT Glue resolution metadata. Don't paste this into
external systems — it can include redacted-but-still-sensitive snippets
of customer data.
### Ad hoc: how much have we spent this month?
```sql
SELECT round(sum(estimated_cost_usd)::numeric, 2) AS spend_usd,
count(*) AS analyses
FROM analyzer_analyses
WHERE triggered_at >= date_trunc('month', now());
```
---
## Things that are not implemented yet
- No automated retries on failed jobs.
- No `viewed_at` tracking on share rows (column exists, nothing writes
to it).
- No `email_sent_at` column — share email send state lives only in the
HTTP response and the server log.
- No pre-Sonnet cost gate. The circuit breaker only protects against
Opus.
- No autocomplete on the share-recipient field.
If any of these become a real operational problem, file the work — the
stubs are intentional and called out in
`wulf-pulse-ticket-analyzer-build-notes.md`.
feat(analyzer): Phase 2 — full stage persistence, fingerprints, aggregate reports, cost guards Eight sub-phases per docs/ticket-analyzer-phase2-spec.md: 2.1 Schema (migration 070): analyzer_stage_executions table; source_snapshot, aggregate_fingerprint, fingerprint_generated_at columns on analyzer_analyses. model_traces marked LEGACY (kept for back-compat). 2.2 Every pipeline stage records a row to analyzer_stage_executions, success or failure. Worker persists a status='failed' analyzer_analyses row when the pipeline throws so partial stage records have a parent. Pipeline exposes raw triage/sonnet/opus responses for downstream stages. 2.3 Stage 3 prompt updated with markdown formatting rules + banned filler phrases. Added react-markdown + remark-gfm + @tailwindcss/typography. New <AnalysisMarkdown> component replaces <ProseText>; coerces stray headers to bold paragraphs. 2.4 Stage 6 fingerprint (Haiku) runs after persistence, failure-tolerant. scripts/backfill-fingerprints.ts reconstructs Stage 6 input from the legacy model_traces blob. 2.5 Browse UI rebuild at /analyzer/tickets: multi-select for client/issue/ queue/status/priority/assignee, sticky filter bar, active-filter chips, bulk selection persisted via localStorage, "Analyze N selected" + "Generate aggregate report" actions. New <MultiSelect> primitive. Staleness uses last_activity_date > completed_at heuristic per spec C.1. 2.6 Aggregate reports (migration 071): runner is fire-and-forget, persists SQL distributions immediately so UI shows partial state during the Sonnet reduce call. Three endpoints, three pages (/analyzer/reports[/new /:id]). IT Glue context fetcher capped at 200 doc titles. 2.7 Cost guards (migration 072): per-request $5 confirmation, soft-warn at $20/day, hard-block at $50/day with ANALYZER_DAILY_COST_OVERRIDE_USERS override. Every gating decision audited. 2.8 Runbook + build notes updated. 128 vitest tests passing, tsc clean. Migrations 070/071/072 idempotent (IF NOT EXISTS). model_traces double-write retained — drop in a future migration once aggregate reports have soaked. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 14:00:22 -04:00
---
## Phase 2 additions
### Stage execution history (per-analysis forensics)
Migration 070 introduced `analyzer_stage_executions`. Every stage of the
pipeline (preprocess, triage, itglue, analyze, deep_review, fingerprint)
now writes a row including the input it saw and the output it produced.
Failed stages get a row with `error_message` populated.
Read it like this:
```sql
-- Full per-stage trace for one analysis
SELECT stage_order, stage, model_id,
input_tokens, output_tokens, latency_ms,
error_message
FROM analyzer_stage_executions
WHERE analysis_id = '<analysis-id>'
ORDER BY stage_order;
-- Inspect a single stage's full input/output
SELECT input_payload, output_payload
FROM analyzer_stage_executions
WHERE analysis_id = '<analysis-id>' AND stage = 'analyze';
```
The legacy `analyzer_analyses.model_traces` JSONB column is preserved for
back-compat. New analyses double-write to both. A future migration will
drop `model_traces` once aggregate reports have soaked.
### Fingerprint stage (Stage 6)
After persistence, the worker runs Haiku-tier fingerprint extraction and
writes the result to `analyzer_analyses.aggregate_fingerprint`. Failure
is non-fatal — the analysis row stays usable, fingerprint stays NULL.
Re-fingerprint on demand:
```sql
SELECT id, ticket_number, analysis_version
FROM analyzer_analyses
WHERE aggregate_fingerprint IS NULL
AND status = 'complete'
ORDER BY triggered_at ASC;
```
Or run the backfill script (idempotent — the SQL filter skips already-fingerprinted rows):
```bash
npx tsx scripts/backfill-fingerprints.ts # process all missing
npx tsx scripts/backfill-fingerprints.ts --limit=50 # cap work
npx tsx scripts/backfill-fingerprints.ts --dry-run # show what would run
```
The script reconstructs the Stage 6 input from
`analyzer_analyses.model_traces.{triage_response, sonnet_response, opus_response}`.
If a row's model_traces is missing those fields (very old format) the script
logs `[skip]` for it and moves on.
Cost: Haiku at ~$0.001 per fingerprint. 1000 analyses ≈ $1.
### Aggregate reports
Migration 071 added `analyzer_aggregate_reports` and relaxed
`analyzer_stage_executions.analysis_id` to be nullable; rows now carry
either an `analysis_id` or an `aggregate_report_id` (CHECK enforces
exactly one).
Workflow:
1. User selects tickets on `/analyzer/tickets` and clicks
**Generate aggregate report**.
2. POST `/api/analyzer/aggregate-reports` validates: ≤100 tickets, all
have a fingerprint, none are stale. Inserts a 'pending' row, fires
the runner via `void runAggregateReport(id)`, returns immediately.
3. Runner does:
- Loads fingerprints
- Computes SQL distributions (categories, clients, root cause,
resolution path) and writes them to the row immediately
- Fetches IT Glue doc titles for affected clients (if requested)
- Calls Sonnet (Opus opt-in) with a structured payload
- Writes the LLM-derived fields, marks `status='complete'`
4. UI polls `GET /api/analyzer/aggregate-reports/:id` every 3s while
pending/running.
Inspect a report's sub-stage trace (linked via `aggregate_report_id`):
```sql
SELECT stage_order, stage, model_id, latency_ms, error_message
FROM analyzer_stage_executions
WHERE aggregate_report_id = '<report-id>'
ORDER BY stage_order;
```
Daily spend on aggregate reports:
```sql
SELECT date_trunc('day', generated_at) AS day,
count(*) AS reports,
round(sum(estimated_cost_usd)::numeric, 2) AS spend_usd
FROM analyzer_aggregate_reports
WHERE generated_at > now() - interval '14 days'
GROUP BY 1
ORDER BY 1 DESC;
```
### Cost guards (Phase 2.7)
Three thresholds enforce per-user daily spend:
- **$5 per request** — a single aggregate report estimated above $5
triggers a confirmation modal. Frontend re-POSTs with
`confirmedCost: true` if the user clicks through.
- **$20 per user/day** — soft warn. Surfaced via `softWarn:true` in the
cost evaluation; no enforcement.
- **$50 per user/day** — hard block. POST returns 403 unless the user
is in `ANALYZER_DAILY_COST_OVERRIDE_USERS` (comma-separated list of
Better Auth user ids).
Daily spend is computed as the trailing 24h sum of
`analyzer_analyses.estimated_cost_usd` + `analyzer_aggregate_reports.estimated_cost_usd`
for the user.
Every gating decision writes a row to `analyzer_cost_audit`:
```sql
SELECT created_at, user_id, action, estimated_cost,
daily_spend_before, decision, decision_reason
FROM analyzer_cost_audit
ORDER BY created_at DESC
LIMIT 50;
```
To grant a user override capability:
```bash
# in ~/projects_env/wulf-pulse.env
ANALYZER_DAILY_COST_OVERRIDE_USERS=user_id_1,user_id_2
```
Restart `pulse-app` to pick up the change.
### Browse / filter ticket list
`/analyzer/tickets` rebuilt as the primary entry point in Phase 2.5:
- Multi-select for client, issue type, queue, status, priority, assignee
- Sticky filter bar with period chips (today/yesterday/this+last week,
30d/60d, custom range, all time)
- Active-filter chips below the bar; click to remove
- Bulk row selection persisted in localStorage
(`analyzer:ticket-selection:v1`) — survives pagination but is
session-scoped (no user id baked in)
- Bulk "Analyze N selected" sequentially queues jobs for each ticket
(forces re-analyze on stale; analyzes from scratch on un-analyzed)
- Bulk "Generate aggregate report" routes selected tickets to
`/analyzer/reports/new`; only enabled when all selected are
`analyzedState === 'current'`
**Staleness heuristic**: a ticket's `analyzedState` is computed as
`stale` when `tickets.last_activity_date > latest_analysis.completed_at`.
The Phase 2 spec calls for content-hash-based comparison; that requires
either caching the current hash on the tickets row (sync change) or
computing it on read for the visible page (slow). The date heuristic
gets ~95% of the value at zero compute cost; revisit when there's real
load signal.
---
## Link-aware bundles (Phase 3)
A "bundle" is an aggregate report launched from a single ticket page —
typically a master/problem ticket that names other tickets in its
description. Instead of forcing the user to analyze each constituent
manually and then visit `/analyzer/reports/new`, the bundle endpoint
fans out per-ticket analyses and chains them into an aggregate report
automatically.
### How it works end-to-end
1. User loads `/analyzer/ticket/<ticket-number>`.
2. `<RelatedTicketsPanel/>` calls `GET /api/analyzer/tickets/:tn/links`
(cheap, no LLM) — regex extraction over the description and retained
notes for `T\d{8}\.\d{4}` references, plus the structured
`RELATED TICKETS:` block detector and `problem_ticket_id` resolution.
The panel renders only when refs exist or the ticket looks like a
problem ticket.
3. (Optional) User flips the "AI-suggest more" Switch — this POSTs the
same endpoint with `includeSuggested: true`, runs one Haiku pass over
recent same-company tickets (±30 days, capped at 50 candidates), and
returns up to 5 suggestions with one-line reasons.
4. User clicks **"Analyze with N linked tickets"**. The panel POSTs to
`/api/analyzer/tickets/:tn/analyze-bundle` with
`linkedTicketNumbers: [...]`.
5. The bundle endpoint:
- Verifies every ticket exists locally (one SQL round-trip).
- Per ticket: idempotency-checks via content hash. If a complete
analysis exists, it's reused; otherwise a fresh `analyzer_jobs` row
is queued.
- Cost-guard runs against **new work only** plus the aggregate-reduce
step. $5 confirmation threshold and $50 daily hard block are the
same gates as standalone aggregate reports.
- Inserts an `analyzer_aggregate_reports` row in `'pending_analyses'`
state with `expected_ticket_numbers` populated (or straight to
`'pending'` and immediately fires the runner if everything was
already complete).
6. Worker polls and runs each queued job. After each successful
analysis, `chainTriggerForCompletedAnalysis()` looks up bundles
waiting on that ticket, appends the new analysis_id, and (if the full
set is now satisfied) flips status to `'pending'` and fires
`runAggregateReport()`.
7. Frontend polls `GET /api/analyzer/aggregate-reports/:id` every 3s and
navigates to `/analyzer/reports/:id` on completion.
### Status state machine
```
pending_analyses ──── all expected analyses complete ────► pending
running
complete | failed
```
`'pending_analyses'` is the new state Phase 3 introduces.
Manual-multi-select reports created via `/analyzer/reports/new` skip it
and start at `'pending'` (their analyses must already be complete to
even submit).
### Inspecting a bundle
```sql
SELECT id, status, ticket_count,
array_length(expected_ticket_numbers, 1) AS expected,
array_length(analysis_ids, 1) AS collected,
triggered_by_ticket_number,
generated_at
FROM analyzer_aggregate_reports
WHERE expected_ticket_numbers IS NOT NULL
ORDER BY generated_at DESC
LIMIT 20;
```
Find which expected tickets a stuck `pending_analyses` bundle is still
waiting on:
```sql
WITH r AS (
SELECT id, expected_ticket_numbers, analysis_ids
FROM analyzer_aggregate_reports
WHERE id = '<report-id>'
)
SELECT etn.ticket_number,
(SELECT bool_or(aa.id = ANY(r.analysis_ids))
FROM analyzer_analyses aa
WHERE aa.ticket_number = etn.ticket_number
AND aa.status = 'complete') AS has_collected_analysis
FROM r,
LATERAL UNNEST(r.expected_ticket_numbers) AS etn(ticket_number);
```
The `false` rows are the tickets we're still waiting on. Cross-reference
with `analyzer_jobs` filtered by those ticket numbers to see whether the
job is queued, in-flight, or failed.
### Cost shape
For a typical 4-ticket problem bundle on fresh tickets:
| Step | Model | Approx cost |
|---|---|---|
| Link discovery (explicit) | none | ~free |
| AI-suggested arm (if toggled) | Haiku | ~$0.005 |
| Per-ticket pipeline × 4 | Haiku → Sonnet (+ optional Opus) | $0.20 $1.20 |
| Aggregate reduce | Opus | ~$0.50 |
| **Total** | | **~$1 $2** |
The bundle endpoint's per-ticket estimate is a flat $0.15 (pessimistic
Sonnet) used purely for the cost guard. Real spend is captured per row
on `analyzer_analyses.estimated_cost_usd` once each pipeline run
completes.
### When the panel doesn't render
The panel is intentionally invisible on tickets that aren't candidates
for bundling:
- No `T<YYYYMMDD>.<####>` references found in description or retained
notes
- `tickets.problem_ticket_id` is null
- Title contains neither "master problem ticket" nor "problem ticket"
If a user expects to see the panel and doesn't, the most common reason
is that the referenced tickets aren't in our local mirror yet (sync
gap) — `discoverExplicitLinks` filters refs against
`tickets.ticket_number` to keep ghost links out of the UI. Run the
ticket sync and reload.
### Suggested-arm limits
The Haiku call drops any suggestion whose ticket_number isn't in the
candidate list it was given (hallucination guard). Suggestions are
capped at 5 and never auto-included — the user has to tick the
checkbox. If the LLM call throws, the failure is logged and the panel
still shows the explicit refs (the suggestion arm is opportunistic, not
load-bearing).
---
## IT Glue asset audits (Phase 4)
The asset-audit pipeline analyzes one IT Glue Application record at a time
against ticket history + IT Glue's own field schema, surfaces documentation
gaps and "promote-from-Notes" suggestions, and lets admins push approved
changes back to IT Glue. Every change is recorded in three audit layers
(see Phase 4 build notes).
Permissions:
- Read audit / run audit → any authenticated user (cheap, ~$0.01).
- Apply or Revert → `requirePermission('itglue', 'write')` — admin or
super-admin only.
- `/admin/itglue-writes` → admin-only.
### Inspecting audits
```sql
-- Latest audit per asset, lowest scoring first
SELECT a.asset_id,
fa.name AS application_name,
fa.organization_name,
a.overall_score,
a.ticket_count,
jsonb_array_length(a.field_gaps) AS gap_count,
jsonb_array_length(a.notes_promotions) AS promo_count,
a.provider, a.model_used,
a.estimated_cost_usd,
a.generated_at
FROM itglue_asset_audits a
JOIN itg_flexible_assets fa ON fa.id = a.asset_id::bigint
WHERE a.status = 'complete'
ORDER BY a.asset_id, a.generated_at DESC;
```
```sql
-- Failed audits (forensics)
SELECT id, asset_id, generated_at, provider, model_used,
LEFT(error_message, 200) AS error
FROM itglue_asset_audits
WHERE status = 'failed'
ORDER BY generated_at DESC
LIMIT 20;
```
### Inspecting writes
```sql
-- All committed writes in the last 7 days, with provenance
SELECT w.performed_at,
w.field_name,
w.before_value, w.after_value,
w.performed_by_user_id,
w.audit_id,
fa.name AS application_name,
fa.organization_name
FROM itglue_writes w
JOIN itg_flexible_assets fa ON fa.id = w.asset_id::bigint
WHERE w.status = 'committed'
AND w.performed_at >= NOW() - INTERVAL '7 days'
ORDER BY w.performed_at DESC;
```
```sql
-- Failed writes (admin should investigate)
SELECT id, asset_id, field_name, error_message, performed_at
FROM itglue_writes
WHERE status = 'failed'
ORDER BY performed_at DESC
LIMIT 20;
```
### How a revert works
Reverts produce a brand-new `itglue_writes` row whose `before_value` /
`after_value` are swapped from the original, and mark the original row
`status='reverted'`. The chain is always traceable:
```sql
-- Find every write + its revert (if any) for one asset
SELECT id, field_name, status,
before_value, after_value,
performed_at,
audit_id,
source_evidence ->> 'reverts_write_id' AS reverts_id
FROM itglue_writes
WHERE asset_id = '17096940'
ORDER BY performed_at;
```
### Cost-guard
Audit runs charge ~$0.01 (DeepSeek) or ~$0.10 (Claude) per call. Same
guard primitives as ticket analyses:
```sql
SELECT created_at, user_id, action, estimated_cost,
decision, decision_reason
FROM analyzer_cost_audit
WHERE action = 'itglue_audit'
ORDER BY created_at DESC
LIMIT 50;
```
### When the data-builder returns no ticket evidence
`buildAssetAuditContext` joins via `companies.company_name = itg_organizations.name`
(case-insensitive) to map IT Glue org → Autotask company id, then matches
ticket fingerprints whose summary or fingerprint mention the asset name. Two
common reasons for an empty `ticket_evidence` array:
1. The IT Glue org's `name` doesn't match any `companies.company_name`
exactly — fix by aligning the names, or extend the join.
2. The asset's `name` is too generic ("Office", "Email") and the ILIKE match
pulls nothing distinctive — accept the audit will rely on field schema +
peer exemplars only, no per-ticket grounding.
### Generic audit_log surfaces every change too
`audit.log()` is called after every successful Apply/Revert with action
`itglue.write` or `itglue.revert`. `/admin/audit-log` shows it. So admins
have two views: domain-specific at `/admin/itglue-writes` (with diffs +
revert button), and the generic admin feed at `/admin/audit-log`.
---
## Phase 4.1: ticket-first capture, Configurations, xrefs
### Inspecting the cross-reference index
```sql
-- Every ticket↔asset linkage in the last 7 days
SELECT created_at,
ticket_number,
relationship,
asset_type,
asset_id,
source,
confidence,
details
FROM itglue_ticket_xrefs
WHERE created_at >= NOW() - INTERVAL '7 days'
ORDER BY created_at DESC
LIMIT 100;
```
```sql
-- Most-referenced IT Glue assets across all tickets (heat-map for which
-- docs the analyzer leans on most)
SELECT asset_type,
asset_id,
COUNT(*) AS reference_count,
COUNT(DISTINCT ticket_number) AS distinct_tickets,
MAX(created_at) AS last_referenced
FROM itglue_ticket_xrefs
WHERE relationship = 'referenced'
GROUP BY asset_type, asset_id
ORDER BY reference_count DESC
LIMIT 30;
```
```sql
-- Assets that have been UPDATED via ticket-driven audits but never
-- REFERENCED — possibly newly-introduced docs that haven't proven their
-- worth yet, or docs the analyzer's retrieval stage isn't finding.
SELECT u.asset_type, u.asset_id, COUNT(*) AS update_count
FROM itglue_ticket_xrefs u
WHERE u.relationship = 'updated'
AND NOT EXISTS (
SELECT 1 FROM itglue_ticket_xrefs r
WHERE r.asset_type = u.asset_type
AND r.asset_id = u.asset_id
AND r.relationship = 'referenced'
)
GROUP BY u.asset_type, u.asset_id
ORDER BY update_count DESC;
```
### Ticket-scoped audits
```sql
-- All audits triggered from a specific ticket
SELECT a.id, a.asset_type, a.asset_id,
fa.name AS asset_name,
a.overall_score,
jsonb_array_length(a.field_gaps) AS gap_count,
a.provider, a.estimated_cost_usd,
a.generated_at
FROM itglue_asset_audits a
LEFT JOIN itg_flexible_assets fa
ON fa.id = a.asset_id::bigint AND a.asset_type = 'flexible_asset'
WHERE a.triggered_by_ticket_number = 'T20260502.0033'
ORDER BY a.generated_at DESC;
```
```sql
-- Every write a given ticket drove (denormalized for one-query lookup)
SELECT w.performed_at, w.asset_type, w.asset_id, w.field_name,
w.before_value, w.after_value, w.status
FROM itglue_writes w
WHERE w.triggered_by_ticket_number = 'T20260502.0033'
ORDER BY w.performed_at;
```
### Configuration audits
Same shape as Application audits but `asset_type='configuration'`:
```sql
SELECT a.id, c.name, c.hostname, c.configuration_type_name,
a.overall_score, jsonb_array_length(a.field_gaps) AS gap_count,
a.provider, a.generated_at
FROM itglue_asset_audits a
JOIN itg_configurations c ON c.id = a.asset_id::bigint
WHERE a.asset_type = 'configuration'
AND a.status = 'complete'
ORDER BY a.generated_at DESC
LIMIT 30;
```
### When asset-matcher returns nothing
`matchAssetsForAnalysis(analysisId)` joins `companies → itg_organizations`
on `LOWER(name)`. If a ticket comes back with `flexibleAssets: []` and
`configurations: []`, two common causes:
1. The IT Glue org name doesn't match the Autotask company name (case-
insensitive exact). Fix by aligning the names in either system, or
extend the join in `lib/services/analyzer/asset-audit/asset-matcher.ts`.
2. The fingerprint's `applications_involved` / `device_classes` don't
contain any term that substring-matches a real asset name. The audit
panel will show "No matches" — the user can still manually navigate
to the relevant asset's detail page and run an asset-first audit.
### Cost shape for ticket-first audits
Per-asset audit on a ticket-scoped run:
| Provider | Cost | Latency |
|---|---|---|
| Anthropic (Sonnet) | ~$0.05 | 30-60s |
| OpenRouter (DeepSeek V4 Pro) | ~$0.005 | 60-180s |
A typical ticket with 1 Application match + 1 Configuration match audited
on DeepSeek runs ~$0.01 and ~3 minutes total.
## Phase 4.3: LogLift event-log evidence pipeline
### Required env vars
```
B2_KEY_ID=… # Backblaze B2 application key id
B2_APP_KEY=… # Backblaze B2 application key secret
B2_BUCKET=wulf-audits # default; matches existing n8n bucket
B2_REGION=us-west-002 # default
B2_ENDPOINT=s3.us-west-002.backblazeb2.com # default; no scheme
OPENCLAW_API_KEY=… # webhook auth + collector variable
BETTER_AUTH_URL=… # base URL the collector POSTs back to
```
### One-time discovery
```bash
# Sign in as admin → /admin/rmm-overshell → click "Re-discover LogLift"
# Or via curl with an admin session cookie:
curl -X POST "$BETTER_AUTH_URL/api/admin/rmm/settings/discover-loglift" \
-H "Cookie: better-auth.session_token=…" -i
```
Confirm:
```sql
SELECT loglift_component_uid, loglift_component_name, loglift_discovered_at
FROM rmm_settings WHERE id = true;
```
### Inspecting LogLift uploads
```sql
-- Recent LogLift executions, with match status
SELECT e.id, e.run_id, e.target_hostname, e.status,
e.evidence_object_key,
e.parsed_evidence -> 'event_count_total' AS event_count,
e.parsed_evidence -> 'webhook_summary' -> 'criticalEvents' AS critical_events,
e.queued_at, e.completed_at
FROM rmm_executions e
WHERE e.transport = 'b2_upload'
ORDER BY e.queued_at DESC
LIMIT 25;
```
```sql
-- LogLift uploads that didn't match a Configuration (review for hostname
-- typos or unmapped Configurations)
SELECT e.run_id, e.target_hostname, e.target_company_id,
e.queued_at
FROM rmm_executions e
WHERE e.transport = 'b2_upload'
AND e.status = 'complete'
AND e.asset_id IS NULL
ORDER BY e.queued_at DESC;
```
```sql
-- LogLift uploads that auto-fired an audit
SELECT a.id AS audit_id, a.asset_id AS configuration_id,
a.overall_score, a.generated_at,
l.action, l.created_at AS triggered_at
FROM audit_log l
JOIN itglue_asset_audits a ON a.id::text = (l.details ->> 'audit_id')
WHERE l.action = 'rmm.loglift.audit_triggered'
ORDER BY l.created_at DESC LIMIT 25;
```
### Manual replay of a B2 object
If a webhook came in but Pulse was down, you can replay by re-POSTing
the original webhook payload (the collector keeps the metadata; if not,
build it from the object key + B2's metadata API). The receiver is
idempotent on `run_id` — a duplicate replay with the same `run_id` will
update the existing row rather than insert a duplicate.
### Inspecting a stuck b2_upload row
```sql
SELECT id, run_id, target_hostname, status, started_at, timeout_at,
evidence_object_key
FROM rmm_executions
WHERE transport = 'b2_upload'
AND status = 'running'
ORDER BY started_at;
```
After 5 minutes, the Overshell worker's timeout sweep flips stuck rows
to `timeout`. If the upload arrives later, the receiver still updates
the same row by `run_id` (the unique index makes this safe).
### Force a one-off audit replay from existing evidence
If the auto-audit failed at upload time (e.g. LLM timeout) and the
evidence is already in `rmm_executions`, you can re-fire the audit:
```sql
-- Find the configuration_id from the most recent LogLift upload
SELECT asset_id::text AS configuration_id
FROM rmm_executions
WHERE transport = 'b2_upload'
AND target_hostname ILIKE 'YNGHYNWNP01'
ORDER BY completed_at DESC LIMIT 1;
```
Then trigger via the existing audit endpoint:
```bash
curl -X POST "$BETTER_AUTH_URL/api/itglue/asset-audit/run" \
-H "Content-Type: application/json" \
-H "Cookie: better-auth.session_token=…" \
-d '{"assetType":"configuration","assetId":"<config_id>","provider":"anthropic"}'
```
### Why no Telegram summary?
Out of scope for v1 — that flow was a notification, not a data path.
Audits show up on the Configuration page automatically. If you want a
Slack/Teams ping when an auto-audit completes, hook it off the
`rmm.loglift.audit_triggered` audit_log entry.