NAOMS Devlog

Building a sovereign, local-first memory & identity system โ€” in the open, honestly.

Naming the format your machine writes

The picture-to-PDF path names its output format on the command line, so the encoder is chosen by that declaration and not read off a generated temporary filename that has no extension

Technology Architect free July 21, 2026ยท9 min readยทhonest-surfaces
TL;DR Your machine turns a picture into a PDF using a converter program installed on that same machine, and the command that runs it names the output format outright rather than leaving it to be guessed from a filename. That naming is what makes the bytes it writes agree with the type it reports.

What a converter is here, and what it is allowed to claim

Turning one kind of file into another has to happen somewhere, and in most software it happens on somebody else's machine: you hand the file up, a service hands a result back, and what actually ran is not yours to ask about.

Here it runs on your own machine. Some of it is built in and travels with the app. The heavier conversions โ€” including the one this piece is about โ€” are handed to separate programs installed on that same machine: ordinary command-line tools of the kind a person might run by hand. For those, a consequence follows that the design has to take seriously: what your machine can convert depends on what is installed on it, and no two machines have to agree.

So the system has to find out rather than assume. The first time a conversion is asked for โ€” or the list of possible ones is โ€” it probes for those outside tools, running each one to see whether it is there. A tool that is present and can be run counts as available; the version it prints is noted, but it is running at all that decides the question. Each converter declares which tools it can use, and one that names tools is loaded when at least one of them is available, and left out when none is. The conversion it would have offered is then simply not on the menu. (A couple of converters name no outside tools and are always present.)

That is a deliberately modest promise, and it is the honest one. A system that advertises a conversion it has no way to perform has told you something false about itself before you have asked it for anything.

Turning a picture into a PDF was added on this day, backed by ImageMagick โ€” a long-established open-source image toolkit of exactly the sort already sitting on a developer's machine. Before this there was picture-to-picture and document-to-PDF, and nothing joined the two. Everything below is about that one path, in that one direction.

What actually happens to the bytes

flowchart TD
  A["your store โ€” your files, encrypted, on your own machine"] -->|"decrypted out"| B["scratch file in
(generated name, no extension)"] B --> C["converter program
installed on that machine"] C --> D["scratch file out
(generated name, no extension)"] D --> E["split into chunks; each named by a hash of
a per-file random value plus its contents,
then encrypted as it is written"] E --> F["a record of the store is appended to your log"] F --> G["the view replayed from that log
gives the file an address"] D --> H["receipt: hashes, size, which tool, how long โ€”
and the type that was asked for"]

Your files live in an encrypted store on your own machine, and the converter does not reach into it. The file is decrypted out of it first, onto a scratch file on disk; the tool reads that path and writes to a second scratch file; and it is that second file which is taken back into your store afterwards. Both scratch paths are generated by the system, not chosen by anyone โ€” a short prefix and sixteen random bytes written as hex. Hold on to that detail; the whole of the next section lives in it.

One default and one bound worth knowing, neither of them asked about: the page is written at a fixed 150 dots per inch, and if a maximum dimension is requested the picture is only ever shrunk to fit it, never enlarged.

Going back in, the output is split into chunks. Each chunk is named by a hash โ€” but a hash of a per-file random value combined with the chunk's own contents, taken before any encryption โ€” and then encrypted as it is written. The salt is on purpose, and the store's own note gives the reason: if identical files produced identical names, anyone who could see those names could test whether you were holding a particular file. Salting removes that. The price is that two separate files with identical contents are not recognised as the same file. The file's own identifier is a hash of the small manifest describing it, and that manifest carries the random value too.

Then a record that the file was stored is appended to your own log โ€” the append-only history this system keeps โ€” and the view rebuilt by replaying that log โ€” the projection, the current state derived from the history rather than kept alongside it โ€” is what gives the file an address you can open later. That split, appending to the log and reading from the projection, is the shape underneath most of this system. The result is recorded under a heading for converted output, tagged with the type it was made from โ€” and given, as its own recorded type, the type that was asked for. That is the same value the receipt reports, and it is worth holding on to.

Finally the caller gets a small structured receipt: the hash of what went in, the hash of what came out, the identifier of the new file, its size, which tool did the work and how long it took. And its type โ€” which is the type that was asked for, faithfully recorded. It is not a measurement of the bytes that were produced. Those two are ordinarily the same thing, and the rest of this is about the morning they were not.

What went wrong

The command that ran the image tool was assembled the obvious way: the path to read from, a couple of options, and the path to write to.

That tool decides which format to write from the extension on the output path. Give it a name ending in .png and it writes a PNG; .pdf and it writes a PDF. The convention is so ordinary it is easy to look straight past.

The scratch paths this pipeline generates have no extension. That helper is not new โ€” it has produced names the same way since April, months before this converter existed โ€” and it ends its names with random hex. There is no dot, and nothing after it.

So there was no extension to read, and what came out was image bytes. The repair records the tool as having re-emitted the source picture rather than encoding anything; we have that diagnosis for it, not an independent comparison of the two files, and it is worth saying which of those this rests on.

Everything downstream then worked exactly as designed. Those bytes were chunked, encrypted and stored. (The log-and-projection step described above joined this path later the same morning; through this window the bytes were simply stored.) And a receipt came back saying the result was a PDF โ€” because a PDF is what had been asked for.

The stored file held a picture. The label beside it said PDF. Open it and your reader would tell you the file was damaged, or show you the photograph, and either way the system had said one thing and done another.

flowchart TD
  A["the system generates an output path:
a prefix plus 16 random bytes โ€” no dot, no extension"] --> B["image tool runs, writing to that path"] B --> C{"which format
should it write?"} C -->|"before: taken from the name โ€” nothing there to read"| D["image bytes out โ€”
receipt says PDF"] C -->|"after: named outright on the command line"| E["PDF bytes out โ€”
receipt says PDF"]

This is the specific kind of failure this project treats as the serious one. Nothing crashed. What happened is that a claim was recorded which the bytes did not support โ€” and a system whose value rests on records you can check is badly served by records that are quietly wrong. It is the same principle as never filing a guess about your photo as a fact, and the same reason a system that lies to itself cannot be whole.

Why the check that was run did not catch it

The converter had been exercised by hand before it landed, and it had genuinely produced a PDF. That check was run at a shell prompt, with an output path typed out in full โ€” ending in .pdf.

Given that path the tool infers PDF from the extension, writes a PDF, and the check passes. It passed for a reason the pipeline's own generated paths never reproduce.

The instrument differed from the thing it was measuring in exactly the one variable that mattered, and it differed in the direction that produces a pass. The end-to-end check that would have exercised the real path did exist, but on that day it needed a background service to be up, and quietly stood down without one, so it was not what stood between this and the store.

The window was short โ€” the converter landed early in the morning and the repair was in the same day's work, a few hours later โ€” and the path that hands a converted file back into a conversation was not built until four days after that. But that is timing, not a control. Nothing about the shape of the hand-run check would have caught this on a slower day.

How it was fixed

By saying the format out loud.

The tool accepts an explicit format prefix on its output path, and the argument list now carries one. That forces the encoder regardless of what the filename looks like. There is nothing left to infer.

before:   โ€ฆ <input>   <options>   <output-path>
after:    โ€ฆ <input>   <options>   pdf:<output-path>

The obvious alternative was to give the scratch file a .pdf ending instead. That is not what was done, and the difference is the whole point of the repair: it would have left the filename as the thing deciding the format, and merely arranged for the filename to be right this time.

Note what was deliberately not changed, because the asymmetry is the point. The input side was deliberately left alone: the tool goes on working that format out for itself, which the repair notes it does by reading the short signature at the front of the file. That is an external program's behaviour rather than something measured here, but the reason to leave it be is worth naming: reading a format from the content is a measurement of the thing itself, while reading it from a name is a guess about it. One of those can be wrong while everything around it looks correct. The repair was to stop guessing on the side where the answer was already known.

It is worth being exact about what this does and does not settle. It removes the place where this defect lived โ€” the one point on this path that let the bytes come out as something other than what was requested: the step where a filename could decide the output format. It does not turn the receipt's type field into a measurement โ€” that field still records what was asked for, and the fix works by making the request and the bytes agree rather than by checking them against each other afterwards.

A check now covers it on any machine that has the image tool, and it can fail there. It builds a real image, converts it to an output path deliberately generated without an extension โ€” the same shape production uses โ€” then reads the resulting file back and requires its first five bytes to be the marker a PDF begins with. Not the receipt, not the converter's return value: the bytes on disk. Remove the format prefix and the tool writes image bytes, those five bytes stop matching, and the check goes red.

On a machine without the tool that check is skipped rather than run, which is the honest arrangement for a converter whose availability is a property of the machine. A skip is visible as a skip. It is not counted as a pass.


Written by AI agents from real project logs; owned and edited by Mujo.

โ† more in Technology   home โœฆ   all โ†’