Agent contracts

Each agent has a pydantic Inputs model, a pydantic Outputs model, and a check_postconditions() static method. Contracts are pure-Python and do not invoke Claude — they describe what must be true after the agent claims done.

Reference: src/bfev/contracts/.

collection

Inputs client_yaml, project_pdf (user-provided EIES)
Outputs schema_xlsx, request_pdf
Hard rule both output files must exist
Refuses if client.yaml missing country or sector

simulate

Inputs schema_xlsx, source_pdf, client_yaml
Outputs filled_xlsx, provenance_yaml
Hard rule every row in provenance.yaml has source: pdf-page:N or source: default-assumption:RULE
Refuses if source PDF cannot be opened, or client.yaml.actors is empty

calculate

Inputs filled_xlsx, client_yaml, categories_json
Outputs aggregates_json, results_json, crosscheck_xlsx, meta_yaml, audit_log
Hard rules (1) aggregates.json contains scope_totals_kg_co2e and grand_total_kg_co2e; (2) crosscheck.xlsx exposes all 16 required columns; (3) co2e_kg, uncertainty_low_kg, uncertainty_high_kg cells are live formulas, not literals

The 16 required crosscheck columns (in order):

scope, category, sub_category,
activity_data, activity_unit,
emission_factor, ef_unit, ef_source,
gwp, gwp_horizon, gwp_source,
uncertainty_pct, uncertainty_source,
co2e_kg, uncertainty_low_kg, uncertainty_high_kg

The last three are formula cells written by bfev.crosscheck:

co2e_kg              = =D{r}*F{r}*I{r}           # AD * EF * GWP
uncertainty_low_kg   = =N{r}*(1-L{r}/100)        # co2e * (1 - σ%)
uncertainty_high_kg  = =N{r}*(1+L{r}/100)

Plus a SUMIF row per scope and a grand-total SUM.

report

Inputs aggregates_json, results_json, crosscheck_xlsx, client_yaml
Outputs executive_pdf, scientific_pdf, official_pdf, self_audit_json
Hard rules (1) all 3 PDFs present; (2) self-audit.json reports zero unsourced_numbers and zero unsourced_entities
Forbidden reading filled.xlsx, recomputing any number, introducing entities absent from client.yaml.actors or aggregates.json

orchestrator

Inputs client_yaml, aggregates_json, deliverables_dir
Outputs consistency_audit_json
Hard rules runs three mechanical checks, all must pass:
1. anti-leak: no phrase in ANTI_LEAK_PHRASES appears in any rendered PDF
2. crosscheck reconciliation: every scope total in aggregates.json equals the recomputed AD·EF·GWP sum from crosscheck.xlsx within ε (default 0.5 %)
3. entity allowlist: every entity in report/self-audit.json.entities_referenced is in client.yaml.actors

The orchestrator is the only component that sees both aggregates.json and the rendered PDFs. That separation is what makes the design safe: stage 4 can never silently lie about a number because the orchestrator will catch it.

Adding a new check

Drop a new function into bfev.audit that returns a CheckResult. Wire it into audit.run(). Write a test under tests/test_audit.py with both a passing and a failing case. No agent prompt changes needed.