Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion crates/api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ latest run whose sweep has *finished*, never an in-flight one.
| `GET /api/runs/{id}/rates` | The headline output: per rung, the count of each status (`pass`/`fail`/`skipped`/`error`/`unclaimed`), plus `agent_count` as the shared denominator. `GROUP BY rung, status` over one run, backed by `idx_check_results_rates`. |
| `GET /api/agents?run=&chain=&rung=&status=&limit=&offset=` | The directory, one page at a time: `{items, page:{limit,offset,total}}`. `rung`+`status` filter to e.g. "everything failing rung 4"; `limit` clamped to 500 (default 100). `run` defaults to the latest completed run. |
| `GET /api/agents/{chain}/{id}?run=` | One agent: its snapshot, every rung this run asked (in rung order, with full evidence), and the HTTP archive summary (status, content-type, size, sha256, final URL — never the body). `run` defaults to the latest completed run. |
| `GET /api/search?q=&runs=` | One `q` (same match semantics as `/api/agents?q=`) across several caller-named runs — `runs` is comma-separated UUIDs, both parameters required, at most 16 runs. Returns `[{run_id, chain, total, items}]` in the caller's order: `total` counts every match in that run, `items` is the top 5 by the shared relevance, each the same shape as a directory row. Unknown run ids are dropped, not fatal. The only endpoint that spans runs — and it never blends their rows. |
| `GET /api/methodology` | `spec_commit`, `checker_version`, `schema_version`, and the rung-4 required-field list — re-exported from `checks`, never restated. |
| `GET /api/healthz` | Liveness: process up + Postgres reachable. Not `/healthz` — that path is reserved on Cloud Run and never reaches the container. |

Expand All @@ -40,7 +41,8 @@ latest run whose sweep has *finished*, never an in-flight one.
| `src/error.rs` | `ApiError` + `IntoResponse`/`From` impls (so handlers can `?`). `NotFound` → 404, `BadRequest` → 400, `Internal` → 500. |
| `src/routes/runs.rs` | `GET /api/runs`, and `latest_completed` — the shared "fill in a missing `run=`" lookup every other handler calls into. |
| `src/routes/rates.rs` | `GET /api/runs/{id}/rates` — the one permitted aggregate: population counts, never a per-agent one. |
| `src/routes/agents.rs` | The directory and single-agent detail. |
| `src/routes/agents.rs` | The directory and single-agent detail, plus `q_match_sql`/`q_relevance_sql` — the one definition of what `q` matches, shared with `/api/search`. |
| `src/routes/search.rs` | `GET /api/search` — cross-run search, grouped per run. The caller names the runs because "canonical" is decided by the web repo's `published-runs.json`, not by this API. |
| `src/routes/methodology.rs` | The provenance constants and rung-4 field list, served as data. |

## Design notes
Expand All @@ -58,6 +60,9 @@ latest run whose sweep has *finished*, never an in-flight one.
- **Every query is run-scoped.** `agent_snapshots` and `check_results` are
both keyed by `run_id`; blending rows from two different runs would compare
an agent to itself across two different points in time as if they were one.
`/api/search` reads several runs in one request, but its results stay
partitioned per run — the rule is about blending, not about how many runs a
response may mention.
- **Hardening.** 10s request timeout, 256 in-flight request cap, clamped page
sizes. Per-IP rate limiting is a fast-follow.

Expand Down
7 changes: 6 additions & 1 deletion crates/api/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,12 +81,17 @@ async fn main() -> anyhow::Result<()> {
let app = Router::new()
// JSON API — chain is part of every agent identity path, and every
// endpoint below is scoped to one run (explicit `?run=`, or the
// latest completed one).
// latest completed one), except `/api/search`, which spans several
// runs precisely by keeping them in separate groups.
.route("/api/runs", get(routes::runs::list))
.route("/api/runs/{id}/rates", get(routes::rates::get))
.route("/api/runs/{id}/findings", get(routes::findings::get))
.route("/api/agents", get(routes::agents::list))
.route("/api/agents/{chain}/{id}", get(routes::agents::get_one))
// The one read endpoint that spans runs — the caller names them (the
// canonical set lives in the web repo, not here), and results stay
// grouped per run. See `routes::search`.
.route("/api/search", get(routes::search::get))
.route("/api/methodology", get(routes::methodology::get))
// The one endpoint that writes nothing and reads no run: it judges a
// document the caller supplies, with the same checker the sweep uses.
Expand Down
62 changes: 48 additions & 14 deletions crates/api/src/routes/agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ pub struct ListParams {
/// linkable than this one.
pub facet: Option<String>,
/// Free-text search over the document's `name` and `description`, or an
/// owner-address prefix. See `search_sql` below for why the owner is
/// owner-address prefix. See [`q_match_sql`] for why the owner is
/// matched by prefix rather than folded into the full-text vector.
pub q: Option<String>,
#[serde(default = "default_limit")]
Expand Down Expand Up @@ -104,6 +104,42 @@ fn parse_facets(raw: &str) -> ApiResult<Vec<(i16, String)>> {
Ok(out)
}

/// The one definition of what `q` matches, over the aliases every caller
/// binds: `s` = `agent_snapshots`, `d` = `agent_documents` (LEFT JOINed on
/// run/chain/agent). Three OR'd forms: full text over name+description (the
/// generated `search` column, GIN-indexed), trigram similarity on the name
/// (the "typed it slightly wrong" case), and an owner-address prefix — prefix
/// rather than folded into the full-text vector because an address is one
/// 42-char token nobody types in full, and `LIKE 'prefix%'` is what
/// `idx_snapshots_owner` can serve.
///
/// The agent's on-chain `agentWallet` is deliberately NOT matched: no table
/// stores it (rung 1 evidence carries `ownerOf`, not `getAgentWallet` — see
/// `analysis/payments-per-chain.md` on why it cannot be reconstructed from
/// events either). Matching it means a sweep-time `getAgentWallet` read into
/// a real column first, not a scan of evidence JSONB here.
///
/// `p` is the SQL placeholder (e.g. `"$7"`), passed in because the two
/// endpoints that share this fragment bind it at different positions. Shared
/// verbatim by `/api/agents` and `/api/search` so "a match" can never mean
/// two different things depending on which box the text was typed into.
pub(crate) fn q_match_sql(p: &str) -> String {
format!(
"({p}::text IS NULL \
OR d.search @@ plainto_tsquery('simple', {p}) \
OR d.name % {p} \
OR s.owner LIKE lower({p}) || '%')"
)
}

/// The relevance both search-ordered queries sort by. Negated because ASC is
/// the only direction that keeps `/api/agents`' NULL-search branch (a
/// constant 0) sorting consistently, and `/api/search` inherits the sign so
/// the two endpoints rank identically.
pub(crate) fn q_relevance_sql(p: &str) -> String {
format!("-similarity(coalesce(d.name, ''), {p})")
}

/// One rung's bare status for an agent in the directory — no evidence (that
/// is the detail endpoint's job), just the shape a directory row needs to
/// show all seven questions side by side. A rung this run never asked is
Expand Down Expand Up @@ -223,11 +259,12 @@ pub async fn list(
// matching `check_results` row. Counting matched facets and requiring
// the count to equal `cardinality` is what makes this AND rather than
// OR. `idx_check_results_lookup` backs the correlated subquery.
// 3. Free text ($7).
// 3. Free text ($7) — the shared `q_match_sql` fragment, spliced in by
// `format!` below.
//
// None of these folds a per-agent count into a verdict — they select WHICH
// agents appear, never how well any one of them did.
let filter_sql = "WHERE s.run_id = $1 \
let filter_head = "WHERE s.run_id = $1 \
AND ($2::text IS NULL OR s.chain = $2) \
AND ( \
($3::smallint IS NULL AND $4::text IS NULL) \
Expand All @@ -248,22 +285,19 @@ pub async fn list(
AND c.rung = f.rung AND c.status = f.status \
) \
) = cardinality($5::smallint[]) \
) \
AND ( \
$7::text IS NULL \
OR d.search @@ plainto_tsquery('simple', $7) \
OR d.name % $7 \
OR s.owner LIKE lower($7) || '%' \
)";
let filter_sql = format!("{filter_head} AND {}", q_match_sql("$7"));

// Relevance first when searching, and only then — an unsearched directory
// stays in the stable (chain, agent_id) order that makes pagination
// reproducible. `similarity` is negated because ASC is the only direction
// that keeps the NULL-search branch's constant 0 sorting consistently.
let order_sql = "ORDER BY \
// reproducible. See `q_relevance_sql` for why the similarity is negated.
let order_sql = format!(
"ORDER BY \
CASE WHEN $7::text IS NULL THEN 0::real \
ELSE -similarity(coalesce(d.name, ''), $7) END, \
s.chain, s.agent_id";
ELSE {} END, \
s.chain, s.agent_id",
q_relevance_sql("$7")
);

let items_sql = format!(
"SELECT s.chain, s.agent_id, s.owner, s.agent_uri, s.block_number, s.observed_at, d.name \
Expand Down
4 changes: 4 additions & 0 deletions crates/api/src/routes/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
//! * [`findings`] — the same population, cross-cut into the handful of
//! numbers the census leads with. Still counts, still no score.
//! * [`agents`] — the directory and single-agent detail.
//! * [`search`] — one `q` across several caller-named runs, grouped per
//! run. The only endpoint that touches more than one run, and it still
//! never blends their rows.
//! * [`methodology`] — the provenance constants and rung-4 field list, as data.
//! * [`validate`] — the pre-flight checker: judge a draft document before it
//! is minted, using `crates/checks` unmodified.
Expand All @@ -24,5 +27,6 @@ pub mod findings;
pub mod methodology;
pub mod rates;
pub mod runs;
pub mod search;
pub mod subscribe;
pub mod validate;
Loading