Skip to content
CivicProof

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.

CivicProof architecture on AWSThe browser calls a Lambda Function URL in response-streaming mode. Inside Lambda, the Next.js app runs a Strands agent whose tool calls are authorized by Cedar and whose claims are checked by a verifier against the bundled records corpus and records it fetches live from official portals, archived in S3. Lambda calls Gemini (key in Secrets Manager) with Amazon Nova on Bedrock as fallback, Textract for OCR, Amazon Location for the road at a pin, reads and writes DynamoDB, and sends metrics to CloudWatch.BrowserReport form, map,case page, packetHTTPSAWS Lambda · Function URL (response streaming)Next.js 16 standalone server via Lambda Web Adapter · deployed with AWS SAMAPI routesValidation (Zod), rate limits,NDJSON event streamStrands agent loopModel proposes a tool call;hooks trace every stepCase serviceOwner key, timeline,packets, PDFCedar policiesagent-tools.cedar · case-actions.cedardeny by default, per-run budgetsTools + grounding verifierfind_projects_near, search/fetch_public_record,record_claim → quote must appear verbatimon the cited page, or the claim is unverifiedRecordsbundled corpus: SHA-256 checked, projectalignments (GeoSadak, OSM); plus recordsfetched live, archived with URL and hashauthorizeGemini · Amazon NovaGemini runs the agent; Nova onBedrock takes over if it failsOfficial portals (live)OMMAS PMGSY road lists,searched and fetched by the agentAmazon S3photos, PDFs, archive offetched recordsAmazon TextractOCR for scanned uploads(RTI replies, letters)Location · Secrets Managerroad name at the pin;the Gemini keyAmazon DynamoDBcases (optimistic locking)daily run counters (TTL)Amazon CloudWatchlogs, per-run metrics (EMF),dashboard, alarmtool useOutside AWS: the Gemini API, the official portals, and OpenFreeMap vector tiles. OpenStreetMap Nominatim replaces Amazon Location only when running locally.

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.

  1. 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.

  2. 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).

  3. 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.

  4. 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.

  5. 05Gaps

    flag_missing names what could not be established and the record that would settle it; these become the RTI application.

  6. 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 →