diff --git a/CHANGELOG.md b/CHANGELOG.md index a4da8ed..a3e2361 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,14 @@ +## 2026-10-25 (evening, attended) — the `published` gate follows D50 = A: the newest 5 plus the pinned MinAgent and day-0 agent (R-1290; no binary change) + +- **D50 = A** (operator, 2026-10-25): the registry keeps the newest 5 released agent versions, the hub's MinAgent floor and + the day-0 manifest's agent. `scripts/retention-policy.json`: `generic_versions_kept` 10 → 5, new `pinned` + (`hub_min_agent` 0.158.1, `day0_agent` 0.165.0 — copies of hub state, because CI holds no hub secret; the session that + records a vouch or a new MinAgent updates them in the same commit). `check-published-versions.py` asserts the window + plus every pin; a pin with no tag FAILS. CI was red since the operator's hand prune of 0.159.0–0.160.0 (job 2334). +- **Decoys** (`test_gate_decoys.py`, via `FELHOM_RETENTION_POLICY`, test only): a pinned version older than the window is + asserted; its 404 convicts; a pin with no tag convicts. Seen failing on the old gate (two LIVE HOLES): felhom.eu + `documentation/audits/night-2026-10-25/redproof-d50.txt`. + ## v0.165.0 (2026-10-25) — D43 = A: BYO hosts keep their own SSH settings (R-1257); the line test for a box that seeds all day (R-1250) — released in the attended release run; **VOUCHED by the operator 2026-10-25 18:38:49 CET** (binary `e9d04a47…`, bundle `8dea1f34…`) **Found at the release (felhom.eu R-1285, P4):** on a BYO host upgraded from 0.164.0 the drop-in is removed only on the bundle run AFTER the one that installs this wrapper — the first run is planned by the old wrapper. Proven on a BYO test host (VM 9371 on demo-hp): the second `agent_config_update` logged `BUNDLE REMOVED … (ours, on a BYO host — D43)`. diff --git a/scripts/check-published-versions.py b/scripts/check-published-versions.py index 12402bb..b20888c 100644 --- a/scripts/check-published-versions.py +++ b/scripts/check-published-versions.py @@ -98,7 +98,8 @@ TAG_RE = re.compile(r"^v(\d+\.\d+\.\d+)$") # THE retention number, read from the one file that owns it. A check and the policy it enforces # must read the same number from the same place, or they drift and the drift looks like a defect # in something else — which is exactly what happened on 2026-08-08/09 (R-287). -RETENTION_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "retention-policy.json") +RETENTION_FILE = os.environ.get("FELHOM_RETENTION_POLICY") or \ + os.path.join(os.path.dirname(os.path.abspath(__file__)), "retention-policy.json") # the env: decoy tests only def retention_kept(): @@ -114,6 +115,21 @@ def retention_kept(): raise ValueError("generic_versions_kept must be a positive int, got %r" % (n,)) return n + +def retention_pinned(): + """D50 = A: the versions kept besides the newest N — the hub's MinAgent floor and the day-0 agent. Copies of hub + state the vouching session writes into the policy file (CI holds no hub secret). Missing key = no pins.""" + with open(RETENTION_FILE, encoding="utf-8") as fh: + pins = json.load(fh).get("pinned") or {} + if not isinstance(pins, dict): + raise ValueError("pinned must be an object, got %r" % (pins,)) + out = {} + for name, v in pins.items(): + if not isinstance(v, str) or not re.match(r"^\d+\.\d+\.\d+$", v): + raise ValueError("pinned %s must be X.Y.Z, got %r" % (name, v)) + out[name] = v + return out + tried = [] @@ -211,6 +227,7 @@ def main(): all_versions = versions try: keep = retention_kept() + pins = retention_pinned() except Exception as e: inconclusive("cannot read the retention policy (%s): %s" % (RETENTION_FILE, e)) @@ -218,10 +235,15 @@ def main(): # lexically: "0.9.0" > "0.10.0" as strings, and that would silently drop the wrong end. def _key(v): return tuple(int(x) for x in v.split(".")) - versions = sorted(all_versions, key=_key)[-keep:] + window = sorted(all_versions, key=_key)[-keep:] + # D50 = A: the pinned versions are asserted wherever they sit. A pin with no tag cannot be installed at all — + # a failure, never a skip. + unpinned_tags = sorted(("%s=%s" % (k, v)) for k, v in pins.items() if v not in all_versions) + versions = sorted(set(window) | set(v for v in pins.values() if v in all_versions), key=_key) dropped = [v for v in all_versions if v not in versions] - print(" %d released version(s); retention policy keeps the newest %d" % (len(all_versions), keep)) + print(" %d released version(s); retention policy keeps the newest %d plus the pinned %s" % ( + len(all_versions), keep, ", ".join("%s=%s" % kv for kv in sorted(pins.items())) or "(none)")) print(" verifying: %s" % ", ".join(versions)) if dropped: # NEVER silent. A bounded check that does not say what it stopped covering is how a @@ -232,6 +254,9 @@ def main(): print(" their configs resolve; what is no longer asserted is the BINARY's presence.") bad = [] + for pin in unpinned_tags: + bad.append((pin, ["the pinned version has NO git tag — nothing can install it (D50 = A)"])) + print(" FAIL pinned %s: no git tag v%s" % (pin, pin.split("=")[1])) for v in versions: pkg_url = "%s/api/packages/%s/generic/%s/%s/%s" % (GITEA_BASE, OWNER, PKG, v, PKG) raw_url = "%s/%s/%s/raw/tag/v%s/%s" % (GITEA_BASE, OWNER, REPO, v, PROBE_CONFIG) diff --git a/scripts/retention-policy.json b/scripts/retention-policy.json index f15c515..e1b0d17 100644 --- a/scripts/retention-policy.json +++ b/scripts/retention-policy.json @@ -1,41 +1,33 @@ { "_comment": [ - "THE retention number for published agent artifacts. One file, read by everything that", - "depends on it, because a check and the policy it enforces must read the same number from the", - "same place or they drift — and the drift looks like a defect in something else.", + "THE retention rule for published agent artifacts. One file, read by everything that depends on it, because a check", + "and the policy it enforces must read the same rule from the same place or they drift — and the drift looks like a", + "defect in something else (R-287, 2026-08-08/09; again R-1290, 2026-10-25).", "", - "WHAT WENT WRONG WITHOUT IT (2026-08-08/09). The registry stopped serving felhom-agent", - "0.120.0 and older, while scripts/check-published-versions.py demanded that EVERY git tag", - "still be downloadable. Both rules are individually sensible; together they are impossible.", - "CI went red at a commit whose own run had been green the day before, on a true finding that", - "no one could act on. The red will return at the next publish unless the two read one number.", + "D50 = A (operator ruling 2026-10-25, felhom.eu `09` §3 decision 262): the registry keeps the NEWEST 5 released agent", + "versions, PLUS the hub's MinAgent floor, PLUS the version the hub's day-0 artifact manifest names. The operator prunes", + "by hand to that rule (on 2026-10-25 he removed 0.159.0–0.160.0 and kept 0.158.1, the floor). CI asserts exactly these", + "and never more. Before D50 the number was 10 (the R-267 newest-10 prune, R-287/R-291).", "", - "HOW THE NUMBER WAS ARRIVED AT. generic_versions_kept is 10 because that is what the registry", - "keeps: the deleter was ESTABLISHED on 2026-08-10 (R-287) — the newest-10 prune of generic packages", - "run under R-267 (felhom-agent and felhom-golden generic held exactly 10 afterwards). Until then this", - "file called the 10 an observed state with no located ruling; that is superseded (corrected", - "2026-10-05, R-291). Container packages are not pruned by that rule.", + "THE PINS ARE COPIES OF HUB STATE, because CI cannot read the hub without a secret it must not hold. The session that", + "records an agent vouch (day0_agent) or declares a new MinAgent with a floor (hub_min_agent) updates `pinned` in the", + "same commit (felhom-build-deploy skill, agent release). A pin that names a version with no tag is a FAILURE, never a", + "skip — a pin nobody can install is the defect the pin exists to catch.", "", - "SO THIS FILE IS A FLOOR, NOT A LICENCE. It says: CI may assume nothing older than the newest", - "N generic versions is still downloadable. It does NOT authorise deleting anything, and the", - "operator should confirm or replace the number — at which point this file changes and both", - "readers follow it in the same commit.", + "SO THIS FILE IS A FLOOR, NOT A LICENCE: it says what CI may assume is still downloadable. It does not authorise", + "deleting anything else.", "", - "THE DEEPER BOUND, recorded so a future session does not have to re-derive it: the principled", - "limit is the hub's vouched min_agent floor (0.127.0 on 2026-08-09). Nothing can install an", - "agent below it — the hub refuses to vouch one and boxes update to the floor — so a released", - "version below the floor being un-downloadable costs nothing real. Bounding on the floor would", - "be better than bounding on a count, and it needs the gate to read the hub, which is network", - "the gate does not have today. Filed as the follow-up in R-287.", - "", - "NEVER retire a git TAG to satisfy this. felhom-host-install.sh fetches an agent's config", - "files from raw/tag/v/configs/, so deleting a tag retires the ability to install that", - "version at all — a strictly worse act than an un-downloadable binary." + "NEVER retire a git TAG to satisfy this. felhom-host-install.sh fetches an agent's config files from", + "raw/tag/v/configs/, so deleting a tag retires the ability to install that version at all." ], - "generic_versions_kept": 10, + "generic_versions_kept": 5, "readers": [ - "scripts/check-published-versions.py — bounds its assertion to the newest N versions" + "scripts/check-published-versions.py — asserts the newest N versions plus every pinned version" ], - "recorded": "2026-08-09", - "recorded_by": "CC 2026-08-09; the number's source (the R-267 newest-10 prune, established 2026-08-10 by R-287) recorded 2026-10-05 (R-291)" + "recorded": "2026-10-25", + "recorded_by": "CC 2026-10-25, D50 = A (operator); earlier history: R-287 (2026-08-09), R-291 (2026-10-05)", + "pinned": { + "hub_min_agent": "0.158.1", + "day0_agent": "0.165.0" + } } diff --git a/scripts/test_gate_decoys.py b/scripts/test_gate_decoys.py index df65df7..166a408 100644 --- a/scripts/test_gate_decoys.py +++ b/scripts/test_gate_decoys.py @@ -179,13 +179,24 @@ def published_cases(base): TAGS = ["v0.150.%d" % i for i in range(3)] # inside any retention window >= 3 VERS = [t[1:] for t in TAGS] + # D50 = A: the gate also asserts the PINNED versions. The decoys run against their own policy file (the real pins + # name versions the fake Gitea does not hold); PINS is what each case writes there. + pol_dir = tempfile.mkdtemp(prefix="retention-decoy-") + pol = os.path.join(pol_dir, "retention-policy.json") + PINS = {} + def case(name, setup, expect_rc, must=()): FAKE.reset() FAKE.tags = list(TAGS) FAKE.packages = set(VERS) FAKE.raw = set(VERS) + PINS.clear() setup() - rc, out = run([sys.executable, gate], ROOT, child_env(base)) + with io.open(pol, "w", encoding="utf-8") as fh: + json.dump({"generic_versions_kept": keep, "pinned": dict(PINS)}, fh) + env = child_env(base) + env["FELHOM_RETENTION_POLICY"] = pol + rc, out = run([sys.executable, gate], ROOT, env) if not FAKE.hits: fails.append("published/%s: the gate never asked the fake Gitea - the seam is not wired" % name) report("published: " + name, rc, out, expect_rc, must) @@ -220,6 +231,25 @@ def published_cases(base): case("BY DESIGN: a version older than the retention window is not asserted", retired, 0, ("NOT ASSERTED", "0.9.0")) + def pinned_old_ok(): + FAKE.tags = ["v0.8.0"] + ["v0.10.%d" % i for i in range(keep)] + FAKE.packages = set(t[1:] for t in FAKE.tags) + FAKE.raw = set(t[1:] for t in FAKE.tags) + PINS["hub_min_agent"] = "0.8.0" + case("GENUINE (D50): a pinned version older than the window is asserted and downloads", pinned_old_ok, 0, + ("verifying: 0.8.0", "ALL RELEASED VERSIONS INSTALLABLE")) + + def pinned_old_gone(): + pinned_old_ok() + FAKE.packages.discard("0.8.0") + case("FACT (D50): a pinned version older than the window whose package 404s", pinned_old_gone, 1, + ("FAIL v0.8.0", "binary NOT downloadable")) + + def pinned_no_tag(): + PINS["day0_agent"] = "0.150.9" + case("FACT (D50): a pin that names a version with no tag", pinned_no_tag, 1, + ("FAIL pinned day0_agent=0.150.9", "no git tag")) + def five_hundred(): FAKE.tags_status = 500 case("INCONCLUSIVE: the tags api answers 500", five_hundred, 2, ("INCONCLUSIVE",))