# SAGE-Commons Architecture
## Supervisory Agentic Governance & Evidence Engine for UN Data Commons

**Release 0.1.0** · contract `sage-commons/assessment/v2` · ruleset schema 2.x · MIT

Three version strings appear in a record and only the first is a release. `contract_version` is the shape of an assessment; `ruleset.schema_version` is the version of the rules data, which the loader gates on and refuses to run outside.

> **SAGE-Commons** is an unauthenticated **remote MCP server** that any AI agent, on Claude, ChatGPT, Perplexity or Manus, connects to with one URL. It sits between the agent and the statistical claims it wants to make, resolves them against the UN System Data Commons knowledge graph across *every* publishing agency, and returns a verdict plus a re-verifiable Evidence Passport. It contains no language model. No figure is asserted without provenance.

---

## Table of Contents

1. [System Overview](#1-system-overview)
2. [Request Lifecycle, End-to-End Flow](#2-request-lifecycle--end-to-end-flow)
3. [Module Map](#3-module-map)
   - [Tool Surface](#3a-tool-surface)
   - [Source Scopes](#3b-source-scopes)
4. [The Comparability Gate](#4-the-comparability-gate)
5. [Evidence Passport & Hash Chain](#5-evidence-passport--hash-chain)
6. [Client Integration Matrix](#6-client-integration-matrix)
7. [State Precedence and Verdicts](#7-state-precedence-and-verdicts)
8. [Module Dependency Graph](#8-module-dependency-graph)
9. [Worked Scenarios (S1–S5)](#9-worked-scenarios-s1s5)
10. [Directory Structure](#10-directory-structure)

---

## 1. System Overview

SAGE-Commons sits as a **supervisory layer** between an AI agent and the numbers it is about to state. The agent calls it over MCP before asserting a statistic; the engine answers with evidence or with a refusal.

```mermaid
flowchart TB
    subgraph HOSTS ["🌐  AI Hosts (remote MCP connector, no auth)"]
        direction TB
        CL["Claude"]
        GPT["ChatGPT"]
        PPX["Perplexity"]
        MAN["Manus"]
        ANY["any MCP client"]
    end

    subgraph EDGE ["☁️  AWS (sage_commons/asgi.py)"]
        direction TB
        AGW["API Gateway HTTP API"]
        LAM["Lambda · python3.13 · arm64\nstateless · JSON responses"]
    end

    subgraph MCP ["⚙️  MCP Tool Surface  (sage_commons/server.py)"]
        direction TB
        T1["🔎  resolve_indicator"]
        T2["⚖️  ground_claim"]
        T3["⬚  check_coverage"]
        T4["📜  finalize_passport"]
        T5["✅  verify_passport"]
        T6["🔌  search · fetch  (ChatGPT shims)"]
    end

    subgraph CORE ["🧮  Deterministic Core, no LLM anywhere"]
        direction TB
        RES["resolver.py\n(group candidates by agency)"]
        GRD["grounder.py\n(facet-pinned, concurrent fetch)"]
        CMP["comparator.py\n(unit · scale · denominator\nconstraints · period · date)"]
        DIV["divergence.py\n(spread vs tolerance → verdict)"]
        COV["coverage.py\n(NO_EVIDENCE detection)"]
        LED["ledger.py\n(per-inquiry SHA-256 chain)"]
    end

    subgraph DATA ["💾  Sources & Artefacts"]
        direction TB
        UN["UN System Data Commons MCP\n(6 tools · protocol 2025-06-18)"]
        EXT["Google Data Commons\n(additional source · labelled EXTERNAL)"]
        RUL["rules/statistical-integrity.json\n(hash-pinned ruleset)"]
        S3["S3 · passports/{id}.json\n(write-once)"]
    end

    subgraph UI ["🖥️  Evidence Ledger"]
        PANEL["MCP Apps panel\nui://sage-commons/evidence-ledger.html"]
        MD["Markdown additional source\n(always present)"]
    end

    HOSTS -- "MCP over Streamable HTTP" --> EDGE
    EDGE --> MCP
    MCP --> CORE
    CORE --> UN
    CORE -.->|only if UN cannot answer| EXT
    CMP --> RUL
    DIV --> RUL
    LED --> S3
    MCP --> UI
    MCP -- "verdict + passport" --> HOSTS
```

---

## 2. Request Lifecycle: End-to-End Flow

Every claim passes through four stages. A failure at the comparability stage returns a refusal *before* any arithmetic is done.

```mermaid
sequenceDiagram
    autonumber
    participant User
    participant Agent as AI Agent
    participant MCP as SAGE-Commons
    participant RES as resolver
    participant GRD as grounder
    participant CMP as comparator
    participant UN  as UN Data Commons
    participant S3  as Passport Store

    User->>Agent: "What is maternal mortality in Nigeria?"
    Agent->>MCP: resolve_indicator("maternal mortality ratio")
    MCP->>RES: search + group by agency
    RES->>UN: search_indicators (never with `places`, upstream 500s)
    UN-->>RES: candidates
    RES-->>Agent: 3 agencies publish this → ground them together

    Agent->>MCP: ground_claim([3 DCIDs], country/NGA)
    MCP->>GRD: round 1, metadata for all, concurrently
    GRD->>UN: get_variable_metadata
    UN-->>GRD: facets · units · periods · provenances
    MCP->>GRD: round 2, observations, each pinned to its facet
    GRD->>UN: get_observations × 3 (asyncio.gather)
    UN-->>GRD: values

    MCP->>CMP: comparable?

    alt Structurally incomparable
        CMP-->>MCP: ⛔ hard mismatch (unit · age band · period)
        MCP-->>Agent: INCOMPARABLE + the offending dimension
        Note over MCP,Agent: No arithmetic is performed
    else Comparable
        CMP-->>MCP: ✅ (with any soft warnings)
        MCP->>MCP: spread vs tolerance
        alt Within tolerance
            MCP-->>Agent: ✅ CORROBORATED
        else Beyond tolerance
            MCP-->>Agent: ⚠️ CONTESTED, every source shown, none preferred
        end
    end

    MCP-->>Agent: + hash-linked receipt

    Agent->>MCP: finalize_passport([receipts])
    MCP->>MCP: verify each receipt · re-link chain · seal
    MCP->>S3: PutObject (IfNoneMatch, write once)
    MCP-->>Agent: passport_id + public URL + replay manifest
    Agent-->>User: verdict, every agency, citation
```

---

## 3. Module Map

Each module owns one responsibility and none of them calls a model.

**Retrieval.** `upstream.py` speaks the UN Data Commons MCP with the protocol pinned to 2025-06-18, parses SSE frames, gathers independent calls concurrently, and never sends a `places` argument, which the upstream rejects. `rest.py` does scoped inspection of already-resolved identifiers and refuses wildcards. `external.py` reaches Google Data Commons, `publishers.py` the World Bank Indicators API and the IMF DataMapper, `wpp.py` reads the World Population Prospects extract shipped with the package, and `websource.py` admits a figure an agent read off a publisher's own site, through a registry rather than a heuristic.

**Assessment.** `resolver.py` groups candidates by publishing agency. `grounder.py` pins each figure to its facet and resolves lineage from the graph rather than from assertion. `comparator.py` runs thirteen comparability dimensions and collects every failure before returning. `divergence.py` walks an eleven-state precedence machine and may return no verdict at all, which is a normal outcome rather than an error.

**Derivation.** `trend.py` computes within-series change and distance from a registered SDG target. `subgroups.py` reports the gradient inside one indicator along a single named dimension, and reports the indicator without that dimension separately as the total, never as one of the groups it summarises. `freshness.py` assesses coverage recency and reports uncertainty as published. Every derivation carries a transformation receipt with the ten fields the ruleset requires.

**Custody.** `canonical.py` canonicalises to an RFC 8785 subset and hashes numbers as strings so any language reproduces the digest. `ledger.py` chains checkpoints per inquiry. `passport.py` seals, and reports which verification levels actually ran. `store.py` writes passports once to S3 and holds receipts and admitted web figures so an agent carries only handles.

**Governance.** `ruleset.py` loads the rules data, gates its major version, and refuses to start rather than run under rules this build does not implement. `models.py` rejects unknown metadata keys, because a typo in a lineage field would otherwise present as unresolved lineage.

## 3a. Tool Surface

Nine tools, asserted at import: if one fails to register the server refuses to start rather than answering with a shorter list.

| Tool | Mode | Seals |
|---|---|---|
| `resolve_indicator` | discovery | no |
| `ground_claim` | verification | receipt |
| `analyze_trend` | derivation | receipt |
| `compare_subgroups` | derivation | receipt |
| `check_coverage` | discovery | no |
| `finalize_passport` | custody | passport |
| `verify_passport` | custody | no |
| `search` / `fetch` | citation shims | no |

It was fourteen. `assemble_evidence`, `project_scenarios`, `compile_advisory`, `record_web_source` and `inspect_identifier` were withdrawn, and their modules left on disk unregistered so the decision is reversible.

None of them was broken. Together they were a surface no agent could choose from: `search` and `resolve_indicator` called one function, `compile_advisory` and `finalize_passport` shared a trigger sentence and an input shape, and `assemble_evidence` pulled agents in with a capitalised imperative and then returned no verdict, so they called `ground_claim` afterwards anyway. Answering one question took five calls and about ten thousand tokens. An agent with a per-server rate limit spent its budget on the surface rather than the question and shipped half an answer, which is the failure mode this engine exists to prevent, arriving by a different road. Connecting now costs about 9,100 characters rather than 16,800.

A non-UN figure is admitted with `ground_claim(external_variable_dcid=…)`, one argument on a call already being made, rather than a twelve-parameter tool of its own.

One panel renders in-chat where a host supports MCP Apps, and adapts to the record rather than showing one layout for everything: the cohort ledger, a line with its target distance, a subgroup comparison with the total marked outside it. The same record is rendered on the site, so a host that cannot draw a panel costs nothing but a click.

The panel's `ui://` URI carries a fingerprint of its own content. Hosts cache app resources by URI, and this one was a fixed string while the panel was rewritten five times, so early clients kept drawing current records with a copy old enough to call them legacy.

`analyze_trend` and `compare_subgroups` each seal a receipt, so a trend or a gradient can be cited from a passport in its own right rather than only through the claim it describes.

### One envelope across every tool

Six tools carried `contract_version` and `ruleset`; three did not. Two exposed the inquiry as a block and four as a flat field. An agent reading a payload had to know which tool produced it, which is a small tax on every call and a large one on the tool nobody tested.

The two fields that are facts about the build rather than about the result, `contract_version` and `ruleset`, are stamped in one place, so a tool cannot forget them. A caller's own value always wins over the stamp. `inquiry_id` is copied from the passport block when only the block is present: the same value, never invented, with the block left untouched so the panel and the public record page keep reading it.

`operational_status` is deliberately **not** stamped. It is a fact about what happened, and defaulting it to `COMPLETE` is how a passport once described a finding the engine had routed to `NEEDS_REVIEW` as complete. A tool that does not state it has not run to completion, and a test names which tools must carry it.

Discovery and verification open no inquiry and claim none. A fabricated handle is worse than a missing field: an agent would pass it to `finalize_passport` and get a chain attesting to nothing. A tool that produced no observation of its own returns no `receipt_id` rather than an empty one, because "no receipt" and "a receipt I failed to find" are different things to an agent about to seal.

The alignment was additive and verified as such: every key every tool returned before is still returned.

## 3b. Source Scopes

Five, used together, because a cohort drawn from one scope inherits its assumptions silently. One of them is primary.

| Scope | Retrieval | Label | Note |
|---|---|---|---|
| UN System Data Commons | engine, MCP and scoped REST | publishing agency | **Primary.** Queried first on every assembly, through a verified identifier registry. |
| Google Data Commons | engine, REST | `EXTERNAL` | A second origin for concepts the UN graph also carries. |
| World Bank · IMF | engine, REST | publisher | Indicators API and DataMapper, both keyless. |
| UN World Population Prospects | package data | `UN Population Division` | The published projection, cited rather than computed. |
| Official web publication | connected agent | `WEB` | Admitted through a publisher registry, never fetched by the engine. |

UN publishers republish one another constantly, so a cohort of three agencies can still be one origin. `lineage` reports publisher count against independent origin count on every finding, and it is usually the most important line in the record.

### Attribution, and who is allowed to supply a figure

A figure the UN graph does not carry is still a figure somebody published. Reported as "not a UN figure" it is unattributed, which is not a weaker citation but no citation at all.

So every publisher the engine will accept from is registered with its organisation type and the mandate it publishes under: UN System bodies, intergovernmental organisations with a statistical mandate their members agreed to, and national statistical offices or the statutory publisher for a series. The five profile concepts the UN graph has no verified identifier for are attributed to World Bank WDI and IMF WEO by name, with the series, the mandate and the source URL beside each.

Commercial forecasters are refused rather than labelled. Not because their work is poor, but because the engine's claim is that every figure traces to an accountable official publisher, and one figure that cannot breaks that claim for every figure next to it.

### The verified identifier registry

The UN graph is the source this engine is built on, so reaching it cannot depend on guessing what an identifier means. An earlier attempt at mapping external identifiers by pattern resolved one in six, and the failure mode is worse than a miss: a DCID that resolves to the wrong indicator produces a figure that is real, citable and about something else.

So the registry in `statistical-integrity.json` holds only identifiers probed against the live graph, each filed under the concept its returned **name and unit** describe rather than the one its code suggests. Eighteen identifiers across fourteen concepts, hash-pinned with the rest of the ruleset.

One entry carries the whole argument. `undata/sdg/NY_GDP_PCAP` returns data for every country tried and reads like GDP per capita. It is *"Annual growth rate of real GDP per capita"*, unit `Percent`. Filed by its code it would have put a rate where a level belongs, which is the error that once produced "324% GDP growth in 2040" from compounding a rate as though it were a level. It is registered under `gdp_per_capita_growth`, and a test asserts it never appears under `gdp_per_capita`.

Where the graph and the registry disagree at retrieval time, the graph wins and the registry is the note: unit and provenance on every returned series are read from the response, not from the registry.

A concept with no verified UN identifier in this build is not silently filled from elsewhere. It is admitted through `ground_claim(external_variable_dcid=…)`, labelled `EXTERNAL`, and its lineage is resolved on the same terms as a UN figure's. It never substitutes for one, and the record says so rather than letting the distinction sit in an attribution nobody reads.

### The retrieval chain

Grounding a cohort is a fan-out: several publishers, several hosts, one request each, all of it inside one tool call. Three properties make that safe to run against endpoints that cost nothing to use and owe us nothing.

The tool that did the widest fan-out, `assemble_evidence`, is withdrawn. The retrieval machinery below is not: it is in `upstream.py` and `publishers.py` and every remaining tool runs on it.

**A budget, not a hope.** The gateway cuts at 29 seconds. Work runs against a wall-clock budget and whatever has not returned is reported as unretrieved rather than waited for. A Nigeria profile is 33 retrievals across four hosts: about 9 seconds on a cold container, and under a second once the IMF payload is cached.

**One request per publisher where the API allows it.** The IMF was the leg that made this necessary. DataMapper serves one indicator per request at roughly ten seconds each, so six concepts serialised behind a per-host limit exceeded the budget and a Germany profile came back PARTIAL. Two facts fixed it: the API accepts indicators slash-separated and returns them together, and it ignores the country in the path, returning all 229 regardless. So the chain issues one request for every IMF concept and caches the result by indicator, which means the first country in a container pays about sixteen seconds and every country after it pays nothing. Batching stays an implementation detail in the report: a failed batch is recorded against each concept that was in it, because a reader asked per concept.

**The primary source goes first.** Attempts take slots in the order they are issued, so the UN legs are queued ahead of the rest. A budget that runs short then costs a corroborating series, never the one the record is built on. That guarantee is ordering, not a preference stated in prose, and a test asserts it.

**Bounded concurrency, per host.** Two gates: one across the run, a tighter one per host. The per-host gate is the one that matters. Adding the UN graph took a Nigeria profile from 22 attempts to 33, and under a global cap alone two World Bank legs that answer fine on their own came back empty, because six concurrent requests all landed on one host. A source that fails under load we created is indistinguishable, in the report, from a source that has nothing, so the engine does not create it.

**Three silences, kept apart.** A publisher that answered with nothing is a coverage finding about that publisher. A request that failed is a retrieval failure. A request that never returned is neither. A publisher that catches its own timeout and returns an empty series counts as the second, not the first: the clients that do this now flag it, and the chain files it as a failure, because otherwise an IMF timeout reads as a country with no GDP. Collapsing them is the substitution the whole engine refuses, and it is what turns a slow request into a false claim that evidence does not exist. North Korea returns four concepts because it does not report GDP to the World Bank or the IMF; the report says that, rather than implying the figures are unknowable.

## 3c. The Panel

One panel, four record shapes. A cohort assessment, a country profile, one series over time and a gradient inside one indicator are different questions, and rendering them all as the same source table buries whatever the reader actually asked for. The engine says which shape it returned; the panel reads that and adapts the layout, the tab labels and the controls, while the verdict language, the caveat list and the audit trail stay identical because those are the same question every time.

| Record | What it draws |
|---|---|
| Cohort assessment | The verdict, every publisher's value including dissenting ones, divergence and lineage. |
| Country profile | A concept and publisher matrix, openable per concept into each publisher's series; the attribution table; the three silences kept visually apart. |
| Trend | The series as a line, the change, and the distance from an agreed target when one is registered. |
| Gradient | Subgroups as a comparison, with the national total drawn outside the gradient rather than among the groups it summarises. |

Two rules the drawing obeys. A sparkline answers "which way, how steadily" and nothing a reader would quote comes from it; the exact decimal string is always in a table and always on hover. And the zero baseline is included only when the data already sit near it, because forcing zero onto a series that lives between 31 and 36 flattens the difference the reader opened the panel for, while never including it exaggerates a small change. Neither default is honest on its own, so the rule is written down rather than silently chosen.

**Panels are parse-checked in CI.** A project-wide replace of the word "fallback" once hit three JavaScript identifiers in the evidence ledger, including a default parameter, and turned the whole script into a syntax error. Nothing failed loudly: the tool still returned, the resource still served, and the panel rendered an empty shell for every record. Every inline script is now parsed, and every record shape is rendered headlessly and asserted to produce content.

## 4. The Comparability Gate

The distinguishing component. Thirteen dimensions are checked **before** any two figures are placed side by side, and each returns one of four outcomes; not two.

`PASSED` · `FAILED` · `UNKNOWN` · `NOT_APPLICABLE`

That middle pair is the point. Collapsing "unknown" into "fine" is how an unstated denominator comes to read as an agreeing one. Unknown is now its own answer and it blocks assurance rather than passing quietly. `NOT_APPLICABLE` is separate again: an absolute count has no denominator to be *missing*.

```mermaid
flowchart TD
    IN(["figures[] from grounder"])
    IN --> N{"fewer than 2?"}
    N -- yes --> TRIV["trivially comparable"]
    N -- no --> D1

    D1["concept_definition\nis it even the same thing?"]
    D2["unit_family · unit_scale\ncount vs ratio · per 1k vs per 100k"]
    D3["denominator\nread from the unit AND the declared name"]
    D4["constraint_properties\nAGE · SEX · URBANIZATION · WEALTH"]
    D5["observation_period · reference_date\nP1Y vs P5Y · same period required"]
    D6["geographic_definition · measurement_basis\nrelease_vintage · coverage_definition"]
    D7["currency_price_basis · index_reference_base\nnominal vs PPP · reference base"]

    D1 --> D2 --> D3 --> D4 --> D5 --> D6 --> D7 --> AGG

    AGG["collect EVERY outcome\nnot just the first failure"]
    AGG --> CHK{"worst outcome?"}
    CHK -- FAILED --> STOP["⛔ INCOMPARABLE\nname the dimension\nNO arithmetic performed"]
    CHK -- UNKNOWN --> REV["⚠️ ROUTED TO REVIEW\nverdict null\nevery figure retained"]
    CHK -- PASSED --> GO["✅ proceed to lineage"]

    style STOP fill:#fce8e6,stroke:#b3261e
    style REV fill:#fdf3d8,stroke:#9a6700
    style GO fill:#e8f5ee,stroke:#1a7f52
```

**Why this matters.** Searching the live graph for "child stunting" returns `undata/sdg/SH_STA_STNTN` as an absolute **count** (11,438,100 children in Nigeria) alongside `undata/unicef/FD_STUNTING` as a **proportion**, for ages **2–4** rather than **under 5**. A model asked to reconcile those produces a confident, meaningless number. The gate refuses, and names both reasons.

**Where the metadata contradicts itself.** Two dimensions read more than one source of evidence, because the graph does not always agree with itself:

- `undata/unicef/MNCH_MMR` carries the unit `…PER_100000_COUNT_BIRTHS` while its own declared name reads *"per 100,000 **live** births"*. Reading the unit alone invents a mismatch with UNSD where none exists, so the **denominator is read from both** and the evidence source is recorded.
- UNDP HDRO labels the same MMEIG figure a `RATE` where UNSD and UNICEF label it a `RATIO`. At the same scale with a compatible denominator that is a vocabulary conflict, not evidence that two things measure differently; so it routes to review with both labels retained. An absolute count against a normalised measure still fails hard.

---

## 5. Evidence Passport & Hash Chain

Each inquiry carries its **own** chain, seeded from its identity and question. There is no global append-only log, two concurrent Lambda invocations would fork it, and a chain scoped to one line of reasoning is the more meaningful artefact anyway.

```mermaid
flowchart LR
    subgraph GEN ["Genesis"]
        G["sha256(canonical({inquiry_id, question}) ‖ GENESIS)"]
    end

    subgraph C1 ["Checkpoint 1, ground_claim"]
        P1["payload: verdict · figures · replay_calls"]
        H1["entry_hash = sha256(canonical(payload) ‖ prev_hash)"]
    end

    subgraph C2 ["Checkpoint 2, ground_claim"]
        P2["payload"]
        H2["entry_hash"]
    end

    subgraph SEALED ["finalize_passport"]
        BODY["body: verdict · findings · sources\nchain · replay manifest · ruleset sha256"]
        SL["seal: sha256(body) + optional HMAC"]
    end

    G -->|prev_hash| H1
    H1 -->|prev_hash| H2
    H2 --> BODY
    BODY --> SL

    style SEALED fill:#e8f5ee,stroke:#388e3c
```

**Canonical form**, the property that makes a passport verifiable by a stranger, in another language: UTF-8 JSON, keys sorted, no whitespace, and **every number carried as a string**. RFC 8785 specifies ECMAScript number formatting that most JSON libraries do not implement; one disagreeing digit breaks a hash with no way to distinguish that from tampering. Values are therefore hashed exactly as the source reported them, and all arithmetic runs on `Decimal`.

**What the chain proves.** That the record was not altered. **Not** that the figures are correct, and **not** that we issued it, anyone can compute SHA-256. That is why the passport carries a replay manifest: authenticity rests on reproducibility, not secrecy. A forged passport passes the hash check and fails the replay.

---

## 6. Client Integration Matrix

One URL, no credentials, no per-client build.

```mermaid
flowchart TB
    subgraph CLIENTS ["Add as a custom connector · Auth: None"]
        C1["Claude\nSettings → Connectors"]
        C2["ChatGPT\nDeveloper mode"]
        C3["Perplexity\nCustom remote connector"]
        C4["Manus\nCustom MCP server"]
    end

    subgraph CONTRACT ["What makes authless work"]
        A1["POST /mcp never returns 401"]
        A2["no WWW-Authenticate header, ever"]
        A3["404 on all four .well-known OAuth paths"]
        A4["405 on GET/DELETE /mcp"]
        A5["stateless, no Mcp-Session-Id required"]
    end

    subgraph SURFACE ["What each client gets"]
        S1["9 tools"]
        S2["Markdown verdict, always"]
        S3["Evidence Ledger panel, where MCP Apps is supported"]
        S4["search + fetch, ChatGPT citation shape"]
    end

    C1 --> CONTRACT
    C2 --> CONTRACT
    C3 --> CONTRACT
    C4 --> CONTRACT
    CONTRACT --> SURFACE
```

> **The 401 rule is the whole trick.** Claude decides whether to begin OAuth from the *status code*: a `401` carrying `WWW-Authenticate` starts the flow, and the `.well-known` documents are only consulted afterwards. Serving a partial OAuth metadata document is what drags a client into Dynamic Client Registration, which then fails against a server that has no sign-in service. An authless server must return 404 there; there is no "I am authless" document to publish.

---

## 7. State Precedence and Verdicts

`divergence.py` walks the ordered states the ruleset declares and stops at the first that applies. The order is normative and lives in the ruleset, not in the code.

**A verdict is nullable, and separate from execution state.** This is the single most important change from the earlier design, which had one axis: five verdicts, one of which had to be returned. So an upstream timeout, an unresolved denominator and a genuine absence of data were all squeezed into the same vocabulary, and the honest answer, *we could not establish this*, had nowhere to live except `NO_EVIDENCE`, which says something much stronger.

```mermaid
flowchart TD
    IN2(["figures[] + ComparabilityReport"])
    IN2 --> S1{"upstream failed?"}
    S1 -- yes --> E1["verdict null\nUPSTREAM_ERROR\nnever reported as absence"]
    S1 -- no --> S2{"known comparability\nFAILURE?"}
    S2 -- yes --> V4["⛔ INCOMPARABLE\n(only on a complete cohort)"]
    S2 -- no --> S3{"required metadata\nUNKNOWN?"}
    S3 -- yes --> R1["verdict null\n⚠️ NEEDS_REVIEW\nevery figure retained"]
    S3 -- no --> S4{"any eligible\nobservations?"}
    S4 -- no --> V5["⬚ NO_EVIDENCE\n(only if discovery completed)"]
    S4 -- yes --> S5{"lineage resolved?"}
    S5 -- no --> R2["verdict null\n⚠️ NEEDS_REVIEW\nindependence not demonstrated"]
    S5 -- yes --> S6{"≥ 2 independent\nORIGINS?"}
    S6 -- no --> V2["◽ SINGLE_SOURCE"]
    S6 -- yes --> S7{"active profile\nmatches?"}
    S7 -- no --> R3["verdict null\n⚠️ NEEDS_REVIEW\nno tolerance invented"]
    S7 -- yes --> Q4{"within threshold?"}
    Q4 -- yes --> V1["✅ CORROBORATED"]
    Q4 -- no --> V3["⚠️ CONTESTED"]

    style R1 fill:#fdf3d8,stroke:#9a6700
    style R2 fill:#fdf3d8,stroke:#9a6700
    style R3 fill:#fdf3d8,stroke:#9a6700
    style V3 fill:#fdf3d8,stroke:#9a6700
    style V4 fill:#fce8e6,stroke:#b3261e
```

### Publisher count is not independence

The step that surprises people. Three UN portals republishing one MMEIG estimate are **one origin**, not three sources agreeing; and the graph says so if you ask it. Over the REST surface, `undata/p/SDG` and `undata/p/UNICEF` both declare `isPartOf: United Nations Statistics Division`. Lineage is therefore grouped by origin and read from the graph rather than asserted, and a provenance with no record at all yields no origin, which correctly blocks a corroboration claim instead of inventing a source.

### There is no default tolerance

A verdict requires an **active profile** matched to a measurement signature. Only one is active:

| Profile | Status | Metric | Threshold |
|---|---|---|---|
| `exact_record_agreement` | **active** | absolute difference | `0` |
| `relative_positive_candidate` | candidate | relative to min, positive |, |
| `proportion_candidate` | candidate | percentage-point |, |
| `signed_quantity_candidate` | candidate | symmetric pairwise |, |

Candidates stay inactive until domain expert review, held-out calibration and an approved measurement signature are all recorded. Until then most cross-source questions return a null verdict and `NEEDS_REVIEW`; which is the honest answer, and the cost is measured rather than hidden: every null verdict carries an `unanswerable_reason`.

The earlier design shipped a 1% default and per-family numbers that were, candidly, chosen rather than calibrated. They are gone.

---

## 8. Module Dependency Graph

```mermaid
flowchart TB
    ASGI["asgi.py\n☁️ HTTP surface · Lambda handler\nauthless contract · lifespan"]

    ASGI --> SRV["server.py\n⚙️ 9 MCP tools · 1 adaptive panel · resources"]

    SRV --> RES["resolver.py\n🔎 agency grouping"]
    SRV --> GRD["grounder.py\n📥 figures + provenance"]
    SRV --> COV["coverage.py\n⬚ gap detection"]
    SRV --> PSP["passport.py\n📜 seal · render · verify"]
    SRV --> STO["store.py\n💾 S3 write-once"]

    RES --> UPS["upstream.py\n🌐 UN Data Commons MCP"]
    GRD --> UPS
    COV --> UPS
    UPS --> FIX["fixtures.py\n🎞️ record / replay"]
    GRD -.-> FBK["external.py\n🔶 EXTERNAL source"]

    GRD --> CMP["comparator.py\n⚖️ comparability gate"]
    CMP --> RUL["ruleset.py\n📘 statistical-integrity.json"]
    CMP --> DIV["divergence.py\n📊 spread → verdict"]
    DIV --> RUL

    PSP --> LED["ledger.py\n🔗 per-inquiry chain"]
    LED --> CAN["canonical.py\n#️⃣ canonical JSON + SHA-256"]
    PSP --> CAN
    DIV --> MOD["models.py\n📦 Figure · Finding"]
    CMP --> MOD

    SRV --> PANEL["ui/evidence-ledger.html\n🖥️ MCP Apps panel"]
```

---

## 9. Worked Scenarios (S1–S5)

The five verdicts, each validated against the live UN endpoint.

### S1: no verdict, routed to review *(the stage case)*

Nigeria, maternal mortality ratio, 2023. Three publishers.

```mermaid
flowchart TD
    A["resolve_indicator('maternal mortality ratio')"] --> B["4 agencies: sdg, undphdro, unicef, who"]
    B --> C["ground_claim(3 DCIDs, country/NGA, 2023)"]
    C --> D1["UNSD 992.82921 · per 100,000 live births"]
    C --> D2["UNICEF 992.83 · deaths per 100,000 births"]
    C --> D3["UNDP HDRO 1047.235352 · per 100,000, denominator unstated"]
    D1 & D2 & D3 --> E{"comparability gate"}
    E -->|"6 dimensions undeclared by at least one source"| F["no verdict"]
    D1 & D2 --> G["origin: UN Statistics Division"]
    D3 --> H["origin: none in the graph"]
    G & H --> I["lineage: 3 publishers, 1 independent origin, 1 unattributable"]
    F & I --> V(["verdict null · NEEDS_REVIEW"])
```

**What the engine returns, live:** `verdict: null`, `operational_status: NEEDS_REVIEW`.

Not because the figures are close or far, but because coverage definition, denominator, geographic definition, measurement basis, release vintage and unit family are not published by every source, and unknown is not agreement. The 5.48% spread between UNSD and UNDP HDRO is reported as an observed difference, explicitly not as a verdict.

The stronger finding sits underneath. UNSD and UNICEF both resolve to the UN Statistics Division, so they are one estimate republished rather than two agencies agreeing. UNDP HDRO has no provenance record in the graph at all, so its independence cannot be established either way. Three names, one origin, one unknown.

> **Do not rehearse this diagram.** Run the call. A document describing a verdict is evidence about the document; the engine is the only authority on what the engine returns, and this section has been wrong before.

### S2: INCOMPARABLE: the unit trap

```mermaid
flowchart LR
    S2(["'Child stunting in Nigeria'"])
    S2 --> G2["ground_claim\nSDG 11,438,100 (COUNT)\nUNICEF 31.5 (PROPORTION)"]
    G2 --> C2["comparator\n⛔ unit family: count ≠ proportion\n⛔ age band: Y0T4 ≠ Y2T4"]
    C2 --> V2(["⛔ INCOMPARABLE\nboth reasons reported\nno arithmetic attempted"])

    style V2 fill:#fce8e6,stroke:#b3261e
```

### S3: why CORROBORATED is hard to reach

An earlier version of this document showed UNSD 992.82921 and UNICEF 992.83 corroborating each other under a 2% tolerance. **That is false under the current ruleset**, in three separate ways, and it is kept here as a worked example of what v2 changed.

```mermaid
flowchart TD
    A["UNSD 992.82921"] --> D{"active profile?"}
    B["UNICEF 992.83"] --> D
    D --> E["exact_record_agreement · threshold 0"]
    E --> F{"992.82921 = 992.83 ?"}
    F -->|no| G["not exact agreement"]
    A & B --> H["both origin: UN Statistics Division"]
    H --> I["1 independent origin, not 2"]
    G & I --> V(["no corroboration"])
```

1. **There is no 2% tolerance.** `default_tolerance_pct` is null and every family profile is `production_enabled: false`. The only active profile is `exact_record_agreement` at threshold **0**. An uncalibrated threshold decides verdicts by accident, so v2 removed them rather than keeping convenient ones.
2. **These two values are not equal.** 992.82921 and 992.83 differ. Under a zero threshold that is not agreement.
3. **They are one origin.** Both publishers resolve to the UN Statistics Division, so even exact equality would be one estimate republished, not independent corroboration.

`CORROBORATED` requires two demonstrated independent origin groups agreeing under an active profile with all required metadata resolved. The engine has reached it where a national statistical register is admitted alongside the UN series: Germany's population from Destatis, UNICEF and UNIDO returns **3 publishers, 2 independent origins**.

### S4: SINGLE_SOURCE: nothing to corroborate against

```mermaid
flowchart LR
    S4(["one agency publishes it"])
    S4 --> V4(["◽ SINGLE_SOURCE\nusable, explicitly uncorroborated"])
```

### S5: NO_EVIDENCE: the silence verdict

```mermaid
flowchart LR
    S5(["indicator exists,\nplace not covered"])
    S5 --> C5["check_coverage\nwhich agencies · which years\nnearest containing geography"]
    C5 --> V5(["⬚ NO_EVIDENCE\nthe gap is the finding\nnever substituted"])
```

---

## 10. Directory Structure

```
supervisory-agentic-governance-evidence-engine-commons/
│
├── sage_commons/                  # The engine. No language model anywhere.
│   ├── asgi.py                    # HTTP surface, authless contract, Lambda handler
│   ├── server.py                  # 9 MCP tools, 1 adaptive UI panel, sage:// resources
│   ├── upstream.py                # UN Data Commons MCP client (protocol 2025-06-18)
│   ├── rest.py                    # Scoped REST inspection; wildcards refused
│   ├── external.py                # Google Data Commons, labelled EXTERNAL
│   ├── publishers.py              # World Bank Indicators API, IMF DataMapper
│   ├── wpp.py                     # UN World Population Prospects, published projection
│   ├── websource.py               # Admission gate for figures read off a publisher's site
│   ├── resolver.py                # Agency-grouped indicator resolution
│   ├── grounder.py                # Facet-pinned fetch, lineage from the graph
│   ├── comparator.py              # 13-dimension comparability gate
│   ├── divergence.py              # 11-state precedence, nullable verdicts
│   ├── forecast.py                # Scenario projection: level and rate method families
│   ├── advisory.py                # Brief structure and gap assembly
│   ├── freshness.py               # Coverage recency, coverage summary, uncertainty
│   ├── trend.py                   # Within-series change, SDG target gap
│   ├── subgroups.py               # Gradient inside one indicator
│   ├── coverage.py                # NO_EVIDENCE detection, containing geographies
│   ├── ledger.py                  # Per-inquiry SHA-256 chain
│   ├── canonical.py               # RFC 8785 subset; numbers hashed as strings
│   ├── passport.py                # Envelope, seal, verification levels, Markdown
│   ├── models.py                  # Figure and Finding; unknown keys rejected
│   ├── ruleset.py                 # Loads and version-gates the rules data
│   ├── store.py                   # S3 write-once passports, receipts, web figures
│   ├── data/wpp2024.json          # WPP extract: 237 countries x 3 variants x 77 years
│   ├── rules/                     # statistical-integrity.json, hash-pinned
│   ├── ui/                        # Three panels: ledger, scenarios, dossier
│   └── tests/                     # Acceptance matrix, 100+ cases
│
├── web/                           # Cloudflare Workers site
│   ├── index.html                 # Overview
│   ├── verify/                    # In-browser chain verification
│   ├── passport/                  # /passport/{id}, the citable record
│   ├── stage/                     # /stage/{id}, laid out for a projector
│   ├── architecture/              # This document, rendered
│   ├── record.js / record.css     # One renderer, shared by all three surfaces
│       └── ARCHITECTURE.md       # This file
│
├── tools/extract_wpp.py           # Rebuilds the WPP extract on a new revision
├── worker.js                      # Routes /passport/{id} and /stage/{id}
├── deploy/deploy.sh               # Lambda, IAM, S3, API Gateway
└── pyproject.toml                 # Release 0.1.0; engine installable without the server
```

## Key Design Principles

| Principle | Implementation |
|---|---|
| **Deterministic** | No model in the engine. Same inputs + same ruleset → same verdict, always |
| **Refuses rather than guesses** | `INCOMPARABLE` halts before arithmetic; `CONTESTED` never averages or ranks |
| **Silence is a result** | `NO_EVIDENCE` is a first-class verdict, gaps are never filled |
| **Provenance is mandatory** | Every figure carries its agency, unit, year, DCID and facet id |
| **Verifiable by a stranger** | Canonical JSON + replay manifest; the verifier runs in the browser |
| **Stateless by construction** | Per-inquiry chains, agent-carried receipts, no session, no fork on concurrency |
| **Degrades gracefully** | Markdown verdict always present; the UI panel is a bonus, never the only copy |
| **External data is labelled** | Non-UN figures stamped `EXTERNAL` and never substituted for a UN source |
| **Rules are reviewable data** | Tolerances live in versioned JSON a statistician can read, not in code |

---

*Generated from the live repository · SAGE-Commons Engine · MIT License*
