From 01484d2ed659743b076eca3d17a9c5b5d2d79956 Mon Sep 17 00:00:00 2001 From: Andreas Trawoeger Date: Fri, 23 Oct 2026 23:21:28 +0200 Subject: [PATCH] feat(git-submodules): bound NEGLECT in currency mode with an opt-in `stale_after` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A pin that is not current is CHURN: an upstream that moves daily is behind between every wave, and a gate that failed on the DISTANCE would be red roughly three days out of four on a healthy tree. What recurred in the org is a CADENCE defect — the owner simply stops advancing the pin — and no verb could see it: `verify` answers consistency, `currency` answers currency, and neither says whether the owner still maintains the pin at all. `stale_after` is the opt-in bound that separates the two. It is measured on the AGE of the owner's own last commit that touched the pin's path, never on the pin's distance from the served ref, and a pin that is not current AND has not been advanced within the bound is STALE and FAILS. A `cur` pin is never stale (a quiet upstream is not neglect); a `blocked-by` edge is not eligible (the producer's neglect is the producer's business, and failing here over another repo's PR is the permanently-red trap again); unset leaves the verdict and the report byte-unchanged. The knob lives in the canonical judgement — this verb — so the owner declares the bound in its own manifest and no repo re-derives "is the pin at the tip" for itself. Proven differentially: the same fixture, the same behind pin and the same 168h bound, differing only in the date of the commit that recorded the pin, give one verdict each way. Without the change the knob is accepted and ignored, and the arm fails. Refs opencharly/plugin-task#42, opencharly/charly#1048 Assisted-by: Claude Code minimax/minimax-m3.1-flash:cloud (fully tested and validated) --- README.md | 10 ++ candy/plugin-task/currency_test.go | 112 ++++++++++++++++++++++ candy/plugin-task/params/cue_types_gen.go | 13 +++ candy/plugin-task/schema/task.cue | 12 +++ candy/plugin-task/verbs.go | 106 ++++++++++++++++++++ scripts/gate-task.sh | 65 +++++++++++++ 6 files changed, 318 insertions(+) diff --git a/README.md b/README.md index 6f5adf6..1c0375e 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,16 @@ currency: box/arch: box/arch context: [runtime] +# `stale_after` is OPT-IN and bounds NEGLECT, not churn. A pin that is not current +# AND that this repo has not advanced for LONGER than the bound is STALE, and a +# stale pin FAILS. The bound is on the AGE of this repo's last advance of that +# path — never on the pin's distance from the served ref: a busy upstream is behind +# between every wave, so a gate that failed on the distance would be permanently +# red on a healthy tree, which trains every reader to ignore it. Leave it unset and +# the verdict is the structural one alone. +# +# git-submodules: {mode: currency, stale_after: "168h"} + # Assert the harness config files are identical to charly's twins. harness: task: diff --git a/candy/plugin-task/currency_test.go b/candy/plugin-task/currency_test.go index 2b6e5f2..69bfc08 100644 --- a/candy/plugin-task/currency_test.go +++ b/candy/plugin-task/currency_test.go @@ -6,6 +6,7 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/opencharly/spec/spec" ) @@ -400,3 +401,114 @@ func TestGitSubmodules_CurrencyRefusesDepthForBOTHEdgeShapes(t *testing.T) { }) } } + +// curStale builds the fixture the staleness arms need: a superproject whose `src` pin is +// either behind its upstream (`behind`) or at it (`current`), whose `d` pin is current, and +// whose LAST commit — the one that touched the pins — is dated `ago` in the past. +// +// The date is the fixture's whole point: the age of this repo's own bookkeeping for a pin +// is read from that commit, so the same tree is the healthy regime or the neglect regime +// depending only on when it was last advanced. +func curStale(t *testing.T, tmp, name string, behind bool, ago time.Duration) (dir, c1, c2 string) { + t.Helper() + dir, run, c1, c2, _ := curSuper(t, filepath.Join(tmp, name)) + curRecord(t, dir, "d", c2) // current: isolates the arm under test + if behind { + curRecord(t, dir, "src", c1) + } else { + curRecord(t, dir, "src", c2) + } + stamp := time.Now().Add(-ago).UTC().Format(time.RFC3339) + t.Setenv("GIT_AUTHOR_DATE", stamp) + t.Setenv("GIT_COMMITTER_DATE", stamp) + run("commit", "-qm", "record the pins") + return dir, c1, c2 +} + +// TestGitSubmodules_CurrencyStalenessBoundsNeglect proves the control that the recurring +// stale twin needed — a bound on NEGLECT, not on churn. +// +// The measurement behind it: a twin whose upstream moves daily is behind its served ref +// roughly 75% of wall-clock time, with continuous behind-windows of 8 and 10 days. A gate +// that failed on the DISTANCE would therefore be red on a healthy tree, which trains every +// reader to ignore it. What actually recurred was charly's own ADVANCE CADENCE: gaps of +// 8 and 10 days with no advance at all, against a healthy maximum of 5 days. +// +// So the bound is on the AGE of this repo's last advance of a pin, and the arms below are +// a one-variable differential: the same tree, the same pin, the same bound — only the date +// of the last advance differs, and the verdict flips with it. +func TestGitSubmodules_CurrencyStalenessBoundsNeglect(t *testing.T) { + tmp := t.TempDir() + bound := "168h" + + // Arm 1 — the tree as it is, with the knob UNSET: the verdict is the structural one + // alone, exactly as before the knob existed. The opt-in is real, and a repo that has + // not declared a bound sees no change at all. + oldDir, _, _ := curStale(t, tmp, "old", true, 240*time.Hour) + st, msg := runMaintenanceVerbIn(oldDir, "git-submodules", map[string]any{"mode": "currency"}) + if st != spec.StatusPass { + t.Fatalf("without a declared bound the verdict must be unchanged; got %s: %s", st, msg) + } + if !strings.Contains(msg, "1 of 2 pin(s) NOT current") || strings.Contains(msg, "STALE") { + t.Fatalf("an undeclared bound must not mention staleness; got:\n%s", msg) + } + + // Arm 2 — the same tree WITH the bound: the pin is behind AND this repo has not + // advanced it for 240h, which is past the 168h bound, so it is STALE and FAILS. + st, msg = runMaintenanceVerbIn(oldDir, "git-submodules", map[string]any{ + "mode": "currency", "stale_after": bound, + }) + if st != spec.StatusFail { + t.Fatalf("a pin neglected for longer than the bound must fail; got %s: %s", st, msg) + } + for _, want := range []string{ + "1 STALE pin(s) (neglected for more than 168h)", + "src: behind, and this repo has not advanced it for 240h", + "(bound 168h)", + "FAILED on the STALE pin(s) above", + } { + if !strings.Contains(msg, want) { + t.Fatalf("the stale verdict must name the pin, its age and the bound; missing %q in:\n%s", want, msg) + } + } + + // Arm 3 — THE DIFFERENTIAL. The same tree, the same behind pin, the same bound, and the + // ONLY difference is when this repo last advanced it: an advance a minute ago is + // CHURN-IN-PROGRESS, not neglect, so the run PASSES. A control that failed here would + // be red on a healthy tree, which is the failure this knob exists to avoid. + freshDir, _, _ := curStale(t, tmp, "fresh", true, time.Minute) + st, msg = runMaintenanceVerbIn(freshDir, "git-submodules", map[string]any{ + "mode": "currency", "stale_after": bound, + }) + if st != spec.StatusPass { + t.Fatalf("a pin advanced a minute ago is churn, not neglect; got %s: %s", st, msg) + } + if strings.Contains(msg, "STALE") { + t.Fatalf("a recently advanced pin must not be stale; got:\n%s", msg) + } + if !strings.Contains(msg, "1 of 2 pin(s) NOT current") { + t.Fatalf("the not-current count must still be reported; got:\n%s", msg) + } + + // Arm 4 — the IDLE-UPSTREAM trap: a pin that is CURRENT but was advanced a long time + // ago. An upstream that has not moved is not neglected, and the bound must not fire on + // it — otherwise every quiet repo in the org goes red on its own silence. + idleDir, _, _ := curStale(t, tmp, "idle", false, 240*time.Hour) + st, msg = runMaintenanceVerbIn(idleDir, "git-submodules", map[string]any{ + "mode": "currency", "stale_after": bound, + }) + if st != spec.StatusPass { + t.Fatalf("a current pin is never stale, however old its last advance; got %s: %s", st, msg) + } + if strings.Contains(msg, "STALE") { + t.Fatalf("a current pin must not be reported stale; got:\n%s", msg) + } + + // Arm 5 — a malformed bound is a loud failure, never a silently disabled control. + st, msg = runMaintenanceVerbIn(oldDir, "git-submodules", map[string]any{ + "mode": "currency", "stale_after": "7d", + }) + if st != spec.StatusFail || !strings.Contains(msg, "not a duration") { + t.Fatalf("a malformed bound must fail loud; got %s: %s", st, msg) + } +} diff --git a/candy/plugin-task/params/cue_types_gen.go b/candy/plugin-task/params/cue_types_gen.go index 865f3e2..51251b2 100644 --- a/candy/plugin-task/params/cue_types_gen.go +++ b/candy/plugin-task/params/cue_types_gen.go @@ -46,6 +46,19 @@ type GitSubmodulesInput struct { // skip lists submodule paths the verb must not touch. Skip []string `yaml:"skip,omitempty" json:"skip,omitempty"` + + // stale_after is the OWNER's bound on its OWN bookkeeping, and it is opt-in: unset + // => the knob is off and the currency verdict is the structural one alone. + // + // It is a duration ("168h"). A pin that is not current AND that this repo has not + // advanced for LONGER than this bound is STALE, and a stale pin FAILS. + // + // The bound is on the AGE of this repo's last advance of that path — NOT on the + // pin's distance from the ref it names. The distance is CHURN: a busy upstream is + // behind between every wave, so a gate that failed on the distance would be + // permanently red on a healthy tree, and a permanently red gate trains every reader + // to ignore it. The age is NEGLECT, and neglect is the defect this bounds. + StaleAfter string `yaml:"stale_after,omitempty" json:"stale_after,omitempty"` } // #FileParityInput asserts (or syncs) that paired files are byte-identical. diff --git a/candy/plugin-task/schema/task.cue b/candy/plugin-task/schema/task.cue index 03e5d3b..d7bd8fc 100644 --- a/candy/plugin-task/schema/task.cue +++ b/candy/plugin-task/schema/task.cue @@ -55,6 +55,18 @@ intent?: {[string]: string} @go(Intent) // skip lists submodule paths the verb must not touch. skip?: [...string] + // stale_after is the OWNER's bound on its OWN bookkeeping, and it is opt-in: unset + // => the knob is off and the currency verdict is the structural one alone. + // + // It is a duration ("168h"). A pin that is not current AND that this repo has not + // advanced for LONGER than this bound is STALE, and a stale pin FAILS. + // + // The bound is on the AGE of this repo's last advance of that path — NOT on the + // pin's distance from the ref it names. The distance is CHURN: a busy upstream is + // behind between every wave, so a gate that failed on the distance would be + // permanently red on a healthy tree, and a permanently red gate trains every reader + // to ignore it. The age is NEGLECT, and neglect is the defect this bounds. + stale_after?: string @go(StaleAfter) } // #FileParityInput asserts (or syncs) that paired files are byte-identical. diff --git a/candy/plugin-task/verbs.go b/candy/plugin-task/verbs.go index 8035ee3..5833bff 100644 --- a/candy/plugin-task/verbs.go +++ b/candy/plugin-task/verbs.go @@ -10,7 +10,9 @@ import ( "path/filepath" "regexp" "sort" + "strconv" "strings" + "time" "github.com/opencharly/plugin-task/candy/plugin-task/params" pb "github.com/opencharly/spec/proto" @@ -859,6 +861,25 @@ func runGitSubmodules(projDir string, in params.GitSubmodulesInput) (spec.Status // on them would be permanently red — which trains every reader to ignore it, the // mirror problem inverted. So the Message LEADS with the number of pins that are // not current, and a green run can never read as "everything is fine". + // + // The owner's own bound on its OWN bookkeeping, opt-in (`stale_after`). It answers the + // question the currency report cannot: a pin that is not current is CHURN (a busy + // upstream is behind between every wave), but a pin that is not current AND that this + // repo has not advanced for longer than the bound is NEGLECT. The bound is on the AGE + // of this repo's last advance of that path — NOT on the pin's distance from the ref it + // names. A gate on the DISTANCE would be permanently red on a healthy tree, which + // trains every reader to ignore it; the AGE is what neglect looks like, and it is + // what a healthy tree keeps small. + var staleAfter time.Duration + staleBoundSet := false + if raw := strings.TrimSpace(in.StaleAfter); raw != "" { + d, err := time.ParseDuration(raw) + if err != nil { + return spec.StatusFail, fmt.Sprintf( + "git-submodules: stale_after %q is not a duration this verb can parse (e.g. \"168h\")", raw) + } + staleAfter, staleBoundSet = d, true + } type pin struct { path, intent, recorded, served, which, state string } @@ -935,6 +956,56 @@ func runGitSubmodules(projDir string, in params.GitSubmodulesInput) (spec.Status return strings.TrimSpace(ref) } + // durText renders a bound or an age in the OWNER's own units: whole hours as "168h", + // so the report echoes the bound the owner wrote rather than a Go duration literal. + durText := func(d time.Duration) string { + if d%time.Hour == 0 { + return fmt.Sprintf("%dh", int(d/time.Hour)) + } + return d.String() + } + + // staleEligible names the states that mean "THIS repo's own pin does not hold the + // value its intent names" — the only states whose neglect is THIS repo's business. + // An EDGE that is `blocked-by` is NOT eligible: the producer's neglect is the + // producer's business, and failing here over another repo's PR would be exactly the + // permanently-red gate the org's pin rule forbids. + staleEligible := func(state string) bool { + switch { + case state == "behind", state == "not-on-the-served-line": + return true + case strings.HasPrefix(state, "differs"): + // The recorded value differs from the served one and no checkout was + // available to classify the difference. It is still NOT CURRENT. + return true + } + return false + } + + // lastAdvancedAt reads the AGE of this repo's OWN bookkeeping for a pin: the + // timestamp of the last commit that touched the pin's path. This is the owner's own + // record of when it last advanced the pin — never the pin's distance from the served + // ref, which is churn. An ABSENT record (the path is in no commit of this repo's + // history) is reported as "never advanced here" rather than as a fresh pin, because + // a path with no bookkeeping has not been advanced for as long as the repo has + // existed. + lastAdvancedAt := func(p string) (time.Time, bool) { + out, _, exit := hostCapture(context.Background(), projDir, + fmt.Sprintf("git log -1 --format=%%ct -- %s", shellQuote(p))) + if exit != 0 { + return time.Time{}, false + } + ts := strings.TrimSpace(out) + if ts == "" { + return time.Time{}, false + } + secs, err := strconv.ParseInt(ts, 10, 64) + if err != nil { + return time.Time{}, false + } + return time.Unix(secs, 0), true + } + // THE GRAPH, classified once for EVERY path, then the depth rule walked over EVERY // edge source. This replaces a check that read `pinned_from` and therefore guarded one // of the two documented edge shapes: with `pin_map: {d: inner}` and @@ -1137,6 +1208,33 @@ func runGitSubmodules(projDir string, in params.GitSubmodulesInput) (spec.Status // guard is what retired it. Recording it anyway is the write-only state R2 deletes. } + // The NEGLECT judgement (opt-in). A pin that is not current AND that this repo has + // not advanced for longer than the owner's bound is STALE, and it FAILS. The bound + // is on this repo's OWN bookkeeping age, so a healthy tree stays green while a + // neglected one goes red — the two regimes the bound is calibrated against. + var stale []string + if staleBoundSet { + now := time.Now() + for _, r := range rows { + if !r.leaf || !staleEligible(r.state) { + continue + } + at, known := lastAdvancedAt(r.path) + if !known { + stale = append(stale, fmt.Sprintf( + "%s: %s, and this repo's own history records no advance of it — it has never been advanced here", + r.path, r.state)) + continue + } + age := now.Sub(at) + if age > staleAfter { + stale = append(stale, fmt.Sprintf( + "%s: %s, and this repo has not advanced it for %s (bound %s) — advance it: mode=bump advances every pin to the ref its intent names", + r.path, r.state, durText(age.Round(time.Hour)), durText(staleAfter))) + } + } + } + // Render: the count of pins that are NOT current leads, because a green run must // never be readable as "everything is fine". byState := map[string]int{} @@ -1209,11 +1307,19 @@ func runGitSubmodules(projDir string, in params.GitSubmodulesInput) (spec.Status if len(detail) > 0 { head += "; NOT CURRENT: " + strings.Join(detail, "; ") } + if len(stale) > 0 { + head += fmt.Sprintf("; %d STALE pin(s) (neglected for more than %s): %s", + len(stale), durText(staleAfter), strings.Join(stale, "; ")) + } if len(structural) > 0 { sort.Strings(structural) return spec.StatusFail, fmt.Sprintf("%s; %d STRUCTURAL failure(s): %s; producer-first legs: %s", head, len(structural), strings.Join(structural, "; "), legText) } + if len(stale) > 0 { + return spec.StatusFail, fmt.Sprintf("%s; FAILED on the STALE pin(s) above; producer-first legs: %s", + head, legText) + } return spec.StatusPass, fmt.Sprintf("%s; producer-first legs: %s", head, legText) default: diff --git a/scripts/gate-task.sh b/scripts/gate-task.sh index c63935d..3c40ef4 100755 --- a/scripts/gate-task.sh +++ b/scripts/gate-task.sh @@ -507,6 +507,71 @@ fi echo "$exp_out" | grep -q 'graph depth > 1' || { echo "FAIL: the refusal must name the depth; got: $exp_out" >&2; exit 1; } echo "$exp_out" | grep -q 'which is ITSELF an edge' || { echo "FAIL: the refusal must name the source's shape; got: $exp_out" >&2; exit 1; } +# --------------------------------------------------------------------------- +# 2b) verb:git-submodules currency — the STALENESS bound. +# +# The bound is on THIS repo's OWN advance cadence — the AGE of the last commit +# that touched the pin — and never on the pin's distance from the served ref. +# MEASURED on charly's own six box/* twins: each is behind its served ref about +# 75% of wall-clock time, with continuous behind-windows of 8 and 10 days, while +# the healthy maximum gap between advances was 5 days. A gate on the DISTANCE +# would therefore be red on a healthy tree, which trains every reader to ignore +# it. The AGE is what neglect looks like. +# +# The two fixtures below are the SAME tree with the SAME behind pin and the SAME +# bound; they differ ONLY in the date of the commit that recorded the pin, and +# the verdict flips with it. That difference is the whole point of the knob. +# --------------------------------------------------------------------------- +. "$FIX/shas" +stale_fixture() { + local dir="$1" stamp="$2" + mkdir -p "$dir" + ( + cd "$dir" && git init -q -b main + echo u > u.txt && git add -A && G commit -qm u + printf '[submodule "gap"]\n\tpath = gap\n\turl = %s\n\tbranch = main\n' "$FIX/subsrc" > .gitmodules + git add .gitmodules && G commit -qm gm + git update-index --add --cacheinfo "160000,$C1,gap" + GIT_AUTHOR_DATE="$stamp" GIT_COMMITTER_DATE="$stamp" G commit -qm "pin gap" + # Materialize the checkout so the pin is judged against the served ref (behind), + # not merely "differs (no checkout to judge ancestry)". + git -c protocol.file.allow=always submodule update --init -q gap + ) + cat > "$dir/charly.yml" <<'YML' +currency-stale: + task: + description: ask whether every pin is current, with a bound on this repo's own neglect + dir: . + plan: + - check: no pin is neglected past the bound + git-submodules: {mode: currency, stale_after: "168h"} + context: [runtime] +YML +} + +echo "== verb:git-submodules currency (stale_after: a pin NEGLECTED past the bound must FAIL) ==" +stale_fixture "$WORK/stale-old" "2020-01-01T00:00:00Z" +if stale_out="$(cd "$WORK/stale-old" && "$CH" task currency-stale 2>&1)"; then + echo "FAIL: a pin neglected for years must fail the bound" >&2; exit 1 +fi +printf '%s\n' "$stale_out" | grep -q 'STALE pin(s)' || { + echo "FAIL: the verdict must name the stale pin(s); got: $stale_out" >&2; exit 1; } +printf '%s\n' "$stale_out" | grep -q 'has not advanced it for' || { + echo "FAIL: the verdict must name the age; got: $stale_out" >&2; exit 1; } +printf '%s\n' "$stale_out" | grep -q '(bound 168h)' || { + echo "FAIL: the verdict must name the bound; got: $stale_out" >&2; exit 1; } +printf '%s\n' "$stale_out" | sed 's/^/ | /' + +echo "== verb:git-submodules currency (stale_after: the SAME pin, advanced recently, must PASS) ==" +stale_fixture "$WORK/stale-fresh" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" +fresh_out="$(cd "$WORK/stale-fresh" && "$CH" task currency-stale 2>&1)" || { + echo "FAIL: a pin advanced just now is churn, not neglect" >&2; printf '%s\n' "$fresh_out" >&2; exit 1; } +printf '%s\n' "$fresh_out" | grep -q '0 failed' || { + echo "FAIL: the run must pass; got: $fresh_out" >&2; exit 1; } +printf '%s\n' "$fresh_out" | grep -q '1 of 1 pin(s) NOT current' || { + echo "FAIL: the not-current count must still be reported; got: $fresh_out" >&2; exit 1; } +echo " the same behind pin, advanced recently, passed the bound" + echo "== verb:file-parity (identical must PASS, drift must FAIL) ==" par_out="$("$CH" task parity)"; echo "$par_out" | grep -q '0 failed' || { echo "FAIL: identical pair must pass" >&2; exit 1; } echo drifted >> r.copy