Category: Uncategorized

  • Cloudflare, Supabase and pg_cron: the stack behind a one-person back office

    Cloudflare, Supabase and pg_cron: the stack behind a one-person back office

    By Farough Darakhshan · 10/20/2026

    For most of this year, the systems that run the back office of a water-damage restoration business lived on a Mac mini under my desk. Invoice tools, a dashboard poller, a job page, an assistant, a dozen scheduled scripts.

    It worked until it didn't. When the Mac went to sleep, or its disk hung, everything stopped at once. The invoice engine was reachable only through a tunnel to that one machine. The poller that moved jobs from the claims dashboard to the engine stopped polling. Nobody got an error. Work just quietly didn't happen.

    On 9/30/2026 I decided to move it. This is what the stack looks like now and the failures I found moving it, most of which were silent.

    The rule: claim work runs on always-on cloud

    I track 500 to 600 insurance claims at a time through six stages: track, invoice, send, defend against the adjuster's cuts, collect, and hand unpaid files to counsel.

    The rule I set: anything that serves one of those six stages runs on always-on cloud infrastructure. That means Cloudflare, Supabase or Google Cloud. Nothing business-critical runs on a scheduled job on a Mac or behind a home tunnel.

    The rest of the hardware still has a job:

    • Shared web hosting runs WordPress sites, and only that.
    • Local machines, including the three local-model boxes, take bulk work that can wait: overnight photo reading, document sorting. Each of those jobs has a cloud model as a fallback.

    The claims dashboard I use most is built on Supabase. Cloudflare Workers handle the small services around it. Cloudflare D1, its SQLite database, holds the legacy claims records.

    Failure one: schedules that never fired

    On 10/01 I created a batch of new cloud jobs. Some were Cloudflare Workers with cron triggers. Some were GitHub repositories with an Actions schedule: block. Eight job repos in all.

    By the end of that day, none of the scheduled runs had happened. The Worker crons on Workers created that day never fired. The Actions schedules in all eight repos missed their times. Both platforms had accepted the schedule. Neither reported an error. The dashboards showed jobs that looked scheduled and were dead.

    So I picked one scheduler I could watch and moved everything to it.

    The fix: pg_cron is the only scheduler

    Supabase includes pg_cron, a scheduler that runs inside the Postgres database. Every cloud job is now triggered from there. A pg_cron entry does one of three things:

    • POSTs to the Worker's authenticated /run endpoint
    • calls GitHub's workflow_dispatch API to start an Actions workflow
    • starts a Google Cloud Run job

    Three rules came with it.

    1. One trigger per job. The Worker cron or the Actions schedule block gets removed. A second trigger is only allowed if the job has a run lock, so a double start is harmless.
    2. A job is not working until a scheduled run is observed. A manual run proves the code works. It proves nothing about the schedule. The first real proof was a pg_cron run at 09:00 UTC on 10/01.
    3. Anything else that schedules gets the same test. One Cloud Run job is driven by Google Cloud Scheduler, which showed as enabled. Enabled is not the same as fired, so its first run gets checked too.

    The broader lesson is that a scheduler's "accepted" is not evidence. The only evidence is a run that happened at the time it was supposed to.

    Failure two: D1 writes that wrote nothing

    Cloudflare D1 is SQLite at the edge, and it is pleasant to work with. It also has sharp edges that look like silence.

    On 9/7/2026, two separate debugging detours had the same cause. A write through wrangler d1 execute reported no rows changed and no error. The write had been rejected by a CHECK constraint, a rule on a column that lists the only values it accepts. Wrangler prints that failure in a shape a naive JSON parse throws away.

    The traps were plausible English values that were not on the list. The column for a claim's work stage lists ten stages, from "Needs Review" to "Send to Legal". None of them means finished, so "Complete" is rejected. The column for the contracting company spells names with "dba" and no commas or periods, so the name as it prints on a contract is rejected.

    How I diagnose it now: run the write one column at a time. The column that fails is the one with the constraint. Then read the table's SQL from sqlite_master and use the exact strings.

    Failure three: a table rebuild that broke every foreign key

    SQLite cannot change some column definitions in place. The usual pattern is to copy the table to a new one, drop the old one, and rename. On standard SQLite you guard it with PRAGMA legacy_alter_table = ON.

    D1 accepts that pragma and ignores it. The rename step then rewrites the foreign key of every child table to point at the copy, and the migration drops the copy. Every child table now references a table that no longer exists.

    I proved this on the remote database on 9/8/2026 with a throwaway parent and child table, before touching real data. The first real attempt failed and rolled back cleanly. An earlier migration had used the same pattern on the main claims tables and happened to come through. I do not read that as proof the pattern is safe.

    Failure four: migrations split on semicolons

    My D1 helper sends one statement per call, so a migration file gets cut into statements first. Cutting on every ; tears apart any string that contains a semicolon. On 9/17 a note with three semicolons in it broke an UPDATE while the INSERT after it applied normally. That reads as a half-applied migration.

    Now note text uses commas, and files are cut on real statement boundaries.

    What I would tell another small operator

    • Put the work that earns money on infrastructure that does not sleep.
    • Pick one scheduler you can see, and trust it only after a scheduled run.
    • When a database write "succeeds" quietly, check the constraints before the code.
    • Test a destructive migration pattern on a scratch table on the real platform first.

    None of this is exotic. It is the plumbing that decides whether an invoice goes out on Tuesday or nobody notices it didn't.

    I'm Farough (Frank) Darakhshan, and these are working notes from building a restoration back office. More projects are at faroughdarakhshan.com/projects, and the public code is on faroughdarakhshan.org.

  • Supervisory Hours From the Photo Record

    Supervisory Hours From the Photo Record

    By Farough Darakhshan

    I write the final invoices for water-damage jobs and defend them when adjusters cut them. Supervisory hours are one of the lines that get denied as a matter of routine. The usual question is simple: how do you know a supervisor was there, and for how long?

    This note is about the rule I use to answer it, and why the answer is in the photos.

    Why history could not answer it

    The first approach we tested was predicting supervisory hours from past invoices. If a job of a given size and type usually carried a certain number of supervisor hours, a model should be able to estimate the next one.

    It could not. Tested against 18 held-out invoice lines, the predictions missed by a combined $49,152. Supervisory time does not follow job size closely enough to be estimated. It follows what actually happened on site that day.

    So the source had to be a record of what actually happened on site. A timesheet would do, if anyone kept one. What every job already had was a photo record.

    The rule

    On 10/01/2026 I set the rule that every invoice now follows.

    On site: a supervisor takes a CompanyCam photo within 15 minutes of arriving on the job each day, and a final photo within 30 minutes before leaving.

    On the invoice:

    1. Hours for a job on a day = the timestamp of the last photo minus the timestamp of the first photo.
    2. Add 30 minutes of drive time.
    3. Bill only in half-hour steps. Never minutes.
    4. Always round up to the next half hour. Three hours and five minutes bills as 3.5.
    5. Photos from anyone on the job count, not only the supervisor's.
    6. Times are read in Eastern time, per job, per day.

    A worked example with round numbers: the first photo of the day is at 8:10 AM and the last is at 11:25 AM. That span is 3 hours 15 minutes. Add 30 minutes of drive time for 3 hours 45 minutes. Round up to the next half hour: 4.0 hours on the invoice.

    Why each piece is there

    The 15- and 30-minute photo windows tie the photo record to arrival and departure. Without them, the first photo might be an hour after arrival and the span would understate the day. With them, the span is a fair bracket of time on site.

    Anyone's photos count because a supervisor on site is often directing a crew that is doing the photographing. If the technician's photo shows the work in progress at 2:40 PM, the job was staffed at 2:40 PM. Restricting the span to the supervisor's own camera would throw away real evidence.

    Half-hour steps, rounded up, keep the number simple to read and simple to check. An adjuster can take the photo times, apply the rule, and reach the same figure. A line billed in minutes invites an argument about each minute.

    Drive time is a flat 30 minutes. It is a fixed allowance, not estimated per trip.

    The day with one photo

    This is the part I changed my mind on.

    An earlier version of this method, used before the 10/01 rule, padded every workday with a 30-minute buffer before the first photo and another after the last. A day with only a single photo still got a full one-hour buffer.

    That gave a number for every day. It also gave a number for days the record cannot support. One photo is a moment, not a span.

    Under the current rule, a day with one photo has no span. It is flagged for the project manager to answer from what they know. It is never guessed.

    The result is an invoice where every supervisory hour computed from photos traces to two timestamps an adjuster can open and look at.

    The timeline document

    For each job, the timeline goes into the client's folder as a short document: a link to the job's photos, and one line per day with the first and last photo time. Something like:

    2/3/26 (first photo at 5:46pm – Last photo at 11:39pm)

    That document is the working record of crew presence I write the invoice from: the dates, the two times, and the photos behind them.

    Traps worth knowing

    The API pages short. If you pull this data from the CompanyCam API, a request for 100 projects per page silently returns 50. A short page is not the end of the data. Stop only on an empty page. One early run read 50 of 1,590 projects and nearly reported that a job had no photo project at all.

    Equipment hour meters are not a clock. It is tempting to use a dehumidifier's run-hour panel as proof of time on a job. Crews do not reset those meters between jobs, so a panel can carry a previous job's hours. Photo capture times, install and removal sheets with a matching address, and the first and last capture dates on the photo project are what actually date a job.

    Most jobs are photographed as bookends. Install day and removal day carry most of the photos, with little between. That is fine for dating equipment, but it means a monitoring visit with no photo has no supervisory span. The arrival-and-departure photo rule for supervisors closes that gap going forward.

    What I would pass on

    If you bill supervision on insurance work, find the record that already exists before you invent a new one. For us it was the photo timeline. The rule turns it into a number anyone can recompute, and the one-photo rule keeps the number honest.


    Farough (Frank) Darakhshan writes the final invoices for water-damage jobs in Northern Virginia and builds the tools that turn field records into invoice lines. More of his systems are on the projects page.

  • Aries: the office assistant on a Mac mini, and what it actually does

    Aries: the office assistant on a Mac mini, and what it actually does

    I'm Farough (Frank) Darakhshan. I work in water-damage restoration in Northern Virginia, and most of my day is paperwork: final invoices, adjuster email, and following up until a claim is paid. Aries is the assistant I built to carry part of that load. This post is what it is, how it is put together, and where it still falls short.

    Why a separate machine

    Aries started on my main Mac. Every time that Mac slept, restarted, or got busy with something else, the assistant stopped. So I moved it to its own Mac mini that does nothing else.

    That one change mattered more than any model upgrade. An assistant that is up only when your laptop is awake is not an assistant.

    How it is built

    There is no web server and no framework. Aries is about 20 scheduled jobs. Each job runs a recipe written in plain Markdown and writes its results to plain files: state, logs, memory.

    Every model call goes through one wrapper script. The wrapper does four things:

    • uses a key that belongs to Aries alone, with its own spending cap
    • routes each job to one of three model tiers, so cheap work gets a cheap model
    • writes a spend log, one line per call
    • detects refusals, so a job that got "I can't help with that" fails loudly instead of saving it as an answer

    Plain files sound primitive. They are the reason I can debug it. When a job does something odd, I open a text file and read what it saw.

    Since 10/01/2026 the scheduled jobs run on a local model on my own hardware instead of a paid API. The paid route is still there as a fallback.

    What it does every day

    The morning briefing. At 8:00 AM Aries emails me a briefing and sends a push notification to my phone.

    The mail pass. Every 15 minutes it reads incoming business mail, sorts it, and turns the parts that need action into to-do items.

    Drafts. For mail that needs a reply, it writes a draft in the mailbox. It never sends client or adjuster mail on its own.

    The to-do list. Its items land on a shared board my team can read. That board lives in the cloud, so it stays readable even when the Mac mini is offline.

    The rules it runs under

    The most useful decision I made was writing down exactly what Aries may do without asking.

    Inside its own house it acts freely: its own code, its own state and logs, its memory, builds, configuration, model calls. Outside that house, four things never happen without me: spending money, deleting customer data, sending external communications, and anything touching physical hardware.

    A detail that took me a while to see: the scheduled jobs carry their permissions in code. They never read the rules file that governs an interactive session on that machine. So loosening that file would not make the assistant more capable. It would only give an interactive session more reach into my mail. I left it tight.

    Reading other people's email safely

    An assistant that reads email is reading text written by strangers. Some of that text could try to give it instructions.

    So the jobs that read other people's mail call the model with no tools attached at all. The model can only return text. The jobs that do get the command-line agent, with its tools, are the ones whose input is text Aries wrote itself, like its nightly reflection.

    The reason is practical. The command-line agent blocks tools only by listing them one by one. An API call with no tools has nothing to block.

    Sending: report the real outcome

    Aries has its own path to send a message, and that path is deliberately narrow:

    • it can only message an allowlist
    • every real send needs an explicit confirm flag, and a dry run prints the message without sending
    • it writes a journal row before it makes the request, so a crash mid-send leaves a visible "attempted" record instead of nothing
    • it waits for the final status, delivered or failed, and reports that

    That last rule exists because of a real failure. An earlier report said a message was "sent, verified." The provider had accepted the request, and the message was never delivered. Accepted is not delivered.

    Text messages are refused outright for now, because the carrier registration that US business texting requires is not in place. The tool checks for that registration and unblocks itself once it exists. Nobody patches around it.

    What it costs

    On 8/10/2026 I measured it. The API key was paying for all 92 calls a day: about $3.10 a day, about $93 a month. That was a surprise. I had assumed the flat-rate subscription covered most of it. It covered almost none, because most jobs had moved to direct API calls.

    The lesson is boring and important. Measure the bill from the log, not from what the setup is supposed to do.

    Memory and learning

    Aries keeps two separate stores. One is a research corpus with a small local search index. The other is a learning store: things it was corrected on, patterns, notes.

    The learning log is append-only. A pre-commit hook refuses any change that would delete lines from it. Every six hours a governed export of what it has learned is injected into each job as a short block, capped at 4 KB. If that export is stale or missing, the block says so in capital letters. It never silently runs on old knowledge.

    That warning earned its keep. From 9/24 to 9/30/2026 the lookup was down because an index could not rebuild. Now a failed lookup sends a push to my phone the same hour.

    Backups

    Every night at 3:00 AM a job bundles the code, the knowledge corpus, memory, and state, and copies the bundle to cloud storage and to a second machine. A health check watches the backup status file and complains if a night is missed.

    The first version of Aries, built on a workflow tool, lost its data in one event. It was retired in June 2026. That is why the backup job exists.

    Where it falls short

    Here is the honest number. As of 10/01/2026, Aries had written 147 reply drafts. I had sent zero of them. Sixty-four sat unused for more than a week.

    Part of that is a design gap. The feedback system had no way to record "not used," so an ignored draft taught it nothing. Part of it is that a draft I have to rewrite is not faster than writing it myself.

    So the briefing and the to-do list earn their place every day. The drafting does not yet. That is the open problem, and it is a product question, not a wiring bug.

    What I would tell another small-business owner

    • Give the assistant its own machine and its own key with a spending cap.
    • Write the permission rules down before the first job runs, and keep "send," "spend" and "delete" behind you.
    • Make every failure loud: refusals, stale knowledge, missed backups.
    • Count what you actually use. A pile of drafts is not output.

    More of what I build is on my projects page, and the public code is listed on faroughdarakhshan.org.

  • A claim graph built from records, not from an LLM

    A claim graph built from records, not from an LLM

    By Farough Darakhshan · 10/20/2026

    At any given time I am tracking 500 to 600 water-damage insurance claims. Each one moves through the same six stages: track it, write the invoice, send it, defend it against the adjuster's cuts, collect, and if it goes unpaid, hand it to counsel.

    The questions I ask every day are not hard. They are relationship questions:

    • Which claims do we have for this homeowner?
    • Which adjuster handles this claim, and at which carrier?
    • What did this carrier actually pay on that invoice?
    • How did we answer this denial reason last time?

    Each one has an exact answer sitting in a record somewhere: a contract, an invoice register, an email header, a board row. The problem was getting to it.

    The first attempt: let the model build the graph

    I had a document library feeding a retrieval system, RAGFlow, which can build a knowledge graph by having a language model read every document and extract entities and relationships.

    It looked good in a demo. Then I audited it, twice, with relationship questions I already knew the answers to. It could not answer them reliably. A model that reads a scanned invoice and writes down "adjuster: J. Smith" makes small mistakes, and in a graph small mistakes compound. Two spellings of one adjuster become two nodes. A claim number with one wrong digit becomes a new claim. Ask "what else did this adjuster handle?" and you get half the list.

    For a chat answer that is annoying. For a claim worth five figures it is useless, because I have to check every answer against the source anyway.

    The second attempt: build it from records in code

    On 9/30/2026 I threw the extraction approach out and built the graph in plain code. No language model creates a node or an edge. Every node comes from a record that already has structure:

    • the client and property registry
    • the metadata sidecar written next to every file in the library
    • a claim-tag index that maps files to claims
    • email headers: sender, recipients, date, subject (not bodies)
    • the invoice queue board, synced from the project management tool
    • the invoice register, one line per invoice ever issued

    A few inputs were read by a model earlier, such as facts pulled from contracts and the differences between our estimate and the carrier's. Those are allowed in, but every edge that comes from them carries the label "derived". When I see that label I know to open the source before I rely on it.

    How it is built and served

    The pieces are small Python scripts: one builds the graph, one queries it, one serves it, one evaluates it. The output is a SQLite file plus two JSON Lines files, one for nodes and one for edges, and a build record that says what went in.

    It rebuilds every hour and takes one to two minutes. A graph that is a day stale answers yesterday's question.

    The same graph is available two ways. In the chat interface I use with the local models, it is a tool the model can call. In Claude Code and Codex, it is an MCP tool. Either way the model does not guess the relationship. It asks the graph, and the graph answers from records.

    How I measured it

    I wrote a set of 25 relationship questions with known answers, the same kind I ask every day, plus control questions where the right answer is "I don't know".

    On 9/30 the graph scored:

    • 21 of 25 correct
    • 0.89 F1 on questions whose answer is a list
    • every control passed
    • every lookup under 0.3 seconds

    The controls matter as much as the score. A tool that confidently answers a question it cannot answer is worse than one that answers nothing.

    Two behaviors I care about more than the score

    It says "ask which" on an ambiguous name. Restoration has a lot of shared surnames, and the same last name can belong to several unrelated clients. When a name matches more than one person, the graph lists the candidates and asks which one. It never merges them. Merging two people with the same name is how an invoice goes to the wrong house.

    It cites nothing on an unknown name. If the name is not in the records, the answer is empty. No near-miss, no "closest match".

    Both behaviors come for free from building in code. A lookup either finds a key or it does not.

    What it cannot answer

    The four misses and the known gaps are worth stating plainly.

    • Line-item facts are not in the graph. It knows that an invoice exists, who it went to and what was paid. It does not know how many supervisory hours were on line 14, or which section of the IICRC standard a line relies on. Those live in the invoice documents.
    • Synced mail starts on 9/22/2026. Older correspondence is in the library as files but not yet in the header index.
    • A client missing from the registry is missing from the graph. The graph is only as complete as its records. When I find a job that never made it into the registry, the fix is to add it there, not to teach the graph to guess.

    Why this matters past my own office

    Before this, the same business documents lived in eight different stores, and the same 1,897 files existed three times over with nothing cross-referenced. Most small companies look like that. The tempting fix is to point a language model at the pile and ask it to make sense of it.

    My experience is that a model is good at reading a document and bad at being the system of record. Let code own the relationships. Let the model read the one page you need once the graph has told you which page it is.

    If you run a restoration company, or any business with many open files and many parties per file, start with the records you already have in structured form. An invoice register and email headers will get you most of the way.

    I'm Farough (Frank) Darakhshan, and these are working notes on the systems behind a restoration back office. The other projects are at faroughdarakhshan.com/projects, and the public code is on faroughdarakhshan.org.

  • Three AMD Strix Halo boxes as a local model farm: what actually works

    Three AMD Strix Halo boxes as a local model farm: what actually works

    By Farough Darakhshan · 10/20/2026

    I bought three AMD Strix Halo mini PCs to run language models locally for the back office of a water-damage restoration business. The idea was simple. A lot of the reading in this work is bulk reading: photos, PDFs, carrier estimates, long email threads. Cloud models are fast, but every page costs money. A box on a shelf costs electricity.

    This is what I learned in the first month, with the numbers I measured. Some of it works well. Some of it bit me hard.

    The hardware and the operating system

    Each box runs Rex OS, which is Debian 13 with AMD's packages on top. Each has 125 GB of RAM, a 1.6 TB NVMe drive, and an integrated GPU that maps system memory instead of owning its own.

    That last point matters more than anything else on these machines. The GPU shows 512 MB of dedicated VRAM. Everything a model needs comes from GTT, the shared memory the GPU is allowed to map. On the first box that is about 94 GB.

    There is a setting in Rex OS called "video shared RAM %". It writes a kernel option and takes effect after a reboot. It is a ceiling, not a reservation. Memory the GPU does not use stays free for the operating system. I run the first box at 75% and the other two at 90%, which is what the largest model needs.

    The first trap: rocm-smi and amd-smi lie on this chip. They report 512 MB of VRAM and zero load while the GPU is pegged. The real number is in /sys/class/drm/card1/device/gpu_busy_percent. Every dashboard I built reads that file.

    Lemonade as the model server

    All three boxes run Lemonade 11.5.0 as the model server. One server gives me several API shapes on the same port:

    • OpenAI-style chat completions, for most scripts
    • the Responses API, which Codex uses
    • an Anthropic Messages endpoint, meant for Claude Code
    • embeddings
    • an MCP endpoint

    Every route needs a bearer token, and the firewall only lets in the handful of machines that are supposed to talk to it.

    Rex OS pins Lemonade to 11.5.0. A tool-schema bug that breaks Claude Code against Lemonade was fixed upstream in 11.5.1, but that fix does not reach these boxes until Rex ships it. So Claude Code against the local models is on hold. I check the package policy before I assume any upstream fix is installed.

    Two more Lemonade rules I keep:

    • Never install the llamacpp:rocm backend through Lemonade. The llama.cpp build Rex ships as the system build is the one that actually runs on this GPU.
    • --parallel 1 is set globally to avoid another known bug, and raised only per model where I have tested it.

    Model placement

    The boxes are not interchangeable. I gave each one a job.

    The first box is the fast one. It keeps Qwen3-Coder-30B-A3B for code, Qwen3.6-35B-A3B for vision, Qwen3-Embedding-4B for embeddings and bge-reranker-v2-m3 for reranking.

    The second box is the heavy one. It keeps Qwen3.5-122B-A10B loaded as a checker for work the smaller models produce.

    The third box came online on 9/28 with the same memory setting as the second, so it can take the large model later.

    One compatibility fact cost me an afternoon: Codex cannot use the Qwen3.5 or Qwen3.6 models through Lemonade. Lemonade itself warns about it. Codex jobs go to the coder model.

    Context slots split your context

    This one is easy to miss. When you give llama.cpp --parallel 4, it does not give each request the full context window. It divides it.

    The 35B vision model at a 32,768-token context with four slots gave each request 8,000 tokens. That was too small for the 60,000-character document chunks I was sending. I raised it to 65,536 with four slots, which is 16,000 tokens per request, and I keep each input under about 12,000 tokens.

    The out-of-memory trap

    Lemonade will load any model a request names. That is convenient on a laptop. On a shared box it is dangerous.

    On 9/27 a test loaded a third model next to the two already resident on the heavy box. The kernel ran out of memory and killed the user systemd manager. When systemd restarted it, every enabled user service came back up, including one I had stopped two hours earlier on purpose.

    On 10/19 it happened again from a different direction. One test call named the 122B model on a box that did not have it loaded. Lemonade loaded it beside everything else, the box ran out of memory, and every rootless container on it died at once, including the claims app I am building.

    The rules I run on now:

    • The heavy box holds the 122B plus one other model, never more.
    • Nothing routes to a model on that box unless it is already loaded.
    • A service that must stay down gets disabled, not only stopped.
    • After any model work, I list running services and compare them with what I started.

    Headless and remote

    All three boxes boot without anyone logging in. After a cold boot, the first two accepted a remote connection in about 17 to 18 seconds. Wake-on-LAN shipped turned off, which is why my first remote power-on failed. It is on now.

    Measured speed

    Vision is the bulk of the work: reading job-site photos and moisture meter displays. I measured it on 10/01.

    • One photo costs 2.2 to 2.5 seconds of image encoding, and that part is serial.
    • A box does 0.25 to 0.35 photos a second no matter how many requests run at once. More concurrency only raises the wait.
    • A PDF page takes 15 to 17 seconds alone, and 37 to 70 seconds under load.
    • 10,295 photos take 8 to 11 hours per box.

    That is slow next to a cloud model and free next to a cloud bill. Work that can run overnight goes to the boxes. Work someone is waiting for goes to the cloud.

    Watch out for llama.cpp's prompt cache when you benchmark. Rerunning the same photos was eight times faster because the prompt was cached. Speed numbers need fresh inputs.

    Do they match a cloud model?

    On some jobs, yes. On a graded test drafting scope lines, the local 35B scored 59.3 against Claude Haiku's 59.2. On a harder job, finding gaps in a claim's evidence, the local model scored an F1 of 19.4 against Haiku's 22.2. Plain code with no model scored 21.5. The data, not the model, was the weak part there.

    So I test each job type before I move it. A box earns a job by matching the cloud on a graded set.

    Never idle, never busywork

    An idle box is money sitting on a shelf. Each box runs an idle worker. When its GPU stays under about 20% for two minutes, it takes the next item from a queue that fits the models it already has loaded.

    The queue holds only real work: open claims, unpaid invoices, disputes, jobs active in the last 30 days. Labeling photos of a claim that was paid two years ago is not work, it is heat. When the real queue runs out, the dashboard says "backlog empty" instead of inventing something.

    Every job runs through one logged wrapper, so whether the boxes earn their keep is decided from the log, not from my impression.

    What I would tell someone starting

    1. Read GPU load from sysfs, not from the vendor tools.
    2. Treat the shared-memory setting as a ceiling and size models to it.
    3. Pin your models, and never let a stray request load a new one on a full box.
    4. Divide context by slots before you size your inputs.
    5. Measure throughput with fresh inputs.
    6. Grade each job against a cloud model before you move it.

    I'm Farough (Frank) Darakhshan. I write these notes as I build the systems that run a restoration back office. The rest of the projects are listed at faroughdarakhshan.com/projects, and the public code is on faroughdarakhshan.org.

  • A Dry Log That Justifies Every Day of Equipment

    A Dry Log That Justifies Every Day of Equipment

    By Farough Darakhshan

    Most of my work is writing final invoices for water-damage jobs and defending them when an adjuster cuts them. Carriers reduce drying charges when there is no supporting dry log, so drying time, how many days the dehumidifiers and air movers stayed in the house, needs its own evidence.

    The document that answers that question is the dry log. This note is the standard I write them to.

    What a dry log is for

    On 09/07/2026 I set a standing rule: a dry log exists to justify the number of drying days we charged for.

    That sounds obvious, but it changes what goes in it. A page of moisture readings rarely justifies anything on its own. Readings show that a wall got drier. They do not show why it took five days instead of three.

    So the log has to carry the evidence that explains the duration, laid out so an adjuster can follow it without being argued at.

    Build the case in this order

    1. Equipment math first. Show why that much equipment was needed for that many days. That means the chamber volume, the dehumidifier sizing against the class of loss, and the air mover count against the affected surface area.

    If the calculation supports the days on its own, it is the strongest evidence there is, and it can stand alone.

    2. Add the conditions that lengthen a dry. When the equipment math does not carry the whole duration, add the conditions that do:

    • Outdoor temperature and humidity on those dates.
    • Whether the property was occupied.
    • The building materials, and how saturated they were.
    • Airflow restriction from the layout, which is why a crew adds or repositions equipment.

    Every one of these genuinely lengthens a dry. Every one is checkable by someone who was not there.

    3. Present it plainly. State the conditions and let the adjuster reach the conclusion. No heading that announces a justification. A report that reads like an argument gets read like one.

    The line that keeps it honest

    Pick the evidence that explains the days best, but only from evidence that is true.

    • Never select readings to reach a number.
    • Never drop a day because it is inconvenient.
    • Never print a figure no instrument produced.

    A dry log that gets checked and holds is worth more than one that reads well, because the carrier checks.

    One format, copied every time

    On 09/17/2026 I picked one report as the only dry log format. It is a psychrometric drying report with nine sections. Every report since copies its structure, its table shapes, its page layout, and its rules about where each number came from. Only the job's own facts change.

    The reasoning is simple. Carriers reduce drying charges for "no supporting dry logs." A format they have already seen survive review is worth more than a prettier one they have not. And a fixed structure turns the next report into assembly instead of design.

    The honesty rules travel with the format:

    • Any span that is modeled rather than measured is labeled as modeled on the same page.
    • A verified reading always beats a curve.
    • Unattended days and zones that were not drying are reported as they are.

    When the readings only exist as photographs

    On older jobs there often is no written dry log at all. The readings exist only as photographs of meter displays in the job's photo record. So the log has to be transcribed, not retrieved.

    The pipeline I use for that is plain:

    1. Pull every photo for the job with its capture time.
    2. Lay them out as labeled contact sheets to find every instrument display in one pass.
    3. Crop each display at full resolution and read it.
    4. Record one row per photograph: instrument, scale, note, and a confidence value. Anything not fully legible is recorded exactly as displayed, with the limitation stated.
    5. Compute grains per pound from the instrument's own mixing ratio where it shows one, otherwise computed.
    6. Add an outdoor line from the nearest airport's hourly weather observations, which the Iowa State Mesonet archives for free.
    7. Split the photo appendix by visit date, so each PDF stays under a carrier's 7 MB per-attachment limit.

    Two traps change the numbers if you miss them. Meter photos are often taken upside down and must be rotated before reading. And a single meter body can show relative humidity, wood moisture, or a relative scale, so the mode indicator has to be read off the display. Without it, the value is only "as displayed."

    Absence of a photo is not absence of work

    Two lessons from the photo record.

    Paper readings exist. Some technicians wrote moisture readings on paper and never uploaded them. So a job with no meter in its photos is not a job where no readings were taken. The accurate wording is "no readings were uploaded."

    Crews photograph bookends. Most jobs are photographed heavily on the install day and the removal day, with little between. An interior gap is not a defect. Equipment installed on the 13th and removed on the 26th was in the house on the 20th whether anyone photographed it or not. I report the gap as a column, never as a verdict.

    What dates a job, and what does not

    It is tempting to use a dehumidifier's run-hour meter as proof of duration. It looks perfect. It is not evidence.

    Crews do not reset the hour meters between jobs. On one job, three dehumidifiers photographed within the same minute read 02:04, 17:01 and 18:29. No simultaneous install produces a sixteen-hour spread. Two of those units arrived with a previous job's hours still on the clock.

    What does date a job:

    • Photo capture times.
    • Install and removal sheets whose printed address matches the job.
    • The first and last capture dates on the job's photo project.

    That is the whole standard: the math first, true conditions second, one fixed format, and nothing on the page that an instrument or a dated record did not produce.


    Farough (Frank) Darakhshan writes the final invoices for water-damage jobs in Northern Virginia and builds the tools that assemble their documentation. More of his systems are on the projects page.

  • Why I Built ClaimForge Instead of Buying Claim Software

    Why I Built ClaimForge Instead of Buying Claim Software

    By Farough Darakhshan

    My main job is writing final invoices for water-damage jobs and then defending those invoices when insurance adjusters try to cut them down. Everything else I build exists to make that one job faster.

    At any time there are 500 to 600 open claims. Each one moves through six stages: track it, invoice it, send it, defend it, collect on it, and, when nothing else works, hand it to counsel for collections. One person cannot do that by hand for hundreds of files. So I needed software.

    ClaimForge is the software. This post explains what it does and why I wrote it to my own rules rather than adapting a general-purpose tool.

    Two earlier attempts that never stuck

    ClaimForge is not my first try. I built, or tried to build, several earlier invoice engines. None of them reached daily use.

    ClaimForge is built around three rules. Each one is about an invoice an adjuster can check rather than pick apart.

    Rule 1: prices come from the contract, not the price list

    Most people assume a restoration invoice is priced straight from the Xactimate list. Mine are not. Most of the time the list price gets adjusted to match our market, and the prices we use most are written into Attachment A of our customer contract.

    That Attachment A has 161 items. Each has a price, a contract price range, and a flag for whether overhead and profit applies.

    So ClaimForge prices every line in a fixed order:

    1. The contract's Attachment A.
    2. My own billed market rate for that line, taken from past invoices.
    3. The Xactimate list, used only for codes, descriptions, and lines with no other price.

    When I checked on 09/30/2026, 25% of my invoice lines differed from the January 2023 list, mostly because of my own rates. A tool that defaults to list price would be wrong on one line in four. Every price in ClaimForge says which of the three sources it came from.

    Rule 2: line names never drift from Xactimate

    The price is mine. The line name is not.

    Every invoice line carries the exact Xactimate code, name, and description. I never paraphrase one, even when a shorter name would read better to a homeowner.

    The reason is the defense step. When an adjuster disagrees with an invoice, they usually send back a comparative estimate as a PDF. If my line names match theirs exactly, the two documents can be lined up line by line. That match shows two things: what was cut outright and what was reduced in quantity or price.

    In testing, the parser read 113 of 116 carrier comparatives into line items. Each cut or reduction then maps to a rebuttal card, a short written answer to one specific kind of reduction. There are about 78 of those cards today. The reply to the adjuster gets drafted from the cards that match.

    All of that depends on the names. If one side calls a line something slightly different, the match breaks, and the comparative goes back to being read by hand.

    Rule 3: what the field recorded never changes

    This is the rule I care about most.

    ClaimForge starts from the ScopeSheet, the approved scope record for the job. It shows that scope for review, writes a native invoice in Invoice Ninja, keeps my notes separate, and records every catalog, rate, or manual decision explicitly. It renders the PDF and builds a private review package for the file.

    Inside the system, the source quantities and units from the ScopeSheet are immutable. The invoice math sits beside them, separately. So do the project manager's evidence and any change the review proposes.

    That separation means a reviewer, or I, can always see three things apart: what the crew recorded, what the invoice charges, and what someone suggested changing. Nothing silently overwrites the field record. When an adjuster asks why a quantity is what it is, the answer starts from a number nobody edited.

    Tests that tell the truth about what they ran

    ClaimForge is PHP with the bcmath extension, Python, and Node with TypeScript. It extends Invoice Ninja, so the parts derived from Invoice Ninja keep their Elastic License 2.0 terms.

    The test runner was built with one rule: never report a pass it did not earn.

    One command runs every suite. It first lists which prerequisites are present. Then it prints PASS, SKIP, or FAIL for each script. A missing prerequisite, such as the bcmath extension or a private fixture, is always a SKIP with its reason printed. It is never counted as a pass and never as a failure. Continuous integration runs the same script.

    That matters because the real price catalogs and customer files are private and are not in the repository. The public test fixtures are synthetic and contain no customer data. A suite that needs the private catalog says plainly that it skipped. I would rather see "SKIP: catalog absent" than a green check that tested nothing.

    Where it stands

    I want to be precise here, because this is a working note, not an announcement.

    The source is integrated and tested. Acceptance of the installed release is still open. The repository is private, because the catalogs, rates, and job records it works from are business records.

    So ClaimForge is not yet the thing I run every invoice through. It is the version built on the three rules above.

    Three questions for any claim tool

    Whether you build or buy, these are the questions I would ask of any invoicing tool:

    • Can it price from your contract before it ever touches a list price, and show which source each price came from?
    • Does it keep exact Xactimate line names, so an adjuster's comparative can be matched line for line?
    • Does it keep the field record separate from what the invoice charges and what a reviewer proposes?

    Those three answers are why ClaimForge exists in the form it does.


    Farough (Frank) Darakhshan writes the final invoices for water-damage jobs in Northern Virginia and builds the software that helps him do it. More of his systems are on the projects page.