Files
felhom.eu/documentation/audits/update-arc-gaps-2026-09-21/unattended-caller.py
T
admin da20722e76
gates / gates (push) Successful in 27s
Update night 2026-09-21: Phase 0 and Phase 1 evidence, the drill method, and two instrument fixes
INTERIM CHECKPOINT — evidence off the machine at the end of the phase that produced it (R-320),
not at the end of the session. Phases 2-5 follow in a later commit.

Phase 0, all three mechanisms proven with their controls:
- the fleet floor to 0.261.0 with its declared MinAgent — both demo boxes in 13 s, the hub
  logging `managed floor SERVED ... from declared (golden 0.258.0)`.
- a PRIVATE DRILL CATALOG (admin/app-catalog-drill), so that broken, dummy, cross-repo and
  engine-major edges can be measured without the live catalog ever carrying one. Positive
  control quoted, and two negative controls: the live catalog's main and both real boxes'
  caches unchanged.
- a throwaway image store on the scratch guest, which is what makes an UNATTENDED HOLD
  measurable at all: an edge that PASSES the within-a-major test and still fails.
  CompareImageRefs was proven to order host:port/ references by RUNNING it (4 positive cases
  + 1 negative control), not by reading it.

Phase 1: real within-a-major upstream edges walked on guest 9202 through the product's own
guarded Update, each app seeded and read back through its OWN front door (R-156), with a
per-edge verdict record in 09's shape. `inconclusive` is never collapsed into `failed`.

TWO INSTRUMENT FIXES, both in this repo's own evidence code:
- 00-api-recipe.md said the app page is /app/<n>; it is /apps/<n>, and every call it described
  404s. Corrected, with the session-expiry note that cost the same time.
- unattended-caller.py's follow() read update_phase/updating off the API ENVELOPE, so both were
  always None and EVERY followed update ran to its 900 s timeout and was then recorded
  `timeout` and never-press-again. Fixed before B1 relied on it. R-623.

No controller, agent or hub code was written. The live catalog carries no broken reference.

Gates: repo_gates.py --fast — all 15 OK, exit 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-21 21:17:46 +02:00

181 lines
8.1 KiB
Python

#!/usr/bin/env python3
"""unattended-caller.py — the Slice 6 spike: an app updates with NOBODY pressing anything.
THIS IS EVIDENCE, NOT PRODUCT. It lives under documentation/audits/ and nothing in the controller
imports it. It exists to MEASURE the mechanism Slice 6 would need before that slice is designed, by
pressing exactly the same guarded Update a person presses — `POST /api/stacks/<n>/update` — and
nothing else. No new endpoint, no new controller code, no privileged path.
WHAT IT DOES NOT DO, deliberately: it does not decide policy. The window is simulated by running it;
the per-app switch of `09` §3b Q2 does not exist yet; it never touches a box that is not the scratch
guest. Run it from DooPlex against guest 9202 only.
THE TWO RULES IT EXISTS TO PROVE
1. within a major, for EVERY compose service, or it does not press at all (§3 decision 3, §3b Q3);
2. a refusal's REASON decides whether it ever presses again (R-609):
TRANSIENT busy updating deploying migrating self_updating -> try next pass
TERMINAL held downgrade -> never again
FOR A HUMAN memory disk no_backup -> log and leave alone
Before R-609 the body carried only a Hungarian sentence, so this distinction was unavailable to
anything that is not a person — which is why a caller like this could not have been written.
Usage: python3 unattended-caller.py --passes 6 --every 300
"""
import argparse, json, re, subprocess, sys, time
CALL = "/tmp/ctl/c.sh" # the helper from 00-api-recipe.md
TRANSIENT = {"busy", "updating", "deploying", "migrating", "self_updating"}
TERMINAL = {"held", "downgrade"}
FOR_A_HUMAN = {"memory", "disk", "no_backup"}
TAG = re.compile(r"^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?(.*)$")
def log(msg):
print("%s %s" % (time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), msg), flush=True)
def call(method, path, data=None):
cmd = [CALL, method, path] + ([data] if data else [])
out = subprocess.run(cmd, capture_output=True, text=True, timeout=180).stdout
try:
return json.loads(out)
except Exception:
return {"_raw": out}
def split_ref(ref):
"""(repo, tag) or (None, None) when the reference carries no plain tag."""
if "@" in ref:
return None, None
i = ref.rfind(":")
if i < 0 or "/" in ref[i + 1:]:
return None, None
return ref[:i], ref[i + 1:]
def same_major(a, b):
"""True only when BOTH tags are plain versions, share a suffix, and share a first number.
Mirrors stacks.CompareImageRefs deliberately: the box's own rule is the one under test, and a
caller that judged 'within a major' differently would measure its own opinion instead.
"""
ra, ta = split_ref(a)
rb, tb = split_ref(b)
if ra is None or ra != rb:
return False
ma, mb = TAG.match(ta or ""), TAG.match(tb or "")
if not ma or not mb:
return False
if ma.group(4) != mb.group(4): # the suffix must be IDENTICAL (…-apache vs …-apache)
return False
if ma.group(2) is None or mb.group(2) is None:
return False # one component is a LINE, not a version
return ma.group(1) == mb.group(1)
def edge_is_within_a_major(st):
"""ALL services must pass. One unorderable service makes the whole edge 'across' -> a human."""
installed = {k: v["ref"] for k, v in (st.get("app_config", {}).get("installed_images") or {}).items()}
catalog = st.get("catalog_images") or {}
if not installed or not catalog or set(installed) != set(catalog):
return False, "service sets differ or nothing recorded"
for svc, want in catalog.items():
got = installed[svc]
if got == want:
continue
if not same_major(got, want):
return False, "across: %s %s -> %s" % (svc, got, want)
return True, "within a major"
def follow(name, timeout=900):
"""Watch one update to its end. Returns done | failed | held | timeout.
FIXED 2026-09-21 (update night), and the bug is worth keeping written down. `call()` returns
the API ENVELOPE — `{"ok": true, "data": {...}}` — and this function read `update_phase` and
`updating` off the envelope, where they do not exist. Both were therefore ALWAYS `None`, the
end test `not updating and phase in ("done","failed")` could never fire, and **every update
this caller followed ran to the 900-second timeout and was then recorded `timeout` and
`never_again`** — including ones that had succeeded in under two minutes. `main()` unwraps
`data` for the stack LIST, which is why the within-a-major half worked and this half did not.
The 2026-09-21 run never caught it because the only pass that reached `follow()` was the one
whose log was lost to a buffering `tail`. An instrument that turns a success into a timeout is
not a measurement — see R-623.
"""
deadline = time.time() + timeout
last = None
while time.time() < deadline:
env = call("GET", "/api/stacks/%s" % name)
st = env.get("data") if isinstance(env, dict) and isinstance(env.get("data"), dict) else env
phase, updating = st.get("update_phase"), st.get("updating")
if phase != last:
log(" %s: phase=%s updating=%s" % (name, phase, updating))
last = phase
if not updating and phase in ("done", "failed"):
held = bool(st.get("hold_reason"))
return "held" if held else phase
time.sleep(2)
return "timeout"
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--passes", type=int, default=6)
ap.add_argument("--every", type=int, default=300)
args = ap.parse_args()
never_again, outcomes = set(), {}
for p in range(1, args.passes + 1):
log("=== pass %d/%d" % (p, args.passes))
call("POST", "/api/sync")
call("POST", "/api/stacks/rescan") # R-607: a sync can say 'no change' and still move
stacks = call("GET", "/api/stacks")
stacks = stacks if isinstance(stacks, list) else stacks.get("data", [])
for st in stacks:
name = st.get("name")
if not st.get("deployed") or st.get("protected") or name in never_again:
continue
installed = {k: v["ref"] for k, v in (st.get("app_config", {}).get("installed_images") or {}).items()}
if not installed or installed == (st.get("catalog_images") or {}):
continue # unknown, or level with the catalog
ok, why = edge_is_within_a_major(st)
if not ok:
log(" %s SKIP — %s" % (name, why))
continue
log(" %s BEHIND and within a major — pressing Update" % name)
r = call("POST", "/api/stacks/%s/update" % name)
if r.get("ok") is False:
reason = (r.get("data") or {}).get("reason", "")
sentence = r.get("error", "")
if reason in TERMINAL:
never_again.add(name)
log(" %s REFUSED reason=%s TERMINAL — will not press again. %s" % (name, reason, sentence))
elif reason in TRANSIENT:
log(" %s REFUSED reason=%s transient — retry next pass. %s" % (name, reason, sentence))
elif reason in FOR_A_HUMAN:
never_again.add(name)
log(" %s REFUSED reason=%s — needs a person. %s" % (name, reason, sentence))
else:
never_again.add(name)
log(" %s REFUSED reason=%r UNKNOWN — stopping on it, safest reading. %s" % (name, reason, sentence))
continue
started = time.time()
end = follow(name)
outcomes[name] = (end, round(time.time() - started, 1))
log(" %s ENDED %s after %.1fs" % (name, end, time.time() - started))
if end in ("held", "timeout"):
never_again.add(name)
if p < args.passes:
time.sleep(args.every)
log("=== summary outcomes=%s never_again=%s" % (outcomes, sorted(never_again)))
return 0
if __name__ == "__main__":
sys.exit(main())