Why a Building-Automation Standard Runs Our Legal Search
Court records are a graph, not a folder. Project Haystack and XETO already solved that shape — here is what we built on top of it, and what shipped this summer.
There is a moment in every document review where the tooling stops helping. You have found the motion. Now you want the response to it, the reply to that response, the exhibit the response attached, the order that resolved it, and the amended version filed three weeks later that quietly changed the argument.
Your search tool offers you a folder and a date range.
That gap — between how litigation is structured and how search tools think documents are structured — is the thing Sound Suite was built to close. This post is about the data model underneath it, why it comes from an unlikely place, and what shipped across the 1.1 through 1.4 releases.
The problem with treating filings as files
A court record is not a pile of documents that happen to share a directory. It is a graph with specific, typed edges:
- A response responds to a motion.
- A reply replies to a response.
- An order resolves a motion.
- An amended motion amends — and supersedes — the original.
- A person is the movant here and the respondent there, in the same case.
Filesystems can express exactly one relationship: containment. Everything above has to be encoded in filenames and remembered by you.
Vector search doesn't fix this either. Embeddings are superb at "find me passages about proportionality" and structurally blind to "find me the response to this motion." Semantically, a motion and the response opposing it look extremely similar — they discuss the same issues in the same vocabulary. That similarity is precisely what makes them hard to tell apart when what you need is the relationship between them, not the resemblance.
So we needed a model with typed relationships, open-ended tags, and a schema that could evolve without a migration every time a jurisdiction invented a filing type.
The unlikely answer: Project Haystack
Project Haystack is a data standard for building automation. Chillers, air handlers, sensor points, meters. It has been in production for over a decade in an industry with the same underlying problem we have: thousands of entities, inconsistent naming across vendors, relationships that matter more than hierarchy, and a schema nobody can finish specifying up front.
Strip away the domain and Haystack's model is:
- Entities with tags — open-set markers and values, not fixed columns.
- Refs — typed pointers from one entity to another.
- Containment idioms —
sitecontainsequipcontainspoint. - A filter language for querying tags directly.
That is a general shape. The legal domain fits it almost embarrassingly well:
| Haystack | Court records |
|---|---|
site |
Case — the outermost container |
equip |
Motion — contains things, and nests |
point |
Filing event — Received, Filed, Responded |
| Tags | motion, signed, opposed, filingDate |
| Refs | respondingTo, resolves, amends |
The equip mapping is the one that earns its keep. In Haystack, equipment nests — an air handler contains a fan which contains a motor. In litigation, a Motion to Compel contains its First Amended version, which contains its Second Amended version. Same containment idiom, no adaptation required.
XETO: the schema that admits it isn't finished
Tags alone are too loose. If motion is just a string, nothing stops a typo creating a parallel universe of moton records.
XETO is Haystack's type language, and it solves this without the rigidity of a relational schema. You declare what a valid record looks like — a Motion requires a caseRef, may carry a judgeRef, and so on — and validate against it at write time.
The important property: XETO is structural, not exhaustive. A jurisdiction with a filing type nobody anticipated gets a tag and a spec, not a schema migration and a redeploy. We validate at runtime through @haxall/haxall — the official Fantom-compiled-to-JavaScript package — using Namespace.fits(), with no offline compile step.
We kept Prisma and SQLite underneath. Tags live in a JSON column; Haystack filter syntax compiles to SQL. There was no JavaScript-native Haystack datastore worth switching an entire application for, and the hybrid gives us relational integrity where we want it and open-set tags where we need them.
People are a pool, not a column
One design decision worth spelling out, because it's where naïve models break.
The same attorney may be the movant on one motion and the respondent on another, in the same case. Modelling role as a field on a person record forces you to either duplicate the person or lose the distinction.
Instead: canonical Person records are immutable and carry only intrinsic tags — person, lawyer, judge. Contextual roles live on separate PersonRole records that bind a person to a scope with role tags layered on. Search by tag returns people; search by role returns roles, joined back to the person.
A case carries judgeRefs[], plaintiffRefs[], respondentRefs[]. A motion carries its own judgeRef — because the judge on one motion is not always the judge on the next, and a model that assumes otherwise will be wrong in exactly the cases that matter.
Making it visible: the Haystack Block View
A graph you cannot see is a graph you cannot trust. Shipped in v1.3.2, the Haystack Block View renders your corpus as connected blocks — and makes the relationships operable.
Selection cascades along real edges
Select a case, get everything in it. Select a motion, and you get the motion plus its responses, replies, amendments, attachments and the order resolving it. Deselect individually to carve out exceptions.
"This motion and everything that argues about it" becomes one click — and stays correct as the case grows. That selection then overrides the case dropdown and any saved preset for every subsequent search.
The part we got wrong first
Our initial version assumed the graph was already correct. It wasn't. Filings arrive without obvious parents all the time — a response whose caption doesn't name what it responds to, an order that references a motion by a shorthand nobody else uses.
A cascade over an incomplete graph is worse than no cascade, because it looks complete. You select a motion, get six records, and have no indication that a seventh exists and was silently omitted.
So the Block View has a second tab. The Editor surfaces unconnected filings as a worklist and lets you assign the missing references. The loop is: filter → notice the graph isn't ready → editor → connect → back to filter. That's why the diagram above shows an excluded Notice of Hearing. Making the gap visible was the actual feature.
v1.3.3 then spent a whole release making linking not fight back — the entire block became a drop target instead of a seven-pixel socket, empty slots stayed visible so you could see what a filing could connect to, and aimed drags stopped occasionally writing a different relationship than the one you intended.
Search chips: precision without losing the question
The other half of the problem. Most search boxes force a choice: write a precise filter, or ask a vague question. Not both.
Chips remove the choice by letting a hard filter sit inline with ordinary prose:
{{ filingType=="motion" }} proportionality objections
One sentence, two mechanisms: a structural filter, and a semantic question asked inside it.
Position is meaningful
Here's the part that surprises people. Each chip pairs with the text that follows it, and each pair becomes its own sub-search:
{{ filingType=="motion" }} proportionality objections {{ filingType=="order" }} what the court actually granted
That runs as two searches — one over motions asking about objections, one over orders asking what survived — merged at the end with results attributed to the chip that produced them. Not as one blended query.
Text before the first chip is treated differently: a framing segment applied as a soft boost across everything, nudging relevance without excluding anything.
A query with no chips behaves exactly as it always did. Nothing changes until you reach for one.
Getting here took a boring fix in v1.1.0: boolean operators were leaking out of chips into ordinary prose, so writing "motions and orders about discovery" parsed "and" as an operator. Constraining operators to chip interiors is what made the whole inline model trustworthy enough to build on.
Documents with structure, not just text
v1.3.0 was the largest release of the year, and it changed what a page is to the system.
Previously a PDF page was a wall of characters. Now block extraction identifies headings, paragraphs, tables, figures and page furniture — the headers and footers that should be ignored — and escalates to OCR only for the parts that need it. Tables come out structured rather than as scrambled columns.
Downstream, that structure reaches search: block type, heading path, speaker and table content inform ranking, and every result carries a breadcrumb showing where in the document it came from.
Two details we're particularly glad about:
Reporter's Record line numbers. Transcripts get per-line stamping, so a citation can point at the line a witness actually said something on — the level of precision the document itself is organised around.
AI readiness scoring. Per-page and per-chunk scores with blank-page classification. You can see which documents are genuinely searchable before relying on an answer drawn from them. A scanned exhibit that OCR'd badly is now visible as a gap rather than a silent hole in your coverage.
That last one reflects a principle running through all of this: a tool that hides its own failures is worse than one that admits them. The same instinct produced the honest-exhaustion reporting in v1.4.0's pattern scan, where "no matches" now means the corpus was actually searched to the end.
Connecting Claude Desktop
Sound Suite speaks HTTP. Claude Desktop speaks stdio MCP. A small bridge sits between them, translating each into the other.
The bridge is deliberately thin — a forwarder, not a layer of cleverness. It proxies exactly two methods (tools/list and tools/call) to the local API, and relays long-running research jobs as MCP progress notifications so an investigation reports what it's doing instead of going silent for minutes.
Everything stays on 127.0.0.1. Your documents are never uploaded anywhere.
v1.4.0 split that surface in two. Tools that run entirely on your hardware are separated from tools that spend API credit against a provider, and each appears as its own server. You can connect an assistant to your corpus without wondering what a given question will bill.
Alongside the bridge is a skill — a document teaching Claude how to use what it finds. An MCP client can see tool names and schemas but not the judgment around them: which call to make first, how to distinguish a genuine absence from a retrieval failure, which numbers are safe to quote.
The skill has one rule we'd recommend to anyone writing docs for an AI to consume: no number in it is a fact about the corpus. Document counts, coverage percentages and tool counts all move. The skill names the call that returns each live value instead of hardcoding it. A bare constant in that file is treated as a bug in the file — because a stale number in an AI's context doesn't produce an error, it produces a confident wrong answer.
Why this matters beyond our corpus
The general lesson isn't about law, and it isn't really about Haystack.
It's that domain structure is worth modelling explicitly, even when a general-purpose tool almost works. Vector search over a folder of PDFs gets you a demo. It does not get you a system that can answer "what did the other side say about proportionality, and what did the court do about it" — because that question is about relationships, and relationships have to exist somewhere before they can be queried.
Haystack was a shortcut to a model we would otherwise have designed badly over several years. The building-automation people had already worked out how to handle open-set tags with a validating schema overlay, typed refs, and containment that nests. We borrowed it nearly wholesale.
The parts we had to build ourselves were the ones genuinely specific to the domain: amendment as a reusable trait applying uniformly to motions, proposed orders, briefs and exhibit lists; the immutable person pool with roles layered on; and the honest admission, in the Editor tab, that the graph is only ever as good as the connections someone has actually made.
Sound Suite runs entirely on your own hardware. Documents are never uploaded. See the documentation for setup, the Haystack Block View guide for scope, or the changelog for the full release history.