Skip to content

Phase 1.5 Batch 3 (M6): keep it running, back it up, get it back - #369

Merged
arunpr614 merged 3 commits into
mainfrom
feat/phase-1.5-batch-3-m6
Aug 30, 2026
Merged

Phase 1.5 Batch 3 (M6): keep it running, back it up, get it back#369
arunpr614 merged 3 commits into
mainfrom
feat/phase-1.5-batch-3-m6

Conversation

@arunpr614

Copy link
Copy Markdown
Owner

Batch 3 is M6 (#215, #216, #217, #218) plus #275. Stacked on #368 so this PR
shows only Batch 3's three commits — GitHub will retarget it to main when #368
merges.

Until now the archive had no way to stay up, no copy, and no rehearsed way back.
Photo bytes are the only unrecoverable asset in this project and there was
exactly one of them.

What is here

Evidence

Everything below ran against throwaway archives under /tmp, never the real
data/. The owner's instance stayed up on port 3000 and answered 200 before and
after every step.

  • Backup while the app served under load: integrity ok, all four table counts
    matched, app healthy after.
  • 12/12 originals where filename = sha256 of content = source = restored; 12
    derivatives byte-identical under cmp.
  • The scheduled job fired unattended off StartCalendarInterval, empty stderr.
  • The restored copy, on another port, rendered /day/2026-08-29 with 100
    images and 100/100 photo URLs returning 200
    .
  • Backing up during a 60-photo ingest caught five originals and two
    derivatives in the second media pass, and the restore found 112 of 112 photo
    files present.
    Pass one alone would have left five dangling.
  • 203 tests pass, tsc --noEmit clean.

Things worth arguing with

  • Media is copied twice, before and after the database snapshot. Backup script: copy the SQLite file and the originals directory #217 does
    not ask for this. Ingest writes photo bytes before committing the row that
    references them, so a photo arriving mid-backup is otherwise a row in the
    snapshot pointing at a file the backup does not have.
  • Derivatives are backed up too, which Backup script: copy the SQLite file and the originals directory #217 also does not ask for — it names
    originals, because those cannot be recreated. But Documented, rehearsed restore procedure #218 asks a restored copy to
    show a day, every photo on the Calendar and Day pages is a derivative, and
    there is no regenerate command in v0.1.
  • The same-device check reports rather than refuses. Backup script: copy the SQLite file and the originals directory #217 says "a second
    path/volume on the host at minimum", so the same device is a documented
    compromise, and the owner is the one who knows whether /Volumes/... is really
    another disk.
  • Restore has no --force. A restore that can overwrite is one typo away
    from replacing the real archive with a copy that is hours old.
  • data/owner.json is not in the backup, so a copy of the backup is not a
    way to sign in. Restore runs set-password, which rotates the session secret.

Limitations, not worked around

  • A LaunchAgent starts at login, not at boot. After a reboot the archive is
    down until someone logs in, so Production build + process supervision #215's third criterion is not verifiable without
    one. A LaunchDaemon would start at boot but runs as root with no login
    keychain — a bigger change than the ticket asked for.
  • install was not run on the real port 3000, because that would mean
    stopping the owner's hand-started server. That is the owner's call.
  • Cloudflare Tunnel in front of the origin; origin bound to loopback; app-level owner sign-in #216's second half is deferred. "Unreachable by its loopback port from
    outside the host" is the Hetzner half; the app runs on the Mac.
  • No markup or CSS changed anywhere in this branch, so there is no render loop to
    report.

One thing for the owner, unrelated to the code

This repository is public and the shared Hetzner host's IP address is already
committed in docs/IMPLEMENTATION-PLAN-POST-M6.md on main, next to the fact
that its login has passwordless root. It has been kept out of the three documents
this PR adds, but rewriting main is forbidden, so removing it from history is
not something to do quietly. The fix is host-side.

🤖 Generated with Claude Code

Two closely related holes, both of the same shape: a rule that was true when it
was written and had no way of staying true.

**Every route is behind the gate, and now a test can say so (#275).** The gate
was already one `onRequest` hook with an allow-list, which is the right shape,
but every test of it named the routes it checked — so a route added in M7 could
be public by oversight and nothing would fail. `buildApp` now records each route
as Fastify registers it, on `app.lidRoutes`, and `coverage.test.ts` walks that
table and asserts every entry is either gated or an exact member of the
exported `PUBLIC_PATHS`. Nothing in a request path reads the table; it exists so
a test can ask the running app what it actually serves.

The hook's placement is load-bearing rather than tidy. `app.register()` defers
until `ready()`, but the `app.get()` calls in `buildApp` run immediately, so an
`onRoute` hook added by a test after `buildApp()` returns catches the nine route
plugins and silently misses `/health` and all four static assets. Installing it
before any registration is what makes the coverage real — a route-coverage test
with a hole in it is worse than none, because it reports coverage it does not
have. That was found by writing the test the wrong way first and mutating a
route to be public: it stayed green.

**`Cache-Control: private, no-store` is now one hook (#216).** Three route files
set the header and four did not, and the four that did not were the oldest — the
M1-M3 HTML pages. Per-route privacy headers always decay that way. An `onSend`
hook sets the private default on anything that has not deliberately opted out,
including the 404 page (which renders the rail, and the rail carries counts) and
the 401 from `rejectUnauthenticated`.

`hasHeader` rather than an unconditional set, because two responses have a
different right answer: the sign-in pages send plain `no-store` (nothing about
them is private *to* anyone; the point is that no cache holds a form with the
previous email in it), and `/app.css` and the three scripts send `no-cache` —
they are on the public allow-list, say nothing about the archive they style, and
revalidating is what makes editing CSS and reloading feel instant.

The six route files lose the header lines, not the reasoning: `media.ts` keeps
the paragraph that first wrote the rule down, and now says why stating it per
route is what left the older pages without it.

No markup or CSS changed, so there is no render loop to report.

Generated with Claude Code
Three things the archive did not have: a way to stay up, a copy, and a
rehearsed way to return from that copy. Photo bytes are the only unrecoverable
asset in this project and until now there was exactly one of them.

**The server as a launchd LaunchAgent (#215).** `scripts/install-service.sh
install|uninstall|status|restart`, writing `~/Library/LaunchAgents/`. It refuses
rather than fights: if something is already listening on the port and there is
no plist, it says which command to stop it with, because a `KeepAlive` job that
cannot bind retries every ten seconds forever while looking installed. It also
checks `data/owner.json` up front — the server's refusal to boot without
credentials is correct, but far more legible now than in a log being rewritten
every ten seconds. `bootstrap`/`bootout` and not the deprecated `load`/`unload`,
because the modern verbs report a real exit status.

Limitation, stated in `docs/OPERATIONS.md` and not worked around: **a LaunchAgent
starts at login, not at boot.** After a reboot the archive is down until someone
logs in. A LaunchDaemon would start at boot but runs as root with no login
keychain, which is a larger change than #215 asked for.

**A backup with two assets and two methods (#217).** `npm run backup`, into
`LID_BACKUP_ROOT`, which has no default because a backup that quietly lands on
the same disk as the thing it backs up looks like protection and is not.

The database goes through SQLite's Online Backup API, never `cp` — a filesystem
copy of a live WAL database can open cleanly and still be wrong. The photographs
are content-addressed and immutable, so copy-if-missing is incremental by
nature: no generations, no rotation, no rsync. Every run then opens the snapshot
it just wrote, runs `integrity_check`, and compares four table counts, so "it
produced a file" is never mistaken for "it produced a database".

**Media is copied twice, before and after the snapshot, and that is the part the
ticket does not ask for.** `ingest/index.ts` writes photo bytes (line 132)
before committing the row that references them (line 138), so a photo arriving
mid-backup would be a row in the snapshot pointing at a file the backup does not
have — a restore with a broken image on a real day. The window is not
hypothetical: backing up while a 60-photo ingest ran caught **five originals and
two derivatives in the second pass**, and a restore from that snapshot found
**112 of 112 photo files present**. Pass one alone would have left five dangling.

**Restore, which is the only thing that proves any of the above (#218).**
`npm run restore` assembles a copy and then checks four things. Three are cheap;
the fourth is the one that matters — it walks every live `media_asset` and looks
for its file on disk, because a restore can pass `integrity_check`, pass
`foreign_key_check`, hold every row and still show grey boxes. It names the day
with the most photos so that opening one page proves database, originals and
derivatives all arrived together. **It refuses to write where an archive already
exists and there is no `--force`**, because a restore that can overwrite is one
typo away from replacing the real archive with a copy that is hours old.

Verified against throwaway archives under `/tmp`, never the real `data/`. A
backup taken while the app was serving under load: integrity ok, all four counts
matched, app healthy after. 12/12 originals where filename = sha256 of content =
source = restored, and 12 derivatives byte-identical under `cmp`. The scheduled
job fired unattended off `StartCalendarInterval` with empty stderr. The restored
copy, run on another port, rendered `/day/2026-08-29` with **100 images and
100/100 photo URLs returning 200**.

Three bugs the rehearsal caught, all of which a reading would not have:

- Derivatives got only one copy pass, so a photo ingested mid-backup had its
  original safe and its thumbnail missing. #217 names originals, but #218 asks a
  restored copy to *show* a day, and every photo on the Calendar and Day pages
  is a derivative — with no regenerate command in v0.1, skipping them restores
  grey boxes.
- Drift was reported backwards: "the snapshot is behind by source_item 23 -> 24"
  about a snapshot one row *ahead*, because live counts were read before the
  snapshot rather than after.
- Snapshots were left in WAL mode, so each one was three files while `restore.ts`
  copies only the `.sqlite`. Here the `-wal` was empty, but a populated one would
  have been silent data loss. Snapshots are now checkpointed and converted to
  `journal_mode=DELETE`, which costs nothing on restore because the app sets WAL
  on every open.

`scripts/backup.test.ts` pins the ordering deterministically: five tests, all
mutation-checked. Two mutations were MISSED and both are recorded rather than
papered over. Reversing the count order still passed — `betweenPasses` fires
before the database is opened, so a test built on it cannot see that bug; the
vacuous test was deleted and replaced with a comment saying why. Removing the
sidecar cleanup also changed nothing, because the `DELETE` conversion already
removes them — and in the one case it would not have been dead code (the pragma
failing) unlinking a populated `-wal` would have destroyed exactly what the
backup exists to preserve. A cleanup that is redundant when things work and
destructive when they don't is the wrong shape, so it now refuses instead.

`data/owner.json` is deliberately not in the backup: it holds the password hash
and the HMAC secret that signs every session, so including it would make a copy
of the backup a way to sign in. Restore runs `set-password` instead, which
rotates the session secret as a side effect.

`docs/OPERATIONS.md` has the procedures and a "what is not covered" section:
off-host copies, retention (snapshots accumulate; nothing prunes), Trash purge,
and the boot-versus-login caveat.

No markup or CSS changed, so there is no render loop to report.

Generated with Claude Code
Three documents that were written in this tree and never committed, plus the
`.mcp.json` that configures the browser-control server the render loop uses.

- `docs/HANDOVER-MILESTONE-EXECUTION-2026-08-21-1829.md` — the M7-M19 ticketing
  and M0.1 session.
- `docs/M0.1-HOST-INVENTORY.md` — #316's read-only reconnaissance of the shared
  Hetzner host: two other resident products, no Docker runtime at all, zero swap,
  and one shared tunnel ingress file. It supersedes several assumptions in
  `reference/HETZNER-SHARED-HOST-DEPLOYMENT-SPIKE.md`.
- `docs/HANDOVER-PHASE-1.5-BATCH-3-2026-08-29-1159.md` — the handover this batch
  was built from.

**This repository is public, so the shared host's IP address and the filename of
the SSH key that reaches it are not in these files.** The handover now says to
read the `brain` alias out of `~/.ssh/config` instead. That user has passwordless
`sudo ALL:ALL`, which is precisely why its address does not belong in a public
document alongside that fact.

Separately and not fixed here, because it needs the owner: the same IP is already
committed in `docs/IMPLEMENTATION-PLAN-POST-M6.md` on `main`. Rewriting `main` is
forbidden, so removing it from history is not something to do quietly — the
options are host-side.

`.mcp.json` pins `chrome-devtools-mcp@1.1.1 --isolated` and contains no
credentials or paths.

Generated with Claude Code
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