R-469: lift the MariaDB half of the engine-major rule; add R-450's own-edge clause
gates / gates (push) Successful in 1s
gates / gates (push) Successful in 1s
Slice 4 shipped 2026-09-13, so the rule's own expiry condition is met — for MariaDB.
PostgreSQL and MySQL stay refused (R-463: no pg_upgrade, refuses to start on an
older major's datadir across eleven templates).
A MariaDB major is now allowed ONLY as its own edge: never in the same commit as
another image move in that template (R-450, the bookstack 0b73e5e shape).
Two new decoy cases; two red-proofs, each seen to fail. 40 cases green.
No template moved.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user