diff --git a/CHANGELOG.md b/CHANGELOG.md index fb32e34..99e9d10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,34 @@ +## RULE LIFT: a MariaDB major may cross, as its OWN EDGE; PostgreSQL may not (2026-09-21, R-469 + R-450) + +The engine-major rule of 2026-09-13 named its own expiry — *until the Update button takes a verified +backup as its precondition* — precisely so it would be removed deliberately rather than forgotten. +**That condition was met on 2026-09-13**, the same day: update arc Slice 4 shipped (controller +v0.237.0/v0.238.0, any backup tier since v0.239.0). This is the deliberate removal, and it removes +exactly half. + +- **LIFTED — the four MariaDB services** (`bookstack-db`, `kimai-db`, `nextcloud-db`, `romm-db`). + They now have both halves: a verified backup in front of the Update, and `MARIADB_AUTO_UPGRADE=1` + on every sidecar (R-459), whose conversion the harness WATCHED run on the E3/E3b edges with the + seeded data read back after. +- **NOT LIFTED — the eleven PostgreSQL services, and MySQL.** Postgres performs no `pg_upgrade` and + REFUSES to start on an older major's datadir (R-463). A backup is a route BACK, not a conversion — + the app simply would not come up. MySQL has nothing measured at all. Both stay refused until R-463 + has a scripted `pg_upgrade` edge proven on all eleven. The refusal now says this instead of citing + the shipped R-448. +- **NEW CLAUSE — one edge, one migration (R-450, recorded 2026-09-02 and now enforced).** A MariaDB + major must be the ONLY image move in its template in that commit. bookstack's `0b73e5e` moved the + application 25.02.2 → 26.05.2 **and** MariaDB 11.6 → 12.3 in one commit: two migrations behind one + edge, and an unreadable failure when it breaks. **Within a major is unaffected** and may still ride + with anything. +- **The gate says what it ALLOWED**, by name, rather than passing in silence — a lifted rule that + goes quiet is a lifted rule nobody can audit. +- **Decoys (R-421): two new cases, and two red-proofs each seen to fail.** Removing the own-edge + check lets the bundled kimai case pass; emptying `LIFTED` turns the lone MariaDB major back into a + refusal. The old "a lone MariaDB major is REFUSED" case is now the "…is ALLOWED" case, which is the + lift itself. 40 cases, all green. + +`scripts/check-engine-major.py`, `CLAUDE.md`, `scripts/test_gate_decoys.py`. No template moved. + ## GATE FIX: the copy gate could not run in CI at all (2026-09-20, R-595) **Six pushes in a row turned CI red while the local pre-push hook was green**, and each sent an alarm diff --git a/CLAUDE.md b/CLAUDE.md index f43302d..f76b9f5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -93,23 +93,28 @@ deployed `app.yaml` (customer secrets) is never overwritten. Full deploy details be correct. Why it exists: papra mounted `papra_data:/app/data` while the app wrote its database to `/app/app-data/db/`, so its backup completed, verified, and contained an empty directory (R-156, Campaign 10). -- **No template moves a database-engine image across a MAJOR version — until Slice 4 ships.** - Operator ruling 2026-09-13. *Until the Update button takes a verified backup as its precondition - (update arc Slice 4, `felhom.eu` `OPEN-ITEMS.md` R-448), no template may move a database-engine - image across a major version.* It covers the **four MariaDB** services — `bookstack-db`, `kimai-db`, - `nextcloud-db`, `romm-db` — and the **eleven PostgreSQL** ones (`docmost-postgres`, - `paperless-postgres` and the rest; the gate finds them by image name, not by this list). **Why now:** - every `mariadb:` sidecar carries `MARIADB_AUTO_UPGRADE=1` since 2026-09-13, so the day a MariaDB pin - moves a major the engine CONVERTS the customer's datadir on the next Update (measured 7 s, own - backup first — `SPIKE-r459-mariadb-upgrade-2026-09-06.md`); PostgreSQL's image converts nothing and - refuses to start on an older major's datadir (R-463). Either way it is a customer-data event with no - backup in front of it. **Within a major** (`11.6 → 11.8`, `16-alpine → 16.4-alpine`) is allowed. +- **A MariaDB major gets its OWN EDGE; PostgreSQL and MySQL may not cross a major at all.** + Operator ruling 2026-09-13, **amended 2026-09-21 by R-469** now that update arc Slice 4 has + shipped. The original rule — *no database-engine image crosses a major until the Update button + takes a verified backup as its precondition* — named its own expiry, and that condition is met: + Slice 4 shipped 2026-09-13 (controller v0.237.0/v0.238.0; any backup tier since v0.239.0). + **What is lifted.** The **four MariaDB** services (`bookstack-db`, `kimai-db`, `nextcloud-db`, + `romm-db`) may now cross a major. They have both halves they need: a verified backup in front of + the Update, and `MARIADB_AUTO_UPGRADE=1` on every sidecar (R-459), whose conversion the harness has + WATCHED run on the E3/E3b edges with the seeded data read back after. + **What is NOT lifted.** The **eleven PostgreSQL** services stay refused: the image performs no + `pg_upgrade` and REFUSES to start on an older major's datadir (R-463). A backup is a route back, + not a conversion — the app simply would not come up. MySQL is refused too, with nothing measured + at all. This half is removed when R-463 has a scripted `pg_upgrade` edge proven on all eleven. + **The new clause — one edge, one migration (R-450).** A MariaDB major must be the ONLY image move + in its template in that commit. bookstack's `0b73e5e` moved the application 25.02.2 → 26.05.2 **and** + MariaDB 11.6 → 12.3 in one commit: two migrations behind one edge, and an unreadable failure when it + breaks. Split it — the engine alone, then the app. **Within a major** (`11.6 → 11.8`, + `16-alpine → 16.4-alpine`) is allowed as before and may ride with anything. **Gate: `scripts/check-engine-major.py`** — fourth row of `catalog_gates.py`, `--fast`, run by - `.githooks/pre-push` with the push range. **It needs a parent commit**, and the CI runner fetches at - `--depth 1` (the same gap as R-452 — not re-filed), so on a shallow clone the runner skips it out - loud; the hook is where it bites. **EXPIRY, so it is removed deliberately and not forgotten:** when - R-448 ships, delete this rule, the gate's row in `catalog_gates.py` and the gate — tracked as its - own register row (`OPEN-ITEMS.md`, blocked-on R-448). The rule does not lapse on its own. + `.githooks/pre-push` with the push range; decoys in `scripts/test_gate_decoys.py`. **It needs a + parent commit**, and the CI runner fetches at `--depth 1` (the same gap as R-452 — not re-filed), + so on a shallow clone the runner skips it out loud; the hook is where it bites. - **Taking an app out of circulation — use `lifecycle:`, never a directory move.** `.felhom.yml` gains an optional `lifecycle:` field: `available` (default; absent/empty means this), `hidden` (not offered for new installs, no explanation owed), `abandoned` (upstream stopped developing it — diff --git a/scripts/check-engine-major.py b/scripts/check-engine-major.py index 9d3b451..0c84af2 100644 --- a/scripts/check-engine-major.py +++ b/scripts/check-engine-major.py @@ -8,11 +8,15 @@ Run from the repo root: Exit 0 no engine crosses a major · 1 REFUSED · 2 INCONCLUSIVE (no parent to diff against, or a pin whose major cannot be read). -THE RULE THIS ENFORCES (app-catalog `CLAUDE.md`, operator ruling 2026-09-13): +THE RULE THIS ENFORCES (app-catalog `CLAUDE.md`, operator ruling 2026-09-13, AMENDED 2026-09-21): - Until the Update button takes a VERIFIED BACKUP as its precondition (update arc Slice 4, - felhom.eu OPEN-ITEMS.md R-448), no template may move a database-engine image across a major - version. + A MariaDB image may cross a MAJOR version, but ONLY AS ITS OWN EDGE — never in the same commit + as any other image move in that template. PostgreSQL and MySQL may NOT cross a major at all. + +The MariaDB half was lifted by R-469 when Slice 4 shipped; the second clause is R-450's second half, +recorded while it was cheap: bookstack's `0b73e5e` moved the application 25.02.2 -> 26.05.2 AND +MariaDB 11.6 -> 12.3 in one commit — TWO migrations behind one edge, and an unreadable failure when +it breaks. One edge, one migration, so a failure names its own cause. WHY IT EXISTS. On 2026-09-13 every `mariadb:` sidecar gained `MARIADB_AUTO_UPGRADE=1`, so the day a MariaDB pin moves a major, the engine will CONVERT the customer's datadir on the next deliberate @@ -54,6 +58,21 @@ import sys # Repository basenames that are database engines. A match here means "judge this service's major". ENGINES = ("mariadb", "mysql", "postgres", "postgresql") +# R-469, 2026-09-21 — THE MARIADB HALF OF THE RULE IS LIFTED; THE POSTGRESQL HALF IS NOT. +# +# The rule's own condition was "until the Update button takes a verified backup as its precondition". +# Slice 4 SHIPPED on 2026-09-13 (controller v0.237.0/v0.238.0, any tier since v0.239.0), so the +# condition is met — for MariaDB. Every `mariadb:` sidecar carries MARIADB_AUTO_UPGRADE=1 (R-459) and +# the harness has WATCHED the conversion run on the E3/E3b edges, with the seeded data read back +# after. So a MariaDB major now has both halves it needs: a backup in front of it, and an engine that +# performs the conversion. +# +# PostgreSQL has neither the second half nor a procedure: its image performs no `pg_upgrade` and +# REFUSES to start on an older major's datadir, across ELEVEN templates (R-463). MySQL is in the same +# position with nothing measured at all. Both stay refused until R-463 produces a scripted +# `pg_upgrade` edge proven on all eleven. +LIFTED = ("mariadb",) + SERVICE_RE = re.compile(r"^ ([A-Za-z0-9_-]+):\s*$") IMAGE_RE = re.compile(r"^\s+image:\s*[\"']?(\S+?)[\"']?\s*$") TEMPLATE_RE = re.compile(r"^templates/[^/]+/docker-compose\.ya?ml$") @@ -146,7 +165,7 @@ def main(argv): return 2 files = [n for n in names.split("\n") if TEMPLATE_RE.match(n)] - compared, refused, unparseable = 0, [], [] + compared, refused, bundled, allowed, unparseable = 0, [], [], [], [] for path in files: rc_b, before_text, _ = git("show", "%s:%s" % (a, path)) rc_a, after_text, _ = git("show", "%s:%s" % (b, path)) @@ -154,6 +173,11 @@ def main(argv): continue # deleted at B: nothing is being offered before = images_in(before_text) if rc_b == 0 else {} after = images_in(after_text) + + # EVERY image move in this template, engine or not. It is the input to the "own edge" rule + # (R-450): a MariaDB major is allowed only when it is the ONLY image this commit moves here. + moves = [svc for svc, img in after.items() if svc in before and before[svc] != img] + for svc, img_after in after.items(): eng_after = engine_of(img_after) if eng_after is None or svc not in before: @@ -168,25 +192,42 @@ def main(argv): if mb is None or ma is None: unparseable.append("%s %s: %s -> %s" % (path, svc, before[svc], img_after)) continue - if mb != ma: - refused.append((path, svc, eng_after[0], mb, ma, before[svc], img_after)) + if mb == ma: + continue + row = (path, svc, eng_after[0], mb, ma, before[svc], img_after) + if eng_after[0] not in LIFTED: + refused.append(row) + continue + # R-469 + R-450: lifted, but it must be the ONLY image this commit moves in this + # template. `others` is named so the refusal can say WHAT it was bundled with. + others = [o for o in moves if o != svc] + if others: + bundled.append(row + (sorted(others),)) + continue + allowed.append(row) print("engine-major gate — range %s..%s: %d compose file(s) changed, %d engine pin(s) compared" % (a, b, len(files), compared)) - if refused: + if refused or bundled: print("") for path, svc, eng, mb, ma, ib, ia in refused: print("ENGINE-MAJOR GATE FAILED: %s service %s moves %s %d -> %d (%s -> %s)." % (path, svc, eng, mb, ma, ib, ia)) - print("RULE (app-catalog CLAUDE.md, operator ruling 2026-09-13): until the Update button takes " - "a VERIFIED BACKUP as its precondition (Slice 4, felhom.eu OPEN-ITEMS.md R-448), no " - "template may move a database-engine image across a MAJOR version.") - print("WHY: MariaDB sidecars now carry MARIADB_AUTO_UPGRADE=1 and WILL convert the customer's " - "datadir on the next Update; PostgreSQL's image refuses to start on an older major's " - "datadir (R-463). Either way this is a customer-data event with no backup in front of it.") - print("EXPIRY: this rule is removed DELIBERATELY when R-448 ships — the removal is its own " - "register row, not a silent edit. Until then, keep the engine within its major.") + print(" WHY: %s performs no datadir conversion of its own and REFUSES to start on an " + "older major's datadir (R-463, eleven templates). The Update takes a backup first " + "since Slice 4, but a backup is a route BACK, not a conversion — the app would " + "simply not come up. This half of the rule stands until R-463 has a scripted " + "pg_upgrade edge PROVEN on all eleven." % eng) + for path, svc, eng, mb, ma, ib, ia, others in bundled: + print("ENGINE-MAJOR GATE FAILED: %s service %s moves %s %d -> %d (%s -> %s) IN THE SAME " + "COMMIT as %s." % (path, svc, eng, mb, ma, ib, ia, ", ".join(others))) + print(" WHY: an engine major gets its OWN EDGE (R-450). bookstack's 0b73e5e moved the " + "application AND MariaDB 11.6 -> 12.3 in one commit: two migrations behind one " + "edge, and an unreadable failure when it breaks. Split it into two commits — the " + "engine alone, then the app.") + print("RULE (app-catalog CLAUDE.md, ruling 2026-09-13, amended by R-469 on 2026-09-21): " + "MariaDB may cross a major AS ITS OWN EDGE; PostgreSQL and MySQL may not cross one at all.") return 1 if unparseable: @@ -197,8 +238,12 @@ def main(argv): "forbids floating tags — pin a numbered tag.") return 2 - print("engine-major gate OK — no database engine crosses a major version" - " (rule: CLAUDE.md, until Slice 4 / R-448 ships)") + for path, svc, eng, mb, ma, ib, ia in allowed: + print("engine-major gate ALLOWED: %s service %s moves %s %d -> %d (%s -> %s) as its own edge " + "— permitted since R-469 (Slice 4 shipped; MARIADB_AUTO_UPGRADE=1 converts the datadir)." + % (path, svc, eng, mb, ma, ib, ia)) + print("engine-major gate OK — no forbidden engine major, and no engine major bundled with " + "another image move (rule: CLAUDE.md, amended by R-469 2026-09-21)") return 0 diff --git a/scripts/test_gate_decoys.py b/scripts/test_gate_decoys.py index 0682101..448f2ad 100644 --- a/scripts/test_gate_decoys.py +++ b/scripts/test_gate_decoys.py @@ -225,16 +225,21 @@ def main(): DOCMOST = "templates/docmost/docker-compose.yml" # ── THE FACTS: these must be refused ───────────────────────────────────────────────── - out = case("FACT: kimai-db mariadb:11.6 -> 12.3 (cross-major)", clone, - [(KIMAI, swap_image("kimai-db", "mariadb:11.6", "mariadb:12.3"))], + out = case("FACT: docmost-postgres 16-alpine -> 17-alpine bundled with the app bump", clone, + [(DOCMOST, lambda t: swap_image("docmost-postgres", "postgres:16-alpine", "postgres:17-alpine")(t))], expect_rc=1, - must_contain=("ENGINE-MAJOR GATE FAILED", "kimai-db", "mariadb 11 -> 12", - "R-448", "EXPIRY")) + must_contain=("ENGINE-MAJOR GATE FAILED", "docmost-postgres", "postgres 16 -> 17")) if "REFUSAL_TEXT" in os.environ: print(out) case("FACT: docmost-postgres postgres:16-alpine -> 17-alpine", clone, [(DOCMOST, swap_image("docmost-postgres", "postgres:16-alpine", "postgres:17-alpine"))], - expect_rc=1, must_contain=("docmost-postgres", "postgres 16 -> 17")) + expect_rc=1, must_contain=("docmost-postgres", "postgres 16 -> 17", "R-463")) + # R-469 + R-450 (2026-09-21): a MariaDB major bundled with the app's own bump is the + # bookstack 0b73e5e shape — two migrations behind one edge — and stays refused. + case("FACT: kimai-db 11.6 -> 12.3 BUNDLED with the kimai app bump", clone, + [(KIMAI, lambda t: swap_image("kimai", "kimai/kimai2:apache-2.57.0", "kimai/kimai2:apache-2.58.0")( + swap_image("kimai-db", "mariadb:11.6", "mariadb:12.3")(t)))], + expect_rc=1, must_contain=("IN THE SAME COMMIT as kimai", "OWN EDGE", "R-450")) case("FACT: kimai-db mariadb:11.6 -> mariadb:lts (major unreadable)", clone, [(KIMAI, swap_image("kimai-db", "mariadb:11.6", "mariadb:lts"))], expect_rc=2, must_contain=("INCONCLUSIVE",)) @@ -243,6 +248,11 @@ def main(): case("GENUINE: kimai-db mariadb:11.6 -> 11.8 (within major)", clone, [(KIMAI, swap_image("kimai-db", "mariadb:11.6", "mariadb:11.8"))], expect_rc=0, must_contain=("engine-major gate OK",)) + # R-469: the LIFT itself. A MariaDB major ALONE in its template is now permitted, and the + # gate says so by name rather than passing in silence. + case("GENUINE: kimai-db mariadb:11.6 -> 12.3 ALONE (the R-469 lift)", clone, + [(KIMAI, swap_image("kimai-db", "mariadb:11.6", "mariadb:12.3"))], + expect_rc=0, must_contain=("ALLOWED", "kimai-db", "mariadb 11 -> 12", "R-469")) # ── THE DECOYS: the label moves, the fact does not — these must pass ───────────────── def comment_and_env(text):