Skip to content

Project › Area › Run: giving the data an owner, and reworking the studio around it - #69

Closed
rexionmars wants to merge 36 commits into
refactor/ui-corrections-and-feature-removalfrom
feat/studio-corrections
Closed

Project › Area › Run: giving the data an owner, and reworking the studio around it#69
rexionmars wants to merge 36 commits into
refactor/ui-corrections-and-feature-removalfrom
feat/studio-corrections

Conversation

@rexionmars

Copy link
Copy Markdown
Owner

Stacked on #68. Review that one first; this branch's diff against main includes it.

Why this exists

A project reported fifty-eight runs from several different fields. Nothing was wrong with the query — nothing owned anything.

  • An area was not a row. It lived in a JSON array inside preferences.extras_json, with an id the browser minted. Nothing in Go had ever seen one, so there was not a single WHERE aoi_id = ? in the package.
  • A run carried project_id and aoi_id with neither in charge. The project came from activeProjectId, sticky session state restored at every boot, so runs inherited whichever project happened to be open.
  • A project had one geometry while the work has many fields.
  • A board belonged to a user, mixed runs from any project, and named runs by ids nothing guaranteed.
  • And only classification returned the id of the run it had written, so five products could not record what they had just produced.

That last one is the root of half the visible symptoms: the studio's live area reported the sentinel "current", boards refused to save, and compositions were filed against no run.

What changes

The chain is project › area › run, enforced where it can be. Areas are rows in a table, scoped to a project. Deleting an area takes its runs and their rasters; deleting a project takes its areas and theirs. None of that is enforced by foreign keys — they are declared here and never turned on — so every cascade is written where the delete is, and each has a test.

Drawing requires a project. There is nowhere to put an area without one, so the map refuses and says so rather than inventing somewhere. That refusal is the whole point: it is what stops a run inheriting a label that got stuck to the session.

No migration. A clean cut, with the copy taken first. There is no rule that turns the old shape into the new one without inventing which area a run was of, and a wrong answer there is worse than an empty database because it reads as work rather than as absence. A snapshot is written before the first DELETE, and the account — users, sessions, preferences — survives.

Every product returns its run id, which lets the board record what it is showing and a composition be filed against the run it was made over.

The hub shows the middle level, which is what makes the model visible at all.

One name for the studio, down to the table: whiteboardsstudios, and the user-facing "board" goes with it. Three names for one thing was the same class of problem this branch exists to remove. The rename runs before any CREATE TABLE — every schema block is IF NOT EXISTS, so running them first would leave an empty studios beside the populated whiteboards and every reader would find the empty one. Tested against a file built in the old shape.

Two defects found by running it

Migrations gated on a version number can be skipped forever. schemaVersion was raised in one commit and the drop it stood for written in the next; a build compiled in between recorded the new number with no work done, and the gate then closed over three columns that could never be dropped again. The drops and renames are ungated now and ask the schema instead — the argument addColumns already made in the other direction. There is a regression test that builds exactly that file.

An overlay could take the map down. addSource throws while the style is being replaced; from an effect unrelated to the basemap it reached the error boundary, which recreated the map surface and lost every overlay. Caught and retried on styledata rather than pre-checked with isStyleLoaded(), which reports false while any source is loading and turns an intermittent failure into a permanent one.

Studio

Reworked around a node canvas, with the globe carrying drawing, relief, search and credits — which is what let the AOI drawing modal and its second map go. The studio opens with nothing on it, because that is where work starts; it names the project it is filing work into, since it is the surface that produces the most and was the only one not saying; and a plane can be shown on the globe beside it, over the ground it measures.

Known and deliberate

  • activeProjectId is still sticky session state. Requiring a project before drawing is what stops it becoming a label that sticks to the work again.
  • The end-to-end script — two areas, switching between them, saving and reopening a studio, deleting an area and seeing the missing member reported — has not been run in full.
  • golangci-lint is not installed on this machine, so that part of the gate has not run.

gofmt, go vet, the Go suite under -race, tsc, 289 frontend tests and check:types pass.

The run editor was a BAND: one row of labelled groups with rules between them,
scrolling sideways, the action pinned at its right end. That shape was written
for the 4rem foot it originally stood in, and it was correct there. It stopped
being correct when the controls moved into a studio area -- `placement="area"`
centred a low strip in a tall rectangle, so the height went to nothing and the
vertical rules were doing work that separate surfaces do better.

THREE MODULES.

`runGraph.ts` is the model: which cards exist for a product, which edges join
them, and where they sit before anyone moves them. `NodeCanvas.tsx` is the
field: pan, zoom about the pointer, cards dragged by their headers, wires, and
a dot grid that travels with the view so a pan reads as the eye moving rather
than the cards. `BoardRunGraph.tsx` is the run editor built on both, holding
the same controls the band held.

THE EDGES ARE NOT A PIPELINE, WHICH IS THE ONE THING WORTH ARGUING ABOUT. A
classification request carries a geometry, a date range with its cloud ceiling
and a model; the period does not consume the area and the model does not
consume the period. Drawing them as a chain would assert an order the request
does not have. What is true is a fan-in -- several inputs, one run -- and that
is what is drawn. The single real dependency is solar's product: irradiation
reads a record and a season, siting reads two slope limits, and neither set
means anything under the other product, so an edge from the product to each
says which choice put it on screen.

For the same reason NOTHING IS REWIREABLE, and the wire ends are filled marks
that take no pointer events at all. The graph is the shape of a request rather
than an arrangement someone chose, and a port that looked draggable and refused
to drag would promise a freedom that does not exist.

WHERE THE CARDS ARE KEPT, and where they are not. Placements go into the board
snapshot beside `places` and `planePlaces`, because they are the same kind of
value -- an arrangement made by hand, which is lost work if it is thrown away
with the board. `ViewJSON` is opaque on the Go side, so this is a frontend
field and not a migration. The VIEW is deliberately not kept: where the field
is panned to is a reading position, and the canvas fits itself to the graph
whenever the set of cards changes, so a reopened board shows the whole graph
rather than wherever it was last looked at. Fitting stops the moment the view
is moved by hand.

TWO DEAD PROPS WENT WITH THE BAND. `onToolChange` and `mode`/`onModeChange`
were declared on BoardRunBarProps and never read in its body: the tool tabs had
already moved to the editor's header, where the handler also syncs `leftPanel`
for the map's dock. A prop nothing calls is a second way in that does not
exist. The mode is still passed and is dealt with separately.

The editor's floor moved from 28rem by 3 to 24 by 14. The 3 was a band's floor
-- a strip of controls needs its own height and nothing more -- and a field
needs room for a graph to be a graph. The width floor moved DOWN because a
narrow field can still be panned.

`runBand.tsx` stays, with one caller instead of two. CanopyRunBar still stands
in a foot and still wants the idiom, and the next workflow to take a foot
should look like the last one -- easier to honour from a module than from
another band's file.

tsc reports no errors, the 289 frontend tests pass, and the production build
completes.
…s honoured again

`modeBlockedBy` says cumulative retention is a Random Forest procedure: under
the other two models the temporal mode produces nothing to accumulate.
ControlPanel has honoured that rule since it was written -- the option is
disabled and the reason is on the pointer. The studio did not. Its mode sat in
the editor header as a `StudioHeaderRadio`, which has no refused state at all:
it draws a chosen half and an unchosen half and nothing else. So the studio
offered a mode the run would not honour, with nothing to say about it.

That is why this could not be a restyle. The control had to become something
that can refuse.

IT IS ALSO IN THE WRONG ROW. The header chooses which product is in view; the
mode is an INPUT the run reads. Those are different questions, and with every
other input now a card, the mode being the one left in the header made the
header look like part of the form. It is a card, and the header keeps the tool
tabs, which are navigation.

THE MODEL NOW HAS TWO EDGES AND BOTH ARE TRUE. It is an input to the run like
the area and the period, and it GATES the mode. The edge from the model to the
mode card is that rule drawn rather than described, which is the standard the
rest of this graph is held to: an edge exists where a real dependency does.

`Choice` regains the `blockedBy` the band's version had and the port to the
graph dropped. It is carried separately from `disabled` on purpose -- disabled
is the whole card going quiet while a run is on, and a rule that refuses an
option has something to say where a busy surface does not.

Four imports left MapScreen with the radio: Clock, Map as MapGlyph,
StudioHeaderRadio and MODE_OPTIONS.

tsc reports no errors and the 289 frontend tests pass.
…modal goes

The board could import an area and clear one, but not MAKE one. The only place
to draw was the work map, and reaching it meant closing the board the area was
being drawn for -- a round trip through the surface you are trying to add to.
The answer at the time was a dialog holding a second map, which is a second map
to explain and a dialog to dismiss. There is a planet in the arrangement now,
so the drawing goes on it.

THE DRAWING IS THE SAME `useAreaDrawing` THE WORK MAP USES, which is why this
is a small change to a large-sounding one. That hook already carries the
one-area rule, when a finished shape is reported and the sync with the copy
held outside; it was extracted for exactly this and its own note said so.

THE SHAPE IN HAND GETS ITS OWN SOURCE, not a feature in the catalog's. The
catalog is what exists -- saved areas, drawn in the accent, pressable to open.
A shape under the pointer is none of those: it is not saved, pressing it must
not activate anything, and it has to be told from the ring it may be about to
replace. Sharing the source would also put it under the catalog's click
handler, so finishing a polygon would land on it as a press. White, which is
what DrawMap already drew with: the colour says "in hand" where saved is
orange. Stroked as well as filled, because DrawMap records that a closed
polygon with no line of its own vanishes the instant it is finished, which
reads as the drawing having failed.

RELIEF, reusing components/map/terrain.ts whole. The DEM goes in UNDER the
catalog: an outline says where work is and hillshade is the ground it is on, so
shading over the ring would be the ground drawn on top of the thing it is
under. Exaggeration 1 and off by default, both for the reasons that module
already gives. It earns more here than on the work map -- a sphere shows the
shape of a coastline on its own, but not why a valley is where it is.

The elevation credit rides the readout whenever relief is on. terrain.ts states
that rule and it is not decorative: the mosaic is assembled from national and
mission datasets whose licences ask to be named, and naming it is also what
keeps it distinct from the DEMs an analysis ran on.

ONE COLUMN OF CHROME, at the top right. The bottom right is the camera's on
this surface, and a second bar beside it would be two instruments for one hand.
Relief leads because it is about the ground; the tools under it are about what
is put on the ground.

WHAT GOES: the pencil on the Area card, the `drawingArea` state, BoardAreaModal
(212 lines) and DrawMap (196). Also the globe's "Open the work map here"
button, which belonged to the globe screen removed on the previous branch and
has had no caller since.

The Area card is left with the two verbs that act on a shape it already has.
The globe is not in any workspace preset, so drawing is now reached by turning
an area into a Globe from the editor-type menu -- which is the studio's own
model, and the editor's hint in that menu was rewritten to say the surface
draws rather than only shows.

tsc reports no errors, the 289 frontend tests pass, and the build completes.
An area drawn on the globe appeared on the globe and the Area card kept saying
"none". The shape reached the application and was erased on the way in.

TWO SURFACES RUN THIS HOOK AT ONCE. The work map stays mounted under the studio
-- that is why the title bar withholds its telemetry rather than unmounting it
-- and the globe now draws as well. Both are bound to the one area the
application holds.

So finishing a shape on the globe set that area, and the new value arrived at
the WORK MAP as a polygon its own store did not have. The sync effect cleared
the store to replace it; `draw.clear()` raises `change` with "delete" like a
hand does; and the listener answered a synchronisation with
`onPolygonDrawn(null)`. The saved catalog entry survived, which is the orange
ring that was on screen, and the area itself was gone -- which is exactly the
pair that was reported.

The guard is narrow: while this hook is writing the store to match what it was
given, the store has nothing to report. It is set for the whole replacement
rather than per call, because cleared-and-not-yet-refilled is a state the hook
passes THROUGH, and reporting from inside it would announce an empty store as
an area that was removed. Restored in a `finally`, so a throw in addFeatures
cannot leave a surface permanently unable to report.

The caller's `clear` is a real edit -- someone pressed the bin -- so it still
reports, and now does so explicitly rather than through the listener the guard
silences. A removal nobody hears is a shape that stays in the application after
it has left the screen.

OLDER THAN THE GLOBE. Any path that sets the area from outside a mounted map
would trip it: search, import, adopting a run's geometry. It needed two live
surfaces to become constant, and that is what made it visible.

The mechanism was arrived at by reading rather than by a runtime trace, and
confirmed against the reported symptom. tsc reports no errors and the 289
frontend tests pass.
"none" in small type in the middle of a card is not where the eye goes first,
and the area is what a run is blocked on when it is missing. The card says so
now: an accent border, and the header glyph in accent-quiet.

TWO LIGHTS THAT MUST NOT MEAN THE SAME THING. The run card was already the
accent, saying "this is where the graph arrives", with its header FILLED. The
area card says something else -- "this is carrying a value" -- so it is
OUTLINED and never filled. A fill there would read as a second run button, and
the difference between the action and a satisfied input has to survive both
being lit at once. The accent is the only colour this chassis has to say either
with, so the shapes carry the distinction rather than the hue.

`CanvasNode.accent` becomes `tone: "action" | "held"`, so that distinction is
declared in the type instead of living in a boolean the next reader would apply
to the wrong card.

ONLY THE AREA. It is the one input whose emptiness is a real state: a period
has dates, a model is one of three, a mode is one of two. Lighting those would
be a light that is always on.

The glyph joins the border because the border is at the edge of vision when the
eye is on the value in the middle of the card, and the glyph is the part of the
header already being looked past.

tsc reports no errors, the 289 frontend tests pass, and the build completes.
The drawing modal carried a search bar, and it went with the modal. The work
map has one, but MapScreen withholds it for as long as the studio is up -- so
between the two changes the studio lost every way of reaching a place by name,
which was half of what that modal was for.

REVEALED, NOT ALWAYS UP. The work map keeps its bar permanently because it has
the width for it. A studio area may be a quarter of that wide, and a bar across
the top of a sphere would cover the pole to answer a question that is asked
once and then not again for a while. It opens from the column, on a magnifier.

The column now reads in the order the work happens: find the place, light the
ground, draw on it.

IT OPENS TO THE LEFT of that column rather than under it. Under it is where the
drawing tools are, and a panel that covered them would hide the controls a
reader reaches for immediately after arriving somewhere. Bounded by the pane
rather than given a width, so a narrow area gets a narrow bar instead of one
that runs off the edge.

ONE GEOCODER, which is what the change to SearchBar buys. It had hard-coded the
work map's placement into its own body -- centred, 26rem, above the map's
chrome -- so a second caller had to accept a bar wider than its pane or own a
second copy of the search. Placement is a prop now, and its DEFAULT is the work
map's own values, so that mount is unchanged by this becoming a choice. It also
gains autoFocus, which a bar that is revealed needs and a permanent one does
not.

Zoom 14 on arrival, which is FlyToController's figure. That controller's export
comment says it is shared precisely so there is one answer to how far the
application zooms on arrival, and a second answer here would be a globe that
lands closer or further than the map for the same search.

tsc reports no errors, the 289 frontend tests pass, and the build completes.
A compass glyph said there WAS a tilt and not what it was. This control has
always been the way back from a turned camera; what it could not do was report
the camera it was undoing, so a reader who had orbited had to move the view to
find out where they had got to.

It is an attitude gizmo now, in the shape the reference instruments use -- an
artificial horizon, Earth's navigation ball -- and in three parts:

THE RING IS THE GROUND, seen from where the camera is. Straight down it is a
circle; laid over towards the horizon it closes into a line. That is not a
metaphor for pitch, it is the ground plane's actual projection, so
`ry = r * cos(pitch)` is the whole of it.

NORTH RIDES THE RING. At bearing 0 it is at the top, turning the camera carries
it around, and the ring's compression carries it up and down with the tilt --
its vertical half taking the same cosine, so the mark sits ON the ring at every
angle rather than crossing it. One mark and not four cardinals: at 22px the
other three are three more things to resolve and none says anything the first
does not.

THE BAR IS THE VIEWER and does not move. Everything else turns under it, which
is what makes the gizmo read as the world moving rather than the instrument.

DRAWN BY MUTATION, NOT BY RENDER. The hook in this file returns a boolean and
its comment gives the reason: bearing and pitch change on every frame of a
drag, and holding them in React state would re-render the bar sixty times a
second for two numbers nothing else reads. The angles are written onto the two
elements that carry them, from the map's own rotate, pitch and move events.
`level` is in the effect's dependency list because the control unmounts when
the view is square, so coming back it has new elements to paint.

THE BUTTON KEEPS ITS MEASUREMENTS. 2.125rem outside, with the drawing at 22px
where the glyph was 16; the six pixels come out of the padding. MapChrome
states what a control of a different size costs in a stack of them, and on the
work map this one heads such a stack.

Visibility is unchanged: it appears with the tilt it undoes and leaves with it.
Level, the gizmo would be a plain circle with north at the top, which is what
its absence already says.

tsc reports no errors, the 289 frontend tests pass, and the build completes.
The surface switches MapLibre's attribution control off, and the mount says
why: it renders its links as anchors, and in this WKWebView an anchor with
target="_blank" opens nothing, while the licence asks the credit to be
REACHABLE. The bargain was that the application would render the parts itself
through BrowserOpenURL.

It stopped holding up its end. The screen that rendered them was the globe
destination, removed on the previous branch, and the comment at the mount still
pointed at it -- so the studio's globe drew Esri imagery and credited nobody.
Only the elevation mosaic was named, and only because relief brought its own
rule with it.

The parts now ride the readout at this surface's foot, in the title bar's order
and with its separators, so the two surfaces credit one set of providers one
way. Not `telemetry`: it is a sentence about ownership rather than a
measurement, so it takes the sans face the title bar uses for the same line.

IT WRAPS RATHER THAN TRUNCATES, which is where it departs from the title bar.
That one may hide its credit below xl and clip what is left, because there the
map is the whole window and the line is reachable on the surface that draws the
imagery. A studio area can be a few inches wide, and a truncated credit in the
only place it appears is a credit that is not given.

`pointer-events-auto` on the credits alone: the line around them is a readout
and must not take the pointer off the planet.

tsc reports no errors, the 289 frontend tests pass, and the build completes.
… account

The run log was in three places and in none of the right ones.

THE PROPERTIES PANEL SWAPPED ITSELF FOR IT. That panel answers "what is this
raster" about one a reader picked -- a question they asked and are waiting on
-- and while a run was going it replaced its whole body with the log. The
argument recorded for that was that the figures it displaced were stale and the
live question was what the run was doing. Both halves are wrong: the reader
asked for the legend, and the run's account belongs where the run was started.

THE FOOT'S STRIP CARRIED A SECOND COPY of the same line, stage and percentage.
Its own header claims the strip exists to give that column back -- and the
column was never given back, because the panel still took itself over. So the
strip was paying for a fix it had not made and repeating a line at the same
time. Its middle zone goes; the button in it was inert anyway, since
`onOpenLog` is optional and BoardSurface never passed it.

THE CARD HAD A HAIRLINE AND A TOOLTIP. The run band kept the stage in a tooltip
and the argument was sound about a single line: one that rewrites itself
several times a second costs more attention than it returns. A card settles it
rather than abandoning it -- fixed width, the line truncates, the words change
and nothing moves. And a LOG is not that line: each entry is read once and
stays, nothing is overwritten, and the stack is the only account of what the
run did. `RunLog` moves here whole, bounded and scrolled, because a twelve-
stage water run would otherwise grow the card past the graph it sits in.

THE PERCENTAGE WAS 7900%. The sidecar emits a percentage -- `emit_progress(10,
'querying STAC catalog')` -- and StudioStatusBar has always read it that way.
The band multiplied by a hundred to build the width of its progress hairline,
which asked for 7900% of a bar and got a full one, so the error was invisible
for as long as its only reader was a width. Drawn as a figure it said 7900%.
Clamped now rather than trusted.

What remains is two places answering two moments: the stack inline in the card
while the run is going, and the same stack behind the card's Method panel once
it is over -- which is what that panel was always documented to be for.

`runLog`, `runRunning` and `runProgress` stop being threaded through the board,
which no longer reads a log anywhere.

tsc reports no errors and the 289 frontend tests pass.
A composition appeared under the current run without anyone asking for one, and
it was not created: it had been in the gallery since the session opened.

`activateProject` loads a project's overlays whole and says so deliberately --
"the gallery is always loaded so the compositions stay one click away in
Overlay Tools; only the display is conditional". What decides whether one is
RELEVANT is `scopeCompositionsToView`, and its rule for a composition with no
run of its own is whether its extent meets the AOI on screen.

That is the right rule for Overlay Tools and the wrong one for a run's asset
list. Drawing an area over ground a stored composition happens to cover brought
it into scope, and this loop then listed it under the current run as though
that run had produced it.

The defect was attribution, not scope. One with a `runId` has already been
filtered to this run by the scoping. One without belongs to the project, and
earns a place in a run's assets only by being the composition actually on the
map -- which is the case this list exists to report.

tsc reports no errors and the 289 frontend tests pass.
…t one

Drawing a second area collapsed the board to one. The previous ground's rasters
went, the catalog gained entries nobody made, and every area the map had moved
on from was renamed "run-untitled". Four defects, one gesture, and they took
several attempts to separate because three of them looked like the same
duplication.

THE DRAWING STORE REPORTED TWICE, AND THE TWO REPORTS WERE DIFFERENT SHAPES.
Two surfaces run `useAreaDrawing` at once -- the work map keeps its own draw
tool and stays mounted under the studio -- and the sync effect puts the area
the application holds into each store. So drawing starts from a store that
already has a polygon in it. terra-draw raises "update" in select mode while
both are present, `emit` took the LAST polygon of the pair, and the application
received a geometry it had not asked about and filed it as a new area. Two
earlier attempts guarded by comparing geometries and neither could ever have
worked, because the two reports do not carry the same shape. The store now
refuses to report while it holds more than one: that is a state the hook passes
THROUGH, never one to speak from.

THE SYNC REPORTED ITSELF AS AN EDIT. `draw.clear()` and `draw.addFeatures()`
raise the same events a hand does, so a shape arriving from elsewhere erased
itself -- the shape drawn on the globe reached the application and was cleared
by the work map being told about it. Guarded for the whole replacement, since
cleared-and-not-yet-refilled is a state to pass through as well.

RETENTION WAS TIED TO TWO GESTURES. `startNewClassification` and
`backToAnalysesList` retained the outgoing run; every other way of changing the
active area -- drawing, pressing one in the catalog, adopting a geometry,
opening a project -- lost the work on it. It is an effect on the ground
changing now, which catches the paths nobody has enumerated. The pair is held
in a ref written during render, because the aoiSignature effects clear the
products and reading state there would be a race against them.

AND IT RETAINED THE WRONG OBJECT. `props.result` is the classification alone;
water, both solar products and the flood envelope are joined at render time by
`resultWithWater`. Retaining the classification kept the one product those
effects do NOT clear and dropped the four they do -- the whole of what was
being lost. `retainRun`'s guard also refused anything without a classification,
on a comment that was true of the live area and of nothing else: the board has
always been able to draw a retained water or solar run, since `legendByArea`
reads those fields off any result. What was missing was a result reaching it.

THREE IDENTITIES HAD TO BE MADE ONE. The board files its live area under the
GROUND, `runId` reaching it is `result.run_id || "current"` -- which a
standalone product never moves off the sentinel -- and retention keyed by the
run. So the retained entry and the area it came from could not find each other:
the entry arrived in the data tree as a run to add by hand, and the planes were
not carried to it. Retention takes the ground now, the carry-over watches the
ground, and one run is kept once however it was filed.

THE CARRY-OVER ALSO ARRIVED A RENDER EARLY. Retention is an effect in App and
the carry is an effect in a child of it, and React runs a child's effects
first -- so it looked for the retained entry before it existed and had already
forgotten which area to carry from. It is remembered at the moment of the
change now and spent when the entry turns up.

TWO CONSEQUENCES OF THE RE-KEYING, both mine, both found by reading the list
the board actually holds rather than by reasoning about it:

  `groundOnBoard` asks `aoiOfRun` which ground each area is of, to keep the
  catalogued drawing from being listed beside the area already holding it. That
  map answers run id -> area id, and asking it about an area id returns
  nothing. Every ground with a retained run was therefore listed twice.

  The retained area's title came from the run list, and asking IT about an area
  id finds nothing either -- and `displayRunLabel` answers an empty label with
  "run-untitled", which is not empty, so the "Previous run" fallback beside it
  never ran. The scene tree lists ground and the data tree lists runs, a rule
  this file states and `resolveAoiDisplayLabel` repeats; the retained area now
  takes its ground's name the way the live area always has.

A retained run can also be let go of. The board's X dropped one added from the
picker and did nothing to a retained one, which lives in App -- tolerable while
retention took two gestures, and not once a reader accumulates them by working.
Its bin now asks the run list whether there is a row to delete, rather than
testing an id prefix that stopped meaning that.

tsc reports no errors, the 289 frontend tests pass, and the build completes.
Selecting between two areas emptied whichever one was selected. The area left
behind kept its raster; the area arrived at came back with nothing, and
switching again reversed which was which.

The retained copy was being filtered out at exactly the moment it was the only
thing left. Arriving at a ground makes it the live one, and the aoiSignature
effects then clear the products -- they belong to the area being left. What
remains is the retained entry, and `assetRuns` excluded any whose id was the
live ground.

That filter was mine, added a few commits ago against a duplication that was
already prevented elsewhere. The live entry is added ONLY when it has assets of
its own and is added FIRST, so `assetOf` prefers it whenever there is a live
run to prefer; and `areas` filters `r.areaId !== live` on its own account, so
no second row was ever going to appear. It cost the case it was not written
for.

tsc reports no errors, the 289 frontend tests pass, and the build completes.
Picking another area lifted it to the top of the tree and slid every row below
it. Choosing between two therefore meant finding both again after each switch.

The list is assembled live-area-first, then the catalog, then the retained
runs. That order is true about how it is BUILT and says nothing a reader wants,
and it makes the live area's identity -- which changes on every selection --
the thing the whole tree is sorted by.

It is ordered by the catalog now: the order the grounds were drawn in, which
nothing a selection does can change. An area with no catalogued ground -- a run
loaded from the picker, an adopted geometry -- sorts after them and keeps the
relative order it was assembled in.

tsc reports no errors, the 289 frontend tests pass, and the build completes.
The data tree listed every retained run under one name, so a solar irradiation
and a solar siting from different grounds could not be told apart.

The name is right in kind: this tree lists runs, unlike the scene tree beside
it, which lists ground. What was wrong is who was asked. `runs.find` is handed
an AREA id, since retained runs began being filed under the ground they were
over; it finds nothing, and `displayRunLabel` answers an empty label with the
placeholder "run-untitled" -- which, not being empty, also stops the "Previous
run" fallback beside it from ever running.

The ground's name is the honest second answer, and `displayRunLabel` already
knows how to make a run name out of one: its own legacy branch turns an area
name into `run-<name>`. A solar run over "drawn 2" now reads "run-drawn-2".

It matters most for the products that record no row of their own. Only
WaterAnalysis carries a run_id on its payload, so solar and flood would
otherwise stay anonymous however many of them a board held.

tsc reports no errors and the 289 frontend tests pass.
…run on

The run graph offered an area, a period and an Apply for both, which is not
what either product is made of. It is not a regression: the band this graph
replaced never carried them either -- it covered the period, the
classification's model and the two solar branches, and nothing else -- so the
gap was carried over rather than introduced.

COMPOSITIONS gain four cards. Scene, because a composition is built from ONE
scene and the list of them is a query over the area and the period that costs a
round trip, so it is asked for rather than assumed. Composite, which gates the
next card the way solar's product gates its own: an RGB composite reads three
bands, an index composite reads one index, and neither set means anything under
the other kind. Bands or Index, one of them, never both. And Stretch, the
percentiles the ramp is fitted between.

THE PERIOD DOES NOT FEED THE RUN HERE, it feeds the scene list, and the edges
say so. Drawing it straight into Apply would assert that the request carries a
date range; it carries a scene.

SURFACE WATER gains its index. MNDWI, NDWI and AWEI as choices rather than a
menu -- three short names that fit, and each title carries what it is computed
from and who published it, because choosing between them is choosing between
definitions.

Those three were a private constant in WaterPanel, correct while that panel was
the only surface offering them. lib/waterOptions.ts now holds them, following
classifyOptions and solarOptions: two renderings of one set of choices is a
design decision, two copies is a defect waiting for someone to add an index.

Every value is the map screen's own state passed straight through, so the panel
and the graph cannot come to disagree about what the next run will be.

tsc reports no errors, the 289 frontend tests pass, and the build completes.
Saving refused with "none of these areas is a saved run yet" on boards that
plainly had one, and the message then sent the reader back to do what had just
failed.

TWO HOLES, BOTH OPENED BY KEYING RETENTION TO THE GROUND.

Members are runs, and the filter dropped any whose id is an area id -- sound
while that meant "a catalogued drawing with nothing on it", and wrong once it
meant "every area but the live one". `aoiOfRun` already answers which ground a
run is of, by `aoi_id` or by ring equality for rows written before that column,
so it is inverted here rather than guessed at. A ground with no run behind it
still resolves to nothing and is still dropped.

The live area was the one place not getting that lookup at all. Its id is
`result.run_id || "current"`, and only a classification fills that field: solar,
water and flood leave it on the sentinel. So the commonest board of all -- draw
an area, run one product, save -- had exactly one member and lost it. Verified
against the store: today's solar runs carry a correct `aoi_id`; the row existed
and the result simply did not know its own id.

THE MESSAGE WAS ALSO WRONG. It said "run something" to a reader who had just
applied a composition, and a composition never becomes a run: RenderComposite
records no row and says why -- it is a rendering, reproducible from its request,
where the other products are measurements returned to and compared. It is saved
as a project overlay, which is why it survives without a board. The refusal now
says that, names the analyses that would give the board something to arrange,
and states that nothing was lost.

The ground-to-run inversion is a workaround for a gap in the Go layer, and the
approved ownership plan removes it: once every persist function returns the run
id it created, the live area has a real one and this lookup can go.

tsc reports no errors and the 289 frontend tests pass.
Five kinds of run reach the database and one of them could say so. Everything
that has gone wrong around identity this week traces here.

`saveRun` has always returned the id and its comment has always said why the
caller needs it: to tell the frontend which run it is now looking at. Six of the
seven wrappers threw it away — water, solar, the two solar rasters, the energy
model, wind and flood — so only a classification came back knowing its own name.

WHAT THAT COST, downstream, in the code this repository has been repairing by
hand: the studio's live area resolves `result.run_id || "current"`, so for those
products it stayed on the sentinel; a board therefore could not record the run
it was plainly showing, and the save path grew an inversion of `aoiOfRun` to
guess the run back out of the ground it was over. A composition applied over a
solar run was filed against no run at all, and then reappeared under a later one
by extent. Each of those was treated as its own defect. They are one.

THE FIELDS WERE DECLARED AND NEVER WRITTEN. `WaterAnalysis.RunID` and
`SolarAnalysis.RunID` have existed since they were written, with comments
explaining that an empty value is `saveRun` withdrawing its claim to have
saved — a withdrawal that had nowhere to go, because nothing ever filled them.
`WindAnalysis`, `FloodAnalysis`, `EnergyModelAnalysis`, `SolarTerrainAnalysis`
and `SolarSitingAnalysis` had no field at all. They have one now, and every
persist path stamps it after the row is written, for the reason the
classification path already states: the stored copy is marshalled inside
`saveRun`, so a result carrying its id would put the id inside its own row.

TWO MORE DOORS TO THE SAME PROBLEM, in `LoadAnalysis`. The wind and water
branches returned a result with no `RunID` while the solar, flood and
classification branches beside them set it. Reopening a saved wind or water run
handed the frontend the same sentinel the live path produced. A run read back
from its own row is the one case where its id is never in doubt.

`SurfaceModel.RunID` goes the other way and is REMOVED, along with
`SurfaceModelRequest.ProjectID` and `.AoiID`. `AnalyzeSurfaceModel` records no
run and argues why: the surface is the ground the measurements were made on,
static and reproducible from the polygon alone, so a row would record nothing
the request does not already say. A field that can never be filled is worse than
no field. Removing the two request fields also removes an outlier rather than
fixing it — `MapSurface` was passing `project_id: ""` and `aoi_id: ""` because
nothing read them.

The five new fields carry LINE comments and not block ones, because
`frontend/scripts/check-types.ts` parses these structs and refuses a `/*` it
cannot read a JSON name from rather than skipping the field in silence. That
script is also what required the matching `run_id?` on the five TypeScript
interfaces, and what confirmed the two sides agree afterwards.

First stage of the approved Project › Areas › Runs plan, and the one that
unblocks deleting the board's workarounds later. Purely additive apart from the
surface-model removal; no consumer has to change yet.

gofmt clean, go vet clean, `go test -race ./...` passes, tsc reports no errors,
289 frontend tests pass, and check-types reports every mirrored struct agreeing.
golangci-lint is not installed on this machine, so that half of the gate was not
run.
An area was a JSON array inside preferences.extras_json.saved_aois: a list
belonging to a user, referenced by inference_runs.aoi_id and projects.area_id,
and validated by nothing. Nothing could query it -- there is not one
`WHERE aoi_id = ?` in this package, because there was no column to put on the
left of it. So a run pointed at something that might not exist, the project it
landed in was whichever one happened to be active, and every reader that needed
to get from a run back to its ground did it by comparing polygon rings.

`areas` is now a row: id, project, user, name, geometry, notes, timestamps. The
schema lives in areas.go rather than store.go for the reason whiteboardSchema
does -- the statements that make a subject belong with the subject -- and it is
applied unconditionally like every other CREATE TABLE IF NOT EXISTS here, since
the version records how far the gated steps have run and says nothing about
which tables exist.

user_id is carried as well as project_id and that is not redundancy: foreign
keys here are declared and never enforced, so every reader scopes by user in the
same statement rather than checking first and trusting after. Without the column
each of those would need a join to projects. The invariant is established once,
where the row is made.

THE LINKS: inference_runs.area_id, project_overlays.area_id,
whiteboards.project_id, projects.last_area_id. NOT NULL DEFAULT '' because
SQLite refuses ADD COLUMN NOT NULL without a default -- and the empty string is
unreachable only because the discard runs first, which is why the order inside
the gated block is not arrangement but meaning. schemaVersion 2 to 3.

THE DISCARD IS NOT A MIGRATION AND DOES NOT PRETEND TO BE. There is no rule that
turns a run naming its ground through a JSON array into a run inside an area
without inventing which area it was of, and a wrong answer there is worse than
an empty database, because it reads as work rather than as absence. Users,
sessions and preferences survive: a theme and a window layout are not domain
data, and losing them would be a second, unnecessary loss.

The copy is taken BEFORE the first delete, and that order is the whole safety
property. VACUUM INTO is the mechanism the backup path already trusts, and it
cannot run inside a transaction, which is also why the deletes follow it rather
than sharing one. Asset directories are moved aside rather than removed,
following the convention RestoreBackup uses: the way back from an irreversible
step should be a rename, not a restore. An ExportBackup archive was the
alternative and is the wrong tool -- it strips password hashes and session
tokens, right for a file a user mails themselves and wrong for a rollback the
application takes on its own, and it re-compresses every asset when the assets
are exactly what the renames keep.

NOTHING TO DISCARD COSTS NOTHING. Every fresh install reaches this step, since a
database created a moment ago reports version 0. Unguarded it left a snapshot
and a runs.replaced-* directory beside a database that never held a row, which
reads as damage where there was none. The store tests caught that; the guard is
a row count.

TESTS: `TestLegacyDatabaseWithoutAVersionMigrates` now asserts the opposite of
what it asserted, deliberately. It promised that pre-version rows survive
migration; they no longer do, and the promise is now that the domain is emptied,
the account is not, and a copy is left beside the database. Two new tests cover
the halves the rows cannot show -- that assets are set aside rather than
removed, and that the catalogue leaves the preferences blob while the theme
beside it does not.

Nothing reads the new columns yet. The application behaves exactly as before,
over an empty database.

gofmt clean, go vet clean, `go test -race ./...` passes, tsc and the 289
frontend tests unaffected. golangci-lint is not installed here, so that half of
the gate was again not run.
…its runs

The table landed last commit with nothing able to reach it. This is the surface:
CreateArea, ListAreas, GetArea, UpdateArea, DeleteArea, ListRunsByArea, and the
cascades that make the chain hold.

CREATEAREA READS THE PROJECT IN THE TRANSACTION IT WRITES IN, for two things at
once: that the project exists and is this caller's, and whose user_id the area
carries. That is where the invariant "an area's user is its project's user" is
established, and establishing it once is what lets every reader afterwards scope
by user in one statement instead of joining to projects a dozen times.

THE PROVISIONAL NAME IS MINTED SERVER-SIDE. "drawn", "drawn 2", "drawn 3" was
computed in the frontend from its own copy of the catalogue, and two draws
reported in one React batch read the same stale list and produced two areas with
one name between them -- a defect this repository has already fixed twice from
the other end, and which cannot recur once the count comes from the table. It is
numbered per project: two fields called "drawn" in two projects is not a
collision, and numbering across them would open the second project at "drawn 4".

CASCADES ARE WRITTEN WHERE THE DELETE IS, because foreign keys here are declared
and never enforced. DeleteArea takes its runs, their rasters, the board members
naming them and the compositions filed under it. DeleteRun stops leaving
references dangling: the board member goes, and an overlay's run_id is CLEARED
rather than the overlay deleted -- the composition is still a real raster of real
ground, and its own comment already defines empty as "belongs to the project".
GetWhiteboard's Missing reporting stays; it is the honest answer for a gap this
code does not create.

DELETEPROJECT NO LONGER DETACHES ITS RUNS, and that is a destructive change to a
control that already read as destructive. `project_id = NULL` was right while a
run could stand on its own; under the chain there is nowhere to detach to, since
a run with no project is a run of no ground. It takes the areas, the runs, their
rasters, the boards of that project and its overlays. The test that asserted the
old promise now asserts this one, with the reasoning it replaced written down --
it was not wrong, its premise was.

LISTRUNSBYPROJECT WAS MISSING THE AREA. It selected sixteen columns where
ListRuns and GetRun select the whole row, and the one it omitted was the link
back to the ground: a run read through the project list came back claiming no
area, while the same run read any other way named one. The hub reads runs
through here, which is exactly why grouping them by ground was impossible from
the screen where it matters most.

InferenceRun gains AreaID beside AoiID. Two names for one thing is the confusion
this change exists to remove, and carrying both is a transition, not a design:
AoiID goes when the frontend stops writing it.

Five new tests cover what the database cannot enforce for itself -- the user
taken from the project, another user's project reported absent rather than
forbidden, per-project numbering, the delete cascade including rasters and board
members, and the run listing that had been dropping the area.

gofmt clean, go vet clean, `go test -race ./...` passes. golangci-lint is still
not installed here.
The catalogue needed no API: it was a JSON array inside a preferences blob, so
the frontend held the whole of it and the store never saw an area at all. That
is exactly what let a run's aoi_id point at something nothing could check. An
area is a row now, and a row is reached by asking.

`app_areas.go` is thin over the store, like app_projects.go and
app_whiteboards.go beside it: resolve the effective user, hand through. The
rules stay where a test reaches them without a running application -- that an
area belongs to a project, that its user is the project's, that deleting it
takes the runs of it.

CreateArea takes an optional name and usually gets none, because the store mints
"drawn", "drawn 2" from what the project already holds; a caller with a better
name -- an imported file -- passes it and keeps it.

DeleteArea's doc says what changed about it rather than only what it does:
removing an entry from the old JSON array left the runs alone, since nothing
linked them. It does not any more, and the caller is the one that has to ask
first.

The generated bindings are regenerated with it, since they are committed. Only
the three runtime files' permission bits came back different, which is generator
noise and was put back.

gofmt clean, go vet clean, Go and frontend suites pass. golangci-lint still
absent here.
A, B and C were geojson files beside the binary, offered as a SECOND way to name
a ground: a request carried either an `area_id` selecting one of them or a
polygon, and every reader in the runner had to handle both -- twelve branches
across seven analyses, each resolving the same question two ways. That second
path is one of the duplications the ownership work exists to remove, and it is
the one that made `area_id` ambiguous: the column on a run means the catalogued
ground, the field on a request meant an example.

WHAT THEY CARRIED BEYOND GEOMETRY, and why none of it is lost:

Two pinned Sentinel tiles, T22JBT and T21JZN. Left empty the STAC catalogue
returns whichever tile covers the ground, which is right for every field and was
only right for those three by coincidence.

A local MapBiomas raster, which the comment in Predict described as the reason
"soja retention" was unavailable for a drawn polygon. That has not been true for
a while: terra/mapbiomas.py resolves an empty path by fetching the Brazil COG
window, and the classifier checks polygon_in_brazil before asking. The local
raster was a shortcut, not the capability. Checked in the sidecar rather than
assumed, because removing a capability quietly is the failure that matters here.

WHAT GOES: the three files, Runner.ListAreas, Runner.loadArea, Runner.AreasDir,
the areasDir field, App.ListEmbeddedAreas, the "Areas" row of the environment
report, and the twelve request branches -- each now "no polygon, no run".

THE PACKAGING WOULD HAVE BROKEN. Both release scripts copy `areas/` into the
bundle unconditionally, and the shell one runs under `set -e`, so the first
release after this commit would have failed at the copy rather than at anything
a reader could connect to the change. Removed from both, and from the workflow
comment that lists what LITE ships.

The frontend stops fetching them and its `areas` list is now permanently empty.
`activeExample` and the state around it survive one more step: they are threaded
through about a hundred places, and unpicking that here would hide the removal
inside the unpicking. Read from an empty list every one of those answers "no
example", which is the behaviour the application now has.

The runner test loses the half that asserted the three areas and keeps the half
about the paths a runner resolves, renamed to say so.

STILL STALE, and named here rather than left to be found: docs/USER_GUIDE.md
tells a reader to "Select embedded area A", and docs/ARCHITECTURE.md lists
"embedded area or GeoJSON polygon" as the first resolution step. Both are
rewritten in the frontend stage, when the flow that replaces them exists to be
described.

gofmt clean, go vet clean, Go and frontend suites pass, package_release.sh
parses. golangci-lint still not installed here.
…that

The second way is gone from the code, not just from the data. Removing the
three example geojson files left `area_id` on every request struct and
`activeExample` on every screen, reading a list that could no longer fill: the
plumbing for a choice with one option left.

WHAT `area_id` ON A REQUEST DID, and why it could not stay: it selected an
example, and `aoi_id` on the same struct named the catalogued ground. Two fields
one letter apart meaning different things, one of them dead. Off both sides now
-- 12 request structs in Go, 11 interfaces in types.ts -- and `aoi_id` is next
to take the name that is now free.

A DEFECT THIS CLOSES. saveRun wrote `{"area_id":"A"}` into polygon_geojson when
a request came with an example instead of a shape: a geometry column holding
something no reader of geometry can parse, decoded on the way out by
parseRunPolygon looking the id up in a list. Both ends are gone, and the
schema-3 migration already emptied every run that could still carry it.
parseRunPolygon returns a geometry now instead of a geometry-or-example pair.

THE READERS, each collapsed to the branch that survives:

`usesExampleArea` chose between the two ways at 12 call sites; every one of them
now requires a polygon and says so -- "Draw an area on the map first", where it
said "draw, search, or load an example".

`resolveProjectGeometry` took a project and a list of areas to resolve
`projects.area_id` against. It takes the project.

`aoiSignature`, `visibleAoi`, `areaLabel`, `analysisPolygonGeoJSON` and
MapSurface's `aoiGeometry`/`aoiName` each had a custom-first-then-example
precedence. There is one term left, so there is no precedence.

LulcSection showed a documented crop rotation looked up by area id from a table
of three, written down because those three fields were known. Nothing supplies a
rotation for a field a user draws, so the panel could only ever have appeared
for A, B and C.

`syncProjectAoi` wrote the example id into `projects.area_id`. It writes "", and
that column stops being written at all -- it is dropped when its last reader in
the hub goes.

The `Area` type itself, both the Go struct and its TS mirror, described an
embedded example: id, label, kml_name, approximate, mapbiomas. Nothing imports
either. Not to be confused with store.Area, which is the row a drawn ground is
now kept in.

`activeExample` is threaded out of App, MapScreen, EnergyScreen, FloodScreen,
MapSurface, ControlPanel, AoiSection and BoardRunGraph.

gofmt clean, go vet clean, Go suite passes under -race, tsc clean, 289 frontend
tests pass, check:types reports every mirrored struct agreeing.
The catalogue lived in preferences.extras_json.saved_aois: a JSON array the
frontend minted ids for, prefixed `aoi:`, named, renamed and deleted entirely
in the browser. Nothing in Go had ever seen an entry. That is the root of the
count the hub was reporting -- a run's aoi_id pointed at a value no query could
resolve and no delete could cascade, so 58 runs from several fields sat inside
one project, because the only thing that knew what an area was could not say
which project it was in.

WHAT REPLACES IT. `ListAreas(projectID)` on opening a project, `CreateArea` on
drawing, `UpdateArea` on renaming, `DeleteArea` on deleting. lib/areas.ts holds
the view the screens read -- id, name, parsed geometry, created_at, notes,
run_count -- and lib/savedAois.ts, with newSavedAoiId, nextDrawnName,
parseSavedAois and createSavedAoi, is deleted: every one of them existed to give
identity to something that had no row.

DRAWING NOW REQUIRES A PROJECT, which was the decision behind this. There is
nowhere to put an area without one, so createArea says so rather than inventing
somewhere, and the outline is cleared with the refusal -- a shape on the map
that the area card calls "none" is the disagreement this change exists to end.
Importing a file refuses the same way, which is why that handler moved from App
into AppBody: it needs the project id, and it was written where it was when an
area belonged to nothing.

THREE DEFECTS THAT GO WITH THE STORE:

Renaming patched the local list and stopped. The name was gone at the next
start, and every run recorded under it still carried the old one. It is an
UPDATE now.

Deleting removed an entry and left the runs -- nothing linked them, so nothing
could follow. DeleteArea takes the runs and their rasters, so this asks first,
naming the count.

The provisional names were minted in the browser from a list that a second
report in the same React batch had not yet changed, which is how two areas
arrived called "drawn 2". The store numbers them in the transaction that writes
them, against what the project actually holds.

Creating a project no longer copies the map's shape into projects.polygon_geojson
-- a project holds areas, and one shape of its own cannot describe a reader
working several fields. handleCreateProjectFromAoi is handleCreateProject.

TWO NAMING NOTES. `savedAois`/`activeAoiId` are `areas`/`activeAreaId`
throughout, the vocabulary the hierarchy uses. Inside BoardSurface the prop is
`catalogAreas`, because `areas` there already means the stacks on the board.
And `isSavedAoiId` is gone: it tested the `aoi:` prefix, and store ids are bare
UUIDs like run ids, so telling them apart is a membership test against the
catalogue.

The request field is still `aoi_id`; it now carries a real area id. Renaming it
to `area_id`, which the previous commit freed, is its own step.

gofmt clean, go vet clean, Go suite passes under -race, tsc clean, 289 frontend
tests pass, check:types clean.
A run carried aoi_id AND area_id, one letter apart, meaning the same thing
against two different catalogues. That pairing was a transition, declared as one
in InferenceRun's own comment; this ends it. aoi_id is gone from the request
structs, from the row, and from the table.

WHY THE COLUMN IS DROPPED RATHER THAN LEFT UNWRITTEN. Nothing writes it, and the
version-3 step already emptied every row that could hold a value, so what would
remain is a column that always reads '' -- which is the same argument areas.go
makes for having no position column: one nothing writes will disagree with the
table one day. Schema 4 drops it. SQLite has done DROP COLUMN since 3.35 and
refuses on an indexed column; this one has no index.

`dropColumn` asks the table rather than trusting the version, for the reason
addColumns gives at length: a database created fresh by this build never had
aoi_id, so an unconditional drop would fail on exactly the files that are
already correct. Any other failure propagates -- a build that believes the
column is gone while it is still there is the disagreement the drop exists to
end.

THE ADD IS DELIBERATELY LEFT IN PLACE at version 1, which reads as pointless and
is not: a database at version 0 passes through every version's statements on its
way up, and removing that line would leave the drop with nothing to drop on the
files that most need the migration to be coherent.

Renamed through: AoiID -> AreaID on eight request structs and on savedRun,
aoi_id -> area_id in types.ts and at the eight call sites, and the board's
aoiOfRun now reads r.area_id.

TestRunAoiIDRoundTrip becomes TestRunAreaIDRoundTrip -- it asserted that an id
like "aoi:abc" round-tripped, and nothing can write one now. Beside it,
TestAoiIDColumnIsDropped checks the table rather than the intent, on a fresh
database and on one migrated up.

Comments that named the old field are corrected where they described the code
and kept where they describe the history, per the standing rule.

gofmt clean, go vet clean, Go suite passes under -race, tsc clean, 289 frontend
tests pass, check:types clean.
A whiteboard belonged to the user and to nothing else. The menu therefore
offered every board ever saved regardless of which project was open, and
SaveWhiteboard checked only that a member's run belonged to the same USER --
so a board could hold runs from several projects, and one project's board could
be filled with another's work by opening it and saving.

whiteboards.project_id gets a writer and a reader. ListWhiteboards takes a
project and filters on it; SaveWhiteboard writes it and refuses a member whose
run belongs to a different one. Neither is enforced by the database: foreign
keys here are declared and never turned on, so these two statements are the
whole of it, which is why both now have a test.

A BOARD SAVED BEFORE THIS COLUMN EXISTED carries no project, and is listed
alongside the open project's own rather than hidden -- a board that cannot be
found is indistinguishable from one that was lost -- and re-saving it is not
refused. The project check applies only when both sides name one.

An empty projectID still returns everything, which is what the storage and
backup views ask for and what a board menu never does. With no project open the
menu is empty rather than complete: listing every project's boards in front of a
reader who has said nothing about which project they mean is the offer being
withdrawn.

WHAT DID NOT COME OUT, and why. The plan expected `runOfGround`, the `"current"`
branch and `liveAreaId`'s second question to go once every product returned a
run id. They stay: the sentinel had two causes and only one is fixed. Every
product returns its id now, but a run whose save FAILED returns none -- that is
saveRun withdrawing its claim, deliberately -- so the ground still has to answer
for it. And retained areas are keyed by the ground either way, so the inversion
is still what resolves them.

The comments there asserted the fixed half in the present tense -- "only a
classification fills that field", "a standalone product leaves it on the
sentinel". Corrected to say what is true now and what the case that remains
actually is, per the standing rule on stale comments. `liveAreaId`'s sentinel
job is restated the same way: it no longer names an example area, because there
are none.

gofmt clean, go vet clean, Go suite passes under -race, tsc clean, 289 frontend
tests pass, check:types clean.
The last step, and the one that makes the model visible: without it the
hierarchy existed in the database and nowhere a reader could see it.

The hub grouped by project alone and put every run of it in one flat grid. That
is the shape that let a project holding several fields read as a single subject
-- fifty-eight runs under one name, with nothing saying which ground each was
over. The project's tab now opens on its GROUNDS, each with the count of runs
measured on it, and opening one lists those runs under a way back.

A ROW FOR THE RUNS OF NO GROUND, shown only when there are some. A run restored
from a shape that was never made an area carries no area_id, and without a row
to select it would be listed nowhere: in the database, absent from the only
place that lists runs. An empty category is a question a reader has to answer to
learn there was nothing in it, so it does not appear when the count is zero.

Leaving a project clears the selected ground with it. An area belongs to one
project, so a selection carried across matches nothing: an empty list under a
heading naming a field that is not in this project.

THREE PIECES OF TEXT THAT HAD BECOME FALSE, corrected rather than left:

The project header printed `label || area_id || "Custom polygon"` under the word
AOI -- one shape per project, the model being replaced. A project working a
dozen fields showed a single line naming one of them. It says how many grounds
the project holds, and those three columns lose their last reader.

The delete-project dialog promised "runs stay on disk but become unassigned".
That was true when DeleteProject detached them; it takes them now, with the
areas and their rasters. A dialog that promises the opposite of what the button
does is worse than no dialog.

The tab said "Runs" and opened on runs. It opens on areas.

DOCUMENTATION. USER_GUIDE section 2 described a project as grouping "an AOI",
and section 3 offered "Areas A / B / C" as the first way to pick one; the
suggested first run said "Select embedded area A". Rewritten around project >
area > run, including that a project is required before drawing and why.
ARCHITECTURE dropped the `areas/` row and now says the polygon is the only way a
request names a ground. API.md loses ListEmbeddedAreas, gains the areas and
project-scoped board methods, and its PredictRequest example no longer sends
`area_id: "A"`. TROUBLESHOOTING said the embedded areas could use a local
MapBiomas raster; it now says what the fetch actually does. Four files still
called the map library Leaflet, which it has not been since the MapLibre move --
corrected in the docs, left alone in the code comments that describe what
Leaflet used to do, because those are history and read as such.

gofmt clean, go vet clean, Go suite passes, tsc clean, 289 frontend tests pass,
check:types clean.
The three columns that made a project carry one shape -- polygon_geojson,
area_id and label -- lose their last readers and their place in the table. They
were written from whatever happened to be on the map while the project was open,
so a workspace holding a dozen fields still showed one line naming one of them,
and the run made in it inherited that line rather than the ground it was over.
That is the confusion this whole change set exists to remove; this is the part
of it that was still in the schema.

WHAT REPLACES THEM is last_area_id, one question instead of three: which ground
to resume on. It is a cursor, not a property -- DeleteArea clears it on every
project pointing at the area, and an id that resolves to nothing is read as no
cursor rather than as an error. Schema 5 drops the three.

UpdateProjectAOI, which wrote all three from the map, is SetProjectLastArea.
`syncProjectAoi` in the frontend goes with it, and its three callers each had a
different reason to exist:

- After a composition and after a classification it wrote the map's polygon onto
  the project. There is nothing to write; the refresh beside it stays, because
  the counts do change.
- `applyAoiRename` wrote the new name to projects.label, so renaming a second
  field overwrote the first. It renames the AREA now, through the store, which
  is where the name belongs and what makes it survive a restart. With nothing
  catalogued on the map -- a geometry adopted from an opened run -- there is no
  row to rename and the label stays a session-local display name, which is what
  it was for every shape before areas existed.

OPENING A PROJECT resumed on its polygon. It resumes on last_area_id, falling
back to the project's first ground, and the composition-in-view test reads that
same shape rather than the project's.

THE HUB CARD DREW THE PROJECT'S OUTLINE and could not honestly go on doing it.
It shows the count of grounds instead of a thumbnail of one of them: drawing
them all would be a query per card in a grid, and a dozen outlines at 64px say
less than the number does. ListProjects counts areas the way it already counted
runs and overlays. Project search matched `p.label` too; a project has grounds,
each with its own name, so it matches the name.

`resolveAoiDisplayLabel` took `projectLabel` as its second candidate. It takes
`areaName`, resolved from the run's own area.

`resolveProjectGeometry` is `polygonFromRow`. It stopped reading projects two
commits ago -- both callers pass RUN rows -- and a helper named for a row it no
longer touches is the kind of thing that sends a reader looking for a project
geometry that does not exist.

TestProjectAOIUpdate is TestProjectLastAreaCursor. It asserted that a malformed
polygon was refused and left the previous AOI intact; there is no polygon on a
project to refuse, so what is left to pin is that the cursor is stored trimmed,
does not disturb the project's own fields, and can be cleared.

gofmt clean, go vet clean, Go suite passes under -race, tsc clean, 289 frontend
tests pass, check:types clean.
FOUND ON THE RUNNING APP, not reasoned about. After the previous commit the
developer's database reported user_version = 5 while projects still declared
polygon_geojson, area_id and label. The gate had closed over three columns that
were never dropped and could never be dropped again: the file said the work was
done, and nothing would reconsider it.

HOW IT HAPPENED. schemaVersion was raised to 5 in one edit and the drop it
stood for written in the next. `wails dev` rebuilt in between. That build opened
the database, found at = 4 < 5, had no step to run for the new number, and
recorded 5.

That is not a mistake in the drops, and fixing it by hand would leave the shape
that produced it. It is what a gate on a number does when the number can be
raised without the work -- and every file opened by such a build is permanently
wrong in a way no later version bump repairs, because the gate it needs has
already passed.

THE FIX IS THE ARGUMENT addColumns ALREADY MAKES: "the table is asked rather
than the version trusted, because on every database already in the field the two
disagree." dropColumn asks the table too, so the drops run at every Open. The
cost is four pragma_table_info queries; what it buys is a file that ends in the
shape this build believes it has, whatever its version says. The version goes
back to recording what has been considered rather than standing as the only
evidence of what exists.

The developer's database repaired itself on the next boot, which is the
behaviour this is for.

TestDropsRunOnADatabaseAlreadyAtThisVersion builds exactly the broken file --
the four columns present, the version already current -- and asserts Open
removes them. Checked that it fails when the gate is put back: all four columns
survive.

gofmt clean, go vet clean, Go suite passes.
… starts

The button greyed out with no raster on screen and no AOI, saying "Nothing to
work on: draw an area or run something first". So the only way into the studio
was to do the work outside it first -- the opposite of what that surface is for,
since it carries a globe to draw on and a run graph to run from.

THE RULE WAS RIGHT WHEN IT WAS WRITTEN. The board could only ARRANGE runs: a
surface mounted over the map with nothing on it and no way to put anything
there was a dead end, so the entry was refused. Drawing and running moved onto
it, and the refusal outlived its reason.

The rule was already admitting the case. `boardAsked` existed as an exception
for "a studio opened by name -- a saved whiteboard, or a session that opens on
it by preference -- stands with nothing on it, because that is the state it
necessarily starts in and its own run band draws an area and starts a run
without leaving it". That is the general case, not an exception, so the whole
condition goes: boardOpen is the state and nothing else.

WHAT ELSE THAT CLOSES. The old condition read visible layers, so the sidebar's
eye could hide every one of them and unmount the surface holding the control --
taking the search field, the overlay tools and the way back in with it. Two
clauses had been added over time to patch that; none of them is needed once the
board stays up because it was opened.

boardHoldsOtherAreas existed only to keep the board mounted when the map's own
result was discarded while another area was on it. Nothing closes the board now
but the toggle, so it has no readers and goes.

An empty studio is not blank: BoardSurface always mounts the live area, so the
globe is there to draw on and the run graph is there to run from. Drawing still
requires a project, and refuses with the reason, as it does on the map.
With the studio open, nothing on screen named the project. The title bar
withholds its project switcher there, and the reason it gives is that the studio
"carries its own data-block naming the board that is loaded" -- which it does,
and which is not a substitute: a board is not a project.

THAT MATTERS MORE IN THE STUDIO THAN ANYWHERE ELSE. It is where areas are drawn
and runs are made, and both are filed under the active project. Work landing in
a project the surface never named is the failure this whole change set exists to
remove -- fifty-eight runs from several fields inside one project, because
`activeProjectId` is sticky session state and nothing on screen said so. Every
other surface names it. The studio was the one still reaching that failure, and
it is the surface that produces the most.

A CRUMB, NOT A SECOND SELECTOR. The title bar's concern is real: two dropdowns
on one row, each saying what is open and meaning something different by it, is
worse than one. Written as `project > board` the relation is stated instead, and
there is still exactly one control on the row.

With no project open it reads "No project", drawn as an absence rather than as a
project called that. It carries more weight here than in the title bar: without
one the studio can neither draw an area nor file a run, so the crumb is the
reason the next attempt will be refused.

The title bar's comment is corrected rather than left: it stated a reason that
was only half true.
…ced it

Reported from the window: a solar run over a drawn area, its raster on the
board, and Save refusing with "none of these areas carries one yet". The
database was right -- the run had its project, its area and the label
run-drawn-20260830-172632. The board could not see it.

WHERE IT WAS LOST. `resultWithWater` spreads `props.result ?? EMPTY_RESULT`, so
`run_id` came from the classification and from nothing else. A standalone
product leaves `props.result` null, so the merged object described a solar run
and carried no id for it; the board reads `result.run_id || "current"`, got the
sentinel, and its own filter drops members on the sentinel.

Stage one gave all seven products a run id precisely so this could stop
happening, and nothing on this side picked it up: the standalone handlers read
`res.run_id` solely to decide whether their toast said "(saved)". All seven set
`currentRunId` now, and the merged result carries it.

That fixes a second reader for the same reason. Saving a composition writes
`run_id: currentRunId ?? ""`, so a composition applied over a solar or flood run
was filed against no run at all -- the defect its own comment describes.

THE NAME BESIDE IT, "run-untitled", was a separate fault in the same row and
would have survived the fix. `displayRunLabel("")` answers "run-untitled", which
is a value rather than an absence, so the `|| title || "Unnamed area"` chain
after it could never run: every row whose run the list could not answer for was
called the same thing, whatever the map called its ground. The run label is
asked for only when there is one. Still reachable two ways -- the list has not
refreshed, or the save was refused and there is no id to find -- and in both the
ground is the honest answer.

Corrected a comment that claimed "only water records a run_id on its payload, so
solar and flood would otherwise be permanently anonymous". True when written,
and the reason that fallback exists; all seven carry one now.
It had three. The database, the store and the bindings said `whiteboard`; the
code said `board`; the interface said "studio" -- and all three appeared within
a few lines of each other, "Nothing for a board to record" sitting beside
`Studio "X" saved.` Two names for one thing is what this whole change set has
been removing.

`studio` wins because it is what a reader sees and what the surface is.

DOWN TO THE TABLE, which is the part that was asked for. `whiteboards` ->
`studios`, `whiteboard_members` -> `studio_members`, `whiteboard_id` ->
`studio_id`. store.Whiteboard is store.Studio, WhiteboardMember is StudioMember,
and the five bindings are SaveStudio, ListStudios, GetStudio, RenameStudio,
DeleteStudio. internal/store/whiteboards.go, app_whiteboards.go,
lib/whiteboards.ts and components/whiteboard/ are all renamed with git mv.

THE RENAME RUNS FIRST, BEFORE ANY CREATE TABLE, and that ordering is the whole
of its safety. Every schema block here is CREATE TABLE IF NOT EXISTS, so running
them against a file still holding `whiteboards` makes an empty `studios` beside
the populated one and every reader finds the empty one -- no error, no missing
table, just studios that are gone. TestABoardSavedAsAWhiteboardOpensAsAStudio
builds the old file and asserts the rows, the arrangement and the member's own
name come through; moving the renames after the CREATE makes it fail.

A FILE CAN HOLD BOTH NAMES, and it is not hypothetical: it is what this author's
database was in when the rename landed, because a dev build compiled with the
new schema and without the rename. Refusing there means a store that will not
open with the work intact on disk behind it, which is worse than either name. An
EMPTY destination is dropped and the rename proceeds -- a CREATE made it and it
holds nothing. A POPULATED one is refused, naming the pair: which of two full
tables is the work is not something this can answer. Both are tested.

The renames are ungated, like the drops beside them and for the reason that
commit gives: a version number can be raised without the work, and a file that
slips through such a window is one no later gate reopens. On a database already
renamed this is three schema queries.

The old index names are dropped rather than renamed. No index is addressed by
name anywhere in this package, so renaming them would be churn with no reader --
but leaving them would give a file two indexes over one column.

USER-FACING TEXT loses "board" too: "Remove from the studio", "Add to the
studio", "Nothing for a studio to record". The Board* component names stay --
BoardSurface, boardScene, boardMemory -- because those name a surface a reader
of the code follows, not a second word for the subject.

CHANGELOG.md is left alone. Those scopes are what the commits said at the time,
and rewriting them would falsify the record.

Verified against the live database: a studio named "compara RJ MA" came through
the rename with its member and its project.

gofmt clean, go vet clean, Go suite passes under -race, tsc clean, 289 frontend
tests pass, check:types clean.
It opened over Brazil at zoom 1.6 every time, however far the reader had
travelled on it, while the work map beside it came back where it was left. The
two show the same planet.

The memory already existed and the globe was the one surface not wired to it:
`initialView` and `handleViewChange` are threaded through MapScreen to the map
already, and the comment on that pair says why there is one of them rather than
two -- "a pan made on the energy screen [would be] lost on the way back to the
map, or the reverse, depending on which one last committed". A globe with its
own memory would be a third way to lose one.

START_ZOOM and the centre over Brazil become the fallback rather than the
opening: what a session with no memory gets.

Reported on moveend, not on move. What the session remembers is where a gesture
ENDED, and writing per frame of a drag would be several hundred writes for one
of them. Through a ref, because the map is built once and its effect closes over
the callback it had at mount; `initialView` is read once there too, as the work
map reads its own, since a prop that moved the camera on every change would
fight the reader's hand.

tsc clean, 289 frontend tests pass.
…ures

Right-click a plane in the viewport and it offers "Send to the map". The raster
is drawn on the work map at its own extent, above the rest.

WHY THIS BELONGS. The studio lifts rasters off their coordinates so two grounds
hundreds of kilometres apart can be read side by side -- which is what it is
for, and also what it costs: a plane here answers "how do these compare" and
cannot answer "where on the ground is this". Sending it back is the second
question, asked of the plane the reader is already pointing at, which is the
argument that put every other entry in this menu.

WHAT TRAVELS is the RasterLayer itself -- image, extent, opacity, its smoothing
setting -- not a reference. The map has no account of the studio's arrangement,
and the run a plane belongs to may not be the one the map is showing; "the third
layer of area two" means nothing on that side. Handing the layer over resolved
also means the class boundary drawn on the map is the one the reader was looking
at, since `smooth` decides where a boundary IS.

A TOGGLE, like solo beside it and for the reason that entry gives: the label
says which way it goes, so sending and taking back are one row rather than a
one-way action with its undo somewhere else.

HELD IN App, NOT IN THE STUDIO, because the map outlives it. State kept by the
surface that sent the raster would be unmounted by the very gesture of closing
the studio to go and look at it.

ONE AT A TIME. Sending a second replaces the first: this is "put that one over
the ground", a question about one raster, and a stack of ad-hoc overlays with no
ordering control is a picture no one asked for.

TWO WAYS OUT, because one is a dead end. The plane menu is the shape of the
gesture, but a raster left on the map with its only control inside a surface the
reader has closed is a mark they cannot lift. Overlay Tools is titled for what
is drawn over the map, so a row appears there naming it, with Take off. Only
while something is sent: an empty row explaining a feature is a permanent cost
paid for an occasional state.

Leaving a project takes it, beside the retained runs and for the same reason --
a raster of the project being left, drawn over the next one's ground, is that
contamination in its most visible form.

tsc clean, 289 frontend tests pass, check:types clean.
…eloading

Found in the window's console while chasing something else: MapSurface was
throwing "Style is not done loading", the error boundary was catching it, and
the surface was recreated from scratch -- a new map, a new style, and every
overlay gone. It reads as rasters that simply do not appear.

`ready` is what the effect gated on, and it does not promise a loaded style. It
is set on the map's `load` event, which fires once; the style is touched after
that, and while a replacement is in flight `addSource` throws. Thrown from an
effect that has nothing to do with the basemap, it left the boundary as the
first thing to notice.

CAUGHT RATHER THAN PRE-CHECKED, and the difference is the whole of it.
`isStyleLoaded()` is the obvious guard and it is the wrong one: it reports false
while any SOURCE is still loading, and the hillshade fetches DEM tiles on every
pan -- so gating on it drops the overlay for most of the time a reader is
moving. That guard was written here first and turned an intermittent failure
into a permanent one, which is how it was found out.

Deferred rather than dropped. `styledata` is how the style says it has settled;
the retry runs the effect again with the same `resolved`, where dropping would
leave the map missing an overlay it had been handed.

tsc clean, 289 frontend tests pass.
…d for

TWO THINGS WERE WRONG WITH THE FIRST VERSION, and the first of them is why it
read as a control that did nothing.

IT SENT TO THE WRONG MAP. "Send to the map" was built against the work map on
the home screen -- the one surface the reader is NOT on while they are in the
studio. The layer was added correctly every time, the camera even flew to the
ground it measures, and all of it happened behind the surface the press was
made on. The map that was meant is the globe in this room, where the areas
already are. That is where it draws now, under the area outlines so they stay
readable over it, and the studio no longer closes itself to reveal a result
that was never there.

The plumbing for the wrong target comes out with it: the state leaves App, the
props leave MapScreen and MapSurface, and the Overlay Tools row that existed to
take a raster off the work map goes. It was held in App on the reasoning that
"the map outlives the studio" -- true, and irrelevant: the globe is part of this
surface, so a raster on it has nothing to outlive.

IT TOOK ONE AT A TIME, on my argument that "a stack with no ordering control is
a picture no one asked for". The ordering was there all along: RasterLayer.order
says which raster reads over which -- a classification over surface water,
confidence over the classification -- and it is the same order the work map
stacks by. The globe sorts on it, so several read here the way the same set
reads there.

ALLOWING SEVERAL EXPOSED A REAL DEFECT IN THE KEY. This filed a sent raster
under the layer's own id, with a comment claiming "the same raster reached from
a second area is the same thing on the ground". That is false the moment there
is more than one area: two runs over two fields both call their raster
`solar:terrain`, so the second would have replaced the first in silence while
the menu marked both as on. The key is the area-and-layer pair, and not a new
one -- `sceneKey` is what the rest of this surface already files per-plane state
under.

The camera fits the UNION of what is drawn rather than the newest. Sending a
second raster over another field and being taken to it alone would hide the
first, which is the comparison being made; two over the same ground give the
same box, so nothing moves.

The entry names its destination: "Show on the globe" / "Take off the globe".

tsc clean, vite build clean, 289 frontend tests pass, gofmt and go vet clean.
@rexionmars

Copy link
Copy Markdown
Owner Author

Closing: this work is in main, and it did not arrive through this PR.

The stack had grown ten branches deep and main had fallen behind the
whiteboardsstudios rename this branch carries — far enough that checking
main out and running it against a migrated database recreated the legacy
tables and shut the store, which is how the gap stopped being theoretical. The
whole stack was consolidated in one merge rather than walked back up the chain
of bases:

  • c80f435 — Merge: the studio, the pointer set, and two stores that would not open

feat/studio-corrections is an ancestor of main as of that commit, so the
diff here is empty against it. The description above stays as the record of what
was decided and why: the project › area › run chain, the clean cut without a
migration, and the two defects found by running it.

Two things landed after this branch and are worth knowing about together with it:

  • The version-gated migration argument made here was extended. An empty legacy
    table beside a populated destination no longer refuses the rename — the shape
    an accidental downgrade leaves, and the one that shut the store above.
  • golangci-lint is still not installed on the machine this was developed on,
    so that part of the gate remains unrun, as this description already noted.

@rexionmars rexionmars closed this Aug 31, 2026
rexionmars added a commit that referenced this pull request Aug 31, 2026
…aming it

golangci-lint reported `(*Runner).mapbiomasPath is unused`, and it was right in
a way that reached the screen.

The function resolved a MapBiomas raster by file name under
global/data/mapbiomas. Its only callers were the three embedded example areas,
removed in 8ead8fa; it outlived them with no caller anywhere. RepoRoot beside it
still described itself as "where the legacy MapBiomas rasters are looked up",
and the environment screen told the reader that the repository root blocks "the
local MapBiomas rasters for embedded areas".

None of that was true any more. Nothing in Go reads a MapBiomas raster from the
repository root: what is left under it is the checked-out virtualenv and the
model directory the runner falls back to, so that is what the screen says now.
A path report that names a consumer which does not exist is worse than no
report, because it is read by someone trying to work out why a run failed.

CI IS WHERE THIS WAS FOUND, and that is the point worth recording. The gate this
repository documents is gofmt, go vet, golangci-lint and go test -race, and
golangci-lint had not run once during the work that landed in main -- it is not
installed on the machine, which was noted in three commit messages and in PR #69
as a known gap. The gap was not theoretical. It is runnable without installing
anything:

    go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.13.1 run ./...

which is the version CI pins, and reports 0 issues here.
@rexionmars
rexionmars deleted the feat/studio-corrections branch August 31, 2026 12:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant