AWS Marketplace·Enterprise deployment — listing in progress.Contact sales·View pricing

INTERTRACE — RUNTIME AI SECURITY • GATEWAY PROTECTION • RUNTIME VERIFICATION • BEHAVIORAL INTELLIGENCE • PROMPT INJECTION DEFENSE • PII REDACTION • SUB-50MS CLASSIFICATION • COMPLIANCE REPORTING • MANAGED AGENTS • OWASP LLM TOP 10 • INTERTRACE — RUNTIME AI SECURITY • GATEWAY PROTECTION • RUNTIME VERIFICATION • BEHAVIORAL INTELLIGENCE • PROMPT INJECTION DEFENSE • PII REDACTION • SUB-50MS CLASSIFICATION • COMPLIANCE REPORTING • MANAGED AGENTS • OWASP LLM TOP 10 • INTERTRACE — RUNTIME AI SECURITY • GATEWAY PROTECTION • RUNTIME VERIFICATION • BEHAVIORAL INTELLIGENCE • PROMPT INJECTION DEFENSE • PII REDACTION • SUB-50MS CLASSIFICATION • COMPLIANCE REPORTING • MANAGED AGENTS • OWASP LLM TOP 10 •
← Back

Finding audit trails that survive a real review

Essay · July 16, 2026 · 13 min readBy Samuel OyanEngineer
findingsauditevidenceRLS

Status history is a trigger, evidence is a table, and every mutation must declare a source. Incidents stay manual. Anything else is a dropdown that forgets.

Security products love a status chip: open, investigating, resolved, false positive. In a demo the chip looks like control. In a real review it is a rumor unless you can answer four questions without reconstructing a story from Slack: who changed the status, in which organization, from which code path, and with what evidence attached at the time of the change. If those answers live only in application memory, or in a JSON blob that any member can overwrite, you do not have an audit trail. You have a dropdown that forgets.

Intertrace treats findings as first-class security records under Postgres row-level security. Gateway ingest may create a finding from a SecurityEventEnvelope. Humans and automations may later change its status. The database, not the UI, is responsible for remembering that the change happened. Application code is responsible for labeling the change with a source. Attachments that would otherwise die in a screenshot thread live in finding_evidence. Incidents are a separate object, opened on purpose. That split is the entire design.

Change, then history
FindingopensourceStatus changewithFindingChangeSourcehistory rowtriggerevidenceattachmentsAudit

The BEFORE UPDATE trigger is the backstop. withFindingChangeSource() is the contract for API routes. finding_evidence is append-only. Skipping any of the three is how a resolved finding becomes unauditable.

What reviewers actually ask

The people who review this system are not impressed by a clean Work hub. They have seen status columns that were patched by a migration, by a support engineer using the service role, or by a script that set resolved_at without recording who ran it. They ask operational questions, then they ask the same questions again against a different finding to see whether the answers are structural or anecdotal.

  1. Who closed this finding, and were they a member of the org at the time? A Clerk session on platform.intertrace.ai is not sufficient if the history row has a null actor and a source of system.
  2. What gateway event, simulation, or posture job produced it? If the finding cannot be joined to gateway_events or to a structured evidence row, the narrative is reconstructed, not recorded.
  3. What was attached at close—not what someone uploaded a month later after the auditor asked? Evidence that can be silently edited is not evidence.
  4. Did we auto-open an incident for this detection? If the answer is always yes, the incident queue is a copy of the finding queue and neither is triaged. If the answer is always no, you need a story for how coordinated response actually starts.

Those questions map onto tables, not onto screenshots of the dashboard. finding_status_history holds from_status, to_status, changed_by, change_source, metadata, and created_at. finding_evidence holds typed attachments scoped to org_id and finding_id. Both are forced through RLS. Both reject the mutations that make a review fail: history cannot be updated, history cannot be inserted except by the trigger, evidence cannot be updated in place.

The trigger is the backstop

Status history is written by a BEFORE UPDATE trigger on findings.status, implemented as record_finding_status_history(). If the new status is not distinct from the old status, the trigger clears the ephemeral write hints and returns. If the status actually changed, it reads status_change_source from the incoming row, normalizes it, attributes an actor, sets a session flag, inserts the history row, then clears the hints so they never persist as columns that look like durable state.

The source list is closed: api, scanner, cron, system, guardian, runtime, ai_operator, posture_engine. Anything else becomes system. That is deliberate. An open-ended source string is how every script invents a new vocabulary and reviewers lose the ability to filter. If a new producer needs a label, it is a migration and a type change in withFindingChangeSource(), not a free-text field in a PATCH body.

Actor attribution has to survive the service-role path. Dashboard members write as auth.uid(). Operator jobs, gateway ingest, and some cron paths use the Supabase service role, where auth.uid() is null. For those writes we pass status_change_by as an ephemeral hint on the findings row. The trigger coalesces auth.uid() with that hint into finding_status_history.changed_by, then nulls the hint. The column on findings is not a second source of truth. It is a write-side envelope, commented as such, cleared on write.

withFindingChangeSource() is the application contract

Application code is not allowed to patch findings.status and hope. lib/findings/audit.ts exports withFindingChangeSource(). You merge your update payload with a FindingChangeSource, and optionally changedBy when the client is the service role. The function returns the same object plus status_change_source (and status_change_by when provided). Routes that forget the helper are not a style issue. They are how history rows become unlabelled.

The type is the documentation. api is a human or a human-equivalent dashboard mutation. scanner and cron are batch producers. guardian and ai_operator are product automations that still have to be distinguishable from a person clicking Resolve. runtime is the data plane. posture_engine is scoring and estate jobs. system is the residual, and it should be rare enough that a query for change_source = system is an interesting review, not the default.

  • Every status-changing API route goes through the helper. Tests in finding-audit.test.ts and operator.test.ts freeze the shape, including the changedBy pass-through.
  • listFindingStatusHistory() reads history newest-first. The UI does not reconstruct order from updated_at on the finding.
  • insertFindingEvidence() writes a typed row: prompt, response, tool_call, policy_rule, trace, gateway_event, screenshot, log, or other. Previews and hashes live on the row. The file bytes, if any, live behind evidence_uri.

We taught this the expensive way. Early dashboard mutations updated a column because the form already had the new value. The page looked correct. An auditor asked for the previous status and the actor. The finding had resolved_by and resolved_at if someone remembered to set them, and nothing that proved the transition from open to in_progress happened at 14:03 from the API rather than from a SQL console. Triggers do not care that the React state was consistent. They care that OLD.status and NEW.status differ.

History is immutable on purpose

A history table that accepts UPDATE is a journal you can edit. We reject updates on finding_status_history with reject_audit_history_update(). We reject direct INSERTs unless the session GUC intertrace.audit_internal is 1, which only the SECURITY DEFINER recorder sets for the duration of its insert. Member INSERT policies were dropped. authenticated may SELECT. It may not insert, update, delete, truncate, or take triggers. FORCE ROW LEVEL SECURITY is on so a table owner in a non-superuser role does not accidentally bypass the policies we tell customers about.

That is the difference between an audit log and a comments table. Comments are useful. They are also deniable. History rows are produced by the same transaction that changed the finding. If the finding status moves and the insert fails, the status change fails. We would rather surface an error on Resolve than persist a resolved chip with no journal line.

Org integrity is a second trigger family. finding_status_history and finding_evidence may not carry an org_id that diverges from the parent finding. Cross-tenant attachments are not a query-bug class we are willing to debug in production. The guard rejects the row. RLS still applies on read: a member of org A does not see org B’s history even if a bug in application code requested it.

What we store in metadata, and what we do not

The recorder currently packs resolved_at, resolved_by, and the status_change_by hint into jsonb metadata on the history row. That is enough to see whether resolution fields moved with the status. It is not a place to dump the prompt. Prompts, tool calls, and traces belong in finding_evidence with a type, a preview, and a hash. Metadata on history is about the transition. Evidence is about the thing that was judged.

finding_evidence is append-only

Structured attachments go in finding_evidence, not in a screenshot lost in Slack, and not in an overwritten column on the finding. The table is append-only: updates are rejected so that correcting a preview means inserting a new row. Deletes remain available to members under RLS for genuine retention and mistake handling; they are a privilege we expect to use rarely and to justify. FORCE ROW LEVEL SECURITY applies here too.

Gateway ingest already writes evidence when it creates a finding: prompt excerpts (or an encrypted-at-rest marker), impact summaries, policy context. That is the point of capturing evidence at creation time. A reviewer who asks what the model saw should get a row with captured_at equal to ingest time, not a paste from a laptop two weeks later. Humans can still attach a screenshot or a log. They cannot silently replace the original preview.

  • evidence_type is a closed enum in application code. Open-ended types become a junk drawer.
  • content_hash lets you prove the preview still matches the stored object if you later put bytes in object storage.
  • org_id is denormalized onto the evidence row so RLS does not have to join on every read, and so the integrity trigger has something to compare.
  • content_preview is truncated on purpose. The finding is not a second copy of gateway_events. Join when you need the envelope.

We do not store findings, evidence, or status history in Convex. Convex holds plan flags. Clerk holds human sessions. The review happens against Postgres because that is where RLS and the triggers live. A billing store that grows a findings table is how you lose both a clean entitlement model and a defensible audit story. That split is described separately; the constraint here is simpler: if it can appear in an investigation, it has an org_id and an RLS policy.

Do not auto-incident everything

Every finding is not a pageable incident. Auto-creating an incident for every detection trains teams to ignore both queues. Findings are the detection record: they have severity, status, history, and evidence. Incidents are a case file you open when you need a coordinated response across related findings, assets, and people. The product exposes manual create in Work. Correlation stays in Traffic, Graph, and Work so that related activity is visible without minting a case.

That is a product decision with audit consequences. You cannot claim we incidented every detection unless you actually did, by hand or by a policy you can show, with incident_status_history that is under the same immutability rules. Authorized security tests and red-team runs must never become incidents automatically; they would page the on-call with your own labs. Bookkeeping threat types and encrypted-prompt markers are findings or events, not a reason to open a war room.

Manual create is not a gap in automation. It is the refusal to let a classifier’s severity field mint a process that humans then rubber-stamp. If a tenant later wants high-severity findings to open incidents, that is an explicit policy with a source, not a hidden branch in ingest that reviewers discover only when they ask why the incident count matches the finding count. The docs say this in one sentence: Intertrace does not auto-create an incident for every finding or gateway event. Use incidents when you need a case file.

Membership, RLS, and who is allowed to lie

A history row with changed_by pointing at a user who is not in org_members is a defect, not a clever break-glass path. org_members is the RLS and dashboard source of truth. Mission Control’s organization_members table is kept in sync by triggers; it is not a second place to grant access to findings. Clerk organization membership is how a human arrives at platform.intertrace.ai. It does not replace Postgres membership for telemetry reads.

Service-role writes are the remaining way to mint history without a member session. That is why status_change_by exists, and why we want those writes to go through named routes with a source of cron, guardian, runtime, or posture_engine rather than a shared admin client that patches status in a loop. Break-glass SQL is still possible for a superuser. It is not a product feature. If we use it, the honest follow-up is an out-of-band note in the incident or the review, not a forged history insert. Forged inserts are rejected.

What RLS does not save you from

RLS stops cross-tenant reads. It does not stop a member of the correct org from resolving a finding without evidence. It does not stop a client from omitting status_change_source. Those are application and trigger concerns. Treat RLS as isolation, the helper as labeling, the trigger as journaling, and evidence as payload. Collapsing those four into “the dashboard is secure” is how reviews go badly after the screenshots look fine.

How a finding should move

  1. Ingest or a lab run creates the finding. Gateway ingest should attach finding_evidence in the same flow: prompt preview or encrypted marker, policy identifiers, event id.
  2. A human or named automation triages. Status goes to in_progress through withFindingChangeSource(..., "api") or the appropriate producer source. History records open → in_progress.
  3. Disposition: resolved, false_positive, or another closed state. Attach whatever additional evidence the disposition depends on before or in the same user action, not after the auditor asks.
  4. If coordinated response is required, a person creates an incident and links findings. The incident has its own history. The findings remain findings.

False positives are first-class. They are not a deleted row. They are a status transition with a source and, when possible, an evidence row that explains the allow path: a policy pack id, a purpose category, a classifier label. ADR 0004 and the analyst-loop eval consume those labels. If you delete the finding to clean the queue, you delete the training signal and the audit line.

Failure modes we have already seen

  • Patching status from a notebook with the service role and no helper. History says system, changed_by is null. The finding looks resolved. The review stops there.
  • Storing the only copy of a prompt on the finding row and later redacting it in place. There is no evidence history, only a shorter string.
  • Opening an incident for every FLAG verdict so that PagerDuty has volume. On-call learns to silence the integration. Real criticals inherit the silence.
  • Writing membership only to Clerk or only to organization_members. The reviewer can sign in and still not see the finding their teammate closed, or can see it when they should not.
  • Replaying a lab run and creating duplicate findings without linking to the original evidence. Counts go up. Provenance goes down.

None of these require an attacker. They require convenience. The trigger and the helper exist to make the convenient path the labeled path. When we add a new mutation surface—an Operator action, a posture job, a bulk resolve—the review checklist is the same: does it call withFindingChangeSource(), does the source exist in the closed list, does evidence land in finding_evidence, and did we resist turning the finding into an incident automatically?

What we will not claim

An append-only history table is not a SOC 2 certificate. It is not a substitute for retention policy, legal hold, or export. It is the minimum structure that lets a competent reviewer reconstruct a finding’s life without trusting the current chip. Policy packs and framework mappings can appear on the finding as inputs. They are not proof that a control operated. Proof is the event, the decision, the evidence row, and the status transition with an actor.

We also will not claim that every historical finding in every environment was written under this contract. Triggers were added and then hardened: immutability, forge rejection, org guards, service-role attribution. Findings created before those migrations have whatever they have. The honest statement in a review is: from this migration forward, status changes journal; evidence is append-only; incidents are a separate, mostly manual object. If a customer needs a backfill, that is a project with its own evidence, not a silent UPDATE of history.

A review you can actually pass

Pick a resolved finding. Show the history rows in order. Show that changed_by is a user in org_members or a documented service path with a non-system source. Show the evidence rows whose created_at is at or before resolution. Show the gateway event or lab run that created the finding, via SecurityEventEnvelope fields on gateway_events, not via a reconstructed narrative. Show that no incident was opened unless someone opened one, and if they did, show incident_status_history the same way.

If you cannot do that, the dashboard is not ready for the review, no matter how quickly the status chip flips. The work is not visual design. It is a trigger, a helper, an append-only evidence table, RLS that is forced on, and the discipline to keep incidents scarce. That is the trail. Everything else is a screenshot.

Continue reading

← Back to blog