Skip to content

Source Model — Maps and georeference — Design Spec (#5120)

Milestone: source-model Manual: TBD — part of the “How Fichero represents a source” section: how a scanned map is tied to the earth by control points, how a place named on any page is tied to a gazetteer, and what leaves Fichero for Allmaps, QGIS or a web map.

Design-led (Testing Constitution). The creative director owns this intent; tests enforce it; code makes them pass. Status: DRAFT. A slice of the source model: read source-model.md first (the rulings and the words), then segments-and-geometry.md (the segment this slice extends). This file is the data-model home for georeferencing that ui/library-view-modes.md says is missing (#5120). It owns no screen: the map view is left to a future UI spec (see “Out of scope”). Every behaviour below is [GAP] with its issue unless it says otherwise.

Intent

A historical map is a source like any other: a page with ink on it. What makes it a map is that some points on it can be tied to places on the earth. Once they are, three things become possible that a researcher needs: the sheet can be laid over a modern map; any segment drawn on it (a road, a parish boundary, a label) can say where it is in the world; and a place named on it, or on any page of any source, can be tied to a shared gazetteer so it can be found, counted, mapped beside other sources and published as Linked Open Data.

This slice answers four questions the archive model had left soft (#5120), and each answer is a behaviour below:

  1. A ground control point (GCP) is a first-class segment, not a by-product of a stored transform. The transform is worked out from the GCPs.
  2. A place is tied to a gazetteer by a typed identifier, never free text, with candidates, confidence and a chosen one.
  3. The coordinate reference system (CRS) is always explicit: every coordinate arrives with an EPSG code, and is stored in WGS 84 (EPSG:4326) (ruled 2026-09-27, #5124). “Lat/lon” alone is never enough.
  4. IIIF Georeference Annotations go in and come out through the format harness, tested on real files other people made, as PAGE, ALTO and TEI already are.

The same data model also has to carry what the wider research pipeline needs: place names and boundaries that change over time, places described relative to other places in prose, and export of places and movements to GeoJSON and a spatial database. Those are specified here too, as data; how they are drawn is not.

Prior art (what this builds on, and what it does not invent)

  • IIIF Georeference Extension (https://iiif.io/api/extension/georef/, context http://iiif.io/api/extension/georef/1/context.json). A W3C Web Annotation with motivation georeferencing: its target is the image (or canvas) plus an SVG selector for the part that is the map (the “resource mask”); its body is a GeoJSON FeatureCollection in which each Feature is one GCP (properties.resourceCoords in pixels, geometry a WGS 84 Point) and an optional transformation (polynomial with an order, thinPlateSpline, helmert, projective). The unit exchanged is the GCP, not the fitted transform. That settles question 1: a model that stored only a transform could not write this format.
  • Allmaps (MIT; github.com/allmaps/allmaps) reads and writes these annotations and is the natural partner the way eScriptorium is for PAGE XML. Its parser accepts two dialects: the published extension (resourceCoords, the georef context) and an earlier one (pixelCoords, a target of type Image, the anno + GeoJSON-LD contexts). Real files exist in both.
  • Mapwarper and the QGIS Georeferencer exchange GCPs as small tables (pixel x, pixel y, map x, map y; QGIS’s .points file names the CRS in a header line). Again: GCPs, not transforms.
  • GDAL world files (six numbers: an affine transform, no CRS; the CRS travels in a .prj beside it) and GeoTIFF (which can carry GCPs as tie points and the CRS as GeoKeys without the image being warped).
  • GeoJSON (RFC 7946) is WGS 84 longitude/latitude only. Anything in another CRS needs a format that carries one: GeoPackage (the OGC SQLite format QGIS opens; SpatiaLite is its sibling).
  • Linked Places Format (LPF, the World Historical Gazetteer’s GeoJSON-LD): a place has several toponyms and several geometries, each with a when (a time span) and a citation. This is the established answer to “names and boundaries change over time”, and this spec adopts its shape rather than inventing one.
  • Gazetteers, already catalogued in kg/kg-enrichment.md: GeoNames (modern), Pleiades (ancient), Getty TGN (historical), Wikidata (hub). This spec adds the World Historical Gazetteer (WHG), which that catalogue lacks and which is built for exactly this material.
  • EPSG codes name a CRS. EPSG:4326’s official axis order is latitude, longitude; GeoJSON and IIIF write longitude, latitude. Stating the order is part of stating the CRS.

What this deliberately does not invent. A second reconciliation system (the KG has one); a second link record (the source model has one: TypedLink); a second geocoder (media/geo.py exists); a second exporter (the formats harness and export_service.py exist).

What exists today (read on disk, 2026-09-27)

  • No georeferencing at all. Nothing in fichero-server/src/ stores a GCP, a transform or a CRS. There is no GeoJSON writer, no DuckDB spatial extension, no PROJ.
  • Coordinates on claims and entities: GeoPoint (models/knowledge.py: lat, lon, precision_m, place_name) on a claim’s claim_geo, and EvidentialPlace (points, regions, paths and sets; lat, lon, geo_bbox, geojson, basis, confidence, source fields) on entities. Neither names a CRS; both mean WGS 84 by assumption. anchors.py already keeps geo_bbox apart from image rectangles by name, which this spec keeps.
  • A geocoder, not a gazetteer link: media/geo.py resolves a place name to a point from a small offline table, then (opt-in) Nominatim, and says which tier answered (#4668). It returns coordinates, never an identity.
  • Authority reconciliation for entities (api/routes/kg/entity_curation.py): an AuthoritySnapshot is a locally cached record of an external authority (wikidata, viaf, loc) fetched only by an explicit, opt-in refresh; candidates are found by matching an entity’s name and aliases against cached snapshots without fetching; a person (or an agent, as itself) confirms one with the audited action entity.link_authority, which appends {authority, authority_id} to entity.metadata["authority_links"] and writes an audit row. Pinned by fichero-server/tests/unit/security/test_external_authority_reconciliation.py. What it lacks for places: no gazetteer is an allowed authority; a candidate has no confidence and is not kept; a rejected candidate is not remembered; a link is a dict in metadata, not a typed record that can be queried.
  • The typed link (models/typed_links.py): one record with type, direction, maker and certainty, and a names type (“Names” / “Is named by”) already in the vocabulary. Its ends can be a segment, note, document, claim or canvas item, not a KG entity.
  • Maps in the app: DatasetMapView.swift and KGMapView.swift (MapKit) plot points from entities and datasets. They draw no scanned map.

The design

A control point is a segment (question 1)

A ground control point is a point segment on one image of a page, in a pass, with a maker and a certainty like any other segment. Its reading is a world coordinate: a point, a CRS, and optionally how precise the person was (“to the nearest 50 m”). A GCP is therefore corrected alone (move the pixel end, retype the world end, withdraw it) as one audited, undoable action, and nothing else changes.

The set of GCPs that georeferences a map is a pass (a “georeferencing pass”). Two people’s georeferencings of one sheet are two passes, both kept; which one is used follows the project’s working-pass rule, as for any layout. A machine may propose GCPs (matching road junctions to a modern map, say); they arrive in a machine pass and are shown as unchosen until a person chooses, as the source model rules.

The pass also holds the mask: an area segment saying which part of the image is the map (not the title cartouche, the margin or the scale bar). A sheet with two maps on it (an inset of the town beside the county) has two masks, each with its own GCPs; the GCPs belong to a mask by a typed link, not by being inside it.

The transformation type (affine, i.e. polynomial order 1; polynomial 2 or 3; thin-plate spline; Helmert; projective) is a property of the pass, chosen by a person, with a default (polynomial 1, which needs 3 GCPs and is what a world file can hold).

The transform is worked out, never the master. It is computed from the pass’s GCPs and its transformation type. A stored copy is a cache that names the version of the GCP set it came from and is thrown away when that changes. From it come each GCP’s residual (how far the fitted transform misses that point, in pixels and metres), which is how a person finds the one GCP that was typed wrong.

This replaces the “geographic anchor kind plus warp transform” of the archival plan (P11): a geographic place is not a new kind of anchor on the image; the segment’s anchor stays on the image, and its place in the world is worked out through the georeferencing pass.

GCPs are measured on one image. On another image of the same page (a rescan, a deskewed copy) they apply only through a known alignment, the rule segments-and-geometry.md already has.

Any segment’s place in the world

On a georeferenced map, any segment can be asked for its place in the world: the engine applies the working georeferencing pass’s transform to the segment’s shape and answers with a shape in world coordinates, its CRS, which pass and transform version produced it, and an error estimate from the residuals. The answer is worked out and never stored as the segment’s truth; a corrected GCP changes every answer at once, which is the point.

A segment outside the mask is answered “outside the map”, never extrapolated silently.

The coordinate reference system is always said (question 3)

  • Stored in WGS 84 (ruled 2026-09-27, #5124). Every world coordinate Fichero stores is in EPSG:4326. The maintainer’s ruling overrides this spec’s earlier recommendation, which was to store coordinates as entered.
  • The input CRS is still always said. A coordinate cannot be converted correctly without knowing what it arrived in, so the CRS it arrived in is an EPSG code (or, for a CRS with no EPSG code, its WKT2 text) and is never assumed. A GCP typed in a national grid (British National Grid, EPSG:27700) is converted to WGS 84 when it is written. The stored coordinate records the CRS it came from and the conversion used, because a datum shift is not exact and the choice is a fact about the result.
  • Exchange default: EPSG:4326, written longitude then latitude wherever the format does so (GeoJSON, IIIF). The axis order is recorded, not assumed.
  • Unknown is a value. A file that arrives with no CRS (a world file with no .prj) is imported with CRS unknown. It cannot be converted, so its numbers are held unconverted and marked unknown: they can be looked at, but they are not a stored world coordinate and cannot be overlaid or exported as geographic data. Once a person declares the CRS, they are converted and stored. It is never taken to be WGS 84.
  • Existing GeoPoint and EvidentialPlace values are WGS 84 by assumption today. They are declared EPSG:4326 by an additive schema change that states the assumption, rather than left implicit.
  • CRS conversion needs PROJ and its database. The engine ships sandboxed and cannot download code at run time, so PROJ (through pyproj, or through DuckDB Spatial, which bundles it) ships with the app at build time.

Places and gazetteers (question 2)

A place segment is any segment that names a place: a label on a map, a place name in a line of a letter, a cell in a register. It is tied to a place entity in the knowledge graph with the existing typed link names, with a maker and certainty. The gazetteer identity belongs to the entity, not to each segment: ten letters that name Popayán are ten names links to one entity, and that entity is reconciled once. Ruled 2026-09-27 (#5123): the gazetteer link lives on the PLACE ENTITY and never on a segment. WHG, Pleiades, Getty TGN and Wikidata all connect to that one entity, and the entity may point to the segments that mention it.

The entity’s gazetteer identity extends the authority seam that exists rather than building a second one:

  • The allowed authorities gain the gazetteers: whg, pleiades, tgn, geonames, alongside wikidata (which is already there). Each authority has one canonical URI form (https://pleiades.stoa.org/places/<n>, http://vocab.getty.edu/tgn/<n>, http://www.wikidata.org/entity/Q<n>, https://sws.geonames.org/<n>/, WHG’s place URI), and an identifier that does not fit its authority’s pattern is refused. Free text is never a gazetteer identity.
  • Candidates are kept, each with its authority, identifier, label, the snapshot it came from, how it was found (exact name, alias, a machine’s match) and a confidence. A person (or an agent, recorded as itself) chooses one; the others stay as rejected candidates so the same wrong match is not offered again. Choosing is the existing audited action, extended.
  • A place may be the same as entries in several gazetteers (Pleiades and Wikidata for one Roman town). Those are several chosen links, not a conflict.
  • Lookups stay opt-in and cached: candidates are found from local snapshots; only an explicit refresh goes to the network, behind the existing external-authority switch. Reading a place never fetches.
  • The geocoder in media/geo.py is a different thing: it answers “roughly where is a place of this name”, not “which place is this”. Its answers are labelled machine guesses and never become a gazetteer link without a person’s choice.

Because the link is typed, “every segment in the library that names Pleiades place 579885” is one query, from the app, MCP and the command line; and the reconciliation leaves as Linked Open Data (the entity owl:sameAs its gazetteer URIs; Linked Places Format for the place itself).

Places over time

Names and boundaries change. The place entity adopts the Linked Places Format shape:

  • Several names, each with a language, a script, a time span when it was used (as far as known) and the source that attests it. “Santa Fe de Bogotá” and “Bogotá” are two names of one place, not two places.
  • Several geometries, each with a time span and a source. An administrative area (a parish, a province, a colonial audiencia) is an area in world coordinates with a span; asking for a place as of a date gives the geometry valid then, or says there is none. A boundary drawn on a georeferenced map is a segment; its worked-out world shape can be adopted as one of the place’s geometries, and remembers the segment and map it came from.
  • A georeferenced map carries the date it depicts (which can differ from when it was drawn or printed), so what it shows can be placed in time.
  • Time spans use the model’s existing evidential dates (EvidentialDateRange), not a new kind of date.

Places described in words

“Two leagues north of the river”, “half a day’s ride from the mission”. A relative description is stored as what it is: a claim with an anchor place (the river, itself a place entity), a relation (north of, near, between, upstream of), a distance as written with its unit (“two leagues”), and a certainty. It resolves to an area of uncertainty, never a point.

A historical unit is kept as written together with the conversion used (which league: the Castilian legua differs from others by region and period), so the resolved area can be recomputed when a better conversion is chosen. It is never folded into metres silently.

Resolving a description to an area is a worked-out thing; the description is the record.

Formats (question 4)

These are readers and writers on the one formats harness (formats/harness.py), obeying every rule in formats-and-training.md: import arrives as a new pass; nothing unrecognised is thrown away; export is validated; every export has a loss report; round trip is tested; real files from other software, never files written to look like them.

  • IIIF Georeference Annotation, in and out. In: both dialects Allmaps accepts. Out: the published extension (resourceCoords, the georef/1 context, a SpecificResource target with an SVG selector for the mask, the transformation type). A georeferencing pass round-trips: GCPs, mask and transformation type come back; what the format cannot carry (makers, certainty, residuals, a CRS other than WGS 84, which it does not allow) is in the loss report, and a GCP entered in another CRS is written in its worked-out WGS 84 form with that said.
  • Validation. The extension publishes a JSON-LD context and normative prose, and no JSON Schema was found when this was written. Validation is therefore a checker written from the extension’s normative requirements, vendored with its source URL and date, the same way the XML schemas are vendored. If an official schema appears, it replaces the checker.
  • GCP tables: the QGIS .points file and Mapwarper’s GCP CSV, in and out, with the CRS carried (QGIS) or declared on import (Mapwarper).
  • World file and GeoTIFF, out (and world file in): a world file with its .prj for an affine pass; a GeoTIFF of the unwarped image with the GCPs as tie points and the CRS as GeoKeys. Neither needs the image to be warped.
  • GeoJSON, out: places, place segments in world coordinates, and entity movements (claims that put a person at a place at a time, as LineStrings or timed Points), each Feature carrying the stable reference back to its segment, claim or entity. WGS 84 only, as RFC 7946 requires; anything else goes to GeoPackage.
  • GeoPackage, out: the same layers in any CRS, for QGIS.
  • KML, in and out: what Mapwarper exports a warped map as (a GroundOverlay with its LatLonBox). It is the one XML format in this family and an OGC standard with a published XSD, so our KML export is validated against the vendored schema like PAGE and ALTO (licence first). Import and export whatever Allmaps and Mapwarper use (maintainer, 2026-09-27): IIIF Georeference for Allmaps, and the GCP CSV and KML for Mapwarper. Each is tested on REAL exports from that software, not only on files we write.
  • Linked Places Format, out: place entities with their names, geometries, spans and gazetteer links, for WHG and other gazetteers.
  • DuckDB Spatial for spatial questions inside the engine (which places fall inside this province as of 1780; which letters name a place within ten km of this one). The extension is shipped at build time; INSTALL spatial at run time is not possible in a sandboxed build.

The real sample, and how its licence was checked. Two files from the Allmaps repository (github.com/allmaps/allmaps, branch main):

File Size Licence What it exercises
packages/annotation/test/input/annotation.1.body-transformation-thin-plate-spline.json (blob 044d48c1, last commit 02cf4afd, 2023-08-21) 1,693 bytes MIT, © Bert Spaan (packages/annotation/LICENSE.md, and "license": "MIT" in that package’s package.json) The published dialect: georef/1 context, SpecificResource target with an ImageService2 source and a four-point SVG mask, a thinPlateSpline transformation, GCPs as resourceCoords + WGS 84 Points (a plan of Paris).
apps/cli/test/input/annotations/7a69f9470b49a744-resourceCrs.json (blob b17998e6, last commit 6c5e737b, 2025-09-05) 2,534 bytes MIT, © Manuel Claeys Bouuaert (apps/cli/LICENSE.md, and "license": "MIT" in apps/cli/package.json) The earlier dialect: an AnnotationPage, a target of type Image with a fourteen-point mask, GCPs as pixelCoords (a plan in Delft).

The repository root declares no licence (GitHub reports none); each package declares its own, and both packages these files live in declare MIT in a LICENSE.md and in package.json. That was checked before naming them, from licence metadata only. MIT may sit in this AGPL repository and its permanent history. Only the annotation JSON is vendored; the map images it points at belong to the libraries that hold them and are not fetched or vendored (the tests need the pixel and world coordinates, not the pixels). The IIIF extension’s own context.json is not vendored until its licence is confirmed: the IIIF/api repository declares none. A further real file from a different producer (a Mapwarper or QGIS export under a permissive licence) is still wanted, so one producer’s reading of the format is not the only one tested. Each vendored file gets a row in tests/unit/formats/fixtures/PROVENANCE.md.

Out of scope (left to a future map-view UI spec)

  • The map view: placing GCPs by clicking on the scan and on a basemap, the side-by-side editor, residuals shown on the map, opacity and swipe.
  • Warping the image on the client (WebGL or Metal), tile servers and serving warped tiles, basemap choice and its licensing.
  • The 3D globe of #1755 (RealityKit).
  • Drawing places, boundaries and movements over a basemap in the Library’s .geoMap mode (today DatasetMapView / KGMapView); what they draw is defined here, how they draw it is not.

A UI spec for the map surface should cite this file for every record it shows; it adds no fields.

Behaviors

Control points and the transform - source.geo.gcp-is-a-segment — [PARTIAL] (#4933) a ground control point is a point segment on one image, in a pass, with a maker and certainty, whose reading is a world point with a CRS. Built 2026-09-28 (#5122): a control-point segment with a point anchor; its world end is a world-point reading (maker, history, corrections, undo as any reading), stored in WGS 84 (fichero-server/tests/unit/api/test_a_control_point_has_a_place_on_the_earth.py::test_the_files_first_gcp_is_stored_in_wgs84_with_what_it_arrived_as; from a file: fichero-server/tests/unit/api/test_a_georeferencing_file_imports_as_a_pass.py). Certainty per GCP is not recorded yet, and no screen shows one. - source.geo.gcp-corrected-alone — [PARTIAL] (#4933) moving, retyping or withdrawing one GCP is one audited, undoable action that changes that GCP’s record and no other. Built 2026-09-28 (#5122), with no new action: move = segment.update, retype = a world-point reading that corrects the old one and counts (#5175), withdraw = segment.delete; each is one audited action that changes that GCP alone and undoes through the audit trail (fichero-server/tests/unit/api/test_one_control_point_is_corrected_alone.py, on the real Allmaps Paris plan). PARTIAL: no screen makes these calls yet. - source.geo.georef-is-a-pass — [PARTIAL] (#4933) a map’s GCPs, mask and transformation type form one georeferencing pass; two georeferencings of one sheet are two passes, and the working-pass rule chooses between them. Built 2026-09-28 (#5122): a georeference file imports as ONE pass holding its GCPs, masks and transformation (fichero-server/tests/unit/api/test_a_georeferencing_file_imports_as_a_pass.py). PARTIAL: two georeferencings of one sheet are two passes by construction, but the working-pass rule has not been exercised on georeferencing passes, and no screen shows them. Working pass (2026-09-28, maps C1): georeferencing passes are ranked by the working-pass rule AMONG THEMSELVES, apart from text passes: an imported georeference no longer outranks a page’s transcription (its text went blank), and a choice of one kind no longer retires the other’s (fichero-server/tests/unit/api/test_machine_gcps_are_unchosen.py::test_a_georeference_imported_onto_a_page_leaves_its_text_alone, ::test_with_two_georeferences_the_rule_picks_and_says_why). - source.geo.mask — [PARTIAL] (#4933) the part of an image that is the map is an area segment in the pass, and a sheet with several maps has several masks each with its own GCPs. Built 2026-09-28 (#5122): the mask is a mask area segment and each GCP controls its mask by a typed link (fichero-server/tests/unit/api/test_a_georeferencing_file_imports_as_a_pass.py). PARTIAL: a sheet with two masks is read format-side (test_iiif_georef.py) but not yet imported through a library in a test. - source.geo.transform-is-derived — [PARTIAL] (#4933) the transform is worked out from the pass’s GCPs and transformation type; a stored copy names the GCP-set version it came from and is discarded when that changes. Built 2026-09-28 (#5122): GET /api/georeference/passes/{pass_id}/transform works the transform out on every read from the pass’s usable GCPs and type – nothing is stored – and names the GCP set it came from (gcp_set_version, which a corrected GCP changes) so a caller’s cache can tell (fichero-server/tests/unit/api/test_the_transform_is_worked_out_from_the_gcps.py::test_affine_misses_by_a_little_and_a_gcp_typed_wrong_misses_by_most). PARTIAL: no screen draws it. - source.geo.transformation-type — [PARTIAL] (#4933) the transformation type is an explicit choice on the pass, defaulting to affine, and too few GCPs for the chosen type is refused with the number needed. Partly built 2026-09-28 (#5122): a typed SegmentPass.transformation (polynomial-1..3, thin-plate-spline, helmert, projective; polynomial-1 when a file states none; an unknown one refused by name), shown on PassRead, existing libraries gain the column on open (fichero-server/tests/unit/api/test_a_georeferencing_file_imports_as_a_pass.py::test_an_existing_library_gains_the_transformation_column_on_open). Choosing it (2026-09-28): georef.set_transformation, audited and undoable, through PUT /api/georeference/passes/{pass_id}/transformation; an unknown type, or one any mask’s GCPs are too few for, is refused with the number needed (fichero-server/tests/unit/api/test_the_transform_is_worked_out_from_the_gcps.py::test_too_few_gcps_is_refused_with_the_number_needed_and_the_choice_undoes). PARTIAL: no screen offers the choice. - source.geo.residuals — [PARTIAL] (#4933) every GCP reports its residual under the current transform in pixels and metres, so a wrong one can be found. Built 2026-09-28 (#5122): every usable GCP reports residual_m (a local plane about the GCPs) and residual_px (the inverse fit), with RMS; a GCP retyped ~700 m off is the worst by more than ten times the honest RMS (fichero-server/tests/unit/api/test_the_transform_is_worked_out_from_the_gcps.py::test_affine_misses_by_a_little_and_a_gcp_typed_wrong_misses_by_most); a thin-plate spline is exact at its GCPs, by construction (::test_the_files_thin_plate_spline_is_exact_at_its_gcps). PARTIAL: no screen shows them. What a residual can and cannot say (2026-09-28, found twice on the real Paris plan): with as few GCPs as a transformation’s parameters allow for, least squares spreads one bad point’s error across the others. Under affine with Paris’s four points, a GCP typed 1 deg off got the SMALLEST residual (archive, 76ba75156’s finding), and a GCP 0.01 deg off was the worst for only one of the four choices (eec2c16ac); leaving one out in turn did not find it either. Under helmert the moved point was the worst for all four. So a high RMS says the SET is wrong; which point is wrong needs redundancy (more GCPs than parameters), and the answer must not be read as naming it before then. - source.geo.machine-gcps-unchosen — [PARTIAL] (#4933) GCPs a machine proposes arrive in a machine pass and are labelled unchosen until a person chooses them. Built 2026-09-28 (#5122, maps C1): the transform and every world shape say whose pass it is, whether it is the image’s working georeferencing pass and why, and unchosen: true for a machine’s pass no person has chosen; pass.choose_working makes it chosen (fichero-server/tests/unit/api/test_machine_gcps_are_unchosen.py::test_a_machines_gcps_are_unchosen_until_a_person_chooses_them). PARTIAL: no screen shows the label. - source.geo.gcp-other-image — [PARTIAL] (#4933) GCPs measured on one image apply to another image of the page only through a recorded alignment, and otherwise Fichero says they do not. Built 2026-09-28 (#5122, maps C2): every GCP, mask and placed shape is carried to the page’s own frame through its image’s recorded relation (Rendition.transform, chained): a pure resample passes, a crop maps exactly; a turned image with no recorded turn, or a relation in pixels of an unknown size, is said – a GCP is left out with the reason, a shape is refused with it (fichero-server/tests/unit/api/test_gcps_on_another_image.py). PARTIAL: rotation angles are not recorded anywhere yet, so every turned image is “cannot”; no screen. A segment’s place in the world - source.geo.world-shape — [PARTIAL] (#4933) on a georeferenced image any segment’s world shape is answered with its CRS, the pass and transform version used and an error estimate, and is never stored as the segment’s truth. Built 2026-09-28 (#5122): GET /api/georeference/segments/{segment_id}/world-shape answers an RFC 7946 geometry in EPSG:4326 with the pass, transformation, gcp_set_version and the fit’s RMS as error_m, worked out on every read (fichero-server/tests/unit/api/test_a_segments_place_in_the_world.py::test_a_label_at_a_gcps_pixel_is_at_that_gcps_place, ::test_a_corrected_gcp_moves_the_answer). With several georeferencing passes on one image the working-pass rule picks and the answer says why (maps C1). PARTIAL: no screen. - source.geo.outside-the-mask — [PARTIAL] (#4933) a segment outside the map’s mask is answered “outside the map”, never extrapolated. Built 2026-09-28 (#5122): a segment not wholly inside any mask is answered outside_the_map: true with no geometry (fichero-server/tests/unit/api/test_a_segments_place_in_the_world.py::test_the_margin_is_outside_the_map_not_extrapolated). PARTIAL: no screen shows it. The coordinate reference system - source.geo.crs-explicit — [PARTIAL] (#4933) every coordinate arrives with its CRS as an EPSG code or WKT2, and a write that names no CRS is refused (or held as unknown, below). Built 2026-09-28 (#5122): a world-point write names its CRS (EPSG code, WKT2 or unknown) and axis order, or is refused (fichero-server/tests/unit/api/test_a_control_point_has_a_place_on_the_earth.py::test_no_crs_is_refused_and_another_crs_is_held_unconverted). PARTIAL: GeoPoint/EvidentialPlace declare EPSG:4326 (crs-declared-on-existing, built); a write naming no CRS there is taken as WGS 84, the only CRS they have ever held. - source.geo.crs-stored-as-wgs84 — [PARTIAL] (#4933; ruled on #5124) a coordinate is converted to WGS 84 (EPSG:4326) and stored in it, recording the CRS it arrived in and the conversion used. Built for EPSG:4326 (2026-09-28, #5122): stored lon/lat with crs_in, axis_order_in, as_entered and conversion recorded; lat,lon turned round (fichero-server/tests/unit/api/test_a_control_point_has_a_place_on_the_earth.py::test_latitude_first_is_turned_round_not_misread). PARTIAL: converting any other CRS waits on proj-at-build. - source.geo.crs-exchange-default — [GAP] (#4933) EPSG:4326 is the exchange default and the axis order written is recorded, never assumed. - source.geo.crs-unknown — [PARTIAL] (#4933) data imported without a CRS is marked unknown and cannot be overlaid or exported as geographic data until a person declares one. Built 2026-09-28 (#5122): a world point with no CRS, or one that cannot be converted yet, is held with its numbers as entered, lon/lat null, crs: unknown and the reason (fichero-server/tests/unit/api/test_a_control_point_has_a_place_on_the_earth.py::test_no_crs_is_refused_and_another_crs_is_held_unconverted). PARTIAL: “declare the CRS later and it is converted” waits on proj-at-build; exports do not yet exist to refuse it. - source.geo.crs-declared-on-existing — [PARTIAL] (#4933) existing GeoPoint and EvidentialPlace coordinates are declared EPSG:4326 by an additive schema change. Built 2026-09-28 (maps D5): both carry crs, fixed to EPSG:4326 and defaulting to it, so a row an older build wrote (no crs key) reads back declaring WGS 84 with no number touched; the claim route answers it and refuses a claim point naming any other CRS (422), and the geocoder’s points say it (fichero-server/tests/unit/api/test_existing_coordinates_declare_wgs84.py::test_a_claim_an_older_build_wrote_reads_back_as_wgs84, ::test_the_claim_route_says_the_crs_and_refuses_another, ::test_the_geocoder_writes_wgs84_and_says_so). PARTIAL: no screen shows it. - source.geo.proj-at-build — [GAP] (#4933) CRS conversion uses PROJ shipped with the app at build time and never downloads code at run time.

Places and gazetteers - source.geo.place-segment-names-entity — [PARTIAL] (#4933) a segment that names a place is joined to a KG place entity by a typed names link with a maker and certainty. Built 2026-09-28 (maps D1): a typed link’s end may be a knowledge-graph entity (and a uri); typed_link.create refuses an entity that does not exist, one merged into another (naming the survivor), and an end kind it does not know (it stored any string before). The real Syriac line l_77, “in the land of Palestine”, names its place entity with a certainty, seen from both ends (fichero-server/tests/unit/api/test_a_place_segment_names_its_entity.py). PARTIAL: no screen. - source.geo.gazetteer-authorities — [PARTIAL] (#4933) WHG, Pleiades, Getty TGN and GeoNames are authorities beside Wikidata, each identifier is stored in its canonical URI form, and one that does not fit its authority’s pattern is refused. Built 2026-09-28 (maps D2): knowledge/authorities.py is the one table – Wikidata, VIAF, LoC and the gazetteers Pleiades, TGN, GeoNames, WHG – with each identifier’s pattern and canonical URI; an id given as its URI reads back; anything else is refused. entity.link_authority takes the gazetteers and refuses a malformed id before anything is stored (fichero-server/tests/unit/api/test_gazetteer_authorities.py, real ids). PARTIAL: WHG’s URI form is https://whgazetteer.org/places/<id> as its site writes it, unverified against a published canonical form; no screen. - source.geo.gazetteer-candidates — [PARTIAL] (#5169, #4933) candidates for an entity come from locally cached authority snapshots without fetching, and a chosen one is recorded by an audited action. Built for Wikidata, VIAF and LoC by exact name or alias match (fichero-server/tests/unit/security/test_external_authority_reconciliation.py, its tests “refresh is opt-in and cache-only matching” and “authority link is persisted and refresh failure is loud”; cited by file because they are async def and the pipeline’s test index reads only def). Built 2026-09-28 (maps D4): gazetteer records are candidates like any other (Pleiades’s Lutetia is found for “Paris” through its alias); each candidate says how it was found (found_by: the entity’s name is the record’s label, or a name meets an alias) with the confidence that method earns – a rank of the method, not a measured probability – and its uri, snapshot_id and state. The chosen one stays offered as chosen; choosing rejects its namesakes at the same authority (other gazetteers stay proposed); a rejection is a different_from typed link with its maker, never offered again until withdrawn; choosing a rejected one withdraws the rejection (fichero-server/tests/unit/api/test_gazetteer_candidates.py::test_each_candidate_says_how_it_was_found_and_how_sure, ::test_choosing_one_keeps_it_and_rejects_its_namesakes_at_that_authority, ::test_a_person_rejects_a_candidate_and_it_stays_rejected_until_withdrawn, ::test_choosing_what_was_rejected_withdraws_the_rejection). PARTIAL: no screen, and a gazetteer’s records have no refresh yet – only Wikidata, VIAF and LoC are fetched; Pleiades, TGN, GeoNames and WHG records arrive as recorded snapshots. - source.geo.gazetteer-typed-record — [PARTIAL] (#4933) a chosen gazetteer link is a typed, queryable record with maker, certainty and time, not a dict in entity metadata, and several chosen links to different gazetteers are allowed. Built 2026-09-28 (maps D3): a chosen link is a same_as typed link from the entity to the authority’s canonical URI – maker, certainty, time, withdraw, several gazetteers per entity, no duplicates. The earlier metadata["authority_links"] is MIGRATED on open (ruled 2026-09-28: one store): each entry becomes a link (its confirmer as maker when the audit recorded one, else external_import “earlier authority link”), a malformed entry is refused and kept under authority_links_refused with why, a second open adds nothing; reads come from the links only (fichero-server/tests/unit/api/test_gazetteer_links_and_query.py). PARTIAL: no screen. - source.geo.gazetteer-query — [PARTIAL] (#4933) every segment that names a given gazetteer place is one query, answering the same from the app, MCP and the command line. Built 2026-09-28 (maps D3): GET /api/links/naming?uri= answers every live segment that names an entity same_as the URI, by any accepted spelling of it, with the certainty of each link; a withdrawn link drops out; nothing is fetched (fichero-server/tests/unit/api/test_gazetteer_links_and_query.py::test_every_segment_naming_the_place_by_any_spelling_of_its_uri). The MCP tool fichero_segments_naming_place is the route, and answers the same (fichero-server/tests/unit/api/test_the_naming_query_everywhere.py); the command line’s links naming is generated from the route on the contract sync. PARTIAL: no screen. - source.geo.gazetteer-offline — [PARTIAL] (#4933) reading or querying places never fetches; only an explicit refresh behind the external-authority switch goes to the network. Pinned 2026-09-28 (maps D4): candidates, a choice and the naming query all answer with every socket refused, and the refresh is refused while the switch is off (fichero-server/tests/unit/api/test_gazetteer_candidates.py::test_reading_choosing_and_querying_places_never_touch_the_network). PARTIAL: no screen. - source.geo.geocoder-is-not-identity — [PARTIAL] (#4933) a geocoder hit is labelled a machine guess of coordinates and never becomes a gazetteer link without a person’s choice. Pinned 2026-09-28 (maps D4): a geocoded point lands inferred, made by geocoder, naming which geocoder answered, and makes no link to any authority; the entity’s candidates stay a person’s to choose (fichero-server/tests/unit/api/test_gazetteer_candidates.py::test_a_geocoder_hit_is_a_labelled_guess_and_never_a_gazetteer_link). PARTIAL: no screen.

Places over time and in words - source.geo.names-over-time — [PARTIAL] (#5127, #5120) a place entity holds several names, each with a language, a script, a time span and the source that attests it. Built 2026-09-28 (maps D6): KnowledgeEntity.names – text in its own script, romanized form, BCP 47 language as the source gives it, ISO 15924 script (the source’s, else the letters’), a dated span with its year numbering named, the attesting source, maker – written by the audited, undoable entity.add_name / entity.withdraw_name; all fifteen of Pleiades’s Lutetia names (fichero-server/tests/unit/api/test_places_over_time.py::test_each_name_keeps_its_language_script_dates_and_source). aliases is DERIVED from the names (ruled 2026-09-28: one answer to “what is it called”): an alias written the old way still reads back, search finds a name by either form, a withdrawn name leaves aliases and undo puts it back, names move with a merge (::test_aliases_read_the_names_and_an_old_alias_still_reads_back, ::test_withdrawing_a_name_takes_it_out_of_aliases_and_undo_puts_it_back, ::test_names_move_with_a_merge). PARTIAL: no screen; the KG writer’s own dedupe merges (_entity_writer, cleanup) carry a merged entity’s name texts as plain aliases, not its names. - source.geo.geometry-over-time — [PARTIAL] (#5120) a place holds several geometries each with a time span and source, and asking for a place as of a date returns the one valid then or says there is none. Built 2026-09-28 (maps D7): EvidentialPlace.when dates the existing place evidence (a span, or a point in time) – no second geometry store; entity.add_geometry / withdraw_geometry (audited, undoable); GET /api/entities/{id}/place?as_of= and the MCP tool fichero_place_as_of answer every geometry valid then (rivals as rivals, each with its source), undated ones apart, or none with the reason – never the nearest; each span’s year numbering is honoured. On Wikidata’s North Magnetic Pole (ten dated positions, 1831-2025) and Pleiades’s Lutetia (fichero-server/tests/unit/api/test_places_over_time.py::test_as_of_a_date_the_place_is_where_it_was_then_or_nowhere_said, ::test_rival_geometries_of_one_period_are_listed_as_rivals_and_undated_apart, ::test_the_source_s_year_numbering_is_honoured, ::test_the_mcp_tool_is_the_route). PARTIAL: no screen; the full date model (source.date.*, #4936) is not built – comparison is by year. - source.geo.boundary-from-map — [PARTIAL] (#5120) a boundary segment’s worked-out world shape can be adopted as a place geometry that remembers the segment and map it came from. Built 2026-09-28 (maps D8): entity.adopt_boundary (audited, undoable) works the segment’s world shape out now and adds it to the place’s geometries with adopted_from_segment_id, adopted_from_pass_id and source_document_id (renamed from source_segment_id / source_pass_id, the claims’ legacy artifact-entry name; older rows read back), its error on the ground as precision_m, and when = the map’s depicted date (undated when nobody has said); a shape outside the map is refused (fichero-server/tests/unit/api/test_a_boundary_from_a_map.py::test_a_boundary_drawn_on_the_map_becomes_the_places_dated_geometry, ::test_a_map_nobody_has_dated_gives_an_undated_geometry, ::test_a_boundary_outside_the_map_is_refused). PARTIAL: no screen; the adopted shape is a copy – a later GCP correction does not move it (it names the segment and pass, so it can be adopted again). - source.geo.map-depicts-date — [PARTIAL] (#5120) a georeferenced map carries the date it depicts, separate from when it was made. Built 2026-09-28 (maps D8): the georeferencing pass carries depicts (an EvidentialDateRange), set by georef.set_depicts (audited, undoable) and answered on every world shape; the map’s made date stays the document’s own. On the Internet Archive’s “Plan général de l’Exposition universelle de 1889” (made 1889; depicts the exhibition, May-October 1889); an older library gains the column on open (fichero-server/tests/unit/api/test_a_boundary_from_a_map.py::test_the_map_says_what_it_depicts_apart_from_when_it_was_made, ::test_a_library_from_before_the_column_opens_and_gains_it). PARTIAL: no screen; per pass, so a sheet whose two maps depict different dates needs two passes. - source.geo.relative-place — [PARTIAL] (#5120) a relative description is stored as an anchor place, a relation, a distance as written and a certainty, and resolves to an area of uncertainty, never a point. Built 2026-09-28 (maps D10): EvidentialPlace.relative (RelativePlace: anchor entity, relation as written, bearing and its half-width, distance as written, number, unit, certainty), written by entity.add_relative_place (audited, undoable); the as-of answer’s relative resolves each on read to a GeoJSON Polygon – a ring (with its hole) at distance ± tolerance around the anchor’s geometry as of the date, or the sector a bearing allows – naming the conversion, its source, the tolerance and why, and whether the anchor’s geometry was dated; never counted among the point geometries. On Alcedo (1786): Ibarra “21 leguas de Quito” and “96 leguas al N de Santa Fe”, anchors from Wikidata (fichero-server/tests/unit/api/test_a_place_relative_to_another.py::test_a_distance_from_an_anchor_is_a_ring_never_a_point, ::test_a_direction_narrows_the_ring_to_its_sector, ::test_an_anchor_with_nowhere_to_measure_from_says_so). PARTIAL: no screen; spherical earth; one anchor geometry is measured from (rivals are counted, not unioned); a polygon anchor is measured from its ring’s mean. - source.geo.historical-units — [PARTIAL] (#5120) a historical distance unit is kept as written with the conversion used, so the resolved area is recomputed when the conversion changes. Built 2026-09-28 (maps D10): knowledge/units.py – legua (legal 5000 varas, común 20000 pies), vara, English statute mile and land league, verst, km, m: public-domain sources only (ruled; no Roman mile). VERIFIED against recorded source text (fixtures/units/): the mile and league from NIST Handbook 44 (2024), Appendix C, pp. C-5, C-10, C-11; the verst, 1066.781 m, from Brockhaus-Efron s.v. «Верста» (fichero-server/tests/unit/api/test_the_unit_values_are_the_recorded_ones.py). The Castilian vara (0.835905 m, the 1849 metric law’s equivalence) could not be retrieved as text, so the legua and vara answer verified: false, “cited, not verified”. The library chooses a unit’s conversion (units.set_conversion, audited, undoable); every area re-resolves on the next read and nothing written changes. The tolerance is explicit (ruled): half the spread of the unit’s own conversions, else a stated default of ±25%, said in the answer’s conversion.tolerance_basis (fichero-server/tests/unit/api/test_a_place_relative_to_another.py::test_choosing_another_conversion_re_resolves_and_rewrites_nothing, ::test_a_unit_with_one_conversion_carries_the_stated_default_tolerance, ::test_an_unknown_unit_or_conversion_is_refused). PARTIAL: no screen; the unit list is small and grows with sources.

Formats - source.geo.iiif-georef-in — [PARTIAL] (#4946, → #5125; the library half → #5122) a IIIF Georeference Annotation in either dialect Allmaps accepts imports as a new georeferencing pass with its GCPs, mask and transformation. Built: the format reads both dialects (formats/iiif_georef.py). A GCP becomes a control-point segment with a normalised point and a WGS 84 world. The mask becomes a mask area segment. Each GCP names its mask in foreign["georef:mask"], because the spec ties them by a typed link and the harness has no links, so nesting is not used to fake it. The transformation is the page’s. Pinned on both real files: fichero-server/tests/unit/formats/test_iiif_georef.py::TestReadingThePublishedDialect::test_the_gcps_are_point_segments_with_both_ends, ::TestReadingTheEarlierDialect::test_pixel_coords_are_read_and_the_size_comes_from_the_svg. Into a library (2026-09-28, #5122): built. format.import writes the pass, the GCPs with both ends, the masks and their links, and the transformation (fichero-server/tests/unit/api/test_a_georeferencing_file_imports_as_a_pass.py, both real files). Still PARTIAL: not yet run from the app’s import on a screen. - source.geo.iiif-georef-out — [PARTIAL] (#4946, → #5125; the library half → #5122) a georeferencing pass exports as a georef/1 annotation that passes the vendored checker, with a loss report naming what it could not carry. Built format-side: the published dialect is written, one Annotation per mask (an AnnotationPage when there are several), and every write passes the checker, which the harness runs (fichero-server/tests/unit/formats/test_iiif_georef.py::TestWriting::test_every_real_file_exports_as_the_published_dialect, ::TestWriting::test_two_masks_become_two_annotations_each_with_its_own_gcps, ::TestTheChecker::test_an_invalid_export_is_no_file). Losses are named: segments that are not GCPs or masks, and the plain image URL when converting the earlier dialect (::TestWriting::test_converting_the_earlier_dialect_names_what_it_did_not_write). A missing pixel size is refused rather than invented. The checker is written from the extension’s normative text (https://iiif.io/api/extension/georef/, read 2026-09-27). Each rule quotes its sentence in the code, and each is fired once by ::TestTheChecker. From a library (2026-09-28, maps C3): GET /api/documents/{id}/export/iiif-georef exports the image’s working georeferencing pass – each GCP’s pixel end (in the page’s frame) and counted world end, the mask each controls, the pass’s transformation – and it passes the checker; a correction made in the library is in the file (fichero-server/tests/unit/api/test_a_georeference_exports_from_the_library.py). - source.geo.iiif-georef-round-trip — [OK] (→ #5125) the two vendored Allmaps files import, export and import again with the same GCPs, mask and transformation type, less what the loss report names (fichero-server/tests/unit/formats/test_iiif_georef.py::TestWriting::test_the_round_trip_keeps_gcps_mask_and_transformation, format to format; and through a library, in and out with the same GCPs and transformation: fichero-server/tests/unit/api/test_a_georeference_exports_from_the_library.py::test_the_pass_goes_out_as_it_came_in). Found on the way: the earlier-dialect file (Delft) fails the published checker. It has no georef context and uses pixelCoords. That is true of the file, and it is why the reader is tolerant and the writer strict (::TestReadingTheEarlierDialect::test_the_published_checker_rejects_it_for_the_stated_reasons). - source.geo.gcp-tables — [PARTIAL] (#4946) GCPs go in and out as a QGIS .points file and a Mapwarper GCP CSV with the CRS carried or declared. QGIS .points built 2026-09-28 (#5122, maps C4): format qgis-points reads and writes the QGIS georeferencer’s file; imported, its GCPs’ pixel ends are placed by the page’s recorded size (the file has none; with none recorded the import is refused by name), and the world ends keep the file’s #CRS – none stated is held unknown, a projected CRS is held until PROJ ships; a person’s correction declaring the CRS makes them usable; the export writes a WGS 84 #CRS and the file’s own numbers back (fichero-server/tests/unit/api/test_gcp_tables_qgis_points.py, two real files from the Allmaps CLI’s tests, MIT). Mapwarper GCP CSV: not built – no real export with a licence was found (mapwarper.net states none for its data; NYPL’s warper is retired), and a format is built on a real file or not at all. - source.geo.world-file-geotiff — [GAP] (#4946) an affine pass exports as a world file with its .prj, and any pass as a GeoTIFF of the unwarped image with GCP tie points and the CRS. Not built, and why (2026-09-28, maps C6): no real world file from a georeferencing tool with a stated licence was found – GDAL’s one (autotest/gdrivers/data/png/test.wld) states no origin, so it is not known to be another program’s output – and a format is built on a real file or not at all. Importing a world file would also mean INVENTING control points (a world file is an affine with no GCPs; the model derives the transform from GCPs), which needs a ruling on whether an imported transform may stand without them. The GeoTIFF half needs a TIFF writer the engine does not ship. - source.geo.kml — [GAP] (#4946, → #5125) a warped map goes in and out as Mapwarper’s KML GroundOverlay. Our export validates against the vendored OGC KML schema, and the tests use real Mapwarper exports (licence first). Not built, and why (2026-09-28, maps C6): the spec’s rule is real Mapwarper exports, licence first; mapwarper.net states no licence for its maps’ data and NYPL’s Map Warper is retired, so there is no real KML to test against yet. - source.geo.geojson-out — [PARTIAL] (#4946) places, place segments and entity movements export as RFC 7946 GeoJSON, each Feature carrying its reference back to its segment, claim or entity. Place segments built 2026-09-28 (#5122, maps C5): GET /api/georeference/documents/{doc_id}/geojson writes the image’s segments of kinds (default place) placed through its working georeferencing pass as an RFC 7946 FeatureCollection: each Feature’s id is the segment and its properties carry the fichero:segment reference, the pass, transform version, error and unchosen; exterior rings counterclockwise; a shape that cannot be placed is listed with its reason in fichero:not_placed. Every write is checked against RFC 7946’s rules (models.geo.geojson_problems, each rule firing in a fixture) (fichero-server/tests/unit/api/test_place_segments_export_as_geojson.py). PARTIAL: place ENTITIES and entity movements are not written yet. - source.geo.geopackage-out — [GAP] (#4946) the same layers export as a GeoPackage in any CRS. - source.geo.linked-places-out — [PARTIAL] (#4946) place entities export as Linked Places Format with names, geometries, time spans and gazetteer links. Built 2026-09-28 (maps D9): GET /api/entities/{id}/linked-places – a FeatureCollection (the LPF v1.1 context) whose Feature has every name as a toponym with its language, citation and when (a romanized form as its own toponym, <lang>-Latn), every dated geometry in a GeometryCollection with its own when, and the same_as gazetteer links as exactMatch; when is ISO 8601, so a source’s historical years are converted (Pleiades -30 -> -0029); worked out each time, nothing stored (fichero-server/tests/unit/api/test_places_export_as_linked_places.py::test_lutetia_leaves_with_every_name_dated_in_iso_8601_and_its_gazetteer_link, ::test_a_moving_place_leaves_with_each_position_dated, ::test_a_place_with_no_history_is_still_a_valid_feature). PARTIAL: one place per call (no whole-library export yet), no screen, and not validated against the LPF JSON schema – its repository states no licence, so the schema is not vendored; each geometry is checked against RFC 7946 instead. - source.geo.duckdb-spatial — [GAP] (#4946) spatial questions in the engine use DuckDB Spatial shipped at build time, never installed at run time. - source.geo.fixtures-licensed — [GAP] (#4946) every vendored geographic fixture is real output from other software with a provenance row naming its source, licence and fetch date, and none is under a non-commercial or share-alike licence.

Changes this spec asks of other specs

For the archive lane to apply; this file edits none of them. Applied 2026-09-27, all seven. The link-end request became a new behaviour, source.link.end-is-entity [GAP]. The point shape was already built (#4925), so it became a pointer.

  • segments-and-geometry.md, “Maps and plans” and the three source.geo.* bullets (control-points, segment-to-world, names-a-place): point each at this file, where it is refined into gcp-is-a-segment, world-shape and place-segment-names-entity. Either retire the three in favour of these ids or keep them as umbrellas that cite them; do not keep both as independent behaviours.
  • segments-and-geometry.md, Links, and source.link.typed: a link end can be a KG entity (LinkEndKind gains entity), so a place segment can name a place entity. Today the ends are segment, note, document, claim and canvas item.
  • segments-and-geometry.md, Shape: a point shape is required (already listed as what the anchor must gain); GCPs are its first user.
  • archival-data-model-plan.md, section 3 (“geographic: lat/lon + warp transform”) and P11: a geographic place is not an anchor kind; a segment’s world place is worked out through a georeferencing pass of GCP segments, with an explicit CRS. The 5a table’s P11 row should name this file.
  • formats-and-training.md: source.format.geo-in / geo-out point at this file’s Formats behaviours; the formats table’s last row adds IIIF Georeference Annotation, GCP tables, GeoPackage and Linked Places Format; the W3C/IIIF row’s “control points for maps” points here.
  • kg/kg-enrichment.md, the gazetteer catalogue: add the World Historical Gazetteer, and note that place reconciliation is specified in this file.
  • ui/library-view-modes.md, around lines 352-355 and 444: the data model for #1755 now has a home (this file); the map surface is still homeless until a map-view UI spec exists.

Test matrix

Leg This surface? Pins File
Pure rule (Swift) n (no screen in this slice)
Availability (Swift) n
Backend (pytest) y GCPs, derived transform, residuals, CRS rules, gazetteer links, time-bounded places to be written: test_georeference.py (backend api tests)
Formats harness (pytest) y IIIF georef in/out/round trip on the Allmaps files; GCP tables; world file; GeoJSON; GeoPackage; LPF to be written: test_iiif_georef.py (formats tests)
MCP y the gazetteer query and GCP actions route fichero-mcp/tests/test_mcp_full.py
CLI y import/export of the geo formats; the gazetteer query fichero-cli/tests/
Click-around (XCUITest, Mac) n (future map-view UI spec)
iPhone / iPad n (future map-view UI spec)
Load (#4634) y worked-out world shapes for every segment of a large map sheet stay bounded fichero-server/tests/perf/

Documentation matrix

Audience Doc leg This feature? Lives in
User user manual + screenshot y (later, with the UI spec) maintainer’s manual
Contributor developer docs y this spec
AI / agent MCP tool description y fichero-mcp/**
Scripter CLI --help y fichero-cli/**
Reference capability/endpoint reference y generated

Accessibility identifiers and UX completeness

None: this slice has no screen. The future map-view UI spec owns them.

Open questions for the creative director

  1. Answered (this spec, ruled 2026-09-27, #5123): On the place entity, never on a segment. ~~Where does a gazetteer identity live?~~ Ruled 2026-09-27 (#5123): on the place entity, never on a segment. This is what was recommended here: a place is reconciled once however many pages name it, and each gazetteer (WHG, Pleiades, Getty TGN, Wikidata) connects to that one entity.
  2. Answered (this spec, ruled 2026-09-27, #5124): Stored in WGS 84 with the input CRS kept explicit. ~~Store coordinates as entered, or normalise to WGS 84 on write?~~ Ruled 2026-09-27 (#5124): stored in WGS 84. The input CRS stays explicit, because converting correctly depends on it. The recommendation here had been “as entered”, and the ruling overrides it. The design and source.geo.crs-stored-as-wgs84 now follow the ruling.
  3. Answered (design lead 2026-10-04, applying the spec’s lean): DuckDB Spatial (which also gives GeoPackage), bundled at build time, subject to its size in the bundle. PROJ’s database is several megabytes. Ship it through pyproj, or through DuckDB Spatial (which bundles PROJ and GDAL and also gives GeoPackage writing)? Recommended: DuckDB Spatial, one native dependency for queries, CRS and GeoPackage, subject to its size in the bundle.

Triaged from the backlog (2026-10-04)

  • source.geo.map-view-georeference-overlay — [GAP] (#1755) a page marked as a map can be laid over a real basemap by control points (2D, several maps at once) and later on a 3D globe; the map surface waits on the map-view UI spec (#5128).