Skip to main content

Case study · regnotify.com · Live

RegNotify

Aviation regulatory intelligence for operators under EASA. Every safety publication ingested on a six-hour cycle, read by a model into what changed, why it matters and what to do, matched against a registered fleet, and tracked to completion, with a grounded assistant over the corpus.

Product regnotify.com Built and run by starmo Status Live, built February to August 2026
5,070regulations 2,783aircraft types As shown on the live site on 4 September 2026. Every item was fetched from EASA, summarized by the model, and embedded for retrieval.

Two surfaces, one pipeline. A public catalog where every publication is a page with a model-written digest, a scoped assistant and a weekly email; and a signed-in workspace where an operator registers a fleet and every publication is judged against it, tracked on a board with an audit trail. Both read from the same ingestion pipeline, described below.

The problem

Hundreds of directives a year, tracked in a spreadsheet

EASA publishes airworthiness directives continuously, alongside safety directives, safety information bulletins and conflict-zone bulletins. In 2025 alone, 474 airworthiness directives in the corpus carry that year's publication date, with 37 information bulletins and 4 safety directives beside them. Each one names aircraft types, engines or parts, carries an effective date and often a compliance deadline, and can supersede an earlier one. Missing one is not a minor error. For a maintenance organization it is an audit finding; for an operator it can be a grounded aircraft.

The people responsible are Continuing Airworthiness managers at small operators, quality staff at Part-145 maintenance organizations, and the consultants who serve both. Most of them track directives the same way: an EASA email alert, a spreadsheet, and the discipline to read every publication to find the three that apply to their fleet. Enterprise compliance suites exist and are built for airlines with a department; at a small operator the department is one person.

These users are technical, conservative and time-poor. They trust precision and distrust flash, and they work in a domain that punishes overclaiming. The product had to read like it was made by someone who understands Part-M, and it had to be honest about what a model can and cannot be trusted with.

The approach

Four decisions

Every publication is a page

Each item is a server-rendered page with the model's digest and a scoped assistant, so the corpus is navigable without an account. The weekly email and the workspace read the same records; nothing is generated twice.

Deterministic before probabilistic

Anything that can be done with a table, a regex or a SQL predicate is done that way first: parsing EASA's own type designations, classifying the affected zone from the title, matching directives to a fleet. The model adds to those results; it never replaces them.

The model interprets and cites, it never claims

Every summary and every answer is grounded in retrieved documents, links back to the EASA original, and carries a disclaimer. Where the retrieved text contradicts the question, the answer says so.

Live early, hardened in production

The public product went live before the workspace existed. The self-healing processing queue, the scheduler heartbeat and the test suite were added while it was live and serving pages.

What shipped

The public surfaces, in the order a visitor meets them

Everything on these screens was produced by the system from real EASA publications. The screens are the live site; the numbers on them are whatever the corpus held on the day of capture.

RegNotify home page: conflict zone map with fifteen active bulletins above a latest-regulations feed and an aircraft filter
01 · Home

The map and the feed. The landing page opens on the live conflict-zone map: EASA's bulletins arrive as a CSV, the pipeline extracts the flight information regions each one names and joins them to a bundled boundary file. Below it, the latest fifty publications grouped by month, with the model's what to do pulled up for the newest. The aircraft filter narrows the feed to a type. Server-rendered and revalidated hourly.

Browse page listing EASA regulations with aircraft-type filter chips and AI summaries
02 · Browse

The catalog. Full-text search over the corpus, type chips carrying the item count for each aircraft, and one card per publication: identifier, title, the model's summary, then the document type, the domain tags and the aircraft it names. Document types carry color only where it encodes urgency: an Airworthiness Directive is tinted error, a Safety Directive warning, an information bulletin brand blue, everything else neutral.

Detail page for an Airworthiness Directive: scoped chat on the left, structured summary on the right
03 · Detail

One document, four questions answered. Summary, what changed, why it matters, what to do: the four fields the summarizer must produce for every item, shown beside the original PDF in a tab. The chat panel on the left is scoped to this one regulation. Each page carries Article, Breadcrumb and FAQ structured data, its own Open Graph image and a canonical URL, which is how a five-thousand-page long tail reaches search.

Weekly digest dialog with a fleet picker on the left and a preview of the Monday email on the right
04 · Digest

The weekly email. Pick a fleet, give an email, and the preview on the right is the email you will get: generated from this week's real items, action-required lines pulled out. Double opt-in, then a welcome digest of the last thirty days so the first email is never empty. Every Monday at 09:00 UTC the scheduler builds one email per subscriber, filtered to their types, with one-click unsubscribe headers.

The workspace

Fleet, applicability, board

Behind sign-in the same shell becomes an inset sidebar workspace built around one idea: the operator registers their aircraft once, and from then on every publication is judged against that fleet. The screens below are a demo operator with three aircraft.

The workspace dashboard: conflict-zone map, a fleet of three registered aircraft, and the fleet-relevant regulation timeline with deadline countdowns
05 · Dashboard

Everything reoriented around the fleet. Two A320s and a 737, each with MSN and engine fitted, and the count that matters: 257 regulations for this fleet, out of five thousand. The timeline is the public feed filtered to those types, with due-in countdowns computed from effective dates and a separate Upcoming Deadlines view so nothing arrives as a surprise. Routes, once added, are drawn as great-circle arcs over the conflict-zone map.

The compliance board: fleet-matched regulations tracked across five columns with per-aircraft chips and overdue badges
06 · Compliance board

From directive to tracked work. Five columns: new, reviewed, action required, completed, not applicable. Drag between them on desktop, grouped lists on mobile. Each card names the aircraft it applies to and computes overdue days against the effective date. Each record holds notes, a due date, an assignee and who last touched it, and carries a checklist the model can draft from the source document. The columns are the states a CAMO manager's spreadsheet already has.

The fleet-aware assistant answering whether a CFM56-5B directive applies, correcting that it is a bulletin rather than an AD, with source chips
07 · Fleet-aware assistant

An assistant that corrects the question. Signed in, the assistant knows the fleet. Asked whether "the CFM56-5B high cycle fatigue AD" applies, it answers with the part numbers and the deadline, cites its sources, and corrects the premise: the issue is addressed in a Safety Information Bulletin, not an AD, and is recommended rather than mandatory. The correction comes from the retrieved bulletin, not from the model's memory.

How applicability is decided

The distinct type names in the fleet registry become the organization's fleet types. For each one the board query ORs three signals: the tagger's aircraft list contains the type, the title mentions it, or EASA's own type designation in the metadata mentions it. Superseded items are excluded. This is deliberately broader than the digest filter, because on the board a false negative costs more than a false positive, and an operator can mark an item not applicable in one drag.

The compliance assistant is the same chat component in a third mode: scoped to one regulation, told the operator's fleet types, and given the full source text up to 12,000 characters instead of retrieved snippets, with a prompt that asks it to reason about applicability for those types specifically.

Architecture

What runs where

Three deployables, plus OpenAI, Resend, PostHog and Sentry. The backend is a single container that also runs the scheduler, so ingestion, processing, the API and the Monday digest share one process and one database connection pool. That is a choice, and it is the reason for the single-worker constraint discussed under Operations.

EMAIL OUT Resend Monday digest · opt-in · reset SOURCES EASA AD · SD · SIB XML export + PDFs ad.easa.europa.eu EASA CZIB CSV export · conflict zones BACKEND · RAILWAY · DOCKER · ONE UVICORN WORKER APScheduler, in-process ingest 6 h · process 1 h · backfill 3 h · CZIB 24 h · digest Mon 09:00 UTC Ingesters fetch · hash · dedupe PDF text, 60 s cap Processor summarize → tag ∥ embed → zone → types → tsvector FastAPI catalog · chat SSE · auth · fleet · compliance · admin SSE rate limits · TTL cache · GZip · JSON access log PostgreSQL 17 + pgvector · Railway 12 tables · HNSW index on 1536-d embeddings · GIN on JSONB and tsvector pool 10 + 10 overflow · recycle 30 min · hnsw.ef_search = 100 Alembic, 30 migrations, run on container start OpenAI gpt-4.1-mini: summary, tags, tasks, chat text-embedding-3-large at 1536 dims 5 background + 3 chat slots · 3 retries Next.js 16 · Vercel RSC + ISR public pages, revalidate 1 h rewrite /api/items → backend, same origin rewrite /m → PostHog EU, cookieless Browser CAMO manager · Googlebot · digest reader 25 / cycle daily Mon 09:00 asyncpg JSON revalidate HTML · ISR chat · auth · workspace · bearer OBSERVABILITY AND CONTROL Sentry both apps · 10 % traces PostHog EU 9 funnel events · memory persistence Admin panel + SSE ingest, backfill, audience, chat logs Health endpoints ready · scheduler heartbeat · 503 if stale
Figure

Blue marks the one path that touches the model on the request path: the browser talks to the API directly for chat, auth and the workspace, while everything public goes through Vercel's cache. Dashed lines are control, not data.

LayerChoiceWhere it runs
FrontendNext.js 16.1 App Router, React 19.2 with the React Compiler on, Tailwind v4 with shadcn primitives on Radix, Zustand for client state, Leaflet for maps, three.js for the zone viewer, react-pdf for the source document.Vercel, edge cache honors backend s-maxage
BackendFastAPI on Python 3.11, SQLAlchemy 2 async over asyncpg, APScheduler, SlowAPI, orjson responses, JSON logs, Sentry.Railway, Docker, one uvicorn worker
DatabasePostgreSQL 17 with pgvector. HNSW index on the embedding column, GIN on the JSONB tag columns and the full-text vector, partial indexes for the public sort and the deadline filter.Railway
ModelOpenAI gpt-4.1-mini for summaries, tags, task drafts and chat; text-embedding-3-large at 1,536 dimensions. The provider sits behind one client module with its own retry and concurrency policy.OpenAI API
EmailResend with Jinja2 templates: opt-in confirmation, verify, reset, and the weekly digest with one-click List-Unsubscribe headers.Resend
AnalyticsPostHog EU behind a same-origin proxy, memory-only persistence so there is no cookie banner; Sentry on both apps.PostHog Cloud EU

Ingestion

How a publication becomes an item

Ingestion is separated from processing on purpose. Fetching is cheap and must never lose a document; processing costs model calls and can fail, retry or be re-run. The boundary between them is one column, processing_status, and every recovery path in the system is a transition on it.

01

Fetch

Every six hours the scheduler runs the three EASA document ingesters under one lock, so a manual trigger from the admin panel queues behind a running job rather than racing it. Each cycle reads the newest 25 entries from EASA's XML export; the five newest are always re-fetched and their PDFs re-hashed, which catches the case where EASA silently replaces a PDF under the same number.

02

Store

Items are keyed on source plus EASA's own identifier. A SHA-256 of the extracted text is the change detector: a known item whose hash moved is reset to pending and goes back through the model. Each item is saved inside its own savepoint, so one malformed record fails alone and the run still completes with a count of what failed.

03

Extract

PDFs are downloaded five at a time with three retries and exponential backoff, stored under their hash, and text-extracted in a worker thread with a 60-second cap and a 500-page ceiling. Null bytes and mojibake are stripped before the text touches Postgres.

04

Claim

Hourly, and immediately after ingestion, the processor claims up to fifty pending items by flipping them to processing in one committed statement before any model call is made. Anything left in processing for more than fifteen minutes is treated as orphaned by a crash and returned to pending.

05

Enrich

Ten items run in parallel, each in its own session. Per item: summarize, then tag and embed concurrently, then classify the affected zone from the title with a deterministic ATA-subject matcher, then merge aircraft types, with EASA's own type designation parsed first and the model's additions de-duplicated behind it, then refresh the full-text vector, then mark completed.

06

Reconcile

The summarizer reports which numbers a directive supersedes. Those are written on the older items, and a nightly sweep at 03:00 UTC repairs gaps left by out-of-order ingestion, so a superseded AD drops out of the board and the digest without a human noticing.

07

Backfill

Historical pages are walked one page per run, every three hours, with the cursor persisted in the source's JSONB config. EASA's AD archive is estimated at 688 pages. The run aborts after three consecutive failures and resumes from the cursor next time.

fetched ingester saved it pending waiting for the model processing claimed, 10 in parallel completed public, searchable failed error kept on the row new or changed claim enriched retries exhausted operator re-queue stalled > 15 min, reclaimed PDF hash changed on re-check
Figure

Solid transitions happen on every run. Of the dashed ones, the stalled reclaim and the hash re-check are automatic; only the re-queue of a failed item waits for an operator. Nothing is ever deleted by the pipeline, and a failed item keeps its error message on the row.

Scheduled jobCadenceGuard
Heartbeatevery 60 shealth endpoint returns 503 if the last beat is older than 5 minutes
EASA ingestionevery 6 hingestion lock; clears the public cache and asks Vercel to revalidate when done
Item processingevery 1 hatomic claim; stalled reclaim first
Backfillevery 3 hone page per run; ingestion lock
CZIB ingestionevery 24 hingestion lock; revision suffixes normalized so a revised bulletin updates one item
Supersession sweepdaily 03:00 UTCidempotent
Weekly digestMonday 09:00 UTCdigest lock; per-subscriber cutoff is their last send, so nothing is sent twice

All jobs run with a single instance and the admin panel can trigger any of them by hand. Progress is published on an in-process event bus and streamed to the admin browser over Server-Sent Events, with a single-use 30-second ticket standing in for a cookie.

AI layer

Four contracts

Four model tasks, each with a narrow contract, plus one policy module they all go through.

TaskContractLimits
SummarizeJSON only, exactly these keys: summary, what changed, why it matters, what to do, published date, compliance deadline, and the list of numbers this document supersedes. Dates are validated as ISO before they are stored; a parse failure falls back to the raw text in the summary field rather than dropping the item.temperature 0.2 · 1,500 tokens out · input cut at 12,000 characters
TagDomains from a fixed list of four (Flight Ops, Maintenance, Training, Safety Mgmt), document type from a fixed enum, aircraft types as "Manufacturer Model" or the single value All. Anything outside the whitelist is dropped server-side.temperature 0.1 · 300 tokens
Embedtext-embedding-3-large reduced to 1,536 dimensions to fit the HNSW index. Content-hash keyed LRU so a re-run of an unchanged item costs nothing.cache of 1,000 entries
Draft tasksThree to eight checklist items from the source document, capped at eight, for the compliance board.8,000 characters in · 10 calls / min per user
ChatThree prompts. Global: answer from the five retrieved documents and cite them. Scoped: the same, anchored on one regulation. Compliance: the full source text and the operator's fleet types, asked to reason about applicability. History is the last ten messages.10 / min and 10 / day per address · 3 concurrent chat calls

Concurrency and failure policy

One OpenAI client with two semaphores: five slots for background processing and three for chat, so a large backfill cannot starve a person waiting on an answer. Retries are three with a doubling backoff, and only for the errors that retrying can fix: connection, timeout, rate limit and server errors. A permanent error fails the item immediately and is recorded on it.

Retrieval

Pure cosine nearest-neighbor over completed items with an embedding, top five, with the session's HNSW search width raised to 100 for recall. There is no hybrid keyword fusion in the chat path; keyword search lives separately on the browse page over the Postgres full-text vector. The answer streams token by token and the sources arrive as a final event, so the browser can render chips only for what was actually used.

Disclaimer as a design rule

Every summary, chat answer and digest carries the line that it is AI-generated and must be verified against the official source. The detail page links the EASA original beside the summary. This is not legal cover only; the users are the people who get audited, and the product's credibility with them depends on never pretending to be the source of truth.

Data model

Twelve tables, one of them heavy

The regulatory item carries everything the public catalog needs in one row: the raw text, the four model fields, the tag arrays as JSONB, the dates, the embedding and the full-text vector. Everything else is either provenance for that row or state that belongs to an organization.

TableHoldsNotable
regulatory_itemsOne row per publication: identifier, title, the raw text and its hash, the stored PDF, the four summary fields, domains and aircraft types as JSONB, item type, affected zone, the three dates, embedding, full-text vector, supersession pointer and processing status.Unique on source + external id. HNSW on the embedding (m 16, ef_construction 64). GIN on each JSONB column and the tsvector. Partial index where a deadline exists.
regulatory_sourcesThe four feeds, their poll interval, last polled and last success, and the backfill cursor in a JSONB config.The cursor is the only mutable state the backfill has.
ingestion_runsOne row per run: found, new, updated, error, timing.What the admin panel's history reads from.
organizationsName and plan.Users, aircraft, routes and compliance records hang off it.
usersEmail, bcrypt hash, organization, role, verification and reset tokens with expiries, personal aircraft types.Organisation cascades on delete.
aircraftTail number, MSN, type name, engine type, per organization.Unique per organization on tail number.
routesOrigin and destination ICAO with names and coordinates.Unique per organization on the pair.
compliance_recordsOrganisation plus item, status, notes, due date, assignee, last editor, completed at.Unique on organization + item; four composite indexes for the board.
compliance_tasksChecklist rows under a record with completion and sort order.Cascade on record delete.
subscribersEmail, aircraft types, confirmation state and tokens, unsubscribe token, optional link to a user, referral code and count.Confirmation tokens expire after 48 hours.
digest_logOne row per email sent: items included, status, error, sent at, opened at, open count.Feeds the audience view and the per-subscriber cutoff.
chat_sessions · chat_messagesSession with messages: role, content, sources used, response time.Admin reads them lazily per session.

How the schema got here

Thirty Alembic migrations between late February and August 2026. Read in order they are the product's history.

01

Core ingestion

Items, sources, runs, subscribers, digest log, chat. Then embedding dimension churn: 1,536 to 3,072 and back to 1,536 once the HNSW index was added, because pgvector's index caps at 2,000 dimensions.

02

Search and SEO

Composite and GIN indexes, then the fields the public pages needed: identifier, aircraft types, view count, supersession pointer.

03

The multi-tenant pivot

Organisations and users, then aircraft, then compliance records and tasks. Compliance moved from per-aircraft to per-organization in the same week, which is the moment the board became a workflow rather than a checklist.

04

Map and audience

Routes for the dashboard map; open tracking on the digest log.

05

Performance and the zone viewer

Two indexes for the public list sort and the fleet filter, then the affected-zone column for the viewer. That is the current head.

Frontend

Static to Google, live to the operator

One Next.js application with three shells and three ways of reaching the backend. There is no middleware file: route protection is a client-side gate and the real check is the bearer token on every API call, and the analytics proxy is a rewrite in the Next config rather than an edge function.

Browser regnotify.com VERCEL · NEXT.JS Server components render with fetch, revalidate 3600 s Rewrite /api/items/* same origin, no preflight, s-maxage kept Rewrite /m/* PostHog EU, cookieless FastAPI · Railway CORS max-age 24 h bearer JWT, 24 h SlowAPI per address in-process TTL cache PostHog Cloud EU events, pageviews GET page server fetch /api/items?q= rewrite direct, bearer token: chat SSE · auth · compliance · admin /m/e proxied
Figure

Public reads never leave the site's origin, so they need no preflight and Vercel's edge can cache them on the backend's own headers. Only the authenticated and streaming calls cross origins, in blue.

RouteRendersCacheCrawl
/Map, latest items, aircraft filter; signed-in users are bounced to the dashboardISR 1 h, zones 30 minindex
/easaPaginated catalog, search, type chips, fleet filterISR 1 hindex
/easa/[slug]Detail with scoped chat, PDF tab, lineage and the zone viewer, a three.js line drawing of the aircraft with the affected region lit; Article, Breadcrumb and FAQ JSON-LD; per-item OG imageISR 1 hindex
/easa/aircraft/[type]Per-type hub; top 50 types pre-rendered at build, the rest on demandISR 1 hindex
/conflict-zonesFull-page map with zone list; routes panel when signed inISR 30 minindex
/chatFull-page chatnoneindex
/login · /register · /forgot-password · /reset-password · /verify-emailClient forms posting to the APIstaticnoindex
/dashboard · /dashboard/complianceWorkspace: map with routes, fleet registry, timeline; the kanban board with tasks and compliance chatclientnoindex
/admin/*Own login, own React Query island: dashboard, items, ingestion, users, subscribers, analytics, chat logsclientnoindex
/api/revalidateThe one route handler: secret-checked purge of the public pages and sitemap, called by the backend after ingestion

Shells and state

A public top-nav shell, an inset sidebar workspace once signed in, and a separate admin shell with its own auth store; one small component picks the shell from the path and the auth state. Client state is four Zustand stores. React Query is used only in the admin panel, where it doubles as a real-time bus for the Server-Sent Events that carry ingestion and backfill progress; everywhere else the data layer is plain fetch with abort controllers.

Search presence

The sitemap lists the static pages, one entry per aircraft type, and every item with its last-modified date, regenerated hourly. Robots allow the catalog and, deliberately, the chat page; they disallow the API, admin, workspace and auth routes. The root layout declares a WebSite with a SearchAction pointing at the catalog search, and the About page an Organization. Security headers and a preconnect to the API host are set in the root layout.

Analytics without a banner

PostHog is loaded lazily, configured for memory-only persistence and identified-only profiles, and served through the same-origin path /m rather than the conventional /ingest, because the common filter lists match well-known proxy paths. Nine events cover the digest sign-up from dialog opened to confirmed, plus account creation, with the landing pageview fired only after initialization so the referrer and campaign parameters survive.

Operations

In production

Deploy. The backend image is a two-stage Python 3.11 build. The container starts as root only to take ownership of the mounted uploads volume, drops to an unprivileged user, runs alembic upgrade head, and then starts uvicorn with one worker, a 65-second keep-alive and a concurrency limit of 100. Railway's health check is the readiness endpoint, which runs a real query. The frontend is a standard Vercel build with the API host as an environment variable.

Why one worker is load-bearing. The scheduler, the public response cache, the embedding cache, the chat daily-limit table and the SSE event bus all live in the process. A second worker would run the scheduler twice and split every cache. Scaling out means moving those four things to Postgres or Redis first; scaling up is a bigger container.

Secrets. Settings are validated at import. In production, detected as any database URL that is not localhost, a missing OpenAI key, a default admin key or JWT secret, or an empty admin password hash stops the process from starting. In development they warn.

Cache invalidation. After every ingestion the backend clears its in-process cache and posts to the frontend's revalidate route with a shared secret, which purges the home page, the catalog, the conflict-zone page and the sitemap. Public responses also carry cache headers, so Vercel and browsers hold them between purges.

Tests and CI. 175 backend tests in sixteen files, run against a real local Postgres: the public API, admin API including SSE tickets and uploads, chat streaming with a mocked model, subscribe and confirm flows, register with digest opt-in, the welcome digest, open tracking, ingester resilience, processor recovery and retry semantics, scheduler health, CZIB revision handling, type parsing and zone classification. CI runs ruff on the backend and type-check, lint and a production build on the frontend on every push and pull request. The frontend has no test suite.

Numbers that shape behavior

SettingValueEffect
Items per ingestion cycle · re-check window25 · 5Covers a publication burst; newest five always re-hashed
Processing batch · parallel · stall reclaim50 · 10 · 15 minBounded model concurrency; crash recovery without an operator
Model slots, background · chat5 · 3Backfill cannot starve a live question
Public cache TTL: list · types · zones300 s · 1 h · 30 minPlus HTTP s-maxage for the edge; 256-entry cap
Public rate limits: list · chat · subscribe · register · login100 · 10 · 5 · 5 · 10 / minPer address; chat also 10 per day
Digest: items per email · send timeout50 · 30 sSends run one at a time in a thread
Token lifetimes: session · verify · reset · confirm · SSE ticket24 h · 48 h · 1 h · 48 h · 30 sAll short-lived; the SSE ticket is also single-use
Database pool · recycle10 + 10 · 30 minRecycle was 5 minutes; quiet periods were paying a fresh handshake per request
PDF upload cap50 MBMagic bytes checked, path traversal guarded on read

Project rules

Rules for the ingestion pipeline, the admin state bus and the SSE lifecycle live beside the code, so that anyone editing those files, human or model, reads them first.

Have a workflow like this?

Tell us about it: if it is not a fit, we say so in the first five minutes.