-- Endpoint data model — anchor on configuration_items, sidecar everything else. -- -- Adds three tables: -- 1. device_external_ids — cross-reference: one row per (tool, device-in-tool). -- 2. device_observations — time-series script/telemetry results (JSONB), -- with optional B2 pointer for blobs that don't belong inline. -- 3. endpoint_audits — LLM-produced audits anchored to a configuration_item. -- Replaces itglue_asset_audits rows where asset_type='configuration'. -- Flexible-asset audits stay in itglue_asset_audits (different lifecycle). -- -- Backfill strategy is conservative: write source rows for every tool, but -- only auto-link to a configuration_item when the match is deterministic -- (Autotask's own rmm_device_uid for Datto). Hostname/serial fuzzy linking -- is the reconciliation cron's job — it logs conflicts instead of merging. -- --------------------------------------------------------------------------- -- 1. device_external_ids -- --------------------------------------------------------------------------- CREATE TABLE IF NOT EXISTS device_external_ids ( id BIGSERIAL PRIMARY KEY, configuration_item_id BIGINT NULL REFERENCES configuration_items(id) ON DELETE SET NULL, source TEXT NOT NULL, source_id TEXT NOT NULL, hostname TEXT, serial TEXT, mac TEXT, -- No FK on company_id: configuration_items already tolerates dangling -- company_ids (Autotask retains CIs after company hard-deletes), and the -- xref shouldn't be stricter than its anchor. company_id BIGINT NULL, last_seen_at TIMESTAMPTZ, link_confidence TEXT, linked_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT device_external_ids_source_check CHECK (source IN ('autotask','datto_rmm','itglue','s1','veeam','addigy','auvik')), CONSTRAINT device_external_ids_link_confidence_check CHECK (link_confidence IS NULL OR link_confidence IN ('canonical','exact_uid','exact_serial','hostname_in_company','mac','manual')), CONSTRAINT device_external_ids_unique UNIQUE (source, source_id) ); CREATE INDEX IF NOT EXISTS ix_device_external_ids_ci ON device_external_ids(configuration_item_id) WHERE configuration_item_id IS NOT NULL; CREATE INDEX IF NOT EXISTS ix_device_external_ids_unlinked ON device_external_ids(source, last_seen_at) WHERE configuration_item_id IS NULL; CREATE INDEX IF NOT EXISTS ix_device_external_ids_hostname ON device_external_ids(LOWER(hostname)) WHERE hostname IS NOT NULL; CREATE INDEX IF NOT EXISTS ix_device_external_ids_serial ON device_external_ids(serial) WHERE serial IS NOT NULL; COMMENT ON TABLE device_external_ids IS 'Cross-reference from per-tool device IDs to a canonical Autotask configuration_item. configuration_item_id NULL = seen in tool X but no Autotask CI matched yet; reconciliation cron tries to link.'; -- --------------------------------------------------------------------------- -- 2. device_observations -- --------------------------------------------------------------------------- CREATE TABLE IF NOT EXISTS device_observations ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), configuration_item_id BIGINT NULL REFERENCES configuration_items(id) ON DELETE SET NULL, source TEXT NOT NULL, kind TEXT NOT NULL, collected_at TIMESTAMPTZ NOT NULL, payload JSONB NOT NULL, evidence_object_key TEXT, run_id TEXT, supersedes_id UUID NULL REFERENCES device_observations(id) ON DELETE SET NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), CONSTRAINT device_observations_source_check CHECK (source IN ('autotask','datto_rmm','itglue','s1','veeam','addigy','auvik','loglift','overshell','manual')) ); CREATE INDEX IF NOT EXISTS ix_device_observations_ci_kind_time ON device_observations(configuration_item_id, kind, collected_at DESC) WHERE configuration_item_id IS NOT NULL; CREATE INDEX IF NOT EXISTS ix_device_observations_kind_time ON device_observations(kind, collected_at DESC); CREATE INDEX IF NOT EXISTS ix_device_observations_run_id ON device_observations(run_id) WHERE run_id IS NOT NULL; COMMENT ON TABLE device_observations IS 'Time-series telemetry / script results per endpoint. Payload is JSONB for direct querying; large blobs (raw event-log gzips, screenshots) live in B2 referenced by evidence_object_key.'; -- --------------------------------------------------------------------------- -- 3. endpoint_audits -- --------------------------------------------------------------------------- -- An audit anchors on an Autotask configuration_item when one is known, but -- IT Glue is also a valid anchor for devices we track there but not in -- Autotask (orphans, IT-Glue-first onboarding). At least one must be set — -- the CHECK below enforces it. CREATE TABLE IF NOT EXISTS endpoint_audits ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), configuration_item_id BIGINT NULL REFERENCES configuration_items(id) ON DELETE SET NULL, itglue_configuration_id BIGINT NULL, organization_id BIGINT NULL, generated_by_user_id TEXT NULL REFERENCES "user"(id) ON DELETE SET NULL, generated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), provider TEXT NOT NULL DEFAULT 'anthropic', model_used TEXT, asset_snapshot JSONB NOT NULL, observations_consumed UUID[] NOT NULL DEFAULT '{}', ticket_count INTEGER NOT NULL DEFAULT 0, field_gaps JSONB NOT NULL DEFAULT '[]'::jsonb, notes_promotions JSONB NOT NULL DEFAULT '[]'::jsonb, contradictions JSONB NOT NULL DEFAULT '[]'::jsonb, overall_score NUMERIC(3,2), estimated_cost_usd NUMERIC(10,4), total_input_tokens INTEGER, total_output_tokens INTEGER, status TEXT NOT NULL DEFAULT 'complete', error_message TEXT, triggered_by_ticket_number TEXT, triggered_by_analysis_id UUID NULL REFERENCES analyzer_analyses(id) ON DELETE SET NULL, triggered_by_observation_id UUID NULL REFERENCES device_observations(id) ON DELETE SET NULL, legacy_itglue_audit_id UUID NULL, CONSTRAINT endpoint_audits_provider_check CHECK (provider IN ('anthropic','openrouter')), CONSTRAINT endpoint_audits_status_check CHECK (status IN ('pending','running','complete','failed')), CONSTRAINT endpoint_audits_anchor_check CHECK (configuration_item_id IS NOT NULL OR itglue_configuration_id IS NOT NULL) ); -- Idempotent guard for re-runs against an already-created table. ALTER TABLE endpoint_audits ADD COLUMN IF NOT EXISTS itglue_configuration_id BIGINT NULL; DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM pg_constraint WHERE conname = 'endpoint_audits_anchor_check' ) THEN ALTER TABLE endpoint_audits ADD CONSTRAINT endpoint_audits_anchor_check CHECK (configuration_item_id IS NOT NULL OR itglue_configuration_id IS NOT NULL); END IF; END $$; CREATE INDEX IF NOT EXISTS ix_endpoint_audits_ci_time ON endpoint_audits(configuration_item_id, generated_at DESC) WHERE configuration_item_id IS NOT NULL; CREATE INDEX IF NOT EXISTS ix_endpoint_audits_org_time ON endpoint_audits(organization_id, generated_at DESC) WHERE organization_id IS NOT NULL; CREATE INDEX IF NOT EXISTS ix_endpoint_audits_ticket ON endpoint_audits(triggered_by_ticket_number) WHERE triggered_by_ticket_number IS NOT NULL; CREATE INDEX IF NOT EXISTS ix_endpoint_audits_legacy ON endpoint_audits(legacy_itglue_audit_id) WHERE legacy_itglue_audit_id IS NOT NULL; CREATE INDEX IF NOT EXISTS ix_endpoint_audits_itglue_config ON endpoint_audits(itglue_configuration_id, generated_at DESC) WHERE itglue_configuration_id IS NOT NULL; COMMENT ON TABLE endpoint_audits IS 'LLM-produced audits anchored to a configuration_item. The configuration variant of itglue_asset_audits — flexible-asset audits stay in itglue_asset_audits since their lifecycle is different.'; COMMENT ON COLUMN endpoint_audits.legacy_itglue_audit_id IS 'Backfill pointer to the original itglue_asset_audits row this was migrated from. Drop after cutover is complete.'; -- --------------------------------------------------------------------------- -- Backfill: device_external_ids -- --------------------------------------------------------------------------- -- Autotask: every CI is its own canonical row (the anchor). INSERT INTO device_external_ids (configuration_item_id, source, source_id, hostname, serial, mac, company_id, last_seen_at, link_confidence, linked_at) SELECT ci.id, 'autotask', ci.id::text, ci.reference_title, ci.serial_number, ci.rmm_device_audit_mac_address, ci.company_id, ci.synced_at AT TIME ZONE 'UTC', 'canonical', NOW() FROM configuration_items ci WHERE NOT EXISTS ( SELECT 1 FROM device_external_ids dx WHERE dx.source = 'autotask' AND dx.source_id = ci.id::text ) ON CONFLICT (source, source_id) DO NOTHING; -- Datto RMM: link via Autotask's stored rmm_device_uid (deterministic). -- Rows that don't match stay unlinked; reconciliation cron will retry by hostname. INSERT INTO device_external_ids (configuration_item_id, source, source_id, hostname, serial, mac, company_id, last_seen_at, link_confidence, linked_at) SELECT ci.id, 'datto_rmm', drd.uid, drd.hostname, NULL, NULL, ci.company_id, drd.last_seen, CASE WHEN ci.id IS NOT NULL THEN 'exact_uid' ELSE NULL END, CASE WHEN ci.id IS NOT NULL THEN NOW() ELSE NULL END FROM datto_rmm_devices drd LEFT JOIN configuration_items ci ON ci.rmm_device_uid = drd.uid ON CONFLICT (source, source_id) DO NOTHING; -- IT Glue: stays unlinked initially. Reconciliation matches by serial → hostname. INSERT INTO device_external_ids (source, source_id, hostname, serial, mac, last_seen_at) SELECT 'itglue', igc.id::text, COALESCE(igc.hostname, igc.name), igc.serial_number, igc.mac_address, igc.synced_at FROM itg_configurations igc ON CONFLICT (source, source_id) DO NOTHING; -- SentinelOne: stays unlinked initially. Reconciliation matches by computer_name + company. INSERT INTO device_external_ids (source, source_id, hostname, last_seen_at) SELECT 's1', sa.id::text, sa.computer_name, sa.last_active_date AT TIME ZONE 'UTC' FROM s1_agents sa ON CONFLICT (source, source_id) DO NOTHING; -- Veeam: stays unlinked initially. Reconciliation matches by hostname (the -- agent's `name` field). No serial / MAC available. INSERT INTO device_external_ids (source, source_id, hostname, last_seen_at) SELECT 'veeam', vba.instance_uid, vba.name, vba.synced_at FROM veeam_backup_agents vba ON CONFLICT (source, source_id) DO NOTHING; -- --------------------------------------------------------------------------- -- Backfill: endpoint_audits from itglue_asset_audits (configuration only) -- --------------------------------------------------------------------------- -- We snapshot the configuration audits into endpoint_audits but keep the -- originals in itglue_asset_audits until the receiver flips. legacy_itglue_audit_id -- preserves the link both ways during the dual-source window. -- itglue_asset_audits.asset_id (when asset_type='configuration') is an IT Glue -- Configuration ID, not an Autotask CI. We anchor primarily on IT Glue here -- and let the reconciler later fill in configuration_item_id via the xref. INSERT INTO endpoint_audits ( configuration_item_id, itglue_configuration_id, organization_id, generated_by_user_id, generated_at, provider, model_used, asset_snapshot, ticket_count, field_gaps, notes_promotions, contradictions, overall_score, estimated_cost_usd, total_input_tokens, total_output_tokens, status, error_message, triggered_by_ticket_number, triggered_by_analysis_id, legacy_itglue_audit_id ) SELECT dx.configuration_item_id, ia.asset_id, ia.organization_id, ia.generated_by_user_id, ia.generated_at, ia.provider, ia.model_used, ia.asset_snapshot, ia.ticket_count, ia.field_gaps, ia.notes_promotions, ia.contradictions, ia.overall_score, ia.estimated_cost_usd, ia.total_input_tokens, ia.total_output_tokens, ia.status, ia.error_message, ia.triggered_by_ticket_number, ia.triggered_by_analysis_id, ia.id FROM itglue_asset_audits ia LEFT JOIN device_external_ids dx ON dx.source = 'itglue' AND dx.source_id = ia.asset_id::text WHERE ia.asset_type = 'configuration' AND NOT EXISTS ( SELECT 1 FROM endpoint_audits ea WHERE ea.legacy_itglue_audit_id = ia.id ); -- --------------------------------------------------------------------------- -- updated_at trigger for device_external_ids -- --------------------------------------------------------------------------- CREATE OR REPLACE FUNCTION device_external_ids_set_updated_at() RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = NOW(); RETURN NEW; END; $$ LANGUAGE plpgsql; DROP TRIGGER IF EXISTS trg_device_external_ids_updated_at ON device_external_ids; CREATE TRIGGER trg_device_external_ids_updated_at BEFORE UPDATE ON device_external_ids FOR EACH ROW EXECUTE FUNCTION device_external_ids_set_updated_at();