diff --git a/.github/acr-retention/purge-old-images.yaml b/.github/acr-retention/purge-old-images.yaml index c116e97a42..e5404a1bfd 100644 --- a/.github/acr-retention/purge-old-images.yaml +++ b/.github/acr-retention/purge-old-images.yaml @@ -22,7 +22,30 @@ version: v1.1.0 # in direct contradiction, and nothing but the lock reconciles them. # # 🚨 Raising --ago / --keep is NOT a fix: it moves the cliff. A pin left stable for a quarter walks -# off the new one just the same. +# off the new one just the same. The window below is NOT offered as pin protection — the lock is — +# it is the decided policy the other two stores already enforce, stated here so that re-enabling +# this task cannot silently apply a window nobody chose. +# +# 🚨 THE WINDOW IS 30 DAYS BY AGE WITH NO BUILD-COUNT QUOTA, AND THAT IS A DECISION, NOT A TUNING. +# #3842, quoted in #3438's body and in #3859's acceptance list: *retain unreferenced continuous +# artifacts for at least 30 days by age, without a build-count quota*. #3843 clamped +# `PrebuiltBundleRetention` to it, #3846 clamped `AssemblyCacheRetention` to it, and #3843's own +# body records that the registry was left out: *"The ACR task record and live cloud cleanup are +# unchanged."* This step was the third store and the one nobody asserted — it carried +# `--ago 7d --keep 10` over five continuously-republished repositories, `memex-portal-ai` among +# them, which is the image BOTH production portals run. +# +# `--keep` is gone rather than raised. It counts NEWER BUILDS, so a repository republished many +# times a day ages its own manifests out in hours regardless of `--ago` — which is #3438's root +# cause stated as a flag, not a second-order concern. +# +# 🚨 THE RECORD IS AHEAD OF THE REGISTRY HERE, ON PURPOSE, AND tasks.json DECLARES IT under +# `recordAheadOfRegistry`. The live task is DISABLED (paused, Memex#219) and still carries +# `--ago 7d --keep 10`; `acr-retention-tasks.sh apply` is what pushes this file onto it, so this +# is the window a re-enable would install. Do NOT "fix" the drift by running +# `acr-retention-tasks.sh record` — that overwrites this file FROM the live task and silently +# restores the 7-day quota window. `lock-pinned-digests.py --check-retention-record` then reds, +# on every pull request, which is how that mistake is caught. # # PROTECTION IS THE MANIFEST LOCK, not this policy. Live pinned digests carry # `--delete-enabled false`; `acr purge` skips locked manifests by default and neither step passes @@ -36,7 +59,7 @@ version: v1.1.0 # Doc: Doc/Architecture/PinnedImageRetention. Incidents: Memex#122, MeshWeaver#3438 (Education, # Manufacturing and SocialMedia CI down simultaneously 2026-09-05T15:29Z on a purged tester digest). steps: - - cmd: acr purge --filter 'mw-plugin-test:.*' --filter 'memex-migration:.*' --filter 'memex-portal-ai:.*' --filter 'memex-portal-next:.*' --filter 'memex-bake:.*' --ago 7d --keep 10 --untagged + - cmd: acr purge --filter 'mw-plugin-test:.*' --filter 'memex-migration:.*' --filter 'memex-portal-ai:.*' --filter 'memex-portal-next:.*' --filter 'memex-bake:.*' --ago 30d --untagged disableWorkingDirectoryOverride: true timeout: 3600 - cmd: acr purge --filter 'memex-portal:.*' --ago 30d --untagged diff --git a/.github/acr-retention/tasks.json b/.github/acr-retention/tasks.json index 9183bd6f38..613753007b 100644 --- a/.github/acr-retention/tasks.json +++ b/.github/acr-retention/tasks.json @@ -52,9 +52,16 @@ "creationDate": "2026-08-31T20:33:48.969331+00:00", "notes": [ "🚨 DISABLED 2026-09-12 — see the top-level `pause` block. This was `Enabled` at `0 3 * * *`; every note below describes the policy it runs WHEN RE-ENABLED.", - "ONE task, TWO steps: a 7d --keep 10 step over the CI image repositories, and a separate", - "30d step for memex-portal. The split is behaviour-preserving — merging the filter lists", - "under the stricter window would retune memex-portal without anyone deciding to.", + "ONE task, TWO steps: a 30d step over the CI image repositories, and a separate 30d step", + "for memex-portal. The filter lists stay SEPARATE even at one window — they are not", + "symmetric (mw-plugin-test is unique to the first, memex-portal to the second), and a", + "union-merge would silently retune one of them the next time either window moves.", + "🚨 The first step was `--ago 7d --keep 10` until 2026-09-13 (MeshWeaver#3438). #3842's", + "decided rule is at least 30 days by age with NO build-count quota; #3843 and #3846", + "clamped the bundle store and the assembly cache to it and left the registry out in as", + "many words. `lock-pinned-digests.py --check-retention-record` now asserts it on every", + "pull request. See `recordAheadOfRegistry` below: the live task still carries the old", + "window, and this record is what `apply` would push.", "Both steps share ONE 3600 s task budget; the per-step `timeout` is a per-step ceiling.", "🚨 NEITHER STEP PASSES `--include-locked`, and that is the whole of the pin protection:", "`acr purge` skips locked manifests by default. Adding that flag would delete every", @@ -81,6 +88,17 @@ ] } ], + "recordAheadOfRegistry": { + "inForce": true, + "since": "2026-09-13", + "tasks": [ + "purge-old-images" + ], + "what": "purge-old-images.yaml step 1 records `--ago 30d` with no `--keep`; the live (disabled) task still carries `--ago 7d --keep 10`.", + "why": "#3842's decided rule — at least 30 days by age, no build-count quota — was landed in the bundle store (#3843) and the assembly cache (#3846) and explicitly NOT in the registry. The record is what `acr-retention-tasks.sh apply` pushes, so stating the decided window here is what makes re-enabling the task install a window somebody chose. Nothing was applied: this is a record change, not a registry change.", + "clearedBy": "the next `acr-retention-tasks.sh apply` — after which `verify` finds no drift on these tasks and reds on THIS declaration being stale, which is how it expires rather than rotting.", + "issue": "MeshWeaver#3438" + }, "pause": { "inForce": true, "since": "2026-09-12", diff --git a/.github/scripts/acr-retention-tasks.sh b/.github/scripts/acr-retention-tasks.sh index 17aad51a47..c1e7d925ce 100755 --- a/.github/scripts/acr-retention-tasks.sh +++ b/.github/scripts/acr-retention-tasks.sh @@ -69,7 +69,50 @@ cmd_show() { done } +# The tasks `recordAheadOfRegistry` declares, space-separated; empty when nothing is declared. +# 🚨 NO `2>/dev/null` AND NO `|| true`. A tasks.json this cannot read must stop the caller, not +# answer "nothing is declared" — that is the swallow that turns "the call failed" into "the answer +# is empty", and here it would decide whether a drift is excused. Callers say `|| return 1`. +declared_ahead() { + if ! python3 -c " +import json, sys +d = json.load(open('$RECORD/tasks.json')) +a = d.get('recordAheadOfRegistry') +if a is None: + print(''); sys.exit(0) +# FAIL CLOSED ON A MALFORMED SWITCH: an inForce of the STRING true is not the BOOLEAN true, and +# every reader asks 'is True'. A typed quote mark would read as 'nothing is declared' and skip the +# overwrite guard below, while looking to a human exactly like an armed declaration. +if not isinstance(a, dict) or not isinstance(a.get('inForce'), bool): + sys.exit('recordAheadOfRegistry.inForce is ' + repr(a.get('inForce') if isinstance(a, dict) else a) + + ', not a JSON boolean') +print(' '.join(a.get('tasks') or []) if a['inForce'] else '')"; then + echo "::error::cannot read $RECORD/tasks.json, so it is unknown whether the record is" >&2 + echo " deliberately ahead of the registry. Refusing to decide either way." >&2 + return 1 + fi +} + cmd_record() { + # 🚨 `record` OVERWRITES THE RECORD FROM LIVE, so it is the one command that can silently undo a + # policy the record carries and the registry does not. On 2026-09-13 that was the whole of + # MeshWeaver#3438's remaining window fix: re-recording would have restored `--ago 7d --keep 10` + # into a file whose comments explain at length why it must not say that. + local ahead + ahead=$(declared_ahead) || return 1 + if [ -n "$ahead" ]; then + echo "🚨 tasks.json declares recordAheadOfRegistry for: $ahead" + echo " Recording OVERWRITES those files from the LIVE task, which discards the recorded" + echo " policy and restores whatever the registry currently carries." + echo " If that is what you want, say so in the same diff that deletes the declaration." + printf ' Type OVERWRITE to continue: ' + local confirm + read -r confirm + if [ "$confirm" != "OVERWRITE" ]; then + echo "aborted." + return 1 + fi + fi # 🚨 CAPTURE THROUGH THE SAME READER `verify` COMPARES WITH. Recording with a hand-typed # `az … | base64 -d > file` and verifying through `$(…)` differ by one trailing newline, and the # verifier then reports permanent drift over a byte nobody wrote. Measured 2026-09-07: the first @@ -83,7 +126,9 @@ cmd_record() { } cmd_verify() { - local task drift=0 checked=0 + local task drift=0 checked=0 ahead + ahead=$(declared_ahead) || return 1 + ahead=" $ahead " for task in $TASKS; do local recorded="$RECORD/$task.yaml" if [ ! -f "$recorded" ]; then @@ -99,14 +144,72 @@ cmd_verify() { live_yaml "$task" > "$live" || { rm -f "$live"; return 1; } checked=$((checked + 1)) if ! diff -u "$recorded" "$live" > "${live}.diff" 2>&1; then - echo "::error::task '$task' DRIFTED from .github/acr-retention/$task.yaml:" - cat "${live}.diff" - echo " Someone changed retention in the cloud without updating the record — or the record" - echo " was changed without applying it. Decide which is right, then re-run \`apply\` or" - echo " re-record with \`record\`. The reasoning in these comments is the only copy there is." - drift=1 + # 🚨 A DECLARED drift is the record being deliberately AHEAD of the registry — the state a + # policy change lives in between "decided" and "applied". Reporting it as an incident would + # make `verify` permanently red, and a drift report that is always red is one nobody reads + # (this file learned that on 2026-09-07 over a trailing newline). It is still PRINTED. + if [ "$ahead" != " " ] && [ "${ahead#* $task }" != "$ahead" ]; then + echo " $task: drift is DECLARED (tasks.json → recordAheadOfRegistry) — the record is" + echo " deliberately ahead of the registry and \`apply\` is what closes it:" + sed 's/^/ /' "${live}.diff" + # 🚨 A DECLARATION EXCUSES THE WINDOW MOVING, NEVER THE TASK BECOMING SOMETHING ELSE. + # Keyed on the task NAME alone, this exemption would print — and pass — a live definition + # that had gained `--include-locked` (which deletes every manifest the lock protects), + # swapped its filters (purging a repository nobody decided to purge), or stopped being an + # `acr purge` at all. The record-side checks below read the RECORDED file and would never + # see it. So the LIVE steps of a declared task are held to the invariants that are not + # what the declaration is about. + local live_steps + live_steps=$(grep -E "^[[:space:]]*-[[:space:]]+cmd:" "$live" || true) + if [ -z "$live_steps" ]; then + echo "::error::the LIVE definition of '$task' has no \`cmd:\` step, so the declared drift" + echo " excused a task that no longer purges anything." + drift=1 + fi + if printf '%s\n' "$live_steps" | grep -q -- "--include-locked"; then + echo "::error::the LIVE definition of '$task' passes --include-locked." + echo " That flag DELETES LOCKED MANIFESTS — the entire protection the lock job provides." + echo " A recordAheadOfRegistry declaration is about the retention WINDOW; it does not" + echo " excuse this, and without this line the drift would have printed as declared." + drift=1 + fi + if printf '%s\n' "$live_steps" | grep -qv "acr purge"; then + echo "::error::the LIVE definition of '$task' has a \`cmd:\` step that is not an acr purge." + drift=1 + fi + # The declaration is about the window, so the FILTERS must still match the record. A live + # filter set the record does not carry is a repository nobody reviewed into the purge. + local live_filters recorded_filters + live_filters=$(printf '%s\n' "$live_steps" | grep -o -- "--filter '[^']*'" | sort | uniq) + recorded_filters=$(grep -E "^[[:space:]]*-[[:space:]]+cmd:" "$recorded" \ + | grep -o -- "--filter '[^']*'" | sort | uniq) + if [ "$live_filters" != "$recorded_filters" ]; then + echo "::error::the LIVE definition of '$task' purges a different set of repositories than" + echo " the record does. A recordAheadOfRegistry declaration covers the WINDOW, not WHAT" + echo " is purged. live: $(printf '%s' "$live_filters" | tr '\n' ' ')" + echo " recorded: $(printf '%s' "$recorded_filters" | tr '\n' ' ')" + drift=1 + fi + else + echo "::error::task '$task' DRIFTED from .github/acr-retention/$task.yaml:" + cat "${live}.diff" + echo " Someone changed retention in the cloud without updating the record — or the record" + echo " was changed without applying it. Decide which is right, then re-run \`apply\` or" + echo " re-record with \`record\`. The reasoning in these comments is the only copy there is." + drift=1 + fi else echo " $task: matches the record byte for byte." + # 🚨 THE DECLARATION EXPIRES BY BEING CHECKED, never by being remembered. Once `apply` has + # run, a still-standing `recordAheadOfRegistry` is a stale exemption that would excuse the + # NEXT real drift on this task — the same failure the instance roster's stale-entry arm + # exists for. + if [ "$ahead" != " " ] && [ "${ahead#* $task }" != "$ahead" ]; then + echo "::error::tasks.json declares recordAheadOfRegistry for '$task' and there is NO drift." + echo " The record has been applied, so the declaration now excuses nothing and would" + echo " excuse the next real drift on this task. Delete it from tasks.json." + drift=1 + fi fi rm -f "$live" "${live}.diff" @@ -161,7 +264,64 @@ cmd_verify() { return $drift } +# 🚨 THE RE-ENABLE INTERLOCK (MeshWeaver#3859, acceptance criterion 1). +# +# `apply` is the ONLY thing in this repository that can turn a destructive schedule back on: it +# pushes `status` out of tasks.json with `az acr task update --status`. Until this function existed +# it did so with no reference to anything — so editing one word in tasks.json (`Disabled` → +# `Enabled`) and running `apply` restored the 03:00 purge with NO protection decision consulted, +# which is the criterion this issue states in as many words: *failed / unavailable / incomplete +# protection collection cannot be followed by deletion*. +# +# This does NOT close #3859. The two clocks are still independent — the lock is an Actions cron at +# 01:00, the purge an ACR timer task at 03:00, and nothing makes the second wait for the first, so +# a nightly deletion is still not downstream of that night's protection verdict. What it closes is +# the ACT of re-enabling, which is the one edge this repository owns. The nightly interlock needs a +# registry-side mechanism and an RBAC grant, both of which #3859 says to choose with the maintainer. +# +# 🚨 It refuses rather than warns, and it names the condition RATHER THAN asking for a flag. A +# prompt answered "yes" is not a decision anyone can audit; `pause.reEnableWhen` is, and lifting +# the pause is a reviewed diff against this record. +assert_pause_permits_enabling() { + local paused reenable since blocked + # 🚨 FAIL CLOSED, same reason as declared_ahead: a non-boolean `inForce` must stop the apply, not + # convert to `no`. `pause.inForce: "true"` would otherwise walk straight past this interlock. + paused=$(python3 -c " +import json, sys +d = json.load(open('$RECORD/tasks.json')) +p = d.get('pause') +if p is None: + print('no'); sys.exit(0) +if not isinstance(p, dict) or not isinstance(p.get('inForce'), bool): + sys.exit('pause.inForce is ' + repr(p.get('inForce') if isinstance(p, dict) else p) + + ', not a JSON boolean — refusing to decide whether the purge is paused') +print('yes' if p['inForce'] else 'no')") || return 1 + [ "$paused" = "yes" ] || return 0 + # 🚨 NO `|| true` — a status this cannot read must stop the apply, not be treated as Disabled. + blocked=$(python3 -c " +import json +d = json.load(open('$RECORD/tasks.json')) +print(' '.join(t['name'] for t in d['tasks'] if str(t.get('status','')).lower() == 'enabled'))") || return 1 + [ -n "$blocked" ] || return 0 + since=$(python3 -c "import json;print((json.load(open('$RECORD/tasks.json')).get('pause') or {}).get('since',''))") + reenable=$(python3 -c "import json;print((json.load(open('$RECORD/tasks.json')).get('pause') or {}).get('reEnableWhen',''))") + echo "::error::REFUSING to apply: the record declares an in-force PAUSE (since $since) and asks" >&2 + echo " to enable: $blocked" >&2 + echo "" >&2 + echo " re-enable when: $reenable" >&2 + echo "" >&2 + echo " A pause is the mitigation held in front of an incomplete protection decision, so pushing" >&2 + echo " an Enabled status while it stands puts deletion back in front of the protection that is" >&2 + echo " not finished — MeshWeaver#3859's first acceptance criterion. Lift the pause DELIBERATELY:" >&2 + echo " delete the \`pause\` block from .github/acr-retention/tasks.json in a reviewed diff that" >&2 + echo " says what satisfied \`reEnableWhen\`, then run this again." >&2 + return 1 +} + cmd_apply() { + # Before the prompt, not after: a refusal the operator reads only after typing the registry name + # teaches them the prompt is the gate. + assert_pause_permits_enabling || return 1 echo "🚨 This MUTATES shared registry infrastructure every deployment depends on." echo " Registry: $REGISTRY Tasks: $TASKS" echo " Re-read Doc/Architecture/PinnedImageRetention before continuing." diff --git a/.github/scripts/lock-pinned-digests.py b/.github/scripts/lock-pinned-digests.py index eb55b862d3..14f5e7cec2 100755 --- a/.github/scripts/lock-pinned-digests.py +++ b/.github/scripts/lock-pinned-digests.py @@ -1605,6 +1605,181 @@ def run(repos: list[str], registry_name: str, apply: bool, release_enabled: bool # ── The retention record: a credential-free assertion that runs on every pull request ────────── +# 🚨 THE DECIDED WINDOW FOR CONTINUOUS ARTIFACTS, and it is not a preference of this script. +# The platform owner's rule (#3842, quoted verbatim in #3438's body and in #3859's acceptance +# list): *"Retain unreferenced continuous artifacts for at least 30 days by age, without a +# build-count quota."* Two code paths already CLAMP to it rather than default to it — +# `PrebuiltBundleRetention` (src/MeshWeaver.Hosting/PrebuiltBundleRetention.cs, `MinimumAge` and +# the `< 30 days ⇒ 30 days` clamp in its planner) and `AssemblyCacheRetention` +# (src/MeshWeaver.Compiler.Pipeline/AssemblyCacheRetention.cs, the same clamp) — and both carry +# `KeepNewestPerSource` as a property that explicitly NO LONGER DRIVES DELETION. +# +# The registry lane was the third store and the one nobody asserted. #3843's own body says so: +# *"The ACR task record and live cloud cleanup are unchanged."* So the recorded purge kept +# `--ago 7d --keep 10` over five continuously-republished repositories — `memex-portal-ai` +# included, which is the image BOTH production portals run — while the rule that governs the +# other two stores said 30 days and no quota. Nothing compared them. +# +# 🚨 `--keep` IS THE BUILD-COUNT QUOTA, AND IT IS THE HALF THE AGE WINDOW CANNOT REPLACE. `--keep N` +# counts NEWER BUILDS, so the more often a repository is republished the faster its older manifests +# become eligible — which is why frequent republishing DESTROYS a pin rather than protecting it +# (#3438's own root cause). A 30-day `--ago` beside a `--keep 10` still collects a manifest ten +# builds old on the day it is written. Both halves, or neither is the rule. +MINIMUM_PURGE_AGE_DAYS = 30 +AGO_UNIT_DAYS = {"d": 1.0, "h": 1.0 / 24, "m": 1.0 / 1440, "s": 1.0 / 86400} +AGO_TOKEN_RE = re.compile(r"(\d+)([dhms])") + + +def parse_ago_days(value: str) -> float | None: + """`--ago` as DAYS, or None when it cannot be read. + + 🚨 UNPARSEABLE IS None, NEVER ZERO AND NEVER A DEFAULT. The caller reds on None. A duration + this function cannot read is one nobody has checked, and folding it into a number would spell + "not measured" exactly like "measured and fine" — the confusion #3438 is made of.""" + text = (value or "").strip() + if not text: + return None + consumed = 0 + days = 0.0 + for amount, unit in AGO_TOKEN_RE.findall(text): + consumed += len(amount) + len(unit) + days += int(amount) * AGO_UNIT_DAYS[unit] + # Every character must have been part of a token, or something was silently ignored. + return days if consumed == len(text) and days > 0 else None + + +# 🚨 THE INVOCATION, NOT THE LINE. A `cmd:` is a SHELL line and may hold more than one command, so +# searching the whole line lets `cmd: echo --ago 30d; acr purge --filter 'x:.*' --untagged` satisfy +# a window the purge itself does not carry — the flag is present on the line and absent from the +# command that deletes. Each `acr purge` is isolated at the first shell separator and checked on its +# own, and EVERY invocation on the line must pass. +PURGE_INVOCATION_RE = re.compile(r"acr\s+purge\b(?P[^;&|\n]*)") +# 🚨 BOTH SPELLINGS, AND THE BARE FLAG. `--keep=10` is the same quota as `--keep 10`, and a bare +# `--keep` is a malformed step rather than a compliant one; a whitespace-only match passed all three. +KEEP_RE = re.compile(r"--keep(?:[=\s]+(?P[^\s;&|]+))?") +AGO_RE = re.compile(r"--ago(?:[=\s]+(?P[^\s;&|]+))?") + + +def check_window_policy(where: str, step: str) -> list[str]: + """Does ONE recorded `acr purge` step obey the decided continuous-artifact window?""" + problems: list[str] = [] + invocations = [match.group("args") for match in PURGE_INVOCATION_RE.finditer(step)] + if not invocations: + # The caller already reds on a `cmd:` that is not an `acr purge`; this is the belt, so a + # reshaped line cannot silently skip the window check while passing that one. + return [f"{where}: no `acr purge` invocation could be isolated from this step, so its " + f"window was NOT checked:\n {step.strip()[:160]}"] + for args in invocations: + problems.extend(check_one_invocation(where, step, args)) + return problems + + +def check_one_invocation(where: str, step: str, args: str) -> list[str]: + problems: list[str] = [] + ago = AGO_RE.search(args) + if ago is not None and not ago.group("value"): + return [f"{where}: a purge step carries a bare `--ago` with no duration, so the window was " + f"NOT checked:\n {step.strip()[:160]}"] + if not ago: + problems.append( + f"{where}: a purge step declares NO `--ago` window:\n {step.strip()[:160]}\n" + " acr purge then has no age floor at all, so a manifest is eligible the moment it " + "is superseded. The decided rule is at least " + f"{MINIMUM_PURGE_AGE_DAYS} days by age (#3842).") + else: + days = parse_ago_days(ago.group("value")) + if days is None: + problems.append( + f"{where}: `--ago {ago.group('value')}` could not be read as a duration, so the " + "window was NOT checked. An unreadable window is not a satisfied one.") + elif days < MINIMUM_PURGE_AGE_DAYS: + problems.append( + f"{where}: a purge step retains for `--ago {ago.group('value')}` " + f"({days:g} day(s)), under the decided floor of {MINIMUM_PURGE_AGE_DAYS} days:\n" + f" {step.strip()[:160]}\n" + " #3842: *retain unreferenced continuous artifacts for at least 30 days by " + "age*. PrebuiltBundleRetention and AssemblyCacheRetention already CLAMP to that " + "floor; this record is the third store and the only one nobody asserted.") + keep = KEEP_RE.search(args) + if keep: + problems.append( + f"{where}: a purge step carries a BUILD-COUNT QUOTA " + f"`--keep{('=' + keep.group('value')) if keep.group('value') else ' (bare)'}`:\n" + f" {step.strip()[:160]}\n" + " #3842 rules one out in as many words — *without a build-count quota* — and it is " + "the half an age window cannot replace: `--keep` counts NEWER BUILDS, so the more " + "often a repository is republished the FASTER its older manifests become eligible. " + "That is #3438's own root cause, not a second-order concern.") + return problems + + +def check_in_force_is_boolean(manifest: dict, block: str) -> list[str]: + """🚨 A DECLARATION WHOSE SWITCH IS NOT A BOOLEAN IS FAIL-OPEN, AND SILENTLY. + + Every reader of these blocks asks `inForce is True`, which is correct for a JSON boolean and + catastrophic for `"true"`: a string is not `True`, so a record that LOOKS like an in-force pause + reads as no pause at all — `apply` proceeds past the interlock, `record` skips the overwrite + guard, and this very coherence check passes an `Enabled` task sitting under an apparent pause. + A typed quote mark would disarm three guards at once and every one of them would report success. + + So the SHAPE is asserted here, on every pull request, before any of them reads the value. The + shell halves fail closed on the same condition rather than trusting this to have run.""" + block_value = manifest.get(block) + if block_value is None: + return [] + if not isinstance(block_value, dict): + return [f"tasks.json: `{block}` is {type(block_value).__name__}, not an object. Every " + "reader of it asks for fields it cannot have."] + if "inForce" not in block_value: + return [f"tasks.json: `{block}` has no `inForce`. Every reader treats its absence as NOT " + "in force, so a declaration written without it silently declares nothing."] + if not isinstance(block_value["inForce"], bool): + return [f"tasks.json: `{block}.inForce` is {block_value['inForce']!r}, not a JSON boolean. " + "`\"true\"` is not `true`: every reader asks `is True`, so a quoted value disarms " + "the guard while reading, to a human, as if it were armed."] + return [] + + +def check_pause_coherence(manifest: dict) -> list[str]: + """Is the record's pause DECLARATION consistent with the statuses it is recorded beside? + + 🚨 A PAUSE IS A CLAIM ABOUT THE REGISTRY, AND THE RECORD CAN CONTRADICT IT SILENTLY. + `retention_windows` prints "PAUSED" only when it finds no ENABLED step, so a task flipped to + `Enabled` while `pause.inForce` is still true makes the report print windows and never mention + the pause — a record asserting both that cleanup is stopped and that it runs at 03:00, with + nothing red. The reverse is the shape that goes stale: every task Disabled and no declaration, + which reads identically to a retention that silently stopped. + """ + problems: list[str] = [] + problems.extend(check_in_force_is_boolean(manifest, "pause")) + problems.extend(check_in_force_is_boolean(manifest, "recordAheadOfRegistry")) + pause = manifest.get("pause") or {} + in_force = pause.get("inForce") is True + tasks = manifest.get("tasks") or [] + enabled = [str(task.get("name")) for task in tasks + if str(task.get("status", "")).lower() == "enabled"] + if in_force: + for field_name in ("since", "reason", "reEnableWhen"): + if not str(pause.get(field_name, "")).strip(): + problems.append( + f"tasks.json: `pause.inForce` is true with no `{field_name}`. A pause with no " + f"{field_name} is indistinguishable from a retention that silently stopped — " + "which is the state #3438 spent a week telling apart from a policy.") + if enabled: + problems.append( + f"tasks.json: `pause.inForce` is true and {enabled} is recorded `Enabled`. The " + "record then asserts both that cleanup is stopped and that it runs on its " + "schedule, and the report prints the window without ever mentioning the pause. " + "Lift the pause deliberately (delete the block, citing what satisfied " + "`reEnableWhen`) or record the task as Disabled — never both.") + elif not enabled: + problems.append( + "tasks.json: every recorded task is Disabled and no in-force `pause` explains it. A " + "stopped retention and a stale record read identically, and only one of them is a " + "decision. Declare the pause (`inForce`/`since`/`reason`/`reEnableWhen`) or record " + "the enabled task.") + return problems + def retention_windows(root: str) -> tuple[list[str], str]: """The ENABLED purge steps this protection is racing, as recorded — the "over what window" half @@ -1725,6 +1900,11 @@ def check_retention_record(root: str) -> int: if "acr purge" not in step: problems.append(f"{yaml_path.name} has a `cmd:` step that is not an `acr purge`: " f"{step.strip()[:120]} — this record is for retention tasks.") + # 🚨 EVERY RECORDED STEP, not only the enabled ones. The record is what + # `acr-retention-tasks.sh apply` PUSHES, so a disabled task carrying a 7-day window is + # a 7-day window one command away from running — which is exactly the state this + # record was in while the pause held it off. + problems.extend(check_window_policy(yaml_path.name, step)) # 🚨 THE DENOMINATOR. Zero recorded tasks, or zero steps across them, reads exactly like a # clean record while having inspected nothing — the confusion #3438 is made of. @@ -1736,12 +1916,17 @@ def check_retention_record(root: str) -> int: problems.append("ZERO purge steps were found across every recorded task, so the " "--include-locked assertion inspected nothing.") + problems.extend(check_pause_coherence(manifest)) + for problem in problems: print(f"::error::{problem}") if problems: return 1 print(" no recorded purge step passes --include-locked, so `acr purge` skips locked " "manifests — which is what makes a lock a protection.") + print(f" every recorded step retains for at least {MINIMUM_PURGE_AGE_DAYS} days by age with " + "no --keep build-count quota (#3842), and the record's pause declaration agrees with " + "the statuses it is recorded beside.") return 0 @@ -2443,6 +2628,113 @@ def roster_of(document: str) -> tuple[dict, list[str]]: "ARM 24c: a MISSING retention record reported windows, or reported no problem — " "an unstatable window must say so, never print as an empty list") + # ── ARM 31: the decided WINDOW is asserted on the record, and the assertion can fail ──────── + # 🚨 The control that matters is the one this repository's own record FAILED on 2026-09-13: + # `--ago 7d --keep 10` over `memex-portal-ai`, the image both production portals run. It is + # driven here as a literal so the arm still fires the day the record is right. + check(check_window_policy("x.yaml", " - cmd: acr purge --filter 'memex-portal-ai:.*' " + "--ago 7d --keep 10 --untagged"), + "ARM 31: the exact step this repository carried until 2026-09-13 — a 7-day window with a " + "ten-build quota over the image both production portals run — passed the window policy") + check(any("--keep" in problem for problem in + check_window_policy("x.yaml", " - cmd: acr purge --filter 'a:.*' --ago 90d " + "--keep 10 --untagged")), + "ARM 31: a BUILD-COUNT QUOTA beside a generous age window passed. `--keep` counts NEWER " + "BUILDS, so a repository republished many times a day ages its own manifests out in " + "hours whatever `--ago` says — the age window cannot replace this half (#3842)") + check(check_window_policy("x.yaml", " - cmd: acr purge --filter 'a:.*' --untagged"), + "ARM 31: a purge step with NO `--ago` at all passed — no age floor is not a long one") + check(any("could not be read" in problem for problem in + check_window_policy("x.yaml", " - cmd: acr purge --filter 'a:.*' --ago forever " + "--untagged")), + "ARM 31: an UNREADABLE `--ago` passed. A window nobody could parse is a window nobody " + "checked, and it must never spell the same as one that was checked and was fine") + check(not check_window_policy("x.yaml", " - cmd: acr purge --filter 'a:.*' --ago 30d " + "--untagged"), + "ARM 31: the DECIDED window itself was rejected — the gate would red on the fix, which " + "is how a gate gets muted") + check(not check_window_policy("x.yaml", " - cmd: acr purge --filter 'a:.*' --ago 720h " + "--untagged"), + "ARM 31: 720h is 30 days and was rejected; the unit table is what makes this a duration " + "comparison rather than a string match") + check(parse_ago_days("7d") == 7 and parse_ago_days("1d12h") == 1.5 + and parse_ago_days("30") is None and parse_ago_days("30dx") is None, + "ARM 31: the `--ago` parser is wrong — a bare number or a trailing character must be " + "UNREADABLE, never a silent default") + + # ── ARM 31c: the window is read off the INVOCATION, and both `--keep` spellings count ────── + # Review findings on #4213, each driven as its own arm. + check(check_window_policy("x.yaml", " - cmd: echo --ago 30d; acr purge --filter 'a:.*' " + "--untagged"), + "ARM 31c: a `--ago` sitting on the LINE but not in the `acr purge` INVOCATION satisfied " + "the window. A `cmd:` is a shell line and may hold more than one command; the flag that " + "counts is the one on the command that DELETES") + check(any("BUILD-COUNT QUOTA" in problem for problem in + check_window_policy("x.yaml", " - cmd: acr purge --filter 'a:.*' --ago 30d " + "--keep=10 --untagged")), + "ARM 31c: `--keep=10` passed. It is the same quota as `--keep 10`, and a whitespace-only " + "match reads the compliant spelling and misses the other") + check(any("bare" in problem for problem in + check_window_policy("x.yaml", " - cmd: acr purge --filter 'a:.*' --ago 30d --keep")), + "ARM 31c: a BARE `--keep` passed — a malformed step is not a compliant one") + check(check_window_policy("x.yaml", " - cmd: acr purge --filter 'a:.*' --ago --untagged"), + "ARM 31c: a bare `--ago` with no duration passed, so the window was never checked") + check(any("--ago 7d" in problem for problem in + check_window_policy("x.yaml", " - cmd: acr purge --filter 'a:.*' --ago 30d " + "--untagged && acr purge --filter 'b:.*' --ago 7d " + "--untagged")), + "ARM 31c: a SECOND `acr purge` on the same line escaped the window check — every " + "invocation deletes, so every invocation is checked") + check(not check_window_policy("x.yaml", " - cmd: acr purge --filter 'a:.*' --ago=30d " + "--untagged"), + "ARM 31c: the `--ago=30d` spelling of a COMPLIANT window was rejected") + + # ── ARM 31d: a declaration whose switch is not a BOOLEAN is fail-open, and reds ───────────── + for block in ("pause", "recordAheadOfRegistry"): + check(any("not a JSON boolean" in problem for problem in + check_in_force_is_boolean({block: {"inForce": "true"}}, block)), + f"ARM 31d: `{block}.inForce` as the STRING \"true\" was accepted. Every reader asks " + "`is True`, which is False for a string — so a typed quote mark disarms the " + "interlock, the overwrite guard and the coherence gate at once, while reading to a " + "human as if it were armed") + check(check_in_force_is_boolean({block: {}}, block), + f"ARM 31d: `{block}` with no `inForce` was accepted; every reader treats its absence " + "as NOT in force, so it declares nothing while looking like a declaration") + check(check_in_force_is_boolean({block: "yes"}, block), + f"ARM 31d: `{block}` as a scalar was accepted") + check(not check_in_force_is_boolean({}, block), + f"ARM 31d: an ABSENT `{block}` was reported as malformed — absent and wrong are " + "different, and only one of them is a problem") + check(not check_in_force_is_boolean({block: {"inForce": False}}, block), + f"ARM 31d: a well-formed `{block}.inForce: false` was rejected") + check(any("not a JSON boolean" in problem for problem in check_pause_coherence( + {"tasks": [{"name": "t", "status": "Enabled"}], + "pause": {"inForce": "true", "since": "s", "reason": "r", "reEnableWhen": "w"}})), + "ARM 31d: the coherence gate passed an `Enabled` task sitting under a pause whose " + "`inForce` is a string — the exact record the apply interlock would also walk past") + + # ── ARM 31b: the PAUSE declaration and the recorded statuses cannot contradict each other ─── + check(any("is recorded `Enabled`" in problem for problem in check_pause_coherence( + {"tasks": [{"name": "t", "status": "Enabled"}], + "pause": {"inForce": True, "since": "s", "reason": "r", "reEnableWhen": "w"}})), + "ARM 31b: a record asserting BOTH an in-force pause and an Enabled purge task passed. " + "`retention_windows` prints the window and never mentions the pause in that state, so " + "the contradiction is invisible in the report") + check(any("no `reEnableWhen`" in problem for problem in check_pause_coherence( + {"tasks": [{"name": "t", "status": "Disabled"}], + "pause": {"inForce": True, "since": "s", "reason": "r"}})), + "ARM 31b: a pause with no re-enable condition passed — that is a retention that stopped, " + "written as if it were a decision") + check(check_pause_coherence({"tasks": [{"name": "t", "status": "Disabled"}]}), + "ARM 31b: every task Disabled with NO declaration passed. A stopped retention and a " + "stale record read identically, and only one of them is a decision") + check(not check_pause_coherence({"tasks": [{"name": "t", "status": "Enabled"}]}), + "ARM 31b: an ordinary running retention with no pause block was rejected") + check(not check_pause_coherence( + {"tasks": [{"name": "t", "status": "Disabled"}], + "pause": {"inForce": True, "since": "s", "reason": "r", "reEnableWhen": "w"}}), + "ARM 31b: this repository's own paused shape was rejected") + # ── ARM 24d: a roster that declares one installation TWICE decides nothing, and reds ──────── with tempfile.TemporaryDirectory() as scratch: folder = Path(scratch) / ".github" / "acr-retention" @@ -2589,7 +2881,7 @@ def _calls(function) -> set[str]: "unresolved tag / indeterminate / unreadable registry all RED with nothing released, " "release arm off by default and live when enabled, report-only writes nothing, a lock " "write that exits 0 without taking and one whose read-back cannot answer are both RED " - "and counted as protecting NOTHING, and the two existing pin extractors still agree. AXIS 3: the set an installation is RUNNING is locked though no file pins it, its migration twin with it, the TAG is locked beside the manifest, an installation that did not answer is INCOMPLETE and refuses the unlock arm, silence is never retirement, a stale roster entry and an unknown running set are RED, the digest extractor is controlled against a fixture rather than inferred from the fleet, a tag lock that did not take is counted as protecting NOTHING, a locked INDEX is expanded to the platform manifests acr-cli would otherwise collect out from under it, and the harness provably drives the same path as run().") + "and counted as protecting NOTHING, and the two existing pin extractors still agree. AXIS 3: the set an installation is RUNNING is locked though no file pins it, its migration twin with it, the TAG is locked beside the manifest, an installation that did not answer is INCOMPLETE and refuses the unlock arm, silence is never retirement, a stale roster entry and an unknown running set are RED, the digest extractor is controlled against a fixture rather than inferred from the fleet, a tag lock that did not take is counted as protecting NOTHING, a locked INDEX is expanded to the platform manifests acr-cli would otherwise collect out from under it, and the harness provably drives the same path as run(). THE RECORD: every recorded purge step is held to #3842's decided window — at least 30 days by age, no `--keep` build-count quota — with the exact `--ago 7d --keep 10` step this repository carried until 2026-09-13 driven as a literal control, a bare or unreadable `--ago` RED, and the decided window itself proven to PASS; and the pause declaration cannot contradict the statuses it is recorded beside, in either direction. The window is read off each `acr purge` INVOCATION rather than the shell line, every invocation on it, `--keep=N` and a bare `--keep` count as quotas, a bare `--ago` is unchecked rather than compliant, and a declaration whose `inForce` is the STRING \"true\" — which every `is True` reader silently treats as absent — is RED in both blocks.") return 0 diff --git a/src/MeshWeaver.Documentation/Data/Architecture/ArtifactRetentionInterlock.md b/src/MeshWeaver.Documentation/Data/Architecture/ArtifactRetentionInterlock.md index 78aa5d3c81..d7aaf1b8b0 100644 --- a/src/MeshWeaver.Documentation/Data/Architecture/ArtifactRetentionInterlock.md +++ b/src/MeshWeaver.Documentation/Data/Architecture/ArtifactRetentionInterlock.md @@ -327,11 +327,95 @@ stamp with the reason, or install it. The registry lane already carries the same `.github/acr-retention/instances.json`, with the reason and the issue; the two are deliberately separate files because they are two different stores, and neither infers the other's answer. +## The window is DECIDED, and the record is now held to it + +The policy is #3842's, quoted verbatim in #3438's body and in #3859's acceptance list: + +> *Retain unreferenced continuous artifacts for at least 30 days by age, without a build-count +> quota.* + +**Three stores implement it; two of them were clamped to it in code and the third was not asserted +at all.** `PrebuiltBundleRetention` clamps `MinimumAge` up to 30 days in its planner (#3843) and +`AssemblyCacheRetention` does the same (#3846) — and both carry `KeepNewestPerSource` as a property +that explicitly no longer drives deletion. #3843's own body records the gap in as many words: *"The +ACR task record and live cloud cleanup are unchanged."* So `.github/acr-retention/purge-old-images.yaml` +kept `--ago 7d --keep 10` over five continuously-republished repositories — `memex-portal-ai` +included, the image **both production portals run** — and nothing compared it to the rule that +governed the other two. + +🚨 **`--keep` is the half an age window cannot replace.** `--keep N` counts NEWER BUILDS, so the +more often a repository is republished the FASTER its older manifests become eligible. A 30-day +`--ago` beside a `--keep 10` still collects a manifest ten builds old on the day it is written. +That is #3438's own root cause — *republishing frequency is what destroys a pin* — restated as a +flag, so a quota is removed rather than raised. + +🚨 **The flag that counts is the one on the command that DELETES, and a quota has two spellings.** +A `cmd:` is a shell line: `echo --ago 30d; acr purge --filter 'x:.*' --untagged` carries the window +on the line and not on the purge. So each `acr purge` invocation is isolated at the first shell +separator and checked on its own — **every** invocation on the line — and the quota check reads +`--keep N`, `--keep=N` and a bare `--keep` alike. A bare `--ago` with no duration is *unchecked*, +which is a failure, not a pass. + +🚨 **And a declaration whose switch is not a boolean is fail-open, silently.** Every reader of +`pause` and `recordAheadOfRegistry` asks `inForce is True` — correct for a JSON boolean, and +catastrophic for `"true"`, which is not `True`. One typed quote mark would disarm the apply +interlock, the `record` overwrite guard and the coherence gate at once, while reading to a human as +if it were armed. The shape is asserted on every pull request, and both shell halves **fail closed** +on the same condition rather than trusting that to have run. + +`lock-pinned-digests.py --check-retention-record` now asserts both halves over **every** recorded +purge step, enabled or not — the record is what `acr-retention-tasks.sh apply` pushes, so a disabled +task carrying a 7-day window is a 7-day window one command away from running. It needs no +credential and runs on every pull request (`dotnet-test.yml`, the workflow-shell lane). An `--ago` +it cannot parse is RED, not a default: a window nobody could read is one nobody checked. + +**This is a RECORD change, not a registry change.** The live task is disabled and still carries the +old window; `tasks.json` declares that deliberate gap under `recordAheadOfRegistry`, and three +things follow from the declaration rather than from anyone remembering it: + +- `acr-retention-tasks.sh verify` prints the declared drift instead of reporting it as an incident + — a drift report that is always red is one nobody reads — **and reds when a declared task shows + NO drift**, because after `apply` the declaration excuses nothing and would excuse the next real + drift. 🚨 **The exemption covers the WINDOW moving, never the task becoming something else:** the + declaration is keyed by task name, so without a second check a live definition that had gained + `--include-locked`, swapped its filters or stopped being an `acr purge` would print as *declared* + and leave `verify` green — and the record-side checks read the recorded file, so they would never + see it. The LIVE steps of a declared task are held to all three. +- `acr-retention-tasks.sh record` — the one command that overwrites the record FROM live — refuses + without an explicit `OVERWRITE`, because re-recording would silently restore `--ago 7d --keep 10` + into a file whose comments explain at length why it must not say that. +- The gate above reds on the restored window on the very next pull request. + +## The re-enable interlock — the one edge this repository owns + +#3859's first acceptance criterion is *failed / unavailable / incomplete protection collection +cannot be followed by deletion*. `acr-retention-tasks.sh apply` is the only thing in this repository +that can turn a destructive schedule back on: it pushes `status` out of `tasks.json` with +`az acr task update --status`. It used to do that with reference to nothing — so changing one word +in `tasks.json` (`Disabled` → `Enabled`) and running `apply` restored the 03:00 purge with **no +protection decision consulted at all**, while the `pause` block two screens below still said the +protection was incomplete. + +`apply` now **refuses** while `pause.inForce` is true and any task is recorded `Enabled`, naming +`pause.reEnableWhen`, and it refuses *before* the confirmation prompt so the prompt cannot be +mistaken for the gate. It refuses rather than warns, and it asks for no override flag: a prompt +answered "yes" is not a decision anyone can audit, whereas deleting the `pause` block in a reviewed +diff that says what satisfied `reEnableWhen` is. The record's own coherence is gated too — a record +asserting both an in-force pause and an `Enabled` task is RED, and so is every task disabled with no +declaration explaining it, because a stopped retention and a stale record otherwise read identically. + +🚨 **This does not close #3859, and the distinction is the whole of what is left.** It interlocks +the ACT of re-enabling. It does not interlock the nightly deletion: the lock is still an Actions +cron at 01:00 and the purge still an ACR timer task at 03:00, so a given night's deletion is still +not downstream of that night's protection verdict. + ## What is still the maintainer's, and is not code -1. **`purge-old-images` carries `memex-portal-ai` on its 7-day step** — the image both production - portals run — while the 30-day step covers only `memex-portal`, a repository no overlay pins. - Whether that is the intended policy is a decision, not a defect. +1. **The decided window has not been APPLIED to the registry.** The record states it; the live + (disabled) task still carries `--ago 7d --keep 10`. Applying it is the same act as lifting the + pause, and it belongs to whoever owns the registry. Its cost is storage: dropping `--keep 10` + and moving 7d → 30d retains strictly more, over five repositories that republish many times a + day. 2. **The two clocks are still two clocks.** The lock is a GitHub Actions cron at 01:00; the purge is an ACR timer task at 03:00; nothing makes the second wait for the first. A pin that lands in that window meets the purge unprotected, and if the lock job does not run at all the purge still does. diff --git a/src/MeshWeaver.Documentation/Data/Architecture/PinnedImageRetention.md b/src/MeshWeaver.Documentation/Data/Architecture/PinnedImageRetention.md index 0be69da83a..a9def1945f 100644 --- a/src/MeshWeaver.Documentation/Data/Architecture/PinnedImageRetention.md +++ b/src/MeshWeaver.Documentation/Data/Architecture/PinnedImageRetention.md @@ -378,6 +378,12 @@ not deleted, so that re-enabling it is a visible act rather than a rediscovery. | CI images | `mw-plugin-test`, `memex-migration`, `memex-portal-ai`, `memex-portal-next`, `memex-bake` | `--ago 7d --keep 10 --untagged` | | Production portal | `memex-portal` | `--ago 30d --untagged` | +> 🚨 **That table is the 2026-09-06 reading and the CI step has since MOVED in the record.** On +> 2026-09-13 it became `--ago 30d --untagged` with no `--keep`, which is #3842's decided policy +> ([ArtifactRetentionInterlock](../ArtifactRetentionInterlock) → *The window is DECIDED*). The +> **live** task is disabled and still carries the old window; `tasks.json` declares that gap under +> `recordAheadOfRegistry`. Read the record, not this page, for what `apply` would install. + 🚨 **Two steps, not one, and that is the point.** Unioning the filters under the stricter window would have dragged `memex-portal` from 30 days to `7d --keep 10` with nobody deciding to. The merge removed the second *task* — the "which of two lists?" question — while preserving each repository's diff --git a/src/MeshWeaver.Documentation/Data/Architecture/ReleasedArtifactRetention.md b/src/MeshWeaver.Documentation/Data/Architecture/ReleasedArtifactRetention.md index c9d6139c26..74e30ed9bf 100644 --- a/src/MeshWeaver.Documentation/Data/Architecture/ReleasedArtifactRetention.md +++ b/src/MeshWeaver.Documentation/Data/Architecture/ReleasedArtifactRetention.md @@ -97,8 +97,14 @@ inventory. No pin removal, green build or major-version comparison authorizes an on its own. The files under `.github/acr-retention/` record the cloud tasks last read; they are not -automatically deployed. The recorded 7-day/count-based CI cleanup is not the desired -30-day policy. Update the live task only with the coordinated protection path ready, +automatically deployed. The recorded CI cleanup carried a 7-day/count-based window until +2026-09-13; it now records this page's 30-day, no-quota policy, and +`lock-pinned-digests.py --check-retention-record` asserts it over every recorded step on +every pull request. The LIVE task is disabled and still carries the old window — the gap is +declared in `tasks.json` under `recordAheadOfRegistry`, and re-capturing the record from the +live task would silently restore the quota, which is why `acr-retention-tasks.sh record` +refuses without an explicit confirmation. +Update the live task only with the coordinated protection path ready, a reviewed report-only deletion list, and verification that the superseded task stays disabled. Verify the actual purge-tool version enforces age for untagged artifacts and preserves referenced manifests. Never include locked manifests in a purge. diff --git a/src/MeshWeaver.Documentation/Data/WhatsNew/2026-09-13-retention-keeps-continuous-artifacts-for-thirty-days.md b/src/MeshWeaver.Documentation/Data/WhatsNew/2026-09-13-retention-keeps-continuous-artifacts-for-thirty-days.md new file mode 100644 index 0000000000..01ad649650 --- /dev/null +++ b/src/MeshWeaver.Documentation/Data/WhatsNew/2026-09-13-retention-keeps-continuous-artifacts-for-thirty-days.md @@ -0,0 +1,58 @@ +--- +Name: Retention keeps continuous artifacts for thirty days, and re-enabling cleanup is interlocked +Category: Fix +Description: The registry's recorded purge now states the decided policy — at least 30 days by age with no build-count quota — a gate holds every recorded step to it on every pull request, and turning the paused cleanup back on refuses while the pause that stands in front of it is still in force. +Icon: ShieldLock +Order: -20260913 +--- + +# Retention keeps continuous artifacts for thirty days, and re-enabling cleanup is interlocked + +The retention policy this platform decided is one sentence: **retain unreferenced continuous +artifacts for at least 30 days by age, without a build-count quota.** Two of the three stores that +implement it were clamped to it in code — the prebuilt-bundle store and the assembly cache, both of +which raise a shorter configured window up to 30 days rather than trusting it. The third store is +the container registry, and it was left out in as many words. + +So the recorded purge kept a **seven-day window with a ten-build quota** over five repositories that +are republished many times a day, one of which is the image both production portals run. Nothing +compared that to the rule the other two stores obey. + +## What changed + +**The record states the decided policy, and a gate holds it there.** Every recorded purge step must +retain for at least 30 days by age and must carry no `--keep` build-count quota. The check needs no +credential and runs on every pull request, over **every** recorded step rather than only the enabled +ones — the record is what gets pushed to the registry when cleanup is re-enabled, so a disabled task +carrying a short window is a short window one command away from running. A duration the check cannot +parse is a failure, not a default: a window nobody could read is a window nobody checked. + +**The quota is removed rather than raised, and that is the point.** `--keep N` counts *newer builds*, +so the more often a repository is republished the faster its older images become eligible for +deletion — which is exactly how a stable, deliberately long-lived reference gets collected. A +generous age window beside a build-count quota still deletes something ten builds old on the day it +is written. Both halves, or neither is the policy. + +**Turning cleanup back on is interlocked.** Cleanup is currently paused, on purpose, while artifact +protection is being completed — and the record carries the condition that would end the pause. The +one command that can re-enable the schedule now **refuses** while that pause stands, naming the +condition, and refuses before its confirmation prompt so the prompt cannot be mistaken for the gate. +Lifting the pause is a reviewed change to the record that says what satisfied the condition, not a +prompt somebody answered. The record cannot quietly contradict itself either: declaring a pause +while also recording a running cleanup task is a failure, and so is every task being stopped with +nothing explaining why — a retention that halted and a record that went stale otherwise read exactly +the same. + +**A deliberate gap between the record and the registry is declared rather than remembered.** The +registry has not been changed; the record is ahead of it, and it says so. The verification command +prints that gap instead of reporting it as an incident — a drift report that is always red is one +nobody reads — and fails once the gap is closed, so the declaration expires by being checked. The +command that re-captures the record from the registry now refuses without an explicit confirmation, +because re-capturing would silently restore the short window. + +## What this does not change + +Nothing was deleted, no cleanup was run, and the registry was not modified. Cleanup remains paused. +The nightly protection run and the nightly deletion are still two independent schedules, so a given +night's deletion is still not downstream of that night's protection result; that remains open, and +it needs a registry-side mechanism rather than a wider window.