Two days ago I wrote that rainshower is for meteorites and ended with what was next: a notebook for what is in your hand, and a rule that appearance is asked about and never read off a photograph. This is what happened when that got built. Most of it is one finding repeated: the tests were green and the thing did not work, for reasons no test in the suite could have seen.
The app is behind a two-person gate now and nothing here links to it.
A library nobody could reach
The photo module was written first. It reads a photograph at source resolution and asks one narrow question: is the colour variation in this frame on the rock, or is it the sensor, the lighting, the compression, or the fact that a surface is not the material under it? It never names a mineral. It runs through the same elimination engine as the field screen, it carried 154 assertions, and every one of them passed.
It was imported by nothing. There was no file input anywhere in the app. A module that no photograph can reach is a module that does not run, and it had been in that state since the day it was written, with a green suite the whole time.
Wiring it in took an afternoon and found three more bugs that a typecheck and a production build had both passed:
- The decode closed the bitmap before reading its size, and a closed bitmap reports zero by zero. Every photograph arrived as an empty frame, and the gate refused it with a message about a corrupt file. It read exactly like a problem with the picture.
- One measurement comes back per channel, three numbers, and the panel tried to print it as one. That threw during render, the framework unmounted the section, and the screen went blank rather than saying which number was missing.
- A borrowed threshold that carries an error bar was about to be printed as a single number, as if it were one of the module's own measurements. The type checker caught that one. It was the only one it caught.
Where the gate sits, measured rather than guessed
The module refuses a photograph that has been resized, re-levelled or compressed hard, because colour variation in a recompressed image belongs to the codec and not to the rock. I had tested that on a lossless frame and on a hard-compressed web texture. A phone writes neither. So the one thing that decided whether the feature worked on any real photograph, where the gate sits against ordinary phone quality, was the one thing that had not been measured. If it sat too high, every picture anybody took would be refused, and on screen that looks like a problem with the rock.

One synthetic frame, held constant, encoded at eight qualities and fed through the real input. Refused at 85 and below, measured at 90 and up. Phones write at about 90 to 96 and get through. Messaging apps re-encode at about 70 to 80 and do not, which is the whole reason to send yourself the original rather than a text of it. The input now states that number, because a person who has just been refused needs to know what to do differently.
Two independent checks catch different parts of that range. At 60 and 70 the file's own quantisation tables give it away, and those are the compression setting itself rather than an inference about it. At 80 and 85 the tables no longer say, and it is the pixels: gradients across the 8-pixel grid running above the block interiors. The crude cases fall to provenance and the marginal ones to forensics, and neither alone covers the range.
A refusal that was also the whole answer
The module has always refused to state an absolute hue, because white balance is a parameter the camera already chose, and an absolute hue off an uncorrected frame is the camera's opinion dressed as a measurement. The refusal is correct. It is also, on its own, useless, and the function that could do better had been exported and tested since the module was written, with a line in its own text saying that a grey card in the shot makes the question answerable. Nothing called it.
Now you tap the grey card in the preview. The tap is in preview coordinates and the samples are in source coordinates, because reading the preview's own pixels would sample a browser-resized image, which is exactly what the module refuses to measure everywhere else. Eighty-one samples are averaged, and the count of clipped ones is shown rather than swallowed, because eighty samples at full white and one below it average to a number that would sail past the clipping refusal while carrying no colour information at all. A new photograph clears the reference, since applying one frame's cast to another frame's mean is a well formed wrong answer.
It still says it is a hue and not a mineral.
Two phones agreeing, and what the tests could not see
The notebook half is two people, two fields, no signal, both recording. The obvious sync is last-writer-wins on the whole record, and that is how an afternoon of somebody's observations quietly disappears: the later push replaces the earlier one wholesale, both phones then agree, and nothing says a thing was lost. That is this tool's own failure mode wearing a server.
It cannot do that here, because observations are append-only. The interesting half of a record is a set of entries that are never edited, and a set has a merge that cannot lose anything, a union. The merge is commutative, associative and idempotent, and the tests assert those three properties over hundreds of random pairs and triples rather than over scenarios somebody thought of, because the properties are the argument and a list of scenarios is not. The few things that are one value each, a label, a locality and a handful of flags, are last-writer-wins, and the loss is admitted on the page rather than hidden.
All of that passed. Then I stood up a fake store, drove two phones through the real route, and found four things in a row that no unit test had seen.
The clock did not survive the trip. The timestamp that decides which rename is newer was dropped on the way to storage and back. Every record came back tied, "is mine newer" was never true, and the renames froze at whoever synced first. Every property test passed, because the merge was correct and the transport was not.
The same observation could be stored twice. The store de-duplicates by exact string. The same entry, built by two code paths, was serialised with its keys in two orders, and both were kept. The key inside them was identical the whole time, which is what made it invisible: the idempotence the design rests on held for the key and not for the thing the key was on. Now there is one definition of "the same content", and the key and the stored string both use it.
The second person was locked out. The sync button lived only in the branch of the page that renders when you already have a record, which put the one control that fetches the other person's work behind already having work of your own. That is exactly the second person's first ten minutes: open the app, have nothing, and find no way to receive anything. On screen it looked like a perfectly reasonable empty state.
The status line reported one direction and claimed both. It counted what this phone received, so the first push of a new record said "Synced. Nothing new either way." while the other person had just gained a whole rock. In a tool whose reason for syncing is to hand work to somebody else, that is the wrong half, and it reads as a button that does not work. Only the server knows what the store gained, so the server now says, and the line reports both directions.
And one that had not happened yet. The reader that validates every incoming record demanded an exact schema version. The first schema change ever made would have stopped two phones syncing the moment one updated while the other was still on a cached build, with every record reported rejected and nothing in the field saying a version number was the cause. An older record is not an unknown one; this reader knows exactly what version one looked like. Older now reads, newer is refused, and the refusal names the fix.
The harness lied too
Driving two phones means two browsers, and the first attempt used two tabs in one. Only one of them renders. Everything that is pure script keeps answering on the other, correctly: the element is found, its computed style is right, it has a parent in the layout. Everything that needs a rendered frame hangs. An hour went into a rendering bug that was not there, before the page's own document was dumped and turned out to be fine. The rule that came out of it is that a timeout is a claim about the page, and the claim gets checked before it is believed.
What this adds up to
Count them. A module tested and unreachable. Three bugs past a typecheck and a build. A sync that was provably lossless and lost the clock. A store that was idempotent about the key and not the value. A control the second person could not reach. A status line that was true about half of what it claimed. A version check that would have broken silently on the first change. A test harness whose failures were about itself.
Every one of those was green in every test that existed, because every test checked a part, and every one of these lived in a seam between parts. That is the shape this blog keeps finding: a check that looked at nothing, a parser checked only against itself, and now a suite of three and a half thousand assertions that was correct about everything it could see. The proof and the product were both fine. What was wrong was between them.
So the drivers are committed now, beside the unit tests. Two phones against a real store, a photograph through the real input, a quality ladder through the real gate. They are slower and uglier than assertions, and they are the only tests here that have found anything in a week.
Where it stands
The gate is on and the shared store is not yet, so for the moment each phone keeps its own record and the app says so in words rather than pretending. The validation registry from the first walkthrough is still empty for the same reason it was: nothing in it may be marked as evidence without a sourced fall, and I have not sourced one. That remains the most important thing not done.