Skip to content

fix(git-submodules): refuse a non-positive stale_after, and never report an unreadable history as neglect - #44

Merged
github-actions[bot] merged 2 commits into
mainfrom
feat/pin-staleness-hardening
Oct 23, 2026
Merged

github-actions[bot] merged 2 commits into
mainfrom
feat/pin-staleness-hardening

Conversation

@aitrawog

@aitrawog aitrawog commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Summary

verb:git-submodules in mode: currency gained the opt-in stale_after knob in
#43 (merged as 131b7df, tagged v2026.296.2124). The validator's
review of that PR carried two non-blocking observations. R1 is explicit that a finding
raised on your own PR is fixed, or rebutted on the thread with evidence — never filed away.
Both are fixed here, in a follow-up PR, because #43 is merged and tagged and its branch can
no longer carry a fix.

Head 51f89a1af81694ead6d834b85b6e4a8bd5978a11, on feat/pin-staleness-hardening, branched off origin/main at
c908275. This is the successor of #43 and carries the same scope
(#42); the consumer leg is opencharly/charly#1048.

The validator's BLOCK on this PR is also fixed here, not filed away. The BLOCK was a
defect in the evidence this PR offered, not in the code: the two differential pastes were
selected from their logs by a key that several arms share, so the paste carried the wrong
arm's output. The root fix went into the control that produced the paste, not only into the
body — see "The evidence control, fixed" below and the RCA posted on this thread.

Finding 1 — a non-positive bound was honoured instead of refused

Expected. A bound of zero or less is not a bound: it marks EVERY not-current pin stale,
which is the permanently-red gate this knob exists to avoid.

Actual. stale_after: "0s" parsed and was applied. The live gate reports
1 STALE pin(s) (neglected for more than 0h) — the permanently-red verdict, reached
through the operator's own hand.

Mechanism. The parse block rejected only text time.ParseDuration could not read. Any
duration it could read was accepted, and the positivity of the bound was never tested.

Missed control. The knob's contract is enforced at the reading side (the age is
compared against the bound) and never at the bound side (whether the bound is a bound at
all). Every arm in the unit test and in the live gate exercised a positive bound, so no arm
could reach the gap.

Blast radius. Only a repo that declared a zero or negative bound. The knob is opt-in and
no repo declares it yet, so nothing in the org is affected today.

Root cause. The value was validated as a duration but not as a bound.

Root fix. A non-positive duration is refused in the verb — the canonical place — with a
message that names the reason. Covered by a unit arm (three values: 0s, 0, -1h) and by
a live arm that asserts both the refusal and the absence of any staleness judgement.

Finding 2 — a failed history read was reported as neglect

Expected. A pin whose age cannot be measured is not reported as neglected. An unknown
age is not a claim that the owner neglected the pin.

Actual. An unreadable history produced it has never been advanced here and a STALE
verdict — a claim about the owner's conduct, backed by no evidence.

Mechanism. lastAdvancedAt returned (time.Time{}, false) for every failure mode of
git log, and the caller read the single false as "never advanced". But two different
things arrive at that same exit status: a repo with no history (the path is in no commit
— a FACT, and it is neglected) and a repo whose history cannot be read (a broken object
store, a transient git failure — the age is UNKNOWN).

Missed control. git reports those two conditions identically, and nothing in the verb
told them apart. Measured before choosing a discriminator: git log exits 128 for an unborn
HEAD ("does not have any commits yet") and 128 for a broken object store ("fatal: bad object
HEAD"); git rev-parse --verify -q HEAD — the first discriminator reached for — exits 1 for
BOTH an unborn HEAD and a broken HEAD ref, so it cannot separate them either. The
discriminator that works is git rev-list --all --count: zero commits means there is nothing
here that could have been advanced (the fact), a count that cannot be produced means the
history cannot be read (the failure).

Blast radius. Any repo whose object store is temporarily unreadable. The verdict would
name a pin STALE on no evidence, and the owner would be told to do work that is not the
cause.

Root cause. One absence — no age — carried two meanings, and one of them was a claim the
evidence did not support.

Root fix. The read now has three outcomes, and only one of them is a claim about the pin:
a measured age, no-history (the path is in no commit of this repo's history, so it
has never been advanced here — and it IS neglected), and read-failed (the age could not
be measured — the workflow being unable to JUDGE the pin, which is a structural-class
failure naming the read error). The read-failure case is routed to the structural class
precisely so that a transient git error can never manufacture a stale verdict. Covered by a
unit arm (a removed HEAD commit object) and by a live arm that asserts the verdict names the
read failure and carries no STALE pin.

How tested

Every command below was run on the committed tree above, in the session worktree
opencharly/plugin-task, with the working tree clean (git status --short prints nothing).

The diff itself:

$ git rev-parse HEAD
51f89a1af81694ead6d834b85b6e4a8bd5978a11
$ git diff --stat origin/main...HEAD
 README.md                          |  8 +++++
 candy/plugin-task/currency_test.go | 69 ++++++++++++++++++++++++++++++++++++++
 candy/plugin-task/verbs.go         | 67 +++++++++++++++++++++++++++++-------
 scripts/gate-task.sh               | 66 ++++++++++++++++++++++++++++++++----
 4 files changed, 192 insertions(+), 18 deletions(-)

The Go gates, the repo's own CI set:

$ cd candy/plugin-task && gofmt -l .
$ cd candy/plugin-task && go build ./...
build: OK
$ cd candy/plugin-task && go vet ./...
vet: OK
$ cd candy/plugin-task && go test -count=1 ./...
ok  	github.com/opencharly/plugin-task/candy/plugin-task	4.432s
?   	github.com/opencharly/plugin-task/candy/plugin-task/cmd/serve	[no test files]
?   	github.com/opencharly/plugin-task/candy/plugin-task/params	[no test files]

The configured linter set, under the toolchain candy/plugin-task/go.mod declares
(go 1.26.4) — the same toolchain CI selects through go-version-file (the linter is
not on this host's PATH; that condition is recorded under ## Filed issues):

$ GOTOOLCHAIN=go1.26.4 /home/atrawog/go/bin/golangci-lint run ./...
0 issues.

The structural check over the repo's own manifest, with the worktree-local charly build
(R9 — not a shared install):

$ /home/atrawog/Sync/Atrapub/a/claude/opencharly/.worktrees/light-init/charly/bin/charly box validate
charly box validate: OK — checked 1 candy, 7 distros, 7 builders; 0 warnings, 0 errors

The evidence control, fixed

The previous round of this PR pasted its differential evidence by a key that several arms
share — the recorded SHA of the pin under test. Every stale fixture in one run pins the
same twin commit, so that SHA appears in more than one arm's verdict, and the extraction
returned the wrong arm's line. The fix is in the control, not only in the body:

  • Each stale arm now has its own fixture (so its own pin and its own HEAD), its own
    task name
    and its own bound. The task name appears in the run's own output, so an
    arm's evidence block names the arm that produced it.
  • The unreadable arm now demonstrates its own brokenness: it asserts that the very read
    the verb performs — git log -1 --format=%ct -- <path> — fails on the unreadable
    fixture and succeeds on the healthy fixture, and it fails loudly if either does not
    hold. The arm can no longer pass for the wrong reason.
  • Each arm's captured output is printed with the gate's own | convention, so the
    pasted block is the arm's own output, under the arm's own header.
  • The evidence below is extracted from the logs by the arm's header, not by a SHA and
    not by hand.

The live gate — this repo's own proof that the verbs work against a real charly
binary and real git fixtures. It is the ONLY live execution of these verbs, and it runs
every one of the five maintenance verbs in the same run:

$ bash scripts/gate-task.sh
== fetching charly main (compiles candy/plugin-task in) ==
Cloning into '/tmp/plugin-task-gate.Qxi5E2/charly'...
coherence OK: charly main's sdk/spec pins lead this module's requires
== building charly with candy/plugin-task compiled in from the working tree ==
== charly task list ==
task failtask: 1 step(s), 1 failed
  [fail] run — this fails
      exit=1, want 0 (stderr: )
charly: error: command "task": task "failtask" failed (1 step(s) failed)
== verb:task nested hop (a composite must carry the leaf step's own reason) ==
   | task composite: 1 step(s), 1 failed
   |   [fail] run — the outer step (a nested task step)
   |       task "leafreason" failed (1 step(s) failed
   |         [fail] run — the leaf step (its Message is the reason)
   |             exit=3, want 0 (stderr: leaf-reason: boom on stderr))
   | charly: error: command "task": task "composite" failed (1 step(s) failed)
   the composite carried the leaf step's reason AND its path across the hop
== verb:git-submodules verify (stale -> must FAIL) ==
task pins-verify: 1 step(s), 1 failed
  [fail] check — the pin holds
      verify: 1 failure(s): pin mismatch: target (5b2b0e5775508bb548732709f00b236ba240d99d) != ../src's target (ae719a64cd8ecfdc773d49cca59d5cec538f0dea)
charly: error: command "task": task "pins-verify" failed (1 step(s) failed)
== verb:git-submodules bump (must move the pin to src's twin) ==
== verb:git-submodules verify (now current -> must PASS) ==
== verb:task nested hop (a composite must carry the nested run's OWN report) ==
   | task pins-verify-nested: 1 step(s), 0 failed
   |   [pass] run — the outer step (a nested task step)
   |       verify: 1 policy pin(s) hold across 1 submodule(s); 1 checkout(s) clean at their gitlink
   the composite carried the nested pin report across the hop
== verb:git-submodules verify (missing twin -> must FAIL loudly, never vacuous) ==
task missing-pin: 1 step(s), 1 failed
  [fail] check — the missing twin fails
      verify: 1 failure(s): no gitlink for "nope/absent" in "../src"
charly: error: command "task": task "missing-pin" failed (1 step(s) failed)
== verb:git-submodules bump (unmaterialized submodule -> the pin advances IN THE INDEX, no checkout) ==
   | task bump-all: 1 step(s), 0 failed
   |   [pass] run — bump
   |       pinned 0 pin_map entr(y|ies) () after rolling 0 submodule(s) to their default-branch HEAD and advancing 1 pin(s) in the index
   unmaterialized pin advanced in the index (5b2b0e5775508bb548732709f00b236ba240d99d -> ae719a64c); no checkout created; umbrella untouched (main @ c8e027529)
== verb:git-submodules status (uninitialized submodule -> '- (uninitialized)', never the umbrella HEAD) ==
   status reported '- (uninitialized)'; umbrella HEAD (c8e0275) not leaked as a pin
== verb:git-submodules bump (pinned_from with NO CHECKOUT and no 'branch=' -> the refusal names the missing checkout) ==
   | task bump-from-source: 1 step(s), 1 failed
   |   [fail] run — bump
   |       1 pin(s) FAILED: refused to pin 1 pin_map entr(y|ies): pinned_from "src" has no checkout, so its twin gitlinks cannot be read — materialize it with `git submodule update --init src`; rolled 0 submodule(s) to their default-branch HEAD; advanced 0 pin(s) in the index; pinned 0 pin_map entr(y|ies): 
   | charly: error: command "task": task "bump-from-source" failed (1 step(s) failed)
   the refusal named the missing checkout and the materialize command; the stale diagnosis is gone
== verb:git-submodules bump ORDER (pinned_from is itself rolled first) ==
== verb:git-submodules currency (after the ordered bump every pin must be CURRENT) ==
== verb:git-submodules currency (an edge over an edge must be REFUSED, never answered) ==
== verb:git-submodules currency (an EXPLICIT edge over an edge must be REFUSED too) ==
== verb:git-submodules currency (stale_after: a pin NEGLECTED past the bound must FAIL) ==
   | task currency-stale: 1 step(s), 1 failed
   |   [fail] check — no pin is neglected past the bound
   |       currency: 1 of 1 pin(s) NOT current [1 behind]; intents: 1 upstream leaf/leaves (1 implicit), 0 derived-from edge(s); NOT CURRENT: gap: behind; intent upstream:main (implicit: .gitmodules branch=); recorded 5b2b0e5775; served ae719a64cd (branch); 1 STALE pin(s) (neglected for more than 168h): gap: behind, and this repo has not advanced it for 59710h (bound 168h) — advance it: mode=bump advances every pin to the ref its intent names; FAILED on the STALE pin(s) above; producer-first legs: gap (5b2b0e5775 -> ae719a64cd)
   | charly: error: command "task": task "currency-stale" failed (1 step(s) failed)
== verb:git-submodules currency (stale_after: the SAME pin, advanced recently, must PASS) ==
   the same behind pin, advanced recently, passed the bound
== verb:git-submodules currency (stale_after: a NON-POSITIVE bound must be REFUSED) ==
   | task currency-nonpositive: 1 step(s), 1 failed
   |   [fail] check — no pin is neglected past the bound
   |       git-submodules: stale_after "0s" is not a POSITIVE duration — a bound of zero or less marks every not-current pin stale, which is the permanently-red gate this bound exists to avoid
   | charly: error: command "task": task "currency-nonpositive" failed (1 step(s) failed)
   a bound of zero was refused, and no staleness was judged
== verb:git-submodules currency (an UNREADABLE history is not neglect) ==
   the fixture's history read genuinely fails, and the healthy fixture's succeeds
   | task currency-unreadable: 1 step(s), 1 failed
   |   [fail] check — no pin is neglected past the bound
   |       currency: 1 of 1 pin(s) NOT current [1 behind]; intents: 1 upstream leaf/leaves (1 implicit), 0 derived-from edge(s); NOT CURRENT: gap: behind; intent upstream:main (implicit: .gitmodules branch=); recorded 5b2b0e5775; served ae719a64cd (branch); 1 STRUCTURAL failure(s): gap: the age of this repo's own last advance of it could not be read (fatal: bad object refs/heads/main) — the staleness of this pin cannot be judged; producer-first legs: gap (5b2b0e5775 -> ae719a64cd)
   | charly: error: command "task": task "currency-unreadable" failed (1 step(s) failed)
   an unreadable history was reported as unreadable, not as neglect
== verb:file-parity (identical must PASS, drift must FAIL) ==
task parity: 1 step(s), 1 failed
  [fail] check — the pair is identical
      parity drift in 1 pair(s): r != r.copy
charly: error: command "task": task "parity" failed (1 step(s) failed)
== verb:splice-region (stale -> check must FAIL, sync -> current) ==
task splice-check: 1 step(s), 1 failed
  [fail] check — the region is current
      splice-region: TARGET is stale (the marked region differs)
charly: error: command "task": task "splice-check" failed (1 step(s) failed)
== verb:module-pins (stale -> must FAIL, adopt -> must PASS) ==
task mods-check: 1 step(s), 1 failed
  [fail] check — the pins hold
      stale pins: tools/m1: github.com/opencharly/sdk != v1.2.3
charly: error: command "task": task "mods-check" failed (1 step(s) failed)
== verb:prune (removes merged worktree+branch, keeps unmerged) ==
== verb:prune (uninitialized submodule -> the SUPERPROJECT is never attributed to it) ==
   uninitialized submodule contributed nothing; the project's own targets still reported
== verb:prune mode=prune with an uninitialized submodule present (must complete cleanly) ==
   mode=prune completed cleanly; unmerged kept, merged reaped, uninitialized dir intact
gate-task: PASS — command:task + all five maintenance verbs executed live against charly main

The differential proof (R7). Each fix was reverted in place, in the working tree, and
the arm was re-run against the same fixtures; the file was then restored byte-for-byte
from the committed copy. The arms discriminate — each one goes red with its own fix
reverted. Each block below is that arm's own output, extracted by its own header.

Fix 1 reverted (fix 2 in place), so the run reaches the currency-nonpositive arm and
stops there:

$ bash scripts/gate-task.sh
== verb:git-submodules currency (stale_after: a NON-POSITIVE bound must be REFUSED) ==
   | task currency-nonpositive: 1 step(s), 1 failed
   |   [fail] check — no pin is neglected past the bound
   |       currency: 1 of 1 pin(s) NOT current [1 behind]; intents: 1 upstream leaf/leaves (1 implicit), 0 derived-from edge(s); NOT CURRENT: gap: behind; intent upstream:main (implicit: .gitmodules branch=); recorded 4e2689187a; served 2820e1febe (branch); 1 STALE pin(s) (neglected for more than 0h): gap: behind, and this repo has not advanced it for 59710h (bound 0h) — advance it: mode=bump advances every pin to the ref its intent names; FAILED on the STALE pin(s) above; producer-first legs: gap (4e2689187a -> 2820e1febe)
   | charly: error: command "task": task "currency-nonpositive" failed (1 step(s) failed)
FAIL: the refusal must name the reason; got: task currency-nonpositive: 1 step(s), 1 failed
  [fail] check — no pin is neglected past the bound
      currency: 1 of 1 pin(s) NOT current [1 behind]; intents: 1 upstream leaf/leaves (1 implicit), 0 derived-from edge(s); NOT CURRENT: gap: behind; intent upstream:main (implicit: .gitmodules branch=); recorded 4e2689187a; served 2820e1febe (branch); 1 STALE pin(s) (neglected for more than 0h): gap: behind, and this repo has not advanced it for 59710h (bound 0h) — advance it: mode=bump advances every pin to the ref its intent names; FAILED on the STALE pin(s) above; producer-first legs: gap (4e2689187a -> 2820e1febe)
charly: error: command "task": task "currency-nonpositive" failed (1 step(s) failed)

The arm that must REFUSE the bound instead reports the permanently-red verdict it exists
to prevent — neglected for more than 0h, bound 0h — on the currency-nonpositive
task, and the gate's assertion fails on it. That is the discrimination the previous round
failed to show.

Fix 2 reverted (fix 1 in place), so the run reaches the currency-unreadable arm:

$ bash scripts/gate-task.sh
== verb:git-submodules currency (an UNREADABLE history is not neglect) ==
   the fixture's history read genuinely fails, and the healthy fixture's succeeds
   | task currency-unreadable: 1 step(s), 1 failed
   |   [fail] check — no pin is neglected past the bound
   |       currency: 1 of 1 pin(s) NOT current [1 behind]; intents: 1 upstream leaf/leaves (1 implicit), 0 derived-from edge(s); NOT CURRENT: gap: behind; intent upstream:main (implicit: .gitmodules branch=); recorded 792b8695ae; served 04eee0d3d8 (branch); 1 STALE pin(s) (neglected for more than 168h): gap: behind, and this repo's own history records no advance of it — it has never been advanced here; FAILED on the STALE pin(s) above; producer-first legs: gap (792b8695ae -> 04eee0d3d8)
   | charly: error: command "task": task "currency-unreadable" failed (1 step(s) failed)
FAIL: the verdict must name the read failure; got: task currency-unreadable: 1 step(s), 1 failed
  [fail] check — no pin is neglected past the bound
      currency: 1 of 1 pin(s) NOT current [1 behind]; intents: 1 upstream leaf/leaves (1 implicit), 0 derived-from edge(s); NOT CURRENT: gap: behind; intent upstream:main (implicit: .gitmodules branch=); recorded 792b8695ae; served 04eee0d3d8 (branch); 1 STALE pin(s) (neglected for more than 168h): gap: behind, and this repo's own history records no advance of it — it has never been advanced here; FAILED on the STALE pin(s) above; producer-first legs: gap (792b8695ae -> 04eee0d3d8)
charly: error: command "task": task "currency-unreadable" failed (1 step(s) failed)

The arm that must report an unreadable history as unreadable instead reports it as
neglect — the false claim this fix removes. The same difference in the unit test, with the
same fix reverted:

$ go test -run TestGitSubmodules_CurrencyStalenessBoundsNeglect ./...
--- FAIL: TestGitSubmodules_CurrencyStalenessBoundsNeglect (0.18s)
    currency_test.go:552: an unreadable history must never be reported as neglect; got:
        currency: 1 of 2 pin(s) NOT current [1 behind, 1 cur]; intents: 2 upstream leaf/leaves (2 implicit), 0 derived-from edge(s); NOT CURRENT: src: behind; intent upstream:main (implicit: .gitmodules branch=); recorded fd1dba9962; served 266b55ad58 (branch); 1 STALE pin(s) (neglected for more than 168h): src: behind, and this repo's own history records no advance of it — it has never been advanced here; FAILED on the STALE pin(s) above; producer-first legs: src (fd1dba9962 -> 266b55ad58)
FAIL
FAIL	github.com/opencharly/plugin-task/candy/plugin-task	0.182s
?   	github.com/opencharly/plugin-task/candy/plugin-task/cmd/serve	[no test files]
?   	github.com/opencharly/plugin-task/candy/plugin-task/params	[no test files]
FAIL

Both live blocks and the unit block now say the same thing about the same fixture: with
fix 2 reverted the age is not measured, and the verdict is a stale claim.

The firstLine helper is pre-existing (the validator's secondary concern). It is not
added by this diff:

$ grep -rn "func firstLine" --include="*.go" .
candy/plugin-task/cli.go:470:func firstLine(s string) string {

and its callers — the two cli.go sites and the comment above them are pre-existing, the
two verbs.go sites are this change's:

$ grep -rn "func firstLine" --include="*.go" .
candy/plugin-task/cli.go:470:func firstLine(s string) string {
$ grep -rn "firstLine(" --include="*.go" . | grep -v "func firstLine"
candy/plugin-task/cli.go:246:// print only `firstLine(Text)`, so a passing step's output was discarded and a
candy/plugin-task/cli.go:253:	line := fmt.Sprintf("  [%s] %s — %s", s.Result.Status.String(), s.Keyword, firstLine(s.Text))
candy/plugin-task/cli.go:447:		fmt.Fprintf(&b, "\n  [%s] %s — %s", s.Result.Status.String(), s.Keyword, firstLine(s.Text))
candy/plugin-task/verbs.go:1023:					return time.Time{}, "read-failed", firstLine(cErr)
candy/plugin-task/verbs.go:1029:				return time.Time{}, "read-failed", firstLine(errOut)

The sweep (R5). The old two-outcome helper is gone, replaced in place by the
three-outcome form; the "never advanced" claim now has exactly one producer, and it is
reachable only from the no-history case:

$ grep -rn "lastAdvancedAt" --include="*.go" .
candy/plugin-task/verbs.go:993:		// lastAdvancedAt reads the AGE of this repo's OWN bookkeeping for a pin: the
candy/plugin-task/verbs.go:1017:		lastAdvancedAt := func(p string) (at time.Time, how, detail string) {
candy/plugin-task/verbs.go:1255:				at, how, detail := lastAdvancedAt(r.path)
$ grep -rn "has never been advanced here" --include="*.go" .
candy/plugin-task/currency_test.go:577:	if !strings.Contains(msg, "has never been advanced here") {
candy/plugin-task/verbs.go:1268:						"%s: %s, and this repo's own history records no advance of it — it has never been advanced here",

The README's stale_after block now states both rules the code enforces — the bound must
be positive, and an unreadable history is never reported as neglect.

Rulebook compliance

  • R0 — skills loaded before the first tool action: /charly-internals:git-workflow
    (and its references/pre-validator-self-audit.md), /charly-internals:plugin,
    /charly-internals:root-cause-analyzer, and this repo's own AGENTS.md.
    /charly-image:layer is N/A — no candy entity field and no plan step changed; the diff
    is Go source, the README, and the gate script.
  • R1 — the two findings the validator raised on feat(git-submodules): bound NEGLECT in currency mode with an opt-in stale_after #43 are fixed here, and each carries a
    fresh RCA in this body (expected, actual, mechanism, missed control, blast radius, root
    cause, root fix). The validator's BLOCK on this PR was an anomaly, and it got its
    own fresh RCA: the mechanism, the missed control, the blast radius, the root cause and
    the root fix are in "The evidence control, fixed" above and on the thread. Every finding
    raised on this PR is fixed here — none is filed away.
  • R2 — no duplication: the bound's validation lives in the one canonical verb, and the
    read-failure split replaces the two-outcome helper in place rather than adding a second
    helper alongside it. The sweep above shows one definition site and one claim producer.
  • R3 — no workaround: the refusal is the product's behaviour, not a guard around a race
    or a suppressed failure; there is no sleep, no retry, and no ad-hoc shell.
  • R4 — product before prose: the README's stale_after block is updated in the same
    change, and it states the two rules the code now enforces.
  • R5 — the legacy two-outcome read is deleted in the same cut, with the claim-keyed
    sweep pasted above.
  • R6 — no force-push, no amend after a push, no hook bypass, and no direct push to
    main. The branch is fresh off origin/main at c908275; the pre-merge branch
    feat/pin-staleness is left untouched, and no other session's work was touched.
  • R7 — the coverage fails without the change: each fix was reverted in place and its
    arm was re-run against the same fixtures, with the arm's own output pasted above.
  • R8 — the live boundary runs live: the gate builds a real charly binary with this
    candy compiled in and drives it against real git fixtures, and no service is mocked.
  • R9 — the binary equals the source: the gate builds its own binary from the committed
    tree and the run above is that binary's output; charly box validate used the
    worktree-local opencharly/charly/charly/bin/charly, not a shared install.
  • R10 — a fresh, disposable proof: the gate clones charly main into a fresh
    temporary directory, builds a fresh binary there, and every fixture is created and
    destroyed inside it.

Change classification

Plugin library, runtime behaviour (a fix to an opt-in parameter). The change class's
gate is this repo's full bed — gofmt, go build, go vet, go test, golangci-lint,
charly box validate and scripts/gate-task.sh — all run on the committed tree above, with
zero warnings. The umbrella's R10 rule that a plugin-library change runs the full assembled
bed is satisfied by this repo's own assembled bed: the plugin is compiled into the binary
the live gate builds, and every one of the five maintenance verbs is executed live in that
same run.

Producer-first. This is the producer leg for opencharly/charly#1048: the verb ships
here, and the owner declares the bound and advances its twin in the consumer repo
afterwards.

Filed issues

Closes #42

Agent: charly

Assisted-by: Claude Code minimax/minimax-m3.1-flash:cloud (fully tested and validated)

…ort an unreadable history as neglect

Two findings the validator raised on #43 as non-blocking observations are fixed
here. Neither is filed away: a finding raised on your own PR is fixed, or
rebutted on the thread with evidence.

A bound of zero or less is not a bound. `stale_after: "0s"` parsed and was
honoured, marking EVERY not-current pin stale — the permanently-red gate the
knob exists to avoid, arrived at by the operator's own hand. A non-positive
bound is now refused as the operator error it is, and the refusal names why.

A failed history read is not neglect. `lastAdvancedAt` treated any `git log`
failure as "never advanced here" and flagged the pin stale, so a transient git
error could manufacture a false stale verdict — a claim the evidence does not
support. The read now has three outcomes: a measured age, "no history" (the
path is in no commit of this repo's history — a FACT, and it IS neglected),
and "read failed" (the age could not be measured — the workflow being unable
to JUDGE the pin, which is a structural-class failure naming the read error).
The two are told apart by COUNTING the repo's commits, because `git log`
refuses identically on an unborn HEAD and on a broken object store, and only
the first of those is the "never advanced" fact.

Proven differentially, in the unit test and in the live gate: with the
refusal reverted, `stale_after: "0s"` reports `1 STALE pin(s) ... (bound 0h)`;
with the read-failure split reverted, an unreadable history is reported as
`has never been advanced here` and as STALE. Both arms are green with both
fixes in.

Refs #42, #43

Assisted-by: Claude Code minimax/minimax-m3.1-flash:cloud (fully tested and validated)
@github-actions

Copy link
Copy Markdown

Review — BLOCK

What the diff does

The Go change is coherent and, on its own, looks correct:

  • candy/plugin-task/verbs.go adds a d <= 0 refusal for stale_after with a message naming the reason, and replaces the two-outcome lastAdvancedAt with a three-outcome form (ok / no-history / read-failed) that routes read failures to the structural class and never to stale.
  • candy/plugin-task/currency_test.go adds arms 6 (three non-positive values), 7 (removed HEAD object → read failure) and 8 (path in no commit → never advanced), and they plausibly exercise the new branches.
  • README.md states both new rules and matches the code.
  • scripts/gate-task.sh adds live arms for the refusal and the unreadable-history case.

The file count and diffstat in the body (4 files, 173 insertions, 12 deletions) reconcile exactly with the four diffs.

There are no comments to disposition (0 comment threads), so no maintainer sign-off exists and the change must stand on its own evidence.

Blocks

  1. scripts/gate-task.sh non-positive arm + body "Fix 1 reverted" differential — the pasted output cannot come from this task. The arm declares stale_after: "0s". The body's own Finding 1 "Actual" says the pre-fix behaviour is 1 STALE pin(s) (neglected for more than 0h), and with the fix reverted the verb sets staleAfter = 0, so the verdict must read bound 0h. The pasted differential instead reports (neglected for more than 168h) … (bound 168h) — the bound belonging to the unrelated currency-stale task. The claimed R7 proof for Fix 1 is therefore not the output of currency-nonpositive and does not demonstrate the fix. Required: re-run the differential against the actual currency-nonpositive task and paste the real output (refusal when fixed; bound 0h when reverted).

  2. Body "Fix 2 reverted" live differential contradicts both the body's own "Actual" and the unit differential. The body states the pre-fix behaviour for an unreadable history is it has never been advanced here, and the unit differential (block 2) indeed shows has never been advanced here. The live differential (block 3) instead shows a measured age — has not advanced it for 59710h (bound 168h) — which is only reachable when git log succeeds. But the fixed live arm is claimed to pass on a grep 'could not be read', which requires git log to fail on the same fixture. Both cannot be true: either the fixture's object removal is a no-op (making the live arm vacuous) or the pasted differential is wrong. Required: make the object removal demonstrably break the history read, re-run the live arm with and without Fix 2, and paste mutually consistent output.

Secondary concern (not independently blocking)

verbs.go calls firstLine(...) in the new code, but firstLine is not added anywhere in the diff. If it does not already exist in the package, the build fails despite the body's green build output. Verify it is a pre-existing helper (the body's go build/go vet/go test claims would otherwise be false).

Disposition

The code and unit arms are directionally sound, and the README is consistent with them. But the "How tested" section — the only evidence offered for the two fixes and for R7 — is internally contradictory in both differential proofs, so the change cannot stand on its own evidence as submitted.

Verdict: BLOCK


ollama-cloud/deepseek-v4.1-flash — action-review.

View action run

The validator's BLOCK on #44 was a defect in this PR's own EVIDENCE, not in
the code: the differential pastes were extracted from their logs by a key that
several arms share (the recorded SHA of the pin under test — every stale
fixture in one run pins the same twin commit), so the paste carried the wrong
arm's output. The root fix belongs in the control that produced the paste, not
only in the PR body.

* Each stale arm gets its OWN fixture (so its own pin and its own HEAD), its
  OWN task name and its OWN bound. The task name appears in the run's own
  output, so an arm's evidence block names the arm that produced it, and no two
  arms can produce the same text.
* The unreadable arm now DEMONSTRATES its own brokenness: it asserts that the
  very read the verb performs — `git log -1 --format=%ct -- <path>` — FAILS on
  the unreadable fixture and SUCCEEDS on the healthy fixture, and it fails
  loudly if either does not hold. The arm can no longer pass for the wrong
  reason (a no-op object removal would have left a readable history).
* Each arm's captured output is printed with the gate's own "   | " convention,
  so the pasted block is the arm's own output, under the arm's own header.

The verb, the unit test and the README are unchanged by this commit: the two
fixes under review are the same ones, proven by the same arms, which are now
self-identifying.

Assisted-by: Claude Code minimax/minimax-m3.1-flash:cloud (fully tested and validated)
@aitrawog

aitrawog commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor Author

(This comment was first posted as a bare path reference — gh api -f body=@file takes the value literally, so the body was the path and not the text. The content below is the real one; the correction is noted rather than quietly overwritten.)

STATUS — the validator's BLOCK is fixed in the control that produced it; the round's fix is committed, the body is rewritten, and the RCA is below.

Head. 51f89a1af81694ead6d834b85b6e4a8bd5978a11 on feat/pin-staleness-hardening in opencharly/plugin-task (successor of #43, merged as 131b7df / tagged v2026.296.2124). The PR is #44; the scope is #42; the consumer leg is opencharly/charly#1048.

Done this round. Each stale arm now has its own fixture, its own task name and its own bound, so every arm's output names the arm that produced it. The unreadable arm now demonstrates its own brokenness: the very read the verb performs must FAIL on the unreadable fixture and SUCCEED on the healthy fixture, or the arm fails loudly. The evidence in the body is extracted from the logs by the arm's header, and each block carries that header. Both differentials were re-run against the strengthened gate and both discriminate: fix 1 reverted shows the currency-nonpositive task reporting neglected for more than 0h … (bound 0h), and fix 2 reverted shows the currency-unreadable task — on the same fixture whose read is demonstrated to fail — reporting the false claim has never been advanced here, matching the unit differential. The validator's secondary concern is answered in the body: firstLine is pre-existing at candy/plugin-task/cli.go:470 with two pre-existing call sites.

Next. The validator's verdict on this head. Nothing is blocked: the consumer leg opencharly/charly#1048 waits on this producer leg landing and its tag being written, and starts on that. Issue #42 stays open until #44 lands, and is closed with its RESOLVED record then.


RCA — the BLOCK on this PR was a defect in my own evidence, and the root fix went into the control that produced it.

What the validator found. Both differential pastes in the "How tested" section could not have come from the arms they claimed to describe: the fix-1 paste carried the 168h bound of the unrelated currency-stale task, and the fix-2 paste carried a measured age (only reachable when git log succeeds) for a fixture whose own arm claims git log fails. Both were internally inconsistent with the arms they were offered as proof of.

Mechanism. I extracted each evidence block from the differential log by the recorded SHA of the pin under test. That key is not unique to an arm: every stale fixture in one run pins the same twin commit (the run's shared fixture source, $C1), so the same SHA appears in several arms' verdict lines of that one log — the healthy currency-stale arm and the currency-unreadable arm both report recorded a8e3633002 in the fixed run. The extraction took the first match, and that was the healthy currency-stale arm's line — which I then pasted into the fix-1 claim and the fix-2 claim. The paste was real text from a real run; it was simply the wrong arm's text.

Missed control. My own verification of each paste checked that the text existed somewhere in the differential log ("verbatim in body = True") and stopped there. It never checked the text was the output of the arm the claim was about. That is a presence check standing in for a provenance check — the check was weaker than the claim it was backing, so a wrong-arm paste passed it. The pre-push self-audit reviewed the body for completeness and for lint, and neither looks at whether a pasted block belongs to the claim it sits under.

Blast radius. The PR body only. No shipped behaviour was wrong: the two fixes and their unit arms were judged directionally sound, and the README matches the code. But the body was the only R7 proof offered for both fixes, so both proofs were unsupported — the change could not stand on its own evidence, which is exactly what the verdict said.

Root cause. Evidence was selected by an ambiguous key (a SHA shared by several arms) instead of by the arm's own identity.

Root fix — in the control, not only in the body.

  1. Each arm now has its own fixture, its own task name and its own bound. The task name appears in the run's own output, so an arm's evidence block is self-identifying, and no two arms can produce the same text.
  2. The unreadable arm now demonstrates its own brokenness. It asserts that the very read the verb performs — git log -1 --format=%ct -- <path> — fails on the unreadable fixture and succeeds on the healthy fixture, and fails loudly if either does not hold. The arm can no longer pass for the wrong reason (a no-op object removal would have left a readable history).
  3. Each arm's captured output is printed with the gate's own | convention, so the pasted block is the arm's own output, under the arm's own header.
  4. The differential extraction is now keyed by the arm header, not by a SHA.

The secondary concern is answered. firstLine is a pre-existing helper in this package, not something this diff adds:

$ grep -rn "func firstLine" --include="*.go" .
candy/plugin-task/cli.go:470:func firstLine(s string) string {
$ grep -rn "firstLine(" --include="*.go" . | grep -v "func firstLine"
candy/plugin-task/cli.go:246:// print only `firstLine(Text)`, so a passing step's output was discarded and a
candy/plugin-task/cli.go:253:	line := fmt.Sprintf("  [%s] %s — %s", s.Result.Status.String(), s.Keyword, firstLine(s.Text))
candy/plugin-task/cli.go:447:		fmt.Fprintf(&b, "\n  [%s] %s — %s", s.Result.Status.String(), s.Keyword, firstLine(s.Text))
candy/plugin-task/verbs.go:1023:					return time.Time{}, "read-failed", firstLine(cErr)
candy/plugin-task/verbs.go:1029:				return time.Time{}, "read-failed", firstLine(errOut)

The two cli.go call sites (253, 447) and the comment at 246 are pre-existing; the two verbs.go call sites are this change's. The build claims in the body are therefore true, and the helper is not new to the diff.

The rewritten body pastes each arm's own block, keyed by its own header, with the demonstration line included, and the two differentials re-run against the strengthened gate.

Agent: charly

Assisted-by: Claude Code minimax/minimax-m3.1-flash:cloud (fully tested and validated)

@github-actions

Copy link
Copy Markdown

Review — PASS

What the diff does

The two code fixes are present and correct, and they match the body's claims.

Fix 1 — refuse a non-positive bound. candy/plugin-task/verbs.go adds, immediately after the time.ParseDuration failure branch, a d <= 0 refusal with a message that names the reason ("... is not a POSITIVE duration — a bound of zero or less marks every not-current pin stale, which is the permanently-red gate this bound exists to avoid"). The parse block is the canonical site, so the bound is validated as a bound, not merely as a duration.

Fix 2 — three-outcome history read. The old lastAdvancedAt func(string) (time.Time, bool) is replaced in place by func(string) (time.Time, string, string) with outcomes ok / no-history / read-failed. On a failing git log, it discriminates via git rev-list --all --count: a count that cannot be produced → read-failed (carrying firstLine(errOut) / firstLine(cErr)); a produced count of zero → no-history; otherwise read-failed. The caller routes read-failed to the structural class and no-history to the stale class, so a transient git failure can never manufacture a STALE verdict. This matches the body's RCA and the stated discriminator.

Tests. currency_test.go adds Arm 6 (0s, 0, -1h all refused; no STALE pin(s) in the message), Arm 7 (HEAD commit object removed → fail, no STALE pin(s), message contains could not be read), and Arm 8 (path in no commit → has never been advanced here, and not could not be read). These exercise the new branches and would fail without the fixes. Arm 8 correctly pins the "fact vs failure" distinction.

README states both enforced rules and matches the code.

Gate script. stale_fixture is parameterised by task name and bound; the heredoc is now unquoted so $task/$bound expand (the YAML contains no other $/backtick, so this is safe). The non-positive arm uses its own fixture/task/bound (0s) and asserts refusal plus absence of STALE pin(s). The unreadable arm removes the HEAD object and demonstrates its own brokenness — it asserts the verb's own read (git log -1 --format=%ct -- gap) fails on the unreadable fixture and succeeds on the healthy one, so the arm cannot pass vacuously. This is exactly the control the prior review asked for.

Evidence consistency (re-derived against current state)

  • Diffstat reconciles: 8 + 69 + 55 + 60 = 192 insertions; 0 + 0 + 12 + 6 = 18 deletions; total 210 lines, matching the four file headers and the body's 192/18.
  • Fix 1 reverted block now shows the currency-nonpositive task, bound 0h, neglected for more than 0h, and the gate's FAIL: the refusal must name the reason. This is the pre-fix behaviour and is internally consistent.
  • Fix 2 reverted block now shows has never been advanced here (the conflated pre-fix behaviour) on the currency-unreadable task, with the gate's FAIL: the verdict must name the read failure. This is consistent with the body's Finding 2 "Actual" and with the unit differential, which shows the same string.
  • The live-pass gate output shows the refusal ("0s" is not a POSITIVE duration, no STALE) and the unreadable case as a STRUCTURAL failure naming fatal: bad object refs/heads/main, with no STALE — consistent with the code paths.
  • firstLine is documented as pre-existing (cli.go:470) with grep evidence; the diff only adds call sites in verbs.go, same package, and strconv is already imported. The secondary concern is addressed.

Comments dispositioned

  • Comment 6375341056 (github-actions[bot], BLOCK). All three items are resolved in the current state: the Fix 1 differential now shows bound 0h on the correct task; the Fix 2 differential now shows has never been advanced here rather than a measured age; and firstLine is evidenced as pre-existing. This comment is stale relative to the current body and is dismissed. It is not a maintainer sign-off.
  • Comment 6375468802 (@aitrawog). The body is a bare path reference (@/tmp/r1-repro/pr44-round1-comment.md) with no retrievable content; it provides no evidence and is not a sign-off. Non-substantive; no effect on the verdict.

No designated maintainer sign-off is present, so the change stands on its own evidence — which is now internally consistent.

Non-blocking observations

  • The body says each arm has "its own HEAD"; the evidence key is actually the task name, which is robust even if fixture SHAs collide. The stated keying (by the arm, not by a SHA) is the operative one and is correct.
  • read-failed is the conservative outcome for the exotic case of an unborn HEAD with commits only on other refs (count > 0 but git log fails); it cannot produce a false neglect claim, so it is safe.

Blocks

None.

Verdict: PASS


ollama-cloud/deepseek-v4.1-flash — action-review.

View action run

@github-actions
github-actions Bot enabled auto-merge (squash) October 23, 2026 21:53
@github-actions
github-actions Bot merged commit 1b9cc78 into main Oct 23, 2026
6 checks passed
charly-auto-merge Bot added a commit that referenced this pull request Oct 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

currency mode: nothing bounds NEGLECT — a pin that is not current is churn, and the owner can stop advancing it forever unnoticed

1 participant