Unityzm · internal

siting

A console for screening land against siting criteria and resolving the consent chain that governs its acquisition. Generic by construction: a project is data on the volume, never content in the repo.

build v0.18.1 phase P2 up since 2026-09-11 14:27Z

Build log

  1. P0

    Domain live, build log published

    live

    The host answers over HTTPS and states which build is serving. This page is the progress channel for every phase after it.

    • /healthz proves the persistent volume is mounted AND writable by writing and unlinking a probe file — the API flag alone cannot tell you that.
    • /healthz carries the version, so the ~5s container restart that any definition update causes is distinguishable from a failed deploy.
    • Dependency-free: node's own http, no npm install, no native build.
  2. P1

    Screening engine and data spine

    in build

    Point and polygon screening fanned across independent data sources, returning each answer plus an agreement flag rather than one confident answer — and carrying an offline boundary set so an upstream outage degrades the console instead of silencing it.

    • Protected land, flood return periods, mining tenements, designated farm land and administrative attribution are QUERIED via WMS GetFeatureInfo, not just drawn. The catalogue was already there; the point query is what makes it a screening engine.
    • Eleven layers carry a positive control — a coordinate measured to fall inside them. A layer that stops returning its own control stops being allowed to mean 'clear'.
    • 'No fixture' is a third state and is NOT rounded up to 'pass'. farm_blocks has no control, so its empty answers are reported as unverified rather than clear.
    • A wrong layer name returns an XML ServiceExceptionReport with HTTP 200. That is treated as an error, never as an empty result.
    • Protected land is genuinely dual-sourced and its two sources agree at the control point. Administrative attribution is NOT: the geoportal's district layer carries OSM_ID and is republished OpenStreetMap, so it is marked not-independent and its agreement is never reported as corroboration.
    • Slope and aspect from terrain tiles decoded in pure JS via node:zlib — no GDAL, no native dependency, no npm install. Validated across the range: 32% at Batoka Gorge, 2% on the Kafue Flats.
    • Soil clay fraction from SoilGrids, scaled by the response's own d_factor rather than read raw.
    • Every source carries endpoint, method, licence and elapsed time. /api/selftest answers 503 when any control fails.
    • The build context is derived from what captain-definition COPYs, and that check only caught "listed but absent". P1's three new modules were required by the entrypoint and listed nowhere — an image that builds clean, then dies on MODULE_NOT_FOUND and replaces a working host with a crash loop. deploy.sh now walks the require graph from the entrypoint and refuses to upload an incomplete context. Proven in both directions.
    • Administrative attribution now has a genuinely independent second opinion. The geoportal's district layer and OpenStreetMap share a lineage, so they could only ever agree with themselves; the OCHA Common Operational Dataset does not, and it CONFIRMED the district finding across all check points.
    • That second source is a bundled file, not an endpoint, which is why the console still answers the consent-chain question while the geoportal is unreachable — as it was for the whole session this was wired in.
    • The bundled set is simplified to a fifth of a megabyte per level from 36 MB. The error that introduced was MEASURED, not estimated, and each file carries its own worst vertex displacement (221 m). A point nearer to a boundary than that is reported as not decidable rather than attributed — the layer-control rule applied to geometry instead of to reachability.
    • Controls run against the bundled set on the same terms as the network ones, because a static file does not go down, it goes wrong, and a wrong one still answers confidently. Two known-inside points and one known-outside point: a truncated copy fails both positives, and the true negative is what catches a geometry test that has been broken into always returning the first unit.
    • The derivation script ships alongside the data. A derived artefact committed without its derivation is a number nobody can check.
    • 0.11.0 — fixed a silent-wrongness bug shared by every coordinate endpoint. Number(null), Number(undefined) and Number('') are all 0, and 0 is a valid latitude, so a MISSING lat was indistinguishable from a deliberate request for the equator: /api/screen, /api/terrain and /api/footprint answered 200 about the Gulf of Guinea instead of refusing. The same coercion made an unsupplied slope threshold clamp to the 0.5% floor rather than the 5% default, so a zone search silently ran at a tenth of its intended threshold and reported that almost nothing passed — an answer that looked like an answer. Both now reject the empty cases before coercing.
    • 0.13.0 — an upload limit that reported itself as a broken server. The body cap was one number, 256 KB, applied to every endpoint including the two that exist to receive bulk data; a 758 KB sweep was refused, and refused by destroying the socket mid-body, so the client saw an NGINX 502 rather than 'too large'. Answering 413 from the stream was still not enough — with an Expect: 100-continue upload the connection died before the real status could flush and curl reported a bare 100. The limit is now checked against the DECLARED Content-Length before a byte is read, so the response reaches the caller, names the limit and names the size that exceeded it; the streaming check remains as the backstop for a chunked body that declares no length. Bulk routes get their own larger cap instead of one number that has to be wrong for somebody.
    • 0.14.0 — THE GEOPORTAL IS BACK. After five sessions unreachable, gis.szi.gov.zm answers again: GetCapabilities 318 KB, TLS verifying, and 11 of 11 positive controls passing — Lower Zambezi, Chiawa GMA, Kafue Flats Ramsar, Kakula Forest, Districts. Everything screened during the outage carries `unknown` on four criteria and is worth re-running rather than being read as an answer.
    • 0.16.0 — responses are compressed, and large ones stop being pretty-printed. /api/blocks was 1.89 MB and took 112 SECONDS: it shipped every block's full cell geometry on every page load, indented at two spaces a line. On a phone that is not slow, it is broken — the panel sat empty long enough to look like a fault. JSON of this shape compresses by an order of magnitude, so gzip is negotiated on Accept-Encoding with Vary set so an intermediary cannot hand a compressed body to a client that never asked. Small responses stay indented because that is how most of this gets read in a terminal.
  3. P2

    Map and candidate register

    live

    A details list beside a map. Each candidate shows the district, province and constituency that govern its acquisition, what the screening sources found, and when they were last asked.

    • The basemap is proxied through this origin, so the content-security policy stays 'self' rather than being widened to a third-party tile host — and no viewer's IP reaches one. Tiles outside the country box or the zoom range are refused rather than passed upstream; an unbounded proxy is somebody else's free CDN.
    • The register starts EMPTY and lives on the volume. The image knows how to hold points, not which points — a second project needs no code change.
    • Points are pasted a block at a time, because a form that takes one point at a time is a form nobody uses. A paste of twelve lines with two bad ones reports two added and two rejected WITH the reason; a partial import must never look like a clean one.
    • A screen is cached with the time it was taken and the age is shown, because a screen taken while a source was down is indistinguishable from a fresh one otherwise.
    • Nothing renders as a pass. An unconstrained point reads as 'nothing found', and a point whose sources could not answer takes the warning colour rather than the clear one — an outage is not good news.
    • A candidate within 1.5 km of a district boundary is flagged: a site footprint there can cross into the next district, and with it a second council and a second chief.
    • Screens run one at a time. Overpass is a donated service that rate-limits, and a dozen concurrent screens is how a console stops working for everybody.
    • DELIBERATELY NOT IN THIS PHASE: polygon capture, the full field capture sheet, and a pass/caution/reject rating. The list and the map were what was asked for.
    • 0.9.0 — the atlas at /atlas: the surrounding record, plotted, with a sidenav and a search over everything. A candidate is screened against land, but it is ACQUIRED from people, and the people are in wards, settlements and leader registers rather than in a slope raster. Layers, fields and labels all come from the imported file, so a different workbook yields different layers with no code change and the repo carries no project's data — it arrives on the volume. Every field of every record is indexed, not a nominated name column, because the term someone actually remembers is often a P-code or a source label sitting in a column nobody thought to nominate. Records with no coordinates are searchable and listed as unmappable rather than dropped: a register whose chiefs are invisible because they lack a lat/lon would suggest it holds nothing about the people who decide a conversion. Layer colours are generated on the golden angle rather than drawn from a fixed palette, so more layers than anyone anticipated still separate instead of repeating.
    • 0.10.0 — the map is the root. / now serves the atlas and the build log moved to /build. The security half of that swap is the part worth recording: / was the ONE path the gate deliberately exempted, because a build log naming no point is meant to be public. The moment it started serving plotted project coordinates that exemption would have published the entire register to anyone holding the URL, so / and /index.html were added to the protected set in the same change rather than afterwards. /build, /build.json, /healthz and the selftest stay open, so the progress channel is intact and a monitor still reads the host without a session.
    • 0.13.0 — the atlas becomes usable in the hand. One panel serves both shapes: a left sidenav on a desktop, a bottom sheet over a full-bleed map on anything with a coarse pointer. Deliberately ONE element rather than a mobile copy of the controls, because a second copy is how a layer toggle ends up working in one layout and not the other. The breakpoint tests the POINTER, not just the width — a narrow desktop window still has a cursor, and a touch laptop still has fingers. Tap targets are 44px throughout, text inputs are 16px because anything smaller makes iOS zoom the page on focus, the sheet drags open and closed with a threshold so a tap is still a tap, safe-area insets are honoured, and map fitting allows for the sheet covering the bottom third — otherwise half the data lands behind it.
    • 0.14.1 — the `hidden` attribute had stopped working on two elements. It is only display:none in the user-agent stylesheet, so any class rule setting display outranks it: .detail-pane (display:flex) left an empty record panel sitting over the map in BOTH layouts — a strip across the bottom of a phone, a floating card on a desktop — and .qclear (display:grid) kept the search-clear cross visible with nothing to clear. Neither showed up in markup review because the markup was correct; both were obvious the moment the page was actually opened in a browser. Fixed once globally rather than per-selector, so the next toggle does not have to remember the trap.
    • 0.15.0 — four mobile faults reported from a real phone, all confirmed and all with concrete causes. Leaflet's own control containers are z-index 1000 and its attribution bar spans the FULL WIDTH along the bottom — exactly where the sheet lives — so at z-index 900 the panel sat UNDER Leaflet: it read as 'Leaflet is hiding Results, Layers and Sites' and taps in that band reached the map instead of the list. Sheet now 1200, detail 1300, map button 1100, and Leaflet's bottom controls are lifted clear of the peek rather than hidden, because ODbL attribution has to stay visible. Blur was the tile layer: on a device pixel ratio of 2 or 3 a 256px tile is stretched over 512 or 768 device pixels, so detectRetina now fetches the next zoom and draws it at half size — a real doubling of resolution, and the proxy serves to z18 against a map capped at 16 so the extra level exists.
    • 0.15.0 — loading indicators, counted at the ONE place every request goes through rather than at each call site, so no fetch can be added later that forgets to show itself. A thin indeterminate bar answers 'is it working'; a named placeholder in the waiting panel answers 'on what', because a bare spinner leaves a slow request and a broken one looking identical. The counter decrements on the failure path as well as the success path — an indicator that only clears on success sticks on the first error and then lies for the rest of the session.
    • 0.15.1 — the legend was eating map gestures. It is a caption, but without pointer-events:none it is a 370x57 strip lying across the map in exactly the band a thumb drags through on a phone, and it swallowed the drag. The map stopped panning in that band, which from the outside is indistinguishable from a broken map — and it was the second half of the reported 'can't scroll on the map', the first half being Leaflet's full-width attribution bar sitting above the sheet.
    • 0.15.2 — the last of the dead band over the map. On a phone Leaflet's attribution wraps to the FULL width and stands 42px tall, so even lifted clear of the sheet it laid a strip across the map exactly where a thumb drags. The container no longer takes pointer events; the links inside still do, because ODbL attribution has to remain clickable and making it unusable is not an acceptable way to win back the gesture.
    • 0.18.0 — the full public-map catalogue, as context alongside the project data. All twelve ZNSDI overlay groups are now switchable from the Layers tab: roads and rail, transmission, flood at 1-in-100 and 1-in-20, drought, FARM BLOCKS, mining cadastre, parks and reserves, Lusaka growth 2035, proposed BRT, districts and wards. The two Planning groups were the only ones this console lacked against the public map, and a console that quietly carries a reduced catalogue is worse than one that carries none. Images come through this host's proxy, which is what keeps img-src 'self' and what makes the layers keep working from a network that cannot reach szi.gov.zm. The proxy is gated with the tiles — an open WMS relay is somebody else's map server. An overlay whose tiles never arrive is MARKED rather than left blank: drawing nothing silently reads as 'there is nothing here', and on a flood layer that is the most dangerous available misreading. Overlays are context and say so — switching one on does not re-run a criterion or change a block's verdict.
    • 0.18.0 — zoom ceiling raised from 16 to 18. Sixteen was too coarse to see a field boundary, let alone a track. The tile proxy now carries to z19 because detectRetina spends one level on sharpness rather than reach, so the map reaches 18 on a normal screen and 17 on a retina one — both against tiles that exist, which is the thing to verify before raising a maximum, since asking for a level the proxy refuses fails as silent blank tiles rather than an error.
    • 0.18.1 — the zoom FLOOR was only on the tile layer, so the map still allowed zooming out past it and simply drew nothing. Same silent-blank failure as asking for a zoom the proxy refuses, and from the outside indistinguishable from a broken basemap. Both bounds now sit on the map itself and match the proxy's.
  4. P3

    Contiguous-area finder

    in build

    Grids each search zone, scores every cell against the screening criteria, then clusters passing cells into contiguous blocks that meet the area target. Returns a ranked shortlist, never a single answer.

    • 0.6.0 — the first piece of P3, and the one that changes an answer rather than adding a view. A site of a couple of hundred hectares is well over a kilometre across, so the authorities it answers to are decided by the ground it covers, not by the pin in the middle of it. /api/footprint walks the outline of a footprint of a given area and reports the SET of districts, constituencies and provinces it touches. It does not assume a shape — a 200 ha square reaches 707 m to its edge and 1000 m to its corner, a circle 798 m to its rim — so all three outlines are walked and the worst case is the headline. A sample landing closer to a boundary than the bundled set's own simplification error is indeterminate, and one such sample degrades the verdict to `uncertain`, never to `contained`. Single-sourced, and it says so.
    • 0.7.0 — slope across the footprint rather than under the pin. A centre reading is one 37-metre pixel, and a pin dropped in a gully reads like a cliff in level country; /api/footprint?terrain=1 now samples a 9x9 grid over the ground the plant would occupy and reports the distribution — median, p90, max, and the share of the area under 3, 5, 8 and 10 percent. The centre reading is returned beside it so the two can be compared, because the disagreement between them is the point: a centre sample has been measured reading 'workable' over a footprint with 12 buildable hectares in it. Coverage is reported too — a profile built from a third of its grid is not a profile, and averaging whatever landed would hide it.
    • 0.8.0 — the ranked shortlist, which is the thing the console was built to produce. Each candidate can now be PROFILED: the footprint verdict and the slope distribution across it, cached on the volume beside its screen but separately from it, because a screen is a handful of queries and a profile is ~80 tiled elevation reads and neither should force the other to be recomputed. /api/shortlist ranks on buildable hectares at 5% slope and returns unprofiled candidates LAST rather than dropping them — a candidate nobody has measured is not a candidate that failed, and omitting it would read as a rejection. The row shows the buildable area in hectares as well as a share, flags a footprint that crosses a district boundary as two councils and potentially two chiefs, and where the pin's own slope disagrees with the median of the ground around it by 5 points or more it says so — that disagreement has been measured at both extremes on real pins and is exactly what a centre-only reading hides.
    • 0.11.0 — /api/zone searches the GROUND rather than the pins, which is the only way to tell 'this corridor is unusable' apart from 'this pin was badly placed'. It grids a search area, scores every cell on slope, and flood-fills the passing cells into contiguous blocks. Four-connected, not eight: cells touching at a corner are two fields either side of a gully, not one site. AREA IS NOT SHAPE, and that is the trap — 200 ha strung along a river in a 40 m ribbon is still 200 ha and still useless, so every block reports its bounding box, how much of that box it fills, and the largest SQUARE that fits inside it. Read the square, not the area. A cell whose elevation could not be read is never a passing cell; it is counted and surfaced as coverage, because treating an unreadable cell as flat is how a DEM outage becomes a shortlist of imaginary sites. Terrain only — a block here is flat and nothing more, unscreened against protected land, flood, mining or occupation.
    • 0.12.0 — the blocks are on the map. Drawn as the CELLS that passed, not as a bounding box: fill ratios run about 0.3 to 0.7, so a box would show roughly twice the land that qualified, and would show it as a tidy rectangle — precisely the impression the numbers exist to prevent. The largest inscribed square is drawn over the top as a dashed outline, because that is the part a plant could actually occupy. Rank drives opacity rather than colour, since these are all the same kind of thing and a colour scale would imply a judgement the terrain screen has not made; a block big enough for the target is outlined but never coloured as a pass, because it is flat ground, not a screened site. Coverage is printed on every search, not only when it is poor — a block list built from a partly-read grid is not a block list of that ground.
    • 0.13.0 — blocks are persisted and plotted. A zone search is thousands of tiled elevation reads and a sweep across a whole area of interest is that cost sixty times over: fine to pay once, absurd on every page load. Saved blocks live in their own store, NOT the candidate register — a register point is a human decision, a block is the output of a slope threshold, and merging them would let machine output quietly acquire the standing of a judgement. Saved blocks draw in a different colour from a live search so the two are never confused. Blocks near mapped water are FLAGGED and kept, never filtered out: terrain screening rewards floodplain, and hiding the flat ground beside a river would conceal the single biggest weakness of screening on slope alone.
    • 0.14.0 — blocks carry their screening. The store keeps verdict and confidence per criterion, and the list's caveat is COMPUTED rather than asserted: a flat 'terrain only' line would be false once some blocks are screened, and a caveat that is sometimes false trains the reader to skip it. The map shows a constrained block in a different marker colour and the row names only the criteria that are NOT clear — a row of four 'clear' badges reads as an endorsement, whereas a list of what stands in the way reads as what it is. A block with no screening says 'not screened' rather than looking clean.
    • 0.16.0 — saved blocks load in two passes. The summary is small, arrives at once, and carries everything the list needs plus enough for the map to draw each marker and inscribed square; the cell geometry follows and upgrades the drawing to the true shape. Drawing only the squares would have been faster still and dishonest — a block's real outline branches and the square flatters it — so the order is fast, then true. If the second pass fails the map keeps what it has and says the outlines are what is missing.
    • 0.17.0 — blocks draw as outlines on a canvas instead of a rectangle per cell. A sweep on screen was 28,312 separate map features, each an SVG path with its own DOM node — 28,582 paths in a 29,066-node document, re-rasterised on every pan. That is what 'slow on mobile' was. The server now traces each block's boundary: an edge belongs to the outline exactly when the cell beyond it is not in the block, and the edges stitch into closed rings in integer grid space so corners meet exactly rather than within a tolerance. Holes fall out as separate rings, which is correct — an unbuildable knoll inside a block is not buildable ground, and one block here has 86 of them. 28,312 features become 1,190 polygons, drawn by Leaflet's canvas renderer with no DOM node each. The picture is the same; the cost is not.
  5. P4

    Consent chain and dossier

    planned

    Resolves district, ward, local authority and traditional authority for a parcel, and emits the ordered consent chain with its statutory basis — including the additional wildlife-authority consent where a parcel falls inside a game management area.

    • Every authority record stores source, confidence, and who verified it when.
    • A dossier renders as UNVERIFIED until a human has confirmed the authority by phone or visit. The console never asserts a traditional authority as fact.
    • Exports to the formats field officers actually use.
  6. P5

    Hardening

    planned

    Named accounts with per-record salted hashes, server-side capability checks, append-only audit, rate limiting, and backups of the volume.

    • 0.5.0 — a shared-passphrase gate now stands in front of every surface that carries project data: the console, the candidate register, the screening and terrain endpoints, and the tile proxy. It fails CLOSED: a host with no passphrase configured refuses those surfaces with 503 rather than publishing them, which is the opposite of this platform's presence-flag kill switches and is deliberate — a lock whose configuration goes missing must not open. Constant-time comparison over digests, an 8-attempt/15-minute rate limit keyed on the LAST X-Forwarded-For entry (the trusted edge appends the real peer there; the left entries are caller-supplied), and a session cookie whose key is derived from the passphrase, so rotating the passphrase expires every live session without a separate revocation step. This is NOT the named accounts, per-record hashes or append-only audit P5 still owes — nothing here records WHO did anything. It is the minimum that had to exist before real coordinates went onto a public host.

Standing caveats