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.mdfirst (the rulings and the words), thensegments-and-geometry.md(the segment this slice extends). This file is the data-model home for georeferencing thatui/library-view-modes.mdsays 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:
- 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.
- A place is tied to a gazetteer by a typed identifier, never free text, with candidates, confidence and a chosen one.
- 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.
- 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/, contexthttp://iiif.io/api/extension/georef/1/context.json). A W3C Web Annotation with motivationgeoreferencing: 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.resourceCoordsin pixels,geometrya WGS 84 Point) and an optionaltransformation(polynomialwith 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 typeImage, 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
.pointsfile 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
.prjbeside 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’sclaim_geo, andEvidentialPlace(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.pyalready keepsgeo_bboxapart from image rectangles by name, which this spec keeps. - A geocoder, not a gazetteer link:
media/geo.pyresolves 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): anAuthoritySnapshotis 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 actionentity.link_authority, which appends{authority, authority_id}toentity.metadata["authority_links"]and writes an audit row. Pinned byfichero-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 inmetadata, not a typed record that can be queried. - The typed link (
models/typed_links.py): one record with type, direction, maker and certainty, and anamestype (“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.swiftandKGMapView.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
GeoPointandEvidentialPlacevalues 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, alongsidewikidata(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.pyis 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, aSpecificResourcetarget 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
.pointsfile 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
.prjfor 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
GroundOverlaywith itsLatLonBox). 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 spatialat 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
.geoMapmode (todayDatasetMapView/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 threesource.geo.*bullets (control-points,segment-to-world,names-a-place): point each at this file, where it is refined intogcp-is-a-segment,world-shapeandplace-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, andsource.link.typed: a link end can be a KG entity (LinkEndKindgainsentity), 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-outpoint 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
- 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.
- 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-wgs84now follow the ruling. - 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).