New-app checklist: reviewed, a gate (onboarding), the wger pilot record, the existing apps' gap page
gates / gates (push) Successful in 2s

NEW-APP-CHECKLIST.md: the reviewer's draft reviewed - 60 rows in 10 groups, each with how/why and a since date;
7 rows added, 16 sharpened, 9 wrong claims fixed. onboarding/_TEMPLATE.md (one line per id), onboarding/wger.md
(the pilot, exempt app, 11 open rows each a register row), onboarding/EXISTING-APPS-GAPS.md (read only, from
scripts/onboarding_gaps.py). Gate onboarding (scripts/check-onboarding.py) in --fast: a template directory not
among the 53 published before 2026-10-01 needs a complete record; decoys in test_gate_decoys.py (16 cases, 5 gate
mutants seen red). CLAUDE.md, REUSE.md 5, README point to it. No template changed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-10-01 15:20:52 +02:00
parent 6d72c091e0
commit dc0ab8b2a8
15 changed files with 1068 additions and 96 deletions
+24
View File
@@ -1,3 +1,27 @@
## The new-app checklist: in the catalog, a gate, piloted on wger (2026-10-01, evening)
- **`NEW-APP-CHECKLIST.md`** — the reviewer's draft (`6f18f74`) reviewed: 60 rows in 10 groups (fit · images, start
command, database · storage and backup · accounts and strangers · health · resources · updates · mail · text ·
sign-off), each with how to test it, why it exists, and a `since` date. 7 rows added (0.9 self-call at the public name,
1.7 every entrypoint switch read and decided, 1.8 debug off, 1.9 `data_key`, 2.8 an upload opens again, 3.9 sign in as
each client does, 8.5 the website count); 16 sharpened; 9 wrong claims fixed (citations, a "how" that could not show
R-737, rows that duplicate a gate now name it).
- **`onboarding/_TEMPLATE.md`** — the record a new app starts from: one line per id, `done | evidence`, `n/a | reason`,
`open | what is missing`. **`onboarding/wger.md`** — the pilot record (wger is exempt; 11 rows open, each a register row).
- **Gate `onboarding`** (`scripts/check-onboarding.py`, in `catalog_gates.py --fast`, so the hook and CI): a template
directory not among the 53 published before 2026-10-01 (listed by name) needs a record answering every id whose `since`
≤ its `opened:`, none `open`, every `done` naming a non-empty file or a directory holding one, every `n/a` with a
4-word reason; `_TEMPLATE.md` must carry every id; rows inside an HTML comment do not count. Evidence in a sibling repo
(`felhom.eu/…`) is checked where it sits beside the catalog and listed NOT CHECKED where it does not (the CI runner).
Decoys in `test_gate_decoys.py` (16 cases; 5 gate mutants each seen turning the suite red); `test_catalog_gates.py`
counts 11 gates.
- **`onboarding/EXISTING-APPS-GAPS.md`** + `scripts/onboarding_gaps.py` — read only: per checklist group, what the
committed files show for each of the 53 (headline: fit 52, images/DB 53, storage 38, accounts 39, health 49,
resources 24, updates 26, text 52). It found 8 templates whose `mem_limit` is under the sum of their limits (R-758).
- CLAUDE.md, REUSE.md §5 and README "Adding a New App": step 0 is `cp onboarding/_TEMPLATE.md onboarding/<app>.md`.
- No template changed. Found by the pilot on the live wger template: R-762 (no CSS/JS, no photos served), R-763
(strangers sign up, guest accounts), R-764 (no mail). Evidence `felhom.eu/documentation/audits/new-app-checklist-2026-10-01/`.
## calibre-web: the admin login name is generated at install (2026-10-01, late afternoon) ## calibre-web: the admin login name is generated at install (2026-10-01, late afternoon)
- **`09` §3 decision 61** (operator ruling, R-752 option A): a new deploy field `ADMIN_USER` (`type: secret`, - **`09` §3 decision 61** (operator ruling, R-752 option A): a new deploy field `ADMIN_USER` (`type: secret`,
+7
View File
@@ -20,6 +20,13 @@ deployed `app.yaml` (customer secrets) is never overwritten. Full deploy details
## Conventions ## Conventions
- **Adding a NEW app starts with `cp onboarding/_TEMPLATE.md onboarding/<app>.md`** (operator request
2026-10-01). `NEW-APP-CHECKLIST.md` is the list — 60 checks in 10 groups, each with how to test it and
why it exists; the record answers every id `done` (with evidence that exists), `n/a` (with a reason) or
`open`. The template and its complete record are published in ONE commit: `scripts/check-onboarding.py`
(gate `onboarding`, in `--fast`, so the hook and CI) refuses a new template directory without one. The 53
apps published before 2026-10-01 are exempt by name; what the catalog shows for them is
`onboarding/EXISTING-APPS-GAPS.md` (regenerate with `python3 scripts/onboarding_gaps.py`).
- **See `REUSE.md` before adding or editing an app** — canonical example app (paperless-ngx), - **See `REUSE.md` before adding or editing an app** — canonical example app (paperless-ngx),
required `.felhom.yml` fields, healthcheck family per image type, memory-limit rules, traps. required `.felhom.yml` fields, healthcheck family per image type, memory-limit rules, traps.
- Update `REUSE.md` in the same commit that changes a catalog-wide convention. - Update `REUSE.md` in the same commit that changes a catalog-wide convention.
+4
View File
@@ -2,6 +2,10 @@
> Created with the REUSE.md rollout (2026-07-03). History: `CHANGELOG.md`; format spec: `README.md`. > Created with the REUSE.md rollout (2026-07-03). History: `CHANGELOG.md`; format spec: `README.md`.
- **2026-10-01 (evening) — THE NEW-APP CHECKLIST.** `NEW-APP-CHECKLIST.md` (60 rows, `since` per id) + `onboarding/`
(`_TEMPLATE.md`, `wger.md` pilot, `EXISTING-APPS-GAPS.md`) + gate `onboarding` (`check-onboarding.py`, `--fast`, decoys).
The 53 published apps exempt by name (operator default, may reverse). A new app = template + complete record in one
commit. Open from the pilot: R-758..R-764 (R-762/R-763 P2 on wger).
- **2026-09-24 (morning) — STEP DEFINITIONS (`5ed599c`).** Every ladder entry but the newest has its own compose at - **2026-09-24 (morning) — STEP DEFINITIONS (`5ed599c`).** Every ladder entry but the newest has its own compose at
`templates/<app>/steps/<ladder.step_key(to)>.yml` (sha256 of `to`, canonical JSON, 16 hex = controller `templates/<app>/steps/<ladder.step_key(to)>.yml` (sha256 of `to`, canonical JSON, 16 hex = controller
`stacks.StepKey`); gate rule 4 + decoys; the writer keeps the superseded step; 8 backfilled `stacks.StepKey`); gate rule 4 + decoys; the writer keeps the superseded step; 8 backfilled
+124 -85
View File
@@ -1,119 +1,158 @@
# NEW-APP CHECKLIST — draft (reviewer, 2026-10-01) # NEW-APP CHECKLIST — what a new catalog app must have measured before it reaches the live catalog
**What this is.** The list of things every new catalog app must have measured and recorded BEFORE it reaches the live > Operator request 2026-10-01: before new apps are added, a checklist every new app passes. Reviewer's draft the same
catalog. Each item exists because something went wrong on a real app — the "why" column names it. The filled-in copy > day, reviewed and piloted on wger by CC (`felhom.eu/documentation/audits/new-app-checklist-2026-10-01/`).
per app is the app's **onboarding record**; a gate refuses a new app without a complete one. > **The gate:** `scripts/check-onboarding.py` (in `catalog_gates.py --fast`, so the pre-push hook and CI run it).
**How to read an item.** Each answer is **done** (with an evidence path), **n/a** (with a one-line reason), or **open** **What this is.** Every check below exists because something went wrong on a real app — the *why* column names it.
(blocks publishing). "Measured" means on the bench or on scratch guest 9202 through the product — never read from The filled-in copy per app is the app's **onboarding record**, `onboarding/<app>.md`, started by copying
upstream alone. Upstream reading is allowed to PLAN a test, never to CLOSE an item. `onboarding/_TEMPLATE.md`. A new template directory without a complete record is refused by the gate.
**Where it lives (proposed — CC confirms):** the checklist in `app-catalog-felhom.eu/NEW-APP-CHECKLIST.md`; one record per **How an item is answered.** One line per id in the record: `<id> | done | <evidence path>`, `<id> | n/a | <reason>`
app in `app-catalog-felhom.eu/onboarding/<app>.md` (outside `templates/`, so the box never syncs it — the controller copies or `<id> | open | <what is missing>`.
only `docker-compose.yml` and `.felhom.yml`, `sync.go:364`). The new app is tested from the drill catalog on 9202 and - **done** names evidence that exists: a non-empty file, or a directory holding one. Paths are written from the
reaches the live catalog only with a complete record. workspace root — `app-catalog-felhom.eu/…` or `felhom.eu/documentation/audits/…`. Several paths: separate them with
` ; `. A note may follow the path(s) after ` — `.
- **n/a** carries a reason of at least four words.
- **open** blocks publishing.
**Measured, not read.** "Measured" means on the bench (LXC 9401 on demo-hp) or on scratch guest 9202 (drill catalog
only, `09` §6.5) through the product. Upstream reading may PLAN a test; it never CLOSES an item. Where the *how* says
"read", reading is the test (a licence, a tag list).
**The `since` column.** The date an id joined the list. A record carries every id whose `since` is on or before the
record's `opened:` date; an id added later binds only apps opened after it. A record may answer a newer id anyway.
**The 53 apps in the catalog on 2026-10-01 are exempt** (operator default, may be reversed); what the catalog
already shows for them is `onboarding/EXISTING-APPS-GAPS.md`. An exempt app's record, if one exists, must still be
well-formed, and may say `open`.
--- ---
## 0. Fit — should we offer it at all? ## 0. Fit — should we offer it at all?
| # | Check | How | Why (what went wrong before) | | id | since | Check | How | Why (what went wrong before) |
|---|---|---|---| |---|---|---|---|---|
| 0.1 | Open-source licence; we pull the upstream image, never redistribute | read the repo | the business sells installation, not software | | 0.1 | 2026-10-01 | Open-source licence; we pull the upstream image, never redistribute it | read the repo's licence file | the business sells installation, not software |
| 0.2 | Upstream is alive: a release in the last 6 months, issues answered | read the repo | plant-it went `abandoned`, its image is gone | | 0.2 | 2026-10-01 | Upstream is alive: a release in the last 6 months, issues answered | read the repo | plant-it is `lifecycle: abandoned` (`templates/plant-it/.felhom.yml`) |
| 0.3 | An official image with **version tags** (not `latest`-only), amd64 (+arm64 if Pi) | registry | pins and digests need real tags | | 0.3 | 2026-10-01 | An official image with **version tags** (not `latest` only), amd64 (+ arm64 if `pi_compatible`) | `docker manifest inspect <image>:<tag>` — the tag list can be stale (REUSE.md §2 "Image pinning") | pins, digests and the ladder need real tags |
| 0.4 | **Telemetry / phone-home** — on by default? a switch to turn it off? | docs + one packet capture or log read on 9202 | data sovereignty is the pitch | | 0.4 | 2026-10-01 | **Telemetry / phone-home** — on by default? a switch to turn it off? Start-time downloads (exercise sync, model fetch)? | read the entrypoint (1.7) for start-time network jobs; on 9202, the app's log of its first start | data sovereignty is the pitch |
| 0.5 | Needs the internet at runtime (claim tokens, first-start downloads)? | docs + a first start | plex (plex.tv claim), adventurelog (world data), immich (geodata) | | 0.5 | 2026-10-01 | Needs the internet at runtime (claim tokens, first-start downloads)? | docs + the first start on 9202 | plex (`PLEX_CLAIM`), adventurelog's world data (`09` decision 41), immich's geodata import (R-732) |
| 0.6 | Needs ports other than HTTP(S)? The tunnel carries HTTP only | compose + docs | gitea SSH, media DLNA, game servers — say what the household loses | | 0.6 | 2026-10-01 | Needs ports other than HTTP(S)? The tunnel carries HTTP only — say what the household loses | compose + docs | gitea SSH, DLNA, game servers |
| 0.7 | Phone / desktop apps: do they work through the tunnel and the setup gate? | docs + one real client if one exists | immich's phone app vs the gate (decision 46) | | 0.7 | 2026-10-01 | Phone / desktop apps: which login route do they call, and does it work through the tunnel and the setup gate? | docs for the route; then that route with `curl` through traefik on 9202 (3.9) — a real client only if one is at hand | wger's phone-app login route answered 500 while the web login worked (R-737); a gated app is unreachable for a phone app (`09` decision 46) |
| 0.8 | What a household gets from it, in one sentence (Hungarian) | — | the catalog card and `use_cases` | | 0.8 | 2026-10-01 | What a household gets from it, in one sentence (Hungarian) | — | the catalog card and `use_cases` |
| 0.9 | 2026-10-01 | The app does not call ITSELF at its public name (server-side), or the bench override is recorded | the env/config the server reads for its own URL; then the bench start | wanderer cannot run on the bench at all (R-739) — no bench, no update proof |
## 1. Images and the database ## 1. Images, the start command and the database
| # | Check | How | Why | | id | since | Check | How | Why |
|---|---|---|---| |---|---|---|---|---|
| 1.1 | Every image pinned to a concrete tag; resolvable | `catalog_gates.py <app>` (gates 1, 2) | `:latest` breaks restore fidelity | | 1.1 | 2026-10-01 | Every image pinned to a concrete tag that resolves | **gates `image-pins` + `image-resolvable`** (`catalog_gates.py <app>`) | `:latest` breaks restore fidelity |
| 1.2 | Database engine and major = **what the app's own upstream compose runs** | upstream compose at the pinned tag | decision 42's rule; avoids an early conversion | | 1.2 | 2026-10-01 | Database engine and major = what the app's own upstream compose runs | upstream compose at the pinned tag | `09` decisions 37/42 (one conversion, not two) |
| 1.3 | MariaDB sidecar: `MARIADB_AUTO_UPGRADE=1`; PostgreSQL 18: mount at `/var/lib/postgresql` | compose | R-459; PG 18 refuses `/data` | | 1.3 | 2026-10-01 | MariaDB sidecar: `MARIADB_AUTO_UPGRADE=1`; PostgreSQL 18: mount at `/var/lib/postgresql` | compose (no gate checks either today) | R-459; PG 18 refuses `/var/lib/postgresql/data` (CLAUDE.md engine rule) |
| 1.4 | The app migrates its own database at start, **or** the switch that makes it do so is set | an update on 9202, login read back | wger: no migration without `DJANGO_*` switch, update said "done", login 500 (R-738) | | 1.4 | 2026-10-01 | The app migrates its own database at start, **or** the switch that makes it do so is set | 1.7's read names the switch; then **prove it**: install the PREVIOUS upstream release on the bench, seed, move to the pin (`upgrade-test.py --move`), read back through the login | wger ran no migration without `DJANGO_PERFORM_MIGRATIONS`; the update said `done`, the login answered 500 (R-738). A new app at its newest tag has no "next" update to try — the step INTO the pin is the test |
| 1.5 | The app runs its production server, not a dev server | process list in the container | wger `runserver` (R-755) | | 1.5 | 2026-10-01 | The app runs its production server, not a development server | process list in the running container (`ps` / `/proc/*/cmdline`) | wger ran `manage.py runserver` (R-755) |
| 1.6 | Every secret the app needs is generated (no image default, no empty key) | compose + a login on 9202 | wger JWT key missing → login 500 (R-737) | | 1.6 | 2026-10-01 | Every key and secret the app READS is set or generated — none left at an image default or empty | list the env names the app's settings read (grep its settings source for env reads of `*KEY*`, `*SECRET*`, `*TOKEN*`, `*PEM*`); each one set in the compose; then 3.9's logins on every route | wger read `JWT_PRIVATE_KEY`, the template set none: the API login answered 500 on the right password while the web login worked (R-737); grafana falls back to `admin` on an empty field (R-708) |
| 1.7 | 2026-10-01 | **Every start-time switch in the image's entrypoint is read and decided** (migrations, production server, debug, start-time downloads, static files) | read the entrypoint inside the image (`docker run --rm --entrypoint cat <image> <entrypoint>`); list each `if $VAR` and the value the template sets | one read of wger's `entrypoint.sh` shows `DJANGO_PERFORM_MIGRATIONS` AND `WGER_USE_GUNICORN` — R-738 and R-755 in one file (`felhom.eu/documentation/audits/more-night-apps-2026-09-30/box/wger/entrypoint-read.txt`) |
| 1.8 | 2026-10-01 | Debug / development mode is OFF on the public origin | 1.7's read + the running container's env; an error page through traefik shows no stack trace | adventurelog ran Django with `DEBUG=True` on the public origin (R-482) |
| 1.9 | 2026-10-01 | A secret whose loss destroys data or locks people out (it encrypts stored data, or signs 2FA secrets / long-lived tokens) carries `data_key: true` and a comment saying why it must never be regenerated; a key that only signs sessions does not | what each generated secret is used for (the app's settings source) | a restore must RECOVER that key; regenerating it destroys data or locks out 2FA (felhom-app-catalog skill §4) |
## 2. Storage and backup ## 2. Storage and backup
| # | Check | How | Why | | id | since | Check | How | Why |
|---|---|---|---| |---|---|---|---|---|
| 2.1 | Every path the app writes is mounted — **measured** | gate 3, `check-volume-persistence.py <app>` | papra backed up an empty folder (R-156) | | 2.1 | 2026-10-01 | Every path the app writes is mounted — **measured** | **gate `volume-persistence`** (`check-volume-persistence.py <app>`, scratch host) | papra backed up an empty folder (R-156) |
| 2.2 | Where each path lives: named volume (NVMe) / `${HDD_PATH}/appdata` / `${USERDATA_PATH}` | compose, REUSE.md skeleton | the household can browse userdata, not appdata | | 2.2 | 2026-10-01 | Where each path lives: named volume (NVMe) / `${HDD_PATH}/appdata` / `${USERDATA_PATH}` | compose; REUSE.md §2 "Compose file skeleton" | the household can browse userdata, not appdata |
| 2.3 | Backup class for every HDD path (`mandatory` / `excluded` …) and `data_paths` labels | `.felhom.yml backup:` | DB rows that point at files need those files (07) | | 2.3 | 2026-10-01 | Backup class for every HDD path (`backup:` in `.felhom.yml`) and what the tier-1 unit holds | `.felhom.yml backup:`; the app's backup page on 9202 | DB rows point at files (07); the tier-1 unit holds NO drive-side data (R-537, R-538) |
| 2.4 | File owner / uid fits the box (PUID/PGID where the image wants them) | first start on 9202 | linuxserver images chown their tree | | 2.4 | 2026-10-01 | File owner / uid fits the box (PUID/PGID where the image wants them) | first start on 9202 | linuxserver images chown their tree |
| 2.5 | **A backup and a restore through the product, data read back** | 9202: seed → backup → remove → restore → read back | the promise is the restore, not the backup | | 2.5 | 2026-10-01 | **A backup and a restore through the product, data read back** | 9202: seed → backup → remove → restore → read back | the promise is the restore, not the backup |
| 2.6 | "Remove with data" and "remove keep data" both work | 9202 | R-756 (refused with a folder present) | | 2.6 | 2026-10-01 | "Remove with data" and "remove, keep data" both do what they say | 9202, then list what is left on the drive | "remove with data" was inert (R-442); refused with the folder present (R-756, cause not yet known) |
| 2.7 | Off-site size estimate for a typical household | measured size after seed + a sentence | decision 50's size warning | | 2.7 | 2026-10-01 | Off-site size for a typical household | measured size after the seed + one sentence | `09` decision 50 (the page names the largest apps) |
| 2.8 | 2026-10-01 | A file the household uploads opens again **through the front door** | 9202: upload through traefik, open it the way the page does | adventurelog's photos uploaded and rendered broken (R-483) |
## 3. Accounts and strangers ## 3. Accounts and strangers
| # | Check | How | Why | | id | since | Check | How | Why |
|---|---|---|---| |---|---|---|---|---|
| 3.1 | First-admin class (FIRST-ADMIN.md 1–6), **measured** | fresh install on 9202 | decision 45 | | 3.1 | 2026-10-01 | First-admin class (FIRST-ADMIN.md 1–6), **measured**, and the household can make its first account on a fresh install | fresh install on 9202, through traefik | `09` decision 45; wishlist could not be signed up to while the deploy said success (R-612) |
| 3.2 | Known default login → `after_install` with a generated password (and name, if the name is public and lockable) | 9202: default fails, generated works, wrong fails | bookstack, calibre-web (decisions 45, 61) | | 3.2 | 2026-10-01 | Known default login → `after_install` with a generated password (and a generated name, if the name is public and a lock targets it) | 9202: default fails, generated works, wrong fails | bookstack, claper (R-702), calibre-web (`09` decisions 45, 61) |
| 3.3 | Open first-run screen → `setup_gate` (+ probe that **flips**, measured before and after) | 9202 as a stranger | 32 apps, decision 46 | | 3.3 | 2026-10-01 | Open first-run screen → `setup_gate` (+ a probe that **flips**, measured before and after) | 9202 as a stranger; **gate `probe-measured`** refuses a probe with no measurement above it | 33 apps gated today (`09` decision 46); a probe that never flips blocks the household (R-715) |
| 3.4 | Open sign-up after the setup → `signup_block` / `after_setup` switch; case-insensitive | 9202 as a stranger | decisions 47–49 | | 3.4 | 2026-10-01 | Open sign-up after the setup → `signup_block` / `after_setup`; the block case-insensitive, the API sign-up blocked too | 9202 as a stranger, after the setup | R-711, R-512; `09` decisions 47–49 |
| 3.5 | The install window: a stranger reaches nothing before the password is replaced | poll once a second during install | R-741 | | 3.5 | 2026-10-01 | The install window: a stranger reaches nothing before the password is replaced | poll the default login once a second from the install press | R-741 (fixed box-wide in controller v0.284.x — a new `after_install` app still proves it) |
| 3.6 | **Lock-out**: N wrong passwords by a stranger — who is locked (name / address / everyone), for how long | 9202 through traefik | mealie 24 h, wger everyone (R-747, R-752, R-753) | | 3.6 | 2026-10-01 | **Lock-out**: N wrong passwords by a stranger for the public name — who is locked (name / address / everyone), for how long | 9202 through traefik; then the household's right password, and a SECOND member's | mealie 24 h (R-747); behind the tunnel every visitor has ONE address, so a per-address lock locks everyone (R-753) — wger (R-752) |
| 3.7 | The household can change its password and add family members; the page says how | 9202 | `add_people` copy | | 3.7 | 2026-10-01 | The household can change its password and add family members; the page says how | 9202 | `add_people` copy |
| 3.8 | Secrets pass to commands as arguments, never inside program code | review `after_install` | security review 2026-09-29 | | 3.8 | 2026-10-01 | Secrets pass to commands as arguments, never inside program code | review `after_install` | claper pasted the password into Elixir code (R-713); security review 2026-09-29 |
| 3.9 | 2026-10-01 | **Sign in the way each client does, through traefik over https**: the browser form (with its https `Origin` and CSRF cookie) AND every API login route a phone/desktop app uses — right password works, wrong refused | 9202, `curl` with the headers a browser sends; the API route from 0.7 | wger refused every browser sign-in behind traefik (CSRF, R-712); wger's API login 500 (R-737) |
## 4. Health ## 4. Health
| # | Check | How | Why | | id | since | Check | How | Why |
|---|---|---|---| |---|---|---|---|---|
| 4.1 | Compose healthcheck of the right family, `127.0.0.1` not `localhost` | REUSE.md §2 | vaultwarden IPv6 trap | | 4.1 | 2026-10-01 | Compose healthcheck of the family the IMAGE has (inspected, one tool per run), dialling `127.0.0.1` not `localhost` | the inspection loop in the felhom-app-catalog skill §2; REUSE.md §2 (no gate checks `localhost`; 0 templates use it today) | rallly's guessed `wget` (ENOENT); vaultwarden's IPv6 trap |
| 4.2 | The controller probe dials what the compose healthcheck dials; a real health path where one exists | gate 7 | a wrong probe stops a working app after an update (R-618) | | 4.2 | 2026-10-01 | The controller probe dials what the compose healthcheck dials; a real health path where one exists | **gate `probe-matches-compose`** | a wrong probe stops a working app after an update (R-618) |
| 4.3 | Healthy within `start_period` on a **cold first start** (incl. one-time imports) | 9202, timed | immich geodata import | | 4.3 | 2026-10-01 | Healthy within `start_period` on a **cold first start** (incl. one-time imports), with no restarts | 9202, timed, `RestartCount` read | glance crash-looped on every fresh install (R-473); immich's first start restarted 12× (R-676) — `09` decision 28 stops ≥ 6 in 10 min |
| 4.4 | Negative control: a broken app reads unhealthy | stop the DB, read the status | a probe that is always green proves nothing | | 4.4 | 2026-10-01 | Negative control: a broken app reads unhealthy | stop the DB (or break the app's data dir), read the status | a probe that is always green proves nothing; uptime-kuma parked on its wizard read healthy (R-613) |
| 4.5 | The probe-named container is the stack name; sidecars `<app>-db` … | compose | `findProbeContainer` fallback picks the DB | | 4.5 | 2026-10-01 | The probe-named container is the stack name; sidecars `<app>-db` … | compose (REUSE.md §2 "Probe-container naming") | `findProbeContainer` falls back to the DB; paperless's probe never ran (R-630) |
## 5. Resources ## 5. Resources
| # | Check | How | Why | | id | since | Check | How | Why |
|---|---|---|---| |---|---|---|---|---|
| 5.1 | **First start from birth, swap OFF**: peak `anon`, `oom_kill` = 0 | bench, sampled every 2 s | immich DB killed at 512 MiB, hidden by swap on 9202 (R-732, R-733) | | 5.1 | 2026-10-01 | **First start from birth, swap OFF**: peak `anon`, `oom_kill` = 0 | bench, the container's own cgroup sampled every 2 s from creation (`memory.stat` anon, `memory.events` oom_kill — never Docker's `OOMKilled`, R-528) | immich's DB killed at 512 MiB, hidden by swap on 9202 (R-732, R-733); calcom could not start at its limit (R-703) |
| 5.2 | 10-minute light-load soak: peak < 80 % of the limit, 0 kills, 0 restarts | harness memory watch | romm OOM at +76 s (decision 22) | | 5.2 | 2026-10-01 | 10-minute soak under the household's heaviest ordinary act: peak `anon` < 80 % of the limit, 0 kills, 0 restarts | harness memory watch (`upgrade-test.py`) + one burst of that act | romm OOM-looped for six hours after an update called success (R-635, `09` decision 22); paperless lost 11 of 20 uploads at once (R-514) |
| 5.3 | `mem_limit` in `.felhom.yml` = the sum of the compose limits | gate / review | immich's header was 128 MB off | | 5.3 | 2026-10-01 | `mem_limit` in `.felhom.yml` = the sum of the compose limits, and the header comment says the same | arithmetic by hand — **no gate checks it; 8 templates differ today (R-758)** | immich's `mem_limit` was 128 MB under its sum, its header named a 256M database running at 512M (fixed `56c4888`) |
| 5.4 | Node/Java apps: does the heap size itself from the limit? | two watches at two limits | R-693 (docmost) | | 5.4 | 2026-10-01 | Node/Java apps: does the heap size itself from the limit? | two watches at two limits | R-693 (docmost) |
| 5.5 | Pi-compatible (yes/no), disk it pulls (image size) | registry | old images filled 9202 (R-736) | | 5.5 | 2026-10-01 | Pi-compatible (yes/no) and the disk the images pull | registry (image size) | `pi_compatible` is a card field; a box's Docker disk refuses installs when full (R-736) |
## 6. Updates ## 6. Updates
| # | Check | How | Why | | id | since | Check | How | Why |
|---|---|---|---| |---|---|---|---|---|
| 6.1 | An upgrade fixture: seed + read-back **through the app's own front door**, negative control | `upgrade_fixtures*.py` | without it the app never updates itself | | 6.1 | 2026-10-01 | An upgrade fixture: seed + read-back **through the app's own front door**, negative control — or the reason none can exist | `upgrade_fixtures*.py` | the box's health check sees only the front page; only the read-back saw wger broken (R-738); three apps cannot be seeded headless (R-624) |
| 6.2 | A first ladder step proven on bench + 9202 (`--write-ladder`), **or** "manual only" with the reason | harness | 35 of 53 update at night; the rest need a person | | 6.2 | 2026-10-01 | A first ladder step proven on bench + 9202 (`--write-ladder`), **or** "manual only" with the reason | harness; **gates `test-record` + `test-record-move`** check the entry once written | 38 of 53 apps carry a ladder today; zipline needed two steps where one failed (R-742) |
| 6.3 | The undo works on a real failure (one forced-fail case) | 9202 | zipline's real failed jump, undone in 20 s | | 6.3 | 2026-10-01 | The undo works on a real failure (one forced-fail case) | 9202, the drill catalog's image store (`09` §6.5) | zipline's real failed jump, undone in 20 s (R-742) |
| 6.4 | `files_may_change` mark understood (which files change at start) | bench | immich's six marker files (R-734) | | 6.4 | 2026-10-01 | `files_may_change` understood (which files change at start) | bench (`files_changed_detail`) | immich's six marker files (R-734) |
| 6.5 | Tag shape is stable upstream (no `v` dropped, no flavour prefix) | tag list | gramps-web, jellyfin, kimai (R-731) | | 6.5 | 2026-10-01 | Tag shape is stable upstream (no `v` dropped, no flavour prefix) and whether the publisher re-pushes tags | tag list over a year; linuxserver rebuilds weekly | gramps-web, jellyfin, kimai (R-731); same-tag re-pushes (R-743, `09` decisions 52/55) |
## 7. Mail ## 7. Mail
| # | Check | How | Why | | id | since | Check | How | Why |
|---|---|---|---| |---|---|---|---|---|
| 7.1 | Sends mail? → `smtp_mapping` + `${VAR:-}` compose lines; a fresh install with mail OFF boots | 9202 | vaultwarden empty-vars trap | | 7.1 | 2026-10-01 | Sends mail? → `smtp_mapping` + `${VAR:-}` compose lines; a fresh install with mail OFF boots | 9202 | vaultwarden's empty-vars trap (REUSE.md §2 "App-email") |
## 8. Household-facing text and listing ## 8. Household-facing text and listing
| # | Check | How | Why | | id | since | Check | How | Why |
|---|---|---|---| |---|---|---|---|---|
| 8.1 | Hungarian + English, informal „te", no „kérjük"; parity green; freeze updated | `check-copy-i18n.py` | operator rule | | 8.1 | 2026-10-01 | Hungarian + English, informal „te", no „kérjük"; parity green; freeze updated | **gate `copy-i18n`** (`check-copy-i18n.py --capture-freeze` after a copy change) | operator rule; R-560 |
| 8.2 | `app_info`: tagline, use_cases, first_steps, default_creds (if any), add_people | read on 9202's page | the page is the manual | | 8.2 | 2026-10-01 | `app_info`: tagline, use_cases, first_steps, default_creds (if any), add_people — and each one TRUE on 9202 | read on 9202's app page and follow the first steps | paperless's page named a login that did not exist (R-515); first steps named a literal `wiki.DOMAIN` (R-498) |
| 8.3 | Logo + screenshots on felhom.eu by `slug` | the asset URL answers 200 | the card is blank otherwise | | 8.3 | 2026-10-01 | Logo + screenshots on felhom.eu by `slug` | `https://felhom.eu/assets/<slug>-logo.svg` (or `.png`, the controller's fallback) and `<slug>-screenshot-<n>.webp` answer 200 — NOT `-logo.webp`, which the canonical template's comment still names (R-761) | the card is blank otherwise |
| 8.4 | README tables, FIRST-ADMIN row, `category`, `catalog_since` | review | REUSE.md §5 | | 8.4 | 2026-10-01 | README tables, FIRST-ADMIN row, `category`, `catalog_since` | review | REUSE.md §5 |
| 8.5 | 2026-10-01 | The website's app count still holds | `ls templates | wc -l` against `felhom.eu/website` claims; record a drift | felhom-app-catalog skill §6 |
## 9. Sign-off ## 9. Sign-off
| # | Check | How | | id | since | Check | How | Why |
|---|---|---| |---|---|---|---|---|
| 9.1 | `python3 scripts/catalog_gates.py <app>` — all green (exit 0) | bench / scratch | | 9.1 | 2026-10-01 | `python3 scripts/catalog_gates.py <app>` — all green (exit 0) | bench / scratch host (it runs the runtime gate) | the one entry point (R-161) |
| 9.2 | A fresh install on 9202 from the drill catalog, as a household and as a stranger, start to finish | 9202 | | 9.2 | 2026-10-01 | A fresh install on 9202 from the drill catalog, as a household and as a stranger, start to finish | 9202 | the walk finds what single checks miss (R-482..R-488 in one evening) |
| 9.3 | Every item above done or n/a-with-reason; every finding that is not fixed is a register row | the record | | 9.3 | 2026-10-01 | Every item above done or n/a-with-reason; every finding not fixed is a register row | the record | prose is not a record |
| 9.4 | Published to the live catalog in ONE commit with its record | the gate checks | | 9.4 | 2026-10-01 | Published to the live catalog in ONE commit with its record | **gate `onboarding`** | a template and its proof travel together |
---
## Row count
| group | rows |
|---|---|
| 0 Fit | 9 |
| 1 Images, start command, database | 9 |
| 2 Storage and backup | 8 |
| 3 Accounts and strangers | 9 |
| 4 Health | 5 |
| 5 Resources | 5 |
| 6 Updates | 5 |
| 7 Mail | 1 |
| 8 Text and listing | 5 |
| 9 Sign-off | 4 |
| **total** | **60** |
+3
View File
@@ -427,6 +427,9 @@ where the felhom-controller is not used. For controller-based deployments, these
## Adding a New App ## Adding a New App
0. Copy `onboarding/_TEMPLATE.md` to `onboarding/<appname>.md` and answer every check in `NEW-APP-CHECKLIST.md`
(done with evidence / n/a with a reason). The `onboarding` gate (`scripts/check-onboarding.py`, run by
`catalog_gates.py --fast`) refuses a new template directory without a complete record; publish both in one commit.
1. Create `templates/<appname>/docker-compose.yml` following the template standards above 1. Create `templates/<appname>/docker-compose.yml` following the template standards above
2. Create `templates/<appname>/.felhom.yml` following the metadata format 2. Create `templates/<appname>/.felhom.yml` following the metadata format
3. Commit and push — the controller will pick it up on next sync 3. Commit and push — the controller will pick it up on next sync
+13 -10
View File
@@ -1,12 +1,15 @@
# REPORT — calibre-web's admin login name is generated at install (2026-10-01, late afternoon) # REPORT — the new-app checklist: in the catalog, a gate, piloted on wger (2026-10-01, evening)
Full session report: `felhom.eu/REPORT-calibre-name-and-prune-2026-10-01.md`. Full session report: `felhom.eu/REPORT-new-app-checklist-2026-10-01.md`; evidence
`felhom.eu/documentation/audits/new-app-checklist-2026-10-01/`.
- **`09` §3 decision 61** (operator) — catalog `e9f50b5`: `ADMIN_USER` (`type: secret`, `generate: "hex:5"`); `after_install` - **`NEW-APP-CHECKLIST.md`** reviewed: 60 rows in 10 groups (draft 53); 7 added, 16 sharpened, 9 wrong claims fixed.
renames `admin` in `app.db` (Calibre-Web has no rename command), sets the password with its own `cps.py -s`, proves both. - **`onboarding/_TEMPLATE.md`** (one line per id) and **`onboarding/wger.md`** (the pilot; 11 open rows, each a register row).
hu + en copy; the Hungarian freeze re-captured for the five changed calibre-web strings only; FIRST-ADMIN updated. - **Gate `onboarding`** (`scripts/check-onboarding.py`, `--fast`: hook + CI). The 53 published apps are exempt by name.
- **9202:** a stranger got in 0 of 31 times during the install hold; 40 wrong tries on `admin` → the household in at once 16 decoys judged right; 5 deliberately broken versions of the gate each turned the decoy suite red.
with its own name (form and OPDS); `admin` refused. - **`onboarding/EXISTING-APPS-GAPS.md`** from `scripts/onboarding_gaps.py` (read only).
- **demo-hp:** renamed by hand; the box then injected its own `ADMIN_USER` for the installed app (R-757) — renamed again to - **The pilot:** the draft caught R-752 and R-755 as written; it missed R-737 (its "how" logged in on the web form only)
that value; the name is in the operator's credentials file only. and R-738 for a NEW app (nothing to update at the newest tag). Rows 1.4/1.6 sharpened, 1.7/3.9 added — now all four.
- Gates: `catalog_gates.py --fast calibre-web` OK; pushed through the pre-push gates (no `--no-verify`). The new rows then found three live wger defects: R-762, R-763, R-764. No template was changed.
- Gates: `catalog_gates.py --fast` OK (11); `test_gate_decoys.py` 121 OK; `test_catalog_gates.py` OK;
`decoy_coverage_gate.py` 0 unaccounted.
+4
View File
@@ -62,6 +62,10 @@ Templates are config; the few script helpers other scripts must REUSE, never re-
## 5. Extension points (adding a new app) ## 5. Extension points (adding a new app)
0. **First: `cp onboarding/_TEMPLATE.md onboarding/<app>.md`** and work `NEW-APP-CHECKLIST.md` top to bottom — the
record is what the `onboarding` gate (`scripts/check-onboarding.py`) reads; a new template directory without a
complete record is refused at push. The app is tested from the drill catalog on 9202 (`09` §6.5) and reaches the
live catalog in ONE commit with its record.
1. `templates/<app>/docker-compose.yml` — copy `templates/paperless-ngx/docker-compose.yml` skeleton; every service needs `container_name`, `restart: unless-stopped`, `TZ=Europe/Budapest`, `deploy.resources.limits.memory`, a healthcheck (family per §2), Traefik labels on the web service, `traefik-public` external + `<app>-internal` network if it has a DB. 1. `templates/<app>/docker-compose.yml` — copy `templates/paperless-ngx/docker-compose.yml` skeleton; every service needs `container_name`, `restart: unless-stopped`, `TZ=Europe/Budapest`, `deploy.resources.limits.memory`, a healthcheck (family per §2), Traefik labels on the web service, `traefik-public` external + `<app>-internal` network if it has a DB.
2. `templates/<app>/.felhom.yml` — copy `templates/paperless-ngx/.felhom.yml`; required keys per §2; DOMAIN + SUBDOMAIN fields always; `mem_limit` = sum of compose limits; Hungarian user-facing text; `healthcheck.checks` probe. 2. `templates/<app>/.felhom.yml` — copy `templates/paperless-ngx/.felhom.yml`; required keys per §2; DOMAIN + SUBDOMAIN fields always; `mem_limit` = sum of compose limits; Hungarian user-facing text; `healthcheck.checks` probe.
3. Update `README.md` App Catalog + Variable-types tables (convention — every existing app is listed). 3. Update `README.md` App Catalog + Variable-types tables (convention — every existing app is listed).
+87
View File
@@ -0,0 +1,87 @@
# EXISTING APPS — what the catalog already shows, per checklist group
> Generated by `scripts/onboarding_gaps.py` from committed files (catalog `6d72c09`). **Do not edit by hand.**
> Read only: nothing was re-tested. A cell is what a FILE says, not a measurement made today. The 53 apps
> published before the checklist (2026-10-01) are exempt from the onboarding gate; this page is information,
> not work (operator default 2026-10-01, may be reversed).
## Headline — apps whose files show the group covered (of 53)
| group | covered | what "covered" means here (the signal read) |
|---|---|---|
| 0 Fit | 52 / 53 | `lifecycle` available, `use_cases` and `pi_compatible` present — licence, telemetry, internet need and phone apps are recorded nowhere |
| 1 Images, start command, DB | 53 / 53 | pins clean and the engine rules hold (MariaDB auto-upgrade, PG 18 mount) — entrypoint switches, the production server, migrations and secrets read (1.4–1.9) are recorded for NO app |
| 2 Storage and backup | 38 / 53 | the 2026-08-02 persistence sweep read the app CLEAN, and an HDD app carries `backup:` classes — no restore round trip is recorded per app |
| 3 Accounts and strangers | 39 / 53 | a FIRST-ADMIN.md row whose source is MEASURED on a box — lock-out (3.6) is recorded only for the R-752 apps |
| 4 Health | 49 / 53 | every service has a compose healthcheck, a controller probe exists, the exposed container is named like the stack — no negative control is recorded |
| 5 Resources | 24 / 53 | every service limited, `mem_limit` = the sum, and a ladder entry carries a measured memory watch — no first-start-from-birth watch is recorded except immich's |
| 6 Updates | 26 / 53 | a proven (not backfilled) ladder step AND an upgrade fixture |
| 7 Mail | — | not countable from files: whether an app WANTS mail is not recorded; the mapped count is below |
| 8 Text and listing | 52 / 53 | English block, tagline + use_cases + first_steps, listed in README, a FIRST-ADMIN row |
Mail: 6 app(s) carry `smtp_mapping`.
## Found while computing this page
- `mem_limit` differs from the sum of the compose limits (REUSE.md §2 says equal): 8 — adventurelog (384M vs 896), bookstack (512M vs 768), calcom (768M vs 1792), claper (384M vs 640), kimai (384M vs 640), nextcloud (1024M vs 1664), outline (768M vs 1152), zipline (512M vs 768).
- A service with no memory limit: none.
- A MariaDB sidecar without `MARIADB_AUTO_UPGRADE=1`: none.
- A PostgreSQL 18 data mount at the old path: none.
## Per app
| app | 0 fit | 1 images/DB | 2 storage | 3 accounts | 4 health | 5 resources | 6 updates | 7 mail | 8 text |
|---|---|---|---|---|---|---|---|---|---|
| actualbudget | yes | no DB sidecar | sweep clean | class 4, measured + setup_gate | yes | no watch | 0 proven step(s), fixture | — | yes |
| adventurelog | yes | engine rules hold | sweep clean | class 4, measured + setup_gate/signup_block/after_setup | hc missing | watched 73%, mem_limit≠sum | 1 proven step(s), fixture | — | yes |
| audiobookshelf | yes | no DB sidecar | sweep clean, backup classes | class 4, measured + setup_gate | yes | watched 10% | 1 proven step(s), fixture | — | yes |
| bentopdf | yes | no DB sidecar | sweep undetermined | class 5, read only | yes | no watch | 0 proven step(s), no fixture | — | yes |
| bookstack | yes | engine rules hold | sweep clean | class 3, measured + after_install | yes | watched 44%, mem_limit≠sum | 1 proven step(s), fixture | — | yes |
| calcom | yes | engine rules hold | sweep clean | class 4, measured + setup_gate/signup_block/after_setup | yes | watched 62%, mem_limit≠sum | 1 proven step(s), fixture | smtp mapped | yes |
| calibre-web | yes | no DB sidecar | sweep clean, backup classes | class 3, measured + after_install | yes | watched 19% | 1 proven step(s), fixture | — | yes |
| claper | yes | engine rules hold | sweep undetermined | class 3 (+ open sign-up), measured + after_install | yes | watched 48%, mem_limit≠sum | 1 proven step(s), fixture | — | yes |
| code-server | yes | no DB sidecar | sweep clean | class 1, read only | yes | no watch | 0 proven step(s), fixture | — | yes |
| crafty-controller | yes | no DB sidecar | sweep clean | class 1, read only | yes | watched 3% | 1 proven step(s), fixture | — | yes |
| docmost | yes | engine rules hold | sweep undetermined | class 4, measured + setup_gate | yes | watched 80% | 1 proven step(s), fixture | — | yes |
| emby | yes | no DB sidecar | sweep clean, backup classes | class 4, measured + setup_gate | yes | watched 5% | 2 proven step(s), no fixture | — | yes |
| ghost | yes | no DB sidecar | sweep clean | class 4, measured + setup_gate | yes | watched 27% | 2 proven step(s), no fixture | — | yes |
| gitea | yes | no DB sidecar | sweep clean | class 4, measured + setup_gate/signup_block/after_setup | yes | watched 27% | 1 proven step(s), fixture | smtp mapped | yes |
| glance | yes | no DB sidecar | sweep undetermined | class 5, read only | yes | no watch | 0 proven step(s), no fixture | — | yes |
| gokapi | yes | no DB sidecar | sweep clean | class 1, read only | yes | no watch | 0 proven step(s), no fixture | — | yes |
| grafana | yes | no DB sidecar | sweep clean | class 1, read only | yes | watched 47% | 1 proven step(s), fixture | — | yes |
| gramps-web | yes | no DB sidecar | sweep broken | class 4, measured + setup_gate/signup_block/after_setup | yes | no watch | 0 proven step(s), fixture | — | yes |
| home-assistant | yes | no DB sidecar | sweep clean | class 4, measured + setup_gate | yes | watched 32% | 1 proven step(s), fixture | — | yes |
| homebox | yes | no DB sidecar | sweep clean | class 4, measured + setup_gate/signup_block/after_setup | yes | no watch | 0 proven step(s), no fixture | — | yes |
| homepage | yes | no DB sidecar | sweep clean | class 5, read only | yes | no watch | 0 proven step(s), fixture | — | yes |
| immich | yes | engine rules hold | sweep undetermined, backup classes | class 4, measured + setup_gate | probe container not in compose | watched 51% | 2 proven step(s), no fixture | — | yes |
| jellyfin | yes | no DB sidecar | sweep clean, backup classes | class 4, measured + setup_gate | yes | no watch | 0 proven step(s), fixture | — | yes |
| kimai | yes | engine rules hold | sweep clean | class 1, read only | yes | watched 45%, mem_limit≠sum | 2 proven step(s), no fixture | — | yes |
| komga | yes | no DB sidecar | sweep clean, backup classes | class 4, measured + setup_gate | yes | watched 64% | 2 proven step(s), no fixture | — | yes |
| mealie | yes | no DB sidecar | sweep clean | class 3, measured + after_install | yes | watched 23% | 1 proven step(s), fixture | smtp mapped | yes |
| n8n | yes | no DB sidecar | sweep clean | class 4, measured + setup_gate | yes | watched 23% | 3 proven step(s), fixture | — | yes |
| navidrome | yes | no DB sidecar | sweep clean, backup classes | class 4, measured + setup_gate | yes | watched 7% | 2 proven step(s), fixture | — | yes |
| nextcloud | yes | engine rules hold | sweep clean, backup classes | class 1, measured | yes | watched 24%, mem_limit≠sum | 2 proven step(s), fixture | smtp mapped | yes |
| onlyoffice | yes | no DB sidecar | sweep clean | class 5, read only | yes | no watch | 0 proven step(s), fixture | — | yes |
| opengist | yes | no DB sidecar | sweep clean | class 4, measured + setup_gate/signup_block | yes | watched 78% | 1 proven step(s), fixture | — | yes |
| outline | yes | engine rules hold | sweep clean | class 4, measured + setup_gate | yes | watched 34%, mem_limit≠sum | 2 proven step(s), fixture | — | yes |
| paperless-ngx | yes | engine rules hold | sweep clean, backup classes | class 1, read only | probe container not in compose | watched 40% | 1 proven step(s), fixture | — | yes |
| papra | yes | no DB sidecar | sweep broken | class 4, measured + setup_gate/signup_block/after_setup | yes | no watch | 0 proven step(s), fixture | — | yes |
| plant-it | — (abandoned) | no DB sidecar | sweep undetermined | class 4, read only + setup_gate | yes | no watch | 0 proven step(s), no fixture | — | yes |
| plex | yes | no DB sidecar | sweep clean, backup classes | class 2, read only | yes | no watch | 0 proven step(s), fixture | — | yes |
| privatebin | yes | no DB sidecar | sweep broken | class 5, read only | yes | no watch | 0 proven step(s), fixture | — | yes |
| radarr | yes | no DB sidecar | sweep clean, backup classes | class 4, measured + setup_gate | yes | no watch | 0 proven step(s), fixture | — | yes |
| rallly | yes | engine rules hold | sweep clean | class 4, measured + setup_gate | yes | watched 61% | 2 proven step(s), fixture | smtp mapped | yes |
| recipe-importer | yes | no DB sidecar | sweep undetermined | class 4, measured + setup_gate | yes | no watch | 0 proven step(s), no fixture | — | not in README |
| romm | yes | engine rules hold | sweep clean, backup classes | class 4, measured + setup_gate | yes | watched 78% | 2 proven step(s), fixture | — | yes |
| seerr | yes | no DB sidecar | sweep clean | class 4, measured + setup_gate | yes | no watch | 0 proven step(s), no fixture | — | yes |
| sonarr | yes | no DB sidecar | sweep clean, backup classes | class 4, measured + setup_gate | yes | watched 14% | 1 proven step(s), no fixture | — | yes |
| sparkyfitness | yes | engine rules hold | sweep undetermined | class 4, measured + setup_gate/signup_block/after_setup | yes | watched 31% | 1 proven step(s), fixture | — | yes |
| tandoor | yes | engine rules hold | sweep clean | class 4, measured + setup_gate | yes | watched 79% | 1 proven step(s), fixture | — | yes |
| termix | yes | no DB sidecar | sweep clean | class 4, measured + setup_gate/signup_block/after_setup | yes | no watch | 0 proven step(s), fixture | — | yes |
| uptime-kuma | yes | no DB sidecar | sweep undetermined | class 4, measured + setup_gate | yes | watched 43% | 1 proven step(s), fixture | — | yes |
| vaultwarden | yes | no DB sidecar | sweep clean | class 1, read only | yes | no watch | 0 proven step(s), fixture | smtp mapped | yes |
| vikunja | yes | no DB sidecar | sweep clean | class 4, measured + setup_gate/signup_block/after_setup | hc missing | no watch | 0 proven step(s), fixture | — | yes |
| wanderer | yes | no DB sidecar | sweep undetermined | class 4, measured + signup_block/after_setup | yes | no watch | 0 proven step(s), no fixture | — | yes |
| wger | yes | no DB sidecar | sweep clean | class 3, measured + after_install | yes | watched 50% | 1 proven step(s), fixture | — | yes |
| wishlist | yes | no DB sidecar | sweep broken | class 4, measured + setup_gate/signup_block | yes | watched 36% | 1 proven step(s), fixture | — | yes |
| zipline | yes | engine rules hold | sweep undetermined | class 4, measured + setup_gate | yes | watched 34%, mem_limit≠sum | 2 proven step(s), fixture | — | yes |
+75
View File
@@ -0,0 +1,75 @@
# Onboarding record — <app>
app: <app>
opened: YYYY-MM-DD
template_at: <catalog commit the answers were measured against>
<!--
Copy this file to onboarding/<app>.md, set the three lines above, and answer every row below.
One line per id: `<id> | done | <evidence path(s)>`, `<id> | n/a | <reason, 4+ words>`, `<id> | open | <what is missing>`.
Evidence paths are written from the workspace root (app-catalog-felhom.eu/… or felhom.eu/documentation/audits/…);
several paths are separated by " ; "; a note may follow after " — ". The checks, their "how" and "why":
NEW-APP-CHECKLIST.md. The gate: scripts/check-onboarding.py (catalog_gates.py --fast). Do not reorder or merge
rows; do not add free-form lines between them.
-->
0.1 | open | not started: Open-source licence
0.2 | open | not started: Upstream is alive: a release in the last 6 months, issues answered
0.3 | open | not started: An official image with version tags (not latest only), amd64 (+ arm64 if pi_compatible)
0.4 | open | not started: Telemetry / phone-home
0.5 | open | not started: Needs the internet at runtime (claim tokens, first-start downloads)?
0.6 | open | not started: Needs ports other than HTTP(S)? The tunnel carries HTTP only
0.7 | open | not started: Phone / desktop apps: which login route do they call, and does it work through the …
0.8 | open | not started: What a household gets from it, in one sentence (Hungarian)
0.9 | open | not started: The app does not call ITSELF at its public name (server-side), or the bench override …
1.1 | open | not started: Every image pinned to a concrete tag that resolves
1.2 | open | not started: Database engine and major = what the app's own upstream compose runs
1.3 | open | not started: MariaDB sidecar: MARIADB_AUTO_UPGRADE=1
1.4 | open | not started: The app migrates its own database at start, or the switch that makes it do so is set
1.5 | open | not started: The app runs its production server, not a development server
1.6 | open | not started: Every key and secret the app READS is set or generated
1.7 | open | not started: Every start-time switch in the image's entrypoint is read and decided (migrations, …
1.8 | open | not started: Debug / development mode is OFF on the public origin
1.9 | open | not started: A secret whose loss destroys data or locks people out (it encrypts stored data, or …
2.1 | open | not started: Every path the app writes is mounted
2.2 | open | not started: Where each path lives: named volume (NVMe) / ${HDD_PATH}/appdata / ${USERDATA_PATH}
2.3 | open | not started: Backup class for every HDD path (backup: in .felhom.yml) and what the tier-1 unit holds
2.4 | open | not started: File owner / uid fits the box (PUID/PGID where the image wants them)
2.5 | open | not started: A backup and a restore through the product, data read back
2.6 | open | not started: "Remove with data" and "remove, keep data" both do what they say
2.7 | open | not started: Off-site size for a typical household
2.8 | open | not started: A file the household uploads opens again through the front door
3.1 | open | not started: First-admin class (FIRST-ADMIN.md 1–6), measured, and the household can make its first …
3.2 | open | not started: Known default login → after_install with a generated password (and a generated name, …
3.3 | open | not started: Open first-run screen → setup_gate (+ a probe that flips, measured before and after)
3.4 | open | not started: Open sign-up after the setup → signup_block / after_setup
3.5 | open | not started: The install window: a stranger reaches nothing before the password is replaced
3.6 | open | not started: Lock-out: N wrong passwords by a stranger for the public name
3.7 | open | not started: The household can change its password and add family members
3.8 | open | not started: Secrets pass to commands as arguments, never inside program code
3.9 | open | not started: Sign in the way each client does, through traefik over https: the browser form (with …
4.1 | open | not started: Compose healthcheck of the family the IMAGE has (inspected, one tool per run), …
4.2 | open | not started: The controller probe dials what the compose healthcheck dials
4.3 | open | not started: Healthy within start_period on a cold first start (incl. one-time imports), with no …
4.4 | open | not started: Negative control: a broken app reads unhealthy
4.5 | open | not started: The probe-named container is the stack name
5.1 | open | not started: First start from birth, swap OFF: peak anon, oom_kill = 0
5.2 | open | not started: 10-minute soak under the household's heaviest ordinary act: peak anon < 80 % of the …
5.3 | open | not started: mem_limit in .felhom.yml = the sum of the compose limits, and the header comment says …
5.4 | open | not started: Node/Java apps: does the heap size itself from the limit?
5.5 | open | not started: Pi-compatible (yes/no) and the disk the images pull
6.1 | open | not started: An upgrade fixture: seed + read-back through the app's own front door, negative control
6.2 | open | not started: A first ladder step proven on bench + 9202 (--write-ladder), or "manual only" with the …
6.3 | open | not started: The undo works on a real failure (one forced-fail case)
6.4 | open | not started: files_may_change understood (which files change at start)
6.5 | open | not started: Tag shape is stable upstream (no v dropped, no flavour prefix) and whether the …
7.1 | open | not started: Sends mail? → smtp_mapping + ${VAR:-} compose lines
8.1 | open | not started: Hungarian + English, informal „te", no „kérjük"
8.2 | open | not started: app_info: tagline, use_cases, first_steps, default_creds (if any), add_people
8.3 | open | not started: Logo + screenshots on felhom.eu by slug
8.4 | open | not started: README tables, FIRST-ADMIN row, category, catalog_since
8.5 | open | not started: The website's app count still holds
9.1 | open | not started: python3 scripts/catalog_gates.py <app>
9.2 | open | not started: A fresh install on 9202 from the drill catalog, as a household and as a stranger, …
9.3 | open | not started: Every item above done or n/a-with-reason
9.4 | open | not started: Published to the live catalog in ONE commit with its record
+75
View File
@@ -0,0 +1,75 @@
# Onboarding record — wger
app: wger
opened: 2026-10-01
template_at: 82fff32 (wger/server:2.7; catalog main 6d72c09 when measured)
<!--
The pilot record (NEW-APP-CHECKLIST.md, Part C of the 2026-10-01 brief), filled for wger AS IT IS NOW. wger is one of the
53 apps published before the checklist, so the gate only shape-checks this file and `open` is allowed here. Every open
row is a register row: R-759 (this record's open rows), R-755 (the dev server), R-762 (no CSS/JS, no photos served),
R-763 (strangers make accounts), R-764 (no mail). The first pass, against the template as it was on 2026-09-29, and the
"would it have caught it" table: felhom.eu/documentation/audits/new-app-checklist-2026-10-01/.
Measured on 9202 2026-10-01 from the drill catalog (e9f50b5, wger template identical to live): C1..C9.
-->
0.1 | done | felhom.eu/documentation/audits/new-app-checklist-2026-10-01/B/B1-upstream-reads.txt — AGPL-3.0; image pulled from Docker Hub
0.2 | done | felhom.eu/documentation/audits/new-app-checklist-2026-10-01/B/B1-upstream-reads.txt — 2.7 on 2026-09-03; repo pushed 2026-10-01; 251 open issues
0.3 | done | felhom.eu/documentation/audits/new-app-checklist-2026-10-01/B/B1-upstream-reads.txt — plain `x.y` tags, amd64 + arm64
0.4 | done | felhom.eu/documentation/audits/new-app-checklist-2026-10-01/C/C2-static-reads.txt — no start-time sync or download is switched on; no outbound connection at rest; ingredient search can download from wger.de on demand (`DOWNLOAD_INGREDIENTS_FROM=WGER`, not measured)
0.5 | done | felhom.eu/documentation/audits/new-app-checklist-2026-10-01/C/C1-install.txt ; felhom.eu/documentation/audits/new-app-checklist-2026-10-01/C/C2-static-reads.txt — first start runs local migrations only
0.6 | n/a | wger serves only HTTP on port 8000; nothing else is published
0.7 | done | felhom.eu/documentation/audits/new-app-checklist-2026-10-01/C/C3-logins.txt — the phone app's route: right 200 + a token that reads the API, wrong 400; wger is not gated (class 3)
0.8 | done | app-catalog-felhom.eu/templates/wger/.felhom.yml — tagline + five use_cases
0.9 | done | felhom.eu/documentation/audits/more-night-apps-2026-09-30/bench/apps/wger/bench/evidence/MV-wger/verdict.json — runs on the bench; SITE_URL builds links only
1.1 | done | felhom.eu/documentation/audits/new-app-checklist-2026-10-01/B/B3-gates-now.txt — image-pins and image-resolvable exit 0
1.2 | n/a | wger keeps SQLite on its own volume, no database sidecar
1.3 | n/a | no MariaDB or PostgreSQL service in this template
1.4 | done | felhom.eu/documentation/audits/new-app-checklist-2026-10-01/C/C2-static-reads.txt ; felhom.eu/documentation/audits/more-night-apps-2026-09-30/box/wger/step.txt — `DJANGO_PERFORM_MIGRATIONS=True`; the 2.6 -> 2.7 step migrated and read back on the box
1.5 | open | the container runs `manage.py runserver` — `WGER_USE_GUNICORN` is not set (R-755; C2)
1.6 | done | felhom.eu/documentation/audits/new-app-checklist-2026-10-01/C/C2-static-reads.txt ; felhom.eu/documentation/audits/new-app-checklist-2026-10-01/C/C3-logins.txt — of 15 key-like env reads: SECRET_KEY generated, JWT pair made at start (phone route 200), DB password unused by SQLite; the rest belong to features off here (S3, mail, reCAPTCHA, OIDC, PowerSync)
1.7 | open | two entrypoint switches are still undecided: `WGER_USE_GUNICORN` (R-755) and `DJANGO_DEBUG` — unset, so `collectstatic` never runs (R-762; C2, C8)
1.8 | done | felhom.eu/documentation/audits/new-app-checklist-2026-10-01/C/C2-static-reads.txt ; felhom.eu/documentation/audits/new-app-checklist-2026-10-01/C/C3-logins.txt — DEBUG False; an unknown page is a plain 404, no debug page
1.9 | n/a | SECRET_KEY signs sessions and reset links only; the JWT pair lives on the data volume and is backed up with it
2.1 | done | app-catalog-felhom.eu/audits/persistence-sweep-2026-08-02/state/gate.log — wger CLEAN (the two volumes are unchanged since)
2.2 | done | app-catalog-felhom.eu/templates/wger/docker-compose.yml — two named volumes (NVMe), no drive path
2.3 | n/a | needs_hdd false: no drive path to classify; the tier-1 unit holds both named volumes
2.4 | done | felhom.eu/documentation/audits/new-app-checklist-2026-10-01/C/C8-signup-guest-media-static.txt — the app wrote its DB and a photo as user `wger`
2.5 | open | no backup -> remove -> restore -> read back of wger: the box has no per-app backup press outside an Update (R-648) and wger has no newer step yet (R-759)
2.6 | done | felhom.eu/documentation/audits/new-app-checklist-2026-10-01/C/C7-remove.txt ; felhom.eu/documentation/audits/new-app-checklist-2026-10-01/C/C8b-remove-with-data.txt — both choices delete both volumes; for a no-drive app "keep data" keeps only the backups (the page says the app stored no drive data)
2.7 | done | felhom.eu/documentation/audits/new-app-checklist-2026-10-01/C/C4-seed-photo-size.txt — 4.2 MB + 16 KB after the seed
2.8 | open | an uploaded photo is saved (201, file on the volume) but answers 404 through the front door — nothing serves /media/ (R-762; C4, C8)
3.1 | done | felhom.eu/documentation/audits/login-gate-2026-09-29/D/D2-live.txt ; felhom.eu/documentation/audits/new-app-checklist-2026-10-01/C/C3-logins.txt — class 3, measured
3.2 | done | felhom.eu/documentation/audits/login-gate-2026-09-29/D/D2-live.txt ; felhom.eu/documentation/audits/new-app-checklist-2026-10-01/C/C1-install.txt — default refused, generated signs in, wrong refused
3.3 | n/a | class 3: a known default login, not an open first-run screen
3.4 | open | after the setup a stranger signed up and signed in, and each anonymous visit to the dashboard made a guest account — `ALLOW_REGISTRATION` and `ALLOW_GUEST_USERS` default True (R-763; C8)
3.5 | done | felhom.eu/documentation/audits/new-app-checklist-2026-10-01/C/C1-install.txt — 92 stranger tries from the press: 90 no route, 1 the gate, then refused; never let in
3.6 | done | felhom.eu/documentation/audits/lockouts-2026-10-01/B/B5-wger-fix-5min.txt — only the name tried is locked, 5 min; the second member unaffected (`09` decision 58)
3.7 | open | not measured: changing the password and adding a family member; the template has no `add_people` text (R-759)
3.8 | done | app-catalog-felhom.eu/templates/wger/.felhom.yml — the password is `sys.argv[1]`
3.9 | done | felhom.eu/documentation/audits/new-app-checklist-2026-10-01/C/C3-logins.txt — browser form with https Origin: right 302, wrong refused; phone route right 200, wrong 400
4.1 | done | app-catalog-felhom.eu/templates/wger/docker-compose.yml ; felhom.eu/documentation/audits/new-app-checklist-2026-10-01/C/C1-install.txt — wget to 127.0.0.1:8000, docker healthy
4.2 | done | felhom.eu/documentation/audits/new-app-checklist-2026-10-01/B/B3-gates-now.txt — probe-matches-compose exit 0
4.3 | done | felhom.eu/documentation/audits/new-app-checklist-2026-10-01/C/C1-install.txt — healthy 107 s after the press (image pull included), 0 restarts
4.4 | done | felhom.eu/documentation/audits/new-app-checklist-2026-10-01/C/C5-negative-control.txt — paused: the controller read `stopped` within 4 s, `running` 40 s after; a probe-only failure (running but wrong) was not built
4.5 | done | app-catalog-felhom.eu/templates/wger/docker-compose.yml — `container_name: wger`, the only service
5.1 | done | felhom.eu/documentation/audits/new-app-checklist-2026-10-01/C/C6-first-start-memory.txt ; felhom.eu/documentation/audits/new-app-checklist-2026-10-01/C/C6-wger-mem.csv — from birth, 183 s: peak anon 267 MiB = 69.5 % of 384M, swap 0, oom_kill 0
5.2 | done | felhom.eu/documentation/audits/more-night-apps-2026-09-30/bench/apps/wger/bench/evidence/MV-wger/memory-samples.json — 10 min: peak anon 49.8 %, 0 kills, 0 restarts
5.3 | done | app-catalog-felhom.eu/templates/wger/.felhom.yml — mem_limit 384M = the one service's 384M
5.4 | n/a | wger is a Python (Django) app, no self-sizing heap
5.5 | done | felhom.eu/documentation/audits/new-app-checklist-2026-10-01/B/B1-upstream-reads.txt — pi_compatible true, arm64 published, ~375 MB
6.1 | done | app-catalog-felhom.eu/scripts/upgrade_fixtures_box.py — `Wger`: a weight entry through the login + API, with two negative controls
6.2 | done | felhom.eu/documentation/audits/more-night-apps-2026-09-30/bench/apps/wger/bench/evidence/MV-wger/verdict.json ; felhom.eu/documentation/audits/more-night-apps-2026-09-30/box/wger/step.txt — 2.6 -> 2.7 proven on both venues
6.3 | open | not measured for wger: no forced-fail undo (R-759)
6.4 | done | felhom.eu/documentation/audits/more-night-apps-2026-09-30/bench/apps/wger/bench/evidence/MV-wger/verdict.json — no mark: no file changed at start
6.5 | done | felhom.eu/documentation/audits/new-app-checklist-2026-10-01/B/B1-upstream-reads.txt — `x.y` + `x.y.0`, stable 2.1 -> 2.7; pre-releases carry `-dev`/`-alpha` suffixes
7.1 | open | wger has a mail switch (`ENABLE_EMAIL`) and no `smtp_mapping`; its mail goes to the console, so a password-reset mail never leaves the box (R-764; C2)
8.1 | done | felhom.eu/documentation/audits/new-app-checklist-2026-10-01/B/B3-gates-now.txt — copy-i18n exit 0
8.2 | open | not read on 9202's app page this session; `add_people` is absent (R-759)
8.3 | done | felhom.eu/documentation/audits/new-app-checklist-2026-10-01/B/B1-upstream-reads.txt — wger-logo.png and screenshot-1 answer 200
8.4 | done | app-catalog-felhom.eu/README.md — row `fitness.*`; FIRST-ADMIN row; category home; catalog_since set
8.5 | n/a | wger is an existing app; the count does not change
9.1 | open | the runtime volume-persistence gate was not re-run today (last CLEAN 2026-08-02); the other gates exit 0 (B3) (R-759)
9.2 | done | felhom.eu/documentation/audits/new-app-checklist-2026-10-01/C/C1-install.txt ; felhom.eu/documentation/audits/new-app-checklist-2026-10-01/C/C8-signup-guest-media-static.txt — fresh installs from the drill catalog, as household and stranger
9.3 | open | 10 other rows stay open; each is a register row (R-755, R-759, R-762, R-763, R-764)
9.4 | n/a | wger is exempt: published 2026-02-15, before the checklist
+7
View File
@@ -23,6 +23,8 @@ Gates, in order (all must pass; **non-zero exit on any failure**):
and its newest step IS the compose's images (runs in CI too) and its newest step IS the compose's images (runs in CI too)
9. test-record-move git history + the registry for MOVED refs only — an image move adds a PROVEN 9. test-record-move git history + the registry for MOVED refs only — an image move adds a PROVEN
ladder entry whose digests the registry still serves (hook; skipped on CI) ladder entry whose digests the registry still serves (hook; skipped on CI)
10. onboarding static, instant, whole repo — a NEW template directory carries a complete onboarding
record (NEW-APP-CHECKLIST.md; the 53 apps published before 2026-10-01 are exempt by name)
4. engine-major static, needs GIT HISTORY — no database engine pin crosses a MAJOR version 4. engine-major static, needs GIT HISTORY — no database engine pin crosses a MAJOR version
(operator ruling 2026-09-13; expires when Slice 4 / R-448 ships). Runs in the (operator ruling 2026-09-13; expires when Slice 4 / R-448 ships). Runs in the
pre-push hook, which has the full clone; on a SHALLOW clone (CI fetches at pre-push hook, which has the full clone; on a SHALLOW clone (CI fetches at
@@ -107,6 +109,11 @@ GATES = [
# move must add a PROVEN entry whose digests the registry still serves. # move must add a PROVEN entry whose digests the registry still serves.
("test-record", "check-test-record.py", True, True, False), ("test-record", "check-test-record.py", True, True, False),
("test-record-move", "check-test-record-move.py", False, True, True), ("test-record-move", "check-test-record-move.py", False, True, True),
# 2026-10-01 (operator request): a NEW template directory carries a complete onboarding record —
# onboarding/<app>.md answering every NEW-APP-CHECKLIST.md id, none open, every `done` naming evidence that
# exists. Static, files only, so it is --fast and bites in the hook AND in CI. The 53 apps published before
# the checklist are exempt by name inside the script.
("onboarding", "check-onboarding.py", False, True, False),
] ]
VERDICT = {0: "OK", 1: "FAILED", 2: "INCONCLUSIVE"} VERDICT = {0: "OK", 1: "FAILED", 2: "INCONCLUSIVE"}
+260
View File
@@ -0,0 +1,260 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""check-onboarding.py — a NEW catalog app carries a complete onboarding record (NEW-APP-CHECKLIST.md).
WHY. Operator request 2026-10-01: before a new app is offered, it is mapped and tested against one checklist —
storage, database, the first admin, health checks, memory, updates, mail, the household's text. A checklist that
nothing enforces is a wish; this gate is the enforcement. It reads files only (no network, no containers, no git
history), so it runs in `catalog_gates.py --fast`: the pre-push hook and CI.
THE RULE, for every directory under templates/ that is NOT in EXEMPT:
1. `onboarding/<app>.md` exists, its `app:` line names the app, and its `opened: YYYY-MM-DD` is a real date
on or after CUTOFF and not in the future.
2. It answers every checklist id whose `since` date is on or before `opened:` (an id added later binds only
apps opened after it — the checklist stores the date per id).
3. No answer is `open`.
4. Every `done` names evidence that EXISTS: a non-empty file, or a directory holding at least one non-empty
file. An empty directory is a label, not evidence (R-410: a `mkdir` once turned a release gate green).
5. Every `n/a` carries a reason of at least MIN_REASON_WORDS words.
For an EXEMPT app that has a record anyway (wger, the pilot): the record must be well-formed (known ids, no
duplicate, a valid status, `done` evidence that exists, `n/a` with a reason) — `open` and missing ids are allowed.
And `onboarding/_TEMPLATE.md` must carry every checklist id, so a row added to the checklist cannot be forgotten
in the template a new app copies.
EVIDENCE PATHS are written from the workspace root. `app-catalog-felhom.eu/…` resolves against THIS checkout
(whatever its directory is called — the CI runner checks out into another name). Any other first component
(`felhom.eu/…`) resolves against the checkout's parent directory; if that sibling repository is not there (the CI
runner fetches this repo alone) the path is NOT CHECKED and the count is printed — the pre-push hook on DooPlex has
the sibling and checks it. Same shape as engine-major's shallow-clone skip: said out loud, never silent.
WHY THE 53 ARE LISTED BY NAME and not "new since commit X": the CI runner fetches at --depth 1 and has no history
to diff (R-452), and a list is a fact a reader can check. A directory not on the list is new, whatever its age.
Run from the repo root: python3 scripts/check-onboarding.py [--root=DIR] [--today=YYYY-MM-DD]
Exit 0 all records complete · 1 a record is missing or incomplete · 2 the checklist itself cannot be read.
"""
import datetime
import os
import re
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
CATALOG_PREFIX = "app-catalog-felhom.eu/"
CUTOFF = "2026-10-01"
MIN_REASON_WORDS = 4
STATUSES = ("done", "n/a", "open")
# The 53 apps in the catalog on 2026-10-01 (catalog main 9c5eae9 + the checklist commit). They were published
# before the checklist existed; re-testing them is not owed (operator default 2026-10-01, may be reversed). What the
# catalog shows for each is onboarding/EXISTING-APPS-GAPS.md. NEVER add a name here to let a new app through.
EXEMPT = frozenset("""
actualbudget adventurelog audiobookshelf bentopdf bookstack calcom calibre-web claper code-server
crafty-controller docmost emby ghost gitea glance gokapi grafana gramps-web home-assistant homebox homepage
immich jellyfin kimai komga mealie n8n navidrome nextcloud onlyoffice opengist outline paperless-ngx papra
plant-it plex privatebin radarr rallly recipe-importer romm seerr sonarr sparkyfitness tandoor termix
uptime-kuma vaultwarden vikunja wanderer wger wishlist zipline
""".split())
ROW_CHECKLIST = re.compile(r"^\|\s*(\d+\.\d+)\s*\|\s*(\d{4}-\d{2}-\d{2})\s*\|")
ROW_RECORD = re.compile(r"^(\d+\.\d+)\s*\|\s*([^|]*?)\s*\|\s*(.*?)\s*$")
DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
def read_checklist(root):
path = os.path.join(root, "NEW-APP-CHECKLIST.md")
if not os.path.isfile(path):
return None, "NEW-APP-CHECKLIST.md is missing"
ids = {}
for line in open(path, encoding="utf-8"):
m = ROW_CHECKLIST.match(line)
if m:
cid, since = m.group(1), m.group(2)
if cid in ids:
return None, "checklist id %s appears twice" % cid
try:
datetime.date.fromisoformat(since)
except ValueError:
return None, "checklist id %s has a bad since date %r" % (cid, since)
ids[cid] = since
if not ids:
return None, "no `| <id> | <since> |` rows found in NEW-APP-CHECKLIST.md"
return ids, None
def evidence_ok(p):
if os.path.isfile(p):
return os.path.getsize(p) > 0
if os.path.isdir(p):
for dp, _dn, fn in os.walk(p):
for f in fn:
if os.path.getsize(os.path.join(dp, f)) > 0:
return True
return False
def resolve(root, rel, unchecked):
"""Return (abs path or None, problem or None). None, None = not checkable here (counted)."""
rel = rel.strip().strip("`")
if not rel or rel.startswith("/") or ".." in rel.split("/"):
return None, "evidence %r is not a workspace-relative path" % rel
if rel.startswith(CATALOG_PREFIX):
return os.path.join(root, rel[len(CATALOG_PREFIX):]), None
first = rel.split("/", 1)[0]
sib = os.path.join(os.path.dirname(root), first)
if not os.path.isdir(sib):
unchecked.append(rel)
return None, None
return os.path.join(os.path.dirname(root), rel), None
def check_record(root, app, path, checklist, strict, today, unchecked):
probs = []
# A row inside an HTML comment is not an answer: `<!-- … -->` is how a row is set aside, and a commented-out
# `1.4 | done | …` must not count as done (the label without the fact, R-421). Line numbers are kept.
text = re.sub(r"<!--.*?-->", lambda m: "\n" * m.group(0).count("\n"),
open(path, encoding="utf-8").read(), flags=re.S)
head = {}
for line in text.splitlines():
m = re.match(r"^(app|opened):\s*(\S+)\s*$", line)
if m and m.group(1) not in head:
head[m.group(1)] = m.group(2)
if head.get("app") != app:
probs.append("its `app:` line reads %r, not %r" % (head.get("app"), app))
opened = head.get("opened", "")
if not DATE.match(opened):
probs.append("no `opened: YYYY-MM-DD` line")
opened = None
else:
try:
datetime.date.fromisoformat(opened)
except ValueError:
probs.append("`opened: %s` is not a real date" % opened)
opened = None
if strict and opened:
if opened < CUTOFF:
probs.append("`opened: %s` is before the checklist existed (%s) — a new app cannot be opened earlier"
% (opened, CUTOFF))
if opened > today:
probs.append("`opened: %s` is in the future (today %s)" % (opened, today))
seen = {}
for n, line in enumerate(text.splitlines(), 1):
m = ROW_RECORD.match(line)
if not m:
continue
cid, status, rest = m.group(1), m.group(2).lower(), m.group(3)
if cid not in checklist:
probs.append("line %d: id %s is not in the checklist" % (n, cid))
continue
if cid in seen:
probs.append("line %d: id %s answered twice (first on line %d)" % (n, cid, seen[cid]))
continue
seen[cid] = n
if status not in STATUSES:
probs.append("line %d: id %s has status %r — one of done / n/a / open" % (n, cid, status))
elif status == "open":
if strict:
probs.append("id %s is OPEN: %s" % (cid, rest or "(no note)"))
elif status == "n/a":
if len(re.findall(r"[^\W\d_]{2,}", rest)) < MIN_REASON_WORDS:
probs.append("id %s is n/a with no reason of %d+ words: %r" % (cid, MIN_REASON_WORDS, rest))
else: # done
paths = rest.split(" — ", 1)[0]
parts = [p for p in (x.strip() for x in paths.split(" ; ")) if p]
if not parts:
probs.append("id %s is done with no evidence path" % cid)
for p in parts:
ap, why = resolve(root, p, unchecked)
if why:
probs.append("id %s: %s" % (cid, why))
elif ap and not evidence_ok(ap):
probs.append("id %s is done but its evidence %s does not exist (or is empty)" % (cid, p))
if strict and opened:
missing = [c for c, s in sorted(checklist.items(), key=lambda kv: [int(x) for x in kv[0].split(".")])
if s <= opened and c not in seen]
if missing:
probs.append("missing id(s): %s" % ", ".join(missing))
return probs
def main(argv):
root = ROOT
today = datetime.date.today().isoformat()
for a in argv:
if a.startswith("--root="):
root = os.path.abspath(a.split("=", 1)[1])
elif a.startswith("--today="):
today = a.split("=", 1)[1]
elif a in ("--all",):
pass # the runner passes --all through; every template is judged anyway
elif not a.startswith("-"):
pass # app scope is not used: the rule is about the whole templates/ tree
else:
print("unknown option %s" % a)
return 2
checklist, err = read_checklist(root)
if err:
print("ONBOARDING GATE INCONCLUSIVE — %s" % err)
return 2
tdir = os.path.join(root, "templates")
odir = os.path.join(root, "onboarding")
apps = sorted(d for d in os.listdir(tdir) if os.path.isdir(os.path.join(tdir, d)))
new = [a for a in apps if a not in EXEMPT]
problems, unchecked = [], []
tpl = os.path.join(odir, "_TEMPLATE.md")
if not os.path.isfile(tpl):
problems.append(("_TEMPLATE", ["onboarding/_TEMPLATE.md is missing"]))
else:
body = re.sub(r"<!--.*?-->", "", open(tpl, encoding="utf-8").read(), flags=re.S)
have = {m.group(1) for m in (ROW_RECORD.match(l) for l in body.splitlines()) if m}
lack = sorted(set(checklist) - have, key=lambda c: [int(x) for x in c.split(".")])
if lack:
problems.append(("_TEMPLATE", ["onboarding/_TEMPLATE.md lacks checklist id(s): %s" % ", ".join(lack)]))
for app in new:
rec = os.path.join(odir, app + ".md")
if not os.path.isfile(rec):
problems.append((app, ["NEW app with no onboarding record — copy onboarding/_TEMPLATE.md to "
"onboarding/%s.md and answer every id (NEW-APP-CHECKLIST.md)" % app]))
continue
p = check_record(root, app, rec, checklist, True, today, unchecked)
if p:
problems.append((app, p))
exempt_records = []
if os.path.isdir(odir):
for f in sorted(os.listdir(odir)):
name = f[:-3] if f.endswith(".md") else None
if not name or name.startswith("_") or name.isupper() or "-GAPS" in name.upper():
continue
if name not in apps:
problems.append((name, ["onboarding/%s.md names no template directory" % f]))
elif name in EXEMPT:
exempt_records.append(name)
p = check_record(root, name, os.path.join(odir, f), checklist, False, today, unchecked)
if p:
problems.append((name, p))
print("onboarding gate — %d checklist ids; %d template dirs: %d exempt (published before %s), %d new; "
"%d exempt app(s) with a record (shape-checked): %s"
% (len(checklist), len(apps), len(apps) - len(new), CUTOFF, len(new), len(exempt_records),
", ".join(exempt_records) or "none"))
if unchecked:
print(" NOT CHECKED here (sibling repository absent — the pre-push hook on DooPlex checks them): %d path(s)"
% len(unchecked))
for u in unchecked[:10]:
print(" %s" % u)
if problems:
print("ONBOARDING GATE FAILED:")
for app, ps in problems:
for p in ps:
print(" %s: %s" % (app, p))
return 1
print("onboarding gate OK")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
+247
View File
@@ -0,0 +1,247 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""onboarding_gaps.py — what the catalog's FILES already show, per checklist group, for the 53 exempt apps.
Writes onboarding/EXISTING-APPS-GAPS.md. READ ONLY: no network, no containers, no re-testing. It answers "which of
the NEW-APP-CHECKLIST.md groups does the catalog already hold a record for, per old app" from what is committed:
the templates, the ladder entries, the fixture tables, FIRST-ADMIN.md, README.md and the 2026-08-02 persistence
sweep. A cell is a signal the files carry, not a measurement made today — "shown" means a file says it, never
that it is still true. This is information, not work (operator default 2026-10-01).
Run from the repo root (PyYAML needed — this is a local report, not a gate):
python3 scripts/onboarding_gaps.py # rewrite onboarding/EXISTING-APPS-GAPS.md
python3 scripts/onboarding_gaps.py --check # exit 1 if the committed page differs from a fresh run
"""
import datetime
import io
import json
import os
import re
import subprocess
import sys
import yaml
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
OUT = os.path.join(ROOT, "onboarding", "EXISTING-APPS-GAPS.md")
SWEEP = os.path.join(ROOT, "audits", "persistence-sweep-2026-08-02", "state", "gate.log")
sys.path.insert(0, os.path.join(ROOT, "scripts"))
def exempt():
src = io.open(os.path.join(ROOT, "scripts", "check-onboarding.py"), encoding="utf-8").read()
return sorted(re.search(r'EXEMPT = frozenset\("""(.*?)"""', src, re.S).group(1).split())
def mb(v):
m = re.match(r"^\s*(\d+(?:\.\d+)?)\s*([MG])", str(v or ""))
if not m:
return None
return int(float(m.group(1)) * (1024 if m.group(2) == "G" else 1))
def fixture_apps():
names = set()
for f in ("upgrade_fixtures.py", "upgrade_fixtures_box.py", "upgrade_fixtures_box28.py"):
src = io.open(os.path.join(ROOT, "scripts", f), encoding="utf-8").read()
for block in re.findall(r"FIXTURES(?:28)?(?: = |\.update\()\{(.*?)\n\}", src, re.S):
names |= set(re.findall(r'^\s+"([a-z0-9-]+)":', block, re.M))
return names
def sweep_verdicts():
"""The FIRST run's lists in the 2026-08-02 sweep (53 in scope). BROKEN and UNDETERMINED are named; the rest
of the 53 were CLEAN."""
out = {}
if not os.path.isfile(SWEEP):
return out
text = io.open(SWEEP, encoding="utf-8").read()
first = text.split("of 53 in scope")[0]
sec = None
for line in first.splitlines():
if line.startswith("BROKEN"):
sec = "broken"
elif line.startswith("UNDETERMINED"):
sec = "undetermined"
elif sec and re.match(r"^ ([a-z0-9-]+)(:|$)", line):
out[re.match(r"^ ([a-z0-9-]+)", line).group(1)] = sec
return out
def first_admin_rows():
rows = {}
for line in io.open(os.path.join(ROOT, "FIRST-ADMIN.md"), encoding="utf-8"):
cells = [c.strip() for c in line.strip().strip("|").split("|")]
if len(cells) >= 6 and re.match(r"^\**[a-z0-9-]+\**$", cells[0]) and cells[1][:1].isdigit():
rows[cells[0].strip("*")] = {"class": cells[1], "measured": "**M" in cells[5] or cells[5].startswith("M")}
return rows
def main(argv):
apps = exempt()
fx = fixture_apps()
sweep = sweep_verdicts()
fa = first_admin_rows()
readme = io.open(os.path.join(ROOT, "README.md"), encoding="utf-8").read()
rows, tally = [], {g: 0 for g in range(9)}
notes = {"mem_mismatch": [], "no_limit": [], "maria": [], "pg18": []}
for app in apps:
d = os.path.join(ROOT, "templates", app)
fy_text = io.open(os.path.join(d, ".felhom.yml"), encoding="utf-8").read()
fy = yaml.safe_load(fy_text) or {}
dc = yaml.safe_load(io.open(os.path.join(d, "docker-compose.yml"), encoding="utf-8")) or {}
svcs = dc.get("services") or {}
ai = fy.get("app_info") or {}
res = fy.get("resources") or {}
cell = {}
# 0 Fit
life = (fy.get("lifecycle") or "available")
g0 = life == "available" and bool(ai.get("use_cases")) and "pi_compatible" in res
cell[0] = ("yes" if g0 else "—") + ("" if life == "available" else " (%s)" % life)
# 1 Images, start command, database — the files show the ENGINE rules only
engines, ok1 = [], True
for sn, s in svcs.items():
img = str(s.get("image", ""))
env = s.get("environment") or []
env = env if isinstance(env, list) else ["%s=%s" % kv for kv in env.items()]
if img.startswith("mariadb:"):
engines.append("mariadb")
if not any(str(e).replace(" ", "") in ("MARIADB_AUTO_UPGRADE=1", "MARIADB_AUTO_UPGRADE=\"1\"") for e in env):
ok1 = False
notes["maria"].append(app)
if re.match(r"^(postgres|postgis/postgis|ghcr\.io/immich-app/postgres):", img):
engines.append("pg")
if re.match(r"^postgres:18", img) and any(":/var/lib/postgresql/data" in str(v) for v in s.get("volumes") or []):
ok1 = False
notes["pg18"].append(app)
if ":latest" in img or ":" not in img.split("/")[-1]:
ok1 = False
cell[1] = ("engine rules hold" if engines else "no DB sidecar") if ok1 else "ENGINE RULE BROKEN"
g1 = ok1
# 2 Storage and backup
sv = sweep.get(app, "clean" if sweep else None)
hdd = bool(res.get("needs_hdd"))
g2 = sv == "clean" and (not hdd or "backup" in fy)
cell[2] = "%s%s" % ("sweep %s" % sv if sv else "no sweep",
(", backup classes" if "backup" in fy else ", HDD w/o classes") if hdd else "")
# 3 Accounts and strangers
r = fa.get(app)
mech = [k for k in ("after_install", "setup_gate", "signup_block", "after_setup") if fy.get(k)]
g3 = bool(r and r["measured"])
cell[3] = ("class %s, %s" % (r["class"], "measured" if r["measured"] else "read only") if r else "no row") + \
((" + " + "/".join(mech)) if mech else "")
# 4 Health
all_hc = all(s.get("healthcheck") for s in svcs.values())
probe = bool((fy.get("healthcheck") or {}).get("checks"))
# the probe dials the container named like the stack, or the one its `container:` names (R-630)
targets = {c.get("container") for c in (fy.get("healthcheck") or {}).get("checks") or [] if c.get("container")}
names = {s.get("container_name") for s in svcs.values()}
named = (app in names) if not targets else targets <= names
g4 = all_hc and probe and named
cell[4] = "yes" if g4 else ", ".join(x for x, ok in (("hc missing", all_hc), ("no probe", probe),
("probe container not in compose", named)) if not ok)
# 5 Resources
lims = [mb(((s.get("deploy") or {}).get("resources") or {}).get("limits", {}).get("memory")) for s in svcs.values()]
every = all(lims)
total = sum(x for x in lims if x)
ml = mb(res.get("mem_limit"))
if ml != total:
notes["mem_mismatch"].append("%s (%s vs %d)" % (app, res.get("mem_limit"), total))
if not every:
notes["no_limit"].append(app)
ladder = [json.loads(l.strip()[2:]) for l in fy_text.splitlines() if l.strip().startswith('- {"from"')]
watched = [e for e in ladder if e.get("memory_peak_pct") is not None]
g5 = every and ml == total and bool(watched)
cell[5] = ("watched %.0f%%" % watched[-1]["memory_peak_pct"] if watched else "no watch") + \
("" if ml == total else ", mem_limit≠sum") + ("" if every else ", a service w/o limit")
# 6 Updates
proven = [e for e in ladder if e.get("verdict") == "proven" and not e.get("backfilled")]
g6 = bool(proven) and app in fx
cell[6] = "%d proven step(s)%s" % (len(proven), ", fixture" if app in fx else ", no fixture")
# 7 Mail — the files cannot say whether an app WANTS mail; only whether it is mapped
cell[7] = "smtp mapped" if fy.get("smtp_mapping") else "—"
# 8 Text and listing
en = bool((fy.get("i18n") or {}).get("en"))
txt = all(ai.get(k) for k in ("tagline", "use_cases", "first_steps"))
# README's App Catalog table names an app by display name, so the row is found by its subdomain cell
listed = re.search(r"\|\s*%s\.\*\s*\|" % re.escape(str(fy.get("subdomain", "\0"))), readme) is not None
g8 = en and txt and listed and bool(r)
cell[8] = "yes" if g8 else ", ".join(x for x, ok in (("no en", en), ("app_info gap", txt),
("not in README", listed), ("no FIRST-ADMIN row", bool(r))) if not ok)
for g, v in enumerate((g0, g1, g2, g3, g4, g5, g6, None, g8)):
if v:
tally[g] += 1
rows.append((app, cell))
n = len(apps)
sha = subprocess.run(["git", "rev-parse", "--short", "HEAD"], cwd=ROOT, capture_output=True, text=True).stdout.strip()
L = []
L.append("# EXISTING APPS — what the catalog already shows, per checklist group")
L.append("")
L.append("> Generated by `scripts/onboarding_gaps.py` from committed files (catalog `%s`). **Do not edit by hand.**" % sha)
L.append("> Read only: nothing was re-tested. A cell is what a FILE says, not a measurement made today. The 53 apps")
L.append("> published before the checklist (2026-10-01) are exempt from the onboarding gate; this page is information,")
L.append("> not work (operator default 2026-10-01, may be reversed).")
L.append("")
L.append("## Headline — apps whose files show the group covered (of %d)" % n)
L.append("")
L.append("| group | covered | what \"covered\" means here (the signal read) |")
L.append("|---|---|---|")
defs = [
("0 Fit", "`lifecycle` available, `use_cases` and `pi_compatible` present — licence, telemetry, internet need and phone apps are recorded nowhere"),
("1 Images, start command, DB", "pins clean and the engine rules hold (MariaDB auto-upgrade, PG 18 mount) — entrypoint switches, the production server, migrations and secrets read (1.4–1.9) are recorded for NO app"),
("2 Storage and backup", "the 2026-08-02 persistence sweep read the app CLEAN, and an HDD app carries `backup:` classes — no restore round trip is recorded per app"),
("3 Accounts and strangers", "a FIRST-ADMIN.md row whose source is MEASURED on a box — lock-out (3.6) is recorded only for the R-752 apps"),
("4 Health", "every service has a compose healthcheck, a controller probe exists, the exposed container is named like the stack — no negative control is recorded"),
("5 Resources", "every service limited, `mem_limit` = the sum, and a ladder entry carries a measured memory watch — no first-start-from-birth watch is recorded except immich's"),
("6 Updates", "a proven (not backfilled) ladder step AND an upgrade fixture"),
("7 Mail", "not countable from files: whether an app WANTS mail is not recorded; the mapped count is below"),
("8 Text and listing", "English block, tagline + use_cases + first_steps, listed in README, a FIRST-ADMIN row"),
]
for g, (name, d) in enumerate(defs):
L.append("| %s | %s | %s |" % (name, "—" if g == 7 else "%d / %d" % (tally[g], n), d))
L.append("")
L.append("Mail: %d app(s) carry `smtp_mapping`." % sum(1 for _a, c in rows if c[7] == "smtp mapped"))
L.append("")
L.append("## Found while computing this page")
L.append("")
L.append("- `mem_limit` differs from the sum of the compose limits (REUSE.md §2 says equal): %d — %s."
% (len(notes["mem_mismatch"]), ", ".join(notes["mem_mismatch"]) or "none"))
L.append("- A service with no memory limit: %s." % (", ".join(notes["no_limit"]) or "none"))
L.append("- A MariaDB sidecar without `MARIADB_AUTO_UPGRADE=1`: %s." % (", ".join(notes["maria"]) or "none"))
L.append("- A PostgreSQL 18 data mount at the old path: %s." % (", ".join(notes["pg18"]) or "none"))
L.append("")
L.append("## Per app")
L.append("")
L.append("| app | 0 fit | 1 images/DB | 2 storage | 3 accounts | 4 health | 5 resources | 6 updates | 7 mail | 8 text |")
L.append("|---|---|---|---|---|---|---|---|---|---|")
for app, c in rows:
L.append("| %s | %s |" % (app, " | ".join(c[g] for g in range(9))))
L.append("")
body = "\n".join(L)
if "--check" in argv:
cur = io.open(OUT, encoding="utf-8").read() if os.path.isfile(OUT) else ""
strip = lambda t: re.sub(r"catalog `[0-9a-f]+`", "catalog `X`", t)
if strip(cur) != strip(body):
print("EXISTING-APPS-GAPS.md is stale — run python3 scripts/onboarding_gaps.py")
return 1
print("EXISTING-APPS-GAPS.md is current")
return 0
io.open(OUT, "w", encoding="utf-8").write(body)
print("wrote %s — %d apps; covered per group: %s" % (os.path.relpath(OUT, ROOT), n,
", ".join("%d:%s" % (g, "-" if g == 7 else tally[g]) for g in range(9))))
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
+3 -1
View File
@@ -62,7 +62,9 @@ class CatalogGatesFastTest(unittest.TestCase):
# (copy-i18n, probe-matches-compose) — the test was red and nobody ran it. Now 9 with the test # (copy-i18n, probe-matches-compose) — the test was red and nobody ran it. Now 9 with the test
# record's two halves; the slow pair stays out of --fast. STALE AGAIN until 2026-09-30: 10 since # record's two halves; the slow pair stays out of --fast. STALE AGAIN until 2026-09-30: 10 since
# probe-measured joined; the test had been red on main (found by the more-night-apps session). # probe-measured joined; the test had been red on main (found by the more-night-apps session).
self.assertEqual(len(mod.GATES), 10) # 11 since 2026-10-01: onboarding (NEW-APP-CHECKLIST.md), fast and history-free, so it runs in CI too.
self.assertEqual(len(mod.GATES), 11)
self.assertIn("onboarding", [g[0] for g in mod.GATES if g[3] and not g[4]])
self.assertEqual([g[0] for g in mod.GATES if not g[3]], ["image-resolvable", "volume-persistence"]) self.assertEqual([g[0] for g in mod.GATES if not g[3]], ["image-resolvable", "volume-persistence"])
self.assertIn("test-record", [g[0] for g in mod.GATES if g[3] and not g[4]]) # runs in CI too self.assertIn("test-record", [g[0] for g in mod.GATES if g[3] and not g[4]]) # runs in CI too
# the gates that need git history are the ones the CI half cannot run (R-452's shallow gap) # the gates that need git history are the ones the CI half cannot run (R-452's shallow gap)
+135
View File
@@ -55,6 +55,7 @@ COVERS = {
"test-record": "a ladder whose newest step is not the compose's images (a move without a record), a gap, a line that is not one JSON entry, a failed verdict - vs a clean ladder (09 decision 13)", "test-record": "a ladder whose newest step is not the compose's images (a move without a record), a gap, a line that is not one JSON entry, a failed verdict - vs a clean ladder (09 decision 13)",
"test-record-move": "an image move with NO entry, with the entry only in a COMMENT or in README, with a failed/backfilled entry, with a digest the registry no longer serves, memory_tight without a raised limit - vs a proven entry that matches; a ref moving in a compose COMMENT is not a move (09 decision 13)", "test-record-move": "an image move with NO entry, with the entry only in a COMMENT or in README, with a failed/backfilled entry, with a digest the registry no longer serves, memory_tight without a raised limit - vs a proven entry that matches; a ref moving in a compose COMMENT is not a move (09 decision 13)",
"probe-measured": "the measurement written in the TAGLINE or another comment block, not directly above setup_done_probe:; a date with no before/after; before/after with no date; 'read upstream' instead of 'measured' - vs a genuine measured comment (R-715)", "probe-measured": "the measurement written in the TAGLINE or another comment block, not directly above setup_done_probe:; a date with no before/after; before/after with no date; 'read upstream' instead of 'measured' - vs a genuine measured comment (R-715)",
"onboarding": "a NEW template with no record; a record missing an id, or carrying it only inside an HTML comment; a `done` whose path does not exist, is an EMPTY directory (the mkdir shape, R-410) or names an absent sibling-repo file; an `n/a` with an empty or two-word reason; an `open` row; `opened:` backdated before the checklist; the template a new app copies lacking a new id - vs a complete record, an id added after `opened:`, and an exempt app's record with open rows (NEW-APP-CHECKLIST.md)",
"copy-i18n": "Hungarian edited in a COMMENT/README/display_name (label, not copy) vs a real frozen string changed; an English block that is not English, is not matched to a Hungarian twin, or rewrites a credential (R-560). Also the DEGRADED mode CI actually runs — PyYAML shadowed out, freeze only (R-595)", "copy-i18n": "Hungarian edited in a COMMENT/README/display_name (label, not copy) vs a real frozen string changed; an English block that is not English, is not matched to a Hungarian twin, or rewrites a credential (R-560). Also the DEGRADED mode CI actually runs — PyYAML shadowed out, freeze only (R-595)",
} }
@@ -485,6 +486,138 @@ def test_record_cases(clone):
case_tr_move("FACT: a re-test with no new digest reaches the move gate too", clone, case_tr_move("FACT: a re-test with no new digest reaches the move gate too", clone,
[(NF, tr_append(rt(TR_D1, TR_D1, box_evidence="x")))] + with_steps, 1, ("no new digest",), {old: TR_D1}) [(NF, tr_append(rt(TR_D1, TR_D1, box_evidence="x")))] + with_steps, 1, ("no new digest",), {old: TR_D1})
def onboarding_cases():
"""The onboarding gate reads FILES — the checklist, the template, the records, and evidence paths that may live
in a SIBLING repository. So each case runs in its own scratch WORKSPACE: <ws>/app-catalog-felhom.eu (a clone,
with this working tree's checklist + template copied in — the files under test are the ones being edited) and
<ws>/felhom.eu (a stand-in sibling holding one evidence file). The real tree is never touched."""
global ran
ws = tempfile.mkdtemp(prefix="catalog-onboarding-")
cat = os.path.join(ws, "app-catalog-felhom.eu")
sh(["git", "clone", "-q", "file://" + ROOT, cat], cwd=ROOT)
for rel in ("NEW-APP-CHECKLIST.md", os.path.join("onboarding", "_TEMPLATE.md")):
os.makedirs(os.path.dirname(os.path.join(cat, rel)) or cat, exist_ok=True)
shutil.copy(os.path.join(ROOT, rel), os.path.join(cat, rel))
sib = os.path.join(ws, "felhom.eu", "documentation", "audits", "onb")
os.makedirs(sib)
with io.open(os.path.join(sib, "proof.txt"), "w", encoding="utf-8") as fh:
fh.write("measured\n")
shutil.copytree(os.path.join(cat, "templates", "vaultwarden"), os.path.join(cat, "templates", "newapp"))
ev = os.path.join(cat, "onboarding", "evidence", "newapp")
os.makedirs(ev)
with io.open(os.path.join(ev, "e.txt"), "w", encoding="utf-8") as fh:
fh.write("bench output\n")
ids = [m.group(1) for m in (re.match(r"^(\d+\.\d+) \|", l) for l in
io.open(os.path.join(cat, "onboarding", "_TEMPLATE.md"), encoding="utf-8")) if m]
if len(ids) < 50:
raise SystemExit("the template carries %d ids — the fixture drifted, not the gate" % len(ids))
def record(rows=None, opened="2026-10-01", app="newapp"):
rows = rows if rows is not None else ["%s | done | app-catalog-felhom.eu/onboarding/evidence/newapp/e.txt" % i
for i in ids]
return "# Onboarding record\n\napp: %s\nopened: %s\n\n%s\n" % (app, opened, "\n".join(rows))
def full(**over):
out = []
for i in ids:
out.append(over.get(i, "%s | done | app-catalog-felhom.eu/onboarding/evidence/newapp/e.txt" % i))
return [r for r in out if r is not None]
REC = os.path.join(cat, "onboarding", "newapp.md")
def case_onb(name, setup, expect_rc, must=()):
global ran
ran += 1
try:
setup()
r = sh([sys.executable, os.path.join(ROOT, "scripts", "check-onboarding.py"), "--root=" + cat,
"--today=2026-10-02"], cwd=cat)
out = r.stdout + r.stderr
if r.returncode == expect_rc and all(m in out for m in must):
print(" ok %-52s rc=%d (expected %d)" % (name, r.returncode, expect_rc))
else:
fails.append("%s: rc=%d expected %d; missing %s\n%s" % (
name, r.returncode, expect_rc, [m for m in must if m not in out], out[-900:]))
finally:
for f in (REC, os.path.join(cat, "onboarding", "wger.md")):
if os.path.exists(f):
os.remove(f)
shutil.copy(os.path.join(ROOT, "NEW-APP-CHECKLIST.md"), os.path.join(cat, "NEW-APP-CHECKLIST.md"))
shutil.copy(os.path.join(ROOT, "onboarding", "_TEMPLATE.md"), os.path.join(cat, "onboarding", "_TEMPLATE.md"))
empty = os.path.join(cat, "onboarding", "evidence", "hollow")
if os.path.isdir(empty):
shutil.rmtree(empty)
def put(text, path=REC):
def f():
with io.open(path, "w", encoding="utf-8") as fh:
fh.write(text)
return f
try:
print("\n-- onboarding: the facts (each MUST be refused)")
case_onb("FACT: a new template with NO record", lambda: None, 1, ("newapp: NEW app with no onboarding record",))
case_onb("FACT: a record missing id 1.4", put(record(full(**{"1.4": None}))), 1, ("missing id(s): 1.4",))
case_onb("FACT: 1.4 answered only inside an HTML comment",
put(record(full(**{"1.4": "<!--\n1.4 | done | app-catalog-felhom.eu/onboarding/evidence/newapp/e.txt\n-->"}))),
1, ("missing id(s): 1.4",))
case_onb("FACT: done with a path that does not exist",
put(record(full(**{"2.5": "2.5 | done | app-catalog-felhom.eu/onboarding/evidence/newapp/restore.txt"}))),
1, ("id 2.5 is done but its evidence", "restore.txt"))
def hollow():
os.makedirs(os.path.join(cat, "onboarding", "evidence", "hollow"))
put(record(full(**{"5.1": "5.1 | done | app-catalog-felhom.eu/onboarding/evidence/hollow"})))()
case_onb("FACT: done with an EMPTY directory (the mkdir shape)", hollow, 1, ("id 5.1 is done but its evidence",))
case_onb("FACT: done naming an absent file in the sibling repo",
put(record(full(**{"3.6": "3.6 | done | felhom.eu/documentation/audits/onb/lockout.txt"}))),
1, ("id 3.6 is done but its evidence",))
case_onb("FACT: n/a with an EMPTY reason", put(record(full(**{"7.1": "7.1 | n/a | "}))), 1, ("id 7.1 is n/a",))
case_onb("FACT: n/a with a two-word reason", put(record(full(**{"7.1": "7.1 | n/a | not needed"}))), 1, ("id 7.1 is n/a",))
case_onb("FACT: an OPEN row", put(record(full(**{"6.3": "6.3 | open | the forced-fail case is not run yet"}))),
1, ("id 6.3 is OPEN",))
case_onb("FACT: opened: backdated before the checklist", put(record(full(), opened="2026-09-01")),
1, ("before the checklist existed",))
def new_id_template_lacks():
t = io.open(os.path.join(cat, "NEW-APP-CHECKLIST.md"), encoding="utf-8").read()
t = t.replace("\n## 7. Mail", "\n| 6.9 | 2026-10-01 | a new check | how | why |\n\n## 7. Mail", 1)
io.open(os.path.join(cat, "NEW-APP-CHECKLIST.md"), "w", encoding="utf-8").write(t)
put(record(full() + ["6.9 | done | app-catalog-felhom.eu/onboarding/evidence/newapp/e.txt"]))()
case_onb("FACT: a checklist id the template a new app copies lacks", new_id_template_lacks, 1,
("_TEMPLATE.md lacks checklist id(s): 6.9",))
case_onb("FACT: an exempt app's record with a done that points nowhere",
lambda: (put(record(full()))(), put(record(["1.5 | done | app-catalog-felhom.eu/nowhere.txt"],
app="wger"), os.path.join(cat, "onboarding", "wger.md"))()),
1, ("wger: id 1.5 is done but its evidence",))
print("-- onboarding: the genuine articles (each MUST pass)")
case_onb("GENUINE: a complete record (catalog + sibling evidence)",
put(record(full(**{"3.6": "3.6 | done | felhom.eu/documentation/audits/onb/proof.txt — measured on 9202",
"7.1": "7.1 | n/a | the app sends no mail at all"}))), 0, ("onboarding gate OK",))
def later_id():
t = io.open(os.path.join(cat, "NEW-APP-CHECKLIST.md"), encoding="utf-8").read()
t = t.replace("\n## 7. Mail", "\n| 6.9 | 2026-11-01 | a later check | how | why |\n\n## 7. Mail", 1)
io.open(os.path.join(cat, "NEW-APP-CHECKLIST.md"), "w", encoding="utf-8").write(t)
tp = os.path.join(cat, "onboarding", "_TEMPLATE.md")
io.open(tp, "a", encoding="utf-8").write("6.9 | open | not started: a later check\n")
put(record(full()))()
case_onb("GENUINE: an id added AFTER opened: does not bind", later_id, 0, ("onboarding gate OK",))
case_onb("GENUINE: an exempt app's record may say open",
lambda: (put(record(full()))(), put(record(["1.5 | open | the dev server runs (R-755)"], app="wger"),
os.path.join(cat, "onboarding", "wger.md"))()),
0, ("exempt app(s) with a record (shape-checked): wger",))
def no_sibling():
shutil.move(os.path.join(ws, "felhom.eu"), os.path.join(ws, "felhom.eu.away"))
put(record(full(**{"3.6": "3.6 | done | felhom.eu/documentation/audits/onb/lockout.txt"})))()
try:
case_onb("STATED SKIP: sibling repo absent (the CI shape) - printed, not checked", no_sibling, 0,
("NOT CHECKED here", "felhom.eu/documentation/audits/onb/lockout.txt"))
finally:
if os.path.isdir(os.path.join(ws, "felhom.eu.away")):
shutil.move(os.path.join(ws, "felhom.eu.away"), os.path.join(ws, "felhom.eu"))
finally:
shutil.rmtree(ws, ignore_errors=True)
def main(): def main():
gate = os.path.join(ROOT, "scripts", "check-engine-major.py") gate = os.path.join(ROOT, "scripts", "check-engine-major.py")
if not os.path.isfile(gate): if not os.path.isfile(gate):
@@ -961,6 +1094,8 @@ i18n:
finally: finally:
shutil.rmtree(clone, ignore_errors=True) shutil.rmtree(clone, ignore_errors=True)
onboarding_cases()
if fails: if fails:
print() print()
for f in fails: for f in fails: