Clearline · Internal

How It Works

Not the backlog (things to build), not the style guide (UI conventions), and not considerations (open tradeoffs with a revisit trigger).

This is for logic that's already built and deliberate — the reasoning behind behavior that might otherwise look like a bug, so it doesn't get re-explained from scratch (or "fixed") every time someone runs into it. Each entry: what happens, why it's built that way, and where to find it in the code.

Vendor identity is keyed on email domain, not the exact email

What happens

When inviting a vendor, Clearline checks whether the invited email already belongs to an existing vendor account. It checks for an exact email match first; if there isn't one, and the email's domain isn't a personal/generic webmail domain, it falls back to matching by domain. If any vendor already has an account at that domain, the invite is treated as another contact at that same company — the flow won't silently create a disconnected duplicate. Instead it stops and tells the admin who the existing contact is, and the vendor's name on file always wins over whatever company name was typed on this particular invite.

Why

A company's Clearline identity should be the company, not whichever individual happened to get invited first. Two different districts inviting two different people at @abcstaffing.com should land on the same vendor account, not create two disconnected ones. Personal/generic email providers (gmail.com, yahoo.com, outlook.com, icloud.com, protonmail.com, and others — full list in src/lib/emailDomain.ts) have no shared company identity to key on, so those addresses only ever match by exact email, never by domain.

Where in the code

src/app/api/invite-vendor/route.ts (identity resolution ~lines 27–45, the "different contact, existing company" response ~lines 59–80); domain exclusion list in src/lib/emailDomain.ts.

Testing gotcha

This makes @theclearline.io itself risky to test against with plus-aliasing. theclearline.io is not on the personal-domain list, so every karen+N@theclearline.io invite resolves to the same company identity — you can't use plus-aliases on Clearline's own work domain to simulate multiple distinct fake vendors; they all collide into one. For that, plus-alias a real personal webmail address instead (e.g. karen+testco1@gmail.com, karen+testco2@gmail.com) — gmail.com is on the exclusion list, so matching falls back to exact email and each alias is a fully independent identity. Bonus: it's a real inbox, so you can also click through the actual invite/magic-link email if you need to test that far.

AI document checks split "read the document" from "check the requirement" — the model only does the first

What happens

The AI insurance-coverage check runs as two stages that never touch each other's job. First, extraction: the model is given the uploaded certificate and a fixed schema of what to read off it — producer, insured name, each coverage line's amounts, endorsements, certificate holder — the same schema for every district, since it describes the shape of an ACORD 25 form, not any one district's policy. The model returns that shape filled in and nothing else. Second, comparison: Clearline's own code (runInsuranceCheck) takes the filled-in extraction and checks it against this district's configured minimums — coverage amounts, required endorsements, the certificate holder name to match — and decides reviewed / flagged / uncertain from there, building the human-readable finding text itself.

Why

The model is used only for the one thing only it can do — reading an arbitrary document's layout and pulling structured facts out of it. It is never told a district's thresholds and never makes the pass/fail call itself. That keeps the actual compliance decision (does $1.5M meet a $1M minimum, does the certificate holder name match) as plain, deterministic, testable code instead of something an LLM could get subtly wrong or answer inconsistently for two documents with identical numbers. It also means one extraction call works unchanged for every district, since district policy never enters the prompt — only the comparison step downstream ever looks at the district's config.

Where in the code

src/lib/aiCheck.ts — stubExtractInsurance() is the extraction stage (currently a stub returning fixed values regardless of the document; swap its body for a real Claude call when ready, keeping the same InsuranceExtraction return shape and every caller stays the same), runInsuranceCheck() is the comparison stage (real today, not stubbed).