The API promised industry keywords. Modern sites kept returning [].
This is a submission for DEV's Summer Bug Smash: Smash Stories powered by Sentry. The quietest kind of production bug The endpoint was healthy. Payment gating worked. The response matched its JSON schema. Nothing crashed, timed out, or lit up a red dashboard. It was still broken. I had launched a pay-per-call /enrich endpoint for AI agents. Give it a domain and it returns structured public company intelligence: identity, contact surfaces, technology signals, DNS and email infrastructure, social profiles, AI-search readiness-and company.keywords , advertised as industry keywords an agent could use for routing or qualification. For modern sites, that last field kept coming back as this: { "company": { "name": "...", "keywords": [] } } The API had returned a successful, paid, structurally valid answer that omitted one of the signals it promised. That made this bug more interesting than a 500 error. Every shallow health check passed. Only checking whether the output was useful revealed the failure. Root cause: a legacy assumption hiding behind a modern schema The original implementation populated the field from one HTML tag: const keywords = (meta.keywords || "") .split(",") .map((value) => clean(value)) .filter(Boolean) .slice(0, 15); That means the entire field depended on: Many contemporary sites do not publish that legacy tag. They express topics elsewhere: JSON-LD keywords , knowsAbout , applicationCategory , article tags, titles, and descriptions. My parser had confused one old representation with the underlying concept. The schema said βindustry keywordsβ; the code actually meant βthe value of this one optional meta tag.β The first tempting fix was the wrong one The easy patch would have been to split the page title and return the first few long words. That would make the array non-empty, but it would blur together two different kinds of evidence: - topics the publisher explicitly declared; and - terms the service derived from public copy. For an autonomous buyer, that distinction matters. A derived label should not masquerade as a company-authored claim. So the fix needed to improve coverage and preserve provenance. The repair: declared first, deterministic fallback second The corrected pipeline has three stages. 1. Collect declared signals It reads: - meta[name="keywords"] ; - news_keywords ; - article:tag ; - JSON-LD keywords ; - JSON-LD knowsAbout ; and - JSON-LD applicationCategory . The JSON-LD walk handles nested graphs, arrays, strings, and { "name": "..." } objects. 2. Normalize without laundering the evidence Declared values are cleaned, capped at 40 characters, deduplicated case-insensitively, and limited to 15 public response items. If fewer than six declared topics exist, the service deterministically derives a top-up from title and description text. It removes stopwords and numeric noise, ranks by frequency, and preserves first-appearance order as the tie-breaker. There is no model call and no random generation. The same public input produces the same result. 3. Label the provenance The response now states where its list came from: { "company": { "keywords": [ "stripe", "financial", "infrastructure", "grow", "revenue", "services", "platform", "types" ], "keywordsSource": "derived" } } The label is one of: - declared : the page provided a complete set; - mixed : declared topics plus a deterministic top-up; or - derived : no declared topics were available. That small field keeps the improvement honest. A consuming agent can accept derived signals for discovery, or require declared signals for a higher-stakes decision. Before and after BEFORE legacy meta keywords absent ββ company.keywords = [] ββ HTTP 200, schema valid, weak answer AFTER declared meta + JSON-LD topics ββ enough signals ββββββββββββββ> declared ββ sparse or absent ββ deterministic top-up ββ> mixed / derived all paths: case-insensitive dedupe + 15-item cap + provenance label The endpoint still uses only public page data. The payment rail, input contract, URL security guard, and all unrelated response fields are unchanged. Locking the semantic contract down The original repair shipped on June 24. While preparing this retrospective, I noticed the lesson had not been encoded in a focused test. That is exactly how a quiet semantic bug can return during a later refactor. I extracted the keyword pipeline behind a pure keywordSignals(...) seam and added four deterministic regression cases: - No legacy metadata still produces a non-empty derived set. - Meta and nested JSON-LD topics merge and deduplicate before a top-up. - Six declared topics remain purely declared ; marketing copy is not appended. - The public response cap remains 15 after normalization. The full current suite also preserves the gateway's referral safety tests: $ npm test tests 8 pass 8 fail 0 A live keyless CLI probe against stripe.com now returns eight keywords and keywordsSource: "derived" rather than the empty field that exposed the bug. Code and evidence - Original keyword fix commit 660018c - Regression hardening commit 7a17214 - Current enrichment implementation - Focused regression tests - Live x402 resource manifest What I learned A valid payload can still violate the product promise Status codes, JSON parsing, and schema validation prove transport and shape. They do not prove that the signal a customer is buying is present or useful. For data products, a test should assert semantic invariants: the advertised field is populated for representative modern inputs, its provenance is visible, and its bounds remain stable. Prefer concepts over historical representations βIndustry topicsβ can appear in several public formats. Binding the concept to one legacy tag made the implementation brittle even though the code looked reasonable in isolation. Derived data needs an honesty label Filling an empty field is not enough. The consumer should know whether a value was declared, inferred deterministically, or generated. Provenance turns a fallback from a hidden guess into a usable choice. The lasting fix was not merely making [] non-empty. It was making the endpoint say what it knows, how it knows it, and keeping that contract executable in tests. Top comments (0)
Comments
No comments yet. Start the discussion.