Skip to content

feat(catalog): declare how each app is restored (restore@1, Hola spec 007) - #164

Merged
pofallon merged 1 commit into
mainfrom
feat/restore-declarations
Sep 20, 2026
Merged

pofallon merged 1 commit into
mainfrom
feat/restore-declarations

Conversation

@pofallon

Copy link
Copy Markdown
Contributor

Sibling to try-hola/hola#491 (spec 007, restore-on-install). That PR builds the install-side machinery; this one tells it what a restore means for each app.

The platform half works without this — 12 of the 17 apps here restore by plain file copy today — so the two can land independently. The five Postgres-backed apps need both.

What an app declares

accepts: ["restore@1"] marks an app restorable. A restore block adds per-participation detail. The two states are meaningfully different:

Declaration Meaning
no restore@1 nobody has considered restoring this app — not offered as a source
restore@1, no block plain file copy is all it needs — true for every SQLite/flat-file app here
restore@1 + block discards and/or a hook apply

The middle state already exists in this catalog: twelve apps declare accepts: ["backup@1"] with no hooks. This change gives that shape a meaning rather than asking anyone to adopt a new one.

Why discard exists

It's the non-obvious field, and the reason these five need more than a copy.

A file-level tar of a live Postgres data directory is read over minutes while the database writes throughout — page 1 at T+0s, page 100000 at T+180s. That's a smear across time, not a snapshot, and for Postgres a smear is corruption. Hola's own capture helper concedes as much: it suppresses tar's "file changed as we read it" warning because crash-consistency is all it promises.

The .sql dump is the real payload, and it's already captured — all five apps dump into ${HOLA_APP_DATA}/backups, inside the data root. So: discard the smeared data directory, let the container initdb a clean cluster, load the dump into it.

Per-app

App hook service discard healthcheck already declared
guacamole postgres postgres
immich immich-postgres postgres
mealie mealie-postgres postgres
paperless-ngx db pgdata
postiz postiz-postgres postgres

Every discard path was read from that app's own compose.yaml, not copied between apps. paperless-ngx mounts pgdata where the other four mount postgres — and a wrong path here fails silently, discarding nothing and restoring the smear.

Every hook carries -v ON_ERROR_STOP=1. Without it psql exits 0 after a failed statement, which would hand a partially loaded database to an app that then migrates it — exactly the corruption the server's fail-closed hook policy exists to prevent.

Hola waits on each hook service's own declared healthcheck (docker compose up -d --wait <svc>) rather than a bespoke readiness poll, since per-app readiness knowledge in the server is what its generic-primitive rule forbids. All five already declare one, so no compose change was needed.

Schema

Additive. restore is a new optional array; hook reuses the existing $defs/backupHook rather than introducing a second hook format.

restore@1 joins the contractRef enum, and that enum's description is extended to record the one thing that makes it different from its neighbours: it is not a brokered capability contract. Hola's CONTRACTS registry gains no entry for it, and the server never brokers it between two parties — it only says "this app has considered being restored." The enum stays closed for the reason it always was: the server drops an unrecognised ref with a warning, so an open enum would turn a typo into a silent no-op instead of a CI failure.

Verification

All 17 manifests in this catalog validate against the updated schema (ajv), unchanged ones included.

Not yet exercised against real containers. The end-to-end pass — a real restore of one of these five, with the captured data directory discarded and the dump loaded — needs a published bundle carrying these declarations, so it runs after this merges. That gap is called out on try-hola/hola#491 too.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Vck5KSX2CLxhohx14nb5Sh

… 007)

Hola spec 007 (restore-on-install) lets an operator name an existing
deployment of an app as a restore source during a fresh install. The server
captures that source's data root and lays it down before any container
starts. What it cannot know on its own is what a restore MEANS for a given
app — so each app says it here, and the server interprets every declaration
identically with no per-app logic anywhere.

`accepts: ["restore@1"]` marks an app as restorable. A `restore` block adds
detail per backup participation. The two states are meaningfully different:
an app declaring `restore@1` with NO block is restored by plain file copy,
which is correct for every SQLite and flat-file app here; an app declaring
neither has simply never been considered and is not offered as a source.
Twelve apps already sit in the first state by virtue of declaring
`accepts: ["backup@1"]` with no hooks — this gives that existing shape a
meaning rather than asking anyone to adopt a new one. It is the same third
state `backup@1` already relies on, for the same reason.

The five apps touched here back onto Postgres and already dump with `pg_dump`
into `${HOLA_APP_DATA}/backups`, inside the data root, so the dump is already
captured by any data-root copy.

`discard` is the non-obvious field and the reason these five need more than a
file copy. A file-level tar of a LIVE Postgres data directory is read over
minutes while the database writes throughout: it is a smear across time, not
a snapshot, and for Postgres a smear is corruption. The `.sql` dump is the
real payload. So the captured data directory is discarded, the container runs
`initdb` on a clean cluster, and the hook loads the dump into it.

Each `discard` path was read from that app's own compose rather than copied
between apps — paperless-ngx mounts `pgdata` where the other four mount
`postgres`, and a wrong path fails silently by discarding nothing and
restoring the smear.

postiz gets TWO entries, because #163 gave it two backup participations:
`app-db` (pg_dump of postiz-db-local) and `temporal-db` (pg_dumpall of the
temporal cluster). Each keys to its own participation id, discards its own
data directory, and loads its own dump — `temporal-db` against the
maintenance database, since pg_dumpall emits a whole-cluster script carrying
its own \connect lines.

Every hook carries `-v ON_ERROR_STOP=1`. Without it `psql` reports success
after a failed statement, which would hand a partially loaded database to an
app that then migrates it — precisely the corruption the server's fail-closed
hook policy exists to prevent.

Hola waits for each hook's service on the service's OWN declared healthcheck
(`docker compose up -d --wait <svc>`) rather than any bespoke readiness poll,
since per-app readiness knowledge in the server is what its generic-primitive
rule forbids. All five hook services already declare one, so no compose
change was needed.

`restore@1` is added to three places that each enforce a different half of
the rule, and missing any one of them fails CI rather than failing quietly:
`$defs/contractRef` and `$defs/acceptableContractRef` in the schema (the
latter is what `accepts` actually validates against), and the CONTRACTS table
in bin/validate-manifest.mjs, which encodes what a JSON Schema enum cannot —
that acceptance is declared rather than derived, and that a `restore` block
without the matching `accepts` entry is an error.

All three records note the same exception: `restore@1` is a participation
marker, not a brokered capability contract. Hola's CONTRACTS registry gains
no entry for it and the server never brokers it between two parties; it only
says "this app has considered being restored".

Schema and validator changes are additive; all 18 manifests validate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vck5KSX2CLxhohx14nb5Sh
@pofallon
pofallon force-pushed the feat/restore-declarations branch from 907369c to b109465 Compare September 20, 2026 21:47
@pofallon

Copy link
Copy Markdown
Contributor Author

Rebased onto current main — and the rebase caught two things that would have failed CI

My local checkout was stale by ten commits. Rebasing surfaced two real defects in the original push, both of which would have failed CI rather than failing quietly — which is what these gates are for.

1. postiz has two backup participations now, not one

#163 (plural participations) landed after my checkout. postiz went from a singular backup block to [app-db, temporal-db]. My original restore block keyed id: "default" — the legacy singular normalisation — which on today's main matches no participation at all.

Rebuilt with one entry per participation, each with its own discard path and its own dump:

id discard hook
app-db postgres psql … -d postiz-db-local -f /backups/postiz.sql
temporal-db temporal-postgres psql … -d postgres -f /backups/temporal.sql

temporal-db loads against the maintenance database rather than a named one, because its capture uses pg_dumpall, which emits a whole-cluster script carrying its own \connect lines.

This is exactly the plural case spec 007 designed for (its FR-027 / SC-010), so it's a good sign the shape held — but the value was wrong and only the rebase exposed it.

2. accepts validates against acceptableContractRef, not contractRef

Upstream split the enum in two. My original change added restore@1 only to contractRefso every manifest declaring it would have failed validation.

There turned out to be a third gate as well: bin/validate-manifest.mjs carries its own CONTRACTS table, independent of the schema, encoding what a JSON Schema enum can't — which block an acceptor's details live in, and whether acceptance can be derived. restore@1 needed an entry there too.

All three now carry it, each enforcing a different half of the rule:

Where Enforces
$defs/contractRef the ref is known
$defs/acceptableContractRef it's legal in accepts
CONTRACTS in the validator a restore block without the matching accepts entry is an error

restore@1 takes blockRequired: false for the same reason backup@1 does, and that file's own comment already explains it better than I would: the hook-free declaration is *"the positive claim 'safe to copy as it sits', which has to be distinguishable from an app nobody considered."appProvided: false because nothing provides restore@1 — not the platform, not an app.

Verification

All 18 manifests pass both gates. I mutation-tested the two new validator paths rather than assuming they work:

  • a restore block with restore@1 removed from accepts → correctly fails ("the block says HOW the app participates, accepts[] says WHETHER it does")
  • restore@1 in provides → correctly fails

One imprecision I left rather than widen scope: the provides rejection reuses the shared message "is provided by the Hola platform itself", which isn't accurate for restore@1 — nothing provides it. The message still tells the author the right thing to do, so I noted it in the code instead of changing shared wording.

@pofallon
pofallon merged commit 50ca197 into main Sep 20, 2026
13 checks passed
@pofallon
pofallon deleted the feat/restore-declarations branch September 20, 2026 22:10
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