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.

activityKeyMarkdownPersists toDesign doc
vulnetix.affectedprompts/affected.mdCVEAffected.{modules, programFiles, programRoutines} (JSON)(this page)
vulnetix.attackprompts/attack.mdCVEAttackTechnique + CVEAttackMitigation + CVEAttackDetection + CVEAttackD3fend(this page)
vulnetix.cweprompts/cwe.mdReplaces the CVEProblemType set with descriptionType = "CWE", derivedBy = "vulnetix" (only IDs validated against the embedded MITRE CWE catalog)(this page)
vulnetix.ghsapocprompts/ghsapoc.mdExploit (source=vulnetix) + Markdown PoC in S3ghsapoc-activity
vulnetix.snortruleprompts/snortrule.mdSnort rule artefact (post-exploit pass)(this page)
vulnetix.treesitterprompts/treesitter.mdCVETreeSitterQuery + CVETreeSitterCapture + CVETreeSitterPredicatetreesitter-activity

Historical note: the vulnetix.inferalias activity 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 resultVerdictRetryable
stored at least one rowok (unchanged)terminal
stored nothing because a validity gate rejected everythingmalformedyes, bounded by --max-attempts
the write itself failederroryes, 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.

MethodFiresUsed 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 foldingcalled 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 countcallers with their own soft deadline (the NVD two-phase processors)
RunBatch(ctx, []Target)RunBatchUntil with only the process-wide budgetevery 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 categoryProcessorsWire-in style
NVDnist-nvd-recent, nist-nvd-modifiedtwo-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.
NVDnist-nvd-yearRunBatch after each batch commits
GHSAghsa-git-processor, ghsa-rss-processorgit → processor.Config.Enricher (pipeline calls RunBatch); rss → RunBatch per advisory commit
OSVosv-json-processorRunBatch collected inside the batch tx, fired once after commit
MSRCmsrc-csaf-processorRunBatch per storeItem commit
RHSArhsa-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

VarRequired?DefaultPurpose
PIX_INFERENCE_ENABLEDyesunsetMaster switch. true enables every activity. Anything else = no-op.
CF_AIG_TOKENyesn/aCloudflare AI Gateway bearer token. AI_GATEWAY_TOKEN is also accepted.
AI_GATEWAY_URLyesn/aGateway base URL (OpenAI-compat endpoint).
GITHUB_TOKENonly ghsapocunsetRaises GitHub API rate limit when fetching commit diffs for ghsapoc.
S3_BUCKET_NAMEoptionalunsetWhen 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):

ColumnEffect
enabledWhen false, loadPixConfig returns “no enabled row” — activity errors
providerInformational only — runtime ignores
inferenceModelSent to the gateway as workers-ai/{model}
systemPromptTextSystem message body. Empty/NULL → embedded markdown fallback (with warn)
responseMaxTokensMaps to OpenAI-compat max_tokens
temperatureMaps to temperature
topPMaps 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 + originalResponse columns capture the full audit trail. Token usage is in promptTokens / completionTokens / totalTokens.

7. Hard rules (from AGENTS.md)

  • RunBatch MUST be called outside the batch transaction. The LLM call is HTTP I/O and persist* opens its own transactions.
  • ✅ Collect targets only for successfully stored records — never for records that hit ROLLBACK TO SAVEPOINT.
  • ✅ Pass the canonical CVEMetadata.source slug — not the processor name — so derivedBy audits cleanly back to the primary row.
  • ❌ Never call RunCVE / RunBatch from inside a pgx.Tx. The enricher’s own DB writes will deadlock against the parent tx.

  • 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)