Checks
Every check the validator can report, with a stable anchor. Error messages link here, so these URLs do not move.
62 checks catalogued · 42 implemented
Discovery
Manifest reachable over HTTPS as JSON
- Rule
- The manifest MUST be served over HTTPS with Content-Type: application/json.
- Fix
- Serve the manifest over HTTPS with a JSON content type, and check it from outside your network.
Soft-404: a discovery path answering 200 + text/html is absent
- Rule
- A manifest is JSON. HTML at a discovery path means an SPA catch-all is answering — the manifest is absent, not present.
- Fix
- Exclude the discovery path from your catch-all so the file is served instead of your app shell.
robots.txt returns 200 text/plain
- Rule
- L2+ preconditions include a real robots.txt.
- Fix
- Serve a real robots.txt at the root with a text/plain content type.
Manifest at the RECOMMENDED path, or a <link rel> advertisement
- Rule
- The manifest SHOULD live at /.well-known/cabuya.json, or be advertised with <link rel="cabuya">.
- Fix
- Move the manifest to /.well-known/cabuya.json, or advertise its location with a <link rel="cabuya-manifest">.
Manifest validates against manifest.schema.json
- Rule
- The manifest MUST conform to its schema.
- Fix
- Correct the manifest against the published schema; the finding names the failing property.
publisher.canonical_url matches the registry entry
- Rule
- A registered publisher’s manifest MUST agree with its registry entry about its canonical URL.
- Fix
- Make the canonical URL in the manifest match the one in your registry entry, or open a pull request to change the entry.
Every feeds[].url is absolute HTTPS and reachable
- Rule
- Declared feeds MUST resolve.
- Fix
- Give every feed an absolute https URL that resolves from outside your network.
crawl_policy_url resolves
- Rule
- The declared crawl/reuse policy SHOULD be fetchable — consumers must honor it.
- Fix
- Point crawl_policy_url at a page that exists, or remove the field.
conformance_target does not exceed the measured level
- Rule
- conformance_target is a declaration; it MUST NOT claim more than this run measures. Reported as a mismatch, never as the level.
- Fix
- Lower conformance_target to the level you actually reach, or fix what is blocking the level you claim. The target is an intention, not a claim.
Envelope
The document does not conform to its published JSON Schema
- Rule
- Every feed and manifest MUST validate against its versioned schema.
- Fix
- Correct the value so it satisfies the schema. The finding gives the pointer and the expected shape.
Envelope required fields present and well-typed
- Rule
- last_updated, ttl, version, publisher_id and license are REQUIRED on every feed envelope.
- Fix
- Add the missing envelope field. All five are required on every feed.
last_updated is RFC 3339 with a UTC offset
- Rule
- The generation timestamp MUST be unambiguous.
- Fix
- Write last_updated as an RFC 3339 timestamp with an explicit UTC offset, e.g. 2026-08-17T14:02:00Z.
license present
- Rule
- An unlicensed feed does not conform — absence blocks every consumer’s legal review.
- Fix
- Declare a licence. An unlicensed feed blocks every consumer’s legal review, which is a harder problem than a missing field.
license is an SPDX id, or license_url accompanies it
- Rule
- A machine-resolvable licence SHOULD be used.
- Fix
- Use an SPDX identifier, or keep the custom string and add license_url pointing at the terms.
permitted_use present, values within the closed enum
- Rule
- Consent-to-reuse travels in the envelope: display | aggregate | redistribute | ai_answer | ai_train.
- Fix
- Add permitted_use with values from the closed enum: display, aggregate, redistribute.
version is a supported spec version
- Rule
- Supported versions span at most two MAJORs.
- Fix
- Set version to a supported spec version. Consumers use it to decide how to read the rest.
Access-Control-Allow-Origin: * present
- Rule
- The one non-obvious MUST: without it every browser-based consumer needs a proxy.
- Fix
- Send Access-Control-Allow-Origin: * on the feed. Without it a browser client cannot read you at all.
ttl is a positive, plausible integer (1–86400)
- Rule
- ttl is the caching contract; implausible values break polling consumers.
- Fix
- Set ttl to a positive number of seconds between 1 and 86400. It is the caching contract, not a hint.
Feed ≤ 5 MB and ≤ 10 000 records, or shards declared
- Rule
- Beyond the size guidance, publishers SHOULD shard by municipality and declare shards in the manifest.
- Fix
- Shard the feed and declare the shards, or reduce it below 5 MB and 10 000 records.
Content-Type: application/json, UTF-8
- Rule
- Transport hygiene.
- Fix
- Serve the feed as application/json with UTF-8. A text/plain feed is one many clients will refuse.
Record
last_confirmed_at KEY present on every record (null is legal)
- Rule
- The confirmation key is REQUIRED; null is the honest "never confirmed". Omission is not.
- Fix
- Include last_confirmed_at on every record. If nobody has confirmed it, publish null — that is the honest answer and consumers know what to do with it. Never invent one.
id matches the {publisher_id}:{local_id} shape
- Rule
- Record identity is {publisher_id}:{local_id} — globally unique with zero coordination.
- Fix
- Use the {publisher_id}:{local_id} form for the record id, with your own publisher id.
No minting in another publisher’s namespace
- Rule
- A publisher MUST NOT mint ids in another publisher’s namespace.
- Fix
- Mint ids only in your own namespace. Reference another publisher’s record with same_as instead.
Locator rule: address_text OR lat+lon present
- Rule
- A place you cannot locate directs no one.
- Fix
- Give the record an address_text, or a lat and lon pair, or both. A place nobody can find is not a place.
Both locators present (RECOMMENDED)
- Rule
- Address and coordinates together survive more consumer contexts than either alone.
- Fix
- Add the second locator. Coordinates and an address answer different questions for a person on the ground.
public_url present and absolute
- Rule
- Link-out is the contact mechanism — contact values never travel.
- Fix
- Add an absolute public_url pointing at your own page for this place, so a consumer can hand the reader back to you.
place_kind within the enum
- Rule
- The shared vocabulary is what makes crosswalks possible; unknown kinds use other + a namespaced extension.
- Fix
- Use a place_kind from the enum, and put your own vocabulary in place_kind_ext alongside it.
municipality_code is a valid DIVIPOLA code
- Rule
- Territorial coding is DIVIPOLA; publishers keep their raw string in municipality_text.
- Fix
- Use the official DIVIPOLA code for the municipality. The shape is five digits.
source{} present with source_id
- Rule
- Provenance is structured, never prose — attribution and chains depend on it.
- Fix
- Add source with a source_id, so attribution survives every hop the record makes.
CR-2: name contains no operational-state token
- Rule
- Names MUST NOT encode operational state — state belongs in lifecycle_status / service_status.
- Fix
- Take the operational state out of the name and put it in service_status. A name that says CLOSED is a name that is wrong the moment it reopens.
name and status fields do not contradict each other
- Rule
- A record that says two things about its own state is a record a consumer cannot render honestly.
- Fix
- Reconcile the name and the status fields. When they disagree a consumer has to guess, and it will guess wrong half the time.
CR-1: updated_at is not a reused last_confirmed_at
- Rule
- An edit is not a confirmation; the two timestamps do not interconvert.
- Fix
- Set updated_at to when the row changed and last_confirmed_at to when a human verified it. Reusing one for the other tells consumers a place was checked when it was only edited.
expires_at set on inherently temporary place kinds
- Rule
- Temporary places SHOULD declare when they stop being true.
- Fix
- Set expires_at on a place that is temporary by nature, so consumers can stop showing it without asking you.
same_as entries are fully-qualified and one-hop
- Rule
- same_as is a one-hop, non-transitive claim — never a transitive chain.
- Fix
- Write same_as entries as fully-qualified {publisher_id}:{id} references, one hop only, and never as an authority claim.
Unknown members are preserved, never rejected
- Rule
- Extensibility applies to the validator first: an unknown member MUST NOT fail validation.
- Fix
- Preserve unknown members rather than dropping them. A field you do not understand may be the one another consumer needs.
x_ extensions are namespaced x_{publisher}_{field}
- Rule
- Namespaced extensions prevent two publishers colliding on one private field name.
- Fix
- Namespace the extension as x_{publisher}_{field}, so two publishers can extend the same record without colliding.
es baseline present for localized strings
- Rule
- es is the REQUIRED baseline for human-readable strings; en is RECOMMENDED.
- Fix
- Provide the es baseline for the localized string. Spanish is the floor every consumer can rely on.
No duplicate id within one feed
- Rule
- Two records with one id make every downstream dedupe wrong.
- Fix
- Remove the duplicate id. Two records with one id make every consumer’s merge non-deterministic.
Person-level data
A contact value appears in any field, extensions included
- Rule
- Contact values MUST NOT travel in feeds — namespaced extensions do not exempt them.
- Fix
- Remove the contact value. Use contact_available to say that contact exists, and public_url to send the reader to you for it.
confirmed_by is a role token, never a personal name
- Rule
- confirmed_by ∈ team | volunteer | official_source | partner:{publisher_id}.
- Fix
- Replace the personal name in confirmed_by with a role token: team, volunteer, official_source, or partner:{publisher_id}.
Free text matches a personal-data pattern
- Rule
- Free text is the third leak channel: publishers MUST strip personal data from description / warning_text.
- Fix
- Remove the personal data from the free text. The finding names the field and the pattern class, never the value it matched.
A person-level entity appears
- Rule
- Person-level data never federates — this is a join prohibition, not a field omission.
- Fix
- Remove the person-level entity. Cabuya carries places; a person’s situation stays inside the application that owns it.
A field name matches the deny-list even if the value looks clean
- Rule
- A field named for contact data will eventually carry it.
- Fix
- Rename the field. A deny-listed name signals person-level data even when today’s values look clean, and the next row may not be.
A moderation verdict about a third party is republished
- Rule
- Moderation verdicts do not federate; suppressed records are omitted, never labelled downstream.
- Fix
- Remove the moderation verdict. Republishing a judgement about a third party spreads it beyond anyone who can correct it.
Behaviour
Feed reachable on two probes; content-type stable
- Rule
- A feed that answers differently on two probes cannot be consumed reliably.
- Fix
- Serve the feed consistently. Two probes returning different content types means a consumer cannot cache you at all.
Always-now: last_updated advances with the probe clock on identical content
- Rule
- last_updated MUST be generated at build/publish time, never per request — a per-request timestamp is worse than no signal.
- Fix
- Set last_updated to when the content actually changed. A timestamp that follows the clock makes every consumer refetch identical bytes forever.
last_updated older than 7 × ttl (the stale badge state)
- Rule
- Staleness is information, not failure — but it must be visible.
- Fix
- Regenerate the feed, or raise ttl to match how often you really update. Stale beyond seven times your own ttl is a promise you are not keeping.
Per-shard lastmod present (the incremental-sync pattern)
- Rule
- A per-shard lastmod is working incremental sync at zero cost.
- Fix
- Publish a lastmod per shard so consumers can fetch only what changed.
Declared shards reachable and envelope-consistent
- Rule
- A declared shard that disagrees with its siblings breaks every consumer that trusts the manifest.
- Fix
- Make every declared shard reachable and consistent with the envelope that declares it.
Licensing
Declared licence is not share-alike
- Rule
- Share-alike licences poison aggregation for downstream consumers.
- Fix
- Choose a licence that is not share-alike. A viral licence makes every aggregator’s whole dataset derivative, which is why most will not read you.
attribution string present for aggregators to display
- Rule
- Attribution is a consumption MUST; an explicit string makes it easy to honor.
- Fix
- Add an attribution string for aggregators to display. Credit that travels with the data is the point of the network.
Read API
Read API base reachable; envelope shape identical to the feed
- Rule
- One schema, four transports.
- Fix
- Serve the read API at the declared base with the same envelope shape as the static feed.
Static ≡ API: the same record is byte-compatible from both surfaces
- Rule
- The equivalence rule is what lets one schema serve four transports.
- Fix
- Return byte-compatible records from the API and the feed. A consumer must not have to know which one it read.
cursor pagination ordered on a server sequence, not a timestamp
- Rule
- Timestamp cursors silently drop offline-composed records that arrive late.
- Fix
- Paginate on a server-side sequence rather than a timestamp. Timestamps collide and records get skipped.
Documented query parameters accepted
- Rule
- municipality, kind, bbox, updated_since, limit, cursor.
- Fix
- Accept the documented query parameters, or remove them from the documentation.
CORS * on the API; no auth required for reads
- Rule
- Reads are public by design.
- Fix
- Serve the API with CORS * and no authentication for reads. Public-interest data behind a key is not public.
Consumes ≥ 1 peer feed (partly self-declared)
- Rule
- L3 requires consuming as well as serving — the one requirement a probe cannot fully measure, so it is reported as info with the limitation stated.
- Fix
- Consume at least one peer feed. The level is about interoperating, not only about publishing.
Write API
POST accepts the {source, external_id, place} envelope
- Rule
- The write envelope is fixed.
- Fix
- Accept the {source, external_id, place} envelope on POST.
Idempotency on (source, external_id): a replay does not duplicate
- Rule
- Re-sending is an upsert of the sender’s own contribution, never a duplicate.
- Fix
- Make writes idempotent on (source, external_id). A replay after a timeout must not create a second record.
409 on an id conflict outside the sender’s namespace
- Rule
- Namespace discipline is enforced at the write boundary.
- Fix
- Return 409 when a sender tries to write an id outside its own namespace.
In auth:none mode, a moderation state is echoed and rate limiting is observable
- Rule
- Open writes REQUIRE mitigations: rate limiting, a moderation queue, and an echoed state.
- Fix
- Echo the moderation state and make rate limiting observable, so an unauthenticated sender knows what happened to its write.
Republished records carry source.source_id = the original sender
- Rule
- The sender’s identity travels with the record forever.
- Fix
- Keep source.source_id as the original sender when republishing. Attribution is the thing that must survive the hop.