R-758: mem_limit is the sum of the compose limits — eight figures corrected, gate mem-limit-sum (--fast, stdlib) with decoys

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-05 21:28:10 +02:00
parent 8940d768d3
commit 1b24d139bd
15 changed files with 292 additions and 29 deletions
+6
View File
@@ -25,6 +25,8 @@ Gates, in order (all must pass; **non-zero exit on any failure**):
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)
11. mem-limit-sum static, instant, whole repo — `.felhom.yml` resources.mem_limit equals the sum of every
service's deploy.resources.limits.memory, and every service has one (R-758)
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
pre-push hook, which has the full clone; on a SHALLOW clone (CI fetches at
@@ -117,6 +119,10 @@ GATES = [
# 2026-10-02 (`09` §3 decisions 63/64): a family-gated template's exceptions are literal prefixes, it declares
# min_controller >= 0.287.0, and the newest baked golden knows the gate. Static, files only.
("family-gate", "check-family-gate.py", False, True, False),
# R-758 (2026-10-05): `.felhom.yml` resources.mem_limit is the SUM of the compose limits (REUSE.md §2). Eight
# templates were under it — the deploy screen and the box's overcommit warning read less than the app may take.
# Static, stdlib only (no PyYAML on CI), so --fast: the hook and CI.
("mem-limit-sum", "check-mem-limit-sum.py", True, True, False),
]
# 3 (R-605): the gate's HARNESS refused to run — its canary failed or it had nothing to judge — so NO app was
+193
View File
@@ -0,0 +1,193 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""check-mem-limit-sum.py — `.felhom.yml` `resources.mem_limit` must equal the SUM of the compose limits (R-758).
WHAT WENT WRONG. REUSE.md §2 says `mem_limit` = the sum of every service's
`deploy.resources.limits.memory` (paperless: 768+256+128=1152M). Nothing checked it, and on 2026-10-01 the new-app
checklist's gap page found eight templates UNDER their sum (calcom 768M declared, 1792M enforced). Docker enforces the
compose limits, so no app was starved; what was wrong is the figure the deploy screen shows the household and the
figure the box's overcommit warning adds up (controller `memoryVerdict`, deploy.go) — both read less than the app may
take.
THE FACT, and the labels that are not it:
* The fact is a service's `memory:` key UNDER `deploy: resources: limits:` (Compose enforces that), and the
`mem_limit:` key UNDER `.felhom.yml`'s top-level `resources:`.
* NOT the fact: a `memory:` under `reservations:` (not a limit — and REUSE forbids reservations anyway), a figure in a
COMMENT (the compose header's "RAM: … (mem_limit: 384M)" line, the `.felhom.yml` arithmetic comment), a
`mem_limit:` anywhere but under `resources:`, the per-step files under `steps/` (a superseded step's own
definition, written by the ladder writer — not what a fresh install deploys).
* A service with NO limit is refused too (REUSE.md §2: every service has one): the sum would be a lie of omission.
stdlib only, a line reader: the CI runner has no PyYAML (memory note "catalog CI has no PyYAML"), and a gate that
needs it would have to degrade — this one never needs it.
USAGE
python3 scripts/check-mem-limit-sum.py # every template directory
python3 scripts/check-mem-limit-sum.py kimai calcom # only these
python3 scripts/check-mem-limit-sum.py --root=<dir> # judge another checkout (the decoy suite)
(`--all` is accepted and changes nothing: hidden and abandoned apps are always judged — a deployed one still runs.)
Exit: 0 every template's mem_limit is its sum · 1 convicted · 2 inconclusive (a figure nobody can read).
Decoys: scripts/test_gate_decoys.py `mem_sum_cases` (COVERS "mem-limit-sum").
"""
import io
import os
import re
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SIZE_RE = re.compile(r"^\s*([0-9]+(?:\.[0-9]+)?)\s*([kKmMgG])(?:i?[bB])?\s*$")
def to_mb(value):
"""'384M' / '1G' / '1.5g' / '512Mi' -> MB (int, rounded); None when unreadable."""
m = SIZE_RE.match(str(value).strip().strip('"').strip("'"))
if not m:
return None
n, unit = float(m.group(1)), m.group(2).lower()
return int(round(n / 1024.0 if unit == "k" else n * 1024 if unit == "g" else n))
def _strip_comment(line):
"""Drop a trailing `# comment` that is not inside quotes (good enough for these files' scalars)."""
out, q = [], None
for i, ch in enumerate(line):
if q:
if ch == q:
q = None
elif ch in ("'", '"'):
q = ch
elif ch == "#" and (i == 0 or line[i - 1] in " \t"):
break
out.append(ch)
return "".join(out).rstrip()
def _walk(text):
"""Yield (path_of_keys, key, value) for every `key: value` / `key:` line, by indentation. List items and
block scalars are skipped (no limit lives in one)."""
stack = [] # [(indent, key)]
block_indent = None # inside a `|` / `>` block scalar: skip lines deeper than this
for raw in text.split("\n"):
line = _strip_comment(raw)
if not line.strip():
continue
indent = len(line) - len(line.lstrip(" "))
if block_indent is not None:
if indent > block_indent:
continue
block_indent = None
s = line.strip()
if s.startswith("- "):
continue
m = re.match(r"^([A-Za-z0-9_.\-]+):(?:\s+(.*))?$", s)
if not m:
continue
while stack and stack[-1][0] >= indent:
stack.pop()
key, val = m.group(1), (m.group(2) or "").strip()
yield [k for _i, k in stack], key, val
if val in ("|", ">", "|-", ">-", "|+", ">+"):
block_indent = indent
elif val == "":
stack.append((indent, key))
def compose_limits(text):
"""{service: limit_mb or None (no limit) or 'bad:<raw>' (unreadable)}."""
services = {}
for path, key, val in _walk(text):
if path == ["services"]:
services.setdefault(key, None)
elif len(path) == 5 and path[0] == "services" and path[2:] == ["deploy", "resources", "limits"] \
and key == "memory":
mb = to_mb(val)
services[path[1]] = mb if mb is not None else "bad:" + val
return services
def declared_limit(text):
"""The top-level `resources: mem_limit:` -> (mb or None, raw)."""
for path, key, val in _walk(text):
if path == ["resources"] and key == "mem_limit":
return to_mb(val), val
return None, None
def check_app(tdir, app):
"""-> (verdict 0/1/2, message)."""
d = os.path.join(tdir, app)
try:
compose = io.open(os.path.join(d, "docker-compose.yml"), encoding="utf-8").read()
meta = io.open(os.path.join(d, ".felhom.yml"), encoding="utf-8").read()
except (IOError, OSError) as e:
return 2, "%s: unreadable template (%s)" % (app, e)
lims = compose_limits(compose)
if not lims:
return 2, "%s: no services found in docker-compose.yml" % app
bad = sorted(s for s, v in lims.items() if isinstance(v, str))
if bad:
return 2, "%s: a memory limit nobody can read on %s (%s)" % (app, ", ".join(bad),
", ".join(lims[s][4:] for s in bad))
missing = sorted(s for s, v in lims.items() if v is None)
if missing:
return 1, "%s: service(s) with NO deploy.resources.limits.memory: %s (REUSE.md §2: every service has one)" % (
app, ", ".join(missing))
total = sum(lims.values())
ml, raw = declared_limit(meta)
if raw is None:
return 1, "%s: .felhom.yml declares no resources.mem_limit (the sum is %dM)" % (app, total)
if ml is None:
return 2, "%s: .felhom.yml mem_limit %r is not a size" % (app, raw)
if ml != total:
parts = "+".join("%d" % lims[s] for s in lims)
return 1, "%s: .felhom.yml mem_limit %s is not the sum of the compose limits %s=%dM" % (app, raw, parts, total)
return 0, "%s: %dM = the sum" % (app, total)
def main(argv):
root, apps = ROOT, []
for a in argv:
if a.startswith("--root="):
root = a.split("=", 1)[1]
elif a == "--all":
continue # catalog_gates.py passes it for hidden/abandoned apps; this gate judges every directory anyway
elif a.startswith("-"):
print("unknown option: %s" % a)
return 2
else:
apps.append(a)
tdir = os.path.join(root, "templates")
if not os.path.isdir(tdir):
print("mem-limit-sum: no templates/ under %s" % root)
return 2
every = sorted(n for n in os.listdir(tdir) if os.path.isdir(os.path.join(tdir, n)))
unknown = [a for a in apps if a not in every]
if unknown:
print("mem-limit-sum: no such template: %s" % ", ".join(unknown))
return 2
convicted, undecided = [], []
for app in (apps or every):
v, msg = check_app(tdir, app)
if v == 1:
convicted.append(msg)
elif v == 2:
undecided.append(msg)
for m in convicted:
print("REFUSED: " + m)
for m in undecided:
print("INCONCLUSIVE: " + m)
n = len(apps or every)
if convicted:
print("mem-limit-sum: %d of %d template(s) refused — set .felhom.yml resources.mem_limit to the sum of the "
"compose limits (and show the arithmetic in a comment, like paperless-ngx)" % (len(convicted), n))
return 1
if undecided:
print("mem-limit-sum: INCONCLUSIVE on %d of %d template(s) — never a pass" % (len(undecided), n))
return 2
print("mem-limit-sum gate OK: %d template(s), every mem_limit is the sum of its compose limits" % n)
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
+3 -1
View File
@@ -64,7 +64,9 @@ class CatalogGatesFastTest(unittest.TestCase):
# probe-measured joined; the test had been red on main (found by the more-night-apps session).
# 11 since 2026-10-01: onboarding (NEW-APP-CHECKLIST.md), fast and history-free, so it runs in CI too.
# 12 since family-gate joined — STALE AGAIN until 2026-10-05 (found by the burn-down, round 2).
self.assertEqual(len(mod.GATES), 12)
# 13 since 2026-10-05: mem-limit-sum (R-758), fast and history-free, so it runs in CI too.
self.assertEqual(len(mod.GATES), 13)
self.assertIn("mem-limit-sum", [g[0] for g in mod.GATES if g[3] and not g[4]])
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.assertIn("test-record", [g[0] for g in mod.GATES if g[3] and not g[4]]) # runs in CI too
+62
View File
@@ -57,6 +57,7 @@ COVERS = {
"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)",
"family-gate": "family_gate written only in a COMMENT (not gated, no min_controller owed); min_controller only in a comment; a golden DIRECTORY named 0.287.0 with no bake log (the mkdir shape, R-410); a sibling with no golden (stated NOT CHECKED, never a pass of rule 3) - vs the facts: an unanchorable exception (regex, '/', '..'), an exception list with no gate, min_controller below 0.287.0, the newest baked golden below 0.287.0; and a genuine family app passes (decisions 63/64, finding F1)",
"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)",
"mem-limit-sum": "the right figure only in a COMMENT (the compose header's 'mem_limit: 640M', the .felhom.yml arithmetic) while the field is wrong; a `memory:` under reservations: (not a limit) making the sum come out right; a `mem_limit:` outside resources:; a steps/ file with the old figure (not judged) - vs the facts: a field under the sum, a service with no limit, an unreadable size (R-758)",
"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). A retrieval promise REGISTERED in ALLOWLIST_EN passes only for its own app+path+sentence with a real reason; an entry for another app, a rewritten sentence, a stale entry or a two-word reason convicts (R-594). Also the DEGRADED mode CI actually runs — PyYAML shadowed out, freeze only (R-595)",
}
@@ -686,6 +687,66 @@ def family_gate_cases():
shutil.rmtree(ws, ignore_errors=True)
def mem_sum_cases():
"""check-mem-limit-sum.py reads FILES: templates/*/docker-compose.yml + .felhom.yml, via --root (R-758)."""
global ran
import tempfile
ws = tempfile.mkdtemp(prefix="catalog-memsum-")
try:
COMPOSE = (
"# demo - header\n# RAM: ~100M (mem_limit: 640M)\n"
"services:\n"
" demo:\n image: demo/demo:1.0\n container_name: demo\n environment:\n - X=1\n"
" deploy:\n resources:\n limits:\n memory: 384M\n"
" demo-db:\n image: postgres:16-alpine\n command: |\n memory: 9999M\n"
" deploy:\n resources:\n limits:\n memory: 256M # the DB\n"
"volumes:\n demo_data:\n")
META = ('display_name: "Demo"\n# mem_limit: "999M" is not this line\n'
'resources:\n mem_request: "100M"\n mem_limit: "640M" # 384+256\n pi_compatible: true\n')
def run(name, compose, meta, expect_rc, must=(), extra=None):
global ran
cat = os.path.join(ws, "cat")
shutil.rmtree(cat, ignore_errors=True)
d = os.path.join(cat, "templates", "demo")
os.makedirs(d)
io.open(os.path.join(d, "docker-compose.yml"), "w", encoding="utf-8").write(compose)
io.open(os.path.join(d, ".felhom.yml"), "w", encoding="utf-8").write(meta)
if extra:
extra(d)
r = sh([sys.executable, os.path.join(ROOT, "scripts", "check-mem-limit-sum.py"), "--root=" + cat], ROOT)
out = r.stdout + r.stderr
ran += 1
ok = r.returncode == expect_rc and all(m in out for m in must)
print(" %s %-70s rc=%d (expected %d)" % ("ok" if ok else "XX", name, r.returncode, expect_rc))
if not ok:
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[-400:]))
def steps(d):
os.makedirs(os.path.join(d, "steps"))
io.open(os.path.join(d, "steps", "abc.felhom.yml"), "w").write('resources:\n mem_limit: "384M"\n')
print("\n-- mem-limit-sum: genuine and decoys")
run("GENUINE: 384+256 = 640M, the field says 640M", COMPOSE, META, 0, ("mem-limit-sum gate OK",))
run("GENUINE: 1G is 1024M (1G + 256M = 1280M)", COMPOSE.replace("memory: 384M", "memory: 1G"),
META.replace('"640M"', '"1280M"'), 0, ("gate OK",))
run("DECOY: a steps/ file with an old figure is not judged", COMPOSE, META, 0, ("gate OK",), extra=steps)
print("-- mem-limit-sum: the facts (each MUST be refused)")
run("FACT: the field under the sum; the right figure only in comments", COMPOSE,
META.replace('mem_limit: "640M" # 384+256', 'mem_limit: "384M" # 384+256=640M'), 1, ("not the sum", "384+256=640M"))
run("FACT: reservations: memory makes nothing a limit (service w/o limit)",
COMPOSE.replace(" limits:\n memory: 256M", " reservations:\n memory: 256M"),
META.replace('"640M"', '"384M"'), 1, ("NO deploy.resources.limits.memory", "demo-db"))
run("FACT: mem_limit only OUTSIDE resources: (top level) is no declaration", COMPOSE,
META.replace(' mem_limit: "640M" # 384+256\n', '').replace('display_name: "Demo"\n', 'display_name: "Demo"\nmem_limit: "640M"\n'),
1, ("declares no resources.mem_limit",))
run("FACT: an unreadable size is INCONCLUSIVE, never a pass", COMPOSE.replace("memory: 384M", "memory: lots"), META, 2,
("INCONCLUSIVE",))
finally:
shutil.rmtree(ws, ignore_errors=True)
def main():
gate = os.path.join(ROOT, "scripts", "check-engine-major.py")
if not os.path.isfile(gate):
@@ -1197,6 +1258,7 @@ i18n:
onboarding_cases()
family_gate_cases()
mem_sum_cases()
if fails:
print()