Wikimedia communities each define who may vote, in their own words, on their own policy
pages. canivote answers one question over HTTP — does this account meet this policy? —
and shows its working: the community's original wording, an English rendering, per-rule
pass/fail, and the exact API queries used, so anybody can reproduce the verdict by hand.
It is a small, standalone, API-first service — no database, no OAuth, no accounts, no
background jobs. Everything it reads is public. wiki-polis
is the first consumer, but canivote is not a wiki-polis component and does not inherit
that project's operational patterns.
The full machine-readable contract for every endpoint is openapi.json,
served at /openapi.json (OpenAPI 3.1). What follows is its /check in prose.
GET /check?user=<name>&policy=<policy id>
$ curl 'https://canivote.toolforge.org/check?user=Jimbo Wales&policy=meta-global'
{
"verdict": "eligible",
"user": "Jimbo Wales",
"policy": "meta-global",
"reason": "Meets every rule that can be checked automatically.",
"checked_at": "2026-09-07T12:00:00+00:00",
"criteria": [
{
"metric": "is_blocked",
"label": "not blocked site-wide on Meta-Wiki",
"scope": "meta.wikimedia.org",
"operator": "is",
"required": {
"value": false,
"display": "not blocked site-wide on Meta-Wiki"
},
"observed": {
"value": false,
"display": "not blocked site-wide on Meta-Wiki"
},
"passed": true,
"source": "platform"
},
{
"metric": "is_globally_locked",
"label": "not globally locked",
"scope": "global",
"operator": "is",
"required": {
"value": false,
"display": "not globally locked"
},
"observed": {
"value": false,
"display": "not globally locked"
},
"passed": true,
"source": "policy"
},
{
"metric": "is_globally_blocked",
"label": "not globally blocked",
"scope": "global",
"operator": "is",
"required": {
"value": false,
"display": "not globally blocked"
},
"observed": {
"value": false,
"display": "not globally blocked"
},
"passed": true,
"source": "policy"
}
],
"policy_text": {
"language": "en",
"original": "Global locks prevent an account from logging in to any Wikimedia wiki.",
"english": "Your account must not be globally locked or globally blocked.",
"sources": [
"https://meta.wikimedia.org/wiki/Global_locks",
"https://meta.wikimedia.org/wiki/Global_blocks"
],
"verified": "2026-09-01"
},
"queries": [
"https://meta.wikimedia.org/w/api.php?action=query&list=users&ususers=Jimbo+Wales&usprop=groups%7Cblockinfo%7Cregistration&format=json&formatversion=2&maxlag=5",
"https://meta.wikimedia.org/w/api.php?action=query&meta=globaluserinfo&guiuser=Jimbo+Wales&format=json&formatversion=2&maxlag=5",
"https://meta.wikimedia.org/w/api.php?action=query&list=globalblocks&bgtargets=Jimbo+Wales&bglimit=1&format=json&formatversion=2&maxlag=5"
]
}Field notes:
metricis the bare identifier;labelis that metric with its parameters applied. They are not one-to-one — German Wikipedia's policy has twoedit_countrules whose labels differ ("article-namespace edits on German Wikipedia" and "...in the 12 months before that"), because they measure different quantities. Switch onmetric; showlabel.sourcesays where a criterion came from:policyfor something the community wrote,platformfor a condition Wikimedia imposes whoever is asking (a global lock, a site-wide block). A policy that states one of the platform rules itself keeps its own version, and it is markedpolicy.scopeis where the measurement applies: a wiki hostname such asde.wikipedia.org, orglobalfor anything CentralAuth answers (locks and global blocks). Group by it to say which set of requirements someone fails.observed.boundedappears only on counts. It means counting stopped once the threshold was met, so the real number is that or higher — we do not look further than the question needs.verdictis"eligible","not_eligible", or"indeterminate". A rule that could not be checked (seepassed: nullbelow) is not the same as a rule that failed, and a single boolean cannot say so — collapsing them would discard the one distinction this design exists to keep.- Each entry in
criteriahaspassed: true,passed: false, orpassed: null.nullmeans the rule is unmeasurable for this account (for example, an account old enough to predate MediaWiki's registration logging) — it is scored as neither a pass nor a fail. observed.bounded: truemeans counting stopped once a threshold was met — the real number is this value or higher, not necessarily exact. Counting-to-a-cap is what makes a check with a large edit count cost one request instead of a full contributions scan;boundedis how that gets communicated back rather than hidden.requiredandobservedcarry both a machine-readablevalueand a rendereddisplaystring, so a consumer can compute ("you need 43 more edits"), localise, or chart, without having to parse English prose back into numbers.policy_text.verifiedis the date the policy's source pages were last read by a human. If that was a long time ago, the wording here may no longer match what the wiki actually says — the sources are listed so you can check.querieslists the exact upstream API URLs used to reach the verdict. Anyone can open one in a browser and see exactly what this tool saw.
GET /policies— every policy this tool knows, with a community's own wording and the rules a person wrote from it. Lets you check that translation without running a single query.GET /health— for uptime checks.GET /— name, repository, and a list of endpoints.
It does not read policy pages, and it does not interpret prose. A person reads a
community's page and writes the machine-readable rules; the tool only executes
them. original_text and sources travel with every verdict so a reader can
audit that person's translation — they are evidence for the reader, never input
to the program.
That boundary is deliberate. A tool that inferred rules from wikitext would
leave nobody able to tell whether a verdict reflected the community's rule or
the tool's reading of it, which is the one thing this design exists to avoid.
Clauses that cannot be mechanised are carried as text under not_checked
rather than approximated.
A policy anchors on its own instant — "150 mainspace edits by 1 November", "two
weeks before the vote opened". Pass as_of to measure against it:
/check?user=Example&policy=enwiki-arbcom&as_of=2026-11-01T00:00:00Z
Without it, everything is measured from now, which quietly answers a different question: asked on 15 November, "by 1 November" becomes "by 15 November" and a fortnight of edits the policy excludes are counted.
A future as_of is allowed — asking whether you will be eligible when voting
opens is the ordinary case. The response reports checked_at (when we ran) and
as_of (what we measured against) separately.
/check is rate limited. A request over the limit gets
429 Too Many Requests with a Retry-After header, and makes no upstream call to
Wikimedia — the limit is checked before anything is asked of the MediaWiki API, not
after.
The ceiling is set from what Wikimedia's API allows a well-behaved anonymous client, divided by the number of calls one check costs. If you need a higher allowance for a legitimate use, open an issue rather than working around it.
Everything a wiki's eligibility rule decomposes into lives in policies.yaml, one entry
per policy. It carries the community's wording verbatim, an English rendering of it, and
the rules that wording becomes — a reviewable diff, not a code change. /policies serves
this file directly, so what is written there is what the public sees.
A rule is one metric (see metrics.py for what can be measured — edit counts, time
since first edit, block/lock state, user groups, and so on), one operator (at_least,
more_than, at_most, fewer_than, is, includes, excludes), and one value.
Durations can be a plain integer of days or {amount: N, unit: days|weeks|months|years}.
Not everything is modellable: a clause requiring human judgement (e.g. "excluding
vandalism") should not be silently dropped. List it under that policy's not_modelled
instead, so a verdict never looks more complete than it actually is.
Every operator works with edit_count. Counting stops at a cap, and the cap is widened
by one row for more_than, at_most and is — enough to distinguish "exactly N" from
"more than N". What load_policies() refuses at import time is a threshold the API
cannot count to: the ceiling is 500 rows, so a policy demanding 600 edits is rejected at
startup rather than failing every voter at request time while reporting "at least 500".
The response body is entirely public data. The access log is not: it is the one place that pairs who asked with whom they asked about — a fact this tool creates and that does not exist anywhere else. Application logs deliberately keep usernames out for this reason; be careful not to reintroduce them (for example, in error messages or debug logging) if you're extending this code.
Pathoschild's AccountEligibility answers a related question for enwiki RfAs and is worth knowing about if you're working in this space. No code from it was copied into this project.
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt pytest ruff
.venv/bin/pytest -q
.venv/bin/ruff check .Tests replay JSON fixtures recorded from the live MediaWiki API (tests/fixtures/) and
make no network calls themselves. See tests/_record_fixtures.py to re-record them.
MIT — see LICENSE.