wulf-pulse/docs/itglue-asset-audit-spec.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

25 KiB
Raw Blame History

Feature: IT Glue Asset Audit & Documentation Write-back

Status: shipped (Phases 4 + 4.1 of the AI Ticket Analyzer) Migrations: 075_itglue_audit.sql, 076_itglue_ticket_xrefs.sql Build notes: docs/wulf-pulse-ticket-analyzer-build-notes.md → Phase 4 + 4.1 Operator runbook: docs/wulf-pulse-ticket-analyzer-runbook.md → "IT Glue asset audits"


What it is, in one paragraph

For each IT Glue flexible-asset Application record and Configuration record (server, workstation, network device), Pulse runs an LLM audit that compares the record's current contents against (a) the field schema with hints, (b) other well-filled records at the same client, (c) recent ticket history that mentions the asset. It surfaces field gaps ("Wulf Application Champion is empty — Jake Hammel is the de-facto SME per T20260502.0033"), note promotions (free text in Notes that belongs in a structured field), and contradictions (Notes say "2-3 VMs" but Application-on-Device tags only 1). Admins can apply a suggestion with one click — Pulse PATCHes IT Glue and records every change with full before/after diff and revert capability.

Two complementary entry points:

  • Asset-first (Phase 4) — admin browses lowest-scoring Application or Configuration records and runs an audit against all-time history. Useful for periodic backlog sweeps.
  • Ticket-first (Phase 4.1) — every analyzed ticket gets a "Check IT Glue documentation" button on its analysis page. Click → matched IT Glue records appear → audit per record uses just this ticket's evidence. Findings frame as "what did this ticket teach us that the documentation doesn't say?" Drives a forward-only cross-reference index (itglue_ticket_xrefs) of which tickets referenced or updated which documentation, which is also the lookup index for a future RAG automation.

Why we built it

The analyzer already extracts documentation_gaps_observed per ticket (Phase 2.4 fingerprint). Until Phase 4 nothing acted on the signal. The catalyzing example was T20260502.0033 (Hynes — tags not printing from Simple Shop Floor), resolved by Collin + Jake identifying a stopped Windows service on MISYS-SQL processing BarTender scan-folder text files. The IT Glue Application record MISYS 6.3 had 5/17 fields filled, missing:

  • Wulf Application Champion (Jake is the SME, but only by tribal knowledge)
  • Vendor Maintenance/Support (escalation contact "Steve Cianflone" buried in free-text Notes)
  • The integration architecture (Simple Shop Floor → MISYS-SQL → BarTender scan folder)
  • The named services on each VM

The next tech who hits this issue would re-discover everything. Phase 4 turns the analyzer's passive "documentation_gaps_observed" output into an actionable backlog with a write-back path.


User-facing flows

List view — /analyzer/itglue/applications

Shows every Application record across all clients, sorted by lowest audit score first (so unaudited and worst-scored assets bubble up). Each row: asset name, client, populated-field count, last-audit timestamp + provider, score badge.

┌────────────────────────────────────────────────────────────────────┐
│ MISYS 6.3                                              ▌ 55%       │
│ Hynes Industries · 5 fields populated · last audited today (Claude)│
└────────────────────────────────────────────────────────────────────┘

Detail view — /analyzer/itglue/applications/[id]

Three regions:

  1. Header — asset name, client, score badge, "Open in IT Glue" link, the <ProviderToggle> (Claude vs DeepSeek), and a primary Run audit button.
  2. Audit findings (visible after a run) —
    • Field gaps — severity-toned cards (red/amber/blue by confidence), each with: field name, why-it-matters, suggested value (when the LLM has evidence), evidence ticket links, and an Apply button.
    • Promote from Notes — quoted substring → target field → suggested structured value, with Apply.
    • Contradictions — pure observations (no Apply, by design).
  3. Current fields — every field rendered in IT Glue's order; populated shown normally, empty shown muted with the field's hint inline as guidance.
  4. Write history — every Apply/Revert for this asset, with inline before/after diffs and a Revert button on committed writes.
  5. Audit history — score over time across runs.

Admin view — /admin/itglue-writes

Cross-asset write log, status filters (pending / committed / failed / reverted), full diff per row, ordered most-recent-first.


Permissions

Action Permission
Read audit / View asset detail requireAuth() (any signed-in user)
Run a fresh audit requireAuth()
Apply a suggestion (PATCH IT Glue) requirePermission('itglue', 'write')
Revert a previous write requirePermission('itglue', 'write')
/admin/itglue-writes cross-asset feed requirePermission('admin', 'access')

itglue.write is granted to admin and super-admin roles only (see lib/permissions.ts). The Apply / Revert buttons render on the page for non-admins but are disabled with a tooltip explaining why.


Pipeline

Single LLM call, provider-aware via stageModelsFor(provider).deep_analysis (Claude Sonnet for Anthropic; DeepSeek V4 Pro for OpenRouter).

Inputs (six context arms)

  1. Asset snapshot — the asset's current traits JSONB, redacted via lib/services/analyzer/itglue-redact.ts to strip any password/secret/ key/token/credential-keyed values.
  2. Field schema with hints — every field on the asset type, in IT Glue's display order, with the hint text IT Glue surfaces in its own editor (e.g. "Tag any application servers or Devices it's installed on").
  3. Peer exemplars from same client — top 5 most-filled flexible assets of the same type at the same client (ranked by populated trait keys), redacted.
  4. Best-in-class peers across all clients — top 3 most-filled instances of the same asset type globally, redacted.
  5. Per-field fill-rate stats — the % of (a) this client's assets and (b) all assets of this type that have field X populated. Pure SQL aggregate; no token cost. Lets the LLM rank gaps by "unusual to be missing" vs "usually missing anyway."
  6. Ticket evidence — up to 20 most-recent complete analyses for the client whose summary or fingerprint mentions the asset name, with their aggregate_fingerprint payload included in full.

Output schema (AssetAuditResponse in lib/types/analyzer.ts)

{
  field_gaps: Array<{
    field_name: string,                     // must exist on the asset type
    why_missing_matters: string,
    suggested_value: string | null,         // null if no concrete evidence
    evidence_ticket_numbers: string[],
    confidence: 'high' | 'medium' | 'low',
  }>,
  notes_promotions: Array<{
    quoted_note_text: string,               // exact substring of Notes
    target_field: string,
    suggested_value: string,
    confidence: 'high' | 'medium' | 'low',
  }>,
  contradictions: Array<{
    description: string,
    evidence: string,
  }>,
  overall_score: number,                    // 0..1 self-rated completeness
}

System-prompt rules (highlights)

  • Never invent a field name not present in the schema.
  • Never suggest a value you cannot point to evidence for — use null instead.
  • Don't suggest password/secret/key/token/credential-shaped fields. (The Apply endpoint also blocks these defensively.)
  • Use fill-rate stats: a gap that's empty here but populated >80% elsewhere is a stronger gap than one that's empty 80% of the time globally.

Pipeline files

  • lib/services/analyzer/asset-audit/data-builder.ts — collects all six inputs.
  • lib/services/analyzer/asset-audit/prompt.ts — system prompt + payload builder (with 80KB cap; trims peer_global first, oldest tickets next).
  • lib/services/analyzer/asset-audit/runner.ts — single callLLMStage call, persistence on success or failure.
  • lib/services/analyzer/asset-audit/persistence.ts — typed read/write of both DB tables.

Data model

itglue_asset_audits (one row per audit run)

Column Notes
id UUID PK
asset_type TEXT Currently 'flexible_asset' only
asset_id BIGINT IT Glue resource id
asset_type_id, organization_id BIGINT Denormalized for fast filters
generated_by_user_id TEXT FK user(id), nullable on user delete
generated_at TIMESTAMPTZ
provider, model_used Which LLM produced the analysis
asset_snapshot JSONB Redacted traits at audit time
ticket_count INT How many fingerprints fed in
field_gaps, notes_promotions, contradictions JSONB LLM output
overall_score NUMERIC(3,2)
estimated_cost_usd, total_input_tokens, total_output_tokens Cost telemetry
status pending / running / complete / failed
error_message TEXT

itglue_writes (one row per write attempt)

Column Notes
id UUID PK
audit_id UUID FK to the audit that prompted the change (nullable)
asset_type, asset_id What was written
field_name TEXT Human field name (mapped to trait key on apply)
before_value, after_value JSONB Pre/post diff
performed_by_user_id TEXT FK user(id)
performed_at TIMESTAMPTZ
status pendingcommitted / failed / reverted
itglue_response JSONB Raw API response for forensics
error_message TEXT
source_evidence JSONB { ticket_numbers, gap_description } or { reverts_write_id }

Generic audit_log (existing — also written for every change)

audit.log() is called on every successful Apply/Revert with action itglue.write or itglue.revert, resource flexible_asset, resourceId = asset_id, and details = { field_name, before, after, audit_id }. Surfaces in /admin/audit-log next to every other admin action.


API contract

All routes under /api/analyzer/itglue/.

GET /applications

List all Application records joined to their latest audit. Returns: { applications: [{ id, name, organizationId, organizationName, traitCount, latestAudit }] }.

GET /applications/[id]

Asset detail + field schema for rendering. Returns: { asset, fields }.

GET /applications/[id]/audit?history=1

Latest audit (and optionally history of last 20). Returns: { audit: AssetAuditRow | null, history?: AssetAuditRow[] }.

POST /applications/[id]/audit

Body: { provider?: 'anthropic' | 'openrouter' }. Runs a fresh audit. Cost-guard records action='itglue_audit' in analyzer_cost_audit. Returns: { audit: AssetAuditRow }.

POST /applications/[id]/apply (admin)

Body: { auditId, fieldName, suggestedValue, sourceEvidence? }. Inserts pending itglue_writes row → calls updateFlexibleAsset on the IT Glue client → marks committed/failed → refreshes the local mirror → writes generic audit_log. Returns: { writeId, status: 'committed', asset } or 502 on IT Glue error.

POST /applications/[id]/revert/[writeId] (admin)

Re-applies the original before_value. Inserts a new write row with swapped before/after; original row → status='reverted'. Returns: { writeId, revertedWriteId, status: 'committed', asset }.

GET /applications/[id]/writes

Per-asset write history. Returns: { writes: AssetWriteRow[] }.

GET /writes (admin)

Cross-asset write log with status filter. Query: ?status=committed&limit=100&offset=0. Returns: { writes: AssetWriteRow[] }.


Safeguards

Three-layer credential refusal

  1. Prompt — system prompt instructs the LLM not to suggest password, secret, key, token, or credential fields.
  2. Redactionredact() from lib/services/analyzer/itglue-redact.ts strips matching keys from the asset snapshot, peer traits, and all payloads before they reach the LLM.
  3. EndpointPOST /apply regex-blocks any field_name matching /(password|secret|key|token|credential)/i and returns 400 even if a compromised payload made it through the first two layers.

Three-layer audit trail

Layer Captures Retention
itglue_asset_audits Every audit run with full LLM context (asset snapshot, model used, cost, ticket count) Forever
itglue_writes Every PATCH attempt with before/after diff, status (pending → committed | failed | reverted), audit provenance, raw IT Glue API response, source_evidence Forever
audit_log (generic) One row per Apply/Revert in the format admins are used to Per existing policy

Revert chain semantics

A revert produces a new itglue_writes row whose before_value and after_value are swapped from the original. The new row carries source_evidence = { reverts_write_id }. The original row's status flips to 'reverted'. To trace any state, walk the audit_id and reverts_write_id graph — it's always complete.

Per-record sync after every write

refreshFlexibleAssetById() runs after a successful PATCH (or a successful revert) so itg_flexible_assets.traits reflects the change immediately. The route also returns the freshly-PATCH'd asset in the response so the UI can update without waiting for the mirror.


Cost & latency

Provider Per-audit cost (typical) Per-audit latency
Anthropic (Sonnet) ~$0.10 3060s
OpenRouter (DeepSeek V4 Pro) ~$0.01 60180s

Apply is one IT Glue PATCH + one local upsert + one audit_log insert — typically < 1s.

A full audit + 5 applies on a typical Application record: ~$0.10 (Claude) or ~$0.01 (DeepSeek), 35 minutes including click-through.


Limitations & non-goals

  • Applications only in v1. The schema generalizes (asset_type is a column), but Configurations / Procedures / Domains / Passwords each have different prompt nuances. Adding more asset types is straightforward — duplicate the data-builder and prompt files, add a new asset detail page.
  • No bulk-apply. Admin clicks each suggestion individually. If a single audit produces 10 gaps that's 10 clicks. Bulk-apply is an easy fast-follow once we trust quality.
  • No two-step approval workflow. Admin-direct write per the design decision; the audit log is the safety net. The existing approval_requests table from the pipeline engine is available if we later want to require tech-proposes / admin-approves.
  • No auto-create of new asset records. Apply only updates existing assets. If an audit reveals a totally missing record, the suggestion is shown but Apply is disabled with an explainer; admins create the stub manually in IT Glue.
  • No inline editor for suggested values. Admin sees the LLM's suggestion verbatim; if they want to tweak, they edit the value in IT Glue afterward. v2 could add an inline editor.
  • No password / secret / key / token / credential writes through this surface — ever. Refused at three layers (above).

Inspecting state (operational SQL)

Audits, lowest score first

SELECT a.asset_id,
       fa.name AS application_name, fa.organization_name,
       a.overall_score,
       jsonb_array_length(a.field_gaps)        AS gap_count,
       jsonb_array_length(a.notes_promotions)  AS promo_count,
       a.provider, 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.generated_at DESC, a.overall_score ASC;

Writes in the last 7 days

SELECT performed_at, field_name, status,
       before_value, after_value,
       performed_by_user_id, audit_id
  FROM itglue_writes
 WHERE performed_at >= NOW() - INTERVAL '7 days'
 ORDER BY performed_at DESC;

Find what's still waiting on which write

SELECT id, field_name, before_value, after_value,
       source_evidence ->> 'reverts_write_id' AS reverts_id,
       status, performed_at
  FROM itglue_writes
 WHERE asset_id = '17096940'
 ORDER BY performed_at;

Audit cost-guard decisions

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;

How a typical session looks

  1. Tech opens /analyzer/itglue/applications. MISYS 6.3 is at the top with no audit yet.
  2. Tech clicks the row → lands on the detail page. They see 12/17 fields are empty.
  3. Tech clicks Run audit (DeepSeek selected for cost). 60180s later, the audit panel populates: 4 high-confidence field gaps, 1 notes promotion, 1 contradiction, score 0.55.
  4. Tech (admin) clicks Apply on Wulf Application Champion → suggested value "Jake Hammel". A PATCH lands on IT Glue, the local mirror refreshes, the field shows the new value, a write row appears in the History section.
  5. Tech clicks Apply on the Vendor Maintenance/Support promotion ("Steve Cianflone" extracted from Notes).
  6. The next time someone opens this asset in IT Glue, the documentation reflects what tickets have been telling us all along.

Files (quick reference)

Schema: migrations/075_itglue_audit.sql

Pipeline: lib/services/analyzer/asset-audit/

  • data-builder.ts, prompt.ts, runner.ts, persistence.ts, runner.test.ts

API: app/api/analyzer/itglue/applications/

  • route.ts (list)
  • [id]/route.ts (detail)
  • [id]/audit/route.ts (GET/POST audit)
  • [id]/apply/route.ts (admin write)
  • [id]/revert/[writeId]/route.ts (admin revert)
  • [id]/writes/route.ts (per-asset history)

Plus app/api/analyzer/itglue/writes/route.ts (admin cross-asset feed).

UI:

  • app/analyzer/itglue/applications/page.tsx (list)
  • app/analyzer/itglue/applications/[id]/page.tsx (detail)
  • app/admin/itglue-writes/page.tsx (admin cross-asset)

Modified:

  • lib/services/itglue-client.tsupdateFlexibleAsset, refreshFlexibleAsset, getRawSingle, isITGlueConfigured, internal patch
  • lib/services/itglue-sync-service.tsrefreshFlexibleAssetById
  • lib/permissions.tsitglue: ['read', 'write']
  • lib/types/analyzer.tsAssetAuditResponse schema + request types
  • components/navigation/app-navigation.tsx — nav entry

Phase 4.1 additions

Ticket-first capture flow

Trigger: opt-in. The user clicks "Check IT Glue documentation" on an analysis detail page (/analyzer/analysis/[id]).

Pipeline:

  1. GET /api/analyzer/analyses/[id]/itglue-suggestions — runs matchAssetsForAnalysis(id) and returns matched flexible_assets + configurations (top 5 each by score) along with any existing ticket-scoped audits.
  2. User clicks "Audit for this ticket" on a matched asset → POST to the same endpoint with { assetType, assetId, provider }.
  3. The runner builds context with ticketScopeAnalysisId set, so ticket evidence is exactly the one analysis the user clicked from. The system prompt picks up a ticket-scoped suffix instructing the LLM to frame findings as "what this ticket taught us."
  4. The audit row is persisted with triggered_by_ticket_number + triggered_by_analysis_id populated.
  5. Apply works from the same per-asset routes as the asset-first flow. When the audit is ticket-scoped, the resulting itglue_writes row also carries triggered_by_ticket_number, and the apply path inserts an xref row with relationship='updated'.

Asset matching is loose: substring + word-boundary match between the ticket's aggregate_fingerprint.{applications_involved, device_classes, vendors_involved} and itg_flexible_assets.name (Application type only)

  • itg_configurations.{name, hostname} for the same client. Score 3 (exact) > 2 (word-boundary) > 1 (substring). Top 5 per kind.

Configuration support

Same audit/apply/revert pattern as Applications, with these differences:

  • Flat schema — Configurations have ~17 editable top-level columns rather than a traits JSONB blob. The data-builder synthesizes a trait-style map for prompt consistency, then the apply route maps field name → IT Glue dash-case attribute (primary_ip'primary-ip').
  • Hand-curated field hints — IT Glue Configurations don't expose a _fields table; the 16 hand-written hints live in lib/services/analyzer/asset-audit/data-builder.ts (CONFIGURATION_FIELDS constant). Each hint is the per-field documentation the LLM sees.
  • Configuration-flavored prompt — focuses on hostname/FQDN consistency, OS version currency, named services capture (in operating_system_notes), IP/MAC hygiene, contact ownership.
  • Column allowlist on apply — even though the audit's output schema doesn't restrict field_name, the configuration apply route refuses anything outside name | hostname | primary_ip | mac_address | serial_number | asset_tag | position | notes | operating_system_notes. FK-shaped fields (manufacturer_id, model_id, operating_system_id, contact_id, location_id) are read-only via this surface for v1.
  • Per-record sync helperrefreshConfigurationById(id) mirrors refreshFlexibleAssetById(id); called after every successful Apply.

Cross-reference index — itglue_ticket_xrefs

ticket_number | analysis_id | asset_type | asset_id | relationship | source | confidence | details | created_at

Three relationship types:

Relationship Meaning Source
referenced The analyzer cited this asset/doc when analyzing the ticket. Comes from analyzer_analyses.itglue_docs_referenced. analyzer_referenced
updated A ticket-driven audit produced a write on this asset. audit_write
should_have_referenced Reserved — gap text suggests we should have found this asset/doc but didn't. Not auto-populated yet. manual (future)

Populated by:

  • Post-analysis hook in lib/services/analyzer/worker.tsinsertReferencedXrefsFromAnalysis runs after every successful analysis insertion. Maps each ITGlueDocReference.doc_type → asset_type. Best-effort.
  • Post-apply hook in the apply routes — insertUpdatedXref runs after a successful PATCH if the audit was ticket-scoped. Best-effort.
  • No backfill — table fills forward. The unique index (ticket_number, analysis_id, asset_type, asset_id, relationship, source) makes ingestion idempotent.

Two views consume it:

  1. Asset detail page — "Tickets that touched this asset" section under the audit panel, with sub-sections for Referenced by (with the LLM's relevance reason) and Updated by (with the field name and write history link).
  2. GET /api/analyzer/tickets/[ticketNumber]/itglue-xrefs — ticket-side view; available for future ticket-page surfacing.

This table is also the lookup index a future RAG automation will use: given a new ticket's entities, fetch historically-referenced docs as warm candidates for the analyzer's IT Glue retrieval stage.

Ticket-linkage columns

Both audit and write tables carry denormalized ticket linkage:

  • itglue_asset_audits.triggered_by_ticket_number (and triggered_by_analysis_id) — set on ticket-scoped audits. Null on asset-first audits.
  • itglue_writes.triggered_by_ticket_number — copied forward from the audit on Apply, so "every write a given ticket drove" is a one-query lookup. Null when the audit was asset-first.

Files added in 4.1

  • migrations/076_itglue_ticket_xrefs.sql
  • lib/services/analyzer/asset-audit/xrefs.ts
  • lib/services/analyzer/asset-audit/asset-matcher.ts
  • app/api/analyzer/analyses/[id]/itglue-suggestions/route.ts
  • Configuration parallel route tree under app/api/analyzer/itglue/configurations/
  • xref endpoints: app/api/analyzer/itglue/applications/[id]/xrefs/route.ts, app/api/analyzer/itglue/configurations/[id]/xrefs/route.ts, app/api/analyzer/tickets/[ticketNumber]/itglue-xrefs/route.ts
  • app/analyzer/itglue/configurations/page.tsx (list)
  • app/analyzer/itglue/configurations/[id]/page.tsx (detail)
  • components/analyzer/itglue-suggestions-panel.tsx

Files modified in 4.1

  • lib/services/itglue-client.tsupdateConfiguration, refreshConfiguration
  • lib/services/itglue-sync-service.tsrefreshConfigurationById
  • lib/services/analyzer/asset-audit/data-builder.ts — assetType dispatch + ticket-scope mode + Configuration field schema
  • lib/services/analyzer/asset-audit/prompt.ts — Configuration prompt + ticket-scoped suffix + assetType-aware payload
  • lib/services/analyzer/asset-audit/runner.ts — accepts assetType and ticketScopeAnalysisId; persists triggered_by_*
  • lib/services/analyzer/asset-audit/persistence.ts — asset_type union extended; getLatestTicketScopedAudit; createPendingWrite accepts asset_type + ticket linkage
  • lib/services/analyzer/worker.ts — post-analysis xref ingestion hook
  • app/api/analyzer/itglue/applications/[id]/apply/route.ts — ticket linkage + xref insert on Apply
  • app/api/analyzer/itglue/applications/[id]/revert/[writeId]/route.ts — asset_type now passed to createPendingWrite
  • app/analyzer/analysis/[id]/page.tsx<ItglueSuggestionsPanel/> rendered
  • app/analyzer/itglue/applications/[id]/page.tsx — "Tickets that touched this asset" section
  • components/navigation/app-navigation.tsx — split entries