Zakat-Eligibility Triage

Zakat-Eligibility Triage

A triage agent for crowdfunding campaigns. It reads a submitted campaign, assembles the evidence a zakat determination would turn on, names what the text leaves missing or contested, and hands the file to a qualified human reviewer.

Where this came from

  • I went looking for a real problem, not a demo. I read the LaunchGood blog to find a pain point worth building against, and one stood out: after a campaign is submitted, zakat verification means a wait.
  • The wait matters most exactly when it hurts most. LaunchGood's own Ramadan giving report states that 78% of zakat donations arrive inside Ramadan, thirty days when reviewer attention is scarcest and an organizer can least afford to sit in a queue.
  • So the goal became: make the human review faster and easier, without taking the decision away from the human.

The assumptions this is built on

  • People decide, not software. Somebody on the LaunchGood team has to approve zakat eligibility, and a submitted campaign pauses there.
  • Campaign pages are written to move hearts, not to prove eligibility. The facts a reviewer needs, what a family owns, what they owe, who receives the money, are usually not on the page.
  • The crunch is Ramadan. The queue is longest at the moment waiting is least affordable.
  • The two mistakes are not equal. A wrong yes is silent, the donor never learns their obligation went undischarged. A wrong no is loud, and it lands on a family in the biggest giving month of the year.

If any assumption is wrong, the design has room to absorb the correction. The rest of this page, and the demo, follow from these four.

The two constraints it started from

Before any code, one question: what is the worst thing this system could do silently? Two answers came back, and they became constraints, written down first, enforced everywhere.

  • Constraint one: a fabricated quote must be unable to survive. Every claim carries the organizer's exact words, and code searches the story for them, letter by letter. A quote that is not found does not get corrected. It kills the run.
  • Constraint two: an outcome must be unable to exist without a named human behind it. The only place in the entire database that can hold an outcome is the human decisions table, and it refuses a row without a name and a written reason.

Every agentic system worth trusting starts this way: name the worst silent failure, make that state impossible rather than discouraged, and only then let the AI build inside the walls.

Two versions, one brain

  • This system exists twice. The original was built fully in code, and it still runs, untouched, exactly as submitted.
  • Then feedback reached me from the hiring manager, through the brother who referred me: no-code and low-code tooling matters in this role. So I cloned the system and rebuilt its operational layer on n8n, within days.
  • The front end is identical. The brain is identical. What changed is the nervous system: when runs happen, what retries, and who gets told now live on a visual canvas.
  • Two names to remember for later in the demo: the n8n workflow canvas, and a Slack integration where reviewers are notified and follow-ups are approved. Both appear soon.

The split in one line: n8n owns when things run and who gets told, the code owns every rule. The full argument is in sections 05 and 06 of the system design, decision record ADR-0010.

The problem

Zakat is obligatory almsgiving, and it may only go to the eight categories of recipient named in Surah At-Tawbah (9:60). The word rendered "only" is why the list is read as exhaustive rather than illustrative, so eligibility is a question about which named category a campaign falls under and not about how deserving it looks.

That review load is not spread evenly through the year. A Ramadan giving report cited in section 1.5 of the research brief states that "78% of Zakat donations came inside of Ramadan", so the demand arrives compressed into thirty days, at the one point in the year when reviewer attention is scarcest. A triage system's value sits almost entirely inside that window, and so does its risk.

A wrong determination is a religious harm in both directions, and the failure asymmetry in section 6 of the research brief is what shapes the design. A campaign wrongly badged eligible may leave a donor's obligation undischarged, and that harm is silent. A campaign wrongly denied loses access to the zakat donor pool at the moment that pool is largest, which during Ramadan is effectively a denial for the year, and the harm falls on people who are by construction likely to be poor. Only one of the two generates its own corrective signal, and only one is recoverable.

So the honest default under uncertainty is a question, not a verdict. It costs throughput exactly where throughput is scarcest, which is the real price of this design and is stated rather than hidden.

What it does

Prepares a cited evidence file about a submitted campaign so that a qualified human reviewer can adjudicate it. For a submitted campaign it:

  1. extracts a typed record of the facts from free-text campaign copy
  2. maps the text against each of the eight recipient categories as supported, not supported, or insufficient evidence, citing the exact span behind every mapping
  3. reports what evidence is missing and what a reviewer should ask the organizer
  4. retrieves comparable previously adjudicated cases and shows them to the reviewer as precedent, rather than feeding them back to the model to imitate
  5. refuses to determine ambiguous cases and escalates with the question a human must answer
  6. records the human decision, which is authoritative

Trust design

The agent prepares the file and a qualified human adjudicates. That boundary is architectural rather than a disclaimer: there is no code path in which a determination is published without a recorded human decision.

The agent never issues a religious ruling. It emits findings about text evidence, and carries no score or confidence figure anywhere.

A number would be a determination with a decimal point in it, and no field in any schema accepts one.

ADR-0001

Citations are verbatim quotes resolved to offsets server side.

A supported finding with no citation is unrepresentable: the union types the citation list as non-empty, so it fails to construct in TypeScript and to parse at runtime.

ADR-0003

Retrieved precedent renders to the reviewer and never enters a prompt.

Retrieval belongs to the render, and src/lib/triage.ts has no way to reach it. A prompt-recording trace test and an import-graph fence hold it.

ADR-0004

The refusal is deterministic code over typed output.

The model cannot talk the pipeline out of an escalation, and escalate: true carries a non-empty reason list, so a bare needs-review flag cannot be constructed.

ADR-0006

Nothing in citation position is model-authored.

Campaign spans are byte-checked against the story, scholarly-difference text is retrieved by id from versioned human-authored data, and model prose is guarded against quotation and citation shapes.

ADR-0007

The only representation of an outcome in the entire schema is a human decision row.

SQL CHECK constraints enforce it, and the suite proves the constraints by inserting past the application-level validation.

ADR-0008

The three statuses

A finding is a statement about the campaign text, never a determination of eligibility.

supported

the story states the qualifying facts the category asks for, and the spans stating them are cited. Hardship, urgency, a sympathetic account and a sum of money are not qualifying facts.

insufficient_evidence

the story engages the category, or gestures at it, and the qualifying facts are missing. The absent fact is named and the question that would obtain it travels with it.

not_supported

the story does not engage the category at all, or engages it and points away.

Evaluation

The gate runs in CI on pull requests into main and on pushes to main, and a missed threshold exits non-zero and fails the build. A missing ANTHROPIC_API_KEY fails the job rather than skipping it, because a gate that passes without running is green for a run nobody performed. The latest green run:

GateRequiresObserved
citation-validity100.0%100.0% (43 of 43)
citation-anchoring90.0%100.0% (14 of 14)
category-agreement80.0%97.2% (140 of 144)
escalation-agreement75.0%83.3% (15 of 18)
missing-evidence-coverage75.0%92.9% (39 of 42)
judge/respondedat most 2 unjudged0 unjudged of 18
judge/no-ruling0 failures0 failures in 18
judge/evidence-not-assertion85.0%94.4% (17 of 18)
judge/sendable-questions85.0%100.0% (18 of 18)
judge/unresolved-only-where-engaged66.7%66.7% (12 of 18)

The coverage and engagement floors were lowered after this run, in a commit arguing from two runs on byte-identical code (issue #32); the table shows the floors as they stand, and the run cleared the stricter originals.

The gate went red four times before it went green. Each red caught something real, and none was fixed by lowering a bar without an argument. Every run is in the repository's Actions.