de/wasserversorger-discover
Discover which German water suppliers exist and which municipalities each one serves — the population-weighted coverage sweep that builds the Wasserversorger roster and the municipality↔company links. Run this before wasserversorger-enrich, which refuses an empty roster.
Wasserversorger discovery (Steps 0/1)
STOP — read this before spawning anything
Do ALL work synchronously inside this run. NEVER spawn background subagents and end your turn waiting for them. This run terminates the moment you produce your final message, and SIGKILLs everything still running. Synchronous subagents are fine; background ones are not — when you invoke a subagent you must block on its result inside your own turn.
This has now failed three times in CountryOS (netzbetreiber 2026-08-04; wasserversorger 2026-08-19 and 2026-08-20 — runs ending "continuing to wait for both shards to finish" and "I'll resume once they report back"). Each time the shards' work survived but the merge, coverage rebuild and journal never ran, and the ledger still recorded
ok.The other way to kill the run is memory: see "Never read the campaign artifacts whole" — the 2026-08-21T18:00 run was OOM-killed and took the whole container with it.
If you catch yourself about to write "waiting for", "I'll resume once", or "continuing to monitor" — you are about to kill the run. Finish the merge/coverage/journal sequence instead, even on partial results: a short honest run is resumable, an abandoned one wastes its spend.
This failure now recurs one level deeper too (2026-08-25 13:00): the 2 synchronous shard subagents you spawn will themselves spawn their own background sub-subagents and end their turn saying "I'll wait for them" — the exact same failure, just delegated. Telling a subagent "do this synchronously yourself" in a shared preamble is not enough; general-purpose agents default to parallelizing anything that looks parallelizable. Put this exact line in every research-subagent prompt you write, not just in your own instructions: "You may not use the Agent tool or the Task tool for any reason in this task. Do all research yourself with WebSearch/WebFetch/Bash directly, and do not end your turn until your output files exist on disk with every assigned item accounted for." That line, and only that line, stopped the recursion when tested. A softer phrasing did not. If a shard still goes quiet for a long stretch despite this, message it for a status check before assuming it's dead and dispatching replacements — a stalled-looking shard can still be working in the background and land its own complete merge later, and running replacements concurrently with it produces duplicate work and real cross-shard collisions (a company minted twice under two different
company_ids — this happened once, caught only because the final domain-collision check was re-run after the shard merge, not before). Always recompute the domain-collision check across everyroster.part-*.jsonlshard about to be merged, not just against the existingroster.jsonl, as the very last step beforeconsolidate_roster.mjs— no single shard can see a duplicate the other shard just minted.
You are in the country workspace. Campaign files: campaigns/wasserversorger/.
Plan and measured pilots: _tasks/netzbetreiber-contact/german-water/PLAN.md.
This skill answers who exists and who serves whom. wasserversorger-enrich answers
what are their details and starts from the roster this one produces.
There is no MaStR for water — no register lists the ~6,000 Wasserversorger and none maps them to the 10,943 Gemeinden. Constructing that mapping is the actual project; everything downstream is fork-and-run.
Two different numbers: the headline metric and the queue order
Report population coverage — share of the German population covered by an identified supplier. That is the headline metric and it stays: population is heavily concentrated, and the consumer of this data (energiemarie's city→supplier finder) is queried in proportion to it.
Rank the queue by municipalities claimable per fetch — not by population per municipality. These are different questions and conflating them cost a run.
Measured, and the reason for the rule:
| Sweep | Companies | Link rows | Links per company |
|---|---|---|---|
| Head sweep (big-city Stadtwerke) | 29 | 31 | ~1 |
| Pilot 1 (Verband member lists) | 25 | 905 | ~36 |
Population-per-municipality ranking sends you to Stadtwerke, where a company buys exactly one municipality. The deliverable is the n-n table across all 10,943 Gemeinden, and at 1:1 that never completes. A Verband's member list is one fetch that claims a whole block.
So: use the residual list to identify the Verbände serving those municipalities, then fetch the member list once and claim the block. Population is the tiebreaker between candidates of equal yield, not the sort key.
The head sweep was still worth doing — it bought a third of the population cheaply and it is done. Past ~200k inhabitants the head is exhausted and the 1:1 regime is simply the wrong tool.
Batch selection — find the block, not the municipality
node campaigns/wasserversorger/coverage.mjs
Unions every link source (links.jsonl, longtail.jsonl, roster-links.jsonl), writes
coverage.json and residual-top.csv — the unclaimed municipalities.
residual-top.csv ranks municipalities; that is not the queue. The queue is the
Gemeindeverband layer:
node campaigns/wasserversorger/build_gv_layer.mjs
writes gemeindeverband-queue.csv (every administrative unit with unclaimed members, ranked by
how many) and gemeindeverband-members.csv (unit → member AGS). Source is Destatis GV100AD,
the official Gemeindeverzeichnis — authoritative for both the unit list and its membership, so
membership never has to be crawled or guessed. Take units top-down; population breaks ties.
Batch size and the phase boundary
Work 60 units per run. Measured 2026-08-18 as a CountryOS task: 6 units took 13.5 minutes and 34 turns ($7.02) with 2 subagents, so 60 units lands around 2h15 and ~340 turns — inside the task's budget and inside the 5-hour schedule interval, with headroom for slow hosts and retries. If a run is going to exceed its budget, stop cleanly and report where you stopped; resumability makes a short run harmless and a killed run wasteful.
PHASE 1 IS COMPLETE (2026-08-24): 5,762/10,943 municipalities claimed, 38.97% of population. Only 2 units with >=5 unclaimed members remain.
PHASE 2a — CURRENT PHASE. Take singleton units with residual_population >= 10,000, ordered
by residual_population descending. As of 2026-08-24 that pool is 1,229 units covering
34.9M people — it lifts population coverage from 39% to roughly 81%.
"Singleton" means one municipality per unit, not a small one. These are the kreisfreie Städte, and they are where the population actually is: Chemnitz 246k, Halle 227k, Mainz 225k, Erfurt 219k, Lübeck 217k, Oberhausen, Kassel, Hagen, Potsdam, Saarbrücken, Hamm, Ludwigshafen. The 3,174 singleton units hold 43.7M people — 52% of Germany. This is the well-documented, one-company-per-city regime the early head sweep already handled successfully, not a grind.
Use resolve_singleton.mjs as a pre-pass, for site resolution only. It resolves and
Impressum-verifies the official municipal website for 99% of inputs (Wikidata P439->P856
covers 94.7% of this population), which saves you the site-finding step. Do not trust its
supplier extraction — measured 2026-08-24 at 7.5% unattended on small municipalities. Treat
its candidates[] as leads with sources attached, and adjudicate yourself. For a city, the
supplier is usually the municipal Stadtwerke and its own site states it plainly.
When the phase-2a pool is empty, move to phase 2b below. Report the 2a completion numbers first (population coverage, municipalities claimed) so the transition is visible in the journal.
PHASE 2b — the hard residual, ordered by yield
2,493 units / 3,861 municipalities / 14.9M people. Work multi-member units first: 673 units covering 2,041 municipalities (3.0 each), then the 1,820 small singletons (1.0 each). Same 60-unit batches.
The script's 8% ceiling does NOT bound you. resolve_singleton.mjs scored 7.5% unattended
because it cannot search — html.duckduckgo.com answers 202 with an anti-bot page, so the
"<name> Wasserversorgungssatzung" lookup that works interactively is unscriptable. You have
WebSearch. That is precisely the method that carried phase 1 (see "Go to the
Wasserversorgungssatzung first"). Use the script for site resolution (99%, Impressum-verified),
then search for the Satzung and read §1(1). Do not conclude "not published" from a site crawl
alone — that was the wv-de-019 false-negative pattern, at scale.
Before grinding 2,493 units, spend the first run testing for a bulk source. Every
enumerable source tested in this campaign has paid off — GV100AD turned unbounded discovery into
a finite queue. The untested candidate is the Kreis-level Gesundheitsamt / Landkreis register:
TrinkwV assigns supply-area monitoring to them, there are only ~400 Kreise, and residual-blocks.csv
already ranks which Kreise hold the residual. Check the top few for a published
Wasserversorgungsanlagen list with operator names. If two or three states publish one, that
replaces hundreds of unit fetches; if not, report that and grind. Do not spend more than one run
finding out.
Expect genuine absence. Measured 2026-08-24: in 60 of 80 scored rows the supplier's name
appears on no page a site crawl reaches, and 7 more are municipalities that supply themselves
with no distinct entity. self_supplied (the Gemeinde is the operator) is a correct, complete
answer — record it as such with the sentence that establishes it, and never invent a
"Stadtwerke <name>" that the sources do not name. That heuristic scored 0-for-159 against
verified data.
When 2b is exhausted, STOP and report what remains and why. Some municipalities genuinely do not publish this, and an honest gap with evidence beats a fabricated row.
On cost figures in this file: cost_usd in the ledger is API-equivalent, not spend.
CountryOS runs on subscription auth, so marginal cost is zero; those numbers exist to price a
country if that ever changes. Size batches by wall-clock and reliability, never by the dollar
figure.
This converts discovery from unbounded search into bounded enumeration: a known list of units, one water page per unit, each claiming its members at once. Measured 2026-08-14 against a residual of 10,081: 4,255 units, of which 1,121 multi-member units cover 6,947 municipalities (69%) and 3,134 are singletons that still need individual work.
The layer does not exist everywhere — check before planning a state
Multi-member units and the share of that Land's Gemeinden they cover (GV100AD, 2026-06-30):
| Land | units | multi-member | median size | Gemeinden in a multi-member unit |
|---|---|---|---|---|
| Rheinland-Pfalz | 171 | 129 | 13 | 2,259 of 2,301 (98%) |
| Schleswig-Holstein | 168 | 83 | 11 | 1,019 of 1,104 (92%) |
| Mecklenburg-Vorpommern | 117 | 77 | 8 | 686 of 726 (94%) |
| Baden-Württemberg | 462 | 270 | 3 | 911 of 1,103 (83%) |
| Thüringen | 189 | 79 | 5 | 491 of 601 (82%) |
| Niedersachsen | 428 | 114 | 5 | 650 of 964 (67%) |
| Brandenburg | 193 | 52 | 5 | 272 of 413 (66%) |
| Sachsen-Anhalt | 122 | 18 | 7 | 114 of 218 (52%) |
| Bayern | 1,546 | 311 | 3 | 982 of 2,217 (44%) |
| Sachsen | 309 | 70 | 2 | 179 of 418 (43%) |
| NRW, Hessen, Saarland | — | 0 | — | 0 — every Gemeinde is its own unit |
So the tier strategy is excellent in RP (171 units for 2,301 Gemeinden — 13:1) and useless in NRW, Hessen and the Saarland, where there is no tier to exploit and municipalities must be worked individually or via Zweckverband/regional-supplier member lists (Gelsenwasser-style).
The unit is not automatically the supplier
The Verbandsgemeinde/Amt is an administrative tier. It may run the water itself (Verbandsgemeindewerke, an Eigenbetrieb), or delegate to a Zweckverband or a separate AöR. Measured 2026-08-14: 2 of the first 3 units delegate. The unit is usually NOT the supplier.
A Wasserversorgung section on the VG site does not mean the VG supplies the water. The VG
bills and administers regardless. This caught me: VG Altenkirchen-Flammersfeld has a
Wasserversorgung menu, forms and a Trinkwasserqualität page, and I provisionally recorded it as
self-supplying — then its own page said "die Stadt Altenkirchen und alle Ortsgemeinden … sind an
das überörtliche Netz des Zweckverbandes Wasserversorgung Kreis Altenkirchen angeschlossen".
The navigation was not evidence; the sentence was.
Read to the sentence that names an entity. The Trinkwasserqualität / Wasserqualität page is
usually where it appears, because §46 TrinkwV forces the supplier to identify itself there. Then
set supplier_kind: eigenbetrieb for a Werke, zweckverband or a named AöR where delegated.
Go to the Wasserversorgungssatzung first — it beats the website
Every RP Verbandsgemeinde must publish an Allgemeine Wasserversorgungssatzung. Its §1(1) is the single best source in this whole campaign, because it states in one paragraph:
- the operator's exact legal name ("das Wasserversorgungsunternehmen Verbandsgemeindewerke Herrstein-Rhaunen – Wasserversorgung –"),
- its legal form ("als öffentliche Einrichtung in der Form des Eigenbetriebs"),
- every Ortsgemeinde it covers, by name, and
- any carve-out, naming the other supplier.
Search "<unit name>" Wasserversorgungssatzung and read that PDF rather than crawling the site
menu. One document replaces the homepage → water-page → scope-hunt chain, and it is legally
authoritative rather than marketing copy.
Expect it to be a scanned PDF — Herrstein-Rhaunen's returns 11 bytes from pdftotext. Render
and read it (see the memory-safety section).
The instrument, not the page — the single biggest lesson of 2026-09-02
A pass at the 13 highest-value unresolved municipalities resolved 12 of them, every one of which had failed 2-6 times before. They did not fail for lack of effort: prior passes had usually ruled out the right Verbände correctly. They failed by reading the wrong kind of document.
1. For a Gemeinde: go to the Ortsrecht index, not the water page. Seven of the twelve resolved
because someone finally opened the statute listing. Every prior pass had stopped at a
"Ver- und Entsorgung" or "Wasser" nav page that names no operator. Look for Ortsrecht,
Satzungen, Satzungen & Verordnungen, Bekanntmachungen, Amtliche Bekanntmachungen,
Ortsrechtssammlung — the Wasserabgabesatzung PDF hangs off there. Paths that worked:
reichenschwand.de/gemeinde-service/ortsrecht, gemeinde-hopferau.de/bekanntmachung.html,
rattenberg.de/rathaus/bekanntmachungen-satzungen/satzungen,
gemeindeahorn.de/politik-und-verwaltung/satzungen.
2. For a Verband: its "our members" page is NOT a service split. Its Verbandssatzung is. This one cost a wrong answer before it was caught. Rosenthal am Rennsteig — the largest gap in the campaign at the time — was recorded as a non-serve on 2026-09-02 from ZV WALO's website member list, which does not separate water from wastewater. ZV WALO's own Verbandssatzung then showed §2 listing it as a full member with no service restriction (the Satzung restricts only Saalburg-Ebersdorf, to five named Ortsteile — so an absent carve-out is meaningful) and §3(1) naming drinking water among the Verband's Aufgaben. Königsfeld went the same way: four passes exhausted ZWA Hainichen's overview and press pages; the answer was Anlage 1 of its Verbandssatzung, naming Königsfeld among the Mittelsachsen water-supply members.
Corollaries worth carrying:
- A Beitrags- und Gebührensatzung (BGS/WAS) in the Gemeinde's own name is as good as the Wasserabgabesatzung. Check the Satzungsgeber, not the letterhead — a Verwaltungsgemeinschaft often administers the paperwork while each member Gemeinde runs its own supply (the whole of VGem Seeg works this way).
- A Wasserwehrsatzung is flood defence, not supply. Rochlitz's joint Ortsrecht index carries only that for all four of its VwG members — which is itself evidence that none self-supplies.
- Membership is not supply. Kreischa sits in TWZ Weißeritzgruppe's member table but self-supplies most of its territory through its own Eigenbetrieb. Corroborate a member list against a second source before claiming.
State registries: one exists, one does not. Saxony publishes a genuine per-zone
Trinkwasserversorgungsgebiete register at apps.gesunde.sachsen.de (80 zones, operator + member
municipalities) — it resolved Dorfhain. It does not cover the Rochlitz/Hainichen pocket of
Mittelsachsen; that was verified against the raw HTML of all 80 zones, so it is a coverage gap in
the source, not a search failure. Thüringen has no equivalent — and note that tlug-jena.de is
a decommissioned domain, not a flaky one: TLUG became TLUBN at tlubn.thueringen.de, whose
water section is narrative statistics only. Passes have burned retries on tlug-jena.de reading
its SSL/503 failures as transient. Retire it.
A note on what the PDF tooling fix actually bought. Of the PDFs opened in that pass, almost none were scans — they were plain-text documents that had simply never been fetched, because the tooling had been reporting every PDF as unreadable and the campaign had learned not to bother. The fix mattered less as OCR than as permission to look.
Units DO fragment — corrected 2026-08-18
An earlier run concluded from VG Altenkirchen-Flammersfeld's "alle Ortsgemeinden" that units resolve whole. That was one generous case. Herrstein-Rhaunen's Satzung covers 49 of its 50 Ortsgemeinden and carves out Gösenroth to the Zweckverband "Wasserwerk Hunsrück II" in Kirchberg.
So a unit is a queue entry, not a single answer: read the scope statement to the end and expect an exception clause. A carve-out is a second company with one member, and it is normal, not an error. Never claim a unit's members wholesale without reading whether the Satzung excludes any.
The scope statement usually still covers most of the unit at once, so the yield estimate broadly
holds — but budget for carve-outs, and treat Teilbereich/partial wording as a hard stop on
claiming the rest.
Beware a false positive: Zweckverband on a VG homepage is often Abfallwirtschaft (waste), not
water. Check what the Zweckverband is for before recording it.
Two invariants in the tracker, both load-bearing:
- A link row with a null
company_idis an unresolved marker, not a claim — it stays in the residual. (This was a real bug: counting them reported 28.5% instead of 27.77%.) - Ortsteile and non-municipal members (Landkreise, Zweckverbände, industrial members) have no AGS by construction and are excluded from the denominator, not counted as failures.
Split the batch across at most 2 SYNCHRONOUS subagents — invoke them so you block on their
results within your own turn (never run_in_background), because ending the turn to wait kills
them (see the banner at the top). Each is a multi-GB process; the container
caps at 6 GB — 4+ concurrent agents OOM the run). Give each its own company_id range and its own
shard files so two writers never touch one file.
Two regimes, and knowing which one you are in
| Regime | Method | Yield |
|---|---|---|
| Head — municipalities above ~200k | One fetch to the city's Stadtwerke Impressum | |
| Tail — everything below | Supplier-first: crawl a Verband's Verbandsmitglieder / Versorgungsgebiet page |
~36 links per fetch, small municipalities |
The head is done (2026-08-13: everything above ~200k inhabitants is claimed or explicitly unresolved). Do not restart there. The remaining ~10,200 municipalities are a tail problem, and the tail is only affordable supplier-first.
The organising unit is not the Gemeinde, and it varies by Land
This is the single most important thing to get right in a fragmented state — the block boundary is a different administrative object in each one:
| Land | Organising unit | Note |
|---|---|---|
| Rheinland-Pfalz | Verbandsgemeinde (→ Verbandsgemeindewerke) | 2,301 Gemeinden but only ~130 VGs. Ortsgemeinden are tiny and rarely self-supply. |
| Schleswig-Holstein | Amt, and Wasserbeschaffungsverbände | 1,104 Gemeinden. Never propagate a supplier across an Amt without evidence — pilot 2 found Neversdorf and Högersdorf sharing Amt Leezen with different suppliers. |
| Bayern | Zweckverband / Gemeindewerke | 2,217 Gemeinden; both regimes coexist. |
| Brandenburg, MV | Amt / Zweckverband (often WAZV, water and sewerage) | |
| Sachsen-Anhalt, Thüringen | Verbandsgemeinde / Zweckverband |
Membership in a Verbandsgemeinde or Amt is not evidence of water supply. The VG/Amt is a general-purpose administrative tier; its Werke may serve all, some or none of its Ortsgemeinden. Claim a block only from a page that states the water supply area. If the page lists the VG's Ortsgemeinden generally, that is a candidate list, not a claim.
Pilot 2 overturned the plan's original assumption and it stays overturned: self-supply is a large-municipality pattern. Among Gemeinden under 1,000 inhabitants, 8 of 10 are served by a Zweckverband. So the tail is mostly reachable supplier-first, and the roster of Verbände is worth more investment than per-municipality lookups.
Per municipality
- Identify the retail supplier. Usually the municipal Stadtwerke, but never assume — Gelsenwasser, Harzwasserwerke, RheinEnergie and similar serve well beyond their home city.
- Verify on the supplier's own Impressum. Record the entity name as printed and the legal
form verbatim when stated (
Körperschaft des öffentlichen Rechts,Anstalt des öffentlichen Rechts,GmbH,Eigenbetrieb der Stadt …). - Retail vs wholesale is a first-class field, not a note. A Fernwasserversorger delivers bulk
water while a local utility bills the household; a member list frequently names the wholesaler.
Set
roletoretail/wholesale/both. For a consumer-facing answer the retailer is the right company — 31% of pilot 1's mapped rows came from companies flagged bulk, which is why this field exists. - Harvest the supply area. If the supplier publishes a member/Versorgungsgebiet list, capture every name. This is the highest-yield action available: one fetch claimed 181 municipalities at best.
Output
Append after each municipality, never batch at the end.
roster.part-<slice>.jsonl — one line per distinct company:
{"company_id":"wv-de-101","name":"…","official_domain":"bwb.de","website_url":"https://…",
"impressum_url":"https://…","legal_form_verbatim":"Anstalt des öffentlichen Rechts",
"supplier_kind":"stadtwerke|eigenbetrieb|zweckverband|wasserverband|private_gmbh|other",
"role":"retail|wholesale|both","land":"…","hq_ags":"11000000","handelsregister_nr":null,
"handelsregister_evidence":"printed_in_impressum|absent_from_impressum_page",
"supply_area_status":"list_found|partial|count_only|self_only|not_published",
"supply_area_url":null,"member_count_published":null,"alternate_domains":[],
"confidence":"high|medium|low","fetches":2,"checked_at":"YYYY-MM-DD","notes":null}
roster-links.part-<slice>.jsonl — one line per municipality↔company link, carrying the
verbatim published name:
{"company_id":"wv-de-101","raw_name":"<exactly as published>","ags":"11000000|null",
"land_hint":"…","member_kind":"gemeinde|ortsteil|landkreis|utility|unknown",
"role":"retail","type":"aep","quartier":null,"source_url":"https://…",
"confidence":"high","checked_at":"YYYY-MM-DD","notes":null}
Never resolve an AGS in the crawl. Carry through only AGS values handed to you in the batch
(the seeded municipalities); for names read off a member list, set ags: null and record
raw_name verbatim. Then run node map_roster.mjs, which merges the shards and resolves names
through lib.mjs's normName/agsIndex — the same normaliser map_ags.mjs uses, so the two
paths cannot drift. It reports exact_norm / land_scoped / expanded_qualifier / ambiguous /
unmatched and prints every row needing review.
Expand geographic qualifiers, never strip them. Suppliers abbreviate ("Auw b. Prüm", "Hüttingen a.d. Kyll") where the register writes them out ("Auw bei Prüm", "Hüttingen an der Kyll"). The qualifier is the disambiguator: Hüttingen an der Kyll and Hüttingen bei Lahr are two real Gemeinden in the same Kreis. Stripping it turns a clean miss into a wrong match.
A block claim carries two sources. The supply statement is usually made at
Verbandsgemeinde/Amt level ("die Wasserversorgung obliegt für den Bereich der VG …") while the
municipality names come from that VG's own Ortsgemeinde index. Record the name list in
source_url and the supply statement in supply_evidence_url, so a reviewer can check both
halves of the inference. If the supply statement says Teilbereich — partial — do not claim the
unit whole; that is inference, not evidence.
An unresolved municipality gets a row too — company_id: null, the seeded AGS, and notes
saying what you checked and what blocked you. An explicit unresolved row beats a silent gap, and
the tracker keeps it in the residual where it belongs.
A municipality with NO central supply is a resolution, not a gap — say so with
outcome: "no_central_supply". Some Gemeinden have no water utility at all: every household is
on its own Hausbrunnen. Recorded as a plain unresolved row that reads as "we looked and failed",
so it stays in the residual, resurfaces in every batch, and gets re-researched forever. Six
municipalities were in that loop until 2026-09-02 — Lockstedt, Mühlenbarbek and Wiedenborstel
(Amt Kellinghusen), Kittlitz and Fredeburg (Amt Lauenburgische Seen) — confirmed 26 times across
15 shard files between them, each confirmation paid for. Set outcome: "no_central_supply" with
company_id: null and coverage.mjs retires them permanently.
The bar is a first-party statement from the Amt or Gemeinde — a Ver-/Entsorgung page listing
that municipality's drinking-water row as Private Wasserversorgung, or naming it among members
with no central supply. Not a search snippet, and not your own inference from an absence. "I could
not find a supplier" is still unresolved; this marker means "I found the authority saying there
is none".
Watch the direction of travel before you set it: Großharrie was declined for this marker on 2026-09-02 precisely because its council voted in 2021 to build a central supply (~€2.3m) and nobody has established whether it was completed. A 2021 absence is not a 2026 absence. Check for newer evidence before settling a municipality that was moving.
Identity: official_domain is the key
Same rule as the enrich skill, and the reason is in wasserversorger-enrich/SKILL.md. Normalise
before comparing: lowercase, strip scheme and www., reduce to the registrable domain (eTLD+1).
When the domain cannot be resolved: pending_domain
Discovery finds a delegated supplier by name, on the VG's page — and the delegate's own website is often not linked there. Guessing it fails more often than not (three guesses for Zweckverband Wasserversorgung Kreis Altenkirchen all failed to resolve).
Since official_domain is the dedup key, a row without one cannot be deduped or safely imported.
Make the gap explicit and recoverable rather than blank:
- Set
official_domain: nullanddomain_status: "pending_domain". Never leave a blank field with no marker — at scale that silently accumulates key-less rows which only surface at import time, as duplicates or rejects. - Put what you do have in
notes: the verbatim supplier name and the page that named it. That is what the later lookup starts from. - A
pending_domainrow is not importable. It is still a valid discovery result — the municipality↔supplier link is real and evidenced — but the company row is incomplete. wasserversorger-enrichtreats these as priority input, because they block the load.
Rows that do have a domain carry domain_status: "resolved".
Never auto-merge on a domain collision. Write every collision to a review queue and keep both
rows. Small Eigenbetriebe sit on the municipality's domain, Samtgemeinden and
Verwaltungsgemeinschaften share one website across several municipalities, and parent/subsidiary
pairs share a domain too — so one domain legitimately covers several distinct suppliers. A wrong
merge here is the same undetectable error that disqualified handelsregister_nr.
Collision check at the end of every sweep, across the sweep and the existing roster:
node -e '…group roster.part-*.jsonl + companies.jsonl by normalised domain, print any with >1…'
Measured so far: 0 collisions across 44 companies — but that population is big utilities on their own domains. The failure mode lives in the small-municipality tail and is still untested. Treat a clean collision report from a head sweep as not yet evidence.
One shape to expect: one brand over several legal entities. HAMBURG WASSER is Hamburger Wasserwerke GmbH (drinking water) plus Hamburger Stadtentwässerung AöR (sewerage) on one domain. Record the water entity.
The non-supplier filter
16% of pilot 1's roster were not water suppliers at all — Murrtal, Obere Lippe, WVER and BRW are flood-protection and river-basin bodies that look exactly like Wasserverbände.
Cheap automated filter: no analysis page anywhere + no Trinkwasser content on the site. All
four defects had no analysis_url whatsoever, and TrinkwV §45/§46 obliges every real supplier to
publish. A "complete" record is not a fit one — the only pilot company complete on every field was
a flood-protection Verband.
Watch also for Wasser- und Bodenverbände (land drainage) in Ortsrecht listings. They are not suppliers either, and they trapped two of pilot 2's municipalities.
The Steps 0/1 loop
Discovery is iterative, not a single pass:
- Sweep the top of
residual-top.csv. - Re-run
coverage.mjs. Member lists harvested in step 1 claim municipalities you never visited. - Municipalities still unclaimed surface new suppliers — feed them back in.
- Repeat until the residual stops shrinking materially, then switch regimes (head → Verband sweep → per-municipality).
Reconcile only on gemeindeschluessel (AGS), zero-padded to 8. Never join on gemeindename —
names duplicate across Länder and carry status suffixes ("Kiel, Landeshauptstadt").
Hard rules
Country rules in ../../CLAUDE.md apply in full (never invent data; research only, never contact
anyone). Plus:
- Every row carries
source_urlandconfidence. The absence of that gate is the entire reason Stefanie's existing ~1,700 matches cannot be used. Do not inherit them unverified. - A negative must be earned before it is recorded — follow the site's own labelled links first. Pilot 3's one false negative was a supplier marked as publishing no emergency number whose homepage linked "Notdienst" three times.
- Label inference as inference. "Zweckverband ⇒ KdöR ⇒ no Handelsregister" is legally sound and is not a scraped fact. Say which in the reason.
- Overlap is real. The pivot is n-n and 9 pilot municipalities are legitimately claimed by two Verbände (a bulk supplier plus a local one). Do not dedupe these away.
Never read the campaign artifacts whole
The generated artifacts outgrew casual reading and keep growing with coverage — as of
2026-08-22: GV100AD_30062026.txt 3.6 MB, roster-links.jsonl 3.3 MB (was 450 KB two days
earlier), ags-city-map.csv 960 KB, gemeindeverband-members.csv 548 KB.
Query them, never load them. Use grep, grep -F, awk, sed -n '<range>p', wc -l,
head, or a small .mjs that streams line by line. Never cat one into context, and never
read one with a file-reading tool. To answer "what is in the queue" or "which AGS are
unclaimed", filter to the rows you need first and read only those.
This costs nothing in accuracy — same data, same answers — so it is the default, not an optimisation. Two things go wrong when it is ignored:
- Cost. A run on 2026-08-20 spent $11.91 in 3 turns and 54 seconds. That shape means a very large object entered context, not that any work happened.
- The container dies. The 2026-08-21T18:00 run was OOM-killed (
OOMKilled: true,exit=null, empty stdout and stderr, no ledger session). The orchestrator container has a 6 GB cap shared by the parent and its subagents, each already a multi-GB process. Inflated context pushes the total over, and the kernel kills the whole container — taking any other country's concurrently scheduled task with it.
The same rule already applies to fetched HTML below, for the same reason. It applies to our own files too.
Memory safety and polite fetching
Fixed-string searches (grep -F) or read the file directly — never wide-context regexes like
.{0,150}pattern.{0,150} on saved HTML. On minified single-line pages those allocate gigabytes and
have OOM-killed the host (2026-07-22). To read around a match, find the literal and slice a bounded
window.
Never hammer one host. Known traps, all measured:
78.46.40.200hard-bans this IP after ~7 requests and the ban is durable (still refusing an hour later, not a 429). Two campaign companies share that host — reckenberg-gruppe.de and fernwasser-franken.de — so one ban took out two suppliers. Check DNS before assuming a site is down; shared hosting means a per-domain rate limit is not a per-host rate limit.l.de(Leipziger Gruppe) returns 403 to both curl and WebFetch. Bot protection, not absence — record unresolved.bodensee-wasserversorgung.deserves an incomplete TLS chain; curl and WebFetch both fail cert verification. It needs a CA workaround or it reads as unreachable rather than absent.- A guessed domain can resolve to the WRONG REAL ORGANISATION, which is worse than failing.
vghr.dereturns HTTP 200 for "Herrstein-Rhaunen" and 301-redirects tovg-hanstein-rusteberg.de, a different Verbandsgemeinde in Thüringen; the correct domain isvg-hr.de. Never accept a guessed domain on status code alone — confirm the entity name on the page. Prefer one search over three guesses. - Impressum paths are not guessable.
/impressum404s on Stadtwerke Düsseldorf (/rechtliches/impressum/), Stadtwerke Karlsruhe (/de/impressum/) and N-ERGIE (a long portal URL). Fetch the homepage and read the footer link. - WebFetch's summarising model truncates long lists — a 106-name member table came back as "~70 names and 15 water supply associations". Anything over ~50 members needs raw-HTML parsing.
- Normalise Unicode before matching. One site's heading
Öffungszeiten(their typo) did not match agrep -Ffor the same string — theÖwas not the precomposed codepoint.
Resumability
- A municipality is pending when its AGS appears in
residual-top.csvafter a freshcoverage.mjsrun — nothing else. - Append after each municipality. An interrupted run keeps its completed work.
- Keep the per-subagent shards as the evidence trail; merge separately.
- Do all work synchronously inside the run. Never spawn background agents and end your turn waiting — the run terminates the moment you produce your final message, killing anything still running (learned on netzbetreiber, 2026-08-04).
- If a subagent dies (API 529s do happen), check its shard file before assuming partial work exists — a subagent that died early writes nothing at all.
Finish
node campaigns/wasserversorger/coverage.mjs, thenbuild_gv_layer.mjs, thenmap_roster.mjs(mergesroster-links.part-*.jsonlshards intoroster-links.jsonland resolves AGS — this is the current pipeline; run it every time shards changed). Thennode campaigns/wasserversorger/consolidate_roster.mjs, which mergesroster.part-*.jsonlintoroster.jsonl/roster.csvand exportsroster-links.csv— this is the Sheet-import artifact for the main sweep (620+ companies as of 2026-08-22). Do not runmap_ags.mjs/export_csv.mjsfor this. Those two are pilot-only leftovers (they readcompanies.part-*.jsonl/links.part-*.jsonl, the 25-company/905-row pilot, and writecompanies.jsonl/companies.csv/links.jsonl/links.csv) — flagged wrong 7+ times across 2026-08-18 through 2026-08-22 before being fixed on 2026-08-22. Only rerun them if the pilot data itself changes, which it has not since 2026-08-12. Rerunbuild_ags_map.mjswhenevernormNamechanges —ags-city-map.csvcachesname_normand a stale cache silently depresses the exact-match rate.- Run the domain-collision check (across
roster.jsonl+companies.jsonl) and report what each collision actually was. - Journal entry (see country CLAUDE.md).
- Final message: population coverage and the delta, municipalities claimed vs residual, roster size, collisions, where returns fell off by Bundesland, and anything contradicting PLAN.md.