import { type ErrorCode, type FindingEntry, type FindingEvidence, type FindingFilter, type FindingKind, type FindingRuleEntry, type FindingRuleInput, type FindingSeverity, type FindingStatusInput, type TenantId } from '@substrat-run/contracts'; import type { RedactionSql } from './subject-redaction.js'; /** * How long a finding outlives its last occurrence — the issue store's window, since a Recurring * finding is the tenant's projection of an issue and should be recognisable for as long. */ export declare const FINDING_RETENTION_DAYS = 180; /** * The findings store (#1748), as statements both adapters run against their directory — the * SQLite host over better-sqlite3, the hosted one inside ControlPlaneDO — so the lifecycle is * written once. `sql` is the row-returning executor subject erasure already uses. * * Detection is ON WRITE: the call that records an ops failure or a sweep run also observes the * finding it implies, so there is no cron and no scan. Per occurrence that is one indexed rule * lookup and one primary-key upsert, over evidence that is already bounded upstream. */ export declare const FINDINGS_DDL = "\n CREATE TABLE IF NOT EXISTS _substrat_findings (\n id TEXT PRIMARY KEY,\n tenant_id TEXT NOT NULL,\n kind TEXT NOT NULL,\n subject TEXT NOT NULL,\n status TEXT NOT NULL,\n regressed INTEGER NOT NULL DEFAULT 0,\n severity TEXT NOT NULL,\n title TEXT NOT NULL,\n operation TEXT,\n codes TEXT NOT NULL DEFAULT '[]',\n vertical TEXT,\n scope_id TEXT,\n seen_count INTEGER NOT NULL,\n first_seen TEXT NOT NULL,\n last_seen TEXT NOT NULL,\n last_version TEXT,\n resolved_version TEXT,\n resolved_at TEXT,\n resolution TEXT,\n acknowledged_at TEXT,\n rule_id TEXT,\n evidence TEXT NOT NULL,\n likely_cause TEXT,\n UNIQUE (tenant_id, kind, subject)\n );\n CREATE INDEX IF NOT EXISTS _substrat_findings_tenant_seen ON _substrat_findings (tenant_id, last_seen);\n CREATE INDEX IF NOT EXISTS _substrat_findings_seen ON _substrat_findings (last_seen);\n CREATE INDEX IF NOT EXISTS _substrat_findings_quiet ON _substrat_findings (last_seen) WHERE status IN ('open', 'acked');\n CREATE TABLE IF NOT EXISTS _substrat_finding_rules (\n id TEXT PRIMARY KEY,\n tenant_id TEXT NOT NULL,\n kind TEXT,\n operation TEXT,\n code TEXT,\n subject TEXT,\n expires_at TEXT NOT NULL,\n reason TEXT NOT NULL,\n created_by TEXT NOT NULL,\n created_at TEXT NOT NULL\n );\n CREATE INDEX IF NOT EXISTS _substrat_finding_rules_tenant ON _substrat_finding_rules (tenant_id, expires_at);\n CREATE INDEX IF NOT EXISTS _substrat_finding_rules_expiry ON _substrat_finding_rules (expires_at);\n"; /** One occurrence, as a source hands it to `observeFinding`. */ export interface FindingObservation { tenantId: string; kind: FindingKind; subject: string; severity: FindingSeverity; title: string; operation: string | null; code: ErrorCode | null; vertical: string | null; scopeId: string | null; version: string | null; evidence: FindingEvidence; } /** * The Recurring source: a tenant's own ops failure, keyed on the same fingerprint as the fleet * issue. A failure with no tenant is the platform's own and stays in the staff issue store. */ export declare function findingOfOpsFailure(f: { tenantId?: string | null; scopeId?: string | null; operation: string; stage?: string | null; code?: ErrorCode | null; vertical?: string | null; version?: string | null; }): FindingObservation | null; /** * The Invariant and Drift sources: a scheduled run that failed (expected 0 failed runs, * observed N) and a freshness expectation judged stale. Every other sweep row implies nothing. */ export declare function findingOfSweepRun(r: { kind: string; unit: string; outcome: string; tenantId?: string | null; scopeId?: string | null; vertical?: string | null; version?: string | null; operation?: string | null; eventType?: string | null; }): FindingObservation | null; /** * Record one occurrence on its finding, opening it if new. The transitions, in order: * - an unexpired rule covers it → `suppressed` (counted all the same: suppressing hides the * finding, never the evidence); * - it was `resolved` → `open`, `regressed`, and its likely cause is worked out afresh from * THIS regression: the version it came back under, when that is not the one it was resolved * under, and none otherwise — never a cause left over from an earlier cycle; * - it was `suppressed` and no rule covers it any longer → `open`; * - otherwise the status stands (`acked` stays acked: somebody is already on it). */ export declare function observeFinding(sql: RedactionSql, o: FindingObservation, occurredAt: string): void; /** * Findings, most recently seen first, keyset-paged (#2198): `lastSeen` descending, `id` ascending * on a tie, and `filter.cursor` resumes strictly after a row, `findingCursorOf`'s (lastSeen, id). * A page returns at most `limit` rows (default 100, ceiling `FINDINGS_LIMIT_MAX`); the HTTP layer * wraps it with `pageOf`, so the walk ends on a short page. A `limit` or `cursor` that is not one * is refused, never read as "from the start" — a walk that silently restarted would never end. */ export declare function listFindings(sql: RedactionSql, filter?: FindingFilter): FindingEntry[]; export interface FindingChange { before: FindingEntry; after: FindingEntry; } /** * The audit row a findings mutation writes — `recordAdmin`'s action, target, before and after. * Every mutation below hands it to `audit` INSIDE its own statements, so the adapter that runs * them in one unit commits the change and its row together, or neither (#1748). */ export interface FindingAudit { action: 'setFindingStatus' | 'createFindingRule' | 'revokeFindingRule' | 'resolveStaleFinding'; target: { tenantId: TenantId; vertical: string | null; }; before: unknown; after: unknown; } /** * A person's verdict: acknowledge, resolve, or reopen. Keyed on (tenant, id), so a tenant can * never move another tenant's finding by guessing its id. Reopening a suppressed finding lifts * it out of its rule for now; the next occurrence the rule still covers suppresses it again — * revoking the rule is what ends a suppression. */ export declare function setFindingStatus(sql: RedactionSql, tenantId: TenantId, id: string, status: FindingStatusInput, at: string, audit: (row: FindingAudit) => void): FindingChange | undefined; /** * Create a suppress rule and apply it to the findings it covers now: each one not already * resolved becomes `suppressed` under it. Returns the rule and the ids it suppressed, for the * caller's audit row. The input is parsed by the caller (`findingRuleInput`); the expiry is * held here (`assertFindingRuleExpiry`), against the same `at` every other write in the call uses. */ export declare function createFindingRule(sql: RedactionSql, tenantId: TenantId, input: FindingRuleInput, createdBy: string, at: string, audit: (row: FindingAudit) => void): { rule: FindingRuleEntry; suppressed: string[]; }; /** * End a rule now. Its findings stay suppressed until their next occurrence, which then opens * them — the same thing an expiry does, brought forward. */ export declare function revokeFindingRule(sql: RedactionSql, tenantId: TenantId, ruleId: string, at: string, audit: (row: FindingAudit) => void): { before: FindingRuleEntry; after: FindingRuleEntry; } | undefined; /** A tenant's rules, newest first; `activeAt` keeps only those unexpired at that instant. */ export declare function listFindingRules(sql: RedactionSql, tenantId: TenantId | undefined, activeAt?: string, limit?: number): FindingRuleEntry[]; /** What one `pruneFindings` pass did. */ export interface FindingPruneReport { /** Resolved and suppressed findings deleted. */ deleted: number; /** Open or acked findings resolved as `stale`, each audited. */ staled: number; /** Expired suppress rules deleted. */ rulesDeleted: number; } /** * The findings retention pass, bounded by `limit` per step, oldest first. Run in ONE unit with * `audit`, which the adapter points at its own admin-log write, so a stale resolution never * lands without its row. * * An `open` or `acked` finding is never deleted for going quiet: deleting it would take it out * of the inbox of whoever was watching it with nothing said. One quiet for the whole * `FINDING_RETENTION_DAYS` is RESOLVED as `stale` instead, and audited. A `resolved` or * `suppressed` finding is deleted once both its last occurrence and its last resolve are past * the horizon — so a stale-resolved finding stays readable for one more window. An expired rule * is deleted once its expiry is past the horizon; the admin log keeps its story. */ export declare function pruneFindings(sql: RedactionSql, nowMs: number, limit: number, audit: (row: FindingAudit) => void): FindingPruneReport; //# sourceMappingURL=findings.d.ts.map