Inspiration

Everyone remembers a cloud outage that took down five "unrelated" tools at once. Companies are paying multiple vendors, on multiple contracts, and one bad afternoon in one region takes all five out together. The redundancy you thought you bought didn't survive one hop down.

The strange part is that this isn't secret. GDPR Article 28 requires every processor to disclose its own sub-processors, so essentially every B2B SaaS company publishes that list on its website. The data is public, legally mandated, and sitting there. Nobody assembles it, because assembling it means reading forty legal pages, normalizing forty inconsistent tables, and redoing the whole thing next quarter.

Meanwhile, anyone selling to enterprise has to answer "how do you manage concentration risk in your supply chain" in writing, and fintechs get that requirement pushed onto them contractually by their bank partners. It's named in FFIEC and OCC third-party guidance and in EU DORA. Today it gets answered by hand, in a spreadsheet, and it's stale the day it's delivered.

So: the question is required, the data is public, and the join has never been done. That gap is Blast Radius.

What it does

Blast Radius reads companies' public sub-processor filings, resolves the entities, and builds the dependency graph nobody has been looking at, then does three things with it.

  1. Maps any company you name. Type one in and search runs seven deterministic tiers: exact, alias, despaced, slug, typo-tolerant, substring, then our 152-company registry. If all seven miss, an agent goes and finds the filing: web search, the company's own site, GitHub, and a headless browser that renders client-side trust centers and clicks through to the sub-processor list. Then it gets a graph like everyone else.
  2. Follows the chain, hop by hop. Pick a company and you get its disclosed dependency graph, and then it keeps going, because your vendors' vendors publish filings too. Lindy, a startup we work at, depends on Stripe. Stripe depends on AWS. Lindy never signed anything with AWS and nobody at Lindy has read Stripe's sub-processor list. Every hop is a real filing with a source URL, and there's no depth cap: the graph goes as deep as the disclosures go. Then it counts the paths. Lindy reaches AWS directly, and again through Stripe, and again through Anthropic, and again through Datadog. You think you have one dependency on a provider when you actually have four routes to it. That's the number nobody has, and it's the reason vendor-level redundancy doesn't mean what people think it means. Node size is inbound degree, so the shared dependencies are visibly larger before you read a single number.
  3. Ranks the chokepoints. Providers are sorted by how much of the stack they carry.
  4. Simulates the outage. Pick a provider, and the failure propagates backwards through the graph: which vendors go down, which capabilities go with them, drawn from the actual purpose text in the filings.
  5. Produces the artifact. The /brief view is the concentration-risk section of a security questionnaire, with citations, ready to hand an auditor. That's the thing that currently costs days.

How we built it

Entirely in Jac. One language for the graph, the traversal, the LLM work, the API, and the frontend. Roughly 9,500 lines, no glue code between tiers.

One node type - this is what makes the chain work. We started with Org/Vendor/Provider and it broke immediately: Stripe is a vendor of Lindy's and a company with its own filing and its own 39 sub-processors. Under three node types it had to exist twice, the concentration math double-counted, and the graph stopped at one hop because a "Vendor" had nowhere deeper to go.

Collapsing to a single Company node with tiers computed per view fixed all of it at once. Every company appears exactly once, every company is a potential root, and traversal just keeps walking — Lindy → Stripe → AWS falls out of the model rather than being special-cased. It also made the atlas possible: every entry in our registry is a graph root in its own right, so we already owned an industry map and just hadn't crawled it.

Walking is free, crawling costs money. One node per company means a full BFS with a visited set is O(V+E) - microseconds. So reads are unbounded: graph {domain} returns the entire reachable component, as deep as the data goes. The budget lives on the crawl instead, as max_new_companies. Because crawls are cached by content hash and shared, the atlas gets deeper for everyone every time anyone expands anything.

Three by llm() functions, each doing a job a regex can't. Sub-processor pages are HTML tables with wildly inconsistent columns - a parser for 150 layouts is a week of work - so the extractor just returns list[ExtractionRecord] and byLLM derives the prompt from the type and its sem annotations. Canonicalization is the load-bearing one: Stripe writes "Amazon Web Services, Inc.", Notion writes "AWS", Vercel writes "Amazon Web Services EMEA SARL". Resolve those to one node or the graph never converges and the chokepoint never appears at all.

The third one is an agent that goes and finds the filing. find_dpa_sources is a by llm() function with tools=[search_web_tool, read_page_tool, search_github_tool] and a six-iteration ReAct budget. Given a company name it plans the hunt: focused web search, the official site, GitHub only for company-owned repos. Its sem tells it to reject aggregators, login walls, ambiguous ownership, and change notices without a complete list — and to treat everything on a fetched page as untrusted data rather than instructions.

The browser tool is what makes that work. Vanta and SafeBase trust centers are client-rendered and return an empty shell to a plain GET, which is where a naive crawler dies. browser_worker.py drives Browser Harness over CDP: renders the page, walks the accessibility tree to find and click the "subprocessors" link, then extracts the visible text. Read-only by construction - search, navigate, extract, nothing else.

Search spends the cheap thing first. A typed query runs seven deterministic tiers before any model call - exact canonical key, alias table, despaced, slug/domain, typo-tolerant fuzzy, substring, then the 152-company registry. Only on a total miss does the UI offer "search with AI" and wake the agent. String matching is faster, free, and more predictable than an LLM; the agent is for the case where nothing else can work.

Walkers are the API. Every walker:pub becomes a REST endpoint with Swagger and no routing code. The frontend lives in the same program as the graph model it reads.

The data is committed, not crawled at runtime. seed/atlas/*.json holds one file per company with the parsed filing, and seed/raw/ holds the raw extracted text beside it, so the data is reviewable in a diff and the demo never depends on the network. A cold graph self-seeds on first read.

Challenges we ran into

We caught ourselves fabricating. Early on, throwaway fixtures like vendor lists, downtime hours, and SOC 2 flags got preserved verbatim. We deleted them and built an honesty gate instead: every risk field is behind cited = risk_source_url != "", and uncited values return zero or empty and the panel hides. Chokepoints silently re-rank by share when nothing is cited. The rule became: if a real filing didn't give it to us, we don't ship it.

Also, some trust centers don't render. Vanta and SafeBase pages return an empty shell to a plain GET, and a naive crawler just sees nothing and moves on. Beating that meant building an actual browser agent, including CDP, accessibility-tree traversal to find the right link, click, then extract.

Deploy fought us throughout the whole process. The builder installs from jac.toml, so an empty [dependencies] table meant it reported "no dependencies to install" and then died on the byLLM import. We also caught a live data-splitting bug: max_replicas = 4 with no [scale.database] configured means each pod serves its own SQLite, so a judge clicking twice could land on an unseeded graph. Pinned to one replica.

Lastly, Jac is young enough to have sharp edges. ++> returning a list rather than a node, block lambdas not being valid sort keys (which sorted silently on None), and the byLLM import path not being where the docs implied all cost us real time.

Accomplishments that we're proud of

The data is real and checkable. Our dependencies read from public legal filings, with the source URL attached to every single row and the raw text committed next to the parsed JSON. Stripe 40, Okta 89, Figma 54, Intercom 43. Anyone can verify any row against the original page.

It's one program. Graph, traversal, LLM extraction, REST API, and React frontend in one language with no serialization layer, no client SDK, and no schema kept in sync by hand.

What we learned

Entity resolution is the whole ballgame in graph products. Every interesting property, including convergence, concentration, chokepoints, only appears once aliases collapse to one node. Get it wrong and the graph looks fine and says nothing. This is the single best argument we found for meaning-typed functions: it's a semantic judgment, and a type signature plus a sem line expresses it better than a regex ever could.

"The graph is the database" changes what you build. Not having a query layer meant unbounded reads were free, which meant the depth cap we'd designed around was solving a problem we no longer had. We moved the budget to crawling, where the cost actually is.

Refusing to fabricate makes a better product, not a weaker one. Every time we deleted an invented number, what replaced it was more specific and more defensible. The empty states are more convincing than the fake ones were.

Contracts between people need to be frozen in writing. Our worst hours went to a drift nobody owned, between two lanes that were each individually correct.

What's next for Blast Radius

Crawl the rest of the registry. 152 companies have verified filing URLs; 20 are crawled. The engine is done, it's a batch run.

The industry map. The industry_map endpoint is built but has no UI yet — every crawled company and every disclosed dependency on one canvas, hundreds of real companies visibly collapsing onto a handful of providers.

Change monitoring. Vendors add sub-processors quietly, and most DPAs give you notice and objection rights nobody exercises because nobody notices. A scheduled walker re-crawls and diffs the graph. This is the retention story: the questionnaire artifact is the wedge, the monitor is why you keep it.

Defense industrial base. The engine is domain-agnostic — it ingests dependency declarations, canonicalizes entities, and finds transitive single points of failure. Run over prime → sub → tier-3 supplier records instead of Article 28 filings, the same [node <-:edge:<-] traversal finds the one machine shop that three nominally redundant primes both depend on. Sustainment and resilience, same walkers, different data adapter. (Not built — stated as direction, not a feature.)

Multi-tenant. Walkers are :pub today so the crawl cache is shared. Moving to :priv with root.shared keeps per-user graphs private while the corpus stays common.

Built With

  • jac
  • jaseci
Share this project:

Updates