How it was built
A point-in-time legal database, an MCP server, a grounded assistant and a nightly pipeline, built solo. This page is the part usually left out: the decisions, the things that broke, and how correctness is actually proven rather than asserted.
The problem
Ask any legal site what a law says and you get today's text. Almost every question that matters is about a date: what applied when the contract was signed, when the fine was issued, when the breach happened. Official publishers do hold dated consolidated editions , but scattered across formats (Akoma Ntoso XML, Formex XML, legacy XHTML), with no article-level access and no machine interface. Lex turns that into one queryable, verifiable history.
The shape of it
official publishers nightly, one scheduled job
Legilux · EUR-Lex/Cellar ──────────────────────────────────
│ ingest → anomaly gate → derive →
│ verbatim bytes (>5% drop = determinism
▼ + sha256 commit nothing) guard
EVIDENCE REPOS ──────────────────────────────────────────►
append-only, never rewritten │
│ ▼
│ deterministic extraction DERIVED REPO (per article)
▼ (immutable profiles) │
SIGNED SQLITE INDEX (ECDSA P-256) ◄─────┘
│
├──► MCP server (public, no key)
└──► this site (Container Apps, scale-to-zero)Azure: Container Apps behind a managed certificate, Container Registry, Azure OpenAI (gpt-5-mini) for the assistant, Application Insights via OpenTelemetry, Azure DNS. The web app runs at 0.25 vCPU and scales to zero, idle cost is essentially the registry and the DNS zone.
Decisions worth defending
| decision | why, and what it cost |
|---|---|
| Store publisher bytes verbatim | The evidence layer is never "cleaned". A hash over cleaned text proves nothing about what the state published. Cost: two layers to maintain instead of one. |
| Extraction profiles are immutable | Once akn-lu/1 is published, its output for a given input can never change, a frozen-fingerprint test fails the build if it does. Improvements ship as a new profile. Cost: no silent fixes, ever. |
| Refusals are part of the API | Seven typed refusal codes instead of empty results. A caller can distinguish "no such law", "no version that day" and "text withheld". Cost: more surface to test. |
| A small model, tightly fenced | The assistant only picks lookups and quotes results, no agent framework, no chain of reasoning over law. Cheap, auditable, and wrong answers are visible against the evidence shown beside them. |
| Nightly commits nothing when unsure | A >5% drop in works, or a re-derivation that is not byte-identical, aborts the run. A partial upstream response must never rewrite history. |
Forty-eight numbered decisions like these are recorded in the specification, each with its rationale, so "why did you do it that way" has a written answer rather than a recollection.
The machinery that keeps it fresh
Law changes while you sleep, so the corpus is rebuilt while I do. One scheduled job at 02:17 UTC drives the whole fleet, no manual step exists, and there is deliberately only one credential and one cron for all publishers.
| stage | what it does, and how it refuses to do damage |
|---|---|
| 1. Ingest | Asks each publisher what versions exist, downloads any it has not seen, and writes them verbatim. Existing files are never reopened for writing , the evidence layer is append-only by construction. |
| 2. Anomaly gate | If the work count drops more than 5%, the run assumes the upstream response was partial, discards everything and commits nothing. A bad night leaves yesterday's good data in place. |
| 3. Derive | Regenerates the per-article layer from the verbatim files. |
| 4. Determinism guard | If derived output changed while no source file did, that means the extractor is non-deterministic, the run fails loudly and commits nothing, because a silent extraction drift would corrupt history. |
| 5. Index & publish | Rebuilds the search index, signs it (ECDSA P-256), publishes it as a release asset, regenerates the JSONL and Parquet datasets. |
| 6. Report | Writes a three-state outcome per publisher (ran_committed / ran_no_change / failed_*) and opens an issue on failure. |
The result travels with the data: every index carries a signed stamp recording when it was built and from which corpus commit, and every tool response returns it. This is that stamp, read live from the running indexes:
| publisher | index built | from corpus commit | signature |
|---|---|---|---|
| eu-eurlex | 2026-08-04T13:17:09Z | 6974532 | valid |
| lu-legilux | 2026-08-04T13:15:37Z | 2f51cf1 | valid |
Nothing here is typed by hand, if the pipeline stopped, this table would say so. The same values come back from the coverage tool in every API response.
What broke, and what it taught
A silently dead search, caught in production. The nightly job built the search index without the per-article layer. Nothing errored: the index was valid, signed, and published , it just had zero provisions in it, so search returned nothing. The automated eval suite caught it by asking a question a user would ask and noticing the assistant could no longer find a Luxembourg code.
Fix: the index step now runs after derivation and takes the article layer as a required input, so the failure cannot recur. Lesson: a green build is not a working system , the only tests that would have caught this are the ones that exercise it end to end, the way someone actually uses it.
A parser that quietly duplicated text. Adding Formex XML support introduced doubled paragraphs where an article had introductory text followed by a list. It looked plausible on screen. It was caught by re-reading real output rather than trusting a passing test, fixed the same day, and pinned with a fingerprint test so the profile can never drift again.
How correctness is proven, not claimed
| mechanism | what it guarantees |
|---|---|
| 34 unit tests | parsers, temporal logic, index schema, signing |
| Frozen profile fingerprints | a published extraction can never change output |
| Determinism guard in CI | re-derivation is byte-identical or the run commits nothing |
| 10 end-to-end AI evals | the assistant picks the right tools and never cites a source it was not given |
| LLM-judged groundedness | answers scored against the evidence actually returned |
| ECDSA-signed indexes | anyone can verify a build was not altered, recipe |
Scale
1,409 laws 4,705 dated versions 333,000+ articles indexed ~7,200 lines of C# across 10 projects 3 XML/HTML dialects parsed nightly, unattended
What I would do differently
Version the index schema migration path from day one, schema v2 required a full rebuild rather than a migration. Put the end-to-end evals in the nightly pipeline, not only in my hands; they caught the worst bug of the project and should be a gate, not a habit. And treat the derived layer's release assets as part of the deploy, not a follow-up step, the two times something shipped stale, that was why.
Who built this
Lex is built and run by Soufien Hajji, a senior .NET, Azure and AI engineer in Luxembourg. It is a personal project, unaffiliated with any publisher or public body, built the way I build professionally: the pipeline is deterministic, the claims are testable, and the parts that cannot be verified say so rather than guessing.
Why it is built this way → · Use it from your own code → · The data model → · Verify a build yourself → · The dataset →