aienrich — AI Gateway Enrichment Overview
aienrich is the package at scripts/go-processors/internal/aienrich/
that orchestrates Cloudflare AI Gateway calls and persists the structured
results to Postgres. It runs post-commit — never inside a primary
transaction — and is gated by a single env var so disabling it is a
zero-code change.
Authoritative wire-up rules live in
scripts/go-processors/AGENTS.md§ “AI Enrichment via aienrich”. This page is the reader-friendly version of the same contract.
1. Activities (Pix activityKey values)
Every activity is one row in the Pix table, seeded by a Vulnetix/SaaS
migration. The runtime reads Pix.systemPromptText first; the markdown
embedded in the Go binary is a fallback so a fresh environment still
works before pix-prompts-sync runs.
activityKey | Markdown | Persists to | Design doc |
|---|---|---|---|
vulnetix.affected | prompts/affected.md | CVEAffected.{modules, programFiles, programRoutines} (JSON) | (this page) |
vulnetix.attack | prompts/attack.md | CVEAttackTechnique + CVEAttackMitigation + CVEAttackDetection + CVEAttackD3fend | (this page) |
vulnetix.cwe | prompts/cwe.md | Replaces the CVEProblemType set with descriptionType = "CWE", derivedBy = "vulnetix" (only IDs validated against the embedded MITRE CWE catalog) | (this page) |
vulnetix.ghsapoc | prompts/ghsapoc.md | Exploit (source=vulnetix) + Markdown PoC in S3 | ghsapoc-activity |
vulnetix.snortrule | prompts/snortrule.md | Snort rule artefact (post-exploit pass) | (this page) |
vulnetix.treesitter | prompts/treesitter.md | CVETreeSitterQuery + CVETreeSitterCapture + CVETreeSitterPredicate | treesitter-activity |
Historical note: the
vulnetix.inferaliasactivity attempted to infer a CVE-* identifier for vendor advisories that lacked one. It was removed because the intended path for filling that kind of gap is a CPE-dictionary vector embedding feeding the CWE pass — not an LLM guess at a CVE id. CWE inference (vulnetix.cwe) is the surviving “fill the gap when the upstream feed omits it” activity.
Every activity writes one PixLog row regardless of outcome (success,
sentinel, parse failure, gateway error). The only exception is
vulnetix.ghsapoc’s first gate: when the target Exploit row already
exists for that GHSA, the activity returns silently with no PixLog.
The verdict must match the record, not the reply
Every activity also rolls its attempt up into CveEnrichmentOutcome, the table
the enrichment queue anti-joins on. ok, declined and not_attempted are
terminal: an advisory carrying one is never asked again.
That rollup is written the moment the model answers — before anything is
stored — because the audit trail has to survive a write that dies. So a pass
that answered well and then stored nothing used to be recorded as ok and
retired permanently. Measured on 2026-08-31 that was 4,788 advisories: 3,239
cwe, 1,501 attack (the ATT&CK catalog gate rejected every technique id the
model returned, so the transaction committed empty), 41 treesitter, 7
affected.
Each pass now reports what it actually persisted (notePersisted in
internal/aienrich/pixlog.go) and the verdict is revised when the answer did
not land:
| Persist result | Verdict | Retryable |
|---|---|---|
| stored at least one row | ok (unchanged) | terminal |
| stored nothing because a validity gate rejected everything | malformed | yes, bounded by --max-attempts |
| the write itself failed | error | yes, bounded by --max-attempts |
The revision is an UPDATE guarded on outcome = 'ok', and never touches
attemptCount — that attempt was counted once already, and a declined or
not_attempted verdict describes the reply and stays correct however the write
went. sql/enrichment-outcome-repair.sql re-opens the rows written before this.
Replaying an answer PixLog already holds
PixLog is the store of what every model ever answered — 1.39M rows carrying
the reply verbatim. Persisting a reply is a separate step, and it can fail:
a write error, a validity gate that refuses the whole answer, or a verdict
written before the write was attempted. The answer survives in PixLog in every
one of those cases.
cmd/aienrich-pixlog-replay re-runs the parse and persist half of a pass over
the stored reply. It calls the same parse* gate and the same persist* writer
the live pass calls (aienrich.Replay), so a reply the live pass would have
refused is refused identically. It builds its Enricher with
aienrich.NewForReplay, which holds no gateway credentials, so it cannot spend
inference budget at all.
Run it before a corpus sweep, so the sweep is not asked to reproduce answers we already have:
just go-aienrich-pixlog-replay all prod "" "" "" "" "" true # dry-run sizing
just go-aienrich-pixlog-replay all prod
Selection drives off CveEnrichmentOutcome, one row per (advisory, source,
pass), and reaches for the reply through a LATERAL. Driving off PixLog
directly reads better — the log is the source of truth for what was answered —
but it does not run: 1.39M rows across seven passes means a wave walks a long
stretch of the index probing for the activity key and anti-joining twice per
row. One 300-row wave for the affected pass had not returned after ten minutes;
the same sweep against the outcome table finishes in 30 seconds.
The LATERAL picks the newest reply that is neither a transport error nor a
pre-call skip — deliberately not pixLogUuid, which points at the LAST
attempt. For an advisory whose retry errored, the last attempt is the error and
the good answer is the one before it.
Excluded from selection: verdicts that are already declined or
not_attempted (terminal, and nothing in the reply to persist), and a
malformed verdict a PREVIOUS REPLAY wrote against the very PixLog row this
one would re-read. That last test is what makes the tool converge. It keys on
the replay reason prefix, not on pixLogUuid alone: a malformed verdict
from the live classifier — or from sql/enrichment-outcome-repair.sql, whose
re-opened rows have a perfectly good reply and a failed write — points at that
same reply, and skipping those would omit exactly the population the replay
exists to recover.
An advisory with no outcome row at all is invisible to this selection.
sql/enrichment-outcome-seed-missing.sql gives those a verdict without
touching any existing one; run it first. It seeded 449 rows on 2026-09-01.
What counts as “nothing to persist” is decided by classifyOutcome, the same
function the live pass uses, not by a second sentinel test. treesitter stamps
its own sentinel into a synthetic PixLog row when no source could be resolved,
and a genuine model refusal carries the same literal — only the no-grounding
marker tells them apart. 327,283 treesitter rows are the first kind, so a replay
that judged them itself would rewrite every one of them from not_attempted to
declined, asserting a refusal the model never made.
A replay verdict records the judgement of a parser, and parsers change. After
changing a parse* gate, sweep that pass once with --reopen, which drops the
“a previous replay already refused this exact reply” exclusion and leaves every
other bound in place. escapeJSONControlChars is the case in point: it repairs
a tree-sitter reply whose queryText held literal newlines — 78% of sampled
treesitter parse rejections — all of which a prior replay had already marked
malformed and would otherwise never re-read.
snortrule and crit are not replayable. snortrule keys on an Exploit
uuid, and the rule it writes links to CVEs rather than back to that exploit, so
there is no predicate for “this exploit already produced a rule”. crit
persists outside the package — its candidate is validated and staged to S3 by
crit-inference-processor.
ecosystemFunctions maps to programRoutines
The affected pass returns an ecosystemFunctions list of qualified symbols
(package.Function, pkg.Type.Method). The CVE Record Format has no such
field, and its programRoutines is “a list of the affected source code
functions, methods, subroutines, or procedures” — which is what those strings
are. They merge into programRoutines on the affected row, deduplicated
against the bare programRoutines names, in the spec’s {"name": ...} shape.
internal/osv already stores Go ecosystem_specific.imports[*].symbols in the
same column.
They previously went into modules — the column the schema reserves for
component and feature names — on a second CVEAffected row with a NULL vendor
and product, which no consumer can attribute to a product.
sql/affected-ecosystem-functions-migrate.sql rewrites the 5,325 rows written
that way.
Successful CWE inference treats the latest accepted response as the complete
Vulnetix-derived set for that (cveId, source) target. The write transaction
deletes only rows where derivedBy = "vulnetix", then inserts the accepted
catalog IDs. Source-published problem types remain unchanged. This makes
repeated enrichment idempotent and removes classifications that a later
inference no longer returns.
2. Three entry points
(*Enricher) exposes four call shapes; processors pick the one that
matches their pipeline.
| Method | Fires | Used by |
|---|---|---|
RunCVE(ctx, cveID, source) | affected → attack → cwe → treesitter (90 s budget) | (helper; not currently called directly) |
RunWithAliases(ctx, cveID, source, aliases) | affected → attack → cwe → treesitter (90 s budget); aliases parked for future cross-source context folding | called by RunBatchUntil per target |
RunBatchUntil(ctx, []Target, deadline) | per target: RunWithAliases + RunGHSAPoC (180 s budget for ghsapoc); stops starting new targets once deadline (or the process budget) passes, returns the deferred count | callers with their own soft deadline (the NVD two-phase processors) |
RunBatch(ctx, []Target) | RunBatchUntil with only the process-wide budget | every other wired processor |
Enrichment is best-effort and time-boxed. The Enricher derives a
process-wide budget deadline once at construction from
EXPECTED_DURATION_MINUTES (the budget minus a margin of 10 % clamped to
[2 min, 10 min] — enrichmentDeadline in internal/aienrich/enricher.go).
RunBatch/RunBatchUntil stop starting new targets at that deadline so a slow
or unhealthy AI gateway cannot consume a processor’s whole runtime and trip the
overtime watcher. A CVE the budget does not reach this run keeps its stored
CVEMetadata row with no AI enrichment, and nothing currently re-queues it —
there is no async enrichment sweeper. Local backfills never set
EXPECTED_DURATION_MINUTES, so they are unbounded.
Enricher.Enabled() is the safe predicate for skipping a no-op call; on
a nil receiver (feature disabled) it returns false and every method
is also a safe no-op.
3. Source-category coverage
Every primary CVEMetadata writer is wired:
| Source category | Processors | Wire-in style |
|---|---|---|
| NVD | nist-nvd-recent, nist-nvd-modified | two-phase: ingest every batch first, then RunBatchUntil once against the run’s soft deadline. The resume tracker only advances when the ingest completed, so a budget-truncated run refetches its window instead of skipping records. |
| NVD | nist-nvd-year | RunBatch after each batch commits |
| GHSA | ghsa-git-processor, ghsa-rss-processor | git → processor.Config.Enricher (pipeline calls RunBatch); rss → RunBatch per advisory commit |
| OSV | osv-json-processor | RunBatch collected inside the batch tx, fired once after commit |
| MSRC | msrc-csaf-processor | RunBatch per storeItem commit |
| RHSA | rhsa-rss-processor, redhat-csaf-processor (Phase A + Phase B VEX fallback) | RunBatch per advisory / per VEX-fallback commit |
The shared structured-JSON passes (affected, attack, cwe, treesitter)
fire on every wired source. ghsapoc self-gates to source == "github"
only. The CWE pass in particular is valuable across every source slug —
NVD often publishes a CVE with no CWE on the initial release, and most
vendor / distro advisories never assign one.
4. Environment
| Var | Required? | Default | Purpose |
|---|---|---|---|
PIX_INFERENCE_ENABLED | yes | unset | Master switch. true enables every activity. Anything else = no-op. |
CF_AIG_TOKEN | yes | n/a | Cloudflare AI Gateway bearer token. AI_GATEWAY_TOKEN is also accepted. |
AI_GATEWAY_URL | yes | n/a | Gateway base URL (OpenAI-compat endpoint). |
GITHUB_TOKEN | only ghsapoc | unset | Raises GitHub API rate limit when fetching commit diffs for ghsapoc. |
S3_BUCKET_NAME | optional | unset | When unset, ghsapoc PoC markdown is not uploaded (Exploit row still written with NULL r2* columns). |
All three required vars are set globally for every ECS task in
terraform/go-schedules.tf (local.go_task_environment for the env vars
plus local.go_task_secrets for the bearer). Disable for a single task
by overriding its environment block to omit PIX_INFERENCE_ENABLED.
5. Per-activity Pix runtime knobs
Every activity reads its config from the Pix row at activityKey = vulnetix.<stem>. The columns the runtime consumes (see
pix.go::loadPixConfig):
| Column | Effect |
|---|---|
enabled | When false, loadPixConfig returns “no enabled row” — activity errors |
provider | Informational only — runtime ignores |
inferenceModel | Sent to the gateway as workers-ai/{model} |
systemPromptText | System message body. Empty/NULL → embedded markdown fallback (with warn) |
responseMaxTokens | Maps to OpenAI-compat max_tokens |
temperature | Maps to temperature |
topP | Maps to top_p |
In-process cache: 5 min TTL per activityKey. Gateway errors fire
invalidatePixCache(activityKey) so a misconfigured row is re-read on
the next attempt.
6. Operator runbook
6.1 Sync prompt edits
just pix-prompts-sync prod # or local
Iterates scripts/go-processors/internal/aienrich/prompts/*.md and
pushes each into Pix.systemPromptText where activityKey = vulnetix.<filename-stem>. Idempotent: rows already matching the file’s
content are skipped.
6.2 Swap model for one activity
just pix-model-set vulnetix.treesitter '@cf/zai-org/glm-5.2'
ACTIVITY=all updates every vulnetix.* row. Idempotent.
6.3 Disable one activity at runtime
UPDATE "Pix" SET enabled = false WHERE "activityKey" = 'vulnetix.treesitter';
Cache TTL is 5 min — next call after that picks up the change.
6.4 Disable everything for one ECS task
In terraform/go-schedules.tf, override the module’s environment to a
map that omits PIX_INFERENCE_ENABLED. aienrich.NewFromEnv then
returns nil for that task and every aienrich method is a no-op.
6.5 Auditing inference runs
Every call writes a PixLog row. Look for rejected = true to find
parse failures / empty responses / gateway errors. The systemPromptText
userPrompt+originalResponsecolumns capture the full audit trail. Token usage is inpromptTokens/completionTokens/totalTokens.
7. Hard rules (from AGENTS.md)
- ✅
RunBatchMUST be called outside the batch transaction. The LLM call is HTTP I/O andpersist*opens its own transactions. - ✅ Collect targets only for successfully stored records — never for
records that hit
ROLLBACK TO SAVEPOINT. - ✅ Pass the canonical
CVEMetadata.sourceslug — not the processor name — soderivedByaudits cleanly back to the primary row. - ❌ Never call
RunCVE/RunBatchfrom inside apgx.Tx. The enricher’s own DB writes will deadlock against the parent tx.
8. Related docs
- ghsapoc-activity — GHSA PoC generation, full design
- treesitter-activity — tree-sitter query generation, full design
scripts/go-processors/AGENTS.md§ “AI Enrichment via aienrich” — wiring rules.repo/treesitter-schema.md— tree-sitter schema record (Prisma + idempotent SQL)
S3 Persistence
Not used. This processor does not currently archive payloads or quarantine failures to S3. Per the S3 Persistence Contract this is non-compliant — see the compliance matrix for the implementation roadmap.
⚠ Not in the compliance matrix — status needs verification.
Expected paths when implemented:
- Archive:
aienrich/files/{sha256}/{filename} - Quarantine:
failed-feeds/aienrich/{YYYY-MM-DD}/{reason}/{filename} - Likely reasons: (none documented)