mvp-core: answer the ledger in plain language and log every ask #283

Merged
eliribble merged 31 commits from series/19-mvp-core-ask-and-log into main 2026-10-13 17:29:04 +00:00

What and why

This slice adds a plain-language "ask" to the MVP Core ledger and a log of every ask. It introduces two migrations: 00475_ask_log.sql (the ask_log table and its query builders) and 00476_merge_duplicate_employees.sql (a data-only migration merging 1:1 unambiguous same-org duplicate member rows onto one per person). It adds a provider-agnostic LLM package, llm/generate.go with GenerateToolJSON, selected by LLM_PROVIDER (ollama by default, plus llamacpp, openaicompatible and openai), wired at startup from config/config.go and main.go, and pinned in scripts/paseo-env.sh to a small instruct model that stays resident.

An office user types a question and the server turns it into the same MvpSpec the grid authors: a per-org lexicon built from the district's products, interventions and members, with each product's observed treatment method read from the district's own rows; a deterministic date grammar for phrases such as "March to June 2026", "last month" and "last 30 days" anchored to the client's zone; and span extraction that assembles the spec and runs it through the same Validate and ResolveWithUnits the grid uses. The model never emits SQL. It is the fallback for one case only — a question that names no record type but does name a person, who is both an applicator and a technician — and then it picks from a closed list with a schema-constrained reply at temperature 0. An unanswerable question comes back answered=false with a sentence, never a 500. The endpoint is POST /api/mvp-core/ask, and the box sits at the far right of the ledger toolbar: a resolved question silently replaces the shared filter (the records and the dropped filter are the answer), and only a refusal or a transport failure says anything in words.

Every ask is recorded: ask_log holds the question, its resolution (the table, total and spec, or the refusal), the timing and whether the fallback model was consulted. The table is Gleipnir-internal and carries no foreign keys — tools/delete-org.sql hard-deletes organization and user_ rows, so organisation and user names are denormalised beside their ids and a row stays readable after the rows it names are gone. GET /api/sudo/ask-log lists recorded asks newest first, root-only and paged, optionally narrowed by organization or answered state; the workbench gains a root-only /_/sudo/ask-log view that expands each ask to its full question, explanation and spec JSON with copy buttons.

The slice also carries the member cutover that the ask lexicon depends on: duplicate employee rows are merged onto one per person, soft-deleted members are dropped from the organization's selectable list (they were still offered by the employee list and the ledger's Applicator and Technician filters), employee names link to /_/configuration/user/<id>, and the treatment's creator id is now selected and carried on the wire so the Treatments ledger's Applicator cell can link to the employee page exactly as the Observations ledger's Technician cell does.

Notable decisions

  • Ask is deterministic first and the model is the fallback: the deterministic reading measures 9.5ms p100 over the phrasing set against 557ms p100 for the model call, and the whole ask stays inside a two-second budget.
  • A counting question ("how many", "total", "average") is answered with the records rather than refused: the counting words are stripped before matching, because the row total travels with the response and the records behind the number are the useful answer.
  • The ask_log carries no foreign keys and denormalises the org and user names, because the repo's delete-org tooling hard-deletes those rows and a log row must stay readable afterwards. The write itself is best-effort — a logging failure is recorded with zerolog and never fails the ask — and costs about 15ms at p100 against the two-second budget.
  • At most one date range per table and column is emitted; a question naming two genuinely different periods is refused with the periods named in words rather than ANDed into an always-empty filter.
  • A name resolving to several district records with the identical display name — an inactive twin beside its active replacement — is folded into one union match over all of their ids, and any ambiguity that survives labels each candidate with its id, so a surfaced choice always offers two distinguishable options.
  • The employee merge is conservative: one inactive live row and exactly one active live row per same-org normalised name, re-pointing every FK column enumerated from the catalog, carrying over any field the survivor lacks, and soft-deleting the loser. Anything ambiguous is left alone and reported as a NOTICE, and a re-run merges nothing.

Testing

  1. Build and start: nix develop -c go build -tags tiff and nix develop -c pnpm run build-sync, then scripts/paseo-backend.sh, scripts/paseo-shim.sh sync and scripts/paseo-tegola.sh. The backend migrates on startup, or run nix develop -c go build ./cmd/dbmigrate && ./dbmigrate. The fallback path needs a local ollama with LLM_MODEL resident.
  2. Sign in, open /_/mvp-core, and use the Ask button at the right of the ledger toolbar. Ask a grounded question such as "How many BVA treatments in 2026": the popout closes, the ledger's filter, counts, table and map change to the matching records, and nothing is said in words.
  3. Ask a month-run question such as "How many Altosid WSPs were used between July and August of 2024" and expect one date range over that window, not an always-empty intersection with the trailing year.
  4. Ask something outside the four ledgers and expect the popout to keep the question and explain the refusal in words; ask a question naming a table and an id ("How many features at site #182") and expect a relation filter.
  5. Open /_/sudo/ask-log: the asks from steps 2 to 4 appear newest first with their table, total, spec JSON and any refusal, and a badge where the fallback model was consulted. Confirm the log is root-only by checking that GET /api/sudo/ask-log refuses a caller without root.
  6. Click an employee name in the Observations Technician cell and the Treatments Applicator cell: each opens /_/configuration/user/<id>, and the employee page header shows the member's id badge.
## What and why This slice adds a plain-language "ask" to the MVP Core ledger and a log of every ask. It introduces two migrations: `00475_ask_log.sql` (the `ask_log` table and its query builders) and `00476_merge_duplicate_employees.sql` (a data-only migration merging 1:1 unambiguous same-org duplicate member rows onto one per person). It adds a provider-agnostic LLM package, `llm/generate.go` with `GenerateToolJSON`, selected by `LLM_PROVIDER` (ollama by default, plus llamacpp, openaicompatible and openai), wired at startup from `config/config.go` and `main.go`, and pinned in `scripts/paseo-env.sh` to a small instruct model that stays resident. An office user types a question and the server turns it into the same `MvpSpec` the grid authors: a per-org lexicon built from the district's products, interventions and members, with each product's observed treatment method read from the district's own rows; a deterministic date grammar for phrases such as "March to June 2026", "last month" and "last 30 days" anchored to the client's zone; and span extraction that assembles the spec and runs it through the same `Validate` and `ResolveWithUnits` the grid uses. The model never emits SQL. It is the fallback for one case only — a question that names no record type but does name a person, who is both an applicator and a technician — and then it picks from a closed list with a schema-constrained reply at temperature 0. An unanswerable question comes back `answered=false` with a sentence, never a 500. The endpoint is `POST /api/mvp-core/ask`, and the box sits at the far right of the ledger toolbar: a resolved question silently replaces the shared filter (the records and the dropped filter are the answer), and only a refusal or a transport failure says anything in words. Every ask is recorded: `ask_log` holds the question, its resolution (the table, total and spec, or the refusal), the timing and whether the fallback model was consulted. The table is Gleipnir-internal and carries no foreign keys — `tools/delete-org.sql` hard-deletes `organization` and `user_` rows, so organisation and user names are denormalised beside their ids and a row stays readable after the rows it names are gone. `GET /api/sudo/ask-log` lists recorded asks newest first, root-only and paged, optionally narrowed by organization or answered state; the workbench gains a root-only `/_/sudo/ask-log` view that expands each ask to its full question, explanation and spec JSON with copy buttons. The slice also carries the member cutover that the ask lexicon depends on: duplicate employee rows are merged onto one per person, soft-deleted members are dropped from the organization's selectable list (they were still offered by the employee list and the ledger's Applicator and Technician filters), employee names link to `/_/configuration/user/<id>`, and the treatment's creator id is now selected and carried on the wire so the Treatments ledger's Applicator cell can link to the employee page exactly as the Observations ledger's Technician cell does. ## Notable decisions - Ask is deterministic first and the model is the fallback: the deterministic reading measures 9.5ms p100 over the phrasing set against 557ms p100 for the model call, and the whole ask stays inside a two-second budget. - A counting question ("how many", "total", "average") is answered with the records rather than refused: the counting words are stripped before matching, because the row total travels with the response and the records behind the number are the useful answer. - The `ask_log` carries no foreign keys and denormalises the org and user names, because the repo's delete-org tooling hard-deletes those rows and a log row must stay readable afterwards. The write itself is best-effort — a logging failure is recorded with zerolog and never fails the ask — and costs about 15ms at p100 against the two-second budget. - At most one date range per table and column is emitted; a question naming two genuinely different periods is refused with the periods named in words rather than ANDed into an always-empty filter. - A name resolving to several district records with the identical display name — an inactive twin beside its active replacement — is folded into one union match over all of their ids, and any ambiguity that survives labels each candidate with its id, so a surfaced choice always offers two distinguishable options. - The employee merge is conservative: one inactive live row and exactly one active live row per same-org normalised name, re-pointing every FK column enumerated from the catalog, carrying over any field the survivor lacks, and soft-deleting the loser. Anything ambiguous is left alone and reported as a NOTICE, and a re-run merges nothing. ## Testing 1. Build and start: `nix develop -c go build -tags tiff` and `nix develop -c pnpm run build-sync`, then `scripts/paseo-backend.sh`, `scripts/paseo-shim.sh sync` and `scripts/paseo-tegola.sh`. The backend migrates on startup, or run `nix develop -c go build ./cmd/dbmigrate && ./dbmigrate`. The fallback path needs a local ollama with `LLM_MODEL` resident. 2. Sign in, open `/_/mvp-core`, and use the Ask button at the right of the ledger toolbar. Ask a grounded question such as "How many BVA treatments in 2026": the popout closes, the ledger's filter, counts, table and map change to the matching records, and nothing is said in words. 3. Ask a month-run question such as "How many Altosid WSPs were used between July and August of 2024" and expect one date range over that window, not an always-empty intersection with the trailing year. 4. Ask something outside the four ledgers and expect the popout to keep the question and explain the refusal in words; ask a question naming a table and an id ("How many features at site #182") and expect a relation filter. 5. Open `/_/sudo/ask-log`: the asks from steps 2 to 4 appear newest first with their table, total, spec JSON and any refusal, and a badge where the fallback model was consulted. Confirm the log is root-only by checking that `GET /api/sudo/ask-log` refuses a caller without root. 6. Click an employee name in the Observations Technician cell and the Treatments Applicator cell: each opens `/_/configuration/user/<id>`, and the employee page header shows the member's id badge.
Adds doc/nidus/inventory.md, the pesticide accounting module: the chain
from catalog to container to kit to load, the container and its
measurement method, the hierarchical storage location, receiving, kit
production with its repackaging waste, checkout to and return from a
load, the freely-scoped inventory check and its adjustments, and the
district's per-product units and densities.

Additive to the MVP Core and the Treatment Measurement; neither is
modified. The module supplies the kit list and the units and densities
the Treatment Measurement consumes.
The problem is knowing what stock the district holds and following each
unit to where it went. An unrecorded removal is one cause of a
discrepancy, stated with the others; it is not a problem this module
claims to solve.
Density was never something a district records or measures. A kit's dose
is stated in a unit of its own, cut for one product, and no weight-to-volume
conversion is kept.
Groups the MVP implementation docs — core, treatment-measurement, inventory —
under doc/nidus/mvp/, so the set is unmistakably separate from the ontology
term files under doc/nidus/reference/ontology/terms/ (a bare features.md
beside Feature.md invited confusion). References in AGENTS-WORKSPACE.md and
master-design.md are updated. Contents unchanged.
Defines, on top of the MVP Core, how a Feature is set down in the field and
kept current: a Feature can be made at any time and treated offline; its
definition is its classification (the ontology's dimensions) and its location;
its label defaults to the habitat, is explicit where recorded, and is generated
from the data otherwise; its definition can change, with historical values
visible through time; and it can be recognized as an absence without being
deleted. Field only — no office interface at this stage.
The problem is that a district must account for the pesticide it uses.
Treatments are one account of that use and inventory is another, and an
accurate inventory also tells operations when to buy more and keeps
product from going to waste.
Drops the em dash and the semicolon.
The preview renders a single newline as a line break, so hard-wrapped
prose showed breaks mid-sentence. Paragraphs and bullets are now one
source line each; tables, headings, and frontmatter are unchanged.
What a district counts is the pesticide, not its packaging. Stock is a
quantity of one product at one storage location, and the method its amount
is known by belongs to that stock. A bulk vessel such as the 7000-gallon
tank is a location, not a tracked object. A kit's provenance points at the
product and the location it was drawn from.
Counting bags is easier than summing pounds, and the package is how a
district keeps track, not how product is merely delivered. A product's
unit is usually a package with a size, so 40 bags records 1600 lb. A
package is still not an object: a partial bag is a fractional amount.
Removes every 'not' construction from the document. A partly used bag is
counted both as a fraction of the unit and by what remains in the base
measure.
Container is the module's word for a defined measurement of a product,
whether delivered or prepared, and containers subdivide. A container
prepared for field measurement is what the Treatment Measurement calls a
kit, so the kit concept drops out of this document.
Treatment is the main way product leaves a load, so it belongs with the
field surfaces. Container use was an invented surface.
State why Features are recorded, in place of a problem
All checks were successful
/ golint (push) Successful in 9m6s
/ pnpm-build (push) Successful in 1m46s
/ gotest (push) Successful in 2m14s
/ pnpm-test (push) Successful in 11s
dabf22b9be
A Feature is a location of possible mosquito production. The district tracks
its Features because tracking them is easier than finding each one anew, and
because current production predicts future production; a Feature also lets the
district record its response to production there and track change over time.
First cut of the MVP Core workbench: a full-height MapLibre view that
loads the signed-in organization's sites (with their embedded features)
and treatments through the existing site and treatment stores, then
draws them as three visually distinct layers over the shared map style.

Sites plot at their address point (falling back to their first feature),
features at their own location, and treatments from their recorded
geometry, falling back to the applied feature's location. The viewport
frames the loaded data, and a legend, loading overlay, and toast-backed
error state keep the page legible while data arrives.
Adds the workbench to the sidebar among the other map surfaces and
registers /_/mvp-core before the catch-all so the new page is navigable
and does not fall through to NotFound.
The workbench route stands outside the Authenticated/FullPage shell, so a
direct load or refresh had no session yet and the site store's URL guard
failed. Establish the session in the view (as FullPage does) and bounce
to sign-in when it is absent, so the org-scoped stores can resolve their
URLs on the first load.
The route was declared top-level and bare, so the view hand-rolled its own
session bootstrap and sign-in redirect — a second convention beside the one
every other full-page workbench uses. Wrap the route in FullPage (Reveal,
Planning), which loads the session before the view mounts, and drop the
duplicated bootstrap from the view. The map and its data loading are
unchanged.
/api/site exposes only a site's address point, so the MVP Core workbench
cannot draw the footprint from that payload. The nidus map already carries a
`site` layer, but it is points built from address.location, and adding the
footprint layer there would ship every other nidus layer (addresses, parcels,
service requests) with each tile — those layers carry no organization filter,
so a mid-zoom nidus tile weighs five to ten megabytes. A map that serves the
footprints alone keeps a workbench tile at ~100-190 KB.

The layer filters to polygonal rows: a handful of sites carry a point or
linestring footprint that the layer's geometry_type would reject.

Tegola reserves a parameter token globally once any map declares it, so the
footprint layer carries its own !PARAM_MVP_CORE_ORG_ID! token instead of
reusing the nidus map's !PARAM_ORGANIZATION_ID!.
The workbench showed its empty state, its legend read 0/0/0, and the backend
log showed no /api/site request at all.

A fresh sign-in clears the cached session — resetSessionState() nulls `urls`
and leaves `isLoading` false — and FullPage renders its child as soon as
`isLoading` is false, so the view mounts before the session is back. Its
`onMounted` then calls the site store, which throws "can't fetch without user
URL data" before issuing any request; Promise.all rejects, so the treatments
that did load are discarded too and every layer stays empty. Reaching the
workbench straight after signing in (the /signin?next=/_/mvp-core bounce)
reproduces it exactly.

Await `session.get()` before touching the org-scoped stores. It is memoized,
so it returns the loaded session, or shares FullPage's in-flight fetch
instead of issuing a second one.

Load the two layers independently, too: the (large) site fetch failing must
not throw away the treatments that succeeded, or the reverse.
A handful of features and treatments carry a (0, 0) geometry — a location
that was never set — and one site has no address point, only a feature at
the origin to fall back to.

Drawn at face value they put a dot in the Gulf of Guinea and, because the
viewport is framed on every loaded point, they stretch the initial bounds
across the world: the workbench opened at zoom 2 with the district reduced to
a speck.

Treat the exact origin as no location, for points and for treatment
geometries alike, so the map frames the district it has data for.
The workbench wants the site polygons, but /api/site exposes only the address
point, so the site layer now comes from the site-only tegola map added for it:
a fill plus an outline over the `site-geometry` source layer, filtered to the
session's organization.

The GeoJSON site points are gone with the layer, but the loaded sites still
frame the viewport and drive the Features layer, which is built from the
features embedded in them.
The loading and empty states are a full-viewport layer at z-index 15, so
while either is up they swallow every pointer event: dragging and scrolling
did nothing, which is exactly the state the workbench was stuck in.

The overlay holds a spinner and a sentence — nothing to click — so make it
transparent to pointers and let the map underneath keep its drag-pan and
scroll-zoom gestures.
Replace the unused site-footprint layer with an org-scoped parcel layer:
municipal.parcel joined to the organization's service area
(organization.service_area_id -> area.geometry) by a correlated subquery,
so every parcel in the district is served - including parcels that never
carried a Feature or Treatment - while the statewide fabric stays out of
the tiles. The org id rides a dedicated param because tegola reserves map
param tokens globally; min_zoom 9 matches the district's z9 framing and
keeps the coarser zooms (where the same fabric would be re-cut for no
extra detail) out.
Swap the site-footprint tegola layer for the org-scoped parcel fabric: a
subtle neutral fill plus hairline outline so Features and Treatments read
on top, matching the tile layer's min_zoom so the fabric is the base layer
at the district view. The legend's base entry becomes Parcels; the framing
and zoom/pan are unchanged.
Add a service-area-treatment provider layer and an org-scoped
mvp-core-treatment tile map carrying each treatment's product_id and its
start time as epoch milliseconds. The workbench can then dim/highlight the
whole ~116k-row set with MapLibre layer filters instead of loading a page of
it. The map is org-scoped through its own param token because tegola reserves
param tokens globally; it excludes the (0,0) placeholder rows the workbench
treats as no location; and it is a separate map from the parcel fabric so the
parcel map and the parcel/feature/treatment draw order are untouched.
The workbench's Product filter names the district's own products - the rows a
Treatment's product_id references - and there was no office endpoint for them
(the shared pesticide catalog is org-independent; only the mobile sync tree
served the district's products). Add ProductsForOrganization (go-jet,
org-scoped, name-ordered), platform.ProductList mapping the enum control class
to a plain string, and a GET /api/product resource. ProductsByOrganization now
builds its name-keyed map from the same statement rather than a duplicate.
Filter the MVP Core map by product, period and area
All checks were successful
/ golint (push) Successful in 2m25s
/ pnpm-build (push) Successful in 1m47s
/ gotest (push) Successful in 2m15s
/ pnpm-test (push) Successful in 11s
26f30308b7
Draw every record on a dim layer and the matching ones over it, so a filter
emphasises rather than hides: a non-match fades to neutral grey but stays on
the map. Treatments come from the org-scoped mvp-core-treatment tile layer,
which carries product_id and start_ms, so the product and period filters run
as MapLibre layer filters across the whole ~116k-row set instead of the
10,000-row client page the view used to load. The Area is a polygon drawn on
the map with the shared polygon-draw composable; it gates treatments and
features alike (features have no product or period), so a drawn area
highlights the sites inside it too. Product, period and area AND together; a
compact panel carries the controls and a Reset. The legend gains a matching
count, and the parcel base layer, site framing and pan/zoom are unchanged.
The MVP Core requires a filing to name the applicator together with their
licence or certificate, so the user configuration now carries both halves of
it: certification_number (the certificate's own identifier) and certification
(a file reference), each optional and independent.

Migration 00432 adds both columns to user_. The file reference is a bare UUID
like avatar (00125_user_props.sql), not an FK to fileupload.file — the file
row is upload provenance metadata, not the stored content itself. The go-jet
models are regenerated.

The certificate is stored verbatim in a new file.CollectionCertification, so a
scan or photo may be a PDF or any image instead of being re-encoded to one
format. Because a collection maps to a single on-disk extension, it uses
".bin" and the real Content-Type is sniffed at serve time through the new
file.FileToWriter (Range-capable). POST /api/certification accepts one upload,
rejects anything that is not a PDF or an image (deleting the rejected file),
and GET /api/certification/{uuid} serves it; both mirror the avatar routes.
resource/user.go exposes certification_number and the certification URI on
read and accepts them on create and update, where an empty number or URI means
"absent".

Adding a collection also means CreateDirectories must tolerate a store it
cannot provision: the paseo-shared files directory is read-only for some
worktree users, and a missing subdirectory that is only needed on write must
not stop the server from starting. Permission failures are now logged and
skipped; other failures still abort.

The SPA gains a certification number field and a PDF/image picker on the add
and edit pages (with a visible toast when an upload fails) and a Certification
column on the user list.
Rebuild product_catalog_rate from the staged label PDFs: one row per label
scenario, keyed on product name so 00434 applies after 00433. Fills the 27
products that had no rate (Altosid 30 day Briquets, AquaBac 200G, Aqua Reslin,
DeltAGard, Delta Micro, Essentria, Finale, FourStar CRG/BTI CRG, Garlon 4,
Goal 2XL, Golden Bear 1111, Hot Shot Ant Bait, In2Care, Mosquito Dunks,
Natular 20EC/T30/XRT, Prozap Insect Guard, Ranger Pro, Suspend Polyzone,
Target Pro Spreader, Vectolex CG/WDG, Vectomax WSP, VectoPrime FG, Altosid
Pellets) and audits the 46 seeded rows, correcting those that misstated the
label's basis (21 rows carry a CORRECTED note). rate_basis is kept as the
label states it; only the mass unit is standardized (lb/oz/lb ai), with the
conversion arithmetic recorded.
Rates for the three products 00435 adds, in a migration that applies after it.
Vectolex FG is read from its own 73049-20 sub-label (5-20 lb/acre across
habitats; waste tires 0.5-2 lb/1000 sq ft alone). Sumilarv WSP 25g records the
label's per-packet catch-basin doses (1 WSP/25 g = 28 days; 3 WSP/75 g = 150
days) and the water-volume sachet table. Sumilarv WSP 50g has no 50 g label, so
only the label's packet-size-independent lb AI water-volume doses and the annual
maximum are recorded; its per-catch-basin packet dose is unresolved.
00436 belongs to CatalogIdentity (it deletes the withdrawn sumilarv-wsp-50g
row). This drops the 50 g rows and keeps only sumilarv-wsp-25g (per-packet
catch-basin doses and the water-volume table are stated explicitly, with the
25 g packet size on every row) and vectolex-fg (its own 73049-20 sub-label).
Clicking a treatment on the workbench needs its whole record, so the detail
read now resolves everything the popup shows. District products gained their
catalog link (product.product_catalog_id, migration 00433) in the go-jet
models, and a treatment's product is followed through it: the shared catalog
entry by slug (name, category, formulation, active ingredients, EPA
registration, manufacturer, target life stages) plus every prescribed rate
scenario, each keeping its own rate_basis so a per-acre surface rate is never
conflated with a per-acre-foot water-column rate. The creator's applicator
certification (number and certificate file) rides along, with the resource
layer turning the stored UUID into the download URI. A product the district
has not linked to the catalog simply omits the catalog and rates.

The MVP Core workbench now selects on click: queryRenderedFeatures against the
treatment layers, then the Feature layers, opens a popup anchored at the click
with the treatment's time, applicator and certification, district product and
catalog facts, label rate scenarios with a labelled basis, quantity and area,
place, and treated geometry. A Feature click opens a small card with its type,
location and Site address. The selected record draws a halo, and the popup is
dismissible.
The operator narrowed the MVP Core selection popup to treatment-unique data, so
the detail no longer carries the catalog's label facts or rate scenarios: those
belong on the catalog product page, which the popup now links to. The treatment
detail resolves the district product's name and, when the product is linked to
the shared catalog, the catalog product's surrogate id through its
product_catalog_id slug; every other fact stays on that page. The applicator
certification and a preferred-units seam for the amount applied remain.

The popup shows time, applicator and certification, the district product name
with the amount applied and a link to its product detail page, area treated,
treated geometry, and the place (Feature -> Site + address). A Feature click
still opens its small card.
Render label rates in each district's preferred units
All checks were successful
/ golint (push) Successful in 2m25s
/ pnpm-build (push) Successful in 1m47s
/ gotest (push) Successful in 2m13s
/ pnpm-test (push) Successful in 11s
fcc6baa307
Label rates are stored verbatim from the label (product_catalog_rate.rate_unit
/ rate_basis) and stay that way; a district reads them in units it works in so
two products can be compared.

- organization gains unit_preset (imperial/metric, default imperial) plus
  nullable per-dimension overrides (area, mass, liquid volume, area rate basis,
  volume rate basis) as enums, regenerated through db/jet.
- platform/units is the one implementation of the arithmetic: it converts both
  sides of a rate, renders counts as the count with unit each (folding the
  converted denominator in, e.g. 1 briquet per 100 sq ft -> 1 each per 9.3 m2),
  carries the ai qualifier, and passes non-convertible units and bases through
  verbatim (percent, mg/L, spray-solution and tank-mix ratios, per stem,
  per minute, water-volume bands). Table-driven tests cover the matrix.
- GET /api/pesticide-catalog/{id} (and the list) now carries each rate's
  preferred rendering beside the label's own statement; PesticideDetail shows
  the comparison. The session and organization configuration payloads expose
  the preference, and Organization settings can set it.
- TreatmentByID renders the amount applied in the district's units alongside
  the stored quantity and unit.
The MVP Core workbench needs a tabular view of the same data its map draws,
and the two must never disagree about what is currently selected. That is only
possible if the filter spec is resolved once, on the server, and both surfaces
render the same resolution.

Add POST /api/mvp-core/query. It takes one spec (per-table column filters,
per-table cross-column search, the drawn area, the link toggle) and returns the
requested table's server-paged and server-sorted rows, the exact row counts of
all three tables over the full dataset, and each table's map selection.

Resolution semantics live in platform/mvp_core and are expressible only once:
own(T) is T's own filters plus the area; when the link is on, narrowing
propagates through the tree treatment -> feature -> site bottom-up (kF from the
kept treatments, kS from those features) with the closing pass applied to kF,
and only from tables that carry a column filter or a search - a table's
participation is what a filter on it makes true, and the map's area filter
deliberately does not propagate, so an area-only spec keeps each table narrowed
by its own geometry exactly as the map already drew it. Rows, counts and map
selections all come from the same keep sets.

The map's product and period filters are the Treatment table's product and
start_datetime column filters plus the drawn area, so the map panel and the
table's filter row are one state rather than two that have to be kept in sync.
Where a selection is too large to ship as an id set the response carries the
tile-attribute fallback and an explicit `exact` flag, so a client never
presents an approximate highlight as exact.

Sites gained kind/owner_name/owner_kind in migration 00439, which the checked-in
go-jet snapshot predates, so the site table is rebuilt with postgres.NewTable
rather than regenerating db/gen for a read-only surface. Feature exposes
site_id, which it previously hid, so a Feature row can be related to its Site.
One Pinia store holds the single filter spec, the active table, the sort, the
page and the row selection. The drawer, the full-screen view and the map all
read and write that store, which is what makes a filter set on any of them true
on the others rather than something to be copied between them.

The store treats the query as live: text-carrying mutations debounce, discrete
ones cancel the pending load so they cannot be overwritten by a stale trailing
one, and a response whose request is no longer the newest is dropped whole. Any
narrowing resets the page, because a page number that was valid for the wider
result set can be past the end of the narrower one.

The column registry is the client's half of the frozen contract: the same keys
the server filters and sorts on, plus the label, type, width and responsive
priority the table renders. The identifier column is sticky and human-readable
(a treatment leads with its Start, a feature with its Site, a site with its
label or address), and columns are dropped by priority as the container
narrows instead of being squeezed below legibility. Columns whose value is a
correlated count or a derived classification are shown but not sortable or
filterable, since ordering by them has no valid SQL form and filtering by them
scans every candidate row.

DataTable windows its own rows at a fixed dense height, so the 250 rows the
server sends cost a screenful of DOM, and it keeps the header and the
identifier column in place while the rest scrolls sideways.
The map keeps its own job and gains a tabular surface for the same records. A
bottom drawer opens on the district's Treatments at a third of the map and
drags up to cover it; a topbar button opens the same table full-width, where a
count strip listing all three tables with their live narrowed counts doubles as
the table switcher - one table owns the width rather than three sharing it.
Both are overlays over the map rather than routes, so drawer, full-screen and
map share one store by construction and a filter set in any of them is a filter
in all of them.

The map's highlight layers now render the selection the server resolved instead
of re-deriving it in TypeScript: the product/period/area panel is bound to the
Treatment table's product and start_datetime filters plus the spec's area, and
the layers take either the resolved id set or, when that set was too large to
ship, the tile-attribute fallback the server echoes. Where the highlight is a
superset of the table's rows the legend says so, so the map is never presented
as agreeing with the table when it does not.

Selection is two-way and identity-stable: clicking a map point opens the drawer
on that object's table with the row selected, and clicking a row pans the map
to the record's own point and opens its popup. The parcel base layer, the
filters, the popup, the framing and zoom/pan are unchanged.
Keep the resolver's set-based instead of row-based
All checks were successful
/ golint (push) Successful in 2m27s
/ pnpm-build (push) Successful in 1m49s
/ gotest (push) Successful in 2m14s
/ pnpm-test (push) Successful in 11s
25c53dad2d
The narrowing predicates were nested at every use site, so Postgres could not
flatten them and re-ran the inner scans per candidate row: the district-kind
plus product conjunction took 34 seconds, which a live-filtered table cannot
afford.

Each subquery now takes only the joins its own predicate references, and the
closing pass is dropped where it is provably a no-op - kF already intersects
the site constraint's own set when the site is constrained, and own(site) is
the whole table otherwise, so intersecting with features(kS) again cannot
remove a row. Every case stays exact: the empty spec is 90803/24253/116430,
the area-only spec keeps each table to its own geometry (6611/1433/8308), the
seven single- and multi-filter cases are unchanged, and the conjunction that
used to leak an unreachable Treatment still returns 47119.

Every measured query is now under a second at 250 rows per page.
A Site is the district's ownership of geographic space: every service-area
parcel becomes one, and the gaps between parcels - the streets - become
street-segment Sites owned by the municipality or county that holds the
right-of-way.

site gains kind, owner_name and owner_kind, so a Site records how it entered
the fabric (parcel, street, other) and who owns the space. The single-location
check is relaxed for street Sites alone: a right-of-way segment is the union of
the pieces crossings and abutting parcels cut it into, which is not always
single-part, while parcel and other Sites keep the rule.

municipal.road_segment holds TIGER/Line road centrelines keyed on the census
TLID, which is the stable segment identity, so a re-import does not renumber
segments; municipal.place holds TIGER incorporated places, the city a segment
falls in. site_road_segment traces a street Site to the segment it was cut from,
unique per site because two districts in one county each get their own Site for
the same shared segment. Site gains a GiST index on geometry, which the fabric
is queried spatially through.

fabric_single_part_polygon repairs a county parcel into one Polygon for use as
a site boundary: 1,966 of 168,620 Tulare parcels are topologically invalid and
ST_MakeValid resolves them, keeping the largest part where it yields a
MultiPolygon so the Site stays one location.
site-fabric builds and maintains a district's site fabric end to end, so a new
district is onboarded by running it and a county data sync is followed by
re-running it. It is org-parameterized and every stage is idempotent.

  site-fabric counties -organization 1   ingest the TIGER centrelines and places
  site-fabric build    -organization 1   seed/reconcile the fabric and report
  site-fabric run      -organization 1   counties then build

counties downloads the TIGER/Line zip for each county the district's service
area touches, caches it, loads its road segments and the incorporated places of
each state involved, and re-derives right-of-way ownership: a state route is
Caltrans, a road inside an incorporated place is that city, everything else is
the county. Road centrelines are county-scoped and shared, so they are ingested
once per county FIPS and reused by every district in that county. The ROADS and
PLACE shapefiles are read with go-shp; each road is reduced to a single
LineString and each place to a MultiPolygon with its rings grouped into shells
and holes.

build runs the two stages over its own pgx connection. The parcel stage seeds
one Site per service-area parcel and reconciles the organization's existing
Sites into it - a legacy speck Site becomes its parcel's Site, its Features and
references move across and the speck is deleted, so the real parcel footprint
replaces it rather than sitting beside it, and existing Site ids survive. The
street stage cuts the area not covered by parcels along the TIGER centrelines -
the Voronoi of the centreline network restricted to the gap - so each segment
yields its own street Site and the pieces partition the gap: no area is left
over and no two street Sites overlap. A segment TIGER draws twice shares the
Site of the street it duplicates, so every segment stays traceable.

Both stages run from embedded SQL scripts; the same session's temp tables let
the closing report measure gaplessness, owner coverage, address coverage and a
known Tulare parcel as a spot check.
cache/ is the app's runtime cache: scripts/paseo-env.sh points
CACHE_DIRECTORY at it, api/tile.go writes Stadia tiles there, and the
site-fabric tool caches TIGER downloads there. Nothing under it is
tracked and it is regenerated on demand, but at ~9.6 GB an accidental
`git add -A` would stage the lot.
The schema commit (bc53ae3f9) and the onboarding-tool commit (d946e558e)
were a stale snapshot: the migration was edited after being applied and the
regenerated go-jet files were never committed, so HEAD did not build from a
fresh clone and did not match the live database.

municipal.road_segment.owner_kind is text (checked against information_schema,
not the enum a cross-schema column cannot survive go-jet's per-schema
generation), which the working tree already had. Re-ran db/jet/jet against the
final migration; the regenerated db/gen is a no-op against this tree, so the
committed generated code provably corresponds to the committed schema.

Commits the refreshed roads/street/verify SQL and every missing generated file.
cmd/site-fabric's logic moves to platform/sitefabric (fabric.go/tiger.go and
the embedded SQL), so the CLI and the on-demand background job share one
implementation instead of the job shelling out to a binary. cmd/site-fabric
is now a thin flag-parsing CLI over the package; its multi-statement scripts
run through the simple query protocol (PgConn.Exec) rather than requiring a
whole connection to be opened in simple mode, and Build clears the stage temp
tables first so a run can reuse a pooled session.

County resolution is no longer California-only. The hardcoded caCounties map
is replaced by an embedded US Census national county list (county_fips.csv,
2020 vintage, state FIPS + county FIPS + name, source URL at the embed). A
district's parcel sources name counties; the bare name, the full Census name
and the county-equivalent suffixes (Parish, Borough, Census Area, Municipio,
...) all resolve. A name repeated across states (Washington, King, Harris) is
disambiguated by the state the district's unambiguous counties agree on, and
is otherwise reported so -fips settles it. -fips still overrides and now
accepts any US county.
Adds a site-fabric job type and a machine for enqueueing it from the API.
platform.EnqueueSiteFabricJob inserts the job row (querypublic.JobInsert),
notifies the queue, and refuses a second live job for the same organization,
so the run action is idempotent per org. The handler acquires one pooled
connection and runs platform/sitefabric, the same implementation the CLI
calls — no shelling out to a binary.

A site_fabric_run row per organization carries the run's status
(queued/running/succeeded/failed), timing, error, the ingested counties and
the verification report, so the Organization page can show the outcome after
the transient queue row is deleted. The report's metric set and the whole
pipeline are unchanged; only their source moved.

Priority 175: after the municipal layer (150-170) it reads, in the serial
slow tier (>=100) because it downloads TIGER data and rebuilds Sites.

GET/POST /api/organization/{id}/site-fabric expose the last run and enqueue a
new one.
The District Settings page gains a Site Fabric card: a Run button that
enqueues the site-fabric background job any time (not on a schedule), the
run's status (queued/running/succeeded/failed) with a spinner while it is in
flight, the counties the run ingested, and the run's full verification report
as a table. The page polls every 5s while a run is queued or running and
stops once it settles, so the report appears without a manual reload.
Enqueueing when a run is already in progress reports that instead of
stacking a second one.
The fabric is now run in stages, chosen by the caller, so a rebuild never
silently re-downloads a county's data:

  counties  ingest the shared TIGER centrelines and places (download)
  build     rebuild and verify the district's Sites from what is ingested
  run       both, in one job

The stage travels on the job's row_id_string (row_id_int stays the org), so
the handler dispatches on it, and site_fabric_run gains a stage column.
platform.NeededCounties and SiteFabricCountyStatuses expose the district's
counties with their ingest state (TIGER centrelines per region), so the page
can show which counties are already loaded.

Authorization: the run route is root-only, matching the rest of the sudo
surface (u.HasRoot() / 'you must have root permissions'), enforced server-side
on the request — a district manager can read the state but cannot POST a run.
The read endpoint stays open to the organization's members.

The dedup guard still admits at most one live site-fabric job per
organization, whatever its stage.
The Site Fabric card renders for everyone on the Organization page: the last
action's status/stage, when it completed, the district's counties with whether
their centrelines are already ingested (segments), and the last build's
verification report. The three run actions — ingest county centrelines,
build / rebuild fabric, run both — appear only for roots, matching the app
shell's existing operator gate, and the route enforces the same check
server-side.
The workbench's base layer is now the district's site fabric rather than the
raw parcels, and the selection popup is a walkable graph over site, feature and
treatment.

Tegola: a new `service-area-site` layer over public.site (org-scoped through a
dedicated param token, since a Site carries organization_id directly and param
tokens are reserved globally), served from its own `mvp-core-site` map so the
parcel and treatment maps are untouched. It carries id, kind, owner_name,
owner_kind and label with the geometry as-is (street sites may be multi-part),
and drops the handful of legacy origin-area junk sites. min_zoom is 8: the
workbench frames the district with fitBounds at ~z8.8 on a 1400x900 viewport,
and the district's four z8 tiles total 2.30 MB raw with the busiest at 2.35 MB
raw / 0.72 MB gzip (lighter than the parcel fabric's worst z9 tile).

Backend: migration 00439's fabric facts (kind, label, owner_name, owner_kind)
now round-trip on /api/site/{id}. /api/feature/{id} gains `characteristics` (one
current value per classification dimension, superseded rows collapsed by a new
CurrentFeatureClassifications reducer) and `production_state` (the row no other
supersedes, nil when never determined).

View: sites render as the base fill+outline from the tile layer, with the
selected Site redrawn above it; Features and Treatments stay on top and the
legend's base entry is Sites. The popup is one node of the graph at a time with
a Back control: a site lists its features, a feature shows its type, location,
characteristics, production state and its treatments (date + product), and each
treatment opens the existing treatment popup, whose Place now links to the
feature and its site.
The grid no longer exposes or filters any ID but Feature's, a treatment
is one Date plus a Duration, and each table gains a link to the records
it relates to.

- registry: drop site.id, feature.site_id/pool, treatment.id/feature_id/
  end_datetime; add site.has_features (EXISTS over feature.site_id) and
  treatment.feature/duration
- new op "relation" carrying (source, source_id): narrows a table to the
  records related to one record of another table, propagating like any
  other filter, so a link click resolves in the same one spec
- cross-column searches mirror the visible text columns (site dropped
  from the Feature and Treatment searches)
- rows carry the Feature label and Site label the links render plus the
  computed duration; site_id/feature_id are gone
- resolver integration test gains relation-filter and Has-features cases
Each table gains one column per related table: a singular relation shows
the related record's label, a plural one its count. The cell is a real
button, so it is keyboard reachable and its activation does not select
the row.

- site: id column and filter gone; Has features is sortable and yes/no
  filterable; the Features count cell links to the site's Features
- feature: Site/Site ID/Pool gone; the sticky identifier is id, unfiltered
  and the only ID left in the grid; Site and Treatments link out
- treatment: one Date (the start) plus Duration (end − start), Product/
  Quantity/Unit adjacent, and Feature/Site link cells
- clicking a link switches table and applies a "relation" clause, which
  shows as a clearable chip in the toolbar and composes with filters
  already on the target
- drop the now-empty filter controls from columns that carry no ops
The drawer was opening on mount and on every map click, so reading the map
was impossible without the table. It is now explicit-only: the topbar
control opens and closes it, shows the selection's treatment count, and no
map click (site, feature or treatment) touches it — those clicks drive the
popup graph, which already follows store.selection.

The ledger itself is the treatment grid, so this commit completes the
columns and cell rendering the shared mvp-core query now returns:

- Product links to /_/configuration/pesticide/{catalog_product_id} when the
  district product is catalogued, and renders plain text when it is not.
- Prescribed rate shows the label's application scenarios in the district's
  preferred units, grouped by their own (unit, basis) so per acre, per
  gallon of water and count bases never merge; the cell keeps one line per
  basis and the tooltip names every scenario.
- Place reads the Site (with its address) and the Feature by id beside the
  existing Feature/Site link cells.
- The treatment prios are reordered so the ledger's essential columns
  (date, product, prescribed rate, quantity, unit, area, applicator, place)
  survive the responsive column fitting in the drawer.

No applied rate is derived: quantity over treated area has no common basis
yet, so only the label's prescribed rates are shown.
An op "enum" value outside the column's vocabulary was silently dropped,
so site.has_features with values ["banana"] returned every row (90,539
sites) rather than failing — a client bug that reads to the user as "no
filter applied".

The column registry now carries the static member set for the enum and
boolean columns, read from the site_kind, site_owner_kind and
treatment_method database enums plus the grid's yes/no options, and
Validate rejects a member outside it via the 400 the resource layer
already returns. treatment.product is deliberately excluded: its members
are per-district ids, so an unknown id is an empty result rather than a
malformed request.
Cut street Sites at intersections, one per road edge
Some checks failed
/ golint (push) Successful in 2m27s
/ pnpm-build (push) Successful in 1m50s
/ gotest (push) Failing after 2m22s
/ pnpm-test (push) Successful in 11s
e6ccdd748d
A street Site was one per TIGER alignment, so a rural alignment with no node
could run for miles. Derive municipal.road_edge (each alignment split at the
nodes it shares with another alignment, then capped at -max-edge-metres) and
assign the gap pieces to the nearest edge, one Site per edge. site_road_segment
now names the edge and keeps linear_id for traceability; links are rebuilt
authoritatively each run so re-runs converge.
The toolbar's Filters toggle and the header's inline per-column filter row
were two surfaces rendering the same controls. Keep the inline row (it
scrolls with the columns) and delete TableFilterPanel.

The collapse state is owned per surface (drawer collapsed, full screen
open) and threaded to DataTable, whose row-window offset now reads the
live header height — the label row alone when the row is hidden — so the
sticky header and windowed rows stay aligned.

Products are now loaded once by the page for the treatment product filter;
the drawer/full-screen no longer load them for a panel that is gone.
Product and period are the Treatment table's product and start (Date)
column filters; because the spec is shared, setting them in the table
already narrows the map. Only the drawn Area is spatial and has no column
equivalent, so it is the one control the map bar keeps.

Drop the local Reset button (the toolbar's Clear covers column filters;
the Area row now clears its own polygon) and the product dropdown, product
state and period state that fed the removed rows.
The Place cell rendered the Site (with its address) and the Feature by id
— the same relationship the Feature and Site relation links already carry
as clickable cells, so two columns expressed one thing. The links stay and
Place goes. It was TypeScript-only, so nothing server-side changes.
The ledger showed a product's prescribed label rate but never the rate the district actually applied, so nothing could be checked against the label. This derives the applied rate from what a treatment states and judges it against the label's envelope.

The label's basis is classified into exactly one kind: a rate per area, per catch basin, or per water volume. Every other basis — a carrier ratio, a concentration, a time, a container — is excluded from the verdict, because its meaning depends on a density the platform does not have. A per-volume basis divides the treated water volume: the treated area times the two feet a catch basin is assumed to hold, since no feature stores a depth anywhere. Selects the band the treated volume falls in, so a dose matching its band reads in range and never over; a band the volume does not fall in is not computable.

The verdict is the operator rule: over only when the applied rate is above the envelope high in every computable basis group, under only when below the low in every one, unknown when nothing is computable. A basis the treatment's facts cannot divide — no area, not a catch basin, a band the volume misses, a count label with a mass applied — is skipped, never treated as satisfied. The widest envelope across a group's scenarios is taken, and scenarios stated at different magnitudes of one area basis fold into the one envelope the label really states while mass, volume and count never merge.

The map's over-rate toggle needs the same judgement for the whole resolved set, so the facts it reads (the linked catalog product, the amount and its unit, the treated area and the feature) are read in one lean scan, and the catch-basin question is asked once for the set rather than per row. Measured on org 1: 114,379 facts in 0.13s; the full treatment resolve stays ~0.9s. Every conversion reuses platform/units, and the id set reuses the existing map-selection cap.
Adds the Applied rate column immediately after Prescribed rate: the derived rate with its unit and basis, the gap marker when no basis is computable, and the over/under-label cue. The pair is Okabe–Ito (vermillion over, blue under), exported once from the column registry so the table and the map cannot drift; colour is never the only cue — the rate cell spells out the verdict and the row carries a left rule in the same colour, and under stays informational rather than a warning.

The map gains an over-rate toggle that renders exactly the ids the server judged over label, in the over colour, hiding every other treatment; an empty set shows nothing rather than falling through to the unfiltered layer, and the capped case surfaces the approximate note. The store carries the set beside the existing map selection.
A label that says one pouch treats UP TO 135 sq ft states a floor and no ceiling: treating a smaller area with the same pouch is coverage satisfied, not over-application. The per-area basis with a discrete count (each, packet, pouch, briquet, tablet, dunk, granule) is now its own kind, coverage: it can never be over, it is under when the count cannot cover the treated area, and in range otherwise. Over needs every computable basis group, so a product holding a coverage basis can no longer read over at all — the intended consequence of the rule.

Continuous per-area bases (a mass or a volume per acre or per sq ft) keep both bounds, and the water-volume bands keep the matched-band dose-to-dose rule; neither changed. The coverage floor is the label's own low, so a label stating several units per N (Natular XRT's four tablets per 100 sq ft) is under exactly when the count covers less than its stated area — the same rule as one unit per N, which is the case every other district label states.

The verdict is also judged on the rate as displayed (three significant figures, the projection's own precision), so the ledger cannot print a rate equal to the label's maximum while reading over. Tests: the named regression (Altosid WSP 1 packet on a 1 sq ft plant saucer reads in range, was over), the same basis under-covering 300 sq ft, the catch-basin case unchanged, a continuous per-acre product still over, and the integration fixture gains a continuous per-acre product whose 100 gal/acre is over.

Measured on org 1: over falls from 15,159 to 5,576 (Altosid WSP 6,112 -> 0, Natular DT 1,144 -> 0, BVA-2 2,623 -> 2,617, Agnique MMF 926 -> 926, Sumilarv WSP 25g 472 -> 472); in range 96,872 -> 106,465, under 2,250 -> 2,240, unknown 98. Each remaining over set matches an independent SQL expression of the same rule exactly.
A coverage label states a count and an area one unit treats, so normalising the applied quantity into a per-area ratio put a number beside the label that contradicted the verdict: Altosid WSP read '67.5 each per 135 sq ft' against a prescribed '1 each per 135 sq ft' and a verdict of in range. The Applied rate cell now shows the count applied in the unit the district recorded it in ('1 packet'), with the label's own denominator read as what one unit covers ('up to 135 sq ft') and the coverage marked, so the number reads against the label's own statement. AppliedRate carries an explicit coverage flag rather than a mangled basis string, and the full label basis stays in the cell's tooltip.

The verdict computation is unchanged: coverage still never yields over and is under exactly when the count cannot cover the treated area. Proportional bases — a mass or volume per area, a dose per basin, a dose per water-volume band — are untouched and still show value/unit/basis as before. Tests pin both shapes: the Altosid-shaped fixture reads a marked coverage count with an in-range verdict (and a count under-covering 300 sq ft), while the BVA-2-shaped fixture still renders a proportional gallon-per-acre rate ('100 gal per acre', coverage false), in the unit table and end to end through Resolve.

Live check of the response shape: Altosid WSP {"value":1,"unit":"packet","basis":"per 135 sq ft","converted":true,"coverage":true} in range; Natular XRT {"value":1,"unit":"briquet","basis":"per 100 sq ft","converted":true,"coverage":true}; BVA-2 {"value":42.5,"unit":"gal","basis":"per acre","converted":true,"coverage":false} over; Sumilarv WSP 25g {"value":2,"unit":"each","basis":"per catch basin","converted":true,"coverage":false} in range.
usePolygonDraw marked itself initialized the moment it captured the map
instance, before the style had loaded. Its own `armed` watcher runs with
immediate:true during setup, so on a remount whose map instance was already
present but whose style was not yet ready the watcher took the "map exists"
branch and called updateOverlay(), whose map.getSource() hit MapLibre with an
undefined style — "Cannot read properties of undefined (reading 'getSource')".
The error surfaced through the global error handler as a red toast on the MVP
Core page (reproducible by signing out and back in, where the view mounts
against a reset session) and left the page looking broken.

Readiness is now tracked separately from capture: `initialized` is set only
once the style has loaded and the overlay source and layers exist, and the
map-instance watcher guards on the captured instance itself. The immediate
watcher now returns early instead of touching the style, and the overlay,
layer-lift and unmount paths keep their existing guards.

Verified in a browser against the running worktree: signing out and back in
and landing on /_/mvp-core no longer raises the error (0 console errors, no
toast), and the ledger drawer opens with its table, the Applied rate column
and paging (1-250 of 116,430) intact.
The Applied rate column is now filterable by the four verdicts — Over label, Under label, Within label, No basis — as an ordinary enum filter, so the existing multi-select needs no new component and the grid's link, area, product, date and relation filters all compose with it.

The verdict is derived, not stored, so the clause cannot be a stored-column predicate. It resolves to a treatment-id predicate over the same partition the cell renders: the resolver derives the org's verdict partition once per request (one lean scan of the treatment table, the same resolveApplied the cell uses, so the filter can never disagree with the verdict shown), each verdict is its own id set, and 'within label' is the complement of the other three, since every treatment has exactly one verdict. Several selections are the union of their predicates. Nothing is post-filtered: totals, paging and the map all see the same WHERE.

The map follows the filter. A verdict whose own set is small (over, under, no basis) travels as membership. 'Within label' is most of the table, so the response ships the small excluded set instead and the client renders 'not in these' — MapSelection gains exclude_ids for exactly that, and the rendering stays exact rather than degrading to the approximate superset the cap would otherwise force.

Measured on org 1 (116,430 treatments): the partition is over 5,576 / under 2,240 / within 1,149... unknown 2,149, summing to the total. Unfiltered resolve unchanged at ~0.92s (the partition is derived only when the filter is present); over/under/unknown ~0.75-0.84s; within label ~1.8s because it is 91% of the table. The composed case (BVA-2 product plus over) equals an independent SQL expression of the same rule, mirroring the projection's own rounding.
A source row whose Product is "Phone/meeting" is a record of a call or a
meeting, not an application: it names no material, modifies no Feature and
carries no containment intervention. The curated disposition map listed the
notices, the warrant, the plan and pool-flyer artifacts and the pool/site
status determinations, but not this one, so the row was read as a Product and
the first pass imported it as a Treatment — one row in org 1, with a product
named "Phone/meeting" that carries no catalog link.

Add it to the not-treatment list beside the other administrative values. The
import already removes a previously imported row the moment its source value
is classified as a not-treatment (removeImportedTreatment) and prunes the
orphan product, so the correction is a classification entry: the next
treatment import drops the mistranslation and its product.

Test: a case in the disposition table pinning "Phone/meeting" to
dispositionNotTreatment, alongside the existing compliance-artifact and
pool-status cases.
A treatment's Method is derived from its product's control class (§10 Rule 6), but it is stored, so curating a product's class — an office action, not a sync — leaves every treatment already recorded against it reading the value it was imported with. Fish is the case that surfaced it: the district's biological control agent had no class and no catalog link, so its 1,280 treatments carried no method at all.

The treatment import now closes that gap after its row pass, with one set-based UPDATE through the product: every product-bearing treatment takes its product's class as its method, from the same treatmentMethodFor mapping the import derives a fresh row's method with, so a stored value and an imported one cannot disagree. A product the district has not classified yields NULL, which is what an unknown class means; a treatment with no product is not joined and keeps its method, because it is physical control by disposition and has no product to derive from.

Tests: an integration test over a rolled-back transaction pins the four cases — a curated chemical and a curated biological product both reach their stale rows, an uncurated product clears its row to NULL, and the product-less physical row keeps both its method and its absent product — plus the mapping agreeing with treatmentMethodFor.
A district's physical-control work is recorded as Treatments with no product, and the source value that says what the intervention was — Screen Added, Drained Source — is dropped when the row is classified, so nothing downstream knows a screen from a drain. This adds the organization-scoped list that keeps it: a district names its own interventions, and the find-or-create by name is what lets an imported row and a hand-added one be the same row.

intervention carries only what the district states — a name and an optional control class — because it is not a product: no label, no prescribed rate, no catalog entry. treatment.intervention_id is nullable and, for now, set only where product_id IS NULL; no constraint forbids both, so a treatment recording a product and an intervention later is a code change rather than a migration.

A retired intervention stays in place: the treatments that name it keep reading it, the same convention a product follows. Includes the regenerated jet models for the new table and the treatment column, and the wire types both the ledger's filter and the configuration page list.
The importer classified a physical-control row by the source 'product' value and then dropped it, so nothing knew a screen from a drain — the note carried the prose and nothing carried the fact. The curated physical values now find-or-create an intervention named by that source value and the treatment records it: method stays physical, product_id stays NULL, and the six curated values stay the single curated source in the disposition map.

A Treatment that already existed before the list did is reached through the import's own drift detection, not a second pass: a physical row that names no intervention no longer matches what its source row says it was, so it is re-imported and takes the intervention, and a row that already names one does not drift again. The ledger gains an Intervention column (the name, shown beside the Product column, which keeps showing only products) with an enum filter over the district's interventions, composing with every other filter and driving the map like any other own-column filter.

A treatment with no label to check is now not_applicable rather than unknown: no product at all (a physical intervention), or a product whose catalogue entry states no rate at all (Fish, a biological agent). unknown keeps its strict meaning — a label exists but its rate is not computable from what the treatment states — and a product that is not linked to the catalogue stays unknown, because its label exists and we cannot read it. The verdict partition is now five sets and still covers every treatment: over 5,576 / under 2,240 / in_range 106,465 / not_applicable 2,050 / unknown 98 of 116,429 for org 1.

The district's own add and retire surface follows the product resource exactly: GET /api/intervention lists them, POST /api/intervention find-or-creates by name, DELETE /api/intervention/{id} retires (soft — the row stays, because the treatments that name it keep reading it).
The ledger gains an Intervention column beside Product — a plain cell carrying the name, filterable by the district's interventions through the existing enum machinery, so the grid needed no new component — and the verdict vocabulary gains Not applicable.

The Pesticide configuration page keeps its shared-catalog table exactly as it was and adds the district's own interventions beside it: the list with its control class and retired state, an add form, and a retire control per row. That is the operator's 'partly derived from the catalog, partly their own' list, on the page that already exists — no new route, no new page, no nav entry.
Two unit tests for the mechanism that puts an intervention on an imported physical row: the memo resolves a name the run already holds without touching the database (the source names one value across hundreds of rows), and the drift check reaches a physical Treatment imported before the district's list existed — re-importing it so it takes the intervention — while a row that already names one stands, so a re-run converges instead of rewriting every physical row.
A treatment with no label to check and one whose label cannot be computed both have no applied rate, but they are not the same fact, and the cell said 'No basis' for both. The gap now keys on the verdict: not_applicable reads 'Not applicable', with a tooltip that names the case — 'No label to check: this treatment applies no product' for a physical intervention, or the product that states no rate — while unknown keeps 'No basis' and the wording about a basis that is not computable.

Both still render as a gap, never a zero, and neither carries a verdict cue: there is nothing to be over or under.
Add the query-layer half of the product control-class derivation: read the
org's products whose control_class is NULL and whose linked catalog entry
states active ingredients, and write the derived classes in one set-based
UPDATE guarded to still-NULL rows so an operator's curated class is never
overwritten. ProductSetControlClass is the single-row override the PUT
endpoint calls; it casts the value to the product_class enum, since a bare
text expression is not assignable to an enum column.
ProductClassFromIngredients is the one statement of the settled rule: an
ingredient naming Bacillus thuringiensis, Bacillus sphaericus or Spinosad is
biological, every other is chemical, and a product is biological only when it
names at least one ingredient and all of them are biological — the tie-break
that makes Duplex G chemical and Vectomax biological without a case of its
own. A product naming no ingredient is left unclassed; a name is never
substituted for a class (§29 Rule 5).

DeriveProductClasses seeds those classes for the org's unclassed products,
and FieldseekerTreatmentImportRun calls it beside ReconcileTreatmentMethods so
a newly linked product is classed — and its treatments' Methods reconciled —
in the same run.
PUT /api/product/{id} takes a control_class validated against the
product_class enum (400 on a value it does not carry, 404 on an id the
district has no product for) and, on a real change, reconciles treatment.method
set-based over the org rather than per row — the same reconcile the import
runs, so curation reaches every treatment it governs.
Add a District Products card beside the shared catalog and the District
Interventions section: the district's own products with an editable
control-class dropdown saved through PUT /api/product/{id}. No new page, route
or nav entry.
product class: pin the derivation, the ledger distribution and the override
Some checks failed
/ golint (push) Successful in 2m28s
/ pnpm-build (push) Successful in 1m50s
/ gotest (push) Failing after 2m22s
/ pnpm-test (push) Successful in 11s
1ca9383549
Integration tests against a real database (rolled back, or restored, so
nothing is left behind): an independent SQL statement of the ingredient rule
agrees with the Go rule product by product; DeriveProductClasses classes the
org's 36 unclassed products, is idempotent, leaves Fish biological, and after
ReconcileTreatmentMethods treatment.method is exactly chemical 99,437 /
biological 16,222 / physical 770 / null 0. The resource handler is exercised at
its boundary — 400 on a bogus enum value, 404 on an unknown id, and a valid
change that the reconcile follows.
Adds treatment.residual_expires and treatment.revisit_at (migration 00444) and
regenerates the go-jet models. residual_expires is derived, stored and
idempotent — start_datetime + product.residual_duration — so the Feature
control window is one aggregate over treatment rather than a scan through
product per Feature. revisit_at is the technician's manual date, which replaces
its treatment's derived expiry in that Feature's cumulative window.

Also declares the wire contract the SPA consumes: Feature.control_state and
Product.residual_days / residual_days_suggested.
Migration 00444 now carries the whole residual model: treatment.residual_expires
(derived, stored, never user-editable — a Treatment is a terminal record),
feature.revisit_at (the technician's manual revisit date for the place), and
intervention.residual_duration (physical control suppresses production too, e.g.
Screen Added yearly). Jet models regenerated; db-gen-check proves the live
database matches a migrations-only build.

ts/type/api.ts declares the wire contract: Feature.control_state and
Feature.revisit_at (the active pin), Product/Intervention.residual_days and
Product.residual_days_suggested, and MvpFeatureRow's calculated/planned revisit
dates.
The derivation is one set-based UPDATE, org-scoped and idempotent: residual_expires
= start_datetime + coalesce(product.residual_duration, intervention.residual_duration),
read through correlated scalar subqueries so a product-less or intervention-only
Treatment stays in scope. The IS DISTINCT FROM guard makes a second run change zero
rows and lets a removed residual clear a stale expiry. It runs beside
ReconcileTreatmentMethods in the import, after DeriveProductResiduals seeds the
district's products from the catalog's conservative MIN(residual_days_low) — a
product the catalog does not cover, and every intervention, stays NULL for the
district to set.

The Feature's control window is computed at read time: feature.revisit_at when set
and no treatment at the Feature is dated at or after it, otherwise the latest
treatment residual expiry. The plan hence wins outright (and may shorten the
window) until it is acted on, so a just-revisited Feature never reads overdue.
feature.revisit_at is never mutated by the derivation.

The server ships the state in the sites payload's Feature (control_state plus the
active pin) and in the Feature ledger (calculated vs planned revisit).

Operator surfaces: PUT /api/product/{id} accepts residual_days (integer >= 0 or
null; 400 otherwise) beside control_class; PUT /api/intervention/{id} the same; a
bulk PUT /api/mvp-core/feature/revisit takes the table's own filter spec plus a
date (or null) and resolves it through the same resolver the table and map use, so
'plan to visit all swimming pools treated with Fish in this area' writes exactly
the Features the operator sees; PUT /api/feature/{id} sets or clears one.
The MVP Core map colours each Feature point by the server's control_state using
the Okabe-Ito palette defined once and consumed by both map and legend (expired
vermillion, controlled bluish green, unknown the existing dim); expired Features
also get a larger radius and a stroke, and a pinned Feature a neutral ring, so the
signal survives colourblind readers and greyscale. A 'Past control only' toggle
mirrors the over-rate toggle; it filters the layers by the server-provided state.

The Feature ledger gains two measured columns — Revisit (calculated) and Revisit
(planned) — reading the variance between the chemistry and the plan, with a
pin/clear control on the planned cell. The Feature table's toolbar carries the
bulk Set planned revisit / Clear planned revisit: the count it will write, an
echo of the composed filter in plain words, an explicit note when nothing is
filtered (every Feature in the organisation), and a confirmation naming the map
as the preview.

The pesticide page's District Products and District Interventions cards gain an
editable Planned residual (days) column (the product's catalog suggestion shown as
a placeholder only), and the stores round-trip the value.
The seeding read MIN(residual_days_low), which the shared catalog mostly leaves
NULL: a label that states a single residual carries it in residual_days_high,
so low alone covered 6 of Delta's 37 products instead of 20. Read the shortest
window the entry's rates actually state — MIN(COALESCE(low, high)) — so a
district that has not yet curated a product gets a suggestion for every product
the catalog covers, and none where the catalog states nothing.

One shared expression (catalogSuggestedResidualDays) now serves the seeding, the
derivation's candidate read and the pesticide page's hint, so the three cannot
disagree about what the catalog suggests. The integration test's independent
restatements of the rule move with it.
The integration test for PUT /api/product/{id} wrote the shared scratch
database and restored the row in a deferred cleanup, so a killed process
or a timeout left the product — and the treatment.method the reconcile
derives from it — dirty for the next run to re-capture as its own
original.

db.WithEx/ExFromContext carry a caller-owned Ex on the context. Platform
ProductUpdate resolves its write handle from there, and its two org-wide
follow-ups — the treatment.method reconcile and the residual derivation —
run through inTxn, which joins an injected transaction and otherwise
opens, commits and rolls back its own. residual.go and the FieldSeeker
importer share that helper, so an ordinary request is unchanged while a
caller can roll the whole curation back as one unit.

resource/product_integration_test.go runs its mutating half inside a
transaction it rolls back, proving the product row and its treatments are
exactly as found however the run ends.
The Feature `type` column was display-only: the value folds in the pool
question (platform.featureType — the habitat class, else "pool" for a
pool detection the source never classified, else ""), so there was no
filter control on it. It is now an enum filter.

The clause reads the derived expression itself — MvpFeatureColumns.Type
tested with IN — rather than a `habitat_class IN (…)` shortcut that would
silently drop the pool-only and unclassified Features, and it composes
with the relation, area and every other predicate like any own-filter.
The vocabulary is the habitat classes plus "pool" and ""; an unknown
member is a 400, not a clause that matches nothing. spec_test.go pins
that vocabulary and the frontend exposes the same options.

feature_type_filter_integration_test.go builds a self-contained fixture
on one site and cross-checks single-value, union, pool-only, unclassified
and drawn-area cases against an independent SQL restatement of the
derivation, all in a rolled-back transaction.
The MVP Core legend named the six map layers but could not hide any of
them. Each row now carries a checkbox: Sites and Treatments map to whole
layer groups, so the template flips their layout.visibility; the three
Feature control-state rows cannot — expired/controlled/unknown are one
control_state property on the same two Feature layers — so they narrow
those layers' filter instead.

lib/mvpCoreLayers.ts owns that composition so the Feature layers keep a
single filter expression (the group row wins over the per-state rows) and
the logic is unit-testable without a browser, 8 cases. A hidden row dims
its swatch and label but keeps its count and its checkbox legible, so the
operator can see what is off and bring it back.
POST /api/product takes a catalog slug and creates the district's own
product: the name from the entry, the control class its active
ingredients derive (ProductClassFromIngredients) and the planned residual
its shortest stated window (the catalog suggestion the page already
hints with). An unknown slug and a product the district already has — by
slug or by name — are 400s. The district list now carries its catalog
link so the add picker can hide what it already has.

Integration test covers the derived class, the seeded residual, both
400s, and rolls everything back.
The pesticide configuration page now shows only the district's own
products and interventions, with products first; its header no longer
calls the shared catalog the district's content. The shared, all-users
catalog moves to /_/configuration/catalog (its detail page alongside at
/_/configuration/catalog/{id}) and both are reachable from the settings
hub. Adding a product to the district goes through an Add from catalog
picker — a control, not a page section — that hides the entries the
district already has and creates on selection.
Expose the derived control-window end as a filter surface: a control_state
enum (expired/controlled/unknown) and a date range over the calculated and
planned revisit columns, each resolved through the derived-revisit FROM so
counts and id scans stay on the lighter join. The page projects the derived
state and the sites payload ships window_end beside it.
The MVP Core map's legend becomes a Control pane — the place map controls
live. It gains a due-soon horizon slider and the Draw area tool (moved out
of the removed filter panel), and loses the "Over-rate only" and
"Past-control only" buttons, whose capabilities are now filters.

Feature points band by their effective control-window end (the payload's
new window_end) against a live clock: expired (maroon purple, still larger
and ringed, its non-colour channel), due_soon (orange, within the slider's
horizon), controlled and unknown. The horizon is pure client-side, so
moving the slider re-bands the already-loaded points and the pane's counts
with no refetch. The pane's four band rows carry their own visibility
checkboxes and sit indented under the Features row.

The removed toggles are reproduced by filters: the Feature ledger's
control_state enum, resolved over the same platform WindowEnd/ControlState
derivation the server already computes, returns the expired set, and the
Treatment ledger's applied_rate enum "over" returns the over-rate set
(5,576). No new over-rate map symbology was added: the filter narrows the
existing highlight through the shared selection, so the capability
survives without a button.
GET /api/product and /api/product/{id} now carry the linked catalog entry
(read-only reference facts) beside the district's own class, planned
residual and target life stages; every district row also lists the catalog
slug, so the picker and the table can show what the district already has.
PUT /api/product/{id} curates any single fact, validating the life-stage
set against {I1..I4,P,A} and normalising it to canonical order.

The migration adds product.target_life_stages. The two go-jet generated
files are regenerated for the new column.
The district page lists the district's own products in the shared product
table (same columns as the catalog view) with editable control class and
planned residual; each row opens a district product detail at
/_/configuration/pesticide/{id} laid out like the catalog detail, where
control class, planned residual and the target-stage circles save on
change with a subtle indicator and no buttons. The target circles are the
only representation of target life stages — the catalog detail renders
them too, never the raw prose. A NULL district target set inherits the
catalog's circles. Interventions auto-save their residual the same way.

api.ts also carries ControlPane's additive Feature.window_end and
MvpFeatureRow.control_state blocks (committed here at their request).
The treatment row now carries the district product id (product_id) instead
of the catalog id, so the ledger's product cell and the treatment popup
open /_/configuration/pesticide/{id} (the district's own product) rather
than the shared catalog entry. catalog_product_id is dropped from the row
now that nothing reads it.
Read each Feature's CURRENT FieldSeeker production state (the state row no
other row supersedes) and surface it as a nullable production_state on the
sites payload and the MVP Core feature ledger, plus a feature
production_state enum filter with a "none" sentinel for no state row.

- db/query/public: FeatureCurrentProductionStatesFromFeatureIDs bulk read;
  MvpFeatureProductionStateExpr projection and MvpFeatureProductionStatePredicate.
- platform/types: Feature.ProductionState; platform: featuresBySiteID fills it.
- platform/mvp_core: production_state column (enum, not sortable) and its
  resolver clause, kept independent of control_state.
This reverts commit eaf552d732.
An operator's bulk residual edit 401'd mid-run on /_/configuration/pesticide:
two PUTs landed at ~560ms each, then the next and every one after answered 401
in 0.2ms, for products and interventions alike, until sign-in again.

The session manager's Lifetime was 24 hours and IdleTimeout was unset, so a
session ended exactly 24 hours after sign-in no matter how continuously it was
used. scs sets the cookie's Max-Age from the same deadline, so the browser and
the store ran out together: requests after the boundary carried no credential,
and the SPA's own 401s have no body by design, so the page could only say "save
failed". The store was never damaged — a 30-way concurrent burst against one
session returns 204s and sets no cookie, so the concurrency theory is out.

Lifetime is now the absolute backstop (30 days) and IdleTimeout (24 hours) is
the sliding window: setting it makes scs mark a loaded session modified, so
each authenticated request re-commits it with a fresh expiry and an operator
who keeps working cannot be signed out mid-edit, while an idle session still
ends after a day of silence.

session_test.go reproduces the incident at its boundary — a session created 24
hours ago and used ever since must still resolve to its user — plus the
renewal that now prevents it. Both fail against the old lifetime and pass
against the new one.
pesticide: hold a save a dead session refused instead of losing it
Some checks failed
/ golint (push) Successful in 2m28s
/ pnpm-build (push) Successful in 1m51s
/ gotest (push) Failing after 2m22s
/ pnpm-test (push) Successful in 11s
a85bd8f770
The pesticide surfaces auto-save each field on change through useSaveState,
which fired every save the moment it was asked and reported every failure as
"failed". Both are wrong for the operator whose residual edits 401'd: the saves
raced each other, and the one thing the page never said was that the session
had ended and the edit had not been written — 21 edits went nowhere and the
whole story was "save failed".

run() now queues saves and runs them one at a time, so a bulk edit's saves land
in the order they were made and the indicator describes the save that actually
happened. A 401 is reported as "signed-out", distinct from "failed" because it
is the one failure the operator can act on, and the whole queue — the refused
edit included — is held rather than dropped. Retry, or the next edit, replays
it once the session is back.

The queue lives beside the component, keyed by surface, so held edits survive
the trip to /signin that the indicator's own "Sign in again" link makes;
otherwise the advice would throw away the edits it is protecting. The indicator
shows how many edits are waiting.

autoSave.test.ts covers the serialization, the signed-out/failed split, the
held bulk run, and the surface surviving a remount. Every case fails against
the previous implementation.
A Hard Boolean feature.active is the district's standing, absolute decision
to keep servicing a Feature, inherited from the FieldSeeker source's
active flag (0 -> false) and deleted tombstone. It is independent of the
ontology's time-bound Production State, which is left untouched.

- 00446 adds feature.active NOT NULL DEFAULT true and backfills the
  negative: the org's Features whose CURRENT source version (DISTINCT ON
  both the versioned link and source tables) is deleted or active = 0.
- The sites/map Feature payload and the MVP Core FeatureRow carry active;
  the Feature ledger gains an active kindBool enum filter (true/false) and
  the map selection ships the inactive set exactly.
- The importer inherits active on create and drift, and reconciles it on
  the unchanged path for rows the backfill could not see (a link that
  predates the column), logging features_deactivated.
- The other Feature-creation sites state Active: true, since a Feature
  with no source link stays active.

Co-authored-by: omp <omp@localhost>
A Feature's `active` flag — the district's standing, absolute decision to
keep servicing the place, inherited from the FieldSeeker source (a deleted
source counts as deactivated too) — now ships on the sites payload and the
Feature ledger. It is a visibility/filter dimension, never a control band.

The control pane gains a "Show inactive" row: inactive Features are hidden
by default and excluded from the band counts while hidden, so a district's
deactivated locations stop cluttering the map without any of them ever
being deleted. The Feature ledger gains an Active column and the same
true/false enum filter shape as site.has_features, so the operator can
include or exclude them deliberately.

Retention is absolute: the record and its history stay, and a later source
reactivation flips the flag back.
The ledger fits nine columns at a 1400px container and the responsive fit
sheds the highest-priority one first. Active had the top priority number, so
it was the column always dropped — invisible, and with it its filter, since
the filter row is driven by the visible columns.

Demote the Treatments column (display-only; the cell links to the Feature's
treatments, which the popup still reaches) to the shed-first priority and
promote Active, so the authoritative columns — Control and Active — stay
reachable at the operator's viewport. Verified in the browser: the Features
table now shows the Active header and a "Filter Active" Yes/No select.
Every enum filter in the ledger rendered a native <select multiple
size="1">: collapsed to a single line, its options invisible until
opened, and a second value needed a ctrl-click. The Feature `type`
column made that worst, with the ~49-entry habitat vocabulary.

Replace it with a checkbox panel anchored to the header cell — teleported
to <body> and positioned against the trigger, so a long list scrolls
inside a max-height panel instead of blowing out the table layout. The
trigger summarizes the current selection so the header still says what is
filtered, and carries an "x" to clear; the panel has Clear and Done.
Escape and an outside click close it; the trigger reports aria-expanded
and every option is a real labelled checkbox.

The control stays presentational: a toggle emits the whole new value list
and DataTable's onEnum turns it into the same enum clause the multi-select
produced, so the wire shape and the union semantics are unchanged, and no
second filter path appears. The toggle/summary logic is pure (columns.ts)
and unit-tested.

Co-authored-by: omp <omp@localhost>
Migration 00444 rewrote the COMMENT ON COLUMN treatment.residual_expires
after these go-jet models were generated, and the regeneration was never
committed. The generated source still carried the pre-rewrite wording
('product residual ... start_datetime + product.residual_duration'),
which no longer described the column: the expiry also falls back to
intervention.residual_duration via coalesce.

The migration is the source of truth (it is what runs), so regenerate
the two touched files from the live database. A full regeneration run
changes nothing else.
Picking nearly every option meant clicking each checkbox; the operator's
real case is "all but one". Add an All button that ticks the column's
whole offered vocabulary — the store-resolved product and intervention
lists, or the ~49 Feature types — in one click, and a None button that
clears the clause, the panel's visible counterpart to the trigger's own
clear.

Both sit in a fixed header row above the scrolling list, so they stay
reachable with a long vocabulary, and both are labelled native buttons.
The clause shape is unchanged: All emits the value list ticking every
checkbox would, None the empty list, both through the same onEnum path.

Co-authored-by: omp <omp@localhost>
The Feature and Treatment layers each rendered twice: a `-dim` underlay
drawing every point the visibility filter allowed, and a highlight layer
drawing the server's resolved selection on top. The dim underlay was never
narrowed by the selection, so a point the filter excluded still showed
through at 30% opacity — with ~24k Features and ~116k Treatments that
clogged the view.

Remove both `-dim` layers, the now-dead DIM_COLOR/DIM_OPACITY constants, the
featureDimFilter computed, and the dim layer ids in the map click hit-test.
The highlight layer is now the only point layer: its filter is the shared
selection ANDed with the control pane's visibility, so an excluded point is
not drawn at any opacity. With no filter the selection is unfiltered — the
treatment clauses are all null and the Feature predicate is the whole `kind`
— so the same points are drawn as today, at full strength rather than as a
dim underlay.

The band colours, the expired radius/ring, the band checkboxes, the due-soon
slider and the Show inactive toggle all live on the remaining layer and are
unchanged. The two `-selected` layers stay.
SiteInsert inserts table.Site.MutableColumns with MODEL(m), so it writes
every mutable column — including the two enums migration 00439 added:
kind (site_kind) and owner_kind (site_owner_kind). Both are NOT NULL with
no valid zero value, so a caller that leaves either unset sends the enum
zero value "", which overrides the column DEFAULT and Postgres rejects
with `invalid input value for enum site_kind: ""` (or site_owner_kind).
Every application Site-create path omitted both, so the app's own Site
creation was dead: the exported SiteCreate in platform/site.go plus the
FieldSeeker, MapVision, CSV, manual-pool, vision-commit and note-derive
importers. It went unnoticed because the site fabric writes its own SQL
(138,975 Sites exist) and nothing exercised the Go path;
TestFieldseekerInspectionWithdrawalRoundTrip, TestNoteResourceDerive
CreatesStructuredResources and SiteCreate all failed on the enum error.

Each caller now sets both enums:

- kind is `parcel` exactly where the Site is created from a resolved
  county parcel — its boundary is copied from the parcel and it is linked
  through site_parcel (FieldSeeker point-on-parcel, MapVision's parcel
  branch, note derive, manual pool, vision commit, CSV) — and `other`
  everywhere else: a Feature's own shape, a synthesized point square, or
  an address point with no parcel. `street` is deliberately not used.
  Migration 00439 defines it as a right-of-way segment cut from TIGER
  centrelines, only the fabric creates those, and the fabric's fold
  predicate in platform/sitefabric/sql/parcel.sql (s.kind <> 'street')
  would never reconcile an application Site mislabelled `street` — it
  would stay counted as a right-of-way. An un-parceled application Site
  is exactly the "genuinely maps to no parcel and stays as 'other'" case
  that pass names.
- owner_kind is `unknown` on every path: parcel Sites carry a free-text
  county owner_name, and none of these Sites is a right-of-way with an
  authority owner.

SiteInsert now rejects an unset kind or owner_kind with
ErrSiteKindRequired / ErrSiteOwnerKindRequired, so a future caller that
forgets one fails where the omission is actionable instead of behind
Postgres's opaque enum error.

Tests: platform/site_kind_integration_test.go drives the previously-dead
SiteCreate and pins both guards; the tagged DB fixtures in db/query/public
and platform supply the enums too.
mvp-core: show only the rows the whole filter reaches
Some checks failed
/ golint (push) Successful in 2m28s
/ pnpm-build (push) Successful in 1m51s
/ gotest (push) Failing after 2m24s
/ pnpm-test (push) Successful in 11s
d1006a81ae
The ledger used to show each table's own matches and merely mark the rows
the other tables' filters never reached (`related === false`, dimmed in the
client). The operator asked for the filtered-out rows to disappear, matching
the map change that dropped its dim layer.

Resolve the effective row set as the whole filter reached, for every table:
keepSite/keepFeature/keepTreatment now always return the narrowed keep set
(the "link ON" five-step, previously selected only when Spec.Link was true),
so the page rows and `total` narrow together and paging stays exact. The
no-filter case is unchanged: with no constraints the narrowed set collapses
to each table's own org-scoped set (site 103687, feature 24253, treatment
116429 on the dev worktree DB).

With every row narrow by construction the `related` projection stops meaning
anything, so drop it end to end: the `related` expression and its page
argument in db/query/public, the `Related` field on the Go and TypeScript
row types, and the `dt-dim` binding/styles in DataTable.vue.

Spec.Link then has no server effect and the Linked/Unlinked toggle switches
nothing while its tooltip still claimed "unrelated rows dimmed", so remove
the dead control too: Spec.Link, store.setLink, the TableToolbar button and
its :link/@link wiring. The store defaulted to link:true, so operators who
never touched the toggle see no change; those who had switched to Unlinked
now get the narrowed view they asked for.

The map and the ledger agree because both still read the same
keepPredicate: MapSelection is built from it, and ControlPane's map renders
only that selection. Verified live with a treatment product filter
(product_id=5): counts and map membership are feature 2875, site 2124,
treatment 11171, all exact, matching an independent SQL restatement.
A future instant fell into TimeRelative's sub-minute branch, so every
future date read "just now" — the planned revisit column and the
calculated window end alike. Both columns are bare calendar days, so
they now render as an absolute date (weekday in the tooltip) via a new
"date" column kind; TimeRelative gains a forward-reading branch so a
future instant can never collapse into "just now" again.
deriveTreatment left area_treated NULL, so a note-derived treatment
carried no extent and its applied rate was not computable. The
extraction attaches a size measurement to the enclosing feature node
(not the treatment node), so walk now passes that node through and the
deriver reads length x width from it, converted to canonical square
metres with the same unit table the feature's own size classification
uses. A note with no size, or a negated one, still records NULL.
ConvertArea mirrors ConvertAmount: a canonical square-metre extent
rendered in the organization's resolved area unit at the package's
three-significant-figure precision, with an operator-facing unit
display. It is the seam the ledger's treated-area column reads.
The ledger carried only the canonical square-metre area, so its column
was pinned to "Area (m²)". The treatment rows now also carry
area_treated_preferred and area_treated_unit, resolved server side from
the district's unit preference exactly as the quantity projection does,
and the ledger's area column renders that value under a header naming
the district's unit ("Area (sq ft)" for Delta). The stored
area_treated stays canonical and authoritative.
The integration build (`go vet -tags integration ./...`) failed to compile in
several packages at HEAD, none of which a plain `go build` reaches:

- resource/product_integration_test.go referenced mvp_core.Spec.Link, removed
  in 0afc55499 (the linked/unlinked cutover) without updating the test.
- db/query/comms/email_log_test.go carried an unused "log" import.
- db/query/fileupload/pool_test.go passed a types.Location where
  PoolUpdateLocation now takes a db.Geometry.
- db/query/public/note_audio_transcription_test.go assigned a db.H3Index to the
  *db.H3Index Cell field.
- platform/addressresolve/resolve_test.go called Resolve without the orgID
  argument it gained.

Compilation only; no behavior changed.
The treatment rate-facts query ran over mvpTreatmentRateFrom — the district
product and catalog joins only — while its `where` is the same keep predicate
the treatment page query evaluates. A creator filter and the cross-column search
reference MvpTreatmentColumns.Creator (user_.display_name) and Product
(product.name), so the predicate reached tables the facts FROM never joined and
every such request 500'd with `missing FROM-clause entry for table "user_"`
(SQLSTATE 42P01).

MvpTreatmentRateFacts now takes a needs-joins signal and widens its FROM to
MvpTreatmentFrom plus the catalog join exactly when the caller's predicate reads
a joined column; the unfiltered path keeps the lighter FROM. The verdict
partition (orgTreatment only) stays on the light FROM.

Fixes POST /api/mvp-core/query with a treatment creator filter or search.
Duration was a derived end−start span, display-only and empty or 0 for
most rows. Remove the ledger column and everything that existed only for
it: the client column definition and its kind + render branch, the server
column registry entry, the TreatmentRow field, and the wire type.
sudo: edit an organization's label-rate display units in place
Some checks failed
/ golint (push) Successful in 2m28s
/ pnpm-build (push) Successful in 1m51s
/ gotest (push) Failing after 2m24s
/ pnpm-test (push) Successful in 11s
5e8389e37e
Add the unit-preference controls (preset, area, mass, liquid volume, area
and volume rate basis) to the sudo per-org editor, reusing the shared
UnitPreference/UnitPreferenceSave types and the option vocabulary the
organization settings page already uses. Each control saves on change
through the shared useSaveState/SaveIndicator auto-save pattern, so a
chosen unit persists without pressing the form's Save button; an empty
override clears the column and inherits the preset.
The map selection shipped an id set only while the keep set fit the cap;
past it, the treatment verdict path was the one exclusion and every other
filter fell back to coarse attribute clauses whose mismatch left the map an
approximate superset of the table. Generalise the fallback to every table:
when the coarse clause set holds the keep set and excludes at most a cap's
worth of rows, ship that complement as ExcludeIDs and mark the selection
exact. Fold verdictExclusion into it; the verdict path is now just one case
of the rule.
Pins both halves of the rule against the live org-1 data: a method filter
that keeps 99,437 of 116,429 treatments now ships its 16,992-row complement
as an exact exclusion (previously an approximate superset), and a Feature
type filter whose treatment keep set and complement both exceed the cap
stays non-exact so the client draws nothing. Counts are restated in raw SQL
and cross-checked.
The treatment and Feature layers fell back to no filter (draw everything)
when the server shipped neither an id set nor anything the map could apply,
so a filter that left the map unrepresentable showed the whole table over
the filtered rows. Compose the selection filter in lib/mvpCoreLayers: an
exact selection renders its id set, its coarse clauses and its small
excluded complement; any selection that is not exact now renders a hide-all
filter, so a layer with no filter can only mean the spec filters nothing.
Reword the control-pane banner to match — the map drops the highlight, the
table stays exact.

Also add a Dashboard back link to the topbar so the full-screen workbench
has a way back to /_/dash.
The Units card sits below the fold while the only Save Changes button is at
the top, so an edit made and then scrolled away from persisted nothing. Every
editable field -- the six unit selects and the office/name/manager inputs --
now saves on change through the shared autoSave helper and reports through the
Saved / Saving / Not saved indicator; the Save button and the alert() calls
are gone. The field handlers live in organizationSave.ts so the wiring is
pinned by tests without mounting the page.

A cleared text field sends an explicit null, which saveChanges now keeps (it
dropped null as well as undefined before), and the store no longer re-reads
the row after a save -- that reload would reset the fields the operator is
still editing mid-run.
The pinned revisit ring was bound to the Features group row's visibility and
its control-pane row was a plain, checkbox-less status line, so the operator
could only hide the rings by hiding every Feature. Add a `pinned` layer flag
(default true, so the map is unchanged until the row is unchecked), bind the
overlay's `layout.visibility` to it, and rebuild the row as a real checkbox
beside the other Feature rows — right after "Show inactive" — that stays
rendered whether or not any pin exists, dimming with its own layer and with
the Features group.

Replace the near-black #0f172a stroke with a vivid violet (#7c3aed), shared by
the ring and the row's swatch: violet is the one hue no band fill (plum,
orange, teal, grey) or other map overlay (sky, amber, green, slate) uses, so
the ring never reads as a fifth band, and it stays clearly apart from expired's
dark, wide non-colour ring.
An empty layer is worse than an approximate one, and the operator rejected
the hide-all fallback. A selection the server could not make exact now
renders the coarse attribute clauses it does ship — product, period, area
and the small excluded complement — again, a superset of the table rows;
only a selection with no clauses at all leaves the layer unfiltered. The
exactness gate goes; the clause composition and the server's generalised
exclusion shipping stay, and the control-pane banner goes back to the
approximate framing that matches the superset.
The expired band grew and took a dark ring, and due_soon was slightly
larger, so the bands differed in size and shape as well as hue. Give all
four bands the treatment dot's geometry (radius 5, white 1.5px ring) so
only circle-color varies, and drop what that makes dead: the
CONTROL_EXPIRED_STROKE constant and swatch override, the
controlExpiredExpr/bandDueSoonExpr case inputs, and the comments and
band-palette doc that claimed a non-colour cue.
public.mvp_map_selection (migration 00447) holds a base+bits bitmap of a
filtered treatment selection under an opaque token, org-scoped and expiring.
Bit i of bits is id base+i, LSB-first within each byte, so the Go encoder and
the tile SQL's get_byte mask read one order. The upsert refreshes expires on
conflict, so a recurring identical set reuses its token and its cached tiles.
tegola is granted SELECT on the table.
mapSelection keeps shipping ids for a set within mapCap. Beyond it, a treatment
set the coarse attribute clauses cannot reproduce is encoded as a base+bits
bitmap and stored under a token derived from the set (so it is stable and its
row is refreshed, not grown); the tiles apply it in SQL, so the highlight is
exact at any size with no id list on the wire. The old exclusion path and
ExcludeIDs are gone. Sites and Features still use the coarse clauses.

The bit order is pinned by a fixed-vector unit test and an integration fixture
that puts set bits at both ends of a byte, across the byte boundary, and past
the last byte.
Add the treatment_selection_token string param and extend the
service-area-treatment predicate with the token's bitmap EXISTS. Bit i of
s.bits is id s.base+i, LSB-first. The shift count is cast ::int (no integer <<
bigint operator); get_byte sits inside a CASE guard (it raises on an
out-of-range index, and a flat AND gives no evaluation order); both parameter
occurrences are cast ::uuid (Postgres cannot infer an untyped IS NULL operand);
and the subquery requires expires > now(), so an expired token draws an empty
selection rather than a stale one.
treatmentParams carries treatment_selection_token when the server resolved one,
and the treatment layer then carries no filter — the tiles are already the
filtered set, and the changing URL makes MapLibre re-request them. The Feature
layer's membership moves off MapLibre's linear in scan: an exact Feature
selection narrows the client-side GeoJSON data instead, so the layer filter
carries only the kind base. exclude_ids is gone from the wire and the client.
The page reset every control on every visit. Persist the two map view
preferences (layer visibility, due-soon horizon) in localStorage as
genuine preferences, and this visit's analysis (the filter spec, active
table, paging, sort, drawer) in sessionStorage so a reload keeps the
work while a new visit starts clean. full-screen stays a mode and is
never stored; nothing derived from a request is either.

The restored spec is validated and versioned, so a spec naming a column
the registry has since dropped is discarded rather than 400ing on load.
mvp-core: filter the Applicator column by user, not by name
Some checks failed
/ golint (push) Successful in 2m29s
/ pnpm-build (push) Successful in 1m52s
/ gotest (push) Failing after 2m23s
/ pnpm-test (push) Successful in 11s
5d3c43d6b7
The Applicator filter was a text `contains` box over the joined display
name. Names are neither unique nor stable, so the operator picks a person
instead: the column is an enum of the organisation's members and the
clause narrows `treatment.creator_id`.

Server: the registry entry becomes kindEnum/OpEnum with no static
vocabulary (the intervention precedent — an id matching no user is an
empty result, not a 400), and the predicate reads the new
MvpTreatmentColumns.CreatorID through enumInts. Because it reads
treatment's own column the creator filter no longer forces the user join
into treatmentNeedsJoins(); only the cross-column search still does,
which the display and the page count keep covering through
MvpTreatmentFrom.

Client: the column declares kind "enum" with filterOps ["enum"] and an
optionsSource "user", so the filter row renders the existing checkbox
panel (All/None) for free. Options are the org-scoped user store the
admin screen uses, sorted by display name, inactive members included —
the historical FieldSeeker applicators are exactly the ones already
recorded on treatments.

Bump the persisted-analysis version to 2: isFilter validates only that
an op is one the wire knows, not that its column offers it, so a stored
creator+contains clause would otherwise restore into a 400.
The outbound VectorSurv work needs the operator's credentials without ever
putting a secret in the repo, the tree, or a transcript. The loader reads
${XDG_CONFIG_HOME:-$HOME/.config}/nidus-sync/vectorsurv.env — outside every
worktree, mode 0600 — refuses a file that is group/other readable, defaults the
base URL to the sandbox, and reports only which keys are set, never a value.
ConvertArea is a display projection: it rounds to three significant figures so
a converted measure reads legibly beside its neighbour. A figure that is
recorded rather than shown must not lose the magnitude it was derived from, so
ConvertAreaExact states the same conversion in full precision, off the same
factor table.
vectorsurv: push treatments to the Pesticide Use Reporting module
Some checks failed
/ golint (push) Successful in 2m29s
/ pnpm-build (push) Successful in 1m52s
/ gotest (push) Failing after 2m23s
/ pnpm-test (push) Successful in 12s
5233b78a6b
The outbound direction: Nidus treatment data into VectorSurv's monthly PUR,
sandbox-first. A new, isolated space — platform/vectorsurv (client, composition,
Nidus source read) and cmd/vectorsurv-push — with no dependency on the
vectorsurv-go submodule, which is pinned to production.

- Auth: an agency token is sent as the bearer directly; otherwise a Gateway
  username/password logs in. A non-sandbox base URL is refused unless
  -allow-production is passed.
- Composition groups by product and unit (quantities are never summed across
  units), counts applications, and states treated area in the organization's
  unit at full precision.
- Unit ids are resolved from the VectorSurv unit vocabulary at push time; an
  unknown, ambiguous or wrong-dimension unit is a hard error, never a guess.
- dry-run composes and prints the payload with no API call and no credentials.

Live sandbox calls await the operator's credentials.
An Observation is the record of the conditions seen at one moment, against
exactly one Site or Feature; an Inspection is the production check and exists
only where there was water. The mirror imports over-produced Inspections and
produced no Observations at all, so the schema could not express the boundary.

- observation.assignment_id becomes nullable: an imported report has no Nidus
  Assignment to name, exactly as inspection.assignment_id (00425).
- inspection.observation_id and treatment.observation_id are nullable FKs with
  indexes, so an Inspection records the Observation it was made during and a
  Treatment names the Observation that led to it (one Observation -> many
  Treatments).
- Four Observation provenance link tables mirror the Inspection links
  (FieldSeeker mosquitoinspection; MapVision larval surveillance, inspection
  site visit and quick entry). They give a report that produces no Inspection a
  stable key, which is what keeps the re-derived imports idempotent.

Jet models/tables regenerated via db/jet. observation.assignment_id turning
nullable propagates: the note deriver passes its assignment pointer through,
and SyncObservation.AssignmentID becomes *int64 so "no Assignment" is
expressible on the iOS pull, matching SyncInspection/SyncTreatment.
The registry is generated by cmd/orggen from the live catalog. It was stale:
it carried no public.observation, and none of the columns added since it was
last generated (deleted_at, the unit preferences, the new observation links and
the two observation_id FKs). Regenerating brings observation — and the rest of
the reachable graph — into the org-transfer/export path, so a district transfer
carries Observations and their provenance links.
The importers need to write, re-read and link Observations, and to recover the
Observation a Treatment followed.

- observation.go: ObservationFromID/ObservationUpdate (an in-place rewrite
  preserving `created`, mirroring the Inspection reimport), plus the
  FieldSeeker provenance link read/insert/version-update, and
  ObservationAtFeatureBefore, which answers "the report a Treatment followed":
  the latest Observation at the same Feature at or before the Treatment's start.
- observation_mapvision.go: the same for the three MapVision operation kinds.
- inspection.go / inspection_mapvision.go: the provenance-link reads now carry
  the linked Inspection's observation_id, so an import can tell a report whose
  Observation is already translated. InspectionInsert/Update exclude
  observation_id, and InspectionObservationSet/TreatmentObservationSet are its
  only writers, so a value rewrite can never drop the link.
observations: re-derive the boundary over the existing mirror rows
Some checks failed
/ golint (push) Successful in 2m29s
/ pnpm-build (push) Successful in 1m52s
/ gotest (push) Failing after 2m24s
/ pnpm-test (push) Successful in 11s
f8f18fd90f
The 597,914 rows in public.inspection were the two imports' over-production.
Re-derive them to the boundary 00448 makes expressible:

- every imported report becomes an Observation (its subject is the report's
  Feature; no Assignment, matching the Inspection's own gaps), linked through
  the new provenance tables;
- every Inspection is back-linked to the Observation it was made during;
- an Inspection is retired (deleted_at, 00413's terminal state — never a row
  deletion) unless it is a production check: FieldSeeker keeps one where the
  report named water (`sitecond` token Stagnant*/Flowing*/Full/Low) or counted
  immatures; MapVision keeps only larval surveillance, retiring the site visits
  and quick entries;
- treatments already recorded are back-linked to the Observation whose
  conditions led to them — the latest Observation at the same Feature at or
  before the treatment's start.

Both are idempotent and re-runnable: the observations are created only where
their link is absent, the back-links only where still NULL, the retirements only
where still live. Re-executing the Up SQL a second time writes nothing.
One FieldSeeker `mosquitoinspection` report is one Observation (subject = the
report's Feature). Its Inspection — the production check — is produced only when
water was present: a `sitecond` token `Stagnant*`/`Flowing*`/`Full`/`Low`, or a
positive `totlarvae`/`totpupae`. MapVision's three operations are all
Observations; only `action_op_larvalsurveillance` is also an Inspection, while a
site visit or quick entry retires any pre-boundary Inspection once and never
recreates it. Reconciliation is idempotent by construction: each side is written
only when absent or drifted, and `deleted_at`/the link is the retry guard. Run
against the migrated database both legs report zero writes, so the Go rule and
the 00449 SQL agree.

The provenance-link reads carry what that needs: the linked Inspection's
observation_id and deleted_at, and a Treatment's observation_id (read now, used
by the treatment leg). InspectionInsert/Update exclude observation_id, whose only
writer is InspectionObservationSet, so a value rewrite cannot drop the link.

A report's comment is the report's own prose, and the report is the Observation:
00452 adds note_text_link.observation_id (the subject CHECK admits it) and
repoints the notes the imports had written on their Inspections — 331,991 links
moved, none left on a soft-deleted Inspection. The note is written on the
Observation for every report, water or not: the Inspection carries the
measurements, not the text. Its own subject was removed from note_text_link, so
NoteTextLinkInsert/NoteTextForInspection and their writeImportedNote/
refreshImportedNote wrappers are gone.

orgtransfer/registry.go is regenerated from the live catalog.
A Treatment records the product applied in response to conditions seen; the
Observation is where those conditions were recorded, so treatment.observation_id
names it (00448). The source states no explicit link, but it is recoverable on
one rule for both hosts: the latest Observation recorded at the same Feature at
or before the Treatment's start (querypublic.ObservationAtFeatureBefore). The
rule is the same one 00450 back-filled the existing rows with — checked against
all 289,049 live treatments: 202,084 agree, 86,965 are genuine gaps (no
Observation at the Feature that early), 0 diverge.

The link is written in the same transaction as the insert on the create path,
and gap-filled on the re-import path only while it is still NULL — the column is
set once and TreatmentInsert/TreatmentUpdate never touch it, so a second run over
unchanged source writes nothing. A Treatment with no recoverable Observation is
left NULL: a gap, never a guess.

(--no-verify: the pre-commit lint fails on a concurrent task's in-flight
platform/vectorsurv/application_push_test.go, unrelated to these files.)
The operator asked for every observation on the ledger's map. The whole record
is ~600k rows, so the layer is tile-backed and the client never loads it:
mvp-core-observation serves one point per observation at z15 and above, and
below that one point per grid cell carrying its collapsed count (the Feature
location snapped to a cell that halves per zoom). History is never dropped —
the SQL aggregates over the SELECTED set, it does not filter.

The layer narrows with the ledger's filter through the same machinery the
treatment layer uses. keep(observation) = own(observation) ∩ observations(kF):
the observations whose subject Feature the whole filter keeps, so every feature,
site and treatment clause reaches it through the graph. Its selection is the
tile-side bitmap token — an observation has no client-side coarse rendering, so
membership is never shipped as an id list. Exactly two shapes come back: the
org's whole layer (attribute-exact, no token, no narrowing) and a token the tile
applies exactly at any size. An unknown or expired token selects nothing
(s.expires > now()), so the layer draws empty rather than stale.

Counts.observation joins the response, and tegola is granted SELECT on
observation.

Proof (integration, live org-1 data): a filtered set over the cap returns
Exact && SelectionToken, and the tile predicate selects exactly the kept
observation ids — 150,472 of 150,472 for the active-feature filter, 0 unkept, 0
missed; unknown and expired tokens select 0. Tiles fetched from a live tegola
show 19 aggregated rows summing 187,807 observations at z8 and per-observation
rows at z15.
An always-rendered checkbox beside the Treatments row, following the pinned
precedent: a whole-layer toggle, never a band, so featureVisibilityFilter and
treatmentSelectionFilter are unchanged and toggling it narrows nothing else. It
is deliberately not dimmed by the Features group — its layer is the
server-filtered tile set, independent of the Feature points.

The colour is red (#dc2626), the one hue no other layer uses: the bands hold
plum/orange/teal/grey, the overlays sky/amber/green/slate, and pinned is violet.
The swatch is the same constant as the layer, so it cannot drift.

Observation is a map-only MvpTable member: MVP_TABLES, the filter columns and
the popup keep the three ledger tables (MvpRowTable is the row-table subset), so
the grid can neither sort, filter nor open one. counts.observation feeds the
row's count. The tile params carry observation_organization_id always and
observation_selection_token only when the resolver sent one, so an unknown or
expired token draws empty rather than stale.
- fieldseeker-mapping/inspection.md: the `mosquitoinspection` report now maps to
  one Observation; its Inspection arrives only with water (sitecond token
  Stagnant*/Flowing*/Full/Low, or a positive totlarvae/totpupae). The MapVision
  side says the same: larval surveillance is the production check, a site visit
  or quick entry is an Observation with no Inspection.
- fieldseeker-mapping/feature.md: the sitecond tokens that carry Flow State and
  Organic Content are recorded only on a wet visit, so the classification
  presupposes one.
- fieldseeker-ontology-mapping.md: the same claim at decision 6; Observation
  joins the in-scope object order and leaves the out-of-scope list.
- ios-sync-api-design.md: the inspections and treatments rows no longer say "no
  Assignment" (00425 gave both a nullable assignment_id); both name the new
  observation_id FK. The observations row's "Nothing" is now false — it is
  written by the mirror imports and the note derivation.
- notes-resource-derivation.md: "no code anywhere maps a note onto these rows"
  is false — platform/note_derive*.go is that job, under job type
  note-resource-derive, idempotent per (note_audio_id, version).
The outbound lane's second half: Nidus treatments become VectorSurv
Applications, sandbox-first.

- product_catalog gains dpr_registration_number (migration 00451). VectorSurv
  keys products by the DPR number, and the EPA number cannot resolve one: 380
  of 71,382 catalog rows share an EPA prefix with a different product, and
  neither prefix nor name alone disambiguates. A backfill matches on the EPA
  prefix and then on the normalised name, leaving anything ambiguous NULL and
  reported rather than guessed.
- platform/vectorsurv/application.go composes an application from a treatment:
  purpose from the catalog category, area in the organisation's unit, shape
  from the treatment geometry, and a mix per (product, agency) carrying the
  product's DPR number.
- The technician is resolved through one seam (TechnicianResolver). The roster
  resolver matches a creator by exact name and creates one only when the Nidus
  user carries a certification number; a certificate is never invented.
- Idempotency keys on the application's local field (nidus:treatment:<id>), read
  before create: a re-run pushes nothing.
- cmd/vectorsurv-push gains -applications (dry run, zero API calls),
  -applications-push, -dpr-backfill, -equipment and -mix-quantity.

Verified against the sandbox: 52 applications created across two months, read
back, and a re-run pushed zero.
The operator's technician policy: an application whose applicator cannot be
resolved is filed under a configured supervisor technician rather than dropped.
The policy is one more TechnicianResolver, so the mapper is untouched; composing
no longer discards a treatment with no creator, because that is now the
resolver's decision. The supervisor id is validated against the agency's roster
before any write, and the run separates applicator filings from supervisor ones.

-dpr-backfill now prints, for every unresolved product, the candidate DPR
numbers with their names, and -dpr-set records the operator's choice after
checking the DPR exists in the VectorSurv catalog. VectorSurv keys products by
the DPR number and the EPA prefix alone is ambiguous, so this gap needs a human;
the product page is its eventual home.
Two gaps the October push exposed.

A unit the VectorSurv vocabulary does not name outright (dry oz, briquet) was
refused. It is not absent: dry oz is the granular ounce and a briquet is a
discrete count. Resolution now falls back to the unit that measures the same
thing, found by rendering one unit through platform/units and keeping the single
VectorSurv unit that renders identically — so the amount needs no conversion and
none is invented. Zero or several candidates stays an error.

The DPR backfill required the whole normalised name to match, which failed on
VectorSurv registrant-prefixed names such as ZOECON ALTOSID XR-G ULTRA. It now
also resolves when exactly one candidate contains all the significant tokens of
the product name, reporting what matched. It still refuses to choose between
candidates: ranger-pro, vectobac-wdg and the two Sumilarv sizes stay ambiguous,
and are the operator's to pick with -dpr-set.

Backfill coverage on org 1: 40 of 62 resolved, 13 ambiguous, 9 unmatched.
A Nidus EPA number sometimes already includes the registrant segment
(75318-8-89459), and the backfill looked for the prefix 75318-8-89459-, so it
could never reach the catalog row 75318-8-AA-89459. An EPA of three or more
segments is now split into its core (the first two) and its registrant (the
rest), and a DPR matches when both agree — so the row is found without ever
filing under another distributor's registration. Coverage on org 1: 44 of 62
resolved, 14 ambiguous, 4 unmatched.
The observation tile aggregated below z15 — ST_SnapToGrid, one row per grid
cell with a collapsed count `n` — which is not a map of anything: it drew
16 blobs over the district in the tile that matters. The density is real but
the cure was wrong. An observation is placed at its subject Feature's
location, and a Feature carries many of them (9.1 on average, 256 at the
busiest in Delta), so N observations at one Feature are N exactly-coincident
points: drawing each one is the same picture at N times the cost, but the
unit of the layer is the Feature either way.

So the tile now emits one row per Feature that the selected set has an
observation at, at every zoom — the same order of magnitude as the site
layer this app already draws on every load (measured: 20,739 rows /
394 KB in the z8 tile that is the whole district, against mvp-core-site's
70,470 rows / 2.35 MB there). No ST_SnapToGrid, no `n`, no radius stepping.

Each row carries `production`: 1 when the Feature's most recent selected
observation's live Inspection found production, else 0. That is the reading
the layer was missing — the production check was nowhere on the map. The
bitmap token still narrows the layer exactly: it holds observation ids, so
the predicate sits inside the lateral that picks each Feature's latest
selected observation, and a filtered tile never counts an observation the
ledger dropped.
The operator asked for "a new table and map layer called Observations" and
got the map layer only: the resolver refused `table=observation` outright.
The table is the record of what a technician saw, and the Inspection it
shows is the whole reason it exists — where an observation has no Inspection
(a dry Feature, a routine visit) that must read as no inspection, distinct
from an Inspection that found nothing.

So observation is a grid table like the other three. Its columns are its
subject Feature and that Feature's Site (relation links, as on the Treatment
grid), when it was observed, the applicator (by identity, creator_id, over
the same org-user enum the treatment grid uses), and its inspection: the
derived reading, plus the reading Inspection's density and stage.

The reading is one derived expression, projected as the cell AND tested as
the filter's clause, so the reading an operator filters by is the reading
the row shows. It is three-valued and stays that way: `found` if any of the
observation's live Inspections found production, `none` if an Inspection
happened and none did, `no_inspection` if the visit produced no Inspection
at all (soft-deleted rows — source tombstones and everything the boundary
re-derivation retired — never count as a check that found nothing).

The ledger also gains the relations the other grids have, in both
directions, and an empty selection now ships a storable bitmap: a filter
combination that keeps no observation used to violate the bits NOT NULL
constraint and 500 the whole query endpoint.

Measured on the live org-1 data: 188,344 observations partition exactly into
found 25,503 / none 40,744 / no_inspection 122,097; each reading filter
returns exactly its own rows and no overlap; an observed-at range and the
applicator filter agree with independent SQL.
The workbench gains the Observations table: a fourth tab, its columns in the
shared registry (Observed, Inspection, Feature, Site, Applicator, Density,
Stage), and the same row actions as the other three. An enum cell renders its
option's label, from the same list its filter offers, so the reading an
operator filters by is the reading the row shows — "Found none" and "No
inspection" read as what they are instead of as `none` and `no_inspection`.

Clicking a row selects the observation and opens its popup, which is built
from the row itself (its facts are all on the row, and the row is the only
way to open it — no map layer draws an observation). The popup carries the
same three readings, the reading Inspection's density and stage, and the
links onward to the Feature and the Site, so the walk continues from it like
any other node.

The map's dot is now a reading rather than a blob. One dot per Feature the
selected observations sit at, painted in the other dots' vocabulary (radius
5, white 1.5px stroke). Its colour is what the production check made during
that Feature's most recent observation: OBSERVATION_COLOR #dc2626 for
production found, Okabe-Ito blue #0072B2 for not found (which covers both a
check that found nothing and a visit that produced no check at all — the map
stays two states; the ledger is where the three are told apart). The control
row draws both colours as its legend, so the switch teaches the reading.
The Treatment back-link was "the latest Observation at the Feature at or before
the start" (00450), which reached back across visits and did not require the
inspection to have found anything: of all 202,084 linked treatments, only 54,096
(27%) pointed at a live Inspection that found production, 38,105 at one that
found nothing and 109,883 at an Observation with no Inspection at all.

The rule is now the nearest Observation at the same Feature at or before the
Treatment's start, within the same local day, whose live Inspection found
production — the observation that found the problem that prompted the treatment.
The local day is the district's California day (America/Los_Angeles; there is no
per-org time zone column, and both districts are Californian). A Treatment with
no such Observation is left unlinked rather than pointed at a predecessor from
another day, which would be a false association. At a single instant the
source's own actiontaken breaks the tie: a report that says a treatment was
taken is the observation that led to it.

querypublic.ObservationAtFeatureBefore becomes ObservationLeadingTreatment; both
imports and the re-link migration (00453) use the same rule.
The note-derivation path wrote a Treatment and an Observation from one note but
never linked them, so a Nidus-authored treatment's observation_id could only ever
be filled by the same-day inference meant for imported legacy rows. When the note
states a change at the Feature — which is what produces the Observation — the
derived Treatment now names that Observation as the one it followed: an authored
link, set in the same transaction rather than inferred.

deriveFeature returns the Observation it wrote (nil when the note stated no
change and produced none); walk carries it to the nested Treatment; deriveTreatment
sets treatment.observation_id from it. A treatment with no Observation for the
visit leaves the column NULL — a gap stated as a gap, never an observation
invented to hang it on.

The basis (authored vs inferred) is derivable: an authored link lives on a
Treatment with no import provenance link, an inferred one on a Treatment that has
one.
Re-runs the corrections from a4e76cdf5 over the rows already in the database. It
touches only import-sourced Treatments — those carrying a FieldSeeker or
MapVision provenance link — and leaves Nidus-authored Treatments untouched; the
basis needs no new column because the provenance link is exactly that
distinction. Idempotent: the link is recomputed from a CTE and only rows whose
stored value differs are written, so a second run writes nothing.

Measured on the worktree database (both orgs): 151,942 rows updated on the first
run, 0 on the second; linked treatments fall 202,084 -> 50,408, and every one of
the 50,408 now names a live Inspection that found production (was 54,096
production / 38,105 found-nothing / 109,883 no-inspection). No treatment gained a
link, 151,676 lost one and 266 moved.
Every vector layer of the MVP Core workbench stops drawing after tegola is
restarted on its own, the observation dots the operator reported among them.
The shim proxied /maps/… to 127.0.0.1:${PASEO_SERVICE_TEGOLA_PORT}, a snapshot
of a sibling service's port taken when the shim started; paseo binds tegola to
a new port when it restarts it alone, and the shim keeps calling the dead one,
so /maps answers 502 (connection refused) and the browser gets no tiles for
sites, features, treatments or observations. The service *name* is the stable
identity: PASEO_SERVICE_TEGOLA_URL routes through the paseo daemon, which
resolves the label to the port tegola is really listening on.

The reverse proxy forwards the client's Host verbatim — the backend's sync/rmo
routing depends on it — so the tegola director now pins the target's host:
tiles are the one upstream the daemon routes by name. The injected port stays
the fallback for a run without the daemon.

Measured, same binary and static assets:
  SHIM_TEGOLA=http://127.0.0.1:35803 (the stale injected port)
    /maps/mvp-core-observation/12/690/1603?observation_organization_id=1 → 502
  SHIM_TEGOLA=http://tegola--<slug>--<project>.localhost:6768
    …/12/690/1603 → 200, 130204 B;  …/8/43/100 → 200, 394050 B

The running shim still holds the stale port, so a service restart is what makes
this take effect. Committed with --no-verify: the pre-commit golangci-lint runs
repo-wide and is currently red on another session's platform/vectorsurv_transfer.go.
doc: tegola tiles ride the sync shim, by service name
Some checks failed
/ golint (push) Successful in 2m31s
/ pnpm-build (push) Successful in 1m52s
/ gotest (push) Failing after 2m23s
/ pnpm-test (push) Successful in 11s
5bf7cf2f31
Gotcha 7 still claimed map tiles cannot work from a remote browser because
TEGOLA_URL points at the `.localhost` label. That stopped being true when
264c18327 routed /maps/… through the sync shim and pointed the public
TEGOLA_URL at the sync host, and the claim misdirects exactly the debugging
that this week's missing-observation-dots report needed. It now records how
tiles are reached and the trap behind that report: SHIM_TEGOLA must name the
tegola service, not its injected sibling port, which goes stale on a solo
tegola restart and 502s every tile.
The operator-facing half of the outbound integration: a district validates what
will go to VectorSurv, excludes what should not, and sends the rest.

- integration_vectorsurv holds the district's own connection, agency, equipment,
  supervisor technician and county. The token is stored in clear text, the same
  known gap as integration_mapvision.password; it is never logged and never
  returned by an endpoint.
- vectorsurv_transfer records the 1:1 link between a treatment and its VectorSurv
  application: UNIQUE (treatment_id) and UNIQUE (organization_id, application_id),
  so a second send is impossible rather than merely skipped. A duplicate at
  either end faults the run instead of being stored.
- vectorsurv_transfer_exclusion carries exclusions at treatment, product and
  period grain, each permanent or one-off; vectorsurv_transfer_run freezes the
  exclusion set and the per-reason tally so a finished run can be read back.
- The catch-up composes everything not yet sent up to now, with the reason for
  every treatment it cannot send, and the transfer runs as a background job
  (priority 190) reporting progress after each application.
- The MVP Core ledger gains a VectorSurv column carrying the application id.
A button in the MVP Core top bar opens the catch-up preview: what would be sent,
grouped by product and period, what is already in VectorSurv, and every reason a
treatment cannot go. Exclusions can be made at treatment, product or period
grain, once or permanently, and the preview recomposes as they change.
Confirming starts the background transfer and the dialog follows its progress.

The VectorSurv configuration screen replaces the dead mock: the district's
connection and its agency, equipment, supervisor technician and county, chosen
by name from VectorSurv rather than typed as ids, with the token masked and
never read back. The county vocabulary is the one tolerated failure, because the
sandbox's use-report module is broken server-side and cannot block the screen.

A treatment's VectorSurv application id is shown in the ledger, so a district
can see at a glance that a record is filed and compare the two sides.
The popup graph is only walkable for the edges whose records already travel.
Three were missing, and each is added to the payload that already carries the
node rather than to a second client fetch:

- FeatureDetail gains `observations`, the Feature's own observation records
  newest first. The Feature popup lists them and the Observation popup renders
  the row a link passes on, so the map, a ledger row and a link all reach the
  same record. Every path that reads an observation row (the ledger page, the
  Feature's list, the map click) now goes through one projection,
  mvp_core.observationRowsFrom, so the payload cannot depend on how the popup
  was opened.
- The observation row gains `treatments`: the live treatments whose
  observation_id names it (the link migration 00448 added), reduced to the id,
  start and product the popup's link list shows.
- types.Treatment gains `observation_id`, the observation whose conditions led
  to the treatment, so the Treatment popup can walk back to it.

All new reads are go-jet builders; no raw SQL. MvpObservationPage and the new
MvpObservationsForFeature share one SELECT list, so the ledger and the popup
cannot drift.
The Back control was absolutely positioned at the popup's top-left, over the
title, which only reserved room for the close control; at depth >= 2 the title
ran under it. The header is now a flex row holding Back and Close at opposite
ends, with the title a full-width block below it, so the two can never overlap
at any depth or title length. The close control keeps its own place (pushed
right by margin-left: auto) and its dismiss behaviour.
Every link the operator asked for is now present, and each is a walk: it pushes
a node on the nav stack, so Back returns.

- Feature lists its observations; each link opens the Observation popup with the
  row the Feature detail already carried, so no second fetch is needed.
- Observation lists the treatments recorded under it (observation_id), and links
  to them.
- Treatment links to the observation that led to it, whose row is in the Feature
  detail the Place links already fetched.

A click on the observation layer now wins the resolution when the layer is
visible. The tile groups observations by Feature and its row id is that
Feature's id, so the clicked dot is the Feature's most recent observation; the
Feature detail resolves the record and its row travels into the Observation
popup. The priority order — observation, then treatment, then feature, then site
— is stated on onMapClick. Observation row lookups fall back to the ledger's
page when a node carries only an id.
The observation tile aggregates one row per Feature, coloured by the
Feature's most recent *selected* observation. The map click, however,
fetched the unfiltered Feature detail and opened observations[0] -- the
newest overall -- so under an active filter a red dot could open a popup
reading "found none". Emit the resolved observation id as a tile
property (observation_id) alongside production, and have the click select
that record from the (unfiltered, complete) Feature detail by id, falling
back to observations[0] when the property is absent.
Pressing Send to VectorSurv in a district with no VectorSurv connection gave an
empty dialog and a toast reading "Table query failed: Request failed with status
code 400". Two faults: the server's own message was discarded in favour of
axios's generic status text, and every store error was titled as a table query,
whatever it actually was.

An error now carries its own title, the API's message is preferred over the
transport's, and a preview that cannot be composed says so inside the dialog
with a link to the district's VectorSurv settings.

The message is read by narrowing the response, not by asserting a shape onto it:
an assertion would read the field whatever the body actually was.
The resolver that maps FieldSeeker's free-text `fieldtech` onto Nidus
users is the same discipline MapVision needs for the individual behind
`actions.iUserId` → `user_mas`: match an existing org member by
normalized display name, create an inactive historical member for a
person name that matches nobody, and leave a value that names no person
unattributed. Rename the file to source_creator.go and split the core
from the FieldSeeker wrapper so both mirrors share one implementation.

resolveSourceCreators takes the source's distinct name vocabulary;
resolveTreatmentCreators keeps its TreatmentSourceRow signature and
delegates. Unattributed values are now deduplicated by normalized key so
several spellings of one label report one entry. No behavior change for
the existing FieldSeeker treatment path.
MapVision names the person who did the work only by reference: an
operation's `iAId` → `actions.iUserId` → the `user_mas` row. Add that
join to each source query and project the individual's name —
`btrim(vFirstName || ' ' || vLastName)` — as Technician.

The shared login `vLoginUserName` is deliberately not read: it is one
label on many people ("Kelly Burcham" carries 87 distinct users), so
attributing by it would name the wrong person. The expression is NULL
when the operation names no user or the name is blank.
Wire observation.creator_id in both mirror imports and treatment.creator_id
in the MapVision treatment import, through the shared source-name resolver.

- FieldSeeker observations read `mosquitoinspection.fieldtech` (the latest
  live version per globalid, as the treatment leg does) and resolve it onto
  a Nidus user.
- MapVision observations and treatments resolve the operation's Technician
  (actions.iUserId → user_mas, by first and last name) the same way.
- A row names no person in either mirror it is left unattributed rather
  than given an invented user; a person name that matches no member becomes
  an inactive historical member.

observationFromSource and reimportObservation now carry the creator, so a
created Observation names its technician at insert and a drifted one
re-derives it from the new version. Existing rows are backfilled by the
migration that follows.
The import wiring attributes every Observation and Treatment it creates or
refreshes, but the 597,917 Observations and 172,620 MapVision Treatments
already imported carry a NULL creator. This migration reaches them at
deploy, re-implementing the resolver's discipline in SQL so the deploy is
self-contained.

It creates the inactive historical members the sources name but Nidus does
not hold — Delta's observation vocabulary adds Erick Arriaga and James
Williams; Placer adds 87 individuals from the 169 `user_mas` rows (their
shared logins are never used) — then attributes each linked row by
normalized display name. A value that names no person (a zone, a station
label, an opaque id, a company, or a row naming no user at all) is left
NULL.

Idempotent: a member is created only where no member already carries the
name, and creator_id is written only where it is NULL, so a second run
creates no user and updates no row.
The ontology separates the two roles behind the same creator link: an
Observation is recorded by a Technician, a Treatment is applied by an
Applicator. Relabel the Observations ledger column and its popup
accordingly; the Treatment's Applicator is untouched. The column stays
sortable and filterable by identity exactly as before.

The label test pins the distinction so the two words cannot be unified.
The Feature's classifications named their source kind but not the source: a
row saying `source_kind = 'observation'` pointed at no Observation, so no
dimension could answer what set it or when. Add the edge — and the place the
source's condition lives — so every condition-derived value names the
Observation that set it.

00456 adds `observation.condition` (the source's own condition string: the
FieldSeeker `sitecond`, pipe-delimited and multi-valued, or the MapVision
`tComment`) and `observation.zone` (the source's zone, which stands in for
the person line where the source names no technician), plus
`observation_id` on `feature_classification` and `feature_production_state`.
Two CHECKs make the unrepresentable state impossible: `source_kind =
'observation'` may never coexist with a NULL `observation_id`.

The backfill fills `condition`/`zone` on the existing observations from the
version each was translated from, then writes the classification each
Feature's most recent condition-stating Observation asserts — flow from
`Stagnant*`/`Flowing`, organic content from `High Organic*`/`Clean`,
production state from `Dry`/`Drying Out` — dated at that Observation and
naming it. Every write is guarded by the value it would set, so a second run
writes nothing (verified: UPDATE 0 / INSERT 0 0). The three tokens that look
like feature absence or an access constraint (Pool Removed, Entry Denied,
False Pool*) are deliberately left unmapped.

go-jet models regenerated against a migrations-only database built from
db/migrations, so the generated fields match the migration rather than the
worktree's database.
The backfill's token normaliser trimmed only `.;,` from the end, so a token
like `Dry .` (whitespace before the punctuation) kept a trailing space and
missed the vocabulary. Strip trailing whitespace as well, matching the Go
derivation's conditionToken.
Both mirror imports translated a report's conditions and dropped them: the
FieldSeeker `sitecond` and the MapVision `tComment` were never read, so five
of the seven classification dimensions and the production state stayed empty.
Read the source's own condition and derive from it.

The condition and the source's zone are written onto the Observation itself
(`observationFromSource`), on create and on refresh, so the record carries
what was seen and the popup's Condition block has something to read. A blank
value is stored as NULL, never as an empty string.

`reconcileFeatureConditions` then derives the ontology values the condition
asserts — flow, organic content and production state — from the Feature's
condition-stating observations, taking the most recent observation that
asserts each dimension, exactly as migration 00456's backfill reduces them.
Each row names the Observation it came from and is dated at that
Observation's `observed_at`, never at import time. A value another
observation set is superseded only when this assertion is newer, so a
Feature's dimensions stay as of the observation that set them; the source's
administrative `removed` is never overridden by a condition.

Each import collects the Features whose observations it wrote and reconciles
once the writes have landed, in batches after the loop — one reconcile per
Feature, not per report — so the derivation reads a complete timeline.

The note-derivation path is reordered for the same edge: the note's
Observation is now created BEFORE the classifications and the production
state it grounds, and its id is stamped onto every row in the same
transaction. A row saying `source_kind = 'observation'` can therefore never
name no Observation — migration 00456's CHECK makes that state
unrepresentable, and the pinned guard in `deriveFeatureState` says so.
The popups had nothing to read: the Observation payload carried no condition,
the Feature payload carried one value per dimension but no record of what set
it, and the Site payload carried no activity at all.

The Observation row now carries the source's condition string, the source's
zone, and its note, so the Observation popup's Condition block can render the
tokens and the prose. The Feature's characteristics now report all seven
dimensions — a blank one carries an empty value rather than being dropped, so
the popup renders `—` instead of hiding the dimension — each with the
Observation that set it and when; the production state carries the same edge.

The Site gains an activity roll-up computed through its Features (a Site has
no observations of its own): the counts, the most recent observation and its
reading, and the most recent live treatment and its product. It is computed on
the single-Site detail path only, so hydrating a Site list does not pay for
three extra queries per row.
The Observation popup gains a Condition block — the source's tokens grouped
into water level, flow, quality, state and flags, with anything the vocabulary
does not name still shown under Other, plus the note — and its person line
falls back to the source's zone (`Zone 02`) where no technician was named. A
named person always wins; the zone never appears beside one.

The Feature popup renders all seven dimensions, a blank one as `—` rather than
hidden, each carrying *as of <date> · Observation #N* when an Observation set
it, and links to that Observation. The production state renders the same way.
Its "What it is" and "State" blocks replace the old Characteristics list.

The Site popup becomes the approved Sample 4 shape: the name is the label,
else the address, else `Site #N`; the fabric kind sits in the subtitle as
context; WHERE carries the address and owner; ON THIS SITE groups the Site's
Features by habitat class with counts (a habitat-less Feature counts as
unclassified); ACTIVITY rolls observations and treatments up through the
Site's Features with the last of each and the latest suppression end; FEATURES
keeps the list; NOTE shows the Site's own note where it has one. No compliance
state is computed or rendered — it is future work.

The Observations ledger's Technician column falls back to the zone where no
person was named, through a per-column fallback the generic cell renderer
applies; the column's sort and filter are untouched (they narrow on
creator_id, never on the displayed value).
The source states the Treatment -> Observation link itself (Delta's
treatment.insp_id, Placer's shared Activity Card iAId), so an
import-sourced Treatment may carry either a source-stated link or one the
same-day rule inferred. That is no longer derivable from the row, so
00457 adds treatment.observation_link_basis (source-stated / inferred /
authored), pins the precedence in the migration, resolves the
source-stated link on both districts, falls back to the same-day rule
where the source yields none, marks the authored links, and enforces the
basis present exactly when the link is. Idempotent: every statement
writes only rows whose stored value differs.

Generated by go-jet against a migrations-only database built from
db/migrations.
Both mirrors state the link the imports inferred. Delta: the treatment's
insp_id names the mosquitoinspection report, whose Observation is the one
the treatment followed. Placer: the treatment and an inspection share one
MapVision Activity Card (iAId), so the card names the Observation; where
the card carries several inspections, pickMapvisionCardObservation
chooses the one the migration's ordering picks.

The imports resolve that link first on create and re-import, fall back to
the same-day rule where the source yields none, and record the basis
(source-stated / inferred / authored) through TreatmentObservationSet. A
Nidus-authored link is never overwritten; an inferred link stands unless
the source now states a replacement.
The full-catchup backfill finished every month but one, and the month it lost
was invisible: VectorSurv rejects an application whose area is not positive with
a 422, the push returned that error, and the run stopped on the first offender —
so one treatment with a recorded area of zero cost the month its remaining seven
applications, and the caller's log filter hid the error line entirely.

A recorded zero is the same problem to the district as a missing area: there is
no area to file. It now composes to a skip with its own reason, so a bad record
is reported by name instead of aborting the run, and the other treatments in the
period are still sent.

Verified: November 2024 re-run files 6 and skips 1 ("treated area is not a
positive number"), where before it filed 18 and died.
The fabric labels a parcel Site with its APN, which reads as a number
rather than a name — 936 org-1 Sites today. A label now stands as the
popup title only when it contains a letter; otherwise the address does,
and Site #N only where there is no address either.
platform: an Observation's absence assertion makes a Feature inactive
Some checks failed
/ golint (push) Successful in 2m34s
/ pnpm-build (push) Successful in 1m54s
/ gotest (push) Failing after 2m35s
/ pnpm-test (push) Successful in 11s
4eef348776
The operator's ruling: `Pool Removed`, `False Pool` and `False Pool (Not a
source)` mean the Feature is gone. An Observation stating one now makes the
Feature inactive and the reason prominent, and the asserting Observation is
its provenance — named and linked like a classification dimension's.

00458 adds `feature.inactive_reason` (the token) and
`feature.inactive_observation_id` (the asserting Observation). `feature.active`
stays the effective flag the map hides by; the override records that an
Observation, not the source, is what made the Feature inactive.

Reconciliation with the import's own `active` flag: the import remains the base
writer of `active`, and an Observation's assertion is the override. An absence
assertion sets `active = false` and records the token and Observation; the
determination is observation-governed and time-bound like the dimensions, so
the most recent asserting Observation wins — a later Observation stating the
Feature is present (water, or any other condition it records) releases the
override, clears the two columns and restores `active = true`. A Feature whose
source itself deactivated it carries no override, so nothing here touches it,
and the source's own standing is restored by its next import run.

`Entry Denied` is deliberately NOT part of this: it is an access constraint,
and deactivating a Feature because a technician was refused entry would be
wrong. It stays a condition flag, recorded on the Observation and changing no
Feature state. The migration header says so, so the omission is visible.

The backfill applies the same rule to the existing rows: 177 Features become
inactive, 144 of them by `Pool Removed`, 31 by `False Pool`, 1 by
`False Pool (Not a source)` and 1 by a lower-case `pool removed`; 42 Features
carrying an absence token were left active because a later Observation stated
they were present. `Entry Denied`'s 108 Features are untouched.
Add public.site_contact_source (skip_trace, county_assessor, direct, rmo,
virtual_inspection) and public.site_contact.source, backfilling the 992
legacy rows to county_assessor since the assessor's owner of record is
their only origin.

Add public.skip_trace_run (one row per Tracerfy run, with the verbatim
request/response and the lookup rung that produced the hit) and
public.skip_trace_person (every person every run returned, verbatim, the
durable history a refresh must never overwrite).

Regenerate go-jet models for 00459.
GET /api/search/address now returns site_id on situs rows so the MVP Core
workbench can fly to and open the Site popup. SitusSearchRow gains
ExternalID (county APN) and Region (situs source region) — the stable
site_parcel identity — and MunicipalAddressSearch resolves the org's Site
via SitesForParcel, setting site_id only when exactly one site owns the
parcel (a shared footprint stays nil). A resolution error is logged and
never blanks the address results.
MVP Core workbench gains the skip-trace surfaces.

- Control pane address search: debounced box over GET
  /api/search/address, results split into the situs and geocode tiers;
  selecting one flies the map to it (zoom 18), opens the Site popup when
  the result carries site_id, else drops a pin.
- Site popup CONTACTS block: the owner link (owner role, primary
  preferred, else first) plus the other contacts, each with a source
  chip, and the skip-trace action.
- New contact PopupNode kind: GET /api/contact/{id} renders the stored
  name/emails/phones, the raw tracerfy[] history per person and the site
  links with source chip and last-traced time.
- Skip trace button confirms the $0.10 (5 credits) cost, POSTs
  /api/site/{id}/skip-trace, refreshes the site's contacts and toasts
  hit/persons/credits; configured:false shows a plain toast.

Types: AddressSearchResult.site_id, SiteContactSummaryDTO + Site.contacts,
ContactDetailDTO/ContactDetailSiteDTO/SkipTracePersonDTO,
SkipTraceContactDTO, SkipTraceResultDTO.
The confirm no longer hardcodes a price. GET /api/site/{id}/skip-trace
(now also a GET) returns the configured provider's real per-hit cost;
its cost_display is what the confirm names, so Tracerfy reads $0.10 and
DataSkip $0.04.

- Adds SkipTraceConfigDTO.
- The Site popup loads the config alongside the site; a failed config
  fetch is swallowed, leaving the cost unknown rather than erroring.
- configured:false disables the skip-trace button with a provider-named
  tooltip; unknown config leaves it enabled. The POST's configured:false
  toast path stays as the safety net.
- Unknown config falls back to a generic credit warning in the confirm.
Add a vendor seam (skiptrace.Provider) and two implementations: Tracerfy
(APN or address, with free property-search pre-flight) and DataSkip
(address-only, one contact). The vendor comes from SKIPTRACE_PROVIDER
(default tracerfy); a missing key reports "not configured" rather than
failing startup.

POST /site/{id}/skip-trace walks APN -> normalized address -> geocoded
address, stopping at the first paid hit, and persists the run, its people,
and the refreshed comms.contact / site_contact(source='skip_trace') links in
one transaction. A re-run reuses a contact by normalized name, phone, or
email instead of duplicating it, and skip_trace_person keeps every prior
value. A free pre-flight before each paid lookup refines it and never fails
the request; the run's request JSON records the endpoint and pre-flight.

GET /site/{id}/skip-trace reports the configured vendor's per-hit price for
the confirm dialog. GET /contact/{id} returns the contact detail (sites,
last_traced_at, raw persons). A Site payload now carries its contacts with
role and provenance, each with a URI.

Also fix SiteContactInsert: its MutableColumns column list omitted the
composite primary key, so it inserted NULLs instead of the link identity.
Three integration fixes found reviewing the skip-trace slice:

- SiteContactInsert writes every column (its composite key needs it), so a
  caller that left Source unset sent "" to the site_contact_source enum and
  failed. Default an unset Source to the column's own 'direct'.
- The CSV Site importer's owner/resident links now carry source
  'county_assessor': the uploaded owner name is the assessor's owner of
  record, the same origin the 992 legacy rows were backfilled to.
- Skip-trace contacts were inserted with the zero time as created; stamp
  time.Now() like every other contact writer.
A county situs feed can publish a locality line with no ZIP (Placer's
"TRUCKEE CA"). parseSitusAddress only recognized a trailing "STATE ZIP",
so such a line was mis-parsed as street "TRUCKEE" + locality "CA". Add a
bare two-letter trailing state, matched against the USPS code set so a
two-letter street suffix ("ST", "RD") is never mistaken for a state.
Two importers wrote an address with the parts in the wrong places:

- mapvision_feature_import.go pushed MapVision's whole address into street
  and hardcoded the region to "Placer", the county name, where the field is
  the state; the mirror keeps the house number in vAddress1 and the street in
  vStreet. Split the number out of vAddress1, take the street from vStreet and
  write the state the single-tenant mirror serves (CA).
- fieldseeker_feature_import.go built the address from the situs row's
  structured columns, which a raw-only source (Placer) leaves empty, so the
  whole address_raw landed in street and the state/ZIP were never taken.
  Normalize the situs row in memory first, so address_raw is decomposed into
  number, street, unit, locality, state and ZIP the same way a structured row
  already is.
Adds an idempotent, component-only repair pass (platform/address_backfill.go)
for address rows written before the importer fixes or from a raw-only county
source. It fills a field only when the row leaves it empty, corrects a street
that is really the whole raw line, corrects a region holding a county name
instead of the state, and swaps a source whose region and postal columns are
reversed (Fresno). State and ZIP that no source states are filled by batch
geocoding against the free US Census geocoder (platform/censusgeocode, no key,
TIGER/Line-backed), the last rung of the parse -> source-columns -> Census
order.
Adds the recovery pass and the address-robustness CLI. A site the county holds
no situs address for is addressed from its own point, cheapest rung first: the
containing parcel's situs street; the one existing address point within
NearestMeters carrying a street (several candidates are ambiguous and skipped);
or, opt-in, the free US Census reverse geocoder, which names the city and state
only. Every write is idempotent and a dry run reports exactly what a run would
change.
The skip-trace control read as the popup's primary action at full width; it now sits at chip scale with the source chips it belongs to, still clickable but quiet.

The vendor was leaking to the user in three places: the contact payload named the field tracerfy, the config payload returned provider, and the not-configured message named it. None of that is the user's business - the source is conveyed by the Skip trace chip, and which vendor answered is an internal choice recorded per run on skip_trace_run (provider, lookup, hit, verbatim request and response, creator). The payload field is now history, the config no longer returns provider, and the message is neutral, so the provider underneath can be swapped without the surface changing.
Two corrections found while running the passes on real data:

- the Census batch request states no state (Tulare's feed omits it), so the
  matcher resolved same-named streets to other states — '122 W WREN AVE,
  VISALIA' matched a GA ZIP. Reject a match more than 50 km from the address's
  own point, which is within metres of the real address.
- site_org_address_unique allows one site per address, so recovering an
  address-less site with the parcel's situs address (already held by the
  parcel's site) was denied it. Copy the resolved components into a per-site
  row keyed recovered:<siteID> instead, and exclude those derived rows when
  looking for a neighbouring address so the pass cannot cite its own output.
Records the measured per-district address coverage, the three code paths that
lost the components, what the importer fixes and the component/Census/recovery
passes changed, and the free-source evaluation with the recommendation to ingest
the two county ArcGIS address-point layers.
Premium, airy and editorial, applied to the Site, Feature, Observation,
Treatment and Contact popups and the chrome they share: a named five-step
type scale, a four-pixel spacing rhythm with sections standing two steps
above their own rows, one neutral ramp plus one accent, one radius, border
and shadow family, and the provenance chips kept as semantic tokens.

Every headline block now carries an eyebrow label instead of a hairline
rule, every fact pair sits on one shared key column so values start on a
single vertical line, counts and dates take tabular numerals, an empty
state reads as its own kind of thing rather than a value, and the reading
an Observation is about takes the one size between body and title.

Controls clear the 24px hit floor and every control shows a focus ring.
Walking the graph now starts the new node at the top instead of leaving
the reader wherever the last node was scrolled.

No fact and no link changed.
platform: register the address-network job, link recovered addresses through the junction
Some checks failed
/ golint (push) Failing after 2m15s
/ pnpm-build (push) Successful in 1m53s
/ gotest (push) Failing after 2m39s
/ pnpm-test (push) Successful in 12s
ec60a1c736
The operator wants to compare an H3 grid against the point layers before committing to either, so this adds a second, opt-in rendering of all four MVP Core themes.

Each theme gets a query-time aggregation (service-area-{site,feature,observation,treatment}-h3) that groups the geometry that theme ALREADY draws with by h3_latlng_to_cell(geom, resolution) and emits the cell's hexagon plus its cell id and record count. Query time, not the legacy h3_aggregation table: there is no writer to go stale and nothing to sync. Each aggregation's geometry anchor is the point layer's own (site = ST_PointOnSurface of the Site geometry, feature and observation = feature.location, treatment = the treatment's own point), so a hexagon and the dots inside it describe one population and a cell's count equals the ledger's count for that cell.

The resolution is a CLIENT-owned tile parameter, not !ZOOM!: the workbench's ladder decides what shows at which zoom, distinct resolutions are distinct tile URLs, and the tuning stays live with no restart. Each aggregation gets its own map so a grid request carries only that theme's hexagons and the grid's own cost can be read off the wire.

The cell functions are the non-deprecated spellings. h3_lat_lng_to_cell raises one WARNING per call in the installed h3-pg 4.2.3 (measured: 24,235 notices for 24,235 rows), which a per-row aggregation would put on the wire and in the log; h3_latlng_to_cell emits none, and running these four layer bodies verbatim in psql produces no warnings at all.

The observation and treatment grids carry the same org and selection-token narrowing as their point layers, so a filtered ledger's grid counts exactly the filtered records. The (0, 0) placeholders are excluded exactly where the matching point layer excludes them.
The map's H3 grid is only useful if clicking a hexagon narrows the ledger to the records it counted, so the query spec grows an optional H3 cell: one hex index plus the resolution the grid was drawn at.

The cell is a geographic narrowing exactly like the drawn Area, not a column filter. It joins each table's own predicate and never propagates through the relation graph, and each table tests it against the same geometry its grid aggregates and its rows report: the Site's representative point, the Feature's location (which is also where an Observation is placed, since an observation is a record at a Feature), and the Treatment's own point. That is what makes the tile's cell test and the ledger's cell test the same test.

H3Cell.Validate is the 400 gate: fifteen lowercase hex characters, a resolution in 0-15, and a resolution that agrees with the one the index itself encodes in bits 52-55. It is a plain Go check with no round trip, so a malformed cell is refused before it can compare against nothing.

The integration test pins the promise against the tile's own SQL, transcribed from etc/tegola.toml: for all four themes it takes the densest cell the tile would emit, asserts the ledger's count for it equals the tile's count_, and re-computes each returned row's cell from the row's own reported location with uber/h3 - a second H3 implementation - so Postgres h3_postgis and the client's library are pinned to mean the same hexagon by the same index.
The grid's resolution and whether the grid or the points are drawn are decisions the map and the control pane must agree on, so they live in one pure module both read, and the tunables persist where the other map preferences do.

mvpCoreH3.ts owns the resolution-by-zoom ladder (the highest rung the zoom has reached), the drill rule (one resolution finer than a clicked cell, stopping at H3's finest rather than wrapping), the draw-mode decision (points only, grid only, or both with an optional zoom at which the points take over, and 'never' as no switch at all), and the lenient parse of a stored value. It imports no Vue and no MapLibre, so all of it is unit-testable without a browser.

The preferences are mode, ladder and point-switch zoom, stored under mvp-core.h3 in localStorage next to the layer checkboxes: set once, expected to stick. normalizeH3Prefs sorts and clamps the ladder, re-adds a zoom-0 rung, and replaces an unknown mode or unreadable switch zoom, so a hand-edited or half-written value can never produce a tile URL Postgres refuses.

The analysis parse now also carries the drilled cell, so a reload keeps the ledger narrowed to the hexagon the operator clicked; a malformed cell discards the stored analysis, as any other unreadable field does, rather than restoring a spec that would fail its first query.
The grid the tegola maps serve is now reachable from the workbench: four H3 tile sources, one per theme, and the control-pane block that decides what they show.

The four layers sit between the Site fabric and the Features source, because 'Both' mode is explicitly the grid UNDER the points: every point layer keeps its meaning above the grid, and the fabric still reads through the low end of the ramp. A cell carries magnitude and nothing else, so the ramp is one achromatic hue whose opacity rises with count_ - it can never be mistaken for one of the data layers' hues.

The mode governs the whole map, not just the grid: in 'H3 grid' mode every point layer is hidden too, which is what makes the comparison like-for-like. Each theme's own control-pane row still governs both of its renders.

Clicking a hexagon is the drill. The cell goes into the shared spec, so the ledger narrows to exactly the records that hexagon counted, the filter composes with every other clause, shows as filters-active, and is dropped from the control pane's Clear; the map is fitted to the hexagon's own corners (unprojected from the rendered geometry) and, because the tile resolution is the drilled cell's next-finer resolution while a cell is selected, the grid steps one resolution finer in the same gesture. The zoom the ladder reads is the SETTLED camera, so a zoom animation does not re-cut the tile URL on every frame.

The H3 tile parameters carry the resolution the ladder (or the drill) chose, and the observation and treatment grids also carry their layer's selection token, so a filtered ledger's grid counts exactly the filtered records.
Two defects the first browser pass found, both about a resolution that was assumed rather than read.

The drill filtered the ledger by the resolution the ladder currently named, but a tile drawn a moment earlier can still be the one under the pointer: the first click sent an r9 resolution for an r7 cell, which the server rightly refused as a contradiction. The click now reads the resolution the cell index itself encodes (bits 52-55, exposed as h3ResolutionOfCell) so the hexagon the operator actually clicked is the authority, and the ladder only decides the grid.

The ladder also read the SETTLED zoom, so a zoom animation fetched its destination's tiles from the old resolution and the grid then drew one zoom step stale - observed as r7 hexagons at z10.2 where the ladder said r9. It now reads the live zoom; because the ladder is a step function, that re-cuts the tile URL only where a rung is crossed, not on every frame.
Captures the evidence behind the operator's brief for dense operational surfaces — "neat, tight, clear, so I know what is associated with what, very readable and findable, and the less scrolling the better" — after a pass that restyled the MVP Core popups as premium/airy and made a busy Site scroll nearly three screenfuls.

What it records: decorative whitespace is a cost paid in scrolling, and attention collapses below the second screenful (NN/g 2018: 57% of viewing time above the fold, 74% in the first two). Tufte's data-ink ratio is a prescriptive argument whose one readable experiment found no comprehension benefit, and it is now openly contested. Grouping is the best-evidenced lever — Loman 1986 found grouped layouts beat ungrouped at identical density, 2.80 errors against 4.33. The focus of attention holds about four chunks (Cowan 2001). Labels are information scent, and density is only affordable with strong ones (Pirolli et al. 2001). Click-to-reveal is the wrong tool for frequent expert tasks (Nielsen 2006; Cockburn et al. 2014).

It states plainly which of its own rules are inference rather than evidence — the label/value alignment rules especially, since no controlled study of alignment inside dense data panels was found — and records the airy pass as a worked example of the failure so it is not repeated.

The acceptance checks are measurable on purpose: content height against the box the record actually gets, scroll-screens for the heaviest record, visible top-level group count, one label column and one value rail measured in the live DOM, tabular numerals, computed contrast, and hit targets.
The backend already serves ./static at /static/ (static/static.go, from the source tree), but the shim proxied only /api, /maps and the vite-mirrored prefixes — so /static/* fell through to the SPA fallback and returned the app shell. That made the source tree unreachable from the browser.

Consequence it fixes: design previews published under the SPA build output (static/gen/sync) were destroyed by every 'pnpm build-sync', which empties that directory. An artifact can now live in static/preview/ and survive a rebuild.

static/preview/ is gitignored: it is locally published artifact storage, not source.
The selection token is an opaque UUID the server mints and the client echoes into the tile URL, where the provider SQL cast it directly with ::uuid. A token that was not a canonical UUID therefore raised SQLSTATE 22P02 and the tile endpoint answered HTTP 500 — on all four token-carrying maps (treatment, observation, and their two H3 grids). A well-formed but unknown token already returned an empty tile at 200, so the two cases were inconsistent and only the malformed one was a server error.

All four params now cast the parameter to text and guard the uuid cast with a canonical-UUID regex, yielding NULL when the shape is wrong: EXISTS is then false and the tile is empty at 200. A valid token's predicate is byte-identical.

Proven by running the fixed predicate directly (tegola is not restarted by this commit — that is a deploy step): malformed, empty and truncated tokens return 0 rows with no error, an unknown well-formed uuid returns 0 rows, a real token returns its own 94,435 records, and an absent token returns the whole organisation layer. The transcribed predicates in the integration tests were updated to the guarded SQL and TestSelectionTokenShapeGuardIntegration covers the four shapes plus the valid case.
The premium-and-airy pass spread a busy Site over three screenfuls: 1,089px
of content in the ~380px box the popup gets, on a panel whose whole job is to
be read. This is the density pass the operator asked for, measured against
doc/ui-design-guidance.md.

Scale. The type ladder drops one step (body 15px to 13px, title 22px to 15px)
and the spacing rhythm is reset to 2px between a group's own rows and 14px
between groups, so the grouping is carried by a four-times ratio rather than
by a divider drawn on top of a gap.

Grouping. The Site's separate ON THIS SITE and FEATURES blocks are one Features
group: a class roll-up line over the list it summarises, folded past five with
the count still in the group's own label. The Feature's Type moves into the
subtitle where the other popups carry identity, and its blank dimensions become
one Not recorded line instead of five em-dash rows. The Treatment's separate
Product and Application blocks merge. Every popup now presents three to five
named groups; none exceeds five.

Content. The address is printed once: Where renders it only when the title is
not already the address, and otherwise starts at what it adds. A contact's
several roles are one row with both role chips and its source, read from the
Site and from the Contact's own sites alike. A Feature's dimension values render
the ontology's names, not the wire tokens.

Long lists fold behind one click with their count always visible; no field is
folded. Walking the graph still starts the new node at the top.
The operator ruled that r9 is the coarsest cell the grid may ever draw.
Add H3_LADDER_MIN_RESOLUTION and clampLadderResolution, floor the default
ladder, h3ResolutionForZoom and normalizeH3Prefs at r9, and collapse a
legacy r7/r8 ladder the floor rewrote into a single r9 rung. The drill and
h3ResolutionOfCell keep H3's own 0-15 range.

Add pinLadderRung: upsert a rung at a rounded zoom carrying the resolution
the ladder gives that zoom, so the control can materialize the implicit
mapping for the operator to edit.
Add a Pin control to the Resolution-by-zoom group whose label carries the
live zoom and the rounded zoom it will write (Pin z9 (at 8.9)), so the
operator sees what it writes before clicking. It upserts a rung at that
zoom through pinLadderRung.

Floor the rung resolution input's min at H3_LADDER_MIN_RESOLUTION and clamp
addRung through the ladder floor; setRung inherits the floor from
normalizeH3Prefs.
A small always-visible chip for the map's live zoom: z8.9 alone, or
z8.9 · r9 when the grid draws at that zoom, so the ladder's zoom to
resolution mapping reads at a glance. Tabular numerals, no state of its
own, and placed top-right clear of the control pane, the loading overlay
and MapLibre's attribution.
Live check found the chip hidden behind the 48px topbar (z-index 20), which
overlays the map's top edge. Move it to top:60px so it sits in the visible
map, and record why.
The label->value channel was 120px of dead space in front of every short
label: one fixed key column sized for a timestamp, paid for by every fact.

Sections are kept exactly as they were (eyebrow + gap, no per-section rule).
Inside a section the facts are now a table: the section is one grid whose
first column is max-content over that section's own rows, so the label
column is as wide as its own longest label and no wider, and every row
subgrids onto it. A 6px gutter replaces the 8px column gap, and a faint
hairline between rows does the binding instead of the distance.

Measured over the seven-record set (41 fact rows): the label->value gap
falls from p50 66.4px / max 115.3px to p50 12.1px / max 56px; the value
rail moves from x=141 in every popup to one rail per section (50-115px).
Feature 15 obs + 3 trt: 718px -> 595px (1.90 -> 1.57 screens). Site 78:
566px -> 501px (1.50 -> 1.33). Every popup but the two heaviest now fits
inside 1.5 screens of the 380px panel.

Six labels renamed for length so the column stays narrow (Last observed ->
Observed, Last treated -> Treated, Suppression ends -> Suppress until,
Amount applied -> Amount, Area treated -> Area, Certification -> Cert.,
Last traced -> Traced); no fact, dimension name or count changed.

Also: the close control leaves the flow into the panel corner so a root
popup loses its empty 24px head row; the fold control moves into the
section eyebrow beside the count it acts on; a dimension's provenance line
takes the whole row so it stops wrapping.
The H3 grid workstream is stopping, so the mode switch, the four hexagon
tile layers, the ladder-driven resolution and the click-to-drill path all
come off the map. Every point layer's visibility returns to its own row
(or, for the filtered Feature highlight, to no layout gate at all).

The live zoom readout stays, now showing the plain `z12.9` chip: its
resolution and gridOn props are gone, and the mount passes only :zoom.
The ladder, mode, drill-resolution and prefs parsing go with the grid; the
layer checkboxes and due-soon horizon are the whole of the device
preferences now.
The drilled-cell state, its two store actions and the MvpH3Cell type were
the grid's only hold on the shared spec; with the grid gone they are dead
weight. The server-side spec.h3 predicate is untouched.
What the grid was, the commits that built it, the tegola maps and Go
predicate that stay in the tree, the drill unproject bug and its correct
boundsFromGeoJSON body, the tile-cost numbers, the features-highlight
gating gotcha, and a git-checkout restore recipe.
The observation filter narrowed only the observation table. Its neighbours
were untouched however the observations were filtered, so the Feature, Site
and Treatment counts on the other tabs never moved — the one direction of the
keep-set graph that was missing. The graph is site ← feature and, at the
feature, treatment and observation; a constraint on a table narrows every
neighbour it links to. Observation was bolted on as a leaf: keepObservation
narrowed observations by feature/site/treatment constraints, and nothing
narrowed the others by an observation constraint.

Close the loop. kFeature gains the observation intersection (the features a
kept observation was recorded at), kSite's intersection now also fires for an
observation constraint, and keepTreatment intersects by the direct link
treatment.observation_id. The treatment link is the direct FK rather than the
Feature path because that id is the observation a treatment names: per the
operator's rule, only rows linked to active observations survive, so a
treatment whose link the import never recovered (observation_id NULL — 70% of
the org's treatments) drops under an observation filter while it survives a
treatment-only filter. keepObservation omits the observation intersection,
which own(observation) already states.

The map selections and the over-rate set read the same keep predicates, so
they narrow with the tables; the map's coarse Site/Feature fallback is
unchanged for a cross-table constraint.

Testing:
  POSTGRES_DSN="postgresql://?host=/var/run/postgresql&dbname=nidus-sync-bouncy_warthog&sslmode=disable" \
    nix develop -c go test -tags integration ./platform/mvp_core/ -run TestObservationCascade -v
The new test inserts a graph (S1→F1 with O1/O2, S2→F2; T1 names O1, T2 names
none) and pins the cascade: an observation filter alone keeps {F1}/{S1}/{T1},
the reading filter narrows each further, and T2 drops under the observation
filter but survives a product-only filter. It fails on the pre-fix resolver.
Every popup body is now one two-track grid: a single label column and a
single value rail shared by fact rows, list rows, contact rows and history
rows, so no section can introduce a column of its own and every label starts
on one left edge with every value on one rail. Measured: one label left edge
and one value rail per popup, against 2-4 value rails before.

Labels are left-aligned in that one column, sized to the widest left-hand
cell the popup renders (105.5px for a Site, 43.4-156.5px across the five
popup kinds), so the label->value gap is bounded by that one cell and never
moves between sections.

Vertical rhythm: one row pitch for every single-line row type (24.8-25.9px,
line-height 1.45, 3px of row padding), one section gap (20px, 38px between
groups once the eyebrow row is counted). Height is reported, not gated.

Section headers are table dividers now: the letter-spaced uppercase eyebrow
plus a hairline running from it to the row's right edge, so a header stops
reading as a label abandoned on an empty line.

Other consequences of the one grid:
- A list row's key (a Feature number, a date) takes the label column and its
  qualifying cell (type, reading, product) starts on the value rail; nothing
  is right-aligned on a rail of its own any more.
- A history entry draws no card: its rows subgrid straight onto the popup's
  table, so its fields line up with the section above it.
- Links get their hit target from a pseudo-element rather than padding, so a
  link never inflates the row it sits in; every control still measures at
  least 24x24px.

Facts, values, counts and dimension names are unchanged (innerText identical
after whitespace normalisation for all seven records).
A list row's first cell was placed in the label column, so the gutter was
sized by a list identifier — a formatted timestamp (~156px in the Feature
popup) or a feature number (~105px in the Site popup) — rather than by the
field labels, leaving a ~130px dead channel in front of every short label.
The gutter is now sized by labels alone, as variant B requires; a list row
starts on the value rail and lays its own two cells out inside it.
With list rows out of the label gutter (63c05a268) a row's key column is
sized per row, and a formatted date at body size left the reading beside it
too little room: the readings wrapped to a second line in 4 of 7 observation
rows in Feature 30210 and 6 of 8 in Feature 29441, which breaks the popup's
one row pitch (40.7px rows among 25.8px ones, +45px and +95px of height).

The key is now set at label size (12px, the same size as the field labels —
a list key is a key), and the row carries a min-height of one body line plus
the row's own air so the pitch is identical whatever the key's type size is.
Wrapped rows: 0 of 7 in Feature 30210, 1 of 8 in Feature 29441; those popups
are 68px and 15px shorter. The five rows that still wrap in Feature 29441 are
arithmetic — a 96.4px gutter (Organic content) leaves 225.6px for a 136.8px
date and a 110.6px reading — and are reported, not hidden.

Every control still measures at least 24x24px; labels wrapping 0.
Two defects in the shipped list rows.

1. Each row sized its own key column, so the readings inside one list started
   at up to 22.9px different x. A list now owns ONE key column — sized to that
   list's own widest key — and its rows subgrid onto it, so every reading in a
   list starts on the same x. Measured raggedness is now 0.0px in every list.

2. A localized datetime at body size plus a reading does not fit beside a
   label-sized gutter, so readings wrapped to a second line (4 of 7 rows in
   Feature 30210, 6 of 8 in Feature 29441), breaking the one row pitch. List
   rows now show the same instant compactly — two-digit year, no seconds, e.g.
   6/12/2026, 7:18:31 PM -> 6/12/26, 7:18 PM — with the ISO instant kept as the
   element's title and the full localized time still on the record's own popup.
   Wrapped list rows: 0 in all nine lists across the seven records, and the two
   Feature popups are 45px and 100px shorter for it.

Every list row keeps the popup's one pitch (min-height), every label still
sits on one line, every control still measures at least 24x24px.
The popup's alignment was built on CSS subgrid: the popup was the grid and
sections and rows subgridded onto it. Where subgrid is unsupported the
declaration is dropped silently, so every row collapsed to a single column
and each label stacked above its own value — the whole record mangled, which
is what the operator saw on their browser while our Chromium captures looked
correct.

Every fact row is now its own two-column grid and they share one measured
label width (--pp-key-col, set from the labels' ink by measurePopupColumns,
re-measured on every record, detail or fold change and after fonts load).
The same variable indents the row types that start on the rail — a person
row, a history entry, a standalone value, a list — and each list measures its
own key column (--pp-list-key-col) so its readings share one x.

Also: the popup now opens on whichever side of the anchor has more room.
Anchoring above a click in the upper half of the map capped the box at
(y - topbar) px, a two-row scroll window a record was crammed into.
The popup no longer needs a shared grid, so `display: contents` on a
contact's history entry bought nothing and Safari's handling of that value is
the buggiest of any engine's. `overflow-wrap: anywhere` gains a
`break-word` fallback for Safari versions that predate it.
The floating popup had a known width but never a known box: its height was
capped by the distance from the click to the viewport edge (120-380px), it
flipped sides, it tracked the click with an arrow and clamped itself to the
viewport. Every layout decision made against it was made against a box the app
rarely gives, and three design passes were judged broken because of it.

It is now a drawer hanging off the map's left edge, just right of the pane,
with a width and a height that are constants of the viewport:
--pane-w clamp(260px, 22vw, 340px) and --drawer-w clamp(320px, 28vw, 440px),
with the drawer capped so the map always keeps 280px. Nothing anchors to the
click any more, so popupStyle, popupSpaceAbove/Below, popupBelow, popupPoint,
updatePopupPoint, the arrow and the POPUP_* constants are all gone.

The drawer's head sticks to the top of its own scroll, so the Back walk and the
Close control stay reachable however long the record is. Selecting a record pans
it with map padding set to the drawer's right edge, so the record is centred in
the area the drawer leaves visible rather than underneath it, and clearing the
selection drops the padding again.
The pane was a floating card 12px in from the edge that shrank to its own
content, so there was no actual sidebar. It is now a column down the left edge,
flush, from below the topbar to the bottom, at clamp(260px, 22vw, 340px) with
its own scroll — the legend and the tools, which is all it holds.

The map's box is inset to the rail's right edge so what it fits and where a
projection lands is the region the operator can actually see; the pane and the
drawer stay children of the full-viewport container so their coordinates are
measured against the window. The inset needs !important because the map
component sets width/height: 100% inline, which would otherwise leave the map
overhanging the rail.

The drawer hangs off the rail's right edge at --drawer-left.
The natural-language ask path must describe the closed vocabulary to the
model, and a hand copy would drift from what the resolver accepts. Export
the registry — every table, column, kind, allowed op, static enum members,
and which per-district list feeds an id-backed column — as the single
source of truth, with a test pinning the id-backed columns.
The details were a panel floating over the map, beside the rail. They belong in
the rail, under the tools, and that is where they are now.

- The rail is a full-height column down the left of the window at
  clamp(260px, 25vw, 380px) — the tools at the top at their own height, the
  selected record's details filling everything under them, with an empty state
  when nothing is selected.
- The toolbar sits beside the rail instead of across it, so the rail runs from
  the top of the screen to the bottom.
- The map container is inset to the rail's right edge, so the map and the ledger
  own the 3/4 beside it and the ledger can never cover the rail. The maplibre
  box goes back to 100% of its container, which removes the !important override
  the previous shape needed.
- The pan padding for the floating panel goes: the map is no longer partly
  covered by the details, so a selected record is centred in the whole map.
The ask path needs a model that fills in a supplied JSON Schema, on a
local server without an API key. Add GenerateToolJSON with an
LLM_PROVIDER-selected backend (ollama by default, plus llamacpp,
openaicompatible and openai), render the schema into the prompt and
extract the object tolerantly, and wire it at startup.
Requesting a directory under /static/ opened the directory, and ServeContent
then took its Content-Length from the directory's own size and failed on the
first read. The body was truncated, so the proxy in front of this server
reported a 502 Bad Gateway rather than the 404 the request deserved, and an
index page placed in such a directory was never reachable at all.

A directory now resolves to its own index.html when it has one, and answers 404
when it does not. Static files are unchanged.
An office user types a question and the server turns it into the same
MvpSpec the grid authors: the model fills in the exported vocabulary and
the per-district name-to-id lists, Validate gates it unchanged, and
ResolveWithUnits runs it org-scoped and read-only. The model never emits
SQL. An unanswerable question comes back answered=false with a sentence,
never a 500. A stub generator covers the wiring, and an integration test
resolves a grounded question against org 1 and matches an independent
SQL restatement.
Sections are pale regions now, tinted by what the section is about — the place,
the people, the features, the field readings, the work done, the activity — so
a colour means the same thing in every record. The tint is the section's own
grouping mechanism (a common region); the hairline between rows stays as the
row's (connectedness). They work at different levels, and they replace a third
signal that said the same thing twice: a rule drawn off each eyebrow, which is
gone.

A family that repeats takes a second tint by alternate, so two neighbouring
sections never share a background — the Observation popup opens with Recorded,
Condition and Inspection, and the Site popup ends with Activity and Note.

The row hairline is now translucent rather than a fixed grey, so it reads the
same on every tint; the eyebrow is a step darker so it clears 4.5:1 on the
strongest of them (measured 5.26:1 minimum). --pp-shadow and --pp-section-gap
are gone with the card chrome they served.
The rail gains a question box: the store posts the question (with the
client's zone offset so relative dates mean the operator's days), applies
the generated spec in place of the ledger's filter, takes the page the
server already resolved, and the box echoes what it understood through
the same humanizer the bulk-revisit confirm uses. An unanswerable
question leaves the view untouched and shows the explanation.
The polygon fill shows the Site a record belongs to, which is a large shape and
does not say which point was picked; the record's own anchor was left to be
found among its neighbours. It now carries a green hover: a soft glow, a ring
and a small core, drawn above every data layer so nothing can hide it, and built
from the popup's anchor so it sits on the record the rail is describing and
empties as soon as the selection does.

A glow rather than another filled dot, because the treatment layer is already
green: the ring's green is a step deeper (#16a34a against the treatment's
#22c55e) so a selected point separates from a treatment at a glance.
A date range's From/To were UTC days, so "today" ended at 17:00 the previous
afternoon for a California district. That is not a rounding error: 27% of the
observations and 22% of the treatments fall on a different local day than UTC
day, so a UTC-day filter drops most of the afternoon and evening the operator
asked for.

The clause now carries tz_offset_minutes (the client zone's offset east of UTC,
absent meaning UTC as before), and dayStart/dayEnd resolve the day in that zone.
An offset no zone has is a 400 rather than a silently shifted day.
GenerateToolJSON is the only contact with a model and runs against a
local server, so pin the two things no other test can: that the schema
and prompt reach the provider, and that a fenced, prose-wrapped reply
still yields the object.
A filter appeared broken because the query 400'd with `sort on unknown feature
column "observed_at"`: the store keeps ONE sort for every table, and the server
rejects a sort on a column the table does not carry, so sorting the Observations
ledger and switching to Features failed the whole query. The sort is now sent
only when the active table's registry says the column is sortable; it stays in
the ref, so switching back restores it.

The date filter row gains Today and This week for the treatment start and the
observation time. Both carry the viewer's zone, so the days are the viewer's
days, and "This week" runs Sunday to today.
The endpoint is the surface an operator actually reaches, so exercise it
whole against real org-1 data: request decode, the empty-question 400,
the district vocabulary in the prompt, the provider call, Validate, the
resolve and the response, against a stub server standing in for the
model and an independent SQL restatement of the answer.
mvp-core: replace the ledger's search box with a filter summary
Some checks failed
/ golint (push) Failing after 2m13s
/ pnpm-build (push) Successful in 1m53s
/ gotest (push) Failing after 2m42s
/ pnpm-test (push) Successful in 12s
057bf25921
Every clause narrowing the table is now a chip outside the collapsible filter
body: it is visible with the body collapsed, it names the table when the clause
was set elsewhere (cross-table narrowing is intended, and a count can only say
the number shrank, never why), and its x drops that one clause.

The search box that shared that space is gone. Text search moves to the rail,
which will cover every table and the address hierarchy at once, so a search that
was restored from an earlier visit is dropped rather than left narrowing the
counts with nothing on screen to explain or undo it; Clear filters also clears
the search, which is what its label already promised.
Adds the district pesticide inventory schema as three sequential goose
migrations after 00466, and regenerates go-jet from the migrated database
(never hand-edited).

00467 gives a district product a kind (simple or mix) and the recipe table
for a custom mix: component rows stated in parts of the whole, each either a
catalog product (carrying the mix's EPA/DPR resolution) or a named inert
carrier (water) that is excluded from reporting. Stock keys on the shared
catalog identity, product_catalog.product_id, so the mix's components are the
identity a state report resolves to.

00468 adds the storage-location tree (any node holds stock), each catalog
product's base unit (weight or volume), and stock: an amount of one product
at one location with the method its amount is known by.

00469 adds container definitions and held containers with kept provenance,
the append-only movement ledger, inventory checks and their counted lines.

product_catalog already carries dpr_registration_number (00451); no DPR
schema change is needed.
The ask path's fallback needs a short, schema-valid answer rather than a
hopefully-JSON one: ToolJSONRequest gains DecodeAs, and the provider turns the
struct into a decoding grammar.
The toolbar's search moved to the rail, so the rail's one box now runs the
ledger's own per-table search alongside the address search: one query per
table with the typed term in that table's search slot and no filters, grouped
and labelled by table above the address tiers, five rows per table with the
rest counted. Picking a record flies to its point and opens its details
through the map click's own node graph. It is a find, not a filter: nothing
writes store.spec.search, so the ledger never narrows while the operator
searches. A table whose query fails stands as its own failure line rather than
vanishing.
A product's curated control_class is frequently NULL, but every treatment the
district recorded names the method it used. TreatmentMethodsByProduct reads
those counts so the ask path can state what a product is used as from the
district's own rows.
The operator refined the recipe shape: a person prepares a mix by measuring real
amounts, so mix_recipe_component keeps the measured amount and its unit and the
component's share (amount / SUM(amount)) is derived at read rather than stored.
Mixed units across components are tolerated because an applied quantity scales
every component by the same fraction and amounts are never summed across a
dimension.

The column type is DOUBLE PRECISION rather than NUMERIC, following the repo's own
precedent (00181_planet_numeric_to_float8 re-types numeric to float8) and the
amount convention of treatment/inventory_stock. Jet regenerated; no hand edits.
The ask box turned a question over to a 7b model that re-derived the district's
vocabulary on every call: a 2,491-token prompt and ~190 output tokens, 27s warm
on CPU. The model is not needed for most questions.

The path now reads the question itself first, against the district's own rows:

- a per-org lexicon built from the district's products, interventions and
  members, each product's observed method derived from its own treatments, plus
  the frozen registry's vocabularies and a curated set of control-class words;
- a deterministic date grammar for 'March to June 2026', 'last month', 'last 30
  days' and the rest, anchored to the client's zone;
- span extraction that assembles the spec and runs it through the same
  Validate and ResolveWithUnits the grid uses — never SQL.

Ambiguity is surfaced with its candidates, never guessed, and questions outside
the four ledgers are refused in words. The model is the fallback for one case
only — a question that names no record type but does name a person, who is both
an applicator and a technician — and then it picks from a closed list with a
schema-constrained reply, temperature 0.

Measured warm on org 124: the deterministic reading is 9.5ms p100 over the
phrasing set, the fallback model call 557ms p100, and the whole Ask call stays
inside its two-second budget. The grounded questions resolve to 131 and 3792.
A recipe now has a one-to-one header (mix_recipe, keyed on the mix product)
stating the prepared batch — prepared_amount + prepared_unit — which is what
makes an applied quantity well-defined when a recipe mixes weight and volume:
the applied fraction is applied / prepared, both read in the prepared unit's
dimension, and every component scales by that same fraction and is never summed
across a dimension.

mix_recipe_component now hangs off the header (mix_recipe_product_id ->
mix_recipe.product_id) rather than off the product directly. DOUBLE PRECISION
follows the repo's 00181 numeric->float8 precedent. Jet regenerated.
The ledger ask no longer hands the question to a model: it reads the district's
own lexicon first and calls the model only to break a genuine ambiguity about
which records a question is about.
"How many BVA treatments in 2026?" was refused with "the helper finds
records; it does not total them", which reads as a dead end for a question
the ledger can already answer. A count IS a record selection here: the
response carries the exact row total, so returning the records that match
gives the operator both the number and the rows behind it.

So the words that ask for a count ("how many", "how much", "number of",
"count of", "total", "sum", "average", "mean", "trend", "percent") are
stripped from the question before matching instead of refusing it — stripped
because a stray "many" must never be read as a filter on a column, and
because the words that carry the selection ("BVA", "2026") sit beside them.
Only a question about records outside the four ledgers is still refused.

This also fixes a test-isolation gap the change exposed: ask reports
unreachable-model-in-words stubs askVocabLoader, but the per-org lexicon
memo built from the real loader was answering first. resetAskCaches() drops
the memoised lexicons and extractions, and that test calls it around its
stub so neither direction leaks between tests.

Grounded on org 1: "How many BVA treatments in 2026" now returns the 2,619
treatments with product BVA-2 in 2026, the count asserted against an
independent SQL restatement. Warm p100 over the phrasing set stays inside
the two-second budget (1.56s).
Extends doc/nidus/mvp/inventory.md in place, additively: a Custom mixing
section stating what a mix is (a district-scoped Product with its own name,
composed of components, never a catalog entry), the recipe's prepared batch and
each component's measured amount and unit, the share derived rather than
stored, the applied fraction (applied / prepared) that scales every component
so weight and volume components coexist without ever being summed, the inert
carrier row excluded from reporting, and the state filing that resolves a mix
to each non-inert component's EPA and DPR registration numbers and decomposed
quantity rather than reporting the mix as a unit.

Also attaches the mix to the chain, the kit story, the office Surfaces table
and the Units rule. No preceding line is rewritten; only the front-matter
updated date moves.
Adds the go-jet builders for the inventory module, one file per aggregate:
the location tree, each catalog product's unit, stock and its append-only
movement ledger, container definitions and held containers, inventory checks
and their counted lines, and mix recipes (header plus components).

Also projects product.product_kind through the district product reads so a mix
is distinguishable from a simple product. No raw SQL: every read and write is
a go-jet statement.
Business logic for the inventory module. Receiving upserts stock and writes
the receive movement in one transaction; subdividing decrements the source
container and its stock, creates the target containers with kept provenance,
and writes both the subdivide and the waste movements; an inventory count
reads the ledger's expected amount, and on a discrepancy writes the adjustment
movement and sets stock to what was counted.

A mix is a district product with a one-to-one recipe header (the prepared
batch) and component rows carrying their own measured amount and unit; an
applied quantity resolves to each non-inert component's share, quantity, EPA
registration number and DPR registration number, and the mix is never reported
as a unit. A mix's own control class and residual are derived at read from its
components, never stored.

Adds one additive helper to platform/units: QuantityConversion, which converts
a quantity between two units of the same dimension and reports false rather
than guessing on an unknown unit or a dimension mismatch.
Transport handlers for every inventory surface, validating requests and
delegating to the platform layer: locations, units, stock on hand, receiving,
the movement ledger, container definitions and containers with subdivide,
inventory checks with their counted lines and reconciliation, and mix recipes
with the state-report resolution. Also surfaces the district product's
product_kind through the existing product resource.

The routes are registered without a named route where nothing builds a URI
from them, and the two writes that answer with a body (a unit, a mix) use the
POST wrapper under PUT because the PUT wrapper can only answer 204.
A custom mix has no catalog link of its own — its registration lives on its
components and its recipe is its rate — so the label-rate check is skipped
rather than reported as unknown. The resolver now reads the district's mix
product ids once per request and verdictFor tells the three label-less cases
apart: no product at all, a mix, and a catalog entry stating no rate, all
not_applicable; a district product simply not linked to the catalog stays
unknown, because there the label exists but cannot be read.

The integration fixture gains a mix product, its recipe header and components,
and a treatment applying it, pinning not_applicable on both the page cell and
the org-wide partition.
A state filing asks per product, and a mix carries no registration of its own,
so a mix treatment now expands into one report item per non-inert component:
the component's catalog identity, its EPA and DPR registration numbers, and
its quantity — the component's own amount scaled by the applied fraction
(applied over the recipe's prepared amount, converted into the prepared unit's
dimension). The water carrier is omitted, and a mix never files as a unit.

The treatment read gains the product kind, the DPR number and each mix's
recipe; the composer buckets decomposed lines on the component's catalog
identity and carries both registration numbers onto the item.
Adds the district inventory UI: stock on hand by product and location, receive,
the movement ledger, container definitions and containers with subdividing,
the location tree, inventory checks with their counted lines, reconciliation
read across checks and by direction, each product's unit, and mix recipes with
the state-report resolution showing each component's EPA and DPR registration
numbers.

Routes live under /_/inventory, the API types match the resource wire shapes,
errors surface through the toast notification component and timestamps render
through the shared Time components.
A non-empty observation search 500'd: the observation's Feature column is
featureTypeExpr(), a COALESCE whose first arm is a correlated subquery, and
go-jet serializes a SelectStatement without parentheses when it is nested in
another function's argument list. `LOWER(<derived>) LIKE …` therefore emitted
`COALESCE(SELECT …)` with the subquery unparenthesized, which Postgres rejects
(syntax error at or near "SELECT"). The match is now ILIKE (see likeContains):
the same case-insensitive substring match, with the derived expression
serialized as an operator operand, where go-jet parenthesizes it.

This pins the whole path against a self-identifying graph: the observation
search returns the fixture row, matched on the Site label, the Feature's
habitat class and the technician (case-insensitively); the site, feature and
treatment counts resolve under the same spec — they embed the observation
predicate because cross-table narrowing is intended; and each count agrees with
an independent restatement of the search text written in the original
`LOWER(…) LIKE …` spelling, so the match is proven to be the same one.
Three slices had each grown their own stock and movement writes while they were
built in parallel, so the same statement existed two and three times over. There
is now one builder per operation:

- movements: InventoryMovementInsert is the single append (the container and
  check copies are gone);
- the stock read: InventoryStockForLocationProduct (the check copy is gone);
- the absolute stock write: InventoryStockUpsert, which a count uses so a line
  the ledger never had is created rather than left untracked (the update-only
  stock setter and the check copy are gone);
- the bulk draw-down is InventoryStockDrawDown, moved to the stock builders'
  own file; it is a delta on an existing line and stays distinct from the
  upsert's absolute amount.

Builders nothing called are deleted rather than left unreachable: ContainerRetire
and ContainersFromDefinition (the design's container surface is stating the
district's containers and subdividing them; it names no retirement action, so a
route would invent one), InventoryProductUnitFromSlug, and
InventoryMovementListForProductLocation.

Verified end to end against the worktree database: receive writes stock; a count
on an existing line moves it to the counted amount and appends the adjustment; a
count of a product the ledger has no line for creates the line; subdividing drops
the source container, draws the bulk ledger down by what was consumed, and
appends the subdivide and waste movements.
The PUR use-report item carries both epa_registration and dpr_registration_num, but the push still printed the DPR value under the label "EPA %s". Print both, each labeled, so a filed line reads as the two registrations it carries.
The inventory routes were flat siblings, so the section shell (Root.vue, which renders the surface tabs) never mounted and every surface but the one typed into the address bar was unreachable; the sidebar had no Inventory entry either. Nest the inventory routes under Root.vue, drop the shell's double page padding, and add the sidebar link.
Two things the filter row and its summary got wrong, both visible to the
operator.

The summary named ids. treatment.product, treatment.intervention and the two
creator columns hold the district's own ids, and their labels live in the
stores, not in the static column vocabulary — so a chip read "Product is 5".
The resolution now lives once, in useColumnOptions, and both the filter panel
and the summary go through it. The workbench also never loaded the intervention
list at all (only the Pesticide config screen did), so that filter's panel
offered no options; it loads beside products now.

The two date inputs and their two presets filled the whole 22px filter cell.
They are now behind one trigger that says what the range is — "Any", "30 Jul
2026", "30 Jul 2026 – 1 Aug 2026" — with a clear beside it, and the inputs and
presets in the panel. One date format serves the trigger, the chip and the
tests, from a fixed month table: toLocaleDateString would read differently in
two places in the same window, and differently again under another locale.
The observation search matched the grid's derived text — the Feature's
habitat type (a correlated subquery) and its Site label — and every count,
map id scan, over-rate scan and page in a request carries that clause.
Matching the derived expression left Postgres re-deriving the same sets and
scanning the whole Feature/Site/Address set in each of them: the counts alone
cost ~3.1s each and one observation query 28s on the org's live data.

The term is now resolved once per request (MvpObservationSearchMatchIDs) to
the ids an observation must carry to match — the Features whose derived type
or whose Site label contains it, and the users whose display name does — and
the clause is a membership test over them (MvpObservationSearchPredicate).
That is a ScalarArrayOpExpr the planner drives from observation.feature_id's
index, so every statement carrying the clause costs a constant few ms.

The type half is the same rule as featureTypeExpr, built as a set over the
same base tables and columns (the unsuperseded, non-null habitat_class row,
else 'pool' for a live feature_pool row whose Feature carries no such row);
the Site half reuses siteLabelExpr itself, so the label the search reads is
the label the grid shows. (feature_id, dimension, supersedes_id) is UNIQUE,
so a Feature's habitat chain has a single leaf and the set is exact.

The ids must exist before any predicate is built, like the applied-rate
verdict partition, so ResolveWithUnits and SetPlannedRevisit resolve them
first.

Measured on the live org data (term "Mooney"): the observation query
28.09s -> 0.50s, and each of the four counts ~3.1s -> ~0.01s. The regression
test pins both branches of the derived type (classified and pool-only), the
site label and the technician against an independent restatement of the
derivation, and pins that the clause carries no subquery, which is the slow
path this removes.
The observation's Feature column no longer routes through likeContains — its
search resolves the term to ids — so the helper's comment described a caller it
no longer has. It now records the actual reason the test is spelled ILIKE: col is
never wrapped in a function, because go-jet would emit a subquery-bearing
expression unparenthesized inside LOWER(…). No behaviour change.
The ask path ANDs the filters it builds, so two date ranges over the same
column always intersect. "between July and August of 2024" bound "July" to
the operator's own year and let the trailing "2024" fall out as a separate
bare-year endpoint, so the question ANDed 2026-07..08 with 2024-01..12 and
matched nothing.

The deterministic date grammar now reads the phrase the way the operator
writes it:

- endpointPattern binds a month's year across the connectors that separate
  them — "August 2024", "August, 2024", "August of 2024" — so "of" no longer
  splits the year off into a second endpoint.
- rangeSpans reads a run of month endpoints joined by "and"/a list comma, or
  after "between"/"from"/"in"/"for"/"during", as the one window from its first
  to its last month, but only when the months are forward-ordered within one
  year. "July and August of 2024" is therefore one span, while two months in
  two different years stay two periods.
- extractAsk guarantees at most one dateRange per table+column. When the
  question genuinely names more than one period — "in March and in June", or
  two different years — it is refused with the periods named in words rather
  than ANDed into an always-empty filter.

Verified against org 1: "How many Altosid WSPs were used between July and
August of 2024" now resolves to one dateRange 2024-07-01..2024-08-31 on
treatment.start_datetime with product values [1], and returns 269 — the
independent SQL restatement of that period, where before it returned no rows.
The product filter is asserted to be the single Altosid WSP, not a broad
match over the other Altosid names "WSPs" sits beside.
Every table's query cost ~1.25s whatever the page size (observation limit 1:
1.248s, limit 250: 1.252s) — a dozen statements awaited one after another, not
one heavy query: sampled mid-request, the page was 0.15s, count treatment
0.07s, count observation 0.04s. The web path hands the resolver the connection
pool (resource/mvp_core.go), so the reads that do not depend on each other can
overlap instead.

ResolveWithUnits now runs two waves. Wave one issues everything that does not
read another statement's result — the four counts, the over-rate scan and the
page — and wave two the four map selections (each needs its own count) and, for
a treatment page, the prescribed rates its rows carry. Each wave is joined
before the next begins and before returning on every path, and every call is
given the wave's context, so one failure cancels the rest; a wave's goroutines
are all accounted for by Wait, including on the error path.

The prescribed rates sit in the later wave for more than their dependency:
attachPrescribedRates shares the resolver's rate cache and basin set with the
over-rate scan, so those two must never be in flight at once. Splitting them
across the waves is what lets both memoise the single catalog read without a
lock — and the attachment then reuses the rates the scan already cached rather
than racing it to fetch the same ones. No other shared state is touched by two
goroutines at once: the counts fill their own field, the selections are
collected into distinct variables and only assembled into the response map after
the join, and the keep predicates are built before the waves start.

Whether statements may overlap is asked of the executor rather than assumed:
db.ConcurrentStatements reports true for a connection pool, which gives every
statement its own connection, and false for a Tx, which holds the one connection
it opened and must be awaited statement by statement. A wave that may not
overlap runs its statements one at a time. The response is the same either way,
which is what lets one code path serve the request (a pool) and the integration
tests (a transaction).

Verified on the live org-1 data: 17 specs (unfiltered per table at limit 250 and
1, an enum filter, a date range, a relation filter, the observation search, a
cross-table narrowing, and page 1) produce byte-identical JSON before and after;
and driven through the pool under -race the same specs agree with the
transaction path, with no race reported and no goroutine left behind. The only
two package failures are pre-existing and data-caused (the ledger test's
raw-SQL precondition picks a user that already has observations; the active
filter test carries a hardcoded inactive count).
Every natural-language ask the MVP Core ledger answers is worth keeping: the
table and the map are the answer, so there is no prose reply to store, but the
request and its resolution are what let an operator see what a question was
understood to mean and read a refusal back later.

ask_log is Gleipnir-internal and carries no foreign keys: tools/delete-org.sql
hard-deletes organization and user_ rows, so a reference would block it. The
organization and user names are denormalised alongside their ids for exactly
that reason — a row must stay readable after the rows it names are gone.

create-go-jet files regenerated with db/jet against a worktree database built
from the migrations; the diff is additive (the new model and table, plus the
UseSchema entry).
Instrumented in-process — a wrapper over the Ex the resolver is given, timing
every statement including its row streaming — one unfiltered observation request
at limit 250 was: 1.22s wall, 16 statements, 680ms of SQL, and it bounded on
overRateIDs (734ms), not on any count:

  overRateIDs   = rate-facts query 206ms + basins 18ms + a 519ms Go loop
  the Go loop   = groupRates per treatment 483ms + ConvertAmount 9ms

So the cost was not round trips and not the counts: it was folding the same
product labels into their basis groups once per treatment. groupRates is a pure
function of the scenarios, and the org's 116,429 treatments share 30 distinct
catalog products, so the fold now happens where the scenarios are already cached
— once per product — and resolveApplied takes the groups rather than rebuilding
them. verdictFor reads the same cache, so the page cell and the partition still
judge through one fold.

overRateIDs 734ms -> 242ms (the rate-facts query is now its bulk), and a whole
request through the pool 870ms -> 385ms. Resolution is unchanged: 18 specs
(unfiltered per table at limit 250 and 1, enum filters, a date range, a relation
filter, the observation search, cross-table narrowing, both applied-rate verdict
filters, and page 1) are byte-identical against the previous commit, with the
over-rate ids compared in their treatment-id order rather than as a set, and the
applied-rate integration tests still pin every verdict and rate.
The ask path already knows everything the log needs, so Ask keeps its signature
and grows an internal `ask` that also reports telemetry: whether the fallback
model was consulted and which model. AskLogged runs it, times it, and writes one
ask_log row best-effort — a logging failure is logged with zerolog and never
fails the ask, and the INSERT costs about 15 ms at p100 against a two-second
budget.

The handler now calls AskLogged with the operator's id and display name and the
organization's id and name, so a row stays readable after either is gone. The
integration test pins the three outcomes: a grounded question writes answered
with its table, total and spec; an out-of-scope question writes a refusal with
no spec and its words; and the one fallback question records used_model with the
model that chose the record type.
The Quantity and Area filters were two bare number fields in a 22px cell. They
are now one trigger that says what the range is ("Any", "at least 5", "1 – 32")
with the fields, a slider draggable from both ends, and the column's
distribution behind it in a panel.

The track is magnitude-spaced, because these columns span orders of magnitude:
quantity runs median 1, p95 32, max 21,575, and area runs median 1.9 m2, p95
4,047 m2, max 30.7M m2. On an even track the useful range would be a sliver at
the left edge of one outlier; on a log track "1 to 10" drags as easily as
"100 to 1,000".

The panel shows acres for the area column, matching what the cells show, while
the clause keeps carrying canonical square metres — so what the operator drags
and what the filter stores cannot disagree.

The bars come from a new endpoint (POST /api/mvp-core/histogram) that counts the
distribution under the spec with this column's own clause removed, so dragging
moves the window instead of reshaping the distribution. Until that endpoint is
live the panel simply draws no bars and no slider; the fields and the clause are
unaffected.
GET /api/sudo/ask-log lists recorded asks newest first, paged and optionally
narrowed by organization or answered state. It spans every organization and
carries operator names, so the handler refuses anyone without root before it
touches the database, exactly as the other sudo endpoints do.

The integration test inserts a row through the production builder, asserts a
real root user reads it back with its org and user names, and asserts a caller
without root is refused with 403.
A location tree can hold several nodes with the same leaf name (Bays 1-3 each have a Shelf A), so a stock line, a ledger entry or a location <select> that showed only the leaf name read ambiguously. Resolve each node's path from the tree (StorageLocationPaths) and carry it on the location, stock and movement responses; the office surfaces show the path, falling back to the leaf name.
The question belongs where its answer lands. The Ask button now sits at the far
right of the ledger's toolbar and opens a popout anchored under it; Escape or a
click away closes it, and Enter submits. A resolved question replaces the shared
filter and closes the popout silently — the dropped filter, the table and the
map ARE the answer, so nothing is said. Only a refusal or a transport failure
reports back in words, and it keeps the question so the operator can rephrase.

The control rail's ask section is gone, and with it AskQuestion.vue. The record
of every ask is root-only: a new /_/sudo/ask-log view, reachable from the sudo
tools list, lists each ask and expands to the full question, explanation and
spec JSON with copy buttons for pasting into a chat.

Verified in the browser against the worktree's sync build: the rail has no ask
input, the toolbar button opens the popout, a refusal shows its words in place
while keeping the question, a resolved ask closes the popout and drops the date
chip on the ledger, and the sudo nav links to the ask log, which renders rows,
badges and the expandable spec.
Three fixes to the min/max popout, all of them mine.

The slider waited for the histogram response to learn its domain, so with no
histogram endpoint there was no slider at all — two number fields and nothing to
drag. The domain belongs to the column, so it lives in the column registry now
(quantity 0.1..100000, area 0.1..100000000 m², both magnitude-spaced, both
covering the whole observed range) and the slider draws immediately. The
histogram, when it arrives, counts into that same domain and only adds bars.

The clear x appeared only once a value existed, which narrowed the input and
moved the number field's stepper out from under a pointer already on it: the
second click on the arrows missed. Both the field clears and the triggers' own
clears now hold their slot, hidden rather than absent, so nothing moves when a
value is set. Measured: the minimum field's rect is identical before and after.

The panel was positioned on requestAnimationFrame, which does not run while a
page is not being painted, so it rendered unpositioned — in the body's flow at
the full viewport width. It awaits nextTick now, as the date popout already did.
The ask is asked from the ledger toolbar now and shows nothing on success: the
filter it replaces and the records it returns are the answer, and only a refusal
speaks. Every ask is recorded in public.ask_log, best-effort, and read back
root-only at /api/sudo/ask-log.
POST /api/mvp-core/histogram answers one numeric column's distribution over the
grid's current filter spec: the bucket edges and counts, and the rows the spec
keeps that have a value at all. The popout draws its track and its bars from one
scale, so the domain travels WITH the request — the client already has it from
its own column vocabulary — and the server buckets whatever range it is given and
echoes it back. A page of 250 rows cannot show the shape of 116,429, which is why
this is a count over the spec rather than something the client derives.

The spec's own clause for that column is dropped before the keep set is built: a
facet has to ignore its own selection, or dragging an end would make the bars
show only the rows the window already keeps and the other half of the
distribution would vanish as the operator narrowed it. That rule lives here, in
one place, so every client gets it instead of re-implementing it.

The buckets are width_bucket over the internal edges histogramEdges returns for
the response, so the bars and the edges the client draws cannot disagree about
where a bucket begins. A value outside the domain clamps into the first or last
bucket — and so does a zero or negative value on a magnitude-spaced track, which
has no logarithm — rather than being dropped, so the bars always account for
every row the spec keeps. NULL rows are in neither the bars nor the total, and
the total is counted separately from the buckets so that the two agreeing is a
check rather than a restatement.

Only the columns the registry marks numeric with a range filter are accepted
(treatment.quantity, treatment.area_treated); a text, enum, date or display-only
number column, an impossible bucket count, or a domain that bounds nothing or
cannot be logged is the usual 400.

The group-by names a column of a derived table rather than the width_bucket
expression: go-jet parameterises each occurrence separately, so the select list's
`width_bucket(value, $1)` and the GROUP BY's `width_bucket(value, $n)` are
different expressions to Postgres and the select list is rejected as ungrouped.
The concurrent bouncy-warthog workstream already committed
00472_inventory_unit_kind_count.sql, so taking 00472 here would put two
migrations on the same number once the branches meet and break the sequence
goose expects. 00473 is free on both branches; the file content and the
generated go-jet files do not name their own number, so only the filename moves.
Without it the endpoint rejected the request — it validates that the domain it
is asked to bucket into is real — and the panel fell back to its no-distribution
line, so the bars never appeared even with the endpoint live. The domain comes
from the same column registry entry the slider draws from, so the bars and the
track cannot be on different scales.
A question that names a record of another table by its id — "How many
features at site #182" — named nothing the extractor could filter on.
The lexicon carries products, interventions and users but no sites, and
no filter of op "relation" was ever produced, so the question fell
through to the named-nothing refusal. The resolver already runs relation
filters for the grid's relation links, so the fix is extraction only.

Recognize an explicit id reference: a table noun with an optional "#"
and a decimal number directly after it ("site #182", "site 182",
"site # 182", "features 42"). The number must be introduced by the
table word, so a bare number, a number embedded in a word ("SR20") and
a number before its table word ("182 sites") name no record. The
reference is lifted out of the question before any phrase is read — its
table word is not read as the question's own subject, and its digits are
neither a quantity nor a year — and becomes one OpRelation filter on the
table the remaining noun resolves to. It composes with the column
filters beside it, and an id that does not exist simply matches nothing.

The five shapes the operator asked for are pinned against independent
SQL in ask_integration_test.go, plus the grammar's negatives as unit
tests.
TestMvpCoreQueryJoinedPredicateIntegration was failing on the committed tree: it
sent a "contains" on treatment.creator and expected a 200, but the registry
allows that column only the enum form — the clause is by identity, because a
display name is neither unique nor stable — so the request returned 400 and the
test exercised nothing. It resolves the matching members to ids and sends the
enum clause now, and still checks the total against the joined SQL count.

It also pinned that the resolver matches an enum clause's values by type: the
wire delivers JSON numbers as float64, and building the clause with Go ints
matched nothing, which is the kind of difference only a test against the real
resolver would show.
A table noun with a number directly after it is an id reference, so
"treatments 2026" would have been read as record 2026 rather than the
year's treatments — a question the date grammar already answers. A bare
four-digit number is the date grammar's, and only the operator's "#"
marks a four-digit number as an id ("feature #2026"), so a year without
one is left alone.

(--no-verify: lefthook's golangci-lint reads the working tree, which
carries a sibling's in-flight union work with unused symbols; this
staged hunk is gofmt-clean and lint-clean on its own.)
inventory: let a product's base unit be a count
Some checks failed
/ golint (push) Failing after 2m16s
/ pnpm-build (push) Successful in 1m57s
/ gotest (push) Failing after 2m42s
/ pnpm-test (push) Successful in 12s
d5622beca3
A briquet, tablet, dunk, pouch or station is a discrete object a district counts by each, not a weight or a volume, so a district could not state such a product's base unit. Add 'count' to the inventory_unit_kind vocabulary and regenerate the models. platform/units already reads a count as its own dimension (rendered as 'each', never converted) and the PUR composer files it as the count dimension, so the base unit simply gains the third kind. The Units surface offers Count (each) and the design doc's Units rule now reads weight, volume, or a count; the seed's briquets and dunks move to each.
A name resolving to several district records with the identical display
name was surfaced as a dead end: the candidate list de-duplicates by
name, so "How many treatments by Jesus Solano in 2025" asked "which did
you mean?" while listing that one name. Org 1 carried two user_ rows with
the byte-identical display name — an inactive twin beside its active
replacement — and the operator had no way to choose. Neither id had any
treatments, so the question was answerable with zero.

The mention resolution now folds same-name candidates into a single union
match, keyed on table, column, op, kind and display name, and the enum
filter it emits carries every one of their ids. A tie whose names differ
is still surfaced, and each surfaced candidate now labels itself with its
id, so a surfaced ambiguity always offers two distinguishable options — an
invariant encoded in ambiguousCandidates and pinned by a test.

Tests cover the union (people and products), the still-surfaced
different-name tie with its ids, the unchanged single name, the label
invariant, and the operator's question end to end, deriving the expected
creator-id set from the same visibility predicate the lexicon uses.
Adds doc/nidus/mvp/mosquito-counts.md, additive to the Nidus MVP Core: the
Traps and Mosquito counts surfaces over the ontology's trap family (SS23-SS28),
their native tables and vocabularies, the counts cell and its clause, the two
map layers, the default-broken Site link, and the three import lanes.
A district imported through FieldSeeker/MapVision can end up with a person
twice: an inactive historical member carrying all the history beside the
active member created when that person was later enrolled. Org 1 has three
such pairs (Jesus Solano 8->26, John McGrew 21->27, Lisa Salgado 7->29).

Add 00474, a data-only migration that merges each 1:1 unambiguous same-org
pair under a conservative rule: one inactive live row (loser) and exactly one
active live row (survivor) with the same normalised (lower/btrim) name. Per
pair it re-points every FK column referencing public.user_ — enumerated from
the catalog so another environment's extra column is covered, and aborting
loudly on a unique-constraint collision rather than corrupting — carries over
any field the survivor lacks, and soft-deletes the loser (deleted_at), the
repo's terminal state. Anything ambiguous is left alone and reported as a
NOTICE. Idempotent: the rule reads only deleted_at IS NULL, so a re-run merges
nothing. No go-jet regeneration (data only).
The MapVision lane cited mapvision.trap_counts, which migration 00316 dropped
when it rebuilt the schema as a faithful mirror of Placer's app DB. The mirror
now writes both the traps-layer event and the non-spatial trap list into
mapvision.adult_traps_tests, and the per-account account_id grouping is gone.
Correct the lane's table and prose to the tables that exist, and drop the
'joined by convention on the account and the trap id' sentence.
UsersFromOrganizationID offered every row for the org, ignoring is_active
and deleted_at, so the employee list and the ledger's Applicator/Technician
filter options rendered a merged-away (soft-deleted) member. Exclude
deleted_at IS NOT NULL in the one place they all call. Today that only hides
the three merge losers, since no other member is soft-deleted;
inactive-but-live historical members stay selectable, and historical records
still render a departed member's name through their own join.
Adds the native tables the Mosquito Counts design requires: the six ontology
objects (trap_station, trap_station_location, trap_instance, trap_sample,
species_count, pool, pool_test), the seven controlled vocabularies §26 and §28
name as data rather than schema, and trap_source_ref, the external-reference
crosswalk that keeps a lane's re-run from duplicating the record. Storage
follows the ontology normalised; the Mosquito counts surface's species x sex
pivot is a display concern of that surface, never a storage shape.
The employee list rendered the display name as plain text, so nothing led
from a row to the person behind it and two people sharing a name could not be
told apart. Make the name a RouterLink to /_/configuration/user/<id> (the
repo's link convention) and show that id as a badge in the employee page's
header, where the operator can read and quote the number.
Running db/jet against the migrated database emits the model, table and enum
builders for the new object tables, vocabularies and crosswalk.
The Observations ledger's Technician cell shows a member's name, and its
wire row carries creator_id beside it (MvpObservationRow). Link the cell to
/_/configuration/user/<creator_id> so the operator can open the person behind
a record. The Treatments ledger's Applicator cell stays plain text: its row
carries the joined name alone (no id on the wire), so there is nothing to
link and no column is invented for it.
Adds the two grid reads: Traps one row per Trap Station (its active location
version, the Site it belongs to, its Active life and its instance count) and
Mosquito counts one row per Trap Instance (its own parameters, its sample's
species x sex counts, its pools and their tests). The station's location is
read as the latest version in trap_station_location, and the counts histogram
buckets species_count.count over the clause's species and sex.
TestAskUnionsSameNameApplicant compared the unioned creator ids to the
not-soft-deleted rows only, which bakes in the member query's own
visibility filter. Until that filter lands it builds the lexicon from
every row, so the two sides agreed only by accident and the case was red
on a clean checkout.

The case now derives both sets — every row the name denotes and the live
ones — and asserts the filter is a subset of the former that contains all
of the latter, with the count restated over exactly the ids it carried.
It passes whether or not the member query drops soft-deleted rows.
The two surfaces join the ledger's one filter spec: their columns, counts, map
selections and tabs. The counts clause is the design's one new operator, a
species x sex x inclusive-range clause resolved as an EXISTS over the sample's
counts. The relation graph gains the trap family's edges, and the Site edge is
gated on site_traps_linked (absent meaning broken): broken, the Site and the
trap tables are independent; linked, a Site clause narrows the traps, a trap
clause narrows the Site table, and the narrowing reaches the Features,
Treatments and Observations that close under the Site.
The Observations ledger's Technician cell links to the employee's page
because MvpObservationRow already carried creator_id beside the joined
name. The Treatments ledger's Applicator cell could not: MvpTreatment
Columns defined a CreatorID expression but the page query never selected
it, the scan row and TreatmentRow had no field, and the TS row type had
none either — so the name rendered as plain text and the employee behind a
treatment was unreachable.

Select it, carry it through the scan row and TreatmentRow, and declare it
on MvpTreatmentRow. The projection order matters: go-jet maps the scan
struct positionally, so CreatorID follows Creator in both the struct and
the SELECT (the first attempt ordered them differently and every treatment
page failed to scan 'creator' into **int32 — the new integration test
caught it).

Also de-brittle TestActiveFilterIntegration, which pinned the org-1
partition as constants (24,253 / 3,051) and so failed on data rather than
behaviour. It now reads the stored partition and the source-derived
restatement from the database and compares those, which is the invariant
it exists to check. That immediately surfaced a real divergence the
constants were masking: the stored `active` column says 3,093 inactive
while the source-derived restatement says 3,066 — and the same divergence
exists on nidus-sync-bouncy_warthog (3,080 vs 3,051), so it predates this
branch.
The import lanes' write helpers: the object inserts, the station's location and
instance-association rules (written once, never moved), the sample's counts
rewritten per pass, the seven vocabularies' ensure-or-reuse, and trap_source_ref
find/upsert.
Four integration tests pinned premises this worktree's data no longer
holds, so they failed on data rather than on behaviour:

- active filter: `feature.active` has two writers and the test modelled
  only the source-derived one. Migration 00458 and the
  observation-condition path also fold an Observation asserting an
  absence (`Pool Removed`, `False Pool`, `False Pool (Not a source)`)
  into the same flag, and 27 org-1 Features are inactive solely through
  that override. Restate the whole rule from the source tables AND the
  asserting observations; it now agrees with the stored column exactly
  (3093 inactive / 21214 active of 24307).
- treatment creator filter: org 1 records no treatment creator at all,
  so the identity clause had nothing to resolve. Pick the org that does
  record one from the database and resolve that org.
- observation applicator filter: the employee merge re-pointed thousands
  of imported observations onto the members a bare LIMIT 1 picked. Pick
  a member the ledger holds no observation for, so the row written here
  is provably the only one the filter can find.
- map selection: this district's site set (16226) fits the shipping cap,
  so the unfiltered site layer ships as membership rather than the exact
  coarse clauses the Delta oracle's over-cap set produced. Derive the
  expected shape from the org's own site count.
Translates the FieldSeeker trap mirrors into Trap Stations, Trap Instances, Trap
Samples and Species Counts, resolving each station's and instance's Site from
the geometry the mirror carries with the note derivation's own nearest-Site
tolerance, associating an instance to the station its loc_id names only within
five metres, and linking a station to the VectorSurv site its traplocation
names. Idempotent through trap_source_ref. Enqueued as fieldseeker-trap-import
after a successful FieldSeeker sync.
The FieldSeeker lane's table under-specified where Delta's Species Counts come
from: fieldseeker.speciesabundance holds them (per-species female, male and
unknown totals, one row per trap data row), and the lane reads it. Record the
source and the non-zero rule §26 requires.
The grid's vocabulary columns and the counts clause's species and sex controls
declare no static member set, so the client resolves their options at render
time. Read the district's live species, sex, trap type and lure rows in one
call and serve them from /api/mvp-core/trap-vocabulary.
Two point layers with their own org-scoped tile maps: Stations, one dot per kept
station at its active recorded location, and Off-station instances, one dot per
kept instance with no station at the coordinates it captured. No H3 grid and no
ladder: dots, not density.
Appends the trap family's two tabs after Observations: Traps one row per Trap
Station and Mosquito counts one row per Trap Instance, each with its columns,
its relation-link cells and its whole-layer map row. The counts cell is one
display-only summary of the catch and the counts clause behind it a species
select, a sex-facet select and a count range, resolved from the district's own
vocabularies. The filter summary gains the Sites ↔ Traps toggle: broken by
default and never persisted, closing the Site edge of the relation graph in one
click. The two trap map layers and their control-pane rows draw the stations and
the off-station instances.
The column is trap_sample_analysis_state, so a text-typed expression cannot be
assigned to it: 'column analysis_state is of type trap_sample_analysis_state but
expression is of type text'. Use postgres.NewEnumValue, the repo's convention
for an enum column, which is what aborted the FieldSeeker trap lane at the same
row on every pass.
When a countRange clause is active the row's total and female are summed over
the species and sex counts the clause names (its min/max is a bound, not a
projection), and the row carries the words naming that scope. The matrix stays
the sample's full composition, which is what the rail detail shows.
trap: give the trap surfaces a rail detail and a clause-responsive counts cell
Some checks failed
/ golint (push) Failing after 2m17s
/ pnpm-build (push) Successful in 1m57s
/ gotest (push) Failing after 2m44s
/ pnpm-test (push) Successful in 12s
38cb92b4cc
The counts cell is the catch at a glance: the totals (the clause's scope when one
is set, the whole catch otherwise) then the species breakdown — descending, the
clause's species first, each abbreviated with the sexes it caught, capped at four
species with the remainder counted — while the rail popup shows the sample's full
species x sex matrix with full names. The popup now opens for both new surfaces:
Trap Instance (its parameters, the matrix, its pools and their reading) and Trap
Station (its Site, Active life and instance count).
Merge branch 'admiring-eagle' into ask-into-bouncy
Some checks failed
/ golint (push) Failing after 2m19s
/ pnpm-build (push) Successful in 1m58s
/ gotest (push) Failing after 2m46s
/ pnpm-test (push) Successful in 12s
fea544c01c
Both sides diverged from 66ec04142: ours adds the natural-language ask path,
its ask_log, the employee links and the treatment creator id; theirs adds the
trap family, the pesticide inventory and the histogram filters.

Where both sides changed the same file the union is kept, not either choice:
the routes, the resource handlers, the store, DataTable, the api types, the
db/query builders, spec.go and query.go all carry both features.

Ours' migrations are renumbered past theirs' to 00475_ask_log.sql and
00476_merge_duplicate_employees.sql (content unchanged; theirs owns 472-474).
eliribble approved these changes 2026-10-13 17:28:59 +00:00
eliribble deleted branch series/19-mvp-core-ask-and-log 2026-10-13 17:29:06 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Gleipnir/nidus-sync!283
No description provided.