Build log · Part 2 of 6

Pipeline: listen, triage, enrich, resolve

Source inventory, stage ordering, queue claims, per-stage cost and latency

Four stages ran between a source poll and a graph row.

 sources: RSS, vendor APIs, search APIs

   LISTEN     continuous · systemd service · dedupe · store raw

   TRIAGE     batched · cheapest model · relevance classification

   ENRICH     batched · mid-tier model · structured extraction

   RESOLVE    deterministic-first entity resolution

   CORPUS     graph write · provenance stamped per edge

Stage ordering

Each stage rejects work before a more expensive stage sees it. Triage ran 8,584 calls for $106.71 on the cheapest available model and rejected 62.1% of items at $0.0124 per call, upstream of an extractor costing roughly four times more per item. The same corpus assembled with the stages in the reverse order carries the enrichment cost on every rejected item as well as every accepted one.

Triage was tuned toward rejection. A false reject is a signal recoverable on a later pass. A false accept is enrichment spend on noise, and is not recoverable.

The same ordering rule governs the stages below triage, stated in the enrichment spike as: never spend an LLM call on a fact the deterministic path already produced. Applied to attack events, the deterministic systems-inheritance pass covered 2,303 of 4,362 events (53%) at zero model cost, leaving approximately 2,060 events in the systems gap and 4,350 in the impacts gap. The extraction handler’s candidate query selected only rows in one of those gaps.

Listen

The listen stage ran as a persistent systemd user service polling feeds and APIs on a fixed interval, deduplicating on source URL and content hash, and writing raw payloads before any interpretation.

The source registry

feed_sources held 309 rows at teardown, 26 of them still enabled. The 2026-05-31 feed-health audit recorded 159 enabled feeds at that point in the run, of which 157 had produced a record in the preceding 30 days and one — the Stimson Center RSS feed — was genuinely broken.

source_typerowsenabled at teardownpoll interval range
RSS21033,600 s – 604,800 s
GROK_SEARCH72043,200 s
STRUCTURED_REPORT111121,600 s – 604,800 s
X_SEARCH10828,800 s
FEDERAL_REGISTER1186,400 s
PR_WIRE113,600 s
OPENALEX1086,400 s
SEC_EDGAR1021,600 s
USA_SPENDING1186,400 s
dvids_api1121,600 s

Cadence clustered at four values: 167 rows at hourly, 72 at twelve-hourly, 36 at six-hourly, 13 daily. The remainder sat at two-hourly, eight-hourly, weekly, or longer. Cadence was a per-row integer column, not a code constant, so retuning a feed was a feed_sources update rather than a deploy.

Each listener implemented one poll method returning a list of raw records. base.py declares source_url as the dedup key. Eleven listener modules existed: rss, grok_search, xai_search, sam_gov, sec_edgar, federal_register, usaspending, openalex, dvids, youtube, and structured_report.

Source classes

The collection decision replaced a free-text category column — 25 ad-hoc labels, 127 enabled rows carrying an empty string — with a closed vocabulary of seven classes. The class is what relevance-gating, prioritisation, and the provenance source_category field all key off.

classcollectsrowsenabled at teardown
incident-threatattacks, strikes, intrusions, sightings, downings625
policy-analysisthink-tank, doctrine, regulatory, arms-control framing410
cuas-vendorcounter-drone and drone OEM commercial activity401
social-osintcurated X/Telegram-style OSINT accounts294
adversary-supply-chainsanctions, seizures, component tracing, export prosecutions95
procurementgovernment solicitations and awards86
researchacademic and technical literature50

The migration did not complete. 102 rows still carried an empty or null category at teardown and a further 13 carried pre-vocabulary labels (allied_mod, defense-security, robotics-industry, conflict, and others). All but five of those 115 rows were disabled, so the residue affected the registry rather than the running collection.

The audit that produced this vocabulary measured yield per source class against the drone product: RSS at 26% drone-relevant over 10,687 records in 30 days, SAM.gov at 3% over 1,036 records, and 26 inspection feeds at approximately 1%.

Source trust tiers

Class describes what a source collects. A separate six-value ladder, rp_source_tiers, describes how far an assertion from it can be trusted, and carries a numeric weight for downstream confidence:

tierweightdefinitionexamples
primary1.00Direct source: company filing, government notice, primary court document, official press release from named partySAM.gov, company press release, SEC filing
tier10.90Established journalism with editorial standards, named byline, fact-checkedReuters, AP, WSJ, FT, Nikkei, Defense News
tier20.75Trade publications and analyst firms with domain credibilityC4ISRNET, Breaking Defense, The Drive, Janes, RAND
tier30.60Blogs, specialist newsletters with named authorship and track recordThe Warzone, Naval News, War on the Rocks
tier40.35Aggregators, anonymous sources, OSINT without corroborationTelegram channels, unattributed OSINT
unverified0.10Anecdotal, social media, single-source without verificationX/Twitter posts, forum threads

The weights are ordinal, not calibrated — the ratio between tier1 at 0.90 and tier4 at 0.35 was set by judgment, not measured against outcomes. The table’s function is to make the judgment explicit and queryable rather than implicit in whichever code path last touched a record.

Deduplication

Three keys operated at different points.

source_url is the intake gate. Each poll cycle loads the full set of URLs already present in listening and drops any raw record whose source_url is a member:

existing_urls = client.get_existing_source_urls()
new_records = [
    r for r in raw_records if r.get("source_url") and r["source_url"] not in existing_urls
]

canonical_id — the source’s own stable identifier (RSS <guid>, DOI, SAM notice id, OFAC action id) — is captured into provenance to make dedup recoverable when a URL drifts.

content_sha256, a hash of title plus content as ingested, detects silent edits and retractions against a record already held, and is what the re-verification loop compares on a later pass.

Signal creation applies a fourth check: check_duplicate_signal(source_url) runs before any signal row is written, so a triage pass over a re-ingested item produces no second signal.

The keyword pre-filter

engine/listen/filters.py compiles a keyword list into one case-insensitive regex and applies it to title plus content after dedup and before extraction. Single words get word boundaries; multi-word phrases match as written.

if " " in kw or "-" in kw:
    parts.append(escaped)
else:
    parts.append(rf"\b{escaped}\b")

The gate is per-feed, read from feed_sources.config.filter_keywords, with a 40-term default list applied to PR_WIRE. Gate policy differed by class: a hard drone gate on broad RSS and policy-analysis; a narrowed source query plus keyword gate on procurement; no gate on social-osint and incident-threat, where account curation already produced 81% drone relevance and an over-eager gate would suppress breaking incidents; no gate on research or adversary-supply-chain, the first because needs_extraction = False means there is no model cost to protect, the second because a component shipment may never use the word “drone”.

The gate sits before extraction because that is where the cost is. Extraction runs per record during listen, upstream of triage, so a triage-level filter would let non-drone records burn the extraction budget first.

Provenance at capture

Provenance is born at collection. Carried through, never reconstructed at publish time.

Every listener emits a uniform object on raw["metadata"], persisted into listening.metadata with no schema change:

{
  "provenance": {
    "feed_source_id":  "<uuid>",              // FK to feed_sources.id
    "feed_name":       "Defense One",
    "source_category": "policy-analysis",
    "retrieved_at":    "2026-05-30T14:22:01Z", // when we pulled it
    "published_at":    "2026-05-29",           // the source's own date
    "canonical_id":    "<rss-guid | doi | sam-notice-id | ofac-id>",
    "canonical_url":   "https://...",
    "content_sha256":  "<hex>",
    "author":          "Jane Doe | null",
    "publisher":       "Defense One | null",
    "redirect_chain":  ["http://old", "https://new"],
    "retrieval_tool":  "rss_listener@<commit>"
  }
}

Before this object existed, the producing feed was unrecoverable from a listening row: _feed_name was a logging string, and downstream citations were reconstructed from a flat source_url — inferred rather than captured. The spike verified the gap by count: 0 of 10,687 RSS rows, 0 of 1,624 GROK, 0 of 1,036 SAM.gov, and 0 of 19 SEC rows carried non-empty metadata in the preceding 30 days. Only OpenAlex (8,391) and DVIDS (93) did.

After the change, coverage is complete: of 11,740 listening rows captured after 2026-06-01, 11,740 carry a provenance key.

Storing the raw payload is what makes provenance reconstructable. Sources mutate and disappear; the captured bytes do not.

Volume

The listen worker collected 55,406 signals over the run. The listening table — raw captures before triage — holds 74,368 rows:

source_typerows
RSS40,027
OPENALEX11,826
GROK_SEARCH10,994
X_SEARCH5,345
SAM_GOV3,749
dvids_api1,620
MANUAL479
STRUCTURED_REPORT193
FEDERAL_REGISTER85
SEC_EDGAR31
USA_SPENDING19

Per-record extraction during listen — the listen_extract agent — ran 2,333 calls for $3.63 total, one error. It is the cheapest paid stage in the pipeline by two orders of magnitude.

Collection ran continuously while processing ran in batches. Decoupling them means a provider outage or a rate limit stalls the queue rather than losing data.

Triage

Triage read PENDING rows from listening, in sub-batches of ten, on a 300-second recurring cadence. One Haiku call per batch, temperature=0.1, max_tokens=4096, with the full list of known company names (capped at 500) injected for entity matching.

The prompt is three ordered gates, not a single classification request. Steps run in sequence and an item that fails an early step is never classified:

Step 1 — scope check. Does the article specifically mention or focus on robotics, drones/UAS/UAV, autonomous systems, unmanned vehicles, counter-UAS, AI-enabled weapons/defense autonomy, or industrial automation in a security, defense, or infrastructure context? Five explicit reject classes follow, each phrased as a rule rather than a preference:

- General defense policy, strategy, or geopolitical commentary
  WITHOUT specific robotics/autonomous systems content → NOT relevant
- Historical military analysis, opinion/editorial, political commentary → NOT relevant
- General cybersecurity, IT, or software news without autonomy angle → NOT relevant
- Consumer electronics, entertainment, pure business news → NOT relevant
- Broad market reports with no specific company action → NOT relevant

Step 2 — event suppression. Trade-show attendance, exhibition announcements, and market-size projections are rejected unless the item carries a product launch, a contract or partnership, or a deployment.

Step 3 — classify. Only items surviving both gates receive a signal_type from a fixed twelve-value enum, a significance of HIGH/MEDIUM/LOW with HIGH bound to a stated threshold (major contract, deployment, or funding above $10M), a one-sentence summary, and a company list matched against the injected names.

Rejected items must carry a skip_reason. The response schema is fixed:

{"items": [{"index": 1, "relevant": true, "signal_type": "CONTRACT_AWARD",
            "significance": "HIGH", "summary": "...", "companies": ["..."],
            "skip_reason": null}]}

Measured outcome across the run: 74,368 listening rows resolved to 46,210 IGNORED and 28,158 PROCESSED — a 62.1% reject rate. The 8,584 triage calls carried 63,591 items, a mean batch of 7.4 against a cap of 10, because the pending queue was usually shorter than a full batch at the 300-second cadence.

Accepted items produced 55,406 signals, distributed as:

signal_typesignalssignificancesignals
PRODUCT_LAUNCH13,530MEDIUM30,590
CONFLICT_USE12,022HIGH17,935
DEPLOYMENT8,366LOW6,881
PARTNERSHIP4,886
POLICY_CHANGE4,600
REGULATORY3,440
FUNDING2,257
CONTRACT_AWARD1,994
LEADERSHIP_CHANGE1,508
EARNINGS1,360
RFP724
ACQUISITION683
BANKRUPTCY10

A further 26 signal rows carry no signal_type.

Triage carried the highest error rate of any pipeline stage: 1,084 of 8,584 calls returned status='error', 12.63%, against 0.24% for attack_event_enrich, 0.11% for classify, 0.04% for listen_extract, and 0.00% for resolution_fallback. The dominant cause was provider-side, not malformed output: across the whole trace log 2,185 calls returned 401 Unauthorized from the router, 265 returned 403 Forbidden, and 246 more carried an explicit daily key-limit message. Credential and quota failures land hardest on the stages that run most often against the shortest cadence, which is why the 300-second loop carries 53× the error rate of the stage with the highest call volume in the project.

A failed batch logged the error and continued to the next batch; the ten listening rows kept triage_status='PENDING' and were picked up on a later tick.

Enrich

Enrichment read gap-gated candidates and asked only for the facts a given row was missing. Output is raw mentions, not resolved entities.

Three mechanisms handled partial extraction and schema violations, all deterministic and all applied after the model returned.

Confidence floor. Every extracted system, weapon, and impact carries a self-reported confidence, clamped to [0,1]. MIN_EXTRACTION_CONFIDENCE = 0.55; anything below is dropped and counted in mentions_skipped rather than written.

Controlled-vocabulary rejection. impact_type must be one of six values (deaths, injuries, damage, disruption, financial, unknown) and weapon category one of a fixed set. A value outside the vocabulary is discarded — the row is not written with a coerced or nearest-match value.

Shape rejection. A response that parses as JSON but is not a dict is logged, the event is stamped as attempted, and the batch continues:

attack_event_enrich: event_id=%s returned non-dict JSON (%s); stamping and skipping

Partial extraction is therefore normal rather than exceptional: an event can contribute two impacts and zero systems, and it still exits the candidate set, because the exit condition is “attempted”, not “yielded rows”.

The JSON retry path

call_llm_json is the single wrapper for structured output. It takes max_json_retries: int = 2 and re-prompts on a parse failure with a fixed appended instruction:

prompt += (
    "\n\nYour previous response was not valid JSON. "
    "Return ONLY valid JSON. No markdown, no trailing text, no comments."
)

Cost accumulates across attempts and the trace records the total, not the last attempt. The status column distinguishes three outcomes:

statusmeaningrows
okparsed on the first attempt82,281
successlegacy value, pre-dating the ok default211
errorall attempts failed, or the call itself failed3,105
json_retryparsed on attempt 2 or 3 after a first-attempt parse failure16

The 16 json_retry rows are the calls the retry recovered. They spread across nine agents and four models, with no clustering by either:

agentmodelrowsfirstlast
conflict_extractoranthropic/claude-sonnet-4-642026-04-172026-06-29
editorial_reviewanthropic/claude-sonnet-4-632026-05-052026-05-30
content_remediationanthropic/claude-haiku-4.522026-05-082026-05-28
seo_rewriteanthropic/claude-haiku-4-522026-04-172026-04-17
attack_event_enrichanthropic/claude-haiku-4.512026-07-052026-07-05
signal_scananthropic/claude-haiku-4.512026-05-082026-05-08
discoveryanthropic/claude-haiku-4.512026-06-062026-06-06
freshness_checkeranthropic/claude-haiku-4.512026-05-122026-05-12
chief-engineeranthropic/claude-haiku-4.512026-07-162026-07-16

Retries that exhausted all three attempts are recorded as status='error' with an error string prefixed json_parse_failed. There are 372 of those, and unlike the recovered set they do cluster:

agentexhausted retries
signal_enricher150
content_remediation110
qa_remediation108
conflict_extractor2
cluster_conflict1
rp_models_smoke1

368 of the 372 came from three agents, all of them long-output stages. The error strings carry the response length, and they concentrate in a narrow band — repeated failures at 6,796 to 7,150 characters, with a second cluster near 9,000. signal_enricher averaged 3,031 output tokens per successful call, the highest of any stage. The failures are truncation at the output ceiling, which a re-prompt for “valid JSON only” cannot fix: the model was not producing malformed JSON, it was producing JSON that ran out of room. Two retries at full prompt cost were spent on each one before the call was abandoned.

A recovered retry is 0.02% of calls. An exhausted one is 0.43%. The retry loop paid for itself only in the sense that it was cheap; it did not address the failure that actually occurred.

Resolve

The deterministic pass owns exact alias matches. resolution_fallback operates only on the misses, across four entity kinds — system, actor, manufacturer, weapon_system — with a separate and higher confidence floor than extraction: MIN_CONFIDENCE = 0.72. Class and type values are constrained to closed sets (fourteen system classes, three system roles, seven actor types), and the resolver returns one of three actions per mention: bind to an existing entity, propose a new one, or skip.

Each resolved edge carries resolution_method and resolution_confidence, and extraction-sourced edges are tagged resolution_method='llm_extraction' so a later audit can separate a mention produced by a model from a mention read off a source. The governing tenet:

Entity resolution is one pattern. Capture raw; deterministic-first; LLM-fallback only on misses; every edge provenance-tagged.

Part 1 carries the normalization function, the alias tables and the closed vocabularies. Resolution ran 913 calls with zero errors, the only stage in the pipeline with a clean error record.

Queue and idempotency

Re-running a stage is safe because of four mechanisms, each of which was added after the failure it prevents was observed.

Claim-on-fetch. Selection and claim are one committed statement. The inner CTE locks the head-N candidates with FOR UPDATE SKIP LOCKED; the outer UPDATE stamps the attempt timestamp and returns the claimed rows:

WITH claimed AS (
    SELECT ae.id
    FROM attack_events ae
    ...
    FOR UPDATE OF ae SKIP LOCKED
)
UPDATE attack_events SET enrich_attempted_at = now()
FROM claimed c
...
RETURNING ...

Before this, every concurrent worker fetched the same head-N candidates and re-enriched each one roughly N times before any stamp landed.

Stamp-on-attempt. The gap query excludes rows where enrich_attempted_at IS NOT NULL, and every event a batch touches is stamped — extracted, empty, and skipped alike. Without it, an event whose extraction yielded no insertable rows stayed in the candidate set permanently and was re-fetched every batch. The observed cost of that loop before it was closed: 6,846 calls hitting 130 distinct events in 24 hours, approximately $22. At teardown, 6,996 of 7,218 attack events carried the stamp.

Advisory locks. Recurring tasks that must not overlap take a transaction-scoped advisory lock on a hashed task name and return a locked status rather than queueing behind the running instance:

cur.execute("SELECT pg_try_advisory_xact_lock(hashtext(%s)) AS locked", (LOCK_KEY,))
if not (cur.fetchone() or {}).get("locked"):
    return {"summary": "... skipped: already running", "status": "locked", "cost_usd": 0.0}

Stuck-row reaping. A recurring task scans three queue tables — jobs, generation_queue, ops_event_deliveries — for rows sitting in a claimed state (running, processing) past a staleness threshold, and either resets them to pending or fails them once an attempt ceiling is reached. Defaults: 45 minutes stale, 3 attempts, 50 rows per table per pass, on a 1,800-second cadence.

Idempotency is the property that makes “re-run it” a valid response to most failures, and it is expensive to retrofit. Three of the four mechanisms above were retrofits, each carrying a backlog reference and a measured cost for the incident that prompted it.

Loop cadence

Three loops, separated by frequency and cost profile.

loopcadencecost profilefunction
collectioncontinuousnear-zeropoll, dedupe, capture raw
enrichmentbatched, scheduleddominantdrain queue, extract, resolve
re-verificationslow, decay-drivenzero by designre-check facts whose freshness decayed

The re-verification loop performs deterministic checks only — HTTP status, content hash comparison against last capture — and escalates to a model only when a change is detected. Its per-check cost is zero by construction, which is what allows it to run on a 30-minute cadence against the whole published set.

Re-verify ran on a 30-minute cadence with a per-entity cooldown stamp to prevent re-checking the same record repeatedly.

Cadence was registry data, not code. eng_recurring_tasks held 40 rows, 29 enabled at teardown, each with a handler dotted path, an interval_seconds, and a worker_group. The intervals span five orders of magnitude:

taskhandlerinterval
health_monitorengine.tasks.health_monitor.run300 s
triageengine.agents.executor.run_triage300 s
signal_enricherengine.agents.executor.run_signal_enricher900 s
signal_embedderengine.tasks.embed_signals.run_embed_signals900 s
job_healthengine.tasks.stuck_row_reaper.run1,800 s
directory_reverifyengine.tasks.directory_reverify.run1,800 s
entity_resolution_groomengine.handlers.entity_resolution_groom.run3,600 s
attack_event_enrichengine.handlers.attack_event_enrich.run3,600 s
discoveryengine.agents.executor.run_discovery7,200 s
conflict_extractorengine.agents.executor.run_conflict_extractor7,200 s
directory_freshness_sloengine.tasks.directory_freshness_slo.run86,400 s
directory_count_rollupengine.tasks.directory_count_rollup.run604,800 s

Retuning a loop was an UPDATE. Disabling one was a boolean flip, which is also how the directory tasks shipped: registered disabled, enabled by the operator at go-live.

Freshness decay

Decay was implemented as a per-cut age threshold plus a fraction-of-population service level, both constants in engine/tasks/directory_ops.py, both environment-overridable.

FRESHNESS_TARGET_DAYS: Final[dict[str, int]] = {
    "drone-detection": 45,       # Fast: regulatory + grant churn
    "public-safety-robots": 30,  # Fast: grant cycles + active collection
    "security-robots": 60,       # Medium: steady vendor set
    "asset-inspection": 120,     # Slow: long asset lifecycles, stable vendors
}
MAX_STALE_FRACTION: Final[float] = float(os.environ.get("RP_DIRECTORY_MAX_STALE_FRACTION", 0.25))

A published company is stale when last_verified_at is null or older than its cut’s target. A cut breaches when more than 25% of its published members are stale. A cut with zero members never breaches.

The re-verification worker selects the stalest members oldest-first with nulls first and UUID as the tie-breaker, ten per cut per run, tightest SLO target first. The verification pass is deterministic and free: fetch the company website, require the extracted page to identify the company, require one provenance source to still identify it, then stamp last_verified_at.

One clause in the selection query carries most of the operational weight:

AND (
    c.provenance ->> 'reverify_last_attempt_at' IS NULL
    OR (c.provenance ->> 'reverify_last_attempt_at')::timestamptz
       < now() - interval '7 days'
)

A failed attempt stamps reverify_last_attempt_at, which removes the company from selection for seven days. Without it, permanently unverifiable companies — no website, dead website — sit at the head of a nulls-first queue and every run re-attempts the same failing set forever. The comment in the source records where that was learned: observed on the first live run (22/23 failures).

Measured across 242 runs between 2026-07-19 and 2026-07-24: 510 attempts, 118 verified, 392 failed, and 200 runs that attempted nothing at all. The cooldown that prevented the starvation loop also emptied the candidate pool: once the stalest cohort had failed once, it was excluded for a week, and the worker had nothing eligible to do on 83% of its ticks.

The dominant failure reason on the first live pass was missing_or_unsafe_website — the stalest members were companies without a website field at all. Freshness recovery was therefore gated on a field backfill rather than on verification capacity, which is not a conclusion the freshness metric itself could produce.

Self-healing and the monitoring chain

Recovery is three mechanisms: Restart=always on the worker units, idempotent stages so any re-run is safe, and a durable queue so a crash loses position rather than data. The requirement they serve:

The engine self-heals. A solo operator must never be the recovery mechanism.

Above them sits a monitor, a remediation worker and an escalation path. The measured behaviour of the three, over the six days before teardown, differs at each link.

The monitor fired, daily, correctly. directory_freshness_slo ran on its 86,400-second cadence and emitted a signal against code:tool_directory_freshness with outcome='error' on six consecutive days. The breach spanned three of four coverage areas throughout:

datedrone-detection (45 d)public-safety (30 d)security (60 d)asset-inspection (120 d)
07-1878.2%82.9%30.5%0.0%
07-1963.8%56.1%28.6%0.0%
07-2050.8%52.4%28.1%0.4%
07-2150.8%52.4%28.1%0.4%
07-2254.4%52.4%28.1%0.4%
07-2355.0%53.7%28.1%0.4%

Against the 25% ceiling, drone-detection (307 published members), public-safety-robots (82), and security-robots (531) breached on every pass. asset-inspection (260) never did.

The remediation worker reported success while doing nothing. Over the same window, directory_reverify emitted outcome='ok' on all 242 runs, including the 200 on which it attempted zero companies. Its health signal measured whether the process completed, not whether the metric moved. On the health board this reads as a green worker beside a red metric, with no edge between them.

Nothing escalated off the board. The alarm was visible to anything that queried the live health board and to nothing else. There was no push path. code:tool_directory_freshness still reads [failing] at teardown, last signal 2026-07-23 20:44 — the breach outlived the system that measured it.

The monitoring path had also shared a failure mode with the path it monitored. The HQ-side telemetry adapters translate engine tables into signals, and nothing scheduled them, so they went stale and the board reported false alarms — wrapper_llm failing and the dependency probes silent — from a two-day-old refresh rather than a real outage. Later, the cron entry that ran the refresh stopped executing entirely: the last heartbeat before the gap is 2026-06-09 20:44, the next is 2026-06-11 09:23, a 36.65-hour hole in the record of whether anything was working.

The fix is a watcher on the watcher, and it is the last step of the refresh script rather than the first:

# 3. Forcing function: heartbeat THIS job last, only after the refresh ran.
$RUN scripts/signals.py --self-heartbeat        >> "$LOG" 2>&1

The job heartbeats itself only after its own work completes, and it is registered with an expected interval of 7,200 seconds. If it stops running, code:telemetry_refresh goes silent and the board alarms on its own staleness. Cron was subsequently replaced with a systemd user timer carrying Persistent=true and RandomizedDelaySec=120. That fix broke silently for eight hours on introduction, because systemd user services get a minimal PATH without ~/.local/bin, where uv lives.

Measured latency by stage

Cost and latency rank the stages differently, and neither ranks them the way call volume does. attack_event_enrich ran 29,805 calls, 34.8% of every call in the project, at $0.0032 each; business_events ran 8,177 for $262.15, 26.3% of total spend on 9.6% of calls, because it reads a full document and emits structured output containing several linked entities. Part 5 carries spend by stage in full.

Percentiles below are over successful calls only, for agents with at least 150 of them.

stagecallsp50p95p99maxmean prompt tokmean output tok
attack_event_enrich29,7324,229 ms8,900 ms11,186 ms41,764 ms1,053441
business_events7,6291,530 ms4,780 ms14,359 ms39,981 ms
triage7,5005,770 ms9,001 ms16,274 ms307,793 ms11,747603
attack_event_sector7,1391,448 ms2,727 ms3,697 ms8,616 ms744101
signal_enricher5,1874,392 ms25,755 ms28,153 ms39,349 ms4,3183,031
listen_extract2,3322,348 ms3,864 ms5,069 ms64,979 ms696176
classify1,8172,815 ms4,040 ms5,659 ms306,374 ms6,178188
editorial_review1,54219,158 ms44,789 ms54,991 ms593,905 ms
conflict_extractor1,21135,827 ms45,517 ms51,832 ms153,976 ms4,2903,213
x_search_listener1,16017,667 ms33,886 ms46,218 ms64,914 ms8,3362,523
resolution_fallback9132,479 ms9,028 ms14,376 ms29,662 ms13,046196
content_remediation58937,255 ms79,305 ms106,889 ms516,071 ms

The p95/p50 ratio separates two shapes. Stages with a bounded output — attack_event_sector at 1.9×, attack_event_enrich at 2.1×, triage at 1.6× — are predictable enough to schedule against. Stages with an unbounded output — signal_enricher at 5.9× — are not: the median call is fast and the tail is six times longer, because output length is a function of how much the source document contained. signal_enricher is also the stage with the most exhausted JSON retries. Long tail and truncation failure are the same underlying property measured two ways.

Maxima are dominated by the retry loop rather than by any single call. A triage call recorded at 307,793 ms and a classify call at 306,374 ms are three-attempt sequences: call_llm_json starts its clock before the first attempt and records total elapsed on the final one.

Per-model latency, over successful calls:

modelcallsp50p95
anthropic/claude-haiku-4.562,3443,779 ms11,466 ms
anthropic/claude-sonnet-4-614,2112,693 ms41,073 ms
anthropic/claude-haiku-4-52,2994,514 ms6,168 ms
text-embedding-3-small1,2601,131 ms1,688 ms
grok-4.31,16017,667 ms33,886 ms
anthropic/claude-opus-4.661228,505 ms36,181 ms
anthropic/claude-sonnet-4.5282110,879 ms139,431 ms
anthropic/claude-opus-417772,796 ms147,876 ms

Sonnet’s median is faster than Haiku’s while its p95 is 3.6× Haiku’s. Model choice did not determine latency; the work assigned to each model did. The tiers with the slowest medians — Opus, and Sonnet 4.5 — were routed only to design and review work, where a two-minute call is not on any critical path.

Throughput

Throughput is measured over active hours, meaning clock hours in which a given stage made at least one call. Idle hours are excluded; a stage on a 300-second cadence with an empty queue does no work and should not be averaged in.

stageactive hourscallsmean calls/active hourmedianpeak hour
attack_event_enrich72629,80541.155,126
triage2,1408,5844.0312
business_events6238,17713.11224
signal_enricher1,9415,4872.835
listen_extract5462,3334.3220

In item terms rather than call terms, triage handled a median of 16 items per active hour, a mean of 29.7, and a peak of 120 — against 2,140 active hours and 63,591 items total.

The gap between attack_event_enrich’s median of 5 calls per hour and its peak of 5,126 is the difference between the recurring mode and the backfill mode. The recurring path used RECURRING_BATCH_SIZE = 5; the backfill path used BATCH_SIZE = 50 and ran until the gap closed. The same handler produced both numbers.

The bottleneck was never model throughput. Summing latency across every non-error call gives the total time each stage spent waiting on a model:

stagecallstotal model-wait
attack_event_enrich29,73339.3 h
conflict_extractor1,21512.4 h
triage7,50011.9 h
signal_enricher5,18710.6 h
editorial_review1,5459.9 h
x_search_listener1,1606.2 h
business_events7,6294.4 h

The trace log spans 2026-04-09 to 2026-07-24, 106 days. The seven stages above account for 94.7 hours of model wait across that window. The constraint was cadence and candidate supply, not inference speed: signal_enricher was active in 1,941 hours and made 2.8 calls in each, because a 900-second loop with a small eligible set has nothing to do most of the time. business_events inverts the relationship between spend and time entirely — 26.3% of project spend against 4.4 hours of model wait, the cheapest stage per unit of time and the most expensive per unit of work.

Model tiering

The routing rule:

Agent dispatch rubric. Route work by class: strategic agents for judgment, Sonnet/Haiku tiers for execution and bulk, Codex via dispatch.py for self-contained implementation.

Applied across the run: Haiku ran 4.4× the call volume of Sonnet at lower total cost; Opus averaged 17× Haiku’s per-call cost across 820 calls, all of them schema design, architectural decisions, or review passes. Aggregate token consumption was 221.7M prompt tokens and 28.7M output tokens, at a mean latency for successful calls of 6,816 ms. Part 5 carries spend by model.

Two Haiku identifiers coexist in the trace log — anthropic/claude-haiku-4.5 and anthropic/claude-haiku-4-5 — differing only in a separator. They are the same tier reached through two spellings, split across 64,437 and 2,301 calls. Model identifiers were configuration strings rather than validated enum values, so a typo routed successfully and cost-by-model reporting silently split a row in two.

Spend ceilings and what they measured

The spend cap is a daily envelope — DAILY_ENVELOPE_USD = 2.70, against a stated ~$80/month steady-state pace — read from the trace log at the top of each task and checked before committing to paid work. The lever the policy names is cadence, not quality. It was added in June, after a $528 month.

What the envelope reads is spend. Stage yield — the fraction of a stage’s output that survived into the corpus and was subsequently read — has no column anywhere in the trace log. attack_event_enrich produced 34.8% of all calls into a layer with no downstream consumer. Every row it produced was dropped at teardown. The trace log recorded its cost exactly and could not represent its value. directory_reverify emitted a success signal on every run including the 200 that attempted nothing, because the signal recorded that the handler returned, not that the metric it exists to move had moved. Both instruments measure the act rather than the effect.


Next: Part 3 — Harness.