Method
How a report becomes a case, and why you can check every step.
CivicProof separates what a language model is good at (reading messy documents, finding the relevant page) from what it must never be trusted with alone (stating facts, taking actions). The model proposes; deterministic code verifies; the reporter decides.
Architecture
One Next.js application on AWS Lambda, deployed with an AWS SAM template in infra/. Every AWS integration is switched on by environment variables, so the same code also runs on a laptop with local files and the rules planner.
This instance: planner=gemini (gemini-3-flash-preview) · store=local · blobs=local · region=ap-south-1
The agent loop
The investigator is a Strands Agents agent driven by a language model: Google Gemini when a Gemini key is set, or a model on Amazon Bedrock. Without either, a rules planner (itself a Strands Model) drives the same tools, policies and verifier by replaying hand-curated extractions, so it can only handle projects already in the records. The case page says which one ran.
Ten tools, all read-only or proposal-only. No tool can send, submit, change status or reach the internet.
01Intake
get_case_report reads the report, the photo's EXIF metadata, any documents the reporter added (scans OCR'd with Amazon Textract) and, when a language model is configured, a vision description of the photo, labelled as an AI observation.
02Locate
find_projects_near measures the distance from the pin to every project alignment. select_project is only permitted for projects the search returned (a Cedar rule).
03Retrieve
list_project_documents, search_documents (full-text over every page) and read_document_page give the model the actual text of the tender, award and completion records.
04Extract
record_claim proposes one fact with a quotation. The verifier checks the quotation against the page and the value against the quotation before the claim counts.
05Gaps
flag_missing names what could not be established and the record that would settle it; these become the RTI application.
06Act
Next actions are derived by rules from verified facts only: a defect-liability repair request if the observation falls inside the stated window, a complaint, and an RTI for the gaps.
The grounding verifier
Every quotation is normalised (whitespace, dashes, quotes, the rupee sign) and searched for on the cited page. Then the claimed value must appear inside the quotation, compared as text, as an amount (₹1.25 crore = Rs. 125.00 lakhs), as a date (31.03.2025 = 31 March 2025) or as a duration (5-year = 60 months).
A quotation that only resembles the page is marked partially verified; one that can't be found is rejected and the claim is stored as unverified, shown with a dashed outline. Two verified claims that disagree are both marked as conflicting. Nothing is chosen silently.
The same verifier runs at ingest: all 7677 curated facts in the corpus are re-checked against the extracted page text on every build, and the build fails if one no longer matches.
A rejected claim: real verifier output
record_claim({
field: "contractor",
value: "M/s Example Builders",
citations: [{ doc_id: "kppp-bbmp-wt-pkg2-award",
page: 1,
quote: "supplierName: M/s Example Builders" }]
})
→ { verification: "unverified",
rejected_citations: [
"Excerpt not found on page 1 of \"KPPP selected-bid
(award) record — tender BBMP/2023-24/RD/WORK_INDENT1733\"
(best token overlap 33%)." ] }Permissions, in Cedar
Two policy files. The first is evaluated by Strands' Cedar intervention before every tool call the model makes; denials are shown in the case trace and returned to the model. The second is evaluated by the API before any change to a case.
policies/agent-tools.cedar
// CivicProof · authorization policy for the investigation agent
//
// Evaluated by the Strands Agents CedarAuthorization intervention before EVERY tool call
// the model makes (Gemini, Amazon Nova on Bedrock, or the rules planner).
// principal : Agent::"investigator"
// action : Action::"<tool name>"
// context : { input: <tool arguments>, session: { call_count, candidate_ids, ... } }
// Anything not permitted here is denied, and the denial is shown in the case trace.
// Research tools only read the public-records corpus.
@id("research-tools")
permit (
principal == Agent::"investigator",
action in [
Action::"get_case_report",
Action::"find_projects_near",
Action::"find_projects_by_name",
Action::"list_project_documents",
Action::"search_documents",
Action::"read_document_page"
],
resource
);
// Keep project search local to the report: no radius above 2 km.
@id("bounded-radius")
forbid (principal, action == Action::"find_projects_near", resource)
when { context.input has radius_m && context.input.radius_m > 2000 };
// Live public records: search the official portals, and fetch only a record a search returned.
// Both are capped per run; every fetch is archived with its URL, time and SHA-256.
@id("public-records-search")
permit (principal == Agent::"investigator", action == Action::"search_public_records", resource)
when { context.session.live_searches < 6 };
@id("fetch-only-found-records")
permit (principal == Agent::"investigator", action == Action::"fetch_public_record", resource)
when {
context.session.found_record_ids.contains(context.input.record_id) &&
context.session.live_fetches < 6
};
// The agent may only link a case to a project a search returned (by location or by name), or a record it fetched.
@id("select-only-located-candidates")
permit (principal == Agent::"investigator", action == Action::"select_project", resource)
when { context.session.candidate_ids.contains(context.input.project_id) };
// Findings are proposals: the grounding verifier decides what they are worth.
@id("record-findings")
permit (
principal == Agent::"investigator",
action in [
Action::"record_claim",
Action::"flag_missing",
Action::"propose_next_action",
Action::"finish"
],
resource
);
// A claim attributed to an official record must cite one.
@id("official-claims-need-citations")
forbid (principal, action == Action::"record_claim", resource)
when {
context.input.origin == "official_record" &&
(!(context.input has citations) || context.input.citations.isEmpty())
};
// Per-run budgets so a looping model cannot run up the Bedrock bill.
@id("reading-budget")
forbid (principal, action == Action::"read_document_page", resource)
when { context.session.call_count > 24 };
@id("claim-budget")
forbid (principal, action == Action::"record_claim", resource)
when { context.session.call_count > 30 };
@id("search-budget")
forbid (principal, action == Action::"search_documents", resource)
when { context.session.call_count > 16 };
policies/case-actions.cedar
// CivicProof · authorization policy for actions on a case
//
// Evaluated by the API (lib/authz.ts) before any state change.
// principal : Public::"anonymous" | Reporter::"<case id>" (holds the owner key) | Agent::"investigator"
// action : ViewCase | CreateCase | StartInvestigation | RecordSubmission | RecordResponse
// | AddNote | EditPacket | AddEvidence | ViewPrivateDocument | ViewPrivateDetails | ChangeStatus
// resource : Case::"<case id>"
// context : { is_owner, to_status, runs_for_case, max_runs_per_case, runs_today, max_runs_per_day }
@id("public-read-and-report")
permit (principal, action in [Action::"ViewCase", Action::"CreateCase"], resource);
// Investigating only reads public records, so anyone may start one, within budget.
@id("investigate-within-budget")
permit (principal, action == Action::"StartInvestigation", resource)
when {
context.runs_for_case < context.max_runs_per_case &&
context.runs_today < context.max_runs_per_day
};
// Only the person who filed the report records what happened in the real world.
@id("owner-records-real-world-actions")
permit (
principal is Reporter,
action in [
Action::"RecordSubmission",
Action::"RecordResponse",
Action::"AddNote",
Action::"EditPacket",
Action::"AddEvidence",
Action::"ViewPrivateDocument",
Action::"ViewPrivateDetails",
Action::"ChangeStatus"
],
resource
)
when { context.is_owner };
// The agent may move a case through investigation states and nowhere else.
@id("agent-investigation-states")
permit (principal == Agent::"investigator", action == Action::"ChangeStatus", resource)
when { ["investigating", "evidence_found", "case_prepared"].contains(context.to_status) };
// Nobody except the reporter can say a case was filed, answered, inspected, acted on,
// resolved, disputed or closed. Each of those is a fact about the world outside this system,
// and CivicProof has no way to observe any of them.
// This holds even if a future policy above is written too broadly.
@id("only-reporter-closes-the-loop")
forbid (principal, action == Action::"ChangeStatus", resource)
when { ["submitted", "awaiting_response", "response_received", "inspection_reported", "action_reported", "resolved", "disputed", "closed"].contains(context.to_status) }
unless { principal is Reporter && context.is_owner };
Security and privacy
- No secrets in the browser. The Gemini key is a server-side environment variable. AWS access comes from the Lambda execution role, scoped to one table, one bucket, Claude models on Bedrock, and Textract text detection (which has no resource-level permissions).
- Uploads are checked by magic bytes, size-limited, re-encoded in the browser, fingerprinted with SHA-256 and served from a private bucket through the app.
- Reporter contact details are stored with the case and never returned by any public API.
- Every input is validated with Zod on the server; error responses never include internals.
- A neutral-language guard stops the model writing accusations (“fraud”, “corruption”…) into findings and asks it to rephrase.
- Investigations are capped per case and per day (Cedar + DynamoDB counter), and each run has a tool-call and time budget.
Known limitations
- City coverage is thin: two city road packages have records (BBMP white-topping and Smart City Tender SURE). Every other project is a rural PMGSY road in Bengaluru Urban, Bengaluru Rural, Ramanagara, Chikkaballapura, Kolar or Tumakuru. Most have no published map line and are found by name. A location nothing matches gets an honest “not found” and an RTI draft, not a guess.
- Live search covers the government's PMGSY portal (OMMAS). The state procurement portal's tender search sits behind a captcha and is not used; BBMP's works-bill public view was not answering when this was built.
- City road alignments are traced from OpenStreetMap by road name, and whole roads are drawn where the tender covers only a reach. Distances for those projects are approximate.
- PMGSY maintenance windows are computed from the physical completion date and the programme guideline's 5-year rule. The individual contract was not available to confirm its terms.
- Contract agreements, completion certificates and measurement books are rarely published. Where they are missing, CivicProof asks for them rather than inferring them.
- Scanned uploads are OCR'd with Amazon Textract on the AWS deployment; running locally without AWS they are stored but cannot be quoted. Scanned pages in the shared corpus are not OCR'd.
- Complaint submission is manual. CivicProof drafts and tracks, but does not send anything to a government portal; there is no supported API to do so.
- The owner key is a bearer secret kept in the browser. Losing it means losing the ability to record updates for that case; there are no accounts.
- Rate limits are per server instance, backed by a daily DynamoDB counter and a Cedar budget policy. They protect the model bill, not against a determined attacker.
Where the records come from
PMGSY road lists and quality grades from OMMAS (Ministry of Rural Development), the PMGSY Programme Guidelines, BBMP tender and award records from the Karnataka Public Procurement Portal's public API, a Bengaluru Smart City status presentation, and a CAG performance audit. Each is listed with its link and hash.
Browse all records →