Skip to content

Commit fec8f12

Browse files
authored
feat(ci): backtest logic/regex detection changes against recorded history in a dedicated CI job (#8147)
* feat(review): capture the model's raw response in linked_issue_scope_mismatch's fired-event metadata (#8139) The existing #8129 raw-context capture (issueText/prTitle/prBody/diff) rebuilds the prompt but never stored what the model actually returned, so a future logic backtest couldn't replay parseLinkedIssueSatisfactionOpinion/ buildLinkedIssueSatisfactionResult against real history -- only the prompt inputs, not the output they need to re-parse. Threads the raw text through runWorkersSatisfactionOpinion and the BYOK path, bounded at the capture site in processors.ts the same way the other raw-context fields are. Part of #8139 (logic/regex backtest CI check); the CI job itself is not yet implemented. * feat(ci): backtest logic/regex detection changes against recorded history in a dedicated CI job (#8139) A PR that rewrites detection logic (not just a threshold, #8138/#8142) now gets the same honest before/after backtest: a new path-filtered workflow checks out the PR's head AND base, replays linked_issue_scope_mismatch's captured raw context (#8129/#8130 + the model-response capture) through both versions of buildLinkedIssueSatisfactionResult, scores them with @loopover/engine's scoreBacktest/compareBacktestScores, posts its own advisory PR comment, and persists a calibration.logic_backtest_run audit event for #8140's track record. backtest-track-record.ts now aggregates both sibling event types, so #8105's Phase-2 decision reads threshold AND logic runs, not a partial record. Runs in CI, not ORB's Worker, because verifying a logic rewrite requires executing the PR's own code -- the same trust boundary validate-code already uses, and one the credential-holding Worker must never cross. Pure core (registry scoped to linked_issue_scope_mismatch, secret_leak permanently excluded per #8130, classify construction, comment/SQL rendering) is fully unit-tested; the CLI is thin IO glue per the export-d1-data.ts precedent. Advisory only -- never a required check, never blocks merge (#8105). * fix(ci): fail the logic backtest open — an advisory job must never red-X a PR on infra errors (#8139) First live run surfaced two problems at once: the repo's CLOUDFLARE_API_TOKEN is scoped Workers Scripts:Edit only (no D1 access), so the corpus export died with Cloudflare 7403 -- and that red check is not harmless, because the review engine auto-closes contributor PRs on ANY failed check, required or not. An advisory job that can go red on an under-scoped token, a D1 outage, or a comment-post hiccup would let our own plumbing close innocent contributor PRs touching the watched paths. Every D1/comment step now catches its own failure, emits a ::notice explaining exactly what was skipped and why, and keeps the job green -- 'never blocks merge' (#8105) now holds against the job's own failures, not just its verdicts. Enabling the backtest for real needs a D1-scoped token in the CLOUDFLARE_API_TOKEN secret; until then the job is a visible no-op. * feat(ci): persist the corpus checksum with each logic-backtest run as its reproducibility freeze point (#8139) The persisted run recorded the comparison and both shas but not WHICH corpus snapshot it scored -- without that, a skeptical re-run can't prove it replayed the same history. The manifest's own checksum (#8084) is already in hand at run time; thread it into the persisted metadata and the PR comment, so checksum + head/base shas + the public scoring code make every run independently re-runnable end to end (the reproducible-backtest posture #8136 is evaluating).
1 parent 80d5fdf commit fec8f12

9 files changed

Lines changed: 870 additions & 17 deletions
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
# Logic/regex-change backtest (#8139, epic #8082). When a PR touches the watched detection-logic paths,
2+
# this job replays linked_issue_scope_mismatch's recorded raw-context history (#8129/#8130 + the #8139
3+
# model-response capture) through BOTH the PR's own head checkout and its base checkout — actually executing
4+
# the two versions of the detection code, which is why this lives in CI and not in ORB's live Worker (the
5+
# Worker holds credentials and must never execute PR-supplied logic; a CI checkout running a PR's own code
6+
# is the exact trust boundary validate-code/validate-tests already use for every PR). Deliberately a
7+
# separate workflow, not a ci.yml job: PRs that don't touch these paths pay nothing, and ones that do aren't
8+
# slowed — this runs fully parallel to (and finishes long before) the ~11-minute test shards.
9+
# Advisory only: never a required check, never blocks merge (#8105). It posts its OWN clearly-labeled PR
10+
# comment, separate from ORB's unified review comment — see #8139's Boundaries for why.
11+
name: backtest-logic
12+
13+
on:
14+
pull_request:
15+
# Explicit list because the default (opened/synchronize/reopened) omits ready_for_review -- once the
16+
# draft guard below skips draft PRs, marking a PR ready must itself trigger a real run (#6670).
17+
# Mirrors selfhost.yml's pull_request.types comment/list exactly.
18+
types: [opened, synchronize, reopened, ready_for_review]
19+
# Exactly the paths whose changes can alter linked_issue_scope_mismatch-adjacent detection logic —
20+
# see #8139's Design section; keep this list in sync with the issue's own spec.
21+
paths:
22+
- "src/rules/**"
23+
- "src/review/content-lane/**"
24+
- "src/settings/agent-actions.ts"
25+
- "src/services/ai-review.ts"
26+
- "src/services/linked-issue-satisfaction.ts"
27+
28+
# Least privilege: the backtest only reads the repo; pull-requests: write is for its own advisory comment.
29+
permissions:
30+
contents: read
31+
pull-requests: write
32+
33+
concurrency:
34+
# pull_request-only workflow, so one ref-scoped group suffices (no push/github.sha split like ci.yml
35+
# needs): a newer push cancels the superseded run — its comment would be overwritten anyway.
36+
group: backtest-logic-${{ github.ref }}
37+
cancel-in-progress: true
38+
39+
jobs:
40+
backtest:
41+
name: logic backtest (advisory)
42+
# Skip draft PRs (#6670, anti-abuse — mirrors selfhost.yml's guard). Fork PRs are excluded at the job
43+
# level rather than per-step: GitHub withholds repo secrets from fork-originated pull_request runs, so
44+
# the D1 corpus read below is impossible there and the whole job (npm ci included) would be waste —
45+
# the fork-notice job below is this workflow's half of ci.yml's paired fork==true/!=true convention.
46+
if: ${{ github.event.pull_request.draft != true && github.event.pull_request.head.repo.fork != true }}
47+
runs-on: ubuntu-latest
48+
timeout-minutes: 15
49+
steps:
50+
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
51+
with:
52+
persist-credentials: false
53+
54+
# The PR's base commit, checked out INSIDE the head workspace: the dynamically imported base modules
55+
# resolve bare npm specifiers by walking up from their own directory into the head checkout's
56+
# node_modules, so one `npm ci` serves both sides of the comparison.
57+
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
58+
with:
59+
ref: ${{ github.event.pull_request.base.sha }}
60+
path: .backtest-base
61+
persist-credentials: false
62+
63+
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
64+
with:
65+
node-version-file: .nvmrc
66+
cache: "npm"
67+
68+
- name: Install deps
69+
run: npm ci --ignore-scripts
70+
71+
# The scripts import @loopover/engine, which resolves to its dist/ build output.
72+
- name: Build engine package
73+
run: npx turbo run build --filter=@loopover/engine
74+
75+
# Every step from here down FAILS OPEN (notice + green, never a red check): the review engine
76+
# auto-closes a contributor PR on ANY failed check, required or not — so an advisory job that can go
77+
# red on an infra problem (an under-scoped CLOUDFLARE_API_TOKEN, a D1 outage, a comment-post hiccup)
78+
# would let OUR plumbing close an innocent contributor's PR. "Never blocks merge" (#8105) has to hold
79+
# against this job's own failures, not just its verdicts.
80+
#
81+
# Read-only corpus export (#8084's CLI, reused as-is — no new D1 read code). The wrangler secrets are
82+
# available here because the fork guard above already excluded fork-originated runs.
83+
- name: Export corpus from D1
84+
id: corpus
85+
env:
86+
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
87+
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
88+
run: |
89+
if npx tsx scripts/backtest-corpus-export.ts --rule-id linked_issue_scope_mismatch --output backtest-corpus.json --remote; then
90+
echo "available=true" >> "$GITHUB_OUTPUT"
91+
else
92+
echo "available=false" >> "$GITHUB_OUTPUT"
93+
echo "::notice::Corpus export from D1 failed (missing or under-scoped CLOUDFLARE_API_TOKEN — it needs D1 read/write on the loopover database). Logic backtest skipped; advisory only, never fails the PR."
94+
fi
95+
96+
- name: Run logic backtest
97+
id: backtest
98+
if: ${{ steps.corpus.outputs.available == 'true' }}
99+
env:
100+
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
101+
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
102+
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
103+
BASE_SHA: ${{ github.event.pull_request.base.sha }}
104+
PR_NUMBER: ${{ github.event.pull_request.number }}
105+
run: |
106+
if npx tsx scripts/backtest-logic-check.ts \
107+
--rule-id linked_issue_scope_mismatch \
108+
--corpus backtest-corpus.json \
109+
--head-root . \
110+
--base-root .backtest-base \
111+
--output backtest-comment.md \
112+
--head-sha "$HEAD_SHA" \
113+
--base-sha "$BASE_SHA" \
114+
--persist --remote --db loopover \
115+
--repo "$GITHUB_REPOSITORY" \
116+
--pr "$PR_NUMBER"; then
117+
echo "ready=true" >> "$GITHUB_OUTPUT"
118+
else
119+
echo "ready=false" >> "$GITHUB_OUTPUT"
120+
echo "::notice::Logic backtest run failed — no comparison produced. Advisory only, never fails the PR."
121+
fi
122+
123+
# Update-in-place keyed on the comment marker so a re-run edits the existing comment instead of
124+
# stacking a new one per push.
125+
- name: Post or update the PR comment
126+
if: ${{ steps.backtest.outputs.ready == 'true' }}
127+
env:
128+
GH_TOKEN: ${{ github.token }}
129+
PR_NUMBER: ${{ github.event.pull_request.number }}
130+
run: |
131+
post_comment() {
132+
marker="<!-- loopover-logic-backtest -->"
133+
# --paginate runs the --jq filter once per page, so pin to the first emitted id.
134+
comment_id=$(gh api "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" --paginate \
135+
--jq "[.[] | select(.body | contains(\"${marker}\")) | .id] | first // empty" | head -n 1)
136+
if [ -n "$comment_id" ]; then
137+
gh api "repos/${GITHUB_REPOSITORY}/issues/comments/${comment_id}" -X PATCH -F body=@backtest-comment.md
138+
else
139+
gh api "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" -F body=@backtest-comment.md
140+
fi
141+
}
142+
if ! post_comment; then
143+
echo "::notice::PR comment post failed — backtest result computed and persisted but not posted. Advisory only, never fails the PR."
144+
fi
145+
146+
# The fork half of ci.yml's paired fork==true/!=true convention: fork-originated pull_request runs get no
147+
# repo secrets, so the D1-backed backtest cannot run — say so visibly instead of failing or going silent.
148+
# Advisory only either way; a skipped backtest never blocks anything (#8105).
149+
fork-notice:
150+
name: logic backtest (skipped for fork PRs)
151+
if: ${{ github.event.pull_request.draft != true && github.event.pull_request.head.repo.fork == true }}
152+
runs-on: ubuntu-latest
153+
timeout-minutes: 5
154+
steps:
155+
- name: Explain the skip
156+
run: echo "::notice::Fork PR — repo secrets are withheld, so the D1-backed logic backtest is skipped. Advisory only; nothing blocks."
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
// Pure core for the logic/regex-change backtest CI check (#8139, epic #8082). A PR that rewrites a rule's
2+
// detection logic (not just a threshold, #8138) gets replayed against the rule's real recorded history: the
3+
// classify function feeds each BacktestCase's captured raw context (#8129/#8130, plus #8139's captured model
4+
// response) through a caller-supplied detection function — dynamically imported by the CLI from the PR's own
5+
// head/base checkouts — and the two resulting scores are compared with @loopover/engine's Pareto-floor
6+
// discipline. No IO here — the CLI (backtest-logic-check.ts) does the dynamic imports, corpus read, D1
7+
// persist, and file writes — mirrors scripts/backtest-corpus-export-core.ts's identical pure-core / thin-IO
8+
// split.
9+
import {
10+
compareBacktestScores,
11+
renderBacktestComparison,
12+
scoreBacktest,
13+
type BacktestCase,
14+
type BacktestComparison,
15+
} from "@loopover/engine";
16+
17+
// Sibling of THRESHOLD_BACKTEST_EVENT_TYPE in src/services/threshold-backtest-run.ts (#8138's writer) — a
18+
// distinct type so #8140's track-record reads can tell threshold runs and logic runs apart. Deliberately not
19+
// imported from src/ (Worker-bound import graph); same hand-mirrored posture as backtest-track-record.ts.
20+
export const LOGIC_BACKTEST_EVENT_TYPE = "calibration.logic_backtest_run";
21+
22+
// Hand-mirrors RAW_CONTEXT_EXCLUDED_CODES in src/rules/advisory.ts (Worker-bound; not imported — same
23+
// posture as the event-type constant above). `secret_leak` is PERMANENTLY excluded (#8130): no raw context
24+
// is ever captured for it by design, so there is nothing to honestly replay — and a registry entry for it
25+
// would invite storing the very content #8130 exists to keep out of the audit trail.
26+
export const LOGIC_BACKTEST_EXCLUDED_RULE_IDS = new Set<string>(["secret_leak"]);
27+
28+
/** One backtestable detection function: where it lives in a checkout and what export to load. The CLI
29+
* imports `exportName` from `<checkoutRoot>/<filePath>` for BOTH the PR's head and base checkouts. */
30+
export type KnownLogicRule = {
31+
filePath: string;
32+
exportName: string;
33+
};
34+
35+
// Mirrors #8138's KNOWN_THRESHOLDS registry shape (src/services/threshold-backtest.ts), keyed by ruleId.
36+
// Scoped to exactly `linked_issue_scope_mismatch` (#8139): its corpus carries the richest raw context
37+
// (issueText/prTitle/prBody/diff via #8129, modelResponseText via #8139), and only the DETERMINISTIC
38+
// post-model step — buildLinkedIssueSatisfactionResult's parse/floor/sanitize — is honestly replayable
39+
// (the prompt build and the model call itself are not reproducible from history). Generalizing to the
40+
// other isConfiguredGateBlocker codes #8130 wires is explicit future scope, not this registry's job yet.
41+
export const KNOWN_LOGIC_RULES: Record<string, KnownLogicRule> = {
42+
linked_issue_scope_mismatch: {
43+
filePath: "src/services/linked-issue-satisfaction.ts",
44+
exportName: "buildLinkedIssueSatisfactionResult",
45+
},
46+
};
47+
48+
/** Resolve a ruleId to its registry entry, failing loud on the permanently excluded `secret_leak` (with the
49+
* #8130 rationale, so the error itself explains the boundary) and on any unregistered ruleId. */
50+
export function resolveKnownLogicRule(ruleId: string): KnownLogicRule {
51+
if (LOGIC_BACKTEST_EXCLUDED_RULE_IDS.has(ruleId)) {
52+
throw new Error(`rule ${ruleId} is permanently excluded from logic backtesting (#8130): no raw context is ever captured for it`);
53+
}
54+
const entry = KNOWN_LOGIC_RULES[ruleId];
55+
if (!entry) {
56+
throw new Error(`unknown logic-backtest rule ${ruleId} (known: ${Object.keys(KNOWN_LOGIC_RULES).join(", ")})`);
57+
}
58+
return entry;
59+
}
60+
61+
/** The dynamically imported detection function's shape — buildLinkedIssueSatisfactionResult's own signature
62+
* (issue text + raw model response in, `{ status }` or null out). A future registry entry must match this
63+
* same shape or grow a per-entry adapter — the classify builder below assumes it. */
64+
export type LogicDetectionFn = (issueText: string | null | undefined, modelResponseText: string) => { status: string } | null;
65+
66+
/** Keep only the cases a detection function can honestly be replayed against: both the captured issue text
67+
* (#8129) and the captured model response (#8139) must be present and non-empty. Older corpus rows predate
68+
* the model-response capture; replaying those would parse empty text and systematically predict "reversed"
69+
* for baseline AND candidate — noise, not signal — so they are skipped and reported, never scored. */
70+
export function filterReplayableCases(cases: readonly BacktestCase[]): BacktestCase[] {
71+
return cases.filter((backtestCase) => {
72+
const metadata = backtestCase.metadata ?? {};
73+
return (
74+
typeof metadata.issueText === "string" &&
75+
metadata.issueText.trim() !== "" &&
76+
typeof metadata.modelResponseText === "string" &&
77+
metadata.modelResponseText.trim() !== ""
78+
);
79+
});
80+
}
81+
82+
/**
83+
* Build a classify function that replays `detect` against a case's captured raw context. The corpus only
84+
* contains actual firings (an "unaddressed" verdict that carried gate authority — see processors.ts's
85+
* recordRuleFired site), so a candidate that reproduces the firing (`status === "unaddressed"`) predicts
86+
* `"confirmed"` (the firing stands); any other outcome — no finding (null), "addressed"/"partial", or a
87+
* thrown error — means the candidate would NOT have fired, predicting `"reversed"`. Same prediction
88+
* semantics as buildConfidenceThresholdClassifier (#8138): candidate-would-not-fire ⇒ "reversed". The
89+
* try/catch mirrors buildLinkedIssueSatisfactionResult's own never-throws fail-safe: a crashing candidate
90+
* never fires, it does not abort the whole backtest.
91+
*/
92+
export function buildLogicClassifier(detect: LogicDetectionFn): (backtestCase: BacktestCase) => "reversed" | "confirmed" {
93+
return (backtestCase) => {
94+
const metadata = backtestCase.metadata ?? {};
95+
const issueText = typeof metadata.issueText === "string" ? metadata.issueText : "";
96+
const modelResponseText = typeof metadata.modelResponseText === "string" ? metadata.modelResponseText : "";
97+
let result: { status: string } | null;
98+
try {
99+
result = detect(issueText, modelResponseText);
100+
} catch {
101+
result = null;
102+
}
103+
return result?.status === "unaddressed" ? "confirmed" : "reversed";
104+
};
105+
}
106+
107+
/**
108+
* Backtest a logic/regex change to a single rule's detection function: score the base checkout's version and
109+
* the head checkout's version as two classifiers over the same replayable corpus (`scoreBacktest`, #8085),
110+
* then compare them with the Pareto-floor discipline (`compareBacktestScores`, #8086). Mirrors
111+
* runThresholdBacktest's shape (packages/loopover-engine/src/calibration/backtest-threshold.ts) with detection
112+
* functions in place of threshold numbers. The excluded-rule guard here is deliberate defense in depth on top
113+
* of {@link resolveKnownLogicRule} — the scoring path itself refuses `secret_leak`, not just the registry.
114+
*/
115+
export function runLogicBacktest(
116+
ruleId: string,
117+
cases: readonly BacktestCase[],
118+
baselineDetect: LogicDetectionFn,
119+
candidateDetect: LogicDetectionFn,
120+
): BacktestComparison {
121+
if (LOGIC_BACKTEST_EXCLUDED_RULE_IDS.has(ruleId)) {
122+
throw new Error(`rule ${ruleId} is permanently excluded from logic backtesting (#8130)`);
123+
}
124+
const baseline = scoreBacktest(ruleId, cases, buildLogicClassifier(baselineDetect));
125+
const candidate = scoreBacktest(ruleId, cases, buildLogicClassifier(candidateDetect));
126+
return compareBacktestScores(baseline, candidate);
127+
}
128+
129+
/** First line of the posted comment — the workflow's update step finds an existing comment by this marker so
130+
* a re-run edits in place instead of stacking a new comment per push. */
131+
export const LOGIC_BACKTEST_COMMENT_MARKER = "<!-- loopover-logic-backtest -->";
132+
133+
/**
134+
* Render the standalone advisory PR comment: marker, what was replayed against what (including the corpus
135+
* checksum — the freeze point that makes the run independently re-runnable, see #8084's manifest), the
136+
* engine's own comparison Markdown (#8088), and the never-blocks-merge note. Deliberately its OWN comment,
137+
* not a section of ORB's unified review comment — see #8139's Boundaries (this CI job runs outside the
138+
* Worker's review flow, and joining that comment would need new Worker↔CI coupling).
139+
*/
140+
export function renderLogicBacktestComment(
141+
comparison: BacktestComparison,
142+
info: { replayableCount: number; skippedCount: number; headSha: string; baseSha: string; corpusChecksum: string },
143+
): string {
144+
const skippedNote = info.skippedCount > 0 ? ` ${info.skippedCount} historical case(s) lacked captured raw context and were skipped.` : "";
145+
return [
146+
LOGIC_BACKTEST_COMMENT_MARKER,
147+
"## Logic backtest",
148+
"",
149+
`Replayed ${info.replayableCount} historical case(s) for \`${comparison.ruleId}\` through the base` +
150+
` (\`${info.baseSha.slice(0, 7)}\`) and head (\`${info.headSha.slice(0, 7)}\`) versions of its detection logic` +
151+
` (corpus checksum \`${info.corpusChecksum.slice(0, 12)}\`).${skippedNote}`,
152+
"",
153+
renderBacktestComparison(comparison),
154+
"_Advisory only — this check never blocks merge (#8105)._",
155+
"",
156+
].join("\n");
157+
}
158+
159+
/** Single-quoted SQL string literal — mirrors backtest-corpus-export.ts's sqlStringLiteral exactly. */
160+
export function sqlStringLiteral(value: string): string {
161+
return `'${value.replace(/'/g, "''")}'`;
162+
}
163+
164+
/**
165+
* Build the INSERT persisting one run for #8140's track-record reads — a sibling row to #8138's
166+
* THRESHOLD_BACKTEST_EVENT_TYPE events, same audit_events columns recordAuditEvent writes (the CLI runs
167+
* outside the Worker, so it goes through `wrangler d1 execute` instead of the repositories module; the
168+
* caller supplies id/createdAt so this stays clock-free like the rest of this file). `metadata.comparison`
169+
* is the field backtest-track-record.ts's reader already looks for; `corpusChecksum` + the two shas are
170+
* the freeze point (#8136's reproducibility posture) — enough for a skeptic to re-export the corpus,
171+
* verify the checksum, and re-run both sides of this exact comparison independently.
172+
*/
173+
export function buildLogicBacktestAuditInsertSql(input: {
174+
id: string;
175+
targetKey: string;
176+
comparison: BacktestComparison;
177+
headSha: string;
178+
baseSha: string;
179+
corpusChecksum: string;
180+
replayableCount: number;
181+
skippedCount: number;
182+
createdAt: string;
183+
}): string {
184+
const metadataJson = JSON.stringify({
185+
comparison: input.comparison,
186+
headSha: input.headSha,
187+
baseSha: input.baseSha,
188+
corpusChecksum: input.corpusChecksum,
189+
replayableCount: input.replayableCount,
190+
skippedCount: input.skippedCount,
191+
});
192+
const values = [
193+
sqlStringLiteral(input.id),
194+
sqlStringLiteral(LOGIC_BACKTEST_EVENT_TYPE),
195+
"'loopover'",
196+
sqlStringLiteral(input.targetKey),
197+
// AuditEventRecord.outcome is a fixed enum — "completed" means "this run recorded successfully"; the
198+
// real verdict lives in detail + metadata.comparison.verdict, mirroring persistThresholdBacktestRuns.
199+
"'completed'",
200+
sqlStringLiteral(`logic backtest for ${input.comparison.ruleId}: ${input.comparison.verdict}`),
201+
sqlStringLiteral(metadataJson),
202+
sqlStringLiteral(input.createdAt),
203+
].join(", ");
204+
return `INSERT INTO audit_events (id, event_type, actor, target_key, outcome, detail, metadata_json, created_at) VALUES (${values})`;
205+
}

0 commit comments

Comments
 (0)