Building hldays #3: A source on every record
In the first post I promised "a source on every record". This post is about what that means in practice: where the data comes from, what "verified" means, and how I keep it from going stale.
The short version: every holiday in hldays carries a link, a verbatim quote and the date it was checked, and the build refuses to call a record verified without them.
What a record looks like
Here is Alenquer's municipal holiday, straight from data/PT/municipalities.yaml:
- id: feriado-municipal
name: { pt: "Feriado Municipal" }
type: local
rule: { easter: 39 }
status: verified
source:
url: "https://irn.justica.gov.pt/Contactos-do-Registo/Espaco-Registos-de-Alenquer"
verified_at: "2026-10-16"
quote: "Encerrado no feriado municipal (quinta-feira da Ascensão)"
And a regional one, Madeira's Autonomy Day, which cites the regional decree itself:
- id: dia-da-autonomia
name: { pt: Dia da Autonomia, en: Autonomy Day }
type: public
rule: { fixed: "04-02" }
valid_from: 2025
status: verified
source:
url: "https://joram.madeira.gov.pt/joram/1serie/Ano%20de%202024/ISerie-206-2024-12-16sup.pdf"
ref: "Decreto Legislativo Regional n.º 17/2024/M"
verified_at: "2026-10-16"
quote: "O dia 2 de abril constitui o feriado do Dia da Autonomia na Região Autónoma da Madeira."
A source has four optional parts:
url: the page or documentref: a human reference, like a law numberquote: the sentence on that page that states the holiday, copied verbatimverified_at: when someone (or something) last confirmed it
The quote matters most. A URL alone tells you where to look. The quote tells you what the page said, so you can check it yourself without trusting me.
All of this goes out in the API response. Every holiday in /v1/holidays has its status and source next to the date, unless you ask for include_sources=false:
curl "https://api.hldays.dev/v1/holidays?jurisdiction=PT-30&year=2026"
{
"date": "2026-04-02",
"holiday_id": "dia-da-autonomia",
"type": "public",
"status": "verified",
"source": {
"url": "https://joram.madeira.gov.pt/joram/1serie/...",
"ref": "Decreto Legislativo Regional n.º 17/2024/M",
"verified_at": "2026-10-16",
"quote": "O dia 2 de abril constitui o feriado do Dia da Autonomia na Região Autónoma da Madeira."
},
"jurisdiction": { "id": "PT-30", "name": "Região Autónoma da Madeira", "level": "subdivision" }
}
The build enforces it
There are only two statuses: verified and unverified. The rule for verified lives in src/core/validate.ts, which runs on every build:
if (h.status === "verified" && !(h.source?.url && h.source?.verified_at))
errors.push(`${hw}: verified holidays need source.url and source.verified_at`);
If any record breaks it, npm run build:data fails, so the tests fail, so nothing deploys. It's a small check, but it means "verified" can't drift into meaning "I'm fairly sure".
What counts as an official source is a convention, written in the README: a law, the Diário da República, a municipality's own website, a government registry page. News articles, bank holiday lists and Wikipedia can be useful leads, but a record that only has those stays unverified.
Collecting Portugal: there is no official list
Portugal has 308 municipalities, and each one sets its own holiday. There is no single official list of them. So the data had to be collected one municipality at a time, in a few passes:
- A pilot of 22 municipalities, researched carefully to learn which sources are reliable.
- A scan of registry pages. This turned out to be the best trick of the whole project.
- Research agents for the rest, with strict instructions.
- A merge script (
research/pt/scripts/merge.py) that combines all of it into the YAML.
The registry trick
Portugal's civil registry offices (IRN) close on the local municipal holiday. Their contact pages say so, in a very regular format: "Encerrado no feriado municipal (...)", "closed on the municipal holiday", with the date or rule in brackets.
Those pages live on an official government domain, and their URLs follow a handful of patterns. So research/pt/scripts/irn_scan.py tries each municipality's name against those patterns and pulls out the bracket:
PATTERNS = ["Registo-Civil-de-{}", "Espaco-Registos-de-{}", "Registo-Civil-Predial-e-Comercial-de-{}",
"Registo-Civil-e-Predial-de-{}", "Registo-Predial-de-{}", "Registo-Predial-e-Comercial-de-{}"]
RE = re.compile(r"Encerrad[oa] no feriado municipal\s*\(([^)]{3,80})\)", re.I)
That found 102 of the 308 municipalities with no search at all. The merge script then turns the bracket into a rule. Fixed dates ("20 de agosto") are a regex; movable ones are a lookup table of every phrase the scan found:
"quinta-feira da ascensão": ({"easter": 39}, None),
"terceira segunda-feira de julho": ({"nth_weekday": {"month": 7, "weekday": 1, "n": 3}}, None),
"segunda-feira após o primeiro domingo de outubro": ({"nth_weekday": {"month": 10, "weekday": 7, "n": 1, "offset": 1}}, None),
Some phrases needed a judgement call, and those carry a note. Mafra's registry says "5ª quinta-feira da Ascensão", which I read as Ascension Thursday ("5ª" is short for quinta-feira, Thursday). Idanha-a-Nova's says "the third Tuesday after Easter Sunday", which is Easter + 16, even though other lists give a Monday. I kept the official text.
Agents for the rest
For the remaining municipalities I used research agents: LLM agents with web access, each given a batch of about ten municipalities and the same instruction file (research/pt/agent-instructions.md). The rules that mattered most:
- Never guess, never fill a field from memory.
- "Official" means an official domain that explicitly says it is the feriado municipal.
- The quote must come from a page the agent actually fetched. If it only saw a search snippet, it has to say so, and the record becomes secondary.
- Report conflicts between sources instead of picking one silently.
Each batch got a hint from a compiled list, labelled as unreliable, so the agent knew what to look for but not what to conclude. The merge script then decides the status, not the agent:
official = a.get("source_tier") == "official" and not a.get("quote_from_snippet")
...
src = {"url": a.get("source_url")}
if official: src["verified_at"] = V
else: src["ref"] = "secondary"
So an agent can't promote its own finding: without an official page and a fetched quote, the record lands as unverified with ref: secondary, and the build check above makes sure it stays that way. The merge also runs in a fixed order (pilot, then IRN, then agents), and an IRN hit that disagrees with a verified pilot record is logged as a mismatch rather than overwriting it.
The agents were useful. They found pages I wouldn't have had the patience to dig up one municipality at a time. Still, I trust the rules in the merge script more than the agents, and I spot-checked a sample of their findings by hand.
Sources that disagree
The most useful thing the research produced is a list of sources not to trust on their own, now in docs/data-review-pt.md:
- gov.pt citizen-service pages sometimes show the district capital's holiday instead of the municipality's (Mortágua, Tabuaço, Pedrógão Grande, Portel), or a stale year.
- The widely copied compiled list is wrong for several places and swaps the two Lagoas and the two Calhetas (each name exists twice, on different islands).
- Some directories still show Alfândega da Fé's pre-2016 date. The câmara moved it from 29 June to 8 May, and its own page says so.
Then there are real conflicts, where official sources disagree with each other or with tradition. Nordeste in the Azores is one: older lists say 18 July, but the câmara's own festival programmes put the holiday on a Monday in mid-July. The data uses "the Monday on or before 18 July" and the note explains why.
Where it stands
From src/generated/data.json at the time of writing:
- 291 of 308 Portuguese municipal holidays are verified: 134 from IRN registry pages, 100 from gov.pt pages, 57 from câmara websites, the Diário da República and other official documents.
- 17 are still unverified, with secondary sources only. They're listed by name in the review doc. The fastest fix is usually an email to the câmara.
- All of Portugal's national and regional holidays are verified, against the consolidated Labour Code and the regional decrees.
Keeping it up to date, without AI
Holidays change. A council votes to move its date, a page gets redesigned, a site goes down. So there's a script that re-checks every source: scripts/verify-sources.ts. It uses no AI. For each record it fetches source.url, strips the HTML, and looks for the saved quote.
Pages change formatting more often than content, so both sides are normalised first (accents, quotes, whitespace):
function normalize(s: string): string {
return s
.normalize("NFD")
.replace(/\p{Diacritic}/gu, "")
.toLowerCase()
.replace(/[“”«»"']/g, "")
.replace(/\s+/g, " ")
.trim();
}
A quote can be shortened with "...", in which case every fragment of 15 characters or more must be on the page. Each record ends up in one bucket:
ok: the quote is still theredate_found: the quote is gone but the date ("13 de junho") is still on the page, usually a redesignchanged: neither found; for a fixed date, the holiday may have movedbroken: HTTP error, timeout or a bad certificateunreadable: a PDF or a page rendered by JavaScript
The baseline run on 16 October 2026 gave 289 ok, 3 date_found, 22 unreadable, 8 changed (6 of them movable holidays, where the page just shows another year's date), 3 broken and 1 without a URL. The plan is to run it yearly and research only the changed and broken entries. That keeps maintenance to a handful of records a year instead of 308.
The other countries
Brazil, Germany, the UK, Greece and the US work differently. Their national and state layers are imported from python-holidays, so their source is the dataset, pinned to a version, and every record is unverified. Instead of quotes they get automatic cross-checks against two other open datasets. That process gets its own post: "Adding a new country: what it takes to go from 6 to 7".
Limitations
verified_atis when the source was checked, not a guarantee that it is still true today. That's what the yearly re-check is for.- PDFs and JavaScript-rendered pages are not checked automatically, so 22 records depend on a person looking.
- The date fallback only works for fixed dates, and only in Portuguese ("13 de junho").
- A registry page saying it closes on a day is strong evidence, but it's still not the municipal deliberation itself. Where the two might disagree, I'd rather show the quote and let you judge.
How do you handle provenance for reference data in your own projects? Do you store the source next to each record, or keep it somewhere else? I'd like to hear about it in the comments.
Next in the series: Business days that know local holidays.