Repository navigation
review(#4213): every reader of a declaration fails closed, and the window is read off the purge - #4223
Conversation
… interlock re-enabling it #3842's rule — retain unreferenced continuous artifacts for at least 30 days by age, without a build-count quota — was landed in two of the three stores that implement it and asserted in none. `PrebuiltBundleRetention` clamps `MinimumAge` up to 30 days (#3843) and `AssemblyCacheRetention` does the same (#3846); #3843's own body records the third store being left out 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` among them — the image both production portals run — and nothing compared it to the rule governing the other two. THE WINDOW `check_retention_record` now holds every recorded `acr purge` step to both halves: an `--ago` of at least 30 days, and no `--keep`. Every step, enabled or not, because the record is what `acr-retention-tasks.sh apply` pushes — a disabled task carrying a 7-day window is a 7-day window one command away from running. An unparseable `--ago` is RED, not a default. The check needs no credential and already runs on every pull request (dotnet-test.yml's workflow-shell lane). `--keep` is removed rather than raised: it counts NEWER BUILDS, so republishing frequency is what destroys a long-lived reference — #3438's own root cause stated as a flag. A 30-day `--ago` beside a `--keep 10` still collects a manifest ten builds old. The record is now deliberately AHEAD of the (disabled) live task, declared in tasks.json under `recordAheadOfRegistry`: `verify` prints that drift instead of reporting it as an incident, and reds once it is closed so the declaration expires by being checked; `record` — the one command that overwrites the record FROM live — refuses without an explicit OVERWRITE, because re-capturing would silently restore the quota window. THE RE-ENABLE INTERLOCK (#3859 acceptance criterion 1) `apply` is the only thing in this repository that can turn a destructive schedule back on, and it did so with reference to nothing: one word in tasks.json (Disabled -> Enabled) plus `apply` restored the 03:00 purge with no protection decision consulted, while the `pause` block two screens below still said protection was incomplete. It now REFUSES while `pause.inForce` is true and any task is recorded Enabled, naming `pause.reEnableWhen`, and refuses before the confirmation prompt so the prompt cannot be mistaken for the gate. The record's own coherence is gated too: an in-force pause beside an Enabled task is RED, and so is every task Disabled with nothing declaring why. This does NOT close #3859. The lock is an Actions cron at 01:00 and the purge an ACR timer task at 03:00; a nightly deletion is still not downstream of that night's protection verdict. That needs a registry-side mechanism and an RBAC grant — the coordination choice #3859 says to make with the maintainer. NOTHING DESTRUCTIVE WAS RUN: no purge, no dry run, no registry mutation, no `apply`. Refs #3438 Refs #3859 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ndow is read off the purge
Six Copilot findings, all real, each with its own falsified arm.
1-2. 🚨 FAIL-OPEN ON A MALFORMED SWITCH. Every reader of `pause` and `recordAheadOfRegistry`
asks `inForce is True` — correct for a JSON boolean, catastrophic for `"true"`, which is
not `True`. ONE typed quote mark would have disarmed the apply interlock, the `record`
overwrite guard AND the new coherence gate at once, while reading to a human exactly like
an armed declaration. The shape is now asserted on every pull request
(`check_in_force_is_boolean`, both blocks: not-an-object, missing `inForce`, and a
non-boolean are each RED), and BOTH shell halves fail closed on the same condition rather
than trusting that to have run. Controlled: `pause.inForce: "true"` + an Enabled task now
refuses `apply` (exit 1) instead of walking past the interlock.
3. 🚨 THE WINDOW WAS READ OFF THE SHELL LINE, NOT THE COMMAND THAT DELETES.
`cmd: echo --ago 30d; acr purge --filter 'x:.*' --untagged` satisfied the floor with the
purge carrying no `--ago` at all. Each `acr purge` is now isolated at the first shell
separator and checked on its own, and EVERY invocation on the line is checked — a second
one with `--ago 7d` no longer rides in behind a compliant first.
4. `--keep=10` and a bare `--keep` both escaped a whitespace-only match, so a recorded step
could keep a build-count quota and pass. All three spellings count; a bare `--ago` with no
duration is now UNCHECKED (red), never compliant.
5. 🚨 THE DECLARED-DRIFT EXEMPTION WAS KEYED ONLY BY TASK NAME, so once `purge-old-images`
was listed, EVERY difference in its live YAML printed as the deliberate record-ahead drift
and left `verify` green — including a live definition that had gained `--include-locked`
(which deletes every manifest the lock protects), swapped its filters, or stopped being an
`acr purge`. The record-side checks read the RECORDED file and would never have seen it.
The LIVE steps of a declared task are now held to all three invariants; the declaration
covers the WINDOW moving, never the task becoming something else.
6. (#4214) the provisioning table said "two AZURE_*"; there are three.
Every fix carries an arm (ARM 31c, ARM 31d) and every arm was falsified: restoring the three
pre-review behaviours reds seven lines, including the two `inForce`-as-a-string arms and the
coherence gate passing an Enabled task under an apparent pause.
Verification: --self-test 0, --check-retention-record 0, check-workflow-shell 0,
Documentation.Test 447 passed / 0 skipped, Release -warnaserror 0 warnings 0 errors.
Refs #3438
Refs #3859
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved critical and moderate findings affect declaration handling, shell command parsing, and retention reporting.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Updates ACR retention controls and documentation to enforce a 30-day, no-quota purge policy with fail-closed interlocks.
Changes:
- Adds retention-window, pause, and coherence validation.
- Guards recording, verification, and re-enabling operations.
- Updates retention records, architecture docs, and release notes.
File summaries
| File | Summary |
|---|---|
src/MeshWeaver.Documentation/Data/WhatsNew/2026-09-13-retention-keeps-continuous-artifacts-for-thirty-days.md |
Adds the user-facing retention change note. |
src/MeshWeaver.Documentation/Data/Architecture/ReleasedArtifactRetention.md |
Updates retention status documentation. |
src/MeshWeaver.Documentation/Data/Architecture/PinnedImageRetention.md |
Clarifies recorded and live retention policy. |
src/MeshWeaver.Documentation/Data/Architecture/ArtifactRetentionInterlock.md |
Documents policy and interlock behavior. |
.github/scripts/lock-pinned-digests.py |
Validates retention policies, declarations, and self-tests. |
.github/scripts/acr-retention-tasks.sh |
Guards recording, verification, and re-enabling. |
.github/acr-retention/tasks.json |
Records pause and registry-drift state. |
.github/acr-retention/purge-old-images.yaml |
Updates the purge window to 30 days. |
Review details
Suppressed comments (8)
.github/scripts/acr-retention-tasks.sh:294
- The apply interlock has the same null/absent ambiguity for
pause: with"pause": null,p is Noneselects the no-pause path and an enabled task can reach the mutation prompt. This should fail closed for a present null, while only an absentpauseblock should printno.
p = d.get('pause')
if p is None:
print('no'); sys.exit(0)
.github/scripts/acr-retention-tasks.sh:178
- The declared-drift path repeats the same text-matching flaw:
grep -qv "acr purge"accepts a livecmd: echo "acr purge --filter ...", and the filter comparison can still match the recorded task. A live task can therefore become a no-op whileverifyreports the drift as declared and exits successfully. Validate the actual command tokens rather than the substring.
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
.github/scripts/acr-retention-tasks.sh:186
- The declared-drift exemption compares only single-quoted
--filter '…'text. A live task can retain all recorded filters and add an unquoted or double-quoted--filterfor another repository, leavinglive_filters == recorded_filters; because declared drift is otherwise accepted,verifywould then greenlight an unreviewed purge target. Compare parsed filter arguments (including every spelling), rather than extracting only this quote form.
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
.github/scripts/lock-pinned-digests.py:1757
- After recording a malformed pause, this function still calls
.geton the raw value. A truthy non-dict such as"yes"makescheck_pause_coherenceraiseAttributeError, so--check-retention-recordcrashes with a traceback instead of returning the collected validation error. Normalize non-dict pause values before reading their fields.
pause = manifest.get("pause") or {}
in_force = pause.get("inForce") is True
.github/scripts/lock-pinned-digests.py:1656
- Stopping the regex at
;only isolates the purge for its window check; it does not reject other commands on the samecmd:line. A recorded step such asacr purge ... --ago 30d --untagged; az acr repository delete ...passes the substring, window, and lock checks while adding an unrelated destructive operation. The record gate must validate the complete shell command and reject non-allowed commands.
# 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<args>[^;&|\n]*)")
.github/scripts/lock-pinned-digests.py:1660
- These matchers now accept
--ago=30d,--keep=10, and a bare--keep, butretention_windows()still extracts only the spaced forms (--ago (\S+)/--keep (\S+)). An enabled record using an accepted equals spelling would therefore be reported as--ago ?or--keep none, contradicting the policy reader and misleading the protection report. Reuse the same parser for the report.
# 🚨 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<value>[^\s;&|]+))?")
AGO_RE = re.compile(r"--ago(?:[=\s]+(?P<value>[^\s;&|]+))?")
.github/scripts/lock-pinned-digests.py:2884
- The success summary still says the self-test has “25 arms”, but this change adds the ARM 31/31b/31c/31d retention checks described later in the same message. That output now understates the coverage it claims to report; update the count or derive it instead of leaving a stale verification summary.
"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.")
src/MeshWeaver.Documentation/Data/Architecture/ArtifactRetentionInterlock.md:418
acr-retention-tasks.sh applydoes not lift the pause in the current record: it writes the YAML and then appliesstatus: Disabledforpurge-old-images; the interlock only refuses when a paused record also has an Enabled task. Thus applying the decided window and lifting the pause are not the same act. Clarify that apply can update the disabled definition, while re-enabling still requires the reviewed pause/status change.
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.
- Files reviewed: 8/8 changed files
- Comments generated: 5
- Review effort level: Lite
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| 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." |
| # 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<args>[^;&|\n]*)") |
| a = d.get('recordAheadOfRegistry') | ||
| if a is None: | ||
| print(''); sys.exit(0) |
| block_value = manifest.get(block) | ||
| if block_value is None: | ||
| return [] |
| # 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 |
Test Results (shard 0)262 tests 262 ✅ 2m 15s ⏱️ Results for commit 7d48482. |
Test Results (shard 1)1 425 tests 1 425 ✅ 1m 28s ⏱️ Results for commit 7d48482. |
…are prose Six Copilot findings, all real. The measurement that settled the design also found a seventh — my own extractor was matching inside COMMENTS. MEASURED across the fleet's TWELVE deployment overlays, 2026-09-13, before deciding anything: exactly TWO foreign hosts. `cr.meshweaver.cloud` (4 references: build x2, pearl x2) and `ghcr.io` (2 — one is `ghcr.io/distribution/distribution`, the registry service's OWN image in memex-cloud's overlay; the other is A LINE OF PROSE in ci-runners describing what the runner image is built from). Applying "every undeclared foreign host reds" literally would therefore have reddened the lane over a third-party image AND over a sentence. This issue already paid for that lesson: 11 of the 31 `sha256:` tokens in its 2026-09-08 hand count were comments. WHAT CHANGED 1. 🚨 THE UNIT IS THE REGISTRY, NOT THE INSTANCE. `instances.json` gains a top-level `registries` table and the per-instance `registry` key is REFUSED. A per-instance field answers "is this one out of scope" and can never answer "is every registry the fleet pins in accounted for" — an installation pinning its declared registry AND a second undeclared one passed it with half its running set unnamed (finding 4). Two dispositions with a mandatory reason: `fleet-unlockable` (ours, this lane cannot lock it) and `third-party` (never ours to protect). Unknown disposition, missing reason and an UNDECLARED host are each RED. 2. 🚨 AN UNDECLARED HOST REDS WHEREVER IT APPEARS (finding 3) — including on an installation that ALSO pins here, which the `if not repositories` branch never reached. And half covered is not covered: pinning both here and in a fleet-unlockable registry is a BLOCKER, because locking the ACR half and reporting success is a partially-protected installation reading as a protected one. 3. 🚨 A `*.azurecr.io` THAT IS NOT THIS REGISTRY IS FOREIGN (finding 2). `--registry meshweaver` locks one ACR; extracting another's repository and looking it up here is this bug reintroduced one registry along. 4. Helm's split `repository:` + sibling `tag:` shape is handled on the foreign path too (finding 5) — without it a foreign-only overlay written that way extracted as NOTHING and fell back into `pins no image at all`: this bug in its second shape. 5. 🚨 THE REPORT NO LONGER CLAIMS PROTECTION NOBODY CHECKED (finding 6). The out-of-scope line read "protected by that registry's own retention"; it now reads "NOT locked here, and whether anything retains that registry is UNVERIFIED". A summary claiming otherwise would make a green run read as covered and support re-enabling cleanup on a false premise — this mechanism's own failure mode, committed by its own report. 6. Whole-line COMMENTS are stripped before extraction, on BOTH paths. 7. Finding 1 (the declared-drift exemption keyed only by task name) is #4223, already open. CONTROLS. Twenty-three arms (ARM 32), falsified in two directions: an inert classifier reds six, and removing comment-stripping plus the host comparison reds four more including "a reference inside a COMMENT was extracted as a pin". Re-run over the ACTUAL committed bytes of all twelve overlays: the comment match is gone (ghcr.io 2 → 1), memex-cloud's 3 ACR + 1 third-party is not a blocker, build and pearl are out of scope, memex is 6 ACR + 0 foreign, and both hosts are accounted for by the committed table. Refs #3438 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Test Results (shard 2)883 tests 883 ✅ 3m 31s ⏱️ Results for commit 7d48482. |
Test Results (shard 5) 6 files 6 suites 4m 46s ⏱️ Results for commit 7d48482. |
Test Results (shard 3)1 047 tests 1 045 ✅ 6m 23s ⏱️ Results for commit 7d48482. |
Test Results (shard 4)1 829 tests 1 637 ✅ 4m 27s ⏱️ Results for commit 7d48482. |
Test Results 18 files 18 suites 22m 52s ⏱️ Results for commit 7d48482. |
Five more findings, all real.
1-2. 🚨 A TEXT SEARCH FOR `--include-locked` IS DEFEATED BY THE SHELL'S OWN QUOTING.
`acr purge --include-"locked" …` EXECUTES with that option — the shell joins the
fragment — and matches no grep for the literal string. That flag deletes every manifest
the lock protects, so a text search was the wrong instrument for the one invariant this
lane rests on, on BOTH sides: the recorded gate and the live check `verify` runs on a
declared task. The same search fails the other way too: `echo "acr purge --filter 'a:.*'
--ago 30d"` CONTAINS the text and deletes nothing, so it was reported as a compliant
purge where no purge runs.
One change answers both: a `cmd:` is tokenized the way a shell would (`shlex`,
punctuation_chars), split on the operators, and only a command whose first two tokens
are literally `acr` and `purge` is a purge — every such command on the line. A step with
unbalanced quotes cannot be tokenized at all, which means nothing in it was checked,
which is now a FAILURE rather than a pass.
3. 🚨 ONE PARSER, BOTH SIDES. `acr-retention-tasks.sh` kept its own greps for
`--include-locked` and `--filter '…'` — the same defeated search one file along. It now
asks `lock-pinned-digests.py --describe-purge-file`, which prints the tokenized steps as
JSON. Controlled: `--include-"locked"` reads back `includeLocked: true`, and
`echo "acr purge …"` reads back `notAPurge`.
4. 🚨 `pause: null` IS NOT THE SAME AS NO `pause`. `dict.get` answers None for an absent key
and for a written null alike, so a present block declaring nothing read exactly like a
record that never had one, and every reader — the apply interlock, the overwrite guard,
the coherence gate — walked past it. Absent and null are now separated before anything
reads the value, in both blocks.
5. The `purge-old-images.yaml` header still called the first step "the 7d step" after its
window moved. Corrected, with the previous window stated as history.
ARM 31e (6 assertions) and a null arm per block, all falsified: restoring a naive tokenizer
and `dict.get`-based null handling reds four, including the `--include-"locked"` bypass and
the unbalanced-quote pass.
Rebuilt onto main after #4223 merged: the branch was based on a pre-rebase copy of the same
commit, so git saw the change applied twice. Only the round-2 delta is re-applied here.
Verification on the rebuilt branch: --self-test 0, --check-retention-record 0,
check-workflow-shell 0, check-pinned-digests --self-test 0, Documentation.Test 459 passed /
0 skipped, Release -warnaserror 0 warnings 0 errors.
Refs #3438
Refs #3859
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#4223 landed the #4213 review round in the same self-test region and the same summary line this branch appends to, so the merge conflicted in two places. Both resolved by KEEPING BOTH: * the self-test arms — this branch's ARM 32 (another registry) followed by main's ARM 31c (the window read off the invocation) and ARM 31d (a non-boolean `inForce` fails closed); * the summary sentence — main's text, with this branch's ANOTHER REGISTRY clause re-appended and updated to describe what actually landed here (an undeclared registry reds wherever it appears, a declared fleet-unlockable one is counted saying protection there is UNVERIFIED, pinning in both is RED because half covered is not covered, a foreign `*.azurecr.io`, helm's split shape, and a comment is prose). Nothing was dropped from either side. Verified after the merge: --self-test 0, --check-retention-record 0, check-workflow-shell 0, Documentation.Test 459 passed / 0 skipped, Release -warnaserror 0 warnings 0 errors. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Six Copilot findings on #4213 and #4214. All six are real; each has its own falsified arm.
1–2. 🚨 Fail-open on a malformed switch — three guards disarmed by one quote mark
Every reader of
pauseandrecordAheadOfRegistryasksinForce is True. That is correct for aJSON boolean and catastrophic for
"true", which is notTrue. A record written with a typedquote mark would read as no declaration at all to:
applyinterlock — it would walk straight past a pause and enable the purge;recordoverwrite guard — it would recapture the live 7-day task withoutOVERWRITE;Enabledtask sitting under an apparent pause.…while reading, to a human reviewing the diff, exactly like an armed declaration.
The shape is now asserted on every pull request (
check_in_force_is_boolean, over both blocks:not-an-object, missing
inForce, and a non-boolean are each RED), and both shell halves failclosed on the same condition rather than trusting that to have run. Controlled against a scratch
record:
3. 🚨 The window was read off the shell LINE, not the command that deletes
cmd: echo --ago 30d; acr purge --filter 'x:.*' --untaggedsatisfied the 30-day floor with thepurge carrying no
--agoat all. Eachacr purgeis now isolated at the first shell separator andchecked on its own — and every invocation on the line, so a second one carrying
--ago 7dnolonger rides in behind a compliant first.
4.
--keep=10and a bare--keepescaped a whitespace-only matchBoth are quotas; a bare one is a malformed step rather than a compliant one. All three spellings
count now, and a bare
--agowith no duration is unchecked — which is a failure, never a pass.5. 🚨 The declared-drift exemption was keyed only by task name
Once
purge-old-imageswas listed underrecordAheadOfRegistry, every difference in its liveYAML printed as the deliberate record-ahead drift and left
verifygreen — including a livedefinition that had gained
--include-locked(which deletes every manifest the lock protects),swapped its filters, or stopped being an
acr purge. The record-side checks read the recordedfile, so they would never have seen it.
The LIVE steps of a declared task are now held to all three invariants. The declaration covers the
window moving, never the task becoming something else.
6. (#4214) "two
AZURE_*" — there are threeCorrected in #4222, with the denominator counted rather than eyeballed: the preflight carries nine
missing+=lines, five already provisioned formain-cdand fourCOMBO_*missing.The negative controls
Restoring the three pre-review behaviours — the window read off the whole line, the whitespace-only
--keep, and theinForceboolean check removed — reds seven arms:Restored:
--self-testexits 0. The shell halves were driven against a scratch record (above), noAzure.
Verification
lock-pinned-digests.py --self-test→ 0 ·--check-retention-record .→ 0check-workflow-shell.py --root .→ 0 (shellcheck covers.github/scripts/*.sh)dotnet build test/MeshWeaver.Documentation.Test -c Release -warnaserror→0 Warning(s) 0 Error(s)dotnet test …→ 447 passed, 0 skippedRefs #3438
Refs #3859
🤖 Generated with Claude Code