That's not a hypothetical gap. Under the U.S. Equal Credit Opportunity Act (Regulation B), a lender must give the specific principal reasons for a denial and must never decide on a prohibited basis — race, religion, sex, marital status, age, or receipt of public assistance. In 2022 the CFPB went further (Circular 2022-03): "the model is too complex to explain" is not a defense. A black box that can't produce its reasons is a system that structurally cannot comply.
The part that bothered us most: discrimination almost never looks like discrimination. Nobody writes if race == X: deny. They write if zip_code in high_default_areas: flag_risk — and in the US, ZIP code is a very good proxy for race. The model thinks it's being neutral. The applicant never finds out.
We also noticed that the "AI fairness" tooling we looked at shares one flaw: it asks a model to double-check a decision it has already been shown. Anchored on the answer, it agrees. A checker that only ever agrees is indistinguishable from no checker.
What it does GlassBox decides a loan application with a team of seven specialist AI agents, and makes every step inspectable.
A reading agent ingests the full application package — identification, income, income proof, collateral, banking relationships, demographics — and tags every extracted fact with a sensitivity class: PERMISSIBLE, PROHIBITED_BASIS, or PROXY_RISK. Three analysts run in parallel — affordability, risk, collateral. Each records its actual chain of thought and the exact facts it cited. An adjudicator combines them into a draft decision. A No-Discrimination agent reviews the work. This is the core of the project (see below). A recourse agent turns any denial into a concrete, numeric action plan. Two things make it more than a dashboard:
The fairness check can't rubber-stamp. The reviewer reaches its own verdict from the permissible facts before it is allowed to see the draft — its function signature literally cannot receive the draft outcome. Only then does it compare. It is also the only agent permitted to read protected data, so it can detect a leak the analysts couldn't even see. It blocks in both directions: it overturns a denial that leaned on a proxy, and it overturns an approval that broke a hard affordability rule.
A denial comes with a way back. The recourse agent distinguishes a fixable problem ("you never uploaded pay stubs") from a substantive one ("your debt-to-income is 52%; our limit is 43% — pay down $500/mo and you qualify"). Then a Re-check button re-runs the real pipeline on the corrected application, so you see the outcome before doing any of the work. It's a result, not a promise.
You can click any agent in the pipeline to see exactly what it did and which facts it read.
How we built it The whole decision engine is Jac — 723 lines, 8 node types, 11 edge types, 12 walkers. Agents are walkers; the provenance graph is the database. No ORM, no migrations, no serializers.
The idea we're proudest of: access control is structural, enforced by edge type, not by a prompt.
edge derived_from: Application --> Fact {} # analysts CAN traverse this edge restricted_from: Application --> Fact {} # ONLY the No-Discrimination agent can Intake routes every PROHIBITED_BASIS fact onto restricted_from. The analysts only ever query derived_from, so they physically cannot read a protected characteristic. A prompt saying "please ignore this" is not an access control. A query that cannot return the fact is.
We deliberately collect demographic data rather than hiding it — you cannot prove a decision didn't use a characteristic you never recorded. The edge-type gate is what makes collecting it safe.
PROXY_RISK facts (ZIP code) deliberately stay on the analyst-visible tier, because they look neutral — that's exactly the leak the reviewer exists to catch.
Reasoning uses Jac's by llm() with typed obj returns and sem prompts, on Groq (openai/gpt-oss-120b). Deliberate split: the verdict logic and leak detection stay deterministic, because a compliance rule cannot be a vibe. The LLM reads documents and writes the human-facing explanations around numbers the rules computed. A MockLLM path runs the entire system offline.
Frontend: Next.js 16 / React 19 / Tailwind v4 — a fixed pipeline diagram, story-mode reveal, clickable agents, and an appeal flow. app.py is a thin FastAPI shim; it contains zero decision logic (grep it for dti or approve — you get a docstring).
Challenges we ran into The rubber-stamp problem. Our first verifier agreed with the adjudicator every single time — because we handed it the draft. We had to restructure it into three phases (independent verdict → leak scan → comparison) and remove the draft outcome from the function signature entirely. We then wrote two adversarial test cases specifically to prove it isn't a yes-man: one where it upholds, one where it overturns an approval. That's the first thing a judge should probe.
A physics bug that made the graph collapse every time. Our force-directed graph kept caving into a single blob. We tuned constants for a while before finding the real cause: repulsion was scaled by the simulation's decaying alpha, but the centering force wasn't — so once things cooled, the only surviving force pulled everything to the middle. Guaranteed collapse. We eventually replaced it with a fixed pipeline diagram: it reads instantly and looks identical on every run.
The landing page rendered blank. Every section was wrapped in a FadeIn that started at opacity: 0 and revealed via an IntersectionObserver that disconnected on its first callback. One missed callback → permanently invisible page. Fixing the observer still wasn't enough (the element stranded mid-transition), so we inverted the design: content now rests visible and the entrance is a keyframe animation, which can only ever end visible.
A .gitignore line that hid our own code. Stock Python boilerplate included an unanchored lib/, which matches any lib directory at any depth — silently excluding web-ui/src/lib, the entire adapter between the UI and the Jac backend. A collaborator cloning the repo got a frontend that couldn't build, with no error explaining why.
Environment and API archaeology. The Mac's system Python (3.9) can't run Jac at all. jac start's built-in auth turned out to be a passkey-style identity/credential scheme, so we wrote our own shim. spawn_walker only creates a walker — you need R.spawn(w, root) to run it. And Groq deprecated llama-3.3-70b-versatile on 2026-06-17, mid-build.
A wrong-guidance bug we're glad we caught. A default of collateral_type: "none" was counting as having collateral, so an unappraised "none" flagged a missing appraisal — reporting a substantive denial as a paperwork problem. Telling a declined applicant to fix the wrong thing is worse than telling them nothing.
Accomplishments that we're proud of The veto demonstrably works in both directions. Maria: draft DENY on a ZIP proxy → reviewer independently says APPROVE → veto → final APPROVE. Riley: draft APPROVE on collateral despite a 48% DTI → reviewer says DENY → veto → final DENY. The reviewer disagrees with the draft in both cases — it is provably not a rubber stamp. Fairness enforced in the data layer. Not a system prompt. Not a policy doc. An edge type. Recourse that's grounded, not generated. Every number in the action plan is exact arithmetic from the same evidence and thresholds used to decide. There's a hard assertion that the recourse text can never name a prohibited basis — a bug, not a wording problem. "Prove it" is a button. Re-check runs the real pipeline on the corrected application. It runs with the network off. MockLLM covers the whole flow. verify_gates.py asserts the behavior that matters — the fairness flip, both reviewer test cases, both recourse modes — from a cleared database, every time. What we learned A prompt is not an access control. If you want a model not to use something, don't ask — make the query unable to return it. A checker that sees the answer will agree with it. Independence has to be structural: it belongs in the function signature, not the instructions. You have to collect the sensitive data to prove you didn't use it. Blindness and auditability pull in opposite directions; edge-typed access control is how we got both. Be deterministic where it's compliance, LLM where it's language. Regulators don't accept "the model felt it was fine," and applicants don't want to read a rules engine. Interfaces that fail closed are dangerous. Twice we shipped UI that hid itself (the blank landing page, the invisible re-check result). Default to visible. Most of a fairness system is the unglamorous part — the sensitivity taxonomy, the veto loop, the assertion, the test cases. What's next for GlassBox Make assessments structurally derivable. Right now we enforce that a stated rationale is checkable against the evidence graph. The stronger guarantee is that an assessment must be mathematically derivable from its cited facts — so an agent can't cite the right evidence while reasoning from something else.
Ship the adverse-action notice. The graph already contains everything Reg B requires. Generating the compliant notice — and the regulator-facing evidence package — is the obvious next artifact.
Widen the proxy detection. ZIP code is the canonical case, but surname, education, and device fingerprint all carry signal. This should be a maintained, versioned list with monitoring, not a constant in a file.
Human review for disagreements. Today a reviewer/adjudicator disagreement with no veto is recorded. It should route to a queue — the cases where two independent processes disagree are exactly the ones a person should see.
A narrow lender pilot. One product, one denial flow. The metrics that matter: manual review time, and how many declined applicants actually return and get approved. Fairness you can't measure is a press release.
Honest limits We're not claiming compliance certification, and we're not claiming to have solved AI bias. An agent could still cite the right evidence and reason from something else — that's the gap the derivability work above closes. What we're claiming is narrower and, we think, more useful: the architecture makes compliance checkable and denials contestable, which is what's missing today.
Built With
- jac
- jaseci
- node.js
Log in or sign up for Devpost to join the conversation.