The problem

A platform that triages security alerts for multiple client organisations already had an investigation agent: it read an alert, called tools, reasoned, and produced a verdict with a confidence score. What it mostly lacked was anything to reason with. One source of address intelligence, cached in the memory of a single process, and no reputation, registration or malware-family context at all.

Adding sources is the easy part of that sentence. Four things made it not easy.

01

Caching threat intelligence is a multi-tenancy question

Every client benefits from a shared cache, because what is known about a public indicator is a global fact. But a cache shared across tenants is exactly the shape of an accidental cross-tenant data leak, so the design has to make the leak impossible rather than unlikely.

02

Enrichment sources return personal data

Registration records, geolocation and campaign context carry names, contact details and network specifics. All of it was headed for a cache, a model prompt and an investigation transcript.

03

A model will attribute confidently from memory

Asked to map a vulnerability to attacker techniques, a model returns real-looking technique identifiers whether or not it has read a definition. The output is plausible, well-formatted, and wrong in a way nothing downstream can detect.

04

You cannot improve what you have not measured

Verdict quality on ambiguous alerts is a judgement, so "did that change help" needs human labels. Without them, a shift in the verdict distribution is not evidence of anything.

The last one is why the most useful thing I did on this project produced no user-visible feature at all.

What I built

An enrichment pipeline, a grounding layer, and the evaluation harness that made the other two assessable.

Already existed
  • An investigation agent with a tool-calling loop, verdicts and confidence scores
  • Automatic tool registration, so a capability becomes an agent tool without touching the loop
  • One address-intelligence source, cached in process memory
  • Vulnerability enrichment from public catalogues, and a model layer to call
  • A configuration file defining priorities, verdicts, stop rules and cache lifetimes, written first, as the contract
What I added
  • A cache-aside indicator enrichment service over a shared cache, deliberately org-agnostic, fanning out to several optional sources
  • A model reconciliation step that resolves disagreement between sources into one interpretation with its reasoning recorded
  • Grounding and validation of attack-technique mapping against a synced authoritative catalogue, replacing the model’s recall
  • A labelled ground-truth baseline and the before-and-after comparison protocol built on it
  • Enforcement of the stop rules the configuration file had specified and the code had never read
  • A cross-tenant cache that cannot be handed tenant data.

    The enrichment service receives an indicator type and value and nothing else. No organisation, no user, no alert. It therefore cannot key an entry to a tenant, which is a stronger guarantee than remembering not to.

  • Personal data distilled out before the cache, not after.

    Sources that return personal detail are reduced to fixed non-personal fact structures before anything is written or shown to a model, with a test per source asserting nothing leaks.

  • Technique attribution grounded in a real catalogue and then validated against it.

    The model selects from the actual active technique list rather than from memory, and every identifier it returns is looked up afterwards, corrected if known, demoted if not.

  • A ground truth, built before the changes it was meant to judge.

    A sampled, human-labelled set of past investigations, with the sampling reason recorded per row and a documented protocol for re-running the same alerts afterwards and comparing by investigation identity.

Architecture

Indicator enrichment: a cross-tenant cache that cannot be given tenant data
Indicator from the alertip · domain · file hashno organisation or user contexttravels with it, by constructionPrivate-address gateprivate · loopback · link-local → stopthree arguments, one gate:external sources know nothing about them,caching one leaks internal topology,refusing to fetch one is an SSRF guardCache lookup: shared across tenantshit → return · error → fetch livekeys carry no organisation id.intelligence about a public indicator isa global fact, not tenant dataFan-out to configured sourcesreputation · scanner · geo · whois · familyevery source optional: unconfigured isnot offered, failing is not fatalDistil to fixed non-personal factsbefore anything is writtenseveral sources return personal data.this is the last point at which thatcan be decided, so it is decided hereReconcile disagreementone model call · skipped when sources agreeverdict, confidence, the conflicts itresolved, and why. unparseable outputfalls back to “unknown”, never to a guessAgent capabilityadvertised only for types a source answersthe investigation loop was not changed:the tool set is generated fromwhatever capabilities existthe service is never handed an organisation id, so it cannot key an entry to a tenantthe cache is safe because of what the service cannot receive, not what it remembers to strip

Three decisions in that path are worth stating as decisions rather than as steps.

The shared cache is a design position, not a shortcut. Intelligence about a public indicator is not any client’s data, so caching it per tenant would multiply cost and slow every investigation for no privacy gain. The privacy work is elsewhere: the service is given no tenant context, so there is no cross-tenant linkage available to store, and the distillation step guarantees the values hold no personal data either. Both halves are needed. Either alone is a liability.

Cache values are deliberately not encrypted in the application. They contain public intelligence and no secret material; at-rest encryption is handled with a per-environment managed key at the infrastructure layer, which is transparent here. Encrypting each value in code would add cost and latency to every cache hit, which is to say to the fast path the cache exists to create, in exchange for nothing.

Everything degrades rather than fails. A source with no credential is not offered. A source that errors is skipped. A cache error fetches live instead of failing the investigation. The reconciliation call is skipped when there is nothing to reconcile, and returns unknown rather than a guess when its own output will not parse. An investigation is the last thing that should fail because a third party is having a bad day.

Four problems worth describing

01

The failure that was the correct answer

Symptom

Running test alerts through the agent, most investigations came back inconclusive with middling confidence: 62% of them, and on one batch 8 of 10. On the face of it, an agent that mostly cannot decide is not much of an agent.

Diagnosis

The alerts carried no indicators, no addresses, no domains, no hashes, so there was nothing for the agent to corroborate against. Its own instructions explicitly license inconclusive when there is nothing to check, and it was following them. Checking the batch of ten individually, 8 of the 8 inconclusive verdicts were the correct call. On one, the agent declined to call a scheduled-task technique benign because the technique is used just as often by administrators as by attackers, and it had no baseline for the account involved and no directory data to establish whether that person was IT staff. It settled at 42% confidence and inconclusive, which is the correct output there. Higher confidence would have meant guessing: with the directory context it lacked, the same alert is a 90%-confidence false positive.

So the 62% was not a quality signal at all. It was a property of my test data. And I had been about to start tuning against it.

Decision

Stop, and build a ground truth before changing anything. I exported all 406 investigations on record as a raw baseline, then sampled the ~150 rows where a human label carries the most information: every true positive, every false positive, and the 40 most confident verdicts, tagging each row with the reason it was sampled so the sample’s own bias is visible. That went into a labelling worksheet with a written guide, so each row gets a human disposition, a judgement of whether the agent was right, and a note. Alongside it: the baseline metrics as they stood, the 62% recorded explicitly as the before number, and the protocol for re-running the same alerts afterwards and comparing them by investigation identity rather than in aggregate.

None of that shipped anything. It is what makes the next three exhibits assessable instead of merely plausible.

The trap here is specific to agents that are allowed to say “I don’t know”. Their honest refusals and their failures produce the same-looking metric, and the instinct is to tune the metric down. I would have improved the number and made the system worse.

02

A model attributing attack techniques from memory

Symptom

Mapping a well-known critical vulnerability to attacker techniques produced identifiers that all existed and read convincingly. One was wrong. The step describing a malicious Java class being fetched and executed was labelled as a command-and-scripting-interpreter technique, which is not what that is. The behaviour was already covered by the exploitation technique in the previous step.

Diagnosis

The model was pattern-matching the phrase “code execution” to a technique it knew by identifier, rather than reasoning from that technique’s actual definition. It could not do otherwise: the authoritative catalogue was being synced into the database on a schedule and was then never passed to the model. The mapping call sent vulnerability data and nothing else, so every attribution came out of training recall, which drifts, is unversioned, and fails silently because a wrong-but-real identifier passes every check a downstream system can perform.

Decision

Two layers, because grounding alone is a request and validation alone is a filter.

Grounding: build a reference block once per process from the active catalogue (identifier, canonical name, tactics, one line each, deprecated techniques excluded at the source rather than filtered afterwards) and send it as a static system block so the model selects from real techniques. It benefits from prompt caching precisely because it never varies.

Validation: look up every identifier the model returns. Known ones are kept with their name, tactics and description overwritten from the catalogue, which is authoritative. Unknown ones are dropped from the trusted index but retained in the stored metadata flagged as unvalidated, because thrown away silently is worse than kept and marked.

Both layers fail open. An empty or unsynced catalogue switches them off and passes the model’s output through unchanged, because the alternative is that a sync failure quietly reduces every mapping to nothing.

Technique attribution for one critical vulnerability
Attribution from training recall
sent to the model    vulnerability data only
catalogue in the db  synced, and never read
identifiers returned all real ← which is why nothing caught it
one step             “java class fetched and executed”
                     → scripting-interpreter technique   wrong
detectable by        nothing downstream. it validates as real.
Grounded, then validated against the catalogue
system block         active catalogue: id, name, tactics
                     deprecated excluded at the source
                     static → benefits from prompt caching
every id returned    looked up: known → canonical values applied
                     unknown → dropped from index, kept + flagged
live run             all returned ids resolved in the catalogue
stability            confirmed core steps identical across runs;
                     only unconfirmed follow-ons varied

One bug I caught in my own validator: a transient database error while looking up an identifier would have silently dropped a possibly-valid technique, making an infrastructure blip indistinguishable from a hallucination. The validator now returns the identifier and a keep decision, so a genuine miss drops and a lookup failure keeps it uncorrected. “I could not check” is not the same answer as “I checked and it is not real”.

03

Three numbers for one limit, and none of them agreed

Symptom

An investigation reported nine steps against a documented limit of six, having been cut off by a hard-coded eight.

Diagnosis

All three numbers were real and in different places. The configuration file, written first and deliberately as the contract both the prompt builder and the enforcement code were meant to read, specified a maximum iteration count, a maximum tool-call count and a wall-clock limit. The loader for that file had been deferred to a later phase. So the code enforced none of it, except one stale constant predating the contract entirely. And the counter it guarded incremented per tool call while the constant was written as a per-turn limit, so any turn calling several tools overshot the cap and reported a number that matched neither document.

Decision

Make the code match the contract it already had, rather than adjust the contract to the code. The single ambiguous constant became two limits with distinct jobs: a hard tool-call ceiling matching the specified figure, and a soft enrichment budget which, once spent, injects a one-time nudge to conclude, and only when the agent is not mid-pivot. A hard cut in the middle of a productive line of enquiry throws away the investigation’s most valuable steps; a nudge lets it finish the thought. The terminal fallback that forces a conclusion stayed as the last resort it always was.

I also went back and marked the documentation contradiction as resolved instead of leaving a note saying two figures disagreed. A known-and-recorded inconsistency is still an inconsistency.

Writing the configuration first as “the source of truth” was the right call and it created its own hazard: for several phases there was an authoritative-looking document that nothing read. A contract with no reader is not a contract, and it is more misleading than no document at all, because the next person believes it.

04

A better design I argued myself out of building

Symptom

A real modelling problem. A vulnerability description often establishes the class of attacker behaviour without establishing the specific method. Forcing the model to pick one specific sub-technique manufactures precision the source does not support; forcing it up to the parent throws away detail that was genuinely established.

Diagnosis

The attractive answer is to represent the uncertainty honestly: one primary technique per step, plus a bounded set of candidate sub-techniques when the method is left open. It is faithful to the evidence, and it is more defensible under audit rather than less. “The description establishes command execution but not which interpreter, so these are the candidates” is easier to justify than a single guess.

Then the costs, which I made myself write down. It is a determinism regression: two runs would return slightly different candidate sets, so I would be trading “sometimes picks the wrong one” for “picks a different set”. It needs a hard cap or it decays into hedging, and a chain where every step lists six possibilities is noise. And it is a schema, storage and validation change rather than a prompt change: one broad parent technique has more than a dozen sub-techniques, so a short chain could balloon from three attributions to twenty.

Decision

Don’t build it. The question I had actually been asking was never one technique or several. It was does the model choose well when a parent has a dozen children. If it does, there is no problem to solve. If it does not, the cheapest available fix is one line of prompt instructing it to emit each plausible sub-technique as its own step rather than guessing between them, which gets most of the benefit for none of the schema change.

Two related calls did get made. Sub-techniques stay in the catalogue: removing them is a lossy one-way door and the grounding and validation layers already prevent invented identifiers, so over-specificity is a prompt concern and not a data-model one. And the sync job was filtering sub-techniques out while the prompt was written expecting them to exist, a disagreement between code and prompt that would have quietly capped attribution detail with no error anywhere.

Included because it is the shape of decision I most often see made badly: a design that is genuinely more correct, cheap to describe, and not worth its cost. Writing the determinism regression down explicitly is what settled it. Until then it read as a pure improvement.

What it costs, and what it was allowed to cost

Two of the design decisions on this pipeline were made on cost rather than on correctness, and both of them are more interesting than a saving would have been, because in each case the cost was structural: a question of how many times you pay, not of how big the thing is.

What a large prompt actually costs, and why one optimisation got two opposite verdicts
Where the cost is decidedit is not the size of the block; it is how many times you pay for itGrounding block709 catalogue entries · ≈10–13k tokensResult written down, permanentlymapped once, never re-derivedpaid once per vulnerabilityResult re-derived on demandthe design not chosenpaid on every investigationThe same optimisation, two workloads, opposite answersThis workloadcatalogue-grounded mapping· block is ≈10–13k tokens· byte-stable between calls· input-dominated callcaching adoptedThe bid-answering runsame technique, other system· ≈4% of prompt byte-stable· 5–6 of 6.4 min is output· output-dominated callcaching dropped · worth ≈1%Whether an optimisation is worth shipping is a property of the workload's shape, not of thetechnique, and the shape is measurable before any of it is written.

The reference block is large and that is not the same as expensive. Grounding the attack-technique mapping means injecting the whole active catalogue, 709 identifiers and names, into the prompt, at roughly ten to thirteen thousand tokens per call. My first reaction was that this was obviously too expensive to ship, and it was the wrong reaction. Each vulnerability is mapped once, ever: the result is written to permanent storage and never re-derived, so the block is paid once per vulnerability rather than once per investigation. Prompt caching then does the rest, because a static catalogue block is exactly the byte-stable prefix that caching exists for.

I did consider the cheaper prompt, keyword-matching the description first and injecting only the twenty or so plausibly relevant techniques, and rejected it. Not because it wouldn’t work, but because the permanent-storage property had already bounded the total spend, so the remaining saving bought complexity in the one place where a wrong narrowing is invisible: a technique dropped before the model ever sees it cannot be recovered by the validator downstream.

The same optimisation, opposite verdicts. Prompt caching was adopted here and dropped on the bid-answering run, and the two decisions are consistent. This call is input-dominated with a large byte-stable block. That one is output-dominated, with five to six of its six-and-a-half minutes spent generating tokens, and only about four per cent of its prompt byte-stable, which caps caching at roughly one per cent of wall clock. The technique is not good or bad; the workload’s shape decides, and the shape is measurable before anything is built.

The commercial constraint was spend not yet committed. The enrichment sources are third-party feeds, and I priced the four commercial candidates before writing anything against them: an address-reputation feed and a scan-noise classifier at around ten thousand a year each, an address-context service at a thousand a month, and a malware-sample service at around five thousand for its commercial tier. The best-known multi-scanner’s free tier is non-commercial only, so using it properly is an enterprise licence as well. Roughly $5,000 to $12,000 a year per source, and a full set is a five-figure annual commitment for data the pipeline had not yet proven it needed.

The engineering consequence, and the reason this belongs in a case study rather than a purchase order: every source is optional, and the pipeline is correct with none of them. Sources are configured independently, skipped cleanly when absent or failing, and the pipeline activates only when the cache is reachable and at least one source exists. That is what let the work proceed against free and freemium tiers, one of which allows a thousand lookups a day, with no spend committed and no code written against a contract nobody had signed yet. Two bounds keep usage inside those tiers by design rather than by luck: a hard cap on enrichment calls per investigation with a softer nudge to conclude well before it, and a shared cache with a day-long expiry, so a repeated indicator is a cache hit rather than a second paid lookup.

709catalogue entries injected per mapping call, ≈10–13k tokens, paid once per vulnerability rather than once per investigation
≈1%of wall clock the same optimisation was worth on an output-bound workload, which is why it shipped here and not there
$5k–$12kper year, per source, across the four commercially licensed candidates. List prices at the time, none of them committed to
$0committed to a paid feed: every source optional, the pipeline correct with none configured

What I am not claiming: any of this converted into an annual figure. The token cost per mapping call is measured and the licence prices are list prices, but the spend depends on how many vulnerabilities get mapped and how many alerts arrive, and I have neither number. The saving I can defend is structural: a cost that recurs per vulnerability instead of per investigation, and a pipeline that does not require a purchase to be correct.

Nor is there an analyst-time figure here, which is the number a reader most wants. Measuring it honestly needs a before: how long a person took to reach the same verdict on the same alerts. That baseline was never captured. What I built instead was the labelled ground-truth set and the before-and-after protocol that would let someone capture it, which is the prerequisite for the claim rather than the claim.

Where it landed

Verified
  • A cache-aside enrichment pipeline over a shared cache, feature-flagged and off by default, activating only when the cache is reachable and at least one source is configured
  • Multiple optional sources across address, domain and file-hash indicators, each fetched on a miss, each skipped cleanly when unconfigured or failing
  • Cache keys carrying no organisation identifier, enforced by the service never receiving one, so it is verified by construction rather than by review
  • Personal data distilled to fixed non-personal structures before any cache write or model prompt, with a per-source test asserting no leak
  • Private, loopback and link-local addresses excluded from lookup and caching, which doubles as an outbound request-forgery guard
  • Model reconciliation of disagreeing sources into a single interpretation carrying its confidence, the conflicts resolved and its reasoning
  • Exposure to the agent as a capability, with no change to the investigation loop, and only for indicator types a configured source can answer
  • Attack-technique attribution grounded in the synced active catalogue and validated against it, with a live run in which every returned identifier resolved
  • Stop rules enforced to match the figures the configuration file had always specified
  • A sampled, human-labelled ground-truth set: 406 investigations exported as the raw baseline, ~150 sampled for labelling, the 62% inconclusive rate recorded as the before number, with its sampling bias recorded and a written before-and-after comparison protocol
  • A full end-to-end run captured as evidence rather than asserted: 181 seconds, a 2,388-line transcript of every model input and output, cache probes and per-call, per-stage and total token counts, kept so the result is auditable rather than remembered
  • Unit tests throughout, integration tests against a real cache instance proving the miss contract and real expiry, and integration coverage of the grounded mapping against a real catalogue and a real model call
What this doesn't solve
  • Verdict quality on genuinely ambiguous alerts is a human judgement. The evaluation harness makes changes comparable; it does not make correctness automatic.
  • An agent permitted to answer “inconclusive” will produce refusals that look identical to failures in any aggregate metric. That is a property of allowing honesty, and the mitigation is labelled data rather than a better metric.
  • Grounding constrains the model to real techniques; it does not guarantee the right one. Confirmed steps were stable across runs, unconfirmed follow-on steps varied, and that variation is inherent to reasoning at a non-zero temperature.
  • The reference block costs real tokens on every mapping call. It is affordable because each vulnerability is mapped once and the result is stored permanently, which is a property of the caching rather than of the prompt.
  • Four of the candidate sources are commercially licensed, at list prices between roughly $5,000 and $12,000 a year each, so which sources exist in a given environment is a budget decision that changes the pipeline’s output. The free tiers the pipeline runs on today are rate-limited, which is a capacity ceiling rather than a correctness one.
  • The verdict distribution I measured came from largely synthetic alerts. It is a baseline for comparison, not a statement about performance on real traffic.
  • That end-to-end run surfaced a defect I have not fixed: only three of the four priority bands can ever fire, because an alert the agent calls a true positive at low severity maps to the lowest band rather than the second. The mapping is a table, so it is a small change, but it means the priority output has been quietly coarser than its own specification, and every band distribution measured before it is corrected is measuring three buckets, not four.

What I'd do differently

Build the ground truth first, not at the phase boundary. I stopped to build it because I noticed I was about to tune against a metric I could not interpret. That was luck arriving as instinct. The labelling harness cost a day and every judgement after it got cheaper, so it should have been the first task of the project rather than the last task before the third phase.

Never write a contract before its reader. Putting the rules in configuration first was right; deferring the loader created a document that looked authoritative, was cited in reviews, and was enforced nowhere. If the loader has to wait, the contract file should say in its own text that nothing reads it yet.

Ask what a model is reasoning from before assessing what it reasons to. The attribution output was well-formed, plausible and internally consistent, and the actual defect was one layer back: the authoritative data existed, was synced, and was never in the prompt. I now check what is in the context window before I evaluate the output.